166 lines
9.9 KiB
Markdown
166 lines
9.9 KiB
Markdown
---
|
|
phase: 03-event-write-back-pwa-install
|
|
plan: 04
|
|
type: tdd
|
|
wave: 3
|
|
depends_on: ["03-02", "03-03"]
|
|
files_modified:
|
|
- apps/api/src/broker/outboxWorker.ts
|
|
- apps/api/src/index.ts
|
|
- apps/api/tests/broker/outboxWorker.test.ts
|
|
autonomous: true
|
|
requirements: [CAL-04, CAL-05, CAL-06, CAL-07]
|
|
user_setup: []
|
|
|
|
must_haves:
|
|
truths:
|
|
- "The worker drains pending outbox rows, builds the VEVENT, PUTs/DELETEs via the broker, and triggers a targeted single-calendar re-sync on success (D-06)"
|
|
- "Transient failures (5xx/network/timeout) back off exponentially within a bounded window; max attempts → dead (D-07)"
|
|
- "Hard failures (400/401/403) stop immediately as failed (D-07)"
|
|
- "412 conflicts route OUT of the retry loop into the conflict flow: mark failed, re-sync, no overwrite (D-08)"
|
|
- "Edit-as-move processes the create row before the linked delete row; create-fail aborts the delete (D-04)"
|
|
- "The worker is started from index.ts as a sibling to the ctag poller"
|
|
artifacts:
|
|
- path: "apps/api/src/broker/outboxWorker.ts"
|
|
provides: "runOutboxDrain + startOutboxWorker (state machine, retry/backoff, re-sync)"
|
|
exports: ["runOutboxDrain", "startOutboxWorker"]
|
|
min_lines: 60
|
|
- path: "apps/api/src/index.ts"
|
|
provides: "startOutboxWorker() wired at startup"
|
|
contains: "startOutboxWorker"
|
|
key_links:
|
|
- from: "apps/api/src/broker/outboxWorker.ts"
|
|
to: "broker/write.ts"
|
|
via: "create/update/deleteCalendarEvent"
|
|
pattern: "(create|update|delete)CalendarEvent"
|
|
- from: "apps/api/src/broker/outboxWorker.ts"
|
|
to: "broker/sync.ts syncCalendar"
|
|
via: "targeted re-sync on confirm (D-06)"
|
|
pattern: "syncCalendar"
|
|
- from: "apps/api/src/index.ts"
|
|
to: "startOutboxWorker"
|
|
via: "background worker startup"
|
|
pattern: "startOutboxWorker"
|
|
---
|
|
|
|
<objective>
|
|
Build the outbox worker — the load-bearing async engine of D-05/06/07/08. It drains
|
|
pending `calendar_outbox` rows, builds the VEVENT (Plan 02 `vevent.ts`), writes through
|
|
the broker (Plan 02 `write.ts`), classifies the response (transient/hard/conflict),
|
|
and on success triggers a targeted single-calendar re-sync (Plan 03 endpoints filled the
|
|
queue; existing `sync.ts` re-syncs). Then wire it into `index.ts` beside the ctag poller.
|
|
|
|
Purpose: this closes the create/edit/delete loop end-to-end — after this plan a queued
|
|
write actually reaches Fastmail and the cache becomes authoritative. Built TDD because
|
|
the state machine (backoff, dead-letter, 412 routing, edit-as-move ordering) is the
|
|
highest-risk logic in the phase.
|
|
|
|
Output: `outboxWorker.ts` GREEN against Plan 01's state-machine tests; worker started at boot.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md
|
|
@.planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md
|
|
@apps/api/src/broker/poller.ts
|
|
@apps/api/src/broker/sync.ts
|
|
@apps/api/src/index.ts
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="tdd" tdd="true">
|
|
<name>Task 1: GREEN — outbox drain state machine (outboxWorker.ts)</name>
|
|
<files>apps/api/src/broker/outboxWorker.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
|
|
<read_first>
|
|
- apps/api/tests/broker/outboxWorker.test.ts (RED state-machine stubs from Plan 01 — the contract)
|
|
- .planning/phases/03-event-write-back-pwa-install/03-RESEARCH.md (§Pattern 4 — full runOutboxDrain reference; status classification sets; §Pitfall 5 edit-as-move; §Pitfall 7 DAVCalendar fetch for re-sync; §Pitfall 4 etag re-fetch)
|
|
- apps/api/src/broker/poller.ts (analog — runX/startX pair, node-cron schedule, per-item error isolation, decrypt-then-client pattern, Drizzle select/where/limit)
|
|
- apps/api/src/broker/sync.ts (syncCalendar signature: client, davCal, userId)
|
|
- apps/api/src/broker/write.ts (create/update/deleteCalendarEvent — from Plan 02)
|
|
- apps/api/src/broker/vevent.ts (buildVeventString — from Plan 02)
|
|
- .planning/phases/03-event-write-back-pwa-install/03-PATTERNS.md (§outboxWorker.ts — exact poller-derived patterns)
|
|
</read_first>
|
|
<behavior>
|
|
RED → GREEN. With mocked db, write.ts, sync.ts, and Fastmail client, tests assert:
|
|
- pending row + mock create response 204/201 → status='done' AND triggerTargetedResync called for that calendarUrl (D-06).
|
|
- mock response 412 → status='failed', re-sync triggered, NO retry, NO overwrite (D-08 conflict flow).
|
|
- mock response 500 (transient) → status stays 'pending', attemptCount incremented, nextAttemptAt advanced by the backoff schedule (D-07).
|
|
- transient failures repeated until attemptCount === MAX_ATTEMPTS → status='dead'.
|
|
- mock response 401/403/400 (hard) → status='failed' immediately, no retry (D-07).
|
|
- edit-as-move pair (shared groupId): the 'create' row is dispatched before the linked 'delete' row; if create fails, the delete is NOT executed (D-04 — duplicate is recoverable, lost event is not).
|
|
</behavior>
|
|
<action>
|
|
Implement `runOutboxDrain()` and `startOutboxWorker()` per RESEARCH.md Pattern 4. Constants: `MAX_ATTEMPTS=5`, `BACKOFF_SECONDS=[15,60,300,600,1800]`, `TRANSIENT_STATUSES={408,429,500,502,503,504}`, `HARD_FAIL_STATUSES={400,401,403}`, `CONFLICT_STATUS=412`. Select `WHERE status='pending' AND next_attempt_at <= NOW()` limit 10. For each row: load the owning member's credential+client (decrypt via crypto.js + createFastmailClient like poller.ts), build the VEVENT via `buildVeventString` from the row payload for create/update, call the matching write.ts function, classify the Response status. On success or 412 call `triggerTargetedResync(calendarUrl, userId)` which fetches calendars via `client.fetchCalendars()`, finds the DAVCalendar by url (Pitfall 7), and calls `syncCalendar` — this captures the fresh etag/objectUrl (Pitfall 4). Update outbox status with the Drizzle update pattern. Order edit-as-move: process rows ordered so a row with `operation='create'` and a groupId runs before its sibling `operation='delete'`; on create failure skip the linked delete. Per-row try/catch logs without crashing the loop; never log decrypted passwords (T-03-04). `startOutboxWorker` schedules `runOutboxDrain` every 15s (node-cron `*/15 * * * * *` or setInterval), mirroring `startBrokerPoller`.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/luc/Projects/familysync && pnpm --filter @familysync/api test -- broker/outboxWorker && pnpm --filter @familysync/api exec tsc --noEmit</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- outboxWorker.test.ts GREEN for all six behaviors (done, 412-conflict, backoff, dead, hard-fail, edit-as-move order).
|
|
- `grep -q "syncCalendar" apps/api/src/broker/outboxWorker.ts` (D-06 re-sync).
|
|
- `grep -Eq "412|CONFLICT_STATUS" apps/api/src/broker/outboxWorker.ts` (D-08).
|
|
- tsc --noEmit passes.
|
|
</acceptance_criteria>
|
|
<done>The outbox worker drains, writes, classifies, re-syncs, and handles backoff/dead/conflict/edit-as-move exactly per D-04/06/07/08; tests GREEN.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Wire startOutboxWorker into index.ts beside the ctag poller</name>
|
|
<files>apps/api/src/index.ts</files>
|
|
<read_first>
|
|
- apps/api/src/index.ts (existing — startBrokerPoller() is called near the bottom; mirror placement/import style)
|
|
- apps/api/src/broker/outboxWorker.ts (from Task 1 — exports startOutboxWorker)
|
|
</read_first>
|
|
<action>
|
|
Add `import { startOutboxWorker } from './broker/outboxWorker.js'` next to the existing poller import. Call `startOutboxWorker()` immediately after the existing `startBrokerPoller()` call, with a one-line comment noting it drains the D-05 outbox every 15s. Do not move or alter the poller, route mounts, OIDC guard, or server-start guard.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/luc/Projects/familysync && grep -q "startOutboxWorker()" apps/api/src/index.ts && pnpm --filter @familysync/api exec tsc --noEmit && pnpm --filter @familysync/api test</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c "startOutboxWorker()" apps/api/src/index.ts` ≥1.
|
|
- Full API test suite GREEN; tsc --noEmit passes.
|
|
</acceptance_criteria>
|
|
<done>The outbox worker starts at API boot alongside the poller; full API suite green.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| worker → Fastmail | The worker is the only component that drains the outbox to Fastmail |
|
|
| stored payload → VEVENT | Member-supplied payload is reconstructed into an ICS PUT |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-03-11 | Repudiation | silent last-write-wins on concurrent edit | mitigate | 412 If-Match conflict routes to conflict flow (re-sync + warn), never overwrites (D-08) |
|
|
| T-03-12 | Denial of Service | a poison row retrying forever | mitigate | MAX_ATTEMPTS=5 then dead-letter; bounded backoff window (~30 min) per D-07 |
|
|
| T-03-13 | Information Disclosure | logging decrypted app password during dispatch | mitigate | Per-item catch logs `err.message` only; never the credential (poller T-03-04 pattern) |
|
|
| T-03-14 | Tampering | partial-failure data loss on edit-as-move | mitigate | create-before-delete ordering; create-fail aborts delete; delete-fail surfaces "remove manually" (D-04) |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `pnpm --filter @familysync/api test` full suite GREEN (includes outboxWorker + routes/events from Plan 03).
|
|
- `grep -c "startOutboxWorker()" apps/api/src/index.ts` ≥1.
|
|
- No tsdav import outside broker/: worker uses write.ts/client.ts only.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- End-to-end backend write loop closed: endpoint → outbox → worker → Fastmail → re-sync → cache authoritative.
|
|
- D-04/D-06/D-07/D-08 all enforced and tested.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/03-event-write-back-pwa-install/03-04-SUMMARY.md` when done.
|
|
</output>
|