-- +goose Up -- records holds one durable notification record per accepted intent. The -- (producer, idempotency_key) UNIQUE constraint replaces the previous Redis -- idempotency keyspace: the durable row IS the idempotency reservation. CREATE TABLE records ( notification_id text PRIMARY KEY, notification_type text NOT NULL, producer text NOT NULL, audience_kind text NOT NULL, recipient_user_ids jsonb NOT NULL DEFAULT '[]'::jsonb, payload_json text NOT NULL, idempotency_key text NOT NULL, request_fingerprint text NOT NULL, request_id text NOT NULL DEFAULT '', trace_id text NOT NULL DEFAULT '', occurred_at timestamptz NOT NULL, accepted_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, idempotency_expires_at timestamptz NOT NULL, CONSTRAINT records_idempotency_unique UNIQUE (producer, idempotency_key) ); -- Newest-first listing index used by operator/audit reads. CREATE INDEX records_listing_idx ON records (accepted_at DESC, notification_id DESC); -- routes stores one row per (notification_id, route_id). next_attempt_at is -- non-NULL only while the row is a scheduling candidate (status pending or -- failed); the partial index keeps the scheduler scan tight. CREATE TABLE routes ( notification_id text NOT NULL REFERENCES records(notification_id) ON DELETE CASCADE, route_id text NOT NULL, channel text NOT NULL, recipient_ref text NOT NULL, status text NOT NULL, attempt_count integer NOT NULL DEFAULT 0, max_attempts integer NOT NULL, next_attempt_at timestamptz, resolved_email text NOT NULL DEFAULT '', resolved_locale text NOT NULL DEFAULT '', last_error_classification text NOT NULL DEFAULT '', last_error_message text NOT NULL DEFAULT '', last_error_at timestamptz, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, published_at timestamptz, dead_lettered_at timestamptz, skipped_at timestamptz, PRIMARY KEY (notification_id, route_id) ); -- Drives the publishers' due-route pull. Partial predicate keeps the index -- narrow: terminal rows (published / dead_letter / skipped) never appear. CREATE INDEX routes_due_idx ON routes (next_attempt_at) WHERE next_attempt_at IS NOT NULL; -- Coarse status / channel filters used by operator views. CREATE INDEX routes_status_idx ON routes (status); CREATE INDEX routes_channel_idx ON routes (channel); -- dead_letters carries the operator-visible record for one route that -- exhausted automated handling. Cascade tied to the parent route row so a -- record-level retention DELETE clears dependent dead-letter rows naturally. CREATE TABLE dead_letters ( notification_id text NOT NULL, route_id text NOT NULL, channel text NOT NULL, recipient_ref text NOT NULL, final_attempt_count integer NOT NULL, max_attempts integer NOT NULL, failure_classification text NOT NULL, failure_message text NOT NULL, recovery_hint text NOT NULL DEFAULT '', created_at timestamptz NOT NULL, PRIMARY KEY (notification_id, route_id), FOREIGN KEY (notification_id, route_id) REFERENCES routes(notification_id, route_id) ON DELETE CASCADE ); CREATE INDEX dead_letters_listing_idx ON dead_letters (created_at DESC, notification_id DESC, route_id DESC); -- malformed_intents stores operator-visible records for stream entries the -- intent validator could not accept. Independent retention pass. CREATE TABLE malformed_intents ( stream_entry_id text PRIMARY KEY, notification_type text NOT NULL DEFAULT '', producer text NOT NULL DEFAULT '', idempotency_key text NOT NULL DEFAULT '', failure_code text NOT NULL, failure_message text NOT NULL, raw_fields jsonb NOT NULL, recorded_at timestamptz NOT NULL ); CREATE INDEX malformed_intents_listing_idx ON malformed_intents (recorded_at DESC, stream_entry_id DESC); -- +goose Down DROP TABLE IF EXISTS malformed_intents; DROP TABLE IF EXISTS dead_letters; DROP TABLE IF EXISTS routes; DROP TABLE IF EXISTS records;