wr02-fresh-etag-before-put (calendarEvents etag re-read at dispatch time)
apps/api/src/broker/outboxWorker.ts
apps/api/tests/broker/outboxWorker.test.ts
added
patterns
TDD RED→GREEN per task
DB sibling-status query pattern for durable inter-row ordering
Module-level boolean drain guard for single-process deployment
Symbol.for('drizzle:Name') for safe Drizzle table identification in tests (JSON.stringify circular)
vi.resetAllMocks() instead of vi.clearAllMocks() when mockImplementationOnce queues must be purged
Per-table mockWhere functions (mockWherePending vs mockWhereCalEvents) to isolate select mocks
modified
apps/api/src/broker/outboxWorker.ts
apps/api/tests/broker/outboxWorker.test.ts
CR-04 durable gate uses DB sibling-status query (not in-memory Set) so create-before-delete ordering holds across drain cycles; in-batch fast path retained as optimization
CR-05 isDraining guard is explicitly documented as single-process-only; multi-replica deployments would need DB row-claim (UPDATE WHERE status='pending' with affected-rows check)
WR-02 fresh etag reads calendarEvents at dispatch time, not calendarOutbox enqueue time; D-08 conflict detection preserved — genuine external changes update calendarEvents.etag differently from any queued row
mockFromFn updated to use Symbol.for('drizzle:Name') to identify Drizzle tables — JSON.stringify throws CircularReference on all MySqlTable instances
All beforeEach blocks switched to vi.resetAllMocks() to prevent unconsumed mockImplementationOnce calls bleeding into subsequent tests
CAL-05
CAL-06
30min
2026-06-05
Phase 03 Plan 11: Outbox Durability and Etag Fix Summary
Durable create-before-delete ordering (DB gate, not in-memory Set), single-process concurrency guard with documented limitation, and fresh-etag re-read before PUT — CR-04, CR-05, WR-02 closed.
Performance
Duration: ~30 min
Started: 2026-06-05T20:54Z
Completed: 2026-06-05T21:06Z
Tasks: 2 (each TDD RED+GREEN)
Files modified: 2
Accomplishments
CR-04: delete rows with groupId now query the DB for their sibling create's status before dispatching; the in-memory failedCreateGroups Set is retained as a fast path but the DB query is the authoritative gate — cross-batch move pairs cannot lose the original event
CR-05: let isDraining = false module-level guard with try/finally ensures overlapping 15s drain cycles are no-ops; carries explicit comment that this is valid only for the single-process Unraid deployment
WR-02: dispatchRow re-reads calendarEvents.etag just before calling updateCalendarEvent; uses the fresh etag as If-Match when available, falls back to row.etag otherwise — rapid successive same-uid edits no longer guarantee a spurious 412
Task Commits
Each task was committed atomically:
Task 1 RED - 6b2cdf3 (test) — Failing tests for CR-04 cross-batch + CR-05 concurrency
Task 1 GREEN - b409c09 (feat) — DB sibling-status gate + isDraining guard
Task 2 RED - 5eb26c0 (test) — Failing test for WR-02 fresh etag
Task 2 GREEN - 09fd1f2 (feat) — calendarEvents etag re-read before PUT
Files Created/Modified
apps/api/src/broker/outboxWorker.ts — Added isDraining guard, durable sibling-status DB check in drain loop, fresh-etag re-read in update dispatch; import calendarEvents from schema
CR-04 durable gate approach (option b from review): query DB for sibling create status rather than blocking the delete row's initial enqueue. This avoids a schema change and keeps the outbox state machine simple; the sibling-status query is cheap (indexed on groupId + operation).
CR-05 single-process scope documented: the isDraining guard comment explicitly states it is invalid for multi-replica deployments and names the DB row-claim alternative. This is a deliberate documentation constraint, not a silent assumption.
WR-02 fresh-etag scope boundary: only the update dispatch is changed. Creates and deletes are unaffected. The fresh etag coalesces rapid edits by the same user; it does not weaken D-08 since a real external change would update calendarEvents.etag to a value never seen in any pending row.
Mock infrastructure fix (deviation auto-fixed): mockFromFn was using JSON.stringify(table) which throws TypeError: Converting circular structure to JSON on all Drizzle MySqlTable instances. Replaced with (table)[Symbol.for('drizzle:Name')]. Added mockWhereCalEvents as a separate mock for calendarEvents selects to isolate it from mockWherePending (calendarOutbox selects). Switched all beforeEach blocks from vi.clearAllMocks() to vi.resetAllMocks() to purge mockImplementationOnce queues between tests.
Found during: Task 1 GREEN — when running tests after implementing the sibling-status DB select
Issue:wireMockChain's mockFromFn used JSON.stringify(table).includes('member_credentials') to identify the credential table. JSON.stringify on a Drizzle MySqlTable object throws TypeError: Converting circular structure to JSON (MySqlInt columns hold a back-reference to their parent table). The catch block silently set isCred = false, making ALL db.select().from(...) calls route to mockWherePending — including credential lookups. Prior tests "worked" accidentally because mockDecryptPassword was mocked to succeed regardless of input, but the new sibling-status select consumed mockWherePending calls out of order, breaking the D-04 ordering test and the CR-04 drain 2 test.
Fix: Replaced with (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] which reads the table name property Drizzle attaches as a Symbol. Added separate mockWhereCalEvents for calendarEvents table selects. Switched all beforeEach to vi.resetAllMocks().
Total deviations: 1 auto-fixed (Rule 1 — bug in test infrastructure)
Impact on plan: Required fix. The mock bug was masked by coincidence in prior plans; the new DB selects surfaced it.
Issues Encountered
None beyond the mock infrastructure deviation above.
Verification
cd apps/api && npx vitest run tests/broker/ — 57/57 pass (7 files)
cd apps/api && npm run build — clean TypeScript compile