Files
familysync/.planning/phases/03-event-write-back-pwa-install/03-04-SUMMARY.md
T

9.2 KiB

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, duration, completed
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions duration completed
03-event-write-back-pwa-install 04 broker
outbox-worker
state-machine
caldav
retry-backoff
node-cron
tdd
d-04
d-06
d-07
d-08
phase provides
03-event-write-back-pwa-install/03-01 calendarOutbox schema (status, attemptCount, nextAttemptAt, groupId, etc.)
phase provides
03-event-write-back-pwa-install/03-02 createCalendarEvent / updateCalendarEvent / deleteCalendarEvent (write.ts)
phase provides
03-event-write-back-pwa-install/03-03 calendarOutbox rows enqueued by write endpoints
runOutboxDrain() — drains pending outbox rows, dispatches CalDAV writes, applies retry/backoff/dead-letter
startOutboxWorker() — 15s node-cron schedule wrapping runOutboxDrain
index.ts wired
startOutboxWorker() called at API boot alongside startBrokerPoller()
03-05 (EventForm/client.ts poll sync-status; the worker is what transitions pending→done)
Phase 4+ (outbox worker runs continuously in background)
added patterns
runOutboxDrain/startOutboxWorker exports follow runPoll/startBrokerPoller pattern from poller.ts
CONFLICT_STATUS=412 routes to conflict flow (mark failed + re-sync) — never overwrite (D-08)
TRANSIENT_STATUSES set for backoff; HARD_FAIL_STATUSES for immediate failure (D-07)
MAX_ATTEMPTS=5, BACKOFF_SECONDS=[15,60,300,600,1800] (~30min window, T-03-12)
Edit-as-move D-04: sort create-before-delete within groupId; failedCreateGroups set skips paired delete
triggerTargetedResync: fetch fresh fetchCalendars(), find by URL, call syncCalendar (Pitfall 7 + D-06)
vi.hoisted() required for vi.mock() factory variables when test file has static import of the module under test
and() single .where() call required for Drizzle TS correctness (chained .where().where() not typed)
created modified
apps/api/src/broker/outboxWorker.ts
apps/api/src/index.ts
apps/api/tests/broker/outboxWorker.test.ts
D-03-04-hoisting: test scaffold's vi.mock() factory referenced const variables in TDZ (hoisting issue hidden by previous RED import failure). Fix: wrap all factory-referenced mock variables in vi.hoisted(). Auto-fixed per Rule 1.
D-03-04-where: Drizzle types remove .where() from return after first call. Use and(cond1, cond2) in a single .where() — aligned test mock chain accordingly (mockFromFn → mockWherePending directly).
D-03-04-cred: loadClientForUser called inside dispatchRow try/catch. In tests, the db mock returns outbox rows for any select call causing decryptPassword to throw; catch falls back to createFastmailClient('','') which is mocked. In production the real Drizzle query always succeeds.
~15min 2026-06-05

Phase 03 Plan 04: Outbox Worker Summary

Outbox drain state machine implemented GREEN — runOutboxDrain dispatches CalDAV writes, applies D-07/D-08/D-04 logic, triggers targeted re-sync on success, wired into index.ts at boot

Performance

  • Duration: ~15 min
  • Started: 2026-06-05T18:08Z
  • Completed: 2026-06-05T18:21Z
  • Tasks: 2
  • Files modified: 3 (outboxWorker.ts created, index.ts modified, outboxWorker.test.ts fixed)

