--- phase: 09-faster-write-back plan: "01" subsystem: api-broker tags: [outbox, event-driven, tdd, drain-trigger, caldav] dependency_graph: requires: [] provides: - signalOutboxDrain (apps/api/src/lib/outboxTrigger.ts) - onOutboxDrain (apps/api/src/lib/outboxTrigger.ts) - scheduleOutboxDrain (apps/api/src/broker/outboxWorker.ts) - drainRequested (apps/api/src/broker/outboxWorker.ts) - initOutboxTrigger (apps/api/src/broker/outboxWorker.ts) affects: - apps/api/src/broker/outboxWorker.ts - apps/api/tests/broker/outboxWorker.test.ts tech_stack: added: - node:events EventEmitter (outboxTrigger.ts — zero external dependency) patterns: - Module-level EventEmitter singleton (same as listEmitter.ts) - scheduleOutboxDrain isDraining guard + drainRequested trailing-re-drain loop (D-05) - TDD RED/GREEN via test(09-01)/feat(09-01) commits key_files: created: - apps/api/src/lib/outboxTrigger.ts modified: - apps/api/src/broker/outboxWorker.ts - apps/api/tests/broker/outboxWorker.test.ts decisions: - "D-TDD-INIT: initOutboxTrigger() called in beforeAll (not beforeEach) for the trigger-wiring describe block — avoids listener accumulation while still wiring the EventEmitter → scheduleOutboxDrain path for signal-driven tests (SC-1, D-05)" - "D-TEST-C-MOCK: Test C (D-07) uses mockImplementationOnce to return the row on the first pending-rows query and empty on subsequent queries — correctly simulates drain 1 processing the row so the trailing re-drain (triggered by drainRequested) finds 0 rows and calls createCalendarEvent exactly once" metrics: duration_seconds: 341 completed_date: "2026-06-12" tasks_completed: 3 files_changed: 3 --- # Phase 09 Plan 01: Outbox Drain Trigger Wiring Summary **One-liner:** Zero-dependency in-process EventEmitter drain signal (`outboxTrigger.ts`) + `scheduleOutboxDrain` wrapper with `drainRequested` trailing-re-drain loop wired to `outboxWorker.ts`, eliminating the up-to-15s polling delay on enqueue. ## What Was Built ### Task 1: `apps/api/src/lib/outboxTrigger.ts` (new file) Module-level `EventEmitter` singleton following the `listEmitter.ts` analog: - `signalOutboxDrain(): void` — `emitter.emit('drain')`, fire-and-forget (D-04) - `onOutboxDrain(handler: () => void): () => void` — registers listener, returns unsubscribe - Only imports `node:events`; no internal dependencies (zero circular-import risk) - No `setMaxListeners` call — single subscriber, default limit of 10 is correct ### Task 2: RED test block in `apps/api/tests/broker/outboxWorker.test.ts` Added `describe('scheduleOutboxDrain — trigger wiring (D-09)', ...)` with 3 failing tests: - Test A (SC-1): `signalOutboxDrain()` triggers drain promptly without timer advance - Test B (D-05): two mid-drain signals collapse to exactly one trailing re-drain - Test C (D-07): two concurrent `scheduleOutboxDrain()` calls invoke `createCalendarEvent` exactly once Tests failed RED because `scheduleOutboxDrain` and `initOutboxTrigger` were not yet exported. ### Task 3: GREEN implementation in `apps/api/src/broker/outboxWorker.ts` Four additions to `outboxWorker.ts`: 1. `import { onOutboxDrain } from '../lib/outboxTrigger.js'` 2. `let drainRequested = false` — trailing-re-drain flag (D-05), immediately after `isDraining` 3. `export function scheduleOutboxDrain(): void` — checks `isDraining`; if true sets `drainRequested = true` and returns; otherwise calls `runOutboxDrain()` with `.catch` (D-02) and `.finally` that resets `drainRequested = false` BEFORE any recursive `scheduleOutboxDrain()` call (Pitfall 3 / T-09-01) 4. `export function initOutboxTrigger(): void` — calls `onOutboxDrain(() => scheduleOutboxDrain())` 5. `startOutboxWorker` setInterval body changed from `runOutboxDrain().catch(...)` to bare `scheduleOutboxDrain()` — 15s interval unchanged (D-08) `runOutboxDrain`'s body, `if (isDraining) return;`, `isDraining = true;`, and `finally { isDraining = false; }` are byte-for-byte unchanged. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Test Design] Test C mock required mockImplementationOnce to correctly simulate drain-1 row consumption** - **Found during:** Task 3 GREEN - **Issue:** Test C expects `createCalendarEvent` called exactly once, but the trailing re-drain triggered by `drainRequested` also executed against the same always-returning mock, calling `createCalendarEvent` twice. - **Fix:** Used `mockWherePending.mockImplementationOnce(() => Promise.resolve([row])).mockImplementation(() => Promise.resolve([]))` so drain 1 gets the row and the trailing drain finds empty. - **Files modified:** `apps/api/tests/broker/outboxWorker.test.ts` - **Commit:** `2b11304` **2. [Rule 2 - Missing test wiring] `beforeAll(initOutboxTrigger)` required to wire EventEmitter listener for signal-driven tests** - **Found during:** Task 3 GREEN (Tests A and B failed because no listener was registered) - **Issue:** Tests A and B call `signalOutboxDrain()` but without `initOutboxTrigger()` registering the listener, the signal went nowhere. - **Fix:** Added `beforeAll(() => { initOutboxTrigger(); })` to the new describe block; also imported `initOutboxTrigger` and changed `import { beforeEach }` to include `beforeAll`. - **Files modified:** `apps/api/tests/broker/outboxWorker.test.ts` - **Commit:** `2b11304` ## TDD Gate Compliance - RED commit: `bcde073` — `test(09-01): add failing trigger-wiring tests for SC-1, D-05, D-07` - GREEN commit: `2b11304` — `feat(09-01): add scheduleOutboxDrain, drainRequested, initOutboxTrigger; route setInterval through wrapper` - RED gate: 3 new tests failing (27 pre-existing passing) - GREEN gate: 30/30 tests passing, `tsc --noEmit` clean ## Verification Evidence ``` npx vitest run tests/broker/outboxWorker.test.ts Test Files 1 passed (1) Tests 30 passed (30) npx tsc --noEmit → (no output, clean) ``` ## Known Stubs None — all symbols produce correct runtime behavior. `signalOutboxDrain` is not yet wired to the enqueue path (Plan 02 adds it to `events.ts`); `initOutboxTrigger` is not yet called at startup (Plan 02 adds it to `index.ts`). These are intentional plan boundaries, not stubs. ## Threat Flags None — this plan introduces no new network endpoints, auth paths, file access patterns, or schema changes. The in-process EventEmitter boundary carries no payload and no user input crosses it. STRIDE mitigations T-09-01 through T-09-04 are implemented and verified by the trigger-wiring tests. ## Self-Check: PASSED - `apps/api/src/lib/outboxTrigger.ts` — FOUND - `apps/api/src/broker/outboxWorker.ts` — verified: `scheduleOutboxDrain`, `initOutboxTrigger`, `drainRequested` present - Task 1 commit `1e12d70` — FOUND - Task 2 commit `bcde073` — FOUND - Task 3 commit `2b11304` — FOUND