Skip to content

Commit f95d0db

Browse files
fix(metadata-protocol): the by-id OCC comparison answers only for a row the caller reads (#28731)
domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:按 id 更新/删除时的版本令牌(If-Match / expectedVersion)比对,只对调用者读得到的记录给出 409;调用者回读"返回无行"时,比对不再自己作答,交给写入门,得到与不带令牌时相同的答复。回读抛出(被拒或故障)一律原样抛出,与本 PR 之前相同。 - 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开) - 风险与回滚:风险低。只改比对分支在"回读返回无行"时的出口;读得到的行仍是 409 带 currentRecord(含能读不能写的行);回读被拒或故障仍原样抛出。回滚 = revert 本 PR。 - 先看:`packages/metadata-protocol/src/protocol-record-reads.ts`(assertVersionOf 的无行交接);`packages/metadata-protocol/src/protocol.record-not-found.test.ts`(有写无读一类的钉子);`packages/runtime/src/domains/data-conflict-caller-visibility.test.ts`(同一类在真实安全层两扇门上的钉子) - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.record-not-found.test.ts`,预期 30 个用例全绿。 - 需要你做的:无,席位落地 ## Route (i) Contract review 6561342764 asked for a ruling between two routes; the maintainer ruled route (i): 「#28731 同意你的推荐」. Only a row the caller's re-read **returns no row for** is handed to the write door; a re-read that throws — a refusal or a fault — propagates as raised, as before this PR. ## What changes - **`assertVersionOf`** (`packages/metadata-protocol/src/protocol-record-reads.ts`, the comparison half of the by-id OCC gate that `updateData` and `deleteData` run before the write is authorized). On a token mismatch it re-reads the row as the caller, as before: - the read returns no row → the comparison gives no answer of its own; the request goes on to `engine.update` / `engine.delete`, whose door answers it — the answer the same request gets without a token, and the answer a token on an id naming no row gets; - the read returns a row → unchanged: `409 CONCURRENT_UPDATE` with that row as `currentRecord`, field-masked as the caller reads it, including a row the caller reads but may not write (option a′ ruled out); - the read throws → unchanged: propagates as raised. - **Governing text restored for the absent row:** ruling of record 5985885287 ("a row the caller cannot read answers exactly what a nonexistent id answers, on update and delete alike"), and the `assertVersionOf` docblock's own "the stale token then gets exactly the answer the same request without a token gets". - **Docblocks** in `protocol.ts` (`ConcurrentUpdateError`, `updateData`, `probeRecord`, `assertVersionOf`) and the record-reads module header describe the absent-row handover only, and say a thrown read propagates. The changeset does the same. **Docs:** one paragraph in the http-protocol page's refusal section: a stale token is answered `409` only for a record the caller can read; for a record absent from the caller's read, and for an id naming none, it changes none of the answers; a caller refused reading the object gets that read refusal. - **Bulk faces checked, nothing to align:** `updateManyData` and `batchData`'s `update` arm carry no version token. - **Clause-② is `no`, patch:** a refusal swap on existing doors, the same classification as #28615. **Metadata-protocol: none.** Changeset: `@objectstack/metadata-protocol` patch. ## Tests - **Protocol** (`protocol.record-not-found.test.ts`, its existing pinned engine double, caller visibility added; no ledger change), per verb: a hidden row with the write refused; a hidden row with the write admitted up to the write door's read question; a read refused while the write is refused too (status and code compared, see below); **a principal with a write grant and no read grant — the read refuses, the write door would admit the write: the stale token propagates the read refusal, the write is never asked and the row is unchanged, while the same double admits the no-token write (the control)**; a read that faults or throws any other 4xx propagates; the readable-row `409` control. - **Runtime, real security layer** (`data-conflict-caller-visibility.test.ts`), per door (RestServer, dispatcher) and verb: an object the caller reads but may not write, with an RLS-hidden row (stale token = the control's `403` body; unknown id the same; a readable row still `409`); **an object the caller may update and delete but not read, with no row rule: a stale token answers `403 PERMISSION_DENIED` and the row is unchanged, on update and on delete; the no-token request lands (`200`), so the class is real.** - **Showcase, single install** (`update-by-id-refusal-order.dogfood.test.ts`): with the token in `If-Match`, the PATCH body's `expectedVersion` and the DELETE's `?expectedVersion`, the two private-OWD rows the member's row scope hides compare status, code **and sentence** for the row, for no row and without a token. On the two objects the member may neither read nor write, its read is refused and propagates, so those families compare **status and code only** and deliberately do not compare the sentence: the read door's refusal and the write door's may word an operation-naming refusal differently. That sentence-level residue is accepted and handled by a separate D12 sentence-alignment item. Controls as before (edit-grant holder `409` carrying the row and `200` with the current token; the plain member's `409` on a row it reads but may not write). - **Showcase behind the organization wall** (`occ-stale-token-organization-wall.dogfood.test.ts`): unchanged; another organization's row is absent from the member's read, so every carrier answers what no token answers. - **Measured set for the showcase:** the four dogfood files that send a version token (declared narrowing, as before: the changed branch is reachable only through a by-id write carrying a non-empty token). The write-grant-without-read-grant class has no showcase principal; it is pinned at the protocol double and at the runtime's two real doors. ## Acceptance notes - **Accepted consequence (per ruling 5985885287):** for a caller the write door refuses first, a mismatched token on a row absent from its read now gets that refusal, as without a token; a caller who may write the object still gets `404 RECORD_NOT_FOUND`, now produced by the write door. - **Accepted residue (route (i)):** a caller with neither the read nor the write grant gets the read door's refusal on an existing row and the write door's on an id naming none — the same status and code (`403 PERMISSION_DENIED`); an operation-naming sentence may differ. Handled by a separate D12 sentence-alignment item; the pin for that case asserts status and code only. - **Observation, not filed (carrier: none):** `@objectstack/rest`'s built `dist/index.js` carries bundled `metadata-protocol` source, so the changed bytes ship in rest's tarball too; rest's own non-test source neither imports that package nor constructs the protocol class. ## Verification Head `8372e0a15` (route (i), on top of a merge of `origin/main` at `c2a5be03b`, merge commit `357b2bac2`). Exit codes captured before any pipe; one locked battery, `os-verify-lock: VERDICT command-exit 0` (held 5078s, shared box). <details> <summary>Build, pins, full suites and typechecks — all green</summary> - `pnpm --workspace-concurrency=2 --filter '@objectstack/dogfood^...' --filter '@objectstack/runtime^...' --filter '@objectstack/runtime' build` — exit 0 - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=1 src/protocol.record-not-found.test.ts` — Tests 30 passed (30), exit 0 - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=1 src/domains/data-conflict-caller-visibility.test.ts` — Tests 24 passed (24), exit 0 - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1` over the four token-carrying files (`update-by-id-refusal-order`, `occ-stale-token-organization-wall`, `data-request-shape-live`, `object-designer-field-reorder`) — Test Files 4 passed (4), Tests 39 passed (39), exit 0 - metadata-protocol `vitest run --maxWorkers=1` — Test Files 301 passed, 4 skipped (305); Tests 22139 passed, 26 skipped (22165); exit 0. `typecheck` exit 0. - runtime `vitest run --project local --maxWorkers=1` — Test Files 515 passed (515); Tests 8343 passed, 19 skipped (8362); exit 0. `typecheck` exit 0. - dogfood `typecheck` — exit 0 </details> <details> <summary>One-shot ablation of the reviewed head's refusal hand-over, restore proven</summary> Mutation through `scripts/ablation-replace.mjs` (wrap mode) plus an EXIT/INT/TERM trap on the absolute path: the reviewed head's catch re-instated (a declared client-band read refusal handed to the write door). A first attempt inside the battery was void — its tree reading was given the dist spelling of the marker, refused with exit 3, and no test ran under the mutation; it is reported here, not counted. The valid run (`VERDICT command-exit 0`, held 169s): - landed: anchor 1 to 0, blob `78150561999f` to `aa7a51f44c28`; injected text 1, removed text 0 on disk; rebuild exit 0; `ablation-dist-preflight --source-marker`: marker present in 2 built files - protocol pins: 6 failed, 24 passed — per verb, the write-grant-without-read-grant case, the both-refused case, and the any-other-4xx propagation case - runtime pins: 4 failed, 20 passed — the write-only object, both doors, both verbs - showcase pins: 21 passed — expected: no showcase principal is in the class, and the refused-read families compare status and code, which both versions share Restore: `git checkout HEAD --` on the absolute path; blob equals the HEAD blob `78150561999f`, `git diff HEAD` empty, working tree clean; rebuild exit 0; `ablation-dist-preflight --absent --source-marker`: marker absent. Restored runs: 30/30, 24/24, 21/21. </details> <details> <summary>Derived gate families — 92 derived, 90 exit 0, 2 NOT MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `8372e0a15` (8 paths vs merge base `c2a5be03b`): 92 commands, each run under the lock and recorded with its exit code; `--ran`: "92 derived famil(ies) accounted for — 90 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)". NOT MEASURED (PREREQUISITE NOT MET, packages outside this build closure unbuilt): `pnpm --filter @objectstack/spec run check:skill-examples`, `pnpm check:dual-build-cjs-loads`. `pnpm check:type-check-debt` ran this time, exit 0. CI at `8372e0a15`: Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard, Test UI, Live E2E UI, Check Changeset — success; Lint UI, Type Check UI, Build & E2E UI — skipped. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a644163 commit f95d0db

