Skip to content

Commit 10220a7

Browse files
os-trumpclaude
andauthored
fix(rest): answer the engine's DUPLICATE_RECORD insert-conflict envelope with the structured 409 UNIQUE_VIOLATION body (#14544)
* test(rest): pin the 409 body for the engine's DUPLICATE_RECORD insert-conflict envelope Red-first pins for the classifyDataError arm: UNIQUE_VIOLATION stays on the wire, field is restored when the dialect named the column, the curated sentence returns on error with the engine's sentence on developerMessage, and no offending value reaches the body (the driver-memory control). Changeset for @objectstack/rest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * fix(rest): answer the engine's DUPLICATE_RECORD insert-conflict envelope with the structured 409 UNIQUE_VIOLATION body One arm in classifyDataError, placed with DELETE_RESTRICTED and CONCURRENT_UPDATE ahead of the declared-status passthrough: the wire code stays UNIQUE_VIOLATION with the producer's spelling on declaredCode, field is restored when the dialect named the column, the curated end-user sentence returns on error with the engine's sentence on developerMessage, and no offending value reaches the body. Gated on the engine's envelope (name and code), so a sandbox body speaking the registered code keeps today's answer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * test(rest): type the raw-error filter in the parity pins so tsconfig.test.json compiles the file Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * fix(rest): drop declaredCode from the DUPLICATE_RECORD arm's 409 body — a vocabulary member is not a demotion Contract review on the card (reading B): DUPLICATE_RECORD is a StandardErrorCode member, and ApiErrorSchema.declaredCode plus ADR-0112's presence-means-demotion amendment define the field as the demoted spelling of an UNREGISTERED code, so it stays absent here and the engine's spelling stays in-process like every dialect code. Pins updated accordingly; the §0 control that both codes parse as ErrorCode now states the reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e27583e commit 10220a7

3 files changed

Lines changed: 585 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
Restore the `field` key and the curated conflict sentence on the 409 body for an insert refused by a unique constraint
6+
7+
Since the ObjectQL engine began answering a driver's unique violation with its own `DUPLICATE_RECORD` envelope (`status: 409`, `object`, `field`, the driver error on `cause`), `POST /api/v1/data/:object` let that envelope leave `classifyDataError` through the generic declared-status passthrough: still 409, but with `code: 'DUPLICATE_RECORD'`, no `field`, and the engine's own sentence in `error`. The `isUniqueViolationError` arm that names the conflicting column (`field: 'email'`, "A record with this email already exists") was no longer reached for an insert conflict. Measured with the real engine and real drivers: on `driver-sqlite-wasm` the `field` key disappeared from every conflict on a single-column index; on `driver-memory` the sentence changed.
8+
9+
A dedicated arm for the engine's envelope now sits with the other structured 409s (`DELETE_RESTRICTED`, `CONCURRENT_UPDATE`), ahead of the passthrough:
10+
11+
- `code` stays `UNIQUE_VIOLATION` — the code every client branching on this conflict already reads.
12+
- `field` is restored whenever the dialect determinably named the column (SQLite, Postgres); composite keys and index-naming dialects (MySQL) carry no `field` key, exactly as before.
13+
- `error` is the curated end-user sentence again; the engine's own sentence rides on `developerMessage`, the same split the `DELETE_RESTRICTED` body uses.
14+
- The body still quotes nothing the driver said — no offending value, no statement, no index name — including on `driver-memory`, whose raw refusal used to echo the offending values as JSON through the passthrough.
15+
16+
`patch`: a restoration of the shipped body's keys and wording; the wire `code` and the status are unchanged. The arm fires for the engine's envelope only; a plugin or sandbox body that throws the registered `DUPLICATE_RECORD` itself keeps the answer it gets today.

