Files
loot-hunt/src/config/database.js
Mike Johnston 4255d95c68
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s
first commit
2026-02-28 00:01:41 -05:00

220 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-sqlite3compatible 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;