docs(04-06): complete live-sync SSE vertical slice plan

This commit is contained in:
Lucas Berger
2026-06-09 13:35:49 -04:00
parent 1652a68c51
commit ca9e97879f
3 changed files with 161 additions and 12 deletions
+4 -4
View File
@@ -16,7 +16,7 @@ Decimal phases appear between their surrounding integers in numeric order.
- [x] **Phase 1: Foundation + Broker Spike** - Auth, Docker scaffold, CalDAV broker read path, and personal-calendar ACL spike (go/no-go gate) (completed 2026-06-04) - [x] **Phase 1: Foundation + Broker Spike** - Auth, Docker scaffold, CalDAV broker read path, and personal-calendar ACL spike (go/no-go gate) (completed 2026-06-04)
- [x] **Phase 2: Calendar Display** - Read-only unified color-coded calendar (week/month/day/agenda) built on the confirmed broker (completed 2026-06-05) - [x] **Phase 2: Calendar Display** - Read-only unified color-coded calendar (week/month/day/agenda) built on the confirmed broker (completed 2026-06-05)
- [x] **Phase 3: Event Write-Back + PWA Install** - Full event CRUD written back to Fastmail, PWA manifest + service worker, guided iOS install flow (completed 2026-06-07) - [x] **Phase 3: Event Write-Back + PWA Install** - Full event CRUD written back to Fastmail, PWA manifest + service worker, guided iOS install flow (completed 2026-06-07)
- [ ] **Phase 4: Shared Lists + Live Sync** - Named collaborative lists with item CRUD and real-time SSE co-edit sync - [x] **Phase 4: Shared Lists + Live Sync** - Named collaborative lists with item CRUD and real-time SSE co-edit sync (completed 2026-06-09)
- [ ] **Phase 5: Web Push Notifications** - VAPID push for event reminders, event changes, and list-change alerts - [ ] **Phase 5: Web Push Notifications** - VAPID push for event reminders, event changes, and list-change alerts
- [ ] **Phase 6: UX Polish** - All-day visual distinction, event-form date/recurrence behavior, recurring-series edit, and auth-flow smoothing - [ ] **Phase 6: UX Polish** - All-day visual distinction, event-form date/recurrence behavior, recurring-series edit, and auth-flow smoothing
@@ -164,7 +164,7 @@ Plans:
**Wave 5** *(blocked on Waves 2 + 4)* **Wave 5** *(blocked on Waves 2 + 4)*
- [ ] 04-06-PLAN.md — Live-sync slice: scoped /api/sse/lists + fan-out triggers + useListSSE bounded-backoff hook + LiveSyncIndicator + polling fallback (LIST-04, D-04/D-10/D-11/D-12) - [x] 04-06-PLAN.md — Live-sync slice: scoped /api/sse/lists + fan-out triggers + useListSSE bounded-backoff hook + LiveSyncIndicator + polling fallback (LIST-04, D-04/D-10/D-11/D-12)
**UI hint**: yes **UI hint**: yes
@@ -213,7 +213,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple
| 1. Foundation + Broker Spike | 4/4 | Complete | 2026-06-04 | | 1. Foundation + Broker Spike | 4/4 | Complete | 2026-06-04 |
| 2. Calendar Display | 5/5 | Complete | 2026-06-05 | | 2. Calendar Display | 5/5 | Complete | 2026-06-05 |
| 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 | | 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 |
| 4. Shared Lists + Live Sync | 5/6 | In Progress| | | 4. Shared Lists + Live Sync | 6/6 | Complete | 2026-06-09 |
| 5. Web Push Notifications | 0/? | Not started | - | | 5. Web Push Notifications | 0/? | Not started | - |
| 6. UX Polish | 0/? | Not started | - | | 6. UX Polish | 0/? | Not started | - |
@@ -223,7 +223,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern. **Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
**Requirements:** TBD **Requirements:** TBD
**Plans:** 5/6 plans executed **Plans:** 6/6 plans complete
Plans: Plans:
+10 -8
View File
@@ -3,15 +3,15 @@ gsd_state_version: 1.0
milestone: v1.0 milestone: v1.0
milestone_name: milestone milestone_name: milestone
status: executing status: executing
stopped_at: Completed 04-03-PLAN.md stopped_at: Completed 04-06-PLAN.md
last_updated: "2026-06-09T17:19:23.873Z" last_updated: "2026-06-09T17:35:35.144Z"
last_activity: 2026-06-09 last_activity: 2026-06-09
progress: progress:
total_phases: 15 total_phases: 15
completed_phases: 3 completed_phases: 4
total_plans: 27 total_plans: 27
completed_plans: 26 completed_plans: 27
percent: 20 percent: 27
--- ---
# Project State # Project State
@@ -26,7 +26,7 @@ See: .planning/PROJECT.md (updated 2026-06-07)
## Current Position ## Current Position
Phase: 04 (shared-lists-live-sync) — EXECUTING Phase: 04 (shared-lists-live-sync) — EXECUTING
Plan: 5 of 6 Plan: 6 of 6
Status: Ready to execute Status: Ready to execute
Last activity: 2026-06-09 Last activity: 2026-06-09
@@ -63,6 +63,7 @@ Progress: [█████████░] 93%
| Phase 04 P03 | 12 | 2 tasks | 9 files | | Phase 04 P03 | 12 | 2 tasks | 9 files |
| Phase 04 P04 | 11 | 2 tasks | 10 files | | Phase 04 P04 | 11 | 2 tasks | 10 files |
| Phase 04 P05 | 10 | 2 tasks | 4 files | | Phase 04 P05 | 10 | 2 tasks | 4 files |
| Phase 04 P06 | 11 | 2 tasks | 7 files |
## Accumulated Context ## Accumulated Context
@@ -95,6 +96,7 @@ Recent decisions affecting current work:
- [Phase 04-04]: listItemsRouter separate from listsRouter, mounted at /api/list-items for PATCH/DELETE item routes per RESEARCH architecture diagram - [Phase 04-04]: listItemsRouter separate from listsRouter, mounted at /api/list-items for PATCH/DELETE item routes per RESEARCH architecture diagram
- [Phase 04-04]: Uncheck rank recomputed to active-bottom (generateKeyBetween(lastActiveRank, null)) in same DB write (Open Question 2 resolved) - [Phase 04-04]: Uncheck rank recomputed to active-bottom (generateKeyBetween(lastActiveRank, null)) in same DB write (Open Question 2 resolved)
- [Phase 04-04]: Delete-wins no-rollback: deleteMutation has no onError handler; item removal from cache is final (D-09) - [Phase 04-04]: Delete-wins no-rollback: deleteMutation has no onError handler; item removal from cache is final (D-09)
- [Phase ?]: LIST-04: SSE connection lives in ListDetail (not hoisted to Lists route); Phase 5 push will own session lifecycle
### Roadmap Evolution ### Roadmap Evolution
@@ -135,6 +137,6 @@ Recent decisions affecting current work:
## Session Continuity ## Session Continuity
Last session: 2026-06-09T17:19:23.865Z Last session: 2026-06-09T17:35:35.134Z
Stopped at: Completed 04-03-PLAN.md Stopped at: Completed 04-06-PLAN.md
Resume file: None Resume file: None
@@ -0,0 +1,147 @@
---
phase: 04-shared-lists-live-sync
plan: "06"
subsystem: api-routes, api-sse, pwa-hooks, pwa-components
tags: [live-sync, sse, fan-out, D-04, D-10, D-11, D-12, tdd, list-04, scoped-sse, bounded-backoff]
dependency_graph:
requires:
- 04-02 (listEmitter.ts + listAccess.ts — fan-out primitives)
- 04-03 (listsRouter CRUD with SSE seam comments)
- 04-04 (listItemsRouter item CRUD with SSE seam comments)
- 04-05 (drag-to-reorder; ListDetail established)
provides:
- GET /api/sse/lists — scoped SSE stream (D-04, T-04-01, T-04-02)
- publishListEvent triggers in lists.ts (item:added/updated/deleted, list:updated/deleted)
- useListSSE — bounded-backoff EventSource wrapper (D-10/D-11)
- LiveSyncIndicator — connected/reconnecting/disconnected status component
- ListDetail wired with useListSSE + LiveSyncIndicator + refetchInterval polling (D-12)
affects:
- apps/api/src/routes/lists.ts (publishListEvent fan-out wired at all 5 mutations)
- apps/api/src/routes/sse.ts (GET /lists endpoint added)
- apps/api/tests/routes/lists.test.ts (LIST-04 spy-based fan-out tests + D-04 scoped tests)
- apps/pwa/src/hooks/useListSSE.ts (new)
- apps/pwa/src/hooks/useListSSE.test.ts (stubs replaced with 8 real assertions)
- apps/pwa/src/components/LiveSyncIndicator.tsx (new)
- apps/pwa/src/routes/ListDetail.tsx (useListSSE + LiveSyncIndicator wired)
tech_stack:
added: []
patterns:
- In-memory EventEmitter fan-out via subscribeListEvents inside streamSSE (per RESEARCH Finding 1)
- resolveUserId duplicated in sse.ts per per-router convention (matches events.ts + lists.ts)
- BACKOFF_STEPS_MS=[250,500,1000,2000,4000,8000]; MAX_ATTEMPTS=6; close-before-retry (Pitfall 3)
- refs (not state) for esRef/attemptsRef/timerRef to avoid re-render loops
- LiveSyncIndicator: role=status (connected/reconnecting) + role=alert (disconnected)
- refetchInterval:30000 polling fallback always active regardless of SSE state (D-12)
key_files:
created:
- apps/pwa/src/hooks/useListSSE.ts
- apps/pwa/src/components/LiveSyncIndicator.tsx
modified:
- apps/api/src/routes/lists.ts (publishListEvent fan-out at 5 mutation handlers)
- apps/api/src/routes/sse.ts (GET /lists scoped endpoint added; resolveUserId helper added)
- apps/api/tests/routes/lists.test.ts (9 new LIST-04 tests: 5 fan-out spy + 4 D-04 scoped)
- apps/pwa/src/hooks/useListSSE.test.ts (stubs → 8 real assertions; MockEventSource class)
- apps/pwa/src/routes/ListDetail.tsx (useListSSE + setSyncState + LiveSyncIndicator)
decisions:
- "SSE connection lives in ListDetail per plan spec; hoisting to Lists route level deferred to Phase 5 (acceptable for Phase 4 per RESEARCH note)"
- "publishListEvent carries minimal payload (id, listId, minimal fields) — client uses only to trigger invalidateQueries/refetch (D-10)"
- "resolveUserId duplicated in sse.ts (not extracted to shared module) — matches per-router convention established in events.ts + lists.ts"
- "getAccessibleListIds called once at SSE connection time (D-03/D-10) — new shares visible after reconnect, acceptable per D-10"
metrics:
duration: "~11 minutes"
completed: "2026-06-09"
task_count: 2
file_count: 7
---
# Phase 4 Plan 6: Live-Sync SSE Vertical Slice Summary
**One-liner:** Scoped GET /api/sse/lists fan-out endpoint + publishListEvent triggers in all 5 mutation handlers + bounded-backoff useListSSE hook + LiveSyncIndicator — LIST-04 live co-edit within seconds, D-04 no-leak proven at route layer.
## TDD Gate Compliance
| Gate | Commit | Status |
|------|--------|--------|
| RED — 5 fan-out spy tests (API) + module-not-found (PWA hook) | 5a8d1ef | PASS — 5 API tests fail (subscribeListEvents receives 0 events; publishListEvent commented out); PWA test file fails (useListSSE.ts not created) |
| GREEN — fan-out wired + SSE endpoint + hook + indicator | 1652a68 | PASS — all 54 API tests pass; all 8 PWA hook tests pass |
| REFACTOR | (skipped) | Implementation was clean on first pass |
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| RED | Failing tests: LIST-04 fan-out spy + D-04 scoped (API) + useListSSE.test.ts (PWA) | 5a8d1ef | tests/routes/lists.test.ts, hooks/useListSSE.test.ts |
| GREEN | fan-out in lists.ts + /api/sse/lists in sse.ts + useListSSE.ts + LiveSyncIndicator + ListDetail wiring | 1652a68 | lists.ts, sse.ts, useListSSE.ts, LiveSyncIndicator.tsx, ListDetail.tsx, useListSSE.test.ts |
## Deviations from Plan
None. Plan executed exactly as written.
## Playwright Browser Check
Ran against `http://localhost:5173/lists/890` (list id 890, Groceries) with API on port 3000 (DEV_AUTH_BYPASS=true):
1. `/lists/890` renders ListDetail with "Nothing here yet" + green dot (LiveSyncIndicator, connected state) in top-right header — PASS
2. Add "milk" → item appears in active items list with checkbox + GripVertical handle — PASS
3. LiveSyncIndicator green dot visible throughout — SSE connection maintained — PASS
4. Added "eggs" item via API (simulating second-user write) → appeared in browser within ~1 second WITHOUT manual refresh — PASS (live co-edit proven: SSE fan-out delivered `item:added` event, React Query invalidated + refetched)
5. SSE stream verified: `curl -N http://localhost:3000/api/sse/lists` received `event: heartbeat` + `event: item:added` with correct `{type, listId, payload}` shape
**Live co-edit confirmed single-context (same dev user): API write → SSE event → React Query invalidation → browser update within ~1 second.**
Note: Two-context cross-member test (two separate authenticated users) requires the full Authelia/Pangolin production topology. With DEV_AUTH_BYPASS (single dev user id=1), a true two-user isolation test would require two separate dev servers. D-04 no-leak invariant is proven at the route/subscription layer by the `getAccessibleListIds` tests (accessible-list gating confirmed green).
## Verification Results
### API Tests
- `tests/routes/lists.test.ts`: 54 passed (0 failed)
- LIST-04 fan-out spy tests (5): all GREEN — subscribeListEvents receives events after each mutation
- D-04 scoped subscription tests (4): all GREEN — private list excluded from getAccessibleListIds for non-owner; shared list included
- All prior LIST-01/02/03 tests: 45 passing (no regressions)
### PWA Tests
- `src/hooks/useListSSE.test.ts`: 8 passed (0 failed)
- D-10 reconnect invalidation — GREEN
- D-11 bounded backoff exhaustion (MAX_ATTEMPTS=6) — GREEN
- D-11 backoff reset on successful reconnect — GREEN
- Pitfall 3 cleanup (close + clearTimeout on unmount) — GREEN
- Pitfall 7 withCredentials:true — GREEN
### TypeScript
- `pnpm --filter @familysync/api typecheck` — PASS
- `pnpm --filter @familysync/pwa exec tsc --noEmit` — PASS
### SSE Endpoint Verification
- `GET /api/sse/lists`: responds with `event: heartbeat` + `event: item:added` per fan-out trigger — PASS
- `event: item:added` data shape: `{type, listId, payload:{id, listId, text}}` — PASS (minimal payload per D-10)
## Known Stubs
| File | Stub | Reason |
|------|------|--------|
| `apps/pwa/src/routes/ListDetail.tsx:379` | List heading shows "List" (not list name) | Pre-existing from Plan 04-04; fetchListItems returns items only; Plan 05/06 spec noted enrichment from ['lists'] cache; non-blocking for LIST-04 |
This stub does not prevent the plan's goal (live co-edit). It was explicitly called out as pre-existing in the Plan 04-04 SUMMARY.
## Threat Surface Scan
All threats from the plan's threat model are mitigated:
| Threat ID | Status | Notes |
|-----------|--------|-------|
| T-04-02 (Info Disclosure — D-04 scoped fan-out leak) | Mitigated | /api/sse/lists subscribes ONLY to getAccessibleListIds channels; 4 route-layer tests assert private list excluded from non-owner's accessible set |
| T-04-01 (Spoofing/AuthZ — unauthenticated SSE subscription) | Mitigated | resolveUserId → 401 on null; same OIDC guard as /api/sse/heartbeat; withCredentials:true sends session cookie |
| T-04-11 (DoS — EventSource reconnect storm) | Mitigated | es.close() before setTimeout; MAX_ATTEMPTS=6 → 'disconnected' state stops retrying; Pitfall 3 test confirms no post-unmount reconnects |
| T-04-12 (Info Disclosure — over-broad payload) | Mitigated | Payload carries minimal {type, listId, id} only; client uses only to invalidate/refetch (D-10); no sensitive data in SSE payload |
No new threat surface beyond the plan's trust boundaries.
## Self-Check: PASSED
- `apps/api/src/routes/lists.ts` (publishListEvent imports + 5 fan-out calls) — FOUND
- `apps/api/src/routes/sse.ts` (GET /lists endpoint) — FOUND
- `apps/pwa/src/hooks/useListSSE.ts` — FOUND
- `apps/pwa/src/components/LiveSyncIndicator.tsx` — FOUND
- `apps/pwa/src/routes/ListDetail.tsx` (useListSSE + LiveSyncIndicator wired) — FOUND
- Commit 5a8d1ef (RED) — FOUND
- Commit 1652a68 (GREEN) — FOUND