v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
5d2be4abb2
commit
d0326b059e
66 files changed
+4016
-2433
No files matched your search
+204
-214
@@ -6,17 +6,29 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by,
|
||||
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.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")
|
||||
@@ -25,15 +37,16 @@ func (s *Store) RecordNewGame(record GameRecord) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.enqueue("record_game", record.GameID, func(tx *sql.Tx) error {
|
||||
const query = `INSERT INTO games (
|
||||
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,
|
||||
black_player_id, black_type, black_level, black_search_time, black_claimed_by,
|
||||
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, end_time_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, ` + claimantName("$7") + `,
|
||||
$8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15)`
|
||||
|
||||
_, err := tx.Exec(query,
|
||||
_, err := tx.ExecContext(ctx, query,
|
||||
record.GameID, record.InitialFEN,
|
||||
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
|
||||
nullableString(record.WhiteClaimedBy),
|
||||
@@ -65,11 +78,11 @@ func (s *Store) RecordMove(record MovePersistence) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.enqueue("record_move", record.Move.GameID, func(tx *sql.Tx) error {
|
||||
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 (?, ?, ?, ?, ?, ?)`
|
||||
if _, err := tx.Exec(insertMove,
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)`
|
||||
if _, err := tx.ExecContext(ctx, insertMove,
|
||||
record.Move.GameID,
|
||||
record.Move.MoveNumber,
|
||||
record.Move.MoveUCI,
|
||||
@@ -80,33 +93,35 @@ func (s *Store) RecordMove(record MovePersistence) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if record.ClaimedBy == "" && record.Result == "" {
|
||||
return nil
|
||||
}
|
||||
// One UPDATE applies the optional claim and optional result. A claim may
|
||||
// only fill an empty slot or repeat the same claimant.
|
||||
claimColumn, nameColumn := "white_claimed_by", "white_name"
|
||||
if record.ClaimColor == "b" {
|
||||
claimColumn, nameColumn = "black_claimed_by", "black_name"
|
||||
}
|
||||
var set []string
|
||||
args := []any{record.Move.GameID}
|
||||
where := "game_id = $1"
|
||||
if record.ClaimedBy != "" {
|
||||
column := "white_claimed_by"
|
||||
if record.ClaimColor == "b" {
|
||||
column = "black_claimed_by"
|
||||
}
|
||||
query := `UPDATE games SET ` + column + ` = ?
|
||||
WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)`
|
||||
result, err := tx.Exec(query, record.ClaimedBy, record.Move.GameID, record.ClaimedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireOneGame(result, record.Move.GameID); err != nil {
|
||||
return err
|
||||
}
|
||||
args = append(args, record.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 record.Result != "" {
|
||||
result, err := tx.Exec(
|
||||
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`,
|
||||
record.Result, record.EndTimeUTC, record.Move.GameID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(result, record.Move.GameID)
|
||||
args = append(args, record.Result, record.EndTimeUTC)
|
||||
set = append(set, fmt.Sprintf("result = $%d, end_time_utc = $%d", len(args)-1, len(args)))
|
||||
}
|
||||
return nil
|
||||
result, err := tx.ExecContext(ctx,
|
||||
"UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(result, record.Move.GameID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,56 +137,10 @@ func (s *Store) RecordGameResult(gameID, result string, at time.Time) error {
|
||||
if at.IsZero() {
|
||||
return errors.New("game result time is required")
|
||||
}
|
||||
return s.enqueue("record_game_result", gameID, func(tx *sql.Tx) error {
|
||||
res, err := tx.Exec(
|
||||
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`,
|
||||
result, at.UTC(), gameID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// RecordSlotClaim persists a claim made independently from a move.
|
||||
func (s *Store) RecordSlotClaim(gameID, color, userID string) error {
|
||||
if gameID == "" || userID == "" {
|
||||
return errors.New("game ID and claimant are required")
|
||||
}
|
||||
if color != "w" && color != "b" {
|
||||
return fmt.Errorf("invalid claim color %q", color)
|
||||
}
|
||||
column := "white_claimed_by"
|
||||
if color == "b" {
|
||||
column = "black_claimed_by"
|
||||
}
|
||||
return s.enqueue("record_slot_claim", gameID, func(tx *sql.Tx) error {
|
||||
query := `UPDATE games SET ` + column + ` = ?
|
||||
WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)`
|
||||
res, err := tx.Exec(query, userID, gameID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// RecordPlayers keeps persisted player configuration aligned with in-memory
|
||||
// configuration changes.
|
||||
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(tx *sql.Tx) error {
|
||||
const query = `UPDATE games SET
|
||||
white_player_id = ?, white_type = ?, white_level = ?, white_search_time = ?, white_claimed_by = ?,
|
||||
black_player_id = ?, black_type = ?, black_level = ?, black_search_time = ?, black_claimed_by = ?
|
||||
WHERE game_id = ?`
|
||||
res, err := tx.Exec(query,
|
||||
white.PlayerID, white.Type, white.Level, white.SearchTime, nullableString(white.ClaimedBy),
|
||||
black.PlayerID, black.Type, black.Level, black.SearchTime, nullableString(black.ClaimedBy),
|
||||
gameID,
|
||||
return s.enqueue("record_game_result", gameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE games SET result = $2, end_time_utc = $3 WHERE game_id = $1`,
|
||||
gameID, result, at.UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -189,21 +158,44 @@ type PlayerRecord struct {
|
||||
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(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(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM moves WHERE game_id = ? AND move_number > ?`,
|
||||
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.Exec(
|
||||
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = ?`,
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = $1`,
|
||||
gameID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -213,28 +205,66 @@ func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
|
||||
})
|
||||
}
|
||||
|
||||
// QueryGames retrieves games with optional filtering. A player filter matches
|
||||
// both creation-time player IDs and claims made after game creation.
|
||||
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
// 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")
|
||||
}
|
||||
started := time.Now()
|
||||
query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE 1=1`
|
||||
var args []any
|
||||
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 != "*" {
|
||||
query += " AND g.game_id = ?"
|
||||
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 != "*" {
|
||||
query += ` AND (g.white_player_id = ? OR g.black_player_id = ?
|
||||
OR g.white_claimed_by = ? OR g.black_claimed_by = ?)`
|
||||
args = append(args, playerID, playerID, 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"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
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)
|
||||
}
|
||||
@@ -251,111 +281,95 @@ func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate games: %w", err)
|
||||
}
|
||||
slog.Debug("storage games queried", "count", len(games), "duration", time.Since(started))
|
||||
return games, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetGameRecord(gameID string) (*GameRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return getGameRecord(s.db, gameID)
|
||||
}
|
||||
|
||||
type gameQueryer interface {
|
||||
Query(query string, args ...any) (*sql.Rows, error)
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
func getGameRecord(queryer gameQueryer, gameID string) (*GameRecord, error) {
|
||||
var record GameRecord
|
||||
row := queryer.QueryRow(`SELECT `+gameSelectColumns+` FROM games g WHERE g.game_id = ?`, gameID)
|
||||
if err := scanGame(row, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// GetMovesForGame returns the complete, undo-consistent replay line.
|
||||
func (s *Store) GetMovesForGame(gameID string) ([]MoveRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return getMovesForGame(s.db, gameID)
|
||||
}
|
||||
|
||||
func getMovesForGame(queryer gameQueryer, gameID string) ([]MoveRecord, error) {
|
||||
const query = `SELECT move_id, game_id, move_number, move_uci,
|
||||
fen_after_move, player_color, move_time_utc
|
||||
FROM moves WHERE game_id = ? ORDER BY move_number ASC`
|
||||
rows, err := queryer.Query(query, gameID)
|
||||
if err != nil {
|
||||
return 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.MoveID, &move.GameID, &move.MoveNumber, &move.MoveUCI,
|
||||
&move.FENAfterMove, &move.PlayerColor, &move.MoveTimeUTC,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan game move: %w", err)
|
||||
}
|
||||
moves = append(moves, move)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate game moves: %w", err)
|
||||
}
|
||||
return moves, nil
|
||||
}
|
||||
|
||||
// GetGameHistory uses one write barrier and one read transaction for a
|
||||
// consistent game-and-moves snapshot.
|
||||
// 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()
|
||||
tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
||||
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()
|
||||
|
||||
record, err := getGameRecord(tx, gameID)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
moves, err := getMovesForGame(tx, gameID)
|
||||
|
||||
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, err
|
||||
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
|
||||
return &record, moves, nil
|
||||
}
|
||||
|
||||
// QueryGamesForUser returns a page of games claimed by userID in either color,
|
||||
// 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, limit, offset int) ([]GameSummaryRecord, error) {
|
||||
if userID == "" || limit < 1 || limit > 101 || offset < 0 {
|
||||
return nil, errors.New("user ID, limit from 1 to 101, and non-negative offset are required")
|
||||
if userID == "" || limit < 1 || limit > MaxUserGamesPage || offset < 0 {
|
||||
return nil, fmt.Errorf("user ID, limit from 1 to %d, and non-negative offset are required",
|
||||
MaxUserGamesPage)
|
||||
}
|
||||
if !validUUID(userID) {
|
||||
return []GameSummaryRecord{}, nil
|
||||
}
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
started := time.Now()
|
||||
query := `SELECT ` + gameSelectColumns + `,
|
||||
(SELECT COUNT(*) FROM moves m WHERE m.game_id = g.game_id) AS move_count
|
||||
const query = `SELECT ` + gameSelectColumns + `,
|
||||
COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen)
|
||||
FROM games g
|
||||
WHERE g.white_player_id = ? OR g.black_player_id = ?
|
||||
OR g.white_claimed_by = ? OR g.black_claimed_by = ?
|
||||
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 g.white_claimed_by = $1 OR g.black_claimed_by = $1
|
||||
ORDER BY g.start_time_utc DESC, g.game_id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
rows, err := s.db.Query(query, userID, userID, userID, userID, limit, offset)
|
||||
LIMIT $2 OFFSET $3`
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
rows, err := s.db.QueryContext(ctx, query, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query user games: %w", err)
|
||||
}
|
||||
@@ -364,7 +378,7 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma
|
||||
games := make([]GameSummaryRecord, 0)
|
||||
for rows.Next() {
|
||||
var summary GameSummaryRecord
|
||||
if err := scanGameSummary(rows, &summary); err != nil {
|
||||
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)
|
||||
@@ -386,45 +400,28 @@ type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanGame(scanner rowScanner, record *GameRecord) error {
|
||||
var whiteClaimed, blackClaimed, result sql.NullString
|
||||
// scanGame scans gameSelectColumns followed by any extra destinations.
|
||||
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
var whiteClaimed, whiteName, blackClaimed, blackName, result sql.NullString
|
||||
var endTime sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
dest := []any{
|
||||
&record.GameID, &record.InitialFEN,
|
||||
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, &whiteClaimed,
|
||||
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &blackClaimed,
|
||||
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime,
|
||||
&whiteClaimed, &whiteName,
|
||||
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
|
||||
&blackClaimed, &blackName,
|
||||
&result, &record.StartTimeUTC, &endTime,
|
||||
); err != nil {
|
||||
}
|
||||
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
|
||||
if endTime.Valid {
|
||||
ended := endTime.Time
|
||||
record.EndTimeUTC = &ended
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanGameSummary(scanner rowScanner, summary *GameSummaryRecord) error {
|
||||
var whiteClaimed, blackClaimed, result sql.NullString
|
||||
var endTime sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
&summary.GameID, &summary.InitialFEN,
|
||||
&summary.WhitePlayerID, &summary.WhiteType, &summary.WhiteLevel, &summary.WhiteSearchTime, &whiteClaimed,
|
||||
&summary.BlackPlayerID, &summary.BlackType, &summary.BlackLevel, &summary.BlackSearchTime, &blackClaimed,
|
||||
&result, &summary.StartTimeUTC, &endTime, &summary.MoveCount,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
summary.WhiteClaimedBy = whiteClaimed.String
|
||||
summary.BlackClaimedBy = blackClaimed.String
|
||||
summary.Result = result.String
|
||||
if endTime.Valid {
|
||||
ended := endTime.Time
|
||||
summary.EndTimeUTC = &ended
|
||||
}
|
||||
record.StartTimeUTC = record.StartTimeUTC.UTC()
|
||||
record.EndTimeUTC = utcPointer(endTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -439,13 +436,6 @@ func requireOneGame(result sql.Result, gameID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isValidResult(result string) bool {
|
||||
switch result {
|
||||
case "white_wins", "black_wins", "draw", "stalemate":
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package pgtest provisions isolated PostgreSQL schemas for tests.
|
||||
//
|
||||
// Set CHESS_TEST_DSN to a database the test role may create schemas in, e.g.
|
||||
//
|
||||
// CHESS_TEST_DSN='postgres://chess_test:secret@127.0.0.1:5432/chess_test?sslmode=disable'
|
||||
//
|
||||
// Tests that need a database are skipped when it is unset.
|
||||
package pgtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// EnvDSN names the environment variable holding the base test DSN.
|
||||
const EnvDSN = "CHESS_TEST_DSN"
|
||||
|
||||
// DSN creates an empty schema, registers its removal as test cleanup, and
|
||||
// returns the base DSN with search_path pinned to that schema.
|
||||
func DSN(t testing.TB) string {
|
||||
t.Helper()
|
||||
base := os.Getenv(EnvDSN)
|
||||
if base == "" {
|
||||
t.Skipf("%s is not set; skipping PostgreSQL test", EnvDSN)
|
||||
}
|
||||
|
||||
suffix := make([]byte, 6)
|
||||
rand.Read(suffix)
|
||||
schema := "chess_test_" + hex.EncodeToString(suffix)
|
||||
|
||||
admin, err := sql.Open("pgx", base)
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "CREATE SCHEMA "+schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create test schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
defer admin.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
|
||||
t.Errorf("drop test schema %s: %v", schema, err)
|
||||
}
|
||||
})
|
||||
return WithParam(base, "search_path", schema)
|
||||
}
|
||||
|
||||
// WithParam adds a runtime parameter to a URL or keyword/value DSN.
|
||||
func WithParam(dsn, key, value string) string {
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
u, err := url.Parse(dsn)
|
||||
if err == nil {
|
||||
query := u.Query()
|
||||
query.Set(key, value)
|
||||
u.RawQuery = query.Encode()
|
||||
return u.String()
|
||||
}
|
||||
}
|
||||
return dsn + " " + key + "=" + value
|
||||
}
|
||||
@@ -2,59 +2,62 @@ package storage
|
||||
|
||||
import "time"
|
||||
|
||||
// UserRecord represents a user account in the database
|
||||
// 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 `db:"user_id"`
|
||||
Username string `db:"username"`
|
||||
Email string `db:"email"`
|
||||
PasswordHash string `db:"password_hash"`
|
||||
AccountType string `db:"account_type"` // "permanent" or "temp"
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt *time.Time `db:"expires_at"` // nil for permanent
|
||||
LastLoginAt *time.Time `db:"last_login_at"`
|
||||
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 `db:"session_id"`
|
||||
UserID string `db:"user_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
SessionID string
|
||||
UserID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// GameRecord represents a row in the games table
|
||||
type GameRecord struct {
|
||||
GameID string `db:"game_id"`
|
||||
InitialFEN string `db:"initial_fen"`
|
||||
WhitePlayerID string `db:"white_player_id"`
|
||||
WhiteType int `db:"white_type"`
|
||||
WhiteLevel int `db:"white_level"`
|
||||
WhiteSearchTime int `db:"white_search_time"`
|
||||
WhiteClaimedBy string `db:"white_claimed_by"`
|
||||
BlackPlayerID string `db:"black_player_id"`
|
||||
BlackType int `db:"black_type"`
|
||||
BlackLevel int `db:"black_level"`
|
||||
BlackSearchTime int `db:"black_search_time"`
|
||||
BlackClaimedBy string `db:"black_claimed_by"`
|
||||
Result string `db:"result"`
|
||||
StartTimeUTC time.Time `db:"start_time_utc"`
|
||||
EndTimeUTC *time.Time `db:"end_time_utc"`
|
||||
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
|
||||
StartTimeUTC time.Time
|
||||
EndTimeUTC *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 `db:"move_count"`
|
||||
MoveCount int
|
||||
FinalFEN string
|
||||
}
|
||||
|
||||
// MoveRecord represents a row in the moves table
|
||||
type MoveRecord struct {
|
||||
MoveID int64 `db:"move_id"`
|
||||
GameID string `db:"game_id"`
|
||||
MoveNumber int `db:"move_number"`
|
||||
MoveUCI string `db:"move_uci"`
|
||||
FENAfterMove string `db:"fen_after_move"`
|
||||
PlayerColor string `db:"player_color"`
|
||||
MoveTimeUTC time.Time `db:"move_time_utc"`
|
||||
GameID string
|
||||
MoveNumber int
|
||||
MoveUCI string
|
||||
FENAfterMove string
|
||||
PlayerColor string
|
||||
MoveTimeUTC time.Time
|
||||
}
|
||||
|
||||
// MovePersistence groups changes caused by one accepted move so the move,
|
||||
@@ -67,72 +70,98 @@ type MovePersistence struct {
|
||||
EndTimeUTC *time.Time
|
||||
}
|
||||
|
||||
// Schema defines tables only. Indexes are applied after legacy column
|
||||
// migrations so upgrading an older games table never references a missing
|
||||
// column.
|
||||
const Schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||
email TEXT COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
account_type TEXT NOT NULL DEFAULT 'temp' CHECK(account_type IN ('permanent', 'temp')),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME,
|
||||
last_login_at DATETIME
|
||||
// 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 = 1
|
||||
|
||||
// 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 IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
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 IF NOT EXISTS games (
|
||||
game_id TEXT PRIMARY KEY,
|
||||
initial_fen TEXT NOT NULL,
|
||||
white_player_id TEXT NOT NULL,
|
||||
white_type INTEGER NOT NULL,
|
||||
white_level INTEGER NOT NULL DEFAULT 0,
|
||||
white_search_time INTEGER NOT NULL DEFAULT 1000,
|
||||
black_player_id TEXT NOT NULL,
|
||||
black_type INTEGER NOT NULL,
|
||||
black_level INTEGER NOT NULL DEFAULT 0,
|
||||
black_search_time INTEGER NOT NULL DEFAULT 1000,
|
||||
start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
result TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate')),
|
||||
end_time_utc DATETIME,
|
||||
white_claimed_by TEXT,
|
||||
black_claimed_by TEXT
|
||||
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 IF NOT EXISTS moves (
|
||||
move_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id TEXT NOT NULL,
|
||||
move_number INTEGER NOT NULL,
|
||||
move_uci TEXT NOT NULL,
|
||||
fen_after_move TEXT NOT NULL,
|
||||
player_color TEXT NOT NULL CHECK(player_color IN ('w', 'b')),
|
||||
move_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
UNIQUE(game_id, move_number)
|
||||
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))
|
||||
);
|
||||
`
|
||||
|
||||
const Indexes = `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
|
||||
ON users(email) WHERE email IS NOT NULL AND email != '';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_temp_created_at
|
||||
ON users(created_at) WHERE account_type = 'temp';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_temp_expires_at
|
||||
ON users(expires_at) WHERE account_type = 'temp' AND expires_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_white_player ON games(white_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_black_player ON games(black_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_white_claimed ON games(white_claimed_by)
|
||||
-- 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 IF NOT EXISTS idx_games_black_claimed ON games(black_claimed_by)
|
||||
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;
|
||||
`
|
||||
`,
|
||||
}
|
||||
|
||||
// ownedTables lists every table created by migrations, children first.
|
||||
var ownedTables = []string{"moves", "games", "sessions", "users", "schema_version"}
|
||||
@@ -1,20 +1,30 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateSession creates or replaces the session for a user (single session per user)
|
||||
// CreateSession creates or replaces the user's single session and records the
|
||||
// login time in the same statement, so a login is one round trip.
|
||||
func (s *Store) CreateSession(record SessionRecord) error {
|
||||
const query = `INSERT INTO sessions (session_id, user_id, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
created_at = excluded.created_at,
|
||||
expires_at = excluded.expires_at`
|
||||
if _, err := s.db.Exec(query, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil {
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
const query = `WITH session AS (
|
||||
INSERT INTO sessions (session_id, user_id, created_at, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
created_at = excluded.created_at,
|
||||
expires_at = excluded.expires_at
|
||||
RETURNING user_id
|
||||
)
|
||||
UPDATE users SET last_login_at = $3 WHERE user_id = (SELECT user_id FROM session)`
|
||||
if _, err := s.db.ExecContext(ctx, query,
|
||||
record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
slog.Debug("storage session created", "user_id", record.UserID, "expires_at", record.ExpiresAt)
|
||||
@@ -23,56 +33,44 @@ func (s *Store) CreateSession(record SessionRecord) error {
|
||||
|
||||
// GetSession retrieves a session by ID
|
||||
func (s *Store) GetSession(sessionID string) (*SessionRecord, error) {
|
||||
if !validUUID(sessionID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
var session SessionRecord
|
||||
query := `SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE session_id = ?`
|
||||
|
||||
err := s.db.QueryRow(query, sessionID).Scan(
|
||||
&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt,
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE session_id = $1`,
|
||||
sessionID,
|
||||
).Scan(&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session.CreatedAt = session.CreatedAt.UTC()
|
||||
session.ExpiresAt = session.ExpiresAt.UTC()
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// GetSessionByUserID retrieves the active session for a user
|
||||
func (s *Store) GetSessionByUserID(userID string) (*SessionRecord, error) {
|
||||
var session SessionRecord
|
||||
query := `SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE user_id = ?`
|
||||
|
||||
err := s.db.QueryRow(query, userID).Scan(
|
||||
&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// DeleteSession removes a session
|
||||
// DeleteSession removes a session (logout). Deleting an absent session is not
|
||||
// an error.
|
||||
func (s *Store) DeleteSession(sessionID string) error {
|
||||
query := `DELETE FROM sessions WHERE session_id = ?`
|
||||
_, err := s.db.Exec(query, sessionID)
|
||||
if err == nil {
|
||||
slog.Debug("storage session deleted")
|
||||
if !validUUID(sessionID) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSessionByUserID removes all sessions for a user
|
||||
func (s *Store) DeleteSessionByUserID(userID string) error {
|
||||
query := `DELETE FROM sessions WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, userID)
|
||||
if err == nil {
|
||||
slog.Debug("storage user sessions deleted", "user_id", userID)
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE session_id = $1`, sessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
slog.Debug("storage session deleted")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteExpiredSessions removes expired sessions
|
||||
func (s *Store) DeleteExpiredSessions() (int64, error) {
|
||||
query := `DELETE FROM sessions WHERE expires_at < ?`
|
||||
result, err := s.db.Exec(query, time.Now().UTC())
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < $1`, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -83,29 +81,20 @@ func (s *Store) DeleteExpiredSessions() (int64, error) {
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
// IsSessionValid checks if a session exists and is not expired
|
||||
func (s *Store) IsSessionValid(sessionID string) (bool, error) {
|
||||
var valid bool
|
||||
const query = `SELECT EXISTS(
|
||||
SELECT 1 FROM sessions WHERE session_id = ? AND expires_at > ?
|
||||
)`
|
||||
err := s.db.QueryRow(query, sessionID, time.Now().UTC()).Scan(&valid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// IsSessionValidForUser verifies both expiry and the binding between a JWT
|
||||
// subject and its persisted session. Checking only the session ID would allow
|
||||
// a malformed server-issued token to authenticate as the wrong subject.
|
||||
func (s *Store) IsSessionValidForUser(sessionID, userID string) (bool, error) {
|
||||
if !validUUID(sessionID) || !validUUID(userID) {
|
||||
return false, nil
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
var valid bool
|
||||
const query = `SELECT EXISTS(
|
||||
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(
|
||||
SELECT 1 FROM sessions
|
||||
WHERE session_id = ? AND user_id = ? AND expires_at > ?
|
||||
)`
|
||||
err := s.db.QueryRow(query, sessionID, userID, time.Now().UTC()).Scan(&valid)
|
||||
WHERE session_id = $1 AND user_id = $2 AND expires_at > $3
|
||||
)`, sessionID, userID, time.Now().UTC()).Scan(&valid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
+256
-173
@@ -3,25 +3,43 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
const (
|
||||
writeQueueCapacity = 1000
|
||||
flushTimeout = 5 * time.Second
|
||||
schemaVersion = 2
|
||||
)
|
||||
queryTimeout = 5 * time.Second
|
||||
writeTimeout = 10 * time.Second
|
||||
migrationTimeout = time.Minute
|
||||
|
||||
var memoryStoreCounter atomic.Uint64
|
||||
// A transient failure (PostgreSQL restart, dropped connection) is retried
|
||||
// before storage degrades. Backoff doubles from writeRetryBase, bounding
|
||||
// the stall of the ordered writer to roughly three seconds.
|
||||
writeAttempts = 6
|
||||
writeRetryBase = 100 * time.Millisecond
|
||||
|
||||
maxOpenConns = 10
|
||||
maxIdleConns = 5
|
||||
|
||||
// schemaLockKey serializes migrations between the server and the CLI.
|
||||
// Advisory locks are scoped to the current database.
|
||||
schemaLockKey int64 = 0x6368657373 // "chess"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStorageDegraded = errors.New("storage is degraded")
|
||||
@@ -32,14 +50,14 @@ var (
|
||||
type writeRequest struct {
|
||||
operation string
|
||||
gameID string
|
||||
run func(*sql.Tx) error
|
||||
run func(context.Context, *sql.Tx) error
|
||||
barrier chan error
|
||||
}
|
||||
|
||||
// Store handles SQLite database operations with async writes for games and sync writes for auth
|
||||
// Store handles PostgreSQL operations with ordered async writes for gameplay
|
||||
// and synchronous writes for accounts and sessions.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
writeChan chan writeRequest
|
||||
healthStatus atomic.Bool
|
||||
writeFailed atomic.Bool
|
||||
@@ -52,68 +70,55 @@ type Store struct {
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// NewStore creates a new storage instance with async writer
|
||||
func NewStore(dataSourceName string, devMode bool) (*Store, error) {
|
||||
dsn := sqliteDSN(dataSourceName)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
// NewStore connects to PostgreSQL and starts the async writer. dsn is a
|
||||
// libpq-style keyword/value string or postgres:// URL; unset fields fall back
|
||||
// to the standard PG* environment variables and ~/.pgpass. The schema is
|
||||
// resolved through search_path, so the role's default or a search_path DSN
|
||||
// parameter selects it.
|
||||
func NewStore(dsn string) (*Store, error) {
|
||||
config, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
// Parse errors may echo the DSN, which can contain a password.
|
||||
return nil, errors.New("invalid database connection string")
|
||||
}
|
||||
if _, ok := config.RuntimeParams["application_name"]; !ok {
|
||||
config.RuntimeParams["application_name"] = "chess-server"
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db := stdlib.OpenDB(*config)
|
||||
db.SetMaxOpenConns(maxOpenConns)
|
||||
db.SetMaxIdleConns(maxIdleConns)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), queryTimeout)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// SQLite benefits from a small pool. WAL and busy_timeout are configured in
|
||||
// the DSN for every connection, unlike connection-local PRAGMA calls.
|
||||
db.SetMaxOpenConns(8)
|
||||
db.SetMaxIdleConns(4)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
writerCtx, writerCancel := context.WithCancel(context.Background())
|
||||
s := &Store{
|
||||
db: db,
|
||||
path: dataSourceName,
|
||||
writeChan: make(chan writeRequest, writeQueueCapacity),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
ctx: writerCtx,
|
||||
cancel: writerCancel,
|
||||
}
|
||||
|
||||
// Initialize health as true
|
||||
s.healthStatus.Store(true)
|
||||
|
||||
// Start async writer
|
||||
s.wg.Add(1)
|
||||
go s.writerLoop()
|
||||
slog.Debug("storage opened",
|
||||
"path", dataSourceName,
|
||||
"dev_mode", devMode,
|
||||
"host", config.Host,
|
||||
"database", config.Database,
|
||||
"user", config.User,
|
||||
"write_queue_capacity", writeQueueCapacity,
|
||||
"max_open_connections", 8,
|
||||
"max_open_connections", maxOpenConns,
|
||||
)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func sqliteDSN(dataSourceName string) string {
|
||||
if dataSourceName == ":memory:" {
|
||||
// Each Store needs a private shared-cache database: shared cache keeps
|
||||
// that Store's pooled connections on one database, while the unique name
|
||||
// prevents independent in-memory stores from leaking into each other.
|
||||
dataSourceName = fmt.Sprintf(
|
||||
"file:chess-memory-%d?mode=memory&cache=shared",
|
||||
memoryStoreCounter.Add(1),
|
||||
)
|
||||
}
|
||||
separator := "?"
|
||||
if strings.Contains(dataSourceName, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
return dataSourceName + separator +
|
||||
"_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL"
|
||||
}
|
||||
|
||||
// IsHealthy returns true if the storage is operational
|
||||
func (s *Store) IsHealthy() bool {
|
||||
return s.healthStatus.Load()
|
||||
@@ -176,49 +181,63 @@ func (s *Store) handleWrite(req writeRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// executeWrite runs a transactional write operation
|
||||
// executeWrite runs one logical mutation as one transaction. A transient error
|
||||
// raised before COMMIT is retried: the transaction was rolled back, so a retry
|
||||
// cannot duplicate it, and the single writer preserves ordering while it waits.
|
||||
// A COMMIT error has an unknown outcome and is never retried. Any unrecovered
|
||||
// failure degrades storage permanently, because skipping one write would leave
|
||||
// later writes applied to an incomplete history.
|
||||
func (s *Store) executeWrite(req writeRequest) error {
|
||||
started := time.Now()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: failed to begin transaction",
|
||||
"operation", req.operation, "game_id", req.gameID, "error", err)
|
||||
return err
|
||||
var err error
|
||||
for attempt := 1; ; attempt++ {
|
||||
var committing bool
|
||||
committing, err = s.runWriteTx(req.run)
|
||||
if err == nil {
|
||||
slog.Debug("storage write committed",
|
||||
"operation", req.operation,
|
||||
"game_id", req.gameID,
|
||||
"attempts", attempt,
|
||||
"duration", time.Since(started),
|
||||
"queue_depth", len(s.writeChan),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if committing || attempt >= writeAttempts || !isTransient(err) {
|
||||
break
|
||||
}
|
||||
backoff := writeRetryBase << (attempt - 1)
|
||||
slog.Warn("storage write failed transiently; retrying",
|
||||
"operation", req.operation, "game_id", req.gameID,
|
||||
"attempt", attempt, "backoff", backoff, "error", err)
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
|
||||
if err := req.run(tx); err != nil {
|
||||
rollbackErr := tx.Rollback()
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: write operation failed",
|
||||
"operation", req.operation,
|
||||
"game_id", req.gameID,
|
||||
"error", err,
|
||||
"rollback_error", rollbackErr,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: failed to commit",
|
||||
"operation", req.operation, "game_id", req.gameID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("storage write committed",
|
||||
"operation", req.operation,
|
||||
"game_id", req.gameID,
|
||||
"duration", time.Since(started),
|
||||
"queue_depth", len(s.writeChan),
|
||||
)
|
||||
return nil
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: write operation failed",
|
||||
"operation", req.operation, "game_id", req.gameID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) enqueue(operation, gameID string, fn func(*sql.Tx) error) error {
|
||||
func (s *Store) runWriteTx(run func(context.Context, *sql.Tx) error) (committing bool, err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
||||
defer cancel()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
if err := run(ctx, tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return true, fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Store) enqueue(operation, gameID string, fn func(context.Context, *sql.Tx) error) error {
|
||||
s.enqueueMu.RLock()
|
||||
defer s.enqueueMu.RUnlock()
|
||||
|
||||
@@ -285,7 +304,7 @@ func (s *Store) flushBeforeRead() error {
|
||||
return s.Flush(ctx)
|
||||
}
|
||||
|
||||
// Close gracefully closes the database connection
|
||||
// Close drains accepted writes and closes the connection pool.
|
||||
func (s *Store) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
s.enqueueMu.Lock()
|
||||
@@ -301,125 +320,189 @@ func (s *Store) Close() error {
|
||||
return s.closeErr
|
||||
}
|
||||
|
||||
// InitDB creates the database schema
|
||||
// InitDB applies pending migrations. It holds a transaction-scoped advisory
|
||||
// lock so concurrent migrators serialize, and issues no DDL when the schema is
|
||||
// current: a runtime role limited to DML can start the server once an owner
|
||||
// role has migrated the schema.
|
||||
func (s *Store) InitDB() error {
|
||||
started := time.Now()
|
||||
tx, err := s.db.Begin()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), migrationTimeout)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var currentVersion int
|
||||
if err := tx.QueryRow("PRAGMA user_version").Scan(¤tVersion); err != nil {
|
||||
return fmt.Errorf("failed to read schema version: %w", err)
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, schemaLockKey); err != nil {
|
||||
return fmt.Errorf("failed to lock schema: %w", err)
|
||||
}
|
||||
if currentVersion > schemaVersion {
|
||||
return fmt.Errorf(
|
||||
"database schema version %d is newer than supported version %d",
|
||||
currentVersion,
|
||||
schemaVersion,
|
||||
)
|
||||
current, err := readSchemaVersion(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current > schemaVersion {
|
||||
return fmt.Errorf("database schema version %d is newer than supported version %d",
|
||||
current, schemaVersion)
|
||||
}
|
||||
if current == schemaVersion {
|
||||
slog.Debug("storage schema current", "version", current)
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(Schema); err != nil {
|
||||
return fmt.Errorf("failed to create schema: %w", err)
|
||||
}
|
||||
|
||||
columns := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{"result", "TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate'))"},
|
||||
{"end_time_utc", "DATETIME"},
|
||||
{"white_claimed_by", "TEXT"},
|
||||
{"black_claimed_by", "TEXT"},
|
||||
}
|
||||
for _, column := range columns {
|
||||
if err := ensureColumn(tx, "games", column.name, column.definition); err != nil {
|
||||
return err
|
||||
for version := current + 1; version <= schemaVersion; version++ {
|
||||
if _, err := tx.ExecContext(ctx, migrations[version-1]); err != nil {
|
||||
return fmt.Errorf("failed to apply schema version %d: %w", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
// These indexes duplicate UNIQUE constraints or are superseded by targeted
|
||||
// partial/composite indexes. Drop them during upgrades as well as omitting
|
||||
// them from new databases.
|
||||
for _, name := range []string{
|
||||
"idx_users_username",
|
||||
"idx_users_email",
|
||||
"idx_users_account_type",
|
||||
"idx_users_expires_at",
|
||||
"idx_sessions_user_id",
|
||||
"idx_moves_game_id",
|
||||
"idx_games_finished_end_time",
|
||||
} {
|
||||
if _, err := tx.Exec("DROP INDEX IF EXISTS " + name); err != nil {
|
||||
return fmt.Errorf("failed to remove redundant index %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(Indexes); err != nil {
|
||||
return fmt.Errorf("failed to create indexes: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", schemaVersion)); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO schema_version (version) VALUES ($1)
|
||||
ON CONFLICT (singleton) DO UPDATE SET version = excluded.version, updated_at = now()`,
|
||||
schemaVersion,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to record schema version: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit schema: %w", err)
|
||||
}
|
||||
slog.Debug("storage schema ready", "version", schemaVersion, "duration", time.Since(started))
|
||||
slog.Info("storage schema migrated", "from", current, "to", schemaVersion,
|
||||
"duration", time.Since(started))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureColumn(tx *sql.Tx, table, column, definition string) error {
|
||||
rows, err := tx.Query("PRAGMA table_info(" + table + ")")
|
||||
// SchemaVersion reports the version recorded in the database; 0 means no
|
||||
// schema has been created in the current search_path.
|
||||
func (s *Store) SchemaVersion() (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), queryTimeout)
|
||||
defer cancel()
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
return readSchemaVersion(ctx, tx)
|
||||
}
|
||||
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, columnType string
|
||||
var notNull, primaryKey int
|
||||
var defaultValue any
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("failed to inspect %s column: %w", table, err)
|
||||
}
|
||||
if name == column {
|
||||
found = true
|
||||
func readSchemaVersion(ctx context.Context, tx *sql.Tx) (int, error) {
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT to_regclass('schema_version') IS NOT NULL`,
|
||||
).Scan(&exists); err != nil {
|
||||
return 0, fmt.Errorf("failed to inspect schema: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return 0, nil
|
||||
}
|
||||
var version int
|
||||
err := tx.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&version)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read schema version: %w", err)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// DropSchema removes every chess table from the current search_path. It is
|
||||
// destructive and requires ownership of the tables.
|
||||
func (s *Store) DropSchema() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), migrationTimeout)
|
||||
defer cancel()
|
||||
// RESTRICT (the default) refuses to drop objects that others depend on,
|
||||
// such as an administrator's reporting view.
|
||||
_, err := s.db.ExecContext(ctx, `DROP TABLE IF EXISTS `+strings.Join(ownedTables, ", "))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// opContext bounds a synchronous operation so a stalled database cannot hold a
|
||||
// request handler indefinitely.
|
||||
func opContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), queryTimeout)
|
||||
}
|
||||
|
||||
// isTransient reports errors after which the same transaction may succeed on a
|
||||
// fresh connection.
|
||||
func isTransient(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "40001", // serialization_failure
|
||||
"40P01", // deadlock_detected
|
||||
"57P01", // admin_shutdown
|
||||
"57P02", // crash_shutdown
|
||||
"57P03": // cannot_connect_now
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(pgErr.Code, "08") // connection_exception class
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close %s schema rows: %w", table, err)
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
|
||||
var connectErr *pgconn.ConnectError
|
||||
var netErr net.Error
|
||||
return errors.As(err, &connectErr) ||
|
||||
errors.As(err, &netErr) ||
|
||||
errors.Is(err, driver.ErrBadConn) ||
|
||||
errors.Is(err, io.EOF) ||
|
||||
errors.Is(err, io.ErrUnexpectedEOF) ||
|
||||
pgconn.SafeToRetry(err)
|
||||
}
|
||||
|
||||
// uniqueViolation returns the violated constraint name for SQLSTATE 23505.
|
||||
func uniqueViolation(err error) (string, bool) {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return pgErr.ConstraintName, true
|
||||
}
|
||||
if found {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// validUUID accepts the canonical 36-character form. Lookups treat anything
|
||||
// else as not found instead of sending PostgreSQL a value that fails to cast.
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
_, err := uuid.Parse(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := tx.Exec("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition); err != nil {
|
||||
return fmt.Errorf("failed to add %s.%s: %w", table, column, err)
|
||||
}
|
||||
slog.Debug("storage schema column added", "table", table, "column", column)
|
||||
return nil
|
||||
return value
|
||||
}
|
||||
|
||||
// DeleteDB removes the database file
|
||||
func (s *Store) DeleteDB() error {
|
||||
// Close connection first
|
||||
if err := s.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close database: %w", err)
|
||||
func utcPointer(value sql.NullTime) *time.Time {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
t := value.Time.UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
// ☣ DESTRUCTIVE: Removes database file
|
||||
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to delete database file: %w", err)
|
||||
// ErrSchemaMismatch reports a database whose schema is absent or at a version
|
||||
// other than the one this binary supports.
|
||||
var ErrSchemaMismatch = errors.New("database schema version mismatch")
|
||||
|
||||
// CheckSchema verifies that the schema is current without changing it.
|
||||
func (s *Store) CheckSchema() error {
|
||||
version, err := s.SchemaVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case version == 0:
|
||||
return fmt.Errorf("%w: schema not initialized (run 'chess-server db init')", ErrSchemaMismatch)
|
||||
case version != schemaVersion:
|
||||
return fmt.Errorf("%w: database has version %d, binary supports %d",
|
||||
ErrSchemaMismatch, version, schemaVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+122
-229
@@ -5,76 +5,22 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserAlreadyExists = errors.New("username or email already exists")
|
||||
ErrUserCapacity = errors.New("user capacity reached")
|
||||
ErrPermanentCapacity = errors.New("permanent user capacity reached")
|
||||
)
|
||||
|
||||
// UserLimits defines registration constraints
|
||||
// userCreateLockKey serializes account creation across processes (server and
|
||||
// CLI) so the capacity check cannot be raced past.
|
||||
const userCreateLockKey int64 = 0x6368657375 // "chesu"
|
||||
|
||||
const userSelectColumns = `user_id, username, email, password_hash, created_at, last_login_at`
|
||||
|
||||
// UserLimits defines public-registration constraints.
|
||||
type UserLimits struct {
|
||||
MaxUsers int
|
||||
PermanentSlots int
|
||||
}
|
||||
|
||||
// DefaultUserLimits returns default POC limits
|
||||
func DefaultUserLimits() UserLimits {
|
||||
return UserLimits{
|
||||
MaxUsers: 100,
|
||||
PermanentSlots: 10,
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserCounts returns current user counts by type
|
||||
func (s *Store) GetUserCounts() (total, permanent, temp int, err error) {
|
||||
query := `SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END) as permanent,
|
||||
COUNT(CASE WHEN account_type = 'temp' THEN 1 END) as temp
|
||||
FROM users`
|
||||
|
||||
err = s.db.QueryRow(query).Scan(&total, &permanent, &temp)
|
||||
return
|
||||
}
|
||||
|
||||
// GetOldestTempUser returns the oldest temporary user for replacement
|
||||
func (s *Store) GetOldestTempUser() (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var email sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users
|
||||
WHERE account_type = 'temp'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`
|
||||
|
||||
err := s.db.QueryRow(query).Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// DeleteExpiredTempUsers removes temporary users past their expiry
|
||||
func (s *Store) DeleteExpiredTempUsers() (int64, error) {
|
||||
query := `DELETE FROM users
|
||||
WHERE account_type = 'temp' AND expires_at IS NOT NULL AND expires_at < ?`
|
||||
result, err := s.db.Exec(query, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err == nil && deleted > 0 {
|
||||
slog.Debug("storage expired temporary users deleted", "count", deleted)
|
||||
}
|
||||
return deleted, err
|
||||
MaxUsers int // total accounts at which registration closes
|
||||
}
|
||||
|
||||
// CreateUser creates an administratively managed user without applying the
|
||||
@@ -83,10 +29,9 @@ func (s *Store) CreateUser(record UserRecord) error {
|
||||
return s.createUser(record, nil, nil)
|
||||
}
|
||||
|
||||
// CreateUserWithinLimits atomically applies registration limits, evicts the
|
||||
// oldest temporary account when required, creates the user, and optionally
|
||||
// creates its initial session. No account is evicted on a duplicate request,
|
||||
// and a session failure rolls back the user and eviction together.
|
||||
// CreateUserWithinLimits atomically applies the registration limit, creates
|
||||
// the user, and optionally creates its initial session. A session failure
|
||||
// rolls back the account.
|
||||
func (s *Store) CreateUserWithinLimits(
|
||||
record UserRecord,
|
||||
session *SessionRecord,
|
||||
@@ -96,69 +41,55 @@ func (s *Store) CreateUserWithinLimits(
|
||||
}
|
||||
|
||||
func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *UserLimits) error {
|
||||
tx, err := s.db.Begin()
|
||||
if session != nil && session.UserID != record.UserID {
|
||||
return errors.New("initial session user does not match new user")
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Check uniqueness within transaction
|
||||
exists, err := s.userExists(tx, record.Username, record.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, userCreateLockKey); err != nil {
|
||||
return fmt.Errorf("lock user creation: %w", err)
|
||||
}
|
||||
|
||||
// Report a duplicate as such even when registration is full. The advisory
|
||||
// lock orders this check with concurrent creations; the unique constraints
|
||||
// still catch a race with renames.
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(
|
||||
SELECT 1 FROM users WHERE username = lower($1) OR email = lower($2)
|
||||
)`, record.Username, nullableString(record.Email)).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check user uniqueness: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return ErrUserAlreadyExists
|
||||
}
|
||||
|
||||
if limits != nil {
|
||||
var total, permanent int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*),
|
||||
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END)
|
||||
FROM users`).Scan(&total, &permanent); err != nil {
|
||||
if limits != nil && limits.MaxUsers > 0 {
|
||||
var total int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&total); err != nil {
|
||||
return fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
if record.AccountType == "permanent" && permanent >= limits.PermanentSlots {
|
||||
return ErrPermanentCapacity
|
||||
}
|
||||
if total >= limits.MaxUsers {
|
||||
result, err := tx.Exec(`DELETE FROM users WHERE user_id = (
|
||||
SELECT user_id FROM users
|
||||
WHERE account_type = 'temp'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evict oldest temporary user: %w", err)
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect temporary user eviction: %w", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
return ErrUserCapacity
|
||||
}
|
||||
return ErrUserCapacity
|
||||
}
|
||||
}
|
||||
|
||||
// Insert user
|
||||
query := `INSERT INTO users (
|
||||
user_id, username, email, password_hash, account_type, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err = tx.Exec(query,
|
||||
record.UserID, record.Username, record.Email,
|
||||
record.PasswordHash, record.AccountType, record.CreatedAt, record.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO users (
|
||||
user_id, username, email, password_hash, created_at
|
||||
) VALUES ($1, lower($2), lower($3), $4, $5)`,
|
||||
record.UserID, record.Username, nullableString(record.Email),
|
||||
record.PasswordHash, record.CreatedAt,
|
||||
); err != nil {
|
||||
return mapUserConflict(err)
|
||||
}
|
||||
if session != nil {
|
||||
if session.UserID != record.UserID {
|
||||
return errors.New("initial session user does not match new user")
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES ($1, $2, $3, $4)`,
|
||||
session.SessionID, session.UserID, session.CreatedAt, session.ExpiresAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create initial session: %w", err)
|
||||
@@ -168,78 +99,74 @@ func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *Us
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Debug("storage user created",
|
||||
"user_id", record.UserID,
|
||||
"account_type", record.AccountType,
|
||||
"initial_session", session != nil,
|
||||
)
|
||||
slog.Debug("storage user created", "user_id", record.UserID, "initial_session", session != nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUserByID removes a user by ID (synchronous, for replacement logic)
|
||||
func (s *Store) DeleteUserByID(userID string) error {
|
||||
query := `DELETE FROM users WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, userID)
|
||||
if err == nil {
|
||||
slog.Debug("storage user deleted", "user_id", userID)
|
||||
// mapUserConflict converts a username/email uniqueness violation, including one
|
||||
// lost to a concurrent writer, into ErrUserAlreadyExists.
|
||||
func mapUserConflict(err error) error {
|
||||
if constraint, ok := uniqueViolation(err); ok &&
|
||||
(constraint == "users_username_key" || constraint == "users_email_key") {
|
||||
return ErrUserAlreadyExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// PromoteToPermament upgrades a temp user to permanent
|
||||
func (s *Store) PromoteToPermanent(userID string) error {
|
||||
query := `UPDATE users SET account_type = 'permanent', expires_at = NULL WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// userExists verifies username/email uniqueness within a transaction
|
||||
func (s *Store) userExists(tx *sql.Tx, username, email string) (bool, error) {
|
||||
var count int
|
||||
query := `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE`
|
||||
args := []any{username}
|
||||
|
||||
if email != "" {
|
||||
query = `SELECT COUNT(*) FROM users
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
OR (email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '')`
|
||||
args = append(args, email)
|
||||
// DeleteUser removes a user synchronously; sessions cascade, while games keep
|
||||
// the claim ID and name snapshot. It returns sql.ErrNoRows when no such user
|
||||
// exists.
|
||||
func (s *Store) DeleteUser(userID string) error {
|
||||
if err := s.execUserUpdate(`DELETE FROM users WHERE user_id = $1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := tx.QueryRow(query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
slog.Debug("storage user deleted", "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserPassword updates user password hash
|
||||
func (s *Store) UpdateUserPassword(userID string, passwordHash string) error {
|
||||
query := `UPDATE users SET password_hash = ? WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, passwordHash, userID)
|
||||
return err
|
||||
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
|
||||
return s.execUserUpdate(`UPDATE users SET password_hash = $2 WHERE user_id = $1`,
|
||||
userID, passwordHash)
|
||||
}
|
||||
|
||||
// UpdateUserEmail updates user email
|
||||
func (s *Store) UpdateUserEmail(userID string, email string) error {
|
||||
query := `UPDATE users SET email = ? WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, email, userID)
|
||||
return err
|
||||
// UpdateUserEmail updates the email; an empty value removes it.
|
||||
func (s *Store) UpdateUserEmail(userID, email string) error {
|
||||
return s.execUserUpdate(`UPDATE users SET email = lower($2) WHERE user_id = $1`,
|
||||
userID, nullableString(email))
|
||||
}
|
||||
|
||||
// UpdateUserUsername updates username
|
||||
func (s *Store) UpdateUserUsername(userID string, username string) error {
|
||||
query := `UPDATE users SET username = ? WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, username, userID)
|
||||
return err
|
||||
func (s *Store) UpdateUserUsername(userID, username string) error {
|
||||
return s.execUserUpdate(`UPDATE users SET username = lower($2) WHERE user_id = $1`,
|
||||
userID, username)
|
||||
}
|
||||
|
||||
// GetAllUsers retrieves all users
|
||||
func (s *Store) GetAllUsers() ([]UserRecord, error) {
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users ORDER BY created_at DESC`
|
||||
// execUserUpdate runs a single-row statement keyed by user_id ($1).
|
||||
func (s *Store) execUserUpdate(query, userID string, args ...any) error {
|
||||
if !validUUID(userID) {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
result, err := s.db.ExecContext(ctx, query, append([]any{userID}, args...)...)
|
||||
if err != nil {
|
||||
return mapUserConflict(err)
|
||||
}
|
||||
if rows, err := result.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query)
|
||||
// GetAllUsers retrieves all users, newest first
|
||||
func (s *Store) GetAllUsers() ([]UserRecord, error) {
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+userSelectColumns+` FROM users ORDER BY created_at DESC, user_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -248,91 +175,57 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) {
|
||||
var users []UserRecord
|
||||
for rows.Next() {
|
||||
var user UserRecord
|
||||
var email sql.NullString
|
||||
err := rows.Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err := scanUser(rows, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateUserLastLoginSync updates user last login time
|
||||
func (s *Store) UpdateUserLastLoginSync(userID string, loginTime time.Time) error {
|
||||
query := `UPDATE users SET last_login_at = ? WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, loginTime, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update last login for user %s: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByUsername retrieves user by username with case-insensitive matching
|
||||
// GetUserByUsername retrieves a user by case-insensitive username.
|
||||
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var email sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users WHERE username = ? COLLATE NOCASE`
|
||||
|
||||
err := s.db.QueryRow(query, username).Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
return &user, nil
|
||||
return s.getUser(`username = lower($1)`, username)
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves user by email with case-insensitive matching
|
||||
// GetUserByEmail retrieves a user by case-insensitive email.
|
||||
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var emailNull sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != ''`
|
||||
|
||||
err := s.db.QueryRow(query, email).Scan(
|
||||
&user.UserID, &user.Username, &emailNull,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if email == "" {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
user.Email = emailNull.String
|
||||
return &user, nil
|
||||
return s.getUser(`email = lower($1)`, email)
|
||||
}
|
||||
|
||||
// GetUserByID retrieves user by unique user ID
|
||||
func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var email sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users WHERE user_id = ?`
|
||||
if !validUUID(userID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return s.getUser(`user_id = $1`, userID)
|
||||
}
|
||||
|
||||
err := s.db.QueryRow(query, userID).Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
func (s *Store) getUser(predicate string, arg any) (*UserRecord, error) {
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
var user UserRecord
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+userSelectColumns+` FROM users WHERE `+predicate, arg)
|
||||
if err := scanUser(row, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user synchronously. Account operations are consistency
|
||||
// sensitive and should not be reported successful before SQLite commits them.
|
||||
func (s *Store) DeleteUser(userID string) error {
|
||||
return s.DeleteUserByID(userID)
|
||||
func scanUser(scanner rowScanner, user *UserRecord) error {
|
||||
var email sql.NullString
|
||||
var lastLoginAt sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.CreatedAt, &lastLoginAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
user.Email = email.String
|
||||
user.CreatedAt = user.CreatedAt.UTC()
|
||||
user.LastLoginAt = utcPointer(lastLoginAt)
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user