Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .changeset/update-unique-violation-duplicate-record-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@objectstack/objectql": minor
---

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

The insert door got this contract in #14095; the update door — one verb over —
did not, and the platform was left with ONE contract for the condition on
`insert` and none on `update`. Measured on a real `ObjectQL` engine over a real
`driver-sqlite-wasm` store with a declared unique index on `email`: driving a
second row onto the first's value through `engine.update` threw a bare `Error`
with no `code`, no `status`, no `cause`, and the whole compiled UPDATE
statement — bound values included — as its message. The REST boundary sanitises
an error with neither `code` nor `status` into `500 INTERNAL_ERROR`, so **the
same user action now answers `409 DUPLICATE_RECORD` on create and
`500 INTERNAL_ERROR` on edit.** A 500 tells a client the server fell over, tells
a form to show a generic failure, and pages whoever watches 5xx rates — for a
conflict the user can fix by typing a different value. "Renaming a record onto
a name someone else already took" is the ordinary form-submission case, and it
was the one left dialect-coupled.

**What `engine.update` now raises** for a recognised unique violation,
identically on every driver and on BOTH driver exits of the door — the by-id
`driver.update` call and the predicate (`multi: true`) `driver.updateMany`
call — and therefore through the scoped-repository facade a hook reaches as
`ctx.api.object(name).update(...)` / `.updateById(...)`: `DuplicateRecordError`
— `code: 'DUPLICATE_RECORD'`, `status: 409`, the driver's own error WHOLE on
`cause`, `object`, a `developerMessage` carrying the remedy, and `field` when —
and only when — `uniqueViolationColumn` determinably named the conflicting
COLUMN (an index name is never reported as a column).

**A multi-row update names no row.** The driver's error does not say which of
the N matched rows conflicted, and the envelope does not invent an answer: it
carries exactly the keys the by-id envelope carries — no count, no row index —
and `field` only when the dialect named a column, exactly as the composite-index
case already behaves on insert.

**Nothing else moves.** A NOT NULL violation, a deadlock, a missing table and an
unreachable store all leave the door as the very object the driver threw —
pinned on identity, on both the by-id and the predicate exits. The verdict is
the shared `isUniqueViolationError` predicate; this door adds no dialect
knowledge of its own. The envelope sits on the two driver exits rather than on
the door's outer `catch`, because that `catch` also sees the `afterUpdate`
dispatch and the roll-up recompute — a unique violation raised by a nested
driver call inside a hook is not this object's to envelope, and is passed
through untouched.

**The operator log is unchanged**: `Update operation failed` still carries the
driver's own diagnosis (the failing column, the redacted statement marker),
because the engine logs the envelope's `cause`, exactly as the insert door does.

Shipped as `minor` rather than a patch, for the reason #14095 was: callers
observe a different error object on a public data-API door. Measured
consequences on real drivers:

- `driver-sqlite-wasm`: the by-id and the predicate refusal both become the
envelope with `field: 'email'` and the raw `SQLITE_CONSTRAINT_UNIQUE` error on
`cause`; the REST status resolution moves from 500 to 409.
- `driver-memory`: its own `UNIQUE_VIOLATION` / 409 refusal is normalised to the
same `DUPLICATE_RECORD` envelope (one code for an application to branch on,
not two), with the driver's error on `cause`; its declared-index sentence
names no single column, so `field` is absent there.

**Deliberately not in this change**: `upsert` — the engine has no such verb
today (`update.options.upsert` is a retired-key tombstone), and a dialect that
converts a conflict into a merge would need its own measurement first; and the
wire `code` the REST layer speaks for this condition, which is a separate lane.
10 changes: 5 additions & 5 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ that silently does not happen.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 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` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11296` |
| 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` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11343` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9895` |
| 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` |
| 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` |
Expand All @@ -119,8 +119,8 @@ that silently does not happen.
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |

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

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

Expand Down
13 changes: 7 additions & 6 deletions packages/objectql/src/duplicate-record-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/types';

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

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

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