Skip to content

fix(driver-sql): introspectUniqueConstraints reports single-column uniqueness on all three dialects (#11202) - #11657

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-11202-isunique-single-column
Aug 24, 2026
Merged

fix(driver-sql): introspectUniqueConstraints reports single-column uniqueness on all three dialects (#11202)#11657
os-warren merged 3 commits into
mainfrom
claude/issue-11202-isunique-single-column

Conversation

@os-warren

@os-warrenos-warren commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

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.ts was 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 isUnique to single-column uniqueness now; the composite-constraint representation (option B) waits for real demand and is not part of this card.

What changed

SqlDriver.introspectUniqueConstraints returns a flat string[] that introspectSchema folds into a per-column isUnique, 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. For UNIQUE (a, b), Postgres claimed a alone is unique andb alone is unique — a claim the constraint does not make — while SQLite claimed neither.

All three arms now normalise their rows to a UniqueConstraintMember and 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:

  • The Postgres arm now keys constraint identity on (schema, name), not name. Its answer spans current_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.
  • A SQLite expression-index term reports name: null from PRAGMA index_info, and the arm used to push it into a string[] as a literal null. It is now discarded — but still counted toward the index's width, so (d, lower(e)) cannot read as single-column and flag d.
  • The returned columns are de-duplicated, so a column carrying both a UNIQUE clause 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

isUnique is not declared on the spec's IntrospectedColumn in packages/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:

Adding isUnique to 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/main

The 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 every constraint_column_usage row, the MySQL arm returning every KEY_COLUMN_USAGE row. 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: introspectedSchemaToObjects turns column.isUnique into a drafted field's unique: true. It is not a consumer that this change breaks — it is the direct beneficiary. Under the old semantics, drafting from a Postgres table with UNIQUE (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/objectstack now sees 4 paths and names two families the pre-amendment diff never matchedcheck:durability-log-level and check-engine-split-ratio, both reached only via packages/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-ratio first refused rather than failing, and the distinction matters: on this container's shallow clone it printed cannot 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 after git fetch --shallow-since=2026-05-19 origin as its own remedy line instructs: exit 0, ratio: 97.6%, horizon oldest 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 at 65f61332; typecheck exit 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 at 5c4e0744; both driver-sql files are byte-identical at 65f61332 (the amendment touches only packages/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 --stat 1 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 the isUnique fold: expected [ 'a', 'email' ] to deeply equal [ 'email' ], expected true to be falsy. The mutation script carried an EXIT INT TERM restore 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_URL unset) and run in the Temporal Conformance (live PG + MySQL) job. This is why the predicate half of the test file exists: it feeds singleColumnUniqueColumns the 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-measure was 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 a test-typecheck-debt.json entry, the structural half (check:type-check-coverage) that decides whether a new test file needs one passed, and both packages' own tsc --noEmit are green. The diff adds no file to any ledgered package. CI runs it regardless.

Test fixtures assert the absence is real before asserting it

"a is 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 sharing a are accepted (so a alone genuinely is not unique — the fact the flag must not contradict), a repeat of the (a, b) pair is rejected, and a repeat of email is 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

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 in packages/objectql/src/util.ts. Nothing else in objectql: no code, no other docs. packages/spec/src/contracts/schema-diff-service.ts is not touched, for the reason given above. No public export added (singleColumnUniqueColumns is exported from the module for its tests, not re-exported from index.ts).

#11122 and #11232 are named here only as the closed landing-coordination items; neither is addressed by this PR.


Generated by Claude Code

…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
@github-actions

github-actionsBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-sql, @objectstack/objectql, touching 5 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via SqlDriver (symbol))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via SqlDriver (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 18 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c251ef4213b48da59bfedacde1dfb9cd32db81cfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 2e6a69317853e57f777b0481349514e35891f4b0 — the merge of head 65f613328d626bb5cc844ca71af648ae0485b02b into base c251ef4213b48da59bfedacde1dfb9cd32db81cf, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c251ef4213b48da59bfedacde1dfb9cd32db81cf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
@os-warren
os-warren marked this pull request as ready for review August 24, 2026 11:31
@os-warren
os-warren added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit 992161bAug 24, 2026
35 checks passed
@os-warren
os-warren deleted the claude/issue-11202-isunique-single-column branch August 24, 2026 11:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment