Skip to content

Commit 5cdb0db

Browse files
huangyiireneclaude
andauthored
fix(security,metadata-protocol): batchData's delete arm stops reporting a deletion it did not perform (#19451)
Fixes #19433 Clause-②: no ## What was wrong `POST /api/v1/data/{object}/batch` with `operation: "delete"` reported a deletion it did not perform. This is the **third and last** of the three by-id delete doors to read the engine's answer: the single-record `DELETE` (PR #19411) and `deleteMany` (PR #19432) already do, and this branch is the one that fell between them with no card. Each row pushed `success: true` as a **literal**, so every engine result that was not the driver contract's `false` was reported as a deletion. The `false` arm — "no row matched" — has reported honestly since #4435 and #5088. The other ending had not: a row that **matched** and was deliberately **not removed** was counted in `succeeded` and shipped an envelope byte-identical to a batch in which every id really went. `sys_permission_set` is the shipped shape of it. Deleting a package-declared set is an ADR-0005 RESET — plugin-security's write-through tombstones the overlay and the record re-projects to the declared body instead of vanishing — so the row matched, the write ran, and the record is still there. On a security-configuration write the envelope told an operator a permission set was gone while it was still being enforced. ## Why this is a defect today — rested on the CONTRACT, not on a shipped handler `IDataEngine.delete` declares `Promise[boolean | number]` (`packages/spec/src/contracts/data-engine.ts`; square brackets here for the generic, the spelling this family's cards use) — the driver boolean for a by-id write, a COUNT of rows removed otherwise. `isDeleteResultShape` in `packages/objectql/src/verb-hook-result-shape.ts` is a pair of `typeof` tests admitting `boolean` and `number`, and the ADR-0112 hook gate in `packages/objectql/src/engine.ts` uses it. So an `afterDelete` handler — or a non-ObjectQL `IDataEngine` — may legally answer `0` **today**, and a numeric zero is the one value that positively means "the row is still there". This PR does **not** rest on "a shipped handler already returns the 0". That claim was made on #19412 and the seat could not verify it; the sibling fix stood because the contract argument is independent, and this one stands the same way. ## Located by symbol, because this family's line numbers have drifted four times `grep -n "throw recordNotFoundError" packages/metadata-protocol/src/protocol.ts` on this branch's base (`23f1de078`): | door | line on this base | state before this PR | |:---|:---|:---| | single-record `deleteData` | `:11438` | reads the engine result (#19306 / PR #19411) | | `runBatchDataLoop` `case 'delete'` | `:12568` | **the literal — this PR** | | `runDeleteManyLoop` | `:13120` | reads the engine result (#19412 / PR #19432) | The file's own comment beside the `batchData` site calls it "the OTHER by-id bulk delete, ten lines from it". ## Measured on the unfixed base, before any edit A fake engine per answer, through `batchData` with one record: | `engine.delete` answers | batch door, before | after this PR | |:---|:---|:---| | `true` | `success: true`, `succeeded: 1` | unchanged | | `1` | `success: true`, `succeeded: 1` | unchanged | | **`0`** | **`success: true`, `succeeded: 1`, `failed: 0`** | **`success: false`, `succeeded: 0`, `failed: 1`, no `errors`** | | `false` | `success: false` + `errors[0].code RECORD_NOT_FOUND`, `failed: 1` | unchanged | | `undefined` | `success: true` | unchanged | Mixed batch `['a', 'kept', 'b']` where `kept` answers `0`, before: all three `success: true`, `succeeded: 3`. Atomic with the same engine, before: **committed**, `succeeded: 3`, `kept` silently still present. ## The envelope measurement this card owes — what transfers, and what does not `batchData`'s envelope is its own, so each of these was read off this file rather than inherited from `deleteMany`. All three shapes turn out to **agree**, and the reason is mechanical: the two surfaces share the machinery. | question | `deleteManyData` | `batchData` | why | |:---|:---|:---|:---| | same `succeeded`/`failed` partition (#7539)? | yes | **yes** | both response builders call the shared `reconcileStoppedBatch`; `buildBatchDataResponse` then sets `success: failed === 0`, `total: records.length` | | an `atomic` arm, aborting on `failed > 0`? | yes | **yes** | `runAtomicBatchData` delegates to the shared `runAtomicBatch`, whose abort condition is `outcome.failed > 0` | | does the `continueOnError` stop apply? | no | **no** | the stop lives in the loop's `catch`, and a surviving row throws nothing | Two things about `batchData` that have no counterpart on `deleteMany`, and are therefore pinned separately: 1. **`runBatchDataLoop` is shared by `create` / `update` / `upsert` / `delete`**, and each `case` owns its own `succeeded++`. Only the `delete` case is touched; the sibling arms keep their counters exactly as they were. 2. **`returnRecords: false`** is a `batchData`-only projection (`buildDeleteManyResponse` has no such flag). It drops `data` and keeps `success` / `index` / `errors`, so the one key this card moves is the key that survives it — pinned. ## The fix The per-row push in `case 'delete'` reads the engine result instead of being a literal: ```ts const removed = deleted !== 0; results.push({ id: record.id, success: removed, index }); if (removed) succeeded++; else failed++; ``` **Not `false` for this case.** That arm is spoken for by the 404 above, about a record this caller can still `GET`; answering it here would trade one wrong answer for a louder one. Only a POSITIVE zero is read as "not removed", and an off-contract `undefined` from a third-party driver keeps its #4435 reading. **And the row gets no `errors[]` entry.** A surviving record is an OUTCOME, not a fault; the single-record door answers the same case with a bare `success: false` on a 200, and this envelope's two per-row codes (`ROLLED_BACK`, `NOT_ATTEMPTED`) both describe a row that never ran. Minting a code for this ending is an `ERROR_CODE_LEDGER` widening in `packages/spec` and belongs to the spec lane. That was ruled on #19412 and the ruling is carried here, not re-opened. HTTP status is untouched — the route hands the protocol result straight to `res.json`, so this stays a 200 whose body reports per row. ## On the wire, per row | case | before | after | |:---|:---|:---| | row removed | `success: true`, in `succeeded` | unchanged | | matched, not removed (packaged-set reset) | `success: true`, in `succeeded` | `success: false`, in `failed`, no `errors[]` | | unknown id | `success: false`, `errors[0].code RECORD_NOT_FOUND` | unchanged | | batch stopped / rolled back | `NOT_ATTEMPTED` / `ROLLED_BACK` | unchanged | Because the counters partition `results`, a surviving row also makes the request-level `success` false, and an `atomic` batch holding one now rolls back rather than committing under a response that called every row deleted. A non-atomic batch is not stopped by it. ## Evidence All readings at `7b9c6376c` (final commit, `origin/main` merged in), worktree clean, base `23f1de078`. **The pin, and that it can fail.** `protocol.bulk-record-not-found.test.ts` gains a `[#19433]` block of five cases beside the existing `[#5088]` one: the NEGATIVE case (engine answers `0`, so `success: false`, `succeeded: 0`, `failed: 1`, **no** `errors` key, and the record is still in the store), a CONTROL leg (a positive count still reports a deletion — without it the pin can pass vacuously), a mixed batch pinning both directions plus the partition and the un-stopped run, the `atomic` arm (the surviving row aborts and the earlier delete is really undone), and `returnRecords: false`. Run against the UNFIXED base first: **4 failed / 18 passed**. After the fix: **22 passed**. **Reverse verification**, both legs from the committed state, mutation proven on disk and restored by blob hash (`scripts/ablation-replace.mjs`, mutation and command in one process): | mutation | anchor | blob | result | |:---|:---|:---|:---| | the four fixed lines back to `success: true` + `succeeded++` | x1 to x0 | `aaa92be21a62` to `d9c2974fa715` | **4 failed / 18 passed** | | `const removed = deleted !== 0` to `const removed = false` | x1 to x0 | `aaa92be21a62` to `39ef16f7b8ee` | **7 failed / 15 passed** | Both restored: blob equals `HEAD`'s (`aaa92be21a62`) and `git diff HEAD` is empty. The first leg also proves the suite resolves `src/protocol.ts` and not `dist/` — `dist/` held the FIXED bytes at that moment (`success: removed` occurs twice in `dist/index.js`) and the run still went red. **Unit + types.** `pnpm --filter @objectstack/metadata-protocol test` — **183 files (3 skipped) / 2621 passed (19 skipped)**, exit 0. `typecheck` exit 0, and `tsc --noEmit --listFiles` names `protocol.bulk-record-not-found.test.ts` once (negative control: a name not in the program is listed 0 times), so the pin is inside the program the script advertises. No public export moved, so no consumer package owes a test here. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **61 families** from this diff; all 61 ran, all exit 0, reconciled with `--ran` carrying each exit code — "61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN". Re-derived after `git fetch origin main` and again after merging it: identical list, so the new `check-dts-references` family that landed on `main` meanwhile is not one this diff owes. Three families (`check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`) exited 3 `PREREQUISITE NOT MET` on the first sweep, were given the repo-wide build CI gives them, and then exited 0; the exit-3 runs are recorded as not measured rather than as failures. Repo-wide `pnpm lint` ran **unnarrowed** (`eslint . --no-inline-config`): exit 0, at this same head. **Changeset — measured, not assumed.** `@objectstack/metadata-protocol` is `private: false` with `files: ["dist", "README.md", "CHANGELOG.md"]`, and the edited symbol ships: `batchData` occurs 25 times in the built `dist`, and the moved expression `success: removed` occurs in both `dist/index.js` and `dist/index.cjs`, against a negative control symbol that occurs 0 times. So a changeset is owed and `skip-changeset` does not apply. **`patch`**: a released package's bug fix whose published accept set does not move — no schema key added or removed, no closed-set member, no new published export, no registry entry, no request shape touched. What changes is the VALUE of an existing key, pulled back to what `BatchOperationResultSchema.success` already declares ("Whether this record was processed successfully"), on a schema whose `errors` was already optional. Hence `Clause-②: no`, measured against this diff rather than copied from #19412's verdict. ## Acceptance notes Noticed while measuring, deliberately **not** carried here: - **The "causal row" of a stopped or rolled-back batch can now name a row that did not fail.** `reconcileStoppedBatch` and `buildRolledBackBatchResponse` both locate the cause with `findIndex(r => !r.success)`, which since PR #19432 can land on a SURVIVING row — a non-success that carries no error. Measured on this branch: a non-atomic `['survivor', 'missing', 'other']` delete batch answers row 2 with `NOT_ATTEMPTED: "record 0 failed — unknown error; the batch stopped there."` when it was row 1 that threw; an atomic batch answers a rolled-back row with `ROLLED_BACK: "record 1 failed — unknown error"` for a row that survived rather than failed. This is already live on `origin/main` through `deleteMany` since PR #19432 landed, and it reproduces identically there — so it is a shared-builder defect, not one this PR introduces, and fixing it means editing builders all three bulk faces read. Reported for filing. No pin in this PR asserts either message. - **`makeStoreEngine`'s fake `delete` answers `{ deleted: 1 }`** — an object, which is neither arm of the declared `boolean | number`. It happens to be `!== 0` so the existing cases keep their reading, but it is a test double speaking off-contract. Noted, not filed: no consumer. - **`os data delete`'s unconditional print** was already recorded on PR #19411 and is not re-reported here. - #19412 is not addressed here; it landed in PR #19432 and this branch does not touch the `runDeleteManyLoop` region. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e368dc commit 5cdb0db

3 files changed

Lines changed: 265 additions & 2 deletions

File tree

‎.changeset/tidy-moons-repeat.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
`POST /api/v1/data/{object}/batch` with `operation: "delete"` stops reporting a deletion it
6+
did not perform.
7+
8+
This is the third and last of the by-id delete doors to read the engine's answer — the
9+
single-record `DELETE` and `deleteMany` already do. Each row of the batch answered
10+
`success: true` for every engine result that was not the driver contract's `false`. The
11+
`false` arm — "no row matched" — has reported honestly since #4435 and #5088; the other
12+
ending had not: a row that MATCHED and was deliberately NOT removed was counted in
13+
`succeeded` and reported as deleted, byte-identical to a real deletion.
14+
15+
`sys_permission_set` is the shipped shape of it. Deleting a package-declared set is an
16+
ADR-0005 RESET: the overlay tombstones and the record re-projects to the declared body
17+
instead of vanishing. That behaviour is unchanged and deliberate — what was wrong is the
18+
answer, and on a security-configuration write it told an operator a permission set was gone
19+
while it was still being enforced.
20+
21+
`IDataEngine.delete` declares `Promise<boolean | number>` — the driver boolean for a by-id
22+
write, a count of rows removed otherwise — so a numeric zero is the one value that positively
23+
means the record is still there. The per-row `success` now reads that count instead of being
24+
a literal.
25+
26+
On the wire, for one row of a `delete` batch:
27+
28+
- removed — `success: true`, counted in `succeeded` (unchanged);
29+
- matched but not removed — `success: false`, counted in `failed`, and no `errors[]` entry:
30+
a surviving record is an outcome, not a fault, and this envelope's two per-row codes
31+
(`ROLLED_BACK`, `NOT_ATTEMPTED`) both describe a row that never ran;
32+
- unknown id — `success: false` with `errors[0].code: RECORD_NOT_FOUND` (unchanged);
33+
- an off-contract `undefined` from a third-party driver keeps its #4435 reading.
34+
35+
Because `succeeded` and `failed` partition `results`, a surviving row also makes the
36+
request-level `success` false, and an `atomic` batch containing one now rolls back rather
37+
than committing under a response that called every row deleted. A non-atomic batch is not
38+
stopped by it: nothing was thrown, so the `continueOnError` stop does not apply and the
39+
remaining records are still attempted. `returnRecords: false` keeps `success`, so the honest
40+
value survives that projection too.
41+
42+
Clause-②: no

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

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,169 @@ describe('[#5088] batchData delete — the driver`s return decides, as in delete
424424
});
425425
});
426426

