Uh oh!
There was an error while loading. Please reload this page.
fix(driver-memory): make bulkUpdate and bulkDelete all-or-nothing - #13875
Conversation
bulkUpdate and bulkDelete were still Promise.all(map(...)) over update/delete, and both of those write into the table synchronously. A mid-batch refusal left every row processed before it already mutated, so the caller got a rejection describing a batch that had partly landed -- the same defect #13340 fixed on bulkCreate, on the third and fourth batch doors it did not reach. bulkUpdate now builds and checks every pending row's post-image before writing any of them -- new construction (each id keeps its own patch, so the projected row set per pending row generalizes updateMany's single-shared-patch posture rather than copying it) -- and bulkDelete resolves every id to a table index first, refusing the whole batch under strictMode before touching the table if one is missing. Both follow update/delete's own existing missing-id contract rather than a third posture. bulkDelete still returns void. Fixes#13435 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
IDataDriver.bulkUpdate is declared Promise<Record<string, unknown>[]> -- no null member. Padding the result with null for a non-strict missing id (as update() itself returns) failed tsc against that contract once the intermediate array was explicitly typed. Follow SqlDriver.bulkUpdate's own existing convention instead: omit the row entirely (if (updated) results.push(updated)) rather than inventing a second "missing" representation. Part of #13435 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9c78cb9e80490ba66d8a9ba59364bb08314d9ad4 && git checkout 9c78cb9e80490ba66d8a9ba59364bb08314d9ad4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9b21037d4198cd14105abd354b019e9323b4fe57 b96aefe383ec3d41c6ef354d9a0a26d2c59284ef && git checkout -B drift-repro 9b21037d4198cd14105abd354b019e9323b4fe57 && git merge --no-ff b96aefe383ec3d41c6ef354d9a0a26d2c59284ef
node scripts/docs-audit/affected-docs.mjs --json 9b21037d4198cd14105abd354b019e9323b4fe57
|
zhuangjianguo
commented
Aug 31, 2026
PM review — ACCEPT on substance. ⛔ It corrects an error in my dispatch order, and the correction found a bigger bug.
1. ⛔ A2.3 falsified — my Zone 1.1 was wrong, and so was the reading I inherited from triageI wrote that ⭐ And the falsification is worth more than the correction. Chasing it found:
2. ⭐ The prohibition was honoured with understanding, not just compliance#13340's standing rule — ⛔ do not copy
⇒ Only the discipline transferred — "check everything before writing anything" — not the shape. That is precisely the distinction #13340's dispatch was drawing, and it is the difference between transferring a pattern and forcing one. ⭐ The neat part: "whichever of two colliding rows is checked second always finds the first already sitting in 3. A2.1 confirmed, so STOP 1 never fired
4. The missing-id posture follows an existing convention rather than inventing a thirdZone 1.2 forbade a third posture. The seat went further than following 5. ⭐⭐ The tsc finding is the most interesting thing in this PR, and it is a latent contract violationTyping the new intermediate array surfaced a real
⇒ A shipped driver returns a value its own declared contract forbids, and an inferred 6. The four-door table, delivered as asked
All four agree: a refusal leaves the table byte-identical to before the call. ⭐ That is the pin I asked for rather than "it throws", and the ablation confirms it discriminates — 8 of 18 fail on the pre-fix blob while ⭐ Also correct: the Status
Generated by Claude Code |
zhuangjianguo
commented
Aug 31, 2026
Docs drift re-verified by hand — clean. ⛔ Not a clean bill of health for the corpus.The bot listed 4 hand-written pages + 3 release-owned, all anchored on the same thing: Searched
⇒ No page in the corpus claims anything about batch atomicity in either direction, so this diff falsifies nothing. All 7 anchored rows are mention-level. ⭐ The one place that did state it was in code, not in docs: the Generated by Claude Code |
zhuangjianguo
commented
Aug 31, 2026
Auto-merge disarmed pending one fix — found in PM review, not by CI. Recording here so another seat does not re-arm it. This PR is otherwise strong and all-green (36/36). The governed-surface predicate was re-run on the final file list and returns 0 — not governed, so ordinary queue landing applies once the fix lands. The defectIn the new consttouchedIds=newSet(updates.map((u)=>u.id));// ids from the CALLERconstsettled=table.filter((r)=>!touchedIds.has(r.id));// STRICT Set.has
...
constindex=table.findIndex((r)=>r.id==u.id);// LOOSE ==
Failure scenario:
Why the sibling door does not have it
This is a regression introduced by this PR: the previous DirectionDerive A regression test with mixed id types, plus a positive control, is required with the fix. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
…introduced the defect Neighbouring comments in this file cite the ISSUE (`[#13435]`, `[#13197]`, `[#13340]`); this one cited #13875, which is the PR that introduced the defect. Comment text only — no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
…own id resolution (objectstack-ai#13917) * fix(driver-memory): bulkUpdate's touched-row set must agree with its own id resolution `bulkUpdate` resolved each id with a loose `==` (matching `update`, one method up) but built its untouched-row set `settled` from the CALLER's ids with a strict `Set.has`. `IDataDriver.bulkUpdate` declares `id: string | number`, so a caller may name a stored `1` as `'1'` — and then the two lookups disagreed: `findIndex` resolved the row and updated it, while `settled` still carried that row's PRE-image. The row was represented twice in the projected set handed to `assertUnique` — once with the value it was vacating, once with the value it was taking — so a batch that merely HANDS a unique value from one row to another was refused with a false `UNIQUE_VIOLATION` / 409. `exceptId` does not help, since it only excludes the row currently being checked, never a sibling row of the same batch. Resolve every id to its table index first, then derive the touched set from the RESOLVED rows' own ids. Both lookups now read the same stored value and cannot drift apart — the property `updateMany` gets for free by drawing its `targetIds` from table rows. The loose resolution is deliberately preserved: narrowing it to `===` would silently change which ids resolve at all, well beyond this defect. `bulkDelete` needs no change, and this is checked rather than assumed: it has exactly ONE id comparison, and dedups on the RESOLVED table index rather than on caller input, so a mixed-type or repeated id collapses to one index by construction. Keying that set on caller ids instead would splice one index twice and take a neighbouring row with it — pinned by test. Regression test uses mixed id types, alongside a positive control with consistent id types so it cannot pass vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * docs(driver-memory): cite the issue this fix closes, not the PR that introduced the defect Neighbouring comments in this file cite the ISSUE (`[objectstack-ai#13435]`, `[objectstack-ai#13197]`, `[objectstack-ai#13340]`); this one cited objectstack-ai#13875, which is the PR that introduced the defect. Comment text only — no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * test(driver-memory): cite the issue the new block pins, matching the rest of the file Comment and describe-title text only — no assertion changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes#13435
What changed
driver-memory'sbulkUpdateandbulkDeletewere stillPromise.all(map(...))overupdate/delete, and both of those write intothe table synchronously. So a mid-batch refusal — a
UNIQUE_VIOLATION/409 onbulkUpdate, a missing-id throw understrictModeonbulkDelete— leftevery row processed before it already mutated, and the caller got a
rejection describing a batch that had partly landed. #13340 fixed the
identical shape on
bulkCreate; this PR is the third and fourth batch door.bulkUpdatenow builds and checks every pending row's post-imagebefore writing any of them. This is new construction, not a copy of a
sibling door:
updateManystamps one shareddataonto every matched row(no per-row pre-image to exclude) and
bulkCreatehas no pre-image at all(every row is new);
bulkUpdategives each id its own patch, so eachpending row gets its own
exceptIdand is checked against a projected rowset — the untouched (
settled) rows plus every already-validated pendingrow's post-image. A not-yet-processed batch row needs no look-ahead entry:
whichever of two colliding rows is checked second always finds the first
already sitting in
pending, the same incremental disciplinebulkCreate/updateManyuse, generalized to per-row patches.bulkDeleteresolves every id to a table index first — refusing thewhole batch under
strictModebefore touching the table if one is missing— and only then splices (highest index first).
update/delete's own existing contract, never athird posture: skip when
strictModeis off (the returned array simplyomits that entry —
IDataDriver.bulkUpdateis declared to resolve toa plain array of row objects (Record-of-string-to-unknown, no
nullmember), and
SqlDriver'sown
bulkUpdatealready resolves a missing id the same way:if (updated) results.push(updated)— this follows that establishedconvention rather than inventing a second one), refuse the whole batch
when it is on.
bulkDeletestill returnsvoid— no current caller reads a per-rowoutcome, so widening the return type stays out of scope (per dispatch).
InMemoryDriverclass docstring, which (since driver-memory:bulkCreateisPromise.all(map(create)), so a refused row leaves every earlier row of the batch landed —updateManyon the same driver refuses before mutating anything #13340) had acaveat paragraph explicitly calling out
bulkUpdate/bulkDeleteasnon-atomic — now updated to state all four batch doors agree.
The four-door table, filled in
updateManybulkCreatebulkUpdatebulkDeletestrictModeAll four now agree: a refusal leaves the table byte-identical to before the
call.
Zone 2 assumptions — measured
assertUnique/assertNoUniqueViolationalready accept a
rowsparameter (defaulting to the live table) and anexceptIdthat filters by row id, not index — the exact seamupdateManyalready uses for its own projected set. No new seam wasneeded.
bulkCreateischeck-then-push with no pre-image;
updateManyis check-then-mutate withone shared patch.
bulkUpdateneeded a new per-row-patch projected-setconstruction; only the discipline (check everything before writing
anything) transferred.
driver-turso'ssuper.callsinherit this fix. They do not:
TursoDriver extends SqlDriver(
@objectstack/driver-sql), which has no relationship toInMemoryDriver—super.bulkUpdate/super.bulkDeleteresolve toSqlDriver's own, separate implementations.SqlDriver.bulkDeleteis asingle
WHERE id IN (...)statement per shard (already atomic on its own);SqlDriver.bulkUpdateis a sequentialfor-awaitloop overupdate()with no transaction, which has the same defect class this PR fixes, one
layer up. Filed as driver-sql:
bulkUpdateis a sequential per-row loop with no transaction — a mid-batch refusal leaves earlier rows committed (driver-turso inherits it viasuper.) #13854 (out of scope here per the dispatch: "do NOT editdriver-turso... if it needs its own change, that is a separate card" —and the actual defect lives in
driver-sql, notdriver-turso).Grepped every
.bulkUpdate(/.bulkDelete(call site outsidedriver-sql/driver-turso's own implementations:driver-memory's owntest suite never called either method before this PR;
lifecycle-service.ts/history-cleanup.ts(the two livebulkDeletecallers) and
driver-mongodb/driver-turso's own test suites all usefresh ids or mock drivers that bypass real batch-write semantics entirely.
A tsc finding worth naming (not a STOP condition — resolved without touching the contract)
Explicitly typing the new
bulkUpdate's intermediate array surfaced a realtscerror (TS2416) that the ORIGINALPromise.all(map(update))shapenever triggered:
update()itself returnsnullfor a missing id undernon-strict mode, which is not part of
IDataDriver.update()'s declaredreturn shape either (a Promise resolving to a single plain row object,
never
null) — but that mismatch was masked byTypeScript's "any absorbs a union" behavior (
toStoredRecord's inferredreturn type collapses the success branch to effectively
any), so it neversurfaced. My new code's plain-object-typed intermediate array did
not get that same accidental pass. Resolved by following
SqlDriver's ownexisting convention for
bulkUpdate(omit a missing row from the resultrather than padding with
null) — nopackages/specchange needed.Testing
New file
memory-bulk-update-delete-atomicity.test.ts, modeled on#13340's
memory-bulk-create-atomicity.test.ts: table-byte-identical (notmerely "it throws") pins for both
bulkUpdateandbulkDelete, in bothstrictModepostures, plus a non-regression suite forupdateManyandbulkCreateand a four-door agreement test.Ablation.
git stash-free,trap ... EXIT INT TERM-guarded,absolute-path script: overwrote
memory-driver.tswith the branch's pre-fixbase blob (
git show BASE-SHA:TARGET-PATH, confirmed landed on disk viaanchored
grep -cin both directions plus agit hash-objectmatch againstthe base blob), ran the new atomicity suite against the mutated tree —
8 of 18 tests failed (table-byte-identical pins for both
bulkUpdateandbulkDelete, and the four-door agreement test specifically on those twodoors, while
bulkCreate/updateManypassed within the SAME mutated run,confirming the ablation targeted the right doors) — then the trap restored
via
git checkout HEAD -- TARGET-PATH, confirmed byte-identical to HEAD byblob hash. See the report for the full anchored-grep + blob-hash detail.
Verified at
b96aefe383(post-merge with origin/main).Generated by Claude Code