first commit
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s
This commit is contained in:
219
src/config/database.js
Normal file
219
src/config/database.js
Normal file
@@ -0,0 +1,219 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user