Adding DB persistence
This commit is contained in:
parent
69ccaa7560
commit
1469282199
@ -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"
|
||||
},
|
||||
|
@ -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 = '';
|
||||
|
@ -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,27 +68,99 @@ export async function initGameDB(): Promise<any> {
|
||||
// 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/<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 {
|
||||
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<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;
|
||||
}
|
||||
|
90
server/tools/import-games-to-db.ts
Normal file
90
server/tools/import-games-to-db.ts
Normal 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
102
server/tools/list-games.ts
Normal 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();
|
Loading…
x
Reference in New Issue
Block a user