---
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
