feat: backend service
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
-- +goose Up
|
||||
-- Initial schema for the consolidated Galaxy backend service.
|
||||
--
|
||||
-- Every backend table lives in the `backend` schema. The schema is created
|
||||
-- here so a fresh deployment can apply this migration against an empty
|
||||
-- database, and search_path is pinned for the rest of the migration so
|
||||
-- subsequent CREATE statements land in `backend` without needing to qualify
|
||||
-- every object. Production deployments additionally pin search_path through
|
||||
-- BACKEND_POSTGRES_DSN.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS backend;
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
-- =====================================================================
|
||||
-- Auth domain
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE device_sessions (
|
||||
device_session_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL,
|
||||
client_public_key bytea NOT NULL,
|
||||
status text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
CONSTRAINT device_sessions_status_chk
|
||||
CHECK (status IN ('active', 'revoked', 'blocked'))
|
||||
);
|
||||
|
||||
CREATE INDEX device_sessions_user_idx ON device_sessions (user_id);
|
||||
CREATE INDEX device_sessions_status_idx ON device_sessions (status);
|
||||
|
||||
CREATE TABLE auth_challenges (
|
||||
challenge_id uuid PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
code_hash bytea NOT NULL,
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
consumed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX auth_challenges_email_idx ON auth_challenges (email);
|
||||
|
||||
CREATE TABLE blocked_emails (
|
||||
email text PRIMARY KEY,
|
||||
reason text NOT NULL,
|
||||
blocked_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- User domain
|
||||
-- =====================================================================
|
||||
|
||||
-- accounts is the editable source of truth for user identity. email and
|
||||
-- user_name remain UNIQUE for live and soft-deleted records: emails are
|
||||
-- never reassigned to a fresh user_id after soft delete, and user_name is
|
||||
-- immutable for the lifetime of the account.
|
||||
CREATE TABLE accounts (
|
||||
user_id uuid PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
user_name text NOT NULL,
|
||||
display_name text NOT NULL DEFAULT '',
|
||||
preferred_language text NOT NULL,
|
||||
time_zone text NOT NULL,
|
||||
declared_country text,
|
||||
permanent_block boolean NOT NULL DEFAULT false,
|
||||
deleted_actor_type text,
|
||||
deleted_actor_id text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT accounts_email_unique UNIQUE (email),
|
||||
CONSTRAINT accounts_user_name_unique UNIQUE (user_name)
|
||||
);
|
||||
|
||||
CREATE INDEX accounts_listing_idx
|
||||
ON accounts (created_at DESC, user_id DESC);
|
||||
|
||||
CREATE INDEX accounts_declared_country_idx
|
||||
ON accounts (declared_country)
|
||||
WHERE declared_country IS NOT NULL;
|
||||
|
||||
-- entitlement_records is the immutable history of entitlement events. tier
|
||||
-- is constrained to the closed MVP set (free, monthly, yearly, permanent) so
|
||||
-- the storage layer rejects typos before the user-package validator can.
|
||||
-- Audit columns (actor_*, reason_code, starts_at, ends_at) mirror the
|
||||
-- shape used by sanction_records/limit_records: the *_active rollup carries
|
||||
-- only the binding, the records table is the durable audit log.
|
||||
CREATE TABLE entitlement_records (
|
||||
record_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES accounts (user_id),
|
||||
tier text NOT NULL,
|
||||
is_paid boolean NOT NULL,
|
||||
source text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_id text,
|
||||
reason_code text NOT NULL DEFAULT '',
|
||||
starts_at timestamptz NOT NULL DEFAULT now(),
|
||||
ends_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT entitlement_records_tier_chk
|
||||
CHECK (tier IN ('free', 'monthly', 'yearly', 'permanent'))
|
||||
);
|
||||
|
||||
CREATE INDEX entitlement_records_user_idx
|
||||
ON entitlement_records (user_id, created_at DESC);
|
||||
|
||||
-- entitlement_snapshots is the read-optimised current entitlement state.
|
||||
-- Exactly one row per user_id; updated atomically with new
|
||||
-- entitlement_records by the user lifecycle store. Audit columns are
|
||||
-- denormalised from the latest entitlement_records row so the read path
|
||||
-- needs no join to render the AccountResponse.entitlement payload.
|
||||
CREATE TABLE entitlement_snapshots (
|
||||
user_id uuid PRIMARY KEY REFERENCES accounts (user_id),
|
||||
tier text NOT NULL,
|
||||
is_paid boolean NOT NULL,
|
||||
source text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_id text,
|
||||
reason_code text NOT NULL DEFAULT '',
|
||||
starts_at timestamptz NOT NULL,
|
||||
ends_at timestamptz,
|
||||
max_registered_race_names integer NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT entitlement_snapshots_tier_chk
|
||||
CHECK (tier IN ('free', 'monthly', 'yearly', 'permanent'))
|
||||
);
|
||||
|
||||
CREATE TABLE sanction_records (
|
||||
record_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES accounts (user_id),
|
||||
sanction_code text NOT NULL,
|
||||
scope text NOT NULL,
|
||||
reason_code text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_id text,
|
||||
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz,
|
||||
removed_at timestamptz,
|
||||
removed_by_type text,
|
||||
removed_by_id text,
|
||||
removed_reason_code text,
|
||||
CONSTRAINT sanction_records_code_chk
|
||||
CHECK (sanction_code IN ('permanent_block'))
|
||||
);
|
||||
|
||||
CREATE INDEX sanction_records_user_idx
|
||||
ON sanction_records (user_id, applied_at DESC);
|
||||
|
||||
-- sanction_active stores the at-most-one active record per
|
||||
-- (user_id, sanction_code), maintained by the user lifecycle store in
|
||||
-- the same transaction as the corresponding sanction_records mutation.
|
||||
CREATE TABLE sanction_active (
|
||||
user_id uuid NOT NULL REFERENCES accounts (user_id),
|
||||
sanction_code text NOT NULL,
|
||||
record_id uuid NOT NULL REFERENCES sanction_records (record_id),
|
||||
PRIMARY KEY (user_id, sanction_code)
|
||||
);
|
||||
|
||||
CREATE INDEX sanction_active_code_idx ON sanction_active (sanction_code);
|
||||
|
||||
CREATE TABLE limit_records (
|
||||
record_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES accounts (user_id),
|
||||
limit_code text NOT NULL,
|
||||
value integer NOT NULL,
|
||||
reason_code text NOT NULL,
|
||||
actor_type text NOT NULL,
|
||||
actor_id text,
|
||||
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz,
|
||||
removed_at timestamptz,
|
||||
removed_by_type text,
|
||||
removed_by_id text,
|
||||
removed_reason_code text
|
||||
);
|
||||
|
||||
CREATE INDEX limit_records_user_idx
|
||||
ON limit_records (user_id, applied_at DESC);
|
||||
|
||||
-- limit_active mirrors sanction_active for user-specific limits. value is
|
||||
-- denormalised so the admin listing predicate can read it without joining
|
||||
-- the full record history.
|
||||
CREATE TABLE limit_active (
|
||||
user_id uuid NOT NULL REFERENCES accounts (user_id),
|
||||
limit_code text NOT NULL,
|
||||
record_id uuid NOT NULL REFERENCES limit_records (record_id),
|
||||
value integer NOT NULL,
|
||||
PRIMARY KEY (user_id, limit_code)
|
||||
);
|
||||
|
||||
CREATE INDEX limit_active_code_idx ON limit_active (limit_code);
|
||||
|
||||
-- =====================================================================
|
||||
-- Admin domain
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE admin_accounts (
|
||||
username text PRIMARY KEY,
|
||||
password_hash bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_used_at timestamptz,
|
||||
disabled_at timestamptz
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Lobby domain
|
||||
-- =====================================================================
|
||||
|
||||
-- games is the durable record of every platform game session. owner_user_id
|
||||
-- is nullable because public games are created by admins through the basic-auth
|
||||
-- surface; the admin identity lives in admin_accounts and does not map to a
|
||||
-- user_id. The partial owner index covers private games only.
|
||||
CREATE TABLE games (
|
||||
game_id uuid PRIMARY KEY,
|
||||
owner_user_id uuid,
|
||||
visibility text NOT NULL,
|
||||
status text NOT NULL,
|
||||
game_name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
min_players integer NOT NULL,
|
||||
max_players integer NOT NULL,
|
||||
start_gap_hours integer NOT NULL,
|
||||
start_gap_players integer NOT NULL,
|
||||
enrollment_ends_at timestamptz NOT NULL,
|
||||
turn_schedule text NOT NULL,
|
||||
target_engine_version text NOT NULL,
|
||||
runtime_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
runtime_binding jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
CONSTRAINT games_visibility_chk
|
||||
CHECK (visibility IN ('public', 'private')),
|
||||
CONSTRAINT games_status_chk
|
||||
CHECK (status IN (
|
||||
'draft', 'enrollment_open', 'ready_to_start', 'starting',
|
||||
'start_failed', 'running', 'paused', 'finished', 'cancelled'
|
||||
))
|
||||
);
|
||||
|
||||
CREATE INDEX games_status_created_idx
|
||||
ON games (status, created_at DESC, game_id DESC);
|
||||
|
||||
CREATE INDEX games_owner_idx
|
||||
ON games (owner_user_id)
|
||||
WHERE visibility = 'private';
|
||||
|
||||
-- applications carries one row per public-game enrollment request. The
|
||||
-- partial UNIQUE on (applicant_user_id, game_id) WHERE status <> 'rejected'
|
||||
-- enforces the single-active constraint at the database level. Rejected
|
||||
-- applications are kept (one applicant may produce multiple rejected rows
|
||||
-- before submitting a successful one).
|
||||
CREATE TABLE applications (
|
||||
application_id uuid PRIMARY KEY,
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
applicant_user_id uuid NOT NULL,
|
||||
race_name text NOT NULL,
|
||||
status text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
decided_at timestamptz,
|
||||
CONSTRAINT applications_status_chk
|
||||
CHECK (status IN ('pending', 'approved', 'rejected'))
|
||||
);
|
||||
|
||||
CREATE INDEX applications_game_idx ON applications (game_id);
|
||||
CREATE INDEX applications_user_idx ON applications (applicant_user_id);
|
||||
|
||||
CREATE UNIQUE INDEX applications_active_per_user_game_uidx
|
||||
ON applications (applicant_user_id, game_id)
|
||||
WHERE status <> 'rejected';
|
||||
|
||||
-- invites carries one row per private-game invitation. invited_user_id is
|
||||
-- nullable so the invite-by-code variant (anonymous redemption) sits on the
|
||||
-- same table. code is unique only when set so user-bound invites without a
|
||||
-- redemption code coexist freely.
|
||||
CREATE TABLE invites (
|
||||
invite_id uuid PRIMARY KEY,
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
inviter_user_id uuid NOT NULL,
|
||||
invited_user_id uuid,
|
||||
code text,
|
||||
status text NOT NULL,
|
||||
race_name text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
decided_at timestamptz,
|
||||
CONSTRAINT invites_status_chk
|
||||
CHECK (status IN ('pending', 'redeemed', 'declined', 'revoked', 'expired'))
|
||||
);
|
||||
|
||||
CREATE INDEX invites_game_idx ON invites (game_id);
|
||||
|
||||
CREATE INDEX invites_invited_idx
|
||||
ON invites (invited_user_id)
|
||||
WHERE invited_user_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX invites_inviter_idx ON invites (inviter_user_id);
|
||||
CREATE INDEX invites_status_expires_idx ON invites (status, expires_at);
|
||||
|
||||
CREATE UNIQUE INDEX invites_code_uidx
|
||||
ON invites (code)
|
||||
WHERE code IS NOT NULL;
|
||||
|
||||
-- memberships carries one row per platform roster entry. Both race_name
|
||||
-- (original casing) and canonical_key are stored explicitly so downstream
|
||||
-- readers do not re-derive the canonical form from race_name. Race-name
|
||||
-- uniqueness across the platform is enforced by race_names below.
|
||||
CREATE TABLE memberships (
|
||||
membership_id uuid PRIMARY KEY,
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL,
|
||||
race_name text NOT NULL,
|
||||
canonical_key text NOT NULL,
|
||||
status text NOT NULL,
|
||||
joined_at timestamptz NOT NULL DEFAULT now(),
|
||||
removed_at timestamptz,
|
||||
CONSTRAINT memberships_game_user_unique UNIQUE (game_id, user_id),
|
||||
CONSTRAINT memberships_status_chk
|
||||
CHECK (status IN ('active', 'removed', 'blocked'))
|
||||
);
|
||||
|
||||
CREATE INDEX memberships_game_idx ON memberships (game_id);
|
||||
CREATE INDEX memberships_user_idx ON memberships (user_id);
|
||||
|
||||
-- race_names is the durable Race Name Directory store. One row covers one
|
||||
-- of three bindings on a canonical key: a registered name (one per
|
||||
-- canonical, immutable holder), a per-game reservation, or a
|
||||
-- pending_registration that is waiting on lobby.race_name.register inside
|
||||
-- the eligible window. The composite primary key (canonical, game_id) lets
|
||||
-- the same user hold reservations for the same race name across multiple
|
||||
-- active games concurrently. Registered rows store the all-zero sentinel
|
||||
-- in game_id so the per-canonical uniqueness rule expresses cleanly as a
|
||||
-- partial UNIQUE index. Cross-user uniqueness across reservation /
|
||||
-- pending_registration is enforced by the lobby module on the write path.
|
||||
CREATE TABLE race_names (
|
||||
name text NOT NULL,
|
||||
canonical text NOT NULL,
|
||||
status text NOT NULL,
|
||||
owner_user_id uuid NOT NULL,
|
||||
game_id uuid NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
||||
source_game_id uuid,
|
||||
reserved_at timestamptz,
|
||||
expires_at timestamptz,
|
||||
registered_at timestamptz,
|
||||
PRIMARY KEY (canonical, game_id),
|
||||
CONSTRAINT race_names_status_chk
|
||||
CHECK (status IN ('registered', 'reservation', 'pending_registration'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX race_names_registered_uidx
|
||||
ON race_names (canonical)
|
||||
WHERE status = 'registered';
|
||||
|
||||
CREATE INDEX race_names_owner_idx
|
||||
ON race_names (owner_user_id, status);
|
||||
|
||||
CREATE INDEX race_names_pending_eligible_idx
|
||||
ON race_names (expires_at)
|
||||
WHERE status = 'pending_registration';
|
||||
|
||||
-- =====================================================================
|
||||
-- Runtime domain
|
||||
-- =====================================================================
|
||||
|
||||
-- runtime_records consolidates the previous gamemaster.runtime_records and
|
||||
-- rtmanager.runtime_records into a single row per game. The status enum
|
||||
-- covers both the engine-state machine (starting, running,
|
||||
-- generation_in_progress, generation_failed, stopped, engine_unreachable,
|
||||
-- finished) and the container-state escape hatch (removed) used by
|
||||
-- reconciliation when the recorded container has disappeared.
|
||||
CREATE TABLE runtime_records (
|
||||
game_id uuid PRIMARY KEY,
|
||||
status text NOT NULL,
|
||||
current_container_id text,
|
||||
current_image_ref text,
|
||||
current_engine_version text,
|
||||
engine_endpoint text NOT NULL,
|
||||
state_path text,
|
||||
docker_network text,
|
||||
turn_schedule text NOT NULL,
|
||||
current_turn integer NOT NULL DEFAULT 0,
|
||||
next_generation_at timestamptz,
|
||||
skip_next_tick boolean NOT NULL DEFAULT false,
|
||||
paused boolean NOT NULL DEFAULT false,
|
||||
paused_at timestamptz,
|
||||
engine_health text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
started_at timestamptz,
|
||||
stopped_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
removed_at timestamptz,
|
||||
last_observed_at timestamptz,
|
||||
CONSTRAINT runtime_records_status_chk
|
||||
CHECK (status IN (
|
||||
'starting', 'running', 'generation_in_progress',
|
||||
'generation_failed', 'stopped', 'engine_unreachable',
|
||||
'finished', 'removed'
|
||||
))
|
||||
);
|
||||
|
||||
CREATE INDEX runtime_records_status_next_gen_idx
|
||||
ON runtime_records (status, next_generation_at);
|
||||
|
||||
CREATE TABLE engine_versions (
|
||||
version text PRIMARY KEY,
|
||||
image_ref text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- player_mappings carries the (game_id, user_id) → (race_name,
|
||||
-- engine_player_uuid) projection installed at register-runtime. The
|
||||
-- composite primary key serves both the per-request lookup and the per-game
|
||||
-- roster reads. The partial UNIQUE on (game_id, race_name) enforces the
|
||||
-- one-race-per-game invariant at the storage boundary.
|
||||
CREATE TABLE player_mappings (
|
||||
game_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
race_name text NOT NULL,
|
||||
engine_player_uuid uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (game_id, user_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX player_mappings_game_race_uidx
|
||||
ON player_mappings (game_id, race_name);
|
||||
|
||||
CREATE TABLE runtime_operation_log (
|
||||
operation_id uuid PRIMARY KEY,
|
||||
game_id uuid NOT NULL,
|
||||
op text NOT NULL,
|
||||
source text NOT NULL,
|
||||
status text NOT NULL,
|
||||
image_ref text NOT NULL DEFAULT '',
|
||||
container_id text NOT NULL DEFAULT '',
|
||||
error_code text NOT NULL DEFAULT '',
|
||||
error_message text NOT NULL DEFAULT '',
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX runtime_operation_log_game_started_idx
|
||||
ON runtime_operation_log (game_id, started_at DESC);
|
||||
|
||||
-- runtime_health_snapshots records every health observation per game.
|
||||
-- Multiple rows per game are expected; readers consume the latest by
|
||||
-- observed_at.
|
||||
CREATE TABLE runtime_health_snapshots (
|
||||
snapshot_id uuid PRIMARY KEY,
|
||||
game_id uuid NOT NULL,
|
||||
observed_at timestamptz NOT NULL DEFAULT now(),
|
||||
payload jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX runtime_health_snapshots_game_idx
|
||||
ON runtime_health_snapshots (game_id, observed_at DESC);
|
||||
|
||||
-- =====================================================================
|
||||
-- Mail outbox domain
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE mail_payloads (
|
||||
payload_id uuid PRIMARY KEY,
|
||||
content_type text NOT NULL,
|
||||
subject text,
|
||||
body bytea NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- mail_deliveries holds one durable record per accepted logical mail
|
||||
-- delivery. The (template_id, idempotency_key) UNIQUE constraint is the
|
||||
-- idempotency reservation. next_attempt_at drives the worker's
|
||||
-- FOR UPDATE SKIP LOCKED pickup; the partial index keeps the scan tight
|
||||
-- because rows in terminal status do not carry next_attempt_at.
|
||||
CREATE TABLE mail_deliveries (
|
||||
delivery_id uuid PRIMARY KEY,
|
||||
template_id text NOT NULL,
|
||||
idempotency_key text NOT NULL,
|
||||
status text NOT NULL,
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
next_attempt_at timestamptz,
|
||||
payload_id uuid NOT NULL REFERENCES mail_payloads (payload_id),
|
||||
last_error text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
sent_at timestamptz,
|
||||
dead_lettered_at timestamptz,
|
||||
CONSTRAINT mail_deliveries_idempotency_unique
|
||||
UNIQUE (template_id, idempotency_key),
|
||||
CONSTRAINT mail_deliveries_status_chk
|
||||
CHECK (status IN ('pending', 'retrying', 'sent', 'dead_lettered'))
|
||||
);
|
||||
|
||||
CREATE INDEX mail_deliveries_due_idx
|
||||
ON mail_deliveries (next_attempt_at)
|
||||
WHERE next_attempt_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX mail_deliveries_status_idx ON mail_deliveries (status);
|
||||
|
||||
CREATE TABLE mail_recipients (
|
||||
recipient_id uuid PRIMARY KEY,
|
||||
delivery_id uuid NOT NULL REFERENCES mail_deliveries (delivery_id) ON DELETE CASCADE,
|
||||
address text NOT NULL,
|
||||
kind text NOT NULL,
|
||||
CONSTRAINT mail_recipients_kind_chk
|
||||
CHECK (kind IN ('to', 'cc', 'bcc', 'reply_to'))
|
||||
);
|
||||
|
||||
CREATE INDEX mail_recipients_delivery_idx ON mail_recipients (delivery_id);
|
||||
|
||||
CREATE TABLE mail_attempts (
|
||||
attempt_id uuid PRIMARY KEY,
|
||||
delivery_id uuid NOT NULL REFERENCES mail_deliveries (delivery_id) ON DELETE CASCADE,
|
||||
attempt_no integer NOT NULL,
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
outcome text NOT NULL,
|
||||
error text NOT NULL DEFAULT '',
|
||||
CONSTRAINT mail_attempts_delivery_attempt_unique
|
||||
UNIQUE (delivery_id, attempt_no),
|
||||
CONSTRAINT mail_attempts_outcome_chk
|
||||
CHECK (outcome IN ('success', 'transient_error', 'permanent_error'))
|
||||
);
|
||||
|
||||
CREATE TABLE mail_dead_letters (
|
||||
dead_letter_id uuid PRIMARY KEY,
|
||||
delivery_id uuid NOT NULL REFERENCES mail_deliveries (delivery_id) ON DELETE CASCADE,
|
||||
archived_at timestamptz NOT NULL DEFAULT now(),
|
||||
reason text NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX mail_dead_letters_listing_idx
|
||||
ON mail_dead_letters (archived_at DESC);
|
||||
|
||||
-- =====================================================================
|
||||
-- Notification domain
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE notifications (
|
||||
notification_id uuid PRIMARY KEY,
|
||||
kind text NOT NULL,
|
||||
idempotency_key text NOT NULL,
|
||||
user_id uuid,
|
||||
payload jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT notifications_idempotency_unique
|
||||
UNIQUE (kind, idempotency_key),
|
||||
CONSTRAINT notifications_kind_chk
|
||||
CHECK (kind IN (
|
||||
'lobby.invite.received', 'lobby.invite.revoked',
|
||||
'lobby.application.submitted', 'lobby.application.approved',
|
||||
'lobby.application.rejected',
|
||||
'lobby.membership.removed', 'lobby.membership.blocked',
|
||||
'lobby.race_name.registered', 'lobby.race_name.pending',
|
||||
'lobby.race_name.expired',
|
||||
'runtime.image_pull_failed', 'runtime.container_start_failed',
|
||||
'runtime.start_config_invalid'
|
||||
))
|
||||
);
|
||||
|
||||
CREATE INDEX notifications_listing_idx
|
||||
ON notifications (created_at DESC, notification_id DESC);
|
||||
|
||||
CREATE TABLE notification_routes (
|
||||
route_id uuid PRIMARY KEY,
|
||||
notification_id uuid NOT NULL REFERENCES notifications (notification_id) ON DELETE CASCADE,
|
||||
channel text NOT NULL,
|
||||
status text NOT NULL,
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
max_attempts integer NOT NULL,
|
||||
next_attempt_at timestamptz,
|
||||
last_attempt_at timestamptz,
|
||||
last_error text NOT NULL DEFAULT '',
|
||||
resolved_email text NOT NULL DEFAULT '',
|
||||
resolved_locale text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
published_at timestamptz,
|
||||
dead_lettered_at timestamptz,
|
||||
skipped_at timestamptz,
|
||||
CONSTRAINT notification_routes_channel_chk
|
||||
CHECK (channel IN ('push', 'email')),
|
||||
CONSTRAINT notification_routes_status_chk
|
||||
CHECK (status IN ('pending', 'retrying', 'published', 'skipped', 'dead_lettered'))
|
||||
);
|
||||
|
||||
CREATE INDEX notification_routes_due_idx
|
||||
ON notification_routes (next_attempt_at)
|
||||
WHERE next_attempt_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX notification_routes_status_idx ON notification_routes (status);
|
||||
CREATE INDEX notification_routes_channel_idx ON notification_routes (channel);
|
||||
CREATE INDEX notification_routes_notification_idx ON notification_routes (notification_id);
|
||||
|
||||
CREATE TABLE notification_dead_letters (
|
||||
dead_letter_id uuid PRIMARY KEY,
|
||||
notification_id uuid NOT NULL REFERENCES notifications (notification_id) ON DELETE CASCADE,
|
||||
route_id uuid NOT NULL,
|
||||
archived_at timestamptz NOT NULL DEFAULT now(),
|
||||
reason text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE notification_malformed_intents (
|
||||
id uuid PRIMARY KEY,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
payload jsonb NOT NULL,
|
||||
reason text NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX notification_malformed_intents_listing_idx
|
||||
ON notification_malformed_intents (received_at DESC);
|
||||
|
||||
-- =====================================================================
|
||||
-- Geo domain
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE user_country_counters (
|
||||
user_id uuid NOT NULL,
|
||||
country text NOT NULL,
|
||||
count bigint NOT NULL DEFAULT 0,
|
||||
last_seen_at timestamptz,
|
||||
PRIMARY KEY (user_id, country)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP SCHEMA IF EXISTS backend CASCADE;
|
||||
Reference in New Issue
Block a user