9 files changed

Lines changed: 652 additions & 39 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the by-id OCC comparison hands a row absent from the caller's read to the write door
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`updateData` and `deleteData` compare a caller's version token (`If-Match`, or `expectedVersion`) before the write is authorized. On a mismatch they re-read the row as the caller. When that read returns no row, the comparison is now dropped and the request goes on to the write, whose door answers it: the answer the request gets without a token, and the answer a token on an id naming no row gets. This restores the governing text for the write doors (ruling of record 5985885287).
11+
12+
- **Unchanged:** a row the caller reads still answers `409 CONCURRENT_UPDATE` with that row as `currentRecord`, including a row the caller reads but may not write. A caller read that throws — a refusal or a fault — propagates as raised, as before. A matching token, or none, reads nothing more than before.
13+
- **The write door's answers for a row absent from the caller's read:** a caller who may not update or delete the object gets that refusal (`403`); a caller who may write the object gets `404 RECORD_NOT_FOUND`, as for an id naming no row.

‎content/docs/protocol/kernel/http-protocol.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,12 @@ each row of `updateMany` and of `batch` with `operation: "update"`. A caller who
753753
update the object gets `404 RECORD_NOT_FOUND` for an id that names no record, and for
754754
a record its row-level access hides.
755755

756+
A version token (`If-Match`, or `expectedVersion`) is compared only against a record the
757+
caller can read: a stale token on such a record is answered `409 CONCURRENT_UPDATE`,
758+
whether or not the caller may write it. For a record the caller's read does not return,
759+
and for an id that names no record, a stale token changes none of the answers above. A
760+
caller refused reading the object gets that read refusal.
761+
756762
### Delete Record
757763

