Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s
220 lines
6.4 KiB
JavaScript
220 lines
6.4 KiB
JavaScript
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;
|