Phase 9: Faster Write-Back (CAL-15) — event-driven outbox drain #14

Merged
luckberg merged 26 commits from gsd/phase-09-faster-write-back into main 2026-06-12 21:19:17 -04:00
Owner

Phase 9: Faster Write-Back (CAL-15)

Event-driven outbox drain so created/edited/deleted events reach Fastmail in ~1–2s instead of waiting up to ~15s for the next interval tick — every existing durability guarantee preserved.

What changed

  • apps/api/src/lib/outboxTrigger.ts (new) — zero-dependency in-process node:events EventEmitter: signalOutboxDrain() / onOutboxDrain(). Single subscriber, fire-and-forget.
  • apps/api/src/broker/outboxWorker.tsscheduleOutboxDrain() wrapper with the existing isDraining guard + a drainRequested trailing-re-drain loop (reset before the recursive call); initOutboxTrigger() subscriber; the 15s setInterval body now routes through the wrapper. runOutboxDrain() internals byte-for-byte unchanged.
  • apps/api/src/routes/events.ts — four fire-and-forget signalOutboxDrain() publish sites, one after each enqueue commit (create, edit-as-move after the transaction resolves, same-calendar update, delete).
  • apps/api/src/index.tsinitOutboxTrigger() wired under isMainModule() after startOutboxWorker() (never registered in the test process).

Verification

  • Phase verification: 5/5 success criteria passed (drain-on-enqueue, optimistic-202 / no inline CalDAV, create-before-delete on moves, exactly-once per uid, 15s fallback intact).
  • TDD: RED → GREEN for the trigger-wiring suite (SC-1 / D-05 / D-07); 30/30 broker tests, 23/23 events-route tests; tsc --noEmit clean; full api suite green (modulo one pre-existing, unrelated flaky lists-share timeout).
  • Code review: 0 blockers, 4 advisory warnings (robustness/teardown), 3 info — see 09-REVIEW.md.

🤖 Generated with Claude Code

## Phase 9: Faster Write-Back (CAL-15) Event-driven outbox drain so created/edited/deleted events reach Fastmail in ~1–2s instead of waiting up to ~15s for the next interval tick — every existing durability guarantee preserved. ### What changed - **`apps/api/src/lib/outboxTrigger.ts`** (new) — zero-dependency in-process `node:events` EventEmitter: `signalOutboxDrain()` / `onOutboxDrain()`. Single subscriber, fire-and-forget. - **`apps/api/src/broker/outboxWorker.ts`** — `scheduleOutboxDrain()` wrapper with the existing `isDraining` guard + a `drainRequested` trailing-re-drain loop (reset before the recursive call); `initOutboxTrigger()` subscriber; the 15s `setInterval` body now routes through the wrapper. `runOutboxDrain()` internals byte-for-byte unchanged. - **`apps/api/src/routes/events.ts`** — four fire-and-forget `signalOutboxDrain()` publish sites, one after each enqueue commit (create, edit-as-move *after* the transaction resolves, same-calendar update, delete). - **`apps/api/src/index.ts`** — `initOutboxTrigger()` wired under `isMainModule()` after `startOutboxWorker()` (never registered in the test process). ### Verification - Phase verification: **5/5 success criteria passed** (drain-on-enqueue, optimistic-202 / no inline CalDAV, create-before-delete on moves, exactly-once per uid, 15s fallback intact). - TDD: RED → GREEN for the trigger-wiring suite (SC-1 / D-05 / D-07); 30/30 broker tests, 23/23 events-route tests; `tsc --noEmit` clean; full api suite green (modulo one pre-existing, unrelated flaky lists-share timeout). - Code review: 0 blockers, 4 advisory warnings (robustness/teardown), 3 info — see `09-REVIEW.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
luckberg added 17 commits 2026-06-12 17:10:38 -04:00
- Module-level singleton EventEmitter, single subscriber, no setMaxListeners
- Export signalOutboxDrain(): void — fire-and-forget drain signal (D-04)
- Export onOutboxDrain(handler): () => void — register/unsubscribe listener
- Only imports node:events; zero internal dependencies (no circular import risk)
- Import scheduleOutboxDrain (not yet exported — causes RED)
- Import signalOutboxDrain from outboxTrigger.ts
- Add describe block 'scheduleOutboxDrain — trigger wiring (D-09)' with 3 tests:
  Test A SC-1: signalOutboxDrain() fires drain promptly without timer advance
  Test B D-05: two mid-drain signals collapse to exactly one trailing re-drain
  Test C D-07: concurrent scheduleOutboxDrain() calls dispatch exactly once via isDraining guard
