---
phase: 03-event-write-back-pwa-install
plan: '11'
subsystem: api-broker
tags: [tdd, gap-closure, outbox-worker, concurrency-guard, etag, durability, calDAV]
dependency_graph:
  requires:
    - 03-10: outbox worker with real VEVENT dispatch + fail-closed credentials
  provides:
    - cr04-durable-create-before-delete (DB sibling-status gate persisted across drain cycles)
    - cr05-drain-concurrency-guard (isDraining module-level guard, single-process)
    - wr02-fresh-etag-before-put (calendarEvents etag re-read at dispatch time)
  affects:
    - apps/api/src/broker/outboxWorker.ts
    - apps/api/tests/broker/outboxWorker.test.ts
tech_stack:
  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'
key_files:
  modified:
    - apps/api/src/broker/outboxWorker.ts
    - apps/api/tests/broker/outboxWorker.test.ts
key_decisions:
  - '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'
requirements-completed: [CAL-05, CAL-06]
duration: 30min
completed: '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:

1. **Task 1 RED** - `6b2cdf3` (test) — Failing tests for CR-04 cross-batch + CR-05 concurrency
2. **Task 1 GREEN** - `b409c09` (feat) — DB sibling-status gate + isDraining guard
3. **Task 2 RED** - `5eb26c0` (test) — Failing test for WR-02 fresh etag
4. **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
- `apps/api/tests/broker/outboxWorker.test.ts` — Added 6 new tests (CR-04 cross-batch x2, CR-04 paired-failed, CR-05 concurrency, WR-02 fresh etag, WR-02 fallback); fixed mock infrastructure (Symbol.for drizzle name, vi.resetAllMocks, mockWhereCalEvents)

## Decisions Made

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

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] Drizzle table identification using JSON.stringify throws CircularReference**

- **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()`.
- **Files modified:** apps/api/tests/broker/outboxWorker.test.ts
- **Committed in:** b409c09 (Task 1 GREEN commit)

---

**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
- `grep -c 'isDraining' apps/api/src/broker/outboxWorker.ts` — 6
- `grep -c 'single-process' apps/api/src/broker/outboxWorker.ts` — 3
- `grep -n 'calendarEvents' apps/api/src/broker/outboxWorker.ts` — etag select in update path confirmed

## Issues Closed

| ID    | Description                                                                                                                               |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| CR-04 | Create-before-delete ordering relied on in-memory Set, broke across drain batches — DB sibling-status gate now authoritative              |
| CR-05 | No concurrency guard — overlapping drain cycles could double-dispatch same row — isDraining guard prevents it (single-process)            |
| WR-02 | Update dispatch used stale enqueue-time etag — rapid successive edits guaranteed 412 — fresh calendarEvents.etag re-read at dispatch time |

## Known Stubs

None. All changes are functional correctness fixes.

## Threat Flags

No new network endpoints, auth paths, or schema changes. The fresh-etag DB read adds one SELECT per update dispatch — no new trust boundary crossed.

## Self-Check: PASSED

- apps/api/src/broker/outboxWorker.ts: FOUND
- apps/api/tests/broker/outboxWorker.test.ts: FOUND
- .planning/phases/03-event-write-back-pwa-install/03-11-SUMMARY.md: FOUND
- 6b2cdf3 (test RED task 1): FOUND
- b409c09 (feat GREEN task 1): FOUND
- 5eb26c0 (test RED task 2): FOUND
- 09fd1f2 (feat GREEN task 2): FOUND
