feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
@@ -0,0 +1,169 @@
-- +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;