Skip to content

Commit 19c554d

Browse files
fix(plugin-hono-server): a Hono door's 409 CONCURRENT_UPDATE carries currentVersion (#23531) (#23582)
Part of #23531 Clause-②: no This lands the card's `currentVersion` half. Its `currentRecord` half stays on the card, ordered after #23586 (patch round 1, the seat's review `6182753773`). ## What this does A stale `If-Match` on `PATCH` or `DELETE /data/:object/:id` is refused by the protocol with `ConcurrentUpdateError` (`409 CONCURRENT_UPDATE`). `RestServer` answers it with the server's `currentVersion` (and `currentRecord`). Both Hono doors answered it with `code` and `message` only: `@objectstack/hono`'s `createHonoApp` (its `dispatch()` catch-all, through `thrownJson`) and a route mounted on `HonoHttpServer` (`wrap()`, and the transport `onError` seam). All three paths render through one function, `declaredEnvelopeForThrow` in `packages/plugins/plugin-hono-server/src/adapter.ts`. That function forwarded only the `details` that the thrown-error resolver derives, and it never reads the two fields, because they are own properties of the error. `declaredEnvelopeForThrow` now asks one helper beside it, `declaredConflictContext`. For a `409 CONCURRENT_UPDATE`, that helper reads the conflict's context off `structuredCodeAnswer` (`@objectstack/types`, already exported). That is the arm `RestServer`'s `mapDataError` answers this conflict through. Everything the arm's body carries is forwarded except three keys: - its own sentence and code, which the envelope already carries as `message` and `code`; - `currentRecord`, which is held (next section). So: - ⛔ no third spelling of the conflict body. The one field the Hono package names is the one it holds back, and it names it only to hold it. Everything else appears only when the producer stated it, by the REST arm's own rule; - the arm must agree with the envelope already decided (same status, same code), so it can only add context and never changes the answer; - only `CONCURRENT_UPDATE` is taken, per the card's scope ruling. Other structured arms are listed under Acceptance notes. **Where it lands:** `error.details`. On the wrapped envelope (`{ success, error }`) that is the declared context slot (`ApiErrorSchema.details`), and it is where `@objectstack/client` reads a wrapped body's context (`error.details = errorBody.details ?? errorBody.error.details ?? errorBody`). That is what objectui's `normaliseClientError` reads `currentVersion` from. The REST door's flat body has no envelope, so it carries the field at the top level. - FROM (measured on this branch's pins before the fix): `409 {"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"Record task/ID was modified by another user (current version T1, expected T0)"}}` - TO: the same, plus `"details":{"currentVersion":"T1"}` Status, `code` and `message` do not move. A throw that is not a `409 CONCURRENT_UPDATE` renders byte-identically to before. The REST door's answer is unchanged. **Both doors pick the change up from `adapter.ts` alone:** `createHonoApp`'s `thrownJson` (`packages/adapters/hono/src/index.ts`, not edited here), `wrap()` through `runHandler`, the `onError` seam, and the metrics observer's status read all call `declaredEnvelopeForThrow`. ## `currentRecord` is held on these doors (#23586) The protocol reads the conflict's current row with `probeRecord`, which is `engine.findOne(object, { where: { id }, context: { isSystem: true } })` (`packages/metadata-protocol/src/protocol.ts`). `assertVersionOf` throws that row as `currentRecord` before `engine.update` / `engine.delete` apply the caller's write policy, and no layer narrows it. So the field may carry a row, or fields, that row- or field-level security hides from the caller. That is filed as #23586 (a security exception, not measured at a door). Forwarding it here would widen that possible disclosure to two more doors. A version token carries no row data, so `currentVersion` lands now. `declaredConflictContext` excludes `currentRecord` by name, with a pointer to #23586 at the exclusion, and the pins assert it ABSENT at both doors. The exclusion is lifted once the producer's answer lands. `RestServer` still forwards the field. That is the producer's question in #23586, and this PR does not touch that door. ## Tests Final head `69215f8575`. - **Pins** (both files keep their controls): - `packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts`, the `[#23531]` block. A direct-mount `PATCH` and `DELETE` route judges `If-Match` and throws a `ConcurrentUpdateError`-shaped refusal that states both fields. The pins assert `error.details` equals `{ currentVersion }` exactly, that it has no `currentRecord`, and that the row's values appear nowhere in the body. - `packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts`, the `[#23531]` block. A real `createHonoApp` over the real dispatcher and protocol (SQLite WASM), with stale `If-Match` on `PATCH` and `DELETE`. `currentVersion` is proved by use: echoed as the next `If-Match`, it is accepted (`PATCH` writes, `DELETE` deletes). `currentRecord` is asserted absent, although the real protocol states it (round 0 read it on this rig as `{ id, name }`). - **Red before the fix** (round 0, pins only, `13b5267b42`): unit `Tests 2 failed | 21 passed (23)`, conformance `Tests 2 failed | 13 passed (15)`. The body then had no `details`. - **Ablation, round 1**, on `69215f8575`: - Method: `node scripts/ablation-replace.mjs` in wrap mode, plus an outer `trap … EXIT INT TERM` restore against the absolute path. Each leg was rebuilt, and `ablation-dist-preflight.mjs` showed the marker in `dist/index.js` and `dist/index.mjs`. - **Leg A** dropped the exclusion: anchor ``&& key !== 'currentRecord'),`` became a key no body carries. The absence pins went RED, unit `2 failed | 21 passed` (`expected { …(2) } to deeply equal { Object (currentVersion) }`) and conformance `2 failed | 13 passed` (`to not have property "currentRecord"`). - **Leg B** disabled the guard: anchor ``if (resolved.code !== 'CONCURRENT_UPDATE') return undefined;`` was made unmatchable. The `currentVersion` pins went RED, unit `2 failed | 21 passed` (`expected undefined to deeply equal …`) and conformance `2 failed | 13 passed` (`expected 'undefined' to be 'string'`). - On a first run, leg B's conformance red came from a `TypeError` instead: the absence assertion ran on an undefined `details` before the `currentVersion` check. The assertions were reordered (`69215f8575`) and both legs re-run, with the result above. - **Restore:** blob equal to HEAD, `git diff HEAD` empty, `git status` clean. After a rebuild, preflight `--absent` found both markers gone from all 6 built files. Both suites were green again. - **Packages** at `69215f8575`: - `pnpm --filter @objectstack/plugin-hono-server typecheck` (tsc, `tsconfig.typecheck.json`, and `check:test-typecheck` OK with 0 debt) and `test`: `Test Files 29 passed`, `Tests 340 passed`. - `pnpm --filter @objectstack/http-conformance typecheck` (`check:test-typecheck` OK, ledger unchanged) and `vitest run`: `Test Files 13 passed`, `Tests 139 passed`. - Both ran after a rebuild of `http-conformance`'s dependency closure, `plugin-hono-server` included. - `@objectstack/hono`'s own suite is not run locally: its vitest config stubs `@objectstack/runtime`, its source is untouched, and the composed-host pin above covers its behaviour. It is declared to CI. - `git merge-tree --write-tree HEAD origin/main` was clean at the start of the round (one docs commit ahead), so the branch was not merged again. The round-0 merge (`9d467b933a`) stands. ## Gates Local, at the final head `69215f8575`: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **66** commands from the 4 changed paths (merge base `fa6a6085c`), the same 66 as round 0. All 66 ran, each exit code captured before any pipe: **66 x exit 0**. - `pnpm check:dual-build-cjs-loads` first exited 3, `PREREQUISITE NOT MET`: this round's fresh worktree had no workspace `dist/`. After a cache-backed `pnpm turbo run build` (`75 successful`, `74 cached`) it measured: `110 published require entry point(s) across 68 package(s) load`. - `--ran` reconciliation: `66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero; every line carries its exit code). - The four artifact-roster gates the derivation marks as keeping their roster in a directory this diff touches, all exit 0: `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing`, `pnpm check:filter-alias-parity`. - Lint: `pnpm lint` is the repo-level scan CI owns (`Lint · ESLint`). Narrowed locally to the three changed sources with the same flags (`eslint --no-inline-config`): exit 0, with 0 errors and 0 warnings each. The ESLint config is unchanged between the merge base and `origin/main`. - Every family CI runs on this PR is CI's to report on its check-runs. ## Changeset `.changeset/23531-hono-conflict-body.md`: `@objectstack/plugin-hono-server` `patch` and `@objectstack/hono` `patch`. The TO body is `details.currentVersion` only, and one sentence says `currentRecord` is held until #23586 and that the REST door's answer is unchanged. `@objectstack/hono` is named because `createHonoApp`'s answer is the measured door, and its CHANGELOG carries the #23365 entry that states this gap. Nothing in its own files changes, and both packages are in the one fixed version group, so naming it moves no version. ## Acceptance notes - **#23586:** `currentRecord` is a system-context row, thrown before the caller's write policy runs. It is held on these two doors until the producer's answer lands, and this card's `currentRecord` half is ordered after it. - **#23587:** the same family, beyond this card. A real `createHonoApp` answered a restricted `DELETE` with `409 DELETE_RESTRICTED` and without the `developerMessage`, `dependentObject`, `dependentCount` and `object` that `RestServer`'s arm carries (measured at `d7484b8621`). Not measured: `METADATA_CONFLICT`'s `currentVersion`, and the engine's `INVALID_FIELD`. Widening `declaredConflictContext` from one code to "the arm agrees on status and code" is the mechanical shape. - `object` is not forwarded: `declaredEnvelopeForThrow` has no route context, so the arm is asked without one, and the arm adds `object` only when given one. - The adapter-side file surface is `declaredEnvelopeForThrow`, the helper beside it, and one added name on the existing `@objectstack/types` import. No new export. --- _Generated by [Claude Code](https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1a9d488 commit 19c554d

