Persist snooze until and reopen it with a periodic sweep (CL-7208) - #514
Merged
Merged
Conversation
New, package-owned Postgres table for CL-7208: `@corbits/mailbox`'s enrichment (packages/inbox's only dependency for a message's status) supports only priority/classification/status -- there is no column to hold a snooze's `until` timestamp, so `POST /:id/snooze` has never had anywhere to durably record when to reopen a snoozed item. - schema.ts: `inbox.snooze` (tenantId, principalId, messageId, until), own schema, following @workbench/onboarding's pattern for a package-owned product table. - migrations.ts: `applyInboxMigrations`, following the same ledger-table pattern as onboarding/routines/notify -- registered in scripts/db-setup.ts's INSTALLED_PACKAGE_MIGRATIONS as `@corbits/inbox`. - snooze-store.ts: `setSnoozeUntil`/`clearSnoozeUntil` (plain upsert/delete), `findDueSnoozes` (a bare, non-claiming SELECT), and `claimAndReopenSnooze` -- the one operation with real teeth. It deletes the due row and flips the message back to `open` inside a single transaction: two hub replicas racing the same row only ever have one `DELETE ... RETURNING` succeed, and a throw between the delete and the status flip rolls both back, leaving the snooze row exactly as it was for the next sweep tick to retry. This is the same mail-then-claim ordering lesson CL-7209 applied to the credential-expiry sweep (act before durably marking "handled"), applied here to the claim itself so a mid-claim failure can never orphan an item snoozed forever. Verified against a real Postgres (not just typechecked): a scratch-db integration test proves setSnoozeUntil/findDueSnoozes/ claimAndReopenSnooze/clearSnoozeUntil all behave correctly, including the race guard (a second claim on an already-claimed row returns false) and the self-healing no-op (claiming a row whose message left `snoozed` some other way cleans up the stale row instead of reopening it). Moves `postgres` from a devDependency to a runtime one (migrations.ts uses it directly, like onboarding/routines/notify) and adds `drizzle-orm`.
`POST /:id/snooze` accepted an `until` timestamp, validated its type,
and echoed it back -- but only ever persisted `{status: "snoozed"}`.
Nothing recorded `until` or ever reopened the item: a snoozed
approval or credential-reconnect sat under the snoozed filter
forever, invisible, with no reminder ever firing.
- routes.ts: `until` is now required and parsed at the boundary with
arktype (never `as`-cast), rejected with 400 if it doesn't parse as
a timestamp or has already passed -- silently snoozing forever is
exactly the bug being fixed, so a past `until` is refused rather
than accepted. `until` is persisted via `setSnoozeUntil` *before*
the status flips to `snoozed`; if the flip then fails, the snooze
row is rolled back too rather than left orphaned.
- apps/hub/src/inbox-unsnooze-sweep.ts: a small periodic sweep
(`tickInboxUnsnoozeSweep`/`createInboxUnsnoozeSweep`) mirroring
`credential-expiry-sweep.ts` and `routine-scheduler.ts` -- the only
other periodic loops in this hub -- rather than inventing a new
scheduling primitive. `@corbits/notify`'s `createNotifyDispatcher`
is shaped around sink delivery (attempts, backoff, a
`SinkRegistry`), not a status flip, and the routine scheduler's
`RoutineStore` is routine-domain-specific; neither fits "reopen a
mailbox row once its snooze elapses" without bending its semantics,
so this repeats the small-setInterval-plus-own-due-scan shape both
already use. Wired into apps/hub/src/index.ts next to
credentialExpirySweep, on the same mailboxDb/mailboxBus every other
mailbox consumer there shares, and stopped in shutdown alongside
the others.
Tests: route-level rejection of a missing/non-string/unparseable/past
`until`; a DB-free unit test of the sweep tick against a fake store
(reopens every due row and publishes once per actual reopen, does
not publish when a claim reports no-op, and a per-row throw is
swallowed -- logged, not propagated -- so one bad row can't abort the
rest of the tick).
…-7208) A Critique pass on the two prior CL-7208 commits found and reproduced a real race against a live Postgres: `/:id/snooze` persisted `until` and flipped `status` to `snoozed` as two independent statements. A sweep tick's `claimAndReopenSnooze` landing in the gap between them could see the snooze row before the status flip landed, find the message not yet `snoozed`, no-op, and delete the row as harmless-looking cleanup -- and then the route's own status flip would still land afterward, leaving the message stuck `snoozed` with the row that was supposed to reopen it already gone. That is exactly the bug CL-7208 exists to fix, reintroduced by the un-transacted write order. Both writes now run inside one `db.transaction`: an uncommitted insert is invisible to any other transaction under read-committed isolation, so a concurrent claim can never observe the snooze row until the status flip has also committed alongside it. A missing message (enrichMailboxMessage returning false) throws a sentinel caught outside the transaction, rolling the insert back too rather than leaving an orphaned row. Adds a regression test that races the route's own snooze write against a concurrent claimAndReopenSnooze call and asserts the message is never left `snoozed` with its snooze row already gone -- whichever side wins the race, the result must be either genuinely `open` or `snoozed` with the row still there for the next tick. Verified by running it against a real Postgres repeatedly (not just typechecked): 8/8 runs green after the fix, reproducibly red before it.
TheGreatAxios
force-pushed
the
cl-7208-inbox-snooze-reopen
branch
from
August 30, 2026 21:29
29b9f2d to
9843bde
Compare
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 #510 (CL-7207,
cl-7207-bulk-ops-atomicity), which is itself stacked on #486 (CL-7206). Base this PR againstcl-7207-bulk-ops-atomicity, notmain, until both merge.POST /:id/snoozeaccepted anuntiltimestamp, validated its type, and echoed it back in the response — but the store call underneath only ever flipped the mailbox row's status to"snoozed". Nothing recordeduntilor ever reopened the item: a snoozed approval or credential-reconnect sat under the snoozed filter forever, invisible, with no reminder ever firing.Changes
packages/inbox/src/schema.ts(new):@corbits/mailbox'senrichMailboxMessageonly supportspriority/classification/status— no column for a timestamp — so this adds the one product table@corbits/inboxneeds:inbox.snooze(tenantId, principalId, messageId, until), its own Postgres schema, following@workbench/onboarding's pattern for a package-owned table.packages/inbox/src/migrations.ts:applyInboxMigrations, the same hand-rolled ledger-table pattern as onboarding/routines/notify, registered inscripts/db-setup.ts'sINSTALLED_PACKAGE_MIGRATIONSas@corbits/inbox.packages/inbox/src/snooze-store.ts(new):setSnoozeUntil/clearSnoozeUntil,findDueSnoozes(a bare, non-claimingSELECT), andclaimAndReopenSnooze— a single transaction that deletes the due row and, only if that succeeds, flips the message back toopenif it's stillsnoozed. Two replicas racing the same row: the second'sDELETE ... RETURNINGsimply returns nothing (the first already deleted it). A throw anywhere inside rolls the whole transaction back, so a failed claim leaves the snooze row exactly as it was for the next tick to retry — the same mail-then-claim ordering lesson CL-7209 applied to the credential-expiry sweep, applied here to the claim itself.packages/inbox/src/routes.ts:/:id/snoozenow requiresuntil, parsed at the boundary with arktype (neveras-cast) per AGENTS.md, and rejects with 400 if it doesn't parse as a valid timestamp or has already passed — silently snoozing forever is exactly the bug being fixed, so a pastuntilis refused rather than accepted or silently reopened. Both the snooze-until write and the status flip run inside onedb.transaction(see "Race found and fixed" below for why).apps/hub/src/inbox-unsnooze-sweep.ts(new): a small periodic sweep (tickInboxUnsnoozeSweep/createInboxUnsnoozeSweep) mirroringcredential-expiry-sweep.tsandroutine-scheduler.ts— the only other periodic loops in this hub — rather than inventing a new scheduling primitive.Which scheduling primitive, and why not the others
I checked
@corbits/notify'screateNotifyDispatcherand@corbits/routines' scheduler machinery first. Neither fits:createNotifyDispatcheris shaped around external-sink delivery (attempts, backoff, aSinkRegistry) — a status flip on our own table isn't a delivery, and bending its retry/attempt semantics onto this would be a worse fit than writing the ~15 lines this needs directly.RoutineStoreis routine-domain-specific (fire schedules, launches) — nothing here is a routine.The actual fit is the pattern both
routine-scheduler.tsandcredential-expiry-sweep.tsalready use: a smallsetIntervaltick, alistDue*/claim*store method, a standalone exported tick function for deterministic tests. That's exactly what CL-7209 reused too (the credential-expiry sweep's own due-scan, not a new mechanism) — this PR repeats that established idiom rather than building a third parallel poller shape. Wired intoapps/hub/src/index.ts'screateHub()next tocredentialExpirySweep, sharing the existingmailboxDb/mailboxBus, stopped in shutdown alongside the others.Race found and fixed mid-review
A Critique pass on the first two commits found and reproduced against a real Postgres a genuine race: persisting
untiland flippingstatustosnoozedas two independent statements let a concurrent sweep tick'sclaimAndReopenSnoozeland in the gap — see the row before the flip, find the message not yetsnoozed, no-op, and delete the row as harmless-looking cleanup — and then the route's own status flip would still land afterward, leaving the message stucksnoozedwith the row that was supposed to reopen it already gone. That is exactly this ticket's bug, reintroduced by the un-transacted write order.Fixed by running both writes in one
db.transaction: an uncommitted insert is invisible to any other transaction under read-committed isolation, so a concurrent claim can never observe the row until the status flip has also committed alongside it. The third commit adds a regression test that races the route's own write against a concurrent claim and asserts the message is never leftsnoozedwith its row gone — verified red-then-green against a real Postgres (reproducibly failed before the fix across 8/8 runs, passed 8/8 after).Tests
until(routes.test.ts).packages/inbox/test/snooze-store.test.ts(DB-gated, scratch-database pattern fromdelivery.test.ts): persists and reads back a due row, claims and reopens it, a second claim on an already-claimed row returns false, a no-longer-snoozed message's stale row gets cleaned up without a spurious reopen, and the TOCTOU regression test above. Ran against a real local Postgres repeatedly during development, not just typechecked.apps/hub/test/inbox-unsnooze-sweep.test.ts: DB-free unit test of the tick loop against a fake store — reopens every due row and publishes once per actual reopen, doesn't publish on a no-op claim, and a per-row throw is swallowed (reported, not propagated) so one bad row can't abort the rest of the tick.Verification
cd packages/inbox && bun run typecheck && bun test— 44 pass (unit), 9 skip locally (DB-gated, noDATABASE_URLin CI-less local runs) — separately ran with a real Postgres: 49 pass, 0 fail, including the new migration genuinely applying (@corbits/inbox: 0001_snooze).cd apps/hub && bun run typecheck && bun test test/inbox-unsnooze-sweep.test.ts— 3 pass, 0 fail.bunx prettier --checkandbunx eslinton all touched/new files — clean.Linear: CL-7208