Skip to content

Make inbox bulk ops report partial progress and surface walk failures (CL-7207) - #510

Merged
TheGreatAxios merged 2 commits into
cl-7206-inbox-cursor-filterfrom
cl-7207-bulk-ops-atomicity
Aug 30, 2026
Merged

TheGreatAxios merged 2 commits into
cl-7206-inbox-cursor-filterfrom
cl-7207-bulk-ops-atomicity

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

Stacked on #486 (CL-7206, cl-7206-inbox-cursor-filter) — base this PR against that branch, not main, until it merges.

mark-all-read, clear-done, and /counts in packages/inbox/src/routes.ts shared a paging walk (listAllOpen) that silently break-ed the moment decodeMailboxListCursor returned null for a nextCursor it had just been handed — a rolling deploy landing two hub versions against @corbits/mailbox could change a cursor's shape mid-walk, and the caller would see a 200 having silently stopped partway through the inbox ({marked: 40} for a 5000-row 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 from scratch.

Changes

  • packages/inbox/src/walk.ts (new): extracts the paging walk into walkAllOpen, driven through an injected listPage so its failure modes are directly unit-testable. Throws IncompleteWalkError on an undecodable cursor, a cursor that doesn't advance, or exceeding MAX_WALK_PAGES (1000 pages × 100 rows/page = 100k rows) — instead of silently truncating or looping forever.
  • packages/inbox/src/bulk.ts (new): extracts runBulkOperation, 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-done route a failed walk through listAllOpenOrReport, which reports it via @corbits/error-sink and answers 500 with a refId instead of continuing with partial/wrong data.
    • mark-all-read's status flip + read flip run inside db.transaction so a throw between them never leaves a row half-mutated.
    • Both bulk routes now report {marked/cleared, failed, complete}. A 200 is reserved for failed === 0; any per-item failure answers 207 instead — 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 bare 200 with a failed count 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/cleared says how many succeeded. The per-item failure is also reported through @corbits/error-sink with 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.ts against runBulkOperation directly, and the walk failure modes (undecodable cursor, non-advancing cursor, page cap) are fully unit-tested in walk.test.ts against walkAllOpen directly with injected listPage stubs — 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 drives POST /mark-all-read or POST /clear-done through a genuine mix of succeeding/failing items and asserts the 207/{failed} response end-to-end — db in routes.test.ts is 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 --check and bunx eslint on touched files — clean.
  • Reviewed by Greybeard (approach) and Critique (implementation); Critique's one correctness-adjacent finding (the 200-always-for-partial-failure gap) is fixed in this PR.

Linear: CL-7207

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.
@TheGreatAxios
TheGreatAxios merged commit 1964daa into main Aug 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant