docs(04): create phase plan — 6 plans across 5 waves for shared lists + live sync
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d89eb47483
commit
38fa6f448b
@@ -0,0 +1,194 @@
|
||||
---
|
||||
phase: 04-shared-lists-live-sync
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["04-02", "04-04", "04-05"]
|
||||
files_modified:
|
||||
- apps/api/src/routes/sse.ts
|
||||
- apps/api/src/routes/lists.ts
|
||||
- apps/api/src/routes/lists.test.ts
|
||||
- apps/pwa/src/hooks/useListSSE.ts
|
||||
- apps/pwa/src/hooks/useListSSE.test.ts
|
||||
- apps/pwa/src/components/LiveSyncIndicator.tsx
|
||||
- apps/pwa/src/routes/ListDetail.tsx
|
||||
autonomous: true
|
||||
requirements: [LIST-04]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "When one member adds/checks/deletes/reorders an item, the other member's open list updates within seconds without a manual refresh"
|
||||
- "A private list's events are never delivered to a member who is not its owner (D-04)"
|
||||
- "On SSE reconnect the client full-refetches the affected list (D-10)"
|
||||
- "After capped backoff is exhausted, the UI shows an 'Updates paused' indicator and stops hammering (D-11); polling keeps data fresh (D-12)"
|
||||
artifacts:
|
||||
- path: "apps/api/src/routes/sse.ts"
|
||||
provides: "GET /api/sse/lists scoped SSE stream"
|
||||
contains: "/lists"
|
||||
- path: "apps/pwa/src/hooks/useListSSE.ts"
|
||||
provides: "bounded-backoff EventSource wrapper invalidating React Query"
|
||||
exports: ["useListSSE"]
|
||||
- path: "apps/pwa/src/components/LiveSyncIndicator.tsx"
|
||||
provides: "connected/reconnecting/disconnected indicator"
|
||||
min_lines: 20
|
||||
key_links:
|
||||
- from: "apps/api/src/routes/lists.ts"
|
||||
to: "publishListEvent"
|
||||
via: "fan-out trigger after every successful item/list write"
|
||||
pattern: "publishListEvent"
|
||||
- from: "apps/api/src/routes/sse.ts"
|
||||
to: "subscribeListEvents + getAccessibleListIds"
|
||||
via: "scoped per-list subscription inside streamSSE"
|
||||
pattern: "subscribeListEvents|getAccessibleListIds"
|
||||
- from: "apps/pwa/src/hooks/useListSSE.ts"
|
||||
to: "/api/sse/lists"
|
||||
via: "EventSource(withCredentials) → invalidateQueries"
|
||||
pattern: "EventSource"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Deliver the live-sync vertical slice (LIST-04, success criterion 3): wire the scoped SSE endpoint (`GET /api/sse/lists`), emit fan-out events from every list/item write (consuming the Plan 02 emitter), and add the bounded-backoff EventSource client hook + LiveSyncIndicator so one member's edits appear for the other within seconds — surviving a brief reconnect — without leaking private-list events (D-04).
|
||||
|
||||
MVP slice: this is the final capability that makes the lists "shared and live" rather than single-user. All CRUD/reorder built in Plans 03–05 becomes collaborative.
|
||||
|
||||
Purpose: Connect the proven scoped fan-out primitive (Plan 02) to real route writes and to a robust client (bounded backoff per D-11, full-refetch-on-reconnect per D-10, polling fallback per D-12), and prove the load-bearing no-leak invariant at the HTTP/route layer.
|
||||
Output: /api/sse/lists endpoint; publishListEvent triggers in lists.ts; useListSSE hook; LiveSyncIndicator; ListDetail consumes the hook and renders the indicator.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/04-shared-lists-live-sync/04-CONTEXT.md
|
||||
@.planning/phases/04-shared-lists-live-sync/04-RESEARCH.md
|
||||
@.planning/phases/04-shared-lists-live-sync/04-PATTERNS.md
|
||||
@.planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md
|
||||
@.planning/phases/04-shared-lists-live-sync/04-VALIDATION.md
|
||||
@apps/api/src/routes/sse.ts
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Scoped /api/sse/lists endpoint + fan-out triggers on every write (LIST-04, D-04/D-10)</name>
|
||||
<files>apps/api/src/routes/sse.ts, apps/api/src/routes/lists.ts, apps/api/src/routes/lists.test.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/routes/sse.ts (existing /heartbeat streamSSE pattern — extend)
|
||||
- apps/api/src/routes/lists.ts (item/list write handlers from Plans 03–04 — add emit seams)
|
||||
- apps/api/src/lib/listEmitter.ts (publishListEvent, subscribeListEvents — Plan 02)
|
||||
- apps/api/src/lib/listAccess.ts (getAccessibleListIds — Plan 02)
|
||||
- apps/api/src/routes/lists.test.ts (LIST-04 stub incl. private-list no-leak)
|
||||
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/api/src/routes/sse.ts" (the /lists endpoint pattern verbatim)
|
||||
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md Finding 1 + Finding 3
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test: a successful POST item / PATCH item / DELETE item / list:update / list:delete causes publishListEvent to fire with the matching ListEvent type for that listId. (LIST-04)
|
||||
- Test (D-04, load-bearing): an event published for member A's PRIVATE list is NOT delivered to member B's /api/sse/lists subscription — B's accessible-list set (getAccessibleListIds) excludes it, so B never subscribes to that channel. This is the "private-list events NOT emitted to a non-owner subscriber" assertion in 04-VALIDATION.md, asserted at the route/subscription layer (Plan 02 proved it at the emitter layer).
|
||||
- Test: a member subscribed via /api/sse/lists DOES receive events for a list shared with them.
|
||||
- Test: the endpoint returns 401 when unauthenticated.
|
||||
</behavior>
|
||||
<action>
|
||||
Extend sseRouter (sse.ts) with `GET /lists` following the 04-PATTERNS pattern: resolveUserId → 401 on null; const accessibleListIds = await getAccessibleListIds(userId); inside streamSSE, for each accessible listId call subscribeListEvents(listId, handler) where the handler writes an SSE event (event: event.type, data: JSON.stringify(event)) when !stream.aborted; run a 30s heartbeat loop; on exit call every unsubscribe. (resolveUserId: reuse the lists.ts copy or import a shared helper consistently — match the existing duplication convention.)
|
||||
|
||||
Add publishListEvent fan-out triggers in lists.ts after every successful write (the seams left in Plans 03–04): item:added after POST item, item:updated after PATCH item, item:deleted after DELETE item, list:updated after PATCH list, list:deleted after DELETE list. Each carries { type, listId, payload } with the minimal payload needed; the client uses events only to trigger invalidate/refetch (D-10), so payload need not be the full row.
|
||||
|
||||
Mount: /api/sse/lists is already under /api/sse (sseRouter mounted in index.ts) — no index.ts change needed beyond what exists. Confirm it sits behind the OIDC/dev-bypass guard.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts && grep -q "publishListEvent" apps/api/src/routes/lists.ts && grep -q "/lists" apps/api/src/routes/sse.ts && pnpm --filter @familysync/api typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- GET /api/sse/lists subscribes only to getAccessibleListIds channels; 401 when unauthenticated.
|
||||
- Every list/item write emits the correct ListEvent via publishListEvent.
|
||||
- The D-04 route-layer no-leak test (private list of member A not delivered to member B) is present and green.
|
||||
- typecheck passes.
|
||||
</acceptance_criteria>
|
||||
<done>Scoped SSE stream live; writes fan out to accessible subscribers only; no-leak invariant proven at the route layer.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: useListSSE bounded-backoff hook + LiveSyncIndicator + ListDetail wiring (LIST-04, D-10/D-11/D-12)</name>
|
||||
<files>apps/pwa/src/hooks/useListSSE.ts, apps/pwa/src/hooks/useListSSE.test.ts, apps/pwa/src/components/LiveSyncIndicator.tsx, apps/pwa/src/routes/ListDetail.tsx</files>
|
||||
<read_first>
|
||||
- apps/pwa/src/hooks/useListSSE.test.ts (D-11 bounded-backoff RED stub from Plan 01)
|
||||
- apps/pwa/src/routes/ListDetail.tsx (already has refetchInterval:30000 from Plan 04)
|
||||
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md Finding 4 (EventSource wrapper verbatim) + Pitfall 3 + Pitfall 7
|
||||
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"useListSSE.ts"
|
||||
- .planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md §"LiveSyncIndicator", §"Live Sync + Reconnect"
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test (D-11): with a mocked EventSource that always errors, the hook retries on the backoff schedule 250→500→1000→2000→4000→cap 8000ms and, after the capped attempts are exhausted (≥6), transitions to 'disconnected' and STOPS scheduling further reconnects.
|
||||
- Test: on a successful (mocked) open, the hook resets the attempt counter, reports 'connected', and invalidates ['list', listId] (full refetch on reconnect, D-10).
|
||||
- Test: on a received list-change event, the hook invalidates ['list', listId].
|
||||
- Test: the hook closes the EventSource and clears timers on unmount (no reconnect storm — Pitfall 3).
|
||||
</behavior>
|
||||
<action>
|
||||
Create apps/pwa/src/hooks/useListSSE.ts using the RESEARCH Finding 4 pattern verbatim: refs for the EventSource/attempt-count/timer (not state), connect() in useCallback, BACKOFF_STEPS_MS=[250,500,1000,2000,4000,8000], MAX_ATTEMPTS=length; new EventSource('/api/sse/lists',{withCredentials:true}); on open → reset attempts, onStateChange('connected'), invalidateQueries(['list',listId]); on each list-change event type → invalidateQueries(['list',listId]); on error → es.close(), if attempts≥MAX → onStateChange('disconnected') and stop, else onStateChange('reconnecting') and setTimeout(connect, backoff[attempt++]); cleanup closes es + clears timer on unmount. Convert the Plan 01 stub into these real assertions (mock EventSource).
|
||||
|
||||
Create LiveSyncIndicator.tsx per UI-SPEC: connected = 8px green dot (var(--color-member-1)), reconnecting = pulsing muted dot + "Reconnecting…", disconnected = red dot + "Updates paused"; role="status" with the aria-labels from UI-SPEC; role="alert" for the disconnected state.
|
||||
|
||||
Wire into ListDetail: call useListSSE({ listId, onStateChange: setSyncState }) and render LiveSyncIndicator in the header. Keep refetchInterval:30000 as the always-on polling fallback (D-12) so data stays fresh even when SSE is 'disconnected'. (Consider hoisting the single SSE connection so it does not reconnect on every list navigation — acceptable to keep it in ListDetail for Phase 4 per RESEARCH note; document the choice.)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "useListSSE" apps/pwa/src/routes/ListDetail.tsx</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- useListSSE.test.ts: bounded-backoff exhaustion test (D-11) and reconnect-invalidate test (D-10) are real and green.
|
||||
- Hook uses withCredentials:true and closes EventSource on error before scheduling retry (no storm).
|
||||
- LiveSyncIndicator renders connected/reconnecting/disconnected with correct ARIA.
|
||||
- ListDetail consumes the hook + renders the indicator; refetchInterval polling fallback retained.
|
||||
- PWA typecheck passes.
|
||||
- Browser check (`playwright-cli`, two contexts where feasible): in context A add an item; context B's open list reflects it within a few seconds without manual refresh. Record in SUMMARY. (Cross-device/iOS-standalone live co-edit remains a device-only manual check per 04-VALIDATION.md.)
|
||||
</acceptance_criteria>
|
||||
<done>Live co-edit works: one member's edits appear for the other within seconds, with bounded reconnect + visible paused state + polling fallback.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| API publisher → SSE subscribers | the load-bearing leak boundary (D-04) |
|
||||
| browser EventSource → /api/sse/lists | session cookie must cross (withCredentials); endpoint behind OIDC |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-02 | Information Disclosure | scoped-fan-out leak (D-04) — load-bearing | mitigate | /api/sse/lists subscribes ONLY to getAccessibleListIds channels; route-layer test asserts member B never receives member A's private-list events |
|
||||
| T-04-01 | Spoofing/AuthZ | unauthenticated SSE subscription | mitigate | resolveUserId → 401; endpoint behind OIDC middleware; EventSource sends session cookie via withCredentials (Pitfall 7) |
|
||||
| T-04-11 | Denial of Service | EventSource reconnect storm | mitigate | es.close() on error + manual bounded-backoff setTimeout; give-up after MAX_ATTEMPTS (Pitfall 3) |
|
||||
| T-04-12 | Information Disclosure | over-broad event payload exposing other lists' data | mitigate | Payload carries only { type, listId, minimal } and is per-list-channel scoped; client uses it solely to trigger invalidate/refetch (D-10) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
<automated>pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts && pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
|
||||
- `playwright-cli` two-context live-update check.
|
||||
- D-04 route-layer no-leak test green.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- LIST-04 satisfied: live co-edit within seconds, surviving a brief reconnect.
|
||||
- D-04 no-leak proven at both emitter (Plan 02) and route (this plan) layers.
|
||||
- D-10 full-refetch-on-reconnect, D-11 bounded backoff + paused indicator, D-12 polling fallback all in place.
|
||||
</success_criteria>
|
||||
|
||||
<artifacts_produced>
|
||||
**Symbols/files this plan creates (exclude from drift verification):**
|
||||
- `GET /api/sse/lists` endpoint on sseRouter (apps/api/src/routes/sse.ts)
|
||||
- `publishListEvent(...)` fan-out triggers in apps/api/src/routes/lists.ts (item:added/updated/deleted, list:updated/deleted)
|
||||
- `apps/pwa/src/hooks/useListSSE.ts` exporting `useListSSE` (bounded-backoff EventSource wrapper)
|
||||
- `apps/pwa/src/components/LiveSyncIndicator.tsx`
|
||||
- ListDetail wiring of useListSSE + LiveSyncIndicator
|
||||
</artifacts_produced>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/04-shared-lists-live-sync/04-06-SUMMARY.md` when done.
|
||||
</output>
|
||||
Reference in New Issue
Block a user