feat(store): persist world.ruleset and serve it from GET /ruleset

Protocol 3.0 §5 (docs/link/v3.md). The shard publishes one world.ruleset frame
per connect describing how it is configured; the sidecar folds it into a
singleton row and serves it back.

Store-backed rather than an RPC, for the same reason /guilds and /houses are
(PROTOCOL_2.md §12.2): a rules page that goes blank while the shard restarts is
worse than one that is briefly stale. `{"ruleset": null}` distinguishes "the
shard has never published one" — an old plugin, or Bridge.RulesetEnabled=false —
from a published ruleset, which the website renders differently.

`rev` (the shard's FNV-1a of the body) is kept alongside the JSON so a reader can
tell "same ruleset, re-sent on reconnect" from "the operator changed something"
without diffing.

PROTOCOL_VERSION stays 2. The 2→3 bump is a hard operator-visible cutover and
happens exactly once, at the end of v3 (§4), not per phase.

Smoke-tested against a fake shard on loopback: frame ingested, GET /ruleset
returns it with plugin_connected=false (outage path), and the route sits behind
the gate (409 on a version mismatch, 401 unauthenticated). cargo build + clippy
clean.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-28 11:19:10 -05:00
parent 7e4177c6c0
commit 8e018a01e9
3 changed files with 74 additions and 0 deletions

View File

@@ -293,6 +293,35 @@ impl Store {
Ok(parse_json_column(rows))
}
// ---- shard ruleset (Protocol 3.0) ----
/// Stores the shard's published ruleset. A singleton (`id = 1`): the shard emits one
/// `world.ruleset` frame per connect describing how it is configured, and only the latest one
/// matters. `rev` is the shard's FNV-1a of the body, kept so a reader can tell "same ruleset,
/// re-sent on reconnect" from "the operator changed something" without diffing the JSON.
pub async fn upsert_ruleset(&self, rev: Option<&str>, json: &str, t: i64) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO ruleset (id, rev, json, updated_t) VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET rev = excluded.rev, json = excluded.json, updated_t = excluded.updated_t",
)
.bind(rev)
.bind(json)
.bind(t)
.execute(&self.pool)
.await?;
Ok(())
}
/// The stored ruleset, or `None` if the shard has never published one. Returning `None` rather
/// than an empty object is deliberate: "not published yet" and "published, everything off" are
/// different answers and the website renders them differently.
pub async fn ruleset(&self) -> anyhow::Result<Option<Value>> {
let row = sqlx::query("SELECT json FROM ruleset WHERE id = 1")
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|r| serde_json::from_str(&r.get::<String, _>("json")).ok()))
}
// ---- Town Cryer news (Protocol 2.1) ----
/// Stores/replaces one external news article (the `news.add` command json), keyed by id. The
@@ -392,4 +421,13 @@ CREATE TABLE IF NOT EXISTS news (
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
-- The shard's published ruleset (Protocol 3.0). Singleton: the CHECK is what makes it one,
-- so an upsert can target id = 1 unconditionally and no second row can ever appear.
CREATE TABLE IF NOT EXISTS ruleset (
id INTEGER PRIMARY KEY CHECK (id = 1),
rev TEXT,
json TEXT NOT NULL,
updated_t INTEGER NOT NULL
);
"#;