docs(05-05): complete list-change push plan

This commit is contained in:
Lucas Berger
2026-06-09 21:29:09 -04:00
parent d2ce4e08c7
commit 9b04528fd6
3 changed files with 185 additions and 9 deletions
+3 -3
View File
@@ -202,7 +202,7 @@ Plans:
**Wave 4** *(blocked on Wave 3)*
- [ ] 05-05-PLAN.md — NOTIF-02 list-change slice: listChangeDispatcher + hook coalescer into mutations, reorder-silent (D-01/D-02/D-03)
- [x] 05-05-PLAN.md — NOTIF-02 list-change slice: listChangeDispatcher + hook coalescer into mutations, reorder-silent (D-01/D-02/D-03)
- [ ] 05-06-PLAN.md — TDD: NOTIF-01 reminderScheduler — shared-timed 15-min scan (query-enforced D-05), all-day excl, dedup, empty-set safe (D-05/D-06/D-07)
**Wave 5** *(blocked on Wave 4)*
@@ -246,7 +246,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple
| 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 | 6/6 | Complete | 2026-06-09 |
| 5. Web Push Notifications | 4/8 | In Progress| |
| 5. Web Push Notifications | 5/8 | In Progress| |
| 6. UX Polish | 0/? | Not started | - |
## Backlog
@@ -255,7 +255,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:** 3/8 plans executed
**Plans:** 5/8 plans executed
Plans:
+8 -6
View File
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: executing
stopped_at: Completed 05-02-PLAN.md
last_updated: "2026-06-10T01:16:12.195Z"
stopped_at: Completed 05-05-PLAN.md
last_updated: "2026-06-10T01:28:57.604Z"
last_activity: 2026-06-10
progress:
total_phases: 15
completed_phases: 4
total_plans: 36
completed_plans: 32
completed_plans: 33
percent: 27
---
@@ -26,7 +26,7 @@ See: .planning/PROJECT.md (updated 2026-06-07)
## Current Position
Phase: 05 (web-push-notifications) — EXECUTING
Plan: 5 of 8
Plan: 7 of 8
Status: Ready to execute
Last activity: 2026-06-10
@@ -69,6 +69,7 @@ Progress: [█████████░] 89%
| Phase 05 P02 | 5 | 1 tasks | 1 files |
| Phase 05 P03 | 5 | - tasks | - files |
| Phase 05 P04 | 11 | 3 tasks | 9 files |
| Phase 05 P05 | 8 | 2 tasks | 4 files |
## Accumulated Context
@@ -107,6 +108,7 @@ Recent decisions affecting current work:
- [Phase ?]: VAPID config is env-injected at runtime via docker-compose.yml environment block; no key baked into image (Phase 5 D-transposability)
- [Phase ?]: dispatchPush uses sub.id (not a separate dbRowId argument) — 2-arg signature matches existing test
- [Phase ?]: coalesceListPush dispatch signature is (listId, actorId, count) — test scaffold canonical; richer payload deferred to Plan 05-05 caller
- [Phase ?]: notifyListChange fires for all list/item mutations except reorder (position) and list-create per D-01
### Roadmap Evolution
@@ -148,6 +150,6 @@ Recent decisions affecting current work:
## Session Continuity
Last session: 2026-06-10T01:16:12.175Z
Stopped at: Completed 05-02-PLAN.md
Last session: 2026-06-10T01:28:57.597Z
Stopped at: Completed 05-05-PLAN.md
Resume file: None
@@ -0,0 +1,174 @@
---
phase: 05-web-push-notifications
plan: 05
subsystem: api/list-change-dispatcher
tags: [web-push, notif-02, list-change, coalescer, tdd, red-green, D-01, D-02, D-03]
dependency_graph:
requires: [05-02, 05-03, 05-04]
provides: [notifyListChange — access-scoped, self-suppressed, coalesced list-change push]
affects:
- apps/api/src/lib/listChangeDispatcher.ts
- apps/api/src/routes/lists.ts
- apps/api/tests/lib/listChangeDispatcher.test.ts
- apps/api/tests/routes/lists.test.ts
tech_stack:
added: []
patterns:
- real-timer + pollUntil polling for async DB assertions (avoids fake-timer + real-I/O mismatch)
- vi.doMock + vi.resetModules per-test pattern (fresh mock instances for each test)
- windowMs optional param for testability (coalescer window override in tests)
key_files:
created:
- apps/api/src/lib/listChangeDispatcher.ts
- apps/api/tests/lib/listChangeDispatcher.test.ts
modified:
- apps/api/src/routes/lists.ts
- apps/api/tests/routes/lists.test.ts
decisions:
- "windowMs exposed as optional 3rd arg on notifyListChange for test-time override (avoids fake-timer/real-I/O race)"
- "pollUntil() helper (inline, no test-library deps) replaces @testing-library/waitFor for async DB assertion polling"
- "vi.doMock + vi.resetModules in beforeEach — required so each test gets a fresh vi.fn() mock instance for dispatchPush"
- "DELETE /:id notifyListChange fires after DB delete — sendListChangePush handles missing list gracefully (early return)"
- "List create (POST /) does NOT notify — empty list is not a change worth pinging (D-01 spirit)"
metrics:
duration: 8
completed_date: "2026-06-10"
tasks_completed: 2
files_changed: 4
---
# Phase 05 Plan 05: listChangeDispatcher — NOTIF-02 List-Change Push — Summary
TDD RED→GREEN: `listChangeDispatcher.ts` (notifyListChange) implemented; hooked into all meaningful list/item mutation points in `routes/lists.ts`; reorder (position) changes excluded; 64 tests GREEN.
## Tasks Executed
### Task 1: listChangeDispatcher — access-scoped, self-suppressed fan-out
**Status:** Completed.
**Commits:**
- RED: `test(05-05): add failing tests for listChangeDispatcher — RED gate` — 97f7026
- GREEN: `feat(05-05): implement listChangeDispatcher — access-scoped, self-suppressed, coalesced push (NOTIF-02)` — 6923104
Created `apps/api/src/lib/listChangeDispatcher.ts` exporting `notifyListChange(listId, actorId, windowMs?)`:
**`notifyListChange`** — wraps `coalesceListPush` with a dispatch closure that:
1. Resolves actor `displayName` and list `name` from DB in parallel
2. Builds audience: `{list owner} {list_shares.userId} MINUS actorId` (D-03)
3. Loads `push_subscriptions` for all audience members
4. Calls `dispatchPush(sub, notification)` per subscription — one failure never aborts the loop
5. D-02 generic copy: `"{Actor} made {N} changes to {ListName}"` — no item text
**Threat mitigations:**
- T-05-14: audience derived from list access (owner + list_shares only) — never all users
- T-05-15: notification body carries actor name + count, no item text (D-02)
- T-05-16: actorId filtered before audience union → actor's own subscriptions never dispatched (D-03)
**Tests (5/5 GREEN):**
- Burst coalescing: 3 rapid calls → 1 `dispatchPush` to non-actor with `count=3`, body contains actor name + "3"
- D-03 self-suppression: actor-only list → 0 dispatches
- T-05-14 access scoping: unrelated 3rd user (no owner/share) → never dispatched
- Empty audience (no other members) → no dispatch, no crash
- Empty audience (other has no subscription) → no dispatch, no crash
### Task 2: Hook notifyListChange into list/item mutations (reorder excluded)
**Status:** Completed.
**Commit:** `feat(05-05): hook notifyListChange into list/item mutations (reorder excluded)` — d2ce4e0
`apps/api/src/routes/lists.ts` updated — `notifyListChange` called (fire-and-forget) after each meaningful mutation:
| Route | Mutation | Push? |
|-------|----------|-------|
| `POST /api/lists/:id/items` | Item added | YES |
| `PATCH /api/list-items/:itemId` | checked/text change | YES |
| `PATCH /api/list-items/:itemId` | position change (reorder) | **NO** (D-01) |
| `DELETE /api/list-items/:itemId` | Item deleted | YES |
| `PATCH /api/lists/:id` | List rename/sharing toggle | YES |
| `DELETE /api/lists/:id` | List deleted | YES |
| `POST /api/lists` | List created | **NO** (empty list, D-01 spirit) |
Critical D-01 guard in `PATCH /list-items/:itemId`:
```typescript
if (patch.position === undefined) {
notifyListChange(item.listId, currentUserId)
}
```
**New tests in lists.test.ts (2 tests):**
- `PATCH { position }` (reorder) does NOT call `notifyListChange` — spy confirms 0 calls
- `PATCH { checked: true }` DOES call `notifyListChange(listId, ownerId)` — spy confirms 1 call with correct args
**Final test count:** 59/59 lists.test.ts + 5/5 listChangeDispatcher.test.ts = **64/64 GREEN**
## Verification
```
pnpm --filter @familysync/api exec vitest run tests/lib/listChangeDispatcher.test.ts tests/routes/lists.test.ts
Test Files 2 passed (2)
Tests 64 passed (64)
```
`pnpm --filter @familysync/api typecheck` — passes (no errors).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fake timer + real DB I/O race condition in listChangeDispatcher tests**
- **Found during:** Task 1 (GREEN phase, first test run)
- **Issue:** `vi.useFakeTimers()` + `vi.runAllTimersAsync()` fires the coalescer timer but returns before the subsequent real DB queries (`sendListChangePush`) complete. This caused the "burst coalesces" and "access scoping" tests to fail (0 `dispatchPush` calls observed even though the logic was correct).
- **Fix:**
1. Switched test approach to real timers (no `vi.useFakeTimers`) with a tiny `windowMs=10ms` passed to `notifyListChange`.
2. Added optional `windowMs` parameter to `notifyListChange` (defaults to `undefined`, which passes through to `coalesceListPush`'s 45s default) — test-only override.
3. Added inline `pollUntil()` helper (no `@testing-library/waitFor` dependency) that polls a predicate until it passes or a 3s timeout.
- **Files modified:** `apps/api/src/lib/listChangeDispatcher.ts`, `apps/api/tests/lib/listChangeDispatcher.test.ts`
- **Commit:** 6923104
**2. [Rule 1 - Bug] `vi.mock()` top-level hoisted mock lost after `vi.resetModules()`**
- **Found during:** Task 1 (first test run attempt with top-level `vi.mock`)
- **Issue:** Top-level `vi.mock('../../src/lib/pushDispatcher.js', ...)` is hoisted before each test file execution, but `vi.resetModules()` in `beforeEach` clears the module registry. When tests dynamically imported `listChangeDispatcher.js`, the fresh load of `pushDispatcher.js` bypassed the mock factory.
- **Fix:** Removed top-level `vi.mock`; used `vi.doMock` inside `beforeEach` (after `vi.resetModules`) so each test's dynamic import of `listChangeDispatcher.js` gets a fresh mocked `pushDispatcher.js`.
- **Files modified:** `apps/api/tests/lib/listChangeDispatcher.test.ts`
- **Commit:** 6923104
## Known Stubs
None. `notifyListChange` is fully wired end-to-end. Push dispatch will fail with a logged error if VAPID keys are malformed (pre-existing infra issue from Plan 05-04, not a stub).
## Threat Flags
No new threat surface beyond what the plan's threat model covers. All three threats mitigated:
| Threat | Status |
|--------|--------|
| T-05-14: Info disclosure — push to non-member | Mitigated — audience = owner list_shares only |
| T-05-15: Info disclosure — item text in payload | Mitigated — D-02 generic copy only |
| T-05-16: Spoofing — actor notified of own change | Mitigated — D-03 excludeUserId = actorId |
## Self-Check
**Files created/verified:**
- [x] apps/api/src/lib/listChangeDispatcher.ts — exists (min_lines: 25 ✓, ~110 lines)
- [x] apps/api/tests/lib/listChangeDispatcher.test.ts — exists
**Key links verified:**
- [x] apps/api/src/routes/lists.ts imports and calls `notifyListChange` at 5 mutation sites
- [x] apps/api/src/lib/listChangeDispatcher.ts calls `coalesceListPush` from `pushCoalescer.ts`
**Commits verified:**
- 97f7026: test(05-05): add failing tests for listChangeDispatcher — RED gate
- 6923104: feat(05-05): implement listChangeDispatcher — VAPID send + access-scoped, self-suppressed, coalesced push (NOTIF-02)
- d2ce4e0: feat(05-05): hook notifyListChange into list/item mutations (reorder excluded)
## TDD Gate Compliance
- RED: `test(05-05): add failing tests for listChangeDispatcher — RED gate` — 97f7026
- GREEN: `feat(05-05): implement listChangeDispatcher...` — 6923104
## Self-Check: PASSED