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

fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentVersion`, as the standalone REST server's does (#23531)

A stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` is refused by the protocol with `409 CONCURRENT_UPDATE`. The standalone REST server's answer carries the server's `currentVersion`, which `wire-format.mdx` documents and a conflict dialog needs: it is the token "Overwrite anyway" sends back as the next `If-Match`. The two Hono doors answered the same refusal with `code` and `message` only. They are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`.

- FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}`
- TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…"}}}`

The field rides `error.details`, the wrapped envelope's declared context slot. `@objectstack/client` reads a wrapped body's context from there, so `err.details.currentVersion` now carries it on these hosts too. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it. Status, `code` and `message` are unchanged, and no other answer changes.

`currentRecord` is held on these two doors until #23586 settles how the protocol reads that row, and the REST server's answer is unchanged.

The `@objectstack/hono` entry for #23365 names this as a known gap. This change closes the gap's `currentVersion` half. Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`.

Clause-②: no
86 changes: 85 additions & 1 deletion packages/plugins/plugin-hono-server/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
resolveThrownHttpError,
looksLikeInternalErrorLeak,
INTERNAL_ERROR_MESSAGE,
structuredCodeAnswer,
} from '@objectstack/types';

/**
Expand Down Expand Up @@ -291,6 +292,14 @@ function toLoggableError(thrown: unknown): Error {
* is not restated there: a second recogniser is how the doors would start
* disagreeing about what counts as declared. Each caller keeps its own
* fallback arm for the `undefined` answer.
*
* ## A conflict's current state (#23531)
*
* A `409 CONCURRENT_UPDATE` also carries the server's `currentVersion` in
* `details`, read off `RestServer`'s classification by
* {@link declaredConflictContext}. Before this, both Hono doors answered that
* `409` with `code` and `message` only. `currentRecord` is held back for now
* (#23586); that helper's docblock says why.
*/
export function declaredEnvelopeForThrow(thrown: unknown): {
status: number;
Expand Down Expand Up @@ -318,6 +327,11 @@ export function declaredEnvelopeForThrow(thrown: unknown): {
? INTERNAL_ERROR_MESSAGE
: resolved.message;

// [#23531] A conflict's current state joins the producer's own context in
// `details`. Absent unless the conflict stated it.
const conflict = declaredConflictContext(thrown, resolved);
const details = conflict ? { ...resolved.details, ...conflict } : resolved.details;

return {
status: resolved.status,
body: {
Expand All @@ -330,13 +344,83 @@ export function declaredEnvelopeForThrow(thrown: unknown): {
// forwards them. Both are absent unless the producer declared
// them, so a throw that carried neither renders the same two
// keys it always did.
...(resolved.details ? { details: resolved.details } : {}),
...(details ? { details } : {}),
...(resolved.userMessage !== undefined ? { userMessage: resolved.userMessage } : {}),
},
},
};
}

/**
* [#23531] The current state a `409 CONCURRENT_UPDATE` carries —
* `currentVersion` — or `undefined` for any other answer, and for a conflict
* that stated none.
*
* ## Why
*
* `wire-format.mdx` ("Concurrent Update — 409 Conflict") and
* `ConcurrentUpdateError`'s docblock (`metadata-protocol`) say the conflict
* answer carries the server's current version and record, and objectui's
* conflict bar reads both. `RestServer` answers them. The two Hono doors did
* not: {@link declaredEnvelopeForThrow} forwards only the `details` that
* `resolveThrownHttpError` derives, and the two fields are own properties of
* the thrown error that resolver never reads.
*
* ## ONE classification, not a third spelling
*
* The fields are read off `structuredCodeAnswer` (`@objectstack/types`), the
* arm `RestServer`'s `mapDataError` answers this conflict through. So the
* Hono doors forward what the REST door forwards, under the same rule (each
* field only when the producer stated it), and an arm that grows a field grows
* it at every door. ⛔ Never list the fields here. The arm's own sentence and
* code are dropped because this envelope already carries both, as `message`
* and `code`. `currentRecord` is the ONE field named here, and it is named
* only to hold it back (the ⚠️ paragraph below says why).
*
* ## Where they land
*
* In `details`, the wrapped envelope's declared context slot
* (`ApiErrorSchema.details`). That is where `@objectstack/client` reads a
* wrapped body's context from (`errorBody.error.details`), and so where
* objectui's `normaliseClientError` finds them. The REST door's flat body has
* no envelope, so it carries them at the top level, beside its `code`.
*
* ## Only this code
*
* The card scoped the change to `CONCURRENT_UPDATE`. The classification has
* other structured arms (`DELETE_RESTRICTED`, `METADATA_CONFLICT`, …); this
* function does not take their fields. Both the status and the code must
* match the envelope already decided, so the arm can only add context to that
* envelope, never change it.
*
* ⚠️ `currentRecord` is HELD here: these doors do not forward it, though the
* REST door does. The protocol reads that row with a system context
* (`probeRecord`), and the OCC check throws it before the caller's write
* policy runs, so it is not narrowed to what the caller may read. It may carry
* a row, or fields, that row- or field-level security hides from the caller.
* Forwarding it would widen that possible disclosure to two more doors. A
* version token carries no row data, so `currentVersion` is forwarded. The
* record waits for the producer's answer (#23586), and the exclusion is lifted
* when that lands.
*/
function declaredConflictContext(
thrown: unknown,
resolved: { status: number; code: string },
): Record<string, unknown> | undefined {
if (resolved.code !== 'CONCURRENT_UPDATE') return undefined;
const arm = structuredCodeAnswer(thrown);
if (arm === undefined || arm.status !== resolved.status || arm.body.code !== resolved.code) {
return undefined;
}
const context = Object.fromEntries(
Object.entries(arm.body).filter(([key]) => key !== 'error'
&& key !== 'code'
// Held: a system-context row the caller may not be allowed to read (#23586).
&& key !== 'currentRecord'),
);
return Object.keys(context).length > 0 ? context : undefined;
}

/**
* The matched route's path parameters, or `{}` when there is no matched route.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,105 @@ describe('an escaped throw carrying a declared ADR-0112 envelope is rendered as
});
});

/**
* [#23531] A stale `If-Match` on a direct-mount route: the `409
* CONCURRENT_UPDATE` carries the server's `currentVersion`, which `RestServer`'s
* `409` carries and objectui's conflict bar reads.
*
* `currentRecord` is asserted ABSENT. `RestServer` forwards it, but the
* protocol reads that row with a system context before the caller's write
* policy runs, so it is held at these doors until the producer answers #23586.
* The producer below states it, so the absence is the door's doing.
*
* The producer below is shaped exactly like `metadata-protocol`'s
* `ConcurrentUpdateError` (`name`, `code`, `status`, and the two own
* properties), because this package does not depend on that one. The routes
* judge the `If-Match` header against a stored version, as `updateData` /
* `deleteData` do. The composed-host twin, over the REAL protocol, is
* `hono-declared-envelope.conformance.test.ts` in `packages/qa/http-conformance`.
*
* On the wrapped envelope the field rides `error.details`: that is the
* envelope's declared context slot (`ApiErrorSchema.details`), and it is where
* `@objectstack/client` reads a wrapped body's context from
* (`errorBody.error.details`).
*/
describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries currentVersion, and holds currentRecord (#23586)', () => {
class ConcurrentUpdateError extends Error {
readonly code = 'CONCURRENT_UPDATE';
readonly status = 409;
readonly currentVersion: string | null;
readonly currentRecord: unknown;
constructor(opts: { currentVersion: string | null; currentRecord: unknown; message: string }) {
super(opts.message);
this.name = 'ConcurrentUpdateError';
this.currentVersion = opts.currentVersion;
this.currentRecord = opts.currentRecord;
}
}

const STORED_VERSION = '2026-10-15T00:00:00.000Z';
const STALE_VERSION = '2001-01-01T00:00:00.000Z';
const STORED = { id: 'task_1', name: 'original', updated_at: STORED_VERSION };

/** A server whose PATCH and DELETE refuse a stale version, as the protocol does. */
function occServer(conflict: { currentVersion: string | null; currentRecord: unknown } = {
currentVersion: STORED_VERSION,
currentRecord: STORED,
}): HonoHttpServer {
const s = server();
const guard = (req: { params: Record<string, string>; headers: Record<string, unknown> }) => {
const sent = String(req.headers['if-match'] ?? '').replace(/^"|"$/g, '');
if (sent !== STORED_VERSION) {
throw new ConcurrentUpdateError({
...conflict,
message: `Record task/${req.params.id} was modified by another user (current version ${STORED_VERSION}, expected ${sent})`,
});
}
};
s.patch('/api/v1/data/task/:id', async (req, res) => { guard(req as any); res.json({ updated: true }); });
s.delete('/api/v1/data/task/:id', async (req, res) => { guard(req as any); res.json({ deleted: true }); });
return s;
}

it.each(['PATCH', 'DELETE'])('%s: 409 CONCURRENT_UPDATE with currentVersion in `error.details`, and no currentRecord', async (method) => {
const res = await call(occServer(), '/api/v1/data/task/task_1', {
method,
headers: { 'If-Match': `"${STALE_VERSION}"`, ...(method === 'PATCH' ? { 'content-type': 'application/json' } : {}) },
...(method === 'PATCH' ? { body: JSON.stringify({ name: 'lost update' }) } : {}),
});
const body = await res.json();

expect(res.status, JSON.stringify(body)).toBe(409);
expect(body.success).toBe(false);
expect(body.error.code).toBe('CONCURRENT_UPDATE');
expect(body.error.details).toEqual({ currentVersion: STORED_VERSION });
// Held until #23586: the producer stated it, and the door does not forward it.
expect(body.error.details).not.toHaveProperty('currentRecord');
expect(JSON.stringify(body)).not.toContain('original');
});

it('control: the version the route stores is accepted, so the 409 rows are the guard and not the rig', async () => {
const res = await call(occServer(), '/api/v1/data/task/task_1', {
method: 'PATCH',
headers: { 'If-Match': `"${STORED_VERSION}"`, 'content-type': 'application/json' },
body: JSON.stringify({ name: 'kept' }),
});
expect(res.status).toBe(200);
});

it('a conflict that names no current state keeps the two-key envelope, as the REST door keeps its body', async () => {
// `RestServer`'s arm forwards each field only when the producer stated it,
// so a conflict carrying neither adds nothing to either door's answer.
const res = await call(occServer({ currentVersion: null, currentRecord: null }), '/api/v1/data/task/task_1', {
method: 'DELETE',
headers: { 'If-Match': `"${STALE_VERSION}"` },
});
const body = await res.json();
expect(res.status).toBe(409);
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
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,62 @@ describe('[#23365] a real createHonoApp host: the dispatch() catch-all renders a
}, 60_000);
});

/**
* [#23531] The stale-`If-Match` `409` above carries the server's
* `currentVersion`, as `RestServer`'s does. The wire contract and
* `ConcurrentUpdateError`'s docblock name it, and objectui's conflict bar reads
* it. Before this card the body was `code` and `message` only.
*
* The field rides `error.details`, the wrapped envelope's declared context
* slot, which is where `@objectstack/client` reads a wrapped body's context
* from. `currentVersion` is proved by USE, not by spelling: echoed back as the
* next `If-Match`, it is accepted ("Overwrite anyway" in the conflict bar), so
* it is the record's real version token.
*
* `currentRecord` is asserted ABSENT. The real protocol below states it (the
* system-context row its `probeRecord` read, thrown before the caller's write
* policy runs), and `RestServer` forwards it. These doors hold it until the
* producer answers #23586, so the absence is the door's doing, not the
* producer's.
*/
describe('[#23531] a real createHonoApp host: the 409 CONCURRENT_UPDATE carries currentVersion, and holds currentRecord (#23586)', () => {
it('PATCH with a stale If-Match: currentVersion in `error.details` and accepted when echoed; no currentRecord', async () => {
const host = await boot();
const id = await host.seed('original');
const answer = await host.send('PATCH', `/data/task/${id}`, { headers: { 'If-Match': STALE_VERSION }, body: { name: 'lost update' } });
expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' });

const details = answer.body.error.details;
expect(typeof details?.currentVersion, JSON.stringify(answer.body)).toBe('string');
// Held until #23586: the protocol stated it, and the door does not forward it.
expect(details, JSON.stringify(answer.body)).not.toHaveProperty('currentRecord');
expect(JSON.stringify(answer.body)).not.toContain('original');
expect(await host.stored(id)).toBe('original');

const overwrite = await host.send('PATCH', `/data/task/${id}`, { headers: { 'If-Match': `"${details.currentVersion}"` }, body: { name: 'overwritten' } });
expect(overwrite.status, JSON.stringify(overwrite.body)).toBe(200);
expect(await host.stored(id)).toBe('overwritten');
}, 60_000);

it('DELETE with a stale If-Match: currentVersion in `error.details` and accepted when echoed; no currentRecord', async () => {
const host = await boot();
const id = await host.seed('kept');
const answer = await host.send('DELETE', `/data/task/${id}`, { headers: { 'If-Match': STALE_VERSION } });
expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' });

const details = answer.body.error.details;
expect(typeof details?.currentVersion, JSON.stringify(answer.body)).toBe('string');
// Held until #23586: the protocol stated it, and the door does not forward it.
expect(details, JSON.stringify(answer.body)).not.toHaveProperty('currentRecord');
expect(JSON.stringify(answer.body)).not.toContain('kept');
expect(await host.stored(id)).toBe('kept');

const overwrite = await host.send('DELETE', `/data/task/${id}`, { headers: { 'If-Match': `"${details.currentVersion}"` } });
expect(overwrite.status, JSON.stringify(overwrite.body)).toBe(200);
expect(await host.stored(id)).toBeUndefined();
}, 60_000);
});

describe('[#23365] a real createHonoApp host: the auth-mount arm renders a declared envelope', () => {
it('an auth service that throws a declared envelope: that envelope, with no cause in the body', async () => {
const host = await boot({
Expand Down
Loading