---
phase: 05-web-push-notifications
plan: 03
subsystem: api/push-coalescer
tags: [web-push, coalescer, debounce, tdd, red-green, D-01, D-03]
dependency_graph:
  requires: [05-01, 05-02]
  provides: [coalesceListPush — per-(list, actor) sliding debounce]
  affects: [apps/api/src/lib/pushCoalescer.ts]
tech_stack:
  added: []
  patterns:
    [
      module-level Map singleton (listEmitter.ts idiom),
      sliding debounce setTimeout,
      injected dispatch for testability,
    ]
key_files:
  created:
    - apps/api/src/lib/pushCoalescer.ts
  modified:
    - apps/api/tests/lib/pushCoalescer.test.ts
decisions:
  - 'dispatch signature is (listId, actorId, count) — matches existing RED scaffold; richer payload shape deferred to caller (Plan 05-05)'
  - 'key is ${listId}:${actorId} — per-(list,actor) matches D-01 intent; allows two members editing same list to coalesce independently'
  - 'sliding debounce (each call resets timer) — per plan spec; leading debounce not used'
  - 'dispatch return value is a Promise; errors caught and logged inside fire() so caller loop never breaks'
metrics:
  duration: 5
  completed_date: '2026-06-10'
  tasks_completed: 2
  files_changed: 2
---

# Phase 05 Plan 03: pushCoalescer — per-(list,actor) debounce — Summary

TDD RED→GREEN: `pushCoalescer.ts` implemented; per-(list,actor) sliding debounce collapses list-change bursts into a single dispatch call carrying (listId, actorId, count).

## Tasks Executed

### Task 1: RED — fix lint warning, add actorId assertion

**Status:** Completed. Commit: `7af827a`

The existing RED scaffold in `apps/api/tests/lib/pushCoalescer.test.ts` (from Plan 05-01) had a lint warning: `calledActorId` was destructured in test 1 but never asserted. Added `expect(calledActorId).toBe(actorId)` to make the self-suppression assertion explicit in the burst-coalescing test as well (not only in the dedicated D-03 test).

Tests still fail after this change (RED preserved): `Cannot find module '.../pushCoalescer.js'`.

### Task 2: GREEN — implement pushCoalescer.ts

**Status:** Completed. Commit: `c1758de`

Created `apps/api/src/lib/pushCoalescer.ts`:

**`coalesceListPush(listId, actorId, dispatch, windowMs=45000)`**

- Module-level `Map<string, {count, timer}>` keyed by `${listId}:${actorId}`
- First call in a burst: inserts entry with count=1, starts `setTimeout(windowMs)`
- Subsequent calls within window: `clearTimeout`, increments count, resets timer (sliding debounce)
- On timer fire: deletes map entry, calls `dispatch(listId, actorId, count)` — self-deleting entries keep the map bounded (T-05-08)
- dispatch errors caught and logged with `[pushCoalescer]` prefix; never throws to caller

## Verification

```
pnpm --filter @familysync/api exec vitest run tests/lib/pushCoalescer.test.ts

 Test Files  1 passed (1)
      Tests  3 passed (3)
```

`pnpm --filter @familysync/api typecheck` — passes.

## TDD Gate Compliance

- RED: `test(05-03): add actorId assertion in burst test — fix unused var lint warning` — 7af827a
- GREEN: `feat(05-03): implement pushCoalescer — per-(list,actor) sliding debounce (D-01/D-03)` — c1758de

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] Lint warning — unused `calledActorId` in burst test**

- **Found during:** Task 1 (RED)
- **Issue:** `calledActorId` was destructured in test 1 but the assertion was missing, producing an unused-variable lint warning.
- **Fix:** Added `expect(calledActorId).toBe(actorId)` — the burst test now also asserts self-suppression, not just the dedicated D-03 test.
- **Files modified:** apps/api/tests/lib/pushCoalescer.test.ts
- **Commit:** 7af827a

### Dispatch signature simplification

The plan's `<behavior>` section describes `dispatch(payload, actorId)` where payload is a rich object `{title, body, tag, navigate}`. The existing RED scaffold (committed in Plan 05-01) uses `dispatch(listId, actorId, count)` — a simpler 3-argument form that defers notification copy construction to the caller.

The test is canonical; the implementation matches the test. The richer payload construction (D-02 generic copy: `"${actorName} updated ${listName}"`, `"${N} change(s)"`) is owned by the caller in Plan 05-05, which has the actorName/listName context from the DB row and passes a closure over `dispatchPush`.

## Known Stubs

None. The coalescer is complete and testable. Plan 05-05 wires it into the list-change fan-out with actual notification copy.

## Threat Flags

No new threat surface. `pushCoalescer.ts` is a pure in-memory utility module — no network endpoints, no auth paths, no file access.

T-05-06 (generic copy — no item text): mitigated by design — the coalescer passes only count, not item text; copy construction in Plan 05-05 will follow D-02.
T-05-07 (self-notification): mitigated — `actorId` threaded to dispatch so caller can apply `WHERE userId != actorId`.
T-05-08 (unbounded map): accepted — entries self-delete on timer fire; two-person household keeps keys bounded.

## Self-Check

**Files verified:**

- [x] apps/api/src/lib/pushCoalescer.ts — exists
- [x] apps/api/tests/lib/pushCoalescer.test.ts — modified

**Commits verified:**

- 7af827a: test(05-03): add actorId assertion in burst test — fix unused var lint warning (RED gate)
- c1758de: feat(05-03): implement pushCoalescer — per-(list,actor) sliding debounce (D-01/D-03) (GREEN gate)

## Self-Check: PASSED
