1
0

Adding DB persistence

This commit is contained in:
James Ketr 2025-10-06 12:21:58 -07:00
parent 69ccaa7560
commit 1469282199
5 changed files with 353 additions and 102 deletions

View File

@ -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"
},

View File

@ -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.
// 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 {
gameDB = await initGameDB();
} catch (e) {
throw new Error('Game DB is not available; persistence is required in DB-only mode');
}
}
if (!gameDB.getGameById) {
throw new Error('Game DB does not expose getGameById; persistence is required');
}
let game: any = null;
if (gameDB && gameDB.getGameById) {
try {
game = await gameDB.getGameById(id);
} catch (e) {
console.error(`${info}: gameDB.getGameById error`, e);
game = null;
}
}
// 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 (!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) {
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);
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);
}
}
}
@ -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 */
/* 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');
}
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 (!exists) {
try {
accessSync(`/db/games/${id}`, fs.F_OK);
exists = true;
} catch (err) {
// file does not exist
}
// if DB check fails treat as non-existent and continue searching
}
if (exists) {
id = '';

View File

@ -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<GameState | null>;
saveGameState(id: string | number, state: GameState): Promise<void>;
deleteGame?(id: string | number): Promise<void>;
[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<any> {
export async function initGameDB(): Promise<GameDB> {
// 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,20 +68,88 @@ export async function initGameDB(): Promise<any> {
// ignore and fallthrough
}
}
// If DB didn't have a state or query failed, attempt to read from the
// filesystem copy at db/games/<id> or <id>.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<void> => {
// 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 {
try {
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', {
replacements: { id, state: payload }
});
} catch (e) {
// if update failed, attempt insert
// 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 }
@ -78,6 +159,10 @@ export async function initGameDB(): Promise<any> {
}
}
}
} catch (finalErr) {
// swallow; we don't want persistence errors to crash the server
}
}
};
}
@ -89,11 +174,11 @@ export async function initGameDB(): Promise<any> {
replacements: { id }
});
} catch (e) {
// swallow errors; callers will rely on fallback behavior
// swallow; callers should handle missing persistence
}
}
};
}
return db;
return db as GameDB;
}

View File

@ -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();

102
server/tools/list-games.ts Normal file
View File

@ -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();