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 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`