setTimeout fires once per window with excludeUserId=actorId
setTimeout
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.
@.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
pushCoalescer — per-(list,actor) debounce
apps/api/src/lib/pushCoalescer.ts, apps/api/tests/lib/pushCoalescer.test.ts
- 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}")
- 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.
Module-level `const pending = new Map}>()`. 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.
cd apps/api && pnpm exec vitest run tests/lib/pushCoalescer.test.ts
Test green: N-burst → 1 dispatch count=N; "1 change" singular/plural; window reset; per-actor isolation; excludeUserId=actorId asserted.
<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