From 740275c36c8958eb2695fd1c8fd2e3b8c8c4fe91 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 16:10:11 +0000 Subject: [PATCH] fix(driver-sql): retire the lookup FOREIGN KEY branch gated on the rejected alias `reference_to` (#11567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createColumn` emitted `table.foreign(name).references('id')` whenever a field carried `reference_to` — a key `FieldSchema` REFUSES as a rejected alias of `reference`. That branch could not fire for any spec-conformant lookup and never had: measured 0 FK constraints across all 44 exported platform objects on live PostgreSQL 16.13 and MySQL 8.0.46. It was reachable only by metadata that went around Zod through raw `registerObject`. Retire the emission, and refuse the key at the DDL seam rather than honouring it in silence — `VALIDATION_ERROR`/400, in the same words `FieldSchema` uses, so one key stops having two doors with opposite answers. Pin the retiring direction (no test anywhere asserted whether a lookup gets a FOREIGN KEY, in either direction) with a positive control, so a re-added emission fails loudly. Correct the fixture corpus that spelled the refused key, and rebuild the two batch-row suites whose raw-fault vehicle was that very FK. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --- .../retire-lookup-fk-reference-to-branch.md | 42 ++++ ...driver-11567-lookup-no-foreign-key.test.ts | 196 ++++++++++++++++++ .../driver-sql/src/sql-driver-schema.test.ts | 2 +- ...ql-driver-string-maxlength-varchar.test.ts | 2 +- packages/drivers/driver-sql/src/sql-driver.ts | 102 ++++++++- .../src/sqlite-wasm-driver-schema.test.ts | 2 +- packages/lint/src/runtime-lazy-deps.test.ts | 2 +- .../src/validate-flow-template-paths.test.ts | 4 +- .../src/engine-multivalue-normalize.test.ts | 2 +- ...a-object-materialization-agreement.test.ts | 2 +- ...-object-search-companion-agreement.test.ts | 2 +- ...river-text-real-driver.integration.test.ts | 18 +- ...ttp-status-real-driver.integration.test.ts | 18 +- .../runtime/src/raw-foreign-key-fixture.ts | 91 ++++++++ packages/spec/liveness/field.json | 2 +- 15 files changed, 472 insertions(+), 15 deletions(-) create mode 100644 .changeset/retire-lookup-fk-reference-to-branch.md create mode 100644 packages/drivers/driver-sql/src/sql-driver-11567-lookup-no-foreign-key.test.ts create mode 100644 packages/runtime/src/raw-foreign-key-fixture.ts diff --git a/.changeset/retire-lookup-fk-reference-to-branch.md b/.changeset/retire-lookup-fk-reference-to-branch.md new file mode 100644 index 0000000000..776c2fe259 --- /dev/null +++ b/.changeset/retire-lookup-fk-reference-to-branch.md @@ -0,0 +1,42 @@ +--- +"@objectstack/driver-sql": patch +--- + +fix(driver-sql): retire the lookup FOREIGN KEY branch gated on the rejected alias `reference_to`, and refuse the key instead of honouring it (#11567) + +`SqlDriver.createColumn` emitted `table.foreign(name).references('id')` for a +relationship field carrying `reference_to`. `reference` is the only relationship +spelling `@objectstack/spec` declares — `reference_to` is a **rejected alias**, +answered by `FieldSchema` with `unrecognized_keys` and *"Did you mean +`reference_to` → `reference`?"* — so that branch could not fire for any +spec-conformant lookup, and never had. + +**This is not a behaviour change for any authored deployment.** Measured across +all 44 exported platform objects on live PostgreSQL 16.13 and MySQL 8.0.46 +before the change: **0** FOREIGN KEY constraints. `reference_to` has zero +non-test assignments repo-wide; the branch was reachable only by metadata that +went around Zod through raw `registerObject` (which deliberately skips it). + +What changes is that the driver no longer disagrees with the spec in silence. A +field still carrying `reference_to` at DDL time now throws +`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same +words `FieldSchema` uses, rather than quietly changing the physical schema. One +key, one answer, on both doors. + +Fix, if you have such metadata — the same rename the schema has always asked for: + +| Wrote | Write instead | +|---|---| +| `{ type: 'lookup', reference_to: 'account' }` | `{ type: 'lookup', reference: 'account' }` | + +Referential integrity is unchanged and remains the **engine's**, applied via +`deleteBehavior` (the `409 DELETE_RESTRICTED`) — which is what +`content/docs/protocol/objectql/types.mdx` has documented since 2026-07-30. + +**Not graded as declared-breaking, deliberately.** ADR-0087's ledger reaches +upgraders about *authorable metadata* that must be rewritten. `reference_to` is +not authorable: the spec refuses it at the authoring door today and did before +this change, so no conformant object definition behaves differently and no +migration is owed to any deployment `objectstack migrate meta` can see. The +prescription above exists for metadata that bypassed validation, not for a +surface this repo ever published as writable. diff --git a/packages/drivers/driver-sql/src/sql-driver-11567-lookup-no-foreign-key.test.ts b/packages/drivers/driver-sql/src/sql-driver-11567-lookup-no-foreign-key.test.ts new file mode 100644 index 0000000000..3c398ffaac --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-11567-lookup-no-foreign-key.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11567] An authored lookup gets NO database `FOREIGN KEY`, and a field that + * still spells `reference_to` is REFUSED rather than honoured. + * + * ## Why this file exists at all + * + * Before it, no test anywhere asserted whether a lookup column does or does not + * get a FOREIGN KEY. Every FK-touching driver test in the repo + * (`sql-driver-introspection.test.ts`, `sql-driver-11201-*`, `sql-driver-11324-*`, + * the sqlite-wasm twin) builds its constraints with RAW knex DDL and only + * introspects them — none goes through `createColumn`, so its emission path had + * **zero coverage in either direction** (#12252). That blind spot is what let a + * live-MySQL observation ("a lookup produces a real CONSTRAINT … FOREIGN KEY") + * stand for three weeks against a doc that said the opposite: both were right + * about different populations, and nothing pinned either. + * + * Retiring the emission without a pin would only MOVE that blind spot, so the + * pin is written in the RETIRING direction: it fails if someone re-adds FK + * emission to `createColumn`. + * + * ## The measurement is a physical catalog read, with a positive control + * + * `PRAGMA foreign_key_list` is SQLite's own account of a table's constraints — + * not the DDL this driver emitted, and not knex's opinion of it. A null result + * from a catalog read is worthless unless the read is known to fire, so + * {@link rawFkTable} builds a REAL FK with raw DDL and the first test asserts + * the pragma reports it. Same probe, same read: one shape produces a + * constraint and the authored shapes do not, which makes the zeros + * measurements rather than vacuous passes. + * + * ## What the zeros mean in production + * + * They are not a new state of affairs — they are the state of affairs made + * checkable. Measured across all 44 exported platform objects on live Postgres + * 16.13 and MySQL 8.0.46 before this change: **0** FK constraints, because + * `reference_to` has zero non-test assignments repo-wide and the branch was + * gated on it. Referential integrity belongs to the ENGINE (`deleteBehavior`, + * the 409 `DELETE_RESTRICTED`), which is what + * `content/docs/protocol/objectql/types.mdx` has told authors since 2026-07-30. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +let driver: SqlDriver | null = null; + +function makeDriver(): SqlDriver { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + } as any); + return driver; +} + +afterEach(async () => { + await (driver as any)?.knex?.destroy?.().catch?.(() => {}); + driver = null; +}); + +/** SQLite's own catalog: one row per FK the table actually carries. */ +async function foreignKeys(d: any, table: string): Promise { + const rows = await d.knex.raw(`PRAGMA foreign_key_list('${table}')`); + return Array.isArray(rows) ? rows : (rows?.rows ?? []); +} + +/** A REAL foreign key, built the way every other FK test here builds one. */ +async function rawFkTable(d: any, parent: string, child: string): Promise { + await d.knex.schema.createTable(parent, (t: any) => t.string('id').primary()); + await d.knex.schema.createTable(child, (t: any) => { + t.string('id').primary(); + t.string('parent_id').references('id').inTable(parent); + }); +} + +describe('#11567 — createColumn emits no FOREIGN KEY for an authored relationship', () => { + it('POSITIVE CONTROL: the pragma reports a foreign key that really is there', async () => { + const d: any = makeDriver(); + await rawFkTable(d, 'ctl_parent', 'ctl_child'); + + const fks = await foreignKeys(d, 'ctl_child'); + expect(fks).toHaveLength(1); + expect(fks[0].table).toBe('ctl_parent'); + expect(fks[0].from).toBe('parent_id'); + // Without this the zeros below could mean "the read never works here". + }); + + it('a lookup / user / master_detail authored the SPEC way carries no constraint', async () => { + const d: any = makeDriver(); + await d.initObjects([ + { name: 'fk_parent', fields: { name: { type: 'text' } } }, + { + name: 'fk_child', + fields: { + name: { type: 'text' }, + // The canonical spelling — the only one `FieldSchema` declares. + parent: { type: 'lookup', reference: 'fk_parent' }, + owner: { type: 'user', reference: 'sys_user' }, + master: { type: 'master_detail', reference: 'fk_parent' }, + many: { type: 'lookup', reference: 'fk_parent', multiple: true }, + }, + }, + ]); + + // The columns exist — so this is a statement about CONSTRAINTS, not about + // a table the driver failed to build. + const columns = await d.knex('fk_child').columnInfo(); + expect(Object.keys(columns)).toEqual( + expect.arrayContaining(['parent', 'owner', 'master', 'many']), + ); + + expect(await foreignKeys(d, 'fk_child')).toEqual([]); + }); + + it('⛔ REGRESSION GUARD: re-adding FK emission to createColumn fails here', async () => { + // Stated separately from the case above, and on the narrowest possible + // object, so the reason a failure appears is unambiguous: one lookup, one + // parent, nothing else that could contribute a constraint. + const d: any = makeDriver(); + await d.initObjects([ + { name: 'g_parent', fields: { name: { type: 'text' } } }, + { name: 'g_child', fields: { parent: { type: 'lookup', reference: 'g_parent' } } }, + ]); + + const fks = await foreignKeys(d, 'g_child'); + expect(fks.map((f: any) => `${f.from} -> ${f.table}.${f.to}`)).toEqual([]); + }); +}); + +describe('#11567 — `reference_to` is refused at the DDL seam, not honoured', () => { + /** The ADR-0112 envelope plus the wording the spec itself uses. */ + function expectRejectedAlias(err: any): void { + // `not.toBeNull` rather than `toBeDefined`: `refusalFrom` returns null when + // NOTHING was thrown, and `expect(null).toBeDefined()` passes — so the + // no-refusal case would have failed later, as a TypeError on `null.code`, + // instead of saying what actually went wrong. + expect(err, 'initObjects did not throw — the refusal never fired').not.toBeNull(); + expect(err.code).toBe('VALIDATION_ERROR'); + expect(err.status).toBe(400); + // The wording is the contract here: the driver must give the SAME verdict + // `FieldSchema` gives, so an author who meets it in either place reads one + // answer and one fix. + expect(err.message).toContain('a rejected alias of `reference`'); + expect(err.message).toContain('Did you mean `reference_to` → `reference`?'); + } + + async function refusalFrom(fields: Record): Promise { + const d: any = makeDriver(); + try { + await d.initObjects([{ name: 'rt_obj', fields }]); + } catch (e) { + return e; + } + return null; + } + + it('a lookup carrying `reference_to` throws with the spec’s own verdict', async () => { + expectRejectedAlias(await refusalFrom({ parent: { type: 'lookup', reference_to: 'rt_parent' } })); + }); + + it('the refusal is not gated on the field TYPE — `FieldSchema` is not either', async () => { + // `unrecognized_keys` fires for `reference_to` on any field, so a text + // field carrying it is the same authoring mistake and gets the same answer. + expectRejectedAlias(await refusalFrom({ note: { type: 'text', reference_to: 'rt_parent' } })); + }); + + it('the refusal is not gated on `multiple` either — the JSON short-circuit used to skip it', async () => { + // A multi-value lookup returns as a JSON column BEFORE the type switch, so + // this shape carried the refused key straight past the seam. It is the + // reason the guard sits ahead of that early return. + expectRejectedAlias( + await refusalFrom({ many: { type: 'lookup', reference_to: 'rt_parent', multiple: true } }), + ); + }); + + it('NON-VACUITY: the same objects authored with `reference` build cleanly', async () => { + // Otherwise every refusal above could be green because `initObjects` throws + // for some unrelated reason on this fixture shape. + const d: any = makeDriver(); + await d.initObjects([ + { name: 'rt_parent', fields: { name: { type: 'text' } } }, + { + name: 'rt_obj', + fields: { + parent: { type: 'lookup', reference: 'rt_parent' }, + note: { type: 'text' }, + many: { type: 'lookup', reference: 'rt_parent', multiple: true }, + }, + }, + ]); + expect(await d.knex.schema.hasTable('rt_obj')).toBe(true); + expect(await foreignKeys(d, 'rt_obj')).toEqual([]); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver-schema.test.ts b/packages/drivers/driver-sql/src/sql-driver-schema.test.ts index 6c596aa092..a60e5d9adb 100644 --- a/packages/drivers/driver-sql/src/sql-driver-schema.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-schema.test.ts @@ -119,7 +119,7 @@ describe('SqlDriver Schema Sync (SQLite)', () => { name: 'multi_test', fields: { tags: { type: 'select', multiple: true } as any, - users: { type: 'lookup', reference_to: 'user', multiple: true } as any, + users: { type: 'lookup', reference: 'user', multiple: true } as any, }, }, ]; diff --git a/packages/drivers/driver-sql/src/sql-driver-string-maxlength-varchar.test.ts b/packages/drivers/driver-sql/src/sql-driver-string-maxlength-varchar.test.ts index c4e4d14efa..346754a464 100644 --- a/packages/drivers/driver-sql/src/sql-driver-string-maxlength-varchar.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-string-maxlength-varchar.test.ts @@ -71,7 +71,7 @@ const stringObject = () => ({ // ── past the varchar ceiling → TEXT, never a clamp ───────── huge_url: { type: 'url', maxLength: 100000 }, // ── the three families deliberately left at 255 ──────────── - a_lookup: { type: 'lookup', maxLength: 20, reference_to: PARENT }, + a_lookup: { type: 'lookup', maxLength: 20, reference: PARENT }, a_user: { type: 'user', maxLength: 30 }, an_autonumber: { type: 'autonumber', maxLength: 8 }, a_secret: { type: 'secret', maxLength: 4000 }, diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 2388abe15f..981b9c2021 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -1474,6 +1474,67 @@ function refuseDateBucketedGroupBy(granularity: string, bucketedHere: string[], throw err; } +/** + * [#11567] A field reached DDL carrying `reference_to` — a key `FieldSchema` + * REFUSES — so this face refuses it too, in the spec's own words. + * + * ## Why the DDL seam needs a door the schema already has + * + * `reference` is the only relationship spelling the spec declares; + * `reference_to` is a REJECTED ALIAS, not a normalised one. Measured on + * `origin/main`: + * + * ``` + * FieldSchema.safeParse({ name:'parent', type:'lookup', reference_to:'p' }) + * => success:false, issue.code = `unrecognized_keys` + * "Unrecognized key(s) on this field: `reference_to`. + * Did you mean `reference_to` → `reference`?" + * ``` + * + * Until #11567 this driver read `reference_to` and ONLY `reference_to`, as the + * gate on a `table.foreign(name).references('id')`. So one key had TWO doors + * with opposite answers: the authoring door refused it, while the DDL door + * silently honoured it and changed the PHYSICAL SCHEMA — and the silent one + * was the one that touched the database. It was reachable only by metadata + * that went around Zod (`registerObject` deliberately skips it, #3896), which + * is why no authored deployment ever had these constraints: measured across + * all 44 exported platform objects on live Postgres 16.13 and MySQL 8.0.46, + * **zero** FOREIGN KEYs existed. The emission is retired; this is the door + * that stays, so the driver stops disagreeing with the spec in silence. + * + * ## Why refuse rather than ignore + * + * Ignoring the key would be a THIRD answer to one question. An author who + * wrote `reference_to` meant to point the field somewhere; a driver that drops + * it on the floor creates a column pointing nowhere and says nothing — the + * "dropped silently" shape #4001 closed at the schema, re-opened one layer + * down. The refusal is stated at the DDL seam because that is the last place + * the mistake is still cheap: before the column exists, not after rows are in + * it. + * + * `VALIDATION_ERROR`/400 rather than a 500 (ADR-0112): the metadata is the + * caller's, the condition is decided entirely by what the caller wrote, and + * the fix is a one-word rename. A bare `throw new Error` would leave + * `code`/`status` undefined and serve an opaque 500 through `mapDataError` for + * a named, caller-fixable condition — the trade {@link refuseDateBucketedGroupBy} + * records above (#6212). + */ +function refuseRejectedReferenceAlias(column: string): never { + const err = new Error( + `[sql-driver] field '${column}' declares \`reference_to\`, a rejected alias of \`reference\`. ` + + `Did you mean \`reference_to\` → \`reference\`? \`reference\` is the only relationship spelling ` + + `@objectstack/spec declares, and \`FieldSchema\` refuses this key with that same verdict ` + + `(\`unrecognized_keys\`) — so a field still carrying it at DDL time went around the schema ` + + `(raw \`registerObject\` skips Zod, #3896). Rename the key. The column is built from ` + + `\`reference\`, and referential integrity is enforced by the ENGINE via \`deleteBehavior\` ` + + `(the 409 DELETE_RESTRICTED), not by a database FOREIGN KEY: #11567 retired the FK DDL this ` + + `key used to gate, which could never fire for a spec-conformant lookup in the first place.`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.VALIDATION_ERROR; + err.status = 400; + throw err; +} + /* * [#8445 → #8567] `isUnbackedConflictTargetError` — "is this the conflict * target is not a key failure?" — is imported from `@objectstack/types` @@ -14196,6 +14257,22 @@ export class SqlDriver implements IDataDriver { field: any, keyed?: { unique: boolean }, ) { + // [#11567] Stated BEFORE the `multiple` short-circuit and before the type + // switch, because the spec's refusal is gated on neither: `FieldSchema` + // answers `unrecognized_keys` for `reference_to` on ANY field, whatever its + // type, and a multi-value lookup (a JSON column, returning immediately + // below) used to carry the key straight past this seam. One key, one + // answer, wherever it appears. + // + // `!== undefined` rather than `'reference_to' in field`: it refuses every + // value the key can actually carry — including the `null` and `''` the old + // truthy gate ignored — while staying immune to a producer that spreads an + // explicit `{ reference_to: undefined }`. Measured: `FieldSchema`'s own + // canonical output does NOT carry `reference_to` as an own key, so key + // presence would have been correct too; this is the narrower of two + // correct predicates. + if (field.reference_to !== undefined) refuseRejectedReferenceAlias(name); + if (field.multiple) { table.json(name); return; @@ -14408,10 +14485,29 @@ export class SqlDriver implements IDataDriver { // is `ERROR 1406 Data too long`. Honouring `maxLength` here would make // the column structurally incapable of holding ANY id — a strictly // worse defect than the one #11431 fixes. + // + // [#11567] ⛔ This arm emits NO `FOREIGN KEY`, and that is the ruled + // contract rather than an omission. It used to carry + // `if (field.reference_to) table.foreign(name)...` — gated on a key + // the spec REFUSES (see {@link refuseRejectedReferenceAlias}), so it + // could not fire for any spec-conformant lookup and never had: zero FK + // constraints existed across all 44 exported platform objects on live + // Postgres 16.13 and MySQL 8.0.46. Retiring it is therefore a no-op for + // every authored deployment, and it is pinned in the retiring direction + // by `sql-driver-11567-lookup-no-foreign-key.test.ts`. + // + // ⛔ Do NOT "restore" this by re-reading the canonical `reference`. + // Measured on a PRISTINE EMPTY database: doing so takes Postgres from + // 44/44 objects synced to 18/44 and MySQL from 37/44 to 15/44, with + // `relation "sys_user" does not exist` — no data involved. Two + // structural causes: `syncSchema` has no topological ordering, so a + // child reaches DDL before its parent; and three of the ten FK targets + // (`sys_file`, `sys_environment`, `sys_package_version`) are not among + // the platform objects at all (ADR-0003 puts them in `service-tenant` / + // `service-storage`). Referential integrity is the ENGINE's, via + // `deleteBehavior` — which is what `content/docs/protocol/objectql/ + // types.mdx` has told authors since 2026-07-30. col = table.string(name); - if (field.reference_to) { - table.foreign(name).references('id').inTable(field.reference_to); - } break; case 'summary': col = table.float(name); diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts index 9ae8c49634..75ef088cd6 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts @@ -115,7 +115,7 @@ describe('SqliteWasmDriver Schema Sync (SQLite)', () => { name: 'multi_test', fields: { tags: { type: 'select', multiple: true } as any, - users: { type: 'lookup', reference_to: 'user', multiple: true } as any, + users: { type: 'lookup', reference: 'user', multiple: true } as any, }, }, ]; diff --git a/packages/lint/src/runtime-lazy-deps.test.ts b/packages/lint/src/runtime-lazy-deps.test.ts index c7cfd26359..2cbe3abb70 100644 --- a/packages/lint/src/runtime-lazy-deps.test.ts +++ b/packages/lint/src/runtime-lazy-deps.test.ts @@ -117,7 +117,7 @@ const PLAIN_OBJECT = { created_at: { type: 'datetime' }, approvals: { type: 'lookup', - reference_to: 'leave_approval', + reference: 'leave_approval', relatedList: true, relatedListFilter: { created_at: { $gte: 'last_30_days' } }, }, diff --git a/packages/lint/src/validate-flow-template-paths.test.ts b/packages/lint/src/validate-flow-template-paths.test.ts index 7427be466c..aeaa97f407 100644 --- a/packages/lint/src/validate-flow-template-paths.test.ts +++ b/packages/lint/src/validate-flow-template-paths.test.ts @@ -18,8 +18,8 @@ const LEAD_OBJECT: AnyRec = { last_name: { name: 'last_name', type: 'text' }, company: { name: 'company', type: 'text' }, full_name: { name: 'full_name', type: 'formula' }, - crm_account: { name: 'crm_account', type: 'lookup', reference_to: 'crm_account' }, - target_channels: { name: 'target_channels', type: 'lookup', reference_to: 'channel', multiple: true }, + crm_account: { name: 'crm_account', type: 'lookup', reference: 'crm_account' }, + target_channels: { name: 'target_channels', type: 'lookup', reference: 'channel', multiple: true }, payload: { name: 'payload', type: 'json' }, }, }; diff --git a/packages/objectql/src/engine-multivalue-normalize.test.ts b/packages/objectql/src/engine-multivalue-normalize.test.ts index db58d4de90..fc2bab948c 100644 --- a/packages/objectql/src/engine-multivalue-normalize.test.ts +++ b/packages/objectql/src/engine-multivalue-normalize.test.ts @@ -33,7 +33,7 @@ const PROJECT_SCHEMA = { labels: { type: 'multiselect', options: ['frontend', 'backend', 'design'] }, tags: { type: 'tags' }, channels: { type: 'select', multiple: true, options: ['email', 'sms'] }, - related_docs: { type: 'lookup', multiple: true, reference_to: 'document' }, + related_docs: { type: 'lookup', multiple: true, reference: 'document' }, // Field.user expands to type 'user' at runtime — the showcase // team_members field ships exactly this shape (#2552 e2e regression). team_members: { type: 'user', multiple: true, reference: 'sys_user' }, diff --git a/packages/rest/src/meta-object-materialization-agreement.test.ts b/packages/rest/src/meta-object-materialization-agreement.test.ts index ef797ede61..ee50af5a1e 100644 --- a/packages/rest/src/meta-object-materialization-agreement.test.ts +++ b/packages/rest/src/meta-object-materialization-agreement.test.ts @@ -123,7 +123,7 @@ const UNTITLED_DECLARATION = { name: 'showcase_project_membership', label: 'Project Membership', fields: { - project: { name: 'project', label: 'Project', type: 'lookup', reference_to: 'showcase_project' }, + project: { name: 'project', label: 'Project', type: 'lookup', reference: 'showcase_project' }, seats: { name: 'seats', label: 'Seats', type: 'number' }, }, }; diff --git a/packages/rest/src/meta-object-search-companion-agreement.test.ts b/packages/rest/src/meta-object-search-companion-agreement.test.ts index aa4c3bb90c..af3cf7ea96 100644 --- a/packages/rest/src/meta-object-search-companion-agreement.test.ts +++ b/packages/rest/src/meta-object-search-companion-agreement.test.ts @@ -96,7 +96,7 @@ const UNTITLED_DECLARATION = { name: 'showcase_project_membership', label: 'Project Membership', fields: { - project: { name: 'project', label: 'Project', type: 'lookup', reference_to: 'showcase_project' }, + project: { name: 'project', label: 'Project', type: 'lookup', reference: 'showcase_project' }, seats: { name: 'seats', label: 'Seats', type: 'number' }, }, }; diff --git a/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts b/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts index ac27a2c6fa..f3eb268f2b 100644 --- a/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts +++ b/packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts @@ -49,6 +49,7 @@ import { captureExpectedReadRefusals, type ExpectedReadRefusalCapture, } from './expected-read-refusal-noise.js'; +import { provisionRawForeignKey } from './raw-foreign-key-fixture.js'; import { validationFailureDetails, resolveThrownHttpError } from '@objectstack/types'; const PARENT = { name: 'bd_parent', fields: { name: { type: 'text' } } }; @@ -56,7 +57,14 @@ const CHILD = { name: 'bd_child', fields: { name: { type: 'text' }, - parent: { type: 'lookup', reference_to: 'bd_parent' }, + // ⚠️ Deliberately NOT declared as a lookup — see + // `raw-foreign-key-fixture.ts`. A canonical `reference` would let the + // ENGINE see the relationship and apply `deleteBehavior` on delete, + // clearing the child BEFORE the parent delete ever reaches the + // database — which dissolves the raw driver fault this suite exists to + // withhold. The column still carries a real FOREIGN KEY (raw DDL); what + // it must not carry is a relationship the engine will resolve for it. + parent: { type: 'text' }, }, }; const NOTE = { @@ -108,6 +116,14 @@ describe('[#8502] a REAL driver fault is withheld from every batch row', () => { // `logger` through the prototype chain to this sink. noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); noise.captureDriver(real); + // [#11567] The FK this suite's raw-fault vehicle depends on is built + // with RAW DDL, because the driver no longer emits one: `reference_to` + // (a key `FieldSchema` refuses) used to gate `createColumn`'s FK + // emission, and that branch is retired. The lookup below is now spelled + // the canonical way and contributes NO constraint — see + // `raw-foreign-key-fixture.ts` for why the vehicle was kept rather than + // dropped along with the key. + await provisionRawForeignKey(real, 'bd_parent', 'bd_child', 'parent'); await real.initObjects([PARENT, CHILD, NOTE]); // Capture the RAW driver error at the seam and let it propagate diff --git a/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts b/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts index a64548bae9..d6c5aca122 100644 --- a/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts +++ b/packages/runtime/src/batch-row-http-status-real-driver.integration.test.ts @@ -42,6 +42,7 @@ import { join } from 'node:path'; import { ObjectQL } from '@objectstack/objectql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SqlDriver } from '@objectstack/driver-sql'; +import { provisionRawForeignKey } from './raw-foreign-key-fixture.js'; import { captureExpectedReadRefusals, type ExpectedReadRefusalCapture, @@ -61,7 +62,14 @@ const CHILD = { name: 'hs_child', fields: { name: { type: 'text' }, - parent: { type: 'lookup', reference_to: 'hs_parent' }, + // ⚠️ Deliberately NOT declared as a lookup — see + // `raw-foreign-key-fixture.ts`. A canonical `reference` would let the + // ENGINE see the relationship and apply `deleteBehavior` on delete, + // clearing the child BEFORE the parent delete ever reaches the + // database — which dissolves the raw driver fault this suite exists to + // withhold. The column still carries a real FOREIGN KEY (raw DDL); what + // it must not carry is a relationship the engine will resolve for it. + parent: { type: 'text' }, }, }; @@ -109,6 +117,14 @@ describe('[#8570] a batch row carries the status its producer DECLARED — real // `logger` through the prototype chain to this sink. noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]); noise.captureDriver(real); + // [#11567] The FK this suite's raw-fault vehicle depends on is built + // with RAW DDL, because the driver no longer emits one: `reference_to` + // (a key `FieldSchema` refuses) used to gate `createColumn`'s FK + // emission, and that branch is retired. The lookup below is now spelled + // the canonical way and contributes NO constraint — see + // `raw-foreign-key-fixture.ts` for why the vehicle was kept rather than + // dropped along with the key. + await provisionRawForeignKey(real, 'hs_parent', 'hs_child', 'parent'); await real.initObjects([TASK, PARENT, CHILD]); // Capture the RAW error at the seam and let it propagate untouched, so diff --git a/packages/runtime/src/raw-foreign-key-fixture.ts b/packages/runtime/src/raw-foreign-key-fixture.ts new file mode 100644 index 0000000000..7e98ad3d4d --- /dev/null +++ b/packages/runtime/src/raw-foreign-key-fixture.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ═══════════════════════════════════════════════════════════════════════════ + * [#11567] A real database FOREIGN KEY, built with RAW DDL, for the tests that + * need a raw driver fault to withhold. + * ═══════════════════════════════════════════════════════════════════════════ + * + * ## Why this exists + * + * Two integration suites here (`batch-row-driver-text-*`, `batch-row-http-status-*`) + * are about what a batch response does with a **raw driver error**: that its + * text is withheld (#8502) and that it carries no declared status (#8570). + * Both need a fault that the DATABASE raises — not a validation refusal — and + * both used a `FOREIGN KEY constraint failed` on delete as the vehicle. + * + * They obtained that FK by declaring `reference_to` on a lookup field, which + * the old `SqlDriver.createColumn` read as the gate on its FK DDL. #11567 + * retired that emission: `reference_to` is a key `FieldSchema` REFUSES (a + * rejected alias of `reference`), so the branch could never fire for a + * spec-conformant lookup, and the driver now refuses the key outright rather + * than honouring it in silence. An authored lookup therefore gets **no** + * FOREIGN KEY — pinned in `sql-driver-11567-lookup-no-foreign-key.test.ts`. + * + * ⚠️ So the FK has to come from somewhere else, and it must still be a REAL + * one. Renaming the key to `reference` alone would have been the wrong repair: + * the constraint would simply vanish, the delete would succeed, and both + * suites would stop exercising the limb they exist to guard — the + * `expect(raw.code).toBe('SQLITE_CONSTRAINT_FOREIGNKEY')` non-vacuity check in + * each is precisely what refuses to let that happen quietly. + * + * Raw DDL is also what every other FK-touching driver test in this repo + * already does (`sql-driver-introspection.test.ts`, + * `sql-driver-11201-introspect-fk-schema-scope.test.ts`, the sqlite-wasm + * twin): build the constraint with knex, then observe it. This helper only + * gives that established practice one name and one rationale instead of two + * copies. + * + * ## ⚠️ Why the FK column is declared `text`, not `lookup` + * + * Measured while making this change, and it is the more interesting half. + * Spelling the child field the canonical way — `{ type: 'lookup', reference: + * 'bd_parent' }` — makes all three FK-dependent tests fail with + * `expected null not to be null`: no raw driver fault is raised at all. The + * constraint is still there and still enforced (`PRAGMA foreign_key_list` + * reports it; a raw `knex.del()` is refused `SQLITE_CONSTRAINT_FOREIGNKEY`), + * but the ENGINE now recognises the relationship and applies `deleteBehavior` + * on the way down, clearing the dependent row BEFORE the parent delete reaches + * the database. The delete then succeeds and there is no driver error to + * withhold. + * + * That is the documented contract doing exactly what it says — referential + * integrity belongs to the engine, not to a database constraint (#11567) — but + * it makes a canonical lookup useless as a *raw-fault* vehicle. So the column + * is declared `text`: it carries a real FOREIGN KEY through raw DDL, and + * carries no relationship the engine will resolve on its behalf. The + * differential is the proof, same tables and same constraint either way: + * `lookup` ⇒ no raw fault, `text` ⇒ `SQLITE_CONSTRAINT_FOREIGNKEY`. + * + * ## Why the table is pre-created rather than altered + * + * SQLite cannot `ALTER TABLE … ADD CONSTRAINT`, so an FK must be present at + * `CREATE TABLE` time. The tables are therefore built here first; the driver's + * own `initObjects` then takes its "table already exists" path and ADDs only + * the columns still missing (`name`), leaving the FK column untouched. The + * audit columns are built through the driver's OWN + * `createAuditTimestampColumn`, so the pre-created table is shaped exactly as + * the driver would have shaped it — including the `updated_at` that + * `initObjects` reads to decide `tablesWithTimestamps` membership. + */ +export async function provisionRawForeignKey( + driver: any, + parentTable: string, + childTable: string, + fkColumn: string, +): Promise { + const knex = driver.knex; + const audit = (t: any) => { + driver.createAuditTimestampColumn(t, 'created_at'); + driver.createAuditTimestampColumn(t, 'updated_at'); + }; + await knex.schema.createTable(parentTable, (t: any) => { + t.string('id').primary(); + audit(t); + }); + await knex.schema.createTable(childTable, (t: any) => { + t.string('id').primary(); + audit(t); + t.string(fkColumn).references('id').inTable(parentTable); + }); +} diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 5a31b6ae97..61a98b8d03 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -193,7 +193,7 @@ "reference": { "status": "live", "evidence": "packages/objectql/src/engine.ts:1672", - "note": "CAVEAT — $expand/cascade/seed live; FK DDL reads reference_to (unmapped from reference)." + "note": "CAVEAT — $expand/cascade/seed live. There is no FK DDL: #11567 retired `createColumn`'s FOREIGN KEY emission, which was gated on the REJECTED alias `reference_to` and so could never fire for a spec-conformant lookup (measured: 0 FK constraints across all 44 exported platform objects on live PG 16.13 + MySQL 8.0.46). Referential integrity is the engine's, via deleteBehavior." }, "referenceVia": { "status": "live",