From 15fdd989e2e9d6d06eb61f9d33f09ed93d18d637 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:12:19 +0100 Subject: [PATCH] feat: add matchmaking durable schema migration --- server/migrations/0001_initial.sql | 138 ++++++++++++++++++++++++++++ server/migrations/test_migration.py | 39 ++++++++ 2 files changed, 177 insertions(+) create mode 100644 server/migrations/0001_initial.sql create mode 100644 server/migrations/test_migration.py diff --git a/server/migrations/0001_initial.sql b/server/migrations/0001_initial.sql new file mode 100644 index 00000000..8b837976 --- /dev/null +++ b/server/migrations/0001_initial.sql @@ -0,0 +1,138 @@ +-- Cosmic Clash matchmaking control plane, migration 0001. +-- PostgreSQL is the durable authority. Redis indexes are rebuildable and do +-- not appear in this schema or in any ownership constraint. + +CREATE TABLE identities ( + player_id TEXT PRIMARY KEY, + steam_id TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + banned_until TIMESTAMPTZ, + ban_reason TEXT +); + +CREATE TABLE sessions ( + session_id TEXT PRIMARY KEY, + player_id TEXT NOT NULL REFERENCES identities(player_id), + token_digest BYTEA NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE queue_tickets ( + ticket_id TEXT PRIMARY KEY, + player_id TEXT NOT NULL REFERENCES identities(player_id), + playlist TEXT NOT NULL CHECK (playlist IN ('casual', 'ranked')), + state TEXT NOT NULL CHECK (state IN ('QUEUED', 'PROPOSED', 'ACCEPTED', 'ALLOCATING', 'PROCESS_READY', 'ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING', 'LIVE', 'RESULT_PENDING', 'COMPLETED', 'CANCELLED', 'EXPIRED', 'FAILED')), + client_build TEXT NOT NULL, + protocol_version INTEGER NOT NULL CHECK (protocol_version > 0), + enqueued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX queue_tickets_one_active_per_player + ON queue_tickets (player_id) + WHERE state IN ('QUEUED', 'PROPOSED', 'ACCEPTED', 'ALLOCATING', 'PROCESS_READY', 'ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING', 'LIVE', 'RESULT_PENDING'); + +CREATE TABLE proposals ( + proposal_id TEXT PRIMARY KEY, + playlist TEXT NOT NULL CHECK (playlist IN ('casual', 'ranked')), + state TEXT NOT NULL CHECK (state IN ('OPEN', 'ACCEPTED', 'DECLINED', 'EXPIRED', 'CANCELLED')), + expires_at TIMESTAMPTZ NOT NULL, + revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE proposal_participants ( + proposal_id TEXT NOT NULL REFERENCES proposals(proposal_id), + player_id TEXT NOT NULL REFERENCES identities(player_id), + ticket_id TEXT NOT NULL REFERENCES queue_tickets(ticket_id), + response TEXT NOT NULL CHECK (response IN ('PENDING', 'ACCEPTED', 'DECLINED', 'TIMED_OUT')), + responded_at TIMESTAMPTZ, + PRIMARY KEY (proposal_id, player_id), + UNIQUE (proposal_id, ticket_id) +); + +CREATE TABLE matches ( + match_id TEXT PRIMARY KEY, + playlist TEXT NOT NULL CHECK (playlist IN ('casual', 'ranked')), + state TEXT NOT NULL CHECK (state IN ('ALLOCATING', 'PROCESS_READY', 'ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING', 'LIVE', 'RESULT_PENDING', 'COMPLETED', 'CANCELLED', 'FAILED')), + region TEXT NOT NULL CHECK (region IN ('EU', 'NA')), + protocol_version INTEGER NOT NULL CHECK (protocol_version > 0), + server_id TEXT, + revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); + +CREATE TABLE match_participants ( + match_id TEXT NOT NULL REFERENCES matches(match_id), + player_id TEXT NOT NULL REFERENCES identities(player_id), + ticket_id TEXT NOT NULL REFERENCES queue_tickets(ticket_id), + slot INTEGER NOT NULL CHECK (slot BETWEEN 0 AND 5), + team INTEGER NOT NULL CHECK (team IN (0, 1)), + connection_generation BIGINT NOT NULL DEFAULT 0 CHECK (connection_generation >= 0), + connected_at TIMESTAMPTZ, + abandoned_at TIMESTAMPTZ, + participation_active BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (match_id, player_id), + UNIQUE (match_id, slot) +); + +CREATE UNIQUE INDEX match_participants_one_active_match + ON match_participants (player_id) + WHERE participation_active; + +CREATE TABLE ratings ( + player_id TEXT PRIMARY KEY REFERENCES identities(player_id), + rating DOUBLE PRECISION NOT NULL DEFAULT 1500, + deviation DOUBLE PRECISION NOT NULL DEFAULT 350, + volatility DOUBLE PRECISION NOT NULL DEFAULT 0.06, + ranked_games INTEGER NOT NULL DEFAULT 0 CHECK (ranked_games >= 0), + revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE result_receipts ( + result_id TEXT PRIMARY KEY, + match_id TEXT NOT NULL UNIQUE REFERENCES matches(match_id), + result_nonce TEXT NOT NULL UNIQUE, + payload_digest BYTEA NOT NULL, + integrity_state TEXT NOT NULL CHECK (integrity_state IN ('CERTIFIED', 'SUPPRESSED', 'REVIEW')), + received_at TIMESTAMPTZ NOT NULL DEFAULT now(), + committed_at TIMESTAMPTZ +); + +CREATE TABLE outbox ( + event_id TEXT PRIMARY KEY, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + revision BIGINT NOT NULL CHECK (revision >= 0), + event_type TEXT NOT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + published_at TIMESTAMPTZ, + UNIQUE (aggregate_type, aggregate_id, revision) +); + +CREATE TABLE audit_events ( + audit_id BIGSERIAL PRIMARY KEY, + actor_type TEXT NOT NULL CHECK (actor_type IN ('PLAYER', 'SERVER', 'SYSTEM', 'ADMIN')), + actor_id TEXT, + action TEXT NOT NULL, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + request_id TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX queue_tickets_candidate_order + ON queue_tickets (playlist, enqueued_at, ticket_id) + WHERE state = 'QUEUED'; + +CREATE INDEX outbox_unpublished_order + ON outbox (created_at, event_id) + WHERE published_at IS NULL; diff --git a/server/migrations/test_migration.py b/server/migrations/test_migration.py new file mode 100644 index 00000000..26ed3bab --- /dev/null +++ b/server/migrations/test_migration.py @@ -0,0 +1,39 @@ +"""Static migration checks; PostgreSQL integration runs in the backend CI.""" + +from pathlib import Path +import unittest + + +SQL = (Path(__file__).parent / "0001_initial.sql").read_text() + + +class MigrationTest(unittest.TestCase): + def test_durable_domains_and_fences_exist(self): + required_tables = { + "identities", "sessions", "queue_tickets", "proposals", + "proposal_participants", "matches", "match_participants", + "ratings", "result_receipts", "outbox", "audit_events", + } + for table in required_tables: + self.assertIn(f"CREATE TABLE {table}", SQL) + self.assertIn("queue_tickets_one_active_per_player", SQL) + self.assertIn("match_participants_one_active_match", SQL) + self.assertIn("UNIQUE (aggregate_type, aggregate_id, revision)", SQL) + + def test_redis_is_not_a_durable_dependency(self): + self.assertNotIn("CREATE TABLE redis", SQL.lower()) + self.assertNotIn("redis_id", SQL.lower()) + self.assertIn("CREATE TABLE outbox", SQL) + self.assertIn("published_at", SQL) + + def test_no_unbounded_or_client_owned_identity_fields(self): + self.assertIn("steam_id TEXT NOT NULL UNIQUE", SQL) + self.assertIn("token_digest BYTEA NOT NULL UNIQUE", SQL) + self.assertIn("payload JSONB NOT NULL", SQL) + self.assertNotIn("steam_ticket TEXT", SQL) + self.assertIn("participation_active BOOLEAN NOT NULL DEFAULT TRUE", SQL) + self.assertIn("WHERE participation_active", SQL) + + +if __name__ == "__main__": + unittest.main()