427+
describe('[#19433] batchData delete — a row that MATCHED and was deliberately NOT removed', () => {
428+
/**
429+
* The THIRD by-id delete door, and the last one still pushing the literal.
430+
* The single-record face (#19306) and `deleteManyData` (#19412) both learned
431+
* to read the engine's answer; this branch — "the OTHER by-id bulk delete,
432+
* ten lines from it", as its own comment calls it — kept `success: true` for
433+
* every result that was not the driver contract's `false`.
434+
*
435+
* `IDataEngine.delete` declares `Promise<boolean | number>`
436+
* (`packages/spec/src/contracts/data-engine.ts`), and `isDeleteResultShape`
437+
* admits the number arm at the ADR-0112 hook gate, so an `afterDelete`
438+
* handler — or a non-ObjectQL engine — may legally answer `0` today. A
439+
* numeric zero is the one value that positively means "the row is still
440+
* there", which is what a package-declared `sys_permission_set` delete is:
441+
* an ADR-0005 RESET, where the overlay tombstones and the record re-projects
442+
* to the declared body instead of vanishing.
443+
*
444+
* Measured on the unfixed base for this door, one record:
445+
*
446+
* `true` / `1` / `0` / `undefined` -> `success: true`, `succeeded: 1`
447+
* `false` -> `success: false` + `RECORD_NOT_FOUND` (#5088, unchanged)
448+
*
449+
* The `0` column is the lie: the row matched, the write ran, the record
450+
* survived, and the envelope was byte-identical to a real deletion.
451+
*/
452+
453+
/**
454+
* A `makeStoreEngine` whose delete speaks the COUNT arm instead of the
455+
* harness's `{ deleted: 1 }`: an unknown id keeps the contract's `false`,
456+
* `survivor` matches and is deliberately kept (`0`), everything else really
457+
* goes (`1`). Same store, so `atomic` rollback is still observed on rows.
458+
*/
459+
function makeCountingEngine(survivor: string) {
460+
const t = makeStoreEngine();
461+
t.engine.delete = vi.fn(async (_object: string, options?: any) => {
462+
assertEngineDeleteDispatch(options);
463+
const id = options?.where?.id;
464+
if (!t.rows.has(id)) return false;
465+
if (id === survivor) return 0;
466+
t.rows.delete(id);
467+
return 1;
468+
});
469+
return t;
470+
}
471+
472+
it('the row is NOT reported as a deletion, and gets no `errors[]` entry', async () => {
473+
const t = makeCountingEngine('t1');
474+
const p = new ObjectStackProtocolImplementation(t.engine);
475+
476+
const res: any = await p.batchData({
477+
object: 'showcase_task',
478+
request: { operation: 'delete', records: [{ id: 't1' }] },
479+
} as any);
480+
481+
// Pre-#19433: { success: true, succeeded: 1, failed: 0, results: [{ success: true }] }.
482+
expect(res).toMatchObject({ success: false, operation: 'delete', total: 1, succeeded: 0, failed: 1 });
483+
expect(res.results).toHaveLength(1);
484+
expect(res.results[0]).toMatchObject({ id: 't1', success: false, index: 0 });
485+
// NOT the not-found row. A surviving record and a record that never
486+
// existed are opposite facts about the same id, and a caller branching
487+
// on `errors[0].code` must not read one as the other. A surviving row
488+
// is an OUTCOME, not a fault; this envelope's two per-row codes
489+
// (`ROLLED_BACK`, `NOT_ATTEMPTED`) both describe a row that never ran.
490+
expect(res.results[0].errors).toBeUndefined();
491+
// And the record really is still there — that is what `0` asserted.
492+
expect(t.rows.has('t1')).toBe(true);
493+
expect(t.engine.delete).toHaveBeenCalledTimes(1);
494+
});
495+
496+
it('CONTROL: a positive count still reports a deletion', async () => {
497+
// Without this leg the pin above can pass vacuously — `deleted !== 0`
498+
// must not collapse into "a number is never a deletion".
499+
const t = makeCountingEngine('none_of_them');
500+
const p = new ObjectStackProtocolImplementation(t.engine);
501+
502+
const res: any = await p.batchData({
503+
object: 'showcase_task',
504+
request: { operation: 'delete', records: [{ id: 't1' }] },
505+
} as any);
506+
507+
expect(res).toMatchObject({ success: true, operation: 'delete', total: 1, succeeded: 1, failed: 0 });
508+
expect(res.results[0]).toMatchObject({ id: 't1', success: true });
509+
expect(t.rows.has('t1')).toBe(false);
510+
});
511+
512+
it('a mixed batch separates the rows that went from the row that stayed', async () => {
513+
// Both directions in ONE run, so neither a blanket `true` nor a blanket
514+
// `false` can pass, and the counters still PARTITION `results` (#7539).
515+
const t = makeCountingEngine('t2');
516+
const p = new ObjectStackProtocolImplementation(t.engine);
517+
518+
const res: any = await p.batchData({
519+
object: 'showcase_task',
520+
request: { operation: 'delete', records: [{ id: 't1' }, { id: 't2' }, { id: 't3' }] },
521+
} as any);
522+
523+
expect(res.results.map((r: any) => [r.id, r.success])).toEqual([
524+
['t1', true], ['t2', false], ['t3', true],
525+
]);
526+
expect(res).toMatchObject({ success: false, total: 3, succeeded: 2, failed: 1 });
527+
expect(res.succeeded + res.failed).toBe(res.total);
528+
// A surviving row is not a THROW, so the `continueOnError` stop in the
529+
// catch never fires: `t3` was still attempted and really went, without
530+
// the flag being set. Measured, not inherited from `deleteMany`.
531+
expect(t.engine.delete).toHaveBeenCalledTimes(3);
532+
expect(t.rows.has('t2')).toBe(true);
533+
expect(t.rows.has('t3')).toBe(false);
534+
});
535+
536+
it('atomic: a surviving row aborts the batch, and the earlier delete is undone', async () => {
537+
// This one is FORCED by the partition, and it is a real change of
538+
// ending: `runAtomicBatch` aborts on `outcome.failed > 0`, so an atomic
539+
// batch holding a package-declared set no longer commits under a
540+
// response that called every row deleted. Before: committed,
541+
// `succeeded: 3`, `t2` silently still present.
542+
const t = makeCountingEngine('t2');
543+
const p = new ObjectStackProtocolImplementation(t.engine);
544+
545+
const res: any = await p.batchData({
546+
object: 'showcase_task',
547+
request: {
548+
operation: 'delete',
549+
records: [{ id: 't1' }, { id: 't2' }, { id: 't3' }],
550+
options: { atomic: true },
551+
},
552+
} as any);
553+
554+
expect(res).toMatchObject({ success: false, total: 3, succeeded: 0, failed: 3 });
555+
expect(res.results.map((r: any) => r.errors?.[0]?.code)).toEqual([
556+
'ROLLED_BACK', undefined, 'ROLLED_BACK',
557+
]);
558+
// Still no `errors[]` on the surviving row, on this arm too.
559+
expect(res.results[1]).toMatchObject({ id: 't2', success: false });
560+
expect(res.results[1].errors).toBeUndefined();
561+
// Rolled back for real: every row is back.
562+
expect(t.rows.has('t1')).toBe(true);
563+
expect(t.rows.has('t2')).toBe(true);
564+
expect(t.rows.has('t3')).toBe(true);
565+
});
566+
567+
it('`returnRecords: false` still carries the honest per-row `success`', async () => {
568+
// `batchData`'s envelope is its own: `deleteManyData` has no such flag.
569+
// The projection drops `data` and keeps `success`/`index`, so the value
570+
// this card moves is the one key that survives it.
571+
const t = makeCountingEngine('t1');
572+
const p = new ObjectStackProtocolImplementation(t.engine);
573+
574+
const res: any = await p.batchData({
575+
object: 'showcase_task',
576+
request: {
577+
operation: 'delete',
578+
records: [{ id: 't1' }],
579+
options: { returnRecords: false },
580+
},
581+
} as any);
582+
583+
expect(res).toMatchObject({ success: false, succeeded: 0, failed: 1 });
584+
expect(res.results[0]).toMatchObject({ id: 't1', success: false, index: 0 });
585+
expect(res.results[0].errors).toBeUndefined();
586+
});
587+
});
588+
589+
427590
describe('[#5100] an id-less row is a CALLER error on both by-id update faces', () => {
428591
it('updateMany: VALIDATION_FAILED/400 before any engine read or write', async () => {
429592
const t = makeStoreEngine();

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

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12566,8 +12566,66 @@ export class ObjectStackProtocolImplementation implements
1256612566
// spurious 404.
1256712567
const deleted = await this.engine.delete(object, { where: { id: record.id }, ...ctxOpt } as any);
1256812568
if (deleted === false) throw recordNotFoundError(object, record.id);
12569-
results.push({ id: record.id, success: true, index });
12570-
succeeded++;
12569+
// [#19433] The SECOND half of this site, and the THIRD and
12570+
// last of the by-id delete doors to learn it — the
12571+
// single-record face (#19306) and `deleteManyData` (#19412)
12572+
// both already read the engine's answer. The paragraph
12573+
// above fixed "no match"; this is "matched, and
12574+
// deliberately NOT removed", where `success` was still a
12575+
// LITERAL for every result that was not the contract's
12576+
// `false`, so that row was reported as a deletion too.
12577+
//
12578+
// `sys_permission_set` is the shipped shape of it: deleting
12579+
// a package-declared set is an ADR-0005 RESET — the overlay
12580+
// tombstones and the record re-projects to the declared body
12581+
// instead of vanishing — so the row MATCHED, the write ran,
12582+
// and the record is still there. On a security-configuration
12583+
// write this envelope told an operator a permission set was
12584+
// gone while it was still being enforced.
12585+
//
12586+
// The defect rests on the CONTRACT, not on any shipped
12587+
// handler: `IDataEngine.delete` declares
12588+
// `Promise<boolean | number>` — the driver's boolean for a
12589+
// by-id write, a COUNT of rows removed otherwise — and
12590+
// `isDeleteResultShape` admits the number arm at the
12591+
// ADR-0112 hook gate, so an `afterDelete` handler or a
12592+
// non-ObjectQL engine may legally answer `0` today.
12593+
//
12594+
// ⛔ Not the `false` arm: that is spoken for by the 404
12595+
// above, about a record this caller can still GET, and
12596+
// answering it here would trade one wrong answer for a
12597+
// louder one. Everything else keeps its #4435 reading, an
12598+
// off-contract `undefined` from a third-party driver
12599+
// included: only a POSITIVE zero is read as "not removed".
12600+
//
12601+
// ⛔ And the row gets NO `errors[]` entry. A surviving
12602+
// record is an OUTCOME, not a fault — the single-record door
12603+
// answers the same case with a bare `success: false` on a
12604+
// 200 — and the two per-row codes this envelope owns
12605+
// (`ROLLED_BACK`, `NOT_ATTEMPTED`) both describe a row that
12606+
// never ran. Minting one for this ending is an
12607+
// ERROR_CODE_LEDGER widening in `packages/spec`, and belongs
12608+
// to the spec lane.
12609+
//
12610+
// This envelope's own consequences, measured here rather
12611+
// than inherited from `deleteManyData`: the row is counted
12612+
// in `failed` because `succeeded` and `failed` PARTITION
12613+
// `results` (#7539, `reconcileStoppedBatch`, shared by all
12614+
// three bulk faces), which makes the request-level `success`
12615+
// false and, on the `atomic` arm, aborts the batch through
12616+
// `runAtomicBatch`'s `failed > 0` — so an atomic batch
12617+
// holding a package-declared set now rolls back instead of
12618+
// committing under a response that called every row deleted.
12619+
// It does NOT stop a non-atomic run: the `continueOnError`
12620+
// stop belongs to the catch below and nothing was thrown, so
12621+
// every remaining record is still attempted. `returnRecords:
12622+
// false` keeps `success`, so the honest value survives that
12623+
// projection too. Only this `case` is touched — the sibling
12624+
// arms of this shared loop keep their own counters.
12625+
const removed = deleted !== 0;
12626+
results.push({ id: record.id, success: removed, index });
12627+
if (removed) succeeded++;
12628+
else failed++;
1257112629
break;
1257212630
}
1257312631
default:

0 commit comments

Comments
 (0)