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
12 changes: 12 additions & 0 deletions .changeset/23714-unique-violation-arm-relay.md
Original file line number Diff line number Diff line change
@@ -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
22 changes: 16 additions & 6 deletions packages/plugins/plugin-hono-server/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`.
Expand Down
32 changes: 24 additions & 8 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined {
function duplicateArmContext(
thrown: unknown,
envelope: { status: number; code: string },
): Record<string, unknown> | 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'),
);
Expand Down Expand Up @@ -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
Expand All @@ -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`
Expand Down
Loading