Files
Module-Rust/server/db/schema.sql
wtclaude 43147b796a feat(rust): site-owned permissions — the site is the author, the game is the cache
R2, and the first phase where this module WRITES to a game. Groups and grants are
authored on the website and pushed into each server's own permission store, so
every plugin that already calls `UserHasPermission` honours them with no adapter,
and a wipe stops being a data-loss event.

**Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and
resolved to every Steam id they have linked at push time (D28); every authored row
carries a scope — a server or `*` (D29); groups are mirrored as real groups rather
than flattened (D30); a holder the site did not author is REPORTED, never undone,
with adopt and revoke offered (D31); one verb, with the plugin diffing locally
(D32); a permission no server has registered is reported unresolved and never
self-registered (D33); authoring is people and groups by hand, with rules deferred
(D34).

**Three sets, and every interesting question is a difference between two.**
`desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because
the site put it there and has since withdrawn it; `present − desired` is drift. The
middle one is why `rust_perm_pushed` exists: a name in the store that is not in the
desired set is either something the site retired or something a human granted, and
those two have opposite correct answers.

**What lands is not what was sent.** A grant naming a permission the server has not
registered did not land — `GrantUserPermission` no-ops silently — and a member the
store has never seen could not be placed. Neither is recorded as pushed, so the
site never believes it gave a privilege it did not.

The loop asks a cheap question every thirty seconds — does the digest of the
desired set still equal what this server last confirmed — and syncs on a change, a
restart, a wipe, a drift hook, a failed attempt past its backoff, or the
fifteen-minute audit that finds drift on a server nobody has touched.

**This module's first admin page**, because a permission model is the first thing
here that has to be composed rather than configured. What is on it is decided by
what an operator can get wrong: four states are invisible from the game and from a
list of grants, and each is a sentence rather than a number.

Walked end to end against a real core at the pinned ref, the real sidecar, and a
stand-in speaking protocol 4 — including a restart that emptied the store and was
fully re-pushed. Four defects the browser found that 133 green tests did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 18:28:32 -05:00

621 lines
33 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- ── 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;