const initSqlJs = require('sql.js'); const path = require('path'); const fs = require('fs'); const dbPath = process.env.DB_PATH || './data/loot-hunt.db'; const dbDir = path.dirname(dbPath); // Ensure data directory exists if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } // ─── Compatibility wrapper ──────────────────────────────── // Wraps sql.js to provide a better-sqlite3–compatible API so models // can call db.prepare(sql).get(...), .all(...), .run(...) seamlessly. let _db = null; // raw sql.js Database instance let _saveTimer = null; // debounced persistence timer function save() { if (!_db) return; const data = _db.export(); fs.writeFileSync(dbPath, Buffer.from(data)); } function scheduleSave() { if (_saveTimer) return; // already scheduled _saveTimer = setTimeout(() => { _saveTimer = null; save(); }, 250); } /** Convert sql.js result set to array of plain objects */ function rowsToObjects(stmt) { const cols = stmt.getColumnNames(); const rows = []; while (stmt.step()) { const values = stmt.get(); const obj = {}; for (let i = 0; i < cols.length; i++) { obj[cols[i]] = values[i]; } rows.push(obj); } stmt.free(); return rows; } /** The public API that models import */ const db = { /** Execute raw SQL (DDL, multi-statement) */ exec(sql) { _db.run(sql); scheduleSave(); }, /** Set a pragma */ pragma(str) { _db.run(`PRAGMA ${str}`); }, /** Prepare a statement — returns object with get/all/run */ prepare(sql) { return { /** Return first matching row as object, or undefined */ get(...params) { const stmt = _db.prepare(sql); if (params.length) stmt.bind(params); const rows = rowsToObjects(stmt); return rows[0] || undefined; }, /** Return all matching rows as array of objects */ all(...params) { const stmt = _db.prepare(sql); if (params.length) stmt.bind(params); return rowsToObjects(stmt); }, /** Execute a write statement; returns { lastInsertRowid, changes } */ run(...params) { const stmt = _db.prepare(sql); if (params.length) stmt.bind(params); stmt.step(); stmt.free(); const changes = _db.getRowsModified(); // sql.js uses last_insert_rowid() for the last rowid const idResult = _db.exec('SELECT last_insert_rowid() as id'); const lastInsertRowid = idResult.length > 0 ? idResult[0].values[0][0] : 0; scheduleSave(); return { lastInsertRowid, changes }; } }; }, /** Wrap a function in a transaction */ transaction(fn) { return (...args) => { _db.run('BEGIN TRANSACTION'); try { const result = fn(...args); _db.run('COMMIT'); scheduleSave(); return result; } catch (err) { _db.run('ROLLBACK'); throw err; } }; }, /** Force an immediate save to disk */ forceSave() { if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; } save(); } }; // ─── Initialisation (synchronous-style via top-level await alternative) ─── // sql.js init is async, so we expose a ready promise that app.js awaits. let _readyResolve; const ready = new Promise(resolve => { _readyResolve = resolve; }); (async () => { const SQL = await initSqlJs(); // Load existing DB from disk or create new if (fs.existsSync(dbPath)) { const fileBuffer = fs.readFileSync(dbPath); _db = new SQL.Database(fileBuffer); } else { _db = new SQL.Database(); } // Enable foreign keys _db.run('PRAGMA foreign_keys = ON'); // Initialize schema _db.run(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL COLLATE NOCASE, password_hash TEXT NOT NULL, is_admin INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS hunts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, short_name TEXT UNIQUE NOT NULL, description TEXT, package_count INTEGER NOT NULL, expiry_date DATETIME, created_by INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS packages ( id INTEGER PRIMARY KEY AUTOINCREMENT, hunt_id INTEGER NOT NULL, card_number INTEGER NOT NULL, unique_code TEXT NOT NULL, first_scanned_by INTEGER, first_scan_image TEXT, last_scanned_by INTEGER, last_scan_hint TEXT, scan_count INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (hunt_id) REFERENCES hunts(id) ON DELETE CASCADE, FOREIGN KEY (first_scanned_by) REFERENCES users(id), FOREIGN KEY (last_scanned_by) REFERENCES users(id), UNIQUE(hunt_id, card_number), UNIQUE(hunt_id, unique_code) ); CREATE TABLE IF NOT EXISTS packages_idx1 (id INTEGER); DROP TABLE IF EXISTS packages_idx1; CREATE TABLE IF NOT EXISTS scans ( id INTEGER PRIMARY KEY AUTOINCREMENT, package_id INTEGER NOT NULL, user_id INTEGER NOT NULL, points_awarded INTEGER NOT NULL DEFAULT 0, scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS sessions ( sid TEXT PRIMARY KEY, sess TEXT NOT NULL, expired DATETIME NOT NULL ); `); // Create indexes (sql.js doesn't support IF NOT EXISTS on indexes in all // versions, so wrap in try/catch) const indexes = [ 'CREATE INDEX IF NOT EXISTS idx_packages_hunt ON packages(hunt_id)', 'CREATE INDEX IF NOT EXISTS idx_packages_code ON packages(unique_code)', 'CREATE INDEX IF NOT EXISTS idx_scans_package ON scans(package_id)', 'CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_id)', 'CREATE INDEX IF NOT EXISTS idx_hunts_short_name ON hunts(short_name)', 'CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired)' ]; for (const idx of indexes) { try { _db.run(idx); } catch (e) { /* index may already exist */ } } save(); // persist initial schema _readyResolve(); })(); db.ready = ready; module.exports = db;