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.
110 lines
5.9 KiB
Markdown
110 lines
5.9 KiB
Markdown
---
|
|
phase: 05-web-push-notifications
|
|
plan: 03
|
|
type: tdd
|
|
wave: 2
|
|
depends_on: [05-01]
|
|
files_modified:
|
|
- apps/api/src/lib/pushCoalescer.ts
|
|
- apps/api/tests/lib/pushCoalescer.test.ts
|
|
autonomous: true
|
|
requirements: [NOTIF-02]
|
|
|
|
must_haves:
|
|
truths:
|
|
- 'A burst of N coalesceListPush calls for the same (listId, actorId) within the window fires exactly ONE dispatch with count=N (D-01)'
|
|
- "The coalesced dispatch passes the actor's userId as excludeUserId so the actor is never notified of their own change (D-03)"
|
|
- "The coalesced notification copy is generic: title 'ActorName updated ListName', body 'N change(s)' — no item text (D-02)"
|
|
- 'A new burst after the window fired starts a fresh count (timer/map entry cleared)'
|
|
artifacts:
|
|
- path: 'apps/api/src/lib/pushCoalescer.ts'
|
|
provides: 'coalesceListPush(listId, actorId, actorName, listName, dispatch, windowMs) — per-(list,actor) debounce'
|
|
exports: ['coalesceListPush']
|
|
min_lines: 25
|
|
key_links:
|
|
- from: 'apps/api/src/lib/pushCoalescer.ts'
|
|
to: 'dispatch callback'
|
|
via: 'setTimeout fires once per window with excludeUserId=actorId'
|
|
pattern: 'setTimeout'
|
|
---
|
|
|
|
<objective>
|
|
TDD the list-change coalescing debounce (D-01): collapse a rapid burst of edits to one list by one member into a single push, naming the actor + list + change count (D-02/D-03 generic copy). Reorder changes are excluded upstream (Plan 05-05 does not call this for position changes).
|
|
|
|
Purpose: Lists are the chattier, lower-stakes source. Without coalescing a grocery burst would fire one push per keystroke-save. The debounce is pure in-memory logic (single process, D-12) and is the load-bearing anti-spam primitive for NOTIF-02.
|
|
|
|
Output: `apps/api/src/lib/pushCoalescer.ts` with `coalesceListPush`, turning the Plan 05-01 RED scaffold GREEN.
|
|
</objective>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@apps/api/src/lib/listEmitter.ts
|
|
@.planning/phases/05-web-push-notifications/05-RESEARCH.md
|
|
@.planning/phases/05-web-push-notifications/05-UI-SPEC.md
|
|
</context>
|
|
|
|
<feature>
|
|
<name>pushCoalescer — per-(list,actor) debounce</name>
|
|
<files>apps/api/src/lib/pushCoalescer.ts, apps/api/tests/lib/pushCoalescer.test.ts</files>
|
|
<read_first>
|
|
- apps/api/src/lib/listEmitter.ts (module-level Map singleton idiom)
|
|
- .planning/phases/05-web-push-notifications/05-RESEARCH.md (Pattern 6 pushCoalescer; D-01 window 30-60s)
|
|
- .planning/phases/05-web-push-notifications/05-UI-SPEC.md (## Notification Content Contract → List change: title "{ActorName} updated {ListName}", body "{N} change{s}", tag "list-change-{listId}", data.url "/lists/{listId}")
|
|
</read_first>
|
|
<behavior>
|
|
- coalesceListPush(listId, actorId, actorName, listName, dispatch, windowMs=45000): keyed by `${listId}:${actorId}`.
|
|
- First call: count=1, sets a setTimeout(windowMs).
|
|
- Subsequent calls within window: count++, clearTimeout + reset timer (sliding window).
|
|
- On timer fire: delete the map entry, call dispatch(payload, actorId) where payload = { title:`${actorName} updated ${listName}`, body:`${count} change${count===1?'':'s'}`, tag:`list-change-${listId}`, navigate:`/lists/${listId}` }.
|
|
- Cases (vi.useFakeTimers):
|
|
- 3 calls within window then advance time → dispatch called once, body "3 changes", excludeUserId=actorId.
|
|
- 1 call then advance → body "1 change".
|
|
- burst, advance past window, second burst, advance → dispatch called twice, each fresh count.
|
|
- two different actorIds on the same list → two independent entries → two dispatches.
|
|
</behavior>
|
|
<implementation>
|
|
Module-level `const pending = new Map<string, {count:number; timer: ReturnType<typeof setTimeout>}>()`. dispatch is injected (Plan 05-05 passes a closure over dispatchPush+subscription-fan-out) so the coalescer stays pure and testable. The `excludeUserId=actorId` argument is how D-03 self-suppression is plumbed; the caller's fan-out filters `WHERE userId != excludeUserId`. Export coalesceListPush only.
|
|
</implementation>
|
|
<verify>
|
|
<automated>cd apps/api && pnpm exec vitest run tests/lib/pushCoalescer.test.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
Test green: N-burst → 1 dispatch count=N; "1 change" singular/plural; window reset; per-actor isolation; excludeUserId=actorId asserted.
|
|
</acceptance_criteria>
|
|
</feature>
|
|
|
|
<threat_model>
|
|
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
| ---------- | ------------------------------------------------------------------------------- |
|
|
| in-process | coalescer holds no external input; actorName/listName come from trusted DB rows |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
| --------- | ---------------------- | ----------------------- | ----------- | -------------------------------------------------------------------------------------- |
|
|
| T-05-06 | Information Disclosure | list-change copy | mitigate | D-02 generic copy — no item text in payload; only actor name + count + list name |
|
|
| T-05-07 | Spoofing | actor self-notification | mitigate | excludeUserId=actorId threaded to the fan-out (D-03); caller filters userId != actorId |
|
|
| T-05-08 | Denial of Service | unbounded pending map | accept | Two-person household, per-(list,actor) keys bounded; entries self-delete on fire |
|
|
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- RED precedes GREEN; pushCoalescer.test.ts green.
|
|
- `pnpm --filter @familysync/api typecheck` passes.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
|
|
- Failing test committed (RED).
|
|
- coalesceListPush implemented; test passes (GREEN).
|
|
- Burst→single, plural rules, window reset, per-actor isolation, self-suppression all verified.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/05-web-push-notifications/05-03-SUMMARY.md` with RED/GREEN commits.
|
|
</output>
|