Files
familysync/.planning/milestones/v1.1-phases/09-faster-write-back/09-02-PLAN.md
T
2026-06-18 22:21:38 -04:00

166 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 09-faster-write-back
plan: 02
type: execute
wave: 2
depends_on: ["09-01"]
files_modified:
- apps/api/src/routes/events.ts
- apps/api/src/index.ts
autonomous: true
requirements: [CAL-15]
must_haves:
truths:
- "Creating an event publishes the drain signal after the outbox insert commits, then returns 202 (SC-1, SC-2)"
- "Editing an event (same-calendar update) publishes the signal after the insert commits, then returns 202"
- "Edit-as-move publishes the signal AFTER the db.transaction resolves — never between the DELETE and CREATE inserts (SC-3 / D-03)"
- "Deleting an event publishes the signal after the insert commits, then returns 202"
- "No route handler makes an inline CalDAV call; signalOutboxDrain() is fire-and-forget, never awaited (SC-2 / D-04)"
- "The drain-signal listener is wired at startup only under isMainModule(), after startOutboxWorker (SC-5)"
artifacts:
- path: "apps/api/src/routes/events.ts"
provides: "Four post-commit signalOutboxDrain() publish sites (create, edit-as-move, same-cal update, delete)"
contains: "signalOutboxDrain"
- path: "apps/api/src/index.ts"
provides: "initOutboxTrigger() wiring under isMainModule(), after startOutboxWorker()"
contains: "initOutboxTrigger"
key_links:
- from: "apps/api/src/routes/events.ts"
to: "apps/api/src/lib/outboxTrigger.ts (signalOutboxDrain)"
via: "import + call after each enqueue commit"
pattern: "signalOutboxDrain\\(\\)"
- from: "apps/api/src/index.ts (isMainModule block)"
to: "apps/api/src/broker/outboxWorker.ts (initOutboxTrigger)"
via: "initOutboxTrigger() after startOutboxWorker()"
pattern: "initOutboxTrigger\\(\\)"
---
<objective>
Wire the drain signal into the live request and startup paths: publish `signalOutboxDrain()` after each of the four enqueue commits in `events.ts` (create, edit-as-move, same-calendar update, delete) and subscribe the drain listener at startup in `index.ts` via `initOutboxTrigger()` under `isMainModule()`.
Purpose: Connects the Plan 01 signal mechanism to real writes (so edits land in ~1-2s — SC-1) and to process startup (so the listener is active in production but never in the test process — SC-5), while keeping the optimistic-202 / no-inline-CalDAV route contract intact (SC-2 / D-04) and preserving create-before-delete ordering for moves (SC-3 / D-03).
Output: Four publish-site edits in `events.ts`, the `initOutboxTrigger()` startup call + import update in `index.ts`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-faster-write-back/09-CONTEXT.md
@.planning/phases/09-faster-write-back/09-RESEARCH.md
@.planning/phases/09-faster-write-back/09-PATTERNS.md
@.planning/phases/09-faster-write-back/09-01-SUMMARY.md
</context>
<artifacts_this_phase_produces>
This plan creates NO new symbols — it consumes symbols created by Plan 01:
| Consumed Symbol | Source File (Plan 01) | Consumed In |
|-----------------|----------------------|-------------|
| `signalOutboxDrain` | `apps/api/src/lib/outboxTrigger.ts` | `apps/api/src/routes/events.ts` (4 call sites) |
| `initOutboxTrigger` | `apps/api/src/broker/outboxWorker.ts` | `apps/api/src/index.ts` (startup wiring) |
Both symbols MUST already exist (Plan 01, Wave 1) before this plan runs — enforced by `depends_on: ["09-01"]`.
</artifacts_this_phase_produces>
<tasks>
<task type="auto">
<name>Task 1: Publish signalOutboxDrain() after each enqueue commit in events.ts (4 sites)</name>
<files>apps/api/src/routes/events.ts</files>
<read_first>
- apps/api/src/routes/events.ts (full file — confirm the four enqueue sites and their commit boundaries: Site 1 POST /create `await db.insert(calendarOutbox)` at ~L300 then `return c.json({ uid }, 202)` at L309; Site 2 PATCH /:uid/edit edit-as-move `await db.transaction(...)` at L408432 then `return c.json({ uid: newUid }, 202)` at L432; Site 3 PATCH /:uid/edit same-calendar update `await db.insert(calendarOutbox)` at ~L436 then `return c.json({ uid }, 202)` at L447; Site 4 DELETE /:uid `await db.insert(calendarOutbox)` at ~L511 then `return c.json({ uid }, 202)` at L521. All four sites are inside existing try/catch blocks.)
- apps/api/src/lib/outboxTrigger.ts (Plan 01 — import `signalOutboxDrain` from here)
</read_first>
<action>
Modify `apps/api/src/routes/events.ts`. (1) Add `import { signalOutboxDrain } from '../lib/outboxTrigger.js';` with the existing imports. (2) Insert a fire-and-forget `signalOutboxDrain();` call (NOT awaited — D-04) immediately AFTER the awaited enqueue and BEFORE the `return c.json(..., 202)` at each of the four sites:
- Site 1 (POST /create): after `await db.insert(calendarOutbox).values({...})` (~L300308), before `return c.json({ uid }, 202)` (L309).
- Site 2 (PATCH /:uid/edit, edit-as-move): after `await db.transaction(async (tx) => {...})` RESOLVES (L408432) — the call goes on the line AFTER the `await db.transaction(...)` statement, NOT inside the transaction callback. This is the critical placement (D-03 / Pitfall 6 / Pitfall 1): publishing inside the callback would fire before the DELETE+CREATE rows are durably committed. Place it before `return c.json({ uid: newUid }, 202)` (L432).
- Site 3 (PATCH /:uid/edit, same-calendar update): after `await db.insert(calendarOutbox).values({ operation: 'update', ... })` (~L436445), before `return c.json({ uid }, 202)` (L447).
- Site 4 (DELETE /:uid): after `await db.insert(calendarOutbox).values({ operation: 'delete', ... })` (~L511519), before `return c.json({ uid }, 202)` (L521).
Do NOT change any route's status code, response body, validation, ownership checks, or the no-inline-CalDAV behavior — the only change is adding the post-commit signal call at each site. Do NOT await the signal. Do NOT add a signal inside the `db.transaction` callback or in any `catch` block.
</action>
<verify>
<automated>cd apps/api && grep -c "signalOutboxDrain()" src/routes/events.ts | grep -qx 4 && echo "4 signal sites" && ! grep -Pzo "db\.transaction\(async[^)]*\)\s*=>\s*\{[^}]*signalOutboxDrain" src/routes/events.ts && echo "no signal inside transaction callback" && npx tsc --noEmit 2>&1 | grep -E 'events\.ts' || echo "tsc clean for events.ts"</automated>
</verify>
<acceptance_criteria>
- `events.ts` imports `signalOutboxDrain` from `'../lib/outboxTrigger.js'`
- Exactly four `signalOutboxDrain()` calls exist (`grep -c "signalOutboxDrain()" src/routes/events.ts` == 4), one per enqueue site
- At Site 2 the `signalOutboxDrain()` call appears AFTER the `await db.transaction(...)` statement, not inside its callback (no `signalOutboxDrain` between the two `tx.insert` calls)
- No `signalOutboxDrain()` is awaited (`grep -n "await signalOutboxDrain" src/routes/events.ts` returns nothing — D-04 fire-and-forget)
- No route handler gained an inline CalDAV call; status codes and bodies unchanged (still 202 with `{ uid }` / `{ uid: newUid }`)
- `cd apps/api && npx tsc --noEmit` reports no errors in `events.ts`
</acceptance_criteria>
<done>Four post-commit signal sites added; edit-as-move signal fires after the transaction commits; no awaited signal, no inline CalDAV; tsc clean.</done>
</task>
<task type="auto">
<name>Task 2: Wire initOutboxTrigger() at startup under isMainModule()</name>
<files>apps/api/src/index.ts</files>
<read_first>
- apps/api/src/index.ts (full file — confirm: import `import { startOutboxWorker } from './broker/outboxWorker.js';` at L16; `isMainModule()` function at L100107; the `if (isMainModule())` startup block at L112 with `startBrokerPoller()` (L136), `startOutboxWorker()` (L138), `startReminderScheduler()` (L141))
- apps/api/src/broker/outboxWorker.ts (Plan 01 — `initOutboxTrigger` is exported from this module alongside `startOutboxWorker`)
</read_first>
<action>
Modify `apps/api/src/index.ts`. (1) Update the existing import at L16 from `import { startOutboxWorker } from './broker/outboxWorker.js';` to also import `initOutboxTrigger`: `import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';`. (2) Inside the existing `if (isMainModule())` block, add `initOutboxTrigger();` on the line immediately AFTER `startOutboxWorker();` (L138) — so the fallback interval is registered before the signal listener subscribes (logical ordering; both are synchronous). Add an inline comment: `// subscribe drain signal listener (D-01)`. Do NOT add the call outside the `isMainModule()` guard — the listener MUST be gated so tests that import `app` never register it (Pitfall 4 — listener/timer leakage in tests). Do NOT modify `isMainModule()` itself, `startBrokerPoller`, `startReminderScheduler`, or the `serve(...)` call.
</action>
<verify>
<automated>cd apps/api && grep -q "import { startOutboxWorker, initOutboxTrigger }" src/index.ts && grep -q "initOutboxTrigger();" src/index.ts && awk '/if \(isMainModule\(\)\)/{f=1} f&&/initOutboxTrigger\(\)/{print "inside guard"; exit}' src/index.ts | grep -q "inside guard" && echo "guarded" && npx tsc --noEmit 2>&1 | grep -E 'index\.ts' || echo "tsc clean for index.ts"</automated>
</verify>
<acceptance_criteria>
- `index.ts` imports `initOutboxTrigger` from `'./broker/outboxWorker.js'` (same import statement as `startOutboxWorker`)
- `initOutboxTrigger();` appears inside the `if (isMainModule())` block, on the line after `startOutboxWorker();`
- No `initOutboxTrigger()` call exists outside the `isMainModule()` guard (Pitfall 4)
- `isMainModule()`, `startBrokerPoller`, `startReminderScheduler`, and `serve(...)` are unchanged
- `cd apps/api && npx tsc --noEmit` reports no errors in `index.ts`
</acceptance_criteria>
<done>initOutboxTrigger() wired after startOutboxWorker() inside the isMainModule() guard; import updated; tsc clean.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → route handler | Unchanged — same Zod validation, ownership checks, and optimistic-202 contract. This plan adds no new input, no new endpoint, no new status path. |
| route handler → in-process EventEmitter | A committed enqueue calls `signalOutboxDrain()` in-process. No payload, no network, fire-and-forget. |
| process startup → EventEmitter listener | `initOutboxTrigger()` registers the single drain listener only under `isMainModule()`. The test process never crosses this boundary. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-09-05 | Tampering | edit-as-move signal placement | mitigate | `signalOutboxDrain()` is published AFTER `await db.transaction(...)` resolves, never inside the callback (D-03 / Pitfall 1) — so the DELETE+CREATE rows are durably committed before any drain can observe them; create-before-delete ordering (SC-3) is never raced. Verify step asserts no signal inside the transaction callback. |
| T-09-06 | Denial of Service | listener registered in test process | mitigate | `initOutboxTrigger()` is called only inside `if (isMainModule())` (Pitfall 4) — tests importing `app` never register the listener, so no spurious cross-test drains and no open handle preventing process exit. Verify step asserts the call is inside the guard. |
| T-09-07 | Denial of Service | fire-and-forget signal blocking the request | accept | `signalOutboxDrain()` is synchronous and emits to a single in-process listener that returns immediately (the listener calls `scheduleOutboxDrain` which is itself fire-and-forget); it cannot block the 202 response. No CalDAV I/O happens inline (SC-2 / D-04). Low risk, no mitigation needed beyond not awaiting the call. |
No `npm`/`pip`/`cargo` install in this plan — no supply-chain (T-09-SC) threat. No new external package (RESEARCH Package Legitimacy Audit: none introduced).
</threat_model>
<verification>
- `cd apps/api && npx vitest run` — FULL suite GREEN (the existing events.ts route tests confirm 202 + no inline CalDAV still hold after the signal additions; the Plan 01 trigger-wiring tests still pass)
- `cd apps/api && npx tsc --noEmit` — no type errors in `events.ts` or `index.ts` (mandatory — Vitest passes while tsc fails per [[vitest-passes-tsc-fails]])
- `grep -c "signalOutboxDrain()" apps/api/src/routes/events.ts` == 4 — all four publish sites present
- `initOutboxTrigger()` is inside the `isMainModule()` block (Pitfall 4)
</verification>
<success_criteria>
- Four post-commit `signalOutboxDrain()` publish sites in events.ts (create, edit-as-move-after-transaction, same-cal update, delete) — SC-1
- Route handlers still return optimistic 202 with no inline CalDAV; signal is fire-and-forget — SC-2 / D-04
- Edit-as-move signal fires after the transaction commits, preserving create-before-delete ordering — SC-3 / D-03
- initOutboxTrigger() wired under isMainModule() after startOutboxWorker(); fallback interval still active — SC-5
- Full Vitest suite GREEN + tsc --noEmit clean
</success_criteria>
<output>
Create `.planning/phases/09-faster-write-back/09-02-SUMMARY.md` when done.
</output>