From 1469282199ab0983b9fa1c5dd8e3c110ed8a6ef2 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Mon, 6 Oct 2025 12:21:58 -0700 Subject: [PATCH] Adding DB persistence --- server/package.json | 2 + server/routes/games.ts | 126 +++++++++++---------------- server/routes/games/store.ts | 135 +++++++++++++++++++++++------ server/tools/import-games-to-db.ts | 90 +++++++++++++++++++ server/tools/list-games.ts | 102 ++++++++++++++++++++++ 5 files changed, 353 insertions(+), 102 deletions(-) create mode 100644 server/tools/import-games-to-db.ts create mode 100644 server/tools/list-games.ts diff --git a/server/package.json b/server/package.json index f234a4a..c29f04a 100644 --- a/server/package.json +++ b/server/package.json @@ -7,6 +7,8 @@ "start:legacy": "export $(cat ../.env | xargs) && node app.js", "build": "tsc -p tsconfig.json", "start:dev": "ts-node-dev --respawn --transpile-only src/app.ts", + "list-games": "ts-node-dev --transpile-only tools/list-games.ts", + "import-games": "ts-node-dev --transpile-only tools/import-games-to-db.ts", "test": "jest", "type-check": "tsc -p tsconfig.json --noEmit" }, diff --git a/server/routes/games.ts b/server/routes/games.ts index a3a1797..4e92f8a 100755 --- a/server/routes/games.ts +++ b/server/routes/games.ts @@ -32,8 +32,9 @@ const debug = { // normalizeIncoming imported from './games/utils.ts' import { initGameDB } from './games/store'; +import type { GameDB } from './games/store'; -let gameDB: any; +let gameDB: GameDB | undefined; initGameDB().then((db) => { gameDB = db; }).catch((e) => { @@ -620,47 +621,39 @@ const loadGame = async (id) => { return cached; } - // Try to load from the configured game DB first (if available). Fall - // back to the original file-based storage for compatibility. - let game: any = null; - if (gameDB && gameDB.getGameById) { + // Load game from the configured game DB. In DB-only mode a missing DB or + // missing game is considered an error; we still allow creating a new game + // when one doesn't exist. + // Ensure the gameDB is initialized (handle startup race where init may + // still be in progress). If initialization fails, surface a clear error. + if (!gameDB) { try { - game = await gameDB.getGameById(id); + gameDB = await initGameDB(); } catch (e) { - console.error(`${info}: gameDB.getGameById error`, e); - game = null; + throw new Error('Game DB is not available; persistence is required in DB-only mode'); } } - // If DB didn't return a game, try the original filesystem-backed storage - // including the existing backup/restore behavior. - if (!game) { - let raw = await readFile(`/db/games/${id}`).catch(() => { return; }); - if (raw) { - try { - game = JSON.parse(raw as any); - console.log(`${info}: Creating backup of /db/games/${id}`); - await writeFile(`/db/games/${id}.bk`, JSON.stringify(game)); - } catch (error) { - console.log(`Load or parse error from /db/games/${id}:`, error); - console.log(`Attempting to load backup from /db/games/${id}.bk`); - raw = await readFile(`/db/games/${id}.bk`).catch(() => { return; }); - if (raw) { - try { - game = JSON.parse(raw as any); - console.log(`Saving backup to /db/games/${id}`); - await writeFile(`/db/games/${id}`, JSON.stringify(game, null, 2)); - } catch (error) { - console.error(error); - game = null; - } - } - } - } + if (!gameDB.getGameById) { + throw new Error('Game DB does not expose getGameById; persistence is required'); } - + + let game: any = null; + try { + game = await gameDB.getGameById(id); + } catch (e) { + console.error(`${info}: gameDB.getGameById error`, e); + game = null; + } + if (!game) { game = await createGame(id); + // Persist the newly-created game immediately + try { + await gameDB.saveGameState(game.id, game); + } catch (e) { + console.error(`${info}: Failed to persist newly created game ${game.id}`, e); + } } /* Clear out cached names from player colors and rebuild them @@ -3490,29 +3483,15 @@ const saveGame = async (game) => { console.error(error); }); */ - // Prefer DB persistence when available, but gracefully fall back to the - // original file-based storage on error or when DB is not configured. - if (gameDB && gameDB.saveGameState) { - try { - await gameDB.saveGameState(game.id, reducedGame); - } catch (e) { - console.error(`${info}: gameDB.saveGameState failed for ${game.id}`, e); - try { - await mkdir('/db/games', { recursive: true }); - await writeFile(`/db/games/${game.id}`, JSON.stringify(reducedGame, null, 2)); - } catch (error) { - console.error(`Unable to write to /db/games/${game.id}`); - console.error(error); - } - } - } else { - try { - await mkdir('/db/games', { recursive: true }); - await writeFile(`/db/games/${game.id}`, JSON.stringify(reducedGame, null, 2)); - } catch (error) { - console.error(`Unable to write to /db/games/${game.id}`); - console.error(error); - } + if (!gameDB || !gameDB.saveGameState) { + console.error(`${info}: gameDB.saveGameState is not available; cannot persist game ${game.id}`); + return; + } + + try { + await gameDB.saveGameState(game.id, reducedGame); + } catch (e) { + console.error(`${info}: gameDB.saveGameState failed for ${game.id}`, e); } } @@ -4108,13 +4087,13 @@ router.ws("/ws/:id", async (ws, req) => { delete audio[id]; delete games[id]; try { - if (gameDB && gameDB.deleteGame) { - await gameDB.deleteGame(id); + if (!gameDB || !gameDB.deleteGame) { + console.error(`${session.id}: gameDB.deleteGame is not available; cannot remove ${id}`); } else { - fs.unlinkSync(`/db/games/${id}`); + await gameDB.deleteGame(id); } } catch (error) { - console.error(`${session.id}: Unable to remove /db/games/${id}`); + console.error(`${session.id}: Unable to remove game ${id} via gameDB.deleteGame`, error); } } } @@ -5019,23 +4998,16 @@ const createGame = async (id) => { while (!id) { id = randomWords(4).join('-'); try { - /* If a game with this id exists in the DB or filesystem, look for a new name */ - let exists = false; - if (gameDB && gameDB.getGameById) { - try { - const g = await gameDB.getGameById(id); - if (g) exists = true; - } catch (e) { - // ignore DB errors and fall back to filesystem check - } + /* If a game with this id exists in the DB, look for a new name */ + if (!gameDB || !gameDB.getGameById) { + throw new Error('Game DB not available for uniqueness check'); } - if (!exists) { - try { - accessSync(`/db/games/${id}`, fs.F_OK); - exists = true; - } catch (err) { - // file does not exist - } + let exists = false; + try { + const g = await gameDB.getGameById(id); + if (g) exists = true; + } catch (e) { + // if DB check fails treat as non-existent and continue searching } if (exists) { id = ''; diff --git a/server/routes/games/store.ts b/server/routes/games/store.ts index 92f34d3..9ff7bf8 100644 --- a/server/routes/games/store.ts +++ b/server/routes/games/store.ts @@ -1,33 +1,46 @@ import type { GameState } from './state.js'; +import { promises as fsp } from 'fs'; +import path from 'path'; + +export interface GameDB { + sequelize?: any; + Sequelize?: any; + getGameById(id: string | number): Promise; + saveGameState(id: string | number, state: GameState): Promise; + deleteGame?(id: string | number): Promise; + [k: string]: any; +} /** * Thin game DB initializer / accessor. * This currently returns the underlying db module (for runtime compatibility) * and is the single place to add typed helper methods for game persistence. */ -export async function initGameDB(): Promise { +export async function initGameDB(): Promise { // dynamic import to preserve original runtime ordering // path is relative to this file (routes/games) // Prefer synchronous require at runtime when available to avoid TS module resolution // issues during type-checking. Declare require to keep TypeScript happy. let mod: any; try { - mod = (globalThis as any).require('../db/games'); + // Use runtime require to load the DB module. This runs under Node (ts-node) + // so a direct require is appropriate and avoids relying on globalThis. + // eslint-disable-next-line @typescript-eslint/no-var-requires + mod = require('../../db/games'); } catch (e) { - // If require isn't available (very unusual in our Node container), - // return a safe no-op DB object rather than attempting a dynamic import. - // This keeps runtime behavior unchanged in the normal case and avoids - // needing // @ts-ignore for module resolution during type-checking. - return { - sequelize: undefined, - Sequelize: undefined, - getGameById: async (_id: string | number) => null, - saveGameState: async (_id: string | number, _state: GameState) => { /* no-op */ }, - deleteGame: async (_id: string | number) => { /* no-op */ } - } as any; + // DB-only mode: fail fast so callers know persistence is required. + throw new Error('Game DB module could not be loaded: ' + String(e)); } // If the module uses default export, prefer it - const db = (mod && (mod.default || mod)); + let db: any = (mod && (mod.default || mod)); + // If the required module returned a Promise (the db initializer may), await it. + if (db && typeof db.then === 'function') { + try { + db = await db; + } catch (e) { + throw new Error('Game DB initializer promise rejected: ' + String(e)); + } + } // attach typed helper placeholders (will be implemented incrementally) if (!db.getGameById) { @@ -55,27 +68,99 @@ export async function initGameDB(): Promise { // ignore and fallthrough } } - return null; + // If DB didn't have a state or query failed, attempt to read from the + // filesystem copy at db/games/ or .json so the state remains editable. + try { + const gamesDir = path.resolve(__dirname, '../../../db/games'); + const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + '.json')]; + for (const filePath of candidates) { + try { + const raw = await fsp.readFile(filePath, 'utf8'); + return JSON.parse(raw) as GameState; + } catch (e) { + // try next candidate + } + } + return null; + } catch (err) { + return null; + } }; } if (!db.saveGameState) { db.saveGameState = async (id: string | number, state: GameState): Promise => { + // Always persist a JSON file so game state is inspectable/editable. + try { + const gamesDir = path.resolve(__dirname, '../../../db/games'); + await fsp.mkdir(gamesDir, { recursive: true }); + // Write extensionless filename to match existing files + const filePath = path.join(gamesDir, String(id)); + const tmpPath = `${filePath}.tmp`; + await fsp.writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf8'); + await fsp.rename(tmpPath, filePath); + } catch (err) { + // Log but continue to attempt DB persistence + // eslint-disable-next-line no-console + console.error('Failed to write game JSON file for', id, err); + } + + // Now attempt DB persistence if sequelize is present. if (db && db.sequelize) { const payload = JSON.stringify(state); + // Try an UPDATE; if it errors due to missing column, try to add the + // column and retry. If update affects no rows, try INSERT. try { - await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { - replacements: { id, state: payload } - }); - } catch (e) { - // if update failed, attempt insert try { - await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', { + await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { replacements: { id, state: payload } }); - } catch (err) { - // swallow; callers should handle missing persistence + // Some dialects don't return affectedRows consistently; we'll + // still attempt insert if no row exists by checking select. + const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', { + replacements: { id }, + type: db.Sequelize.QueryTypes.SELECT + }); + if (!check || check.length === 0) { + await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', { + replacements: { id, state: payload } + }); + } + } catch (e: any) { + const msg = String(e && e.message ? e.message : e); + // If the column doesn't exist (SQLite: no such column: state), add it. + if (/no such column: state/i.test(msg) || /has no column named state/i.test(msg) || /unknown column/i.test(msg)) { + try { + await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT'); + // retry insert/update after adding column + await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { + replacements: { id, state: payload } + }); + const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', { + replacements: { id }, + type: db.Sequelize.QueryTypes.SELECT + }); + if (!check || check.length === 0) { + await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', { + replacements: { id, state: payload } + }); + } + } catch (inner) { + // swallow; callers should handle missing persistence + } + } else { + // For other errors, attempt insert as a fallback + try { + await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', { + replacements: { id, state: payload } + }); + } catch (err) { + // swallow; callers should handle missing persistence + } + } } + } catch (finalErr) { + // swallow; we don't want persistence errors to crash the server } } }; @@ -89,11 +174,11 @@ export async function initGameDB(): Promise { replacements: { id } }); } catch (e) { - // swallow errors; callers will rely on fallback behavior + // swallow; callers should handle missing persistence } } }; } - return db; + return db as GameDB; } diff --git a/server/tools/import-games-to-db.ts b/server/tools/import-games-to-db.ts new file mode 100644 index 0000000..b4c7dbf --- /dev/null +++ b/server/tools/import-games-to-db.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env ts-node +import path from 'path'; +import fs from 'fs/promises'; +import { initGameDB } from '../routes/games/store'; + +async function main() { + const gamesDir = path.resolve(__dirname, '../../db/games'); + let files: string[] = []; + try { + files = await fs.readdir(gamesDir); + } catch (e) { + console.error('Failed to read games dir', gamesDir, e); + process.exit(2); + } + + let db: any; + try { + db = await initGameDB(); + } catch (e) { + console.error('Failed to initialize DB', e); + process.exit(3); + } + + if (!db || !db.sequelize) { + console.error('DB did not expose sequelize; cannot proceed.'); + process.exit(4); + } + + for (const f of files) { + // ignore dotfiles and keep only .json or numeric filenames + if (f.startsWith('.')) continue; + const full = path.join(gamesDir, f); + try { + const stat = await fs.stat(full); + if (!stat.isFile()) continue; + const raw = await fs.readFile(full, 'utf8'); + const state = JSON.parse(raw); + // Derive id from filename (strip .json if present) + const idStr = f.endsWith('.json') ? f.slice(0, -5) : f; + const id = isNaN(Number(idStr)) ? idStr : Number(idStr); + const payload = JSON.stringify(state); + + // Ensure state column exists; attempt a safe ALTER if needed. + try { + await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT'); + } catch (e) { + /* ignore: may already exist */ + } + + // If filename is numeric use id column; otherwise use path column so we don't write strings into an INTEGER id. + try { + if (typeof id === 'number') { + const rows: any[] = await db.sequelize.query('SELECT id FROM games WHERE id=:id', { + replacements: { id }, + type: db.Sequelize.QueryTypes.SELECT + }); + if (rows && rows.length) { + await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { replacements: { id, state: payload } }); + console.log(`Updated game id=${id}`); + } else { + await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', { replacements: { id, state: payload } }); + console.log(`Inserted game id=${id}`); + } + } else { + // use path column to record the filename identifier + const rows: any[] = await db.sequelize.query('SELECT id FROM games WHERE path=:path', { + replacements: { path: idStr }, + type: db.Sequelize.QueryTypes.SELECT + }); + if (rows && rows.length) { + await db.sequelize.query('UPDATE games SET state=:state WHERE path=:path', { replacements: { path: idStr, state: payload } }); + console.log(`Updated game path=${idStr}`); + } else { + await db.sequelize.query('INSERT INTO games (path, state) VALUES(:path, :state)', { replacements: { path: idStr, state: payload } }); + console.log(`Inserted game path=${idStr}`); + } + } + } catch (e) { + console.error('Failed to insert/update game', idStr, e); + } + } catch (e) { + console.error('Failed to read/parse', full, e); + } + } + + console.log('Import complete'); + process.exit(0); +} + +main(); diff --git a/server/tools/list-games.ts b/server/tools/list-games.ts new file mode 100644 index 0000000..c1f5139 --- /dev/null +++ b/server/tools/list-games.ts @@ -0,0 +1,102 @@ +#!/usr/bin/env ts-node +import { initGameDB } from '../routes/games/store'; + +type Args = { + gameId?: string; +}; + +function parseArgs(): Args { + const args = process.argv.slice(2); + const res: Args = {}; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if ((a === '-g' || a === '--game') && args[i+1]) { + res.gameId = String(args[i+1]); + i++; + } + } + return res; +} + +async function main() { + const { gameId } = parseArgs(); + + let db: any; + try { + db = await initGameDB(); + } catch (e) { + console.error('Failed to initialize game DB:', e); + process.exit(1); + } + + if (!db || !db.sequelize) { + console.error('DB does not expose sequelize; cannot run queries.'); + process.exit(1); + } + + if (!gameId) { + // List all game ids + try { + const rows: any[] = await db.sequelize.query('SELECT id FROM games', { type: db.Sequelize.QueryTypes.SELECT }); + if (!rows || rows.length === 0) { + console.log('No games found.'); + return; + } + console.log('Games:'); + rows.forEach(r => console.log(` - ${r.id}`)); + } catch (e) { + console.error('Failed to list games:', e); + process.exit(1); + } + } else { + // For a given game ID, try to print the turns history from the state + try { + const rows: any[] = await db.sequelize.query('SELECT state FROM games WHERE id=:id', { + replacements: { id: gameId }, + type: db.Sequelize.QueryTypes.SELECT + }); + if (!rows || rows.length === 0) { + console.error('Game not found:', gameId); + process.exit(2); + } + const r = rows[0] as any; + let state = r.state; + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (e) { + console.error('Failed to parse stored state JSON:', e); + process.exit(3); + } + } + + if (!state) { + console.error('Empty state for game', gameId); + process.exit(4); + } + + console.log(`Game ${gameId} summary:`); + console.log(` - turns: ${state.turns || 0}`); + if (state.turnHistory || state.turnsData || state.turns_list) { + const turns = state.turnHistory || state.turnsData || state.turns_list; + console.log('Turns:'); + turns.forEach((t: any, idx: number) => { + console.log(`${idx}: ${JSON.stringify(t)}`); + }); + } else if (state.turns && state.turns > 0) { + console.log('No explicit turn history found inside state; showing snapshot metadata.'); + // Print limited snapshot details per turn if available + if (state.turnsData) { + state.turnsData.forEach((t: any, idx: number) => console.log(`${idx}: ${JSON.stringify(t)}`)); + } + } else { + console.log('No turn history recorded in state.'); + } + } catch (e) { + console.error('Failed to load game state for', gameId, e); + process.exit(1); + } + } +} + +main();