Skip to content

Commit cac0be8

Browse files
fix(rest,runtime)!: both /data write doors validate the request as the caller sent it; a number expectedVersion is refused 400 (#23571)
Fixes #23491 Fixes #23517 Clause-②: no One PR for a two-card family fold: both `PATCH /data/:object/:id` doors, `RestServer`'s registered handler and the runtime dispatcher's `/data` domain (the only answer on a `createHonoApp` host), now check `UpdateDataRequestSchema` against the request **as the caller sent it**. The first commit is #23491 and the second is #23517. ## What changed **Commit 1, #23491: a request-schema gate on the dispatcher, and no coerced copy on `RestServer`** - `packages/runtime/src/domains/data.ts`, `PATCH` branch: the assembled request `{ object, id, data, expectedVersion? }` is parsed with `UpdateDataRequestSchema` after the version-token check and before `callData`. The token is stripped from `data`, and nothing is defaulted. A failure returns `400 VALIDATION_FAILED` with `error.details.fields` through `deps.errorFromThrown(validationFailure(…))`. It is returned rather than thrown, as the sibling version refusal is. - `packages/runtime/src/domains/data.ts`, `POST /data/:object` create branch: the same gate with `CreateDataRequestSchema`. H4 measured the same gap there, and a worse one, so it is folded in (see the table). - `packages/rest/src/rest-server.ts`, `PATCH` handler: `data ?? {}` is gone from both the gate input and the forward. The gate now parses the patch as sent. **Commit 2, #23517: a number token is refused, and both doors gate and forward the raw token** - `versionTokenRefusal`, both copies, still statement for statement (`diff` of the two function bodies is empty): the `typeof token === 'number' && Number.isFinite(token)` arm is deleted, so a number joins the refused shapes. The prescription now says "send the version you read as a string". Both docblocks name the change. - Both `PATCH` doors put the raw token into the gate input and the forward (`{ expectedVersion }`, no longer `String(expectedVersion)`). With the arm gone, anything that reaches the gate is already a non-blank string, so the gate and the forward see the same value the caller sent. - `DELETE` is untouched. Its `?expectedVersion` and every `If-Match` are strings by transport. `data-delete-expected-version.test.ts` (34 tests) runs green on both doors, unchanged. Landing is exactly the claimed file surface: the two branches of `data.ts`, the `PATCH` handler and the `versionTokenRefusal` of `rest-server.ts`, their tests, and one changeset. No other region of `rest-server.ts` was touched. ## The `null` answer: `400` on both `PATCH` doors Measured first, on the rig PR #23515 built (real better-sqlite3 store, real ObjectQL, real protocol; `RestServer`'s registered handler and `HttpDispatcher.dispatch()` side by side), at `086fa4640f`: - `RestServer` answered a JSON `null` body `200`, called `updateData` with `{}`, and **moved the record's `updated_at`** (`…26.716Z` to `…26.723Z`) with no field changed. Every other holder of the old version would then lose its next guarded save to a request that wrote nothing. - The dispatcher answered the same body `500 INTERNAL_ERROR` ("Cannot read properties of null (reading 'id')"). I picked `400 VALIDATION_FAILED` for both. The declared type is a record, and `data ?? {}` was the consumer-side leniency Prime Directive 12 forbids. **This narrows `RestServer`'s accept set (`200` to `400`)**. It stays `Clause-②: no`: no key is added and no code is new. The changeset carries the narrowing in its FROM/TO list. ## Before / after, both doors (measured, `086fa4640f` vs this head) | request | `RestServer` before | dispatcher before | both doors after | |---|---|---|---| | `PATCH` body `[{…}]` | 400 VALIDATION_FAILED | 500 INTERNAL_ERROR ("no such column: 0") | 400 VALIDATION_FAILED, `fields: [data]` | | `PATCH` body `"garbage"` | 400 VALIDATION_FAILED | 500 INTERNAL_ERROR | 400 VALIDATION_FAILED, `fields: [data]` | | `PATCH` body `42` | 400 VALIDATION_FAILED | 500 INTERNAL_ERROR | 400 VALIDATION_FAILED, `fields: [data]` | | `PATCH` body `null` | **200, version moved** | 500 INTERNAL_ERROR | 400 VALIDATION_FAILED, `fields: [data]`, version unchanged | | `PATCH` `expectedVersion: 5` / `0`, stale or current `If-Match` | 409 CONCURRENT_UPDATE (forwarded `"5"` / `"0"`) | 409 CONCURRENT_UPDATE | 400 VALIDATION_FAILED, `updateData` never asked | | `POST` body `[{…}]` | 400 VALIDATION_FAILED | **201, one row created per element** | 400 VALIDATION_FAILED, no row | | `POST` body `"garbage"` | 400 VALIDATION_FAILED | 400 INVALID_FIELD (engine) | 400 VALIDATION_FAILED, no row | | `POST` body `42` | 400 VALIDATION_FAILED | **201, an empty row** | 400 VALIDATION_FAILED, no row | | `POST` body `null` | 201, an empty row | 201, an empty row | unchanged on both (see below) | Envelopes keep each door's own convention (H5). `RestServer`: `{ error: 'Invalid update request', code, fields, object }`, and for a token refusal the `mapDataError` envelope it already gave `""`. Dispatcher: `{ success: false, error: { code, message, details: { fields } } }`. The pins compare status, code, field names and store state across doors. They never compare envelope bytes. ## `POST` create with `null`: left as both doors answer it today `RestServer`'s create assembles `{ object, data: req.body ?? {} }`. That region is outside this PR's surface, and the seat's same-file serial rule holds it. So the dispatcher's new create gate asks **the same assembled request, statement for statement**, `?? {}` included. The two doors answer a `null` create alike (`201`, an empty row), and the parity row pins that agreement without pinning the `201` itself. This is the one `??` default the diff adds, on purpose. The alternative was a dispatcher-only `400`, which would split one route into two answers. That leniency is retired on both doors at once or not at all. It is raised as an open question in the report. ## Pins - New: `packages/runtime/src/domains/data-write-request-gate.test.ts`. `PATCH` and `POST` through `dispatch()` beside `RestServer`'s registered handler. It covers an array, a string, a number and `null`. Each row asserts `400` and `VALIDATION_FAILED`, `fields` naming `data`, the protocol method never called, and the store unchanged. For `PATCH`, the store check includes `updated_at`, so the flipped `null` row asserts that the version did not move. A cross-door parity block and a record-body control for each verb are included. 26 tests. - New in `data-update-expected-version.test.ts`: a `[#23517]` block. `expectedVersion: 5` and `0`, each beside a stale and a current `If-Match`, on both doors: `400 VALIDATION_FAILED`, `updateData` never asked, the row unchanged. A parity block checks status, code and sentence. Controls: the same token as the string `'5'` is still judged (`409`), and the current `If-Match` alone still updates. - `packages/rest/src/request-schema-gate.conformance.test.ts`: `null` joins the `PATCH` row's violating bodies. **Flipped pins** (repo-wide grep for a numeric `expectedVersion` answering `409` and for a `null` `PATCH` answering `200`: two hits, both in `data-update-expected-version.test.ts`, and no `null`-200 pin anywhere): - `control: a body token of 0 … judged as "0" (refused 409)` now asserts `400 VALIDATION_FAILED`, `updateData` never asked, and the row unchanged beside the **current** `If-Match`. That keeps its original point: `0` is never dropped as "no token". - `EDGE_SHAPES` `a number token` (`20000101`) is removed from the judged-shape parity list. Numbers are pinned in the new `[#23517]` block. ## Tests and gates (on `3dd76b2a8`) All local runs below are on `3dd76b2a8` (the final commit). Exit codes were captured before any pipe. - `@objectstack/rest` suite, `vitest run --project local`: **284 files, 5,856 passed, 326 skipped, exit 0**. - `@objectstack/runtime` suite, `vitest run --project local`: **382 files, 5,673 passed, 19 skipped, exit 0**. - Targeted: `data-write-request-gate.test.ts` 26/26, `data-update-expected-version.test.ts` 65/65, `data-delete-expected-version.test.ts` 34/34; `request-schema-gate.conformance.test.ts` 16/16. - `pnpm --filter @objectstack/rest typecheck` exit 0, and `pnpm --filter @objectstack/runtime typecheck` exit 0 (the test layer compiles; the runtime debt ledger is unchanged). - eslint (the repo config, `--no-inline-config`) over `packages/rest/src` and `packages/runtime/src`: exit 0, no findings. Repo-wide `pnpm lint` is left to CI. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 65 gate lines at this head. 64 exit 0. `pnpm check:dual-build-cjs-loads` exit 3 `PREREQUISITE NOT MET` (it needs every package's `dist/`): **NOT MEASURED locally**, CI's check-run is the reading. `--ran` reconciliation: 65 derived, 64 run, 1 NOT-MEASURED, 0 UNRUN. **Ablations.** One per behaviour. Each ran through `scripts/ablation-replace.mjs` against the committed fix, with the anchor proven to hit and a restore proven against `HEAD`. These suites resolve `data.ts` and `@objectstack/rest` from source, so no `dist/` step was involved. | ablated | red | |---|---| | dispatcher `PATCH` gate line deleted | 8/26 in the new file: the 4 dispatcher `PATCH` rows and the 4 `PATCH` parity rows | | `RestServer` gate input back to `data ?? {}` | the `RestServer` `null` row and the `null` parity row (2/26), and the conformance `PATCH` row (1/16) | | dispatcher `versionTokenRefusal` number arm restored, alone | **4/65, only the four `[#23517]` parity rows (sentence differs)**. The raw-token request gate still refused the number `400` on the dispatcher, so the per-door rows stayed green. That is the second guard holding, and it is reported as observed. | | the number arm restored **and** the gate input back to `String(expectedVersion)` | 9/65: the 4 dispatcher `[#23517]` rows, the 4 parity rows, and the flipped `0` control | | dispatcher create gate line deleted | 6/26: the 3 dispatcher `POST` rows and the 3 `POST` parity rows | Not re-run: this branch is three commits behind `origin/main` (`252775d1d`). Those commits touch `packages/cli`, `packages/metadata-protocol` and `packages/spec` tests, nothing under `packages/rest` or `packages/runtime`, and `git merge-tree` is clean. CI runs the merge ref. ## Acceptance notes - **A sibling with the same gap, out of scope:** the dispatcher's `POST /data/:object/query` has no `FindDataRequestSchema` gate either. Measured on the same rig at this head: `{ limit: 'ten' }` and the retired `{ joins: [] }` answer `200` with the full set on the dispatcher, while `RestServer` refuses both `400 VALIDATION_FAILED` (`query.limit`, and the `joins` tombstone). It is reported to the seat as a finding and is not fixed here. - A numeric token against a legacy epoch-ms `updated_at`: the protocol's `canonicalVersionInstant` reads a `number` column as an instant, so I checked whether a number token could ever have matched. On better-sqlite3 the column reads back as ISO text, and `expectedVersion: 1700000000000` answered `409` before this change (now `400`). The string spelling `'1700000000000'` still answers `409`, as before. Other drivers were not measured. - Check order differs on an object whose API exposure denies the update: RestServer runs enforceApiAccess before its request gate (403), while the dispatcher runs its gate before callData, whose exposure check is inside (400). Before this PR the dispatcher answered that exposure refusal too, so this ordering is new on the dispatcher. Read in source, not measured. - *(This bullet replaces the PR's original last bullet, which the dev's report `os-dev-report` on #23491 marked wrong. It was written by the `domain:cli` seat 1 PM, session `session_01FLwgbTSmGMrqsXkBDsiwfG`, as a read-modify-write of the live body. The dev does not PATCH a PR body.)* --- _Generated by [Claude Code](https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 70d4c06 commit cac0be8

6 files changed

Lines changed: 542 additions & 40 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/rest': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
fix(rest,runtime)!: both `/data` write doors validate the request as the caller sent it: a `PATCH` or `POST` create whose body is not a record, and a `PATCH` whose `expectedVersion` is a number, are refused `400 VALIDATION_FAILED` on both doors before the engine is asked
7+
8+
`RestServer` validates `PATCH /data/:object/:id` against `UpdateDataRequestSchema` and `POST /data/:object` against `CreateDataRequestSchema`. The runtime dispatcher's `/data` domain, the only answer on a host that mounts just the `@objectstack/hono` catch-all, validated neither, so a body that is not a record reached the engine. On a `PATCH` it answered `500` with the engine's own error. On a `POST`, an array created one row per element and a number created an empty row, both answered `201`. `RestServer`'s own `PATCH` gate parsed a coerced copy of the request: it read a JSON `null` body as `{}`, and it read a numeric `expectedVersion` as its string. Both doors now validate the assembled request as the caller sent it, and they answer each case alike.
9+
10+
Clause-②: no
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of two HTTP routes on two doors: a write whose body is not the record the request schema declares, or whose body version token is a number where the schema declares a string, is now refused where it was applied or judged, and the remedy is in the request a caller sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
13+
14+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
15+
16+
What changes for you, wherever `@objectstack/rest` or the runtime dispatcher serves these routes:
17+
18+
- `RestServer`, `PATCH /data/:object/:id` with a JSON `null` body: FROM `200`, an update that changed no field and still moved the record's `updated_at` (so every other holder of the old version saw a conflict on its next guarded save), TO `400 VALIDATION_FAILED` naming `data`, the record unchanged. The runtime dispatcher answers the same.
19+
- The runtime dispatcher, `PATCH /data/:object/:id` with an array, a string, a number or `null` as the body: FROM `500` carrying the engine's own error text, nothing written, TO `400 VALIDATION_FAILED` naming `data`, before the engine is asked. `RestServer` already answered `400` for the first three.
20+
- The runtime dispatcher, `POST /data/:object` with an array, a string or a number as the body: FROM `201` with one row created per array element, `201` with an empty row for a number, and the engine's own `400 INVALID_FIELD` for a string, TO `400 VALIDATION_FAILED` naming `data`, with no row created. `RestServer` already answered this.
21+
- Both doors, `PATCH /data/:object/:id` with a body `expectedVersion` that is a number (`5`, `0`): FROM judged as its string (`'5'`) and answered `409 CONCURRENT_UPDATE`, TO `400 VALIDATION_FAILED` before the protocol is asked, whatever `If-Match` is sent beside it. The request schema declares the token a string, and a number is now one of the token shapes that can never carry a version.
22+
23+
How it renders: `RestServer` answers `{ error, code: 'VALIDATION_FAILED', fields, object }`, the envelope its request gate already used. The runtime dispatcher returns its own `400 VALIDATION_FAILED` envelope, `fields` under `error.details`, instead of throwing, so a `@objectstack/hono` `createHonoApp` host renders it as `400`.
24+
25+
Unchanged: a record body, `{}` included; a string `expectedVersion` (judged; `409` on a mismatch); `If-Match` and the `DELETE`'s `?expectedVersion`, which are strings by transport; and a JSON `null` body on a `POST` create, which both doors still read as an empty record and answer `201`.
26+
27+
To send a patch or a row, send the fields as a JSON object. To guard a write, send the version you read (the record's `updated_at`) as a string. The `@objectstack/client` SDK's `data.update` sends the version as an `If-Match` header string, so an SDK caller is affected only when the `data` it passes is not an object, or carries a number `expectedVersion`.

‎packages/rest/src/request-schema-gate.conformance.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ const CASES: GateCase[] = [
131131
{
132132
route: 'PATCH /api/v1/data/:object/:id',
133133
params: PARAMS_ID,
134-
violating: ['garbage', 42, [{ name: 'x' }]],
134+
// #23491 — `null` was read as an empty patch (`data ?? {}`) and answered
135+
// 200, an update that changed no field and still moved the version.
136+
violating: ['garbage', 42, [{ name: 'x' }], null],
135137
valid: { name: 'ACME (renamed)' },
136138
spy: 'updateData',
137139
},

‎packages/rest/src/rest-server.ts‎

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,17 +1743,25 @@ function sendMetaItemAbsent(res: any): void {
17431743
* - ABSENT: the spelling consulted is `undefined` or `null`. The body key or
17441744
* the query name falls through to `If-Match`, and no `If-Match` either is
17451745
* the legal unguarded write.
1746-
* - JUDGED: a string with something in it, or a finite number. The door
1747-
* forwards its string, and the protocol compares it (`409
1748-
* CONCURRENT_UPDATE` on a mismatch; the quoted-empty tag `""` is the
1749-
* protocol's own `400`, `MalformedVersionTokenError`).
1746+
* - JUDGED: a string with something in it. The door forwards it, and the
1747+
* protocol compares it (`409 CONCURRENT_UPDATE` on a mismatch; the
1748+
* quoted-empty tag `""` is the protocol's own `400`,
1749+
* `MalformedVersionTokenError`).
17501750
* - REFUSED `400 VALIDATION_FAILED`: anything else the caller put in a token
1751-
* position — an empty or blank string, a boolean, an array, an object. It
1752-
* can never carry a version, so it is the protocol's ruled answer to `""`
1753-
* (maintainer decision #20 ①: a token shape that can never carry a version
1754-
* is refused, neither read as "no token" nor answered `409`), given here
1755-
* because the protocol never sees these: it reads a blank string as no
1756-
* token.
1751+
* position — an empty or blank string, a number, a boolean, an array, an
1752+
* object. It can never carry a version, so it is the protocol's ruled
1753+
* answer to `""` (maintainer decision #20 ①: a token shape that can never
1754+
* carry a version is refused, neither read as "no token" nor answered
1755+
* `409`), given here because the protocol never sees these: it reads a
1756+
* blank string as no token.
1757+
*
1758+
* [#23517] A number is in the refused set, no longer judged as its string.
1759+
* `UpdateDataRequestSchema.expectedVersion` declares a string, and the door
1760+
* forwarded `String(5)`, so the request gate below only ever parsed the
1761+
* coerced copy and a token the contract never admits was answered `409` as if
1762+
* it were a stale version. Only a body token can be a number: `If-Match` and
1763+
* `?expectedVersion` are strings by transport, so the `DELETE` and every
1764+
* header token are unaffected.
17571765
*
17581766
* What it replaces is a truthiness test. The door forwarded a token only when
17591767
* it was truthy, and `bodyVersion ?? If-Match` reads `If-Match` only when the
@@ -1779,14 +1787,13 @@ function sendMetaItemAbsent(res: any): void {
17791787
function versionTokenRefusal(token: unknown, spelling: string): string | undefined {
17801788
if (token === undefined || token === null) return undefined;
17811789
if (typeof token === 'string' && token.trim() !== '') return undefined;
1782-
if (typeof token === 'number' && Number.isFinite(token)) return undefined;
17831790
const shape = typeof token === 'string' ? 'empty'
17841791
: Array.isArray(token) ? 'an array'
17851792
: typeof token === 'object' ? 'an object'
17861793
: `the ${typeof token} \`${String(token)}\``;
17871794
return `${spelling} is ${shape}, which can never carry a version, so it is refused rather than read as `
1788-
+ 'no token. Send the version you read (the record\'s `updated_at`), or omit both expectedVersion and '
1789-
+ 'If-Match to perform an unguarded write.';
1795+
+ 'no token. Send the version you read as a string (the record\'s `updated_at`), or omit both '
1796+
+ 'expectedVersion and If-Match to perform an unguarded write.';
17901797
}
17911798

17921799
/**
@@ -9173,12 +9180,29 @@ export class RestServer {
91739180
// validated as the assembled `UpdateDataRequestSchema`
91749181
// request so a non-record body 400s instead of reaching
91759182
// the engine.
9183+
//
9184+
// [#23491] …the patch AS THE CALLER SENT IT. It was
9185+
// `data ?? {}`, so a JSON `null` body parsed as an empty
9186+
// patch and answered `200`: an update that changed no
9187+
// field and still moved the record's version. `null` is
9188+
// not the record the contract declares, so it is refused
9189+
// like an array, a string or a number, and the runtime
9190+
// dispatcher's twin (`domains/data.ts`) asks this gate
9191+
// over the same assembled request.
9192+
//
9193+
// [#23517] …and the token as the caller sent it, never
9194+
// its `String()`: this gate parsed `String(5)`, so a
9195+
// number `expectedVersion: z.string()` declares no room
9196+
// for was forwarded and judged `409`.
9197+
// `versionTokenRefusal` above already refuses it, so what
9198+
// reaches this gate and the forward below is a non-blank
9199+
// string.
91769200
const { UpdateDataRequestSchema } = await import('@objectstack/spec/api');
91779201
const updateInput = {
91789202
object: req.params.object,
91799203
id: req.params.id,
9180-
data: data ?? {},
9181-
...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}),
9204+
data,
9205+
...(expectedVersion != null ? { expectedVersion } : {}),
91829206
};
91839207
const parsedUpdateOne = (UpdateDataRequestSchema as any).safeParse(updateInput);
91849208
if (!parsedUpdateOne.success) {
@@ -9193,8 +9217,8 @@ export class RestServer {
91939217
const updateRequest: ServerScopedDataRequest<UpdateDataRequest> = {
91949218
object: req.params.object,
91959219
id: req.params.id,
9196-
data: data ?? {},
9197-
...(expectedVersion != null ? { expectedVersion: String(expectedVersion) } : {}),
9220+
data,
9221+
...(expectedVersion != null ? { expectedVersion } : {}),
91989222
...(environmentId ? { environmentId } : {}),
91999223
...(context ? { context } : {}),
92009224
};

‎packages/runtime/src/domains/data-update-expected-version.test.ts‎

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,12 +294,12 @@ for (const [doorName, boot] of DOORS) {
294294
* row. What either door does with them is `RestServer`'s reading, not this
295295
* file's: a token is the body key when it is not nullish (else `If-Match`),
296296
* the key is stripped from the patch whatever its value, and a token is
297-
* forwarded as its string unless it can never carry a version, which is
298-
* refused (#23492, pinned in its own block below).
297+
* forwarded unless it can never carry a version, which is refused (#23492,
298+
* pinned in its own block below; a number among them since #23517, pinned in
299+
* the block after it).
299300
*/
300301
const EDGE_SHAPES: ReadonlyArray<readonly [string, (version: string) => PatchOptions]> = [
301302
['a stale token', () => ({ body: { name: AFTER, expectedVersion: STALE } })],
302-
['a number token', () => ({ body: { name: AFTER, expectedVersion: 20000101 } })],
303303
['a null body token beside a stale If-Match', () => ({ body: { name: AFTER, expectedVersion: null }, headers: { 'If-Match': STALE } })],
304304
['a null body token beside the current If-Match', (v) => ({ body: { name: AFTER, expectedVersion: null }, headers: { 'If-Match': v } })],
305305
['an empty-string body token beside a stale If-Match', () => ({ body: { name: AFTER, expectedVersion: '' }, headers: { 'If-Match': STALE } })],
@@ -346,7 +346,8 @@ describe('[#23462] the two doors answer each token shape with the same status, c
346346
* position that can never carry a version is refused with the protocol's own
347347
* answer to the quoted-empty tag `""`, `400 VALIDATION_FAILED`, before
348348
* `updateData` is asked; maintainer decision #20 ① rules that shape refused,
349-
* neither read as "no token" nor answered `409`.
349+
* neither read as "no token" nor answered `409`. A number joined this set
350+
* with #23517 and has its own block below.
350351
*/
351352
const UNUSABLE: ReadonlyArray<readonly [string, PatchOptions]> = [
352353
["a body expectedVersion of '' beside a stale If-Match", { body: { name: AFTER, expectedVersion: '' }, headers: { 'If-Match': STALE } }],
@@ -391,10 +392,13 @@ for (const [doorName, boot] of DOORS) {
391392
expect(await door.name()).toBe(BEFORE);
392393
}, 60_000);
393394

394-
it('control: a body token of 0 is a number, judged as "0" (refused 409), never dropped', async () => {
395+
it('control: a body token of 0 is never dropped: it is refused 400, so the current If-Match beside it is not read', async () => {
396+
// [#23517] It was judged as '0' (409) until a number joined the
397+
// refused set. Either way it is not read as "no token": had it
398+
// been dropped, the current If-Match would have applied the write.
395399
const door = await boot();
396-
conflict(await door.patch({ body: { name: AFTER, expectedVersion: 0 }, headers: { 'If-Match': door.version } }));
397-
expect(door.forwarded()).toEqual([{ expectedVersion: '0', data: { name: AFTER } }]);
400+
refused(await door.patch({ body: { name: AFTER, expectedVersion: 0 }, headers: { 'If-Match': door.version } }));
401+
expect(door.forwarded()).toEqual([]);
398402
expect(await door.name()).toBe(BEFORE);
399403
}, 60_000);
400404
});
@@ -411,6 +415,71 @@ describe('[#23492] the two doors refuse each token that can never carry a versio
411415
}
412416
});
413417

418+
// ── A number token ────────────────────────────────────────────────────────────
419+
420+
/**
421+
* [#23517] `UpdateDataRequestSchema.expectedVersion` declares a string. Both
422+
* doors forwarded a number as `String(token)`, so `RestServer`'s request gate
423+
* only ever parsed the coerced copy and the dispatcher had no gate: a body
424+
* `expectedVersion: 5` was judged as `'5'` and answered `409
425+
* CONCURRENT_UPDATE` on both doors (measured on this rig before the fix,
426+
* beside a stale and beside the current `If-Match`; the row unchanged). Now a
427+
* number is refused `400 VALIDATION_FAILED` before `updateData` is asked, the
428+
* answer maintainer decision #20 ① gives a token shape that can never carry a
429+
* version. The `If-Match` beside it is never read: the body key is the token
430+
* whenever it is not nullish.
431+
*/
432+
const NUMBER_TOKENS: ReadonlyArray<readonly [string, number]> = [['5', 5], ['0', 0]];
433+
const IF_MATCH_BESIDE: ReadonlyArray<readonly [string, (version: string) => string]> = [
434+
['a stale If-Match', () => STALE],
435+
['the current If-Match', (version) => version],
436+
];
437+
438+
for (const [doorName, boot] of DOORS) {
439+
describe(`[#23517] ${doorName}: PATCH /data/:object/:id refuses a number expectedVersion`, () => {
440+
for (const [tokenName, token] of NUMBER_TOKENS) {
441+
for (const [besideName, ifMatch] of IF_MATCH_BESIDE) {
442+
it(`expectedVersion: ${tokenName} beside ${besideName}: 400 VALIDATION_FAILED, updateData never asked, the row unchanged`, async () => {
443+
const door = await boot();
444+
refused(await door.patch({ body: { name: AFTER, expectedVersion: token }, headers: { 'If-Match': ifMatch(door.version) } }));
445+
expect(door.forwarded()).toEqual([]);
446+
expect(await door.name()).toBe(BEFORE);
447+
}, 60_000);
448+
}
449+
}
450+
451+
it('control: the same token as a string is judged, not refused (409 against a stale version)', async () => {
452+
const door = await boot();
453+
conflict(await door.patch({ body: { name: AFTER, expectedVersion: '5' }, headers: { 'If-Match': door.version } }));
454+
expect(door.forwarded()).toEqual([{ expectedVersion: '5', data: { name: AFTER } }]);
455+
expect(await door.name()).toBe(BEFORE);
456+
}, 60_000);
457+
458+
it('control: If-Match alone is a string by transport, so the current one still updates', async () => {
459+
const door = await boot();
460+
updated(await door.patch({ body: { name: AFTER }, headers: { 'If-Match': door.version } }));
461+
expect(door.forwarded()).toEqual([{ expectedVersion: door.version, data: { name: AFTER } }]);
462+
expect(await door.name()).toBe(AFTER);
463+
}, 60_000);
464+
});
465+
}
466+
467+
describe('[#23517] the two doors refuse a number expectedVersion alike', () => {
468+
for (const [tokenName, token] of NUMBER_TOKENS) {
469+
for (const [besideName, ifMatch] of IF_MATCH_BESIDE) {
470+
it(`expectedVersion: ${tokenName} beside ${besideName}: the same status, code and sentence`, async () => {
471+
const answers: Answer[] = [];
472+
for (const [, boot] of DOORS) {
473+
const door = await boot();
474+
answers.push(await door.patch({ body: { name: AFTER, expectedVersion: token }, headers: { 'If-Match': ifMatch(door.version) } }));
475+
}
476+
const [rest, catchAllAnswer] = answers;
477+
expect(catchAllAnswer).toEqual(rest);
478+
}, 60_000);
479+
}
480+
}
481+
});
482+
414483
// ── The dispatcher's fallback updater ─────────────────────────────────────────
415484

416485
/**

0 commit comments

Comments
 (0)