Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): introspectUniqueConstraints reports single-column uniqueness on all three dialects (#11202) - #11657
Conversation
…iqueness on all three dialects (#11202) The SQLite arm kept unique indexes of exactly one column while the Postgres and MySQL arms returned every member of every composite constraint, so `UNIQUE (a, b)` made `introspectSchema`'s per-column `isUnique` claim that `a` alone is unique and `b` alone is unique — a claim the constraint does not make. All three arms now normalise to `UniqueConstraintMember` and decide through one predicate, `singleColumnUniqueColumns`. Per maintainer ruling 2026-08-23 (option A->B), a composite representation waits for real demand. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 18 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 2e6a69317853e57f777b0481349514e35891f4b0 && git checkout 2e6a69317853e57f777b0481349514e35891f4b0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c251ef4213b48da59bfedacde1dfb9cd32db81cf 65f613328d626bb5cc844ca71af648ae0485b02b && git checkout -B drift-repro c251ef4213b48da59bfedacde1dfb9cd32db81cf && git merge --no-ff 65f613328d626bb5cc844ca71af648ae0485b02b
node scripts/docs-audit/affected-docs.mjs --json c251ef4213b48da59bfedacde1dfb9cd32db81cf
|
…e with the narrowed producer contract (#11655) The consumer-side copy still stated the composite-member semantics #11202 retires, directly above introspectedSchemaToObjects — the flag's one real consumer. PM-authorized surface amendment on top of the #11202 claim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11202
Fixes#11655
PM-authorized surface amendment (spec seat, on top of claim comment #11202 (comment)): the one-line TSDoc correction in
packages/objectql/src/util.tswas added to this card's declared file surface, because the stale sentence states the semantics this PR retires and sits directly above the flag's one real consumer.Maintainer ruling 2026-08-23 (option A→B), verbatim and untranslated: 「10950 不考虑存量,其他接受你的建议」. Narrow
isUniqueto single-column uniqueness now; the composite-constraint representation (option B) waits for real demand and is not part of this card.What changed
SqlDriver.introspectUniqueConstraintsreturns a flatstring[]thatintrospectSchemafolds into a per-columnisUnique, and the three dialect arms disagreed about what that list meant. SQLite pushed a column only when the unique index had exactly one column; Postgres and MySQL returned every member of every composite constraint. ForUNIQUE (a, b), Postgres claimedaalone is unique andbalone is unique — a claim the constraint does not make — while SQLite claimed neither.All three arms now normalise their rows to a
UniqueConstraintMemberand decide through one predicate,singleColumnUniqueColumns, so a fourth dialect cannot quietly acquire a fourth meaning. A column is reported iff some unique constraint covers that column and nothing else.The predicate is kept in JS rather than pushed into each dialect's SQL deliberately: it makes the Postgres and MySQL narrowing measurable without a provisioned live server, which is the half of this change that no local run could otherwise reach.
Three details worth reading in review:
(schema, name), notname. Its answer spanscurrent_schemas(false)and Postgres auto-names a unique constraint after the table and column (orders_email_key), so two same-named tables in two schemas produce two different constraints under one name. Keyed on the name alone they fuse into an apparent two-member constraint and a genuinely single-column unique disappears from the answer. This is the driver-sql (PG): introspectForeignKeys' information_schema query is not schema-scoped — a same-named table in another schema contributes its foreign keys #11201 defect class, one method over, and it has its own pin plus the counterfactual.name: nullfromPRAGMA index_info, and the arm used to push it into astring[]as a literalnull. It is now discarded — but still counted toward the index's width, so(d, lower(e))cannot read as single-column and flagd.UNIQUEclause and a hand-made unique index is named once.Both defensive choices in the predicate fail toward not flagging, on purpose: an under-claimed flag is a missed optimisation downstream, an over-claimed one is the defect being fixed.
The contract sentence, and where it actually lives
isUniqueis not declared on the spec'sIntrospectedColumninpackages/spec/src/contracts/schema-diff-service.ts— it is not on the spec contract at all. It is a documented "SQL-introspection extra" carried on two derived interfaces, and the narrowed sentence landed on both:driver-sql'ssql-driver.ts— the contract sentence proper;packages/objectql/src/util.ts, which still read "Whether this column has a unique constraint" — the composite-member phrasing this PR retires — immediately aboveintrospectedSchemaToObjects, the one code path that reads the flag. That is the [finding] objectql's derivedIntrospectedColumn.isUniquedoc still says "has a unique constraint" after #11202 narrowed the producer to single-column uniqueness #11655 fix, and it is the PM-authorized amendment noted at the top. Documentation only; no objectql code changed.Adding
isUniqueto the spec contract was considered and rejected: public-surface expansion with zero pull, against the spec's own recorded position that these are extras it does not declare.Premise check against
origin/mainThe issue body is a lead, not a spec, so its claims were re-verified on the synced branch before implementing. All three hold: the SQLite single-column filter (
if (info.length === 1)), the PG arm returning everyconstraint_column_usagerow, the MySQL arm returning everyKEY_COLUMN_USAGErow.premise_still_valid: true.One dispatched assumption did not hold, in the supporting direction. The card's four-probe census (comment 5383952467) found no consumer of the flag. A fresh census found one:
introspectedSchemaToObjectsturnscolumn.isUniqueinto a drafted field'sunique: true. It is not a consumer that this change breaks — it is the direct beneficiary. Under the old semantics, drafting from a Postgres table withUNIQUE (a, b)produced two fields each declaring a single-column uniqueness the database never enforced, and deploying that draft would create two single-column unique constraints that did not exist. This is the "AI-authored metadata gets it wrong" axis, concretely.Verification
Gate union re-run at the final commit
65f61332, after re-deriving on the amended diff:node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstacknow sees 4 paths and names two families the pre-amendment diff never matched —check:durability-log-levelandcheck-engine-split-ratio, both reached only viapackages/objectql/src. Both were run; all 22 families exit 0.Selected verdict lines, quoted from the gates themselves:
check:driver-conformance(OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt.),check:engine-double-contract(OK — 397 pinned),check:cross-package-test-inputs(OK: 16 package(s) read outside themselves, all declared),check:where-matcher(292 matcher(s) discovered … none new.),check:nul-bytes(OK (scanned 6508 text file(s) … no raw ASCII control bytes).),check-changeset-no-major(✓ This diff introduces no major bump.).check-engine-split-ratiofirst refused rather than failing, and the distinction matters: on this container's shallow clone it printedcannot compute the ADR-0076 D7 trigger metric — this clone is shallow and its oldest visible commit … sits INSIDE the window, refusing to emit a ratio that would be "real, plausible and WRONG". Re-run aftergit fetch --shallow-since=2026-05-19 originas its own remedy line instructs: exit 0,ratio: 97.6%, horizonoldest visible commit 2026-05-21 (predates the window). Nothing about the diff caused it; it is a report-only metric and is unmoved by this change.@objectstack/objectql— 231 files, 4103 tests, all passed at65f61332;typecheckexit 0 at the same head, after building its dependency closure.@objectstack/driver-sql— 121 files passed, 8 skipped; 1911 tests passed, 104 skipped;typecheck: Done. Measured at5c4e0744; both driver-sql files are byte-identical at65f61332(the amendment touches onlypackages/objectql/src/util.ts).Reverse-verified by ablation. With the width filter removed (
if (columns.length !== 1) continue;→ a no-op, mutation proven on disk: deleted-text count 0, injected-marker count 1,git diff --stat1 insertion / 1 deletion; restore leg proven the same way), 9 of the new pins fail — the Postgres row-shape cases, the MySQL row-shape case, the end-to-end SQLite cell, and theisUniquefold:expected [ 'a', 'email' ] to deeply equal [ 'email' ],expected true to be falsy. The mutation script carried anEXIT INT TERMrestore trap.Live Postgres and MySQL are not provisioned in this container, so those two cells are named skips locally (
OS_TEST_POSTGRES_URL/OS_TEST_MYSQL_URLunset) and run in theTemporal Conformance (live PG + MySQL)job. This is why the predicate half of the test file exists: it feedssingleColumnUniqueColumnsthe exact row shapes each dialect's query returns, so the narrowing decision for PG and MySQL is measured locally even though their arms cannot execute. What remains unmeasured locally is only that the queries return those rows.One declared narrowing:
check:type-check-debt --re-measurewas not run locally — it requires a full workspace build to re-measure per-ledger-entry counts. Its population is read from the gate's own model rather than guessed: neither driver-sql nor objectql carries atest-typecheck-debt.jsonentry, the structural half (check:type-check-coverage) that decides whether a new test file needs one passed, and both packages' owntsc --noEmitare green. The diff adds no file to any ledgered package. CI runs it regardless.Test fixtures assert the absence is real before asserting it
"
ais not flagged" goes green for free on a table whose composite constraint never landed. Every live cell therefore first makes the database its own witness: two rows sharingaare accepted (soaalone genuinely is not unique — the fact the flag must not contradict), a repeat of the(a, b)pair is rejected, and a repeat ofemailis rejected. The fixture carries no primary key on purpose, so the suite measures the composite-vs-single question and nothing else — see the finding below for why that matters.Out-of-scope finding, filed unassigned
introspectUniqueConstraintsflags a single-column PRIMARY KEY as unique while PG/MySQL never do — and SQLite disagrees with itself by key type #11654 — the residual divergence in this same method: SQLite flags a single-column PRIMARY KEY as unique (its non-INTEGER key is materialised as a unique auto-index withorigin: 'pk', which the arm never inspects) while PG/MySQL filter onCONSTRAINT_TYPE = 'UNIQUE'and never see primary keys. SQLite also disagrees with itself: anINTEGER PRIMARY KEYis a rowid alias with no auto-index, so it is not flagged. Measured, both cases. Not fixed here — the correct form is a ruling (a PK column is unique, so the flag is not false, only inconsistent), which fails the bounded in-place exemption.Scope
Declared surface, as amended:
packages/drivers/driver-sql/src/sql-driver.ts, one new sibling test, the changeset, and — by the PM authorization at the top — the single TSDoc sentence inpackages/objectql/src/util.ts. Nothing else in objectql: no code, no other docs.packages/spec/src/contracts/schema-diff-service.tsis not touched, for the reason given above. No public export added (singleColumnUniqueColumnsis exported from the module for its tests, not re-exported fromindex.ts).#11122and#11232are named here only as the closed landing-coordination items; neither is addressed by this PR.Generated by Claude Code