feat(03-11): GREEN — durable create-before-delete gating + drain concurrency guard (CR-04, CR-05)

- CR-04: delete rows with groupId query DB for sibling create status before dispatch
  - sibling 'pending': defer delete to later cycle (leave row pending)
  - sibling 'failed'/'dead': mark delete failed permanently (original event preserved, D-04)
  - sibling 'done': dispatch delete normally
- CR-05: module-level isDraining guard; overlapping 15s cycles are no-ops
  - SINGLE-PROCESS ONLY — documented limitation for multi-replica deployments
- Fix mockFromFn to use Symbol.for('drizzle:Name') instead of JSON.stringify (circular)
- Update D-04 ordering test to queue sibling-status mock response
This commit is contained in:
Lucas Berger
2026-06-05 21:01:57 -04:00
parent 6b2cdf3683
commit b409c09e25
2 changed files with 174 additions and 94 deletions
+15 -9
View File
@@ -126,16 +126,12 @@ function wireMockChain() {
mockUpdate.mockReturnValue({ set: mockUpdateSet })
// mockFromFn differentiates by table argument:
// - memberCredentials table → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
// - anything else → returns mockPendingRows (outbox query)
// - anything else (calendarOutbox, calendarEvents) → returns mockWherePending
// Use Symbol.for('drizzle:Name') to identify the table — JSON.stringify throws on circular Drizzle
// table structures so it cannot be used for table identification.
mockFromFn.mockImplementation((table: unknown) => {
// Drizzle table objects have a Symbol.for('drizzle:Name') property and a [Table.Symbol.Name].
// The safest approach: JSON.stringify often includes the table config name.
let isCred = false
try {
isCred = JSON.stringify(table).includes('member_credentials')
} catch {
// table not serializable — not a credential table
}
const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? ''
const isCred = tableName === 'member_credentials'
return {
where: isCred
? vi.fn().mockResolvedValue([FAKE_CRED_ROW])
@@ -307,6 +303,16 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
// Both rows in the pending list
mockPendingRows = [deleteRow, createRow]
// The durable sibling-status check (CR-04) runs for the delete row with groupId.
// It queries calendarOutbox for the sibling create's status. By the time the delete
// is processed (create was sorted and dispatched first), we simulate the sibling as 'done'.
// The base mockWherePending returns mockPendingRows for all calendarOutbox selects; we
// override just the sibling-status call with mockImplementationOnce queued after the
// pending-rows select call.
mockWherePending
.mockImplementationOnce(() => Promise.resolve([deleteRow, createRow])) // pending-rows select
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])) // sibling-status select
await runOutboxDrain()
// CREATE must be called before DELETE