feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
@@ -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;
@@ -0,0 +1,13 @@
-- +goose Up
-- Persist the locale captured at send-email-code so it can be replayed at
-- confirm-email-code when the auth flow needs `preferred_language` to seed
-- a freshly-created `accounts` row. Existing rows default to '' and are
-- treated by the auth service as "no captured locale", in which case the
-- service falls back to the geoip-derived language and finally to "en".
ALTER TABLE backend.auth_challenges
ADD COLUMN preferred_language text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE backend.auth_challenges
DROP COLUMN preferred_language;
@@ -0,0 +1,17 @@
// Package migrations exposes the goose migrations applied at backend startup.
package migrations
import (
"embed"
"io/fs"
)
//go:embed *.sql
var migrationFiles embed.FS
// Migrations returns the embedded goose migration filesystem. Migration files
// sit at the FS root, so callers pass "." as the directory argument to
// galaxy/postgres.RunMigrations.
func Migrations() fs.FS {
return migrationFiles
}