Files
familysync/.planning/phases/04-shared-lists-live-sync/04-06-SUMMARY.md
T

8.8 KiB

phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
phase plan subsystem tags dependency_graph tech_stack key_files decisions metrics
04-shared-lists-live-sync 06 api-routes, api-sse, pwa-hooks, pwa-components
live-sync
sse
fan-out
D-04
D-10
D-11
D-12
tdd
list-04
scoped-sse
bounded-backoff
requires provides affects
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)
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)
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)
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)
created modified
apps/pwa/src/hooks/useListSSE.ts
apps/pwa/src/components/LiveSyncIndicator.tsx
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)
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
duration completed task_count file_count
~11 minutes 2026-06-09 2 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