170 lines
7.3 KiB
SQL
170 lines
7.3 KiB
SQL
-- +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;
|