The plugin now answers a save once the files are written, with pending and a writeId, and reports the reload later as a config.outcome event. The save is recorded as reloading with a settle_by of two plugin ceilings plus slack on the database's clock; ingest settles the row by (server, writeId), only while it is still reloading, so a replay moves nothing and a late outcome still lands. A row past settle_by reads as lost. GET /admin/rust/config/:serverId/writes/:writeId serves the poll; the page polls it every two seconds, holds the Save button while it waits, and says whether a rolled-back plugin came back on its old file. config.outcome is a staff kind: it carries the server's log tail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
146 lines
5.1 KiB
JavaScript
146 lines
5.1 KiB
JavaScript
// ── 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 }
|