docs(04-06): complete live-sync SSE vertical slice plan
This commit is contained in:
@@ -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 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)
|
||||
- [ ] **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 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)*
|
||||
|
||||
- [ ] 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
|
||||
|
||||
@@ -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 |
|
||||
| 2. Calendar Display | 5/5 | Complete | 2026-06-05 |
|
||||
| 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 | - |
|
||||
| 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.
|
||||
**Requirements:** TBD
|
||||
**Plans:** 5/6 plans executed
|
||||
**Plans:** 6/6 plans complete
|
||||
|
||||
Plans:
|
||||
|
||||
|
||||
+10
-8
@@ -3,15 +3,15 @@ gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: executing
|
||||
stopped_at: Completed 04-03-PLAN.md
|
||||
last_updated: "2026-06-09T17:19:23.873Z"
|
||||
stopped_at: Completed 04-06-PLAN.md
|
||||
last_updated: "2026-06-09T17:35:35.144Z"
|
||||
last_activity: 2026-06-09
|
||||
progress:
|
||||
total_phases: 15
|
||||
completed_phases: 3
|
||||
completed_phases: 4
|
||||
total_plans: 27
|
||||
completed_plans: 26
|
||||
percent: 20
|
||||
completed_plans: 27
|
||||
percent: 27
|
||||
---
|
||||
|
||||
# Project State
|
||||
@@ -26,7 +26,7 @@ See: .planning/PROJECT.md (updated 2026-06-07)
|
||||
## Current Position
|
||||
|
||||
Phase: 04 (shared-lists-live-sync) — EXECUTING
|
||||
Plan: 5 of 6
|
||||
Plan: 6 of 6
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-06-09
|
||||
|
||||
@@ -63,6 +63,7 @@ Progress: [█████████░] 93%
|
||||
| Phase 04 P03 | 12 | 2 tasks | 9 files |
|
||||
| Phase 04 P04 | 11 | 2 tasks | 10 files |
|
||||
| Phase 04 P05 | 10 | 2 tasks | 4 files |
|
||||
| Phase 04 P06 | 11 | 2 tasks | 7 files |
|
||||
|
||||
## 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]: 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 ?]: LIST-04: SSE connection lives in ListDetail (not hoisted to Lists route); Phase 5 push will own session lifecycle
|
||||
|
||||
### Roadmap Evolution
|
||||
|
||||
@@ -135,6 +137,6 @@ Recent decisions affecting current work:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-06-09T17:19:23.865Z
|
||||
Stopped at: Completed 04-03-PLAN.md
|
||||
Last session: 2026-06-09T17:35:35.134Z
|
||||
Stopped at: Completed 04-06-PLAN.md
|
||||
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
|
||||
Reference in New Issue
Block a user