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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user