Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): retire the lookup FOREIGN KEY branch gated on the rejected alias reference_to, and refuse the key - #12287
Conversation
…jected alias `reference_to` (#11567) `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift CheckThis PR changes 3 package(s): 32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 134 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 78da0b1756486c7a4eefbc600d0ea3247c884252 && git checkout 78da0b1756486c7a4eefbc600d0ea3247c884252
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 48c7c34c8850c11f6b4d8b1c93500fa4653e8b9f 740275c36c8958eb2695fd1c8fd2e3b8c8c4fe91 && git checkout -B drift-repro 48c7c34c8850c11f6b4d8b1c93500fa4653e8b9f && git merge --no-ff 740275c36c8958eb2695fd1c8fd2e3b8c8c4fe91
node scripts/docs-audit/affected-docs.mjs --json 48c7c34c8850c11f6b4d8b1c93500fa4653e8b9f
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11567
Implements the maintainer ruling of 2026-08-25 (comment): A + D — retire the FK branch, and refuse
reference_toloudly.What changed
SqlDriver.createColumnemittedtable.foreign(name).references('id').inTable(field.reference_to)for a relationship field carryingreference_to.referenceis the only relationship spelling@objectstack/specdeclares —reference_tois a rejected alias — so that branch could not fire for any spec-conformant lookup, and never had.col = table.string(name).reference_toat DDL time now throwsVALIDATION_ERROR/400 naming it as a rejected alias ofreference, in the same wordsFieldSchemauses.sql-driver.ts:13994-13995; on currentmainthe arm is at 14412–14413 (+418 lines, from the four PRs that landed on this file). Located viacreateColumn'scase 'lookup': / case 'user':arm.I re-verified the ground rather than inheriting it
reference_tois a rejected alias, not a normalised oneFieldSchema.safeParsemyself:success:false,issue.code=unrecognized_keys, message "Unrecognized key(s) on this field:reference_to. Did you meanreference_to→reference?". The canonicalreferenceparsessuccess:true.reference_to; a repo-wide sweep finds zero non-test assignments of that key (sole non-test occurrence is a comment). No platform object can reach the branch, which is the mechanism behind the measured 0-FK reading.createColumn. Confirmed.Live PG/MySQL were not re-provisioned (no servers running in this container); the 0-FK figure is carried from the investigation, and what I add is the deterministic mechanism plus a permanent pin.
⭐ The transition, measured in both directions
The refusal is a new rejection door on an existing accept path, so here is exactly what now throws that did not:
createColumn{ type:'lookup', reference:'p' }(canonical){ type:'lookup', reference_to:'p' }VALIDATION_ERROR/400{ type:'text', reference_to:'p' }{ type:'lookup', reference_to:'p', multiple:true }A deliberate decision worth review: I placed the guard at the top of
createColumn, ahead of theif (field.multiple)short-circuit, rather than inside thelookup/userarm. Reason:FieldSchemaanswersunrecognized_keysforreference_toon any field regardless of type, and a multi-value lookup returns as a JSON column before the type switch — so an arm-local guard would leave that shape silently accepting a key the spec refuses, reproducing the very split the ruling closes, just narrower. This is the wider of the two readings; it is flagged here so it can be narrowed deliberately if the reviewer disagrees.Predicate is
!== undefined, not key-presence: measured thatFieldSchema's canonical output does not carryreference_toas an own key, so both are correct —!== undefinedis the narrower one and is immune to a producer spreading{ reference_to: undefined }.Obligation 1 — the FK-emission pin, in the retiring direction
New:
packages/drivers/driver-sql/src/sql-driver-11567-lookup-no-foreign-key.test.ts(7 tests). It reads SQLite's physical catalog (PRAGMA foreign_key_list), not emitted DDL, and carries a positive control that builds a real FK with raw DDL — so the zeros are measurements, not vacuous passes.Obligation 2 — the fixture corpus.⚠️ The count is not 9
#12252 lists 9 fixtures in 6 packages. Measured here: 12 assignments across 8 packages. Three were missing from that list:
packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-schema.test.ts:118SqliteWasmDriver extends SqlDriver, so it inheritscreateColumnand would have gone redpackages/drivers/driver-sql/src/sql-driver-string-maxlength-varchar.test.ts:74packages/drivers/driver-mongodb/src/mongodb-driver.test.ts:347batch-row-driver-text-real-driver.integration.test.tsandbatch-row-http-status-real-driver.integration.test.tspin the very branch being deleted: they assertSQLITE_CONSTRAINT_FOREIGNKEYon a parent delete, and that FK existed only becausereference_toreached the FK branch. A rename alone would have dissolved the raw driver fault these security-withhold suites (#8502/#8570) exist to guard.So the vehicle was rebuilt rather than renamed: a new
packages/runtime/src/raw-foreign-key-fixture.tsprovisions a real FK with raw DDL — which is what every other FK-touching test here already does.⭐ A finding that fell out of that repair
Spelling the child field the canonical way (
{ type:'lookup', reference:'bd_parent' }) made all three FK-dependent tests fail with "no raw fault" — even though the FK was present and enforced. The engine now recognises the relationship and appliesdeleteBehavior, clearing the dependent row before the parent delete reaches the database. That istypes.mdx's documented contract demonstrating itself, and it is why the FK column is declaredtextin those two fixtures — documented at the helper. Differential, same tables and same constraint:lookup⇒ no raw fault,text⇒SQLITE_CONSTRAINT_FOREIGNKEY.Bounded in-place fix, declared
packages/spec/liveness/field.json— thereferenceentry's note read "FK DDL reads reference_to (unmapped from reference)", which this diff falsifies. Corrected in place: same defect class, mechanical, and the ledger'sliveverdict is untouched ($expand/cascade/seed still carry it).⛔ Not done, deliberately
content/docs/protocol/objectql/types.mdx:731-739already states this contract correctly and has since 2026-07-30.reference_to, a spelling the spec rejects — so no authored lookup is ever indexed #12251 (driver-mongodb) untouched.Changeset grading — argued, not assumed
@objectstack/driver-sql: patch, not declared-breaking. ADR-0087's ledger reaches upgraders about authorable metadata;reference_tois not authorable — the spec refused it at the authoring door before this change too — so no conformant object definition behaves differently and no migration is owed to any deploymentobjectstack migrate metacan see.node scripts/check-adr-0087-registration.mjsexits 0.Verification
Gate union derived, not recalled —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, 15 paths ⇒ 26 path-matched families + 6 convention-triggered, all run at740275c36c(the final commit):check:driver-conformance,check:engine-double-contract,check:where-matcher,check:published-files,check:test-source-alias,check:nul-bytes, the four@objectstack/specliveness gates, andcheck-adr-0087-registration.check:type-check-coverageexit 0;check:type-check-debt --re-measure→ "✓ check:type-check-coverage --self-test — 47 semantic case(s) + 65 observation case(s) + 29 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) hold."@objectstack/driver-sqlfull suite: 2044 passed, 0 failed (136 files).runtime(7),objectql,rest,lint,driver-sqlite-wasm.check-dev-prereqswas red until the workspace was fully built, then green on the same diff — an environment precondition, not a finding.Ablation — direction predicted in writing first, both legs
No rebuild leg is owed: the pin imports
../src/index.js, a relative source path, so nodistis in the resolution (ablation-dist-preflightdoes not apply). Mutations proved on disk by anchored counts before any result was read;trap … EXIT INT TERMarmed before the first mutation.referencereference_torefusals RED, non-vacuity GREENBoth legs restored byte-identical to HEAD (
git hash-object==git rev-parse HEAD:path), verified per leg.38because I passed a multi-line pattern togrep -cF, which counts matching lines — that number is meaningless. Leg A's real proof is its injected-anchor count of 1 plus the differential; leg B's counts (removed 0 / injected 1) are clean.Generated by Claude Code