package storage import ( "context" "database/sql" "errors" "fmt" "log/slog" "strings" "time" "github.com/jackc/pgx/v5/pgconn" ) const gameSelectColumns = ` g.game_id, g.initial_fen, g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name, g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name, g.result, g.termination, g.start_time_utc, g.end_time_utc` // claimantName snapshots a claimant's username inside the writing // transaction; NULL when the slot is unclaimed or the account is gone. func claimantName(param string) string { return "(SELECT u.username FROM users u WHERE u.user_id = " + param + ")" } // MaxUserGamesPage bounds QueryGamesForUser; callers request one extra row to // detect a following page. const MaxUserGamesPage = 101 // RecordNewGame asynchronously records a new game. Terminal custom-FEN games // include their result in this insert rather than relying on a second write. // Claimed slots also record the claimant's current username. func (s *Store) RecordNewGame(record GameRecord) error { if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" { return errors.New("game ID, initial FEN, and player IDs are required") } if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil { return err } return s.enqueue("record_game", record.GameID, func(ctx context.Context, tx *sql.Tx) error { query := `INSERT INTO games ( game_id, initial_fen, white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name, black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name, start_time_utc, result, termination, end_time_utc ) VALUES ($1, $2, $3, $4, $5, $6, $7, ` + claimantName("$7") + `, $8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15, $16)` _, err := tx.ExecContext(ctx, query, record.GameID, record.InitialFEN, record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime, nullableString(record.WhiteClaimedBy), record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime, nullableString(record.BlackClaimedBy), record.StartTimeUTC, nullableString(record.Result), nullableString(record.Termination), record.EndTimeUTC, ) return err }) } // RecordMove atomically persists an accepted move and any first-move claim or // terminal result caused by that move. func (s *Store) RecordMove(record MovePersistence) error { if record.Move.GameID == "" || record.Move.MoveNumber < 1 || record.Move.MoveUCI == "" || record.Move.FENAfterMove == "" { return errors.New("move game ID, positive move number, UCI, and resulting FEN are required") } if record.Move.PlayerColor != "w" && record.Move.PlayerColor != "b" { return fmt.Errorf("invalid move color %q", record.Move.PlayerColor) } if record.ClaimColor != "" && record.ClaimColor != "w" && record.ClaimColor != "b" { return fmt.Errorf("invalid claim color %q", record.ClaimColor) } if (record.ClaimColor == "") != (record.ClaimedBy == "") { return errors.New("claim color and claimant must be provided together") } if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil { return err } return s.enqueue("record_move", record.Move.GameID, func(ctx context.Context, tx *sql.Tx) error { const insertMove = `INSERT INTO moves ( game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc ) VALUES ($1, $2, $3, $4, $5, $6)` if _, err := tx.ExecContext(ctx, insertMove, record.Move.GameID, record.Move.MoveNumber, record.Move.MoveUCI, record.Move.FENAfterMove, record.Move.PlayerColor, record.Move.MoveTimeUTC, ); err != nil { return gameMissingOr(err, record.Move.GameID) } if record.ClaimedBy == "" && record.Result == "" { return nil } return updateGameEnd(ctx, tx, record.Move.GameID, record.ClaimColor, record.ClaimedBy, record.Result, record.Termination, record.EndTimeUTC) }) } // GameEnd is a terminal transition not accompanied by a move: a resignation, // a draw by agreement, or an engine that found no legal move. ClaimColor and // ClaimedBy optionally record the acting user's claim of an empty slot. type GameEnd struct { GameID string Result string Termination string EndTimeUTC time.Time ClaimColor string ClaimedBy string } // RecordGameEnd persists the result, how it was reached, and any claim in one // transaction. func (s *Store) RecordGameEnd(end GameEnd) error { if end.GameID == "" { return errors.New("game ID is required") } if end.EndTimeUTC.IsZero() { return errors.New("game end time is required") } ended := end.EndTimeUTC.UTC() if err := validateOutcome(end.Result, end.Termination, &ended); err != nil { return err } if end.Result == "" { return errors.New("game result is required") } if end.ClaimColor != "" && end.ClaimColor != "w" && end.ClaimColor != "b" { return fmt.Errorf("invalid claim color %q", end.ClaimColor) } if (end.ClaimColor == "") != (end.ClaimedBy == "") { return errors.New("claim color and claimant must be provided together") } return s.enqueue("record_game_end", end.GameID, func(ctx context.Context, tx *sql.Tx) error { return updateGameEnd(ctx, tx, end.GameID, end.ClaimColor, end.ClaimedBy, end.Result, end.Termination, &ended) }) } // updateGameEnd applies an optional claim and an optional result in one // UPDATE. A claim may only fill an empty slot or repeat the same claimant. func updateGameEnd(ctx context.Context, tx *sql.Tx, gameID, claimColor, claimedBy, result, termination string, ended *time.Time) error { claimColumn, nameColumn := "white_claimed_by", "white_name" if claimColor == "b" { claimColumn, nameColumn = "black_claimed_by", "black_name" } var set []string args := []any{gameID} where := "game_id = $1" if claimedBy != "" { args = append(args, claimedBy) param := fmt.Sprintf("$%d", len(args)) set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)", claimColumn, param, nameColumn, nameColumn, claimantName(param))) where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param) } if result != "" { args = append(args, result, termination, ended) set = append(set, fmt.Sprintf("result = $%d, termination = $%d, end_time_utc = $%d", len(args)-2, len(args)-1, len(args))) } res, err := tx.ExecContext(ctx, "UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...) if err != nil { return err } return requireOneGame(ctx, tx, res, gameID) } // PlayerRecord is the persistence subset of a player configuration. type PlayerRecord struct { PlayerID string Type int Level int SearchTime int ClaimedBy string } // RecordPlayers keeps persisted player configuration aligned with in-memory // configuration changes. Claims are carried over unchanged by the service, so // their name snapshots are left as they are. func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error { if gameID == "" || white.PlayerID == "" || black.PlayerID == "" { return errors.New("game ID and player IDs are required") } return s.enqueue("record_players", gameID, func(ctx context.Context, tx *sql.Tx) error { const query = `UPDATE games SET white_player_id = $2, white_type = $3, white_level = $4, white_search_time = $5, white_claimed_by = $6, black_player_id = $7, black_type = $8, black_level = $9, black_search_time = $10, black_claimed_by = $11 WHERE game_id = $1` res, err := tx.ExecContext(ctx, query, gameID, white.PlayerID, white.Type, white.Level, white.SearchTime, nullableString(white.ClaimedBy), black.PlayerID, black.Type, black.Level, black.SearchTime, nullableString(black.ClaimedBy), ) if err != nil { return err } return requireOneGame(ctx, tx, res, gameID) }) } // RewindGame atomically removes undone moves and clears a previously terminal // result so replay readers never observe an ongoing line with a stale outcome. func (s *Store) RewindGame(gameID string, afterMoveNumber int) error { if gameID == "" || afterMoveNumber < 0 { return errors.New("game ID and a non-negative move number are required") } return s.enqueue("rewind_game", gameID, func(ctx context.Context, tx *sql.Tx) error { if _, err := tx.ExecContext(ctx, `DELETE FROM moves WHERE game_id = $1 AND move_number > $2`, gameID, afterMoveNumber, ); err != nil { return err } res, err := tx.ExecContext(ctx, `UPDATE games SET result = NULL, termination = NULL, end_time_utc = NULL WHERE game_id = $1`, gameID, ) if err != nil { return err } return requireOneGame(ctx, tx, res, gameID) }) } // DeleteAnonymousGames queues removal of games that no registered user // claimed and whose last activity (start, last move, or end) precedes cutoff. // Games still loaded in memory are listed in live and always kept, so a queued // or future write can never target a deleted row. Moves cascade. func (s *Store) DeleteAnonymousGames(cutoff time.Time, live []string) error { if cutoff.IsZero() { return errors.New("anonymous game cutoff is required") } if live == nil { live = []string{} } return s.enqueue("delete_anonymous_games", "", func(ctx context.Context, tx *sql.Tx) error { result, err := tx.ExecContext(ctx, `DELETE FROM games g WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL AND g.start_time_utc < $1 AND (g.end_time_utc IS NULL OR g.end_time_utc < $1) AND NOT EXISTS ( SELECT 1 FROM moves m WHERE m.game_id = g.game_id AND m.move_time_utc >= $1 ) AND NOT (g.game_id = ANY($2::uuid[]))`, cutoff.UTC(), live) if err != nil { return err } if deleted, err := result.RowsAffected(); err == nil && deleted > 0 { slog.Info("storage anonymous games deleted", "count", deleted, "inactive_before", cutoff.UTC()) } return nil }) } // QueryGames is the administrative game lookup. Either filter may be empty or // "*" for all; a player filter matches creation-time player IDs and claims. func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) { query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE true` var args []any if gameID != "" && gameID != "*" { if !validUUID(gameID) { return nil, fmt.Errorf("invalid game ID %q", gameID) } args = append(args, gameID) query += fmt.Sprintf(" AND g.game_id = $%d", len(args)) } if playerID != "" && playerID != "*" { if !validUUID(playerID) { return nil, fmt.Errorf("invalid player ID %q", playerID) } args = append(args, playerID) query += fmt.Sprintf(` AND $%d IN (g.white_player_id, g.black_player_id, g.white_claimed_by, g.black_claimed_by)`, len(args)) } query += " ORDER BY g.start_time_utc DESC, g.game_id DESC" if err := s.flushBeforeRead(); err != nil { return nil, err } ctx, cancel := opContext() defer cancel() rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query games: %w", err) } defer rows.Close() games := make([]GameRecord, 0) for rows.Next() { var record GameRecord if err := scanGame(rows, &record); err != nil { return nil, fmt.Errorf("scan game: %w", err) } games = append(games, record) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate games: %w", err) } return games, nil } // GetGameHistory returns the game row and its ordered moves after every // previously accepted write has committed. Both reads share one REPEATABLE READ // snapshot, so a concurrent move or rewind cannot produce a mixed history. func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error) { if !validUUID(gameID) { return nil, nil, sql.ErrNoRows } if err := s.flushBeforeRead(); err != nil { return nil, nil, err } started := time.Now() ctx, cancel := opContext() defer cancel() tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) if err != nil { return nil, nil, fmt.Errorf("begin game history read: %w", err) } defer tx.Rollback() var record GameRecord row := tx.QueryRowContext(ctx, `SELECT `+gameSelectColumns+` FROM games g WHERE g.game_id = $1`, gameID) if err := scanGame(row, &record); err != nil { return nil, nil, err } rows, err := tx.QueryContext(ctx, `SELECT game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc FROM moves WHERE game_id = $1 ORDER BY move_number`, gameID) if err != nil { return nil, nil, fmt.Errorf("query game moves: %w", err) } defer rows.Close() moves := make([]MoveRecord, 0) for rows.Next() { var move MoveRecord if err := rows.Scan( &move.GameID, &move.MoveNumber, &move.MoveUCI, &move.FENAfterMove, &move.PlayerColor, &move.MoveTimeUTC, ); err != nil { return nil, nil, fmt.Errorf("scan game move: %w", err) } move.MoveTimeUTC = move.MoveTimeUTC.UTC() moves = append(moves, move) } if err := rows.Err(); err != nil { return nil, nil, fmt.Errorf("iterate game moves: %w", err) } if err := tx.Commit(); err != nil { return nil, nil, fmt.Errorf("finish game history read: %w", err) } slog.Debug("storage game history queried", "game_id", gameID, "move_count", len(moves), "duration", time.Since(started)) return &record, moves, nil } // UserGamesQuery selects a page of a user's games, newest first. After, when // set, continues strictly below that (start time, game ID) key; Offset // remains for clients that page by position and must be 0 with After. type UserGamesQuery struct { Limit int Offset int After *GameCursor Color string // "" either, "w" or "b": the color the user claimed Status string // "" any, "ongoing", or "finished" } // GameCursor is a keyset position in a newest-first game listing. type GameCursor struct { StartTimeUTC time.Time GameID string } // QueryGamesForUser returns a page of games claimed by userID, newest first. // The last move is read through a LATERAL probe of the moves primary key, so // the summary costs one index lookup per game rather than a move count over // the whole line. func (s *Store) QueryGamesForUser(userID string, q UserGamesQuery) ([]GameSummaryRecord, error) { if userID == "" || q.Limit < 1 || q.Limit > MaxUserGamesPage || q.Offset < 0 { return nil, fmt.Errorf("user ID, limit from 1 to %d, and non-negative offset are required", MaxUserGamesPage) } if q.After != nil && (q.Offset != 0 || !validUUID(q.After.GameID)) { return nil, errors.New("a cursor requires a valid game ID and offset 0") } if !validUUID(userID) { return []GameSummaryRecord{}, nil } args := []any{userID, q.Limit, q.Offset} var where string switch q.Color { case "": where = "(g.white_claimed_by = $1 OR g.black_claimed_by = $1)" case "w": where = "g.white_claimed_by = $1" case "b": where = "g.black_claimed_by = $1" default: return nil, fmt.Errorf("invalid color filter %q", q.Color) } switch q.Status { case "": case "ongoing": where += " AND g.result IS NULL" case "finished": where += " AND g.result IS NOT NULL" default: return nil, fmt.Errorf("invalid status filter %q", q.Status) } if q.After != nil { args = append(args, q.After.StartTimeUTC, q.After.GameID) where += " AND (g.start_time_utc, g.game_id) < ($4, $5)" } if err := s.flushBeforeRead(); err != nil { return nil, err } started := time.Now() query := `SELECT ` + gameSelectColumns + `, COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen) FROM games g LEFT JOIN LATERAL ( SELECT m.move_number, m.fen_after_move FROM moves m WHERE m.game_id = g.game_id ORDER BY m.move_number DESC LIMIT 1 ) last ON true WHERE ` + where + ` ORDER BY g.start_time_utc DESC, g.game_id DESC LIMIT $2 OFFSET $3` ctx, cancel := opContext() defer cancel() rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query user games: %w", err) } defer rows.Close() games := make([]GameSummaryRecord, 0) for rows.Next() { var summary GameSummaryRecord if err := scanGame(rows, &summary.GameRecord, &summary.MoveCount, &summary.FinalFEN); err != nil { return nil, fmt.Errorf("scan user game: %w", err) } games = append(games, summary) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate user games: %w", err) } slog.Debug("storage user games queried", "user_id", userID, "count", len(games), "limit", q.Limit, "offset", q.Offset, "cursor", q.After != nil, "duration", time.Since(started), ) return games, nil } // ResolveGameID expands a game ID prefix of at least 8 hexadecimal digits, // as printed by administrative listings, to the unique full ID. func (s *Store) ResolveGameID(prefix string) (string, error) { prefix = strings.ToLower(prefix) if validUUID(prefix) { return prefix, nil } if len(prefix) < 8 || len(prefix) > 36 || strings.Trim(prefix, "0123456789abcdef-") != "" { return "", fmt.Errorf("game ID or a prefix of at least 8 hex digits required, got %q", prefix) } if err := s.flushBeforeRead(); err != nil { return "", err } ctx, cancel := opContext() defer cancel() rows, err := s.db.QueryContext(ctx, `SELECT game_id FROM games WHERE game_id::text LIKE $1 || '%' ORDER BY game_id LIMIT 2`, prefix) if err != nil { return "", fmt.Errorf("resolve game ID: %w", err) } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return "", fmt.Errorf("resolve game ID: %w", err) } ids = append(ids, id) } if err := rows.Err(); err != nil { return "", fmt.Errorf("resolve game ID: %w", err) } switch len(ids) { case 0: return "", sql.ErrNoRows case 1: return ids[0], nil default: return "", fmt.Errorf("game ID prefix %q is ambiguous", prefix) } } type rowScanner interface { Scan(dest ...any) error } // scanGame scans gameSelectColumns followed by any extra destinations. func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString var endTime sql.NullTime dest := []any{ &record.GameID, &record.InitialFEN, &record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, &whiteClaimed, &whiteName, &record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &blackClaimed, &blackName, &result, &termination, &record.StartTimeUTC, &endTime, } if err := scanner.Scan(append(dest, extra...)...); err != nil { return err } record.WhiteClaimedBy = whiteClaimed.String record.WhiteName = whiteName.String record.BlackClaimedBy = blackClaimed.String record.BlackName = blackName.String record.Result = result.String record.Termination = termination.String record.StartTimeUTC = record.StartTimeUTC.UTC() record.EndTimeUTC = utcPointer(endTime) return nil } // ErrGameMissing reports a write for a game whose row no longer exists, // typically deleted by hand while the server still held the game. The writer // drops such a write without degrading storage; see SetGameMissingHandler. var ErrGameMissing = errors.New("game row no longer exists") // requireOneGame checks that an UPDATE matched its game, telling a missing // row apart from a row whose guard (such as a claim) did not match. func requireOneGame(ctx context.Context, tx *sql.Tx, result sql.Result, gameID string) error { rows, err := result.RowsAffected() if err != nil { return err } if rows == 1 { return nil } var exists bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM games WHERE game_id = $1)`, gameID, ).Scan(&exists); err != nil { return err } if !exists { return fmt.Errorf("%w: %s", ErrGameMissing, gameID) } return fmt.Errorf("game %s was not updated", gameID) } // gameMissingOr maps the moves-to-games foreign-key violation, raised when a // move is inserted for a deleted game, to ErrGameMissing. func gameMissingOr(err error, gameID string) error { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23503" && pgErr.ConstraintName == "moves_game_id_fkey" { return fmt.Errorf("%w: %s", ErrGameMissing, gameID) } return err } func isValidResult(result string) bool { switch result { case "white_wins", "black_wins", "draw", "stalemate": return true default: return false } } // validTermination mirrors the games_termination_check constraint. func validTermination(result, termination string) bool { switch result { case "white_wins", "black_wins": return termination == "checkmate" || termination == "resignation" case "stalemate": return termination == "stalemate" case "draw": switch termination { case "insufficient_material", "threefold_repetition", "fifty_move_rule", "agreement": return true } } return false } // validateOutcome checks that a result, its termination, and the end time are // all present and consistent, or all absent. func validateOutcome(result, termination string, ended *time.Time) error { if result == "" { if termination != "" || ended != nil { return errors.New("termination and end time require a game result") } return nil } if !isValidResult(result) { return fmt.Errorf("invalid game result %q", result) } if !validTermination(result, termination) { return fmt.Errorf("termination %q cannot produce result %q", termination, result) } if ended == nil || ended.IsZero() { return errors.New("terminal game result requires an end time") } return nil } // IsGameNotFound keeps callers independent from database/sql details. func IsGameNotFound(err error) bool { return errors.Is(err, sql.ErrNoRows) }