Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/retire-lookup-fk-reference-to-branch.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<any[]> {
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<void> {
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<string, any>): Promise<any> {
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([]);
});
});
2 changes: 1 addition & 1 deletion packages/drivers/driver-sql/src/sql-driver-schema.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
},
},
];
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 },
Expand Down
102 changes: 99 additions & 3 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`
Expand DownExpand Up@@ -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;
Expand DownExpand Up@@ -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);
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
},
},
];
Expand Down
2 changes: 1 addition & 1 deletion packages/lint/src/runtime-lazy-deps.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' } },
},
Expand Down
Loading
Loading