Part 1 final build: Los Santos RP written on camera by the agent

rp_core (sessions, scrypt auth, self-written MySQL-wire driver rp_db),
county-records NUI: residency-file auth, identity-record character
intake, survey-grid spawn, procedural night-city loading screen.
Secrets replaced with .example files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 19:27:48 +00:00
co-authored by Claude Fable 5
parent cb0649016c
commit 80c1d75d2f
41 changed files with 6801 additions and 0 deletions
@@ -0,0 +1,15 @@
fx_version 'cerulean'
game 'gta5'
name 'rp_db'
description 'MariaDB client speaking the MySQL wire protocol directly - no external packages'
author 'Los Santos RP'
version '1.0.0'
server_scripts {
'server/mysql.js',
}
files {
'lib/db.lua',
}
@@ -0,0 +1,87 @@
-- ---------------------------------------------------------------------------
-- DB - Lua front end for rp_db.
--
-- Loaded *into* the consuming resource with:
-- server_scripts { '@rp_db/lib/db.lua', 'server/whatever.lua' }
--
-- so that Citizen.Await suspends the caller's own coroutine instead of trying
-- to yield across a cross-resource export call.
--
-- Every function comes in two flavours:
-- DB.Query(sql, params) -> rows, err (blocking, inside a thread)
-- DB.QueryAsync(sql, params, cb) -> cb(rows, err) (callback, anywhere)
--
-- Values are always passed as `?` parameters. Never build SQL by concatenation.
-- Use DB.NULL where you need a literal SQL NULL (Lua nil cannot survive a table).
-- ---------------------------------------------------------------------------
DB = {}
DB.NULL = 'RP_NULL'
local backend = exports.rp_db
local function async(method)
return function(sql, params, cb)
backend[method](backend, sql, params or {}, cb)
end
end
local function blocking(method)
return function(sql, params)
local p = promise.new()
backend[method](backend, sql, params or {}, function(result, err)
-- wrapped: a promise resolved with nil would never wake the coroutine
p:resolve({ result = result, err = err })
end)
local r = Citizen.Await(p)
if r.err then return nil, r.err end
return r.result
end
end
--- Returns all rows as an array of tables (empty table when nothing matched).
DB.Query = blocking('query')
--- Returns the first row as a table, or nil.
DB.Single = blocking('single')
--- Returns the first column of the first row, or nil.
DB.Scalar = blocking('scalar')
--- Returns the AUTO_INCREMENT id produced by an INSERT.
DB.Insert = blocking('insert')
--- Returns the number of rows changed by an UPDATE/DELETE.
DB.Update = blocking('update')
DB.QueryAsync = async('query')
DB.SingleAsync = async('single')
DB.ScalarAsync = async('scalar')
DB.InsertAsync = async('insert')
DB.UpdateAsync = async('update')
--- All statements commit together or none of them do.
--- statements: { { query = 'UPDATE ..', values = { 1, 2 } }, ... }
function DB.Transaction(statements)
local p = promise.new()
backend:transaction(statements, function(ok, err)
p:resolve({ ok = ok, err = err })
end)
local r = Citizen.Await(p)
if not r.ok then return false, r.err end
return true
end
function DB.TransactionAsync(statements, cb)
backend:transaction(statements, cb)
end
--- Blocks until the connection pool has at least one live connection.
--- Returns false if the database never came up within `timeout` ms.
function DB.WaitReady(timeout)
local waited, step = 0, 100
timeout = timeout or 30000
while not backend:isReady() do
if waited >= timeout then return false end
Citizen.Wait(step)
waited = waited + step
end
return true
end
@@ -0,0 +1,746 @@
// ---------------------------------------------------------------------------
// rp_db - a MySQL/MariaDB client written for this server.
//
// Speaks the MySQL client/server protocol directly over a TCP socket: framing,
// handshake, mysql_native_password / caching_sha2_password (fast path), and
// the text result-set protocol. No external packages - only node built-ins.
//
// Deliberate choices:
// * CLIENT_MULTI_STATEMENTS is NOT negotiated, so a stacked-query injection
// ("'; DROP TABLE ..") is rejected by the server even if escaping failed.
// * CLIENT_LOCAL_FILES is NOT negotiated, so the server can never ask us to
// read a local file.
// * sql_mode is pinned at connect time, which also guarantees backslash
// escaping semantics that format() relies on.
// ---------------------------------------------------------------------------
const net = require('net');
const crypto = require('crypto');
// -- capability flags --------------------------------------------------------
const CAP = {
LONG_PASSWORD: 0x00000001,
FOUND_ROWS: 0x00000002,
LONG_FLAG: 0x00000004,
CONNECT_WITH_DB: 0x00000008,
LOCAL_FILES: 0x00000080,
PROTOCOL_41: 0x00000200,
TRANSACTIONS: 0x00002000,
SECURE_CONNECTION: 0x00008000,
MULTI_STATEMENTS: 0x00010000,
MULTI_RESULTS: 0x00020000,
PLUGIN_AUTH: 0x00080000,
CONNECT_ATTRS: 0x00100000,
PLUGIN_AUTH_LENENC: 0x00200000,
};
const CLIENT_CAPS =
CAP.LONG_PASSWORD | CAP.LONG_FLAG | CAP.CONNECT_WITH_DB | CAP.PROTOCOL_41 |
CAP.TRANSACTIONS | CAP.SECURE_CONNECTION | CAP.PLUGIN_AUTH | CAP.PLUGIN_AUTH_LENENC;
const COM = { QUIT: 0x01, QUERY: 0x03, PING: 0x0e };
// MySQL column type codes we care about when coercing text-protocol values.
const T_INT = new Set([1, 2, 3, 8, 9, 13]); // TINY SHORT LONG LONGLONG INT24 YEAR
const T_FLOAT = new Set([0, 4, 5, 246]); // DECIMAL FLOAT DOUBLE NEWDECIMAL
// ---------------------------------------------------------------------------
// Buffer helpers
// ---------------------------------------------------------------------------
class Reader {
constructor(buf) { this.b = buf; this.o = 0; }
u8() { return this.b[this.o++]; }
u16() { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; }
u32() { const v = this.b.readUInt32LE(this.o); this.o += 4; return v; }
skip(n) { this.o += n; return this; }
bytes(n) { const v = this.b.subarray(this.o, this.o + n); this.o += n; return v; }
nulStr() {
let i = this.b.indexOf(0, this.o);
if (i < 0) i = this.b.length;
const s = this.b.toString('utf8', this.o, i);
this.o = i + 1;
return s;
}
// length-encoded integer; returns null for the 0xfb NULL marker
lenenc() {
const f = this.b[this.o++];
if (f < 0xfb) return f;
if (f === 0xfb) return null;
if (f === 0xfc) { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; }
if (f === 0xfd) { const v = this.b.readUIntLE(this.o, 3); this.o += 3; return v; }
const v = this.b.readBigUInt64LE(this.o); this.o += 8;
return Number(v);
}
lenencStr() {
const n = this.lenenc();
if (n === null) return null;
return this.bytes(n).toString('utf8');
}
remaining() { return this.b.length - this.o; }
}
class Writer {
constructor() { this.parts = []; }
u8(v) { const b = Buffer.alloc(1); b[0] = v; this.parts.push(b); return this; }
u16(v) { const b = Buffer.alloc(2); b.writeUInt16LE(v); this.parts.push(b); return this; }
u32(v) { const b = Buffer.alloc(4); b.writeUInt32LE(v >>> 0); this.parts.push(b); return this; }
zeros(n) { this.parts.push(Buffer.alloc(n)); return this; }
raw(b) { this.parts.push(b); return this; }
nulStr(s) { this.parts.push(Buffer.from(s, 'utf8')); return this.u8(0); }
lenencBuf(b) {
if (b.length < 0xfb) this.u8(b.length);
else if (b.length < 0x10000) { this.u8(0xfc).u16(b.length); }
else { const t = Buffer.alloc(4); t.writeUIntLE(b.length, 0, 3); this.u8(0xfd).raw(t.subarray(0, 3)); }
return this.raw(b);
}
done() { return Buffer.concat(this.parts); }
}
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
const sha1 = (d) => crypto.createHash('sha1').update(d).digest();
const sha256 = (d) => crypto.createHash('sha256').update(d).digest();
function xorBuf(a, b) {
const out = Buffer.alloc(a.length);
for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i % b.length];
return out;
}
// SHA1(pw) XOR SHA1( scramble + SHA1(SHA1(pw)) )
function authNative(password, scramble) {
if (!password) return Buffer.alloc(0);
const s1 = sha1(Buffer.from(password, 'utf8'));
const s2 = sha1(s1);
return xorBuf(s1, sha1(Buffer.concat([scramble, s2])));
}
// SHA256(pw) XOR SHA256( SHA256(SHA256(pw)) + scramble ) [fast path only]
function authCachingSha2(password, scramble) {
if (!password) return Buffer.alloc(0);
const s1 = sha256(Buffer.from(password, 'utf8'));
const s2 = sha256(s1);
return xorBuf(s1, sha256(Buffer.concat([s2, scramble])));
}
function authResponse(plugin, password, scramble) {
switch (plugin) {
case 'mysql_native_password': return authNative(password, scramble);
case 'caching_sha2_password': return authCachingSha2(password, scramble);
case 'mysql_clear_password': return Buffer.concat([Buffer.from(password, 'utf8'), Buffer.alloc(1)]);
default: return null; // unsupported -> connection fails loudly
}
}
// ---------------------------------------------------------------------------
// Value escaping. Every query goes through format(); values are never
// concatenated into SQL anywhere else in this codebase.
// ---------------------------------------------------------------------------
const ESCAPE_MAP = {
'\0': '\\0', '\b': '\\b', '\t': '\\t', '\n': '\\n',
'\r': '\\r', '\x1a': '\\Z', '"': '\\"', "'": "\\'", '\\': '\\\\',
};
function escapeString(s) {
return "'" + s.replace(/[\0\b\t\n\r\x1a"'\\]/g, (c) => ESCAPE_MAP[c]) + "'";
}
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
function escapeValue(v) {
if (v === null || v === undefined) return 'NULL';
switch (typeof v) {
case 'boolean': return v ? '1' : '0';
case 'number':
if (!Number.isFinite(v)) throw new Error('cannot bind non-finite number');
return String(v);
case 'bigint': return String(v);
case 'string': return escapeString(v);
case 'object':
if (v instanceof Date) {
return escapeString(
`${v.getFullYear()}-${pad2(v.getMonth() + 1)}-${pad2(v.getDate())} ` +
`${pad2(v.getHours())}:${pad2(v.getMinutes())}:${pad2(v.getSeconds())}`
);
}
if (Buffer.isBuffer(v)) return 'x' + escapeString(v.toString('hex'));
if (Array.isArray(v)) return v.map(escapeValue).join(', ');
return escapeString(JSON.stringify(v)); // plain objects -> JSON columns
default:
throw new Error('cannot bind value of type ' + typeof v);
}
}
// Replace `?` placeholders, skipping any that appear inside string/identifier
// literals or comments so that user data containing '?' can never shift binding.
function format(sql, params) {
if (!params || (Array.isArray(params) && params.length === 0)) return sql;
const list = Array.isArray(params) ? params : [params];
let out = '';
let pi = 0;
let i = 0;
const n = sql.length;
while (i < n) {
const c = sql[i];
if (c === "'" || c === '"' || c === '`') {
const quote = c;
out += c; i++;
while (i < n) {
if (sql[i] === '\\' && quote !== '`') { out += sql[i] + (sql[i + 1] || ''); i += 2; continue; }
if (sql[i] === quote) {
if (sql[i + 1] === quote) { out += quote + quote; i += 2; continue; } // doubled quote
out += quote; i++; break;
}
out += sql[i]; i++;
}
continue;
}
if (c === '-' && sql[i + 1] === '-') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; }
if (c === '#') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; }
if (c === '/' && sql[i + 1] === '*') {
out += '/*'; i += 2;
while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) out += sql[i++];
out += '*/'; i += 2; continue;
}
if (c === '?') {
if (pi >= list.length) throw new Error('not enough parameters for query');
out += escapeValue(list[pi++]);
i++; continue;
}
out += c; i++;
}
if (pi !== list.length) throw new Error(`parameter count mismatch (query takes ${pi}, got ${list.length})`);
return out;
}
// ---------------------------------------------------------------------------
// Connection
// ---------------------------------------------------------------------------
const MAX_PAYLOAD = 0xffffff;
class Connection {
constructor(opts, id) {
this.opts = opts;
this.id = id;
this.socket = null;
this.buf = Buffer.alloc(0);
this.seq = 0;
this.state = 'offline';
this.busy = false;
this.pinned = false; // reserved by a transaction; pump() must skip it
this.pending = null; // {resolve, reject, sql}
this.rs = null; // in-flight result set
this.multi = null; // reassembly buffer for >16MB payloads
this.serverCaps = 0;
this.onIdle = null;
this.retryDelay = 1000;
}
log(msg) { console.log(`^5[rp_db]^7 conn#${this.id} ${msg}`); }
err(msg) { console.log(`^1[rp_db]^7 conn#${this.id} ${msg}`); }
connect() {
if (this.state !== 'offline') return;
this.state = 'connecting';
this.buf = Buffer.alloc(0);
this.seq = 0;
const sock = net.createConnection({ host: this.opts.host, port: this.opts.port });
this.socket = sock;
sock.setNoDelay(true);
sock.on('data', (d) => {
this.buf = this.buf.length ? Buffer.concat([this.buf, d]) : d;
this.drainPackets();
});
sock.on('error', (e) => this.fail(e.message));
sock.on('close', () => { if (this.state !== 'offline') this.fail('connection closed'); });
}
fail(reason) {
const wasReady = this.state === 'ready' || this.state === 'query';
this.state = 'offline';
if (this.socket) { this.socket.destroy(); this.socket = null; }
if (this.pending) {
const p = this.pending; this.pending = null; this.busy = false;
p.reject(new Error(reason));
}
this.busy = false;
this.pinned = false; // a dropped connection must not stay reserved
this.rs = null;
if (wasReady) this.err(`lost connection: ${reason}`);
else this.err(`connect failed: ${reason}`);
// exponential backoff, capped
setTimeout(() => this.connect(), this.retryDelay);
this.retryDelay = Math.min(this.retryDelay * 2, 30000);
}
// Split the stream into protocol packets, reassembling 16MB-split payloads.
drainPackets() {
for (;;) {
if (this.buf.length < 4) return;
const len = this.buf.readUIntLE(0, 3);
if (this.buf.length < 4 + len) return;
const seq = this.buf[3];
const payload = this.buf.subarray(4, 4 + len);
this.buf = this.buf.subarray(4 + len);
this.seq = (seq + 1) & 0xff;
if (len === MAX_PAYLOAD) { // more to come
this.multi = this.multi ? Buffer.concat([this.multi, payload]) : payload;
continue;
}
let full = payload;
if (this.multi) { full = Buffer.concat([this.multi, payload]); this.multi = null; }
try { this.onPacket(full); }
catch (e) { this.err(`packet handling error: ${e.message}`); this.fail(e.message); return; }
}
}
send(payload) {
if (!this.socket) return;
const chunks = [];
let off = 0;
do {
const slice = payload.subarray(off, off + MAX_PAYLOAD);
const head = Buffer.alloc(4);
head.writeUIntLE(slice.length, 0, 3);
head[3] = this.seq & 0xff;
this.seq = (this.seq + 1) & 0xff;
chunks.push(head, slice);
off += slice.length;
} while (off < payload.length);
this.socket.write(Buffer.concat(chunks));
}
sendCommand(cmd, text) {
this.seq = 0; // every command starts a new sequence
const body = text ? Buffer.from(text, 'utf8') : Buffer.alloc(0);
this.send(Buffer.concat([Buffer.from([cmd]), body]));
}
onPacket(p) {
switch (this.state) {
case 'connecting': return this.onHandshake(p);
case 'auth': return this.onAuthResult(p);
case 'init': return this.onInitResult(p);
case 'query': return this.onQueryPacket(p);
default:
this.err(`unexpected packet in state ${this.state}`);
}
}
// -- handshake -------------------------------------------------------------
onHandshake(p) {
const r = new Reader(p);
const protocol = r.u8();
if (protocol === 0xff) return this.fail(this.readError(p).message);
if (protocol !== 10) return this.fail(`unsupported protocol version ${protocol}`);
this.serverVersion = r.nulStr();
r.u32(); // connection id
const scramble1 = r.bytes(8);
r.skip(1); // filler
let caps = r.u16();
let scramble2 = Buffer.alloc(0);
let plugin = 'mysql_native_password';
if (r.remaining() > 0) {
r.u8(); // charset
r.u16(); // status flags
caps |= r.u16() << 16;
const authDataLen = r.u8();
r.skip(10);
if (caps & CAP.SECURE_CONNECTION) {
const n = Math.max(13, authDataLen - 8);
scramble2 = r.bytes(n);
// trailing NUL is part of the padding, not the scramble
if (scramble2.length && scramble2[scramble2.length - 1] === 0) {
scramble2 = scramble2.subarray(0, scramble2.length - 1);
}
}
if (caps & CAP.PLUGIN_AUTH) plugin = r.nulStr();
}
this.serverCaps = caps >>> 0;
this.scramble = Buffer.concat([scramble1, scramble2]);
const resp = authResponse(plugin, this.opts.password, this.scramble);
if (resp === null) return this.fail(`server requested unsupported auth plugin '${plugin}'`);
const w = new Writer();
w.u32(CLIENT_CAPS).u32(MAX_PAYLOAD).u8(45 /* utf8mb4_general_ci */).zeros(23);
w.nulStr(this.opts.user);
w.lenencBuf(resp);
w.nulStr(this.opts.database);
w.nulStr(plugin);
this.authPlugin = plugin;
this.state = 'auth';
this.send(w.done());
}
onAuthResult(p) {
const marker = p[0];
if (marker === 0x00) return this.afterAuth();
if (marker === 0xff) return this.fail(this.readError(p).message);
if (marker === 0xfe) { // AuthSwitchRequest
const r = new Reader(p); r.u8();
const plugin = r.nulStr();
let scramble = r.bytes(r.remaining());
if (scramble.length && scramble[scramble.length - 1] === 0) scramble = scramble.subarray(0, scramble.length - 1);
const resp = authResponse(plugin, this.opts.password, scramble);
if (resp === null) return this.fail(`server switched to unsupported auth plugin '${plugin}'`);
this.scramble = scramble;
this.authPlugin = plugin;
return this.send(resp);
}
if (marker === 0x01) { // caching_sha2 extra auth data
const status = p[1];
if (status === 0x03) return; // fast auth ok -> OK packet follows
if (status === 0x04) {
return this.fail(
'caching_sha2_password requires a full authentication exchange (TLS or the ' +
'server public key), which this driver does not implement. Give the database ' +
"user mysql_native_password, e.g. ALTER USER .. IDENTIFIED VIA mysql_native_password."
);
}
return this.fail(`unexpected auth continuation 0x${status.toString(16)}`);
}
this.fail(`unexpected auth response 0x${marker.toString(16)}`);
}
// Pin session settings, then announce readiness.
afterAuth() {
this.retryDelay = 1000;
this.state = 'init';
this.sendCommand(
COM.QUERY,
"SET NAMES utf8mb4, sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', " +
"time_zone='+00:00', autocommit=1"
);
}
onInitResult(p) {
if (p[0] === 0xff) return this.fail(this.readError(p).message);
this.state = 'ready';
this.busy = false;
this.log(`ready (server ${this.serverVersion}, auth ${this.authPlugin})`);
if (this.onIdle) this.onIdle(this);
}
// -- errors ----------------------------------------------------------------
readError(p) {
const r = new Reader(p);
r.u8();
const code = r.u16();
let sqlState = '';
if (p[3] === 0x23) { r.skip(1); sqlState = r.bytes(5).toString('ascii'); }
const message = p.subarray(r.o).toString('utf8');
const e = new Error(`${message} (errno ${code}${sqlState ? ', sqlstate ' + sqlState : ''})`);
e.errno = code; e.sqlState = sqlState;
return e;
}
readOk(p) {
const r = new Reader(p);
r.u8();
const affectedRows = r.lenenc() || 0;
const insertId = r.lenenc() || 0;
return { affectedRows, insertId };
}
isEof(p) { return p[0] === 0xfe && p.length < 9; }
// -- queries ---------------------------------------------------------------
query(sql) {
return new Promise((resolve, reject) => {
if (this.state !== 'ready') return reject(new Error('connection not ready'));
this.busy = true;
this.state = 'query';
this.pending = { resolve, reject, sql };
this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 };
this.sendCommand(COM.QUERY, sql);
});
}
finish(value, error) {
const p = this.pending;
this.pending = null;
this.rs = null;
this.state = 'ready';
this.busy = false;
if (p) { error ? p.reject(error) : p.resolve(value); }
if (this.onIdle) this.onIdle(this);
}
onQueryPacket(p) {
const rs = this.rs;
if (rs.stage === 'head') {
if (p[0] === 0x00) return this.finish(this.readOk(p));
if (p[0] === 0xff) return this.finish(null, this.readError(p));
if (p[0] === 0xfb) return this.finish(null, new Error('LOCAL INFILE requested but not enabled'));
const r = new Reader(p);
rs.colCount = r.lenenc();
rs.stage = 'columns';
return;
}
if (rs.stage === 'columns') {
if (this.isEof(p)) { rs.stage = 'rows'; return; }
const r = new Reader(p);
r.lenencStr(); r.lenencStr(); r.lenencStr(); r.lenencStr(); // catalog, schema, table, org_table
const name = r.lenencStr();
r.lenencStr(); // org_name
r.lenenc(); // length of fixed fields
r.u16(); // charset
r.u32(); // column length
const type = r.u8();
rs.columns.push({ name, type });
return;
}
// stage === 'rows'
if (p[0] === 0xff) return this.finish(null, this.readError(p));
if (this.isEof(p)) return this.finish(rs.rows);
const r = new Reader(p);
const row = {};
for (let i = 0; i < rs.colCount; i++) {
const col = rs.columns[i];
const raw = r.lenencStr();
row[col.name] = raw === null ? null : coerce(raw, col.type);
}
rs.rows.push(row);
}
ping() {
if (this.state !== 'ready') return;
this.busy = true;
this.state = 'query';
this.pending = {
resolve: () => {}, reject: (e) => this.err(`ping failed: ${e.message}`), sql: 'PING',
};
this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 };
this.sendCommand(COM.PING, '');
}
}
// Text protocol hands us strings; give Lua sensible types back.
function coerce(raw, type) {
if (T_INT.has(type)) {
const n = Number(raw);
return Number.isSafeInteger(n) ? n : raw; // keep huge BIGINTs exact as strings
}
if (T_FLOAT.has(type)) {
const n = Number(raw);
return Number.isNaN(n) ? raw : n;
}
return raw; // strings, dates, JSON, blobs
}
// ---------------------------------------------------------------------------
// Pool
// ---------------------------------------------------------------------------
class Pool {
constructor(opts) {
this.opts = opts;
this.queue = [];
this.conns = [];
for (let i = 0; i < opts.size; i++) {
const c = new Connection(opts, i + 1);
c.onIdle = () => this.pump();
this.conns.push(c);
}
}
start() {
this.conns.forEach((c, i) => setTimeout(() => c.connect(), i * 120));
setInterval(() => {
for (const c of this.conns) if (c.state === 'ready' && !c.busy) c.ping();
}, 30000);
}
get ready() { return this.conns.some((c) => c.state === 'ready'); }
free() { return this.conns.find((c) => c.state === 'ready' && !c.busy && !c.pinned); }
pump() {
while (this.queue.length) {
const conn = this.free();
if (!conn) return;
const job = this.queue.shift();
clearTimeout(job.timer);
if (job.settled) continue;
job.settled = true;
conn.query(job.sql).then(job.resolve, job.reject);
}
}
exec(sql) {
return new Promise((resolve, reject) => {
const job = { sql, resolve, reject, settled: false };
// Each job owns its own deadline, so a query still fails loudly when the
// database is down and no connection ever becomes free to trigger pump().
job.timer = setTimeout(() => {
if (job.settled) return;
job.settled = true;
const i = this.queue.indexOf(job);
if (i >= 0) this.queue.splice(i, 1);
reject(new Error('timed out waiting for a database connection'));
}, this.opts.queueTimeout);
this.queue.push(job);
this.pump();
});
}
// Pins one connection for the whole transaction so no other query can
// interleave between START TRANSACTION and COMMIT.
async transaction(statements) {
const held = await this.acquire();
try {
await held.query('START TRANSACTION');
for (const st of statements) await held.query(st);
await held.query('COMMIT');
return true;
} catch (e) {
try { await held.query('ROLLBACK'); } catch (_) { /* connection already gone */ }
throw e;
} finally {
held.release();
}
}
acquire() {
return new Promise((resolve, reject) => {
const started = Date.now();
const attempt = () => {
const conn = this.free();
if (conn) {
conn.pinned = true;
return resolve({
query: (sql) => conn.query(sql),
release: () => { conn.pinned = false; this.pump(); },
});
}
if (Date.now() - started > this.opts.queueTimeout) {
return reject(new Error('timed out waiting for a database connection'));
}
setTimeout(attempt, 25);
};
attempt();
});
}
}
// ---------------------------------------------------------------------------
// FiveM bridge - exposes the pool to the Lua resources as exports.
// ---------------------------------------------------------------------------
// Lua cannot put nil inside a table without creating a hole, so callers pass
// this sentinel when they need a real SQL NULL. rp_db exposes it as DB.NULL.
const NULL_SENTINEL = 'RP_NULL';
function unsentinel(v) { return v === NULL_SENTINEL ? null : v; }
function normalizeParams(p) {
if (p === null || p === undefined) return [];
if (Array.isArray(p)) return p.map(unsentinel);
if (typeof p === 'object') {
const keys = Object.keys(p);
if (keys.length === 0) return []; // Lua {} arrives as an empty map
if (keys.every((k) => /^[0-9]+$/.test(k))) { // Lua array table
return keys.sort((a, b) => a - b).map((k) => unsentinel(p[k]));
}
return [p]; // a real object -> JSON column
}
return [unsentinel(p)];
}
const pool = new Pool({
host: GetConvar('rp_db_host', '127.0.0.1'),
port: parseInt(GetConvar('rp_db_port', '3306'), 10),
user: GetConvar('rp_db_user', 'rp'),
password: GetConvar('rp_db_pass', ''),
database: GetConvar('rp_db_name', 'rp'),
size: parseInt(GetConvar('rp_db_pool', '4'), 10),
queueTimeout: 20000,
});
// Run a query and hand the result to a Lua callback as (result, err).
// The SQL *template* is logged on failure, never the formatted text, so bound
// values (password hashes and the like) stay out of the console.
function run(sql, params, cb, shape) {
const done = (result, err) => {
if (err) {
console.log(`^1[rp_db]^7 query failed: ${err}`);
console.log(`^1[rp_db]^7 in: ${String(sql).trim().slice(0, 200)}`);
}
if (typeof cb === 'function') {
try { cb(result, err || null); }
catch (e) { console.log(`^1[rp_db]^7 callback threw: ${e.message}`); }
}
};
let text;
try { text = format(sql, normalizeParams(params)); }
catch (e) { return done(null, e.message); }
pool.exec(text).then(
(res) => { try { done(shape(res), null); } catch (e) { done(null, e.message); } },
(e) => done(null, e.message)
);
}
const rowsOf = (res) => (Array.isArray(res) ? res : []);
global.exports('query', (sql, params, cb) => run(sql, params, cb, (r) => r));
global.exports('single', (sql, params, cb) => run(sql, params, cb, (r) => rowsOf(r)[0] || null));
global.exports('scalar', (sql, params, cb) => run(sql, params, cb, (r) => {
const row = rowsOf(r)[0];
if (!row) return null;
const k = Object.keys(row)[0];
return k === undefined ? null : row[k];
}));
global.exports('insert', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.insertId) || 0));
global.exports('update', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.affectedRows) || 0));
// statements: array of {query = ..., values = {...}} tables from Lua
global.exports('transaction', (statements, cb) => {
let list;
try {
const arr = Array.isArray(statements) ? statements : Object.values(statements || {});
list = arr.map((s) => format(s.query, normalizeParams(s.values)));
} catch (e) {
console.log(`^1[rp_db]^7 transaction rejected: ${e.message}`);
if (typeof cb === 'function') cb(false, e.message);
return;
}
pool.transaction(list).then(
() => { if (typeof cb === 'function') cb(true, null); },
(e) => {
console.log(`^1[rp_db]^7 transaction rolled back: ${e.message}`);
if (typeof cb === 'function') cb(false, e.message);
}
);
});
global.exports('isReady', () => pool.ready);
global.exports('nullSentinel', () => NULL_SENTINEL);
pool.start();