Make inbox bulk ops report partial progress and surface walk failures (CL-7207) - #510
Merged
TheGreatAxios merged 2 commits intoAug 30, 2026
Merged
Conversation
New, DB-free pure modules for CL-7207, unit-tested without a live
Postgres (bulk.ts already documents this as the package's convention
for testing product rules against the mailbox FKs):
- walk.ts: walkAllOpen() extracts the paging walk `/counts`,
mark-all-read, and clear-done all share, driven through an
injected listPage so its failure modes are directly testable: an
undecodable nextCursor, a cursor that never advances, and a page
cap (MAX_WALK_PAGES) now throw IncompleteWalkError instead of
looping forever or silently breaking.
- bulk.ts: runBulkOperation() applies a per-item action without
letting one item's failure abort the rest, returning
{succeeded, failed} instead of throwing out of the whole loop.
Committed together with their tests, rather than as a red-then-green
pair: both are net-new extractions with no prior standalone behavior
to reproduce as a failing test, and typechecking one half without
the other isn't meaningful. The next commit wires both into
routes.ts, which is where CL-7207's actual user-visible behavior
changes.
… (CL-7207)
mark-all-read, clear-done, and /counts shared a walk (listAllOpen)
that silently truncated on an undecodable nextCursor -- rolling
deploy skew between two hub versions against @corbits/mailbox could
change a cursor's shape mid-walk, and the caller would see success
having silently stopped partway through the inbox. mark-all-read
and clear-done also mutated one item at a time with no transaction
tying each item's writes together and no per-item error isolation:
a throw on item N threw the whole handler as a 500, left items
before N mutated, item N in a state the UI had never rendered
(done but unread), and everything after N untouched, with no way
for a retry to resume instead of re-walking and re-mutating
everything from scratch.
- listAllOpen now delegates to walk.ts's walkAllOpen, and its
callers (/counts, mark-all-read, clear-done) report a failed
walk through @corbits/error-sink and answer 500 with a refId
instead of silently returning wrong data.
- mark-all-read's status flip + read flip, and clear-done's trash,
run inside db.transaction so a failure between them never leaves
a row half-mutated.
- Both now use bulk.ts's runBulkOperation so one item's failure
doesn't abort the rest: the response is {marked/cleared, failed,
complete} rather than a 500 with no signal of how far the
operation got. A 200 with `failed: 0` is the only "fully done"
shape; any failed item answers 207 instead, so a caller that
only checks the status code (not the body) can't mistake a
partial run for a complete one.
Adds @corbits/error-sink as a dependency of @corbits/inbox.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on #486 (CL-7206,
cl-7206-inbox-cursor-filter) — base this PR against that branch, notmain, until it merges.mark-all-read,clear-done, and/countsinpackages/inbox/src/routes.tsshared a paging walk (listAllOpen) that silentlybreak-ed the momentdecodeMailboxListCursorreturnednullfor anextCursorit had just been handed — a rolling deploy landing two hub versions against@corbits/mailboxcould change a cursor's shape mid-walk, and the caller would see a200having silently stopped partway through the inbox ({marked: 40}for a 5000-row inbox).mark-all-readandclear-donealso mutated one item at a time with no transaction tying each item's writes together and no per-item error isolation: a throw on item N threw the whole handler as a 500, left items before N mutated, item N in a state the UI had never rendered (done-but-unread), and everything after N untouched, with no way for a retry to resume instead of re-walking and re-mutating from scratch.Changes
packages/inbox/src/walk.ts(new): extracts the paging walk intowalkAllOpen, driven through an injectedlistPageso its failure modes are directly unit-testable. ThrowsIncompleteWalkErroron an undecodable cursor, a cursor that doesn't advance, or exceedingMAX_WALK_PAGES(1000 pages × 100 rows/page = 100k rows) — instead of silently truncating or looping forever.packages/inbox/src/bulk.ts(new): extractsrunBulkOperation, which applies a per-item action without letting one item's failure abort the rest, returning{succeeded, failed}.packages/inbox/src/routes.ts:/counts,mark-all-read,clear-doneroute a failed walk throughlistAllOpenOrReport, which reports it via@corbits/error-sinkand answers500with arefIdinstead of continuing with partial/wrong data.mark-all-read's status flip + read flip run insidedb.transactionso a throw between them never leaves a row half-mutated.{marked/cleared, failed, complete}. A200is reserved forfailed === 0; any per-item failure answers207instead — a caller that only checks the HTTP status code (not the body) can no longer mistake a partial run for a complete one. This was added after a Critique pass flagged that a bare200with afailedcount buried in the body doesn't satisfy "the caller must be able to tell a complete run from a partial one."How a caller tells complete vs. partial
200+{failed: 0, complete: true}— every eligible item was processed.207+{failed: N > 0, complete: false}— some items failed;marked/clearedsays how many succeeded. The per-item failure is also reported through@corbits/error-sinkwith the item id, so a person can find exactly which rows failed and why.500+{error, refId}— the walk itself failed (undecodable cursor, non-advancing cursor, or the walk exceeded its page cap); no partial counts are returned because the eligible-item set itself couldn't be established.Known test-coverage gap
Per-item failure and per-item catch-and-continue logic is fully unit-tested in
bulk.test.tsagainstrunBulkOperationdirectly, and the walk failure modes (undecodable cursor, non-advancing cursor, page cap) are fully unit-tested inwalk.test.tsagainstwalkAllOpendirectly with injectedlistPagestubs — this is the mid-walk-failure test the ticket's outcome 4 asks for. What's not covered is a live-Postgres, route-level integration test that drivesPOST /mark-all-readorPOST /clear-donethrough a genuine mix of succeeding/failing items and asserts the207/{failed}response end-to-end —dbinroutes.test.tsis a Proxy stub (throws on any access), which can prove "the walk fails loudly" but can't fake a real per-item write failure without a live database. Flagging this rather than skipping silently.Verification
cd packages/inbox && bun run typecheck && bun test— 40 pass, 3 skip (pre-existing, DB-gated, unrelated), 0 fail.bunx prettier --checkandbunx eslinton touched files — clean.200-always-for-partial-failure gap) is fixed in this PR.Linear: CL-7207