4 files changed

Lines changed: 259 additions & 1 deletion

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
'@objectstack/hono': patch
4+
---
5+
6+
fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentVersion`, as the standalone REST server's does (#23531)
7+
8+
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`.
9+
10+
- FROM `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…"}}`
11+
- TO `{"success":false,"error":{"code":"CONCURRENT_UPDATE","message":"…","details":{"currentVersion":"…"}}}`
12+
13+
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.
14+
15+
`currentRecord` is held on these two doors until #23586 settles how the protocol reads that row, and the REST server's answer is unchanged.
16+
17+
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`.
18+
19+
Clause-②: no

‎packages/plugins/plugin-hono-server/src/adapter.ts‎

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
resolveThrownHttpError,
4848
looksLikeInternalErrorLeak,
4949
INTERNAL_ERROR_MESSAGE,
50+
structuredCodeAnswer,
5051
} from '@objectstack/types';
5152

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

330+
// [#23531] A conflict's current state joins the producer's own context in
331+
// `details`. Absent unless the conflict stated it.
332+
const conflict = declaredConflictContext(thrown, resolved);
333+
const details = conflict ? { ...resolved.details, ...conflict } : resolved.details;
334+
321335
return {
322336
status: resolved.status,
323337
body: {
@@ -330,13 +344,83 @@ export function declaredEnvelopeForThrow(thrown: unknown): {
330344
// forwards them. Both are absent unless the producer declared
331345
// them, so a throw that carried neither renders the same two
332346
// keys it always did.
333-
...(resolved.details ? { details: resolved.details } : {}),
347+
...(details ? { details } : {}),
334348
...(resolved.userMessage !== undefined ? { userMessage: resolved.userMessage } : {}),
335349
},
336350
},
337351
};
338352
}
339353

354+
/**
355+
* [#23531] The current state a `409 CONCURRENT_UPDATE` carries —
356+
* `currentVersion` — or `undefined` for any other answer, and for a conflict
357+
* that stated none.
358+
*
359+
* ## Why
360+
*
361+
* `wire-format.mdx` ("Concurrent Update — 409 Conflict") and
362+
* `ConcurrentUpdateError`'s docblock (`metadata-protocol`) say the conflict
363+
* answer carries the server's current version and record, and objectui's
364+
* conflict bar reads both. `RestServer` answers them. The two Hono doors did
365+
* not: {@link declaredEnvelopeForThrow} forwards only the `details` that
366+
* `resolveThrownHttpError` derives, and the two fields are own properties of
367+
* the thrown error that resolver never reads.
368+
*
369+
* ## ONE classification, not a third spelling
370+
*
371+
* The fields are read off `structuredCodeAnswer` (`@objectstack/types`), the
372+
* arm `RestServer`'s `mapDataError` answers this conflict through. So the
373+
* Hono doors forward what the REST door forwards, under the same rule (each
374+
* field only when the producer stated it), and an arm that grows a field grows
375+
* it at every door. ⛔ Never list the fields here. The arm's own sentence and
376+
* code are dropped because this envelope already carries both, as `message`
377+
* and `code`. `currentRecord` is the ONE field named here, and it is named
378+
* only to hold it back (the ⚠️ paragraph below says why).
379+
*
380+
* ## Where they land
381+
*
382+
* In `details`, the wrapped envelope's declared context slot
383+
* (`ApiErrorSchema.details`). That is where `@objectstack/client` reads a
384+
* wrapped body's context from (`errorBody.error.details`), and so where
385+
* objectui's `normaliseClientError` finds them. The REST door's flat body has
386+
* no envelope, so it carries them at the top level, beside its `code`.
387+
*
388+
* ## Only this code
389+
*
390+
* The card scoped the change to `CONCURRENT_UPDATE`. The classification has
391+
* other structured arms (`DELETE_RESTRICTED`, `METADATA_CONFLICT`, …); this
392+
* function does not take their fields. Both the status and the code must
393+
* match the envelope already decided, so the arm can only add context to that
394+
* envelope, never change it.
395+
*
396+
* ⚠️ `currentRecord` is HELD here: these doors do not forward it, though the
397+
* REST door does. The protocol reads that row with a system context
398+
* (`probeRecord`), and the OCC check throws it before the caller's write
399+
* policy runs, so it is not narrowed to what the caller may read. It may carry
400+
* a row, or fields, that row- or field-level security hides from the caller.
401+
* Forwarding it would widen that possible disclosure to two more doors. A
402+
* version token carries no row data, so `currentVersion` is forwarded. The
403+
* record waits for the producer's answer (#23586), and the exclusion is lifted
404+
* when that lands.
405+
*/
406+
function declaredConflictContext(
407+
thrown: unknown,
408+
resolved: { status: number; code: string },
409+
): Record<string, unknown> | undefined {
410+
if (resolved.code !== 'CONCURRENT_UPDATE') return undefined;
411+
const arm = structuredCodeAnswer(thrown);
412+
if (arm === undefined || arm.status !== resolved.status || arm.body.code !== resolved.code) {
413+
return undefined;
414+
}
415+
const context = Object.fromEntries(
416+
Object.entries(arm.body).filter(([key]) => key !== 'error'
417+
&& key !== 'code'
418+
// Held: a system-context row the caller may not be allowed to read (#23586).
419+
&& key !== 'currentRecord'),
420+
);
421+
return Object.keys(context).length > 0 ? context : undefined;
422+
}
423+
340424
/**
341425
* The matched route's path parameters, or `{}` when there is no matched route.
342426
*

‎packages/plugins/plugin-hono-server/src/handler-throw-declared-envelope.test.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,105 @@ describe('an escaped throw carrying a declared ADR-0112 envelope is rendered as
214214
});
215215
});
216216

217+
/**
218+
* [#23531] A stale `If-Match` on a direct-mount route: the `409
219+
* CONCURRENT_UPDATE` carries the server's `currentVersion`, which `RestServer`'s
220+
* `409` carries and objectui's conflict bar reads.
221+
*
222+
* `currentRecord` is asserted ABSENT. `RestServer` forwards it, but the
223+
* protocol reads that row with a system context before the caller's write
224+
* policy runs, so it is held at these doors until the producer answers #23586.
225+
* The producer below states it, so the absence is the door's doing.
226+
*
227+
* The producer below is shaped exactly like `metadata-protocol`'s
228+
* `ConcurrentUpdateError` (`name`, `code`, `status`, and the two own
229+
* properties), because this package does not depend on that one. The routes
230+
* judge the `If-Match` header against a stored version, as `updateData` /
231+
* `deleteData` do. The composed-host twin, over the REAL protocol, is
232+
* `hono-declared-envelope.conformance.test.ts` in `packages/qa/http-conformance`.
233+
*
234+
* On the wrapped envelope the field rides `error.details`: that is the
235+
* envelope's declared context slot (`ApiErrorSchema.details`), and it is where
236+
* `@objectstack/client` reads a wrapped body's context from
237+
* (`errorBody.error.details`).
238+
*/
239+
describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries currentVersion, and holds currentRecord (#23586)', () => {
240+
class ConcurrentUpdateError extends Error {
241+
readonly code = 'CONCURRENT_UPDATE';
242+
readonly status = 409;
243+
readonly currentVersion: string | null;
244+
readonly currentRecord: unknown;
245+
constructor(opts: { currentVersion: string | null; currentRecord: unknown; message: string }) {
246+
super(opts.message);
247+
this.name = 'ConcurrentUpdateError';
248+
this.currentVersion = opts.currentVersion;
249+
this.currentRecord = opts.currentRecord;
250+
}
251+
}
252+
253+
const STORED_VERSION = '2026-10-15T00:00:00.000Z';
254+
const STALE_VERSION = '2001-01-01T00:00:00.000Z';
255+
const STORED = { id: 'task_1', name: 'original', updated_at: STORED_VERSION };
256+
257+
/** A server whose PATCH and DELETE refuse a stale version, as the protocol does. */
258+
function occServer(conflict: { currentVersion: string | null; currentRecord: unknown } = {
259+
currentVersion: STORED_VERSION,
260+
currentRecord: STORED,
261+
}): HonoHttpServer {
262+
const s = server();
263+
const guard = (req: { params: Record<string, string>; headers: Record<string, unknown> }) => {
264+
const sent = String(req.headers['if-match'] ?? '').replace(/^"|"$/g, '');
265+
if (sent !== STORED_VERSION) {
266+
throw new ConcurrentUpdateError({
267+
...conflict,
268+
message: `Record task/${req.params.id} was modified by another user (current version ${STORED_VERSION}, expected ${sent})`,
269+
});
270+
}
271+
};
272+
s.patch('/api/v1/data/task/:id', async (req, res) => { guard(req as any); res.json({ updated: true }); });
273+
s.delete('/api/v1/data/task/:id', async (req, res) => { guard(req as any); res.json({ deleted: true }); });
274+
return s;
275+
}
276+
277+
it.each(['PATCH', 'DELETE'])('%s: 409 CONCURRENT_UPDATE with currentVersion in `error.details`, and no currentRecord', async (method) => {
278+
const res = await call(occServer(), '/api/v1/data/task/task_1', {
279+
method,
280+
headers: { 'If-Match': `"${STALE_VERSION}"`, ...(method === 'PATCH' ? { 'content-type': 'application/json' } : {}) },
281+
...(method === 'PATCH' ? { body: JSON.stringify({ name: 'lost update' }) } : {}),
282+
});
283+
const body = await res.json();
284+
285+
expect(res.status, JSON.stringify(body)).toBe(409);
286+
expect(body.success).toBe(false);
287+
expect(body.error.code).toBe('CONCURRENT_UPDATE');
288+
expect(body.error.details).toEqual({ currentVersion: STORED_VERSION });
289+
// Held until #23586: the producer stated it, and the door does not forward it.
290+
expect(body.error.details).not.toHaveProperty('currentRecord');
291+
expect(JSON.stringify(body)).not.toContain('original');
292+
});
293+
294+
it('control: the version the route stores is accepted, so the 409 rows are the guard and not the rig', async () => {
295+
const res = await call(occServer(), '/api/v1/data/task/task_1', {
296+
method: 'PATCH',
297+
headers: { 'If-Match': `"${STORED_VERSION}"`, 'content-type': 'application/json' },
298+
body: JSON.stringify({ name: 'kept' }),
299+
});
300+
expect(res.status).toBe(200);
301+
});
302+
303+
it('a conflict that names no current state keeps the two-key envelope, as the REST door keeps its body', async () => {
304+
// `RestServer`'s arm forwards each field only when the producer stated it,
305+
// so a conflict carrying neither adds nothing to either door's answer.
306+
const res = await call(occServer({ currentVersion: null, currentRecord: null }), '/api/v1/data/task/task_1', {
307+
method: 'DELETE',
308+
headers: { 'If-Match': `"${STALE_VERSION}"` },
309+
});
310+
const body = await res.json();
311+
expect(res.status).toBe(409);
312+
expect(Object.keys(body.error).sort()).toEqual(['code', 'message']);
313+
});
314+
});
315+
217316
describe('an escaped throw that is NOT such an envelope keeps today’s behaviour exactly', () => {
218317
it('a plain Error answers the byte-identical 500 with no cause in the body', async () => {
219318
const s = server();

‎packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,62 @@ describe('[#23365] a real createHonoApp host: the dispatch() catch-all renders a
240240
}, 60_000);
241241
});
242242

243+
/**
244+
* [#23531] The stale-`If-Match` `409` above carries the server's
245+
* `currentVersion`, as `RestServer`'s does. The wire contract and
246+
* `ConcurrentUpdateError`'s docblock name it, and objectui's conflict bar reads
247+
* it. Before this card the body was `code` and `message` only.
248+
*
249+
* The field rides `error.details`, the wrapped envelope's declared context
250+
* slot, which is where `@objectstack/client` reads a wrapped body's context
251+
* from. `currentVersion` is proved by USE, not by spelling: echoed back as the
252+
* next `If-Match`, it is accepted ("Overwrite anyway" in the conflict bar), so
253+
* it is the record's real version token.
254+
*
255+
* `currentRecord` is asserted ABSENT. The real protocol below states it (the
256+
* system-context row its `probeRecord` read, thrown before the caller's write
257+
* policy runs), and `RestServer` forwards it. These doors hold it until the
258+
* producer answers #23586, so the absence is the door's doing, not the
259+
* producer's.
260+
*/
261+
describe('[#23531] a real createHonoApp host: the 409 CONCURRENT_UPDATE carries currentVersion, and holds currentRecord (#23586)', () => {
262+
it('PATCH with a stale If-Match: currentVersion in `error.details` and accepted when echoed; no currentRecord', async () => {
263+
const host = await boot();
264+
const id = await host.seed('original');
265+
const answer = await host.send('PATCH', `/data/task/${id}`, { headers: { 'If-Match': STALE_VERSION }, body: { name: 'lost update' } });
266+
expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' });
267+
268+
const details = answer.body.error.details;
269+
expect(typeof details?.currentVersion, JSON.stringify(answer.body)).toBe('string');
270+
// Held until #23586: the protocol stated it, and the door does not forward it.
271+
expect(details, JSON.stringify(answer.body)).not.toHaveProperty('currentRecord');
272+
expect(JSON.stringify(answer.body)).not.toContain('original');
273+
expect(await host.stored(id)).toBe('original');
274+
275+
const overwrite = await host.send('PATCH', `/data/task/${id}`, { headers: { 'If-Match': `"${details.currentVersion}"` }, body: { name: 'overwritten' } });
276+
expect(overwrite.status, JSON.stringify(overwrite.body)).toBe(200);
277+
expect(await host.stored(id)).toBe('overwritten');
278+
}, 60_000);
279+
280+
it('DELETE with a stale If-Match: currentVersion in `error.details` and accepted when echoed; no currentRecord', async () => {
281+
const host = await boot();
282+
const id = await host.seed('kept');
283+
const answer = await host.send('DELETE', `/data/task/${id}`, { headers: { 'If-Match': STALE_VERSION } });
284+
expect(envelope(answer), JSON.stringify(answer.body)).toEqual({ status: 409, success: false, code: 'CONCURRENT_UPDATE' });
285+
286+
const details = answer.body.error.details;
287+
expect(typeof details?.currentVersion, JSON.stringify(answer.body)).toBe('string');
288+
// Held until #23586: the protocol stated it, and the door does not forward it.
289+
expect(details, JSON.stringify(answer.body)).not.toHaveProperty('currentRecord');
290+
expect(JSON.stringify(answer.body)).not.toContain('kept');
291+
expect(await host.stored(id)).toBe('kept');
292+
293+
const overwrite = await host.send('DELETE', `/data/task/${id}`, { headers: { 'If-Match': `"${details.currentVersion}"` } });
294+
expect(overwrite.status, JSON.stringify(overwrite.body)).toBe(200);
295+
expect(await host.stored(id)).toBeUndefined();
296+
}, 60_000);
297+
});
298+
243299
describe('[#23365] a real createHonoApp host: the auth-mount arm renders a declared envelope', () => {
244300
it('an auth service that throws a declared envelope: that envelope, with no cause in the body', async () => {
245301
const host = await boot({

0 commit comments

Comments
 (0)