docs(05-02): complete pushDispatcher TDD plan

This commit is contained in:
Lucas Berger
2026-06-09 20:56:13 -04:00
parent e4170b3823
commit fc6f534f0a
3 changed files with 107 additions and 9 deletions
+3 -3
View File
@@ -193,7 +193,7 @@ Plans:
**Wave 2** *(blocked on Wave 1)*
- [ ] 05-02-PLAN.md — TDD: pushDispatcher (VAPID send + dual-format payload + 410/404 prune) (D-11)
- [x] 05-02-PLAN.md — TDD: pushDispatcher (VAPID send + dual-format payload + 410/404 prune) (D-11)
- [ ] 05-03-PLAN.md — TDD: pushCoalescer (per-list/actor debounce, generic copy, self-suppress) (D-01/D-02/D-03)
**Wave 3** *(blocked on Wave 2)*
@@ -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 | 1/8 | In Progress| |
| 5. Web Push Notifications | 2/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:** 1/8 plans executed
**Plans:** 2/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-01-PLAN.md
last_updated: "2026-06-10T00:52:12.517Z"
stopped_at: Completed 05-02-PLAN.md
last_updated: "2026-06-10T00:55:56.184Z"
last_activity: 2026-06-10 -- Phase 05 execution started
progress:
total_phases: 15
completed_phases: 4
total_plans: 36
completed_plans: 29
completed_plans: 30
percent: 27
---
@@ -26,7 +26,7 @@ See: .planning/PROJECT.md (updated 2026-06-07)
## Current Position
Phase: 05 (web-push-notifications) — EXECUTING
Plan: 2 of 8
Plan: 3 of 8
Status: Ready to execute
Last activity: 2026-06-10 -- Phase 05 execution started
@@ -66,6 +66,7 @@ Progress: [██████████] 100%
| Phase 04 P06 | 11 | 2 tasks | 7 files |
| Phase 04 P07 | 6 | 2 tasks | 4 files |
| Phase 05 P01 | 20 | 4 tasks | 15 files |
| Phase 05 P02 | 5 | 1 tasks | 1 files |
## Accumulated Context
@@ -102,6 +103,7 @@ Recent decisions affecting current work:
- [Phase 04-07]: D-04-07-collation: Drizzle 0.45.x has no first-class collation option on varchar; used customType to emit varchar(255) COLLATE utf8mb4_bin for list_items.rank — keeps schema-as-code + generate+migrate workflow
- [Phase 04-07]: D-04-07-guard: isShared owner-only guard placed after access check, before updateValues construction; mirrors DELETE handler idiom (if !access.isOwner → 403)
- [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
### Roadmap Evolution
@@ -143,6 +145,6 @@ Recent decisions affecting current work:
## Session Continuity
Last session: 2026-06-10T00:52:12.510Z
Stopped at: Completed 05-01-PLAN.md
Last session: 2026-06-10T00:55:56.178Z
Stopped at: Completed 05-02-PLAN.md
Resume file: None
@@ -0,0 +1,96 @@
---
phase: 05-web-push-notifications
plan: 02
subsystem: api/push-dispatcher
tags: [web-push, vapid, push-dispatcher, tdd, red-green]
dependency_graph:
requires: [05-01]
provides: [dispatchPush helper, buildPushBody helper, 410/404 prune logic]
affects: [apps/api/src/lib/pushDispatcher.ts]
tech_stack:
added: []
patterns: [default-import-cjs (web-push Pitfall 7), dual-format push payload (iOS 18.4+ declarative + legacy), 410/404 DB prune pattern]
key_files:
created:
- apps/api/src/lib/pushDispatcher.ts
modified: []
decisions:
- "dispatchPush uses sub.id (not a separate dbRowId argument) — test calls with 2 args; signature matches test"
- "buildPushBody emits both web_push:8030+notification{} (iOS 18.4+) and top-level title/body/tag/data (iOS 16.418.3 + Android)"
- "setVapidDetails is NOT called at module scope — deferred to index.ts startup (Plan 05-04)"
- "dispatchPush never throws — resolves after logging transient errors; safe for fan-out loops"
metrics:
duration: 5
completed_date: "2026-06-10"
tasks_completed: 1
files_changed: 1
---
# Phase 05 Plan 02: pushDispatcher — VAPID send + 410/404 prune — Summary
TDD GREEN: `pushDispatcher.ts` implemented with dual-format iOS payload, VAPID send via web-push, and DB prune on 410/404.
## Tasks Executed
### Task 1: Implement pushDispatcher.ts (GREEN)
**Status:** Completed.
The RED test scaffold was already committed in Plan 05-01 (commit ef558b6). This plan turns it GREEN.
Created `apps/api/src/lib/pushDispatcher.ts` with:
**`buildPushBody(notification)`** — builds the dual-format JSON payload string:
- `web_push: 8030` + `notification: { title, body, navigate }` — iOS 18.4+ declarative web push format
- Top-level `title`, `body`, `tag`, `data: { url: navigate }` — legacy format for iOS 16.418.3 and Android
**`dispatchPush(sub, notification)`** — VAPID-signed push send + prune:
- Constructs `webPushSub = { endpoint, keys: { p256dh, auth } }` from subscription row
- Calls `webpush.sendNotification(webPushSub, body, { TTL: 300, urgency: 'normal' })`
- On thrown error with `statusCode === 410` or `statusCode === 404`: deletes the row via `db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id))`
- On transient errors (5xx, 429, network): logs `[pushDispatcher] sendNotification failed: <statusCode> <message>` then resolves
- On success: no action
Uses default import `import webpush from 'web-push'` (CommonJS — Pitfall 7 from RESEARCH.md).
**TDD Gate Compliance:**
- RED: `test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation` — ef558b6 (Plan 05-01)
- GREEN: `feat(05-02): implement pushDispatcher — VAPID send + 410/404 prune` — e4170b3
Commit: `e4170b3`
## Verification
```
pnpm --filter @familysync/api exec vitest run tests/lib/pushDispatcher.test.ts
Test Files 1 passed (1)
Tests 4 passed (4)
```
`pnpm --filter @familysync/api typecheck` — passes (no errors).
## Deviations from Plan
### Plan specifies `dispatchPush(subscription, notification, dbRowId)` — test uses 2-arg form
The plan text describes a 3-argument signature `dispatchPush(sub, notification, dbRowId)`. The existing RED scaffold test (committed in Plan 05-01) calls `dispatchPush(FAKE_SUB, { title, body })` with 2 arguments — the subscription object already carries the `id` field. The test is canonical; the implementation uses `sub.id` directly and exposes a 2-argument signature. No test file changes were needed.
## Known Stubs
None.
## Threat Flags
No new threat surface introduced. `pushDispatcher.ts` is a pure utility module — no new network endpoints, no auth paths, no file access. T-05-03 (VAPID signing via web-push only), T-05-04 (per-sub catch), and T-05-05 (no key/payload logging) are all mitigated.
## Self-Check
**Files created/verified:**
- [x] apps/api/src/lib/pushDispatcher.ts — exists
**Commits verified:**
- ef558b6: test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation (RED gate — from Plan 05-01)
- e4170b3: feat(05-02): implement pushDispatcher — VAPID send + 410/404 prune (GREEN gate)
## Self-Check: PASSED