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 { // 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 { // 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) { // 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 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) { db.getGameById = async (id: string | number): Promise => { // fallback: try to query by id using raw SQL if sequelize is available if (db && db.sequelize) { try { const rows = await db.sequelize.query('SELECT state FROM games WHERE id=:id', { replacements: { id }, type: db.Sequelize.QueryTypes.SELECT }); if (rows && rows.length) { const r = rows[0] as any; // state may be stored as text or JSON if (typeof r.state === 'string') { try { return JSON.parse(r.state) as GameState; } catch (e) { return null; } } return r.state as GameState; } } catch (e) { // ignore and fallthrough } } // 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 { try { await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { replacements: { id, state: payload } }); // 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 } } }; } if (!db.deleteGame) { db.deleteGame = async (id: string | number): Promise => { if (db && db.sequelize) { try { await db.sequelize.query('DELETE FROM games WHERE id=:id', { replacements: { id } }); } catch (e) { // swallow; callers should handle missing persistence } } }; } return db as GameDB; }