v0.7.0 cli client with readline added, directory structure updated

This commit is contained in:
2025-11-13 08:55:06 -05:00
parent 52868af4ea
commit 6bdc061508
52 changed files with 2260 additions and 157 deletions

View File

@ -0,0 +1,138 @@
// FILE: lixenwraith/chess/internal/server/storage/game.go
package storage
import (
"database/sql"
"fmt"
"log"
)
// 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
}
}
// 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
}

View File

@ -0,0 +1,86 @@
// FILE: lixenwraith/chess/internal/server/storage/schema.go
package storage
import "time"
// UserRecord represents a user account in the database
type UserRecord struct {
UserID string `db:"user_id"`
Username string `db:"username"`
Email string `db:"email"`
PasswordHash string `db:"password_hash"`
CreatedAt time.Time `db:"created_at"`
LastLoginAt *time.Time `db:"last_login_at"`
}
// 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 users (
user_id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
email TEXT COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
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 UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email) WHERE email IS NOT NULL AND email != '';
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);
`

View File

@ -0,0 +1,186 @@
// FILE: lixenwraith/chess/internal/server/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 for games and sync writes for auth
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
}
// IsHealthy returns true if the storage is operational
func (s *Store) IsHealthy() bool {
return s.healthStatus.Load()
}
// 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
}
}
// 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
}

View File

@ -0,0 +1,187 @@
// FILE: lixenwraith/chess/internal/server/storage/user.go
package storage
import (
"database/sql"
"fmt"
"log"
"time"
)
// CreateUser creates user with transaction isolation to prevent race conditions
func (s *Store) CreateUser(record UserRecord) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Check uniqueness within transaction
exists, err := s.userExists(tx, record.Username, record.Email)
if err != nil {
return err
}
if exists {
return fmt.Errorf("username or email already exists")
}
// Insert user
query := `INSERT INTO users (
user_id, username, email, password_hash, created_at
) VALUES (?, ?, ?, ?, ?)`
_, err = tx.Exec(query,
record.UserID, record.Username, record.Email,
record.PasswordHash, record.CreatedAt,
)
if err != nil {
return err
}
return tx.Commit()
}
// userExists verifies username/email uniqueness within a transaction
func (s *Store) userExists(tx *sql.Tx, username, email string) (bool, error) {
var count int
query := `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE`
args := []interface{}{username}
if email != "" {
query = `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE OR email = ? COLLATE NOCASE`
args = append(args, email)
}
err := tx.QueryRow(query, args...).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// UpdateUserPassword updates user password hash
func (s *Store) UpdateUserPassword(userID string, passwordHash string) error {
query := `UPDATE users SET password_hash = ? WHERE user_id = ?`
_, err := s.db.Exec(query, passwordHash, userID)
return err
}
// UpdateUserEmail updates user email
func (s *Store) UpdateUserEmail(userID string, email string) error {
query := `UPDATE users SET email = ? WHERE user_id = ?`
_, err := s.db.Exec(query, email, userID)
return err
}
// UpdateUserUsername updates username
func (s *Store) UpdateUserUsername(userID string, username string) error {
query := `UPDATE users SET username = ? WHERE user_id = ?`
_, err := s.db.Exec(query, username, userID)
return err
}
// GetAllUsers retrieves all users
func (s *Store) GetAllUsers() ([]UserRecord, error) {
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
FROM users ORDER BY created_at DESC`
rows, err := s.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var users []UserRecord
for rows.Next() {
var user UserRecord
err := rows.Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
// UpdateUserLastLoginSync updates user last login time
func (s *Store) UpdateUserLastLoginSync(userID string, loginTime time.Time) error {
query := `UPDATE users SET last_login_at = ? WHERE user_id = ?`
_, err := s.db.Exec(query, loginTime, userID)
if err != nil {
return fmt.Errorf("failed to update last login for user %s: %w", userID, err)
}
return nil
}
// GetUserByUsername retrieves user by username with case-insensitive matching
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
FROM users WHERE username = ? COLLATE NOCASE`
err := s.db.QueryRow(query, username).Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
return &user, nil
}
// GetUserByEmail retrieves user by email with case-insensitive matching
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
FROM users WHERE email = ? COLLATE NOCASE`
err := s.db.QueryRow(query, email).Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
return &user, nil
}
// GetUserByID retrieves user by unique user ID
func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
FROM users WHERE user_id = ?`
err := s.db.QueryRow(query, userID).Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
return &user, nil
}
// DeleteUser removes a user from the database
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
}
}