Skip to content

fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390) - #14603

Merged
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope
Sep 2, 2026
Merged

fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390)#14603
os-musk merged 6 commits into
mainfrom
claude/issue-14390-update-unique-violation-envelope

Conversation

@os-musk

@os-musk os-musk commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14390

engine.update now answers a driver's unique-constraint refusal with the ADR-0112 envelope the insert door has carried since #14095DuplicateRecordError: code: 'DUPLICATE_RECORD', status: 409, the driver's own error whole on cause, object, developerMessage, and field only when uniqueViolationColumn determinably named the conflicting column. Both driver exits of the door are covered — the by-id driver.update call and the predicate (multi: true) driver.updateMany call — so the scoped-repository facade (ctx.api.object(name).update(...) / .updateById(...)) inherits it. Everything that is not a recognised unique violation passes through untouched, on identity. The Update operation failed log line keeps the driver's own diagnosis through the envelope's cause.

Triage's asymmetry sentence, which the changeset carries: the same user action now answers 409 DUPLICATE_RECORD on create and 500 INTERNAL_ERROR on edit.

Measured — before and after, real engine on real drivers

Rig: a real ObjectQL, engine.registerDriver(driver, true) + init(), an object with indexes: [{ fields: ['email'], unique: true }], driven through engine.update. Temporary vitest rig in packages/runtime (has both drivers as deps), run against the built @objectstack/objectql dist before and after the change; the rig was not committed. Raw JSON in the report comment's tests field.

