|
| 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