packages/rest/src/error-response.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,84 @@ function classifyDataError(error: any, object?: string): { status: number; body:
698698
},
699699
};
700700
}
701+
// [#14389] The engine's insert-conflict envelope → 409 `UNIQUE_VIOLATION`,
702+
// with the structured `field` restored.
703+
//
704+
// Since #14095 `engine.insert` answers a driver's unique violation with the
705+
// `DuplicateRecordError` envelope — `code: 'DUPLICATE_RECORD'`, `status:
706+
// 409`, `object`, `field` when the dialect determinably named a column,
707+
// the driver error whole on `cause`. It DECLARES a status, so it reached
708+
// the declared-status passthrough below first and left through it: status
709+
// right, `field` gone, and the platform's own sentence in `error`, while
710+
// the `isUniqueViolationError` arm further down — holding the curated
711+
// end-user wording and the `field` key since #7821 — was never reached
712+
// for an insert conflict any more. Surfaced FIRST, beside the two
713+
// structured 409s above, for the same reason they are: the structured
714+
// field must survive the generic catch-alls.
715+
//
716+
// **The wire `code` stays `UNIQUE_VIOLATION`** (triage ruling on the card,
717+
// 2026-09-02: the answer that changes nothing for clients — every consumer
718+
// branching on this conflict today reads `UNIQUE_VIOLATION`, and renaming
719+
// a wire code under existing consumers is a published-contract change,
720+
// not a door's call). **No `declaredCode` beside it.** `DUPLICATE_RECORD`
721+
// is a `StandardErrorCode` member, and `ApiErrorSchema.declaredCode`
722+
// (`packages/spec/src/api/contract.zod.ts`, with its docblock) together
723+
// with ADR-0112's "presence means demotion" amendment define the field as
724+
// the demoted spelling of an UNREGISTERED code — absent when the
725+
// producer's code IS a vocabulary member. So the field stays ABSENT here
726+
// (contract review on the card, 2026-09-02), and the engine's spelling
727+
// stays in-process exactly as every dialect code (`SQLITE_CONSTRAINT_UNIQUE`,
728+
// `23505`, `ER_DUP_ENTRY`) always has at the `isUniqueViolationError` arm
729+
// below. ADR-0112's 2026-08-29 scope correction declares the hand-written
730+
// `declaredCode` emission population to be exactly one site; this arm is
731+
// not a second. Pinned in `rest-duplicate-record-arm.test.ts`: §0 (both
732+
// codes parse as `ErrorCode` — the reason) and §1 (`not.toHaveProperty`).
733+
//
734+
// **Two sentences — the `DELETE_RESTRICTED` split above.** `error` is the
735+
// curated end-user sentence the #6250/#7821 arm has always produced: fixed
736+
// text plus, at most, the bare column identifier the ENGINE resolved
737+
// through `uniqueViolationColumn` (`field` is read off the envelope, not
738+
// re-derived — the envelope is the contract). `developerMessage` is the
739+
// engine's own sentence (`message`): it names the object and the column
740+
// and carries no value. The envelope's own `developerMessage` is
741+
// deliberately NOT relayed — it addresses the in-process caller of
742+
// `engine.insert` ("attached as `cause`", "branch on `code ===
743+
// 'DUPLICATE_RECORD'`"), and neither holds on this wire.
744+
//
745+
// **The body echoes nothing the driver said.** `cause` never reaches the
746+
// wire and the sentence is fixed text. This matters most for
747+
// `driver-memory`, whose raw refusal declares `status: 409` itself and so
748+
// ALREADY took the passthrough before #14095 — with a message quoting the
749+
// offending values as JSON. The envelope removed that; this arm keeps it
750+
// removed.
751+
//
752+
// ⚠️ Gated on the ENVELOPE — name AND code — not on the code alone as the
753+
// two siblings above are, and the difference is load-bearing: they relay
754+
// `error.message`, this arm REPLACES it. A hook that deliberately throws
755+
// the registered `DUPLICATE_RECORD` from a sandbox body is a different
756+
// producer speaking a member of the vocabulary; it keeps the answer the
757+
// sandbox unwrap door gives it today (its own sentence, its own code —
758+
// `rest-thrown-code-vocabulary.test.ts` §2) rather than having its
759+
// sentence swapped for this one and the QuickJS debug wrapper shipped as
760+
// `developerMessage`.
761+
if (error?.code === 'DUPLICATE_RECORD' && error?.name === 'DuplicateRecordError') {
762+
const field = typeof error?.field === 'string' && error.field.length > 0 ? error.field : undefined;
763+
const refused = typeof error?.object === 'string' && error.object.length > 0 ? error.object : object;
764+
return {
765+
status: 409,
766+
body: {
767+
error: field
768+
? `A record with this ${field} already exists`
769+
: 'A record with this value already exists',
770+
code: 'UNIQUE_VIOLATION',
771+
...(typeof error?.message === 'string' && error.message.length > 0
772+
? { developerMessage: error.message }
773+
: {}),
774+
...(field ? { field } : {}),
775+
...(refused ? { object: refused } : {}),
776+
},
777+
};
778+
}
701779
// A declared datasource that is refused by the host policy, or failed to
702780
// connect under OS_ALLOW_DRIVER_CONNECT_FAILURE → 503 (framework#3828).
703781
// Handled before the catch-alls because nothing about the REQUEST is wrong:

0 commit comments

Comments
 (0)