// ── The SQL half of the configuration audit ─────────────────────────────── // // One table, two questions: record what a save did, and show an operator what // has been done to a server lately. // // Nothing here talks to a game. The game half is `sidecarClient`, and the two // are deliberately not mixed: this file is what remains true after the plugin // has been reloaded, rolled back, or lost. const core = require('../../core') /** * Records one save attempt — including the ones that never reached a file. * * A refusal is written for the same reason a success is: an operator asking why * a setting is not what they set has to be able to see that somebody tried and * was told no, and a table that only holds successes answers that question with * silence. */ async function recordWrite(row) { const result = await core.query( `INSERT INTO rust_config_writes (server_id, path, plugin, reload_target, tier, user_id, outcome, reloaded, changes, version_before, version_after, detail, write_id, settle_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))`, [ row.serverId, row.path, row.plugin || null, row.reloadTarget || null, row.tier || 'form', row.userId || null, row.outcome, row.reloaded ? 1 : 0, row.changes ? JSON.stringify(row.changes) : null, row.versionBefore || null, row.versionAfter || null, row.detail ? String(row.detail).slice(0, 500) : null, row.writeId || null, // Seconds from now, on the database's clock — the one `lost` is read against. // NULL for a write nothing is waiting on, and DATE_ADD of NULL is NULL. row.settleSeconds != null ? Number(row.settleSeconds) : null, ], ) return result && result.insertId != null ? Number(result.insertId) : null } /** * Settles a `reloading` write from the plugin's `config.outcome` (protocol 13). * * Found by the plugin's `writeId`, scoped to the server that sent it — a write * id is only unique within one plugin. Only a row still `reloading` moves, so a * replayed frame settles nothing twice. A row whose `settle_by` has passed is * still `reloading` in the table (it reads as `lost`), so a late outcome still * lands. Returns whether a row moved. */ async function settleWrite(serverId, outcome) { const result = await core.query( `UPDATE rust_config_writes SET outcome = ?, reloaded = ?, restored = ?, version_after = ?, detail = ?, log_tail = ?, settled_at = NOW() WHERE server_id = ? AND write_id = ? AND outcome = 'reloading'`, [ outcome.outcome, outcome.reloaded ? 1 : 0, outcome.restored == null ? null : outcome.restored ? 1 : 0, outcome.versionAfter || null, outcome.detail ? String(outcome.detail).slice(0, 500) : null, outcome.log || null, serverId, outcome.writeId, ], ) return Number((result && result.affectedRows) || 0) > 0 } /** One write, by its row id, for the page that is waiting on it. */ async function getWrite(serverId, id) { const rows = await core.query( `${SELECT_WRITE} WHERE server_id = ? AND id = ?`, [serverId, id], ) return rows[0] ? shapeWrite(rows[0]) : null } /** * The recent history for one server, newest first. * * `changes` comes back parsed, and a row whose JSON will not parse comes back * with `null` rather than throwing — a corrupt audit row must not be able to * break the page that displays the rest of them. */ async function recentWrites(serverId, limit = 50) { const rows = await core.query( `${SELECT_WRITE} WHERE server_id = ? ORDER BY id DESC LIMIT ?`, [serverId, Math.max(1, Math.min(Number(limit) || 50, 200))], ) return rows.map(shapeWrite) } // `lost` is decided by the database's clock, in the same statement that reads // the row, so it cannot disagree with `settle_by`, which that clock also wrote. const SELECT_WRITE = `SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier, user_id AS userId, outcome, reloaded, restored, changes, version_before AS versionBefore, version_after AS versionAfter, detail, log_tail AS log, write_id AS writeId, settled_at AS settledAt, created_at AS createdAt, (outcome = 'reloading' AND settle_by IS NOT NULL AND settle_by < NOW()) AS lost FROM rust_config_writes` /** * A row as the page reads it. A write still `reloading` past its `settle_by` * reads as `lost`: the plugin was reloaded or the link dropped before it could * say, and the files are whatever a re-read shows. */ function shapeWrite(row) { const { lost, ...rest } = row return { ...rest, outcome: Number(lost) ? 'lost' : row.outcome, reloaded: Boolean(row.reloaded), restored: row.restored == null ? null : Boolean(row.restored), changes: parseChanges(row.changes), } } function parseChanges(raw) { if (!raw) return null try { return JSON.parse(raw) } catch { return null } } module.exports = { recordWrite, settleWrite, getWrite, recentWrites, parseChanges, shapeWrite }