driver door BEFORE (origin/main 9c7d9d4) AFTER
driver-sqlite-wasm by-id Error, code/status/cause all absent, keys=[], message = the compiled UPDATE with bound values (update \duly_note` set `id` = '…', `email` = 'a@b.example', … where `id` = '…' - UNIQUE constraint failed: duly_note.email`) DuplicateRecordError DUPLICATE_RECORD / 409, field: 'email', object: 'duly_note', cause = that same raw error
driver-sqlite-wasm predicate (where: {title}, multi: true, 2 matched rows onto one value) same raw shape, message = update … set \email` = 'z@z.example' … where `title` = 'grp' - UNIQUE constraint failed: duly_note.email` envelope, field: 'email', cause = raw; the matched rows are untouched (both still hold their old emails)
driver-memory by-id its own envelope UNIQUE_VIOLATION / 409, no cause, declared-index sentence (… over (\email`) …`) DUPLICATE_RECORD / 409, cause = the UNIQUE_VIOLATION error (cause.code === 'UNIQUE_VIOLATION'), no fielduniqueViolationColumn answers undefined for that sentence, measured, so none is invented
driver-memory predicate same UNIQUE_VIOLATION / 409 same envelope, no field
both control (update onto a free value) succeeds succeeds (non-vacuity)

REST boundary status resolution (resolveThrownHttpError(e, 500).status from @objectstack/types, the passthrough the REST layer uses): raw driver error → 500; DuplicateRecordError409. The wire code is not decided here (#14389's lane, untouched; packages/rest not edited).

Placement — Zone 2 assumption 1: measured, and the outer catch is NOT equivalent

update() has one try { (engine.ts, before the branch on isByIdWrite) whose catch (e) logs Update operation failed and rethrows. Measured contents of that try: the value-shape/validation passes, assertReferencesResolve, both driver exits, and the after phase — triggerHooks('afterUpdate', …) (per-row and record forms), recomputeSummaries (which re-enters this.update(desc.parentObject, …) on the master object), hydrateWriteFormulas, stripSearchCompanion. The before-phase hooks (beforeUpdate, per-row before hooks, unscoped-multi dispatch) run BEFORE that try. So an envelope at the outer catch would also run on errors thrown by afterUpdate hook code: a nested ctx.api write already envelopes on its own door with its own object name (idempotent at a second seam), but a raw driver error from a hook that reaches a store directly would be re-attributed to THIS object. The narrowest placement that wraps exactly the two driver exits was chosen (two four-line try { … } catch (driverError) { throw envelopeUniqueViolation(driverError, object); } blocks); the outer catch was changed only in what it logs. Pinned: engine-update-duplicate-record.test.ts › "a raw unique violation thrown INSIDE an afterUpdate hook is not attributed to this object" (the caller receives the hook's raw SQLITE_CONSTRAINT_UNIQUE error on identity, not a doc envelope).

Operator log — Zone 2 assumption 2: measured before and after

Before (real sqlite-wasm): Update operation failed carried err.message = "UNIQUE constraint failed: duly_note.email [statement and bound values redacted]", meta { object: 'duly_note' }. After: byte-identical on both doors — the catch logs e instanceof DuplicateRecordError ? e.cause : e through the same redactBoundStatement, exactly as the insert door does. The envelope's own message (Duplicate record refused on 'duly_note': a unique constraint on 'email' already holds this value. No record was written.) opens with no SQL verb and carries no statement or bound value; pinned (the raw message DOES open with update, which sanitizeRowError's backstop would discard).

Recognisability across drivers — Zone 2 assumption 3

Measured live on the two in-process stores above (sqlite-wasm: by-id and predicate; driver-memory: by-id and predicate). driver-sqlite-wasm overrides neither update nor updateMany (SqlDriver's builder.update(...) propagates the knex error raw, no wrapping — read at sql-driver.ts update/updateMany), so the SQL family's shapes are the dialect fixtures sql-driver-unique-violation-predicate.test.ts records: Postgres (23505, column in detail) and MySQL (ER_DUP_ENTRY/1062, index name only) are pinned through fixtures in the new pin file. NOT MEASURED on a live Postgres, MySQL, MongoDB or Turso store — no such store is reachable from this container.

Multi-row — Zone 2 assumption 4

Driven for real (two matched rows onto one value through updateMany, table above) and pinned: the predicate envelope carries exactly the own-keys of the by-id envelope (Object.keys set-equal), no rows/count/index property, no "one of N" wording, the same message; field is present only when the dialect named a column (sqlite fixture → email; mysql fixture → absent; driver-memory live → absent). No row attribution is invented, per triage.

Facades — Zone 2 assumption 5

ObjectRepository.update(data) and updateById(id, data) (what a hook reaches as ctx.api.object(name)) both delegate to engine.update — read at engine.ts class ObjectRepository; there is no second update door. Both are pinned reaching the envelope.

Changeset level — Zone 2 assumption 6

@objectstack/objectql: minor, the level #14095 shipped for the same envelope on insert (.changeset/insert-unique-violation-duplicate-record-envelope.md): callers observe a different error object on a public data-API door. node scripts/check-changeset-no-major.mjs✓ This diff introduces no major bump. (exit 0; the pnpm check:changeset-no-major spelling does not exist as a script — exit 254 "Command not found", i.e. that spelling is NOT MEASURED; the derived node scripts/… spelling is the one run). check:empty-changeset exit 0. The changeset carries triage's asymmetry sentence.

Clause-② self-reading

Yes, read from this diff. The accept/reject set of engine.update is unchanged: every input that was accepted is still accepted, every driver refusal is still a refusal, and the negative controls (NOT NULL, deadlock, missing table, unreachable store) are pinned on identity on both exits. What changes is the SHAPE of one existing refusal on a published verb: a recognised unique violation gains code (DUPLICATE_RECORD) and status (409) where it had neither, with the driver error moved one step down onto cause, so the REST answer for that refusal moves 500 → 409 (measured through resolveThrownHttpError). No new public export (DuplicateRecordError / DUPLICATE_RECORD_CODE were exported by #14095); no packages/spec change; no signature change on DuplicateRecordError. needs:contract-review is hung on this PR for the in-seat contract review; the PR stays draft.

The negative contract, verbatim, one pin per class per exit

engine-update-duplicate-record.test.ts (29 cases): NOT NULL, missing table, deadlock, unreachable store — each asserted toBe(raw) (identity), not a DuplicateRecordError, status undefined, on the by-id door AND on the predicate door (8 pins), plus the predicate-level control that SQLite's NOT NULL sentence is not a unique violation. Refusal pins assert code AND status, never a bare toThrow().

Ablation

Reverted ONLY the two envelope throws (throw envelopeUniqueViolation(driverError, object);throw driverError;, 2 occurrences) from the committed state, with a trap … EXIT INT TERM restore to HEAD. Predicted before the run: the update pin file goes red on every positive-contract case — 16 of 29 (the idempotency pin, the 9 negative controls, the placement pin, the two raw-reader pins and the non-enveloped log pin stay green) — and the insert pin file stays green (its door is untouched). Observed: 16 failed / 13 passed in engine-update-duplicate-record.test.ts, engine-insert-duplicate-record.test.ts 24/24 passed. Mutation proven on disk by grep counts (envelope throws 2 → 0, ABLATION-14390 marker 0 → 2) and blob hash (36bec3e6…85c62f46…, ≠ HEAD). Restore proven: git hash-object = HEAD:packages/objectql/src/engine.ts (36bec3e6…), git diff HEAD --stat empty, marker count 0, throws 2. No dist/ leg applies: both pin files import ./engine relatively from src, so no exports resolution to dist/ is on the path.

Validation

git rev-parse --short HEAD = b488ecc44 (the last commit; the union below was derived and run at this head — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands with no path list, 63 commands).

Gate union at b488ecc44: 63 derived commands run, 58 exit 0, 5 NOT MEASURED in their own words (exit codes captured after a redirect, never through a pipe):

  • node scripts/check-test-completeness.mjs → exit 3: "Nothing was measured … pass a saved turbo run test log — or, running the family locally, record this gate as NOT MEASURED."
  • pnpm check:dual-build-cjs-loads → exit 3: "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (53 unbuilt packages; a whole-repo pnpm build is CI's run.)
  • node scripts/pm/check-half-states.mjs → exit 3: needs GitHub read access this container does not have ("Treat this exit as an unread instrument").
  • pnpm --filter @objectstack/spec run check:skill-examples → exit 1, prerequisite text: "Build first, then re-run: pnpm --filter @objectstack/client-react build" — the client-react closure build never acquired the shared verification lock (see below), so this is NOT MEASURED, not a red.
  • pnpm check:type-check-debt → exit 3: "Build the closure first … ⛔ This is NOT a pass and NOT a finding: nothing was measured."

Every other derived gate exit 0 at b488ecc44, including the families the seed named: check:engine-double-contract (the pin file's driver double is a DRIVER shape — primary key second — and sits out of scan scope; no engine-double-contract.pinned.json rows were asked for), check:objectql-double-limit ("305 double(s) graded, 106 apply the caller's bound or refuse it loudly"), check:system-context-census ("109 elevation read sites … all anchored; 145 anchors resolve"), check:changeset-no-major ("✓ This diff introduces no major bump."), check-empty-changeset, check:test-source-alias, check:cross-package-test-inputs, check:swallow-census-controls, check:durability-log-level, check:where-matcher, check:type-check-coverage, spec check:docs / check:liveness / check:empty-state.

Always-runs: pnpm lint (whole repo, eslint . --no-inline-config) exit 0 at b488ecc44; pnpm check:nul-bytes exit 0 ("scanned 7923 text file(s) … no raw ASCII control bytes"); pnpm check:error-status-conformance exit 0 ("51 code(s) reconciled … 140 producer site(s) derived").

Declared narrowing on the post-merge head. After the second git merge origin/main (28 commits, moving packages/spec, packages/types, packages/metadata, packages/metadata-protocol), the dependency-closure rebuild could not be completed on this shared box: two attempts acquired scripts/pm/os-verify-lock.sh and were killed by the container's 10-minute foreground cap during packages/spec's DTS leg (exit 143), and five attempts (spec build ×3, spec DTS-only leg, pin suites) ended queue-timeout (exit 99) · never acquired behind other seats' whole-repo turbo run build/typecheck and closure-build holds (holders named in the report's tests). The six adjacent suites WERE measured at b488ecc44 on a later acquisition: Test Files 6 passed (6) · Tests 222 passed (222) (held the lock 18s · waited 160s), against the merged tree's JS dist — spec's ESM/CJS legs had completed before the cap kill; only its .d.ts leg is missing, which vitest does not read. So at b488ecc44 the objectql typecheck and the full objectql vitest run are NOT MEASUREDpackages/spec/dist/*.d.ts is absent in this worktree. The last measured readings are at eb144eaf6 (one merge earlier, closure freshly built from that tree): pnpm --filter @objectstack/objectql typecheck exit 0 (check:test-typecheck: OK — … 44 file(s) / 242 error(s) / 69 pinned signature(s), i.e. the debt ledger unchanged and the new pin file compiling with zero errors), pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2Test Files 260 passed (260) · Tests 4513 passed (4513) (held the lock 200s), and the six adjacent suites (engine-update-duplicate-record, engine-insert-duplicate-record, engine-autonumber-resync, driver-fault-redaction, engine-update-dispatch, hook-input-shape-contract) → 222/222. Between eb144eaf6 and b488ecc44 the only commits are the merge and the census regeneration; the four hand-edited files are byte-identical at both heads (git diff eb144eaf6 b488ecc44 -- … empty). CI's TypeScript Type Check and Test Core jobs are the authority on the merged head.

Deviations and notes

  • packages/objectql/src/duplicate-record-error.ts: prose-only, no signature change — the module header and envelopeUniqueViolation's doc now name both doors, and two words in string literals: the object param doc ("refused insert" → "refused write") and developerMessage ("refused this insert" → "refused this write"), because the envelope is now raised by update too and a developerMessage calling an update an insert is a wrong attribution handed to the application author. No pin asserted the old word (git grep "refused this insert" → only the source line); the insert pin file's developerMessage assertions (DUPLICATE_RECORD, cause) are unchanged and green.
  • The message sentence (… No record was written.) is unchanged: it is pinned by the insert file and is true of a refused update as well (nothing was persisted); making it verb-aware would be a constructor signature change, which is out.
  • upsert: there is no engine-level upsert doorObjectQL has no upsert method (update.options.upsert is a retired-key tombstone, [finding] options.upsert is accepted by engine.update()'s option surface and never read — a declared-but-unenforced key (ADR-0049) #8057), update() never reaches a driver upsert, and the driver-level upsert (driver-sql, driver-memory, driver-mongodb) shares no code path with either update exit. No stop condition was met and nothing was widened.
  • content/docs/permissions/system-context.mdx re-anchored twice with check-system-context-census.mjs --fix (never hand-edited): once for the engine.ts line shifts, once per merge of origin/main (the os-regen merge driver deferred it; the discharge commit follows each merge).
  • Measurements were taken on the tree at 12a0c1dc8 (before merging origin/main); engine.ts and duplicate-record-error.ts are byte-identical in the final head to that commit for the changed hunks (the merges brought no change to either file).
  • Two git merge origin/main (28 + 6 commits); never rebased or force-pushed.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68


Generated by Claude Code

…DUPLICATE_RECORD (#14390)

Both driver exits of engine.update — the by-id driver.update call and the
predicate driver.updateMany call — now answer a recognised unique violation
with the ADR-0112 DuplicateRecordError envelope the insert door has carried
since #14095. Everything that is not a unique violation passes through
untouched; the Update operation failed log line keeps the driver's own
diagnosis through the envelope's cause.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…shifts (#14390)

Regenerated with node scripts/check-system-context-census.mjs --fix; never hand-edited.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Discharges the os-regen deferral the merge of origin/main recorded for
content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
)

Discharges the os-regen deferral recorded by the second merge of origin/main
for content/docs/permissions/system-context.mdx; regenerated with
node scripts/check-system-context-census.mjs --fix, never hand-edited.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
@github-actions github-actions Bot added the size/l label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 1 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via DUPLICATE_RECORD (literal, a string literal in ObjectQL))
  • content/docs/protocol/kernel/error-handling.mdx (via DUPLICATE_RECORD (literal, a string literal in ObjectQL))
What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 65 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 16 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 2a265361968a435a9303ad08aacb16f497118556packageMentionDocs.

Which tree this was computed on

This run read content/docs from 86458eddd7e2ca21870730662a91548b608f7d32 — the merge of head b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 into base 2a265361968a435a9303ad08aacb16f497118556, 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 86458eddd7e2ca21870730662a91548b608f7d32 && git checkout 86458eddd7e2ca21870730662a91548b608f7d32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a265361968a435a9303ad08aacb16f497118556 b488ecc44b0ddb03896a0bcc66f1ef365ade81e2 && git checkout -B drift-repro 2a265361968a435a9303ad08aacb16f497118556 && git merge --no-ff b488ecc44b0ddb03896a0bcc66f1ef365ade81e2

node scripts/docs-audit/affected-docs.mjs --json 2a265361968a435a9303ad08aacb16f497118556

⚠️ 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 2a265361968a435a9303ad08aacb16f497118556 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-musk os-musk added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026 — with Claude

os-musk commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Validation addendum at b488ecc44 (supersedes one sentence of the body's "Declared narrowing" paragraph). After the body was written, packages/spec's DTS leg finally acquired the shared verification lock (ACQUIRED after 15s, held the lock 261s): DTS ⚡️ Build success in 224025ms, check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present, and then, chained in the same hold, pnpm --filter @objectstack/objectql typecheck → exit 0, check:test-typecheck: OK — @objectstack/objectql's test layer compiles under packages/objectql/tsconfig.test.json; 44 file(s) / 242 error(s) / 69 pinned signature(s) held in test-typecheck-debt.json. So the objectql typecheck IS measured at b488ecc44 (green, debt ledger unchanged). What remains NOT MEASURED at this head is the full objectql vitest run — the rest of the closure (types/core/metadata*/formula, all moved by the merge) could not be rebuilt (queue-timeout (exit 99) · never acquired · never reached the head of the queue, holder pnpm check:type-check-debt of another seat, held 231s); the six adjacent suites at this head are 222/222 as the body says, and the last full run is 260 files / 4513 tests green at eb144eaf6. CI's Test Core job is the authority on the merged head.

Generated by Claude Code


Generated by Claude Code

os-musk commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Landing provenance (engine execution seat, session session_0112hMx9hjJ9BgB28X97DS68): flipped ready and armed auto-merge (squash) at 14:57Z on head b488ecc44.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants