From b79900b1bf8efb5ecee69150837b9a9fcd00064dad7fc4aea5b33585b075127b Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Thu, 30 Oct 2025 09:52:23 -0400 Subject: [PATCH] v0.3.0 storage with sqlite3 and pid management added --- .gitignore | 1 + README.md | 27 +- cmd/chessd/cli/cli.go | 131 ++++++++++ cmd/chessd/main.go | 78 +++++- cmd/chessd/pid.go | 106 ++++++++ doc/api.md | 23 +- doc/architecture.md | 52 +++- doc/development.md | 70 +++-- go.mod | 1 + go.sum | 4 +- internal/http/handler.go | 17 +- internal/service/service.go | 92 ++++++- internal/storage/schema.go | 63 +++++ internal/storage/storage.go | 316 +++++++++++++++++++++++ test/README.md | 83 ++++++ test/run-server-with-db.sh | 71 +++++ test/test-api.sh | 2 +- test/test-db.sh | 502 ++++++++++++++++++++++++++++++++++++ 18 files changed, 1570 insertions(+), 69 deletions(-) create mode 100644 cmd/chessd/cli/cli.go create mode 100644 cmd/chessd/pid.go create mode 100644 internal/storage/schema.go create mode 100644 internal/storage/storage.go create mode 100755 test/README.md create mode 100755 test/run-server-with-db.sh create mode 100755 test/test-db.sh diff --git a/.gitignore b/.gitignore index a43c98f..f7e2abd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ dev log bin +db build.sh diff --git a/README.md b/README.md index e1f9190..cc78759 100644 --- a/README.md +++ b/README.md @@ -12,21 +12,25 @@ # Chess -Go backend server providing a RESTful API for chess gameplay. Integrates Stockfish engine for move validation and AI opponents. +Go backend server providing a RESTful API for chess gameplay. Integrates Stockfish engine for move validation and computer opponents. ## Features - RESTful API for chess operations -- Stockfish engine integration for validation and AI +- Stockfish engine integration for validation - Human vs human, human vs computer, computer vs computer modes - Custom FEN position support -- Asynchronous AI move calculation -- Configurable AI strength and thinking time +- Asynchronous engine move calculation +- Configurable engine strength and thinking time +- Optional SQLite persistence with async writes +- PID file management for singleton enforcement +- Database CLI for storage administration ## Requirements - Go 1.24+ - Stockfish chess engine (`stockfish` in PATH) +- SQLite3 (for persistence features) ### Installation @@ -45,14 +49,17 @@ git clone https://git.lixen.com/lixen/chess cd chess go build ./cmd/chessd -# Standard mode (1 request/second/IP) -./chessd +# Standard mode with persistence +./chessd -storage-path chess.db -# Development mode (10 requests/second/IP) -./chessd -dev +# Development mode with PID lock on localhost custom port +./chessd -dev -pid /tmp/chessd.pid -pid-lock -port 9090 -# Run tests (requires dev mode) -./test/test-api.sh +# Database initialization (doesn't run server) +./chessd db init -path chess.db + +# Query stored games (doesn't run server) +./chessd db query -path chess.db -gameId "*" ``` Server listens on `http://localhost:8080`. See [API Reference](./doc/api.md) for endpoints. diff --git a/cmd/chessd/cli/cli.go b/cmd/chessd/cli/cli.go new file mode 100644 index 0000000..a413e85 --- /dev/null +++ b/cmd/chessd/cli/cli.go @@ -0,0 +1,131 @@ +// FILE: cmd/chessd/cli/cli.go +package cli + +import ( + "chess/internal/storage" + "flag" + "fmt" + "os" + "strings" + "text/tabwriter" +) + +// Run is the entry point for the CLI mini-app +func Run(args []string) error { + if len(args) == 0 { + return fmt.Errorf("subcommand required: init, delete, or query") + } + + switch args[0] { + case "init": + return runInit(args[1:]) + case "delete": + return runDelete(args[1:]) + case "query": + return runQuery(args[1:]) + default: + return fmt.Errorf("unknown subcommand: %s", args[0]) + } +} + +func runInit(args []string) error { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + path := fs.String("path", "", "Database file path (required)") + + if err := fs.Parse(args); err != nil { + return err + } + + if *path == "" { + return fmt.Errorf("database path required") + } + + store, err := storage.NewStore(*path, false) + if err != nil { + return fmt.Errorf("failed to create store: %w", err) + } + defer store.Close() + + if err := store.InitDB(); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + fmt.Printf("Database initialized at: %s\n", *path) + return nil +} + +func runDelete(args []string) error { + fs := flag.NewFlagSet("delete", flag.ContinueOnError) + path := fs.String("path", "", "Database file path (required)") + + if err := fs.Parse(args); err != nil { + return err + } + + if *path == "" { + return fmt.Errorf("database path required") + } + + store, err := storage.NewStore(*path, false) + if err != nil { + return fmt.Errorf("failed to open store: %w", err) + } + + if err := store.DeleteDB(); err != nil { + return fmt.Errorf("failed to delete database: %w", err) + } + + fmt.Printf("Database deleted: %s\n", *path) + return nil +} + +func runQuery(args []string) error { + fs := flag.NewFlagSet("query", flag.ContinueOnError) + path := fs.String("path", "", "Database file path (required)") + gameID := fs.String("gameId", "", "Game ID to filter (optional, * for all)") + playerID := fs.String("playerId", "", "Player ID to filter (optional, * for all)") + + if err := fs.Parse(args); err != nil { + return err + } + + if *path == "" { + return fmt.Errorf("database path required") + } + + store, err := storage.NewStore(*path, false) + if err != nil { + return fmt.Errorf("failed to open store: %w", err) + } + defer store.Close() + + games, err := store.QueryGames(*gameID, *playerID) + if err != nil { + return fmt.Errorf("query failed: %w", err) + } + + if len(games) == 0 { + fmt.Println("No games found") + return nil + } + + // 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)) + + 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, + blackInfo, + g.StartTimeUTC.Format("2006-01-02 15:04:05"), + ) + } + w.Flush() + + fmt.Printf("\nFound %d game(s)\n", len(games)) + return nil +} \ No newline at end of file diff --git a/cmd/chessd/main.go b/cmd/chessd/main.go index 080bcfb..7411a37 100644 --- a/cmd/chessd/main.go +++ b/cmd/chessd/main.go @@ -2,6 +2,11 @@ package main import ( + "chess/cmd/chessd/cli" + "chess/internal/http" + "chess/internal/processor" + "chess/internal/service" + "chess/internal/storage" "flag" "fmt" "log" @@ -9,29 +14,69 @@ import ( "os/signal" "syscall" "time" - - "chess/internal/http" - "chess/internal/processor" - "chess/internal/service" ) func main() { + // Check for CLI database commands + if len(os.Args) > 1 && os.Args[1] == "db" { + if err := cli.Run(os.Args[2:]); err != nil { + log.Fatalf("CLI error: %v", err) + } + os.Exit(0) + } + // Command-line flags var ( - host = flag.String("host", "localhost", "Server host") - port = flag.Int("port", 8080, "Server port") - dev = flag.Bool("dev", false, "Development mode (relaxed rate limits)") + host = flag.String("host", "localhost", "Server host") + port = flag.Int("port", 8080, "Server port") + dev = flag.Bool("dev", false, "Development mode (relaxed rate limits)") + 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)") ) flag.Parse() - // 1. Initialize the Service (Pure State Manager) - svc, err := service.New() + // Validate PID flags + if *pidLock && *pidPath == "" { + log.Fatal("Error: -pid-lock flag requires the -pid flag to be set") + } + + // Manage PID file if requested + if *pidPath != "" { + cleanup, err := managePIDFile(*pidPath, *pidLock) + if err != nil { + log.Fatalf("Failed to manage PID file: %v", err) + } + defer cleanup() + log.Printf("PID file created at: %s (lock: %v)", *pidPath, *pidLock) + } + + // 1. Initialize Storage (optional) + var store *storage.Store + if *storagePath != "" { + log.Printf("Initializing persistent storage at: %s", *storagePath) + var err error + store, err = storage.NewStore(*storagePath, *dev) // CHANGED: Added *dev parameter + if err != nil { + log.Fatalf("Failed to initialize storage: %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)") + } + + // 2. Initialize the Service with optional storage + svc, err := service.New(store) if err != nil { log.Fatalf("Failed to initialize service: %v", err) } defer svc.Close() - // 2. Initialize the Processor (Orchestrator), injecting the service + // 3. Initialize the Processor (Orchestrator), injecting the service proc, err := processor.New(svc) if err != nil { log.Fatalf("Failed to initialize processor: %v", err) @@ -42,8 +87,8 @@ func main() { } }() - // 3. Initialize the Fiber App/HTTP Handler, injecting the processor - app := http.NewFiberApp(proc, *dev) + // 4. Initialize the Fiber App/HTTP Handler, injecting processor and service + app := http.NewFiberApp(proc, svc, *dev) // Server configuration addr := fmt.Sprintf("%s:%d", *host, *port) @@ -54,9 +99,14 @@ func main() { log.Printf("Listening on: http://%s", addr) log.Printf("API Version: v1") if *dev { - log.Printf("Rate Limit: 10 requests/second per IP (DEV MODE)") + log.Printf("Rate Limit: 20 requests/second per IP (DEV MODE)") } else { - log.Printf("Rate Limit: 1 request/second per IP") + log.Printf("Rate Limit: 10 requests/second per IP") + } + if *storagePath != "" { + log.Printf("Storage: Enabled (%s)", *storagePath) + } else { + log.Printf("Storage: Disabled") } log.Printf("Endpoints: http://%s/api/v1/games", addr) log.Printf("Health: http://%s/health", addr) diff --git a/cmd/chessd/pid.go b/cmd/chessd/pid.go new file mode 100644 index 0000000..54b659b --- /dev/null +++ b/cmd/chessd/pid.go @@ -0,0 +1,106 @@ +// FILE: cmd/chessd/pid.go +package main + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "syscall" +) + +// managePIDFile creates and manages a PID file with optional locking. +// Returns a cleanup function that must be called on exit. +func managePIDFile(path string, lock bool) (func(), error) { + // Open/create PID file with exclusive create first attempt + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + if !os.IsExist(err) { + return nil, fmt.Errorf("cannot create PID file: %w", err) + } + + // File exists - check if stale + if lock { + if err := checkStalePID(path); err != nil { + return nil, err + } + } + + // Reopen for writing (truncate existing content) + file, err = os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return nil, fmt.Errorf("cannot open PID file: %w", err) + } + } + + // Acquire exclusive lock if requested + if lock { + if err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + file.Close() + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, fmt.Errorf("cannot acquire lock: another instance is running") + } + return nil, fmt.Errorf("lock failed: %w", err) + } + } + + // Write current PID + pid := os.Getpid() + if _, err = fmt.Fprintf(file, "%d\n", pid); err != nil { + file.Close() + os.Remove(path) + return nil, fmt.Errorf("cannot write PID: %w", err) + } + + // Sync to ensure PID is written + if err = file.Sync(); err != nil { + file.Close() + os.Remove(path) + return nil, fmt.Errorf("cannot sync PID file: %w", err) + } + + // Return cleanup function + cleanup := func() { + if lock { + // Release lock explicitly, file close works too + syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + } + file.Close() + os.Remove(path) + } + + return cleanup, nil +} + +// checkStalePID reads an existing PID file and checks if the process is running +func checkStalePID(path string) error { + // Try to read existing PID + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("cannot read existing PID file: %w", err) + } + + pidStr := string(data) + pid, err := strconv.Atoi(strings.TrimSpace(pidStr)) + if err != nil { + // Corrupted PID file + return fmt.Errorf("corrupted PID file (contains: %q)", pidStr) + } + + // Check if process exists using kill(0), never errors on Unix + proc, _ := os.FindProcess(pid) + + // Send signal 0 to check if process exists + if err = proc.Signal(syscall.Signal(0)); err != nil { + // Process doesn't exist or we don't have permission + if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { + return fmt.Errorf("stale PID file found for defunct process %d", pid) + } + // Process exists but we can't signal it (different user?) + return fmt.Errorf("process %d exists but cannot verify ownership: %v", pid, err) + } + + // Process is running + return fmt.Errorf("stale PID file: process %d is running but not holding lock", pid) +} \ No newline at end of file diff --git a/doc/api.md b/doc/api.md index 4805119..a96aa87 100644 --- a/doc/api.md +++ b/doc/api.md @@ -6,6 +6,25 @@ Content-Type: `application/json` (required for POST/PUT) ## Endpoints +### Health Check +`GET /health` + +Returns server status. + +**Response (200):** +```json +{ + "status": "healthy", + "time": 1699123456, + "storage": "ok" +} +``` + +Storage states: +- `"disabled"` - No storage path configured +- `"ok"` - Database operational +- `"degraded"` - Write failures detected, operating memory-only + ### Create Game `POST /games` @@ -29,8 +48,8 @@ Creates new game with specified players. ``` - `type` (integer, required): 1=human, 2=computer -- `level` (integer, 0-20): AI skill level for computer players -- `searchTime` (integer, 100-10000ms): AI thinking time for computer players +- `level` (integer, 0-20): Engine skill level for computer players +- `searchTime` (integer, 100-10000ms): Engine thinking time for computer players - `fen` (string): Starting position in FEN notation (default: standard position) **Response (201):** diff --git a/doc/architecture.md b/doc/architecture.md index d8b6565..183dfa5 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -9,13 +9,17 @@ Fiber web server handling HTTP requests/responses. Implements routing, rate limi 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. ### Service Layer (`internal/service`) -In-memory state storage without chess logic. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, and player configuration. +In-memory state storage without chess logic. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, and player configuration. Coordinates with storage layer for optional persistence. + +### Storage Layer (`internal/storage`) +Optional SQLite persistence with async write pattern. Buffered channel (1000 ops) processes writes sequentially in background. Graceful degradation on write failures. WAL mode for development environments. ### Supporting Modules - **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication - **Game** (`internal/game`): Game state with snapshot history - **Board** (`internal/board`): FEN parsing and ASCII generation - **Core** (`internal/core`): Shared types, API models, error constants +- **CLI** (`cmd/chessd/cli`): Database management commands ## Request Flow @@ -35,12 +39,28 @@ In-memory state storage without chess logic. Thread-safe game map protected by R 5. Callback updates game state via service 6. Client polls for completion +## Persistence Flow + +### Write Operations +1. Service layer calls storage method (RecordNewGame, RecordMove, DeleteUndoneMoves) +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 + +### Query Operations +1. CLI invokes Store.QueryGames with filters +2. Direct database read (no queue) +3. Results formatted as tabular output + ## Concurrency - **HTTP Server**: Fiber handles concurrent connections - **Game State**: Single RWMutex protects game map (concurrent reads, serial writes) - **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes - **Validation Engine**: Single mutex-protected instance for synchronous validation +- **Storage Writer**: Single goroutine processes write queue sequentially +- **PID Lock**: File-based exclusive lock prevents multiple instances ## Data Structures @@ -58,4 +78,32 @@ type Snapshot struct { Commands encapsulate operations with type and arguments, processed by single Execute method. ### Player Configuration -Players identified by UUID, configured with type (human/computer), skill level, and search time. \ No newline at end of file +Players identified by UUID, configured with type (human/computer), skill level, and search time. + +### Storage Schema +```sql +games ( + game_id TEXT PRIMARY KEY, + initial_fen TEXT, + white_player_id TEXT, + white_type INTEGER, + white_level INTEGER, + white_search_time INTEGER, + black_player_id TEXT, + black_type INTEGER, + black_level INTEGER, + black_search_time INTEGER, + start_time_utc DATETIME +) + +moves ( + move_id INTEGER PRIMARY KEY, + game_id TEXT, + move_number INTEGER, + move_uci TEXT, + fen_after_move TEXT, + player_color TEXT, + move_time_utc DATETIME, + FOREIGN KEY (game_id) REFERENCES games(game_id) +) +``` \ No newline at end of file diff --git a/doc/development.md b/doc/development.md index ed20dcf..f35aa49 100644 --- a/doc/development.md +++ b/doc/development.md @@ -21,21 +21,49 @@ go build ./cmd/chessd - `-host`: Server host (default: localhost) - `-port`: Server port (default: 8080) - `-dev`: Development mode with relaxed rate limits +- `-storage-path`: SQLite database file path (enables persistence) +- `-pid`: PID file path for process tracking +- `-pid-lock`: Enable exclusive locking (requires -pid) ### Modes ```bash -# Production (1 req/s rate limit) +# In-memory only ./chessd -# Development (10 req/s rate limit) -./chessd -dev +# With persistence +./chessd -storage-path ./db/chess.db + +# Singleton enforcement (requires same PID file path across instances) +./chessd -pid /var/run/chessd.pid -pid-lock + +# Development with all features +./chessd -dev -storage-path chess.db -pid /tmp/chessd.pid ``` +## Database Management + +### CLI Commands +```bash +# Initialize database schema +./chessd db init -path chess.db + +# Query games +./chessd db query -path chess.db [-gameId ID] [-playerId ID] + +# Delete database +./chessd db delete -path chess.db +``` + +Query parameters accept `*` for all records (default if omitted) or specific IDs for filtering. + ## Project Structure ``` chess/ -├── cmd/chessd/ # Entry point +├── cmd/chessd/ +│ ├── main.go # Entry point +│ ├── pid.go # PID file management +│ └── cli/ # Database CLI ├── internal/ │ ├── board/ # FEN/ASCII operations │ ├── core/ # Shared types @@ -43,28 +71,14 @@ chess/ │ ├── game/ # Game state │ ├── http/ # Fiber handlers │ ├── processor/ # Command processing -│ └── service/ # State management +│ ├── service/ # State management +│ └── storage/ # SQLite persistence └── test/ # Test scripts ``` ## Testing -```bash -# Unit tests -go test ./... - -# API tests (requires dev mode) -./chessd -dev & -./test/test-api.sh -``` - -Test script validates: -- Basic CRUD operations -- Computer move triggering ("cccc" mechanism) -- Pending state protection -- Rate limiting -- Input validation -- Error handling +See [test documentation](../test/README.md) for details. ## Configuration @@ -73,6 +87,20 @@ Test script validates: - 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) + +### Storage Configuration +- WAL mode enabled in development for concurrency +- Foreign key constraints enforced +- Async write pattern with 2-second drain on shutdown +- Degradation to memory-only on write failures + +### PID Management +- Singleton enforcement requires same PID file path - all instances must use the same -pid value +- Stale PID detection via signal 0 checking +- Exclusive file locking with LOCK_EX|LOCK_NB +- Automatic cleanup on graceful shutdown ### Validation Rules - Player type: 1 (human) or 2 (computer) diff --git a/go.mod b/go.mod index f826e1e..344a0b3 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/go-playground/validator/v10 v10.28.0 github.com/gofiber/fiber/v2 v2.52.9 github.com/google/uuid v1.6.0 + github.com/mattn/go-sqlite3 v1.14.32 ) require ( diff --git a/go.sum b/go.sum index b4d59b5..703b5e6 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuh github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= -github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -32,6 +30,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/http/handler.go b/internal/http/handler.go index 7ba309a..f6a5cfc 100644 --- a/internal/http/handler.go +++ b/internal/http/handler.go @@ -4,6 +4,7 @@ package http import ( "chess/internal/core" "chess/internal/processor" + "chess/internal/service" "fmt" "strings" "time" @@ -19,15 +20,16 @@ const rateLimitRate = 10 // req/sec type HTTPHandler struct { proc *processor.Processor + svc *service.Service } -func NewHTTPHandler(proc *processor.Processor) *HTTPHandler { - return &HTTPHandler{proc: proc} +func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandler { + return &HTTPHandler{proc: proc, svc: svc} } -func NewFiberApp(proc *processor.Processor, devMode bool) *fiber.App { +func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) *fiber.App { // Create handler - h := NewHTTPHandler(proc) + h := NewHTTPHandler(proc, svc) // Initialize Fiber app app := fiber.New(fiber.Config{ @@ -146,11 +148,12 @@ func customErrorHandler(c *fiber.Ctx, err error) error { return c.Status(code).JSON(response) } -// Health check endpoint +// Health check endpoint with storage status func (h *HTTPHandler) Health(c *fiber.Ctx) error { return c.JSON(fiber.Map{ - "status": "healthy", - "time": time.Now().Unix(), + "status": "healthy", + "time": time.Now().Unix(), + "storage": h.svc.GetStorageHealth(), }) } diff --git a/internal/service/service.go b/internal/service/service.go index 873592a..6cd2614 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -4,24 +4,27 @@ package service import ( "fmt" "sync" + "time" "chess/internal/core" "chess/internal/game" + "chess/internal/storage" "github.com/google/uuid" ) -// Service is a pure state manager for chess games -// It has NO knowledge of chess rules or engine interactions +// Service is a pure state manager for chess games with optional persistence type Service struct { games map[string]*game.Game mu sync.RWMutex + store *storage.Store // nil if persistence disabled } -// New creates a new service instance -func New() (*Service, error) { +// New creates a new service instance with optional storage +func New(store *storage.Store) (*Service, error) { return &Service{ games: make(map[string]*game.Game), + store: store, }, nil } @@ -39,6 +42,25 @@ func (s *Service) CreateGame(id string, whiteConfig, blackConfig core.PlayerConf blackPlayer := core.NewPlayer(blackConfig, core.ColorBlack) s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn) + + // Persist if storage enabled + if s.store != nil { + record := storage.GameRecord{ + GameID: id, + InitialFEN: initialFEN, + WhitePlayerID: whitePlayer.ID, + WhiteType: int(whitePlayer.Type), + WhiteLevel: whitePlayer.Level, + WhiteSearchTime: whitePlayer.SearchTime, + BlackPlayerID: blackPlayer.ID, + BlackType: int(blackPlayer.Type), + BlackLevel: blackPlayer.Level, + BlackSearchTime: blackPlayer.SearchTime, + StartTimeUTC: time.Now().UTC(), + } + s.store.RecordNewGame(record) + } + return nil } @@ -76,11 +98,19 @@ func (s *Service) GetGame(gameID string) (*game.Game, error) { // GenerateGameID creates a new unique game ID func (s *Service) GenerateGameID() string { - return uuid.New().String() + s.mu.RLock() + defer s.mu.RUnlock() + + // Ensure UUID uniqueness (handle potential conflicts) + for { + id := uuid.New().String() + if _, exists := s.games[id]; !exists { + return id + } + } } // ApplyMove adds a validated move to the game history -// The processor has already validated this move and calculated the new FEN func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error { s.mu.Lock() defer s.mu.Unlock() @@ -97,6 +127,20 @@ func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error { // Add the new position to game history g.AddSnapshot(newFEN, moveUCI, nextTurn) + // 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(), + } + s.store.RecordMove(record) + } + return nil } @@ -114,8 +158,7 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error { return nil } -// SetLastMoveResult stores metadata about the last move (score, depth, etc) -// Used by processor to track computer move evaluations +// SetLastMoveResult stores metadata about the last move func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error { s.mu.Lock() defer s.mu.Unlock() @@ -139,7 +182,19 @@ func (s *Service) UndoMoves(gameID string, count int) error { return fmt.Errorf("game not found: %s", gameID) } - return g.UndoMoves(count) + originalMoveCount := len(g.Moves()) + + if err := g.UndoMoves(count); err != nil { + return err + } + + // Delete undone moves from storage if enabled + if s.store != nil { + remainingMoves := originalMoveCount - count + s.store.DeleteUndoneMoves(gameID, remainingMoves) + } + + return nil } // DeleteGame removes a game from memory @@ -155,12 +210,29 @@ func (s *Service) DeleteGame(gameID string) error { return nil } -// Close cleans up resources (currently a no-op as no engine to close) +// GetStorageHealth returns the storage component status +func (s *Service) GetStorageHealth() string { + if s.store == nil { + return "disabled" + } + if s.store.IsHealthy() { + return "ok" + } + return "degraded" +} + +// Close cleans up resources func (s *Service) Close() error { s.mu.Lock() defer s.mu.Unlock() // Clear all games s.games = make(map[string]*game.Game) + + // Close storage if enabled + if s.store != nil { + return s.store.Close() + } + return nil } \ No newline at end of file diff --git a/internal/storage/schema.go b/internal/storage/schema.go new file mode 100644 index 0000000..4a4b8bd --- /dev/null +++ b/internal/storage/schema.go @@ -0,0 +1,63 @@ +// FILE: internal/storage/schema.go +package storage + +import "time" + +// GameRecord represents a row in the games table +type GameRecord struct { + GameID string `db:"game_id"` + InitialFEN string `db:"initial_fen"` + WhitePlayerID string `db:"white_player_id"` + WhiteType int `db:"white_type"` + WhiteLevel int `db:"white_level"` + WhiteSearchTime int `db:"white_search_time"` + 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"` +} + +// MoveRecord represents a row in the moves table +type MoveRecord struct { + MoveID int64 `db:"move_id"` + GameID string `db:"game_id"` + MoveNumber int `db:"move_number"` + MoveUCI string `db:"move_uci"` + FENAfterMove string `db:"fen_after_move"` + PlayerColor string `db:"player_color"` // "w" or "b" + MoveTimeUTC time.Time `db:"move_time_utc"` +} + +// Schema defines the SQLite database structure +const Schema = ` +CREATE TABLE IF NOT EXISTS games ( + game_id TEXT PRIMARY KEY, + initial_fen TEXT NOT NULL, + white_player_id TEXT NOT NULL, + white_type INTEGER NOT NULL, + white_level INTEGER NOT NULL DEFAULT 0, + white_search_time INTEGER NOT NULL DEFAULT 1000, + black_player_id TEXT NOT NULL, + black_type INTEGER NOT NULL, + black_level INTEGER NOT NULL DEFAULT 0, + black_search_time INTEGER NOT NULL DEFAULT 1000, + start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS moves ( + move_id INTEGER PRIMARY KEY AUTOINCREMENT, + game_id TEXT NOT NULL, + move_number INTEGER NOT NULL, + move_uci TEXT NOT NULL, + fen_after_move TEXT NOT NULL, + player_color TEXT NOT NULL CHECK(player_color IN ('w', 'b')), + move_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE, + UNIQUE(game_id, move_number) +); + +CREATE INDEX IF NOT EXISTS idx_moves_game_id ON moves(game_id); +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); +` \ No newline at end of file diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..7827205 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,316 @@ +// FILE: internal/storage/storage.go +package storage + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "sync" + "sync/atomic" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// Store handles SQLite database operations with async writes +type Store struct { + db *sql.DB + path string + writeChan chan func(*sql.Tx) error + healthStatus atomic.Bool + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +// NewStore creates a new storage instance with async writer +func NewStore(dataSourceName string, devMode bool) (*Store, error) { + db, err := sql.Open("sqlite3", dataSourceName) + 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 { + db.Close() + return nil, fmt.Errorf("failed to enable foreign keys: %w", err) + } + + // Configure connection pool + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + + ctx, cancel := context.WithCancel(context.Background()) + + s := &Store{ + db: db, + path: dataSourceName, + writeChan: make(chan func(*sql.Tx) error, 1000), // Buffered for async writes + ctx: ctx, + cancel: cancel, + } + + // Initialize health as true + s.healthStatus.Store(true) + + // Start async writer + s.wg.Add(1) + go s.writerLoop() + + return s, nil +} + +// writerLoop processes async write operations +func (s *Store) writerLoop() { + defer s.wg.Done() + + for { + select { + case <-s.ctx.Done(): + // Drain remaining writes with timeout + deadline := time.After(2 * time.Second) + for { + select { + case fn := <-s.writeChan: + if s.healthStatus.Load() { + s.executeWrite(fn) + } + case <-deadline: + return + default: + return + } + } + + case fn := <-s.writeChan: + // Skip if already degraded + if !s.healthStatus.Load() { + continue + } + s.executeWrite(fn) + } + } +} + +// 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) + return + } + + if err := fn(tx); err != nil { + tx.Rollback() + log.Printf("Storage degraded: write operation failed: %v", err) + s.healthStatus.Store(false) + return + } + + if err := tx.Commit(); err != nil { + log.Printf("Storage degraded: failed to commit: %v", err) + s.healthStatus.Store(false) + return + } +} + +// RecordNewGame asynchronously records a new game +func (s *Store) RecordNewGame(record GameRecord) error { + if !s.healthStatus.Load() { + return nil // Silently drop if degraded + } + + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + _, err := tx.Exec(query, + record.GameID, record.InitialFEN, + record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime, + record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime, + record.StartTimeUTC, + ) + 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 + } + + select { + case s.writeChan <- func(tx *sql.Tx) error { + query := `INSERT INTO moves ( + game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc + ) VALUES (?, ?, ?, ?, ?, ?)` + + _, err := tx.Exec(query, + record.GameID, record.MoveNumber, record.MoveUCI, + record.FENAfterMove, record.PlayerColor, record.MoveTimeUTC, + ) + return err + }: + return nil + default: + // Channel full, drop write + log.Printf("Storage write queue full, dropping move record") + return nil + } +} + +// 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 + } + + 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 + } +} + +// IsHealthy returns the current health status +func (s *Store) IsHealthy() bool { + return s.healthStatus.Load() +} + +// Close gracefully closes the database connection +func (s *Store) Close() error { + // Signal writer to stop + s.cancel() + + // 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 +} + +// InitDB creates the database schema +func (s *Store) InitDB() error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + if _, err := tx.Exec(Schema); err != nil { + return fmt.Errorf("failed to create schema: %w", err) + } + + return tx.Commit() +} + +// DeleteDB removes the database file +func (s *Store) DeleteDB() error { + // Close connection first + if err := s.Close(); err != nil { + return fmt.Errorf("failed to close database: %w", err) + } + + // ☣ DESTRUCTIVE: Removes database file + if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to delete database file: %w", err) + } + + return nil +} + +// QueryGames retrieves games with optional filtering +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` + + var args []interface{} + + // Handle gameID filtering + if gameID != "" && gameID != "*" { + query += " AND 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 += " ORDER BY start_time_utc DESC" + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("query failed: %w", err) + } + defer rows.Close() + + var games []GameRecord + 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) + } + games = append(games, g) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration failed: %w", err) + } + + return games, nil +} \ No newline at end of file diff --git a/test/README.md b/test/README.md new file mode 100755 index 0000000..1933b34 --- /dev/null +++ b/test/README.md @@ -0,0 +1,83 @@ +# Chess API Test Suite + +This directory contains comprehensive test suites for the Chess API server, covering both API functionality and database persistence. + +## Prerequisites + +- `jq` - JSON processor +- `curl` - HTTP client +- `sqlite3` - SQLite CLI (for database tests only) +- Compiled `chessd` binary in parent directory + +## Test Suites + +### 1. API Functionality Tests (`test-api.sh`) + +Tests all API endpoints, error handling, rate limiting, and game logic. + +**Running the test:** +```bash +# Start server in development mode (required for tests to pass) +../chessd -dev + +# In another terminal, run tests +./test-api.sh +``` + +**Coverage:** +- Game creation and management (HvH, HvC, CvC) +- Move validation and execution +- Computer move triggering with "cccc" +- Undo functionality +- Player configuration changes +- Rate limiting (dev mode: 20 req/s) +- Security hardening and input validation + +### 2. Database Persistence Tests (`test-db.sh`) + +Tests database storage, async writes, and data integrity. + +**Running the test:** +```bash +# Terminal 1: Start server with database +./run-server-with-db.sh ../chessd + +# Terminal 2: Run database tests +./test-db.sh + +# When done, press Ctrl+C in Terminal 1 +``` + +**Coverage:** +- Game and move persistence +- Async write buffer behavior +- Multi-game isolation +- Undo effects on database +- WAL mode verification +- Foreign key constraints + +## Important Notes + +1. **Development Mode Required**: The server MUST be started with `-dev` flag for tests to pass. This enables: + - Relaxed rate limiting (20 req/s instead of 10) + - WAL mode for SQLite (better concurrency) + +2. **Database Tests**: The `run-server-with-db.sh` script automatically: + - Creates a temporary test database + - Initializes the schema + - Cleans up on exit (Ctrl+C) + +3. **Test Isolation**: Each test suite can be run independently. The database tests use a separate `test.db` file that doesn't affect production data. + +## Troubleshooting + +**Rate limiting failures:** Ensure server is running with `-dev` flag +**Database test failures:** Check that no other instance is using `test.db` +**Port conflicts:** Default port is 8080, ensure it's available + +## Exit Codes + +- `0` - All tests passed +- `1` - One or more tests failed + +Check colored output for detailed pass/fail information for each test case. \ No newline at end of file diff --git a/test/run-server-with-db.sh b/test/run-server-with-db.sh new file mode 100755 index 0000000..6bb3e2d --- /dev/null +++ b/test/run-server-with-db.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# FILE: run-server-with-db.sh + +set -e + +# Check for argument +if [ $# -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +CHESSD_EXEC="$1" +TEST_DB="test.db" +PID_FILE="/tmp/chessd_test.pid" + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Cleanup function +cleanup() { + echo -e "\n${YELLOW}Cleaning up...${NC}" + + # Kill server if PID file exists + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + echo "Stopping chessd server (PID: $PID)" + kill "$PID" 2>/dev/null || true + sleep 0.5 + kill -9 "$PID" 2>/dev/null || true + fi + rm -f "$PID_FILE" + fi + + # Clean up database files + echo "Removing test database files..." + rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm" + + echo -e "${GREEN}Cleanup complete${NC}" +} + +# Set up trap for cleanup on exit +trap cleanup EXIT SIGINT SIGTERM + +# Clean slate - remove any existing test DB files +rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm" + +# Initialize database +echo -e "${CYAN}Initializing test database...${NC}" +"$CHESSD_EXEC" db init -path "$TEST_DB" + +# Start server +echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}" +echo -e "${GREEN}Starting chessd server with database persistence${NC}" +echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}" +echo "Executable: $CHESSD_EXEC" +echo "Database: $TEST_DB" +echo "Mode: Development (WAL enabled)" +echo "" +echo -e "${YELLOW}Instructions:${NC}" +echo "1. Open another terminal and run: ./test-db.sh" +echo "2. Press Ctrl+C here when testing is complete" +echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}" +echo "" + +# Start chessd in foreground with dev mode and storage +"$CHESSD_EXEC" -dev -storage-path "$TEST_DB" -port 8080 \ No newline at end of file diff --git a/test/test-api.sh b/test/test-api.sh index 160cffb..0187306 100755 --- a/test/test-api.sh +++ b/test/test-api.sh @@ -581,7 +581,7 @@ GAME_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null) print_header "SECTION 8: Player Configuration" # ============================================================================== -test_case "8.1: Create Game with AI Configuration" +test_case "8.1: Create Game with Engine Configuration" RESPONSE=$(api_request POST "$API_URL/games" \ -H "Content-Type: application/json" \ -d '{"white": {"type": 1}, "black": {"type": 2, "level": 10, "searchTime": 500}}') diff --git a/test/test-db.sh b/test/test-db.sh new file mode 100755 index 0000000..1a3bd15 --- /dev/null +++ b/test/test-db.sh @@ -0,0 +1,502 @@ +#!/usr/bin/env bash +# FILE: test-db.sh + +# Database Persistence Test Suite for Chess API +# Tests async writes, persistence, and database integrity + +BASE_URL="http://localhost:8080" +API_URL="${BASE_URL}/api/v1" +TEST_DB="test.db" +API_DELAY=${API_DELAY:-50} + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +MAGENTA='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Test counters +PASS=0 +FAIL=0 + +# Helper functions +print_header() { + echo -e "\n${CYAN}═══════════════════════════════════════════════════════════${NC}" + echo -e "${CYAN}$1${NC}" + echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}" +} + +test_case() { + echo -e "\n${YELLOW}▶ TEST: $1${NC}" + sleep 0.0$API_DELAY +} + +assert_status() { + local expected=$1 + local actual=$2 + local test_name=$3 + + if [ "$actual" = "$expected" ]; then + echo -e "${GREEN} ✓ $test_name: HTTP $actual${NC}" + ((PASS++)) + return 0 + else + echo -e "${RED} ✗ $test_name: Expected HTTP $expected, got $actual${NC}" + ((FAIL++)) + return 1 + fi +} + +assert_json_field() { + local json=$1 + local field=$2 + local expected=$3 + local test_name=$4 + + local actual=$(echo "$json" | jq -r "$field" 2>/dev/null) + + if [ "$actual" = "$expected" ]; then + echo -e "${GREEN} ✓ $test_name: $field = '$actual'${NC}" + ((PASS++)) + return 0 + else + echo -e "${RED} ✗ $test_name: Expected $field = '$expected', got '$actual'${NC}" + ((FAIL++)) + return 1 + fi +} + +assert_db_record() { + local sql_query=$1 + local expected=$2 + local test_name=$3 + + local actual=$(sqlite3 "$TEST_DB" "$sql_query" 2>/dev/null) + + if [ "$actual" = "$expected" ]; then + echo -e "${GREEN} ✓ $test_name: DB query returned '$actual'${NC}" + ((PASS++)) + return 0 + else + echo -e "${RED} ✗ $test_name: Expected '$expected', got '$actual'${NC}" + echo -e "${RED} Query: $sql_query${NC}" + ((FAIL++)) + return 1 + fi +} + +api_request() { + local method=$1 + local url=$2 + shift 2 + curl -s "$@" -X "$method" "$url" + local status=$? + sleep 0.0$API_DELAY + return $status +} + +wait_for_state() { + local game_id=$1 + local target_state=$2 + local max_attempts=${3:-20} + local attempt=0 + + while [ $attempt -lt $max_attempts ]; do + local response=$(api_request GET "$API_URL/games/$game_id") + local current_state=$(echo "$response" | jq -r '.state' 2>/dev/null) + + if [ "$current_state" = "$target_state" ] || [[ "$target_state" = "!pending" && "$current_state" != "pending" ]]; then + return 0 + fi + + ((attempt++)) + sleep 0.1 + done + + return 1 +} + +# Check dependencies +for cmd in jq sqlite3 curl; do + if ! command -v $cmd &> /dev/null; then + echo -e "${RED}Error: $cmd is required but not installed${NC}" + exit 1 + fi +done + +# Check database exists +if [ ! -f "$TEST_DB" ]; then + echo -e "${RED}Error: Test database '$TEST_DB' not found${NC}" + echo "Make sure the server is running with: ./run-server-with-db.sh" + exit 1 +fi + +# Start tests +print_header "Database Persistence Test Suite" +echo "Server: $BASE_URL" +echo "Database: $TEST_DB" +echo "Mode: Development with WAL" +echo "" + +# ============================================================================== +print_header "SECTION 1: Storage Health & Basic Persistence" +# ============================================================================== + +test_case "1.1: Storage Health Check" +RESPONSE=$(api_request GET "$BASE_URL/health") +assert_json_field "$RESPONSE" '.storage' "ok" "Storage is healthy" + +test_case "1.2: Database Schema Verification" +TABLE_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('games', 'moves');" 2>/dev/null) +if [ "$TABLE_COUNT" = "2" ]; then + echo -e "${GREEN} ✓ Database schema verified: games and moves tables exist${NC}" + ((PASS++)) +else + echo -e "${RED} ✗ Database schema incomplete: expected 2 tables, found $TABLE_COUNT${NC}" + ((FAIL++)) +fi + +test_case "1.3: Game Creation Persistence" +RESPONSE=$(api_request POST "$API_URL/games" \ + -H "Content-Type: application/json" \ + -d '{"white": {"type": 1}, "black": {"type": 2, "level": 5, "searchTime": 100}}') + +GAME_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null) +BLACK_ID=$(echo "$RESPONSE" | jq -r '.players.black.id' 2>/dev/null) +WHITE_ID=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null) + +if [ -n "$GAME_ID" ] && [ "$GAME_ID" != "null" ]; then + echo " Game ID: $GAME_ID" + sleep 0.5 # Allow async write + + assert_db_record \ + "SELECT COUNT(*) FROM games WHERE game_id = '$GAME_ID';" \ + "1" \ + "Game record created" + + assert_db_record \ + "SELECT black_player_id FROM games WHERE game_id = '$GAME_ID';" \ + "$BLACK_ID" \ + "Black player ID matches" + + assert_db_record \ + "SELECT black_level FROM games WHERE game_id = '$GAME_ID';" \ + "5" \ + "Black AI level persisted" + + assert_db_record \ + "SELECT initial_fen FROM games WHERE game_id = '$GAME_ID';" \ + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" \ + "Starting FEN persisted" +fi + +# ============================================================================== +print_header "SECTION 2: Move Persistence & Undo" +# ============================================================================== + +test_case "2.1: Human Move Persistence" +if [ -n "$GAME_ID" ]; then + RESPONSE=$(api_request POST "$API_URL/games/$GAME_ID/moves" \ + -H "Content-Type: application/json" \ + -d '{"move": "e2e4"}') + + sleep 0.5 # Allow async write + + assert_db_record \ + "SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \ + "1" \ + "Move record created" + + assert_db_record \ + "SELECT move_uci FROM moves WHERE game_id = '$GAME_ID' AND move_number = 1;" \ + "e2e4" \ + "Move UCI notation stored" + + assert_db_record \ + "SELECT player_color FROM moves WHERE game_id = '$GAME_ID' AND move_number = 1;" \ + "w" \ + "Move color recorded" +fi + +test_case "2.2: Computer Move Persistence" +if [ -n "$GAME_ID" ]; then + # Trigger computer move + api_request POST "$API_URL/games/$GAME_ID/moves" \ + -H "Content-Type: application/json" \ + -d '{"move": "cccc"}' > /dev/null + + wait_for_state "$GAME_ID" "!pending" + sleep 0.5 # Allow async write + + assert_db_record \ + "SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \ + "2" \ + "Computer move persisted" + + COMPUTER_MOVE=$(sqlite3 "$TEST_DB" "SELECT move_uci FROM moves WHERE game_id = '$GAME_ID' AND move_number = 2;" 2>/dev/null) + if [ -n "$COMPUTER_MOVE" ] && [ "$COMPUTER_MOVE" != "e2e4" ]; then + echo -e "${GREEN} ✓ Computer move stored: $COMPUTER_MOVE${NC}" + ((PASS++)) + else + echo -e "${RED} ✗ Computer move not properly stored${NC}" + ((FAIL++)) + fi +fi + +test_case "2.3: Undo Move Database Effect" +if [ -n "$GAME_ID" ]; then + # Undo last move + api_request POST "$API_URL/games/$GAME_ID/undo" \ + -H "Content-Type: application/json" \ + -d '{"count": 1}' > /dev/null + + sleep 0.5 # Allow async write + + assert_db_record \ + "SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \ + "1" \ + "Undo removed move from DB" + + # Undo again + api_request POST "$API_URL/games/$GAME_ID/undo" \ + -H "Content-Type: application/json" \ + -d '{"count": 1}' > /dev/null + + sleep 0.5 + + assert_db_record \ + "SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \ + "0" \ + "All moves removed after undo" +fi + +# ============================================================================== +print_header "SECTION 3: Complex Game Scenarios" +# ============================================================================== + +test_case "3.1: Custom FEN Game Persistence" +CUSTOM_FEN="r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 4 4" +RESPONSE=$(api_request POST "$API_URL/games" \ + -H "Content-Type: application/json" \ + -d "{\"white\": {\"type\": 2, \"searchTime\": 100}, \"black\": {\"type\": 2, \"searchTime\": 100}, \"fen\": \"$CUSTOM_FEN\"}") + +FEN_GAME_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null) +if [ -n "$FEN_GAME_ID" ] && [ "$FEN_GAME_ID" != "null" ]; then + sleep 0.5 + + assert_db_record \ + "SELECT initial_fen FROM games WHERE game_id = '$FEN_GAME_ID';" \ + "$CUSTOM_FEN" \ + "Custom FEN persisted correctly" + + # Clean up + api_request DELETE "$API_URL/games/$FEN_GAME_ID" > /dev/null +fi + +test_case "3.2: Multiple Games Isolation" +# Create two games +RESPONSE1=$(api_request POST "$API_URL/games" \ + -H "Content-Type: application/json" \ + -d '{"white": {"type": 1}, "black": {"type": 1}}') +GAME_ID1=$(echo "$RESPONSE1" | jq -r '.gameId' 2>/dev/null) + +RESPONSE2=$(api_request POST "$API_URL/games" \ + -H "Content-Type: application/json" \ + -d '{"white": {"type": 2, "searchTime": 100}, "black": {"type": 1}}') +GAME_ID2=$(echo "$RESPONSE2" | jq -r '.gameId' 2>/dev/null) + +if [ -n "$GAME_ID1" ] && [ -n "$GAME_ID2" ]; then + sleep 0.5 + + # Make moves in first game + api_request POST "$API_URL/games/$GAME_ID1/moves" \ + -H "Content-Type: application/json" \ + -d '{"move": "d2d4"}' > /dev/null + api_request POST "$API_URL/games/$GAME_ID1/moves" \ + -H "Content-Type: application/json" \ + -d '{"move": "d7d5"}' > /dev/null + + sleep 0.5 + + assert_db_record \ + "SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID1';" \ + "2" \ + "Game 1 has 2 moves" + + assert_db_record \ + "SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID2';" \ + "0" \ + "Game 2 has 0 moves (isolation)" + + # Clean up + api_request DELETE "$API_URL/games/$GAME_ID1" > /dev/null + api_request DELETE "$API_URL/games/$GAME_ID2" > /dev/null +fi + +# ============================================================================== +print_header "SECTION 4: Foreign Key Constraints & Cascade" +# ============================================================================== + +test_case "4.1: Cascade Delete Verification" +# Create game with moves +RESPONSE=$(api_request POST "$API_URL/games" \ + -H "Content-Type: application/json" \ + -d '{"white": {"type": 1}, "black": {"type": 1}}') +CASCADE_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null) + +if [ -n "$CASCADE_ID" ] && [ "$CASCADE_ID" != "null" ]; then + # Make several moves + api_request POST "$API_URL/games/$CASCADE_ID/moves" \ + -H "Content-Type: application/json" -d '{"move": "e2e4"}' > /dev/null + api_request POST "$API_URL/games/$CASCADE_ID/moves" \ + -H "Content-Type: application/json" -d '{"move": "e7e5"}' > /dev/null + api_request POST "$API_URL/games/$CASCADE_ID/moves" \ + -H "Content-Type: application/json" -d '{"move": "g1f3"}' > /dev/null + + sleep 0.5 + + MOVE_COUNT_BEFORE=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM moves WHERE game_id = '$CASCADE_ID';" 2>/dev/null) + echo -e "${BLUE} Moves before delete: $MOVE_COUNT_BEFORE${NC}" + + # Delete game + api_request DELETE "$API_URL/games/$CASCADE_ID" > /dev/null + sleep 0.5 + + # Note: Game deletion is handled in memory, DB records remain + # This is by design - games table persists for history + assert_db_record \ + "SELECT COUNT(*) FROM games WHERE game_id = '$CASCADE_ID';" \ + "1" \ + "Game record persists in DB (by design)" +fi + +# ============================================================================== +print_header "SECTION 5: Async Write Buffer Behavior" +# ============================================================================== + +test_case "5.1: Rapid Write Buffering" +RESPONSE=$(api_request POST "$API_URL/games" \ + -H "Content-Type: application/json" \ + -d '{"white": {"type": 1}, "black": {"type": 1}}') +BUFFER_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null) + +if [ -n "$BUFFER_ID" ] && [ "$BUFFER_ID" != "null" ]; then + # Rapid fire a sequence of legal moves without waiting + echo -e "${BLUE} Sending 10 rapid, legal moves...${NC}" + moves=("e2e4" "e7e5" "g1f3" "b8c6" "f1b5" "a7a6" "b5c6" "d7c6" "e1g1" "f7f6") + for move in "${moves[@]}"; do + api_request POST "$API_URL/games/$BUFFER_ID/moves" \ + -H "Content-Type: application/json" -d "{\"move\": \"$move\"}" > /dev/null + done + + # Immediate check (may show partial writes) + IMMEDIATE_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM moves WHERE game_id = '$BUFFER_ID';" 2>/dev/null) + echo -e "${BLUE} Immediate move count: $IMMEDIATE_COUNT${NC}" + + # Wait for async writes to complete + sleep 1 + + FINAL_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM moves WHERE game_id = '$BUFFER_ID';" 2>/dev/null) + if [ "$FINAL_COUNT" = "10" ]; then + echo -e "${GREEN} ✓ All 10 moves persisted after buffer flush${NC}" + ((PASS++)) + else + echo -e "${RED} ✗ Expected 10 moves, found $FINAL_COUNT${NC}" + ((FAIL++)) + fi + + # Clean up + api_request DELETE "$API_URL/games/$BUFFER_ID" > /dev/null +fi + +# ============================================================================== +print_header "SECTION 6: Database Query Endpoints" +# ============================================================================== + +test_case "6.1: CLI Query Tool Integration" +# Create identifiable game +RESPONSE=$(api_request POST "$API_URL/games" \ + -H "Content-Type: application/json" \ + -d '{"white": {"type": 1}, "black": {"type": 2, "level": 15, "searchTime": 200}}') +QUERY_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null) + +if [ -n "$QUERY_ID" ] && [ "$QUERY_ID" != "null" ]; then + sleep 0.5 + + # Query using partial game ID (first 8 chars) + PARTIAL_ID="${QUERY_ID:0:8}" + FOUND=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM games WHERE game_id LIKE '$PARTIAL_ID%';" 2>/dev/null) + + if [ "$FOUND" = "1" ]; then + echo -e "${GREEN} ✓ Game queryable by partial ID${NC}" + ((PASS++)) + else + echo -e "${RED} ✗ Game not found with partial ID query${NC}" + ((FAIL++)) + fi + + # Verify player type storage + assert_db_record \ + "SELECT white_type || ',' || black_type FROM games WHERE game_id = '$QUERY_ID';" \ + "1,2" \ + "Player types stored correctly" + + # Clean up + api_request DELETE "$API_URL/games/$QUERY_ID" > /dev/null +fi + +# ============================================================================== +print_header "SECTION 7: Storage Degradation Handling" +# ============================================================================== + +test_case "7.1: Storage Health After Normal Operations" +# Check that storage remains healthy after all operations +RESPONSE=$(api_request GET "$BASE_URL/health") +assert_json_field "$RESPONSE" '.storage' "ok" "Storage still healthy after tests" + +test_case "7.2: WAL Mode Verification" +JOURNAL_MODE=$(sqlite3 "$TEST_DB" "PRAGMA journal_mode;" 2>/dev/null) +if [ "$JOURNAL_MODE" = "wal" ]; then + echo -e "${GREEN} ✓ Database is in WAL mode${NC}" + ((PASS++)) +else + echo -e "${RED} ✗ Database not in WAL mode: $JOURNAL_MODE${NC}" + ((FAIL++)) +fi + +# Check WAL file exists +if [ -f "${TEST_DB}-wal" ]; then + echo -e "${GREEN} ✓ WAL file exists${NC}" + ((PASS++)) +else + echo -e "${YELLOW} ⚠ WAL file not found (may be checkpointed)${NC}" +fi + +# ============================================================================== +print_header "Test Summary" +# ============================================================================== + +TOTAL=$((PASS + FAIL)) +SUCCESS_RATE=0 +if [ $TOTAL -gt 0 ]; then + SUCCESS_RATE=$(( (PASS * 100) / TOTAL )) +fi + +echo -e "\n${CYAN}══════════════════════════════════════${NC}" +echo -e "${GREEN}✓ Passed: $PASS${NC}" +echo -e "${RED}✗ Failed: $FAIL${NC}" +echo -e "${CYAN}──────────────────────────────────────${NC}" +echo -e "Total Tests: $TOTAL" +echo -e "Success Rate: ${SUCCESS_RATE}%" +echo -e "${CYAN}══════════════════════════════════════${NC}" + +if [ $FAIL -eq 0 ]; then + echo -e "\n${GREEN}🎉 All database tests passed!${NC}" + exit 0 +else + echo -e "\n${RED}⚠️ Some tests failed. Review the output above.${NC}" + exit 1 +fi \ No newline at end of file