// Package store persists game state as JSON blobs in SQLite. package store import ( "database/sql" "encoding/json" "errors" "fmt" "os" "path/filepath" "time" _ "modernc.org/sqlite" "github.com/greyson/super-auto-pets-board-game/internal/game" ) // ErrNotFound is returned when a game doesn't exist. var ErrNotFound = errors.New("game not found") // Store wraps the SQLite database in the data directory. type Store struct { db *sql.DB } // Open creates the data directory if needed and opens (or initializes) the // database inside it. func Open(dataDir string) (*Store, error) { if err := os.MkdirAll(dataDir, 0o755); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)", filepath.Join(dataDir, "games.db")) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } // modernc.org/sqlite is happiest with a single writer connection. db.SetMaxOpenConns(1) if _, err := db.Exec(` CREATE TABLE IF NOT EXISTS games ( id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, state TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); `); err != nil { db.Close() return nil, fmt.Errorf("init schema: %w", err) } return &Store{db: db}, nil } func (s *Store) Close() error { return s.db.Close() } // Save upserts the full game state. func (s *Store) Save(g *game.Game) error { blob, err := json.Marshal(g) if err != nil { return err } now := time.Now().UnixMilli() _, err = s.db.Exec(` INSERT INTO games (id, code, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at `, g.ID, g.Code, string(blob), now, now) return err } // Load fetches a game by ID. func (s *Store) Load(id string) (*game.Game, error) { return s.loadWhere(`id = ?`, id) } // LoadByCode fetches a game by its join code. func (s *Store) LoadByCode(code string) (*game.Game, error) { return s.loadWhere(`code = ?`, code) } // LoadAny fetches a game by join code or by ID, whichever the argument looks // like — the convenience the report command wants, since a bug report quotes // whichever of the two the reporter had to hand. func (s *Store) LoadAny(idOrCode string) (*game.Game, error) { if g, err := s.LoadByCode(idOrCode); err == nil { return g, nil } else if !errors.Is(err, ErrNotFound) { return nil, err } return s.Load(idOrCode) } // Recent returns the most recently updated games, newest first, for picking one // out by hand. Each is fully loaded, so callers can report on it directly. func (s *Store) Recent(limit int) ([]*game.Game, error) { if limit <= 0 { limit = 20 } rows, err := s.db.Query(`SELECT state FROM games ORDER BY updated_at DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() var games []*game.Game for rows.Next() { var blob string if err := rows.Scan(&blob); err != nil { return nil, err } var g game.Game if err := json.Unmarshal([]byte(blob), &g); err != nil { // One corrupt row shouldn't hide the rest of the list. continue } games = append(games, &g) } return games, rows.Err() } func (s *Store) loadWhere(cond string, arg any) (*game.Game, error) { var blob string err := s.db.QueryRow(`SELECT state FROM games WHERE `+cond, arg).Scan(&blob) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } var g game.Game if err := json.Unmarshal([]byte(blob), &g); err != nil { return nil, fmt.Errorf("corrupt game state %v: %w", arg, err) } return &g, nil }