// ── SQL, and nothing else ───────────────────────────────────────────────── // // Core's own backend is layered `router → controller → model → db`, with models // in pairs: a `.db.js` holding the SQL and a `.model.js` holding the logic that // calls it. The split earns its keep here for the same reason it does in core — // the file with the queries in it has no branching to test, and the file with the // branching in it has no database to stand up. // // Raw parameterised SQL through `core.query`, no ORM. Placeholders always. const core = require('../../core') const SERVERS = 'rust_servers' const STATE = 'rust_server_state' /** * Every configured server, in the operator's own order. * * **The encrypted token comes back on this read and is never returned to a * client.** Decryption happens in the model, one layer up; this file's job is to * fetch a column, not to decide who may see it. */ async function listServers({ enabledOnly = false } = {}) { return core.query( `SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc, protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt FROM ${SERVERS} ${enabledOnly ? 'WHERE enabled = 1' : ''} ORDER BY sort_order ASC, id ASC`, ) } async function getServer(id) { const rows = await core.query( `SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc, protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt FROM ${SERVERS} WHERE id = ?`, [id], ) return rows[0] || null } /** * Create or replace a server row. * * **`sidecar_token_enc` is only written when a value is supplied.** An admin form * that shows a blank token field — which is the only thing it can show, since the * token is write-only — posts an empty string on every save that did not intend * to change it. Writing that through would erase the credential every time an * operator renamed a server, and the failure would present as the bridge going * down for no reason an hour after an unrelated edit. */ async function upsertServer({ id, name, sidecarBaseUrl, sidecarTokenEnc, protocol, enabled, sortOrder }) { const setToken = sidecarTokenEnc !== null && sidecarTokenEnc !== undefined await core.query( `INSERT INTO ${SERVERS} (id, name, sidecar_base_url, sidecar_token_enc, protocol, enabled, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE name = VALUES(name), sidecar_base_url = VALUES(sidecar_base_url), ${setToken ? 'sidecar_token_enc = VALUES(sidecar_token_enc),' : ''} protocol = VALUES(protocol), enabled = VALUES(enabled), sort_order = VALUES(sort_order), updated_at = CURRENT_TIMESTAMP`, [id, name, sidecarBaseUrl, setToken ? sidecarTokenEnc : null, protocol, enabled ? 1 : 0, sortOrder], ) } async function deleteServer(id) { await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id]) } /** The last thing each server said about itself, keyed by server id. */ async function listState() { return core.query( `SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers, hostname, level, seed, world_size AS worldSize, boot_id AS bootId, save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol, last_seen_at AS lastSeenAt, updated_at AS updatedAt FROM ${STATE}`, ) } /** One server's observed state, or `null`. The single-row twin of `listState`. */ async function getState(serverId) { const rows = await core.query( `SELECT server_id AS serverId, reachable, online, players, max_players AS maxPlayers, hostname, level, seed, world_size AS worldSize, boot_id AS bootId, save_created_at AS saveCreatedAt, wipe_id AS wipeId, protocol, last_seen_at AS lastSeenAt, updated_at AS updatedAt FROM ${STATE} WHERE server_id = ?`, [serverId], ) return rows[0] || null } /** * Mark a server unreachable **without forgetting what it last said**. * * `putState` replaces the row whole, which is right when a sidecar answered: the * frame it answered with is the complete truth about that server. It is wrong * when nothing answered. A refresh that cannot reach a sidecar knows exactly one * new fact — that it could not reach it — and writing the whole row from that * one fact sets `hostname`, `level`, `seed`, `world_size` and `wipe_id` to NULL. * * The site's whole premise is that it renders the last thing each server said * while every server is off. A row blanked the first time a game host reboots * cannot do that: the page loses the map, the size, the seed and the wipe, and * what it shows is not "offline, here is what we know" but "offline, and we have * never heard of it". It is invisible in every test that stubs a reachable * sidecar, and it shows up as a page that was complete an hour ago. * * So: three columns move, and the description stays where it is. */ async function markUnreachable(serverId, reachable = false) { await core.query( `INSERT INTO ${STATE} (server_id, reachable, online, players, updated_at) VALUES (?, ?, 0, 0, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE reachable = VALUES(reachable), online = 0, players = 0, updated_at = CURRENT_TIMESTAMP`, [serverId, reachable ? 1 : 0], ) } /** * Replace one server's observed state. * * **`updated_at` is set explicitly, and it has to be.** MariaDB's * `ON UPDATE CURRENT_TIMESTAMP` fires only when an UPDATE actually CHANGES a * value, so an update writing the same numbers back — exactly what a quiet * server looks like — leaves the timestamp where it was. The row would then * cross the freshness window and the page would report the server offline while * it was up and reporting normally. That is invisible to every test and shows up * as a page that was right when you looked at it and wrong an hour later. */ async function putState(state) { await core.query( `INSERT INTO ${STATE} (server_id, reachable, online, players, max_players, hostname, level, seed, world_size, boot_id, save_created_at, wipe_id, protocol, raw, last_seen_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players), max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level), seed = VALUES(seed), world_size = VALUES(world_size), boot_id = VALUES(boot_id), save_created_at = VALUES(save_created_at), wipe_id = VALUES(wipe_id), protocol = VALUES(protocol), raw = VALUES(raw), -- Only a frame moves this; an unreachable write leaves it alone, which is -- what lets a page say how long a server has been down rather than how -- recently we failed to reach it. last_seen_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP`, [ state.serverId, state.reachable ? 1 : 0, state.online ? 1 : 0, state.players || 0, state.maxPlayers || 0, state.hostname || null, state.level || null, state.seed === undefined ? null : state.seed, state.worldSize === undefined ? null : state.worldSize, state.bootId || null, state.saveCreatedAt || null, state.wipeId || null, state.protocol === undefined ? null : state.protocol, state.raw ? JSON.stringify(state.raw) : null, ], ) } module.exports = { SERVERS, STATE, listServers, getServer, upsertServer, deleteServer, listState, getState, markUnreachable, putState, }