Files
Rust-Link/sidecar/src/store.rs
wtclaude e2a58f3455 feat(sidecar): protocol 1 — the transport
The rust-link sidecar: it owns the loopback listener the Oxide bridge plugin
dials into, and serves the website a WebSocket feed plus store-backed reads.

Protocol 1 is deliberately three frames — server.hello, ping/pong, and one
correlated server.status — because phase 1's job is to get every seam working at
once with almost nothing in them.

What is load-bearing rather than incidental:

* The plugin is the TCP client and this process owns the listener, so a Rust
  server opens no extra port. Loopback is the trust boundary on that link and
  there is no token on it; the website-facing surface is the opposite, with auth
  always on and a token generated and persisted on first start.
* Inbound lines are capped at 1 MiB from the start rather than after the first
  large frame arrives. An over-long line is discarded and the connection stays
  up: one malformed frame is not a reason to drop a link live events flow over.
* Store-backed reads answer while the game is off, which is what lets a website
  render a server list during a wipe. /status is the one route that fails when
  the game is down, and /server answers 204 rather than a null when the game has
  never connected -- those are different answers and a client that cannot tell
  them apart renders a server that does not exist.
* The two RPC failures get distinct codes. 503 means the game is down; 504 means
  it is up and did not answer. Different fixes.
* rpc::REPLY_TIMEOUT is a ceiling every later command budget sits under: core
  classifies a budget overrun as retryable unconditionally, so an action whose
  budgetMs does not exceed it can never report retry:false.

One defect found while building, which no unit test would have caught: a
four-connection SQLite pool over :memory: hands out four separate empty
databases, because an in-memory database is per connection. It presents as
'no such table' from a random subset of queries. The pool is now capped at one
connection for an in-memory path, which is the only coherent reading of
:memory: and is what makes it usable at all.

Exercised end to end against a live Rust server: a server.hello travelled game
-> sidecar -> module -> the public website API, and killing this process left
the game untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 19:52:55 -05:00

272 lines
11 KiB
Rust

