Skip to content

Commit ebb6239

Browse files
os-muskclaude
andauthored
fix(objectql): update answers a driver unique violation with the DUPLICATE_RECORD envelope, on every driver (#14390) (#14603)
* fix(objectql): envelope the update door's driver unique violation as 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 * docs: re-anchor the system-context census after the update-door line 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 * docs: regenerate the system-context census on the merged tree (#14390) 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 * docs: regenerate the system-context census on the re-merged tree (#14390) 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f995171 commit ebb6239

5 files changed

Lines changed: 789 additions & 14 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql): `update` answers a driver unique violation with the `DUPLICATE_RECORD` envelope, on every driver (#14390)
6+
7+
The insert door got this contract in #14095; the update door — one verb over —
8+
did not, and the platform was left with ONE contract for the condition on
9+
`insert` and none on `update`. Measured on a real `ObjectQL` engine over a real
10+
`driver-sqlite-wasm` store with a declared unique index on `email`: driving a
11+
second row onto the first's value through `engine.update` threw a bare `Error`
12+
with no `code`, no `status`, no `cause`, and the whole compiled UPDATE
13+
statement — bound values included — as its message. The REST boundary sanitises
14+
an error with neither `code` nor `status` into `500 INTERNAL_ERROR`, so **the
15+
same user action now answers `409 DUPLICATE_RECORD` on create and
16+
`500 INTERNAL_ERROR` on edit.** A 500 tells a client the server fell over, tells
17+
a form to show a generic failure, and pages whoever watches 5xx rates — for a
18+
conflict the user can fix by typing a different value. "Renaming a record onto
19+
a name someone else already took" is the ordinary form-submission case, and it
20+
was the one left dialect-coupled.
21+
22+
**What `engine.update` now raises** for a recognised unique violation,
23+
identically on every driver and on BOTH driver exits of the door — the by-id
24+
`driver.update` call and the predicate (`multi: true`) `driver.updateMany`
25+
call — and therefore through the scoped-repository facade a hook reaches as
26+
`ctx.api.object(name).update(...)` / `.updateById(...)`: `DuplicateRecordError`
27+
`code: 'DUPLICATE_RECORD'`, `status: 409`, the driver's own error WHOLE on
28+
`cause`, `object`, a `developerMessage` carrying the remedy, and `field` when —
29+
and only when — `uniqueViolationColumn` determinably named the conflicting
30+
COLUMN (an index name is never reported as a column).
31+
32+
**A multi-row update names no row.** The driver's error does not say which of
33+
the N matched rows conflicted, and the envelope does not invent an answer: it
34+
carries exactly the keys the by-id envelope carries — no count, no row index —
35+
and `field` only when the dialect named a column, exactly as the composite-index
36+
case already behaves on insert.
37+
38+
**Nothing else moves.** A NOT NULL violation, a deadlock, a missing table and an
39+
unreachable store all leave the door as the very object the driver threw —
40+
pinned on identity, on both the by-id and the predicate exits. The verdict is
41+
the shared `isUniqueViolationError` predicate; this door adds no dialect
42+
knowledge of its own. The envelope sits on the two driver exits rather than on
43+
the door's outer `catch`, because that `catch` also sees the `afterUpdate`
44+
dispatch and the roll-up recompute — a unique violation raised by a nested
45+
driver call inside a hook is not this object's to envelope, and is passed
46+
through untouched.
47+
48+
**The operator log is unchanged**: `Update operation failed` still carries the
49+
driver's own diagnosis (the failing column, the redacted statement marker),
50+
because the engine logs the envelope's `cause`, exactly as the insert door does.
51+
52+
Shipped as `minor` rather than a patch, for the reason #14095 was: callers
53+
observe a different error object on a public data-API door. Measured
54+
consequences on real drivers:
55+
56+
- `driver-sqlite-wasm`: the by-id and the predicate refusal both become the
57+
envelope with `field: 'email'` and the raw `SQLITE_CONSTRAINT_UNIQUE` error on
58+
`cause`; the REST status resolution moves from 500 to 409.
59+
- `driver-memory`: its own `UNIQUE_VIOLATION` / 409 refusal is normalised to the
60+
same `DUPLICATE_RECORD` envelope (one code for an application to branch on,
61+
not two), with the driver's error on `cause`; its declared-index sentence
62+
names no single column, so `field` is absent there.
63+
64+
**Deliberately not in this change**: `upsert` — the engine has no such verb
65+
today (`update.options.upsert` is a retired-key tombstone), and a dialect that
66+
converts a conflict into a merge would need its own measurement first; and the
67+
wire `code` the REST layer speaks for this condition, which is a separate lane.

content/docs/permissions/system-context.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ that silently does not happen.
109109

110110
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
111111
|:--|:---|:---|:---|:---|
112-
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11128` |
113-
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11296` |
112+
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11160` |
113+
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11343` |
114114
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9895` |
115115
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` |
116116
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9943`, `readonly-strict-errors.ts:66` |
@@ -119,8 +119,8 @@ that silently does not happen.
119119
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
120120
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` |
121121
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6460` |
122-
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11889` |
123-
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11818` |
122+
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11955` |
123+
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11884` |
124124

125125
### 3. Sharing (`plugin-sharing`)
126126

@@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
180180
| # | Site | Package | What it does |
181181
|:--|:---|:---|:---|
182182
| 62 | `objectql/src/engine.ts:3413` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
183-
| 63 | `objectql/src/engine.ts:14238` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
183+
| 63 | `objectql/src/engine.ts:14304` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
184184
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
185185
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |
186186

packages/objectql/src/duplicate-record-error.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/types';
44

55
/**
6-
* The ADR-0112 envelope `engine.insert` raises when a driver refuses a row as a
7-
* unique-constraint violation (#14095).
6+
* The ADR-0112 envelope `engine.insert` (#14095) and `engine.update` (#14390)
7+
* raise when a driver refuses a row as a unique-constraint violation.
88
*
99
* ## The defect this retires
1010
*
@@ -82,7 +82,7 @@ export class DuplicateRecordError extends Error {
8282
readonly developerMessage: string;
8383

8484
constructor(
85-
/** The object the refused insert targeted. */
85+
/** The object the refused write targeted. */
8686
public readonly object: string,
8787
cause: unknown,
8888
/** The conflicting column, when the dialect determinably named one. */
@@ -92,7 +92,7 @@ export class DuplicateRecordError extends Error {
9292
this.name = 'DuplicateRecordError';
9393
this.cause = cause;
9494
this.developerMessage =
95-
`The driver refused this insert as a unique-constraint violation. Its own error is attached ` +
95+
`The driver refused this write as a unique-constraint violation. Its own error is attached ` +
9696
`as \`cause\` — branch on \`code === '${DUPLICATE_RECORD_CODE}'\` (ADR-0112) rather than on a ` +
9797
`dialect's code or message, so the handling survives a change of store. To make the write ` +
9898
`idempotent, catch this code and treat the row as already present.`;
@@ -119,8 +119,9 @@ function buildDuplicateMessage(object: string, field?: string): string {
119119
}
120120

121121
/**
122-
* The insert door's driver-error exit: the platform envelope for a unique
123-
* violation, or the caller's own error unchanged for anything else.
122+
* A write door's driver-error exit — `insert` (#14095) and `update` (#14390),
123+
* by-id and predicate alike: the platform envelope for a unique violation, or
124+
* the caller's own error unchanged for anything else.
124125
*
125126
* **Unrecognised is passed through untouched**, which is the whole of the
126127
* negative contract: a NOT NULL violation, a deadlock, a missing table and an

0 commit comments

Comments
 (0)