Files
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

152 lines
11 KiB
Markdown

---
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