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