-- ── The schema fragment ─────────────────────────────────────────────────── -- -- Core replays this file on EVERY boot, statement by statement, immediately -- after its own schema.sql and before it seeds defaults (MODULE_API.md §2.6). -- -- There is no migration runner anywhere in this project. A module's schema is -- not a sequence of changes to apply once — it is a statement of what the tables -- should look like, written so that running it against a database that already -- matches does nothing. Every CREATE carries IF NOT EXISTS; **changing a table -- is an ALTER below the CREATE, never an edit to the CREATE**, because -- `CREATE TABLE IF NOT EXISTS` does nothing at all when the table is already -- there and an edited column would reach fresh installs only. -- -- Every table here is prefixed `rust_`, which is this module's id and the only -- prefix it may create under. -- -- ── Four kinds of table, and the split between them is the whole design ─── -- -- CONFIGURATION `rust_servers` — rows an operator writes, from Admin → Rust. -- OBSERVED STATE `rust_server_state`, `rust_presence` — what a sidecar last -- reported, replaced rather than appended. -- THE RECORD `rust_wipes`, `rust_players`, `rust_player_wipe_stats`, -- `rust_gather_totals` — permanent, and the reason a wipe does -- not erase a player's history. -- THE WINDOW `rust_events` — recent detail, bounded by a sweep. -- -- They are separate tables rather than columns on one because they have -- different writers, different lifetimes and different audiences — and because -- a purge of observed state while keeping the configuration is a thing an -- operator will eventually want. -- -- Teardown is `purge.sql`, which no boot ever runs. -- ── The configured servers ──────────────────────────────────────────────── -- -- One row per Rust game server, and therefore one row per sidecar: the bridge is -- one server to one sidecar, on that server's own host (R8). A community running -- six servers has six rows here, each with its own base URL and its own token. -- -- `id` is the operator's own slug and is what every URL under `/rust/servers/` -- carries. It is deliberately NOT auto-increment: it appears in links people -- share, and a row rebuilt after a mistake should be able to keep its address. -- -- `sidecar_token_enc` holds the sidecar's shared secret **encrypted at rest** -- through `ctx.secretBox` (MODULE_API.md §2.3), like every other secret this -- platform stores. It is write-only in the API: the admin surface accepts a new -- value and never returns the stored one, so a compromised admin session cannot -- read back the credential that reaches the game host. -- -- `protocol` records the wire version this row was configured against. It is -- stored rather than assumed because a fleet is upgraded one host at a time, and -- an operator needs to see WHICH server disagrees rather than that one does. CREATE TABLE IF NOT EXISTS rust_servers ( id VARCHAR(64) NOT NULL PRIMARY KEY, name VARCHAR(120) NOT NULL, sidecar_base_url VARCHAR(255) NOT NULL, sidecar_token_enc TEXT NULL, protocol INT UNSIGNED NOT NULL DEFAULT 1, enabled TINYINT(1) NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- ── What each server last said about itself ─────────────────────────────── -- -- One row per configured server, replaced whole each time this module reads a -- sidecar. It is the table that lets the site render while every game server is -- off, which is the point of the sidecar holding a store at all. -- -- `updated_at` carries no `ON UPDATE CURRENT_TIMESTAMP`, deliberately. That -- clause fires only when an UPDATE actually CHANGES a value, so a writer sending -- the same numbers back — which is exactly what a quiet server looks like — -- would leave the timestamp frozen at the first write and the row would look -- stale while nothing was wrong. The writer sets the column explicitly instead. -- -- `boot_id` is the game process's own identity, not the sidecar's and not the -- plugin's. It changes when the world started over and at no other time, which -- is what makes it the thing to watch: a reconnect of either bridge component -- loses nothing, and a game restart loses everything an event put in the world. -- -- `raw` keeps the whole frame. This module indexes the columns it serves and -- stores the rest verbatim, so a protocol version that adds a field needs no -- migration here — the same dumb-forwarder property the sidecar has, one hop -- further along. CREATE TABLE IF NOT EXISTS rust_server_state ( server_id VARCHAR(64) NOT NULL PRIMARY KEY, reachable TINYINT(1) NOT NULL DEFAULT 0, online TINYINT(1) NOT NULL DEFAULT 0, players INT UNSIGNED NOT NULL DEFAULT 0, max_players INT UNSIGNED NOT NULL DEFAULT 0, hostname VARCHAR(191) NULL, level VARCHAR(120) NULL, seed BIGINT NULL, world_size INT UNSIGNED NULL, boot_id VARCHAR(64) NULL, save_created_at VARCHAR(32) NULL, protocol INT UNSIGNED NULL, raw LONGTEXT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_rust_server_state_server FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE ); -- ── The read path ───────────────────────────────────────────────────────── -- -- Protocol 2 turned the bridge from a greeting into a catalogue, and these are -- the tables that hold it. They divide on one line, and it is the line R12 drew: -- -- PERMANENT `rust_wipes`, `rust_players`, `rust_player_wipe_stats`, -- `rust_gather_totals` — a player's record, kept for ever. All-time -- is a SUM across wipes rather than a second set of counters, so -- there is no second number that can disagree with the first. -- -- BOUNDED `rust_events` — the recent raw window the killfeed reads, pruned -- on a sweep. It is detail, not record: losing last month's -- individual deaths costs a scroll-back, losing last month's -- totals costs a player their history. -- -- DERIVED `rust_presence` — who is on right now, replaced wholesale from -- the `players.online` board. Never a history, never appended. -- -- The sidecar keeps its own bounded copy of the same events (default 14 days), -- so shortening either window loses recent detail and neither loses a total. -- ── Wipes ───────────────────────────────────────────────────────────────── -- -- One row per (server, wipe). The id is the plugin's, derived from the save's -- creation time and stamped on every frame (PROTOCOL.md §8.2) — this module -- never derives one, because two derivations of one fact eventually disagree -- about a boundary. -- -- Rows appear by being MENTIONED: the first frame carrying a wipe id this module -- has not seen creates it. There is no "start a wipe" call and there must not be -- one, because the website is not present when a wipe happens — a wipe is a fact -- about a world that was restarted while nobody was watching. CREATE TABLE IF NOT EXISTS rust_wipes ( server_id VARCHAR(64) NOT NULL, wipe_id VARCHAR(48) NOT NULL, save_created_at VARCHAR(32) NULL, first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (server_id, wipe_id), CONSTRAINT fk_rust_wipes_server FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE ); -- ── Players ─────────────────────────────────────────────────────────────── -- -- Identity, and deliberately nothing else. It is keyed on the Steam id alone -- and carries no server: a player is the same person on all six of a community's -- servers, and everything that is per-server lives in the stats table. -- -- `user_id` is NOT here. Linking a Steam id to a website account is phase 6's -- work (R1), and a column waiting for it would be a column every read has to -- remember is always null. CREATE TABLE IF NOT EXISTS rust_players ( steam_id VARCHAR(32) NOT NULL PRIMARY KEY, name VARCHAR(191) NULL, first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- ── The permanent record ────────────────────────────────────────────────── -- -- One row per player per wipe per server, and the only counters this module -- keeps. R12's "per-wipe detail plus all-time rollups" is satisfied by SUMming -- this rather than by maintaining a second all-time row, because two counters -- for one fact drift the first time an ingest is replayed. -- -- Every column is a COUNT that only ever goes up within a wipe, which is what -- makes ingest idempotent-ish in the only way that matters: the cursor advances -- only after the batch commits, so a crash re-reads a batch it has not counted. -- -- `playtime_sec` comes from `sessionSec` on a disconnect, and a session whose -- start this module never saw contributes NOTHING rather than zero — the plugin -- omits the field, the ingest skips it, and the number stays honestly short -- instead of quietly wrong. CREATE TABLE IF NOT EXISTS rust_player_wipe_stats ( server_id VARCHAR(64) NOT NULL, wipe_id VARCHAR(48) NOT NULL, steam_id VARCHAR(32) NOT NULL, kills INT UNSIGNED NOT NULL DEFAULT 0, deaths INT UNSIGNED NOT NULL DEFAULT 0, suicides INT UNSIGNED NOT NULL DEFAULT 0, npc_kills INT UNSIGNED NOT NULL DEFAULT 0, structures INT UNSIGNED NOT NULL DEFAULT 0, sessions INT UNSIGNED NOT NULL DEFAULT 0, playtime_sec BIGINT UNSIGNED NOT NULL DEFAULT 0, last_seen DATETIME NULL, PRIMARY KEY (server_id, wipe_id, steam_id), KEY idx_rust_stats_kills (server_id, wipe_id, kills DESC), KEY idx_rust_stats_player (steam_id) ); -- ── What they gathered ──────────────────────────────────────────────────── -- -- A row per resource rather than a JSON blob on the stats row, for one reason: -- the leaderboard question is "who gathered the most sulfur this wipe", and that -- is an ORDER BY over a column in every SQL engine and a JSON function call in -- exactly one. The resource name is the game's own shortname, unknown in advance -- and not worth a lookup table. CREATE TABLE IF NOT EXISTS rust_gather_totals ( server_id VARCHAR(64) NOT NULL, wipe_id VARCHAR(48) NOT NULL, steam_id VARCHAR(32) NOT NULL, resource VARCHAR(64) NOT NULL, amount BIGINT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (server_id, wipe_id, steam_id, resource), KEY idx_rust_gather_top (server_id, wipe_id, resource, amount DESC) ); -- ── The recent raw window ───────────────────────────────────────────────── -- -- Every ingested event, whole, for as long as the retention sweep keeps it. The -- killfeed reads this; so does an admin looking at what happened. -- -- `raw` holds the entire frame and the columns beside it are only what a query -- needs to reach — the same rule the sidecar's own store follows, one hop along: -- a protocol version that adds a field needs no migration here. -- -- **`kind` is a security boundary, not a label.** Some kinds carry IP addresses -- and player reports (PROTOCOL.md §8.4), and what makes them safe is that the -- public read is filtered by an allowlist this module holds, default-deny. The -- rows are stored either way, because an operator chasing ban evasion needs them. CREATE TABLE IF NOT EXISTS rust_events ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, server_id VARCHAR(64) NOT NULL, wipe_id VARCHAR(48) NULL, kind VARCHAR(64) NOT NULL, t BIGINT NOT NULL, steam_id VARCHAR(32) NULL, raw LONGTEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_rust_events_server (server_id, id DESC), KEY idx_rust_events_kind (server_id, kind, id DESC), KEY idx_rust_events_wipe (server_id, wipe_id, id DESC), KEY idx_rust_events_created (created_at) ); -- ── Who is on right now ─────────────────────────────────────────────────── -- -- Replaced wholesale every time the `players.online` board arrives, which is on -- every bridge connect and every 60 seconds. It is a BOARD, and the reason it is -- its own table rather than rows in `rust_events` is that a board answers "now" -- and an event answers "then"; storing a board as history is the mistake the -- wire's `type` field exists to prevent, and it would be a shame to make it here -- after the sidecar went to the trouble of not making it there. CREATE TABLE IF NOT EXISTS rust_presence ( server_id VARCHAR(64) NOT NULL, steam_id VARCHAR(32) NOT NULL, name VARCHAR(191) NULL, sleeping TINYINT(1) NOT NULL DEFAULT 0, connected_at DATETIME NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (server_id, steam_id) ); -- ── The ingest cursor ───────────────────────────────────────────────────── -- -- Where this module has read up to in each sidecar's feed. One row per server. -- -- It is persisted rather than held in memory because the alternative is a module -- that re-reads everything on every boot or nothing at all, and both are wrong in -- a way that only shows up in production. The cursor advances **after** the batch -- is written, never before: a crash mid-batch re-reads rows it has not counted, -- which is the safe direction to be wrong in. -- -- A NEW server starts at the sidecar's current end rather than at zero (see -- `GET /feed` with no `since`). A module installed today against a sidecar that -- has been running a month wants what happens next — replaying a fortnight of -- deaths into stats whose wipes it never saw is not a catch-up, it is a -- fabrication of history it was not present for. CREATE TABLE IF NOT EXISTS rust_ingest_cursor ( server_id VARCHAR(64) NOT NULL PRIMARY KEY, last_event_id BIGINT UNSIGNED NOT NULL DEFAULT 0, events_seen BIGINT UNSIGNED NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_rust_cursor_server FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE ); -- ── Who owns which Steam account ────────────────────────────────────────── -- -- R1's identity link, and the reason it is a table rather than a column on -- `rust_players`: a link is a fact about a WEBSITE USER that happens to be keyed -- by a Steam id, and it outlives every row this module writes about play. A -- column here would be null for the overwhelming majority of players and would -- be deleted by any sweep that pruned inactive ones. -- -- **Keyed on `steam_id` alone, fleet-wide.** `rust_players` already made that -- call in protocol 2 and it is the truth of the thing: a Steam account is one -- person across every server an operator runs, where stats are per server and -- per wipe. Linking on one server links for the fleet, because there is nothing -- else it could honestly mean. -- -- **One Steam id, at most one user** — that is what the primary key buys, and it -- is load-bearing rather than tidy. Phase 7 makes the site the author of who may -- do what in game and phase 13 makes it the thing that hands out loot; both are -- grants against a Steam id, and both assume the question "whose is this?" has -- exactly one answer. -- -- The reverse is deliberately NOT constrained: one website user may hold several -- Steam accounts. People have a second account, or a family shares a site login, -- and refusing that would be inventing a rule the game does not have. -- -- `ON DELETE CASCADE` from `users`: a deleted account's links go with it. The -- alternative is a row naming a user id that resolves to nobody, which every -- read would then have to defend against. CREATE TABLE IF NOT EXISTS rust_account_links ( steam_id VARCHAR(32) NOT NULL PRIMARY KEY, user_id INT NOT NULL, -- What the player was called in game when they linked. A display name, kept -- so an operator reading the admin panel sees a person rather than a number; -- never used to identify anybody, because a Rust name changes on a whim. name VARCHAR(191) NULL, -- Which server minted the code. Not part of the identity — the link is -- fleet-wide — but an operator asking "where did this come from" has no other -- way to find out, and a support conversation starts there. server_id VARCHAR(64) NULL, linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_rust_links_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, KEY idx_rust_links_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ── Site-owned permissions (phase 7, R2) ────────────────────────────────── -- -- The website is the author of record for who may do what in game, and the -- framework's own permission store is an ENFORCEMENT CACHE. That is one -- sentence with three consequences, and the tables below are shaped by them: -- -- • Every third-party plugin honours a site grant with no adapter, because -- they all already call `UserHasPermission`. Nothing here is read by the -- game directly; it is pushed into the store the game already consults. -- • A wipe stops being a data-loss event. The game forgets and the site does -- not, so the next sync puts it all back. -- • A hand edit is REPORTED, never silently overwritten (D31). Which means -- the site has to be able to tell a grant it made from one somebody typed -- at a console — and that is a fact only the site can hold, because the -- store records who granted a permission nowhere. -- -- ── A grant is against a WEBSITE USER (D28) ─────────────────────────────── -- -- Not against a Steam id, though a Steam id is what reaches the game. The site -- authors privilege for a PERSON: phase 13's earned entitlements follow whoever -- earned them, and an account unlinked from a person takes their privileges -- with it. The Steam ids are resolved from `rust_account_links` at push time, -- so a player who links a second account gets what they hold on both — which is -- the honest reading of "this person may do this". -- -- A user with no linked account is authored against perfectly well and simply -- reaches nobody until they link. That is visible on the admin screen rather -- than silent, because a grant that reaches nothing looks identical to a grant -- that worked from every other angle. -- -- ── Scope (D29) ─────────────────────────────────────────────────────────── -- -- Every authored row carries one: a server id, or `*` for the whole fleet. The -- game stores permissions per server (each has its own store), an operator -- running a modded server and a vanilla one will not want one set on both, and -- a single-server community never has to think about it. -- ── Groups ──────────────────────────────────────────────────────────────── -- -- Mirrored into the game as REAL groups (D30) rather than flattened into -- per-player grants. Third-party plugins read group membership, BetterChat's -- group API (R15, phase 17) has something to hang on, and an operator reading -- `oxide.show groups` sees what the website shows. -- -- The cost of that fidelity is written down in PLAN.md §12.2 rule 4 and does -- not go away: **a player the store has never seen cannot be put in a group**, -- while a direct grant to the same id works immediately. The sync reports those -- members as pending and the membership lands on their first connection. -- -- The name is the primary key, fleet-wide, even though the row carries a scope: -- one `vip` on the site is one `vip` in the game, pushed to the servers its -- scope names. Two groups of the same name with different scopes would be two -- definitions of one name in every store that received both. CREATE TABLE IF NOT EXISTS rust_perm_groups ( name VARCHAR(64) NOT NULL PRIMARY KEY, title VARCHAR(120) NOT NULL DEFAULT '', rank INT NOT NULL DEFAULT 0, scope VARCHAR(64) NOT NULL DEFAULT '*', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- What each group carries. A row per permission rather than a list on the group -- for the ordinary reason: "which groups grant kits.vip" is the question an -- operator asks when they are about to remove a plugin, and that is a WHERE -- clause here and a scan of every row in the other shape. CREATE TABLE IF NOT EXISTS rust_perm_group_permissions ( group_name VARCHAR(64) NOT NULL, permission VARCHAR(128) NOT NULL, PRIMARY KEY (group_name, permission), CONSTRAINT fk_rust_perm_group_permissions_group FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE ); -- Who is in each group — by website user, like every other authored row. -- -- `added_by` is an admin's user id and deliberately carries NO foreign key: a -- staff member's account being deleted must not delete the record of what they -- did, and `ON DELETE SET NULL` would quietly rewrite history to "nobody". -- The activity log is the audit trail; this column is a convenience beside it. CREATE TABLE IF NOT EXISTS rust_perm_group_members ( group_name VARCHAR(64) NOT NULL, user_id INT NOT NULL, added_by INT NULL, added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (group_name, user_id), KEY idx_rust_perm_members_user (user_id), CONSTRAINT fk_rust_perm_members_group FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE, CONSTRAINT fk_rust_perm_members_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); -- ── Direct grants ───────────────────────────────────────────────────────── -- -- A permission held by one person, without a group. It is not a lesser version -- of membership: it is the shape that reaches a player who has never connected -- to that server, which is exactly what an entitlement earned on the website at -- three in the morning has to do (R16). -- -- `source` is why this table does not need changing in phase 13. Every later -- author — an event action granting the right to redeem a kit, a lease handing -- out a weekend group — writes a row here with its own source rather than a -- store of its own, so there is one answer to "why does this player have this" -- and one place the push reads. CREATE TABLE IF NOT EXISTS rust_perm_grants ( id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, permission VARCHAR(128) NOT NULL, scope VARCHAR(64) NOT NULL DEFAULT '*', source VARCHAR(32) NOT NULL DEFAULT 'admin', note VARCHAR(255) NULL, granted_by INT NULL, granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_rust_perm_grant (user_id, permission, scope), KEY idx_rust_perm_grant_user (user_id), CONSTRAINT fk_rust_perm_grants_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); -- ── What this site has actually put in each game ────────────────────────── -- -- The site's memory of its own authorship, one row per thing it has confirmed -- into one server's store. It is the table that makes D31 possible at all. -- -- Three sets, and every interesting question is the difference between two of -- them: -- -- desired − pushed what to apply -- pushed − desired what to RETIRE, because the site put it there and has -- since withdrawn it -- present − desired drift: somebody else put it there -- -- Without the middle row a withdrawn grant is indistinguishable from a hand -- edit, and those two have opposite correct answers. Inferring it from absence -- is the mistake this table exists to prevent. -- -- It is keyed by Steam id rather than by user, because it records what is in the -- GAME, and the game has never heard of a website account. Unlinking an account -- therefore leaves its row here until the next sync retires it — which is the -- correct behaviour and would be impossible to express keyed the other way. CREATE TABLE IF NOT EXISTS rust_perm_pushed ( server_id VARCHAR(64) NOT NULL, -- `grant` | `member` | `group-permission` | `group` kind VARCHAR(24) NOT NULL, -- a Steam id, or a group name subject VARCHAR(64) NOT NULL, -- a permission, a group name, or '' for the existence of a group object VARCHAR(128) NOT NULL, pushed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (server_id, kind, subject, object), CONSTRAINT fk_rust_perm_pushed_server FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE ); -- ── Drift ───────────────────────────────────────────────────────────────── -- -- What a sync found in a server's store that the site did not author, within -- the namespace the site claims. Rows appear and disappear with the report: -- this is the CURRENT difference, not a history of differences, and a hand edit -- that somebody has since removed should stop being on the screen. -- -- Nothing here is ever removed from the game by the sync itself. An operator -- typing `oxide.grant` during an incident is drift, not an error, and the two -- answers offered to them — adopt it, or revoke it — are both a person's -- decision. CREATE TABLE IF NOT EXISTS rust_perm_drift ( id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, server_id VARCHAR(64) NOT NULL, kind VARCHAR(24) NOT NULL, subject VARCHAR(64) NOT NULL, object VARCHAR(128) NOT NULL, first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_rust_perm_drift (server_id, kind, subject, object), CONSTRAINT fk_rust_perm_drift_server FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE ); -- ── Removing something the site never put there ─────────────────────────── -- -- Revoking a drift row cannot go through `rust_perm_pushed`, because the whole -- point of a drift row is that it was never pushed. It cannot go through the -- authored tables either: a foreign grant often names a Steam id that belongs -- to no website account at all, and there is no user to author it against. -- -- So a revoke is its own instruction with its own lifetime: queued by a person, -- carried in the next sync's retire list, and deleted once a report says the -- game no longer has it. A server that is offline keeps the instruction until -- it comes back, which is the behaviour an operator expects from a website that -- claims to be the author of record. CREATE TABLE IF NOT EXISTS rust_perm_revocations ( id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, server_id VARCHAR(64) NOT NULL, kind VARCHAR(24) NOT NULL, subject VARCHAR(64) NOT NULL, object VARCHAR(128) NOT NULL, requested_by INT NULL, requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_rust_perm_revocation (server_id, kind, subject, object), CONSTRAINT fk_rust_perm_revocations_server FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE ); -- ── The state of the mirror, per server ─────────────────────────────────── -- -- One row per configured server: whether its store currently matches what the -- site authors, when that was last true, and what the last report said. -- -- `dirty` is how everything that should provoke a sync says so without knowing -- anything about syncing: an admin writing a grant, a drift hook firing in the -- game, a server reporting a new boot id or a new wipe. The loop owns WHEN, and -- every other part of the module owns WHETHER. -- -- `desired_hash` and `synced_hash` are the cheap half of that question. A loop -- that pushed the whole set every tick would work and would also write to six -- game servers every thirty seconds for ever; comparing a hash costs one query -- and skips the round trip when nothing has changed. The periodic audit below -- is what keeps that from being a way to never notice drift. CREATE TABLE IF NOT EXISTS rust_perm_sync ( server_id VARCHAR(64) NOT NULL PRIMARY KEY, -- `pending` | `ok` | `failed` state VARCHAR(24) NOT NULL DEFAULT 'pending', dirty TINYINT(1) NOT NULL DEFAULT 1, desired_hash VARCHAR(64) NULL, synced_hash VARCHAR(64) NULL, boot_id VARCHAR(64) NULL, wipe_id VARCHAR(48) NULL, last_attempt_at DATETIME NULL, last_ok_at DATETIME NULL, report LONGTEXT NULL, error VARCHAR(191) NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_rust_perm_sync_server FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE ); -- ── What each server's plugins have registered ──────────────────────────── -- -- The option source the authoring form offers (D33), cached from the live read -- so that opening the form is not six round trips to six game hosts. -- -- It is a cache of a fact that changes when an operator loads a plugin, and it -- is refreshed on every sync — which is also why a name that has stopped being -- registered disappears from the form rather than lingering as a choice that -- silently does nothing. CREATE TABLE IF NOT EXISTS rust_perm_catalogue ( server_id VARCHAR(64) NOT NULL, permission VARCHAR(128) NOT NULL, seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (server_id, permission), CONSTRAINT fk_rust_perm_catalogue_server FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE ); -- ── Changes to tables that already shipped ──────────────────────────────── -- -- An ALTER below the CREATE, never an edit to it: `CREATE TABLE IF NOT EXISTS` -- does nothing against a database that already has the table, so an edited column -- would reach fresh installs only — which is the worst possible distribution for -- a schema change, because it works everywhere it is tested. ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS wipe_id VARCHAR(48) NULL; -- Phase 4. `updated_at` is when THIS module last wrote the row, which is not the -- same fact as when the server last said something — and the pages were reading -- the first as if it were the second, so a server that had been down for three -- days rendered "last reported just now" on every failed poll. -- -- They are genuinely two facts and both are wanted: `updated_at` decides whether -- the row is stale (a module that stopped polling must not leave a page claiming -- a server is up), and `last_seen_at` is when a `server.hello` last arrived. Only -- a successful refresh moves it. ALTER TABLE rust_server_state ADD COLUMN IF NOT EXISTS last_seen_at DATETIME NULL; -- ── Configuration written from the site (phase 7b, R18) ─────────────────── -- -- The audit trail for the most powerful thing this website can do to somebody's -- game host: write a file on it. One row per save attempt, including the ones -- that were refused and the ones the plugin rolled back — a write that did not -- land is exactly the row an operator asking "why is ZoneManager down" needs to -- find. -- -- **No file bodies.** `changes` holds the fields that changed and their before -- and after LITERALS, which is what a person reading this wants, and secrets are -- redacted on the way in (`configEdit.redactChange`). D37 lets an admin read a -- credential on the page they opened deliberately; this table is read by more -- people, for longer, and usually by somebody who was not there. -- -- The versions bracket the write: `version_before` is what the plugin said the -- file was when it was read, `version_after` what it is now. They are the -- plugin's own hashes, echoed — this module never computes one. CREATE TABLE IF NOT EXISTS rust_config_writes ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, server_id VARCHAR(64) NOT NULL, path VARCHAR(255) NOT NULL, plugin VARCHAR(128) NULL, -- What the admin asked us to reload. NULL is an honest value: a file whose -- plugin is not loaded is written and not reloaded, and saying so is the -- difference between "saved" and "in effect". reload_target VARCHAR(128) NULL, -- `form` or `raw`. Which tier an edit came through changes how it should be -- read: a form edit is type-preserving and narrow, a raw edit replaced the -- whole document. tier VARCHAR(16) NOT NULL DEFAULT 'form', user_id INT NULL, -- `applied` | `rolled-back` | `refused` | `unreachable` outcome VARCHAR(24) NOT NULL, reloaded TINYINT(1) NOT NULL DEFAULT 0, changes LONGTEXT NULL, version_before VARCHAR(64) NULL, version_after VARCHAR(64) NULL, detail VARCHAR(500) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_rust_config_writes_server FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE, -- A deleted account must not delete the record that they changed a setting. -- The row stays and the name goes; the alternative is an audit trail that a -- person can erase by closing their account. CONSTRAINT fk_rust_config_writes_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL, KEY idx_rust_config_writes_server (server_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;