fix(driver-sql): judge unique violations with the shared predicate — and the Postgres hole it uncovered (#6543) - #6809
Merged
os-zhuang merged 2 commits intoAug 8, 2026
Conversation
Replaces the private inline regex in `syncDeclaredIndexes` with `isUniqueViolationError` from @objectstack/types, and passes the error object rather than the pre-stringified message so the code/errno channels are read. Refs #6543
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 8, 2026 20:30
os-zhuang
deleted the
claude/issue-6543-unique-violation-shared-predicate
branch
August 8, 2026 20:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6543
What changed
packages/drivers/driver-sql/src/sql-driver.ts— the fourth privateunique-violation vocabulary is gone. Both discriminators in the file now call
isUniqueViolationErrorfrom@objectstack/types, and both are passed theerror object, not a pre-stringified message, which is what buys the
code/errno/causechannels.The premise held — and the card is LIVE, not structural
The issue graded itself
findingon the reasoning that "on the three dialectsthe repo ships, the message channel happens to carry the words". The PM asked
for that to be measured. It does not hold, and the counterexample is on a
first-class shipped dialect.
That reasoning is true of the DML path (a duplicate
INSERT), which is whatthis package's other tests exercise. The migrated site is not on the DML path —
it is on the DDL path,
CREATE UNIQUE INDEXover rows that already violatethe index. Postgres does not reuse its DML phrasing there:
CREATE UNIQUE INDEXover duplicate rows saysUNIQUE constraint failed: product.codeER_DUP_ENTRY: Duplicate entry 'DUP' for key 'uniq_…'could not create unique index "uniq_…", SQLSTATE 23505duplicate key value violates unique constraintis what a conflicting INSERTsays. A conflicting index build says
could not create unique index "…",raises
ERRCODE_UNIQUE_VIOLATION(SQLSTATE23505) onerror.code, and putsthe offending tuple on
error.detail. None of the three old message limbsappear anywhere in it.
Why that matters here specifically: this branch exists to keep a dirty
database booting. On a duplicate-rows failure it logs the constraint as NOT
enforced at
errorand lets the ADR-0120 D4 drift pre-flight report the exactconflicting rows, rather than taking the process down. On Postgres the branch
never fired, so the fall-through
throw eran instead and the deploymentfailed to start — on precisely the legacy-duplicate database (#5030) the
branch was written to survive. Verified by running the new tests against
origin/main: the Postgres case rejects withError: create unique index "uniq_product_…"instead of resolving.The
errnochannel has the same shape one dialect over: a mysql2 errorcarrying
errno: 1062with unremarkable prose was equally invisible.Scope discipline
nullSafe.size > 0guard is untouched (issue note 2) — only thediscriminator moved. A test pins it: a unique violation on a non-NULL-safe
index still fails the sync, because absorbing it would ship an unenforced
constraint the drift pre-flight was never told about.
already existsrace arm still runs ahead of the conflict branch,and a test pins that ordering too.
matched are a strict subset of the predicate's four message limbs, and each
is pinned by an
it.eachcase that passes both before and after.One extra site, deliberately
Three lines below the migrated branch,
createNullSafeUniqueIndexused a bare/duplicate/ias the negative limb of its MySQL functional-key-partfallback — a further spelling of the same vocabulary, in the same function
family. It is now
!isUniqueViolationError(e). This is a strict safetywidening: a conflict must never be misread as "this server rejects functional
key parts" and silently degraded to the bare composite, and a message-only
exclusion did not fire on the
errno-only shape. Leaving it would haverecreated, three lines away, exactly the next-reader problem this issue is
about. Flagged here rather than done quietly — and revertable as one line if
the narrower scope is preferred.
Decision on the fifth spelling (the test assertions) — LEAVE THEM
The issue asked for a decision in this pass on
/UNIQUE constraint failed|duplicate key value/insql-driver-schema.test.ts,sql-driver-unique-tenancy.test.tsandadr0120-three-posture-conformance.test.ts.They stay as they are, deliberately, and the reasoning is written into the
new test file's doc comment so it does not have to be re-derived:
They are not discriminators. They are assertions on what a real driver actually
emitted when a real duplicate INSERT was refused, and their job is to prove the
constraint exists in the database. Routing them through the predicate makes
them strictly weaker, twice over:
errno, a
causewalk). An assertion through it can no longer distinguish"SQLite refused this row on a unique index" from "some error the predicate
happens to accept" — which is the entire content of those tests.
shares that predicate's blind spots. The two stop being independent, and a
wrong predicate passes its own tests. That independence is exactly what
surfaced the Postgres hole above.
The rule that reconciles the two: judge with the predicate, assert on the
literal.
Tests
New:
packages/drivers/driver-sql/src/sql-driver-unique-violation-predicate.test.ts— 9 cases over the branch. 3 fail on
origin/main, all 9 pass here:code(23505)errno(1062)causewrapperit.each)nullSafe.size > 0guard — plain unique still failscode, not baretoThrow)code: '42501')already existsrace still benign, and ordered firstFailures are injected rather than driven through a live Postgres because this
package's unit suite boots SQLite only; the shapes are the wire shapes
pg/mysql2hand knex, message prefix included.Per ADR-0112 no case uses a bare
toThrow— each rejection asserts theresulting error's identity and
code. The absorbed cases assert thedurability-degradation log's content (
NOT enforced,#5030,ADR-0120 D4),which is this site's observable outcome; it logs and continues rather than
mapping to an HTTP envelope, so there is no
statusto assert here.Full package suite: 1077 passed, 48 skipped, 0 failed.
Changeset
patchon@objectstack/driver-sql— no API change, and nothing previouslyabsorbed is absorbed differently.
Gates
Enumerated from
.github/workflows/lint.ymland run one by one, not frommemory. All green:
pnpm lint; the 34check:*steps of the ESLint job;pnpm build;turbo run typecheckacrosspackages/*,packages/*/*,apps/*; examples + downstream-contract typecheck; the 13 spec-scoped gates;check:type-check-debt; andcheck:i18n,check:i18n-coverage,check:app-nav-i18n(these three report PREREQUISITE NOT MET until theworkspace is built — re-run green after
pnpm build).CI converged green on
489b58d: 7/7 workflows success.os-dev report
{ "issue": 6543, "premise_still_valid": true, "premise_notes": "Verified on current origin/main (8825a06) before implementing. The inline regex was at packages/drivers/driver-sql/src/sql-driver.ts:6351, exactly as the prompt's re-measurement said, and packages/types/src/unique-violation.ts exports isUniqueViolationError(error: unknown): boolean plus uniqueViolationColumn. @objectstack/driver-sql already depended on @objectstack/types (line 25 of package.json), so no dependency edge was added.", "branch": "claude/issue-6543-unique-violation-shared-predicate", "pr": "https://github.com/objectstack-ai/objectstack/pull/6809", "pr_state": "draft", "merged": false, "grading_correction": { "issue_graded": "finding (structural only — 'nothing a user hits today')", "measured": "LIVE on a shipped dialect", "evidence": "The issue's reasoning ('on the three dialects the repo ships, the message channel happens to carry the words') holds for the DML path but NOT for the DDL path this site is on. Postgres does not reuse its INSERT phrasing for CREATE UNIQUE INDEX: a conflicting index build says 'could not create unique index \"...\"' and raises ERRCODE_UNIQUE_VIOLATION (SQLSTATE 23505) on error.code, with the offending tuple on error.detail. None of the three old message limbs (unique constraint failed | duplicate entry | duplicate key value) appear in it. This branch exists to keep a dirty database BOOTING (log the constraint as unenforced, let the ADR-0120 D4 pre-flight report the rows) instead of dying; on Postgres it never fired, so the fall-through 'throw e' ran and the deployment FAILED TO START on exactly the legacy-duplicate (#5030) database the branch was written to survive. Demonstrated by running the new tests against origin/main: the Postgres case rejects with 'Error: create unique index \"uniq_product_...\"' instead of resolving.", "same_fix": true }, "rulings_discharged": { "pass_the_error_object_not_msg": "Done. Both migrated call sites pass `e`, not the pre-stringified `msg`, so code / errno / cause are read. This is what makes the Postgres and MySQL-errno cases work; a string-only swap would have fixed neither.", "nullSafe_guard_survives_untouched": "Yes. `nullSafe.size > 0 &&` is byte-identical; only the discriminator to its right changed. Pinned by a test: a unique violation on a NON-NULL-safe index still fails the sync rather than being absorbed.", "fifth_spelling_decision": "DECIDED: LEAVE THEM, with the reason written into the new test file's doc comment (not only the PR body, so the next reader does not re-derive it). The assertions in sql-driver-schema.test.ts, sql-driver-unique-tenancy.test.ts and adr0120-three-posture-conformance.test.ts are observations of what a real driver emitted, not discriminators. Routing them through the predicate weakens them twice: (1) the predicate is deliberately broad, so the assertion could no longer distinguish 'SQLite refused this row on a unique index' from 'some error the predicate accepts' — which is their whole content; (2) a test judging with the same predicate the production path judges with shares its blind spots, so a wrong predicate would pass its own tests. That independence is precisely what surfaced the Postgres hole. Rule recorded: judge with the predicate, assert on the literal.", "english_only": "All GitHub output is English." }, "changes": [ { "file": "packages/drivers/driver-sql/src/sql-driver.ts", "what": "syncDeclaredIndexes' #5030 boot-survival discriminator: inline regex over `msg` -> isUniqueViolationError(e). Import added to the existing @objectstack/types import." }, { "file": "packages/drivers/driver-sql/src/sql-driver.ts", "what": "EXTRA SITE, flagged not silent: createNullSafeUniqueIndex's MySQL functional-key-part fallback used a bare /duplicate/i as its NEGATIVE limb — a further spelling of the same vocabulary three lines below the migrated one. Now !isUniqueViolationError(e). Strict safety widening: a conflict must never be misread as 'this server rejects functional key parts' and silently degraded to the bare composite, and a message-only exclusion did not fire on the errno-only shape mysql2 can hand back. The POSITIVE limb stays a message test — 'does this server support functional key parts' is this site's own question and message is its only channel." }, { "file": "packages/drivers/driver-sql/src/sql-driver-unique-violation-predicate.test.ts", "what": "New, 9 cases. Also carries the fifth-spelling decision in its doc comment." }, { "file": ".changeset/driver-sql-unique-violation-predicate.md", "what": "patch on @objectstack/driver-sql." } ], "tests": { "new_file": "packages/drivers/driver-sql/src/sql-driver-unique-violation-predicate.test.ts", "cases": 9, "fail_before_pass_after": 3, "failing_on_origin_main": [ "absorbs a Postgres index-build conflict that names the verdict only on `code` (SQLSTATE 23505)", "absorbs a MySQL conflict carried only on `errno` (1062)", "reads the violation through a driver `cause` wrapper" ], "regression_guards_passing_both_before_and_after": [ "it.each over the 3 message spellings the inline regex matched — proves nothing was narrowed", "the nullSafe.size > 0 guard still fails the sync for a plain unique", "an unrelated failure is rethrown with identity preserved", "the 'already exists' race arm is still benign AND still ordered ahead of the conflict branch" ], "adr0112_compliance": "No bare toThrow anywhere. Each rejection case asserts the resulting error's identity and `code` ('23505', '42501'). There is no `status` to assert at this site: it logs and continues rather than mapping to an HTTP envelope, so the absorbed cases assert the observable outcome instead — the durability-degradation log's content (NOT enforced / #5030 / ADR-0120 D4).", "injection_note": "Driver failures are injected rather than driven through a live Postgres because this package's unit suite boots SQLite only. The injected shapes are the wire shapes pg/mysql2 hand knex, knex's SQL message prefix included.", "package_suite": "1077 passed | 48 skipped | 0 failed (75 files passed, 4 skipped)" }, "gates": { "enumerated_from": ".github/workflows/lint.yml, step by step — not from memory", "result": "ALL GREEN", "ran": [ "pnpm lint", "the 34 check:* steps of the ESLint job (slot-lookup, query-options-erasure, verify-stand-in, nul-bytes, doc-authoring, docs-audit-scope, role-word, quick-reference-counts, adr-anchors, org-identifier, authz-resolver, service-providers, route-envelope, error-code-casing, wildcard-fallthrough, meta-type-normalized, init-service-contract, durability-log-level, startup-registry-verdict, objectui-changeset, release-notes, release-body, node-version, workflow-status-functions, shard-attestation, published-files, engine-double-contract, kernel-hook-pairs, resume-authority-declared, driver-memory-census, merge-driver, spec-parsed-alias)", "pnpm build (71/71 tasks)", "turbo run typecheck over ./packages/*, ./packages/*/*, ./apps/*", "examples typecheck; @objectstack/downstream-contract typecheck", "spec-scoped: tsc --noEmit, check:generated --reconcile-only, skill-docs, spec-changes, upgrade-guide, authorable-surface, docs, skill-refs, react-blocks, api-surface, exported-any, dual-source-exports, skill-examples", "check:type-check-debt, check:skill-frame-sync, check:skill-compatibility, check:type-check-coverage, check:driver-conformance, check:stall-guard", "check:i18n, check:i18n-coverage, check:app-nav-i18n", "@objectstack/lint check:doc-formula-expressions" ], "note": "check:i18n, check:i18n-coverage and check:app-nav-i18n report PREREQUISITE NOT MET (nothing measured) until the workspace is built — they were re-run green after pnpm build. Worth knowing: these gates exit 1 but piping them reports the PIPE's status, so a naive `| tail` reads green either way." }, "ci": { "head_sha": "489b58d0d3dd36f02ac5a12568dd33fc8358f8f5", "converged": true, "runs": "CI success, Lint & Type Check success, Docs Drift Check success, Check Links success, Console Pin Freshness success, Duplicate Fix Guard success, PR Automation success (7/7)" }, "out_of_scope_findings": [ { "candidate": "The 'benign race' guard one line above the migrated site is /already exists|duplicate key name|exists/i. Its third alternative subsumes the first, so the regex is effectively /duplicate key name|exists/i — any DDL error whose message contains the substring 'exists' anywhere is silently treated as a benign race and the index is skipped.", "filed": false, "reason": "NOT FILED, deliberately, and reported here instead of quietly dropped. Search-first dedup found nothing (no existing issue covers it). But I could not construct a message a SHIPPED driver actually emits on this path that would be wrongly swallowed: the near-misses all say 'does not exist' (no trailing s), and Postgres' index-build fallback detail 'Duplicate keys exist.' lives on error.detail, not message. Filing a defect I cannot demonstrate would be speculation, and this repo's bar is measurement. Recording it as an observation for whoever next touches this catch block: the limb is redundant with 'already exists' and over-broad in principle, so if a real swallow is ever measured, this is the line." } ], "open_questions": [ { "q": "The extra site (createNullSafeUniqueIndex's negative /duplicate/i limb) is one function beyond the issue's literal scope of 'the inline regex'.", "self_ruled": "Migrated it, flagged prominently in the PR body rather than done quietly. Reasoning: it is the same vocabulary in the same function family, three lines away; leaving it would recreate the exact next-reader problem this card exists to close, and the change is a strict safety widening rather than a behaviour change. Trivially revertable as one line if the maintainer prefers the narrower scope." }, { "q": "The Postgres finding means the migrated branch was DEAD on Postgres for its whole life, so no Postgres deployment ever exercised the #5030 boot-survival path — it just failed to start. Whether that warrants a release-note callout is a maintainer call.", "self_ruled": "Not written into content/docs/releases/ (forbidden in a code PR per CLAUDE.md). It is stated in full in the changeset, which is the PR's legitimate input to the release notes." } ], "worktree": "/home/user/objectstack-6543 (dedicated, branched off origin/main 8825a06). No git stash used at any point.", "notes": "Issue was pre-claimed by session_01Hg9Pkg5nDedCRihRsdeCdX; assignee untouched. PR left as DRAFT, nothing merged. content/docs/releases/ untouched." }