feat: use postgres
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user