//! SQLite persistence: the event history, and the last thing the game said about itself.
//!
//! This is what lets the website read the past without asking the game, and what survives a sidecar
//! restart. The event loop writes every live event here as it broadcasts it; REST reads query here
//! instead of round-tripping the plugin.
//!
//! **The sidecar defines no schema for a frame's contents.** Events are persisted whole, as the
//! JSON text that arrived, with only `t` and `kind` lifted out for indexing. That is the
//! dumb-forwarder property doing real work: a protocol version that adds fields to an event needs
//! no change here, and only a version that adds a *new indexed column* ever needs a migration.
use std::path::Path;
use serde_json::Value;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{Row, SqlitePool};
use tracing::info;
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
t INTEGER NOT NULL,
kind TEXT NOT NULL,
json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_kind_id ON events (kind, id DESC);
CREATE INDEX IF NOT EXISTS idx_events_t ON events (t);
-- Exactly one row, id 1: the most recent server.hello. A board, in the sense chapter 4 uses the
-- word — current state with one producer, re-sent on every connect — rather than a history.
CREATE TABLE IF NOT EXISTS server_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
t INTEGER NOT NULL,
json TEXT NOT NULL
);
";
#[derive(Clone)]
pub struct Store {
pool: SqlitePool,
}
impl Store {
/// Opens (creating if absent) the SQLite database and ensures the schema exists.
///
/// `path` is a filesystem path, handed to sqlx as one. It is deliberately **not** formatted
/// into a `sqlite://` URL first: that spelling is parsed as a URL, so it percent-decodes the
/// path and splits it on `?`. Under an installed layout the path is absolute and chosen by the
/// operator — `C:\ProgramData\RunicGateway\rust-link.db`, or something under a home directory
/// with a `%` or `#` in it — and a URL round-trip silently opens a *different* file.
pub async fn open(path: &str) -> anyhow::Result<Self> {
// A service unit can name a data directory that does not exist yet; creating it here means
// one less way for a fresh install to fail on first start.
if let Some(dir) = Path::new(path).parent() {
if !dir.as_os_str().is_empty() && !dir.exists() {
std::fs::create_dir_all(dir)?;
}
}
let opts = SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true);
// An in-memory database is **per connection**, not per process: every connection the pool
// opens gets its own empty one, so a second pooled connection finds none of the schema the
// first created. It presents as `no such table` from a random subset of queries, which is
// as confusing a failure as this file has. A single connection is the only coherent
// reading of `:memory:`, and it is what makes it usable at all.
let max = if is_in_memory(path) { 1 } else { 4 };
let pool = SqlitePoolOptions::new()
.max_connections(max)
.connect_with(opts)
.await?;
sqlx::query(SCHEMA).execute(&pool).await?;
info!(%path, "store ready");
Ok(Self { pool })
}
/// Cheap liveness check for the health endpoint.
pub async fn ping(&self) -> anyhow::Result<()> {
sqlx::query("SELECT 1").execute(&self.pool).await?;
Ok(())
}
/// Appends one live event. Failures are logged by the caller; persistence must never block the
/// live feed.
pub async fn insert_event(&self, t: i64, kind: &str, json: &str) -> anyhow::Result<()> {
sqlx::query("INSERT INTO events (t, kind, json) VALUES (?, ?, ?)")
.bind(t)
.bind(kind)
.bind(json)
.execute(&self.pool)
.await?;
Ok(())
}
/// Most-recent events, newest first, optionally filtered by kind.
pub async fn recent(&self, kind: Option<&str>, limit: i64) -> anyhow::Result<Vec<Value>> {
let limit = limit.clamp(1, 1000);
let rows = match kind {
Some(k) => {
sqlx::query("SELECT json FROM events WHERE kind = ? ORDER BY id DESC LIMIT ?")
.bind(k)
.bind(limit)
.fetch_all(&self.pool)
.await?
}
None => {
sqlx::query("SELECT json FROM events ORDER BY id DESC LIMIT ?")
.bind(limit)
.fetch_all(&self.pool)
.await?
}
};
Ok(parse_json_column(rows))
}
/// Replaces the one `server_state` row. Called for every `server.hello`, which the plugin sends
/// on every connect — so this is an upsert by construction, not by accident.
pub async fn put_server_state(&self, t: i64, json: &str) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO server_state (id, t, json) VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET t = excluded.t, json = excluded.json",
)
.bind(t)
.bind(json)
.execute(&self.pool)
.await?;
Ok(())
}
/// The last thing the game said about itself, or `None` if it has never connected.
///
/// This is the read that makes the website render while the game is off, which is the whole
/// reason the sidecar holds a database at all.
pub async fn server_state(&self) -> anyhow::Result<Option<Value>> {
let row = sqlx::query("SELECT json FROM server_state WHERE id = 1")
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
}
}
/// Whether this path names an in-memory database rather than a file. Covers the bare `:memory:`
/// spelling and the `file:` URI form that carries `mode=memory`.
fn is_in_memory(path: &str) -> bool {
path == ":memory:" || (path.starts_with("file:") && path.contains("mode=memory"))
}
fn parse_json_column(rows: Vec<sqlx::sqlite::SqliteRow>) -> Vec<Value> {
rows.into_iter()
.filter_map(|r| serde_json::from_str(&r.get::<String, _>("json")).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
async fn store() -> Store {
Store::open(":memory:").await.unwrap()
}
/// The trap this file's pool sizing exists for: a multi-connection pool over `:memory:` hands
/// out empty databases. Asserting the *pool* is what makes the reason visible; asserting only
/// that a query works would pass again the moment someone "tidied" the sizing back.
#[tokio::test]
async fn an_in_memory_store_uses_exactly_one_connection() {
assert!(is_in_memory(":memory:"));
assert!(is_in_memory("file:x?mode=memory&cache=shared"));
assert!(!is_in_memory("rust-link.db"));
assert!(!is_in_memory("file:/var/lib/rg/rust-link.db"));
let s = store().await;
assert_eq!(s.pool.options().get_max_connections(), 1);
}
/// `sqlx::query` over a multi-statement string is the kind of thing that quietly runs only the
/// first statement. Both tables and both reads have to work on a real file, under the pool
/// size production uses.
#[tokio::test]
async fn the_whole_schema_is_created_on_a_pooled_file_store() {
let dir = std::env::temp_dir().join(format!("rust-link-test-{}", std::process::id()));
let path = dir.join("schema.db");
let _ = std::fs::remove_dir_all(&dir);
let s = Store::open(path.to_str().unwrap()).await.unwrap();
assert_eq!(s.pool.options().get_max_connections(), 4);
s.insert_event(1, "k", "{}").await.unwrap();
s.put_server_state(1, "{}").await.unwrap();
assert_eq!(s.recent(None, 10).await.unwrap().len(), 1);
assert!(s.server_state().await.unwrap().is_some());
drop(s);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn events_come_back_newest_first_and_filter_by_kind() {
let s = store().await;
s.insert_event(1, "server.hello", &json!({"n": 1}).to_string())
.await
.unwrap();
s.insert_event(2, "other", &json!({"n": 2}).to_string())
.await
.unwrap();
s.insert_event(3, "server.hello", &json!({"n": 3}).to_string())
.await
.unwrap();
let all = s.recent(None, 10).await.unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0]["n"], 3);
let hellos = s.recent(Some("server.hello"), 10).await.unwrap();
assert_eq!(hellos.len(), 2);
assert_eq!(hellos[0]["n"], 3);
}
/// An absent board reads as `None`, not as an empty object. A caller must be able to tell
/// "the game has never connected" from "the game connected and said nothing" — collapsing the
/// two is how a site ends up rendering a server that does not exist.
#[tokio::test]
async fn server_state_is_absent_until_a_hello_arrives() {
let s = store().await;
assert!(s.server_state().await.unwrap().is_none());
s.put_server_state(1, &json!({"serverId": "main", "players": 0}).to_string())
.await
.unwrap();
assert_eq!(s.server_state().await.unwrap().unwrap()["serverId"], "main");
}
/// The board holds exactly one row however many times the plugin reconnects.
#[tokio::test]
async fn a_second_hello_replaces_the_first() {
let s = store().await;
s.put_server_state(1, &json!({"bootId": "a"}).to_string())
.await
.unwrap();
s.put_server_state(2, &json!({"bootId": "b"}).to_string())
.await
.unwrap();
assert_eq!(s.server_state().await.unwrap().unwrap()["bootId"], "b");
let count: i64 = sqlx::query("SELECT COUNT(*) AS c FROM server_state")
.fetch_one(&s.pool)
.await
.unwrap()
.get("c");
assert_eq!(count, 1);
}
#[tokio::test]
async fn the_limit_is_clamped_rather_than_trusted() {
let s = store().await;
for i in 0..5 {
s.insert_event(i, "k", &json!({"i": i}).to_string())
.await
.unwrap();
}
// 0 and negatives would otherwise mean "no rows" and "SQLite's unlimited" respectively.
assert_eq!(s.recent(None, 0).await.unwrap().len(), 1);
assert_eq!(s.recent(None, -1).await.unwrap().len(), 1);
assert_eq!(s.recent(None, 100_000).await.unwrap().len(), 5);
}
}