-- 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;