Skip to content

Commit 7059f30

Browse files
committed
test(driver-sql): pin the SORT axis' unprovisioned-anchor premise (#10744)
An ORDER BY over a registry-injected anchor on an ADR-0015 external object is refused by neither runtime door and dropped by the #3821 ladder, so the rows come back unordered under a success. That runtime fact is the entire warrant of `sort-field-unprovisioned` (#10474), an author-time WARNING, and nothing asserted it — so a future change at either door would leave the warning silently wrong with nothing red. The control leg (a real remote column, which reverses) is load-bearing: without it a driver that stopped sorting entirely would keep every anchor leg green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
1 parent 26dea14 commit 7059f30

1 file changed

Lines changed: 305 additions & 0 deletions

File tree

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ── A PREMISE PIN: the SORT axis over an UNPROVISIONED injected anchor (#10744) ──
4+
//
5+
// ⚠️ READ THIS BEFORE "FIXING" A RED IN THIS FILE. Nothing here asserts that the
6+
// platform behaves WELL. Every case below pins a behaviour that is, on its own
7+
// terms, a defect: a list view ordered by a registry-injected anchor on an
8+
// ADR-0015 `external` object answers `200` with the rows in the driver's
9+
// arbitrary order, on the view's first fetch and every fetch after it. This file
10+
// exists because ANOTHER gate's justification rests on that fact being true, so
11+
// the fact must become red-on-change instead of silently stale.
12+
//
13+
// The gate is `sort-field-unprovisioned` (`@objectstack/lint`,
14+
// `validate-sortable-fields.ts`, #10474) — an author-time WARNING whose entire
15+
// warrant is that NEITHER runtime door refuses this ORDER BY, which makes the
16+
// authoring gate the only door there is. If a future change makes a runtime door
17+
// refuse it, or makes the driver honour it, that warning silently becomes wrong:
18+
// it would describe a silent degradation that no longer happens, on a path that
19+
// is now a loud `400`, at the wrong severity, and nothing would turn red.
20+
//
21+
// ## So what does a red here mean? Not necessarily a regression.
22+
//
23+
// Two readings, and the second is the one a reader will not think of:
24+
//
25+
// 1. Someone BROKE something — the driver stopped sorting, or the fixture
26+
// drifted. The CONTROL case below is what separates this reading from the
27+
// rest; see "why the control is load-bearing".
28+
// 2. Someone FIXED the runtime — a door was widened to refuse an ORDER BY over
29+
// an unprovisioned anchor, or the #3821 ladder was narrowed into a hard
30+
// error. That is a legitimate improvement, and the correct response is to
31+
// RETIRE OR RE-LEVEL `sort-field-unprovisioned` and delete the cases here
32+
// that its warrant needed — ⛔ NOT to restore the behaviour this file
33+
// documents. A premise pin whose red is misread as a regression gets
34+
// "fixed" by putting back the very defect it records.
35+
//
36+
// Either way the answer is a decision, not a patch. The same posture, and for
37+
// the same reason, as `engine-external-tenant-scope.test.ts` (#7738), the
38+
// precedent this file follows.
39+
//
40+
// ## The premise, stated exactly
41+
//
42+
// The two SORT doors both narrow on `formula` ALONE, so an injected anchor —
43+
// a `datetime` or a `lookup` — clears every verdict at both of them:
44+
//
45+
// - `assertSortFieldsExist` (`@objectstack/metadata-protocol`, `protocol.ts`,
46+
// #6994) — the REST ingress. Precedence `unknown` > `dotted` >
47+
// unmaterializable; the third verdict filters `UNMATERIALIZED_SORT_TYPES`,
48+
// which is `formula` alone. An injected anchor IS in `gate.known` (the
49+
// registry injected it into the served schema) and is undotted.
50+
// - `assertOrderByIsMaterializable` (`@objectstack/objectql`, `engine.ts`,
51+
// #7095) — the engine's own boundary, for callers that never pass ingress:
52+
// `schema.fields[f]?.type === 'formula'`, the same narrowing.
53+
//
54+
// Both live in packages that sit ABOVE this one in the dependency graph
55+
// (`driver-sql` depends on `spec`/`core`/`types` only), so this file cannot
56+
// import either door and does not try to. What it pins is the half that is
57+
// observable from here and that the warning actually describes: the ORDER BY
58+
// reaches the driver, and the driver answers success with the sort dropped.
59+
// The doors' own wording and their agreement are pinned one layer up, in
60+
// `packages/objectql/src/query-expression-conformance.test.ts` — which pins the
61+
// FORMULA axis and the doors' agreement about wording, and NOT this case.
62+
// The type half is pinned here from the spec's own injected definitions
63+
// (`ANCHORS ARE NOT 'formula'` below): that is the property both doors narrow
64+
// on, so it is what carries an anchor past both of them.
65+
//
66+
// ## Measured, on this file's own fixture (re-measured 2026-08-22 on `main`)
67+
//
68+
// A real `SqlDriver` over better-sqlite3, `schemaMode: 'external'`, against a
69+
// remote `customers` table carrying exactly `[id, name, email, region,
70+
// lifetime_value]` and none of the seven injected anchors:
71+
//
72+
// ```
73+
// orderBy name asc -> [c1,c2,c3] desc -> [c3,c2,c1] (a real column: reverses)
74+
// orderBy created_at asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error
75+
// orderBy owner_id asc -> [c1,c2,c3] desc -> [c1,c2,c3] asc === desc, 3 rows, no error
76+
// ```
77+
//
78+
// …and the same for the other five anchors. The MECHANISM case records how,
79+
// from the emitted SQL:
80+
//
81+
// ```
82+
// select * from `customers` order by `created_at` asc -- sqlite: no such column
83+
// select * from `customers` -- the #3821 ladder's retry
84+
// ```
85+
//
86+
// So the sort is not "never issued" — it is issued, rejected by the remote
87+
// database, and DROPPED by the #3821 recovery ladder, which returns the rows
88+
// unordered under a success because rows matter more than their order. (On this
89+
// stack knex quotes identifiers with backticks, so SQLite raises rather than
90+
// degrading the name to a string literal the way a double-quoted one would —
91+
// the #5348 shape. Both routes end at the same answer; this one is the measured
92+
// one.)
93+
//
94+
// ## Why the CONTROL is load-bearing, and must never be dropped
95+
//
96+
// `asc === desc` on its own is satisfied by a driver that stopped sorting
97+
// ENTIRELY — a far bigger defect that would leave every anchor case green. The
98+
// reversing control on a real remote column is what makes the anchor result a
99+
// DROPPED sort rather than a coincidence, and it is why the card and triage both
100+
// refused the anchor legs without it. ⛔ Do not delete the control to "simplify"
101+
// this file; deleting it converts every other case here into a tautology.
102+
//
103+
// ## Why the fixture constructs the situation instead of finding it
104+
//
105+
// `examples/app-showcase` declares two federated objects (`showcase_ext_customer`,
106+
// `showcase_ext_order`) and NO list view, view record or page binds either of
107+
// them, so the federated read path has no authored SORT surface anywhere in the
108+
// example apps (#10744). The object below is declared the way
109+
// `examples/app-showcase/src/data/objects/external/customer.object.ts` declares
110+
// it — copied rather than imported, because a test that reads outside its own
111+
// package is invisible to turbo's affected-set and to the `test` task's input
112+
// hashing. It is a fixture of that shape, not a mirror of that file: it does not
113+
// go red when the showcase app changes, and it should not.
114+
//
115+
// The served field set is built the way `applySystemFields`
116+
// (`@objectstack/objectql`, above this package) builds it — the author's fields
117+
// plus the spec's OWN injected definitions, read from
118+
// `injectedSystemColumnDefs`. `Engine.syncObjectSchema` hands
119+
// `registerExternalObject` the post-injection registry object, so this is what
120+
// the driver really receives for a federated object. The anchor list is likewise
121+
// derived from `unprovisionedInjectedColumns` (#7865 / #8116) rather than typed
122+
// out, so an ADR that adds or removes an anchor is covered here automatically
123+
// instead of quietly leaving a hand-written list short.
124+
125+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
126+
import { rmSync } from 'node:fs';
127+
import { tmpdir } from 'node:os';
128+
import { join } from 'node:path';
129+
import {
130+
ObjectSchema,
131+
Field,
132+
injectedSystemColumnDefs,
133+
unprovisionedInjectedColumns,
134+
} from '@objectstack/spec/data';
135+
import type { DriverQuery } from '@objectstack/spec/contracts';
136+
import { SqlDriver } from '../src/index.js';
137+
138+
/**
139+
* Declared exactly as `examples/app-showcase`'s `showcase_ext_customer` is
140+
* declared: an ADR-0015 federated object bound to a remote table whose name
141+
* differs from the object's.
142+
*/
143+
const ExternalCustomer = ObjectSchema.create({
144+
name: 'showcase_ext_customer',
145+
sharingModel: 'public_read_write',
146+
label: 'External Customer',
147+
pluralLabel: 'External Customers',
148+
icon: 'database',
149+
datasource: 'showcase_external',
150+
external: { remoteName: 'customers' },
151+
fields: {
152+
name: Field.text({ label: 'Name', searchable: true }),
153+
email: Field.text({ label: 'Email' }),
154+
region: Field.text({ label: 'Region' }),
155+
lifetime_value: Field.currency({ label: 'Lifetime Value', scale: 2 }),
156+
},
157+
});
158+
159+
/** The platform's injected anchors for this object, with no storage behind them. */
160+
const ANCHOR_DEFS = injectedSystemColumnDefs(ExternalCustomer);
161+
/** Their names, from the spec's own provenance derivation (#7865). */
162+
const ANCHORS = unprovisionedInjectedColumns(ExternalCustomer);
163+
164+
/** What the driver is registered with — `applySystemFields`' output shape. */
165+
function servedSchema(): any {
166+
const source = ExternalCustomer as any;
167+
const fields: Record<string, unknown> = { ...source.fields };
168+
for (const [name, def] of Object.entries(ANCHOR_DEFS)) fields[name] = { ...def };
169+
return { ...source, fields };
170+
}
171+
172+
/** The remote table's real columns — none of the anchors among them. */
173+
const REMOTE_COLUMNS = ['id', 'name', 'email', 'region', 'lifetime_value'] as const;
174+
175+
const ROWS = [
176+
{ id: 'c1', name: 'Acme', email: 'acme@example.com', region: 'EMEA', lifetime_value: 100 },
177+
{ id: 'c2', name: 'Globex', email: 'globex@example.com', region: 'APAC', lifetime_value: 200 },
178+
{ id: 'c3', name: 'Initech', email: 'initech@example.com', region: 'AMER', lifetime_value: 300 },
179+
] as const;
180+
181+
const ASC = ['c1', 'c2', 'c3'];
182+
const DESC = ['c3', 'c2', 'c1'];
183+
184+
let file: string;
185+
let ext: SqlDriver;
186+
/** Every statement the external driver issues, for the MECHANISM case. */
187+
let statements: string[] = [];
188+
189+
async function seedRemote(path: string): Promise<void> {
190+
// A "remote" database the platform does not own: the table is created with
191+
// raw DDL rather than `initObjects`, so its column set is exactly the card's
192+
// and carries nothing the platform would have injected.
193+
const fixture = new SqlDriver({
194+
client: 'better-sqlite3',
195+
connection: { filename: path },
196+
useNullAsDefault: true,
197+
});
198+
(fixture as any).name = 'fixture';
199+
await fixture.connect?.();
200+
const k = (fixture as any).knex;
201+
await k.schema.createTable('customers', (t: any) => {
202+
t.string('id').primary();
203+
t.string('name');
204+
t.string('email');
205+
t.string('region');
206+
t.float('lifetime_value');
207+
});
208+
await k('customers').insert(ROWS.map((r) => ({ ...r })));
209+
await fixture.disconnect?.();
210+
}
211+
212+
async function orderedIds(field: string, order: 'asc' | 'desc'): Promise<string[]> {
213+
const query: DriverQuery = { orderBy: [{ field, order }] };
214+
const rows = await ext.find('showcase_ext_customer', query);
215+
return rows.map((r: any) => r.id);
216+
}
217+
218+
beforeAll(async () => {
219+
file = join(tmpdir(), `os-ext-sort-anchor-${process.pid}-${Date.now()}.db`);
220+
await seedRemote(file);
221+
ext = new SqlDriver({
222+
client: 'better-sqlite3',
223+
connection: { filename: file },
224+
useNullAsDefault: true,
225+
schemaMode: 'external',
226+
} as any);
227+
(ext as any).name = 'extds';
228+
await ext.connect?.();
229+
(ext as any).knex.on('query', (q: any) => statements.push(String(q?.sql ?? '')));
230+
// DDL-free registration (ADR-0015) with the POST-INJECTION schema.
231+
ext.registerExternalObject!(servedSchema());
232+
});
233+
234+
afterAll(async () => {
235+
await ext?.disconnect?.();
236+
if (file) {
237+
try { rmSync(file, { force: true }); } catch { /* ignore */ }
238+
}
239+
});
240+
241+
describe('SORT over an unprovisioned injected anchor on a federated object (premise pin, #10744)', () => {
242+
it('FIXTURE the remote table carries none of the injected anchors', async () => {
243+
const columns = Object.keys(await (ext as any).knex('customers').columnInfo());
244+
expect(columns.sort()).toEqual([...REMOTE_COLUMNS].sort());
245+
246+
// The anchors are addressable on the served object and backed by nothing —
247+
// the `injected-unprovisioned` provenance (#7865) this whole file is about.
248+
expect(ANCHORS).toContain('created_at');
249+
expect(ANCHORS).toContain('owner_id');
250+
for (const anchor of ANCHORS) {
251+
expect(Object.keys(servedSchema().fields)).toContain(anchor);
252+
expect(columns).not.toContain(anchor);
253+
}
254+
});
255+
256+
it("ANCHORS ARE NOT 'formula' — the type both SORT doors narrow on", () => {
257+
// Why both doors let this ORDER BY through, asserted from the spec's own
258+
// injected definitions. `UNMATERIALIZED_SORT_TYPES` (metadata-protocol) and
259+
// the engine's `=== 'formula'` are unreachable from this package; this is
260+
// the same fact from the side this package CAN see. If an anchor ever
261+
// becomes a `formula`, the doors refuse it and this file's premise is gone.
262+
for (const anchor of ANCHORS) {
263+
const type = String((ANCHOR_DEFS as any)[anchor]?.type ?? '');
264+
expect(type).not.toBe('formula');
265+
expect(['datetime', 'lookup']).toContain(type);
266+
}
267+
});
268+
269+
// ── The CONTROL. Read the header before touching this case. ───────────────
270+
it('CONTROL a real remote column IS sorted — asc and desc are reverses', async () => {
271+
expect(await orderedIds('name', 'asc')).toEqual(ASC);
272+
expect(await orderedIds('name', 'desc')).toEqual(DESC);
273+
});
274+
275+
it.each(ANCHORS)(
276+
'ORDER BY %s is neither refused nor applied — asc === desc, all rows, no error',
277+
async (anchor) => {
278+
const asc = await orderedIds(anchor, 'asc');
279+
const desc = await orderedIds(anchor, 'desc');
280+
281+
// Not refused: no door threw, the driver did not throw, all rows came back.
282+
expect(asc).toHaveLength(ROWS.length);
283+
expect(desc).toHaveLength(ROWS.length);
284+
285+
// Not applied: reversing the direction changes nothing. Against the
286+
// CONTROL above — which does reverse — this is a DROPPED sort, not a
287+
// table that happens to be ordered the same way both times.
288+
expect(desc).toEqual(asc);
289+
expect(asc).toEqual(ASC);
290+
},
291+
);
292+
293+
it('MECHANISM the ORDER BY reaches the remote database and the #3821 ladder drops it', async () => {
294+
statements = [];
295+
const query: DriverQuery = { orderBy: [{ field: 'created_at', order: 'desc' }] };
296+
const rows = await ext.find('showcase_ext_customer', query);
297+
expect(rows).toHaveLength(ROWS.length);
298+
299+
// Two statements: the sort IS issued against the remote table (which raises
300+
// an unknown-column error), then the ladder retries with no ORDER BY at all.
301+
expect(statements.length).toBeGreaterThanOrEqual(2);
302+
expect(statements[0]).toMatch(/order by\s+.?created_at.?\s+desc/i);
303+
expect(statements[statements.length - 1]).not.toMatch(/order by/i);
304+
});
305+
});

0 commit comments

Comments
 (0)