220 lines
8.5 KiB
Go
220 lines
8.5 KiB
Go
package storage
|
|
|
|
import "time"
|
|
|
|
// UserRecord represents a user account in the database. Accounts created by
|
|
// public registration and by the CLI are identical and do not expire.
|
|
type UserRecord struct {
|
|
UserID string
|
|
Username string
|
|
Email string // empty when the account has no email (stored as NULL)
|
|
PasswordHash string
|
|
CreatedAt time.Time
|
|
LastLoginAt *time.Time
|
|
}
|
|
|
|
// SessionRecord represents an active user session
|
|
type SessionRecord struct {
|
|
SessionID string
|
|
UserID string
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// GameRecord represents a row in the games table
|
|
type GameRecord struct {
|
|
GameID string
|
|
InitialFEN string
|
|
WhitePlayerID string
|
|
WhiteType int
|
|
WhiteLevel int
|
|
WhiteSearchTime int
|
|
WhiteClaimedBy string
|
|
WhiteName string // claimant's username when the claim was recorded
|
|
BlackPlayerID string
|
|
BlackType int
|
|
BlackLevel int
|
|
BlackSearchTime int
|
|
BlackClaimedBy string
|
|
BlackName string
|
|
Result string
|
|
Termination string // how Result was reached; empty while unfinished
|
|
StartTimeUTC time.Time
|
|
EndTimeUTC *time.Time
|
|
// The first resignation or agreed draw, kept when an undo continues play;
|
|
// ConcessionResult is empty when there was none.
|
|
ConcessionResult string
|
|
ConcessionTermination string
|
|
ConcessionPly int
|
|
ConcessionTimeUTC *time.Time
|
|
}
|
|
|
|
// GameSummaryRecord is a game row plus its replay extent. MoveCount and
|
|
// FinalFEN come from the last move, so a picker needs no move-list read.
|
|
type GameSummaryRecord struct {
|
|
GameRecord
|
|
MoveCount int
|
|
FinalFEN string
|
|
}
|
|
|
|
// MoveRecord represents a row in the moves table
|
|
type MoveRecord struct {
|
|
GameID string
|
|
MoveNumber int
|
|
MoveUCI string
|
|
FENAfterMove string
|
|
PlayerColor string
|
|
MoveTimeUTC time.Time
|
|
}
|
|
|
|
// MovePersistence groups changes caused by one accepted move so the move,
|
|
// first-move slot claim, and terminal result commit in one transaction.
|
|
type MovePersistence struct {
|
|
Move MoveRecord
|
|
ClaimColor string
|
|
ClaimedBy string
|
|
Result string
|
|
Termination string
|
|
EndTimeUTC *time.Time
|
|
}
|
|
|
|
// schemaVersion is the newest schema this binary understands. Migrations are
|
|
// applied in order inside one transaction; PostgreSQL DDL is transactional, so
|
|
// a failed upgrade leaves the previous version intact.
|
|
const schemaVersion = 3
|
|
|
|
// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
|
|
// released migration; append a new one.
|
|
var migrations = []string{
|
|
// v1: initial PostgreSQL schema.
|
|
//
|
|
// Identifiers are uuid (16 bytes, validated on input). Usernames and emails
|
|
// are stored lowercase, so plain unique constraints give case-insensitive
|
|
// uniqueness without citext or a nondeterministic collation. Game-to-user
|
|
// association is by claim: every authenticated human slot records its
|
|
// claimant, and claims survive player reconfiguration. The claimant's
|
|
// username is copied into the game when the claim is written, so replays
|
|
// and PGN keep the name after a rename or account deletion. Games with no
|
|
// claim belong to anonymous players and are purged after inactivity.
|
|
`
|
|
CREATE TABLE schema_version (
|
|
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
version integer NOT NULL CHECK (version > 0),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE users (
|
|
user_id uuid PRIMARY KEY,
|
|
username text NOT NULL,
|
|
email text,
|
|
password_hash text NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
last_login_at timestamptz,
|
|
CONSTRAINT users_username_key UNIQUE (username),
|
|
CONSTRAINT users_email_key UNIQUE (email),
|
|
CONSTRAINT users_username_check
|
|
CHECK (username = lower(username) AND char_length(username) BETWEEN 1 AND 64),
|
|
CONSTRAINT users_email_check
|
|
CHECK (email = lower(email) AND char_length(email) BETWEEN 3 AND 254)
|
|
);
|
|
|
|
CREATE TABLE sessions (
|
|
session_id uuid PRIMARY KEY,
|
|
user_id uuid NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
expires_at timestamptz NOT NULL,
|
|
CONSTRAINT sessions_user_id_key UNIQUE (user_id)
|
|
);
|
|
|
|
CREATE TABLE games (
|
|
game_id uuid PRIMARY KEY,
|
|
initial_fen text NOT NULL CHECK (initial_fen <> ''),
|
|
white_player_id uuid NOT NULL,
|
|
white_type smallint NOT NULL CHECK (white_type IN (1, 2)),
|
|
white_level smallint NOT NULL DEFAULT 0 CHECK (white_level BETWEEN 0 AND 20),
|
|
white_search_time integer NOT NULL DEFAULT 0 CHECK (white_search_time >= 0),
|
|
white_claimed_by uuid,
|
|
white_name text CHECK (char_length(white_name) BETWEEN 1 AND 64),
|
|
black_player_id uuid NOT NULL,
|
|
black_type smallint NOT NULL CHECK (black_type IN (1, 2)),
|
|
black_level smallint NOT NULL DEFAULT 0 CHECK (black_level BETWEEN 0 AND 20),
|
|
black_search_time integer NOT NULL DEFAULT 0 CHECK (black_search_time >= 0),
|
|
black_claimed_by uuid,
|
|
black_name text CHECK (char_length(black_name) BETWEEN 1 AND 64),
|
|
start_time_utc timestamptz NOT NULL DEFAULT now(),
|
|
result text CHECK (result IN ('white_wins', 'black_wins', 'draw', 'stalemate')),
|
|
end_time_utc timestamptz,
|
|
CONSTRAINT games_result_end_time_check CHECK ((result IS NULL) = (end_time_utc IS NULL))
|
|
);
|
|
|
|
-- Moves are numbered 1..n without gaps; the primary key serves ordered replay
|
|
-- reads and the last-move probe used by game listings.
|
|
CREATE TABLE moves (
|
|
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
|
move_number integer NOT NULL CHECK (move_number > 0),
|
|
move_uci text NOT NULL CHECK (move_uci ~ '^[a-h][1-8][a-h][1-8][qrbn]?$'),
|
|
fen_after_move text NOT NULL CHECK (fen_after_move <> ''),
|
|
player_color text NOT NULL CHECK (player_color IN ('w', 'b')),
|
|
move_time_utc timestamptz NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (game_id, move_number)
|
|
);
|
|
|
|
CREATE INDEX sessions_expires_at_idx ON sessions (expires_at);
|
|
-- Candidates for the anonymous-game purge: a game that started after the
|
|
-- cutoff cannot have been idle since it.
|
|
CREATE INDEX games_anonymous_start_idx ON games (start_time_utc)
|
|
WHERE white_claimed_by IS NULL AND black_claimed_by IS NULL;
|
|
CREATE INDEX games_white_claimed_idx ON games (white_claimed_by, start_time_utc DESC, game_id DESC)
|
|
WHERE white_claimed_by IS NOT NULL;
|
|
CREATE INDEX games_black_claimed_idx ON games (black_claimed_by, start_time_utc DESC, game_id DESC)
|
|
WHERE black_claimed_by IS NOT NULL;
|
|
`,
|
|
|
|
// v2: how a result was reached. Results before v2 came only from
|
|
// checkmate and stalemate (the server never produced "draw"), so the
|
|
// backfill is exact for them; a hand-written draw becomes "agreement".
|
|
`
|
|
ALTER TABLE games ADD COLUMN termination text;
|
|
UPDATE games SET termination = CASE result
|
|
WHEN 'stalemate' THEN 'stalemate'
|
|
WHEN 'draw' THEN 'agreement'
|
|
ELSE 'checkmate' END
|
|
WHERE result IS NOT NULL;
|
|
ALTER TABLE games
|
|
ADD CONSTRAINT games_result_termination_check CHECK ((result IS NULL) = (termination IS NULL)),
|
|
ADD CONSTRAINT games_termination_check CHECK (
|
|
(result IN ('white_wins', 'black_wins') AND termination IN ('checkmate', 'resignation'))
|
|
OR (result = 'stalemate' AND termination = 'stalemate')
|
|
OR (result = 'draw' AND termination IN
|
|
('insufficient_material', 'threefold_repetition', 'fifty_move_rule', 'agreement')));
|
|
`,
|
|
|
|
// v3: the first resignation or agreed draw. An undo against the computer
|
|
// may reopen such a game, which clears result and termination; these
|
|
// columns keep the concession on record. Ply is the number of moves
|
|
// played when it was made. Existing concessions are backfilled from the
|
|
// current result and move count.
|
|
`
|
|
ALTER TABLE games
|
|
ADD COLUMN concession_result text,
|
|
ADD COLUMN concession_termination text,
|
|
ADD COLUMN concession_ply integer,
|
|
ADD COLUMN concession_time_utc timestamptz;
|
|
UPDATE games g SET
|
|
concession_result = g.result,
|
|
concession_termination = g.termination,
|
|
concession_ply = (SELECT count(*) FROM moves m WHERE m.game_id = g.game_id),
|
|
concession_time_utc = g.end_time_utc
|
|
WHERE g.termination IN ('resignation', 'agreement');
|
|
ALTER TABLE games ADD CONSTRAINT games_concession_check CHECK (
|
|
(concession_result IS NULL AND concession_termination IS NULL
|
|
AND concession_ply IS NULL AND concession_time_utc IS NULL)
|
|
OR (concession_ply >= 0 AND concession_time_utc IS NOT NULL AND (
|
|
(concession_result IN ('white_wins', 'black_wins') AND concession_termination = 'resignation')
|
|
OR (concession_result = 'draw' AND concession_termination = 'agreement'))));
|
|
`,
|
|
}
|
|
|
|
// ownedTables lists every table created by migrations, children first.
|
|
var ownedTables = []string{"moves", "games", "sessions", "users", "schema_version"}
|