Skip to content

Commit 395ca21

Browse files
fix(runtime): the dispatcher answers the engine's DuplicateRecordError as 409 UNIQUE_VIOLATION, with the arm's fields (#23686) (#23700)
Fixes #23686 Clause-②: no The runtime HTTP dispatcher's shared thrown-error writer, `HttpDispatcher.errorFromThrown`, now answers the engine's `DuplicateRecordError` with the ruled wire spelling, `409 UNIQUE_VIOLATION`. It carries the REST arm's fields in `error.details`: `developerMessage`, `object`, and `field` when the driver named the column. ## Governing text Maintainer ruling, recorded in commit `65846bc46`, as restated at `packages/metadata-protocol/src/protocol.ts`: > Maintainer ruling (2026-09-03, recorded in commit 65846bc): one wire spelling on every route, `UNIQUE_VIOLATION`; the row reports it too. The engine's thrown identity is not renamed: `DuplicateRecordError.code` stays `DUPLICATE_RECORD` in-process. A producer that merely speaks `DUPLICATE_RECORD` (a handler or hook throwing the registered code without being the engine's class) keeps its own code. ## What changed `packages/runtime/src/http-dispatcher.ts`, one branch in `errorFromThrown`, decided before the code is written: - **Code.** `isEngineDuplicateRecordEnvelope(e) ? 'UNIQUE_VIOLATION' : thrown.code`. The key is the existing `@objectstack/types` predicate. `RestServer`'s arm, the import runner's rows and both Hono doors (PR #23682) use the same key: the registered code AND the class name, never message text. There is no second translator. - **Details.** For that class only, the new module-local `duplicateArmContext` adds the fields that `structuredCodeAnswer` (the REST arm, an existing `@objectstack/types` export) states beside its `UNIQUE_VIOLATION`. It drops the arm's own `error` sentence and `code`. The fields are added only when the arm agrees with the envelope on status and code, the Hono doors' agreement rule. The fields are read off that one classification and never listed. They go in `details`, the dispatcher envelope's own context slot (`buildApiError`, `error-envelope.ts`), where `issues` and `fields` already ride. - **Unchanged.** The status (409), `message` (the thrown one, as at the Hono doors), `declaredCode` (absent: `DUPLICATE_RECORD` is registered, so nothing is demoted), `userMessage`, and every other code at this writer. - No export is added. No `packages/types` or `packages/spec` file moves. `packages/runtime/src/domains/packages.ts` is not touched. ## Measured before and after (H1) Rig (scratch, not committed): a real `ObjectQL` over `SqlDriver` (better-sqlite3 `:memory:`), a real `ObjectStackProtocolImplementation`, and the real `HttpDispatcher`, driven through `dispatch()` with a real Fetch `Request`. The identity step is the one stubbed seam. The object `udoc` declares `email` unique, and the write repeats it. Before is at `c9810dd9d`, after is at `ab389b338`. | Door | Before | After | |---|---|---| | `POST /actions/udoc/dup`: a code-registered handler whose `ctx.engine.insert` repeats `email` | `409 {"success":false,"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': a unique constraint already holds these values. No record was written.","httpStatus":409}}` | `409 {"success":false,"error":{"code":"UNIQUE_VIOLATION","message":"Duplicate record refused on 'udoc': …","httpStatus":409,"details":{"developerMessage":"Duplicate record refused on 'udoc': …","object":"udoc"}}}` | | `POST /actions/udoc/set_email/r3`: a declarative `operation: 'update'` action (caller identity) whose write repeats `email` | the same `DUPLICATE_RECORD` body | the same `UNIQUE_VIOLATION` body | | `POST /security/_activation/position/sales_rep`: 5 concurrent first flips (catalog activation) | 1 × `200`, 4 × `409 DUPLICATE_RECORD` on `sys_metadata_activation`, no `details` | 1 × `200`, 4 × `409 UNIQUE_VIOLATION`, `details` `developerMessage` and `object: "sys_metadata_activation"` | | `POST /actions/_activation/udoc/dup`: 5 concurrent first flips (action activation, ledger attached) | 1 × `200`, 4 × `409 DUPLICATE_RECORD` | 1 × `200`, 4 × `409 UNIQUE_VIOLATION`, the same `details` | | `createHonoApp` `POST /api/v1/actions/udoc/dup` (the public door, runtime `dist` rebuilt with the fix) | `409 DUPLICATE_RECORD` with `httpStatus`, as measured on the card at `9a6754fcd` | `409 {"success":false,"error":{"code":"UNIQUE_VIOLATION",…,"httpStatus":409,"details":{"developerMessage":…,"object":"udoc"}}}` | None of the measured bodies has a `field`, and none is invented. The unique index on this tenancy-scoped object is the composite `uniq_udoc_organization_id_email`, and the ledger's is `(metadata_type, name)`, so the driver names no single column. The pin below states a producer-named `field` and finds it in `details`. ## Domain census (H2, H3) | Domain (its catch arm hands the throw to `errorFromThrown`) | Verdict | |---|---| | `actions`: the declared-status arm, for a handler write and a declarative update | crosses: measured, pinned | | action activation (`handleActionActivationWrite`) | crosses when a flip loses a race on the ledger key: measured, pinned | | catalog activation (`catalog-activation.ts`) | crosses when a flip loses a race on the ledger key: measured, pinned | | `meta` save (`PUT /meta/:type/:name`) | not crossing. 5 concurrent first saves: 2 × `200`, 3 × `409 METADATA_CONFLICT`. The protocol's own optimistic check answers, and no `DuplicateRecordError` reaches the arm | | `automation` flow create (`POST /automation`) | not crossing. It saves through the same protocol path: 5 concurrent creates gave 2 × `200`, 3 × `409 METADATA_CONFLICT` (an automation double for the engine registration, with the real protocol and store) | | `mcp` (the transport catch in `mcp.ts`) | not crossing this writer. A tool failure is a JSON-RPC `200` that `@objectstack/mcp` renders, so it never reaches this catch. See "Found, not fixed here" | | `/packages` (install and the rest) | NOT MEASURED. `domains/packages.ts` is held by #23546, and this rig composes no package service. Its catch arms hand throws to this writer, so a `DuplicateRecordError` there gets the same branch. No `/packages` pin was added | | `security` audience-binding suggestions | NOT MEASURED: the `plugin-security` service is not composed in this rig | | a `type: 'flow'` action body | NOT MEASURED: `service-automation` cannot be composed from `packages/runtime`. By reading, a failed run is `400 FLOW_FAILED` (`classifyFlowRefusal`), an envelope built in the domain (H3) | | permission-set clone, `share-links`, `ui` | not crossing, by reading. The clone saves create-only through `saveMetaItem` (the measured `METADATA_CONFLICT` path) and maps that code to its own name-taken refusal. `share-links` writes a generated token, so no request repeats a unique value. `ui` only reads (`getUiView`) | H3: the actions domain's sanitised crash arm and its flow-refusal arm build their own envelopes. Neither produces this class. ## Controls | Control | Before (the ablation run) | After | |---|---|---| | A handler throws `Object.assign(new Error(…), { code: 'DUPLICATE_RECORD', status: 409 })`, which is not the engine's class | `409 DUPLICATE_RECORD`, no `details`, no `declaredCode` | the same | | A handler's `ctx.engine.delete` hits a `restrict` lookup (another engine 409) | `409 DELETE_RESTRICTED`, no `details` | the same | ## Pins The new file `packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts` has 7 rows on a real engine. Codes and key presence are asserted, never wording. - Actions door: a handler write, a declarative update, and the engine's class stating `field: 'email'`. Each answers `409 UNIQUE_VIOLATION` with `httpStatus` 409. `details` keys are exactly `developerMessage` and `object` (plus `field` on the third row), with no `declaredCode`. Neither `DUPLICATE_RECORD` nor the driver's text appears anywhere in the body, and the store is unchanged. - Catalog activation and action activation: the second of two flips loses the ledger key. The pin holds the race window open: the engine's ledger read misses the row the other flip inserted, and every other call, the insert included, reaches the real engine. Each answers `409 UNIQUE_VIOLATION`, and the ledger keeps one row. - CONTROL: a producer speaking `DUPLICATE_RECORD` keeps it. - CONTROL: `DELETE_RESTRICTED` keeps its body. ## Ablation At `ab389b338`, the run used `node scripts/ablation-replace.mjs` in wrap mode, under an outer `trap … EXIT INT TERM` that restores the absolute path. Both fix lines were replaced by `const code = thrown.code;` with the marker `ABLATION_23686_NO_WIRE_MAP`, and by a constant-undefined `armContext`. - On disk, the anchor went from 1 to 0 and the marker from 0 to 1. The blob went from `77430131ece1` to `f03e88487cdf`. - The pins import `./http-dispatcher.js` from source, so no rebuild was needed. - Result: `Tests 5 failed | 2 passed (7)`. Exactly the 5 positive rows were red, each with `expected 'DUPLICATE_RECORD' to be 'UNIQUE_VIOLATION'`. Both controls stayed green. - Restore: the blob equals HEAD (`77430131ece1`) and `git diff HEAD` is empty on the path. ## Tests At the final head `84c825f5a` (`origin/main` `3bc407d30` merged before opening; it touches `objectql` and `metadata-protocol`, and none of the changed files overlap this diff), after a cache-backed `pnpm turbo run build --filter='!@objectstack/docs' --concurrency=2` (`75 successful`): - `pnpm --filter @objectstack/runtime test`: `Test Files 389 passed (389)`, `Tests 5870 passed | 19 skipped (5889)`. - `pnpm --filter @objectstack/runtime test:repo`: `Test Files 3 passed (3)`, `Tests 808 passed (808)`. - `pnpm --filter @objectstack/runtime typecheck`: exit 0. That covers `tsc --noEmit` and `check:test-typecheck: OK`. The new test file compiles under `tsconfig.test.json` and adds no debt. - Consumer suites that read dispatcher error bodies: `@objectstack/http-conformance`, `13` files, `144 passed`; `@objectstack/hono`, `8` files, `161 passed`. No consumer test pins `DUPLICATE_RECORD` at a dispatcher door (`git grep` over every package's tests). - `eslint --no-inline-config packages/runtime`: exit 0, no findings. Repo-wide `pnpm lint` is CI's. ## Gates At `84c825f5a`: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 62 commands from the 3 changed paths (merge base `3bc407d30`). All 62 ran, and each exit code was recorded before any pipe. All 62 exited 0. - `--ran` reconciliation: `62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN`. The zero is derived: all 62 lines carry an exit code. - Among them, all exit 0: `pnpm check:dispatcher-error-vocabulary`, `pnpm check:test-source-alias`, `pnpm check:engine-double-contract`, `pnpm check:cross-package-test-inputs`, `pnpm check:nul-bytes`, `pnpm check:doc-authoring`, `pnpm check:issue-citations`, `pnpm check:dual-build-cjs-loads`, `check-changeset-no-major`, `check-adr-0087-registration` and `check-empty-changeset`. - `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3) on the pre-merge pass, before the full build. It exited 0 once the build was in place and again at this head. - Every family CI runs on this PR is reported on its check-runs. ## Found, not fixed here 1. **The MCP door relays the engine's in-process spelling inside a tool result.** Class (b), measured at a public door. `POST /api/v1/mcp` with `tools/call` `create_record`, repeating a unique value, answered `200`. The JSON-RPC result was `isError: true`, and its text was `{"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': …","status":409}}`. The rig was the real `MCPServerRuntime` from `packages/mcp`, a real engine, and the dispatcher at `ab389b338`. The writer is `@objectstack/mcp`'s `thrownRefusalEnvelope` (through `errorResultWithCode` and `errorResultFromThrown`), which relays `err.code` verbatim. It is not this writer. `Seam: spec:ErrorCode UNIQUE_VIOLATION (ruling 65846bc) → runtime:packages/mcp/src/mcp-http-tools.ts thrownRefusalEnvelope | consumer: MCP clients`. 2. **A sandboxed script body that lets the engine's refusal propagate keeps `DUPLICATE_RECORD`, before and after this PR.** Class (b), measured at a public door. `POST /actions/udoc/sbx` ran a `type: 'script'` body that calls `ctx.api.object('udoc').insert(…)` and does not catch. It answered `409 {"code":"DUPLICATE_RECORD","message":"DuplicateRecordError: Duplicate record refused on 'udoc': …","httpStatus":409}`. The refusal leaves the VM as `SandboxError`, with `code` and `status` lifted and the class name gone, so the shared key does not match. By reading, `RestServer`'s arm and the Hono doors key the same way (not measured there). The client-facing message also carries the `DuplicateRecordError: ` class-name prefix. `Seam: spec:ErrorCode UNIQUE_VIOLATION (ruling 65846bc) → runtime:packages/runtime/src/sandbox/quickjs-runner.ts SandboxError relay + @objectstack/types isEngineDuplicateRecordEnvelope | consumer: every door`. Whether a relayed, uncaught engine envelope counts as "the engine's class" under the ruling is a question for the seat. The ruling's docblock speaks of a producer that *throws* the code. A fix belongs upstream (the relay or the key), not in a second translator at a door. ## Acceptance notes - `dispatcher-plugin`'s `errorResponseBase`, the exit for a throw that leaves `dispatch()`, does not carry this translation. No measured door hands it the engine's class: the write paths of the routes it serves are caught in their domains and answered here. The divergence is declared in `errorFromThrown`'s docblock rather than closed by a second translator. - At this door, as at the Hono doors, `details.developerMessage` repeats `message` byte for byte for this code. The arm relays the engine's sentence, and the dispatcher keeps the thrown `message`. - In an activation race, the losing flip now answers `409 UNIQUE_VIOLATION` instead of `409 DUPLICATE_RECORD`. It is still refused rather than applied, and a retry lands as an update. That behaviour is unchanged and is an observation only. - Docs: no edit. At the dispatcher's doors, the "every route answers the wire code `UNIQUE_VIOLATION`" sentences in `error-handling.mdx` and `error-catalog.mdx` now hold. "the in-process spelling never crosses HTTP" stays false for the two findings above, both outside this writer, so the docs follow their fixes. --- _Generated by [Claude Code](https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 954bf72 commit 395ca21

3 files changed

Lines changed: 395 additions & 4 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): the HTTP dispatcher answers the engine's `DuplicateRecordError` with the wire code `UNIQUE_VIOLATION`, not the engine's in-process `DUPLICATE_RECORD`, with `developerMessage`, `object` and `field` in `error.details` (#23686)
6+
7+
**Wire change at the dispatcher's doors: `409 DUPLICATE_RECORD` becomes `409 UNIQUE_VIOLATION`.** It applies wherever a dispatcher domain hands a caught engine throw to its shared error writer (`HttpDispatcher`'s thrown-error envelope). Measured: `POST /actions/:object/:action`, for a handler or a declarative `operation: 'update'` action whose write repeats a unique value, and the activation doors `POST /actions/_activation/:object/:action` and `POST /security/_activation/:type/:name`, for a flip that loses a race on the activation ledger's unique key. A client of these doors (`@objectstack/hono`'s `createHonoApp`, or any host serving the dispatcher) that matched `code === 'DUPLICATE_RECORD'` on that refusal must match `code === 'UNIQUE_VIOLATION'` instead. That is what the REST server and both Hono doors already answer for the same refusal.
8+
9+
Why: the maintainer ruled one wire spelling for a unique-constraint refusal on every route, `UNIQUE_VIOLATION` (recorded in commit 65846bc46). The REST server, the batch and import rows and the Hono doors already reported it. The dispatcher's own writer reported the engine's spelling.
10+
11+
- FROM `{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': …","httpStatus":409}}`
12+
- TO `{"success":false,"error":{"code":"UNIQUE_VIOLATION","message":"Duplicate record refused on 'udoc': …","httpStatus":409,"details":{"developerMessage":"Duplicate record refused on 'udoc': …","object":"udoc"}}}`
13+
14+
`details` carries what the REST server's answer carries beside its code, each field only when the producer stated it: `developerMessage`, `object`, and `field` when the driver named the conflicting column. The status stays `409` and the `message` is unchanged. Only the engine's `DuplicateRecordError` is translated, recognised by its registered code and its class name together. A handler or hook that throws the registered `DUPLICATE_RECORD` without being that class keeps its own code. Inside the engine, `DuplicateRecordError.code` is still `DUPLICATE_RECORD`, so an in-process caller (a hook, a flow node) is unaffected. Other `409` answers at these doors (`DELETE_RESTRICTED`, `CONCURRENT_UPDATE`) are unchanged.
15+
16+
Clause-②: no
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23686] The runtime dispatcher's own catch arms answer the engine's
5+
* `DuplicateRecordError` with the ruled wire spelling, `409 UNIQUE_VIOLATION`.
6+
*
7+
* Maintainer ruling (2026-09-03, recorded in commit 65846bc46): one wire
8+
* spelling on every route, `UNIQUE_VIOLATION`; the row reports it too.
9+
*
10+
* ## What was wrong
11+
*
12+
* `HttpDispatcher.errorFromThrown` is the one writer the dispatcher domains
13+
* hand a caught throw to. It passed the engine's registered in-process code
14+
* through unchanged, so a door whose catch arm reaches it answered
15+
* `409 DUPLICATE_RECORD` for a repeated unique value, while `RestServer`'s
16+
* arm, the batch and import rows and both Hono doors answer the same throw as
17+
* `UNIQUE_VIOLATION`. Measured before the fix on the rig below:
18+
*
19+
* - `POST /actions/:object/:action`, a handler whose engine write repeats a
20+
* unique value, and a declarative `operation: 'update'` action doing the
21+
* same: `409 {"code":"DUPLICATE_RECORD", …, "httpStatus":409}`, no `details`;
22+
* - `POST /security/_activation/:type/:name` and
23+
* `POST /actions/_activation/:object/:action`, a flip whose ledger read
24+
* misses the row a concurrent flip is inserting (two real concurrent
25+
* flips reproduce it): the same answer, for `sys_metadata_activation`'s
26+
* `(metadata_type, name)` key.
27+
*
28+
* ## The rig
29+
*
30+
* A REAL store per describe: better-sqlite3 `:memory:`, the real `ObjectQL`
31+
* engine, so every duplicate here is the engine's own envelope over a real
32+
* driver refusal, not a double's. The dispatcher is reached the way
33+
* `@objectstack/hono`'s catch-all reaches it, `dispatch()` over a real Fetch
34+
* `Request`, with the identity step the one stubbed seam; the activation
35+
* doors are entered at their domain handlers, as their sibling pins do.
36+
*
37+
* Codes and key presence are asserted, never wording.
38+
*/
39+
40+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
41+
import { ObjectQL, DuplicateRecordError, ObjectStoreActionActivationStore } from '@objectstack/objectql';
42+
import { SqlDriver } from '@objectstack/driver-sql';
43+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
44+
import { SysMetadataActivation } from '@objectstack/platform-objects/system';
45+
import { HttpDispatcher } from './http-dispatcher.js';
46+
import type { HttpProtocolContext } from './http-dispatcher.js';
47+
import { bindTestSecurityCatalog } from './security/security-catalog.testkit.js';
48+
49+
const PREFIX = '/api/v1';
50+
/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */
51+
const TEST_PACKAGE_ID = 'objectstack-test';
52+
const LEDGER = 'sys_metadata_activation';
53+
54+
/** The value `r1` holds; every write below that repeats it is refused by the unique key. */
55+
const TAKEN = 'taken@example.com';
56+
57+
const UDOC = {
58+
name: 'udoc',
59+
label: 'Unique doc',
60+
fields: {
61+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
62+
name: { name: 'name', type: 'text' as const, label: 'Name' },
63+
email: { name: 'email', type: 'text' as const, label: 'Email', unique: true },
64+
},
65+
actions: [
66+
{ name: 'add_duplicate', label: 'Add duplicate', type: 'script', target: 'add_duplicate' },
67+
{ name: 'take_email', label: 'Take email', type: 'script', operation: 'update', patch: { email: TAKEN } },
68+
{ name: 'throw_named_duplicate', label: 'Throw named duplicate', type: 'script', target: 'throw_named_duplicate' },
69+
{ name: 'speak_duplicate', label: 'Speak duplicate', type: 'script', target: 'speak_duplicate' },
70+
{ name: 'delete_parent', label: 'Delete parent', type: 'script', target: 'delete_parent' },
71+
],
72+
};
73+
74+
const UPARENT = {
75+
name: 'uparent',
76+
label: 'Parent',
77+
fields: {
78+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
79+
name: { name: 'name', type: 'text' as const, label: 'Name' },
80+
},
81+
};
82+
83+
const UCHILD = {
84+
name: 'uchild',
85+
label: 'Child',
86+
fields: {
87+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
88+
name: { name: 'name', type: 'text' as const, label: 'Name' },
89+
parent: { name: 'parent', type: 'lookup' as const, reference: 'uparent', deleteBehavior: 'restrict', label: 'Parent' },
90+
},
91+
};
92+
93+
const MEMBER = { userId: 'u_member', isSystem: false };
94+
95+
const ADMIN = (): HttpProtocolContext => ({
96+
request: {},
97+
environmentId: 'platform',
98+
executionContext: { userId: 'u_admin', systemPermissions: ['manage_metadata'] },
99+
} as unknown as HttpProtocolContext);
100+
101+
const liveEngines: ObjectQL[] = [];
102+
afterAll(async () => {
103+
while (liveEngines.length) {
104+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
105+
}
106+
});
107+
108+
async function bootEngine(objects: readonly object[]): Promise<ObjectQL> {
109+
const engine = new ObjectQL();
110+
liveEngines.push(engine);
111+
engine.registerDriver(new SqlDriver({
112+
client: 'better-sqlite3',
113+
connection: { filename: ':memory:' },
114+
useNullAsDefault: true,
115+
}), true);
116+
await engine.init();
117+
for (const o of objects) engine.registry.registerObject(o as any, TEST_PACKAGE_ID);
118+
await engine.syncSchemas();
119+
return engine;
120+
}
121+
122+
function dispatcherOver(services: Record<string, unknown>): HttpDispatcher {
123+
const get = (n: string) => services[n] ?? null;
124+
const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) };
125+
const dispatcher = new HttpDispatcher(kernel);
126+
(dispatcher as any).timedResolveExecutionContext = async () => ({ ...MEMBER });
127+
return dispatcher;
128+
}
129+
130+
/** `POST /api/v1/actions/<rest>` as `@objectstack/hono`'s catch-all hands it to `dispatch()`. */
131+
async function postAction(dispatcher: HttpDispatcher, rest: string) {
132+
const path = `/actions/${rest}`;
133+
const request = new Request(`http://localhost${PREFIX}${path}`, {
134+
method: 'POST',
135+
headers: { 'content-type': 'application/json' },
136+
body: '{}',
137+
});
138+
const res: any = await dispatcher.dispatch('POST', path, {}, {}, { request } as any, PREFIX);
139+
return res.response as { status: number; body: any };
140+
}
141+
142+
/** The answer a repeated unique value earns at this door: the ruled code, and the REST arm's fields. */
143+
function expectUniqueViolation(response: { status: number; body: any }, detailKeys: readonly string[], object: string) {
144+
expect(response.status, JSON.stringify(response.body)).toBe(409);
145+
const error = response.body?.error;
146+
expect(error?.code).toBe('UNIQUE_VIOLATION');
147+
expect(error?.httpStatus).toBe(409);
148+
expect(typeof error?.message).toBe('string');
149+
expect(Object.keys(error?.details ?? {}).sort()).toEqual([...detailKeys].sort());
150+
expect(error.details.object).toBe(object);
151+
expect(typeof error.details.developerMessage).toBe('string');
152+
// `DUPLICATE_RECORD` is a registered member, so nothing was demoted.
153+
expect(error).not.toHaveProperty('declaredCode');
154+
// The in-process spelling does not cross, and neither does the driver's text.
155+
const wire = JSON.stringify(response.body);
156+
expect(wire).not.toContain('DUPLICATE_RECORD');
157+
expect(wire).not.toContain('UNIQUE constraint failed');
158+
}
159+
160+
describe('[#23686] the actions door answers the engine\'s duplicate as 409 UNIQUE_VIOLATION', () => {
161+
let dispatcher: HttpDispatcher;
162+
let engine: ObjectQL;
163+
164+
beforeAll(async () => {
165+
engine = await bootEngine([UDOC, UPARENT, UCHILD]);
166+
const ql = engine as any;
167+
ql.registerAction('udoc', 'add_duplicate', async (ctx: any) => {
168+
await ctx.engine.insert('udoc', { id: 'r_dup', name: 'second', email: TAKEN });
169+
return { written: true };
170+
});
171+
ql.registerAction('udoc', 'throw_named_duplicate', async () => {
172+
// The engine's own class, stating the column the driver named.
173+
throw new DuplicateRecordError('udoc', new Error('driver refusal'), 'email');
174+
});
175+
ql.registerAction('udoc', 'speak_duplicate', async () => {
176+
// A producer that SPEAKS the registered code without being the engine's class.
177+
throw Object.assign(new Error('That address is already on file'), { code: 'DUPLICATE_RECORD', status: 409 });
178+
});
179+
ql.registerAction('udoc', 'delete_parent', async (ctx: any) => {
180+
await ctx.engine.delete('uparent', 'p1');
181+
return { deleted: true };
182+
});
183+
await engine.insert('udoc', { id: 'r1', name: 'first', email: TAKEN });
184+
await engine.insert('udoc', { id: 'r2', name: 'other', email: 'other@example.com' });
185+
await engine.insert('uparent', { id: 'p1', name: 'kept' });
186+
await engine.insert('uchild', { id: 'c1', name: 'dependent', parent: 'p1' });
187+
const protocol = new ObjectStackProtocolImplementation(engine as any);
188+
dispatcher = dispatcherOver({ objectql: engine, data: engine, protocol });
189+
}, 60_000);
190+
191+
it('a handler whose engine write repeats a unique value', async () => {
192+
const response = await postAction(dispatcher, 'udoc/add_duplicate');
193+
194+
// The driver names no single column: the index is the tenancy-scoped composite.
195+
expectUniqueViolation(response, ['developerMessage', 'object'], 'udoc');
196+
// Non-vacuity: the refusal is real, nothing was written.
197+
expect(await engine.find('udoc', { where: { email: TAKEN } })).toHaveLength(1);
198+
});
199+
200+
it('a declarative `operation: \'update\'` action whose write repeats a unique value', async () => {
201+
const response = await postAction(dispatcher, 'udoc/take_email/r2');
202+
203+
expectUniqueViolation(response, ['developerMessage', 'object'], 'udoc');
204+
expect(((await engine.findOne('udoc', { where: { id: 'r2' } })) as any)?.email).toBe('other@example.com');
205+
});
206+
207+
it('the engine\'s class stating a column: `field` rides `details` beside the other two', async () => {
208+
const response = await postAction(dispatcher, 'udoc/throw_named_duplicate');
209+
210+
expectUniqueViolation(response, ['developerMessage', 'field', 'object'], 'udoc');
211+
expect(response.body.error.details.field).toBe('email');
212+
});
213+
214+
it('CONTROL: a producer that merely speaks `DUPLICATE_RECORD` keeps its own code and body', async () => {
215+
const response = await postAction(dispatcher, 'udoc/speak_duplicate');
216+
217+
expect(response.status).toBe(409);
218+
expect(response.body?.error?.code).toBe('DUPLICATE_RECORD');
219+
expect(response.body?.error).not.toHaveProperty('details');
220+
expect(response.body?.error).not.toHaveProperty('declaredCode');
221+
});
222+
223+
it('CONTROL: another engine 409 (`DELETE_RESTRICTED`) keeps its code and its body', async () => {
224+
const response = await postAction(dispatcher, 'udoc/delete_parent');
225+
226+
expect(response.status).toBe(409);
227+
expect(response.body?.error?.code).toBe('DELETE_RESTRICTED');
228+
expect(response.body?.error).not.toHaveProperty('details');
229+
expect(await engine.find('uparent', { where: { id: 'p1' } })).toHaveLength(1);
230+
});
231+
});
232+
233+
/**
234+
* The engine as a flip that races another sees it: its read of the activation
235+
* ledger misses the row the other flip has inserted, so its own insert is the
236+
* one the ledger's unique `(metadata_type, name)` key refuses. Every other
237+
* call reaches the real engine, the insert included, so the refusal is the
238+
* engine's own envelope over a real driver error. Two real concurrent flips
239+
* reproduce the same answer; this holds the race window open so the pin does
240+
* not depend on scheduling.
241+
*/
242+
function ledgerReadMissingTheRacer(engine: ObjectQL): ObjectQL {
243+
return new Proxy(engine, {
244+
get(target, prop) {
245+
if (prop === 'find') {
246+
return async (object: string, options?: unknown) =>
247+
object === LEDGER ? [] : (target as any).find(object, options);
248+
}
249+
const value = Reflect.get(target, prop, target);
250+
return typeof value === 'function' ? value.bind(target) : value;
251+
},
252+
});
253+
}
254+
255+
describe('[#23686] the activation doors answer a ledger write that loses a race as 409 UNIQUE_VIOLATION', () => {
256+
let engine: ObjectQL;
257+
let racing: ObjectQL;
258+
let dispatcher: HttpDispatcher;
259+
260+
beforeAll(async () => {
261+
engine = await bootEngine([UDOC, SysMetadataActivation]);
262+
bindTestSecurityCatalog(engine, {
263+
positions: [{ name: 'sales_rep', permissionSets: ['sales_access'] }],
264+
permissions: [
265+
{ name: 'sales_access', systemPermissions: ['cap_sales'] },
266+
{ name: 'admin_full_access', systemPermissions: ['manage_metadata'] },
267+
],
268+
});
269+
(engine as any).registerAction('udoc', 'add_duplicate', async () => ({ ok: true }));
270+
racing = ledgerReadMissingTheRacer(engine);
271+
const store = new ObjectStoreActionActivationStore(racing as any);
272+
await store.probe();
273+
(engine as any).setActionActivationStore(store);
274+
dispatcher = dispatcherOver({ objectql: racing, data: racing });
275+
}, 60_000);
276+
277+
const ledgerRows = async (type: string, name: string) =>
278+
engine.find(LEDGER, { where: { metadata_type: type, name } });
279+
280+
it('`POST /security/_activation/position/:name` (catalog activation)', async () => {
281+
const flip = () => dispatcher.handleSecurity('/_activation/position/sales_rep', 'POST', { enabled: false }, {}, ADMIN());
282+
283+
const first: any = await flip();
284+
expect(first.response?.status, JSON.stringify(first.response?.body)).toBe(200);
285+
const racer: any = await flip();
286+
287+
expectUniqueViolation(racer.response, ['developerMessage', 'object'], LEDGER);
288+
expect(await ledgerRows('position', 'sales_rep')).toHaveLength(1);
289+
});
290+
291+
it('`POST /actions/_activation/:object/:action` (action activation)', async () => {
292+
const flip = () => dispatcher.handleActions('/_activation/udoc/add_duplicate', 'POST', { enabled: false }, ADMIN());
293+
294+
const first: any = await flip();
295+
expect(first.response?.status, JSON.stringify(first.response?.body)).toBe(200);
296+
const racer: any = await flip();
297+
298+
expectUniqueViolation(racer.response, ['developerMessage', 'object'], LEDGER);
299+
expect(await ledgerRows('action', 'add_duplicate')).toHaveLength(1);
300+
});
301+
});

0 commit comments

Comments
 (0)