Skip to content

Commit 65846bc

Browse files
os-justinclaude
andauthored
fix(metadata-protocol,rest,objectql): a batch/import ROW reports a unique-constraint refusal as UNIQUE_VIOLATION, the route's one wire spelling (#15363)
A driver's unique-constraint refusal reached a client under two spellings on one route: the whole-request failure on `POST /data/:object/batch` and `POST /data/:object/import` answered `UNIQUE_VIOLATION` (the standard-catalog member the published protocol docs give for the 409 constraint-violation body), while a ROW on the same route relayed the engine's registered `DUPLICATE_RECORD` verbatim. Maintainer ruling A (2026-09-03): converge on `UNIQUE_VIOLATION`; the engine's thrown identity does not move; no ledger waiver. - `metadata-protocol` `toRowApiError`: the engine's `DuplicateRecordError` envelope (registered code AND class name, the whole-request arm's own gate) maps to `UNIQUE_VIOLATION` before the verbatim registered-code limb; `httpStatus` 409 and the platform sentence are unchanged. - `rest` `toFailedResult`: the same mapping for import rows, through `isEngineDuplicateRecordEnvelope` exported from `error-response.ts`; the whole-request arm's logic is untouched (its inline gate is what the sandbox drift guard keys on), and the docblock that disclosed the fork under the #14541 review now states the converged rule. - `objectql` `DuplicateRecordError`: the docblock and `developerMessage` name the wire spelling a client branches on and the in-process spelling beside it. - Pins: the runtime real-driver row pin flips and asserts no `DUPLICATE_RECORD` anywhere in the payload; metadata-protocol section 7 drives all three loops plus two guards (a hook merely speaking the registered code, and a name-only impostor, both keep their own code); a new import-runner row pin drives both write paths with the real class and pins door-to-row agreement. - Changesets: `@objectstack/metadata-protocol` and `@objectstack/rest` minor with a BREAKING banner naming the row-level spelling that moves; `@objectstack/objectql` patch for the shipped `developerMessage` string. Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk Co-authored-by: Claude <noreply@anthropic.com>
1 parent b03d01e commit 65846bc

11 files changed

Lines changed: 587 additions & 36 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
fix(metadata-protocol)!: a batch ROW reports a unique-constraint refusal as `UNIQUE_VIOLATION` — the same wire spelling as the whole-request failure on the same route (#14723)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, export, config field or stored metadata changes spelling or shape, `packages/spec` is untouched and `objectstack migrate meta` has nothing to rewrite. What moves is the `code` value one REST response row carries for one condition — the wire report of a driver's unique-constraint refusal on the per-row surface of `POST /api/v1/data/:object/batch` — which now spells the standard-catalog member the whole-request doors and the published protocol docs already use. The consumer note below is guidance for a client branching on that row code; it prescribes no rewrite of any authored artifact. -->
8+
9+
**BREAKING** on the per-row report of `POST /api/v1/data/:object/batch` (and
10+
the multi-object `POST /api/v1/batch`, which rides the same protocol): a row
11+
refused by the engine's `DuplicateRecordError` envelope now reports
12+
`errors[].code: 'UNIQUE_VIOLATION'` where it reported `'DUPLICATE_RECORD'`.
13+
Shipped as `minor` under the repo's launch-window convention for breaking
14+
changes. Maintainer ruling 2026-09-03 on #14723 (verbatim 「同意,然后执行契约
15+
复审」), adopting option A: one wire spelling for a unique-constraint refusal on
16+
every route.
17+
18+
**Why.** `toRowApiError` put a thrown REGISTERED code on the row verbatim, and
19+
`DUPLICATE_RECORD` is registered, so a `DuplicateRecordError` row said
20+
`DUPLICATE_RECORD` while the whole-request failure on the very same route (the
21+
bulk door's classification in `@objectstack/rest`) answered `UNIQUE_VIOLATION`
22+
— the standard-catalog member `content/docs/protocol/kernel/http-protocol.mdx`
23+
documents for the 409 constraint-violation body. Since the bulk doors were
24+
restored to `UNIQUE_VIOLATION`, the two spellings of one condition sat side by
25+
side in one route's responses, which ADR-0112's one-name-per-concept and the
26+
error-code ledger's own header both forbid. The duplication is removed, not
27+
declared: no ledger waiver is added.
28+
29+
**What changes.** The row derivation recognises the engine's envelope by the
30+
same two-part gate the whole-request arm uses — the registered code AND the
31+
class name `DuplicateRecordError`, never message text — and reports
32+
`UNIQUE_VIOLATION`. Everything else on the row is unchanged: `httpStatus: 409`,
33+
the platform sentence (no driver text, no bound value — the driver's error
34+
stays on `cause` and never reaches the row), and the sibling `NOT_ATTEMPTED` /
35+
`ROLLED_BACK` rows.
36+
37+
**What does NOT change.** The engine's thrown identity: `DuplicateRecordError.code`
38+
is still `DUPLICATE_RECORD` for an in-process caller of `engine.insert` /
39+
`engine.update` (a hook, a flow node), and the objectql pins on `insert` /
40+
`insertMany` hold. The single-record `/data` door, which has answered
41+
`UNIQUE_VIOLATION` throughout, does not move. A producer that merely THROWS the
42+
registered `DUPLICATE_RECORD` from its own body without being the engine's
43+
class keeps its own code on the row, exactly as it does at the door.
44+
45+
**Consumer note.** A batch client that branched on a row's `code` reading
46+
`DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it
47+
already handles for the whole-request 409 on that route and on the
48+
single-record door. Measured in-repo and in the sibling repos (hotcrm, objectui,
49+
non-test sources): zero consumers branch on either spelling of a row code.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): `DuplicateRecordError.developerMessage` names the wire spelling a client branches on (#14723)
6+
7+
The envelope's `developerMessage` — the remedy sentence addressed to the
8+
application author — told its reader to "branch on `code === 'DUPLICATE_RECORD'`",
9+
which is the engine's THROWN identity and holds only for an in-process caller of
10+
`engine.insert` / `engine.update`. Every REST route reports the same refusal as
11+
`UNIQUE_VIOLATION`, and since #14723 the per-row reports of the batch and import
12+
surfaces do too, so the sentence was a platform contradicting itself on the one
13+
line an author is most likely to copy. It now says both halves: over the HTTP
14+
API branch on `code === 'UNIQUE_VIOLATION'` on every route, whole-request and
15+
per-row alike; inside the engine the thrown class carries `DUPLICATE_RECORD`.
16+
The class's own docblock says the same. Nothing else about the envelope moves:
17+
`code`, `status`, `cause`, `field`, `object` and the user-facing `message` are
18+
byte-identical, and every pin on the engine's thrown code holds.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
fix(rest)!: an import ROW report spells a unique-constraint refusal `UNIQUE_VIOLATION` — the same wire code as the whole-request failure on the same route (#14723)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, export, config field or stored metadata changes spelling or shape, `packages/spec` is untouched and `objectstack migrate meta` has nothing to rewrite. What moves is the `code` value one import row report carries for one condition — the wire report of a driver's unique-constraint refusal on the per-row surface of `POST /api/v1/data/:object/import` — which now spells the standard-catalog member the whole-request door and the published protocol docs already use. The consumer note below is guidance for a client branching on that row code; it prescribes no rewrite of any authored artifact. -->
8+
9+
**BREAKING** on the per-row results of the import runner
10+
(`POST /api/v1/data/:object/import` and the import job): a row refused by the
11+
engine's `DuplicateRecordError` envelope now reports `code: 'UNIQUE_VIOLATION'`
12+
where it reported `'DUPLICATE_RECORD'`. Shipped as `minor` under the repo's
13+
launch-window convention for breaking changes. Maintainer ruling 2026-09-03 on
14+
#14723 (verbatim 「同意,然后执行契约复审」), adopting option A: one wire
15+
spelling for a unique-constraint refusal on every route.
16+
17+
**Why.** `toFailedResult` relayed the thrown error's own `code`, and the engine's
18+
envelope carries the registered `DUPLICATE_RECORD` — while the whole-request
19+
failure on the same import route answered `UNIQUE_VIOLATION` through
20+
`mapDataError`. Two spellings of one condition on one route, which ADR-0112's
21+
one-name-per-concept and the error-code ledger's header both forbid. The
22+
duplication is removed, not declared: no ledger waiver is added.
23+
24+
**What changes.** The import row derivation applies the whole-request arm's own
25+
predicate — the registered code AND the class name `DuplicateRecordError`,
26+
exported from `error-response.ts` as `isEngineDuplicateRecordEnvelope` and now
27+
shared by the arm and the row report — and reports `UNIQUE_VIOLATION`. A
28+
field-level finding still takes precedence (the envelope carries none), the
29+
row's sentence is unchanged (the platform sentence, sanitised as before; no
30+
driver text), and a producer that merely throws the registered
31+
`DUPLICATE_RECORD` without being the engine's class keeps its own code.
32+
33+
**What does NOT change.** The whole-request doors (single-record, bulk, import,
34+
metadata, UI) already answered `UNIQUE_VIOLATION` and keep doing so; the arm's
35+
logic is untouched beyond reading the shared predicate. The engine's thrown
36+
identity stays `DUPLICATE_RECORD` in-process. This package's `error-response.ts`
37+
docblock that disclosed the fork under the #14541 contract review now states
38+
the converged rule.
39+
40+
**Consumer note.** An import client that branched on a row's `code` reading
41+
`DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it
42+
already handles for the whole-request 409. Measured in-repo and in the sibling
43+
repos (hotcrm, objectui, non-test sources): zero consumers branch on either
44+
spelling of a row code.

packages/metadata-protocol/src/protocol.batch-row-http-status.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
import { describe, it, expect, vi } from 'vitest';
6060
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
6161
import { resolveThrownHttpError } from '@objectstack/types';
62+
import { ErrorCode } from '@objectstack/spec/api';
6263
import { ObjectStackProtocolImplementation } from './protocol.js';
6364

6465
const SCHEMA = {
@@ -452,3 +453,141 @@ describe('[#8570] section 6 — anti-vacuity: the doubles are the shapes they cl
452453
expect(resolveThrownHttpError(declaredServiceUnavailable()).declaredStatus).toBe(503);
453454
});
454455
});
456+
457+
// ─── [#14723] The row speaks the WIRE spelling of a unique-constraint refusal ─
458+
459+
/**
460+
* MEASURED — `@objectstack/objectql`'s `DuplicateRecordError` as it reaches
461+
* these catches (`node`, the built class, `new DuplicateRecordError('leave_request',
462+
* raw, 'title')`): own properties `[stack, message, object, field, code,
463+
* status, name, cause, developerMessage]`, `code: 'DUPLICATE_RECORD'`,
464+
* `status: 409`, `name: 'DuplicateRecordError'`, the driver's error whole on
465+
* `cause`, and the platform sentence on `message` — no statement, no bound
466+
* value. Assignment ORDER matches the BUILT class: the two parameter
467+
* properties land first, then the `readonly` initialisers, then the
468+
* constructor body sets `name`, `cause` and `developerMessage`. The class
469+
* itself is deliberately not imported —
470+
* this package does not depend on `@objectstack/objectql`, and the row's rule
471+
* reads the envelope's declared `code` / `name`, never its prototype.
472+
*/
473+
function engineDuplicateRecordEnvelope(object = 'leave_request', field = 'title'): Error {
474+
const cause = driverFault(
475+
`insert into \`${object}\` (\`${field}\`, \`id\`) values ('dup-value', 'new-4') returning * - ` +
476+
`UNIQUE constraint failed: ${object}.${field}`,
477+
);
478+
const err = new Error(
479+
`Duplicate record refused on '${object}': a unique constraint on '${field}' already holds this value. ` +
480+
'No record was written.',
481+
) as Error & { code: string; status: number; object: string; field: string; cause: unknown; developerMessage: string };
482+
err.object = object;
483+
err.field = field;
484+
err.code = 'DUPLICATE_RECORD';
485+
err.status = 409;
486+
err.name = 'DuplicateRecordError';
487+
err.cause = cause;
488+
err.developerMessage = 'The driver refused this write as a unique-constraint violation.';
489+
return err;
490+
}
491+
492+
/** A hook that deliberately THROWS the registered `DUPLICATE_RECORD` — a different producer. */
493+
function hookSpeakingDuplicateRecord(): Error {
494+
const err = new Error('this row is already there, says the hook') as Error & { code: string; status: number };
495+
err.code = 'DUPLICATE_RECORD';
496+
err.status = 409;
497+
return err;
498+
}
499+
500+
const ENVELOPE_SENTENCE =
501+
"Duplicate record refused on 'leave_request': a unique constraint on 'title' already holds this value. " +
502+
'No record was written.';
503+
504+
describe('[#14723] section 7 — a `DuplicateRecordError` row reports `UNIQUE_VIOLATION`, the route\'s one wire spelling', () => {
505+
it('the create loop: `UNIQUE_VIOLATION` / 409 / the platform sentence, and no `DUPLICATE_RECORD` anywhere in the payload', async () => {
506+
const { protocol } = makeEngine((verb) => (verb === 'insert' ? engineDuplicateRecordEnvelope() : undefined));
507+
508+
const res: any = await protocol.batchData({
509+
object: 'leave_request',
510+
request: { operation: 'create', records: [{ data: { title: 'dup-value' } }] },
511+
});
512+
513+
// The whole row, exactly: the code moved, the status and the sentence
514+
// did not (a declared 409 is quoted — #8502's positive list, unchanged).
515+
expect(res.results[0].errors[0]).toEqual({
516+
code: 'UNIQUE_VIOLATION',
517+
message: ENVELOPE_SENTENCE,
518+
httpStatus: 409,
519+
});
520+
const payload = JSON.stringify(res);
521+
expect(payload).not.toContain('DUPLICATE_RECORD');
522+
// …and the driver's text stays on `cause`, which never reaches a row.
523+
expect(payload).not.toContain('insert into');
524+
expect(payload).not.toContain('UNIQUE constraint failed');
525+
expect(payload).not.toContain('SQLITE_ERROR');
526+
});
527+
528+
it('the update and upsert loops report the same spelling — one helper, every loop', async () => {
529+
const a = makeEngine((verb) => (verb === 'update' ? engineDuplicateRecordEnvelope() : undefined));
530+
const updateRes: any = await a.protocol.updateManyData({
531+
object: 'leave_request', records: [{ id: 'r1', data: { title: 'dup-value' } }],
532+
});
533+
expect(updateRes.results[0].errors[0].code).toBe('UNIQUE_VIOLATION');
534+
expect(updateRes.results[0].errors[0].httpStatus).toBe(409);
535+
536+
const b = makeEngine((verb) => (verb === 'update' ? engineDuplicateRecordEnvelope() : undefined));
537+
const upsertRes: any = await b.protocol.batchData({
538+
object: 'leave_request',
539+
request: { operation: 'upsert', records: [{ id: 'r1', data: { title: 'dup-value' } }] },
540+
});
541+
expect(upsertRes.results[0].errors[0].code).toBe('UNIQUE_VIOLATION');
542+
expect(JSON.stringify(upsertRes)).not.toContain('DUPLICATE_RECORD');
543+
});
544+
545+
it('[GUARD] a producer that merely SPEAKS `DUPLICATE_RECORD` is not the engine\'s envelope and keeps its own code', async () => {
546+
// The same discrimination the whole-request arm makes (#14389 §5):
547+
// the gate is the registered code AND the class name. A hook throwing
548+
// the registered member from its own body is a different producer
549+
// speaking a member of the vocabulary; the verbatim rule still applies.
550+
const { protocol } = makeEngine((verb) => (verb === 'insert' ? hookSpeakingDuplicateRecord() : undefined));
551+
552+
const res: any = await protocol.batchData({
553+
object: 'leave_request',
554+
request: { operation: 'create', records: [{ data: { title: 'x' } }] },
555+
});
556+
557+
expect(res.results[0].errors[0]).toEqual({
558+
code: 'DUPLICATE_RECORD',
559+
message: 'this row is already there, says the hook',
560+
httpStatus: 409,
561+
});
562+
});
563+
564+
it('[GUARD] the name alone does not qualify either — a `DuplicateRecordError` carrying another code keeps that code', async () => {
565+
const impostor = approvalsRecordLock('r1');
566+
impostor.name = 'DuplicateRecordError';
567+
const { protocol } = makeEngine((verb) => (verb === 'update' ? impostor : undefined));
568+
569+
const res: any = await protocol.updateManyData({
570+
object: 'leave_request', records: [{ id: 'r1', data: { progress: 1 } }],
571+
});
572+
573+
expect(res.results[0].errors[0].code).toBe('RECORD_LOCKED');
574+
});
575+
576+
it('anti-vacuity: BOTH spellings are registered, so the verbatim rule alone would have kept `DUPLICATE_RECORD`', () => {
577+
// The mapping is the only thing standing between the envelope and the
578+
// row's old spelling: `toRowApiError`'s verbatim limb admits any
579+
// registered code, and the engine's is registered. Reverting the
580+
// mapping therefore reddens section 7's first case with the row
581+
// reading `DUPLICATE_RECORD` again — measured, not assumed.
582+
expect(ErrorCode.safeParse('DUPLICATE_RECORD').success).toBe(true);
583+
expect(ErrorCode.safeParse('UNIQUE_VIOLATION').success).toBe(true);
584+
585+
const env = engineDuplicateRecordEnvelope() as any;
586+
expect(Object.getOwnPropertyNames(env)).toEqual([
587+
'stack', 'message', 'object', 'field', 'code', 'status', 'name', 'cause', 'developerMessage',
588+
]);
589+
expect(env.name).toBe('DuplicateRecordError');
590+
expect(env.code).toBe('DUPLICATE_RECORD');
591+
expect(resolveThrownHttpError(env).declaredStatus).toBe(409);
592+
});
593+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2031,11 +2031,42 @@ type BatchDataRowResult = BatchOperationResult;
20312031
* `declaredStatus` would mint incoherent rows — `{ code: 'INTERNAL_ERROR',
20322032
* httpStatus: 409 }` for a `statusCode`-spelled refusal whose own code the
20332033
* ledger does not know.
2034+
*
2035+
* ## One wire spelling for a unique-constraint refusal (#14723)
2036+
*
2037+
* The engine answers a driver's unique-constraint refusal with its
2038+
* `DuplicateRecordError` envelope — `code: 'DUPLICATE_RECORD'`, `status: 409`,
2039+
* the driver's error whole on `cause`. `DUPLICATE_RECORD` is a registered
2040+
* member, so the verbatim limb above used to put it on the row as-is, while
2041+
* every WHOLE-REQUEST door in `@objectstack/rest` answers the same class as
2042+
* `UNIQUE_VIOLATION` — the standard-catalog member the published protocol
2043+
* docs give for the 409 constraint-violation body. After #14541 the two
2044+
* spellings sat side by side in one route's responses: a whole-request
2045+
* failure on `POST /data/:object/batch` said `UNIQUE_VIOLATION`, a row on the
2046+
* same route said `DUPLICATE_RECORD`.
2047+
*
2048+
* Maintainer ruling (2026-09-03, #14723): one wire spelling on every route,
2049+
* `UNIQUE_VIOLATION`; the row reports it too. So this limb maps the engine's
2050+
* envelope to the wire spelling BEFORE the verbatim registered-code rule —
2051+
* keyed exactly as the whole-request arm keys it (`error-response.ts`'s
2052+
* `structuredCodeAnswer`: the registered code AND the class name), never by
2053+
* message text. `httpStatus` still reads the declared 409 and `message` is
2054+
* still the envelope's platform sentence (a declared 4xx, so it is quoted); the
2055+
* driver's text stays on `cause`, which never reaches the row.
2056+
*
2057+
* ⛔ The engine's thrown identity is NOT renamed: `DuplicateRecordError.code`
2058+
* stays `DUPLICATE_RECORD` (an in-process caller — a hook, a flow node —
2059+
* branches on that). What moves is what a ROW REPORTS across the HTTP
2060+
* boundary. And a producer that merely SPEAKS `DUPLICATE_RECORD` — a hook
2061+
* throwing the registered code without being the engine's class — keeps its
2062+
* own code on the row, the same discrimination the whole-request arm makes.
20342063
*/
20352064
function toRowApiError(err: any, fallback: string): ApiError {
2036-
const thrown = typeof err?.code === 'string' && ErrorCode.safeParse(err.code).success
2037-
? (err.code as ApiError['code'])
2038-
: undefined;
2065+
const thrown = isEngineDuplicateRecordEnvelope(err)
2066+
? 'UNIQUE_VIOLATION'
2067+
: typeof err?.code === 'string' && ErrorCode.safeParse(err.code).success
2068+
? (err.code as ApiError['code'])
2069+
: undefined;
20392070
const { declaredStatus } = resolveThrownHttpError(err);
20402071
return {
20412072
code: thrown ?? (declaredStatus !== undefined ? standardErrorCodeForHttpStatus(declaredStatus) : 'INTERNAL_ERROR'),
@@ -2044,6 +2075,25 @@ function toRowApiError(err: any, fallback: string): ApiError {
20442075
};
20452076
}
20462077

2078+
/**
2079+
* [#14723] Is this thrown value the ENGINE's unique-violation envelope?
2080+
*
2081+
* The same two-part gate `@objectstack/rest`'s whole-request arm applies —
2082+
* the registered code AND the class name — so a batch row and a whole-request
2083+
* failure recognise the engine's envelope by one rule. Name alone would miss
2084+
* nothing today but would let a future class of the same name speak a
2085+
* different code; code alone would swallow a hook that deliberately throws
2086+
* the registered `DUPLICATE_RECORD` from its own body, which is a different
2087+
* producer speaking a member of the vocabulary and keeps its own spelling.
2088+
* The class itself is not imported: this package does not depend on
2089+
* `@objectstack/objectql`, and the envelope's contract is its declared
2090+
* `code` / `name` / `status`, not its prototype.
2091+
*/
2092+
function isEngineDuplicateRecordEnvelope(err: unknown): boolean {
2093+
const e = err as { code?: unknown; name?: unknown } | null | undefined;
2094+
return e?.code === 'DUPLICATE_RECORD' && e?.name === 'DuplicateRecordError';
2095+
}
2096+
20472097
/**
20482098
* [#8502] The stable sentence a failed batch row says when nothing may be
20492099
* quoted — the operation, named, and no interpolation of any kind.

0 commit comments

Comments
 (0)