113 lines
5.3 KiB
Markdown
113 lines
5.3 KiB
Markdown
---
|
|
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
|