R1: schema & naming reset — squash migrations, rename variants
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s

Squash the 12 goose migrations into one 00001_baseline.sql (there is no prod
data; verified schema-identical to the chain via a pg_dump diff + the green
integration suite) and rename the game-variant labels
english/russian_scrabble/erudit -> scrabble_en/scrabble_ru/erudit_ru across the
backend, the FlatBuffers wire values and the UI.

dawg filenames and the Go enum identifiers are unchanged; the i18n display keys
are kept. Adds PRERELEASE.md (the R1-R7 pre-release tracker), linked from
CLAUDE.md. Contour DB wipe and the scrabble-dictionary tidy are follow-ups.
This commit is contained in:
Ilia Denisov
2026-06-09 12:09:50 +02:00
parent 70e3fab512
commit 26aa154547
54 changed files with 688 additions and 675 deletions
+10 -7
View File
@@ -71,18 +71,18 @@ gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a us
Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
seed a new account's language and display name from the launch fields, and adds
migration `00007` (`accounts.notifications_in_app_only`, default true).
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
the `accounts.notifications_in_app_only` flag (default true).
`accounts.is_guest` marks an ephemeral guest a durable row
with no identity, excluded from statistics. **Stage 10** adds the server-rendered
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
**complaint resolution** lifecycle (migration `00008` adds `disposition`/`resolution_note`/
`resolved_at`/`applied_in_version` + the `status` CHECK) feeding a dictionary-change
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
`resolved_at`/`applied_in_version` columns + the `status` CHECK) feeding a dictionary-change
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds
migration `00010` (`accounts.service_language`): the language tag of the bot a Telegram
`accounts.service_language`: the language tag of the bot a Telegram
user last signed in through, written on every login and returned by
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
@@ -96,7 +96,7 @@ friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (s
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
The current account is primary, except a guest initiator whose linked identity has a
durable owner — then the durable account wins and a fresh session is minted for it.
Migration `00009` adds `paid_account`/`merged_into`/`merged_at`. This supersedes the
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
## Package layout
@@ -176,7 +176,10 @@ warmed.
## Migrations & generated code
Migrations are plain goose SQL under `internal/postgres/migrations` (sequential
`NNNNN_name.sql`), embedded and applied at startup. After changing the schema,
`NNNNN_name.sql`), embedded and applied at startup. The incremental history was
squashed into a single `00001_baseline.sql` before the first production deploy
(there was no production data); new schema changes append as `00002_*` onward.
After changing the schema,
regenerate the committed go-jet code (needs Docker):
```sh
+5 -5
View File
@@ -20,15 +20,15 @@ func TestRendererRendersEveryPage(t *testing.T) {
data any
want string
}{
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya"}}, Pager: NewPager(1, 50, 1)}, "Kaya"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "scrabble_en", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
{"game_detail", GameDetailView{ID: "g1", Variant: "scrabble_en", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1"}}, Pager: NewPager(1, 50, 1)}, "good luck"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Hot-reload"},
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
}
@@ -30,9 +30,9 @@
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
<label>Mark applied for variant
<select name="variant">
<option value="english">english</option>
<option value="russian_scrabble">russian_scrabble</option>
<option value="erudit">erudit</option>
<option value="scrabble_en">scrabble_en</option>
<option value="scrabble_ru">scrabble_ru</option>
<option value="erudit_ru">erudit_ru</option>
</select>
</label>
<label>In version <input type="text" name="version" placeholder="v2" required></label>
+6 -6
View File
@@ -12,7 +12,7 @@ import (
func TestAlphabetTableEnglish(t *testing.T) {
tab, err := AlphabetTable(VariantEnglish)
if err != nil {
t.Fatalf("AlphabetTable(english): %v", err)
t.Fatalf("AlphabetTable(scrabble_en): %v", err)
}
if len(tab) != 26 {
t.Fatalf("size = %d, want 26", len(tab))
@@ -40,23 +40,23 @@ func TestAlphabetTableEnglish(t *testing.T) {
func TestAlphabetTableRussianVariants(t *testing.T) {
ru, err := AlphabetTable(VariantRussianScrabble)
if err != nil {
t.Fatalf("AlphabetTable(russian_scrabble): %v", err)
t.Fatalf("AlphabetTable(scrabble_ru): %v", err)
}
er, err := AlphabetTable(VariantErudit)
if err != nil {
t.Fatalf("AlphabetTable(erudit): %v", err)
t.Fatalf("AlphabetTable(erudit_ru): %v", err)
}
if len(ru) != 33 || len(er) != 33 {
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
}
if ru[0].Letter != "а" || ru[0].Value != 1 {
t.Errorf("russian entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
t.Errorf("scrabble_ru entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
}
if ru[6].Letter != "ё" || ru[6].Value != 3 {
t.Errorf("russian ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
t.Errorf("scrabble_ru ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
}
if er[6].Letter != "ё" || er[6].Value != 0 {
t.Errorf("erudit ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
t.Errorf("erudit_ru ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
}
if ru[32].Letter != "я" || er[32].Letter != "я" {
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
+4 -4
View File
@@ -168,10 +168,10 @@ func TestRegistryLookup(t *testing.T) {
word string
want bool
}{
{"english hit", VariantEnglish, "cat", true},
{"english miss", VariantEnglish, "zzzz", false},
{"russian hit", VariantRussianScrabble, "кот", true},
{"erudit hit", VariantErudit, "кот", true},
{"scrabble_en hit", VariantEnglish, "cat", true},
{"scrabble_en miss", VariantEnglish, "zzzz", false},
{"scrabble_ru hit", VariantRussianScrabble, "кот", true},
{"erudit_ru hit", VariantErudit, "кот", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
+3 -3
View File
@@ -38,11 +38,11 @@ const (
func (v Variant) String() string {
switch v {
case VariantEnglish:
return "english"
return "scrabble_en"
case VariantRussianScrabble:
return "russian_scrabble"
return "scrabble_ru"
case VariantErudit:
return "erudit"
return "erudit_ru"
}
return "unknown"
}
+1 -1
View File
@@ -60,7 +60,7 @@ func TestRegistryValidatesKnownWords(t *testing.T) {
func TestRegistryUnknownLookups(t *testing.T) {
reg, err := Open(testDictDir(), testVersion, VariantEnglish)
if err != nil {
t.Fatalf("open english-only registry: %v", err)
t.Fatalf("open scrabble_en-only registry: %v", err)
}
defer reg.Close()
+7 -7
View File
@@ -45,13 +45,13 @@ func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
t.Fatalf("load available: %v", err)
}
if len(loaded) != 1 || loaded[0] != VariantEnglish {
t.Fatalf("loaded = %v, want [english]", loaded)
t.Fatalf("loaded = %v, want [scrabble_en]", loaded)
}
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
t.Errorf("english v2 solver: %v", err)
t.Errorf("scrabble_en v2 solver: %v", err)
}
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("russian v2 should be absent: got %v", err)
t.Errorf("scrabble_ru v2 should be absent: got %v", err)
}
}
@@ -77,17 +77,17 @@ func TestOpenWithVersionsScansSubdirs(t *testing.T) {
}
}
if got := reg.Versions(VariantEnglish); len(got) != 2 {
t.Errorf("english versions = %v, want two", got)
t.Errorf("scrabble_en versions = %v, want two", got)
}
latest, _, err := reg.Latest(VariantEnglish)
if err != nil {
t.Fatalf("latest english: %v", err)
t.Fatalf("latest scrabble_en: %v", err)
}
if latest != "v2" {
t.Errorf("latest english = %q, want v2", latest)
t.Errorf("latest scrabble_en = %q, want v2", latest)
}
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
t.Errorf("russian versions = %v, want one (no v2 file)", got)
t.Errorf("scrabble_ru versions = %v, want one (no v2 file)", got)
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ func TestWriteGCG(t *testing.T) {
"#character-encoding UTF-8",
"#player1 p1 Alice",
"#player2 p2 Bob",
"#lexicon english/v1",
"#lexicon scrabble_en/v1",
"#title game 00000000-0000-7000-8000-000000000001",
">p1: CATSER? 8H CAT +10 10",
">p2: AS?E I8 .s +2 2",
+1 -1
View File
@@ -94,7 +94,7 @@ func TestGameCacheEviction(t *testing.T) {
cur := time.Unix(1_700_000_000, 0)
cache := newGameCache(time.Hour, func() time.Time { return cur })
id := uuid.New()
cache.put(id, nil, "english")
cache.put(id, nil, "scrabble_en")
if _, ok := cache.get(id); !ok {
t.Fatal("game must be resident after put")
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
const meterName = "scrabble/backend/game"
// gameMetrics holds the game domain's operational instruments. Every game-scoped
// measurement carries a "variant" attribute (english/russian/erudit). The
// measurement carries a "variant" attribute (scrabble_en/scrabble_ru/erudit_ru). The
// instruments default to no-ops (see defaultGameMetrics), so recording is always
// safe; SetMetrics installs the real meter during startup wiring.
type gameMetrics struct {
+4 -4
View File
@@ -35,11 +35,11 @@ func TestGameMetrics(t *testing.T) {
}
started := counterByAttr(t, rm, "games_started_total", "variant")
if started["english"] != 2 || started["russian_scrabble"] != 1 {
t.Errorf("games_started_total = %v, want english:2 russian_scrabble:1", started)
if started["scrabble_en"] != 2 || started["scrabble_ru"] != 1 {
t.Errorf("games_started_total = %v, want scrabble_en:2 scrabble_ru:1", started)
}
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit"] != 1 {
t.Errorf("games_abandoned_total = %v, want erudit:1", abandoned)
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit_ru"] != 1 {
t.Errorf("games_abandoned_total = %v, want erudit_ru:1", abandoned)
}
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
t.Errorf("game_replay_duration observations = %d, want 1", c)
+1 -1
View File
@@ -33,7 +33,7 @@ func TestMoveDurationAnalytics(t *testing.T) {
t0 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
if _, err := testDB.ExecContext(ctx,
`INSERT INTO backend.games (game_id, variant, dict_version, seed, players, turn_timeout_secs, created_at)
VALUES ($1,'english','v1',1,2,86400,$2)`, gid, t0); err != nil {
VALUES ($1,'scrabble_en','v1',1,2,86400,$2)`, gid, t0); err != nil {
t.Fatalf("insert game: %v", err)
}
if _, err := testDB.ExecContext(ctx,
+1 -1
View File
@@ -477,7 +477,7 @@ func TestGameVariant(t *testing.T) {
t.Fatalf("create: %v", err)
}
if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish {
t.Fatalf("GameVariant = %v, %v; want english, nil", v, err)
t.Fatalf("GameVariant = %v, %v; want scrabble_en, nil", v, err)
}
if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) {
t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err)
+1 -1
View File
@@ -90,7 +90,7 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
t.Errorf("picked account %s is not a robot identity", id)
}
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
t.Errorf("russian pick = (%s, %v), want a robot account", ru, err)
t.Errorf("scrabble_ru pick = (%s, %v), want a robot account", ru, err)
}
acc, err := account.NewStore(testDB).GetByID(ctx, id)
if err != nil {
+1 -1
View File
@@ -37,7 +37,7 @@ var gooseMu sync.Mutex
// ApplyMigrations runs every pending Up migration embedded in the backend
// binary against db. The schema is created upfront so goose's bookkeeping table
// (`goose_db_version`, scoped to the DSN search_path) has somewhere to land
// before the first migration runs; migration 00001_init.sql re-asserts the
// before the first migration runs; the baseline migration re-asserts the
// schema with IF NOT EXISTS, so the double-create is idempotent.
//
// The apply is retried on transient connection errors. Both steps are
@@ -0,0 +1,323 @@
-- +goose Up
-- Baseline schema for the Scrabble backend service, consolidating the incremental
-- migration history into a single starting point (there is no production data yet,
-- so the squash carries no data migration). Every backend object lives in the
-- `backend` schema; it is created here so a fresh database can apply this migration,
-- and search_path is pinned for the rest of the file so unqualified CREATE
-- statements land in `backend`. Production also pins search_path via
-- BACKEND_POSTGRES_DSN.
CREATE SCHEMA IF NOT EXISTS backend;
SET search_path = backend, pg_catalog;
-- Durable internal accounts. A guest is a durable row with is_guest set and no
-- identity, excluded from profile/friends/stats/history. The away window (one
-- interval per day, in the account's time_zone) is honoured by the turn-timeout
-- sweeper and the robot's sleep; hint_balance is the purchasable-hint wallet.
-- service_language records the language tag of the bot a Telegram user last
-- authenticated through (out-of-app push routing), distinct from preferred_language
-- (the interface language). merged_into/merged_at turn a merged-away secondary into
-- an audit tombstone; paid_account is a forward-looking one-time-payment marker.
CREATE TABLE accounts (
account_id uuid PRIMARY KEY,
display_name text NOT NULL DEFAULT '',
preferred_language text NOT NULL DEFAULT 'en',
time_zone text NOT NULL DEFAULT 'UTC',
block_chat boolean NOT NULL DEFAULT false,
block_friend_requests boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
away_start time NOT NULL DEFAULT '00:00',
away_end time NOT NULL DEFAULT '07:00',
hint_balance integer NOT NULL DEFAULT 0,
is_guest boolean NOT NULL DEFAULT false,
notifications_in_app_only boolean NOT NULL DEFAULT true,
paid_account boolean NOT NULL DEFAULT false,
merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL,
merged_at timestamptz,
service_language text CHECK (service_language IN ('en', 'ru')),
CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru')),
CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0)
);
-- Platform and email identities attached to an account. external_id is the platform
-- user id (kind='telegram'), the email address (kind='email') or the robot name
-- (kind='robot'); confirmed flips true once an email confirm-code is verified.
CREATE TABLE identities (
identity_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
kind text NOT NULL,
external_id text NOT NULL,
confirmed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email', 'robot')),
CONSTRAINT identities_kind_external_id_key UNIQUE (kind, external_id)
);
CREATE INDEX identities_account_idx ON identities (account_id);
-- Opaque server sessions. token_hash is the hex-encoded SHA-256 of the bearer token;
-- the plaintext token is never stored. Sessions are revoke-only (no TTL): status
-- moves active -> revoked and revoked_at is stamped.
CREATE TABLE sessions (
session_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
token_hash text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz,
revoked_at timestamptz,
CONSTRAINT sessions_status_chk CHECK (status IN ('active', 'revoked')),
CONSTRAINT sessions_token_hash_key UNIQUE (token_hash)
);
CREATE INDEX sessions_account_idx ON sessions (account_id);
-- One match. The live position is event-sourced: this row carries the pinned
-- dictionary, the bag seed and the denormalised turn cursor the sweeper needs, while
-- game_moves is the append-only journal the in-memory engine.Game is replayed from
-- (docs/ARCHITECTURE.md §9). turn_timeout_secs is the per-game move clock; its allowed
-- values are enforced in Go. variant uses engine.Variant's stable labels.
CREATE TABLE games (
game_id uuid PRIMARY KEY,
variant text NOT NULL,
dict_version text NOT NULL,
seed bigint NOT NULL,
status text NOT NULL DEFAULT 'active',
players smallint NOT NULL,
to_move smallint NOT NULL DEFAULT 0,
turn_started_at timestamptz NOT NULL DEFAULT now(),
turn_timeout_secs integer NOT NULL,
hints_allowed boolean NOT NULL DEFAULT true,
hints_per_player smallint NOT NULL DEFAULT 1,
move_count integer NOT NULL DEFAULT 0,
end_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
dropout_tiles text NOT NULL DEFAULT 'remove',
CONSTRAINT games_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0),
CONSTRAINT games_hints_per_player_chk CHECK (hints_per_player >= 0),
CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
),
CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return'))
);
-- The sweeper scans active games oldest-turn-first; a partial index keeps it off the
-- finished archive.
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active';
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a
-- durable account. score is the running/final score, is_winner is stamped on finish
-- (false for every seat on a draw), hints_used counts the per-game allowance consumed
-- before the profile wallet.
CREATE TABLE game_players (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seat smallint NOT NULL,
account_id uuid NOT NULL REFERENCES accounts (account_id),
score integer NOT NULL DEFAULT 0,
hints_used smallint NOT NULL DEFAULT 0,
is_winner boolean NOT NULL DEFAULT false,
PRIMARY KEY (game_id, seat),
CONSTRAINT game_players_account_key UNIQUE (game_id, account_id)
);
CREATE INDEX game_players_account_idx ON game_players (account_id);
-- The append-only, dictionary-independent move journal (docs/ARCHITECTURE.md §9.1).
-- seq orders the moves from 0. payload holds the decoded values needed to both replay
-- the game through the engine and emit GCG without a dictionary. score / running_total
-- / exchanged_count are lifted out for cheap history rendering.
CREATE TABLE game_moves (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seq integer NOT NULL,
seat smallint NOT NULL,
action text NOT NULL,
score integer NOT NULL DEFAULT 0,
running_total integer NOT NULL DEFAULT 0,
exchanged_count smallint NOT NULL DEFAULT 0,
payload text NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (game_id, seq),
CONSTRAINT game_moves_action_chk CHECK (action IN ('play', 'pass', 'exchange', 'resign', 'timeout'))
);
-- Word-check complaints captured in the context of a game's pinned dictionary. The
-- admin review queue resolves them with a disposition that also feeds the offline
-- dictionary-rebuild pipeline: an accepted complaint records whether the word is to be
-- added or removed, and is marked applied once a rebuilt version is hot-reloaded.
CREATE TABLE complaints (
complaint_id uuid PRIMARY KEY,
complainant_id uuid NOT NULL REFERENCES accounts (account_id),
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
variant text NOT NULL,
dict_version text NOT NULL,
word text NOT NULL,
was_valid boolean NOT NULL,
note text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'open',
created_at timestamptz NOT NULL DEFAULT now(),
disposition text NOT NULL DEFAULT '',
resolution_note text NOT NULL DEFAULT '',
resolved_at timestamptz,
applied_in_version text NOT NULL DEFAULT '',
CONSTRAINT complaints_status_chk CHECK (status IN ('open', 'resolved')),
CONSTRAINT complaints_disposition_chk
CHECK (disposition IN ('', 'reject', 'accept_add', 'accept_remove'))
);
CREATE INDEX complaints_status_idx ON complaints (status);
-- Per-account lifetime statistics, recomputed incrementally on each game finish.
-- Guests have no durable stats. A draw increments draws only. max_word_points is the
-- best single move score (folding in every word the move formed and the all-tiles bonus).
CREATE TABLE account_stats (
account_id uuid PRIMARY KEY REFERENCES accounts (account_id) ON DELETE CASCADE,
wins integer NOT NULL DEFAULT 0,
losses integer NOT NULL DEFAULT 0,
draws integer NOT NULL DEFAULT 0,
max_game_points integer NOT NULL DEFAULT 0,
max_word_points integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- The friend graph. A row is created by the requester as 'pending' and flipped to
-- 'accepted' by the addressee; an explicit 'declined' is remembered (anti-spam),
-- while cancelling or unfriending deletes the row. Friendship is symmetric.
CREATE TABLE friendships (
requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
addressee_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
responded_at timestamptz,
PRIMARY KEY (requester_id, addressee_id),
CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined')),
CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id)
);
CREATE INDEX friendships_addressee_idx ON friendships (addressee_id);
-- Per-user blocks. The effect is applied mutually by the social checks (a block in
-- either direction suppresses chat visibility and prevents requests/invitations).
CREATE TABLE blocks (
blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
blocked_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (blocker_id, blocked_id),
CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id)
);
CREATE INDEX blocks_blocked_idx ON blocks (blocked_id);
-- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty body,
-- so one journal carries both chatter and nudges. body is capped at 60 runes (enforced
-- again in Go, where the content filter also rejects links/emails/phone numbers).
-- sender_ip holds the gateway-forwarded client IP as a validated string. Chat is part
-- of the game archive and cascades away only with its game.
CREATE TABLE chat_messages (
message_id uuid PRIMARY KEY,
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
sender_id uuid NOT NULL REFERENCES accounts (account_id),
kind text NOT NULL DEFAULT 'message',
body text NOT NULL DEFAULT '',
sender_ip text,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')),
CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60),
CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '')
);
CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at);
-- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender).
CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at)
WHERE kind = 'nudge';
-- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the 6-digit code
-- (the plaintext is never stored); expires_at bounds the TTL and attempts caps brute
-- force. A row is consumed (consumed_at stamped) on success.
CREATE TABLE email_confirmations (
confirmation_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
email text NOT NULL,
code_hash text NOT NULL,
expires_at timestamptz NOT NULL,
attempts smallint NOT NULL DEFAULT 0,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0)
);
CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id);
-- A friend-game invitation. The inviter (seat 0) proposes the game settings to 1..3
-- invitees; the game starts only when every invitee has accepted, and any decline
-- cancels the whole invitation. Lazily expired after expires_at (no background sweep).
-- game_id is set when the game is started.
CREATE TABLE game_invitations (
invitation_id uuid PRIMARY KEY,
inviter_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
variant text NOT NULL,
turn_timeout_secs integer NOT NULL,
hints_allowed boolean NOT NULL DEFAULT true,
hints_per_player smallint NOT NULL DEFAULT 1,
dropout_tiles text NOT NULL DEFAULT 'remove',
status text NOT NULL DEFAULT 'pending',
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('scrabble_en', 'scrabble_ru', 'erudit_ru')),
CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')),
CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')),
CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0),
CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0)
);
CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id);
-- One row per invitee (the inviter is implicit seat 0). seat is the invitee's seat in
-- the started game (1..3, in invitation order). response tracks each invitee's decision.
CREATE TABLE game_invitation_invitees (
invitation_id uuid NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
seat smallint NOT NULL,
response text NOT NULL DEFAULT 'pending',
responded_at timestamptz,
PRIMARY KEY (invitation_id, account_id),
CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')),
CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3)
);
CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id);
-- One-time friend codes. The player who wants to be added issues a 6-digit code;
-- whoever enters it becomes their friend. Only the SHA-256 hash is stored; expires_at
-- bounds the 12h TTL and consumed_at marks single use. At most one live code per issuer.
CREATE TABLE friend_codes (
code_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
code_hash text NOT NULL,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX friend_codes_account_idx ON friend_codes (account_id);
CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash);
-- Per-(game, account) draft the server persists across reloads and devices: the
-- player's preferred rack tile order and the tiles laid on the board but not yet
-- submitted. board_tiles is reset when an opponent's committed move overlaps a cell.
CREATE TABLE game_drafts (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
rack_order text NOT NULL DEFAULT '',
board_tiles jsonb NOT NULL DEFAULT '[]',
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (game_id, account_id)
);
-- Per-account hidden games. A row hides game_id from account_id's own "my games" list,
-- leaving it visible to the other players. Only finished games are hidden, and the
-- action is irreversible by design (there is no un-hide).
CREATE TABLE game_hidden (
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, game_id)
);
-- +goose Down
DROP SCHEMA IF EXISTS backend CASCADE;
@@ -1,60 +0,0 @@
-- +goose Up
-- Initial schema for the Scrabble backend service: durable accounts, their
-- platform/email identities, and opaque server sessions.
--
-- Every backend table lives in the `backend` schema. The schema is created here
-- so a fresh database can apply this migration, and search_path is pinned for
-- the rest of the migration so the CREATE statements land in `backend` without
-- qualifying every object. Production also pins search_path via
-- BACKEND_POSTGRES_DSN.
CREATE SCHEMA IF NOT EXISTS backend;
SET search_path = backend, pg_catalog;
-- Durable internal accounts. Guests are session-only and never reach this table.
CREATE TABLE accounts (
account_id uuid PRIMARY KEY,
display_name text NOT NULL DEFAULT '',
preferred_language text NOT NULL DEFAULT 'en',
time_zone text NOT NULL DEFAULT 'UTC',
block_chat boolean NOT NULL DEFAULT false,
block_friend_requests boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT accounts_preferred_language_chk CHECK (preferred_language IN ('en', 'ru'))
);
-- Platform and email identities attached to an account. external_id is the
-- platform user id (kind='telegram') or the email address (kind='email');
-- confirmed flips true once an email confirm-code is verified (later stages).
CREATE TABLE identities (
identity_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
kind text NOT NULL,
external_id text NOT NULL,
confirmed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email')),
CONSTRAINT identities_kind_external_id_key UNIQUE (kind, external_id)
);
CREATE INDEX identities_account_idx ON identities (account_id);
-- Opaque server sessions. token_hash is the hex-encoded SHA-256 of the bearer
-- token; the plaintext token is never stored. Sessions are revoke-only (no
-- TTL): status moves active -> revoked and revoked_at is stamped.
CREATE TABLE sessions (
session_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
token_hash text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz,
revoked_at timestamptz,
CONSTRAINT sessions_status_chk CHECK (status IN ('active', 'revoked')),
CONSTRAINT sessions_token_hash_key UNIQUE (token_hash)
);
CREATE INDEX sessions_account_idx ON sessions (account_id);
-- +goose Down
DROP TABLE sessions;
DROP TABLE identities;
DROP TABLE accounts;
@@ -1,133 +0,0 @@
-- +goose Up
-- Stage 3 game domain: the per-game lifecycle, the dictionary-independent move
-- journal, word-check complaints and per-account statistics, plus two account
-- columns the game domain needs.
SET search_path = backend, pg_catalog;
-- Extend accounts with the per-user away window (one interval per day, in the
-- account's local time_zone) honoured by the turn-timeout sweeper -- and, in a
-- later stage, by the robot's sleep -- and a hint wallet (purchasable hints; the
-- purchase flow lands later, so the balance defaults to empty). Profile editing
-- of the away window arrives with the profile surface (Stage 4).
ALTER TABLE accounts
ADD COLUMN away_start time NOT NULL DEFAULT '00:00',
ADD COLUMN away_end time NOT NULL DEFAULT '07:00',
ADD COLUMN hint_balance integer NOT NULL DEFAULT 0,
ADD CONSTRAINT accounts_hint_balance_chk CHECK (hint_balance >= 0);
-- One match. The live position is event-sourced: this row carries the pinned
-- dictionary, the bag seed and the denormalised turn cursor the sweeper needs,
-- while game_moves is the append-only journal the in-memory engine.Game is
-- replayed from (docs/ARCHITECTURE.md §9). turn_timeout_secs is the per-game move
-- clock; its allowed values are enforced in Go. variant uses engine.Variant's
-- stable labels.
CREATE TABLE games (
game_id uuid PRIMARY KEY,
variant text NOT NULL,
dict_version text NOT NULL,
seed bigint NOT NULL,
status text NOT NULL DEFAULT 'active',
players smallint NOT NULL,
to_move smallint NOT NULL DEFAULT 0,
turn_started_at timestamptz NOT NULL DEFAULT now(),
turn_timeout_secs integer NOT NULL,
hints_allowed boolean NOT NULL DEFAULT true,
hints_per_player smallint NOT NULL DEFAULT 1,
move_count integer NOT NULL DEFAULT 0,
end_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
CONSTRAINT games_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
CONSTRAINT games_status_chk CHECK (status IN ('active', 'finished')),
CONSTRAINT games_players_chk CHECK (players BETWEEN 2 AND 4),
CONSTRAINT games_to_move_chk CHECK (to_move >= 0 AND to_move < players),
CONSTRAINT games_turn_timeout_chk CHECK (turn_timeout_secs > 0),
CONSTRAINT games_hints_per_player_chk CHECK (hints_per_player >= 0),
CONSTRAINT games_end_reason_chk CHECK (
end_reason IS NULL OR end_reason IN ('out_of_tiles', 'scoreless', 'resign', 'timeout')
)
);
-- The sweeper scans active games oldest-turn-first; a partial index keeps it
-- off the finished archive.
CREATE INDEX games_active_idx ON games (turn_started_at) WHERE status = 'active';
-- Seats in turn order (seat 0 moves first), one row per player. account_id is a
-- durable account (guests and robots are revisited when they arrive). score is
-- the running/final score, is_winner is stamped on finish (false for every seat
-- on a draw), hints_used counts the per-game allowance consumed before the
-- profile wallet.
CREATE TABLE game_players (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seat smallint NOT NULL,
account_id uuid NOT NULL REFERENCES accounts (account_id),
score integer NOT NULL DEFAULT 0,
hints_used smallint NOT NULL DEFAULT 0,
is_winner boolean NOT NULL DEFAULT false,
PRIMARY KEY (game_id, seat),
CONSTRAINT game_players_account_key UNIQUE (game_id, account_id)
);
CREATE INDEX game_players_account_idx ON game_players (account_id);
-- The append-only, dictionary-independent move journal (docs/ARCHITECTURE.md
-- §9.1). seq orders the moves from 0. payload holds the decoded values needed to
-- both replay the game through the engine and emit GCG without a dictionary: the
-- acting rack, and for a play its direction, placed tiles and formed words; for
-- an exchange the swapped tiles. score / running_total / exchanged_count are
-- lifted out for cheap history rendering.
CREATE TABLE game_moves (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
seq integer NOT NULL,
seat smallint NOT NULL,
action text NOT NULL,
score integer NOT NULL DEFAULT 0,
running_total integer NOT NULL DEFAULT 0,
exchanged_count smallint NOT NULL DEFAULT 0,
payload text NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (game_id, seq),
CONSTRAINT game_moves_action_chk CHECK (action IN ('play', 'pass', 'exchange', 'resign', 'timeout'))
);
-- Word-check complaints captured in the context of a game's pinned dictionary.
-- The admin review queue and the resolution lifecycle land in Stage 9, which
-- owns the status state machine; Stage 3 only ever writes 'open'.
CREATE TABLE complaints (
complaint_id uuid PRIMARY KEY,
complainant_id uuid NOT NULL REFERENCES accounts (account_id),
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
variant text NOT NULL,
dict_version text NOT NULL,
word text NOT NULL,
was_valid boolean NOT NULL,
note text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'open',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX complaints_status_idx ON complaints (status);
-- Per-account lifetime statistics, recomputed incrementally on each game finish.
-- Guests have no durable account and never appear here. A draw increments draws
-- only (neither wins nor losses). max_word_points is the best single move score
-- (which already folds in every word the move formed and the all-tiles bonus).
CREATE TABLE account_stats (
account_id uuid PRIMARY KEY REFERENCES accounts (account_id) ON DELETE CASCADE,
wins integer NOT NULL DEFAULT 0,
losses integer NOT NULL DEFAULT 0,
draws integer NOT NULL DEFAULT 0,
max_game_points integer NOT NULL DEFAULT 0,
max_word_points integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE account_stats;
DROP TABLE complaints;
DROP TABLE game_moves;
DROP TABLE game_players;
DROP TABLE games;
ALTER TABLE accounts
DROP CONSTRAINT accounts_hint_balance_chk,
DROP COLUMN hint_balance,
DROP COLUMN away_end,
DROP COLUMN away_start;
@@ -1,136 +0,0 @@
-- +goose Up
-- Stage 4 lobby & social: the friend graph, per-user blocks, per-game chat (with
-- nudge folded in as a message kind), email confirm-codes, and friend-game
-- invitations -- plus the per-game drop-out tile disposition the multi-player
-- engine needs. Matchmaking is an in-memory pool and persists nothing.
SET search_path = backend, pg_catalog;
-- The disposition of a dropped-out player's tiles in a game with three or more
-- seats (docs/ARCHITECTURE.md §6), chosen at creation: 'remove' burns them
-- (default), 'return' puts them back in the bag. Moot for a two-player game,
-- which ends on the first drop-out. engine.DropoutTiles owns the stable labels.
ALTER TABLE games
ADD COLUMN dropout_tiles text NOT NULL DEFAULT 'remove',
ADD CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return'));
-- The friend graph. A row is created by the requester as 'pending' and flipped to
-- 'accepted' by the addressee; declining, cancelling or unfriending deletes the
-- row. Friendship is symmetric: a player's friends are the accepted rows in
-- either direction. A pair has at most one row (guarded in Go against either
-- direction existing).
CREATE TABLE friendships (
requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
addressee_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
responded_at timestamptz,
PRIMARY KEY (requester_id, addressee_id),
CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted')),
CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id)
);
CREATE INDEX friendships_addressee_idx ON friendships (addressee_id);
-- Per-user blocks. blocker_id has blocked blocked_id; the effect is applied
-- mutually by the social checks (a block in either direction suppresses chat
-- visibility and prevents requests/invitations between the pair).
CREATE TABLE blocks (
blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
blocked_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (blocker_id, blocked_id),
CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id)
);
CREATE INDEX blocks_blocked_idx ON blocks (blocked_id);
-- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty
-- body, so one journal carries both chatter and nudges. body is capped at 60
-- runes (enforced again in Go on input, where the content filter also rejects
-- links/emails/phone numbers). sender_ip holds the gateway-forwarded client IP as
-- a validated string (text, not inet, to avoid go-jet literal friction; the
-- gateway populates it in Stage 6). Chat is part of the game archive and is never
-- purged; it cascades away only with its game.
CREATE TABLE chat_messages (
message_id uuid PRIMARY KEY,
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
sender_id uuid NOT NULL REFERENCES accounts (account_id),
kind text NOT NULL DEFAULT 'message',
body text NOT NULL DEFAULT '',
sender_ip text,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')),
CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60),
CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '')
);
CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at);
-- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender).
CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at)
WHERE kind = 'nudge';
-- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the
-- 6-digit code (the plaintext is never stored, matching the session model);
-- expires_at bounds the TTL and attempts caps brute force. A row is consumed
-- (consumed_at stamped) on success. A re-request deletes the prior pending row
-- for the same (account, lowercased email) and inserts a fresh one.
CREATE TABLE email_confirmations (
confirmation_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
email text NOT NULL,
code_hash text NOT NULL,
expires_at timestamptz NOT NULL,
attempts smallint NOT NULL DEFAULT 0,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0)
);
CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id);
-- A friend-game invitation. The inviter (seat 0) proposes the game settings to
-- 1..3 invitees; the game starts only when every invitee has accepted, and any
-- decline cancels the whole invitation. Lazily expired after expires_at (no
-- background sweep). game_id is set when the game is started.
CREATE TABLE game_invitations (
invitation_id uuid PRIMARY KEY,
inviter_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
variant text NOT NULL,
turn_timeout_secs integer NOT NULL,
hints_allowed boolean NOT NULL DEFAULT true,
hints_per_player smallint NOT NULL DEFAULT 1,
dropout_tiles text NOT NULL DEFAULT 'remove',
status text NOT NULL DEFAULT 'pending',
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')),
CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')),
CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0),
CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0)
);
CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id);
-- One row per invitee (the inviter is implicit seat 0). seat is the invitee's
-- seat in the started game (1..3, in invitation order). response tracks each
-- invitee's pending/accepted/declined decision.
CREATE TABLE game_invitation_invitees (
invitation_id uuid NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
seat smallint NOT NULL,
response text NOT NULL DEFAULT 'pending',
responded_at timestamptz,
PRIMARY KEY (invitation_id, account_id),
CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')),
CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3)
);
CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id);
-- +goose Down
DROP TABLE game_invitation_invitees;
DROP TABLE game_invitations;
DROP TABLE email_confirmations;
DROP TABLE chat_messages;
DROP TABLE blocks;
DROP TABLE friendships;
ALTER TABLE games
DROP CONSTRAINT games_dropout_tiles_chk,
DROP COLUMN dropout_tiles;
@@ -1,15 +0,0 @@
-- +goose Up
-- Stage 5 robot opponent: admit a 'robot' identity kind so the robot pool can be
-- provisioned as durable accounts (one identity row per named robot). This widens
-- the identities kind CHECK only; no table or column changes, so the generated
-- jet code is unaffected.
SET search_path = backend, pg_catalog;
ALTER TABLE identities DROP CONSTRAINT identities_kind_chk;
ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email', 'robot'));
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE identities DROP CONSTRAINT identities_kind_chk;
ALTER TABLE identities ADD CONSTRAINT identities_kind_chk CHECK (kind IN ('telegram', 'email'));
@@ -1,14 +0,0 @@
-- +goose Up
-- Stage 6 gateway edge: mark ephemeral guest accounts. A guest is a durable
-- account row -- the sessions and game_players foreign keys both require one --
-- that carries no identity and no profile, friends, stats or history; is_guest
-- gates that exclusion (statistics recompute skips guest seats). This adds a
-- column, so the generated jet code is regenerated (cmd/jetgen).
SET search_path = backend, pg_catalog;
ALTER TABLE accounts ADD COLUMN is_guest boolean NOT NULL DEFAULT false;
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE accounts DROP COLUMN is_guest;
@@ -1,45 +0,0 @@
-- +goose Up
-- Stage 8 social UI: two changes to the friend graph.
--
-- 1. A declined friend request is now remembered permanently (status 'declined')
-- instead of deleting the row, so a recipient's explicit "no" blocks the same
-- requester from re-sending (anti-spam). An ignored request still lazily
-- expires (30 days, computed from created_at in Go) and can then be re-sent; a
-- one-time friend code from the same person bypasses a prior decline. This
-- widens friendships_status_chk; the Stage 4 "declining deletes the row" rule
-- is superseded (cancelling by the requester still deletes).
--
-- 2. friend_codes backs the code-redeem add-a-friend path: the player who wants to
-- be added issues a one-time 6-digit numeric code; whoever enters it becomes
-- their friend immediately. Only the hex-encoded SHA-256 of the code is stored
-- (the plaintext is never persisted, matching the session and email-code
-- models); expires_at bounds the 12h TTL and consumed_at marks single use. At
-- most one live code exists per issuer (issuing a new one clears the prior
-- unconsumed code, enforced in Go). This adds a table, so the generated jet code
-- is regenerated (cmd/jetgen).
SET search_path = backend, pg_catalog;
ALTER TABLE friendships
DROP CONSTRAINT friendships_status_chk,
ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined'));
CREATE TABLE friend_codes (
code_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
code_hash text NOT NULL,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Backs "clear the issuer's prior live code" on issue.
CREATE INDEX friend_codes_account_idx ON friend_codes (account_id);
-- Backs the redeem lookup by code hash.
CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE friend_codes;
ALTER TABLE friendships
DROP CONSTRAINT friendships_status_chk,
ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted'));
@@ -1,17 +0,0 @@
-- +goose Up
-- Stage 9 Telegram integration: a per-account toggle that confines notifications
-- to the in-app live stream. When notifications_in_app_only is true (the default),
-- the platform side-service (Telegram) sends no out-of-app push; turning it off
-- opts into out-of-app push, which the gateway delivers only while the account has
-- no live in-app stream, so the in-app and platform channels never duplicate. Adds
-- a column, so the generated jet code is regenerated (cmd/jetgen).
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
ADD COLUMN notifications_in_app_only boolean NOT NULL DEFAULT true;
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
DROP COLUMN notifications_in_app_only;
@@ -1,30 +0,0 @@
-- +goose Up
-- Stage 10 admin & dictionary ops: the word-check complaint resolution lifecycle.
-- Stage 3 created complaints with a free-form status (only ever 'open'); the admin
-- review queue (this stage) resolves them with a disposition that also feeds the
-- offline dictionary-rebuild pipeline: an accepted complaint records whether the
-- word should be added or removed, and is marked applied once a rebuilt dictionary
-- version is hot-reloaded. No operator identity is recorded (the gateway gates the
-- console behind Basic-Auth; the backend keeps no admin principal). Adds columns, so
-- the generated jet code is regenerated (cmd/jetgen).
SET search_path = backend, pg_catalog;
ALTER TABLE complaints
ADD COLUMN disposition text NOT NULL DEFAULT '',
ADD COLUMN resolution_note text NOT NULL DEFAULT '',
ADD COLUMN resolved_at timestamptz,
ADD COLUMN applied_in_version text NOT NULL DEFAULT '',
ADD CONSTRAINT complaints_status_chk CHECK (status IN ('open', 'resolved')),
ADD CONSTRAINT complaints_disposition_chk
CHECK (disposition IN ('', 'reject', 'accept_add', 'accept_remove'));
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE complaints
DROP CONSTRAINT complaints_disposition_chk,
DROP CONSTRAINT complaints_status_chk,
DROP COLUMN applied_in_version,
DROP COLUMN resolved_at,
DROP COLUMN resolution_note,
DROP COLUMN disposition;
@@ -1,24 +0,0 @@
-- +goose Up
-- Stage 11 account linking & merge: retire a secondary account into a primary one.
-- merged_into/merged_at turn the secondary into an audit tombstone (its identities
-- are repointed and its non-shared rows transferred to the primary, but the row is
-- kept so the no-cascade game_players/chat/complaints foreign keys of any shared
-- finished game stay valid). merged_into self-references accounts and is SET NULL on
-- delete so a future guest reaper (PLAN.md TODO-3) can still remove a primary.
-- paid_account is a forward-looking lifetime one-time-payment marker (no purchase
-- flow yet); the merge ORs it so a paid status is never lost. Adds columns, so the
-- generated jet code is regenerated (cmd/jetgen).
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
ADD COLUMN paid_account boolean NOT NULL DEFAULT false,
ADD COLUMN merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL,
ADD COLUMN merged_at timestamptz;
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
DROP COLUMN merged_at,
DROP COLUMN merged_into,
DROP COLUMN paid_account;
@@ -1,21 +0,0 @@
-- +goose Up
-- Stage 15 dual Telegram bots: service_language records the language tag of the bot
-- a Telegram user last authenticated through (their last ValidateInitData). It is
-- updated on every Telegram login — new and existing accounts — and routes the
-- user's out-of-app push back through the right bot. It is distinct from
-- preferred_language (the interface language) and from a game's variant language.
-- Nullable: an account that has never signed in through a tagged bot (legacy,
-- email-only or guest) has no value, and push routing falls back to
-- preferred_language. Adds a column, so the generated jet code is regenerated
-- (cmd/jetgen).
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
ADD COLUMN service_language text
CHECK (service_language IN ('en', 'ru'));
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
DROP COLUMN service_language;
@@ -1,21 +0,0 @@
-- +goose Up
-- Stage 17: a per-(game, account) draft the server persists across reloads and devices —
-- the player's preferred rack tile order (#4) and the tiles they have laid on the board but
-- not yet submitted (#5/#6). board_tiles is reset when an opponent's committed move overlaps
-- one of its cells (the draft can no longer be placed). Queried with raw SQL, so no
-- generated jet code is needed.
SET search_path = backend, pg_catalog;
CREATE TABLE game_drafts (
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
rack_order text NOT NULL DEFAULT '',
board_tiles jsonb NOT NULL DEFAULT '[]',
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (game_id, account_id)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE game_drafts;
@@ -1,18 +0,0 @@
-- +goose Up
-- Stage 17: per-account hidden games. A row hides game_id from account_id's own "my games"
-- lobby list, leaving it visible to the other players. Only finished games are hidden, and the
-- action is irreversible by design (there is no un-hide). Queried with raw SQL, so no generated
-- jet code is needed.
SET search_path = backend, pg_catalog;
CREATE TABLE game_hidden (
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, game_id)
);
-- +goose Down
SET search_path = backend, pg_catalog;
DROP TABLE game_hidden;
+6 -6
View File
@@ -75,14 +75,14 @@ func TestPickVariantRouting(t *testing.T) {
s := &Service{poolEN: []uuid.UUID{enID}, poolRU: []uuid.UUID{ruID}}
for i := 0; i < 200; i++ {
if got, err := s.Pick(engine.VariantEnglish); err != nil || got != enID {
t.Fatalf("english Pick = (%v, %v), want (%v, nil)", got, err, enID)
t.Fatalf("scrabble_en Pick = (%v, %v), want (%v, nil)", got, err, enID)
}
}
var en, ru int
for i := 0; i < 4000; i++ {
got, err := s.Pick(engine.VariantRussianScrabble)
if err != nil {
t.Fatalf("russian Pick: %v", err)
t.Fatalf("scrabble_ru Pick: %v", err)
}
switch got {
case enID:
@@ -92,14 +92,14 @@ func TestPickVariantRouting(t *testing.T) {
}
}
if ru <= en {
t.Errorf("russian names should dominate a Russian game: ru=%d en=%d", ru, en)
t.Errorf("scrabble_ru names should dominate a Russian game: ru=%d en=%d", ru, en)
}
if en == 0 {
t.Errorf("some Latin names should appear in Russian games (got 0 of 4000)")
}
// Эрудит routes like Russian Scrabble.
if _, err := s.Pick(engine.VariantErudit); err != nil {
t.Errorf("erudit Pick: %v", err)
t.Errorf("erudit_ru Pick: %v", err)
}
}
@@ -108,10 +108,10 @@ func TestPickVariantRouting(t *testing.T) {
func TestPickFallback(t *testing.T) {
id := uuid.New()
if got, err := (&Service{poolEN: []uuid.UUID{id}}).Pick(engine.VariantRussianScrabble); err != nil || got != id {
t.Errorf("russian fallback to EN = (%v, %v), want (%v, nil)", got, err, id)
t.Errorf("scrabble_ru fallback to EN = (%v, %v), want (%v, nil)", got, err, id)
}
if got, err := (&Service{poolRU: []uuid.UUID{id}}).Pick(engine.VariantEnglish); err != nil || got != id {
t.Errorf("english fallback to RU = (%v, %v), want (%v, nil)", got, err, id)
t.Errorf("scrabble_en fallback to RU = (%v, %v), want (%v, nil)", got, err, id)
}
if _, err := (&Service{}).Pick(engine.VariantEnglish); !errors.Is(err, ErrNoRobotAvailable) {
t.Errorf("empty pool err = %v, want ErrNoRobotAvailable", err)
+1 -1
View File
@@ -91,7 +91,7 @@ func TestGameDTOFromGame(t *testing.T) {
Seats: []game.Seat{{Seat: 0, AccountID: aid, Score: 12}},
}
dto := gameDTOFromGame(g)
if dto.ID != gid.String() || dto.Variant != "english" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 {
if dto.ID != gid.String() || dto.Variant != "scrabble_en" || dto.ToMove != 1 || dto.TurnTimeoutSecs != 86400 {
t.Fatalf("game dto mismatch: %+v", dto)
}
if len(dto.Seats) != 1 || dto.Seats[0].AccountID != aid.String() || dto.Seats[0].Score != 12 {