Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390) - #14603
Conversation
…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
…date-unique-violation-envelope
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
…date-unique-violation-envelope
) 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
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 16 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 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
|
os-musk
commented
Sep 2, 2026
Validation addendum at Generated by Claude Code Generated by Claude Code |
os-musk
commented
Sep 2, 2026
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14390
engine.updatenow answers a driver's unique-constraint refusal with the ADR-0112 envelope the insert door has carried since #14095 —DuplicateRecordError:code: 'DUPLICATE_RECORD',status: 409, the driver's own error whole oncause,object,developerMessage, andfieldonly whenuniqueViolationColumndeterminably named the conflicting column. Both driver exits of the door are covered — the by-iddriver.updatecall and the predicate (multi: true)driver.updateManycall — 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. TheUpdate operation failedlog line keeps the driver's own diagnosis through the envelope'scause.Triage's asymmetry sentence, which the changeset carries: the same user action now answers
409 DUPLICATE_RECORDon create and500 INTERNAL_ERRORon edit.Measured — before and after, real engine on real drivers
Rig: a real
ObjectQL,engine.registerDriver(driver, true)+init(), an object withindexes: [{ fields: ['email'], unique: true }], driven throughengine.update. Temporary vitest rig inpackages/runtime(has both drivers as deps), run against the built@objectstack/objectqldist before and after the change; the rig was not committed. Raw JSON in the report comment'stestsfield.Error,code/status/causeall 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`)DuplicateRecordErrorDUPLICATE_RECORD/ 409,field: 'email',object: 'duly_note',cause= that same raw errorwhere: {title},multi: true, 2 matched rows onto one value)update … set \email` = 'z@z.example' … where `title` = 'grp' - UNIQUE constraint failed: duly_note.email`field: 'email',cause= raw; the matched rows are untouched (both still hold their old emails)UNIQUE_VIOLATION/ 409, nocause, declared-index sentence (… over (\email`) …`)DUPLICATE_RECORD/ 409,cause= theUNIQUE_VIOLATIONerror (cause.code === 'UNIQUE_VIOLATION'), nofield—uniqueViolationColumnanswersundefinedfor that sentence, measured, so none is inventedUNIQUE_VIOLATION/ 409fieldREST boundary status resolution (
resolveThrownHttpError(e, 500).statusfrom@objectstack/types, the passthrough the REST layer uses): raw driver error → 500;DuplicateRecordError→ 409. The wirecodeis not decided here (#14389's lane, untouched;packages/restnot edited).Placement — Zone 2 assumption 1: measured, and the outer catch is NOT equivalent
update()has onetry {(engine.ts, before the branch onisByIdWrite) whosecatch (e)logsUpdate operation failedand rethrows. Measured contents of thattry: the value-shape/validation passes,assertReferencesResolve, both driver exits, and the after phase —triggerHooks('afterUpdate', …)(per-row and record forms),recomputeSummaries(which re-entersthis.update(desc.parentObject, …)on the master object),hydrateWriteFormulas,stripSearchCompanion. The before-phase hooks (beforeUpdate, per-row before hooks, unscoped-multi dispatch) run BEFORE thattry. So an envelope at the outer catch would also run on errors thrown byafterUpdatehook code: a nestedctx.apiwrite 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 THISobject. The narrowest placement that wraps exactly the two driver exits was chosen (two four-linetry { … } 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 rawSQLITE_CONSTRAINT_UNIQUEerror on identity, not adocenvelope).Operator log — Zone 2 assumption 2: measured before and after
Before (real sqlite-wasm):
Update operation failedcarriederr.message = "UNIQUE constraint failed: duly_note.email [statement and bound values redacted]", meta{ object: 'duly_note' }. After: byte-identical on both doors — the catch logse instanceof DuplicateRecordError ? e.cause : ethrough the sameredactBoundStatement, exactly as the insert door does. The envelope's ownmessage(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 withupdate, whichsanitizeRowError'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-wasmoverrides neitherupdatenorupdateMany(SqlDriver'sbuilder.update(...)propagates the knex error raw, no wrapping — read atsql-driver.tsupdate/updateMany), so the SQL family's shapes are the dialect fixturessql-driver-unique-violation-predicate.test.tsrecords: Postgres (23505, column indetail) 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.keysset-equal), norows/count/indexproperty, no "one of N" wording, the samemessage;fieldis 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)andupdateById(id, data)(what a hook reaches asctx.api.object(name)) both delegate toengine.update— read at engine.tsclass 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; thepnpm check:changeset-no-majorspelling does not exist as a script — exit 254 "Command not found", i.e. that spelling is NOT MEASURED; the derivednode scripts/…spelling is the one run).check:empty-changesetexit 0. The changeset carries triage's asymmetry sentence.Clause-② self-reading
Yes, read from this diff. The accept/reject set of
engine.updateis 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 gainscode(DUPLICATE_RECORD) andstatus(409) where it had neither, with the driver error moved one step down ontocause, so the REST answer for that refusal moves 500 → 409 (measured throughresolveThrownHttpError). No new public export (DuplicateRecordError/DUPLICATE_RECORD_CODEwere exported by #14095); nopackages/specchange; no signature change onDuplicateRecordError.needs:contract-reviewis 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 assertedtoBe(raw)(identity), not aDuplicateRecordError,statusundefined, 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 assertcodeANDstatus, never a baretoThrow().Ablation
Reverted ONLY the two envelope throws (
throw envelopeUniqueViolation(driverError, object);→throw driverError;, 2 occurrences) from the committed state, with atrap … EXIT INT TERMrestore toHEAD. 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 inengine-update-duplicate-record.test.ts,engine-insert-duplicate-record.test.ts24/24 passed. Mutation proven on disk by grep counts (envelope throws 2 → 0,ABLATION-14390marker 0 → 2) and blob hash (36bec3e6…→85c62f46…, ≠ HEAD). Restore proven:git hash-object=HEAD:packages/objectql/src/engine.ts(36bec3e6…),git diff HEAD --statempty, marker count 0, throws 2. Nodist/leg applies: both pin files import./enginerelatively fromsrc, so noexportsresolution todist/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 --commandswith 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 savedturbo run testlog — 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-repopnpm buildis 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; noengine-double-contract.pinned.jsonrows 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 nomajorbump."),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, speccheck:docs/check:liveness/check:empty-state.Always-runs:
pnpm lint(whole repo,eslint . --no-inline-config) exit 0 atb488ecc44;pnpm check:nul-bytesexit 0 ("scanned 7923 text file(s) … no raw ASCII control bytes");pnpm check:error-status-conformanceexit 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, movingpackages/spec,packages/types,packages/metadata,packages/metadata-protocol), the dependency-closure rebuild could not be completed on this shared box: two attempts acquiredscripts/pm/os-verify-lock.shand were killed by the container's 10-minute foreground cap duringpackages/spec's DTS leg (exit 143), and five attempts (spec build ×3, spec DTS-only leg, pin suites) endedqueue-timeout (exit 99) · never acquiredbehind other seats' whole-repoturbo run build/typecheckand closure-build holds (holders named in the report'stests). The six adjacent suites WERE measured atb488ecc44on 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.tsleg is missing, which vitest does not read. So atb488ecc44the objectqltypecheckand the full objectql vitest run are NOT MEASURED —packages/spec/dist/*.d.tsis absent in this worktree. The last measured readings are ateb144eaf6(one merge earlier, closure freshly built from that tree):pnpm --filter @objectstack/objectql typecheckexit 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=2→Test 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. Betweeneb144eaf6andb488ecc44the 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'sTypeScript Type CheckandTest Corejobs 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 andenvelopeUniqueViolation's doc now name both doors, and two words in string literals: theobjectparam doc ("refused insert" → "refused write") anddeveloperMessage("refused this insert" → "refused this write"), because the envelope is now raised byupdatetoo and adeveloperMessagecalling 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'sdeveloperMessageassertions (DUPLICATE_RECORD,cause) are unchanged and green.messagesentence (… 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 door —ObjectQLhas noupsertmethod (update.options.upsertis a retired-key tombstone, [finding]options.upsertis accepted byengine.update()'s option surface and never read — a declared-but-unenforced key (ADR-0049) #8057),update()never reaches a driverupsert, and the driver-levelupsert(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.mdxre-anchored twice withcheck-system-context-census.mjs --fix(never hand-edited): once for the engine.ts line shifts, once per merge oforigin/main(the os-regen merge driver deferred it; the discharge commit follows each merge).12a0c1dc8(before mergingorigin/main);engine.tsandduplicate-record-error.tsare byte-identical in the final head to that commit for the changed hunks (the merges brought no change to either file).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