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
16 changes: 16 additions & 0 deletions .changeset/23587-hono-unique-violation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/plugin-hono-server': patch
---

fix(plugin-hono-server): a repeated unique value answered by either Hono door reports the wire code `UNIQUE_VIOLATION`, not the engine's in-process `DUPLICATE_RECORD`, with `developerMessage`, `object` and `field` (#23587)

**Wire change at these doors: `409 DUPLICATE_RECORD` becomes `409 UNIQUE_VIOLATION`.** A client of `@objectstack/hono`'s `createHonoApp` or of a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer` that matched `code === 'DUPLICATE_RECORD'` on a duplicate insert or update must match `code === 'UNIQUE_VIOLATION'` instead, which is what the standalone REST server already answers for the same refusal.

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 rows of `POST /data/:object/batch` and the import runner's rows already report it; the two Hono doors reported the engine's own spelling.

- FROM `{"success":false,"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': …"}}`
- TO `{"success":false,"error":{"code":"UNIQUE_VIOLATION","message":"Duplicate record refused on 'udoc': …","details":{"developerMessage":"Duplicate record refused on 'udoc': …","object":"udoc"}}}`

`details` carries what the REST server's answer carries beside its code, each only when the producer stated it: `developerMessage`, `object`, and `field` when the driver named the conflicting column. The `message` is unchanged: it stays the engine's sentence, as on every other code at these doors. Only the engine's `DuplicateRecordError` is translated, recognised by its registered code and its class name together. A 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. The REST server's answer is unchanged.

Clause-②: no
7 changes: 4 additions & 3 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -505,9 +505,10 @@ These three are answered on every endpoint that accepts an API key: the endpoint
**Retry:** `no_retry`

