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
61 changes: 61 additions & 0 deletions .changeset/insert-unique-violation-duplicate-record-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
"@objectstack/objectql": minor
---

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

The platform recommends "declare a unique index, attempt the insert, swallow the
violation" — it is what lets an idempotent writer be an ordinary job instead of
needing a distributed lock, and `packages/objectql`'s own autonumber-resync doc
argues at length against the read-then-write alternative ("a probe costs a query
on every insert … and is still racy"). **An application could not complete that
pattern**, because the insert door rethrew the DRIVER's error verbatim and left
three bad options: branch on `SQLITE_CONSTRAINT_UNIQUE` (and silently stop being
idempotent the day the deployment moves to Postgres' `23505`, MySQL's
`ER_DUP_ENTRY` or Mongo's `E11000`); pattern-match a message that on the measured
SQLite path is the whole compiled INSERT statement; or use the platform's own
`isUniqueViolationError`, which is correct, dialect-independent — and lives in
`@objectstack/types`, a package an application cannot resolve.

Triage ruling 2026-09-01, verbatim: 「抛一个带既有词表码(`DUPLICATE_RECORD` 已在
ADR-0112 台账里)的平台错误,原驱动错误作 `cause` ⇒ `insert` 在每个驱动上有同一份
契约」.

**What `engine.insert` now raises** for a recognised unique violation, identically
on every driver and on every path a driver create failure leaves the door by
(single row, `bulkCreate` batch, the per-row fallback loop, `insertMany`'s partial
mode, the scoped-repository facade, and the resync's last-chance create):
`DuplicateRecordError` — `code: 'DUPLICATE_RECORD'` (already a member of
`StandardErrorCode`; no `packages/spec` change was needed), `status: 409` (the
conflict status its sibling refusals `DELETE_RESTRICTED` / `CONCURRENT_UPDATE`
declare), 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; #6544's contract is not widened here).

**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, in both the single-row and batch paths. The verdict is the shared
`isUniqueViolationError` predicate; this door adds no dialect knowledge of its own.
`ERR_AUTONUMBER_COLLISION` keeps its narrower identity, because "re-seeded,
re-issued, still refused" says something `DUPLICATE_RECORD` cannot.

Shipped as `minor` rather than a patch because callers observe a different error
object on a public data-API door. Measured consequences, end to end on real
drivers:

- **HTTP status is unchanged at 409** on `driver-sqlite-wasm` and `driver-memory`,
single-column and composite declared indexes alike: REST's declared-status
passthrough honours the envelope's `status`.
- **The wire `code` changes from `UNIQUE_VIOLATION` to `DUPLICATE_RECORD`** (both
registered), and the flat body no longer carries the `field` key on the dialects
that name a column, because the passthrough arm ships neither. Restoring it is a
dedicated `mapDataError` arm in `@objectstack/rest` — another lane, filed
separately, not a rider here.
- **Import row reports improve**: the row `code` was previously whatever dialect
token the driver used (`SQLITE_CONSTRAINT_UNIQUE`, `11000`) and is now
`DUPLICATE_RECORD`.
- **The operator log is unchanged**: the engine logs the driver's own error (the
envelope's `cause`), because the platform logger serializes only `message` and
`stack` — so #8682's "what the database said, including the failing column, is
kept" still holds.
15 changes: 15 additions & 0 deletions .changeset/seed-loader-cause-hop-operator-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/metadata-protocol": patch
---

fix(metadata-protocol): the seed loader's operator line reaches through `cause`, so an enveloped driver fault still names what the database said (#14095)

The seed channel has two halves by design: the payload quotes a caught sentence only when the producer DECLARES a client refusal, and the log carries the caught sentence ALWAYS — because withholding text that nothing else records is indistinguishable from deleting the diagnostic, which is what makes a disclosure fix a net loss for whoever has to fix the database.

`seedFailureCause` read `err.message` and nothing else. That was complete while every producer put its whole diagnosis there. It stopped being complete the moment one of them started ENVELOPING: `engine.insert` now answers a driver unique violation with `DUPLICATE_RECORD` / `status: 409` and keeps the driver's own error whole on `cause`, so the platform sentence sits on `message` and `UNIQUE constraint failed: dt_acct.email` sits one hop down. Read off `message` alone, the operator line printed the platform sentence and the driver's words reached **neither the response nor the log** — the exact loss the two-halves design exists to prevent, arriving through a producer doing the right thing.

So the log follows the hop: `seedFailureCause` now walks the `cause` chain (bounded at 4, the depth `@objectstack/types`' unique-violation predicate walks) and prints the DEEPEST non-empty sentence — the one no wrapper above it restates. The walk is structural, never a type check: this package must not import `@objectstack/objectql`, and an envelope from any producer earns the same treatment.

`seedCauseLabel` moves with it, because the marker would otherwise go false. It used to ask "was this ERROR's text withheld from the payload?", which was the same question while the printed sentence was always `err.message`. Now the two differ: an enveloped fault has its PLATFORM sentence quoted to the caller and its DRIVER sentence printed to the operator, and the old question answered `Cause` — telling an operator the reporter saw words the reporter never saw. It now compares the sentence about to be printed against the one the payload actually quoted, so `Cause` means "these are the same words". All three populations stay correct: withheld outright, enveloped, and plainly declared.

No behaviour changes for a producer that carries no `cause` — the walk finds nothing and returns `err.message`, byte for byte as before.
26 changes: 13 additions & 13 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,18 @@ 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:10986` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11154` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9782` |
| 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:11049` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11217` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9827` |
| 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:9819`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5740` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3604`, `:3614`, `:3641` |
| 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:9864`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5761` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3605`, `:3615`, `:3642` |
| 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:6438` |
| 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:11747` |
| 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:11676` |
| 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:6459` |
| 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:11810` |
| 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:11739` |

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

Expand Down Expand Up @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them.

| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3411` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:14096` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 62 | `objectql/src/engine.ts:3412` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:14159` | 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 All @@ -193,9 +193,9 @@ assuming `isSystem` covers it is a documented source of bugs.

| Assumption | Reality | Anchor |
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1909` (rationale at `:1819`–`1821`, #3760), `flow.zod.ts:685` |
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881`–`1883`, #3760), `flow.zod.ts:685` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9765`–`9782` |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9810`–`9827` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
Expand Down
Loading
Loading