758764
Delete a record by ID.

‎packages/metadata-protocol/src/protocol-record-reads.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
* re-indented one level and otherwise unedited: the same reads, made as the
2222
* same principal, refusing exactly where they did with the same codes and
2323
* messages. The module-level declarations only these bodies read (the OCC
24-
* version-token reading) moved with them, also unedited.
24+
* version-token reading) moved with them, also unedited. One body has changed
25+
* since: `assertVersionOf`'s mismatch branch now hands a row the caller's own
26+
* read returns no row for to the write door instead of answering it (its
27+
* docblock in `protocol.ts` says why); a read that throws still propagates.
2528
*
2629
* ## How the moved bodies reach the class
2730
*
@@ -227,8 +230,14 @@ export async function assertVersionOf(
227230
// it names, turning a resolvable conflict into an unresolvable one.
228231
const currentVersion = currentToken.instant ?? currentToken.token;
229232
const expectedVersionText = expected.instant ?? expected.token;
233+
// A row the caller's own read RETURNS NO ROW for is no conflict this
234+
// gate may report, and no answer of its own either: the comparison is
235+
// dropped and the write door answers, as it answers the same request
236+
// without a token. ONLY an absent row: a read that throws — a refusal
237+
// or a fault — propagates as raised. The method's docblock in
238+
// `protocol.ts` carries the reasoning.
230239
const visible = await this.readAsCaller(object, id, context);
231-
if (!visible) throw recordNotFoundError(object, id);
240+
if (!visible) return;
232241
throw new ConcurrentUpdateError({
233242
currentVersion,
234243
currentRecord: visible,

‎packages/metadata-protocol/src/protocol-version-tokens.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
//
66
// A MOVE, not a redesign: every declaration below is byte-identical to the text that stood
77
// above `ObjectStackProtocolImplementation`; only the imports and the export list at the foot
8-
// are new. `protocol.ts` re-exports what it exported.
8+
// are new. `protocol.ts` re-exports what it exported. One docblock has changed since the move:
9+
// `ConcurrentUpdateError`'s sentence on a row the caller does not read (see `assertVersionOf`).
910

1011
/**
1112
* Simple hash function for ETag generation (browser-compatible)
@@ -29,8 +30,10 @@ function simpleHash(str: string): string {
2930
* and includes both the current server-side version and the current record
3031
* payload so the client can render an informed conflict-resolution UI
3132
* ("Reload latest" vs. "Overwrite anyway"). The record is the row as the
32-
* caller reads it, and a row the caller cannot read is never a conflict: it
33-
* answers not-found instead (#23586, see `assertVersionOf`).
33+
* caller reads it, and a row the caller's read returns no row for is never a
34+
* conflict: the write door answers it, as it answers the same request without
35+
* a token; a caller read that is refused propagates as that refusal (#23586,
36+
* see `assertVersionOf`).
3437
*
3538
* NOTE: This is an *application-level* compare-and-set — not an atomic
3639
* storage-layer CAS. There is a small TOCTOU window between the version

‎packages/metadata-protocol/src/protocol.record-not-found.test.ts‎

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import { describe, it, expect, vi } from 'vitest';
2424
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
25+
import { recordNotFoundError } from '@objectstack/core';
2526
import { ObjectStackProtocolImplementation } from './protocol.js';
2627

2728
const SCHEMA = { name: 'task', fields: { title: { name: 'title', type: 'text' } } };
@@ -43,18 +44,40 @@ function writeRefusal(): Error {
4344
* the write is judged before the row is looked up. `leavesScope` names rows a
4445
* landed update moves out of the caller's row scope, so their readback is
4546
* `null` while the row is still there.
47+
*
48+
* `hidden` names rows a CALLER's read does not return (the system's read
49+
* still does), and a by-id write of one is answered the way the security
50+
* layer answers it: the read question is asked ahead of the write, and a row
51+
* the caller cannot read gets what an id naming no row gets. `readRefusal`
52+
* makes every caller read throw instead — a refusal, or a fault.
4653
*/
4754
function makeProtocol(
4855
rows: Record<string, any> = {},
49-
opts: { refuse?: string; leavesScope?: ReadonlySet<string> } = {},
56+
opts: {
57+
refuse?: string;
58+
leavesScope?: ReadonlySet<string>;
59+
hidden?: ReadonlySet<string>;
60+
readRefusal?: () => Error;
61+
} = {},
5062
) {
5163
const store = new Map<string, any>(Object.entries(rows));
5264
const refused = (o: any) => opts.refuse !== undefined && o?.context?.userId === opts.refuse;
53-
const findOne = vi.fn(async (_object: string, o: any) => { assertEngineFindOnePredicate(_object, o); return store.get(String(o?.where?.id)) ?? null; });
65+
/** A read or write made as a caller rather than as the system. */
66+
const asCaller = (o: any) => o?.context?.isSystem !== true;
67+
const findOne = vi.fn(async (_object: string, o: any) => {
68+
assertEngineFindOnePredicate(_object, o);
69+
const id = String(o?.where?.id);
70+
if (asCaller(o)) {
71+
if (opts.readRefusal) throw opts.readRefusal();
72+
if (opts.hidden?.has(id)) return null;
73+
}
74+
return store.get(id) ?? null;
75+
});
5476
const update = vi.fn(async (_object: string, data: any, o: any) => {
5577
assertEngineUpdateDispatch(data, o);
5678
if (refused(o)) throw writeRefusal();
5779
const id = String(o?.where?.id);
80+
if (asCaller(o) && opts.hidden?.has(id)) throw recordNotFoundError('task', id);
5881
if (!store.has(id)) return null;
5982
const next = { ...store.get(id), ...data };
6083
store.set(id, next);
@@ -63,7 +86,9 @@ function makeProtocol(
6386
const del = vi.fn(async (_object: string, o: any) => {
6487
assertEngineDeleteDispatch(o);
6588
if (refused(o)) throw writeRefusal();
66-
return store.delete(String(o?.where?.id));
89+
const id = String(o?.where?.id);
90+
if (asCaller(o) && opts.hidden?.has(id)) throw recordNotFoundError('task', id);
91+
return store.delete(id);
6792
});
6893
const engine = {
6994
registry: { getObject: (n: string) => (n === 'task' ? SCHEMA : undefined) },
@@ -358,3 +383,113 @@ describe('[#4435] deleteManyData reports per id, not per request', () => {
358383
expect(res.succeeded + res.failed).toBe(res.total);
359384
});
360385
});
386+
387+
describe('a stale version token on a row absent from the caller\'s read gets the write door\'s answer, as no token does', () => {
388+
// The by-id OCC comparison runs before the write is authorized. When the
389+
// tokens disagree it re-reads the row as the caller, and a row that read
390+
// RETURNS NO ROW for leaves the answer to the write door: what the same
391+
// request without a token gets, status, code and sentence, which is also
392+
// what a token on an id naming no row gets (ruling of record 5985885287: a
393+
// row the caller cannot read answers what a nonexistent id answers, on
394+
// update and delete alike). Only an absent row is handed over: a caller
395+
// read that THROWS — a refusal or a fault — propagates as raised.
396+
const CURRENT = '2026-10-30T08:00:00.000Z';
397+
const STALE = '2001-01-01T00:00:00.000Z';
398+
const CALLER = { userId: 'u1' };
399+
const MISSING = 'definitely_not_a_row';
400+
const row = (id: string) => ({ id, title: 'x', updated_at: CURRENT });
401+
/** The read door's refusal of a caller without the object's read grant. */
402+
const readRefusalError = () =>
403+
Object.assign(new Error('Reading this object is not permitted.'), { code: 'PERMISSION_DENIED', status: 403 });
404+
405+
/** A refused call as the doors render it: status, code and sentence. */
406+
const answerOf = (run: () => Promise<unknown>) => run().then(
407+
() => { throw new Error('expected a refusal, but the call resolved'); },
408+
(e: any) => ({ code: e.code, status: e.status, message: e.message }),
409+
);
410+
/** The answer with the id its sentence names written out, so the answers for two ids compare. */
411+
const withoutId = (a: { message: unknown }, id: string) => ({ ...a, message: String(a.message).split(id).join('ID') });
412+
413+
for (const verb of ['updateData', 'deleteData'] as const) {
414+
const write = (p: any, id: string, expectedVersion?: string) => verb === 'updateData'
415+
? p.updateData({ object: 'task', id, data: { title: 'y' }, context: CALLER, ...(expectedVersion ? { expectedVersion } : {}) })
416+
: p.deleteData({ object: 'task', id, context: CALLER, ...(expectedVersion ? { expectedVersion } : {}) });
417+
418+
describe(verb, () => {
419+
it('a hidden row, the write refused: the write refusal answers, for the row and for no row, token or none', async () => {
420+
const { p, store } = makeProtocol({ real: row('real') }, { refuse: 'u1', hidden: new Set(['real']) });
421+
const stale = await answerOf(() => write(p, 'real', STALE));
422+
const noToken = await answerOf(() => write(p, 'real'));
423+
const missing = await answerOf(() => write(p, MISSING, STALE));
424+
expect(stale).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
425+
expect(stale).toEqual(noToken);
426+
expect(withoutId(missing, MISSING)).toEqual(withoutId(stale, 'real'));
427+
expect(store.get('real')).toEqual(row('real'));
428+
});
429+
430+
it('the caller\'s read REFUSES and the write is refused too: the read refusal propagates, with the write\'s status and code', async () => {
431+
// A caller holding neither grant. The refused read is the answer, so its
432+
// sentence is the read door's: status and code are compared here, the
433+
// sentence deliberately is not.
434+
const { p, store } = makeProtocol({ real: row('real') }, { refuse: 'u1', readRefusal: readRefusalError });
435+
const stale = await answerOf(() => write(p, 'real', STALE));
436+
const noToken = await answerOf(() => write(p, 'real'));
437+
const missing = await answerOf(() => write(p, MISSING, STALE));
438+
expect(stale).toEqual({ code: 'PERMISSION_DENIED', status: 403, message: readRefusalError().message });
439+
expect({ code: noToken.code, status: noToken.status }).toEqual({ code: stale.code, status: stale.status });
440+
expect({ code: missing.code, status: missing.status }).toEqual({ code: stale.code, status: stale.status });
441+
expect(store.get('real')).toEqual(row('real'));
442+
});
443+
444+
it('the caller\'s read REFUSES and the write door admits the write (a write grant without a read grant): the read refusal propagates and nothing is written', async () => {
445+
const { p, store, update, del } = makeProtocol({ real: row('real') }, { readRefusal: readRefusalError });
446+
await expect(write(p, 'real', STALE)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
447+
expect(update).not.toHaveBeenCalled();
448+
expect(del).not.toHaveBeenCalled();
449+
expect(store.get('real')).toEqual(row('real'));
450+
// Control: without a token the same double admits the same write, so the
451+
// pin above is not vacuous.
452+
await write(p, 'real');
453+
if (verb === 'updateData') expect(store.get('real')).toMatchObject({ title: 'y' });
454+
else expect(store.has('real')).toBe(false);
455+
});
456+
457+
it('a hidden row, the write admitted up to its read question: the write door\'s 404, for the row and for no row alike', async () => {
458+
const { p, store, update, del } = makeProtocol({ real: row('real') }, { hidden: new Set(['real']) });
459+
const stale = await answerOf(() => write(p, 'real', STALE));
460+
expect(stale).toMatchObject({ code: 'RECORD_NOT_FOUND', status: 404 });
461+
// The write was asked: the 404 is the write door's, not one of the comparison's own.
462+
expect((verb === 'updateData' ? update : del)).toHaveBeenCalledTimes(1);
463+
expect(await answerOf(() => write(p, 'real'))).toEqual(stale);
464+
expect(withoutId(await answerOf(() => write(p, MISSING, STALE)), MISSING)).toEqual(withoutId(stale, 'real'));
465+
expect(store.get('real')).toEqual(row('real'));
466+
});
467+
468+
it('a read that FAULTS, or throws any other 4xx, propagates as raised, and the write is never asked', async () => {
469+
for (const fault of [
470+
new Error('connection reset'),
471+
Object.assign(new Error('store unavailable'), { code: 'SERVICE_UNAVAILABLE', status: 503 }),
472+
Object.assign(new Error('bad read'), { code: 'VALIDATION_FAILED', statusCode: 400 }),
473+
]) {
474+
const { p, update, del } = makeProtocol({ real: row('real') }, { readRefusal: () => fault });
475+
await expect(write(p, 'real', STALE)).rejects.toBe(fault);
476+
expect(update).not.toHaveBeenCalled();
477+
expect(del).not.toHaveBeenCalled();
478+
}
479+
});
480+
481+
it('control: a row the caller reads still conflicts — 409 with that row, and the write is never asked', async () => {
482+
const { p, store, update, del } = makeProtocol({ real: row('real') });
483+
await expect(write(p, 'real', STALE)).rejects.toMatchObject({
484+
code: 'CONCURRENT_UPDATE',
485+
status: 409,
486+
currentVersion: CURRENT,
487+
currentRecord: row('real'),
488+
});
489+
expect(update).not.toHaveBeenCalled();
490+
expect(del).not.toHaveBeenCalled();
491+
expect(store.get('real')).toEqual(row('real'));
492+
});
493+
});
494+
}
495+
});

0 commit comments

Comments
 (0)