Files
Gambling_API/lib/repository.js
T
2026-09-09 14:56:05 +02:00

146 lines
10 KiB
JavaScript

const sqlite3 = require('sqlite3');
const {open} = require('sqlite');
const {KeyedQueue, AppError} = require('./core');
async function createRepository(filename, {beforeCommit = async () => {}} = {}) {
const db = await open({filename, driver: sqlite3.Database});
const queue = new KeyedQueue();
let release;
try {
await db.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;');
release = await require('./database-lock').acquireDatabaseLock(db);
} catch (error) {await db.close(); throw error;}
async function transaction(work) {
return queue.run(['database'], async () => {
await db.exec('BEGIN IMMEDIATE');
try {
const result = await work(db);
await beforeCommit();
await db.exec('COMMIT');
return result;
} catch (error) { await db.exec('ROLLBACK'); throw error; }
});
}
try {
const version = await db.get('PRAGMA user_version');
const oldUsers = await db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='users'");
if (version.user_version === 0 && oldUsers && filename !== ':memory:') {
await db.run('VACUUM INTO ?', `${filename}.${Date.now()}.backup`);
}
await transaction(async tx => {
const {user_version: version} = await tx.get('PRAGMA user_version');
if (version > 4) throw new Error('Database schema is newer than this application');
if (version === 0) {
const old = await tx.get("SELECT name FROM sqlite_master WHERE type='table' AND name='users'");
if (old) await tx.exec('ALTER TABLE users RENAME TO legacy_users');
await tx.exec(`
CREATE TABLE users (
username TEXT PRIMARY KEY COLLATE NOCASE,
password_hash TEXT NOT NULL,
session_hash TEXT,
money INTEGER NOT NULL DEFAULT 200 CHECK(typeof(money) = 'integer' AND money >= 0 AND money <= 9007199254740991)
);
CREATE TABLE lobbies (id TEXT PRIMARY KEY, state TEXT NOT NULL);
CREATE TABLE commands (id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, result TEXT NOT NULL, created_at INTEGER NOT NULL);
CREATE TABLE ledger (id INTEGER PRIMARY KEY, command_id TEXT NOT NULL, username TEXT NOT NULL REFERENCES users(username), delta INTEGER NOT NULL, reason TEXT NOT NULL);
CREATE TABLE migrations (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL);
CREATE TABLE easter_eggs (username TEXT NOT NULL REFERENCES users(username), code TEXT NOT NULL, used_at INTEGER NOT NULL, PRIMARY KEY(username, code));
`);
if (old) {
// Fails and rolls back rather than silently rounding or losing invalid balances.
await tx.exec(`INSERT INTO users(username, password_hash, money) SELECT username, password_hash, money FROM legacy_users; INSERT INTO ledger(command_id,username,delta,reason) SELECT 'schema-v1:' || username, username, money, 'Opening balance migration' FROM users; DROP TABLE legacy_users;`);
}
await tx.exec('PRAGMA user_version = 4');
} else if (version === 1) {
await tx.exec('CREATE TABLE easter_eggs (username TEXT NOT NULL REFERENCES users(username), code TEXT NOT NULL, used_at INTEGER NOT NULL, PRIMARY KEY(username, code)); PRAGMA user_version = 4');
} else if (version === 2) {
const broken = await tx.all("SELECT username, delta FROM ledger WHERE reason = 'Easter egg: KRISHD' AND delta < 0");
for (const entry of broken) {
const letters = [...entry.username].filter(character => /[a-z]/i.test(character)).length;
const correction = -entry.delta * letters;
if (!Number.isSafeInteger(correction)) throw new Error('Invalid KRISHD repair amount');
await tx.run('UPDATE users SET money = money + ? WHERE username = ?', correction, entry.username);
await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,?,?)', `repair:KRISHD:${entry.username}`, entry.username, correction, 'KRISHD multiplier repair');
}
await tx.exec("UPDATE easter_eggs SET code = 'FRETUX' WHERE code = 'FRETUKS'; PRAGMA user_version = 4");
} else if (version === 3) {
await tx.exec("UPDATE easter_eggs SET code = 'FRETUX' WHERE code = 'FRETUKS'; PRAGMA user_version = 4");
}
});
} catch (error) { await release(); await db.close(); throw error; }
const read = work => queue.run(['database'], () => work(db));
return {
transaction,
user: username => read(tx => tx.get('SELECT username, password_hash AS passwordHash, session_hash AS sessionHash, money FROM users WHERE username = ?', username)),
users: () => read(tx => tx.all('SELECT username, money FROM users ORDER BY money DESC, username ASC')),
async register(username, passwordHash, sessionHash) {
try { await transaction(async tx => {
await tx.run('INSERT INTO users(username,password_hash,session_hash) VALUES(?,?,?)', username, passwordHash, sessionHash);
await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,200,?)', `register:${username}`, username, 'Starting chips');
}); }
catch (error) { if (error.code === 'SQLITE_CONSTRAINT') throw new AppError('USERNAME_EXISTS', 'Username already exists', 409); throw error; }
},
setSession: (username, sessionHash) => transaction(tx => tx.run('UPDATE users SET session_hash = ? WHERE username = ?', sessionHash, username)),
async redeemEasterEgg(username, code) {
const repeatable = code === 'LOSERNOOB';
return transaction(async tx => {
const user = await tx.get('SELECT money FROM users WHERE username = ?', username);
if (!user) throw new AppError('USER_NOT_FOUND', 'Account no longer exists', 404);
if (!repeatable && await tx.get('SELECT 1 FROM easter_eggs WHERE username = ? AND code = ?', username, code)) {
throw new AppError('EASTER_EGG_USED', 'This terminal command was already used.', 409);
}
const multiplier = code === 'FRETUX' ? 2 : code === 'KRISHD' ? [...username].filter(character => /[a-z]/i.test(character)).length : 1;
const next = code === 'ILIKEMONEY' ? user.money + 1000 : user.money * multiplier;
if (!Number.isSafeInteger(next) || next < 0) throw new AppError('BALANCE_LIMIT', 'This command would exceed the chip limit.', 409);
const delta = next - user.money;
if (!repeatable) await tx.run('INSERT INTO easter_eggs(username,code,used_at) VALUES(?,?,?)', username, code, Date.now());
if (delta) {
await tx.run('UPDATE users SET money = ? WHERE username = ?', next, username);
await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,?,?)', `easter:${code}:${username}`, username, delta, `Easter egg: ${code}`);
}
return {balance: next, effect: repeatable ? 'matrix' : 'credits'};
});
},
async recover() {
return transaction(async tx => {
const rows = await tx.all('SELECT id,state FROM lobbies');
for (const row of rows) {
const lobby = JSON.parse(row.state);
if (lobby.game?.phase !== 'betting') continue;
for (const [username, amount] of Object.entries(lobby.game.contributions)) {
if (!amount) continue;
await tx.run('UPDATE users SET money = money + ? WHERE username = ?', amount, username);
await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,?,?)', `recovery:${lobby.game.id}`, username, amount, 'Interrupted hand refund');
}
}
await tx.run('DELETE FROM lobbies');
});
},
command: id => read(tx => tx.get('SELECT fingerprint,result FROM commands WHERE id = ?', id)),
async commit({id, fingerprint, lobby, deltas, result, reason}) {
return transaction(async tx => {
const prior = await tx.get('SELECT fingerprint,result FROM commands WHERE id = ?', id);
if (prior) {
if (prior.fingerprint !== fingerprint) throw new AppError('IDEMPOTENCY_CONFLICT', 'Request ID was reused for a different command', 409);
return {duplicate: true, result: JSON.parse(prior.result)};
}
for (const [username, amount] of Object.entries(deltas)) {
if (!Number.isSafeInteger(amount)) throw new Error('Invalid ledger delta');
if (!amount) continue;
const updated = await tx.run('UPDATE users SET money = money + ? WHERE username = ? AND money + ? >= 0', amount, username, amount);
if (updated.changes !== 1) throw new AppError('INSUFFICIENT_CHIPS', 'Insufficient chips', 409);
await tx.run('INSERT INTO ledger(command_id,username,delta,reason) VALUES(?,?,?,?)', id, username, amount, reason);
}
if (lobby.players.length) await tx.run('INSERT INTO lobbies(id,state) VALUES(?,?) ON CONFLICT(id) DO UPDATE SET state=excluded.state', lobby.id, JSON.stringify(lobby));
else await tx.run('DELETE FROM lobbies WHERE id = ?', lobby.id);
await tx.run('INSERT INTO commands(id,fingerprint,result,created_at) VALUES(?,?,?,?)', id, fingerprint, JSON.stringify(result), Date.now());
return {duplicate: false, result};
});
},
totals: () => read(async tx => ({users: (await tx.get('SELECT COALESCE(SUM(money),0) AS total FROM users')).total,
escrow: (await tx.all('SELECT state FROM lobbies')).reduce((sum, row) => sum + (JSON.parse(row.state).game?.pot || 0), 0)})),
close: async () => { await queue.drain(); await release(); await db.close(); }
};
}
module.exports = {createRepository};