The engine throws `DuplicateRecordError`, whose in-process `code` is
`DUPLICATE_RECORD`; the REST door translates that envelope at the boundary, so
every route answers the wire code `UNIQUE_VIOLATION` and the in-process spelling
never crosses HTTP.
`DUPLICATE_RECORD`; the REST door and the
[Hono doors](/docs/protocol/kernel/error-handling#hono-doors) translate that envelope at
the boundary, so every route answers the wire code `UNIQUE_VIOLATION` and the in-process
spelling never crosses HTTP.

### `LOCK_CONFLICT`
**Cause:** The record is locked by another process or user.
Expand Down
37 changes: 25 additions & 12 deletions content/docs/protocol/kernel/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,17 @@ universal one.

Two corollaries, and both of them bite:

- **A refusal a writer *builds* always carries that writer's envelope.** `UNIQUE_VIOLATION`
is built by the `/data` door's classification arms, so it is **always flat**.
- **A refusal a writer *builds* always carries that writer's envelope.**
`RATE_LIMIT_EXCEEDED` is built by the inbound rate limiter, so it is **always nested**.
- **A refusal that is *thrown* and classified at the boundary carries the envelope of
whichever door caught it.** `PERMISSION_DENIED`, `RESOURCE_NOT_FOUND`,
`SERVICE_UNAVAILABLE` and `INTERNAL_ERROR` are all thrown by shared guards and reach
clients in **either** envelope depending on the route. So are `DELETE_RESTRICTED`,
`INVALID_FIELD` and `OBJECT_NOT_FOUND`, which the engine and the protocol throw: the
`@objectstack/rest` `/data` door answers them flat, and a [Hono door](#hono-doors)
answers them nested, with their structured fields under `error.details`.
clients in **either** envelope depending on the route. So are `UNIQUE_VIOLATION`,
`DELETE_RESTRICTED`, `INVALID_FIELD` and `OBJECT_NOT_FOUND`, which the engine and the
protocol throw (`UNIQUE_VIOLATION` is the wire spelling of the engine's
`DuplicateRecordError`, translated by both doors): the `@objectstack/rest` `/data` door
answers them flat, and a [Hono door](#hono-doors) answers them nested, with their
structured fields under `error.details`.

So do not derive the envelope from the code. Derive it from the body you are holding:

Expand Down Expand Up @@ -246,17 +247,27 @@ serves `/data`:

| Code | In `error.details` on a Hono door |
|---|---|
| `UNIQUE_VIOLATION` | `developerMessage`, `object`, and `field` when the driver named the column |
| `DELETE_RESTRICTED` | `developerMessage`, `dependentObject`, and `dependentCount` when the flat door would send it |
| `CONCURRENT_UPDATE` | `currentVersion`, `currentRecord` (the row as the caller reads it) |
| `INVALID_FIELD` | `field`, `object` |
| `OBJECT_NOT_FOUND` | `object` |
| `VALIDATION_FAILED` | `fields` |
| `FEEDS_DISABLED`, `FILES_DISABLED`, `ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE` | `object` |

`UNIQUE_VIOLATION` is not the code the engine throws: its `DuplicateRecordError`, whose
in-process `code` is `DUPLICATE_RECORD`, is answered as `UNIQUE_VIOLATION` here exactly
as on the flat door. A refusal that only *speaks*
`DUPLICATE_RECORD` — a hook throwing the registered code without being the engine's
class — keeps its own code.

⛔ What a Hono door never sends: `httpStatus`, and an `object` the flat door takes from the request's route
(on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `VALIDATION_FAILED`). The `message` is the
thrown one and can differ from the flat door's `error`: an unregistered object reads
`Object 'ghost' not found` here and `Object 'ghost' is not registered` there.
`Object 'ghost' not found` here and `Object 'ghost' is not registered` there, and a repeated
unique value reads the engine's `Duplicate record refused on 'account': …` here, the same
sentence `details.developerMessage` carries, where the flat door's `error` reads
`A record with this email already exists`.

## HTTP Status Codes

Expand Down Expand Up @@ -664,7 +675,9 @@ three if you handle both families.
#### `UNIQUE_VIOLATION`
**HTTP Status:** 409
**Meaning:** The write collides with a unique constraint — a record already holds that value
**Envelope:** flat — built by the `/data` door's duplicate-record arm, so it is never nested
**Envelope:** flat on the `@objectstack/rest` `/data` door, built by its duplicate-record
arm. On a [Hono door](#hono-doors) it is nested: `developerMessage`, `object` and `field`
ride in `error.details`

**Example:**
```json
Expand All @@ -677,10 +690,10 @@ three if you handle both families.
```

The engine throws `DuplicateRecordError`, whose in-process `code` is
`DUPLICATE_RECORD`; the REST door translates that envelope at the boundary, so
every route answers the wire code `UNIQUE_VIOLATION` and the in-process spelling
never crosses HTTP. The refusal is emitted as the flat body shown above, and
`field` is best-effort — see
`DUPLICATE_RECORD`; the REST door and the [Hono doors](#hono-doors) translate that
envelope at the boundary, so every route answers the wire code `UNIQUE_VIOLATION` and
the in-process spelling never crosses HTTP. The REST door emits the flat body shown
above, and `field` is best-effort on either door — see
[HTTP API](/docs/protocol/kernel/http-protocol) for the degraded shape.

**How to fix:**
Expand Down
49 changes: 41 additions & 8 deletions packages/plugins/plugin-hono-server/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
looksLikeInternalErrorLeak,
INTERNAL_ERROR_MESSAGE,
structuredCodeAnswer,
isEngineDuplicateRecordEnvelope,
} from '@objectstack/types';

/**
Expand Down Expand Up @@ -278,9 +279,33 @@ function toLoggableError(thrown: unknown): Error {
* not carved out.
*
* ⛔ `declaredCode` is deliberately NOT forwarded. Under this gate the
* producer's spelling IS the registered member sitting in `code`, so
* `demotedDeclaredCode` returns `undefined` by construction — forwarding it
* would put two spellings of one fact on every envelope this seam renders.
* producer's spelling IS a registered member, so `demotedDeclaredCode`
* returns `undefined` by construction — forwarding it would put two
* spellings of one fact on every envelope this seam renders. That holds for
* the one throw whose `code` is translated below as well: `DUPLICATE_RECORD`
* is registered, and `RestServer`'s arm sends no `declaredCode` beside its
* `UNIQUE_VIOLATION` either.
*
* ## One wire spelling for a unique-constraint refusal (#23587)
*
* Maintainer ruling (2026-09-03, recorded in commit 65846bc46): one wire
* spelling on every route, `UNIQUE_VIOLATION`. The engine's
* `DuplicateRecordError` declares `409` and its in-process identity,
* `DUPLICATE_RECORD`, which an in-process caller (a hook, a flow node)
* branches on and which is ⛔ not renamed. What crosses the HTTP boundary is
* translated: `RestServer`'s arm, `metadata-protocol`'s batch rows and the
* import runner's rows already answer that class as `UNIQUE_VIOLATION`, and
* the Hono doors answered `DUPLICATE_RECORD` until this.
*
* 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
* `message` stays the thrown one, as on every other code at these doors.
*
* ## Exported for the other Hono door (#23365)
*
Expand Down Expand Up @@ -330,18 +355,24 @@ export function declaredEnvelopeForThrow(thrown: unknown): {
? INTERNAL_ERROR_MESSAGE
: resolved.message;

// [#23587] The envelope's code: the producer's own, except the engine's
// 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;

// [#23531, #23587] The structured context `RestServer`'s classification
// states for this refusal joins the producer's own context in `details`.
// Absent unless the producer stated it.
const armContext = declaredArmContext(thrown, resolved);
const armContext = declaredArmContext(thrown, { status: resolved.status, code });
const details = armContext ? { ...resolved.details, ...armContext } : resolved.details;

return {
status: resolved.status,
body: {
success: false,
error: {
code: resolved.code,
code,
message,
// The producer's structured context and its END-USER-addressed
// refusal text (commit 79c46da90), forwarded exactly as the REST twin
Expand Down Expand Up @@ -386,9 +417,11 @@ export function declaredEnvelopeForThrow(thrown: unknown): {
*
* Both the status and the code must match the envelope already decided, so
* an arm can only add context to that envelope, never change it. An arm that
* answers a different code is not consulted: `DuplicateRecordError`'s arm
* answers `UNIQUE_VIOLATION` where this door answers the producer's own
* registered `DUPLICATE_RECORD`, so none of that arm's fields ride here.
* answers a different code is not consulted. `DuplicateRecordError`'s arm
* answers `UNIQUE_VIOLATION`, and it agrees because the envelope's code for
* that class is decided as `UNIQUE_VIOLATION` before this function is asked
* ({@link declaredEnvelopeForThrow}, "One wire spelling"); this rule does not
* move a code itself.
*
* ## Where they land
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
import { describe, it, expect, vi } from 'vitest';
import type { Logger } from '@objectstack/spec/contracts';

import { HonoHttpServer } from './adapter';
import { HonoHttpServer, declaredEnvelopeForThrow } from './adapter';

/**
* The exact bytes `wrap()` answers for a throw carrying no declared envelope,
Expand Down Expand Up @@ -490,6 +490,131 @@ describe('[#23587] a structured refusal on a direct-mount route carries RestServ
});
});

/**
* [#23587] The engine's `DuplicateRecordError` crosses a Hono door as `409
* UNIQUE_VIOLATION`, the one wire spelling the maintainer ruled for every route
* (recorded in commit 65846bc46), with `RestServer`'s arm fields in
* `error.details`. Before this, both doors answered the engine's in-process
* spelling, `DUPLICATE_RECORD`, with `code` and `message` only.
*
* Measured on a real `createHonoApp` over the real engine (SQLite WASM), a
* repeated unique value: `409 {"success":false,"error":{"code":
* "DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': …"}}`, the
* same on a `HonoHttpServer` route and on its raw mount.
*
* The producer below is shaped exactly like `@objectstack/objectql`'s class
* (`name`, `code`, `status`, `httpStatus`, `object`, `field`, the in-process
* `developerMessage` and the driver's error on `cause`), because this package
* does not depend on that one. Three doors render it: a route (`wrap()`), the
* raw mount (the transport `onError` seam), and `declaredEnvelopeForThrow`
* itself, which is what `createHonoApp`'s catch arms call.
*
* The controls are the discrimination the ruling keeps: the mapping keys on the
* registered code AND the class, as `RestServer`'s arm does, so a producer
* that merely SPEAKS `DUPLICATE_RECORD` keeps its own code, and the class name
* without that code changes nothing either.
*/
describe('[#23587] a repeated unique value answers 409 UNIQUE_VIOLATION on a Hono door, with the arm’s fields', () => {
/** The driver's own text, so a row can assert it never reaches the wire. */
const DRIVER_TEXT = "UNIQUE constraint failed: index 'uniq_udoc_email'";

/** The engine's envelope, as `engine.insert` throws it. */
function engineDuplicate(field?: string) {
return Object.assign(
new Error(field
? `Duplicate record refused on 'udoc': a unique constraint on '${field}' already holds this value. No record was written.`
: "Duplicate record refused on 'udoc': a unique constraint already holds these values. No record was written."),
{
name: 'DuplicateRecordError',
code: 'DUPLICATE_RECORD',
status: 409,
httpStatus: 409,
object: 'udoc',
...(field ? { field } : {}),
// The in-process remedy text: it names the engine's spelling, and the
// arm deliberately does not relay it.
developerMessage: "Branch on the platform code: over the HTTP API this refusal is reported as code === 'UNIQUE_VIOLATION'; inside the engine the thrown class carries code === 'DUPLICATE_RECORD'.",
cause: new Error(DRIVER_TEXT),
},
);
}

/** A hook that SPEAKS the registered code without being the engine's class. */
function hookDuplicate() {
return Object.assign(new Error('A hook refused this row as a duplicate of its own bookkeeping.'), {
code: 'DUPLICATE_RECORD',
status: 409,
});
}

type Door = (thrown: () => unknown) => Promise<{ status: number; body: any }>;
const doors: Array<[string, Door]> = [
['a HonoHttpServer route', async (thrown) => {
const s = server();
s.post('/api/v1/direct/udoc', async () => { throw thrown(); });
const res = await call(s, '/api/v1/direct/udoc', { method: 'POST' });
return { status: res.status, body: await res.json() };
}],
['the HonoHttpServer raw mount', async (thrown) => {
const s = server();
s.getRawApp().post('/raw/udoc', async () => { throw thrown(); });
const res = await call(s, '/raw/udoc', { method: 'POST' });
return { status: res.status, body: await res.json() };
}],
['declaredEnvelopeForThrow (createHonoApp’s renderer)', async (thrown) => {
const envelope = declaredEnvelopeForThrow(thrown());
return envelope ? { status: envelope.status, body: envelope.body } : { status: 0, body: undefined };
}],
];

it.each(doors)('%s: the engine’s class answers 409 UNIQUE_VIOLATION with developerMessage, field and object', async (_door, door) => {
const thrown = engineDuplicate('email');
const { status, body } = await door(() => thrown);

expect(status, JSON.stringify(body)).toBe(409);
expect(body.success).toBe(false);
expect(body.error.code).toBe('UNIQUE_VIOLATION');
expect(Object.keys(body.error.details ?? {}).sort()).toEqual(['developerMessage', 'field', 'object']);
expect(body.error.details.field).toBe('email');
expect(body.error.details.object).toBe('udoc');
// One spelling of the code: no demoted `declaredCode`, and the in-process
// spelling appears nowhere on the wire, nor does the driver's text.
expect(body.error).not.toHaveProperty('declaredCode');
expect(JSON.stringify(body)).not.toContain('DUPLICATE_RECORD');
expect(JSON.stringify(body)).not.toContain(DRIVER_TEXT);
});

it.each(doors)('%s: no field when the driver named none, and none invented', async (_door, door) => {
const { status, body } = await door(() => engineDuplicate());

expect(status, JSON.stringify(body)).toBe(409);
expect(body.error.code).toBe('UNIQUE_VIOLATION');
expect(Object.keys(body.error.details ?? {}).sort()).toEqual(['developerMessage', 'object']);
});

it.each(doors)('%s: CONTROL — a hook that speaks DUPLICATE_RECORD keeps its own code and its two keys', async (_door, door) => {
const { status, body } = await door(hookDuplicate);

expect(status, JSON.stringify(body)).toBe(409);
expect(body.error.code).toBe('DUPLICATE_RECORD');
expect(Object.keys(body.error).sort()).toEqual(['code', 'message']);
});

it.each(doors)('%s: CONTROL — the class name with another registered code keeps that code', async (_door, door) => {
const { status, body } = await door(() => Object.assign(new Error('conflict'), {
name: 'DuplicateRecordError',
code: 'RESOURCE_CONFLICT',
status: 409,
object: 'udoc',
field: 'email',
}));

expect(status, JSON.stringify(body)).toBe(409);
expect(body.error.code).toBe('RESOURCE_CONFLICT');
expect(Object.keys(body.error).sort()).toEqual(['code', 'message']);
});
});

describe('an escaped throw that is NOT such an envelope keeps today’s behaviour exactly', () => {
it('a plain Error answers the byte-identical 500 with no cause in the body', async () => {
const s = server();
Expand Down
Loading