Compare commits

..
20 Commits
Author SHA256 Message Date
lixen 5d2be4abb2 v0.11.0 harden persistence and prepare game replays 2026-09-07 14:39:00 -04:00
lixen 21dea47694 v0.10.0 fix to engine game state mgmt, client and web ui updates to match 2026-07-23 14:03:09 -04:00
lixen a23ed1fb21 v0.9.5 web client interactivity improvement 2026-07-06 14:07:16 -04:00
lixen d7d31283d6 v0.9.4 fix for db registration and server compile with cgo 2026-06-10 08:08:20 -04:00
lixen 84bd5d40c8 v0.9.3 db and web fixes for deployment 2026-02-28 01:37:40 -05:00
lixen e689ca83a2 v0.9.2 web and wasm changes for deployment 2026-02-27 18:35:49 -05:00
lixen 84efdb63e0 v0.9.1 web ui fixes and improvements 2026-02-25 10:27:52 -05:00
lixen 4e6aceb0c8 v0.9.0 user and session management improvement, xterm.js addons 2026-02-07 15:37:52 -05:00
lixen fc87e613f1 v0.8.1 web ui responsive style improved 2025-11-19 08:43:12 -05:00
lixen f63ab9e10a v0.8.0 wasm client added, cli client and makefile updated 2025-11-14 05:57:37 -05:00
lixen 35804d4b9a v0.7.1 client readline removed for cross-platform compatibility with wasm, client logic fix fixes and refactor 2025-11-13 14:37:44 -05:00
lixen 9745955e00 v0.7.0 cli client with readline added, directory structure updated 2025-11-13 08:55:06 -05:00
lixen 5d17008f72 v0.6.0 multi-user game support with longpoll, tests and doc updated 2025-11-05 12:08:18 -05:00
lixen b8e3f631a2 v0.5.0 user support with auth added, tests and doc updated 2025-11-05 02:56:41 -05:00
lixen 03ca58809b v0.4.1 web ui improved, docs updated 2025-11-01 16:19:57 -04:00
lixen f761fc955e v0.4.0 web server added 2025-10-31 03:27:22 -04:00
lixen 85be6295fc v0.3.0 storage with sqlite3 and pid management added 2025-10-30 09:52:23 -04:00
lixen 461af20c8c v0.2.0 transitioned to api-only, extended and improved features, docs and tests added 2025-10-29 23:28:19 -04:00
lixen 252efca330 v0.1.1 api with fiber added, basic functionlity tested 2025-10-27 05:16:08 -04:00
lixen 0a834f648c v0.1.0 chess game in go, using external stockfish engine 2025-10-26 17:34:50 -04:00
42 changed files with 3591 additions and 693 deletions
+15 -5
View File
@@ -3,7 +3,7 @@
<td>
<h1>♚♛♜♝♞</h1>
<p>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat&logo=go" alt="Go 1.25"></a>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go" alt="Go 1.26"></a>
<a href="https://opensource.org/licenses/BSD-3-Clause"><img src="https://img.shields.io/badge/License-BSD_3--Clause-blue.svg" alt="License BSD-3"></a>
</p>
</td>
@@ -24,13 +24,16 @@ Go backend server providing a RESTful API for chess gameplay with user authentic
- Asynchronous engine move calculation
- Configurable engine strength and thinking time
- SQLite persistence with async writes for games
- Durable game results, player claims, ordered move history, and replay API
- Authenticated stored-game listing with bounded pagination
- Configurable structured debug logs for persistence, cleanup, and engine work
- User management with secure Argon2 password storage
- PID file management for singleton enforcement
- Database CLI for storage and user administration
## Requirements
- Go 1.25+
- Go 1.26+
- Stockfish chess engine (`stockfish` in PATH)
- SQLite3 (for persistence features)
@@ -78,7 +81,10 @@ go build ./cmd/chess-server
./chess-server -storage-path chess.db
# Development mode with all features
./chess-server -dev -storage-path chess.db -pid /tmp/chess-server.pid -pid-lock -port 9090
./chess-server -dev -storage-path chess.db -pid /tmp/chess-server.pid -pid-lock -api-port 9090
# Detailed persistence and HTTP diagnostics
./chess-server -dev -storage-path chess.db -log-level debug -log-http=true
# Initialize database with user support
./chess-server db init -path chess.db
@@ -135,13 +141,16 @@ The chess server includes an embedded web UI for playing games through a browser
# Full example with authentication enabled
./chess-server -dev -serve -web-port 9090 -api-port 8080 -storage-path chess.db
# Override the API origin seen by browsers when it differs from the listen address
./chess-server -serve -web-api-url https://api.example.com
```
### Features
- Visual chess board with drag-and-drop moves
- Human vs Computer gameplay
- Configurable engine strength (0-20)
- Move history with algebraic notation
- UCI move history with move numbers
- FEN display and custom starting positions
- Real-time server health monitoring
- User authentication support
@@ -155,8 +164,9 @@ Access the UI at `http://localhost:9090` when server is running with `-serve` fl
- [Architecture](./doc/architecture.md) - System design with auth layer
- [Development](./doc/development.md) - Build, test, and user management
- [Client Guide](./doc/client.md) - Interactive debugging client
- [Replay Implementation Tasks](./doc/todo.md) - Remaining CLI, web, archive, and production work
- [Stockfish Integration](./doc/stockfish.md) - Engine communication
## License
BSD 3-Clause
BSD 3-Clause
+2 -2
View File
@@ -40,7 +40,8 @@ func runClient() (restart bool) {
display.Println(display.Cyan, "Chess Debug Client")
display.Println(display.Cyan, "API: %s", s.APIBaseURL)
fmt.Println("Type 'help' for commands\n")
fmt.Println("Type 'help' for commands")
fmt.Println()
registry := command.NewRegistry(s)
@@ -133,4 +134,3 @@ func buildPrompt(s *session.Session) string {
return display.Prompt(b.String())
}
+38 -9
View File
@@ -1,6 +1,8 @@
package cli
import (
"database/sql"
"errors"
"flag"
"fmt"
"os"
@@ -122,17 +124,27 @@ func runQuery(args []string) error {
// Print results in tabular format
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Game ID\tWhite Player\tBlack Player\tStart Time")
fmt.Fprintln(w, strings.Repeat("-", 80))
fmt.Fprintln(w, "Game ID\tWhite Player\tBlack Player\tResult\tStarted\tEnded")
fmt.Fprintln(w, strings.Repeat("-", 130))
for _, g := range games {
whiteInfo := fmt.Sprintf("%s (T%d)", g.WhitePlayerID[:8], g.WhiteType)
blackInfo := fmt.Sprintf("%s (T%d)", g.BlackPlayerID[:8], g.BlackType)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
g.GameID[:8]+"...",
whiteInfo := formatStoredPlayer(g.WhitePlayerID, g.WhiteClaimedBy, g.WhiteType)
blackInfo := formatStoredPlayer(g.BlackPlayerID, g.BlackClaimedBy, g.BlackType)
result := g.Result
if result == "" {
result = "ongoing"
}
ended := "-"
if g.EndTimeUTC != nil {
ended = g.EndTimeUTC.Format("2006-01-02 15:04:05")
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
abbreviateID(g.GameID),
whiteInfo,
blackInfo,
result,
g.StartTimeUTC.Format("2006-01-02 15:04:05"),
ended,
)
}
w.Flush()
@@ -141,6 +153,21 @@ func runQuery(args []string) error {
return nil
}
func formatStoredPlayer(playerID, claimedBy string, playerType int) string {
value := fmt.Sprintf("%s (T%d)", abbreviateID(playerID), playerType)
if claimedBy != "" && claimedBy != playerID {
value += " claim:" + abbreviateID(claimedBy)
}
return value
}
func abbreviateID(value string) string {
if len(value) <= 8 {
return value
}
return value[:8] + "..."
}
func runUser(subcommand string, args []string) error {
switch subcommand {
case "add":
@@ -235,9 +262,11 @@ func runUserAdd(args []string) error {
var userID string
for attempts := 0; attempts < 10; attempts++ {
userID = uuid.New().String()
if _, err := store.GetUserByID(userID); err != nil {
if _, err := store.GetUserByID(userID); errors.Is(err, sql.ErrNoRows) {
// User doesn't exist, ID is unique
break
} else if err != nil {
return fmt.Errorf("failed to check generated user ID: %w", err)
}
if attempts == 9 {
return fmt.Errorf("failed to generate unique user ID after 10 attempts")
@@ -562,7 +591,7 @@ func runUserList(args []string) error {
expires = u.ExpiresAt.Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
u.UserID[:8]+"...",
abbreviateID(u.UserID),
u.Username,
u.AccountType,
email,
@@ -575,4 +604,4 @@ func runUserList(args []string) error {
fmt.Printf("\nTotal users: %d\n", len(users))
return nil
}
}
+43 -16
View File
@@ -8,6 +8,7 @@ import (
"flag"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
@@ -43,14 +44,35 @@ func main() {
storagePath = flag.String("storage-path", "", "Path to SQLite database file (disables persistence if empty)")
pidPath = flag.String("pid", "", "Optional path to write PID file")
pidLock = flag.Bool("pid-lock", false, "Lock PID file to allow only one instance (requires -pid)")
logLevel = flag.String("log-level", "info", "Log level: debug, info, warn, or error")
logHTTP = flag.Bool("log-http", true, "Log HTTP requests")
finishedTTL = flag.Duration("finished-game-ttl", service.FinishedGameTTL, "How long completed games remain in memory (0 disables eviction)")
// Web UI server flags
serve = flag.Bool("serve", false, "Enable web UI server")
webHost = flag.String("web-host", "localhost", "Web UI server host")
webPort = flag.Int("web-port", 9090, "Web UI server port")
serve = flag.Bool("serve", false, "Enable web UI server")
webHost = flag.String("web-host", "localhost", "Web UI server host")
webPort = flag.Int("web-port", 9090, "Web UI server port")
webAPIURL = flag.String("web-api-url", "", "Browser-visible API base URL (defaults to the API listen address)")
)
flag.Parse()
var level slog.Level
if err := level.UnmarshalText([]byte(*logLevel)); err != nil {
log.Fatalf("Invalid -log-level %q: %v", *logLevel, err)
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey {
attr.Value = slog.TimeValue(attr.Value.Time().UTC())
}
return attr
},
})))
// slog.SetDefault bridges the standard logger through the structured
// handler. Avoid embedding a second timestamp inside its message.
log.SetFlags(0)
// Validate PID flags
if *pidLock && *pidPath == "" {
log.Fatal("Error: -pid-lock flag requires the -pid flag to be set")
@@ -78,11 +100,6 @@ func main() {
if err := store.InitDB(); err != nil {
log.Fatalf("Failed to initialize schema: %v", err)
}
defer func() {
if err := store.Close(); err != nil {
log.Printf("Warning: failed to close storage cleanly: %v", err)
}
}()
} else {
log.Printf("Persistent storage disabled (use -storage-path to enable)")
}
@@ -104,20 +121,27 @@ func main() {
// 2. Initialize the Service with optional storage and auth
svc := service.New(store, jwtSecret)
svc.SetFinishedGameTTL(*finishedTTL)
// Start cleanup job for expired users/sessions
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
go svc.RunCleanupJob(cleanupCtx, service.CleanupJobInterval)
cleanupDone := make(chan struct{})
go func() {
defer close(cleanupDone)
svc.RunCleanupJob(cleanupCtx, service.CleanupJobInterval)
}()
// 3. Initialize the Processor (Orchestrator), injecting the service
proc, err := processor.New(svc)
if err != nil {
cleanupCancel()
<-cleanupDone
svc.Shutdown(gracefulShutdownTimeout)
log.Fatalf("Failed to initialize processor: %v", err)
}
// 4. Initialize the Fiber App/HTTP Handler, injecting processor and service
app := http.NewFiberApp(proc, svc, *dev)
app := http.NewFiberApp(proc, svc, *dev, *logHTTP)
// API Server configuration
apiAddr := fmt.Sprintf("%s:%d", *apiHost, *apiPort)
@@ -151,13 +175,16 @@ func main() {
if *serve {
webAddr := fmt.Sprintf("%s:%d", *webHost, *webPort)
apiURL := fmt.Sprintf("http://%s", apiAddr)
if *webAPIURL != "" {
apiURL = *webAPIURL
}
go func() {
log.Printf("Web UI Server starting...")
log.Printf("Web UI Listening on: http://%s", webAddr)
log.Printf("Web UI API target: %s", apiURL)
if err := webserver.Start(*webHost, *webPort, apiURL); err != nil {
if err := webserver.Start(*webHost, *webPort, apiURL, *logHTTP); err != nil {
log.Printf("Web UI server error: %v", err)
}
}()
@@ -179,18 +206,18 @@ func main() {
log.Printf("Server forced to shutdown: %v", err)
}
// Close processor after service shutdown
cleanupCancel() // Stop cleanup before closing processor and storage.
<-cleanupDone
// Close processor before the service so engine callbacks have settled.
if err = proc.Close(); err != nil {
log.Printf("Processor close error: %v", err)
}
cleanupCancel() // Stop cleanup job
// Shutdown service first (includes wait registry cleanup)
// Shutdown service (wait registry, accepted storage writes, database).
if err = svc.Shutdown(gracefulShutdownTimeout); err != nil {
log.Printf("Service shutdown error: %v", err)
}
log.Println("Servers exited")
}
+86 -3
View File
@@ -103,7 +103,9 @@ Returns server and storage status.
Storage states:
- `"disabled"` - No storage path configured
- `"ok"` - Database operational with auth enabled
- `"degraded"` - Write failures detected, operating memory-only
- `"degraded"` - A persistence write failed or the write queue filled; live games continue in memory, but durable history is no longer complete
The top-level `status` is also `"degraded"` when storage is degraded.
### Create Game
`POST /games`
@@ -171,6 +173,82 @@ Response includes all game data. Compare `moves` array length to detect changes.
- Client disconnection cancels wait immediately
- Game deletion notifies all waiting clients
### Get Durable Game History
`GET /games/{gameId}/history`
Returns the persisted replay line even after the live game has been unloaded
from memory or the server has restarted. History is public to anyone who knows
the game ID, matching the existing public live-game read model. Persistent
storage must be enabled.
The response contains the initial FEN and an ordered FEN after every move, so a
client can replay the game without running a chess engine.
**Response (200):**
```json
{
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"result": "white_wins",
"startTimeUtc": "2026-09-07T12:00:00Z",
"endTimeUtc": "2026-09-07T12:15:00Z",
"players": {
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id"},
"black": {"id": "player-id", "color": 2, "type": 1}
},
"moves": [
{
"moveNumber": 1,
"moveUci": "e2e4",
"fenAfterMove": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
"playerColor": "w",
"moveTimeUtc": "2026-09-07T12:00:05Z"
}
]
}
```
`result` is omitted while a game is ongoing. Persisted terminal values are
`white_wins`, `black_wins`, `draw`, and `stalemate`.
Returns 404 when the game has no durable record and 503 when persistence is
disabled.
### List My Stored Games
`GET /users/me/games?limit=50&offset=0`
Returns games associated with the authenticated user at creation time or by a
later first-move slot claim. Requires `Authorization: Bearer <token>` and
persistent storage.
- `limit`: 1-100; defaults to 50
- `offset`: 0-1,000,000; defaults to 0
Each item contains game ID, initial FEN, result/timestamps, players, and move
count. `nextOffset` is present only when another page exists.
**Response (200):**
```json
{
"games": [
{
"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
"initialFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"result": "white_wins",
"startTimeUtc": "2026-09-07T12:00:00Z",
"endTimeUtc": "2026-09-07T12:15:00Z",
"moveCount": 41,
"players": {
"white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id"},
"black": {"id": "player-id", "color": 2, "type": 1}
}
}
],
"limit": 50,
"offset": 0
}
```
### Make Move
`POST /games/{gameId}/moves`
@@ -204,7 +282,8 @@ Returns ASCII board visualization.
### Delete Game
`DELETE /games/{gameId}`
Removes game from memory. Returns 204 on success.
Unloads the live game from memory. Its persisted game and move history remain
available through the history endpoint. Returns 204 on success.
## Error Format
```json
@@ -220,6 +299,8 @@ Error codes:
- `INVALID_MOVE` - Illegal chess move
- `NOT_HUMAN_TURN` - Wrong player type for turn
- `GAME_OVER` - Game already ended
- `GAME_CONFLICT` - Game changed while a move was being validated; refresh and retry
- `STORAGE_UNAVAILABLE` - Durable history/list storage is disabled or degraded
- `RATE_LIMIT_EXCEEDED` - Request limit exceeded
- `INVALID_REQUEST` - Malformed request
- `INVALID_CONTENT_TYPE` - Missing/wrong Content-Type header
@@ -242,4 +323,6 @@ Tokens are HS256-signed JWTs valid for 7 days. Include in Authorization header:
Authorization: Bearer <token>
```
Token claims include `sub` (user ID), `username`, `email`, and `exp` (expiration).
Token claims include `sub` (user ID), `username`, `email`, `session_id`, and
`exp` (expiration). Authentication requires the session to exist, be unexpired,
and belong to the JWT subject.
+69 -27
View File
@@ -2,14 +2,18 @@
## Components
### Transport Layer (`internal/http`)
### Transport Layer (`internal/server/http`)
Fiber web server handling HTTP requests/responses. Implements routing, rate limiting, content-type validation, JWT authentication middleware, request parsing. Translates HTTP to internal Command objects.
### Processing Layer (`internal/processor`)
### Processing Layer (`internal/server/processor`)
Central command handler containing business logic. Single `Execute(Command)` entry point decouples transport from logic. Uses synchronous UCI engine for validation, asynchronous EngineQueue for computer moves. Commands include optional user context for authenticated operations.
### Service Layer (`internal/service`)
In-memory state storage with authentication support. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, player configuration, user accounts, and JWT token generation. Coordinates with storage layer for persistence of both games and users.
### Service Layer (`internal/server/service`)
In-memory live-game storage with authentication support. A mutex protects each
game transition and callers receive immutable game views rather than mutable
game pointers. The service manages game lifecycle, snapshots, player
configuration, user accounts, JWT tokens, persistence, and terminal-game
eviction. Eviction removes only the memory copy; durable replay data remains.
#### Long-Polling Registry (`internal/service/waiter.go`)
Manages clients waiting for game state changes via HTTP long-polling. Tracks move counts per client, sends notifications on state changes, enforces 30-second timeout. Non-blocking notification pattern handles slow clients gracefully. Coordinates with service layer for game updates and deletion events.
@@ -18,18 +22,31 @@ Manages clients waiting for game state changes via HTTP long-polling. Tracks mov
- **Password Hashing**: Argon2id for secure password storage
- **JWT Management**: HS256 tokens with 7-day expiration
- **User Operations**: Registration, login, profile management
- **Session Tracking**: Last login timestamps
- **Session Tracking**: One persisted session per user, with JWT subject/session
binding and last-login timestamps
### Storage Layer (`internal/storage`)
SQLite persistence with async writes for games, synchronous writes for authentication operations. Buffered channel (1000 ops) processes game writes sequentially in background. User operations use direct database access for consistency. Graceful degradation on write failures. WAL mode for development environments.
### Storage Layer (`internal/server/storage`)
SQLite persistence with ordered asynchronous writes for gameplay and
synchronous writes for authentication. A bounded channel (1,000 operations)
feeds one transactional writer. Move persistence groups the move, first-move
slot claim, and terminal result in one transaction. Replay reads insert a
barrier behind accepted writes and then read the game and moves in one SQLite
snapshot.
WAL, foreign-key enforcement, a five-second busy timeout, and NORMAL
synchronous mode are configured in the connection string so every pooled
connection receives the same settings. The pool is deliberately small (eight
open, four idle) because SQLite has one writer. A write failure or full queue
marks storage degraded; live play remains available in memory and `/health`
reports that durable history may be incomplete.
### Supporting Modules
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
- **Game** (`internal/game`): Game state with snapshot history and player associations
- **Board** (`internal/board`): FEN parsing and ASCII generation
- **Core** (`internal/core`): Shared types, API models, error constants
- **CLI** (`cmd/chessd/cli`): Database and user management commands
- **Client** (`cmd/chess-client`, `internal/client`): Interactive debugging client with command registry, session management, and colored terminal output
- **CLI** (`cmd/chess-server/cli`): Database and user management commands
- **Client** (`cmd/chess-client-cli`, `internal/client`): Interactive debugging client with command registry, session management, and colored terminal output
## Request Flow
@@ -55,9 +72,10 @@ SQLite persistence with async writes for games, synchronous writes for authentic
3. Creates MakeMoveCommand, calls `processor.Execute()`
4. Processor validates move via locked validation engine
5. If legal, gets new FEN from engine
6. Calls `service.ApplyMove()` to update state
7. Persists move with player identification
8. Returns GameResponse
6. Calls `service.ApplyMoveWithState()` with the FEN, turn, and state that were validated
7. Service rejects a stale concurrent commit or atomically updates the move, optional slot claim, and terminal result
8. The same logical mutation is queued as one SQLite transaction
9. Returns GameResponse
### Computer Move
1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}`
@@ -78,32 +96,44 @@ SQLite persistence with async writes for games, synchronous writes for authentic
7. Client disconnection cancels wait via context
8. Game deletion notifies and removes all waiters
### Durable Replay Read
1. Client requests `GET /api/v1/games/{id}/history`
2. Storage queues a barrier after all previously accepted gameplay writes
3. The writer reaches the barrier only after those transactions finish
4. Storage reads the game row and ordered moves in one read transaction
5. The API returns the initial FEN plus every UCI move and resulting FEN
6. This path works after terminal-memory eviction or a server restart
## Persistence Flow
### User Write Operations (Synchronous)
1. Service layer calls storage method directly (CreateUser, UpdateUserPassword, etc.)
2. Operations use database transactions for consistency
3. Unique constraint checks within transaction
4. Immediate commit or rollback
5. Returns success or specific error (duplicate username, etc.)
1. Service serializes public registrations within the process
2. Storage checks uniqueness and capacity in a transaction
3. At capacity, the oldest temporary user is evicted in that transaction
4. The new user and initial session commit together; any failure rolls back the entire operation
5. Login replaces the user's single session with one SQLite UPSERT
6. Other account mutations commit before success is returned
### Game Write Operations (Asynchronous)
1. Service layer calls storage method (RecordNewGame, RecordMove, DeleteUndoneMoves)
1. Service layer calls a storage method (`RecordNewGame`, `RecordMove`, `RecordPlayers`, or `RewindGame`)
2. Operation queued to buffered channel (non-blocking)
3. Writer goroutine processes queue sequentially
4. Transactions ensure atomicity
5. Failures trigger degradation to memory-only mode
4. Each logical mutation commits in one transaction
5. A full queue or write failure is logged and triggers degraded memory-only mode
6. Shutdown rejects new writes and drains every write already accepted
### Query Operations
1. CLI invokes Store.QueryGames or Store.GetUserByUsername with filters
2. Direct database read (no queue)
3. Case-insensitive matching for usernames/emails
4. Results formatted as tabular output
1. Replay-sensitive game reads wait on the async-write barrier
2. User-game filtering matches creation-time player IDs and later claim IDs
3. Move lookup uses the `UNIQUE(game_id, move_number)` index prefix and returns move order directly
4. Username and email lookups are case-insensitive
5. CLI database queries format records as tabular output
## Concurrency
- **HTTP Server**: Fiber handles concurrent connections
- **Game State**: Single RWMutex protects game map (concurrent reads, serial writes)
- **Move Validation**: Optimistic FEN/state/turn checks reject a result if the game changed while Stockfish was validating
- **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes
- **Validation Engine**: Single mutex-protected instance for synchronous validation
- **Storage Writer**: Single goroutine processes game write queue sequentially
@@ -140,6 +170,7 @@ type Snapshot struct {
"sub": "user-id",
"username": "alice",
"email": "alice@example.com",
"session_id": "session-id",
"exp": 1234567890
}
```
@@ -174,7 +205,11 @@ games (
black_type INTEGER,
black_level INTEGER,
black_search_time INTEGER,
start_time_utc DATETIME
start_time_utc DATETIME,
result TEXT, -- white_wins, black_wins, draw, or stalemate
end_time_utc DATETIME,
white_claimed_by TEXT, -- user that claimed the slot after creation
black_claimed_by TEXT
)
-- Move history
@@ -186,10 +221,17 @@ moves (
fen_after_move TEXT,
player_color TEXT,
move_time_utc DATETIME,
FOREIGN KEY (game_id) REFERENCES games(game_id)
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
UNIQUE (game_id, move_number)
)
```
Schema version 2 is applied idempotently to legacy databases with guarded
`ALTER TABLE ADD COLUMN` migrations. Redundant indexes formerly duplicating
UNIQUE constraints or useful index prefixes are removed. Purpose-built partial
indexes cover non-empty email uniqueness, temporary-user cleanup, session
expiry, and post-creation game claims.
## Security Architecture
### Authentication Flow
@@ -209,5 +251,5 @@ moves (
- Passwords never stored in plaintext
- JWT secret rotates on restart (or fixed in dev mode)
- User IDs use UUIDs with collision detection
- Transactions ensure data consistency
- Transactions keep registration, sessions, moves, claims, results, and rewinds internally consistent
- Case-insensitive queries prevent duplicate accounts
+13 -5
View File
@@ -12,6 +12,14 @@ The chess client is an interactive command-line debugging tool for the chess ser
- Verbose mode for detailed API request/response inspection
- Long-polling support for real-time game updates
## Replay Foundation
The Go API client exposes `GetGameHistory(gameID)` and
`GetMyGames(limit, offset)`, including durable result, timestamps, player
claims, move count, ordered UCI moves, and FEN after every move. Interactive
`games`/`replay` commands and terminal playback controls are intentionally
deferred to the dedicated replay iteration; see [Replay Implementation Tasks](./todo.md).
## Building
```bash
go build ./cmd/chess-client-cli
@@ -128,10 +136,10 @@ chess > state
```
#### `delete` / `d`
Delete game from server.
Unload a live game from server memory. Durable history remains available.
```
chess > delete # Delete current game
chess > delete <gameId> # Delete specific game
chess > delete # Unload current live game
chess > delete <gameId> # Unload specific live game
```
#### `poll` / `p`
@@ -230,9 +238,9 @@ ASCII board with colored pieces:
```
### Move History
Displayed in algebraic notation with move numbers:
Displayed in UCI notation with move numbers:
```
History: 1.e4 e5 2.Nf3 Nc6 3.Bb5
History: 1.e2e4 e7e5 2.g1f3 b8c6 3.f1b5
```
## Workflows
+56 -32
View File
@@ -2,7 +2,7 @@
## Prerequisites
- Go 1.24+
- Go 1.26+
- Stockfish in PATH
- SQLite3
- Git
@@ -14,7 +14,7 @@
git clone https://github.com/lixenwraith/chess
cd chess
go build ./cmd/chess-server
go build ./cmd/chess-client
go build ./cmd/chess-client-cli
```
## Running
@@ -29,20 +29,30 @@ go build ./cmd/chess-client
- `-storage-path`: SQLite database file path (enables persistence and authentication)
- `-pid`: PID file path for process tracking
- `-pid-lock`: Enable exclusive locking (requires -pid)
- `-log-level`: `debug`, `info`, `warn`, or `error` (default: `info`)
- `-log-http`: Enable API and web request logs (default: `true`)
- `-finished-game-ttl`: How long terminal games stay in memory (default: `1h`; `0` disables eviction)
- `-web-api-url`: Browser-visible API origin for the embedded web client; useful when its public origin differs from the listen address
### Modes
```bash
# In-memory only (no persistence or auth)
./chessd
./chess-server
# With persistence and authentication
./chessd -storage-path ./db/chess.db
./chess-server -storage-path ./db/chess.db
# Development with all features
./chessd -dev -storage-path chess.db -pid /tmp/chessd.pid -serve
./chess-server -dev -storage-path chess.db -pid /tmp/chess-server.pid -serve
# Detailed persistence, engine-queue, cleanup, and request logs
./chess-server -dev -storage-path chess.db -serve -log-level debug -log-http=true
# Web UI is public at one origin while the API is exposed at another
./chess-server -serve -web-api-url https://api.example.test
# Initialize database with user tables
./chessd db init -path chess.db
./chess-server db init -path chess.db
```
## Database Management
@@ -50,60 +60,64 @@ go build ./cmd/chess-client
### Schema Initialization
```bash
# Create all tables (users, games, moves)
./chessd db init -path chess.db
./chess-server db init -path chess.db
```
### User Management CLI
```bash
# Add user with password
./chessd db user add -path chess.db -username alice -password SecurePass123
./chess-server db user add -path chess.db -username alice -password SecurePass123
# Add user with email
./chessd db user add -path chess.db -username bob -email bob@example.com -password BobPass456
./chess-server db user add -path chess.db -username bob -email bob@example.com -password BobPass456
# Interactive password input
./chessd db user add -path chess.db -username charlie -interactive
./chess-server db user add -path chess.db -username charlie -interactive
# List all users
./chessd db user list -path chess.db
./chess-server db user list -path chess.db
# Update password
./chessd db user set-password -path chess.db -username alice -password NewPass789
./chess-server db user set-password -path chess.db -username alice -password NewPass789
# Update email
./chessd db user set-email -path chess.db -username alice -email newemail@example.com
./chess-server db user set-email -path chess.db -username alice -email newemail@example.com
# Update username
./chessd db user set-username -path chess.db -current alice -new alice2
./chess-server db user set-username -path chess.db -current alice -new alice2
# Import with existing Argon2 hash
./chessd db user set-hash -path chess.db -username alice -hash '$argon2id$v=19$m=65536,t=3,p=2$...'
./chess-server db user set-hash -path chess.db -username alice -hash '$argon2id$v=19$m=65536,t=3,p=2$...'
# Delete user
./chessd db user delete -path chess.db -username alice
./chess-server db user delete -path chess.db -username alice
```
### Game Query CLI
```bash
# Query all games
./chessd db query -path chess.db -gameId "*"
./chess-server db query -path chess.db -gameId "*"
# Query games for specific user
./chessd db query -path chess.db -playerId "550e8400-e29b-41d4-a716-446655440000"
./chess-server db query -path chess.db -playerId "550e8400-e29b-41d4-a716-446655440000"
# Query specific game
./chessd db query -path chess.db -gameId "a1b2c3d4-e5f6-7890-1234-567890abcdef"
./chess-server db query -path chess.db -gameId "a1b2c3d4-e5f6-7890-1234-567890abcdef"
# Delete database (destructive)
./chessd db delete -path chess.db
./chess-server db delete -path chess.db
```
## Authentication Configuration
### JWT Secret Management
- **Production**: Cryptographically secure 32-byte secret generated on startup
- **Production**: A cryptographically secure 32-byte secret is generated on
startup. This intentionally invalidates JWTs after a restart even though the
SQLite session rows remain; configuring a stable deployment secret is tracked
in `doc/todo.md`.
- **Development** (`-dev`): Fixed secret for testing consistency
- **Sessions**: Valid for 7 days, renewed on each login
- **Sessions**: Stored for 7 days and renewed on each login; effective token
lifetime is also bounded by signing-key rotation
### Password Requirements
- Minimum 8 characters
@@ -171,6 +185,12 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
# Test real-time game updates via long-polling
./test/test-longpoll.sh
# Unit, migration, and persistence tests
go test ./...
# Concurrency checks for the state/persistence boundary
go test -race ./internal/server/storage ./internal/server/service
```
## Configuration
@@ -180,10 +200,10 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
- Worker count: 2 (internal/processor/processor.go)
- Queue capacity: 100 (internal/processor/queue.go)
- Min search time: 100ms (internal/processor/processor.go)
- Write queue: 1000 operations (internal/storage/storage.go)
- DB connections: 25 max, 5 idle (internal/storage/storage.go)
- Write queue: 1000 operations (internal/server/storage/storage.go)
- DB connections: 8 max, 4 idle (internal/server/storage/storage.go)
- JWT expiration: 7 days (internal/service/user.go)
- Long-poll timeout: 25 seconds (internal/service/waiter.go)
- Long-poll timeout: 30 seconds (internal/server/service/waiter.go)
- Long-poll channel buffer: 1 (internal/service/waiter.go)
### Authentication Configuration
@@ -193,11 +213,13 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
- Hash algorithm: Argon2id (memory-hard, side-channel resistant)
### Storage Configuration
- WAL mode enabled in development for concurrency
- Foreign key constraints enforced
- Async write pattern for games with 2-second drain on shutdown
- WAL mode and NORMAL synchronous mode enabled on every connection
- Foreign key constraints and a five-second busy timeout enabled on every connection
- Async write pattern for games; shutdown drains every accepted write
- Replay reads wait for prior queued writes and use one read transaction
- Synchronous writes for user operations (data consistency)
- Degradation to memory-only on write failures
- Registration capacity/eviction, user creation, and initial session are atomic
- A full queue or write failure degrades to memory-only and is visible in logs and `/health`
- Case-insensitive collation for usernames and emails
### Rate Limiting Configuration
@@ -249,6 +271,8 @@ See [test documentation](../test/README.md) for comprehensive test suites coveri
- No password recovery mechanism
- No email verification for registration
- Fixed worker pool size for engine calculations
- No real-time game updates (polling required)
- Long-polling limited to 25 seconds per request
- REST API only
- No push-based game updates (30-second long-polling is used)
- Live games are not rehydrated after restart; persisted games are currently replay-only
- Database history has no automatic retention policy
- Curated-game metadata and replay controls are deferred to [Replay Implementation Tasks](./todo.md)
- REST API only
+266
View File
@@ -0,0 +1,266 @@
# Replay Implementation Tasks
This plan covers the remaining work needed for first-class replay in the web
and CLI clients, durable player-game browsing, and a curated archive of famous
games. The persistence/API foundation completed by the database audit is listed
first so later work does not duplicate or bypass it.
## Foundation Available Now
- [x] Store terminal `result` and `end_time_utc` on each game.
- [x] Persist `white_claimed_by` and `black_claimed_by`, including claims made
on the first valid move after game creation.
- [x] Commit each move, first-move claim, and move-caused terminal result in one
SQLite transaction.
- [x] Rewind moves and clear a stale terminal result/end time in one transaction.
- [x] Return ordered UCI moves with `fenAfterMove` through
`GET /api/v1/games/{gameId}/history`.
- [x] Return bounded pages through authenticated
`GET /api/v1/users/me/games?limit=&offset=`.
- [x] Provide matching Go client DTOs and methods (`GetGameHistory`,
`GetMyGames`) without prematurely adding CLI presentation.
- [x] Evict terminal games from memory after a configurable TTL while retaining
durable rows and moves.
- [x] Add an async-write barrier and a single SQLite read snapshot for immediate,
internally consistent replay reads.
- [x] Configure browser API origin through `/config`, with `/chess` fallback for
the static deployment at `lixen.com/projects/chess/`.
- [x] Add debug-level persistence, cleanup, engine queue, and lifecycle logging.
## Decisions Required Before Replay UI Work
| Decision | Current behavior | Decision needed |
|---|---|---|
| History visibility | Public to anyone with a game UUID, like live game reads | Keep public, make games private by default, or add per-game visibility |
| Live mutation authorization | Configure, undo, computer-trigger, and unload remain UUID-based; claimed slots protect human moves only | Define owner/opponent/spectator permissions before replay and archive UI expose more game discovery |
| Database retention | Indefinite; only terminal in-memory state is evicted | Retention by account type, archive status, age, or explicit deletion |
| Delete semantics | `DELETE /games/{id}` unloads memory but retains history | Name it “close/unload,” or add a separate authorized durable delete |
| Durability guarantee | Gameplay continues after a write failure; health becomes degraded | Keep best-effort, acknowledge writes, retry with an outbox, or fail gameplay closed |
| Result model | `white_wins`, `black_wins`, `draw`, `stalemate` | Keep compatibility or split outcome (`1-0`, `0-1`, `1/2-1/2`) from termination reason |
| Archived-game owner | Not created | Protected system/demo user, separate archive owner table, or ownerless source records |
| Replay notation | UCI plus authoritative FEN after each move | Add SAN and canonical PGN at import/write time or derive them on read |
Record these choices in an ADR before changing the v1 response contract. Until
privacy is decided, do not add searchable public player-game indexes or expose
usernames in public history.
## Phase 1 — Complete the Durable Game Model
### Results and termination
- [ ] Represent outcome separately from termination reason. Candidate fields:
`outcome`, `termination`, and optional `result_detail`.
- [ ] Detect and persist all supported draw paths, not only stalemate:
insufficient material, repetition, fifty/seventy-five-move rule, and agreed
draw if that interaction is added.
- [ ] Define behavior for resignation, timeout, abandonment, engine failure,
and administrative termination.
- [ ] Add constraints covering valid combinations: an end time requires a
terminal outcome; an ongoing game has neither.
- [ ] Decide whether undoing a finished rated/player game is allowed. If yes,
preserve an audit event rather than silently rewriting official history.
### Stable participant metadata
- [ ] Snapshot display names at game start/end so replay remains readable after
a user rename or temporary-account deletion.
- [ ] Separate historical participant identity from mutable controller config.
Changing a human slot to a computer must never remove the user's game link.
- [ ] Decide whether anonymous players receive a durable pseudonym, remain
unnamed, or are excluded from archive browsing.
- [ ] Add optional clocks/time-control metadata before timeout results are
supported.
### Notation and integrity
- [ ] Add SAN per ply and canonical PGN, or add a deterministic backend
converter from the stored initial FEN/UCI line.
- [ ] Validate that `move_number`, `player_color`, FEN side-to-move, and the
previous position form one legal continuous line.
- [ ] Add a stored content hash for import idempotency and corruption checks.
- [ ] Add a repair/audit CLI command that reports broken game rows without
mutating them; make repair an explicit separate operation.
- [ ] Define a schema-migration policy beyond v2, including forward-version
rejection, backup instructions, and rollback limitations.
## Phase 2 — Replay and Library APIs
### Player games
- [ ] Add filters to the authenticated list: `status`, `result`, color, opponent
type, and date range.
- [ ] Replace offset pagination with a stable `(start_time_utc, game_id)` cursor
before the table grows large; retain v1 offset parameters during migration.
- [ ] Return a compact display label/opponent summary so clients do not recreate
association logic.
- [ ] Define whether an authenticated user may list a game merely created for
their random player ID versus one explicitly claimed by them.
- [ ] Add authorization tests for expired/deleted sessions and attempts to list
another user's games.
### Replay payload
- [ ] Version the history payload before adding annotations, evaluations,
comments, variations, clocks, or PGN tags.
- [ ] Include a canonical final FEN and normalized outcome/termination fields.
- [ ] Decide whether long games return one payload or paged/chunked moves.
- [ ] Add `ETag`/`If-None-Match` for immutable finished histories.
- [ ] Add a downloadable PGN response with correct `Content-Type` and filename.
- [ ] Return an explicit “ongoing/incomplete” marker when history is requested
before a terminal result.
### Live-game restoration
- [ ] Decide whether a server restart should make unfinished games playable or
replay-only.
- [ ] If play must resume, load the last persisted FEN, next turn, player config,
claims, and move list into memory at startup.
- [ ] Mark games interrupted in `pending` state as recoverable `stuck` or
`ongoing`; never re-submit an engine task blindly.
- [ ] Define reconciliation when the service previously entered degraded mode
and memory contains moves absent from SQLite.
## Phase 3 — Curated Famous-Game Archive
### Schema and ownership
- [ ] Add a game origin such as `player`, `curated`, or `imported`.
- [ ] Add searchable archive metadata: title, event, site, event date, round,
white/black display names, Elo values, ECO/opening, source URL, source license,
attribution text, and import timestamp.
- [ ] Add publication state, featured flag, and explicit featured rank/order.
- [ ] Create a protected demo/system identity only if ownership remains tied to
users. It must not consume temporary-user capacity, expire, authenticate, or
be evicted/deleted through normal user tools.
- [ ] Prefer a separate protected archive owner over credentials embedded in
seed scripts.
- [ ] Add only indexes backed by actual archive queries; confirm each with
`EXPLAIN QUERY PLAN` and a representative data volume.
### Import pipeline
- [ ] Add `chess-server db archive import` for one PGN or a directory.
- [ ] Parse PGN tags, comments, NAGs, and variations deliberately; document
which are preserved and which are discarded in the first version.
- [ ] Validate every main-line move from its initial position and generate the
authoritative FEN sequence before opening the transaction.
- [ ] Import a game and all moves in one transaction.
- [ ] Make repeated imports idempotent by source key/content hash.
- [ ] Add dry-run, structured error output, per-file summary, and all-or-nothing
versus continue-on-error modes.
- [ ] Preserve source attribution and verify redistribution rights for every
bundled collection.
- [ ] Seed a small, reviewed fixture set in tests; keep large archives outside
the executable and repository unless licensing and binary size are accepted.
### Archive API
- [ ] Add a public, bounded curated list endpoint with stable sorting.
- [ ] Add exact filters required by the UI (featured, player name, event, year,
ECO); do not expose an unconstrained database query API.
- [ ] Reuse the same history representation for player and curated games.
- [ ] Cache immutable curated list/history responses and invalidate only on
archive administration.
## Phase 4 — CLI Replay Experience
- [ ] Add `games`/`games mine` to call `GetMyGames`, show pagination, result,
colors, opponent/controller, date, and move count.
- [ ] Add `games featured` after the curated endpoint exists.
- [ ] Add `replay <gameId>` and allow selection from a prior list result.
- [ ] Render the initial FEN before ply 1; never assume the standard start.
- [ ] Add next/previous/start/end navigation, move-number jump, and optional
autoplay speed.
- [ ] Display UCI initially and SAN once the backend contract supplies it.
- [ ] Clearly separate replay state from live session state: replay commands
must not poll, move, undo, configure, or delete the live game.
- [ ] Add `pgn save <path>` after the PGN endpoint is defined.
- [ ] Cover empty lists, ongoing histories, custom FEN, malformed/incomplete
history, expired auth, server restart, and deleted live-memory state.
## Phase 5 — Web Replay Experience
- [ ] Add “My games” for authenticated users and a separate “Classic games”
collection available without login.
- [ ] Build accessible loading, empty, pagination, and error states.
- [ ] Add a replay route/deep link, for example `?replay=<gameId>`, that works
beneath `/projects/chess/` and does not assume the API shares that path.
- [ ] Initialize from `initialFen`; step by assigning the stored
`fenAfterMove`, not by replaying moves through a browser chess engine.
- [ ] Add previous/next/start/end buttons, move-list selection, keyboard
controls, autoplay speed, pause, and current-ply announcement.
- [ ] Disable move, computer-trigger, undo, and player-configuration actions in
replay mode.
- [ ] Stop live long-polling when replay mode begins and restore it only when a
live game is explicitly reopened.
- [ ] Show result, termination, players, date/event, source attribution, and
custom-start notice.
- [ ] Make browser back/forward restore list filters and replay ply.
- [ ] Test both embedded `/config` and the deployed `/chess` fallback, including
CORS and reverse-proxy headers.
- [ ] Add responsive and accessibility checks for board orientation, focus,
screen-reader labels, reduced motion, and high contrast.
## Phase 6 — Durability, Operations, and Scale
- [ ] Choose and implement the durability contract from the decision table.
For acknowledged persistence, return success only after a writer receipt or
use a durable outbox with retries and ordering.
- [ ] Expose counters/metrics for queue depth, enqueue rejection, write latency,
failed transaction, barrier latency, replay read latency, and terminal-memory
eviction.
- [ ] Add request/game correlation fields to logs without logging JWTs,
passwords, or full private payloads.
- [ ] Configure a stable production JWT signing key (prefer a secret file or
deployment secret) so persisted sessions can survive a server restart;
document rotation and invalidation procedures.
- [ ] Add a bounded degraded-mode recovery procedure; current behavior requires
operator intervention/restart and cannot reconstruct missing writes.
- [ ] Benchmark list and history queries with realistic user/archive sizes and
verify query plans in CI.
- [ ] Set WAL checkpoint and database backup procedures; test online backup and
restore with active reads/writes.
- [ ] Define database retention separately for anonymous, temporary-user,
permanent-user, and curated games.
- [ ] Add authorized durable deletion/anonymization if required by the privacy
policy, with archive records protected from accidental cascades.
## Required Test Matrix
- [ ] Upgrade a production-shaped legacy database to every new schema version
and reopen it with foreign keys enabled on multiple pooled connections.
- [ ] Read history immediately after create, move, terminal move, slot claim,
player reconfiguration, and undo—without sleeps.
- [ ] Run concurrent legal moves from one position; exactly one may commit and
the loser must receive `GAME_CONFLICT`.
- [ ] Submit duplicate computer triggers; only one engine task may run.
- [ ] Fill the write queue/fault SQLite and assert degraded health, visible
logging, and documented client behavior.
- [ ] Shut down with queued writes and prove all accepted writes drain.
- [ ] Restart after a finished game and replay the exact FEN sequence/result.
- [ ] Evict a terminal game from memory and replay it from SQLite.
- [ ] Change a claimed human slot to computer and verify “My games” association
remains.
- [ ] Verify registration duplicate/session failures roll back account creation
and capacity eviction.
- [ ] Exercise public/private history rules for anonymous, owner, opponent, and
unrelated authenticated clients.
- [ ] Validate imported PGNs with promotions, castling, en passant, custom FEN,
comments, and every supported result.
- [ ] Run Go unit/race tests, HTTP integration scripts, JavaScript syntax/tests,
and browser end-to-end replay navigation in CI.
## Replay Definition of Done
- A finished player game survives restart, appears once in its owner's list,
and replays deterministically from the stored initial FEN to the stored final
FEN in both clients.
- A curated game is imported idempotently with source attribution, appears in a
stable public collection, and uses the same replay path as a player game.
- Undo, player reconfiguration, terminal eviction, and concurrent requests
cannot produce a stale result, missing claim, duplicate ply, or mixed history
snapshot.
- Privacy, retention, durable deletion, and degraded-write behavior are
documented and enforced consistently by API, storage, web, and CLI layers.
- Query plans and benchmarks show no redundant indexes or unbounded list scans
at the agreed deployment size.
+19 -6
View File
@@ -23,7 +23,7 @@ type Client struct {
func New(baseURL string) *Client {
return &Client{
BaseURL: baseURL,
BaseURL: strings.TrimRight(baseURL, "/"),
HTTPClient: &http.Client{
Timeout: HttpTimeout,
},
@@ -81,7 +81,7 @@ func (c *Client) doRequest(method, path string, body any, result any) error {
json.Unmarshal([]byte(bodyStr), &prettyBody)
prettyJSON, _ := json.MarshalIndent(prettyBody, "", " ")
display.Println(display.Cyan, "Request Body:")
display.Println(display.Reset, string(prettyJSON))
display.Println(display.Reset, "%s", string(prettyJSON))
} else {
display.Print(display.Blue, "%s\n", bodyStr)
}
@@ -114,10 +114,10 @@ func (c *Client) doRequest(method, path string, body any, result any) error {
if err := json.Unmarshal(respBody, &prettyResp); err == nil {
prettyJSON, _ := json.MarshalIndent(prettyResp, "", " ")
display.Println(display.Cyan, "Response Body:")
display.Println(display.Reset, string(prettyJSON))
display.Println(display.Reset, "%s", string(prettyJSON))
} else {
display.Println(display.Cyan, "Response:")
display.Println(display.Reset, string(respBody))
display.Println(display.Reset, "%s", string(respBody))
}
}
@@ -135,7 +135,7 @@ func (c *Client) doRequest(method, path string, body any, result any) error {
}
}
} else if !c.Verbose {
display.Println(display.Red, string(respBody))
display.Println(display.Red, "%s", string(respBody))
}
return fmt.Errorf("request failed with status %d", resp.StatusCode)
}
@@ -204,6 +204,19 @@ func (c *Client) GetBoard(gameID string) (*BoardResponse, error) {
return &resp, err
}
func (c *Client) GetGameHistory(gameID string) (*GameHistoryResponse, error) {
var resp GameHistoryResponse
err := c.doRequest("GET", "/api/v1/games/"+gameID+"/history", nil, &resp)
return &resp, err
}
func (c *Client) GetMyGames(limit, offset int) (*GameListResponse, error) {
var resp GameListResponse
path := fmt.Sprintf("/api/v1/users/me/games?limit=%d&offset=%d", limit, offset)
err := c.doRequest("GET", path, nil, &resp)
return &resp, err
}
func (c *Client) Register(username, password, email string) (*AuthResponse, error) {
req := &RegisterRequest{
Username: username,
@@ -246,4 +259,4 @@ func (c *Client) RawRequest(method, path string, body string) error {
}
return c.doRequest(method, path, bodyData, nil)
}
}
+38 -1
View File
@@ -52,9 +52,11 @@ type PlayersResponse struct {
type PlayerInfo struct {
ID string `json:"id"`
Color int `json:"color"`
Type int `json:"type"`
Level int `json:"level,omitempty"`
SearchTime int `json:"searchTime,omitempty"`
ClaimedBy string `json:"claimedBy,omitempty"`
}
type MoveInfo struct {
@@ -95,4 +97,39 @@ type HealthResponse struct {
Status string `json:"status"`
Time int64 `json:"time"`
Storage string `json:"storage,omitempty"`
}
}
type GameHistoryResponse struct {
GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"`
Result string `json:"result,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
Players PlayersResponse `json:"players"`
Moves []HistoryMove `json:"moves"`
}
type HistoryMove struct {
MoveNumber int `json:"moveNumber"`
MoveUCI string `json:"moveUci"`
FENAfterMove string `json:"fenAfterMove"`
PlayerColor string `json:"playerColor"`
MoveTimeUTC time.Time `json:"moveTimeUtc"`
}
type GameSummary struct {
GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"`
Result string `json:"result,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
MoveCount int `json:"moveCount"`
Players PlayersResponse `json:"players"`
}
type GameListResponse struct {
Games []GameSummary `json:"games"`
Limit int `json:"limit"`
Offset int `json:"offset"`
NextOffset *int `json:"nextOffset,omitempty"`
}
+2 -2
View File
@@ -72,7 +72,7 @@ func (r *Registry) registerGameCommands() {
r.Register(&Command{
Name: "delete",
ShortName: "d",
Description: "Delete a game",
Description: "Unload a live game (history retained)",
Usage: "delete [gameId]",
Handler: deleteGameHandler,
})
@@ -495,7 +495,7 @@ func deleteGameHandler(s *session.Session, args []string) error {
s.SetLastMoveCount(0)
}
fmt.Printf("%sGame deleted: %s%s\n", display.Green, gameID, display.Reset)
fmt.Printf("%sLive game unloaded (history retained): %s%s\n", display.Green, gameID, display.Reset)
return nil
}
+44 -2
View File
@@ -1,5 +1,7 @@
package core
import "time"
// Request types
type CreateGameRequest struct {
@@ -27,7 +29,7 @@ type GameResponse struct {
GameID string `json:"gameId"`
FEN string `json:"fen"`
Turn string `json:"turn"` // "w" or "b"
State string `json:"state"` // "ongoing", "white_wins", etc
State string `json:"state"` // "ongoing", "white wins", etc
Moves []string `json:"moves"`
Players PlayersResponse `json:"players"`
LastMove *MoveInfo `json:"lastMove,omitempty"`
@@ -45,8 +47,48 @@ type BoardResponse struct {
Board string `json:"board"` // ASCII representation
}
// GameHistoryResponse is the durable replay representation of a game. Moves
// are ordered and include the resulting FEN so clients do not need an engine
// to replay a stored game.
type GameHistoryResponse struct {
GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"`
Result string `json:"result,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
Players PlayersResponse `json:"players"`
Moves []HistoryMove `json:"moves"`
}
type HistoryMove struct {
MoveNumber int `json:"moveNumber"`
MoveUCI string `json:"moveUci"`
FENAfterMove string `json:"fenAfterMove"`
PlayerColor string `json:"playerColor"`
MoveTimeUTC time.Time `json:"moveTimeUtc"`
}
// GameSummary is intentionally sufficient for a client-side game picker; the
// full move list remains on the per-game history endpoint.
type GameSummary struct {
GameID string `json:"gameId"`
InitialFEN string `json:"initialFen"`
Result string `json:"result,omitempty"`
StartTimeUTC time.Time `json:"startTimeUtc"`
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
MoveCount int `json:"moveCount"`
Players PlayersResponse `json:"players"`
}
type GameListResponse struct {
Games []GameSummary `json:"games"`
Limit int `json:"limit"`
Offset int `json:"offset"`
NextOffset *int `json:"nextOffset,omitempty"`
}
type ErrorResponse struct {
Error string `json:"error"`
Code string `json:"code"`
Details string `json:"details,omitempty"`
}
}
+14 -12
View File
@@ -2,15 +2,17 @@ package core
// Error codes
const (
ErrGameNotFound = "GAME_NOT_FOUND"
ErrInvalidMove = "INVALID_MOVE"
ErrNotHumanTurn = "NOT_HUMAN_TURN"
ErrGameOver = "GAME_OVER"
ErrRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
ErrInvalidContent = "INVALID_CONTENT_TYPE"
ErrInvalidRequest = "INVALID_REQUEST"
ErrInvalidFEN = "INVALID_FEN"
ErrInternalError = "INTERNAL_ERROR"
ErrResourceLimit = "RESOURCE_LIMIT"
ErrUnauthorized = "UNAUTHORIZED"
)
ErrGameNotFound = "GAME_NOT_FOUND"
ErrInvalidMove = "INVALID_MOVE"
ErrNotHumanTurn = "NOT_HUMAN_TURN"
ErrGameOver = "GAME_OVER"
ErrRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
ErrInvalidContent = "INVALID_CONTENT_TYPE"
ErrInvalidRequest = "INVALID_REQUEST"
ErrInvalidFEN = "INVALID_FEN"
ErrInternalError = "INTERNAL_ERROR"
ErrResourceLimit = "RESOURCE_LIMIT"
ErrUnauthorized = "UNAUTHORIZED"
ErrConflict = "GAME_CONFLICT"
ErrStorageUnavailable = "STORAGE_UNAVAILABLE"
)
+29 -2
View File
@@ -5,7 +5,7 @@ type State int
const (
StateOngoing State = iota
StatePending // Computer is calculating a move
StateStuck // Computer is calculating a move
StateStuck // Engine work failed and requires recovery or undo
StateWhiteWins
StateBlackWins
StateDraw
@@ -31,4 +31,31 @@ func (s State) String() string {
default:
return "unknown"
}
}
}
// IsTerminal reports whether the game has a durable result. Pending and stuck
// are operational states and must not be archived as completed games.
func (s State) IsTerminal() bool {
switch s {
case StateWhiteWins, StateBlackWins, StateDraw, StateStalemate:
return true
default:
return false
}
}
// Result returns the stable persistence/API value for a terminal state.
func (s State) Result() (string, bool) {
switch s {
case StateWhiteWins:
return "white_wins", true
case StateBlackWins:
return "black_wins", true
case StateDraw:
return "draw", true
case StateStalemate:
return "stalemate", true
default:
return "", false
}
}
+109 -11
View File
@@ -2,6 +2,7 @@ package game
import (
"fmt"
"time"
"chess/internal/server/board"
"chess/internal/server/core"
@@ -25,20 +26,41 @@ type MoveResult struct {
}
type Game struct {
snapshots []Snapshot `json:"snapshots"`
players map[core.Color]*core.Player `json:"players"`
state core.State `json:"state"`
lastResult *MoveResult `json:"lastResult,omitempty"`
snapshots []Snapshot
players map[core.Color]*core.Player
state core.State
lastResult *MoveResult
endTimeUTC *time.Time
}
// View is an immutable copy of the state needed by processors and transports.
// Service returns views instead of exposing mutable Game pointers outside its
// lock, preventing torn responses and concurrent move races.
type View struct {
FEN string
InitialFEN string
NextTurnColor core.Color
Moves []string
WhitePlayer *core.Player
BlackPlayer *core.Player
State core.State
LastResult *MoveResult
EndTimeUTC *time.Time
}
func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnColor core.Color) *Game {
// Determine which player's turn it is initially
var initialPlayerID string
var initialPlayerType core.PlayerType
if startingTurnColor == core.ColorWhite {
initialPlayerID = whitePlayer.ID
initialPlayerType = whitePlayer.Type
} else {
initialPlayerID = blackPlayer.ID
initialPlayerType = blackPlayer.Type
}
whiteCopy := *whitePlayer
blackCopy := *blackPlayer
return &Game{
snapshots: []Snapshot{
@@ -46,19 +68,69 @@ func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnC
FEN: initialFEN,
PreviousMove: "",
NextTurnColor: startingTurnColor,
PlayerType: initialPlayerType,
PlayerID: initialPlayerID,
},
},
players: map[core.Color]*core.Player{
core.ColorWhite: whitePlayer,
core.ColorBlack: blackPlayer,
core.ColorWhite: &whiteCopy,
core.ColorBlack: &blackCopy,
},
state: core.StateOngoing,
}
}
func (g *Game) View() View {
view := View{
FEN: g.CurrentFEN(),
InitialFEN: g.InitialFEN(),
NextTurnColor: g.NextTurnColor(),
Moves: g.Moves(),
State: g.state,
}
if player := g.players[core.ColorWhite]; player != nil {
copy := *player
view.WhitePlayer = &copy
}
if player := g.players[core.ColorBlack]; player != nil {
copy := *player
view.BlackPlayer = &copy
}
if g.lastResult != nil {
copy := *g.lastResult
view.LastResult = &copy
}
if g.endTimeUTC != nil {
copy := *g.endTimeUTC
view.EndTimeUTC = &copy
}
return view
}
func (v View) NextPlayer() *core.Player {
if v.NextTurnColor == core.ColorWhite {
return v.WhitePlayer
}
return v.BlackPlayer
}
func (v View) Player(color core.Color) *core.Player {
if color == core.ColorWhite {
return v.WhitePlayer
}
if color == core.ColorBlack {
return v.BlackPlayer
}
return nil
}
func (g *Game) SetLastResult(result *MoveResult) {
g.lastResult = result
if result == nil {
g.lastResult = nil
return
}
copy := *result
g.lastResult = &copy
}
func (g *Game) LastResult() *MoveResult {
@@ -94,18 +166,23 @@ func (g *Game) AddSnapshot(fen string, move string, nextTurnColor core.Color) {
FEN: fen,
PreviousMove: move,
NextTurnColor: nextTurnColor,
PlayerType: nextPlayer.Type,
PlayerID: nextPlayer.ID,
})
}
func (g *Game) UpdatePlayers(whitePlayer, blackPlayer *core.Player) {
g.players[core.ColorWhite] = whitePlayer
g.players[core.ColorBlack] = blackPlayer
whiteCopy := *whitePlayer
blackCopy := *blackPlayer
g.players[core.ColorWhite] = &whiteCopy
g.players[core.ColorBlack] = &blackCopy
// Update current snapshot's PlayerID to reflect new player
if len(g.snapshots) > 0 {
currentSnap := &g.snapshots[len(g.snapshots)-1]
currentSnap.PlayerID = g.players[currentSnap.NextTurnColor].ID
currentPlayer := g.players[currentSnap.NextTurnColor]
currentSnap.PlayerID = currentPlayer.ID
currentSnap.PlayerType = currentPlayer.Type
}
}
@@ -122,6 +199,7 @@ func (g *Game) UndoMoves(count int) error {
g.snapshots = g.snapshots[:len(g.snapshots)-count]
g.state = core.StateOngoing // Reset game state when undoing
g.lastResult = nil // Clear last result
g.endTimeUTC = nil
return nil
}
@@ -140,9 +218,29 @@ func (g *Game) State() core.State {
}
func (g *Game) SetState(s core.State) {
g.SetStateAt(s, time.Now().UTC())
}
func (g *Game) SetStateAt(s core.State, at time.Time) {
if s.IsTerminal() {
if !g.state.IsTerminal() || g.endTimeUTC == nil {
ended := at.UTC()
g.endTimeUTC = &ended
}
} else if g.state.IsTerminal() {
g.endTimeUTC = nil
}
g.state = s
}
func (g *Game) EndTimeUTC() *time.Time {
if g.endTimeUTC == nil {
return nil
}
copy := *g.endTimeUTC
return &copy
}
func (g *Game) InitialFEN() string {
if len(g.snapshots) > 0 {
return g.snapshots[0].FEN
@@ -191,4 +289,4 @@ func (g *Game) HasComputerPlayer() bool {
black := g.players[core.ColorBlack]
return (white != nil && white.Type == core.PlayerComputer) ||
(black != nil && black.Type == core.PlayerComputer)
}
}
+12 -12
View File
@@ -91,9 +91,14 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
req.Email = strings.ToLower(req.Email)
}
// Create user (temp by default via API)
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password, false)
// Create the user and initial session atomically (temp by default via API).
user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password, false)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrPermanentSlotsFull) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "registration temporarily unavailable",
@@ -114,15 +119,6 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
})
}
// Create session for new user
sessionID, err := h.svc.CreateUserSession(user.UserID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to create session",
Code: core.ErrInternalError,
})
}
// Generate JWT token
token, err := h.svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
@@ -193,6 +189,11 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
// Authenticate user and create session (invalidates previous session)
user, sessionID, err := h.svc.AuthenticateUser(req.Identifier, req.Password)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid credentials",
Code: core.ErrInvalidRequest,
@@ -263,4 +264,3 @@ func (h *HTTPHandler) LogoutHandler(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "logged out"})
}
+104 -10
View File
@@ -1,6 +1,7 @@
package http
import (
"errors"
"fmt"
"strconv"
"strings"
@@ -29,7 +30,7 @@ func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandle
return &HTTPHandler{proc: proc, svc: svc}
}
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) *fiber.App {
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRequests bool) *fiber.App {
// Create handler
h := NewHTTPHandler(proc, svc)
@@ -43,9 +44,13 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
// Global middleware (order matters)
app.Use(recover.New())
app.Use(logger.New(logger.Config{
Format: "${time} ${status} ${method} ${path} ${latency}\n",
}))
if logRequests {
app.Use(logger.New(logger.Config{
Format: "${time} HTTP ${status} ${method} ${path} ${latency}\n",
TimeFormat: time.RFC3339,
TimeZone: "UTC",
}))
}
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
@@ -136,12 +141,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
// Register game routes with auth middleware
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
api.Get("/games/:gameId/history", h.GetGameHistory)
api.Put("/games/:gameId/players", h.ConfigurePlayers)
api.Get("/games/:gameId", h.GetGame)
api.Delete("/games/:gameId", h.DeleteGame)
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
api.Post("/games/:gameId/undo", h.UndoMove)
api.Get("/games/:gameId/board", h.GetBoard)
api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames)
return app
}
@@ -191,10 +198,15 @@ func customErrorHandler(c *fiber.Ctx, err error) error {
// Health check endpoint with storage status
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
storageHealth := h.svc.GetStorageHealth()
status := "healthy"
if storageHealth == "degraded" {
status = "degraded"
}
return c.JSON(fiber.Map{
"status": "healthy",
"status": status,
"time": time.Now().Unix(),
"storage": h.svc.GetStorageHealth(),
"storage": storageHealth,
})
}
@@ -324,7 +336,7 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
}
// First check if game exists and get current state
g, err := h.svc.GetGame(gameID)
g, err := h.svc.GetGameView(gameID)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game not found",
@@ -332,8 +344,8 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
})
}
currentMoveCount := len(g.Moves())
st := g.State()
currentMoveCount := len(g.Moves)
st := g.State
settled := st != core.StateOngoing && st != core.StatePending
// If move count already different, return immediately
if moveCount != currentMoveCount || settled {
@@ -414,6 +426,8 @@ func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
statusCode = fiber.StatusNotFound
case core.ErrUnauthorized:
statusCode = fiber.StatusForbidden
case core.ErrConflict:
statusCode = fiber.StatusConflict
}
return c.Status(statusCode).JSON(resp.Error)
}
@@ -470,7 +484,7 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
return c.JSON(resp.Data)
}
// DeleteGame ends and cleans up a game
// DeleteGame unloads a live game while retaining its durable history.
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
gameID := c.Params("gameId")
@@ -519,3 +533,83 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
return c.JSON(resp.Data)
}
// GetGameHistory serves persisted replay data. Histories are public by game ID,
// matching the existing public live-game read model.
func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error {
gameID := c.Params("gameId")
if !isValidUUID(gameID) {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid game ID format", Code: core.ErrInvalidRequest,
Details: "game ID must be a valid UUID",
})
}
history, err := h.svc.GetGameHistory(gameID)
if err != nil {
switch {
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "game history storage unavailable", Code: core.ErrStorageUnavailable,
})
case errors.Is(err, service.ErrGameNotFound):
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
Error: "game history not found", Code: core.ErrGameNotFound,
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to load game history", Code: core.ErrInternalError,
})
}
}
return c.JSON(history)
}
// GetCurrentUserGames returns a bounded list suitable for CLI and web game
// pickers. The extra row used to compute nextOffset stays internal.
func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(string)
if !ok || userID == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "unauthorized", Code: core.ErrUnauthorized,
})
}
limit, err := queryInt(c, "limit", 50, 1, 100)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid pagination", Code: core.ErrInvalidRequest, Details: err.Error(),
})
}
offset, err := queryInt(c, "offset", 0, 0, 1_000_000)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
Error: "invalid pagination", Code: core.ErrInvalidRequest, Details: err.Error(),
})
}
games, err := h.svc.GetUserGames(userID, limit, offset)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to load stored games", Code: core.ErrInternalError,
})
}
return c.JSON(games)
}
func queryInt(c *fiber.Ctx, name string, defaultValue, minimum, maximum int) (int, error) {
raw := c.Query(name)
if raw == "" {
return defaultValue, nil
}
value, err := strconv.Atoi(raw)
if err != nil || value < minimum || value > maximum {
return 0, fmt.Errorf("%s must be between %d and %d", name, minimum, maximum)
}
return value, nil
}
+25 -8
View File
@@ -1,9 +1,11 @@
package http
import (
"errors"
"strings"
"chess/internal/server/core"
"chess/internal/server/service"
"github.com/gofiber/fiber/v2"
)
@@ -18,15 +20,20 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
if token == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "missing authorization token",
Code: core.ErrInvalidRequest,
Code: core.ErrUnauthorized,
})
}
userID, claims, err := validateToken(token)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token",
Code: core.ErrInvalidRequest,
Code: core.ErrUnauthorized,
})
}
@@ -38,7 +45,8 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
}
}
// OptionalAuth validates JWT if present but allows anonymous access
// OptionalAuth permits an absent token but rejects an invalid token instead of
// silently downgrading an intended authenticated request to anonymous access.
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
return func(c *fiber.Ctx) error {
token := extractBearerToken(c.Get("Authorization"))
@@ -47,11 +55,20 @@ func OptionalAuth(validateToken TokenValidator) fiber.Handler {
}
userID, claims, err := validateToken(token)
if err == nil {
c.Locals("userID", userID)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token", Code: core.ErrUnauthorized,
})
}
c.Locals("userID", userID)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
}
return c.Next()
}
@@ -64,4 +81,4 @@ func extractBearerToken(header string) string {
return ""
}
return strings.TrimPrefix(header, prefix)
}
}
+60
View File
@@ -0,0 +1,60 @@
package http
import (
"errors"
"net/http/httptest"
"testing"
"chess/internal/server/service"
"github.com/gofiber/fiber/v2"
)
func TestOptionalAuthAllowsAbsenceButRejectsInvalidToken(t *testing.T) {
app := fiber.New()
app.Get("/optional", OptionalAuth(func(string) (string, map[string]any, error) {
return "", nil, errors.New("invalid token")
}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
})
request := httptest.NewRequest("GET", "/optional", nil)
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusNoContent {
t.Fatalf("anonymous status = %d, want %d", response.StatusCode, fiber.StatusNoContent)
}
request = httptest.NewRequest("GET", "/optional", nil)
request.Header.Set("Authorization", "Bearer invalid")
response, err = app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusUnauthorized {
t.Fatalf("invalid-token status = %d, want %d", response.StatusCode, fiber.StatusUnauthorized)
}
}
func TestAuthMiddlewareReportsStorageUnavailable(t *testing.T) {
for _, middleware := range []func(TokenValidator) fiber.Handler{AuthRequired, OptionalAuth} {
app := fiber.New()
app.Get("/protected", middleware(func(string) (string, map[string]any, error) {
return "", nil, service.ErrStorageUnavailable
}), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
})
request := httptest.NewRequest("GET", "/protected", nil)
request.Header.Set("Authorization", "Bearer token")
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != fiber.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", response.StatusCode, fiber.StatusServiceUnavailable)
}
}
}
+85 -65
View File
@@ -1,8 +1,9 @@
package processor
import (
"errors"
"fmt"
"log"
"log/slog"
"regexp"
"strings"
"sync"
@@ -185,14 +186,11 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
}
}
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn()); err != nil {
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn(), initialState); err != nil {
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
}
if initialState != core.StateOngoing {
p.svc.UpdateGameState(gameID, initialState)
}
g, err := p.svc.GetGame(gameID)
g, err := p.svc.GetGameView(gameID)
if err != nil {
return p.errorResponse("game creation failed", core.ErrInternalError)
}
@@ -217,13 +215,13 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
args.Black.SearchTime = minSearchTime
}
g, err := p.svc.GetGame(cmd.GameID)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
// Block configuration changes during computer move
if g.State() == core.StatePending {
if g.State == core.StatePending {
return p.errorResponse("cannot change players while computer is calculating", core.ErrInvalidRequest)
}
@@ -237,7 +235,7 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
}
// Get updated game
g, _ = p.svc.GetGame(cmd.GameID)
g, _ = p.svc.GetGameView(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
return ProcessorResponse{
@@ -248,7 +246,7 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
// handleGetGame retrieves game state and triggers computer move if needed
func (p *Processor) handleGetGame(cmd Command) ProcessorResponse {
g, err := p.svc.GetGame(cmd.GameID)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
@@ -272,27 +270,30 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
}
g, err := p.svc.GetGame(cmd.GameID)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
// Validate game state
switch g.State() {
switch g.State {
case core.StatePending:
return p.errorResponse("computer move in progress", core.ErrInvalidRequest)
case core.StateStuck:
return p.errorResponse("game is stuck due to engine error", core.ErrGameOver)
case core.StateWhiteWins, core.StateBlackWins, core.StateDraw, core.StateStalemate:
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State()), core.ErrGameOver)
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
case core.StateOngoing:
break
default:
return p.errorResponse("game is in invalid state", core.ErrInvalidRequest)
}
currentColor := g.NextTurnColor()
currentColor := g.NextTurnColor
currentPlayer := g.NextPlayer()
if currentPlayer == nil {
return p.errorResponse("current player is missing", core.ErrInternalError)
}
// Handle computer move trigger
if strings.TrimSpace(args.Move) == "cccc" {
@@ -300,10 +301,18 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
return p.errorResponse("not computer player's turn", core.ErrNotHumanTurn)
}
p.svc.UpdateGameState(cmd.GameID, core.StatePending)
p.triggerComputerMove(cmd.GameID, g)
if err := p.svc.BeginComputerMove(cmd.GameID, g.FEN, currentColor); err != nil {
if errors.Is(err, service.ErrGameChanged) {
return p.errorResponse("game changed; refresh and retry", core.ErrConflict)
}
return p.errorResponse(fmt.Sprintf("failed to start computer move: %v", err), core.ErrInternalError)
}
if err := p.triggerComputerMove(cmd.GameID, g); err != nil {
p.svc.UpdateGameState(cmd.GameID, core.StateStuck)
return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit)
}
g, _ = p.svc.GetGame(cmd.GameID)
g, _ = p.svc.GetGameView(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
response.LastMove = &core.MoveInfo{
PlayerColor: currentColor.String(),
@@ -322,16 +331,11 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
}
// Authorization: first-move-claims-slot model
slotOwner := g.GetSlotOwner(currentColor)
slotOwner := currentPlayer.ClaimedBy
if slotOwner == "" {
// Slot unclaimed - claim it with this move
if cmd.UserID != "" {
if err := p.svc.ClaimGameSlot(cmd.GameID, currentColor, cmd.UserID); err != nil {
return p.errorResponse(fmt.Sprintf("failed to claim slot: %v", err), core.ErrInternalError)
}
}
// Anonymous users can also claim by making a move (slot remains "unclaimed" but move proceeds)
// An authenticated user claims only when the validated move commits.
// Anonymous moves deliberately leave the slot unclaimed.
} else if cmd.UserID != "" && slotOwner != cmd.UserID {
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
}
@@ -345,7 +349,7 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
return p.errorResponse("invalid move format", core.ErrInvalidMove)
}
currentFEN := g.CurrentFEN()
currentFEN := g.FEN
// Validate move and classify the resulting position in one engine session
p.mu.Lock()
@@ -365,16 +369,22 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
}
// Atomic commit: move + state + metadata, single notification
if err = p.svc.ApplyMoveWithState(cmd.GameID, move, newFEN, finalState, &game.MoveResult{
Move: move,
PlayerColor: currentColor,
GameState: finalState,
if err = p.svc.ApplyMoveWithState(cmd.GameID, service.MoveCommit{
ExpectedFEN: currentFEN, ExpectedState: core.StateOngoing, ExpectedTurn: currentColor,
ActorUserID: cmd.UserID, MoveUCI: move, NewFEN: newFEN, State: finalState,
Result: &game.MoveResult{Move: move, PlayerColor: currentColor, GameState: finalState},
}); err != nil {
if errors.Is(err, service.ErrSlotOwner) {
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
}
if errors.Is(err, service.ErrGameChanged) {
return p.errorResponse("game changed while move was being validated; refresh and retry", core.ErrConflict)
}
return p.errorResponse(fmt.Sprintf("failed to apply move: %v", err), core.ErrInternalError)
}
// buildGameResponse populates LastMove from the committed LastResult
g, _ = p.svc.GetGame(cmd.GameID)
g, _ = p.svc.GetGameView(cmd.GameID)
return ProcessorResponse{
Success: true,
Data: p.buildGameResponse(cmd.GameID, g),
@@ -387,12 +397,12 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
// snapshot had legal moves made from it, so resetting to Ongoing is sound
// without re-classification.
func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
g, err := p.svc.GetGame(cmd.GameID)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
if g.State() == core.StatePending {
if g.State == core.StatePending {
return p.errorResponse("cannot undo while computer move is in progress", core.ErrInvalidRequest)
}
@@ -410,25 +420,22 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
}
// Reset game state to ongoing after undo
p.svc.UpdateGameState(cmd.GameID, core.StateOngoing)
g, _ = p.svc.GetGame(cmd.GameID)
g, _ = p.svc.GetGameView(cmd.GameID)
return ProcessorResponse{
Success: true,
Data: p.buildGameResponse(cmd.GameID, g),
}
}
// handleDeleteGame removes a game
// handleDeleteGame unloads a game from live memory.
func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
g, err := p.svc.GetGame(cmd.GameID)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
// Only block deletion if actively computing
if g.State() == core.StatePending {
if g.State == core.StatePending {
return p.errorResponse("cannot delete game while computer move is in progress", core.ErrInvalidRequest)
}
@@ -443,12 +450,12 @@ func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
// handleGetBoard returns board visualization
func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
g, err := p.svc.GetGame(cmd.GameID)
g, err := p.svc.GetGameView(cmd.GameID)
if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound)
}
b, err := board.ParseFEN(g.CurrentFEN())
b, err := board.ParseFEN(g.FEN)
if err != nil {
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
}
@@ -457,7 +464,7 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
return ProcessorResponse{
Success: true,
Data: core.BoardResponse{
FEN: g.CurrentFEN(),
FEN: g.FEN,
Board: ascii,
},
}
@@ -467,18 +474,18 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
// re-classifies via the validation engine: worker output is never trusted for
// end-state determination, and no-move results are verified against the
// position rather than the IsMate info-line byproduct.
func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
fen := g.CurrentFEN()
color := g.NextTurnColor()
func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
fen := g.FEN
color := g.NextTurnColor
player := g.NextPlayer()
p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
currentGame, err := p.svc.GetGame(gameID)
if err != nil || currentGame.State() != core.StatePending {
return p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
currentGame, err := p.svc.GetGameView(gameID)
if err != nil || currentGame.State != core.StatePending || currentGame.FEN != fen {
return // Deleted, or state resolved elsewhere
}
if result.Error != nil {
log.Printf("engine error for game %s: %v", gameID, result.Error)
slog.Error("computer engine failed", "game_id", gameID, "error", result.Error)
p.svc.UpdateGameState(gameID, core.StateStuck)
return
}
@@ -508,10 +515,19 @@ func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
return
}
p.svc.ApplyMoveWithState(gameID, result.Move, newFEN, finalState, &game.MoveResult{
Move: result.Move, PlayerColor: color,
Score: result.Score, Depth: result.Depth, GameState: finalState,
})
if err := p.svc.ApplyMoveWithState(gameID, service.MoveCommit{
ExpectedFEN: fen, ExpectedState: core.StatePending, ExpectedTurn: color,
MoveUCI: result.Move, NewFEN: newFEN, State: finalState,
Result: &game.MoveResult{
Move: result.Move, PlayerColor: color,
Score: result.Score, Depth: result.Depth, GameState: finalState,
},
}); err != nil {
slog.Error("failed to apply computer move", "game_id", gameID, "error", err)
if !errors.Is(err, service.ErrGameChanged) && !errors.Is(err, service.ErrGameNotFound) {
p.svc.UpdateGameState(gameID, core.StateStuck)
}
}
})
}
@@ -583,27 +599,28 @@ func (p *Processor) checkGameEnd(gameID, fen string) {
}
return
}
log.Printf("game %s: end-state check attempt %d failed: %v", gameID, attempt+1, err)
slog.Warn("game end-state check failed",
"game_id", gameID, "attempt", attempt+1, "error", err)
}
p.svc.UpdateGameState(gameID, core.StateStuck)
}
// buildGameResponse constructs standard game response
func (p *Processor) buildGameResponse(gameID string, g *game.Game) core.GameResponse {
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
resp := core.GameResponse{
GameID: gameID,
FEN: g.CurrentFEN(),
Turn: g.NextTurnColor().String(),
State: g.State().String(),
Moves: g.Moves(),
FEN: g.FEN,
Turn: g.NextTurnColor.String(),
State: g.State.String(),
Moves: g.Moves,
Players: core.PlayersResponse{
White: g.GetPlayer(core.ColorWhite),
Black: g.GetPlayer(core.ColorBlack),
White: g.WhitePlayer,
Black: g.BlackPlayer,
},
}
// Include last move if available
if result := g.LastResult(); result != nil {
if result := g.LastResult; result != nil {
resp.LastMove = &core.MoveInfo{
Move: result.Move,
PlayerColor: result.PlayerColor.String(),
@@ -628,6 +645,9 @@ func (p *Processor) errorResponse(message, code string) ProcessorResponse {
// Close cleans up resources
func (p *Processor) Close() error {
p.queue.Shutdown(5 * time.Second)
return p.validationEng.Close()
queueErr := p.queue.Shutdown(5 * time.Second)
p.mu.Lock()
engineErr := p.validationEng.Close()
p.mu.Unlock()
return errors.Join(queueErr, engineErr)
}
+59 -11
View File
@@ -3,7 +3,7 @@ package processor
import (
"context"
"fmt"
"log"
"log/slog"
"sync"
"time"
@@ -33,11 +33,15 @@ type EngineResult struct {
// EngineQueue manages async engine computations
type EngineQueue struct {
tasks chan EngineTask
workers int
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
tasks chan EngineTask
workers int
wg sync.WaitGroup
callbackWG sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
submitMu sync.RWMutex
closed bool
shutdownOnce sync.Once
}
// NewEngineQueue creates a queue with specified worker count
@@ -76,7 +80,7 @@ func (q *EngineQueue) worker(id int) {
if eng, err = engine.New(); err == nil {
break
}
log.Printf("worker %d: engine init failed: %v; retrying", id, err)
slog.Warn("engine worker initialization failed; retrying", "worker", id, "error", err)
select {
case <-q.ctx.Done():
return
@@ -84,6 +88,7 @@ func (q *EngineQueue) worker(id int) {
}
}
defer eng.Close()
slog.Debug("engine worker started", "worker", id)
for {
select {
case task, ok := <-q.tasks:
@@ -99,6 +104,10 @@ func (q *EngineQueue) worker(id int) {
// processTask executes a single engine calculation
func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult {
started := time.Now()
defer func() {
slog.Debug("engine task completed", "game_id", task.GameID, "duration", time.Since(started))
}()
result := EngineResult{GameID: task.GameID}
if err := eng.NewGame(); err != nil {
result.Error = err
@@ -134,8 +143,18 @@ func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult
// Submit adds a task to the queue
func (q *EngineQueue) Submit(task EngineTask) error {
q.submitMu.RLock()
defer q.submitMu.RUnlock()
return q.submitLocked(task)
}
func (q *EngineQueue) submitLocked(task EngineTask) error {
if q.closed {
return fmt.Errorf("queue is shutting down")
}
select {
case q.tasks <- task:
slog.Debug("engine task queued", "game_id", task.GameID, "queue_depth", len(q.tasks))
return nil
case <-q.ctx.Done():
return fmt.Errorf("queue is shutting down")
@@ -146,8 +165,24 @@ func (q *EngineQueue) Submit(task EngineTask) error {
// SubmitAsync submits a task without blocking for result
func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *core.Player, callback func(EngineResult)) error {
if player == nil {
return fmt.Errorf("computer player is missing")
}
if callback == nil {
return fmt.Errorf("engine callback is missing")
}
respChan := make(chan EngineResult, 1)
if err := q.Submit(EngineTask{GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan}); err != nil {
q.submitMu.RLock()
err := q.submitLocked(EngineTask{
GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan,
})
if err == nil {
// Registered while the submit lock is held, so Shutdown cannot begin
// waiting between a successful send and this Add.
q.callbackWG.Add(1)
}
q.submitMu.RUnlock()
if err != nil {
return err
}
budget := 1000
@@ -156,11 +191,18 @@ func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *
}
wait := time.Duration(budget)*time.Millisecond*2 + 30*time.Second // search budget + queue-wait headroom
go func() {
defer q.callbackWG.Done()
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case result := <-respChan:
callback(result)
case <-time.After(wait):
case <-timer.C:
callback(EngineResult{GameID: gameID, Error: fmt.Errorf("engine timeout")})
case <-q.ctx.Done():
// Live state is intentionally abandoned during server shutdown. The
// last fully committed position remains the durable replay boundary.
return
}
}()
return nil
@@ -168,12 +210,18 @@ func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *
// Shutdown gracefully stops the queue
func (q *EngineQueue) Shutdown(timeout time.Duration) error {
q.cancel()
close(q.tasks)
q.shutdownOnce.Do(func() {
q.submitMu.Lock()
q.closed = true
q.cancel()
close(q.tasks)
q.submitMu.Unlock()
})
done := make(chan struct{})
go func() {
q.wg.Wait()
q.callbackWG.Wait()
close(done)
}()
+40
View File
@@ -0,0 +1,40 @@
package processor
import (
"context"
"sync/atomic"
"testing"
"time"
"chess/internal/server/core"
)
func TestEngineQueueShutdownCancelsAndWaitsForCallback(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
queue := &EngineQueue{
tasks: make(chan EngineTask, 1),
workers: 1,
ctx: ctx,
cancel: cancel,
}
player := &core.Player{Type: core.PlayerComputer, SearchTime: 10_000}
var callbackCalled atomic.Bool
if err := queue.SubmitAsync("game-1", "fen", core.ColorWhite, player, func(EngineResult) {
callbackCalled.Store(true)
}); err != nil {
t.Fatal(err)
}
if err := queue.Shutdown(time.Second); err != nil {
t.Fatal(err)
}
if callbackCalled.Load() {
t.Fatal("shutdown callback mutated live state after cancellation")
}
if err := queue.Submit(EngineTask{}); err == nil {
t.Fatal("submit succeeded after shutdown")
}
if err := queue.Shutdown(time.Second); err != nil {
t.Fatalf("second shutdown was not idempotent: %v", err)
}
}
+205 -63
View File
@@ -1,7 +1,9 @@
package service
import (
"errors"
"fmt"
"log/slog"
"time"
"chess/internal/server/core"
@@ -11,8 +13,32 @@ import (
"github.com/google/uuid"
)
var (
ErrGameNotFound = errors.New("game not found")
ErrGameChanged = errors.New("game changed while move was being validated")
ErrSlotOwner = errors.New("player slot is owned by another user")
)
type MoveCommit struct {
ExpectedFEN string
ExpectedState core.State
ExpectedTurn core.Color
ActorUserID string
MoveUCI string
NewFEN string
State core.State
Result *game.MoveResult
At time.Time
}
// CreateGame registers a new game with pre-constructed players
func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, initialFEN string, startingTurn core.Color) error {
func (s *Service) CreateGame(
id string,
whitePlayer, blackPlayer *core.Player,
initialFEN string,
startingTurn core.Color,
initialState core.State,
) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -29,11 +55,14 @@ func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, i
s.computerGames.Add(1)
}
// Store game with provided players
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
now := time.Now().UTC()
g := game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
g.SetStateAt(initialState, now)
s.games[id] = g
// Persist if storage enabled
if s.store != nil {
result, _ := initialState.Result()
record := storage.GameRecord{
GameID: id,
InitialFEN: initialFEN,
@@ -41,14 +70,21 @@ func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, i
WhiteType: int(whitePlayer.Type),
WhiteLevel: whitePlayer.Level,
WhiteSearchTime: whitePlayer.SearchTime,
WhiteClaimedBy: whitePlayer.ClaimedBy,
BlackPlayerID: blackPlayer.ID,
BlackType: int(blackPlayer.Type),
BlackLevel: blackPlayer.Level,
BlackSearchTime: blackPlayer.SearchTime,
StartTimeUTC: time.Now().UTC(),
BlackClaimedBy: blackPlayer.ClaimedBy,
Result: result,
StartTimeUTC: now,
EndTimeUTC: g.EndTimeUTC(),
}
if err := s.store.RecordNewGame(record); err != nil {
slog.Error("failed to queue game persistence", "game_id", id, "error", err)
}
s.store.RecordNewGame(record)
}
slog.Debug("game created", "game_id", id, "state", initialState.String(), "persistent", s.store != nil)
return nil
}
@@ -62,23 +98,85 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
if !ok {
return fmt.Errorf("game not found: %s", gameID)
}
if g.State() == core.StatePending {
return errors.New("cannot change players while computer is calculating")
}
oldWhite := g.GetPlayer(core.ColorWhite)
oldBlack := g.GetPlayer(core.ColorBlack)
oldHasComputer := g.HasComputerPlayer()
newHasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
if !oldHasComputer && newHasComputer && s.computerGames.Load() >= MaxComputerGames {
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
}
// Player configuration is mutable, but historical user association is not.
// Preserve a human ID while the slot remains human, and preserve any claim
// even if the slot later becomes computer-controlled.
if oldWhite != nil {
if oldWhite.Type == core.PlayerHuman && whitePlayer.Type == core.PlayerHuman {
whitePlayer.ID = oldWhite.ID
}
whitePlayer.ClaimedBy = oldWhite.ClaimedBy
}
if oldBlack != nil {
if oldBlack.Type == core.PlayerHuman && blackPlayer.Type == core.PlayerHuman {
blackPlayer.ID = oldBlack.ID
}
blackPlayer.ClaimedBy = oldBlack.ClaimedBy
}
// Update the game's players
g.UpdatePlayers(whitePlayer, blackPlayer)
if oldHasComputer != newHasComputer {
if newHasComputer {
s.computerGames.Add(1)
} else {
s.computerGames.Add(-1)
}
}
if s.store != nil {
err := s.store.RecordPlayers(gameID, playerRecord(whitePlayer), playerRecord(blackPlayer))
if err != nil {
slog.Error("failed to queue player persistence", "game_id", gameID, "error", err)
}
}
slog.Debug("game players updated", "game_id", gameID)
return nil
}
// GetGame retrieves a game by ID
func (s *Service) GetGame(gameID string) (*game.Game, error) {
// GetGameView retrieves an immutable game snapshot by ID.
func (s *Service) GetGameView(gameID string) (game.View, error) {
s.mu.RLock()
defer s.mu.RUnlock()
g, ok := s.games[gameID]
if !ok {
return nil, fmt.Errorf("game not found: %s", gameID)
return game.View{}, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
return g, nil
return g.View(), nil
}
// BeginComputerMove is an optimistic state transition: only the request that
// observed the current ongoing position may enqueue engine work.
func (s *Service) BeginComputerMove(gameID, expectedFEN string, expectedTurn core.Color) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State() != core.StateOngoing || g.CurrentFEN() != expectedFEN || g.NextTurnColor() != expectedTurn {
return ErrGameChanged
}
if player := g.NextPlayer(); player == nil || player.Type != core.PlayerComputer {
return errors.New("current player is not a computer")
}
g.SetStateAt(core.StatePending, time.Now().UTC())
s.waiter.NotifyGame(gameID, len(g.Moves()), core.StatePending)
slog.Debug("computer move started", "game_id", gameID, "turn", expectedTurn.String())
return nil
}
// GenerateGameID creates a new unique game ID
@@ -95,70 +193,86 @@ func (s *Service) GenerateGameID() string {
}
}
// ApplyMove adds a validated move to the game history
func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
// ApplyMoveWithState verifies that the position validated by the processor is
// still current, then commits the move, optional first-move claim, and result as
// one in-memory transition and one SQLite transaction.
func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
// Determine whose turn it was before this move
currentTurn := g.NextTurnColor()
nextTurn := core.OppositeColor(currentTurn)
if g.CurrentFEN() != commit.ExpectedFEN ||
g.State() != commit.ExpectedState ||
currentTurn != commit.ExpectedTurn {
return ErrGameChanged
}
// Add the new position to game history
g.AddSnapshot(newFEN, moveUCI, nextTurn)
// Notify waiting clients about the state change
s.waiter.NotifyGame(gameID, len(g.Moves()), g.State())
// Persist if storage enabled
if s.store != nil {
moveNumber := len(g.Moves())
record := storage.MoveRecord{
GameID: gameID,
MoveNumber: moveNumber,
MoveUCI: moveUCI,
FENAfterMove: newFEN,
PlayerColor: currentTurn.String(),
MoveTimeUTC: time.Now().UTC(),
currentPlayer := g.NextPlayer()
claimUserID := ""
if currentPlayer == nil {
return errors.New("current player is missing")
}
if currentPlayer.Type == core.PlayerHuman {
owner := g.GetSlotOwner(currentTurn)
switch {
case owner != "" && commit.ActorUserID == "":
return ErrSlotOwner
case owner != "" && owner != commit.ActorUserID:
return ErrSlotOwner
case owner == "" && commit.ActorUserID != "":
claimUserID = commit.ActorUserID
}
s.store.RecordMove(record)
}
return nil
}
// ApplyMoveWithState atomically records a move, its resulting state, and move
// metadata, then notifies waiters exactly once with the settled state.
func (s *Service) ApplyMoveWithState(gameID, moveUCI, newFEN string, state core.State, result *game.MoveResult) error {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
at := commit.At.UTC()
if commit.At.IsZero() {
at = time.Now().UTC()
}
currentTurn := g.NextTurnColor()
g.AddSnapshot(newFEN, moveUCI, core.OppositeColor(currentTurn))
g.SetState(state)
if result != nil {
g.SetLastResult(result)
if claimUserID != "" {
if err := g.ClaimSlot(currentTurn, claimUserID); err != nil {
return err
}
}
g.AddSnapshot(commit.NewFEN, commit.MoveUCI, core.OppositeColor(currentTurn))
g.SetStateAt(commit.State, at)
if commit.Result != nil {
g.SetLastResult(commit.Result)
}
s.waiter.NotifyGame(gameID, len(g.Moves()), state)
if s.store != nil {
s.store.RecordMove(storage.MoveRecord{
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: moveUCI,
FENAfterMove: newFEN, PlayerColor: currentTurn.String(),
MoveTimeUTC: time.Now().UTC(),
})
result, _ := commit.State.Result()
persistence := storage.MovePersistence{
Move: storage.MoveRecord{
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: commit.MoveUCI,
FENAfterMove: commit.NewFEN, PlayerColor: currentTurn.String(), MoveTimeUTC: at,
},
ClaimColor: currentTurn.String(),
ClaimedBy: claimUserID,
Result: result,
EndTimeUTC: g.EndTimeUTC(),
}
if claimUserID == "" {
persistence.ClaimColor = ""
}
if err := s.store.RecordMove(persistence); err != nil {
slog.Error("failed to queue move persistence",
"game_id", gameID, "move_number", len(g.Moves()), "error", err)
}
}
s.waiter.NotifyGame(gameID, len(g.Moves()), commit.State)
slog.Debug("game move applied",
"game_id", gameID,
"move_number", len(g.Moves()),
"move", commit.MoveUCI,
"state", commit.State.String(),
"slot_claimed", claimUserID != "",
)
return nil
}
@@ -169,12 +283,21 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
g.SetState(state)
previousState := g.State()
now := time.Now().UTC()
g.SetStateAt(state, now)
if s.store != nil && state.IsTerminal() && !previousState.IsTerminal() {
result, _ := state.Result()
if err := s.store.RecordGameResult(gameID, result, now); err != nil {
slog.Error("failed to queue game result persistence", "game_id", gameID, "error", err)
}
}
// Notify unconditionally; the registry decides.
s.waiter.NotifyGame(gameID, len(g.Moves()), state)
slog.Debug("game state updated", "game_id", gameID, "from", previousState.String(), "to", state.String())
return nil
}
@@ -186,7 +309,7 @@ func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) erro
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
g.SetLastResult(result)
@@ -200,7 +323,10 @@ func (s *Service) UndoMoves(gameID string, count int) error {
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State() == core.StatePending {
return errors.New("cannot undo while computer move is in progress")
}
originalMoveCount := len(g.Moves())
@@ -215,8 +341,11 @@ func (s *Service) UndoMoves(gameID string, count int) error {
// Delete undone moves from storage if enabled
if s.store != nil {
remainingMoves := originalMoveCount - count
s.store.DeleteUndoneMoves(gameID, remainingMoves)
if err := s.store.RewindGame(gameID, remainingMoves); err != nil {
slog.Error("failed to queue game rewind persistence", "game_id", gameID, "error", err)
}
}
slog.Debug("game moves undone", "game_id", gameID, "count", count, "remaining_moves", len(g.Moves()))
return nil
}
@@ -228,7 +357,10 @@ func (s *Service) DeleteGame(gameID string) error {
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if g.State() == core.StatePending {
return errors.New("cannot delete game while computer move is in progress")
}
// Decrement computer game count if applicable
@@ -240,6 +372,16 @@ func (s *Service) DeleteGame(gameID string) error {
s.waiter.RemoveGame(gameID)
delete(s.games, gameID)
slog.Debug("game unloaded from memory", "game_id", gameID)
return nil
}
func playerRecord(player *core.Player) storage.PlayerRecord {
return storage.PlayerRecord{
PlayerID: player.ID,
Type: int(player.Type),
Level: player.Level,
SearchTime: player.SearchTime,
ClaimedBy: player.ClaimedBy,
}
}
+164
View File
@@ -0,0 +1,164 @@
package service
import (
"errors"
"path/filepath"
"testing"
"time"
"chess/internal/server/core"
"chess/internal/server/game"
"chess/internal/server/storage"
)
func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) {
svc := newPersistentTestService(t)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
); err != nil {
t.Fatal(err)
}
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
commit := MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
ActorUserID: "user-1", MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
}
if err := svc.ApplyMoveWithState("game-1", commit); err != nil {
t.Fatal(err)
}
view, err := svc.GetGameView("game-1")
if err != nil {
t.Fatal(err)
}
if view.WhitePlayer.ClaimedBy != "user-1" || view.State != core.StateWhiteWins {
t.Fatalf("in-memory move did not settle atomically: %+v", view)
}
history, err := svc.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if history.Players.White.ClaimedBy != "user-1" || history.Result != "white_wins" || len(history.Moves) != 1 {
t.Fatalf("history did not settle atomically: %+v", history)
}
if err := svc.ApplyMoveWithState("game-1", commit); !errors.Is(err, ErrGameChanged) {
t.Fatalf("stale move error = %v, want ErrGameChanged", err)
}
}
func TestUndoClearsDurableTerminalResult(t *testing.T) {
svc := newPersistentTestService(t)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
); err != nil {
t.Fatal(err)
}
if err := svc.ApplyMoveWithState("game-1", MoveCommit{
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate,
}); err != nil {
t.Fatal(err)
}
if err := svc.UndoMoves("game-1", 1); err != nil {
t.Fatal(err)
}
history, err := svc.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if history.Result != "" || history.EndTimeUTC != nil || len(history.Moves) != 0 {
t.Fatalf("undo left terminal persistence: %+v", history)
}
}
func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.T) {
svc := newPersistentTestService(t)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
white.ID = "user-1"
white.ClaimedBy = "user-1"
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
); err != nil {
t.Fatal(err)
}
replacementWhite := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
replacementBlack := core.NewPlayer(
core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack,
)
if err := svc.UpdatePlayers("game-1", replacementWhite, replacementBlack); err != nil {
t.Fatal(err)
}
history, err := svc.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if history.Players.White.ID != "user-1" || history.Players.White.ClaimedBy != "user-1" {
t.Fatalf("white ownership was replaced: %+v", history.Players.White)
}
if history.Players.Black.Type != core.PlayerComputer || history.Players.Black.Level != 12 {
t.Fatalf("black configuration was not persisted: %+v", history.Players.Black)
}
computerWhite := core.NewPlayer(
core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite,
)
if err := svc.UpdatePlayers("game-1", computerWhite, replacementBlack); err != nil {
t.Fatal(err)
}
history, err = svc.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if history.Players.White.ClaimedBy != "user-1" {
t.Fatalf("player-type change discarded historical ownership: %+v", history.Players.White)
}
}
func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
svc := newPersistentTestService(t)
svc.SetFinishedGameTTL(time.Nanosecond)
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
if err := svc.CreateGame(
"game-1", white, black, "terminal", core.ColorWhite, core.StateStalemate,
); err != nil {
t.Fatal(err)
}
svc.cleanupFinishedGames(time.Now().UTC().Add(time.Second))
if _, err := svc.GetGameView("game-1"); !errors.Is(err, ErrGameNotFound) {
t.Fatalf("terminal game remains in memory: %v", err)
}
if history, err := svc.GetGameHistory("game-1"); err != nil || history.Result != "stalemate" {
t.Fatalf("durable history was removed: history=%+v err=%v", history, err)
}
}
func newPersistentTestService(t *testing.T) *Service {
t.Helper()
store, err := storage.NewStore(filepath.Join(t.TempDir(), "chess.db"), false)
if err != nil {
t.Fatal(err)
}
if err := store.InitDB(); err != nil {
t.Fatal(err)
}
svc := New(store, []byte("test-secret-test-secret-test-secret"))
t.Cleanup(func() {
if err := svc.Shutdown(time.Second); err != nil {
t.Errorf("shutdown: %v", err)
}
})
return svc
}
+109
View File
@@ -0,0 +1,109 @@
package service
import (
"context"
"errors"
"fmt"
"chess/internal/server/core"
"chess/internal/server/storage"
)
// GetGameHistory returns a durable replay even after the live game has been
// evicted from memory or the server has restarted.
func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
record, moves, err := s.store.GetGameHistory(gameID)
if err != nil {
if storage.IsGameNotFound(err) {
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
}
if isStorageUnavailable(err) {
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
}
return nil, fmt.Errorf("get game history: %w", err)
}
history := &core.GameHistoryResponse{
GameID: record.GameID,
InitialFEN: record.InitialFEN,
Result: record.Result,
StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC,
Players: playersResponse(*record),
Moves: make([]core.HistoryMove, 0, len(moves)),
}
for _, move := range moves {
history.Moves = append(history.Moves, core.HistoryMove{
MoveNumber: move.MoveNumber,
MoveUCI: move.MoveUCI,
FENAfterMove: move.FENAfterMove,
PlayerColor: move.PlayerColor,
MoveTimeUTC: move.MoveTimeUTC,
})
}
return history, nil
}
// GetUserGames returns a bounded page of games associated either at creation
// or by a later slot claim.
func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameListResponse, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
records, err := s.store.QueryGamesForUser(userID, limit+1, offset)
if err != nil {
if isStorageUnavailable(err) {
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
}
return nil, fmt.Errorf("get user games: %w", err)
}
hasNext := len(records) > limit
if hasNext {
records = records[:limit]
}
response := &core.GameListResponse{
Games: make([]core.GameSummary, 0, len(records)),
Limit: limit,
Offset: offset,
}
if hasNext {
next := offset + limit
response.NextOffset = &next
}
for _, record := range records {
response.Games = append(response.Games, core.GameSummary{
GameID: record.GameID,
InitialFEN: record.InitialFEN,
Result: record.Result,
StartTimeUTC: record.StartTimeUTC,
EndTimeUTC: record.EndTimeUTC,
MoveCount: record.MoveCount,
Players: playersResponse(record.GameRecord),
})
}
return response, nil
}
func isStorageUnavailable(err error) bool {
return errors.Is(err, storage.ErrStorageDegraded) ||
errors.Is(err, storage.ErrStoreClosed) ||
errors.Is(err, storage.ErrWriteQueueFull) ||
errors.Is(err, context.DeadlineExceeded)
}
func playersResponse(record storage.GameRecord) core.PlayersResponse {
return core.PlayersResponse{
White: &core.Player{
ID: record.WhitePlayerID, Color: core.ColorWhite, Type: core.PlayerType(record.WhiteType),
Level: record.WhiteLevel, SearchTime: record.WhiteSearchTime, ClaimedBy: record.WhiteClaimedBy,
},
Black: &core.Player{
ID: record.BlackPlayerID, Color: core.ColorBlack, Type: core.PlayerType(record.BlackType),
Level: record.BlackLevel, SearchTime: record.BlackSearchTime, ClaimedBy: record.BlackClaimedBy,
},
}
}
+82 -21
View File
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"sync/atomic"
"time"
@@ -20,28 +21,41 @@ const (
TempUserTTL = 24 * time.Hour
SessionTTL = 7 * 24 * time.Hour
CleanupJobInterval = 1 * time.Hour
FinishedGameTTL = 1 * time.Hour
)
// Service coordinates game state, user management, and storage
type Service struct {
games map[string]*game.Game
mu sync.RWMutex
userMu sync.Mutex
store *storage.Store
jwtSecret []byte
waiter *WaitRegistry
computerGames atomic.Int32 // Active games with computer players
finishedTTL time.Duration
}
// New creates a new service instance with optional storage
func New(store *storage.Store, jwtSecret []byte) *Service {
return &Service{
games: make(map[string]*game.Game),
store: store,
jwtSecret: jwtSecret,
waiter: NewWaitRegistry(),
games: make(map[string]*game.Game),
store: store,
jwtSecret: jwtSecret,
waiter: NewWaitRegistry(),
finishedTTL: FinishedGameTTL,
}
}
// SetFinishedGameTTL configures how long terminal games remain in memory.
// Durable rows and moves are never removed by this cleanup. A non-positive
// duration disables terminal-game eviction.
func (s *Service) SetFinishedGameTTL(ttl time.Duration) {
s.mu.Lock()
s.finishedTTL = ttl
s.mu.Unlock()
}
// GetStorageHealth returns the storage component status
func (s *Service) GetStorageHealth() string {
if s.store == nil {
@@ -55,6 +69,13 @@ func (s *Service) GetStorageHealth() string {
// RegisterWait registers a client to wait for game state changes
func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
s.mu.RLock()
defer s.mu.RUnlock()
if _, ok := s.games[gameID]; !ok {
notify := make(chan struct{})
close(notify)
return notify
}
return s.waiter.RegisterWait(gameID, moveCount, ctx)
}
@@ -80,6 +101,9 @@ func (s *Service) GetComputerGameCount() int32 {
// ClaimGameSlot claims a player slot for a user
func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string) error {
if userID == "" {
return errors.New("claimant user ID is required")
}
s.mu.Lock()
defer s.mu.Unlock()
@@ -88,7 +112,17 @@ func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string)
return fmt.Errorf("game not found: %s", gameID)
}
return g.ClaimSlot(color, userID)
if err := g.ClaimSlot(color, userID); err != nil {
return err
}
if s.store != nil {
if err := s.store.RecordSlotClaim(gameID, color.String(), userID); err != nil {
slog.Error("failed to queue slot claim persistence",
"game_id", gameID, "color", color.String(), "error", err)
}
}
slog.Debug("game slot claimed", "game_id", gameID, "color", color.String(), "user_id", userID)
return nil
}
// GetSlotOwner returns the user who claimed a slot
@@ -113,9 +147,8 @@ func (s *Service) Shutdown(timeout time.Duration) error {
}
s.mu.Lock()
defer s.mu.Unlock()
s.games = make(map[string]*game.Game)
s.mu.Unlock()
if s.store != nil {
if err := s.store.Close(); err != nil {
@@ -128,6 +161,7 @@ func (s *Service) Shutdown(timeout time.Duration) error {
// RunCleanupJob runs periodic cleanup of expired users and sessions
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
s.cleanupExpired()
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -142,22 +176,49 @@ func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
}
func (s *Service) cleanupExpired() {
if s.store == nil {
if s.store != nil {
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
slog.Error("cleanup failed to delete expired users", "error", err)
} else if deleted > 0 {
slog.Info("cleanup deleted expired temporary users", "count", deleted)
}
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
slog.Error("cleanup failed to delete expired sessions", "error", err)
} else if deleted > 0 {
slog.Info("cleanup deleted expired sessions", "count", deleted)
}
}
s.cleanupFinishedGames(time.Now().UTC())
}
func (s *Service) cleanupFinishedGames(now time.Time) {
s.mu.Lock()
if s.finishedTTL <= 0 {
s.mu.Unlock()
return
}
// Cleanup expired temp users
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
// Log but don't fail
fmt.Printf("cleanup: failed to delete expired users: %v\n", err)
} else if deleted > 0 {
fmt.Printf("cleanup: deleted %d expired temp users\n", deleted)
cutoff := now.Add(-s.finishedTTL)
removed := make([]string, 0)
for gameID, g := range s.games {
ended := g.EndTimeUTC()
if !g.State().IsTerminal() || ended == nil || ended.After(cutoff) {
continue
}
if g.HasComputerPlayer() {
s.computerGames.Add(-1)
}
delete(s.games, gameID)
removed = append(removed, gameID)
}
s.mu.Unlock()
// Cleanup expired sessions
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
fmt.Printf("cleanup: failed to delete expired sessions: %v\n", err)
} else if deleted > 0 {
fmt.Printf("cleanup: deleted %d expired sessions\n", deleted)
for _, gameID := range removed {
s.waiter.RemoveGame(gameID)
}
}
if len(removed) > 0 {
slog.Info("cleanup evicted terminal games from memory",
"count", len(removed), "retention", s.finishedTTL)
}
}
+100 -59
View File
@@ -1,8 +1,10 @@
package service
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"time"
@@ -14,6 +16,7 @@ import (
var (
ErrStorageDisabled = errors.New("storage disabled")
ErrStorageUnavailable = errors.New("storage unavailable")
ErrAtCapacity = errors.New("at capacity")
ErrPermanentSlotsFull = errors.New("permanent slots full")
)
@@ -30,14 +33,25 @@ type User struct {
// CreateUser creates new user with registration limits enforcement
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) {
if s.store == nil {
return nil, ErrStorageDisabled
}
user, _, err := s.createUser(username, email, password, permanent, false)
return user, err
}
// Check registration limits
total, permCount, _, err := s.store.GetUserCounts()
if err != nil {
return nil, fmt.Errorf("failed to get user count: %w", err)
// RegisterUser creates the account and its initial session in one SQLite
// transaction, so a successful registration always returns a usable account.
func (s *Service) RegisterUser(username, email, password string, permanent bool) (*User, string, error) {
return s.createUser(username, email, password, permanent, true)
}
func (s *Service) createUser(
username, email, password string,
permanent, withSession bool,
) (*User, string, error) {
s.userMu.Lock()
defer s.userMu.Unlock()
if s.store == nil {
return nil, "", ErrStorageDisabled
}
// Determine account type
@@ -45,32 +59,22 @@ func (s *Service) CreateUser(username, email, password string, permanent bool) (
var expiresAt *time.Time
if permanent {
if permCount >= PermanentSlots {
return nil, fmt.Errorf("%w (%d/%d)", ErrPermanentSlotsFull, permCount, PermanentSlots)
}
accountType = "permanent"
} else {
expiry := time.Now().UTC().Add(TempUserTTL)
expiresAt = &expiry
}
// Handle capacity - remove oldest temp user if at max
if total >= MaxUsers {
if err := s.removeOldestTempUser(); err != nil {
return nil, fmt.Errorf("%w: %v", ErrAtCapacity, err)
}
}
// Hash password
passwordHash, err := auth.HashPassword(password)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
return nil, "", fmt.Errorf("failed to hash password: %w", err)
}
// Generate unique user ID
userID, err := s.generateUniqueUserID()
if err != nil {
return nil, fmt.Errorf("failed to generate unique ID: %w", err)
return nil, "", fmt.Errorf("failed to generate unique ID: %w", err)
}
// Create user record
@@ -93,35 +97,46 @@ func (s *Service) CreateUser(username, email, password string, permanent bool) (
ExpiresAt: expiresAt,
}
if err = s.store.CreateUser(record); err != nil {
return nil, err
var sessionID string
var session *storage.SessionRecord
if withSession {
sessionID = uuid.New().String()
session = &storage.SessionRecord{
SessionID: sessionID,
UserID: userID,
CreatedAt: user.CreatedAt,
ExpiresAt: user.CreatedAt.Add(SessionTTL),
}
}
return user, nil
}
// removeOldestTempUser removes the oldest temporary user to make room
func (s *Service) removeOldestTempUser() error {
oldest, err := s.store.GetOldestTempUser()
if err != nil {
return fmt.Errorf("no temp users to remove: %w", err)
limits := storage.UserLimits{
MaxUsers: MaxUsers,
PermanentSlots: PermanentSlots,
}
// Delete their session first
_ = s.store.DeleteSessionByUserID(oldest.UserID)
// Delete the user
if err := s.store.DeleteUserByID(oldest.UserID); err != nil {
return fmt.Errorf("failed to remove oldest user: %w", err)
if err = s.store.CreateUserWithinLimits(record, session, limits); err != nil {
switch {
case errors.Is(err, storage.ErrUserAlreadyExists):
return nil, "", fmt.Errorf("username or email already exists: %w", err)
case errors.Is(err, storage.ErrPermanentCapacity):
return nil, "", fmt.Errorf("%w (%d maximum)", ErrPermanentSlotsFull, PermanentSlots)
case errors.Is(err, storage.ErrUserCapacity):
return nil, "", fmt.Errorf("%w: no temporary account can be replaced", ErrAtCapacity)
default:
return nil, "", fmt.Errorf("%w: create user: %v", ErrStorageUnavailable, err)
}
}
slog.Debug("user created",
"user_id", userID,
"account_type", accountType,
"initial_session", withSession,
)
return nil
return user, sessionID, nil
}
// AuthenticateUser verifies credentials and creates a new session
func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) {
if s.store == nil {
return nil, "", fmt.Errorf("storage disabled")
return nil, "", ErrStorageDisabled
}
var userRecord *storage.UserRecord
@@ -136,6 +151,9 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
if err != nil {
auth.HashPassword(password) // Timing attack prevention
if !errors.Is(err, sql.ErrNoRows) {
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
}
return nil, "", fmt.Errorf("invalid credentials")
}
@@ -161,11 +179,14 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
}
if err := s.store.CreateSession(sessionRecord); err != nil {
return nil, "", fmt.Errorf("failed to create session: %w", err)
return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
}
slog.Debug("user authenticated", "user_id", userRecord.UserID)
// Update last login
_ = s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC())
if err := s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC()); err != nil {
slog.Warn("failed to record user login time", "user_id", userRecord.UserID, "error", err)
}
return &User{
UserID: userRecord.UserID,
@@ -180,28 +201,38 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
// ValidateSession checks if a session is valid
func (s *Service) ValidateSession(sessionID string) (bool, error) {
if s.store == nil {
return false, fmt.Errorf("storage disabled")
return false, ErrStorageDisabled
}
return s.store.IsSessionValid(sessionID)
valid, err := s.store.IsSessionValid(sessionID)
if err != nil {
return false, fmt.Errorf("%w: validate session: %v", ErrStorageUnavailable, err)
}
return valid, nil
}
// InvalidateSession removes a session (logout)
func (s *Service) InvalidateSession(sessionID string) error {
if s.store == nil {
return fmt.Errorf("storage disabled")
return ErrStorageDisabled
}
return s.store.DeleteSession(sessionID)
if err := s.store.DeleteSession(sessionID); err != nil {
return fmt.Errorf("%w: invalidate session: %v", ErrStorageUnavailable, err)
}
return nil
}
// GetUserByID retrieves user information by user ID
func (s *Service) GetUserByID(userID string) (*User, error) {
if s.store == nil {
return nil, fmt.Errorf("storage disabled")
return nil, ErrStorageDisabled
}
userRecord, err := s.store.GetUserByID(userID)
if err != nil {
return nil, fmt.Errorf("user not found")
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user not found")
}
return nil, fmt.Errorf("%w: get user: %v", ErrStorageUnavailable, err)
}
return &User{
@@ -237,12 +268,19 @@ func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
return "", nil, err
}
// Validate session is still active
if sessionID, ok := claims["session_id"].(string); ok && s.store != nil {
valid, err := s.store.IsSessionValid(sessionID)
if err != nil || !valid {
return "", nil, fmt.Errorf("session invalidated")
}
if s.store == nil {
return "", nil, ErrStorageDisabled
}
sessionID, ok := claims["session_id"].(string)
if !ok || sessionID == "" {
return "", nil, fmt.Errorf("token has no persisted session")
}
valid, err := s.store.IsSessionValidForUser(sessionID, userID)
if err != nil {
return "", nil, fmt.Errorf("%w: validate token session: %v", ErrStorageUnavailable, err)
}
if !valid {
return "", nil, fmt.Errorf("session invalidated")
}
return userID, claims, nil
@@ -254,19 +292,22 @@ func (s *Service) generateUniqueUserID() (string, error) {
for i := 0; i < maxAttempts; i++ {
id := uuid.New().String()
if _, err := s.store.GetUserByID(id); err != nil {
if _, err := s.store.GetUserByID(id); errors.Is(err, sql.ErrNoRows) {
return id, nil
} else if err != nil {
return "", fmt.Errorf("%w: check generated user ID: %v", ErrStorageUnavailable, err)
}
}
return "", fmt.Errorf("failed to generate unique user ID")
}
// CreateUserSession creates a session for a user without re-authenticating
// Used after registration to avoid redundant password hashing
// CreateUserSession creates a session for a trusted internal caller without
// re-authenticating. Public registration uses RegisterUser so account and
// initial session creation remain atomic.
func (s *Service) CreateUserSession(userID string) (string, error) {
if s.store == nil {
return "", fmt.Errorf("storage disabled")
return "", ErrStorageDisabled
}
sessionID := uuid.New().String()
@@ -278,9 +319,9 @@ func (s *Service) CreateUserSession(userID string) (string, error) {
}
if err := s.store.CreateSession(sessionRecord); err != nil {
return "", fmt.Errorf("failed to create session: %w", err)
return "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
}
slog.Debug("user session created", "user_id", userID)
return sessionID, nil
}
+48
View File
@@ -0,0 +1,48 @@
package service
import (
"testing"
"github.com/lixenwraith/auth"
)
func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
svc := newPersistentTestService(t)
user, sessionID, err := svc.RegisterUser("alice", "", "Password1", false)
if err != nil {
t.Fatal(err)
}
validToken, err := svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
t.Fatal(err)
}
if gotUserID, _, err := svc.ValidateToken(validToken); err != nil || gotUserID != user.UserID {
t.Fatalf("valid token rejected: user=%q err=%v", gotUserID, err)
}
missingSession, err := auth.GenerateHS256Token(svc.jwtSecret, user.UserID, nil, SessionTTL)
if err != nil {
t.Fatal(err)
}
if _, _, err := svc.ValidateToken(missingSession); err == nil {
t.Fatal("token without a persisted session was accepted")
}
wrongSubject, err := auth.GenerateHS256Token(svc.jwtSecret, "other-user", map[string]any{
"session_id": sessionID,
}, SessionTTL)
if err != nil {
t.Fatal(err)
}
if _, _, err := svc.ValidateToken(wrongSubject); err == nil {
t.Fatal("session was accepted for a different JWT subject")
}
if err := svc.InvalidateSession(sessionID); err != nil {
t.Fatal(err)
}
if _, _, err := svc.ValidateToken(validToken); err == nil {
t.Fatal("invalidated session was accepted")
}
}
+52 -58
View File
@@ -18,10 +18,12 @@ const (
// WaitRegistry manages clients waiting for game state changes via long-polling
type WaitRegistry struct {
mu sync.RWMutex
waiters map[string][]*WaitRequest // gameID → waiting clients
shutdown chan struct{}
wg sync.WaitGroup
mu sync.RWMutex
waiters map[string][]*WaitRequest // gameID → waiting clients
shutdown chan struct{}
wg sync.WaitGroup
closed bool
shutdownOnce sync.Once
}
// WaitRequest represents a single client waiting for game updates
@@ -31,6 +33,8 @@ type WaitRequest struct {
Timer *time.Timer // Timeout timer
Context context.Context // Client connection context
GameID string // Game being watched
done chan struct{}
finish sync.Once
}
// NewWaitRegistry creates a new wait registry
@@ -44,7 +48,12 @@ func NewWaitRegistry() *WaitRegistry {
// RegisterWait registers a client to wait for game state changes
func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
w.mu.Unlock()
notify := make(chan struct{})
close(notify)
return notify
}
// Create wait request
req := &WaitRequest{
@@ -52,11 +61,12 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
Notify: make(chan struct{}, WaitChannelBuffer),
Context: ctx,
GameID: gameID,
done: make(chan struct{}),
}
// Setup timeout timer
req.Timer = time.AfterFunc(WaitTimeout, func() {
w.handleTimeout(req)
w.complete(req)
})
// Add to waiters map
@@ -64,20 +74,15 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
// Setup cleanup on context cancellation
w.wg.Add(1)
w.mu.Unlock()
go func() {
defer w.wg.Done()
select {
case <-ctx.Done():
// Client disconnected
w.removeWaiter(gameID, req)
case <-req.Notify:
// Notification received
req.Timer.Stop()
w.removeWaiter(gameID, req)
w.complete(req)
case <-w.shutdown:
// Server shutting down
req.Timer.Stop()
close(req.Notify)
w.complete(req)
case <-req.done:
}
}()
@@ -87,7 +92,7 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
// NotifyGame notifies all clients waiting on a game about state change
func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state core.State) {
w.mu.RLock()
waitList := w.waiters[gameID]
waitList := append([]*WaitRequest(nil), w.waiters[gameID]...)
w.mu.RUnlock()
if len(waitList) == 0 {
return
@@ -95,33 +100,31 @@ func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state cor
settled := state != core.StateOngoing && state != core.StatePending
for _, req := range waitList {
if settled || req.MoveCount != currentMoveCount {
select {
case req.Notify <- struct{}{}:
default:
}
w.complete(req)
}
}
}
// RemoveGame removes all waiters for a game (called before game deletion)
func (w *WaitRegistry) RemoveGame(gameID string) {
w.mu.Lock()
waitList := w.waiters[gameID]
delete(w.waiters, gameID)
w.mu.Unlock()
w.mu.RLock()
waitList := append([]*WaitRequest(nil), w.waiters[gameID]...)
w.mu.RUnlock()
// Notify all waiters that game is gone
for _, req := range waitList {
select {
case req.Notify <- struct{}{}:
default:
}
w.complete(req)
}
}
// Shutdown gracefully shuts down the wait registry
func (w *WaitRegistry) Shutdown(timeout time.Duration) error {
close(w.shutdown)
w.shutdownOnce.Do(func() {
w.mu.Lock()
w.closed = true
close(w.shutdown)
w.mu.Unlock()
})
// Wait for all goroutines with timeout
done := make(chan struct{})
@@ -138,36 +141,27 @@ func (w *WaitRegistry) Shutdown(timeout time.Duration) error {
}
}
// handleTimeout handles wait request timeout
func (w *WaitRegistry) handleTimeout(req *WaitRequest) {
// Send timeout notification
select {
case req.Notify <- struct{}{}:
// Timeout notification sent
default:
// Channel full or closed
}
}
// complete removes a waiter and closes its notification channel exactly once.
// The registry never consumes Notify itself: the HTTP handler is its sole
// consumer, so a state-change signal cannot be lost to a cleanup goroutine.
func (w *WaitRegistry) complete(req *WaitRequest) {
req.finish.Do(func() {
req.Timer.Stop()
// removeWaiter removes a specific waiter from the registry
func (w *WaitRegistry) removeWaiter(gameID string, req *WaitRequest) {
w.mu.Lock()
defer w.mu.Unlock()
waitList := w.waiters[gameID]
for i, waiter := range waitList {
if waiter == req {
// Remove from slice
w.waiters[gameID] = append(waitList[:i], waitList[i+1:]...)
break
w.mu.Lock()
waitList := w.waiters[req.GameID]
for i, waiter := range waitList {
if waiter == req {
w.waiters[req.GameID] = append(waitList[:i], waitList[i+1:]...)
break
}
}
}
if len(w.waiters[req.GameID]) == 0 {
delete(w.waiters, req.GameID)
}
w.mu.Unlock()
// Clean up empty entries
if len(w.waiters[gameID]) == 0 {
delete(w.waiters, gameID)
}
// Stop timer if still running
req.Timer.Stop()
close(req.done)
close(req.Notify)
})
}
+81
View File
@@ -0,0 +1,81 @@
package service
import (
"context"
"sync"
"testing"
"time"
"chess/internal/server/core"
)
func TestWaitRegistryDeliversNotificationToCallerAndRemovesWaiter(t *testing.T) {
registry := NewWaitRegistry()
notify := registry.RegisterWait("game-1", 0, context.Background())
registry.NotifyGame("game-1", 1, core.StateOngoing)
select {
case <-notify:
case <-time.After(time.Second):
t.Fatal("game update was consumed before reaching the caller")
}
select {
case _, ok := <-notify:
if ok {
t.Fatal("completed notification channel returned another value")
}
default:
t.Fatal("completed notification channel was not closed")
}
registry.mu.RLock()
remaining := len(registry.waiters["game-1"])
registry.mu.RUnlock()
if remaining != 0 {
t.Fatalf("completed waiter remains registered: %d", remaining)
}
if err := registry.Shutdown(time.Second); err != nil {
t.Fatal(err)
}
}
func TestWaitRegistryCompletionAndShutdownAreConcurrentAndIdempotent(t *testing.T) {
registry := NewWaitRegistry()
ctx, cancel := context.WithCancel(context.Background())
notify := registry.RegisterWait("game-1", 0, ctx)
var callers sync.WaitGroup
callers.Add(3)
go func() {
defer callers.Done()
registry.NotifyGame("game-1", 1, core.StateOngoing)
}()
go func() {
defer callers.Done()
registry.RemoveGame("game-1")
}()
go func() {
defer callers.Done()
cancel()
}()
callers.Wait()
select {
case <-notify:
case <-time.After(time.Second):
t.Fatal("concurrent completion did not notify caller")
}
if err := registry.Shutdown(time.Second); err != nil {
t.Fatal(err)
}
if err := registry.Shutdown(time.Second); err != nil {
t.Fatalf("second shutdown failed: %v", err)
}
afterShutdown := registry.RegisterWait("game-2", 0, context.Background())
select {
case <-afterShutdown:
default:
t.Fatal("registration after shutdown did not return a closed signal")
}
}
+426 -86
View File
@@ -1,137 +1,477 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"log/slog"
"time"
)
// RecordNewGame asynchronously records a new game
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.result, g.start_time_utc, g.end_time_utc`
// RecordNewGame asynchronously records a new game. Terminal custom-FEN games
// include their result in this insert rather than relying on a second write.
func (s *Store) RecordNewGame(record GameRecord) error {
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
return errors.New("game ID, initial FEN, and player IDs are required")
}
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
return err
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `INSERT INTO games (
game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time,
black_player_id, black_type, black_level, black_search_time,
start_time_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
return s.enqueue("record_game", record.GameID, func(tx *sql.Tx) error {
const 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,
start_time_utc, result, end_time_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := tx.Exec(query,
record.GameID, record.InitialFEN,
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
nullableString(record.WhiteClaimedBy),
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
record.StartTimeUTC,
nullableString(record.BlackClaimedBy),
record.StartTimeUTC, nullableString(record.Result), record.EndTimeUTC,
)
return err
}:
return nil
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping game record")
return nil
}
})
}
// RecordMove asynchronously records a move
func (s *Store) RecordMove(record MoveRecord) error {
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
// 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 := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
return err
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `INSERT INTO moves (
return s.enqueue("record_move", record.Move.GameID, func(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,
record.Move.GameID,
record.Move.MoveNumber,
record.Move.MoveUCI,
record.Move.FENAfterMove,
record.Move.PlayerColor,
record.Move.MoveTimeUTC,
); err != nil {
return err
}
_, err := tx.Exec(query,
record.GameID, record.MoveNumber, record.MoveUCI,
record.FENAfterMove, record.PlayerColor, record.MoveTimeUTC,
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
}
}
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)
}
return nil
})
}
// RecordGameResult persists a terminal transition not accompanied by a move,
// such as a no-legal-moves engine response.
func (s *Store) RecordGameResult(gameID, result string, at time.Time) error {
if gameID == "" {
return errors.New("game ID is required")
}
if !isValidResult(result) {
return fmt.Errorf("invalid game result %q", result)
}
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,
)
return err
}:
return nil
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping move record")
return nil
}
if err != nil {
return err
}
return requireOneGame(res, gameID)
})
}
// DeleteUndoneMoves asynchronously deletes moves after undo
func (s *Store) DeleteUndoneMoves(gameID string, afterMoveNumber int) error {
if !s.healthStatus.Load() {
return nil // Silently drop if degraded
// 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")
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `DELETE FROM moves WHERE game_id = ? AND move_number > ?`
_, err := tx.Exec(query, gameID, afterMoveNumber)
return err
}:
return nil
default:
// Channel full, drop write
log.Printf("Storage write queue full, dropping undo operation")
return nil
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)
})
}
// QueryGames retrieves games with optional filtering
// 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,
)
if err != nil {
return err
}
return requireOneGame(res, gameID)
})
}
// PlayerRecord is the persistence subset of a player configuration.
type PlayerRecord struct {
PlayerID string
Type int
Level int
SearchTime int
ClaimedBy string
}
// 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 > ?`,
gameID, afterMoveNumber,
); err != nil {
return err
}
res, err := tx.Exec(
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = ?`,
gameID,
)
if err != nil {
return err
}
return requireOneGame(res, gameID)
})
}
// 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) {
query := `SELECT
game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time,
black_player_id, black_type, black_level, black_search_time,
start_time_utc
FROM games WHERE 1=1`
if err := s.flushBeforeRead(); err != nil {
return nil, err
}
started := time.Now()
query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE 1=1`
var args []any
// Handle gameID filtering
if gameID != "" && gameID != "*" {
query += " AND game_id = ?"
query += " AND g.game_id = ?"
args = append(args, gameID)
}
// Handle playerID filtering
if playerID != "" && playerID != "*" {
query += " AND (white_player_id = ? OR black_player_id = ?)"
args = append(args, 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)
}
query += " ORDER BY start_time_utc DESC"
query += " ORDER BY g.start_time_utc DESC, g.game_id DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
return nil, fmt.Errorf("query games: %w", err)
}
defer rows.Close()
var games []GameRecord
games := make([]GameRecord, 0)
for rows.Next() {
var g GameRecord
err := rows.Scan(
&g.GameID, &g.InitialFEN,
&g.WhitePlayerID, &g.WhiteType, &g.WhiteLevel, &g.WhiteSearchTime,
&g.BlackPlayerID, &g.BlackType, &g.BlackLevel, &g.BlackSearchTime,
&g.StartTimeUTC,
)
if err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
var record GameRecord
if err := scanGame(rows, &record); err != nil {
return nil, fmt.Errorf("scan game: %w", err)
}
games = append(games, g)
games = append(games, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration failed: %w", err)
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.
func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error) {
if err := s.flushBeforeRead(); err != nil {
return nil, nil, err
}
started := time.Now()
tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{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 {
return nil, nil, err
}
moves, err := getMovesForGame(tx, gameID)
if err != nil {
return nil, nil, 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
}
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 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
FROM games g
WHERE g.white_player_id = ? OR g.black_player_id = ?
OR g.white_claimed_by = ? OR g.black_claimed_by = ?
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)
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 := scanGameSummary(rows, &summary); 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", limit,
"offset", offset,
"duration", time.Since(started),
)
return games, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanGame(scanner rowScanner, record *GameRecord) error {
var whiteClaimed, blackClaimed, result sql.NullString
var endTime sql.NullTime
if err := scanner.Scan(
&record.GameID, &record.InitialFEN,
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, &whiteClaimed,
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &blackClaimed,
&result, &record.StartTimeUTC, &endTime,
); err != nil {
return err
}
record.WhiteClaimedBy = whiteClaimed.String
record.BlackClaimedBy = blackClaimed.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
}
return nil
}
func requireOneGame(result sql.Result, gameID string) error {
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows != 1 {
return fmt.Errorf("game %s was not updated", gameID)
}
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":
return true
default:
return false
}
}
func validateResultTime(result string, ended *time.Time) error {
if result == "" {
if ended != nil {
return errors.New("end time requires a game result")
}
return nil
}
if !isValidResult(result) {
return fmt.Errorf("invalid game result %q", 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)
}
+52 -24
View File
@@ -24,17 +24,26 @@ type SessionRecord struct {
// 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"`
BlackPlayerID string `db:"black_player_id"`
BlackType int `db:"black_type"`
BlackLevel int `db:"black_level"`
BlackSearchTime int `db:"black_search_time"`
StartTimeUTC time.Time `db:"start_time_utc"`
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"`
}
type GameSummaryRecord struct {
GameRecord
MoveCount int `db:"move_count"`
}
// MoveRecord represents a row in the moves table
@@ -48,7 +57,19 @@ type MoveRecord struct {
MoveTimeUTC time.Time `db:"move_time_utc"`
}
// Schema defines the SQLite database structure
// MovePersistence groups changes caused by one accepted move so the move,
// first-move slot claim, and terminal result commit in one transaction.
type MovePersistence struct {
Move MoveRecord
ClaimColor string
ClaimedBy string
Result string
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,
@@ -61,12 +82,6 @@ CREATE TABLE IF NOT EXISTS users (
last_login_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_account_type ON users(account_type);
CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users(expires_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email) WHERE email IS NOT NULL AND email != '';
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE,
@@ -75,9 +90,6 @@ CREATE TABLE IF NOT EXISTS sessions (
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS games (
game_id TEXT PRIMARY KEY,
initial_fen TEXT NOT NULL,
@@ -89,7 +101,11 @@ CREATE TABLE IF NOT EXISTS games (
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
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 IF NOT EXISTS moves (
@@ -103,8 +119,20 @@ CREATE TABLE IF NOT EXISTS moves (
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
UNIQUE(game_id, move_number)
);
`
CREATE INDEX IF NOT EXISTS idx_moves_game_id ON moves(game_id);
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)
WHERE white_claimed_by IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_games_black_claimed ON games(black_claimed_by)
WHERE black_claimed_by IS NOT NULL;
`
+44 -23
View File
@@ -2,30 +2,23 @@ package storage
import (
"fmt"
"log/slog"
"time"
)
// CreateSession creates or replaces the session for a user (single session per user)
func (s *Store) CreateSession(record SessionRecord) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Delete any existing session for this user
deleteQuery := `DELETE FROM sessions WHERE user_id = ?`
if _, err := tx.Exec(deleteQuery, record.UserID); err != nil {
return fmt.Errorf("failed to delete existing session: %w", err)
}
// Insert new session
insertQuery := `INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`
if _, err := tx.Exec(insertQuery, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil {
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 {
return fmt.Errorf("failed to create session: %w", err)
}
return tx.Commit()
slog.Debug("storage session created", "user_id", record.UserID, "expires_at", record.ExpiresAt)
return nil
}
// GetSession retrieves a session by ID
@@ -60,6 +53,9 @@ func (s *Store) GetSessionByUserID(userID string) (*SessionRecord, 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")
}
return err
}
@@ -67,6 +63,9 @@ func (s *Store) DeleteSession(sessionID string) error {
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)
}
return err
}
@@ -77,16 +76,38 @@ func (s *Store) DeleteExpiredSessions() (int64, error) {
if err != nil {
return 0, err
}
return result.RowsAffected()
deleted, err := result.RowsAffected()
if err == nil && deleted > 0 {
slog.Debug("storage expired sessions deleted", "count", deleted)
}
return deleted, err
}
// IsSessionValid checks if a session exists and is not expired
func (s *Store) IsSessionValid(sessionID string) (bool, error) {
var count int
query := `SELECT COUNT(*) FROM sessions WHERE session_id = ? AND expires_at > ?`
err := s.db.QueryRow(query, sessionID, time.Now().UTC()).Scan(&count)
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 count > 0, nil
}
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) {
var valid bool
const query = `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)
if err != nil {
return false, err
}
return valid, nil
}
+305 -65
View File
@@ -3,9 +3,11 @@ package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"log/slog"
"os"
"strings"
"sync"
"sync/atomic"
"time"
@@ -13,48 +15,67 @@ import (
_ "github.com/mattn/go-sqlite3"
)
const (
writeQueueCapacity = 1000
flushTimeout = 5 * time.Second
schemaVersion = 2
)
var memoryStoreCounter atomic.Uint64
var (
ErrStorageDegraded = errors.New("storage is degraded")
ErrStoreClosed = errors.New("storage is closed")
ErrWriteQueueFull = errors.New("storage write queue is full")
)
type writeRequest struct {
operation string
gameID string
run func(*sql.Tx) error
barrier chan error
}
// Store handles SQLite database operations with async writes for games and sync writes for auth
type Store struct {
db *sql.DB
path string
writeChan chan func(*sql.Tx) error
writeChan chan writeRequest
healthStatus atomic.Bool
writeFailed atomic.Bool
closed atomic.Bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
enqueueMu sync.RWMutex
closeOnce sync.Once
closeErr error
}
// NewStore creates a new storage instance with async writer
func NewStore(dataSourceName string, devMode bool) (*Store, error) {
db, err := sql.Open("sqlite3", dataSourceName)
dsn := sqliteDSN(dataSourceName)
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Enable WAL mode in development for better concurrency
if devMode {
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
}
}
// Enable foreign keys
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
// 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())
s := &Store{
db: db,
path: dataSourceName,
writeChan: make(chan func(*sql.Tx) error, 1000), // Buffered for async writes
writeChan: make(chan writeRequest, writeQueueCapacity),
ctx: ctx,
cancel: cancel,
}
@@ -65,10 +86,34 @@ func NewStore(dataSourceName string, devMode bool) (*Store, error) {
// Start async writer
s.wg.Add(1)
go s.writerLoop()
slog.Debug("storage opened",
"path", dataSourceName,
"dev_mode", devMode,
"write_queue_capacity", writeQueueCapacity,
"max_open_connections", 8,
)
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()
@@ -77,96 +122,291 @@ func (s *Store) IsHealthy() bool {
// writerLoop processes async write operations
func (s *Store) writerLoop() {
defer s.wg.Done()
slog.Debug("storage writer started")
defer slog.Debug("storage writer stopped")
for {
select {
case <-s.ctx.Done():
// Drain remaining writes with timeout
deadline := time.After(2 * time.Second)
// Every accepted operation is drained before shutdown. The queue is
// bounded, so shutdown remains bounded by actual database work rather
// than an arbitrary timer that can discard replay history.
for {
select {
case fn := <-s.writeChan:
if s.healthStatus.Load() {
s.executeWrite(fn)
}
case <-deadline:
return
case req := <-s.writeChan:
s.handleWrite(req)
default:
return
}
}
case fn := <-s.writeChan:
// Skip if already degraded
if !s.healthStatus.Load() {
continue
}
s.executeWrite(fn)
case req := <-s.writeChan:
s.handleWrite(req)
}
}
}
// executeWrite runs a transactional write operation
func (s *Store) executeWrite(fn func(*sql.Tx) error) {
tx, err := s.db.Begin()
if err != nil {
log.Printf("Storage degraded: failed to begin transaction: %v", err)
s.healthStatus.Store(false)
func (s *Store) handleWrite(req writeRequest) {
if req.run == nil {
if req.barrier != nil {
var err error
if !s.healthStatus.Load() {
err = ErrStorageDegraded
}
req.barrier <- err
close(req.barrier)
}
return
}
if err := fn(tx); err != nil {
tx.Rollback()
log.Printf("Storage degraded: write operation failed: %v", err)
s.healthStatus.Store(false)
if s.writeFailed.Load() {
slog.Error("storage write skipped after earlier transaction failure",
"operation", req.operation, "game_id", req.gameID)
if req.barrier != nil {
req.barrier <- ErrStorageDegraded
close(req.barrier)
}
return
}
err := s.executeWrite(req)
if req.barrier != nil {
req.barrier <- err
close(req.barrier)
}
}
// executeWrite runs a transactional write operation
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
}
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 {
log.Printf("Storage degraded: failed to commit: %v", err)
s.writeFailed.Store(true)
s.healthStatus.Store(false)
return
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
}
func (s *Store) enqueue(operation, gameID string, fn func(*sql.Tx) error) error {
s.enqueueMu.RLock()
defer s.enqueueMu.RUnlock()
if s.closed.Load() {
return ErrStoreClosed
}
if !s.healthStatus.Load() {
return ErrStorageDegraded
}
select {
case s.writeChan <- writeRequest{operation: operation, gameID: gameID, run: fn}:
slog.Debug("storage write queued",
"operation", operation,
"game_id", gameID,
"queue_depth", len(s.writeChan),
)
return nil
default:
s.healthStatus.Store(false)
slog.Error("storage degraded: write queue full",
"operation", operation,
"game_id", gameID,
"queue_capacity", cap(s.writeChan),
)
return ErrWriteQueueFull
}
}
// Flush waits until every write queued before this call has completed. Replay
// reads use this barrier to provide read-after-write consistency while normal
// gameplay retains the low-latency async write path.
func (s *Store) Flush(ctx context.Context) error {
s.enqueueMu.RLock()
if s.closed.Load() {
s.enqueueMu.RUnlock()
return ErrStoreClosed
}
if !s.healthStatus.Load() {
s.enqueueMu.RUnlock()
return ErrStorageDegraded
}
done := make(chan error, 1)
select {
case s.writeChan <- writeRequest{operation: "flush", barrier: done}:
s.enqueueMu.RUnlock()
case <-ctx.Done():
s.enqueueMu.RUnlock()
return ctx.Err()
}
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Store) flushBeforeRead() error {
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout)
defer cancel()
return s.Flush(ctx)
}
// Close gracefully closes the database connection
func (s *Store) Close() error {
// Signal writer to stop
s.cancel()
s.closeOnce.Do(func() {
s.enqueueMu.Lock()
s.closed.Store(true)
s.cancel()
s.enqueueMu.Unlock()
// Wait for writer with timeout
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
// Writer finished cleanly
case <-time.After(2 * time.Second):
log.Printf("Warning: storage writer shutdown timeout, some writes may be lost")
}
if s.db != nil {
return s.db.Close()
}
return nil
if s.db != nil {
s.closeErr = s.db.Close()
}
})
return s.closeErr
}
// InitDB creates the database schema
func (s *Store) InitDB() error {
started := time.Now()
tx, err := s.db.Begin()
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(&currentVersion); err != nil {
return fmt.Errorf("failed to read schema version: %w", err)
}
if currentVersion > schemaVersion {
return fmt.Errorf(
"database schema version %d is newer than supported version %d",
currentVersion,
schemaVersion,
)
}
if _, err := tx.Exec(Schema); err != nil {
return fmt.Errorf("failed to create schema: %w", err)
}
return tx.Commit()
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
}
}
// 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 {
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))
return nil
}
func ensureColumn(tx *sql.Tx, table, column, definition string) error {
rows, err := tx.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
}
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, &notNull, &defaultValue, &primaryKey); err != nil {
rows.Close()
return fmt.Errorf("failed to inspect %s column: %w", table, err)
}
if name == column {
found = true
}
}
if err := rows.Close(); err != nil {
return fmt.Errorf("failed to close %s schema rows: %w", table, err)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
}
if found {
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
}
// DeleteDB removes the database file
@@ -182,4 +422,4 @@ func (s *Store) DeleteDB() error {
}
return nil
}
}
+525
View File
@@ -0,0 +1,525 @@
package storage
import (
"context"
"database/sql"
"errors"
"path/filepath"
"strings"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
)
func TestInitDBMigratesLegacySchemaAndRemovesRedundantIndexes(t *testing.T) {
path := filepath.Join(t.TempDir(), "legacy.db")
db, err := sql.Open("sqlite3", path)
if err != nil {
t.Fatal(err)
}
const legacy = `
CREATE TABLE 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',
created_at DATETIME NOT NULL,
expires_at DATETIME,
last_login_at DATETIME
);
CREATE TABLE sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
CREATE TABLE 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
);
CREATE TABLE 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,
move_time_utc DATETIME NOT NULL,
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
UNIQUE(game_id, move_number)
);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_account_type ON users(account_type);
CREATE INDEX idx_users_expires_at ON users(expires_at);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_moves_game_id ON moves(game_id);`
if _, err := db.Exec(legacy); err != nil {
t.Fatalf("create legacy schema: %v", err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
store, err := NewStore(path, false)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
if err := store.InitDB(); err != nil {
t.Fatalf("migrate schema: %v", err)
}
columns := tableColumns(t, store.db, "games")
for _, column := range []string{"result", "end_time_utc", "white_claimed_by", "black_claimed_by"} {
if !columns[column] {
t.Errorf("migration did not add games.%s", column)
}
}
indexes := schemaIndexes(t, store.db)
for _, obsolete := 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 indexes[obsolete] {
t.Errorf("redundant index %s remains", obsolete)
}
}
for _, required := range []string{
"idx_users_email_unique", "idx_users_temp_created_at", "idx_users_temp_expires_at",
"idx_sessions_expires_at", "idx_games_white_player", "idx_games_black_player",
"idx_games_white_claimed", "idx_games_black_claimed",
} {
if !indexes[required] {
t.Errorf("required index %s is missing", required)
}
}
var version, foreignKeys int
if err := store.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
t.Fatal(err)
}
if version != 2 {
t.Errorf("schema version = %d, want 2", version)
}
if err := store.db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
t.Fatal(err)
}
if foreignKeys != 1 {
t.Errorf("foreign_keys = %d, want 1", foreignKeys)
}
}
func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
store := newTestStore(t)
started := time.Date(2026, 9, 7, 1, 2, 3, 0, time.UTC)
ended := started.Add(5 * time.Minute)
if err := store.RecordNewGame(GameRecord{
GameID: "game-1", InitialFEN: "initial",
WhitePlayerID: "anonymous-white", WhiteType: 1,
BlackPlayerID: "black", BlackType: 1,
StartTimeUTC: started,
}); err != nil {
t.Fatal(err)
}
if err := store.RecordMove(MovePersistence{
Move: MoveRecord{
GameID: "game-1", MoveNumber: 1, MoveUCI: "e2e4",
FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended,
},
ClaimColor: "w", ClaimedBy: "user-1",
Result: "white_wins", EndTimeUTC: &ended,
}); err != nil {
t.Fatal(err)
}
// GetGameHistory must observe both queued writes without sleeps or polling.
gameRecord, moves, err := store.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if gameRecord.WhiteClaimedBy != "user-1" || gameRecord.Result != "white_wins" || gameRecord.EndTimeUTC == nil {
t.Fatalf("durable game mutation incomplete: %+v", gameRecord)
}
if len(moves) != 1 || moves[0].MoveNumber != 1 || moves[0].FENAfterMove != "after-e2e4" {
t.Fatalf("moves = %+v", moves)
}
owned, err := store.QueryGamesForUser("user-1", 10, 0)
if err != nil {
t.Fatal(err)
}
if len(owned) != 1 || owned[0].MoveCount != 1 {
t.Fatalf("claimed game lookup = %+v", owned)
}
if err := store.RewindGame("game-1", 0); err != nil {
t.Fatal(err)
}
gameRecord, moves, err = store.GetGameHistory("game-1")
if err != nil {
t.Fatal(err)
}
if gameRecord.Result != "" || gameRecord.EndTimeUTC != nil || len(moves) != 0 {
t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", gameRecord, moves)
}
}
func TestConnectionSettingsApplyAcrossPool(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
connections := make([]*sql.Conn, 0, 8)
defer func() {
for _, connection := range connections {
_ = connection.Close()
}
}()
// Keep each connection checked out so the pool must create eight distinct
// SQLite connections, then verify connection-local PRAGMAs on every one.
for range 8 {
connection, err := store.db.Conn(ctx)
if err != nil {
t.Fatal(err)
}
connections = append(connections, connection)
}
for i, connection := range connections {
var foreignKeys, busyTimeout, synchronous int
var journalMode string
if err := connection.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
t.Fatal(err)
}
if err := connection.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busyTimeout); err != nil {
t.Fatal(err)
}
if err := connection.QueryRowContext(ctx, "PRAGMA synchronous").Scan(&synchronous); err != nil {
t.Fatal(err)
}
if err := connection.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode); err != nil {
t.Fatal(err)
}
if foreignKeys != 1 || busyTimeout != 5000 || synchronous != 1 || !strings.EqualFold(journalMode, "wal") {
t.Errorf(
"connection %d settings: foreign_keys=%d busy_timeout=%d synchronous=%d journal_mode=%s",
i, foreignKeys, busyTimeout, synchronous, journalMode,
)
}
}
}
func TestAcceptedWriteCommitsAfterQueueAdmissionFailureMarksHealthDegraded(t *testing.T) {
store := newTestStore(t)
store.healthStatus.Store(false) // Queue saturation rejects new work but accepted work must drain.
done := make(chan error, 1)
store.handleWrite(writeRequest{
operation: "accepted_before_saturation",
run: func(tx *sql.Tx) error {
_, err := tx.Exec(`INSERT INTO users
(user_id, username, password_hash, account_type, created_at)
VALUES ('user-1', 'alice', 'hash', 'permanent', ?)`, time.Now().UTC())
return err
},
barrier: done,
})
if err := <-done; err != nil {
t.Fatalf("accepted write was discarded: %v", err)
}
if _, err := store.GetUserByID("user-1"); err != nil {
t.Fatalf("accepted write was not committed: %v", err)
}
}
func TestWritesAfterTransactionFailureAreSkippedExplicitly(t *testing.T) {
store := newTestStore(t)
failed := make(chan error, 1)
store.handleWrite(writeRequest{
operation: "forced_failure",
run: func(*sql.Tx) error { return errors.New("forced failure") },
barrier: failed,
})
if err := <-failed; err == nil {
t.Fatal("forced transaction failure was not reported")
}
ran := false
skipped := make(chan error, 1)
store.handleWrite(writeRequest{
operation: "after_failure",
run: func(*sql.Tx) error {
ran = true
return nil
},
barrier: skipped,
})
if err := <-skipped; !errors.Is(err, ErrStorageDegraded) {
t.Fatalf("skipped write error = %v, want ErrStorageDegraded", err)
}
if ran {
t.Fatal("write ran after an earlier transaction broke ordering")
}
}
func TestNewerSchemaVersionIsRejected(t *testing.T) {
store := newTestStore(t)
if _, err := store.db.Exec("PRAGMA user_version = 3"); err != nil {
t.Fatal(err)
}
if err := store.InitDB(); err == nil || !strings.Contains(err.Error(), "newer than supported") {
t.Fatalf("InitDB error = %v, want newer-version rejection", err)
}
var version int
if err := store.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
t.Fatal(err)
}
if version != 3 {
t.Fatalf("newer schema version was overwritten: %d", version)
}
}
func TestQueryPlansUseOnlyPurposeBuiltOrConstraintIndexes(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
tests := []struct {
name string
query string
args []any
want []string
}{
{
name: "email partial uniqueness",
query: `SELECT user_id FROM users
WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != ''`,
args: []any{"alice@example.com"}, want: []string{"idx_users_email_unique"},
},
{
name: "temporary expiry cleanup",
query: `SELECT user_id FROM users
WHERE account_type = 'temp' AND expires_at IS NOT NULL AND expires_at < ?`,
args: []any{now}, want: []string{"idx_users_temp_expires_at"},
},
{
name: "oldest temporary account",
query: `SELECT user_id FROM users
WHERE account_type = 'temp' ORDER BY created_at ASC LIMIT 1`,
want: []string{"idx_users_temp_created_at"},
},
{
name: "ordered moves use composite unique constraint",
query: `SELECT move_uci FROM moves
WHERE game_id = ? ORDER BY move_number ASC`,
args: []any{"game-1"}, want: []string{"sqlite_autoindex_moves_1"},
},
{
name: "all user association branches",
query: `SELECT game_id,
(SELECT COUNT(*) FROM moves m WHERE m.game_id = games.game_id)
FROM games WHERE white_player_id = ? OR black_player_id = ?
OR white_claimed_by = ? OR black_claimed_by = ?
ORDER BY start_time_utc DESC, game_id DESC LIMIT ? OFFSET ?`,
args: []any{"user-1", "user-1", "user-1", "user-1", 50, 0},
want: []string{
"idx_games_white_player", "idx_games_black_player",
"idx_games_white_claimed", "idx_games_black_claimed",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
plan := explainQueryPlan(t, store.db, test.query, test.args...)
for _, index := range test.want {
if !strings.Contains(plan, index) {
t.Errorf("query plan does not use %s:\n%s", index, plan)
}
}
})
}
}
func TestForeignKeyCascadeAppliesToSessions(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
if err := store.CreateUser(UserRecord{
UserID: "user-1", Username: "user1", PasswordHash: "hash",
AccountType: "permanent", CreatedAt: now,
}); err != nil {
t.Fatal(err)
}
if err := store.CreateSession(SessionRecord{
SessionID: "session-1", UserID: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
if err := store.DeleteUser("user-1"); err != nil {
t.Fatal(err)
}
if _, err := store.GetSession("session-1"); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("session survived user cascade: %v", err)
}
}
func TestLimitedUserCreationIsAtomicWithInitialSession(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
limits := UserLimits{MaxUsers: 1, PermanentSlots: 1}
first := UserRecord{
UserID: "user-1", Username: "alice", PasswordHash: "hash",
AccountType: "temp", CreatedAt: now, ExpiresAt: timePointer(now.Add(time.Hour)),
}
firstSession := SessionRecord{
SessionID: "session-1", UserID: first.UserID,
CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}
if err := store.CreateUserWithinLimits(first, &firstSession, limits); err != nil {
t.Fatal(err)
}
duplicate := first
duplicate.UserID = "user-duplicate"
if err := store.CreateUserWithinLimits(duplicate, nil, limits); !errors.Is(err, ErrUserAlreadyExists) {
t.Fatalf("duplicate error = %v, want ErrUserAlreadyExists", err)
}
if _, err := store.GetUserByID(first.UserID); err != nil {
t.Fatalf("duplicate registration evicted existing user: %v", err)
}
second := UserRecord{
UserID: "user-2", Username: "bob", PasswordHash: "hash",
AccountType: "temp", CreatedAt: now.Add(time.Minute),
ExpiresAt: timePointer(now.Add(2 * time.Hour)),
}
secondSession := SessionRecord{
SessionID: "session-2", UserID: second.UserID,
CreatedAt: now.Add(time.Minute), ExpiresAt: now.Add(2 * time.Hour),
}
if err := store.CreateUserWithinLimits(second, &secondSession, limits); err != nil {
t.Fatal(err)
}
if _, err := store.GetUserByID(first.UserID); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("oldest temporary user was not replaced: %v", err)
}
if _, err := store.GetSession(firstSession.SessionID); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("evicted user's session survived cascade: %v", err)
}
if _, err := store.GetSession(secondSession.SessionID); err != nil {
t.Fatalf("initial session not committed with user: %v", err)
}
third := UserRecord{
UserID: "user-3", Username: "charlie", PasswordHash: "hash",
AccountType: "temp", CreatedAt: now.Add(2 * time.Minute),
}
conflictingSession := SessionRecord{
SessionID: secondSession.SessionID, UserID: third.UserID,
CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}
wideLimits := UserLimits{MaxUsers: 10, PermanentSlots: 2}
if err := store.CreateUserWithinLimits(third, &conflictingSession, wideLimits); err == nil {
t.Fatal("expected duplicate session failure")
}
if _, err := store.GetUserByID(third.UserID); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("session failure did not roll back user: %v", err)
}
}
func timePointer(value time.Time) *time.Time {
return &value
}
func newTestStore(t *testing.T) *Store {
t.Helper()
store, err := NewStore(filepath.Join(t.TempDir(), "chess.db"), false)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
if err := store.InitDB(); err != nil {
t.Fatal(err)
}
return store
}
func tableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
t.Helper()
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
columns := make(map[string]bool)
for rows.Next() {
var cid, notNull, primaryKey int
var name, columnType string
var defaultValue any
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil {
t.Fatal(err)
}
columns[name] = true
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return columns
}
func schemaIndexes(t *testing.T, db *sql.DB) map[string]bool {
t.Helper()
rows, err := db.Query(`SELECT name FROM sqlite_schema WHERE type = 'index'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
indexes := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
indexes[name] = true
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return indexes
}
func explainQueryPlan(t *testing.T, db *sql.DB, query string, args ...any) string {
t.Helper()
rows, err := db.Query("EXPLAIN QUERY PLAN "+query, args...)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var details []string
for rows.Next() {
var id, parent, notUsed int
var detail string
if err := rows.Scan(&id, &parent, &notUsed, &detail); err != nil {
t.Fatal(err)
}
details = append(details, detail)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return strings.Join(details, "\n")
}
+94 -26
View File
@@ -2,16 +2,22 @@ package storage
import (
"database/sql"
"errors"
"fmt"
"log"
"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
type UserLimits struct {
MaxUsers int
PermanentSlots int
TempTTL time.Duration
}
// DefaultUserLimits returns default POC limits
@@ -19,7 +25,6 @@ func DefaultUserLimits() UserLimits {
return UserLimits{
MaxUsers: 100,
PermanentSlots: 10,
TempTTL: 24 * time.Hour,
}
}
@@ -59,16 +64,38 @@ func (s *Store) GetOldestTempUser() (*UserRecord, error) {
// DeleteExpiredTempUsers removes temporary users past their expiry
func (s *Store) DeleteExpiredTempUsers() (int64, error) {
query := `DELETE FROM users WHERE account_type = 'temp' AND expires_at < ?`
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
}
return result.RowsAffected()
deleted, err := result.RowsAffected()
if err == nil && deleted > 0 {
slog.Debug("storage expired temporary users deleted", "count", deleted)
}
return deleted, err
}
// CreateUser creates user with transaction isolation to prevent race conditions
// CreateUser creates an administratively managed user without applying the
// public-registration capacity policy.
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.
func (s *Store) CreateUserWithinLimits(
record UserRecord,
session *SessionRecord,
limits UserLimits,
) error {
return s.createUser(record, session, &limits)
}
func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *UserLimits) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
@@ -81,7 +108,37 @@ func (s *Store) CreateUser(record UserRecord) error {
return err
}
if exists {
return fmt.Errorf("username or email already 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 {
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
}
}
}
// Insert user
@@ -96,14 +153,36 @@ func (s *Store) CreateUser(record UserRecord) error {
if err != nil {
return 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 (?, ?, ?, ?)`,
session.SessionID, session.UserID, session.CreatedAt, session.ExpiresAt,
); err != nil {
return fmt.Errorf("create initial session: %w", err)
}
}
return tx.Commit()
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,
)
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)
}
return err
}
@@ -121,7 +200,9 @@ func (s *Store) userExists(tx *sql.Tx, username, email string) (bool, error) {
args := []any{username}
if email != "" {
query = `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE OR email = ? COLLATE NOCASE`
query = `SELECT COUNT(*) FROM users
WHERE username = ? COLLATE NOCASE
OR (email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '')`
args = append(args, email)
}
@@ -217,7 +298,7 @@ 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`
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,
@@ -250,21 +331,8 @@ func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
return &user, nil
}
// DeleteUser removes a user from the database (async)
// 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 {
if !s.healthStatus.Load() {
return nil
}
select {
case s.writeChan <- func(tx *sql.Tx) error {
query := `DELETE FROM users WHERE user_id = ?`
_, err := tx.Exec(query, userID)
return err
}:
return nil
default:
log.Printf("Storage write queue full, dropping user deletion")
return nil
}
return s.DeleteUserByID(userID)
}
@@ -357,7 +357,20 @@ function authFetch(url, options = {}) {
}
async function getConfig() {
return { apiUrl: '/chess' };
try {
const response = await fetch('./config', { cache: 'no-store' });
if (!response.ok) throw new Error(`config returned ${response.status}`);
const config = await response.json();
if (typeof config.apiUrl !== 'string' || !config.apiUrl.trim()) {
throw new Error('config is missing apiUrl');
}
return { apiUrl: config.apiUrl.replace(/\/+$/, '') };
} catch (error) {
// Static production hosting currently reverse-proxies the API here.
// The embedded server supplies /config and does not use this fallback.
console.debug('Using default API route:', error.message);
return { apiUrl: '/chess' };
}
}
function startHealthCheck() {
@@ -835,7 +848,7 @@ async function pollOnce() {
gameState.pollController = new AbortController();
try {
const response = await fetch(
const response = await authFetch(
`${gameState.apiUrl}/api/v1/games/${gameState.gameId}?wait=true&moveCount=${moveCount}`,
{ signal: gameState.pollController.signal }
);
@@ -895,7 +908,7 @@ async function undoMoves() {
}
try {
const response = await fetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/undo`, {
const response = await authFetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/undo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: 2 })
+12 -8
View File
@@ -16,7 +16,7 @@ import (
var webFS embed.FS
// Start initializes and starts the web UI server
func Start(host string, port int, apiURL string) error {
func Start(host string, port int, apiURL string, logRequests bool) error {
app := fiber.New(fiber.Config{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
@@ -24,13 +24,17 @@ func Start(host string, port int, apiURL string) error {
})
// Middleware
app.Use(logger.New(logger.Config{
Format: "${time} WEB ${status} ${method} ${path} ${latency}\n",
}))
if logRequests {
app.Use(logger.New(logger.Config{
Format: "${time} WEB ${status} ${method} ${path} ${latency}\n",
TimeFormat: time.RFC3339,
TimeZone: "UTC",
}))
}
app.Use(cors.New())
// Create a sub-filesystem that points to the 'web' directory
webContent, err := fs.Sub(webFS, "web")
// Create a sub-filesystem rooted at the embedded web client.
webContent, err := fs.Sub(webFS, "chess-client-web")
if err != nil {
return fmt.Errorf("failed to create web sub-filesystem: %w", err)
}
@@ -42,7 +46,7 @@ func Start(host string, port int, apiURL string) error {
})
})
// Serve static files from the embedded 'web' directory
// Serve static files from the embedded client directory.
app.Get("*", func(c *fiber.Ctx) error {
path := c.Path()
@@ -84,4 +88,4 @@ func Start(host string, port int, apiURL string) error {
addr := fmt.Sprintf("%s:%d", host, port)
return app.Listen(addr)
}
}
+22
View File
@@ -0,0 +1,22 @@
package webserver
import (
"io/fs"
"testing"
)
func TestEmbeddedWebClientRoot(t *testing.T) {
content, err := fs.Sub(webFS, "chess-client-web")
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"index.html", "app.js", "style.css"} {
data, err := fs.ReadFile(content, name)
if err != nil {
t.Errorf("read embedded %s: %v", name, err)
}
if len(data) == 0 {
t.Errorf("embedded %s is empty", name)
}
}
}
+16 -11
View File
@@ -8,7 +8,7 @@ This directory contains comprehensive test suites for the Chess API server, cove
- `curl` - HTTP client
- `sqlite3` - SQLite CLI (for database tests)
- `base64` - Base64 encoder (for JWT tests)
- Compiled `chessd` binary in accessible path
- Compiled `bin/chess-server` binary (or pass another path to the scripts)
## Running the test server
From repo root
@@ -16,17 +16,21 @@ From repo root
test/run-test-server.sh
```
Pass binary path as first argument of the script if it's not placed in current directory `./chessd`.
Server will run with '-dev' option, enabling db WAL mode and relaxing rate limiting.
Pass the binary path as the first argument when it is not at `bin/chess-server`.
The server runs with `-dev`, debug logging, HTTP request logging, and relaxed rate limiting. WAL is enabled for every persistent server mode.
Will clean up test database and temporary files, so it's preferred for clean testing.
Can be used for all the tests.
Logging can be adjusted without editing the script:
```bash
LOG_LEVEL=info LOG_HTTP=false test/run-test-server.sh bin/chess-server
```
### Pre-configured Users
| Username | Password | Email |
|----------|----------|-------|
| alice | AlicePass123 | alice@example.com |
| bob | BobPass456 | bob@example.com |
| charlie | CharliePass789 | - |
### Features
- Automatically initializes database schema
@@ -34,6 +38,7 @@ Can be used for all the tests.
- Runs on port 8080 (API) and 9090 (Web UI)
- Development mode with relaxed rate limits
- Fixed JWT secret for consistent tokens
- Debug persistence and engine-queue logs by default
- Graceful shutdown on Ctrl+C
### Manual Testing Examples
@@ -67,8 +72,8 @@ Tests core game mechanics and API endpoints.
### Running the test
```bash
# Terminal 1: Start server in development mode
test/run-test-server.sh ./chessd
# Direct (no cleanup required): ./chessd -dev
test/run-test-server.sh bin/chess-server
# Direct (no cleanup required): bin/chess-server -dev
# Terminal 2: Run API tests
test/test-api.sh
@@ -92,11 +97,11 @@ Tests user management, authentication, and persistence via API integration.
### Running the test
```bash
# Terminal 1: Start test server with database
# Server is running with -dev option (WAL mode db)
test/test-db-server.sh ./chessd
# Server is running with -dev and persistent WAL storage
test/test-db-server.sh bin/chess-server
# Terminal 2: Run API integration tests
test/test-db.sh ./chessd
test/test-db.sh bin/chess-server
```
### Coverage
@@ -123,8 +128,8 @@ Tests real-time game updates via HTTP long-polling.
### Running the test
```bash
# Terminal 1: Start server with storage
test/run-test-server.sh ./chessd
# Direct (test.db cleanup required): ./chessd -dev -storage-path test.db
test/run-test-server.sh bin/chess-server
# Direct (test.db cleanup required): bin/chess-server -dev -storage-path test.db
# Terminal 2: Run long-polling tests
test/test-longpoll.sh
+10 -5
View File
@@ -7,6 +7,8 @@ CHESS_SERVER_EXEC=${1:-"bin/chess-server"}
TEST_DB="test.db"
PID_FILE="/tmp/chess-server_test.pid"
API_PORT=${API_PORT:-8080}
LOG_LEVEL=${LOG_LEVEL:-debug}
LOG_HTTP=${LOG_HTTP:-true}
# Colors for output
GREEN='\033[0;32m'
@@ -18,7 +20,7 @@ NC='\033[0m'
# Check executable
if [ ! -x "$CHESS_SERVER_EXEC" ]; then
echo -e "${RED}Error: chess-server executable not found or not executable: $CHESS_SERVER_EXEC${NC}"
echo "Provide the path to chess-server binary as first argument or place it in the current directory."
echo "Provide the path to chess-server as the first argument or build bin/chess-server."
echo "Build the binary if not available: go build ./cmd/chess-server"
exit 1
fi
@@ -90,7 +92,8 @@ echo "Configuration:"
echo " Executable: $CHESS_SERVER_EXEC"
echo " Database: $TEST_DB"
echo " Port: $API_PORT"
echo " Mode: Development (WAL enabled, relaxed rate limits)"
echo " Mode: Development (relaxed rate limits; persistent WAL storage)"
echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)"
echo " Purpose: Backend for chess-server tests"
echo " PID File: $PID_FILE"
echo ""
@@ -105,8 +108,10 @@ echo ""
# Start chess-server in foreground with dev mode and storage
"$CHESS_SERVER_EXEC" \
-dev \
-storage-path "$TEST_DB" \
-dev \
-log-level "$LOG_LEVEL" \
-log-http="$LOG_HTTP" \
-storage-path "$TEST_DB" \
-api-port "$API_PORT" \
-pid "$PID_FILE" \
-pid-lock
-pid-lock