/** * SQLite persistence. games/players hold identity + metadata; tokens covers * both unclaimed room invite links (kind='invite', player_id NULL) and * per-player reconnect secrets (kind='session'). config/state are stored as * JSON text — Phase 1 never needs to query into them. */ import Database from 'better-sqlite3'; const SCHEMA = ` CREATE TABLE IF NOT EXISTS games ( id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, board_id TEXT NOT NULL, config TEXT NOT NULL, state TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'lobby' CHECK (status IN ('lobby','active','finished')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE TABLE IF NOT EXISTS players ( id TEXT PRIMARY KEY, game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, name TEXT NOT NULL, seat INTEGER NOT NULL, color TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), last_seen_at TEXT ); CREATE INDEX IF NOT EXISTS idx_players_game_id ON players(game_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_players_game_seat ON players(game_id, seat); CREATE TABLE IF NOT EXISTS tokens ( token TEXT PRIMARY KEY, game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, player_id TEXT REFERENCES players(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('invite','session')), expires_at TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_tokens_game_id ON tokens(game_id); CREATE INDEX IF NOT EXISTS idx_tokens_player_id ON tokens(player_id); `; let db; export function initDb(dbPath) { db = new Database(dbPath); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec(SCHEMA); return db; } function now() { return new Date().toISOString(); } function deserializeGame(row) { if (!row) return null; return { ...row, config: JSON.parse(row.config), state: JSON.parse(row.state) }; } export function createGame({ id, code, boardId, config, state }) { db.prepare( `INSERT INTO games (id, code, board_id, config, state) VALUES (?, ?, ?, ?, ?)` ).run(id, code, boardId, JSON.stringify(config), JSON.stringify(state)); return getGameById(id); } export function getGameByCode(code) { return deserializeGame(db.prepare(`SELECT * FROM games WHERE code = ?`).get(code)); } export function getGameById(id) { return deserializeGame(db.prepare(`SELECT * FROM games WHERE id = ?`).get(id)); } export function updateGameState(id, state, status) { db.prepare(`UPDATE games SET state = ?, status = ?, updated_at = ? WHERE id = ?`).run( JSON.stringify(state), status, now(), id ); } export function createPlayer({ id, gameId, name, seat, color }) { db.prepare( `INSERT INTO players (id, game_id, name, seat, color) VALUES (?, ?, ?, ?, ?)` ).run(id, gameId, name, seat, color); } export function getPlayersByGame(gameId) { return db.prepare(`SELECT * FROM players WHERE game_id = ? ORDER BY seat`).all(gameId); } export function countPlayers(gameId) { return db.prepare(`SELECT COUNT(*) AS n FROM players WHERE game_id = ?`).get(gameId).n; } export function touchPlayerLastSeen(playerId) { db.prepare(`UPDATE players SET last_seen_at = ? WHERE id = ?`).run(now(), playerId); } export function createToken({ token, gameId, playerId = null, kind, expiresAt = null }) { db.prepare( `INSERT INTO tokens (token, game_id, player_id, kind, expires_at) VALUES (?, ?, ?, ?, ?)` ).run(token, gameId, playerId, kind, expiresAt); } export function getToken(token) { return db.prepare(`SELECT * FROM tokens WHERE token = ?`).get(token) ?? null; }