From e5ffc82484fb69c516f26aa77712784a55666d4a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:24:45 +0000 Subject: [PATCH 1/2] refactor(plugin-hono-server,runtime): read the duplicate-record wire code off the shared arm instead of stamping the literal Both doors that translate the engine's DuplicateRecordError to the ruled wire spelling now read that code off structuredCodeAnswer's arm (@objectstack/types), as @objectstack/mcp's wireCodeOf does, so neither package spells a copy of UNIQUE_VIOLATION. The dispatcher's arm-agreement check compares against the envelope's decided code, as the Hono doors' does. The wire answer is unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG --- .../plugins/plugin-hono-server/src/adapter.ts | 22 +++++++++---- packages/runtime/src/http-dispatcher.ts | 32 ++++++++++++++----- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index 401887f362c..bf7eb8d2e46 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -300,11 +300,12 @@ function toLoggableError(thrown: unknown): Error { * So this seam decides the envelope's `code` for that class BEFORE the arm * is consulted, keyed exactly as the arm keys it: the registered code AND the * class name ({@link isEngineDuplicateRecordEnvelope}, `@objectstack/types`, - * the same predicate the import runner asks), never message text. Then the - * arm agrees with the envelope, and its fields ride `details` under the rule - * {@link declaredArmContext} states for every arm. A producer that merely - * SPEAKS `DUPLICATE_RECORD` (a hook throwing the registered code without - * being the engine's class) keeps its own code and its own body. The + * the same predicate the import runner asks), never message text. The code + * itself is read off that arm (`structuredCodeAnswer`, #23714), never spelled + * here. Then the arm agrees with the envelope, and its fields ride `details` + * under the rule {@link declaredArmContext} states for every arm. A producer + * that merely SPEAKS `DUPLICATE_RECORD` (a hook throwing the registered code + * without being the engine's class) keeps its own code and its own body. The * `message` stays the thrown one, as on every other code at these doors. * * ## Exported for the other Hono door (#23365) @@ -359,7 +360,16 @@ export function declaredEnvelopeForThrow(thrown: unknown): { // unique-constraint envelope, which crosses HTTP as the one ruled wire // spelling (see "One wire spelling" above). Decided here, before the arm // is consulted, so the arm's agreement rule below is unchanged. - const code = isEngineDuplicateRecordEnvelope(thrown) ? 'UNIQUE_VIOLATION' : resolved.code; + // + // [#23714] That wire code is read off the shared classification's arm + // for the class (`structuredCodeAnswer`, the arm `RestServer` answers + // with), so this package spells no copy of it: the stamp stays where the + // error-code ledger lists it, under `@objectstack/types`. The thrown code + // is kept only if that arm ever answered no string code for the class. + const duplicateArmCode = isEngineDuplicateRecordEnvelope(thrown) + ? structuredCodeAnswer(thrown)?.body.code + : undefined; + const code = typeof duplicateArmCode === 'string' ? duplicateArmCode : resolved.code; // [#23531, #23587] The structured context `RestServer`'s classification // states for this refusal joins the producer's own context in `details`. diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 77430131ece..26dba62e314 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -394,16 +394,22 @@ type ClassifiedServiceLookup = * as `message` and `code`. * * The arm must AGREE with the envelope already decided, as at the Hono doors: - * its status must be the thrown one and its code `UNIQUE_VIOLATION`, so it can + * its status must be the thrown one and its code the envelope's, so it can * only add context, never change the answer. Disagreeing, it adds nothing. + * The envelope's code for this class is itself read off the same arm + * (`HttpDispatcher.errorFromThrown`, #23714), so this function spells no copy + * of it either. * * ⛔ Deliberately not every arm. The dispatcher's other 409s * (`CONCURRENT_UPDATE`, `DELETE_RESTRICTED`) keep the body they have; widening * the context this door carries for them is a change of its own. */ -function duplicateArmContext(thrown: unknown, status: number): Record | undefined { +function duplicateArmContext( + thrown: unknown, + envelope: { status: number; code: string }, +): Record | undefined { const arm = structuredCodeAnswer(thrown); - if (arm === undefined || arm.status !== status || arm.body.code !== 'UNIQUE_VIOLATION') return undefined; + if (arm === undefined || arm.status !== envelope.status || arm.body.code !== envelope.code) return undefined; const context = Object.fromEntries( Object.entries(arm.body).filter(([key]) => key !== 'error' && key !== 'code'), ); @@ -1210,9 +1216,11 @@ export class HttpDispatcher { * So this method decides the code for that class before writing it, keyed * exactly as those doors key it: the registered code AND the class name * ({@link isEngineDuplicateRecordEnvelope}, `@objectstack/types`), never - * message text. 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 and its own body. Beside the code, + * message text. The code itself is read off `RestServer`'s arm + * (`structuredCodeAnswer`, #23714), never spelled here. 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 + * and its own body. Beside the code, * the envelope's `details` carries what `RestServer`'s arm carries beside * its own, read off that one classification ({@link duplicateArmContext}) * rather than listed here: `developerMessage`, `object`, and `field` when @@ -1231,9 +1239,17 @@ export class HttpDispatcher { // [#23686] The code this envelope carries: the resolver's, except the // engine's unique-constraint envelope, which crosses HTTP as the one // ruled wire spelling (see "One wire spelling" above). + // + // [#23714] That wire code is read off the shared classification's arm + // for the class (`structuredCodeAnswer`, the arm `RestServer` answers + // with), so this package spells no copy of it: the stamp stays where + // the error-code ledger lists it, under `@objectstack/types`. The + // resolver's code is kept only if that arm ever answered no string + // code for the class. const engineDuplicate = isEngineDuplicateRecordEnvelope(e); - const code = engineDuplicate ? 'UNIQUE_VIOLATION' : thrown.code; - const armContext = engineDuplicate ? duplicateArmContext(e, thrown.status) : undefined; + const duplicateArmCode = engineDuplicate ? structuredCodeAnswer(e)?.body.code : undefined; + const code = typeof duplicateArmCode === 'string' ? duplicateArmCode : thrown.code; + const armContext = engineDuplicate ? duplicateArmContext(e, { status: thrown.status, code }) : undefined; const details = armContext ? { ...thrown.details, ...armContext } : thrown.details; // [#9106] `code`, the NARROWED spelling — the same one the REST door // has served since #8016, so the two doors now agree on `error.code` From 20fb7e3d97b3efb5582e4f7c0c74a1a37bc2f0e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 23:30:51 +0000 Subject: [PATCH 2/2] chore(changeset): patch changeset for the duplicate-record arm relay Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG --- .changeset/23714-unique-violation-arm-relay.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/23714-unique-violation-arm-relay.md diff --git a/.changeset/23714-unique-violation-arm-relay.md b/.changeset/23714-unique-violation-arm-relay.md new file mode 100644 index 00000000000..d50ebce3126 --- /dev/null +++ b/.changeset/23714-unique-violation-arm-relay.md @@ -0,0 +1,12 @@ +--- +'@objectstack/plugin-hono-server': patch +'@objectstack/runtime': patch +--- + +refactor(plugin-hono-server,runtime): the Hono doors and the HTTP dispatcher read the duplicate-record wire code off the shared error classification instead of spelling it themselves (#23714) + +**No behaviour change.** A repeated unique value still answers `409 UNIQUE_VIOLATION` at both Hono doors (a `HonoHttpServer` route or raw mount, and `createHonoApp`'s catch arms through `declaredEnvelopeForThrow`) and at the dispatcher's doors (`HttpDispatcher`'s thrown-error envelope), with the same `message`, the same `details` (`developerMessage`, `object`, and `field` when the driver named the column) and the same envelope keys. The answers were compared byte for byte at those doors before and after. A client or an app author has nothing to change. + +**What changed.** Both translations used to spell the wire code as a string literal. They now read it off the classification the REST server answers with (`structuredCodeAnswer` in `@objectstack/types`), as `@objectstack/mcp` already does. So the code has one source, and neither package stamps a code that the error-code ledger does not list under it. The engine's in-process code is unchanged: `DuplicateRecordError.code` is still `DUPLICATE_RECORD`. + +Clause-②: no