6d6a384bee
Previously a cancelled / finished / start_failed sandbox game would hang in the dev user's lobby until manually cleaned up — `make up` would create a new running game alongside it but the dead tiles piled up. Now backend's `devsandbox.Bootstrap` deletes every terminal sandbox game owned by the dev user before find-or-create runs, so the lobby always shows exactly one running tile. Schema: `runtime_records` and `player_mappings` gain `ON DELETE CASCADE` on their `game_id` foreign keys so a single `DELETE FROM games` cleans every referencing row in one write. Pre-prod migration rule applies — change goes into `00001_init.sql`, not a new migration. API: `lobby.Service.DeleteGame` is the new destructive helper that backs the bootstrap purge. It bypasses the cancel-cascade-notify pipeline; production callers must stay on the regular lifecycle. The dev-sandbox docs in `tools/local-dev/README.md` spell out the new behaviour. Tests: - backend/internal/lobby/lobby_e2e_test.go gains `TestDeleteGameCascadesEverything` proving CASCADE works end-to-end against a real Postgres testcontainer. - backend/internal/devsandbox keeps its existing terminal-status contract test; the new `purgeTerminalSandboxGames` helper rides on the same `terminalSandboxStatus` predicate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
678 lines
28 KiB
SQL
678 lines
28 KiB
SQL
-- +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,
|
|
preferred_language text NOT NULL DEFAULT ''
|
|
);
|
|
|
|
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()
|
|
);
|
|
|
|
-- session_revocations is the durable audit trail of every device-session
|
|
-- revocation. Each revoke writes one row carrying the actor kind, actor
|
|
-- id, and free-form reason. The table is append-only; reading it is the
|
|
-- only way to answer "who and why revoked this session". The
|
|
-- device_session_id column is not a foreign key because device_sessions
|
|
-- rows survive after revoke (status='revoked'), and dropping a session
|
|
-- through a future cleanup must not implicitly drop its audit history.
|
|
CREATE TABLE session_revocations (
|
|
revocation_id uuid PRIMARY KEY,
|
|
device_session_id uuid NOT NULL,
|
|
user_id uuid NOT NULL,
|
|
actor_kind text NOT NULL,
|
|
actor_user_id uuid,
|
|
actor_username text,
|
|
reason text NOT NULL DEFAULT '',
|
|
revoked_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT session_revocations_actor_chk
|
|
CHECK (actor_user_id IS NULL OR actor_username IS NULL)
|
|
);
|
|
|
|
CREATE INDEX session_revocations_user_idx ON session_revocations (user_id, revoked_at DESC);
|
|
CREATE INDEX session_revocations_device_idx ON session_revocations (device_session_id, revoked_at DESC);
|
|
CREATE INDEX session_revocations_actor_kind_idx ON session_revocations (actor_kind, revoked_at DESC);
|
|
|
|
-- =====================================================================
|
|
-- 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_user_id uuid,
|
|
deleted_actor_username 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),
|
|
CONSTRAINT accounts_deleted_actor_chk
|
|
CHECK (deleted_actor_user_id IS NULL OR deleted_actor_username IS NULL)
|
|
);
|
|
|
|
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_user_id uuid,
|
|
actor_username 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')),
|
|
CONSTRAINT entitlement_records_actor_chk
|
|
CHECK (actor_user_id IS NULL OR actor_username IS NULL)
|
|
);
|
|
|
|
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_user_id uuid,
|
|
actor_username 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')),
|
|
CONSTRAINT entitlement_snapshots_actor_chk
|
|
CHECK (actor_user_id IS NULL OR actor_username IS NULL)
|
|
);
|
|
|
|
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_user_id uuid,
|
|
actor_username text,
|
|
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
expires_at timestamptz,
|
|
removed_at timestamptz,
|
|
removed_by_type text,
|
|
removed_by_user_id uuid,
|
|
removed_by_username text,
|
|
removed_reason_code text,
|
|
CONSTRAINT sanction_records_code_chk
|
|
CHECK (sanction_code IN ('permanent_block')),
|
|
CONSTRAINT sanction_records_actor_chk
|
|
CHECK (actor_user_id IS NULL OR actor_username IS NULL),
|
|
CONSTRAINT sanction_records_removed_by_chk
|
|
CHECK (removed_by_user_id IS NULL OR removed_by_username IS NULL)
|
|
);
|
|
|
|
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_user_id uuid,
|
|
actor_username text,
|
|
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
expires_at timestamptz,
|
|
removed_at timestamptz,
|
|
removed_by_type text,
|
|
removed_by_user_id uuid,
|
|
removed_by_username text,
|
|
removed_reason_code text,
|
|
CONSTRAINT limit_records_actor_chk
|
|
CHECK (actor_user_id IS NULL OR actor_username IS NULL),
|
|
CONSTRAINT limit_records_removed_by_chk
|
|
CHECK (removed_by_user_id IS NULL OR removed_by_username IS NULL)
|
|
);
|
|
|
|
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 REFERENCES games (game_id) ON DELETE CASCADE,
|
|
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 REFERENCES games (game_id) ON DELETE CASCADE,
|
|
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;
|