Merge pull request 'refactor(db): squash migrations into a single baseline' (#91) from feature/squash-migrations into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m11s

This commit was merged in pull request #91.
This commit is contained in:
2026-06-20 13:23:44 +00:00
14 changed files with 1260 additions and 760 deletions
File diff suppressed because it is too large Load Diff
@@ -1,16 +0,0 @@
-- +goose Up
-- Allow end_reason = 'aborted': a game whose journal can no longer be reconstructed (a
-- recorded move became illegal under tightened rules) is closed as a draw rather than left
-- unopenable. See engine.EndAborted and Service.voidGame.
SET search_path = backend, pg_catalog;
ALTER TABLE games DROP CONSTRAINT games_end_reason_chk;
ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout', 'aborted')
);
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE games DROP CONSTRAINT games_end_reason_chk;
ALTER TABLE games ADD CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
);
@@ -1,45 +0,0 @@
-- +goose Up
-- Manual account blocking ("suspension"), the operator's hard counterpart to the soft,
-- reversible accounts.flagged_high_rate_at marker. Named "suspension" throughout the schema
-- and Go to stay distinct from the peer-to-peer `blocks` table (one player muting another);
-- the wire/UI vocabulary the player sees is "blocked". A suspension forces the player to
-- forfeit every active game and replaces their whole UI with a terminal "blocked" screen until
-- it is lifted or (for a temporary one) expires. See docs/ARCHITECTURE.md §12.
SET search_path = backend, pg_catalog;
-- The operator-editable reason picklist, one row per reason with its English and Russian text.
-- A suspension snapshots the chosen text (account_suspensions.reason_en/ru), so editing or
-- deleting a reason here never changes or breaks a reason already shown to a blocked player.
CREATE TABLE suspension_reasons (
reason_id uuid PRIMARY KEY,
text_en text NOT NULL,
text_ru text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- The block history: one row per block. blocked_until is NULL for a permanent block and the
-- expiry instant for a temporary one; lifted_at is stamped when an operator unblocks early.
-- reason_en/reason_ru are the text snapshot taken at block time (NULL when no reason was
-- cited); reason_id keeps a loose link to the picklist entry for later analytics and is nulled
-- if that entry is deleted (the snapshot remains the source of truth shown to the player). An
-- account is currently blocked when its newest row has lifted_at IS NULL AND (blocked_until IS
-- NULL OR blocked_until > now()).
CREATE TABLE account_suspensions (
suspension_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
blocked_at timestamptz NOT NULL DEFAULT now(),
blocked_until timestamptz,
reason_en text,
reason_ru text,
reason_id uuid REFERENCES suspension_reasons (reason_id) ON DELETE SET NULL,
lifted_at timestamptz
);
-- The enforcement gate looks up the newest suspension for an account on every authenticated
-- request; this index serves that "latest by account" probe.
CREATE INDEX account_suspensions_account_idx ON account_suspensions (account_id, blocked_at DESC);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_suspensions;
DROP TABLE IF EXISTS suspension_reasons;
@@ -1,55 +0,0 @@
-- +goose Up
-- User feedback ("Обратная связь"): a flat list of messages a registered player sends to the
-- operators from Settings -> Info, each with an optional single attachment, plus the operator's
-- inline reply shown back to the player. Distinct from the in-game chat_messages (peer-to-peer)
-- and from the out-of-app Telegram messages. Also introduces account_roles, the project's first
-- per-account role table, replacing per-feature boolean flags. See docs/ARCHITECTURE.md.
SET search_path = backend, pg_catalog;
-- One feedback message. read_at marks "the operator has dealt with this" (set by every console
-- action: read / reply / archive; never by merely opening the detail). The anti-spam gate forbids
-- a new submission while the player has any read_at IS NULL message. archived_at files a handled
-- message away. The reply lives on the same row: reply_body/replied_at are stamped when the
-- operator answers; reply_read_at is stamped when the player's app first fetches the reply for
-- display ("delivered = read"), and the reply is hidden from the player one week after that.
-- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original
-- file name (its extension drives the safe content-type at serve time). sender_ip is the
-- gateway-forwarded client IP (validated, like chat); channel is the submitting platform
-- (telegram/ios/android/web, validated in Go). The sender's interface/bot language snapshots
-- (lang, channel_lang) are added additively in 00005.
CREATE TABLE feedback_messages (
message_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
body text NOT NULL,
attachment bytea,
attachment_name text,
sender_ip text,
channel text NOT NULL,
read_at timestamptz,
archived_at timestamptz,
reply_body text,
replied_at timestamptz,
reply_read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
-- The per-player probes (anti-spam "has unread", "latest message", "latest reply") and the
-- console's user-scoped search all look an account up newest-first.
CREATE INDEX feedback_messages_account_idx ON feedback_messages (account_id, created_at DESC);
-- The console queue lists messages newest-first across all accounts.
CREATE INDEX feedback_messages_created_idx ON feedback_messages (created_at DESC);
-- Named per-account roles. A reusable replacement for per-feature boolean flags: the first role,
-- 'feedback_banned', forbids only feedback submission (not the whole account, unlike a
-- suspension). Roles are validated in Go (no CHECK here) so new ones need no migration. Granted
-- from the feedback console (the "block" checkbox on delete) and granted/revoked from /users.
CREATE TABLE account_roles (
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
role text NOT NULL,
granted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, role)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_roles;
DROP TABLE IF EXISTS feedback_messages;
@@ -1,15 +0,0 @@
-- +goose Up
-- Snapshot the sender's languages on each feedback message, taken at submit time so the
-- operator console shows the state as it was, not the account's current settings (the same
-- snapshot discipline as a suspension's reason). lang is the sender's interface language;
-- channel_lang is the connector bot language (en/ru) when the message arrived through an
-- external connector (Telegram), else NULL. Additive over 00004 so it applies forward-safe.
SET search_path = backend, pg_catalog;
ALTER TABLE feedback_messages ADD COLUMN lang text;
ALTER TABLE feedback_messages ADD COLUMN channel_lang text;
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS channel_lang;
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS lang;
@@ -1,86 +0,0 @@
-- +goose Up
-- Server-driven advertising banner ("advertising network"): operator-managed campaigns rotated in
-- the client's one-line announcement strip. A campaign is one placement order with a show weight
-- (percent); simultaneously active campaigns compete for shows in proportion to their weights. The
-- single perpetual default campaign fills the unsold remainder up to 100%. Each campaign carries
-- one or more bilingual messages (en + ru, both mandatory), shown by the viewer's bot (service)
-- language. Display timings are global (one settings row). Eligibility (who sees a banner at all)
-- reuses account fields + the no_banner role, so it needs no schema here. See docs/ARCHITECTURE.md
-- and internal/ads.
SET search_path = backend, pg_catalog;
-- One advertising campaign = one placement order. weight is the show percentage (1..100). is_default
-- marks the single perpetual house campaign that fills the remainder up to 100% and is never deleted;
-- it has no validity window (its stored weight is nominal — the rotation derives its effective weight
-- as max(0, 100 - sum of active timed weights)). A time-limited campaign is active while now() lies in
-- [starts_at, ends_at] (a null bound is open-ended on that side). enabled toggles a campaign without
-- deleting it.
CREATE TABLE ad_campaigns (
campaign_id uuid PRIMARY KEY,
name text NOT NULL,
weight integer NOT NULL,
is_default boolean NOT NULL DEFAULT false,
enabled boolean NOT NULL DEFAULT true,
starts_at timestamptz,
ends_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ad_campaigns_weight_chk CHECK (weight BETWEEN 1 AND 100),
-- The default campaign is perpetual (no window).
CONSTRAINT ad_campaigns_default_window_chk CHECK (NOT is_default OR (starts_at IS NULL AND ends_at IS NULL)),
-- A closed window must not be inverted.
CONSTRAINT ad_campaigns_window_chk CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at <= ends_at)
);
-- At most one default campaign.
CREATE UNIQUE INDEX ad_campaigns_default_idx ON ad_campaigns (is_default) WHERE is_default;
-- One bilingual message of a campaign. Both languages are mandatory: the client shows the variant for
-- the viewer's bot (service) language. position orders the messages within a campaign (the round-robin
-- order when the campaign wins a display slot). body_* is minimal markdown (text + links).
CREATE TABLE ad_messages (
message_id uuid PRIMARY KEY,
campaign_id uuid NOT NULL REFERENCES ad_campaigns (campaign_id) ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
body_en text NOT NULL,
body_ru text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Messages of a campaign are read in display order.
CREATE INDEX ad_messages_campaign_idx ON ad_messages (campaign_id, position);
-- Global banner display timings, a single row (id is always TRUE). The client rotator reads these:
-- hold_ms is how long one message shows; edge_pause_ms and scroll_px_per_sec drive the scroll of a
-- message wider than the strip; the transition between messages is fade_out_ms -> gap_ms -> fade_in_ms.
-- All are clamped in Go on update.
CREATE TABLE ad_settings (
id boolean PRIMARY KEY DEFAULT true,
hold_ms integer NOT NULL,
edge_pause_ms integer NOT NULL,
scroll_px_per_sec integer NOT NULL,
fade_out_ms integer NOT NULL,
gap_ms integer NOT NULL,
fade_in_ms integer NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ad_settings_singleton_chk CHECK (id)
);
-- Seed the perpetual default campaign with one bilingual house message, and the default timings
-- (the previous hardcoded UI defaults plus the new fade sequence). Fixed UUIDs keep the seed
-- deterministic across environments.
INSERT INTO ad_campaigns (campaign_id, name, weight, is_default, enabled)
VALUES ('00000000-0000-0000-0000-0000000000ad', 'Default (house)', 100, true, true);
INSERT INTO ad_messages (message_id, campaign_id, position, body_en, body_ru)
VALUES (
'00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0,
'Tip: a play using all 7 tiles earns a +50 bonus.',
'Совет: ход всеми 7 фишками приносит бонус +50 очков.'
);
INSERT INTO ad_settings (id, hold_ms, edge_pause_ms, scroll_px_per_sec, fade_out_ms, gap_ms, fade_in_ms)
VALUES (true, 60000, 5000, 40, 1000, 250, 1000);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS ad_settings;
DROP TABLE IF EXISTS ad_messages;
DROP TABLE IF EXISTS ad_campaigns;
@@ -1,16 +0,0 @@
-- +goose Up
-- A per-seat snapshot of the player's display name, captured when the seat is taken:
-- the human's then-current display name, or a disguised robot's freshly composed
-- per-game name. Storing the name on the seat (rather than always reading the account)
-- freezes what the opponent sees for the life of the game — a later rename no longer
-- rewrites past games — and lets the small robot pool present an ever-changing crowd of
-- differently named opponents. An empty value means "no snapshot": the reader falls back
-- to the account's current display name (legacy rows / pre-migration games). See
-- docs/ARCHITECTURE.md §7.
SET search_path = backend, pg_catalog;
ALTER TABLE game_players ADD COLUMN display_name text NOT NULL DEFAULT '';
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE game_players DROP COLUMN display_name;
@@ -1,20 +0,0 @@
-- +goose Up
-- A per-message bitmask of the seats that have NOT yet read this chat entry: bit i is
-- set while seat i still has the message unread, and is cleared when that seat reads it
-- (opens the move history / chat, or — for a nudge — takes its move). A text message
-- starts with the bits of every seated recipient except the sender; a nudge starts with
-- only the awaited player's bit. The mask is inverted so "anything unread" is a plain
-- `unread_seats <> 0`, which the lobby badge, the admin filter and the unread gauge all
-- use. The read time itself is not retained. See docs/ARCHITECTURE.md §8 (Read receipts).
SET search_path = backend, pg_catalog;
ALTER TABLE chat_messages ADD COLUMN unread_seats smallint NOT NULL DEFAULT 0;
-- Partial index serving the unread scans (per-viewer lobby lookup, admin unread filter,
-- the unread-count gauge): only the comparatively few still-unread rows are indexed.
CREATE INDEX chat_messages_unread_idx ON chat_messages (game_id) WHERE unread_seats <> 0;
-- +goose Down
SET search_path = backend, pg_catalog;
DROP INDEX chat_messages_unread_idx;
ALTER TABLE chat_messages DROP COLUMN unread_seats;
@@ -1,25 +0,0 @@
-- +goose Up
-- Per-account, per-variant best single move: the highest-scoring play the account has
-- ever made in each game variant. It exists so the statistics screen can show the word
-- itself broken down by variant, not just the dimensionless aggregate
-- account_stats.max_word_points. tiles is the move's main word as an ordered JSON array of
-- {letter, value, blank} objects (value 0 and blank true for a wildcard), so the client
-- renders it as game tiles without needing the variant's alphabet table. score is the
-- play's total points (every word it formed plus the all-tiles bonus), matching
-- max_word_points. A row is replaced only by a strictly higher-scoring play. It is written
-- at game finish alongside account_stats; guest and honest-AI games never record statistics,
-- so they never write here. See docs/ARCHITECTURE.md §9.
SET search_path = backend, pg_catalog;
CREATE TABLE account_best_move (
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
variant text NOT NULL,
score integer NOT NULL,
tiles jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, variant)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE IF EXISTS account_best_move;
@@ -1,16 +0,0 @@
-- +goose Up
-- account_stats gains two lifetime counters for the player's statistics screen: moves — the
-- player's plays (tile placements; passes and exchanges do not count) — and hints_used — every
-- hint the player took (the free per-game allowance plus the wallet-charged ones). Both are
-- summed at game finish over the same games that feed the rest of account_stats (durable
-- non-guest accounts; honest-AI games are skipped), so the screen can show the "hint share"
-- = hints_used / moves. See docs/ARCHITECTURE.md §9.
SET search_path = backend, pg_catalog;
ALTER TABLE account_stats ADD COLUMN moves integer NOT NULL DEFAULT 0;
ALTER TABLE account_stats ADD COLUMN hints_used integer NOT NULL DEFAULT 0;
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE account_stats DROP COLUMN hints_used;
ALTER TABLE account_stats DROP COLUMN moves;
@@ -1,27 +0,0 @@
-- +goose Up
-- Per-game blocks of a disguised-robot opponent. A disguised robot is a shared pool
-- account reused across games under different per-game names, so it must never go into
-- `blocks` (that would make the same robot look blocked in the blocker's other games under
-- other names, leaking that it is a bot, and show its pool name instead of the one seen).
-- Each such block is recorded here against the specific game + seat, snapshotting the name
-- the player saw, so the blocked list shows it as a distinct personality and the in-game
-- card re-marks that one seat. The real robot account is never blocked, so the matchmaker
-- leaves robots free. Unblocking deletes the row.
SET search_path = backend, pg_catalog;
CREATE TABLE robot_blocks (
id uuid PRIMARY KEY,
blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seat smallint NOT NULL,
robot_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (blocker_id, game_id, seat)
);
CREATE INDEX robot_blocks_blocker_idx ON robot_blocks (blocker_id);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE robot_blocks;
@@ -1,28 +0,0 @@
-- +goose Up
-- Per-game friend requests sent to a disguised-robot opponent. A disguised robot is a
-- shared pool account reused across games under different per-game names, so such a
-- request must never go into `friendships` (that would befriend/await the shared robot
-- account, leak that it is a bot across the requester's other games under other names,
-- and pin the in-game "request sent" state to the shared account instead of this seat).
-- Each request is recorded here against the specific game + seat, snapshotting the name
-- the player saw, so the in-game scoreboard can re-mark that one seat as already
-- requested. The robot ignores it (it never becomes a friendship); a background reaper
-- deletes a row once its game has been finished for more than the retention window.
SET search_path = backend, pg_catalog;
CREATE TABLE robot_friend_requests (
id uuid PRIMARY KEY,
requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seat smallint NOT NULL,
robot_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (requester_id, game_id, seat)
);
CREATE INDEX robot_friend_requests_requester_idx ON robot_friend_requests (requester_id);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE robot_friend_requests;
@@ -1,31 +0,0 @@
-- +goose Up
-- The first-move draw (docs/ARCHITECTURE.md §6): before a game starts, each seated
-- player draws one tile from the bag and the tile closest to "A" decides who moves
-- first (a blank supersedes all letters); players tied for the best tile re-draw
-- until a single leader remains. Each draw uses fresh entropy (not the game's
-- deterministic bag seed), so this record — not a seed — is the only account of how
-- the order was chosen. It is kept for tournaments, where the draw becomes a manual
-- per-tile call. The record is dictionary-independent: the decoded letter, the blank
-- flag and the numeric draw rank describe each draw without any alphabet table. The
-- winner is reflected as seat 0 in game_players, so no order column is duplicated
-- here. In auto-match the opponent is unknown at draw time (the draw runs against a
-- synthetic placeholder when the game opens), so their draw rows carry a NULL account_id,
-- back-filled when a real opponent joins. Hidden from players for now; surfaced only in the
-- admin console.
SET search_path = backend, pg_catalog;
CREATE TABLE game_setup_draws (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
round smallint NOT NULL,
pick_no smallint NOT NULL,
account_id uuid REFERENCES accounts (account_id) ON DELETE CASCADE,
letter text NOT NULL,
is_blank boolean NOT NULL DEFAULT false,
draw_rank smallint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (game_id, round, pick_no)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE game_setup_draws;
@@ -1,45 +0,0 @@
-- +goose Up
-- Single-bot collapse + per-user variant preferences.
--
-- With one Telegram bot there is no longer a "service language" (which bot a player
-- signed in through), and variant availability is no longer gated by the bot's
-- supported languages but by an explicit per-account preference. variant_preferences
-- is the set of game variants (engine.Variant stable labels) a player is willing to be
-- matched into: it gates the New Game picker and the matchmaker, while an invited friend
-- may still accept any variant. New accounts default to Erudit only (a DB-level default,
-- so no application seed is needed); the set may never be empty and must be a subset of
-- the three known variants. feedback_messages.channel_lang (the bot a message arrived
-- through) likewise loses meaning under one bot and is dropped; the sender's interface
-- language (lang) is kept.
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
ADD COLUMN variant_preferences text[] NOT NULL DEFAULT ARRAY['erudit_ru']::text[],
ADD CONSTRAINT accounts_variant_preferences_nonempty_chk
CHECK (cardinality(variant_preferences) >= 1),
ADD CONSTRAINT accounts_variant_preferences_subset_chk
CHECK (variant_preferences <@ ARRAY['scrabble_en', 'scrabble_ru', 'erudit_ru']::text[]);
ALTER TABLE accounts DROP COLUMN service_language;
ALTER TABLE feedback_messages DROP COLUMN channel_lang;
-- The auto-match pairing query filters open games by (variant, multiple_words_per_turn)
-- and takes the oldest by created_at; games_open_idx (open_deadline_at) does not serve it.
-- A partial index over open games turns the scan-and-sort into a single index seek.
CREATE INDEX games_open_match_idx ON games (variant, multiple_words_per_turn, created_at)
WHERE status = 'open';
-- +goose Down
SET search_path = backend, pg_catalog;
DROP INDEX games_open_match_idx;
ALTER TABLE feedback_messages ADD COLUMN channel_lang text;
ALTER TABLE accounts ADD COLUMN service_language text CHECK (service_language IN ('en', 'ru'));
ALTER TABLE accounts
DROP CONSTRAINT accounts_variant_preferences_subset_chk,
DROP CONSTRAINT accounts_variant_preferences_nonempty_chk,
DROP COLUMN variant_preferences;