Files
familysync/.planning/phases/03-event-write-back-pwa-install/03-10-PLAN.md
T

11 KiB

phase, plan, type, wave, depends_on, gap_closure, autonomous, requirements, files_modified, must_haves
phase plan type wave depends_on gap_closure autonomous requirements files_modified must_haves
03-event-write-back-pwa-install 10 tdd 2
03-09
true true
CAL-04
CAL-05
CAL-06
CAL-07
apps/api/src/broker/outboxWorker.ts
apps/api/src/broker/vevent.ts
apps/api/tests/broker/outboxWorker.test.ts
apps/api/tests/broker/vevent.test.ts
truths artifacts key_links
The worker parses the stored form JSON and PUTs a real VCALENDAR string built by buildVeventString — never raw {"title":...} JSON
The PUT body begins with 'BEGIN:VCALENDAR' for both create and update operations
A single-day all-day event produces DTEND = DTSTART + 1 day (RFC 5545 exclusive end)
A credential-load failure leaves the row pending for retry — the worker never PUTs with empty Basic-auth
The first transient failure waits 15s (BACKOFF_SECONDS[0]), not 60s
path provides contains
apps/api/src/broker/outboxWorker.ts ICS-building dispatch path + removed empty-cred fallback + corrected backoff index + explicit randomUUID import buildVeventString
path provides
apps/api/src/broker/vevent.ts All-day DTEND+1-day exclusivity fix
from to via pattern
apps/api/src/broker/outboxWorker.ts apps/api/src/broker/vevent.ts buildVeventString(parsedFormFields) buildVeventString(
from to via pattern
apps/api/src/broker/outboxWorker.ts apps/api/src/broker/write.ts createCalendarEvent/updateCalendarEvent with the built icsString createCalendarEvent(|updateCalendarEvent(
Make the outbox worker actually write a valid calendar object. Today it PUTs the raw form JSON (`{"title":...}`) to Fastmail — `buildVeventString` (the whole D-13 DATE/DATETIME contract) is dead code (CR-02). It also silently authenticates with empty credentials on any credential-load error (CR-03), skips its first backoff delay (WR-01), and mishandles the all-day exclusive DTEND (WR-04). This plan wires the VEVENT builder into the dispatch path and fixes those correctness defects.

Purpose: a queued write becomes a real, RFC-5545-valid VEVENT on the correct calendar. Output: a worker that builds ICS from the stored form fields and fails closed on bad credentials.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/03-event-write-back-pwa-install/03-REVIEW.md @.planning/phases/03-event-write-back-pwa-install/03-09-SUMMARY.md @apps/api/src/broker/outboxWorker.ts @apps/api/src/broker/vevent.ts @apps/api/src/broker/write.ts

<artifacts_this_phase_produces> No new exported symbols. buildVeventString and RRULE_PRESETS (already exported by vevent.ts) become live call sites for the first time. The worker's dispatch path gains an internal JSON.parse(row.payload)buildVeventString step. </artifacts_this_phase_produces>

Task 1: RED+GREEN — worker builds and PUTs a real VEVENT (CR-02, WR-04) apps/api/src/broker/outboxWorker.ts, apps/api/src/broker/vevent.ts, apps/api/tests/broker/outboxWorker.test.ts, apps/api/tests/broker/vevent.test.ts - apps/api/src/broker/outboxWorker.ts (dispatchRow lines 131-188 — the create/update branches that pass row.payload straight through) - apps/api/src/broker/vevent.ts (buildVeventString signature lines 52-118; NewEventParams lines 21-33; RRULE_PRESETS lines 39-44; all-day DATE handling lines 69-88) - apps/api/src/routes/events.ts (the route stores payload: JSON.stringify(payload) with the new title/start/end fields from plan 03-09) - apps/api/tests/broker/outboxWorker.test.ts (line ~85 hardcodes payload:'BEGIN:VCALENDAR' and mocks write.js — the wrong boundary; the new test must stop mocking the ICS string and assert the worker BUILDS it) - .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (CR-02, WR-04) - RED (worker): with `write.js` NOT mocking away the payload — i.e. spy on `createCalendarEvent` and capture its 4th arg `icsString` — enqueue a create row whose `payload` is `JSON.stringify({title:'Lunch',allDay:false,start:'2026-06-10T12:00:00',end:'2026-06-10T13:00:00',recurrence:'none'})`. Assert the captured icsString `.startsWith('BEGIN:VCALENDAR')` and contains `SUMMARY:Lunch`. Fails today (raw JSON is passed). - RED (worker): an update row likewise yields an icsString starting with `BEGIN:VCALENDAR` passed to `updateCalendarEvent`. - RED (worker): a row whose `payload` is not valid JSON marks the row `failed` (hard fail, no retry). - RED (vevent): a single-day all-day event `{allDay:true, start:'2026-06-10', end:'2026-06-10'}` produces an icsString whose DTEND is `2026-06-11` (start + 1 day), not equal to DTSTART. In `dispatchRow`, for `operation === 'create'` and `operation === 'update'`: `const fields = JSON.parse(row.payload)` wrapped in try/catch; on parse failure return `{success:false, conflict:false, hardFail:true, transient:false, error:'payload parse failed'}` (hard fail — corrupt payload will never self-resolve). Then build the ICS: `const { icsString } = buildVeventString({ uid: row.uid, summary: fields.title, allDay: fields.allDay, dtstart: fields.allDay ? fields.start : new Date(fields.start), dtend: fields.allDay ? fields.end : new Date(fields.end), location: fields.location, description: fields.description, rruleString: fields.recurrence && fields.recurrence !== 'none' ? RRULE_PRESETS[fields.recurrence] : undefined })`. Pass `icsString` (NOT `row.payload`) to `createCalendarEvent(client, davCalendar, row.uid, icsString)` and to `updateCalendarEvent(client, row.calendarObjectUrl, icsString, row.etag ?? null)`. Import `{ buildVeventString, RRULE_PRESETS }` from `./vevent.js`. Delete operations are unchanged (no payload).
WR-04 in vevent.ts: in the all-day branch (lines 69-88), after computing the end DATE
components, advance the end DATE by one calendar day before constructing `endTime`
(use a Date built from ey/em/ed, `setUTCDate(getUTCDate()+1)`, re-read y/m/d) so a one-day
all-day event serializes DTEND = DTSTART + 1 (RFC 5545 exclusive). Keep the timed branch untouched.

Update the existing outbox test that fed a pre-built ICS string so it instead feeds
form JSON and asserts the built ICS (it was testing the wrong boundary). Commit RED then GREEN.
cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts - behavior: the icsString passed to createCalendarEvent starts with 'BEGIN:VCALENDAR' and contains the summary. - behavior: an unparseable payload marks the row failed with no retry. - behavior: single-day all-day event yields DTEND = start + 1 day. - source: `grep -c 'buildVeventString' apps/api/src/broker/outboxWorker.ts` returns >= 1. - test-command: `cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts` passes. Every create/update PUTs a real RFC-5545 VCALENDAR built from the stored form fields, honoring D-13 DATE-vs-DATETIME and the exclusive all-day DTEND. Task 2: RED+GREEN — fail closed on bad credentials, fix backoff index, explicit randomUUID (CR-03, WR-01, WR-08) apps/api/src/broker/outboxWorker.ts, apps/api/tests/broker/outboxWorker.test.ts - apps/api/src/broker/outboxWorker.ts (dispatchRow try/catch fallback lines 135-143; backoff math lines 316-341; BACKOFF_SECONDS lines 40-44) - apps/api/src/routes/events.ts (uses bare `crypto.randomUUID()` at lines 241,317,318 — WR-08 is the route-side instance; vevent.ts already imports from 'crypto') - .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (CR-03, WR-01, WR-08) - RED (CR-03): mock `loadClientForUser` (via the credential/decrypt path) to throw; assert the row is left `pending` (caught by the outer per-row catch in runOutboxDrain) and that `createFastmailClient('', '')` is NEVER invoked. Fails today (the catch falls back to empty creds and proceeds to PUT). - RED (WR-01): a transient failure on a row with attemptCount=0 sets nextAttemptAt ≈ now + 15s (BACKOFF_SECONDS[0]), not +60s. CR-03: Remove the `try { client = await loadClientForUser(row.userId) } catch { client = await createFastmailClient('','') }` fallback in `dispatchRow`. Replace with `const client = await loadClientForUser(row.userId)` and let it throw — the outer per-row `catch` in `runOutboxDrain` (line ~343) already logs and leaves the row pending (correct transient behavior). Tests that previously relied on the empty-cred fallback must instead mock `loadClientForUser` (or the underlying credential select + `createFastmailClient`) to return a fake client. Do NOT add a test-only flag that PUTs with empty creds.
WR-01: change the backoff index from `nextAttemptCount` to `row.attemptCount` (the attempt that just failed):
`const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000`. Keep `nextAttemptCount = row.attemptCount + 1`
for the persisted `attemptCount` and the `>= MAX_ATTEMPTS` dead-letter check. This makes the first retry wait 15s.

WR-08: in events.ts replace the three bare `crypto.randomUUID()` calls (lines 241,317,318) with `randomUUID()`
imported via `import { randomUUID } from 'node:crypto'`, matching vevent.ts. (events.ts is also edited by plan 03-09;
this plan runs after it in a later wave so there is no concurrent edit.)

Commit RED then GREEN.
cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts - behavior: a credential-load failure leaves the row pending and never calls createFastmailClient('', ''). - behavior: first transient retry delay equals BACKOFF_SECONDS[0] (15s). - source: `grep -c "createFastmailClient('', '')" apps/api/src/broker/outboxWorker.ts` returns 0. - source: `grep -c "import { randomUUID } from 'node:crypto'" apps/api/src/routes/events.ts` returns 1. - test-command: `cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts` passes. The worker fails closed on credential errors (retryable), uses the documented 15s-first backoff schedule, and uses an explicitly-imported randomUUID. - `cd apps/api && npx vitest run tests/broker/` green. - `cd apps/api && npm run build` succeeds. - `grep -rn buildVeventString apps/api/src` shows a live call site outside vevent.ts (IN-01 closed).

<success_criteria> Queued writes serialize to valid VCALENDAR via buildVeventString, all-day DTEND is exclusive, credential failures retry instead of writing with empty auth, and the backoff schedule matches its docs. CR-02, CR-03, WR-01, WR-04, WR-08, IN-01 closed. </success_criteria>

Create `.planning/phases/03-event-write-back-pwa-install/03-10-SUMMARY.md` when done.