- 27 pre-existing tests unmodified and passing; 3 new tests failing (RED)
- Add import { onOutboxDrain } from outboxTrigger.js
- Add let drainRequested = false (D-05 trailing-re-drain flag)
- Export scheduleOutboxDrain(): void — isDraining guard + drainRequested loop (D-05/T-09-01)
  drainRequested=false reset precedes recursive call (Pitfall 3)
  errors caught via .catch to prevent crash (D-02/T-09-03)
- Export initOutboxTrigger(): void — registers onOutboxDrain(() => scheduleOutboxDrain())
- startOutboxWorker setInterval body: scheduleOutboxDrain() replaces runOutboxDrain().catch()
  15 * 1000 interval unchanged (D-08)
- runOutboxDrain body/isDraining guard/finally unchanged (D-02/D-07)
- Fix trigger-wiring tests: add beforeAll(initOutboxTrigger) to wire EventEmitter listener;
  fix Test C mock to return empty rows on trailing drain (correct D-07 behaviour)
- 30/30 outboxWorker tests GREEN; tsc --noEmit clean
- import signalOutboxDrain from '../lib/outboxTrigger.js'
- Site 1 (POST /create): fire-and-forget signal after outbox insert, before 202
- Site 2 (PATCH edit-as-move): signal after await db.transaction() resolves, not inside callback (D-03)
- Site 3 (PATCH same-calendar update): signal after outbox insert, before 202
- Site 4 (DELETE): signal after outbox insert, before 202
- extend outboxWorker import to include initOutboxTrigger alongside startOutboxWorker
- call initOutboxTrigger() immediately after startOutboxWorker() inside isMainModule() guard
- gated by isMainModule() so tests importing app never register the drain listener (Pitfall 4)
- 09-02-SUMMARY.md: 4 signalOutboxDrain() sites + initOutboxTrigger() startup wiring
- STATE.md: plan position advanced, metrics recorded
- ROADMAP.md: plan progress updated
- REQUIREMENTS.md: CAL-15 marked complete
docs(phase-09): evolve PROJECT.md after phase completion
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Failing after 56s
CI / api (pull_request) Successful in 1m0s
CI / harness (pull_request) Successful in 4m0s
CI / gate (pull_request) Failing after 1s
ffc1f52f2b
luckberg added 1 commit 2026-06-12 17:24:26 -04:00
docs(09): refresh code review report
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Failing after 56s
CI / api (pull_request) Successful in 59s
CI / harness (pull_request) Successful in 3m55s
CI / gate (pull_request) Failing after 1s
1bc1f134a7
luckberg added 7 commits 2026-06-12 21:02:36 -04:00
luckberg added 1 commit 2026-06-12 21:12:21 -04:00
style(09): prettier-format outboxWorker.test.ts (fix CI format check)
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m20s
CI / api (pull_request) Successful in 58s
CI / harness (pull_request) Successful in 3m52s
CI / gate (pull_request) Successful in 1s
89411ce44b
luckberg merged commit b119f11cf2 into main 2026-06-12 21:19:17 -04:00
luckberg deleted branch gsd/phase-09-faster-write-back 2026-06-12 21:19:18 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: luckberg/familysync#14