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
49 changes: 49 additions & 0 deletions .changeset/batch-row-unique-violation-metadata-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@objectstack/metadata-protocol": minor
---

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)

<!-- 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. -->

**BREAKING** on the per-row report of `POST /api/v1/data/:object/batch` (and
the multi-object `POST /api/v1/batch`, which rides the same protocol): a row
refused by the engine's `DuplicateRecordError` envelope now reports
`errors[].code: 'UNIQUE_VIOLATION'` where it reported `'DUPLICATE_RECORD'`.
Shipped as `minor` under the repo's launch-window convention for breaking
changes. Maintainer ruling 2026-09-03 on #14723 (verbatim 「同意,然后执行契约
复审」), adopting option A: one wire spelling for a unique-constraint refusal on
every route.

**Why.** `toRowApiError` put a thrown REGISTERED code on the row verbatim, and
`DUPLICATE_RECORD` is registered, so a `DuplicateRecordError` row said
`DUPLICATE_RECORD` while the whole-request failure on the very same route (the
bulk door's classification in `@objectstack/rest`) answered `UNIQUE_VIOLATION`
— the standard-catalog member `content/docs/protocol/kernel/http-protocol.mdx`
documents for the 409 constraint-violation body. Since the bulk doors were
restored to `UNIQUE_VIOLATION`, the two spellings of one condition sat side by
side in one route's responses, which ADR-0112's one-name-per-concept and the
error-code ledger's own header both forbid. The duplication is removed, not
declared: no ledger waiver is added.

**What changes.** The row derivation recognises the engine's envelope by the
same two-part gate the whole-request arm uses — the registered code AND the
class name `DuplicateRecordError`, never message text — and reports
`UNIQUE_VIOLATION`. Everything else on the row is unchanged: `httpStatus: 409`,
the platform sentence (no driver text, no bound value — the driver's error
stays on `cause` and never reaches the row), and the sibling `NOT_ATTEMPTED` /
`ROLLED_BACK` rows.

**What does NOT change.** The engine's thrown identity: `DuplicateRecordError.code`
is still `DUPLICATE_RECORD` for an in-process caller of `engine.insert` /
`engine.update` (a hook, a flow node), and the objectql pins on `insert` /
`insertMany` hold. The single-record `/data` door, which has answered
`UNIQUE_VIOLATION` throughout, does not move. A producer that merely THROWS the
registered `DUPLICATE_RECORD` from its own body without being the engine's
class keeps its own code on the row, exactly as it does at the door.

**Consumer note.** A batch client that branched on a row's `code` reading
`DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it
already handles for the whole-request 409 on that route and on the
single-record door. Measured in-repo and in the sibling repos (hotcrm, objectui,
non-test sources): zero consumers branch on either spelling of a row code.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/objectql": patch
---

fix(objectql): `DuplicateRecordError.developerMessage` names the wire spelling a client branches on (#14723)

The envelope's `developerMessage` — the remedy sentence addressed to the
application author — told its reader to "branch on `code === 'DUPLICATE_RECORD'`",
which is the engine's THROWN identity and holds only for an in-process caller of
`engine.insert` / `engine.update`. Every REST route reports the same refusal as
`UNIQUE_VIOLATION`, and since #14723 the per-row reports of the batch and import
surfaces do too, so the sentence was a platform contradicting itself on the one
line an author is most likely to copy. It now says both halves: over the HTTP
API branch on `code === 'UNIQUE_VIOLATION'` on every route, whole-request and
per-row alike; inside the engine the thrown class carries `DUPLICATE_RECORD`.
The class's own docblock says the same. Nothing else about the envelope moves:
`code`, `status`, `cause`, `field`, `object` and the user-facing `message` are
byte-identical, and every pin on the engine's thrown code holds.
44 changes: 44 additions & 0 deletions .changeset/import-row-unique-violation-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@objectstack/rest": minor
---

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)

<!-- 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. -->

**BREAKING** on the per-row results of the import runner
(`POST /api/v1/data/:object/import` and the import job): a row refused by the
engine's `DuplicateRecordError` envelope now reports `code: 'UNIQUE_VIOLATION'`
where it reported `'DUPLICATE_RECORD'`. Shipped as `minor` under the repo's
launch-window convention for breaking changes. Maintainer ruling 2026-09-03 on
#14723 (verbatim 「同意,然后执行契约复审」), adopting option A: one wire
spelling for a unique-constraint refusal on every route.

**Why.** `toFailedResult` relayed the thrown error's own `code`, and the engine's
envelope carries the registered `DUPLICATE_RECORD` — while the whole-request
failure on the same import route answered `UNIQUE_VIOLATION` through
`mapDataError`. Two spellings of one condition on one route, which ADR-0112's
one-name-per-concept and the error-code ledger's header both forbid. The
duplication is removed, not declared: no ledger waiver is added.

**What changes.** The import row derivation applies the whole-request arm's own
predicate — the registered code AND the class name `DuplicateRecordError`,
exported from `error-response.ts` as `isEngineDuplicateRecordEnvelope` and now
shared by the arm and the row report — and reports `UNIQUE_VIOLATION`. A
field-level finding still takes precedence (the envelope carries none), the
row's sentence is unchanged (the platform sentence, sanitised as before; no
driver text), and a producer that merely throws the registered
`DUPLICATE_RECORD` without being the engine's class keeps its own code.

**What does NOT change.** The whole-request doors (single-record, bulk, import,
metadata, UI) already answered `UNIQUE_VIOLATION` and keep doing so; the arm's
logic is untouched beyond reading the shared predicate. The engine's thrown
identity stays `DUPLICATE_RECORD` in-process. This package's `error-response.ts`
docblock that disclosed the fork under the #14541 contract review now states
the converged rule.

**Consumer note.** An import client that branched on a row's `code` reading
`DUPLICATE_RECORD` reads `UNIQUE_VIOLATION` there now — the same value it
already handles for the whole-request 409. Measured in-repo and in the sibling
repos (hotcrm, objectui, non-test sources): zero consumers branch on either
spelling of a row code.
139 changes: 139 additions & 0 deletions packages/metadata-protocol/src/protocol.batch-row-http-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
import { describe, it, expect, vi } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
import { resolveThrownHttpError } from '@objectstack/types';
import { ErrorCode } from '@objectstack/spec/api';
import { ObjectStackProtocolImplementation } from './protocol.js';

const SCHEMA = {
Expand Down Expand Up @@ -452,3 +453,141 @@ describe('[#8570] section 6 — anti-vacuity: the doubles are the shapes they cl
expect(resolveThrownHttpError(declaredServiceUnavailable()).declaredStatus).toBe(503);
});
});

// ─── [#14723] The row speaks the WIRE spelling of a unique-constraint refusal ─

/**
* MEASURED — `@objectstack/objectql`'s `DuplicateRecordError` as it reaches
* these catches (`node`, the built class, `new DuplicateRecordError('leave_request',
* raw, 'title')`): own properties `[stack, message, object, field, code,
* status, name, cause, developerMessage]`, `code: 'DUPLICATE_RECORD'`,
* `status: 409`, `name: 'DuplicateRecordError'`, the driver's error whole on
* `cause`, and the platform sentence on `message` — no statement, no bound
* value. Assignment ORDER matches the BUILT class: the two parameter
* properties land first, then the `readonly` initialisers, then the
* constructor body sets `name`, `cause` and `developerMessage`. The class
* itself is deliberately not imported —
* this package does not depend on `@objectstack/objectql`, and the row's rule
* reads the envelope's declared `code` / `name`, never its prototype.
*/
function engineDuplicateRecordEnvelope(object = 'leave_request', field = 'title'): Error {
const cause = driverFault(
`insert into \`${object}\` (\`${field}\`, \`id\`) values ('dup-value', 'new-4') returning * - ` +
`UNIQUE constraint failed: ${object}.${field}`,
);
const err = new Error(
`Duplicate record refused on '${object}': a unique constraint on '${field}' already holds this value. ` +
'No record was written.',
) as Error & { code: string; status: number; object: string; field: string; cause: unknown; developerMessage: string };
err.object = object;
err.field = field;
err.code = 'DUPLICATE_RECORD';
err.status = 409;
err.name = 'DuplicateRecordError';
err.cause = cause;
err.developerMessage = 'The driver refused this write as a unique-constraint violation.';
return err;
}

/** A hook that deliberately THROWS the registered `DUPLICATE_RECORD` — a different producer. */
function hookSpeakingDuplicateRecord(): Error {
const err = new Error('this row is already there, says the hook') as Error & { code: string; status: number };
err.code = 'DUPLICATE_RECORD';
err.status = 409;
return err;
}

const ENVELOPE_SENTENCE =
"Duplicate record refused on 'leave_request': a unique constraint on 'title' already holds this value. " +
'No record was written.';

describe('[#14723] section 7 — a `DuplicateRecordError` row reports `UNIQUE_VIOLATION`, the route\'s one wire spelling', () => {
it('the create loop: `UNIQUE_VIOLATION` / 409 / the platform sentence, and no `DUPLICATE_RECORD` anywhere in the payload', async () => {
const { protocol } = makeEngine((verb) => (verb === 'insert' ? engineDuplicateRecordEnvelope() : undefined));

const res: any = await protocol.batchData({
object: 'leave_request',
request: { operation: 'create', records: [{ data: { title: 'dup-value' } }] },
});

// The whole row, exactly: the code moved, the status and the sentence
// did not (a declared 409 is quoted — #8502's positive list, unchanged).
expect(res.results[0].errors[0]).toEqual({
code: 'UNIQUE_VIOLATION',
message: ENVELOPE_SENTENCE,
httpStatus: 409,
});
const payload = JSON.stringify(res);
expect(payload).not.toContain('DUPLICATE_RECORD');
// …and the driver's text stays on `cause`, which never reaches a row.
expect(payload).not.toContain('insert into');
expect(payload).not.toContain('UNIQUE constraint failed');
expect(payload).not.toContain('SQLITE_ERROR');
});

it('the update and upsert loops report the same spelling — one helper, every loop', async () => {
const a = makeEngine((verb) => (verb === 'update' ? engineDuplicateRecordEnvelope() : undefined));
const updateRes: any = await a.protocol.updateManyData({
object: 'leave_request', records: [{ id: 'r1', data: { title: 'dup-value' } }],
});
expect(updateRes.results[0].errors[0].code).toBe('UNIQUE_VIOLATION');
expect(updateRes.results[0].errors[0].httpStatus).toBe(409);

const b = makeEngine((verb) => (verb === 'update' ? engineDuplicateRecordEnvelope() : undefined));
const upsertRes: any = await b.protocol.batchData({
object: 'leave_request',
request: { operation: 'upsert', records: [{ id: 'r1', data: { title: 'dup-value' } }] },
});
expect(upsertRes.results[0].errors[0].code).toBe('UNIQUE_VIOLATION');
expect(JSON.stringify(upsertRes)).not.toContain('DUPLICATE_RECORD');
});

it('[GUARD] a producer that merely SPEAKS `DUPLICATE_RECORD` is not the engine\'s envelope and keeps its own code', async () => {
// The same discrimination the whole-request arm makes (#14389 §5):
// the gate is the registered code AND the class name. A hook throwing
// the registered member from its own body is a different producer
// speaking a member of the vocabulary; the verbatim rule still applies.
const { protocol } = makeEngine((verb) => (verb === 'insert' ? hookSpeakingDuplicateRecord() : undefined));

const res: any = await protocol.batchData({
object: 'leave_request',
request: { operation: 'create', records: [{ data: { title: 'x' } }] },
});

expect(res.results[0].errors[0]).toEqual({
code: 'DUPLICATE_RECORD',
message: 'this row is already there, says the hook',
httpStatus: 409,
});
});

it('[GUARD] the name alone does not qualify either — a `DuplicateRecordError` carrying another code keeps that code', async () => {
const impostor = approvalsRecordLock('r1');
impostor.name = 'DuplicateRecordError';
const { protocol } = makeEngine((verb) => (verb === 'update' ? impostor : undefined));

const res: any = await protocol.updateManyData({
object: 'leave_request', records: [{ id: 'r1', data: { progress: 1 } }],
});

expect(res.results[0].errors[0].code).toBe('RECORD_LOCKED');
});

it('anti-vacuity: BOTH spellings are registered, so the verbatim rule alone would have kept `DUPLICATE_RECORD`', () => {
// The mapping is the only thing standing between the envelope and the
// row's old spelling: `toRowApiError`'s verbatim limb admits any
// registered code, and the engine's is registered. Reverting the
// mapping therefore reddens section 7's first case with the row
// reading `DUPLICATE_RECORD` again — measured, not assumed.
expect(ErrorCode.safeParse('DUPLICATE_RECORD').success).toBe(true);
expect(ErrorCode.safeParse('UNIQUE_VIOLATION').success).toBe(true);

const env = engineDuplicateRecordEnvelope() as any;
expect(Object.getOwnPropertyNames(env)).toEqual([
'stack', 'message', 'object', 'field', 'code', 'status', 'name', 'cause', 'developerMessage',
]);
expect(env.name).toBe('DuplicateRecordError');
expect(env.code).toBe('DUPLICATE_RECORD');
expect(resolveThrownHttpError(env).declaredStatus).toBe(409);
});
});
56 changes: 53 additions & 3 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2031,11 +2031,42 @@ type BatchDataRowResult = BatchOperationResult;
* `declaredStatus` would mint incoherent rows — `{ code: 'INTERNAL_ERROR',
* httpStatus: 409 }` for a `statusCode`-spelled refusal whose own code the
* ledger does not know.
*
* ## One wire spelling for a unique-constraint refusal (#14723)
*
* The engine answers a driver's unique-constraint refusal with its
* `DuplicateRecordError` envelope — `code: 'DUPLICATE_RECORD'`, `status: 409`,
* the driver's error whole on `cause`. `DUPLICATE_RECORD` is a registered
* member, so the verbatim limb above used to put it on the row as-is, while
* every WHOLE-REQUEST door in `@objectstack/rest` answers the same class as
* `UNIQUE_VIOLATION` — the standard-catalog member the published protocol
* docs give for the 409 constraint-violation body. After #14541 the two
* spellings sat side by side in one route's responses: a whole-request
* failure on `POST /data/:object/batch` said `UNIQUE_VIOLATION`, a row on the
* same route said `DUPLICATE_RECORD`.
*
* Maintainer ruling (2026-09-03, #14723): one wire spelling on every route,
* `UNIQUE_VIOLATION`; the row reports it too. So this limb maps the engine's
* envelope to the wire spelling BEFORE the verbatim registered-code rule —
* keyed exactly as the whole-request arm keys it (`error-response.ts`'s
* `structuredCodeAnswer`: the registered code AND the class name), never by
* message text. `httpStatus` still reads the declared 409 and `message` is
* still the envelope's platform sentence (a declared 4xx, so it is quoted); the
* driver's text stays on `cause`, which never reaches the row.
*
* ⛔ The engine's thrown identity is NOT renamed: `DuplicateRecordError.code`
* stays `DUPLICATE_RECORD` (an in-process caller — a hook, a flow node —
* branches on that). What moves is what a ROW REPORTS across the HTTP
* boundary. And a producer that merely SPEAKS `DUPLICATE_RECORD` — a hook
* throwing the registered code without being the engine's class — keeps its
* own code on the row, the same discrimination the whole-request arm makes.
*/
function toRowApiError(err: any, fallback: string): ApiError {
const thrown = typeof err?.code === 'string' && ErrorCode.safeParse(err.code).success
? (err.code as ApiError['code'])
: undefined;
const thrown = isEngineDuplicateRecordEnvelope(err)
? 'UNIQUE_VIOLATION'
: typeof err?.code === 'string' && ErrorCode.safeParse(err.code).success
? (err.code as ApiError['code'])
: undefined;
const { declaredStatus } = resolveThrownHttpError(err);
return {
code: thrown ?? (declaredStatus !== undefined ? standardErrorCodeForHttpStatus(declaredStatus) : 'INTERNAL_ERROR'),
Expand All @@ -2044,6 +2075,25 @@ function toRowApiError(err: any, fallback: string): ApiError {
};
}

/**
* [#14723] Is this thrown value the ENGINE's unique-violation envelope?
*
* The same two-part gate `@objectstack/rest`'s whole-request arm applies —
* the registered code AND the class name — so a batch row and a whole-request
* failure recognise the engine's envelope by one rule. Name alone would miss
* nothing today but would let a future class of the same name speak a
* different code; code alone would swallow a hook that deliberately throws
* the registered `DUPLICATE_RECORD` from its own body, which is a different
* producer speaking a member of the vocabulary and keeps its own spelling.
* The class itself is not imported: this package does not depend on
* `@objectstack/objectql`, and the envelope's contract is its declared
* `code` / `name` / `status`, not its prototype.
*/
function isEngineDuplicateRecordEnvelope(err: unknown): boolean {
const e = err as { code?: unknown; name?: unknown } | null | undefined;
return e?.code === 'DUPLICATE_RECORD' && e?.name === 'DuplicateRecordError';
}

/**
* [#8502] The stable sentence a failed batch row says when nothing may be
* quoted — the operation, named, and no interpolation of any kind.
Expand Down
Loading
Loading