Files
galaxy-game/notification/internal/adapters/postgres/migrations/00001_init.sql
T
2026-04-26 20:34:39 +02:00

106 lines
4.4 KiB
SQL

-- +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;