-- +goose Up -- Initial Game Lobby PostgreSQL schema. -- -- Five tables cover the durable surface of the service: -- * games, applications, invites, memberships — the four core -- enrollment entities; -- * race_names — the Race Name Directory, holding the registered / -- reservation / pending_registration bindings keyed by canonical key. -- -- Schema and the matching `lobbyservice` role are provisioned outside -- this script (in tests via -- integration/internal/harness/postgres_container.go::EnsureRoleAndSchema; -- in production via an ops init script). This migration runs as the -- schema owner with `search_path=lobby` and only contains DDL for the -- service-owned tables and indexes. -- games holds one durable record per platform game session. The status + -- created_at index serves the listing/scheduler queries that previously -- read `lobby:games_by_status:*`. The partial owner index serves the -- per-owner listings used by user-lifecycle cascade and "my games" -- listings; public games carry an empty owner_user_id and never enter -- the index. CREATE TABLE games ( game_id text PRIMARY KEY, game_name text NOT NULL, description text NOT NULL DEFAULT '', game_type text NOT NULL, owner_user_id text NOT NULL DEFAULT '', status text NOT NULL, 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, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL, started_at timestamptz, finished_at timestamptz, runtime_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, runtime_binding jsonb ); 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 game_type = 'private'; -- applications carries one row per public-game enrollment request. The -- partial UNIQUE on (applicant_user_id, game_id) WHERE status <> 'rejected' -- replaces the Redis lookup key `lobby:user_game_application:*:*` and -- 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 text PRIMARY KEY, game_id text NOT NULL REFERENCES games(game_id) ON DELETE CASCADE, applicant_user_id text NOT NULL, race_name text NOT NULL, status text NOT NULL, created_at timestamptz NOT NULL, decided_at timestamptz ); 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. race_name is empty -- until the invite transitions to redeemed. The (status, expires_at) index -- serves the enrollment-automation expiration sweep; the per-game, -- per-invitee, and per-inviter indexes serve listing queries from the -- service layer. CREATE TABLE invites ( invite_id text PRIMARY KEY, game_id text NOT NULL REFERENCES games(game_id) ON DELETE CASCADE, inviter_user_id text NOT NULL, invitee_user_id text NOT NULL, race_name text NOT NULL DEFAULT '', status text NOT NULL, created_at timestamptz NOT NULL, expires_at timestamptz NOT NULL, decided_at timestamptz ); CREATE INDEX invites_game_idx ON invites (game_id); CREATE INDEX invites_invitee_idx ON invites (invitee_user_id); CREATE INDEX invites_inviter_idx ON invites (inviter_user_id); CREATE INDEX invites_status_expires_idx ON invites (status, expires_at); -- memberships carries one row per platform roster entry. Both race_name -- (original casing) and canonical_key are stored explicitly because -- downstream readers (capability evaluation, cascade release) consume the -- canonical form without re-deriving it from race_name. Race-name -- uniqueness is enforced by the Race Name Directory (the race_names -- table below) — this table intentionally has no unique constraint on -- canonical_key. CREATE TABLE memberships ( membership_id text PRIMARY KEY, game_id text NOT NULL REFERENCES games(game_id) ON DELETE CASCADE, user_id text NOT NULL, race_name text NOT NULL, canonical_key text NOT NULL, status text NOT NULL, joined_at timestamptz NOT NULL, removed_at timestamptz ); 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_key, immutable holder), a per-game reservation, or a -- pending_registration that is waiting on lobby.race_name.register inside -- the eligible_until_ms window. The composite primary key (canonical_key, -- game_id) lets the same user hold reservations for the same race name -- across multiple active games concurrently, matching the behaviour the -- shared port test suite (lobby/internal/ports/racenamedirtest) covers. -- Registered rows store game_id = '' and keep the source game in -- source_game_id so the per-canonical uniqueness rule expresses cleanly -- as a partial UNIQUE index. Cross-user uniqueness on canonical_key is -- enforced at write time inside transactions guarded by -- pg_advisory_xact_lock(hashtextextended(canonical_key, 0)). CREATE TABLE race_names ( canonical_key text NOT NULL, game_id text NOT NULL DEFAULT '', holder_user_id text NOT NULL, race_name text NOT NULL, binding_kind text NOT NULL, source_game_id text NOT NULL DEFAULT '', reserved_at_ms bigint NOT NULL DEFAULT 0, eligible_until_ms bigint, registered_at_ms bigint, PRIMARY KEY (canonical_key, game_id), CONSTRAINT race_names_binding_kind_chk CHECK (binding_kind IN ('registered', 'reservation', 'pending_registration')) ); -- Exactly one registered binding per canonical_key. Reservations and -- pending_registration entries are differentiated by game_id within the -- primary key. CREATE UNIQUE INDEX race_names_registered_uidx ON race_names (canonical_key) WHERE binding_kind = 'registered'; -- Per-user listings used by ListRegistered / ListReservations / -- ListPendingRegistrations. CREATE INDEX race_names_holder_idx ON race_names (holder_user_id, binding_kind); -- Pending-registration expiration scanner reads only the pending subset -- ordered by eligible_until_ms. CREATE INDEX race_names_pending_eligible_idx ON race_names (eligible_until_ms) WHERE binding_kind = 'pending_registration'; -- +goose Down DROP TABLE IF EXISTS race_names; DROP TABLE IF EXISTS memberships; DROP TABLE IF EXISTS invites; DROP TABLE IF EXISTS applications; DROP TABLE IF EXISTS games;