Skip to content

Commit a251aaa

Browse files
huangyiireneclaude
andauthored
fix(metadata-protocol): a stopped or rolled-back bulk batch names the row that actually failed (#19700)
Fixes #19452 Clause-②: no A stopped or rolled-back bulk batch named a causal row that did not fail, and called the real error 「unknown error」 while that error was sitting in the same array. ## The defect `reconcileStoppedBatch` and `buildRolledBackBatchResponse` both located the causal row with `findIndex(r => !r.success)`. That encoded one invariant: **`!success` means this row failed, and it carries `errors[0]`.** PR #19432 broke that invariant deliberately and correctly: a row that MATCHED and was NOT removed now answers `success: false` with **no `errors` entry**, because a surviving record is an outcome, not a fault. So the locator could land on that survivor, `errors?.[0]?.message` was `undefined`, and the message named the **wrong index** while falling back to 「unknown error」. ## Reproduced before the fix, verbatim Taken by neutralising the fix on the final tree: `packages/metadata-protocol/src/protocol.ts` restored to the blob `origin/main` holds (`be9dd23ad9c865fbcc74ccf20bcc3f633f23845e`), proved on disk by blob hash, then the pins run. Received strings, copied out of the run: | run | row | code | message on the unfixed tree | who really ended the run | |:--|:--|:--|:--|:--| | `deleteMany ['t1'(survives), 'missing'(throws), 't3']` | 2 | `NOT_ATTEMPTED` | `record 0 failed — unknown error; the batch stopped there. Set options.continueOnError to process the remaining records.` | record **1** | | `batchData delete`, same three rows | 2 | `NOT_ATTEMPTED` | identical | record **1** | | `batchData atomic ['t3', 't1'(survives), 'missing'(throws), 't2']` | 0 | `ROLLED_BACK` | `record 1 failed — unknown error` | record **2** | | same run | 3 | `NOT_ATTEMPTED` | `atomic batch aborted by record 1` | record **2** | | `batchData atomic ['t1', 't2'(survives), 't3']` | 0 and 2 | `ROLLED_BACK` | `record 1 failed — unknown error` | nothing failed at all | ## The fix Both builders now call one shared locator, `locateBatchCause`, which finds the causal row by its recorded **fault**: the row's `errors[]` entry. **Why that discriminator cannot drift back the way the boolean did.** `success` is the envelope's outcome bit and its false arm is open by construction — it means "this row is not a success", so every new non-success ending widens it for free, which is exactly what happened. `errors` is not a second boolean: - its declared meaning is a failure. `BatchOperationResultSchema.errors` is documented as *"Array of errors if operation failed"*, and the v17 ADR-0087 migration entry publishes `row.errors?.[0]?.message` / `row.errors?.[0]?.code` to consumers as that read; - its contents are a **closed vocabulary**. Every entry must carry an `ApiError.code` from `StandardErrorCode` union `ERROR_CODE_LEDGER`; an unregistered code fails `BatchOperationResultSchema.parse`. Giving a non-fault ending an `errors[]` entry is therefore a ledger widening in `packages/spec` — which is precisely the step BOTH survivor sites declined to take, in writing, and the step that would have to be taken deliberately for this locator to start lying; - `ApiError.message` is **required**, so a located cause always has text. The 「unknown error」 fallback is **deleted**, not merely unreached: the string no longer appears in either message template. The scan runs from the END of the attempted rows, because a run ends AT the row it stops on — every stop is a `break` in a loop's `catch`, immediately after that row was pushed. A fault that does not stop the run (the `Unknown operation:` arm records one and keeps going) therefore cannot shadow the row that did. One ending has no fault to quote at all: an atomic batch aborted by a lone survivor, where `runAtomicBatch` rolls back on `failed > 0` and nothing ever threw. There the message names the row that did not succeed — `record 1 did not succeed` — instead of inventing a failure. That is the only place `!success` is still read, and it is read for the question that boolean does answer: "which row stopped this batch committing", never "which row failed". ## Acceptance **1. Located by a fault, not by `!success`** — above. **2. The negative case is pinned** — `packages/metadata-protocol/src/protocol.batch-causal-row.test.ts`, 8 tests. Its central assertion is an agreement between the message and the rows beside it, read out of the response rather than hard-coded: the message names `record N failed` for the N that carries an error, contains that row's own error text verbatim, and never contains the string `unknown error`. **3. Both builders** — one locator, two call sites; neither can be fixed apart from the other. **4. All three bulk faces:** | face | arm | covered how | |:--|:--|:--| | `batchData` (delete verb) | non-atomic and atomic | **pinned** — it is one of the two faces that can produce an errors-less non-success row | | `deleteManyData` | non-atomic | **pinned** — the other such face | | `deleteManyData` | atomic | **reasoned** — same two builders, same `runAtomicBatch`; the atomic delete pin runs through `batchData`, whose loop pushes the identical survivor row | | `updateManyData` | both | **reasoned, and the reading is asserted** — `runUpdateManyLoop` has no producer of a non-success row without `errors` (every push is `success: true` or a `toRowApiError` row), so `!success` and "carries a fault" still coincide there. The pin asserts that reading directly: every non-success row in an `updateMany` response carries an `errors` entry. It shares the two builders, so the attribution moves with them, which the same test also checks | ## Ablation Neutralised on the final tree (merge commit `56e5fd90c`), mutation proved on disk by blob hash `2204cd2 -> be9dd23`, restored, restore proved: blob back to `2204cd2`, `git diff HEAD` zero bytes, whole-tree `git status --porcelain` zero lines. - **5 of the 8 new pins go red**, one per message site plus the two non-atomic faces. - **3 stay green** — they are the positive controls: the same assertions over batches with no survivor in them, so a locator that simply stopped naming anything could not pass this file. - **All 5 sibling batch suites stay green (68 tests)** — no existing pin covered this, which is why it shipped. ## Files outside the declared surface, declared rather than quietly widened The dispatch scoped this to `packages/metadata-protocol/src/`. Two files outside it are in the diff, both mechanical and both demanded by the repo's own gates for the in-surface change: - `.changeset/19452-batch-causal-row-located-by-fault.md` — required by the post-task checklist and by `check:empty-changeset`. Measured rather than assumed: `@objectstack/metadata-protocol` is not private, its `files[]` ships `dist`, and the changed symbol is in the built output (`locateBatchCause` present in `dist/index.js`; a nonsense control string returns zero from the same grep). `patch`. - `scripts/engine-double-contract.pinned.json` — `check:engine-double-contract` exited 1 on the new pin file with its own prescription, `--write` and commit. The regeneration reports 3 rows added, 0 lost, and every added row names the new test file (3 lines match the file name, 3 lines match `.test.ts` — the same count, so no other file moved). ## Verification - `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the merged tree (`56e5fd90c`): **69 families, all 69 run, every one exit 0**. Reconciled with `--ran` carrying exit codes: `69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN`. Three families first answered exit 3 (PREREQUISITE NOT MET — `dual-build-cjs-loads`, `lean-entry-closure`, `type-check-debt`); each was discharged by building what it named (`turbo run build` over all packages, 72/72) and re-running, never by calling it inapplicable. - `pnpm --filter @objectstack/metadata-protocol test` — 2653 passed, 19 skipped, 0 failed. `typecheck` — exit 0. - `pnpm lint` — the whole-repo `eslint . --no-inline-config`, exit 0. Run in full, so no narrowing needs declaring. - `origin/main` merged before this reading; the gates above were run on the merged tree. ## Acceptance notes Noted here, not filed, per this seat's standing rule that it files nothing: - `protocol.batch-not-attempted.test.ts` asserts the causal index with `expect(message).toContain('1')`. The assertion is satisfied by any `1` anywhere in the string, so it is much weaker than it reads; it happens to be correct today. Test-quality observation, no live defect, and this PR does not touch that file. - `runBatchDataLoop`'s `Unknown operation:` arm records a `VALIDATION_FAILED` row per record and keeps going even with `continueOnError` absent, because nothing is thrown. `BatchOptionsSchema.continueOnError` declares the opposite default. `batchData` does not parse the verb at its own door, so an in-process caller can reach the arm; the effect is a longer `results` array and nothing else — no write happens and the counters still reconcile. Reported to the PM as a contract-violation candidate with its seam rather than filed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4fba503 commit a251aaa

4 files changed

Lines changed: 419 additions & 7 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a stopped or rolled-back bulk batch names the row that actually failed
6+
7+
`reconcileStoppedBatch` and `buildRolledBackBatchResponse` — the two builders every one of the three bulk-write faces (`batchData`, `updateManyData`, `deleteManyData`) reports through — located the causal row with `findIndex(r => !r.success)`. That encoded an invariant: **`!success` means this row failed, and it carries `errors[0]`.**
8+
9+
That invariant stopped holding when a matched-but-deliberately-not-removed row started answering `success: false` with no `errors` entry — correctly, because a surviving record is an outcome, not a fault. The locator could then land on that survivor, `errors?.[0]?.message` was `undefined`, and the message named the **wrong index** while calling the real error — sitting in the same array — 「unknown error」.
10+
11+
Measured on the unfixed tree:
12+
13+
- non-atomic `deleteMany ['survivor', 'missing', 'other']` — the un-attempted row answered `NOT_ATTEMPTED` *"record 0 failed — unknown error; the batch stopped there. …"* while record **1** is what threw;
14+
- atomic `[t1, survivor, t3]` — the rolled-back rows answered `ROLLED_BACK` *"record 1 failed — unknown error"* for a row that **survived**;
15+
- atomic `[t3, survivor, missing, t2]` — `ROLLED_BACK` said *"record 1 failed — unknown error"* and `NOT_ATTEMPTED` said *"atomic batch aborted by record 1"*, both naming the survivor while record **2** threw.
16+
17+
Both builders now share one locator, `locateBatchCause`, which finds the row by its recorded **fault** — the row's `errors[]` entry. That is the one per-row value whose declared meaning is a failure: `BatchOperationResultSchema.errors` is documented as *"Array of errors if operation failed"*, and the v17 migration entry publishes `row.errors?.[0]?.message` / `.code` to consumers as exactly that read. Its codes are drawn from the closed `StandardErrorCode ∪ ERROR_CODE_LEDGER` vocabulary, so an unregistered code fails `BatchOperationResultSchema.parse` — giving a non-fault ending an `errors[]` entry is a ledger widening in `packages/spec`, not something a call site can do on its own. `ApiError.message` is required, so a located cause always has text and the 「unknown error」 fallback is **deleted** rather than merely unreached.
18+
19+
The scan runs from the end of the attempted rows, because a run ends *at* the row it stops on: every stop is a `break` in a loop's `catch`, immediately after that row was pushed. A fault that does not stop the run (the `Unknown operation:` arm records one and keeps going) therefore cannot shadow the row that did.
20+
21+
One ending has no fault to quote at all — an atomic batch aborted by a lone survivor, where `runAtomicBatch` rolls back on `failed > 0` and nothing ever threw. The rolled-back rows now read *"record 1 did not succeed"*: the row that stopped the batch committing, named as what it is rather than as a failure with an unknown cause.
22+
23+
No envelope field, per-row code, status or count changes; `succeeded` and `failed` still partition `results`. What changes is which row two message strings name, and both of them stop inventing an error that is not there. Clients branch on `errors[0].code`, which is unchanged — the row classification itself was never wrong.
24+
25+
`Clause-②: no` — nothing authorable moves: no `packages/spec` key, export, accept set or stored shape changes, and the per-row code vocabulary is untouched.
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19452] A stopped or rolled-back bulk batch must name the row that actually
5+
* failed — and must never call a real error 「unknown」 while it is sitting in
6+
* the same array.
7+
*
8+
* `reconcileStoppedBatch` and `buildRolledBackBatchResponse` located the causal
9+
* row with `findIndex(r => !r.success)`, which encoded the invariant
10+
* **`!success` ⇒ this row failed and carries `errors[0]`**. #19412 broke that
11+
* invariant deliberately and correctly: a row that MATCHED and was NOT removed
12+
* (`IDataEngine.delete` answering the count arm's `0`) now reports
13+
* `success: false` with ⛔ no `errors` entry, because a surviving record is an
14+
* OUTCOME, not a fault.
15+
*
16+
* ⇒ the locator landed on that survivor, `errors?.[0]?.message` was
17+
* `undefined`, and the message named the WRONG index while falling back to
18+
* 「unknown error」. Measured on the unfixed tree, with the harness below:
19+
*
20+
* deleteMany ['t1'(survives), 'missing'(throws), 't3']
21+
* -> results[2] NOT_ATTEMPTED "record 0 failed — unknown error; the batch
22+
* stopped there. ..." ⚠️ record 1 is what threw
23+
* batchData atomic [t1, t2(survives), t3]
24+
* -> results[0] ROLLED_BACK "record 1 failed — unknown error"
25+
* ⚠️ record 1 SURVIVED; nothing failed
26+
*
27+
* The negative case — a batch whose FIRST non-success row is a survivor — is
28+
* what these pins exist for. The ordinary batches at the bottom are the
29+
* positive controls: the same assertions on a run with no survivor in it, so a
30+
* locator that simply stopped naming anything cannot pass this file.
31+
*/
32+
33+
import { describe, it, expect, vi } from 'vitest';
34+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
35+
import { ObjectStackProtocolImplementation } from './protocol.js';
36+
37+
const SCHEMA = {
38+
name: 'showcase_private_note',
39+
fields: {
40+
title: { name: 'title', type: 'text' },
41+
},
42+
};
43+
44+
/** The row the fake engine rejects — a classified failure, as `toRowApiError` expects. */
45+
const POISON = '__invalid__';
46+
47+
function validationFailure(): Error {
48+
const err: any = new Error('title is invalid');
49+
err.code = 'VALIDATION_FAILED';
50+
err.status = 400;
51+
return err;
52+
}
53+
54+
/**
55+
* In-memory store with real snapshot/rollback transaction semantics — the same
56+
* harness shape the #4620 / #4793 / #7539 suites use, so every row asserted
57+
* here is produced by the actual loops, builders and rollback classifier.
58+
*
59+
* `delete` speaks the COUNT arm of `IDataEngine.delete`: an unknown id keeps
60+
* the contract's `false` (which the loop turns into a thrown
61+
* `RECORD_NOT_FOUND`), the nominated `survivor` matches and is deliberately
62+
* kept (`0`), everything else really goes (`1`).
63+
*/
64+
function makeCountingEngine(survivor: string) {
65+
const rows = new Map<string, any>([
66+
['t1', { id: 't1', title: 'stored one' }],
67+
['t2', { id: 't2', title: 'stored two' }],
68+
['t3', { id: 't3', title: 'stored three' }],
69+
]);
70+
const handle = { id: 'trx-1' };
71+
72+
const insert = vi.fn(async (_object: string, data: any) => {
73+
if (data?.title === POISON) throw validationFailure();
74+
const rec = { id: data.id ?? `new-${rows.size + 1}`, ...data };
75+
rows.set(rec.id, rec);
76+
return rec;
77+
});
78+
const update = vi.fn(async (_object: string, data: any, options?: any) => {
79+
assertEngineUpdateDispatch(data, options);
80+
const id = options?.where?.id;
81+
if (data?.title === POISON) throw validationFailure();
82+
const next = { ...rows.get(id), ...data };
83+
rows.set(id, next);
84+
return next;
85+
});
86+
const del = vi.fn(async (_object: string, options?: any) => {
87+
assertEngineDeleteDispatch(options);
88+
const id = options?.where?.id;
89+
if (!rows.has(id)) return false; // [#4435] the positive not-found value
90+
if (id === survivor) return 0; // [#19412] matched, deliberately NOT removed
91+
rows.delete(id);
92+
return 1;
93+
});
94+
const findOne = vi.fn(async (_object: string, options?: any) => {
95+
assertEngineFindOnePredicate(_object, options);
96+
return rows.get(options?.where?.id) ?? null;
97+
});
98+
99+
const engine: any = {
100+
registry: { getObject: (n: string) => (n === 'showcase_private_note' ? SCHEMA : undefined) },
101+
insert,
102+
update,
103+
delete: del,
104+
findOne,
105+
getDefaultDriverName: () => 'default',
106+
getDriverByName: () => ({ beginTransaction: async () => handle }),
107+
transaction: vi.fn(async (callback: (ctx: any) => Promise<any>, baseContext?: any) => {
108+
const snapshot = new Map(rows);
109+
try {
110+
return await callback({ ...(baseContext ?? {}), transaction: handle });
111+
} catch (err) {
112+
rows.clear();
113+
for (const [k, v] of snapshot) rows.set(k, v);
114+
throw err;
115+
}
116+
}),
117+
};
118+
return { engine, rows, insert, update, del, findOne };
119+
}
120+
121+
const codesOf = (res: any): Array<string | undefined> =>
122+
res.results.map((r: any) => r.errors?.[0]?.code);
123+
124+
/**
125+
* The card's acceptance, as ONE assertion usable on every message that names a
126+
* causal row: it names the row that really failed, it carries that row's own
127+
* error text, and it never says 「unknown error」 while that text exists.
128+
*
129+
* `causalIndex` is read from the response rather than hard-coded, so the pin
130+
* asserts an AGREEMENT between the message and the rows beside it.
131+
*/
132+
function expectAttributedTo(message: string, res: any, causalIndex: number) {
133+
const causalText = res.results[causalIndex].errors[0].message;
134+
expect(causalText).toBeTruthy();
135+
expect(message).toContain(`record ${causalIndex} failed`);
136+
expect(message).toContain(causalText);
137+
expect(message).not.toContain('unknown error');
138+
}
139+
140+
describe('[#19452] a stopped batch attributes the stop to the row that threw, not to a survivor', () => {
141+
it('deleteManyData: a leading survivor does not become the cause', async () => {
142+
const t = makeCountingEngine('t1');
143+
const p = new ObjectStackProtocolImplementation(t.engine);
144+
145+
const res: any = await p.deleteManyData({
146+
object: 'showcase_private_note',
147+
ids: ['t1', 'definitely_missing', 't3'],
148+
} as any);
149+
150+
// Row 0 survived (no `errors`), row 1 threw, row 2 was never attempted.
151+
expect(codesOf(res)).toEqual([undefined, 'RECORD_NOT_FOUND', 'NOT_ATTEMPTED']);
152+
expect(res.results[0]).toMatchObject({ id: 't1', success: false });
153+
expect(res.results[0].errors).toBeUndefined(); // why `!success` lies here
154+
expect(t.rows.has('t1')).toBe(true); // it really is still there
155+
156+
// Pre-fix: "record 0 failed — unknown error; the batch stopped there. ..."
157+
const message = res.results[2].errors[0].message;
158+
expectAttributedTo(message, res, 1);
159+
expect(message).not.toContain('record 0');
160+
expect(message).toContain('continueOnError');
161+
});
162+
163+
it('batchData delete: the same run through the other non-atomic face', async () => {
164+
const t = makeCountingEngine('t1');
165+
const p = new ObjectStackProtocolImplementation(t.engine);
166+
167+
const res: any = await p.batchData({
168+
object: 'showcase_private_note',
169+
request: { operation: 'delete', records: [{ id: 't1' }, { id: 'definitely_missing' }, { id: 't3' }] },
170+
} as any);
171+
172+
expect(codesOf(res)).toEqual([undefined, 'RECORD_NOT_FOUND', 'NOT_ATTEMPTED']);
173+
expect(res.results[0].errors).toBeUndefined();
174+
175+
const message = res.results[2].errors[0].message;
176+
expectAttributedTo(message, res, 1);
177+
expect(message).not.toContain('record 0');
178+
});
179+
});
180+
181+
describe('[#19452] a rolled-back atomic batch attributes the abort to the row that threw', () => {
182+
/**
183+
* One run that reaches all THREE message sites: a committed-then-undone
184+
* row, a survivor, the row that threw, and a row never reached. The two
185+
* interpolations of the causal index get SEPARATE tests, so neither can
186+
* mask the other's reading when this file runs red.
187+
*/
188+
const atomicRunReachingBothInterpolations = async () => {
189+
const t = makeCountingEngine('t1');
190+
const p = new ObjectStackProtocolImplementation(t.engine);
191+
192+
const res: any = await p.batchData({
193+
object: 'showcase_private_note',
194+
request: {
195+
operation: 'delete',
196+
records: [{ id: 't3' }, { id: 't1' }, { id: 'definitely_missing' }, { id: 't2' }],
197+
options: { atomic: true },
198+
},
199+
} as any);
200+
201+
expect(res).toMatchObject({ success: false, total: 4, succeeded: 0, failed: 4 });
202+
expect(codesOf(res)).toEqual(['ROLLED_BACK', undefined, 'RECORD_NOT_FOUND', 'NOT_ATTEMPTED']);
203+
return { t, res };
204+
};
205+
206+
it('the ROLLED_BACK message names record 2, not the survivor at record 1', async () => {
207+
const { t, res } = await atomicRunReachingBothInterpolations();
208+
209+
// Pre-fix: "record 1 failed — unknown error" — record 1 SURVIVED.
210+
expectAttributedTo(res.results[0].errors[0].message, res, 2);
211+
expect(res.results[0].errors[0].message).not.toContain('record 1');
212+
213+
// The rollback is real, and the survivor still carries no `errors`.
214+
expect(res.results[1].errors).toBeUndefined();
215+
expect(t.rows.has('t1')).toBe(true);
216+
expect(t.rows.has('t2')).toBe(true);
217+
expect(t.rows.has('t3')).toBe(true);
218+
});
219+
220+
it('the NOT_ATTEMPTED message names record 2 too — it reads the same index', async () => {
221+
// ⛔ Not assumed to ride along with the two 「unknown error」 sites. This
222+
// wording never says 「failed」 and never quotes a cause, so its only
223+
// defect was the INDEX — but that index is the shared one, so it moves
224+
// with the locator. Pre-fix this read "atomic batch aborted by record 1".
225+
const { res } = await atomicRunReachingBothInterpolations();
226+
227+
expect(res.results[3].errors[0].message).toBe('atomic batch aborted by record 2');
228+
});
229+
230+
it('a rollback caused by a survivor ALONE reports no failure at all', async () => {
231+
// Nothing threw: `runAtomicBatch` aborted on `outcome.failed > 0`, which
232+
// a lone survivor satisfies. There is no causal ERROR to quote, so the
233+
// message must not invent one — and must not call the survivor a
234+
// failure either.
235+
const t = makeCountingEngine('t2');
236+
const p = new ObjectStackProtocolImplementation(t.engine);
237+
238+
const res: any = await p.batchData({
239+
object: 'showcase_private_note',
240+
request: {
241+
operation: 'delete',
242+
records: [{ id: 't1' }, { id: 't2' }, { id: 't3' }],
243+
options: { atomic: true },
244+
},
245+
} as any);
246+
247+
expect(codesOf(res)).toEqual(['ROLLED_BACK', undefined, 'ROLLED_BACK']);
248+
// No row in the whole response carries a fault entry of its own.
249+
expect(res.results.some((r: any) => r.errors?.[0]?.code === 'RECORD_NOT_FOUND')).toBe(false);
250+
251+
// Pre-fix: "record 1 failed — unknown error".
252+
for (const row of [res.results[0], res.results[2]]) {
253+
expect(row.errors[0].message).toBe('record 1 did not succeed');
254+
expect(row.errors[0].message).not.toContain('unknown error');
255+
expect(row.errors[0].message).not.toContain('failed');
256+
}
257+
expect(t.rows.has('t2')).toBe(true);
258+
});
259+
});
260+
261+
describe('[#19452] CONTROLS — an ordinary batch, with no survivor in it, attributes exactly as before', () => {
262+
const threeCreates = [
263+
{ data: { title: 'first valid' } },
264+
{ data: { title: POISON } },
265+
{ data: { title: 'third valid' } },
266+
];
267+
268+
it('batchData create: the stopped tail still names record 1 and quotes its error', async () => {
269+
// The positive control for `expectAttributedTo`: this leg is green on
270+
// the unfixed tree too, so a green above is about the survivor case and
271+
// ⛔ not about an assertion that can no longer fail.
272+
const t = makeCountingEngine('none_of_them');
273+
const p = new ObjectStackProtocolImplementation(t.engine);
274+
275+
const res: any = await p.batchData({
276+
object: 'showcase_private_note',
277+
request: { operation: 'create', records: threeCreates },
278+
} as any);
279+
280+
expect(codesOf(res)).toEqual([undefined, 'VALIDATION_FAILED', 'NOT_ATTEMPTED']);
281+
expectAttributedTo(res.results[2].errors[0].message, res, 1);
282+
expect(res.results[2].errors[0].message).toContain('title is invalid');
283+
});
284+
285+
it('batchData create atomic: ROLLED_BACK still names record 1 and quotes its error', async () => {
286+
const t = makeCountingEngine('none_of_them');
287+
const p = new ObjectStackProtocolImplementation(t.engine);
288+
289+
const res: any = await p.batchData({
290+
object: 'showcase_private_note',
291+
request: { operation: 'create', records: threeCreates, options: { atomic: true } },
292+
} as any);
293+
294+
expect(codesOf(res)).toEqual(['ROLLED_BACK', 'VALIDATION_FAILED', 'NOT_ATTEMPTED']);
295+
expectAttributedTo(res.results[0].errors[0].message, res, 1);
296+
expect(res.results[2].errors[0].message).toBe('atomic batch aborted by record 1');
297+
});
298+
299+
it('updateManyData: the third bulk face cannot produce an errors-less non-success row', async () => {
300+
// This face is covered by REASONING rather than by a survivor pin: it
301+
// has no producer of a non-success row without `errors` — every push in
302+
// `runUpdateManyLoop` is either `success: true` or a `toRowApiError`
303+
// row — so `!success` and "carries a fault" still coincide on it. The
304+
// reading is asserted, not asserted-about: every non-success row here
305+
// carries an `errors` entry, which is exactly what the delete faces
306+
// above violate.
307+
const t = makeCountingEngine('none_of_them');
308+
const p = new ObjectStackProtocolImplementation(t.engine);
309+
310+
const res: any = await p.updateManyData({
311+
object: 'showcase_private_note',
312+
records: [
313+
{ id: 't1', data: { title: 'renamed one' } },
314+
{ id: 't2', data: { title: POISON } },
315+
{ id: 't3', data: { title: 'renamed three' } },
316+
],
317+
} as any);
318+
319+
expect(codesOf(res)).toEqual([undefined, 'VALIDATION_FAILED', 'NOT_ATTEMPTED']);
320+
expect(res.results.filter((r: any) => r.success === false).every((r: any) => (r.errors?.length ?? 0) > 0)).toBe(true);
321+
// And it shares the two builders, so the attribution moves with them.
322+
expectAttributedTo(res.results[2].errors[0].message, res, 1);
323+
});
324+
});

0 commit comments

Comments
 (0)