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

fix(plugin-hono-server): a `409 CONCURRENT_UPDATE` answered by either Hono door carries `currentRecord` as well, the caller's own read of the row, as the standalone REST server's does (#23531)

The two Hono doors are `@objectstack/hono`'s `createHonoApp` and a route mounted on `@objectstack/plugin-hono-server`'s `HonoHttpServer`. Their answer to a stale `If-Match` (or `expectedVersion`) on `PATCH` or `DELETE /data/:object/:id` carried the server's `currentVersion` in `error.details`, and held `currentRecord` back while the protocol read that row with a system context. On a version mismatch the protocol now re-reads the row under the caller's own context (#23586), so the record holds only what the caller may read, and these doors forward it.

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

`currentRecord` is the row as the caller reads it. A field the caller's field-level rules hide is absent from it, and a row the caller's row-level rules hide answers `404 RECORD_NOT_FOUND` with neither field, as the REST server answers. It is read from the classification the REST server applies (`structuredCodeAnswer` in `@objectstack/types`), so it appears only when the conflict stated it, and `@objectstack/client` reads it as `err.details.currentRecord` on these hosts. Status, `code`, `message` and `currentVersion` are unchanged, and no other answer changes.

This supersedes the earlier note that `currentRecord` stays held on these two doors. Nothing changes in `@objectstack/hono`'s own code: `createHonoApp` renders through `declaredEnvelopeForThrow` from `@objectstack/plugin-hono-server`.

Clause-②: no
2 changes: 1 addition & 1 deletion content/docs/api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ Validation failures additionally include a `fields` array (one entry per invalid
}
```

On a host built with `@objectstack/hono`'s `createHonoApp`, a declared refusal thrown out of the dispatcher (the `/data` routes' refusals among them, where its catch-all serves `/data`; a host that registers the kernel REST server's routes ahead of the catch-all, the hosted composition, answers those flat, as above) is answered in this envelope **without** `httpStatus`, and so is one escaping a route mounted on `HonoHttpServer`. The fields the kernel REST server's classification adds beside `code` ride in `details` there, for example `DELETE_RESTRICTED`'s `dependentObject`, except `currentRecord` and an `object` taken from the request's route — see [Wire Format](/docs/api/wire-format#hono-hosts-nest-the-extras).
On a host built with `@objectstack/hono`'s `createHonoApp`, a declared refusal thrown out of the dispatcher (the `/data` routes' refusals among them, where its catch-all serves `/data`; a host that registers the kernel REST server's routes ahead of the catch-all, the hosted composition, answers those flat, as above) is answered in this envelope **without** `httpStatus`, and so is one escaping a route mounted on `HonoHttpServer`. The fields the kernel REST server's classification adds beside `code` ride in `details` there, for example `DELETE_RESTRICTED`'s `dependentObject`, except an `object` taken from the request's route — see [Wire Format](/docs/api/wire-format#hono-hosts-nest-the-extras).

Branch on `error.code`, never on `error.httpStatus` — the status answers "what
class of failure" and the code answers "which failure". A branch that has no
Expand Down
17 changes: 13 additions & 4 deletions content/docs/api/wire-format.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -487,17 +487,26 @@ release group); any code reading them directly should move to `error.code`.
A host built with `@objectstack/hono`'s `createHonoApp`, and a route mounted on
`@objectstack/plugin-hono-server`'s `HonoHttpServer`, answer a declared refusal that
escapes the handler in the nested envelope above, without `httpStatus`. The extras the
REST door's classification adds (`fields`, `currentVersion`, `dependentObject`, …) ride in
`error.details`; `currentRecord`, and an `object` the flat body takes from the request's
route, are not sent. The concurrent update above, on a `createHonoApp` host whose catch-all serves `/data`:
REST door's classification adds (`fields`, `currentVersion`, `currentRecord`, `dependentObject`, …)
ride in `error.details`; an `object` the flat body takes from the request's route is not
sent. `currentRecord` is the row as the caller reads it, on either door: a field the caller's
field-level rules hide is absent from it. The concurrent update above, on a `createHonoApp`
host whose catch-all serves `/data`:

```json
{
"success": false,
"error": {
"code": "CONCURRENT_UPDATE",
"message": "Record task/tsk_01HQ4A7B9D3F5G8J2K4L was modified by another user (current version 2025-01-20T14:15:00.000Z, expected 2025-01-20T14:10:00.000Z)",
"details": { "currentVersion": "2025-01-20T14:15:00.000Z" }
"details": {
"currentVersion": "2025-01-20T14:15:00.000Z",
"currentRecord": {
"id": "tsk_01HQ4A7B9D3F5G8J2K4L",
"title": "Implement login page",
"updated_at": "2025-01-20T14:15:00.000Z"
}
}
}
}
```
Expand Down
5 changes: 2 additions & 3 deletions content/docs/protocol/kernel/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,13 @@ serves `/data`:
| Code | In `error.details` on a Hono door |
|---|---|
| `DELETE_RESTRICTED` | `developerMessage`, `dependentObject`, and `dependentCount` when the flat door would send it |
| `CONCURRENT_UPDATE` | `currentVersion` |
| `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` |

⛔ What a Hono door never sends: `httpStatus`; `CONCURRENT_UPDATE`'s `currentRecord`,
which the flat door sends; and an `object` the flat door takes from the request's route
⛔ 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.
Expand Down
44 changes: 21 additions & 23 deletions packages/plugins/plugin-hono-server/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,13 @@ function toLoggableError(thrown: unknown): Error {
* ## A structured refusal's context (#23531, #23587)
*
* A refusal `RestServer`'s classification answers with structured fields
* (`409 CONCURRENT_UPDATE`'s `currentVersion`, `409 DELETE_RESTRICTED`'s
* `developerMessage` / `dependentObject` / `dependentCount`, …) also carries
* them here, in `details`, read off that classification by
* {@link declaredArmContext}. Before this, both Hono doors answered those
* refusals with `code` and `message` only. `currentRecord` is held back for
* now (#23586); that helper's docblock says why.
* (`409 CONCURRENT_UPDATE`'s `currentVersion` and `currentRecord`, `409
* DELETE_RESTRICTED`'s `developerMessage` / `dependentObject` /
* `dependentCount`, …) also carries them here, in `details`, read off that
* classification by {@link declaredArmContext}. Before this, both Hono doors
* answered those refusals with `code` and `message` only. `currentRecord` is
* forwarded because the producer reads it under the caller's own context
* (#23586, PR #23628); that helper's docblock says why that matters.
*/
export function declaredEnvelopeForThrow(thrown: unknown): {
status: number;
Expand Down Expand Up @@ -378,9 +379,8 @@ export function declaredEnvelopeForThrow(thrown: unknown): {
* 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 or
* the codes 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).
* envelope already carries both, as `message` and `code`. No field is held
* back by name (the ⚠️ paragraph below says what that rests on).
*
* ## The arm must agree with the envelope
*
Expand Down Expand Up @@ -412,18 +412,19 @@ export function declaredEnvelopeForThrow(thrown: unknown): {
* `object` (`INVALID_FIELD`, `OBJECT_NOT_FOUND`, the 403 access arms) carry it
* here as they do there.
*
* ⚠️ `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. The
* record waits for the producer's answer (#23586), and the exclusion is lifted
* when that lands. Every other field an arm carries today is a version token,
* ⚠️ Row data rides here only because its producer reads it as the caller.
* `CONCURRENT_UPDATE`'s `currentRecord` is a row: on a version mismatch the
* protocol re-reads it under the caller's own `context`, so every row- and
* field-level rule of the read door applies (#23586, PR #23628). A row the
* caller cannot read answers `404 RECORD_NOT_FOUND` with neither field, and a
* readable row's `currentRecord` is that field-masked read, which these doors
* forward as the REST door does. It was held here until that landed, while
* the record was the row the existence probe (`probeRecord`) reads with a
* system context. Every other field an arm carries today is a version token,
* an API name, the caller's own input, or a count the producer has already
* narrowed to what the caller may read (`dependentCountIsDisclosable` in
* `@objectstack/objectql`); none is row data read with a system context. An
* arm that grows such a field must be held here by name, as this one is.
* `@objectstack/objectql`). An arm that grows a field read with a system
* context must be held here by name until its producer reads it as the caller.
*/
function declaredArmContext(
thrown: unknown,
Expand All @@ -434,10 +435,7 @@ function declaredArmContext(
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'),
Object.entries(arm.body).filter(([key]) => key !== 'error' && key !== 'code'),
);
return Object.keys(context).length > 0 ? context : undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,27 +216,28 @@ 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.
* CONCURRENT_UPDATE` carries the server's `currentVersion` and `currentRecord`,
* 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.
* `currentRecord` is forwarded as the producer threw it, field for field: the
* row below stands for the caller's own read, which is what `metadata-protocol`
* puts there since #23586 (PR #23628). `error.details` is asserted EXACTLY, so
* a field the door drops or invents turns the row red.
*
* 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`.
* `hono-declared-envelope.conformance.test.ts` in `packages/qa/http-conformance`;
* it also shows a field the caller's read masks staying off `currentRecord`.
*
* On the wrapped envelope the field rides `error.details`: that is the
* On the wrapped envelope the fields ride `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)', () => {
describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries currentVersion and currentRecord', () => {
class ConcurrentUpdateError extends Error {
readonly code = 'CONCURRENT_UPDATE';
readonly status = 409;
Expand Down Expand Up @@ -274,7 +275,7 @@ describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries cur
return s;
}

it.each(['PATCH', 'DELETE'])('%s: 409 CONCURRENT_UPDATE with currentVersion in `error.details`, and no currentRecord', async (method) => {
it.each(['PATCH', 'DELETE'])('%s: 409 CONCURRENT_UPDATE with currentVersion and currentRecord in `error.details`, as the producer stated them', 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' } : {}) },
Expand All @@ -285,10 +286,8 @@ describe('[#23531] a stale If-Match on a direct-mount route: the 409 carries cur
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');
// Exactly the two fields the producer stated: nothing dropped, nothing added.
expect(body.error.details).toEqual({ currentVersion: STORED_VERSION, currentRecord: STORED });
});

it('control: the version the route stores is accepted, so the 409 rows are the guard and not the rig', async () => {
Expand Down
Loading
Loading