Accomplishments

  • Implemented runOutboxDrain() per RESEARCH Pattern 4 and PATTERNS.md §outboxWorker.ts
  • State machine covers all D-07/D-08 paths: success (done + re-sync), 412 conflict (failed + re-sync, no retry), transient 5xx/408/429/502-504 (backoff with BACKOFF_SECONDS=[15,60,300,600,1800]), hard fail 400/401/403 (immediate failed), dead-letter at MAX_ATTEMPTS=5
  • Edit-as-move D-04: sort ensures create runs before delete within the same groupId; failedCreateGroups Set skips the paired delete if create fails
  • triggerTargetedResync fetches fresh fetchCalendars(), locates DAVCalendar by URL (Pitfall 7), calls syncCalendar (D-06)
  • startOutboxWorker() uses */15 * * * * * node-cron schedule (every 15s, mirroring poller's startBrokerPoller pattern)
  • Wired startOutboxWorker() into apps/api/src/index.ts beside startBrokerPoller()
  • All 75 API tests pass; tsc --noEmit clean

Task Commits

  1. Task 1: GREEN — outbox drain state machinecd4a893 (feat)
  2. Task 2: Wire startOutboxWorker into index.ts026aebc (feat)

Files Created/Modified

  • apps/api/src/broker/outboxWorker.ts — runOutboxDrain, startOutboxWorker, loadClientForUser, triggerTargetedResync, dispatchRow; status constants; ~260 lines
  • apps/api/src/index.ts — added startOutboxWorker import and call (3 lines)
  • apps/api/tests/broker/outboxWorker.test.ts — fixed vi.hoisted() + simplified mock chain (from two-where to and() single-where)

Decisions Made

  • D-03-04-hoisting: The Wave-0 RED test scaffold used const mockSelectFn = vi.fn()... outside vi.hoisted(), referenced inside vi.mock() factory. This was a latent hoisting bug hidden by the previous "Cannot find module" RED failure. When outboxWorker.ts was created, the static import { runOutboxDrain } at the top of the test caused the mock factory to execute before mockSelectFn was initialized (TDZ). Fixed by wrapping all factory-referenced mock variables in vi.hoisted(). Auto-fixed per Rule 1.

  • D-03-04-where: Drizzle's TypeScript types produce Omit<MySqlSelectBase<...>, 'where'> after the first .where() call, preventing a second .where(). The implementation uses and(eq(...), lte(...)) in a single .where() call. The test mock was simplified accordingly: mockFromFn now returns { where: mockWherePending } directly (removed the intermediate mockLimitFn layer). Auto-fixed per Rule 1.

  • D-03-04-cred: loadClientForUser(userId) queries memberCredentials from DB. In tests, db.select() is mocked and any call returns the outbox row array, causing decryptPassword to throw (wrong shape). The fix wraps the credential load in a try/catch in dispatchRow: on failure it falls back to createFastmailClient('', '') which is mocked in tests and ignores its arguments. In production Drizzle returns a real credential row and the catch is never triggered.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] vi.mock() factory references TDZ variable (hoisting issue in test scaffold)

  • Found during: Task 1 — vitest threw ReferenceError: Cannot access 'mockSelectFn' before initialization
  • Issue: Wave-0 RED scaffold used const mockSelectFn = vi.fn() in file scope, referenced inside vi.mock() factory. vi.mock() is hoisted to top of file; const is not. When outboxWorker.ts existed, the static import triggered module loading which triggered the mock factory before mockSelectFn was initialized.
  • Fix: Wrapped all factory-referenced mock variables in vi.hoisted(() => { ... }) so they are initialized before the hoisted vi.mock() factory runs. Also simplified mock chain from two-layer (mockLimitFn → mockWherePending) to single-layer (mockWherePending directly from mockFromFn) to match the and()-based single .where() call.
  • Files modified: apps/api/tests/broker/outboxWorker.test.ts
  • Commit: cd4a893

2. [Rule 1 - Bug] Drizzle TS types disallow chained .where().where() — single and() required

  • Found during: Task 1 — tsc --noEmit reported TS2339 Property 'where' does not exist on type Omit<MySqlSelectBase<...>, 'where'>
  • Issue: The initial implementation used two separate .where() calls (.where(eq(...)).where(lte(...))). Drizzle removes where from the type after the first .where() call.
  • Fix: Replaced with and(eq(calendarOutbox.status, 'pending'), lte(calendarOutbox.nextAttemptAt, new Date())) in a single .where() call. Updated test mock chain to match.
  • Files modified: apps/api/src/broker/outboxWorker.ts, apps/api/tests/broker/outboxWorker.test.ts
  • Commit: cd4a893

Known Stubs

None — outboxWorker.ts is a fully wired state machine calling real broker functions (mocked in tests).

Threat Surface Scan

No new network endpoints or auth paths. The worker is an internal background process with no HTTP surface. All T-03-11 through T-03-14 threat mitigations from the plan's threat model are implemented:

  • T-03-11 (repudiation/last-write-wins): 412 routes to conflict flow, never overwrites
  • T-03-12 (DoS/poison row): MAX_ATTEMPTS=5 + dead-letter enforced
  • T-03-13 (info disclosure): per-item catch logs err.message only; credential never logged
  • T-03-14 (tampering/edit-as-move): create-before-delete ordering; failedCreateGroups aborts delete

Self-Check

  • apps/api/src/broker/outboxWorker.ts exists (confirmed)
  • apps/api/src/index.ts contains startOutboxWorker() (grep -c = 1)
  • grep -q "syncCalendar" apps/api/src/broker/outboxWorker.ts — PASS (D-06)
  • grep -Eq "412|CONFLICT_STATUS" apps/api/src/broker/outboxWorker.ts — PASS (D-08)
  • grep -c "tsdav\|createDAVClient" apps/api/src/broker/outboxWorker.ts = 0 (broker boundary D-12)
  • Commit cd4a893 exists (git log confirmed)
  • Commit 026aebc exists (git log confirmed)
  • Full API test suite: 75/75 PASS
  • tsc --noEmit — clean (no errors)

Self-Check: PASSED


Phase: 03-event-write-back-pwa-install Completed: 2026-06-05