Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
38 KiB
Phase 9: Faster Write-Back - Research
Researched: 2026-06-12 Domain: In-process event-driven outbox drain signal (Node.js EventEmitter, TypeScript) Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01: New artifact is a single zero-dependency in-process
EventEmitteratapps/api/src/lib/outboxTrigger.ts. No new npm dependency. - D-02: The signal funnels through the existing
isDraining-guardedrunOutboxDrain()path via adrainRequestedflag. The trigger must NEVER callrunOutboxDrain()directly in a way that bypasses theisDrainingguard or escapes the error-caught wrapper (Pitfall 5 — no double-drain). - D-03: The signal is published after the enqueue transaction commits — never between the two inserts of an edit-as-move (Pitfall 6). For a move, publish once after both CREATE and DELETE rows are committed, so create-before-delete ordering is never raced by the signal.
- D-04: Route handlers still return optimistic 202 immediately and make no inline CalDAV call; the signal is fire-and-forget.
- D-05: Guarantee the last edit in a burst. When a signal arrives while a drain is already in flight, set
drainRequestedand re-run the drain exactly once after the current one finishes (drain-again-if-requested loop). - D-06: No debounce/coalesce window and no rejection of mid-drain signals.
- D-07: Exactly-once per uid preserved — trailing re-drain reuses the same
pending AND next_attempt_at <= NOW()selection andisDrainingguard. - D-08: Keep the 15s
setIntervalfallback exactly as-is. Do not lengthen it. - D-09: Prove SC-1 with an automated integration test on the trigger wiring (Vitest). Also cover trailing-re-drain and no-double-PUT overlap cases.
- D-10: No operator-stopwatch human checkpoint required.
Claude's Discretion
- Exact shape of the
drainRequested/re-drain loop (where the flag lives, whether the loop sits insiderunOutboxDrain'sfinallyor in the scheduler wrapper). - Whether the EventEmitter is a module singleton vs. a tiny custom signal object — either is fine; zero-dep is the only hard constraint.
- Test file placement follows the existing convention (API tests live under
apps/api/tests/, neversrc/).
Deferred Ideas (OUT OF SCOPE)
- Multi-process / multi-replica outbox drain — durable DB row-claim instead of the in-memory
isDrainingguard. Not this phase. - Debounce/coalesce knob for burst write-back — rejected for now (D-06). </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| CAL-15 | A created, edited, or deleted event reaches Fastmail within ~2 seconds (event-driven outbox drain) instead of up to ~15s, while preserving the optimistic-202 accept and all outbox durability guarantees (create-before-delete ordering, drain concurrency guard, fresh-etag-before-PUT, per-uid exactly-once). | Signal mechanism design (Q1–Q4), post-commit publish points (Q3), test wiring (Q5). |
| </phase_requirements> |
Summary
Phase 9 adds a single new module — apps/api/src/lib/outboxTrigger.ts — that is a module-level EventEmitter singleton following the exact pattern already established by apps/api/src/lib/listEmitter.ts. The outbox drain loop in outboxWorker.ts already has all the durability machinery needed; the only required change is adding a drainRequested flag and a trailing-re-drain loop around the isDraining guard, then wiring a listener so an enqueue signal triggers a prompt drain.
The two concrete changes to existing files are: (1) outboxWorker.ts gains the drainRequested flag and a wrapper function (scheduleOutboxDrain) that implements the drain-again-if-requested loop and gets called from both the EventEmitter listener and the 15s setInterval; (2) events.ts gains three call sites that publish the signal after each commit. Startup wiring in index.ts follows the existing startBrokerPoller/startOutboxWorker pattern.
The test strategy builds directly on the existing outboxWorker.test.ts mock infrastructure. No new npm packages are needed.
Primary recommendation: Implement the drainRequested loop as a thin scheduler wrapper (scheduleOutboxDrain) in outboxWorker.ts rather than inside runOutboxDrain's finally, so runOutboxDrain itself remains testable in isolation and its existing tests are unchanged.
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Event-driven drain signal | API / Backend (in-process) | — | Single-process; no Redis for drain; in-memory EventEmitter is correct tier |
| Outbox row enqueue | API / Backend (route handler) | — | Existing responsibility; this phase only adds the post-commit signal publish |
| Drain execution + CalDAV I/O | API / Backend (broker) | — | runOutboxDrain already owns all CalDAV dispatch; signal funnels into it |
| Fallback interval timing | API / Backend (broker) | — | 15s setInterval in startOutboxWorker; unchanged |
| Test wiring validation | API / Backend (Vitest) | — | Unit/integration tests under apps/api/tests/; no UI involved |
Standard Stack
Core
No new packages required for this phase. [VERIFIED: CLAUDE.md + codebase read]
| Module | Purpose | Status |
|---|---|---|
node:events (built-in) |
EventEmitter for the drain signal |
Already used in listEmitter.ts |
vitest |
Test framework for trigger wiring tests | Already installed and configured |
Supporting
No supporting additions needed.
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
Module-level EventEmitter singleton |
Custom minimal signal object ({ on, emit, off }) |
Either satisfies D-01. The EventEmitter approach is idiomatic Node.js, matches listEmitter.ts exactly, and requires zero extra code. A custom object is slightly lighter but offers no practical advantage here. |
| Trailing-re-drain in scheduler wrapper | Trailing-re-drain inside runOutboxDrain's finally |
Wrapper approach keeps runOutboxDrain a pure, independently testable drain cycle with no self-rescheduling logic. The finally approach couples drain + re-scheduling into one function, complicates existing test isolation. |
Installation: No new packages required.
Package Legitimacy Audit
No new external packages are introduced in this phase. The only new code uses node:events (Node.js built-in, zero-dep constraint from D-01). [VERIFIED: codebase read]
Packages removed due to SLOP verdict: none Packages flagged as suspicious SUS: none
Architecture Patterns
System Architecture Diagram
POST /api/events/create
PATCH /api/events/:uid/edit ──► db.insert (/ db.transaction)
DELETE /api/events/:uid │
commit │ (post-commit only)
▼
outboxTrigger.emit('drain')
│
┌────────────────┘
│ listener in outboxWorker.ts
▼
scheduleOutboxDrain() ◄── 15s setInterval also calls this
│
drainRequested already set?
Yes → drainRequested=true (D-05: trailing flag)
No → if (!isDraining) runOutboxDrain()
│
finally: drainRequested?
Yes → clear flag, call runOutboxDrain() again (once)
│
CalDAV PUT/DELETE to Fastmail
│
Mark outbox row 'done'
│
triggerTargetedResync()
Recommended Project Structure
No structural changes to existing directories. One new file added:
apps/api/src/
└── lib/
├── listEmitter.ts # existing — established pattern to follow
└── outboxTrigger.ts # NEW: module-level drain signal emitter
apps/api/tests/
└── broker/
└── outboxWorker.test.ts # extend with trigger wiring tests (D-09)
Key Open Questions — Answers for the Planner
Q1: Where does drainRequested and the trailing-re-drain loop belong?
Answer: In a new scheduleOutboxDrain() wrapper in outboxWorker.ts, NOT inside runOutboxDrain's finally. [VERIFIED: codebase read — outboxWorker.ts]
Rationale from the existing code:
runOutboxDrain() (L602–L786) is a self-contained drain cycle:
if (isDraining) return;guard at the top (L604)isDraining = true;(L605)try { ... } finally { isDraining = false; }(L783–L785)
The finally block currently contains only isDraining = false. Adding a re-drain call there would mean runOutboxDrain recurses into itself via a flag, making the function non-idempotent and complicating the existing 15 test cases that call runOutboxDrain() directly.
Concrete shape:
// Module-level flag (joins isDraining)
let drainRequested = false;
/**
* Scheduler entrypoint called by both the EventEmitter listener and the 15s setInterval.
* Implements the drain-again-if-requested loop (D-05) so the last edit in a burst
* is drained promptly without double-draining the same row (D-02 / D-07).
*
* Fire-and-forget: callers do not await this.
*/
function scheduleOutboxDrain(): void {
if (isDraining) {
// A drain is already in flight — set flag so it re-runs when done (D-05)
drainRequested = true;
return;
}
// Kick off a drain cycle; on completion, check if another was requested
runOutboxDrain()
.catch((err: unknown) => {
console.error('[outboxWorker] Unhandled runOutboxDrain error:', err);
})
.finally(() => {
if (drainRequested) {
drainRequested = false;
scheduleOutboxDrain(); // exactly one trailing re-drain (D-05)
}
});
}
Key properties:
isDrainingis set/cleared insiderunOutboxDrainexactly as today — no change torunOutboxDrain.- When a signal arrives while
isDraining=true,drainRequested=trueis set. After the in-flight drain'sfinallyclearsisDraining=false, the.finally()on the outer Promise re-callsscheduleOutboxDrain(), which seesisDraining=falseand starts a fresh drain. - The recursive
scheduleOutboxDrain()call in.finally()is safe becausedrainRequestedis cleared before the call, so a second re-entrant signal during the trailing drain sets it again to true and produces exactly one more follow-up — not an unbounded chain. - The 15s
setIntervalalready callsrunOutboxDrain()directly (L797). It should be updated to callscheduleOutboxDrain()instead, so the fallback path also benefits from the trailing-re-drain guarantee and doesn't double-drain if a signal-triggered drain is in progress at the 15s mark.
Updated startOutboxWorker:
export function startOutboxWorker(): void {
setInterval(() => {
scheduleOutboxDrain();
}, 15 * 1000);
}
Q2: Module/singleton shape for outboxTrigger.ts
Answer: Module-level EventEmitter singleton, following listEmitter.ts verbatim. [VERIFIED: codebase read — listEmitter.ts]
listEmitter.ts establishes the exact pattern already used in this codebase:
// apps/api/src/lib/outboxTrigger.ts
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
// No persistent listeners — max 1 (the outboxWorker subscriber). Default limit is 10.
/**
* Signal the outbox drain that a new row was enqueued.
* Called from routes/events.ts after each enqueue commit (D-03).
* Fire-and-forget — never awaited.
*/
export function signalOutboxDrain(): void {
emitter.emit('drain');
}
/**
* Subscribe to drain signals. Returns an unsubscribe function.
* Called once from outboxWorker.ts (via initOutboxTrigger in index.ts).
*/
export function onOutboxDrain(handler: () => void): () => void {
emitter.on('drain', handler);
return () => emitter.off('drain', handler);
}
Circular-import safety: outboxTrigger.ts imports only node:events. It has no imports from broker/, routes/, db/, or index.ts. Both consumers import from it:
routes/events.tsimportssignalOutboxDrain(publish side)broker/outboxWorker.tsimportsonOutboxDrainvia a wiring call fromindex.ts(subscribe side)
There is no circular import risk because outboxTrigger.ts depends on nothing in the project.
Timer/listener leakage in Vitest: The EventEmitter itself has no timers. Listeners are added only under isMainModule() in index.ts. Test files import runOutboxDrain from outboxWorker.ts directly — they never trigger isMainModule() initialization — so no listener is registered in the test process. This is the same pattern used for startBrokerPoller, startOutboxWorker, and startReminderScheduler (all gated by isMainModule() at index.ts L112).
Q3: Exact post-commit publish points in events.ts
Answer: Three publish sites, all after the await db.insert()/await db.transaction() call that commits the outbox row(s). [VERIFIED: codebase read — events.ts]
The current enqueue sites and their commit boundaries:
Site 1 — POST /api/events/create (L300–L308):
await db.insert(calendarOutbox).values({ ... });
// ← publish here (after db.insert resolves)
return c.json({ uid }, 202);
The db.insert is a single auto-committed statement (no explicit transaction). Signal fires immediately after the awaited insert.
Site 2 — PATCH /api/events/:uid/edit, same-calendar update (L436–L445):
await db.insert(calendarOutbox).values({ operation: 'update', ... });
// ← publish here
return c.json({ uid }, 202);
Same pattern — single auto-committed insert.
Site 3 — PATCH /api/events/:uid/edit, edit-as-move (L408–L432):
await db.transaction(async (tx) => {
await tx.insert(calendarOutbox).values({ operation: 'delete', ... });
await tx.insert(calendarOutbox).values({ operation: 'create', ... });
});
// ← publish HERE — after the transaction commits (D-03)
// NOT inside the transaction callback (that is before commit, not after)
return c.json({ uid: newUid }, 202);
This is the critical one: the signal must fire after the await db.transaction(...) resolves, not inside the callback. Both rows are committed atomically when db.transaction resolves. Publishing inside the callback would fire the signal before the DELETE row is durably written (violates D-03 / Pitfall 6 — create-before-delete ordering could be raced).
Site 4 — DELETE /api/events/:uid (L511–L519):
await db.insert(calendarOutbox).values({ operation: 'delete', ... });
// ← publish here
return c.json({ uid }, 202);
All four sites are inside a try/catch block. The signal call should go immediately after the successful await, before the return c.json(...). The signal is fire-and-forget — no await on signalOutboxDrain().
Pattern (same for all sites):
await db.insert(calendarOutbox).values({ ... });
signalOutboxDrain(); // fire-and-forget, D-04
return c.json({ uid }, 202);
Q4: Wiring in index.ts under isMainModule()
Answer: Add initOutboxTrigger() export to outboxWorker.ts that subscribes the listener, and call it from index.ts alongside startOutboxWorker(). [VERIFIED: codebase read — index.ts L112–L146]
The existing startup block at index.ts L136–L141:
if (isMainModule()) {
// ... VAPID setup ...
startBrokerPoller();
startOutboxWorker();
startReminderScheduler();
// serve(...)
}
Add the subscription wiring as an exported function in outboxWorker.ts:
import { onOutboxDrain } from '../lib/outboxTrigger.js';
export function initOutboxTrigger(): void {
onOutboxDrain(() => scheduleOutboxDrain());
}
Then in index.ts:
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
if (isMainModule()) {
// ...
startOutboxWorker();
initOutboxTrigger(); // wire drain signal after startOutboxWorker (D-01)
// ...
}
Order matters: startOutboxWorker() should be called before initOutboxTrigger() so the fallback interval is running before the signal listener is active. In practice both are synchronous registrations so order within the same tick is fine, but matching the logical dependency is cleaner.
Q5: Vitest test strategy for trigger wiring (D-09)
Answer: Extend tests/broker/outboxWorker.test.ts with a new describe block for trigger wiring. Use the existing mock infrastructure verbatim; control async timing with vi.useFakeTimers() or manual promise resolution. [VERIFIED: codebase read — tests/broker/outboxWorker.test.ts]
Test placement: apps/api/tests/broker/outboxWorker.test.ts — extend the existing file, or a sibling tests/lib/outboxTrigger.test.ts if isolation is preferred. The existing outboxWorker.test.ts already has the full DB/write/sync mock scaffold. Extending it avoids re-wiring the mock chain.
What to test (D-09 + CAL-15 Success Criteria):
Test A — SC-1: enqueue signal → drain invoked promptly
Strategy: spy on runOutboxDrain (or on createCalendarEvent from write.js) and assert it is called after signalOutboxDrain() without any timer advancement.
it('signal after enqueue invokes runOutboxDrain without waiting 15s', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201));
mockPendingRows = [makeRow()];
signalOutboxDrain();
// Let microtasks / promise chain flush
await new Promise(resolve => setImmediate(resolve));
expect(createCalendarEvent).toHaveBeenCalledTimes(1);
// No timer advance needed — signal fires synchronously
});
Note: signalOutboxDrain calls emitter.emit('drain') synchronously. The listener calls scheduleOutboxDrain(). If isDraining is false, runOutboxDrain() is called immediately (but it returns a Promise). The await new Promise(resolve => setImmediate(resolve)) flushes the microtask queue so the async drain completes.
Test B — SC-4 / D-05: signal during in-flight drain → exactly one trailing re-drain
Strategy: make createCalendarEvent slow (using a manually resolved promise), fire the signal twice, assert runOutboxDrain ran exactly twice (not three or more times).
it('D-05: signal arriving mid-drain triggers exactly one trailing re-drain', async () => {
let resolveFirst: () => void;
const firstDone = new Promise<void>(res => { resolveFirst = res; });
const { createCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(createCalendarEvent)
.mockImplementationOnce(async () => { await firstDone; return makeResponse(201); })
.mockResolvedValue(makeResponse(201));
mockPendingRows = [makeRow({ id: 1 }), makeRow({ id: 2 })];
// Start drain 1 (in-flight)
signalOutboxDrain();
// While drain 1 is in flight, fire two more signals
signalOutboxDrain();
signalOutboxDrain();
// Release drain 1
resolveFirst!();
// Wait for trailing drain to complete
await new Promise(resolve => setImmediate(resolve));
await new Promise(resolve => setImmediate(resolve));
// createCalendarEvent called for rows in drain 1 + exactly one trailing drain
// Not called 3+ times (coalesced signals → one trailing run)
expect(vi.mocked(createCalendarEvent).mock.calls.length).toBeLessThanOrEqual(
mockPendingRows.length * 2
);
});
Test C — SC-4 / D-07: 15s fallback + signal do not double-PUT the same row
Strategy: rely on the existing CR-05 test which already verifies isDraining prevents concurrent runOutboxDrain calls. The scheduler wrapper shares the same isDraining guard, so the existing test covers this. Add a new test that calls scheduleOutboxDrain() twice concurrently and asserts createCalendarEvent is called exactly once.
it('D-07: concurrent scheduleOutboxDrain calls dispatch each row exactly once', async () => {
const { createCalendarEvent } = await import('../../src/broker/write.js');
vi.mocked(createCalendarEvent).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve(makeResponse(201)), 20))
);
mockPendingRows = [makeRow({ id: 1 })];
scheduleOutboxDrain();
scheduleOutboxDrain();
await vi.runAllTimersAsync(); // flush setTimeout(20ms)
expect(createCalendarEvent).toHaveBeenCalledTimes(1);
});
Exporting scheduleOutboxDrain for tests: scheduleOutboxDrain is an internal scheduler function. For testability, export it from outboxWorker.ts similarly to how runOutboxDrain is already exported (runOutboxDrain is exported for unit testing — L19). The same rationale applies.
Deterministic async control: Use setImmediate-based microtask flushing (not vi.useFakeTimers()) for signal→drain tests, since the signal path is entirely microtask-driven (no setTimeout/setInterval). Use vi.useFakeTimers() only for tests that need to advance the 15s fallback interval.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Drain signal mechanism | Custom pub/sub, Redis channel, custom promise queue | node:events EventEmitter |
Built-in, zero-dep, already used in listEmitter.ts. The drain is single-process — no cross-process messaging needed. |
| Concurrency control for drain | Custom mutex, semaphore, DB lock | Existing isDraining module-level flag |
Already covers the single-process deployment; adding a second mechanism creates dual-source-of-truth bugs. |
| Burst coalescing | Debounce timer, queue with max-depth | drainRequested boolean flag |
For a two-user household the burst is never more than 2-3 concurrent edits. A boolean flag is correct and sufficient (D-06). |
| Timer-free async test control | sleep(), await new Promise(r => setTimeout(r, 100)) (flaky) |
setImmediate flush for microtasks; vi.runAllTimersAsync() for fake-timer tests |
Deterministic, zero wall-clock dependency |
Key insight: The entire phase is wiring — connecting an already-correct drain function to an already-proven event mechanism. The only implementation risk is in the flag placement and the post-commit publish ordering, both of which are fully determined by reading the existing code.
Common Pitfalls
Pitfall 1: Publishing the signal inside the db.transaction() callback (edit-as-move)
What goes wrong: The DELETE row is not yet durably written when the callback runs. The signal fires, the drain starts immediately, and it sees only the CREATE row. The DELETE row appears in the next drain cycle with sibling status already 'done', so it proceeds — but CREATE happened before DELETE as required. However, if the signal fires between the two tx.insert calls (impossible in the current sequential async code, but could happen if restructured), only one of the two rows would be in the DB.
Why it happens: The db.transaction(async (tx) => { ... }) callback executes inside the transaction boundary. await db.transaction(...) resolves when the transaction commits.
How to avoid: Publish signalOutboxDrain() after await db.transaction(...) resolves, not inside the callback. The current code structure makes this natural — the await db.transaction(...) is on one line; the signal call goes on the next line after it.
Warning signs: If the test for "edit-as-move creates-before-delete" starts failing intermittently, check where the signal publish is placed relative to the transaction boundary.
Pitfall 2: Calling runOutboxDrain() directly from the signal listener (bypassing isDraining)
What goes wrong: If the signal listener calls runOutboxDrain() directly instead of going through scheduleOutboxDrain(), and the 15s setInterval fires at the same moment, both calls enter runOutboxDrain simultaneously. The second call returns immediately (isDraining guard), but there is a race on the isDraining = true assignment vs. the guard check in a microtask boundary.
Why it happens: EventEmitter listeners run synchronously in the same tick as emit. If isDraining is false when the listener fires, the listener starts a drain. If the setInterval fires in the same tick (impossible in practice for a 15s interval, but possible in tests using fake timers), a second concurrent drain starts.
How to avoid: Both the listener and the setInterval call scheduleOutboxDrain(), not runOutboxDrain() directly. scheduleOutboxDrain checks isDraining before calling runOutboxDrain, and sets drainRequested if already draining. This is exactly D-02.
Pitfall 3: drainRequested flag not reset before the trailing drain call
What goes wrong: If drainRequested is set to false after the trailing scheduleOutboxDrain() call in the .finally(), not before, a new signal arriving between the flag reset and the trailing drain call sets drainRequested=true again. The trailing drain then completes, and .finally() sees drainRequested=true again — triggering an infinite loop.
How to avoid: In the .finally() callback: check drainRequested, set drainRequested = false, then call scheduleOutboxDrain(). The re-check inside scheduleOutboxDrain handles any new signals that arrived after the reset.
.finally(() => {
if (drainRequested) {
drainRequested = false; // reset FIRST
scheduleOutboxDrain(); // then recurse — if new signal arrived, drainRequested=true again
}
});
Pitfall 4: Registering the EventEmitter listener at module import time (timer/listener leakage in tests)
What goes wrong: If onOutboxDrain(handler) is called at the top level of outboxWorker.ts (outside a function), it registers a listener when the module is imported. Vitest imports outboxWorker.ts to test runOutboxDrain. The listener is registered, and any signalOutboxDrain() call in a test unexpectedly triggers the handler, causing spurious drain calls across test isolation boundaries.
How to avoid: Register the listener only inside initOutboxTrigger(), which is called only from index.ts under isMainModule(). This matches the startBrokerPoller/startOutboxWorker/startReminderScheduler pattern exactly.
Pitfall 5: Using setMaxListeners too low on the drain emitter
What goes wrong: Not a real risk here (the drain emitter has exactly one subscriber — initOutboxTrigger). But if the default limit of 10 is somehow hit (e.g. tests that call initOutboxTrigger without cleanup), Node.js emits a MaxListenersExceededWarning.
How to avoid: Since there is exactly one subscriber, the default limit of 10 is fine. No setMaxListeners call needed (unlike listEmitter.ts which sets 200 for SSE fan-out).
Code Examples
Pattern 1: Module-level EventEmitter singleton (from listEmitter.ts) [VERIFIED: codebase read]
// apps/api/src/lib/outboxTrigger.ts
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
// No setMaxListeners needed — single subscriber
export function signalOutboxDrain(): void {
emitter.emit('drain');
}
export function onOutboxDrain(handler: () => void): () => void {
emitter.on('drain', handler);
return () => emitter.off('drain', handler);
}
Pattern 2: scheduleOutboxDrain wrapper with trailing-re-drain loop [VERIFIED: codebase read + logic derivation from D-05]
// In outboxWorker.ts, after `let isDraining = false;`
let drainRequested = false;
function scheduleOutboxDrain(): void {
if (isDraining) {
drainRequested = true;
return;
}
runOutboxDrain()
.catch((err: unknown) => {
console.error('[outboxWorker] Unhandled runOutboxDrain error:', err);
})
.finally(() => {
if (drainRequested) {
drainRequested = false;
scheduleOutboxDrain();
}
});
}
// Export for test access
export { scheduleOutboxDrain };
Pattern 3: Post-commit signal publish in route handler [VERIFIED: codebase read — events.ts]
// In the create handler, after the db.insert:
await db.insert(calendarOutbox).values({ ... });
signalOutboxDrain(); // fire-and-forget (D-04)
return c.json({ uid }, 202);
// In the edit-as-move handler, after the transaction:
await db.transaction(async (tx) => {
await tx.insert(calendarOutbox).values({ operation: 'delete', ... });
await tx.insert(calendarOutbox).values({ operation: 'create', ... });
});
signalOutboxDrain(); // after transaction commits — both rows durable (D-03)
return c.json({ uid: newUid }, 202);
Pattern 4: Startup wiring in index.ts [VERIFIED: codebase read — index.ts L112–L146]
// import addition at top of index.ts:
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
// In the isMainModule() block (after startOutboxWorker):
startOutboxWorker();
initOutboxTrigger(); // subscribe the signal listener
Pattern 5: Vitest async flush without fake timers [ASSUMED — standard Node.js async behavior]
// Flush microtasks + immediate queue (for testing signal→drain without timers)
await new Promise<void>(resolve => setImmediate(resolve));
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| node-cron for scheduled jobs | setInterval only |
Prior to v1.0 (node-cron 4.2.1 silently skipped ticks) | Hard constraint: do not reintroduce node-cron |
| Top-level background worker startup | Gated by isMainModule() |
Phase 03 (WR-04 fix) | Hard constraint: all listeners/timers must be inside isMainModule() |
Deprecated/outdated:
node-cronfor this project: silently skips scheduled executions in the long-running server process.setIntervalonly.
Runtime State Inventory
Step 2.5: SKIPPED (not a rename/refactor/migration phase — greenfield addition of one module and wiring changes to three existing files).
Environment Availability
Step 2.6: No external tool dependencies beyond the existing Node.js + pnpm stack already confirmed operational. All changes are in-process TypeScript.
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
Node.js node:events |
outboxTrigger.ts |
Always — built-in | Same as runtime (22 LTS) | — |
| Vitest | Test suite | Already installed | Per package.json |
— |
| MariaDB (for integration tests) | apps/api/tests/ |
Confirmed (existing CI) | Dev compose | — |
Validation Architecture
Nyquist validation is enabled (config workflow.nyquist_validation not set to false).
Test Framework
| Property | Value |
|---|---|
| Framework | Vitest (vite-native) |
| Config file | apps/api/vitest.config.ts |
| Quick run command | pnpm --filter @familysync/api exec vitest run tests/broker/outboxWorker.test.ts |
| Full suite command | pnpm --filter @familysync/api exec vitest run |
Phase Requirements → Test Map
| Req ID | Success Criterion | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|---|
| CAL-15 / SC-1 | Change reaches Fastmail in ~1-2s | Signal after enqueue triggers runOutboxDrain without interval wait |
unit (trigger wiring) | pnpm --filter @familysync/api exec vitest run tests/broker/outboxWorker.test.ts |
Extend existing |
| CAL-15 / SC-2 | Route handler returns 202 immediately, no inline CalDAV | Existing passing tests cover 202 response; signal is fire-and-forget | unit (existing) | Same | Already exists |
| CAL-15 / SC-3 | Edit-as-move create-before-delete preserved | Existing D-04 / CR-04 tests cover this; signal fires after transaction so ordering is not disturbed | unit (existing) | Same | Already exists |
| CAL-15 / SC-4 | No duplicate PUT when signal and 15s interval overlap | Concurrent scheduleOutboxDrain calls invoke createCalendarEvent exactly once |
unit (trigger wiring) | Same | Extend existing |
| CAL-15 / SC-4 | Trailing re-drain under burst (D-05) | Signal mid-drain → exactly one follow-up drain | unit (trigger wiring) | Same | Extend existing |
| CAL-15 / SC-5 | 15s fallback still runs | Existing startOutboxWorker setInterval unchanged; scheduleOutboxDrain called from interval |
unit (scheduler) | Same | Extend existing |
Sampling Rate
- Per task commit:
pnpm --filter @familysync/api exec vitest run tests/broker/outboxWorker.test.ts - Per wave merge:
pnpm --filter @familysync/api exec vitest run - Phase gate: Full suite green before
/gsd-verify-work
Wave 0 Gaps
- New describe block in
tests/broker/outboxWorker.test.ts— covers SC-1 (trigger wiring), SC-4 (no-double-PUT), D-05 (trailing re-drain) outboxTrigger.tsmust exportsignalOutboxDrainandonOutboxDrainbefore the trigger wiring tests can import themscheduleOutboxDrainmust be exported fromoutboxWorker.tsfor direct test invocation
Security Domain
security_enforcement is enabled (not set to false in config).
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | No | Not touched — auth is unchanged |
| V3 Session Management | No | Not touched |
| V4 Access Control | No | Not touched — ownership checks in events.ts are unchanged |
| V5 Input Validation | No | Not touched — Zod validation at enqueue is unchanged; this phase only adds a post-enqueue signal |
| V6 Cryptography | No | Not touched — credential decryption in outboxWorker.ts is unchanged |
Known Threat Patterns for this phase
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Signal amplification (unbounded re-drain loop) | DoS (against Fastmail rate limits) | drainRequested boolean collapses all mid-drain signals into exactly one trailing drain (D-05 / D-06) |
| Listener memory leak (test isolation) | DoS (open handles preventing process exit) | Listener registered only under isMainModule() — never in test process (Q4 answer above) |
No new network surface, no new auth paths, no new input validation vectors introduced by this phase.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | setImmediate flush is sufficient to observe the signal→drain path in tests without fake timers |
Q5 / Validation Architecture | Test may need await Promise.resolve() or an extra tick; low risk, easily corrected in the test code |
| A2 | Exporting scheduleOutboxDrain from outboxWorker.ts for test access follows the established runOutboxDrain export precedent without breaking encapsulation |
Q5 | Low risk — already documented in outboxWorker.ts L19 as the intended pattern |
All other claims in this document are verified against the codebase.
Open Questions
-
scheduleOutboxDrain— internal or re-exported fromstartOutboxWorker?- What we know:
runOutboxDrainis already exported for testing (L19 comment).scheduleOutboxDrainneeds the same treatment. - What's unclear: whether to export it as a named export at the module level or wrap it inside
startOutboxWorkerand expose via a returned object. - Recommendation: export it as a plain named export (same as
runOutboxDrain) for simplicity and test symmetry. The existing pattern is to export thin named functions.
- What we know:
-
initOutboxTrigger— inoutboxWorker.tsor a separateoutboxTriggerInit.ts?- What we know:
startBrokerPoller,startOutboxWorker,startReminderSchedulerare all exported from their respective broker modules and called fromindex.ts. - Recommendation: keep
initOutboxTriggerinoutboxWorker.tsalongsidestartOutboxWorker— they are functionally related (both are outbox startup concerns) and co-locating them meansindex.tsonly needs one additional import name from the same module.
- What we know:
Sources
Primary (HIGH confidence)
apps/api/src/broker/outboxWorker.ts— Full read; confirmedisDrainingflag structure,runOutboxDraincontrol flow,startOutboxWorkersetInterval,finallyblock shape, existing export rationale.apps/api/src/routes/events.ts— Full read; confirmed exact locations of the three enqueue sites (create L300–308, edit-same-cal L436–445, edit-as-move L408–432, delete L511–519), transaction boundaries.apps/api/src/index.ts— Full read; confirmedisMainModule()guard, startup wiring block L112–L146.apps/api/src/lib/listEmitter.ts— Full read; confirmed EventEmitter singleton pattern,publish/subscribe/unsubscribeAPI shape.apps/api/tests/broker/outboxWorker.test.ts— Full read; confirmed mock infrastructure, existing test coverage,vi.hoisted+vi.mockpattern.apps/api/vitest.config.ts— Read; confirmedfileParallelism: false,environment: 'node', no setup file override..planning/phases/09-faster-write-back/09-CONTEXT.md— Full read; locked decisions D-01…D-10..planning/REQUIREMENTS.md— Read; CAL-15 requirement text confirmed..planning/config.json— Read;nyquist_validationnot set to false → Validation Architecture section required;security_enforcementdefaults to enabled.
Secondary (MEDIUM confidence)
apps/api/tests/lib/listEmitter.test.ts— Read for test pattern; confirmsvi.fn()+unsub()test shape usable for trigger tests.
Metadata
Confidence breakdown:
- Standard stack: HIGH — no new packages; all patterns verified in codebase
- Architecture: HIGH — drain control flow fully read and analyzed
- Pitfalls: HIGH — derived from actual code paths, not assumptions
- Test strategy: HIGH — based on existing test file infrastructure; one ASSUMED item on
setImmediateflush
Research date: 2026-06-12
Valid until: 2026-07-12 (stable internal code — only invalidated by changes to outboxWorker.ts or events.ts)