-- v1.1.1: dead_letters — design notes §4. -- -- Async invocations that exhaust their retry policy land here. Each -- row carries the original event payload verbatim plus the attempt -- history so handlers (registered via `dead_letter` triggers) and the -- dashboard can decide what to do. -- -- Schema mirrors design notes §4. The CHECK constraint on -- `resolution` enforces the closed vocabulary used by both the SDK -- (`dead_letters::resolve(id, reason)`) and the recursion-stop rule -- (`handler_failed`). Sync HTTP failures (`reply_to.is_some()`) never -- land here — they're served via the inbox channel. -- -- Indexes: -- - partial index on unresolved rows: the dashboard's -- unresolved-count badge query (`COUNT(*) WHERE app_id = $1 AND -- resolved_at IS NULL`). -- - GC index on `created_at`: the weekly retention sweep. CREATE TABLE dead_letters ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, -- The outbox.id row that exhausted retries. The outbox row itself -- has been deleted at this point. original_event_id UUID NOT NULL, source TEXT NOT NULL, op TEXT NOT NULL, -- Nullable because direct admin replays may have no trigger row. trigger_id UUID, script_id UUID, payload JSONB NOT NULL, attempt_count INT NOT NULL, first_attempt_at TIMESTAMPTZ NOT NULL, last_attempt_at TIMESTAMPTZ NOT NULL, last_error TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), resolved_at TIMESTAMPTZ, resolution TEXT CHECK (resolution IN ('replayed', 'ignored', 'handled_by_script', 'handler_failed')) ); -- Dashboard unresolved-count badge — partial index on the predicate -- the query uses. CREATE INDEX idx_dead_letters_app_unresolved ON dead_letters (app_id) WHERE resolved_at IS NULL; -- GC sweep scans by creation time. CREATE INDEX idx_dead_letters_gc ON dead_letters (created_at);