docs · authoring
Persistence.
Every Rune juggles three kinds of state. In-memory caches die on reload. rune.store() is durable JSON that the host owns. Anything bigger — relational data, large blobs, anything you'd want to query — should live in an external store. The trick is knowing which is which.
In-memory: free, but ephemeral
Plain module-level variables and instance fields are the cheapest possible state. They cost nothing to read or write, and they live exactly as long as the current script evaluation. /rune reload wipes them. Use them for caches you can rebuild from a durable source.
// ward primes its group cache from Mongo on every load.
let groupCache: GroupDoc[] = [];
export async function refreshGroups() {
groupCache = await listGroups();
}
export function cachedGroupNames(): string[] {
return groupCache.map((g) => g.name);
}The cache is in-memory, but ward refreshes it from Mongo at every script load, and after every mutation. Reload wipes the cache; the prime call rebuilds it. The user never sees the gap.
rune.store: typed, durable JSON
rune.store() returns a typed wrapper around a JSON file the host owns. The file lives at plugins/Rune/store/<script-name>.json. Reads are synchronous; writes flush asynchronously but durably. Use it for small, mostly-read state that you don't want to stand up a database for.
interface ServerState {
motd: string;
greetedAt: Record<string, number>; // uuid → epoch ms
}
const state = rune.store<ServerState>("server-state", {
motd: "welcome to the server.",
greetedAt: {},
});
@Listener
export class GreetOnce {
@EventHandler(Events.PlayerJoinEvent)
onJoin(e: PlayerJoinEvent) {
const player = e.getPlayer();
const uuid = String(player.getUniqueId());
const already = state.get().greetedAt[uuid];
if (already) return;
player.sendMessage(state.get().motd);
state.update((s) => {
s.greetedAt[uuid] = Date.now();
});
}
}state.get() returns the current value (a frozen object). state.update(fn) hands you a draft, lets you mutate it, and persists the result. The host serializes concurrent updates and flushes them in order.
Sizing rule of thumb
rune.storeis sized for "config plus a handful of records per player." If the file would exceed a few megabytes, or if you need range queries or secondary indexes, graduate to a real database. ward keeps transient promotion state in the store and offloads everything else to Mongo.
External: full Node ecosystem
Because Rune embeds Node, you have npm. MongoDB is the well-trodden path — ward uses Mongoose with decorator-defined schemas — but anything you can talk to from Node is fair game: Postgres via pg, SQLite via better-sqlite3, Redis, S3-compatible blob storage, an external HTTP API.
Singleton connection on script load
Reload re-evaluates your script. Don't open a new connection every time; gate the connect on a promise that's re-used across calls.
import mongoose from "mongoose";
let connectionPromise: Promise<typeof mongoose> | null = null;
export function ensureDBConnection() {
if (!connectionPromise) {
connectionPromise = mongoose
.connect(env.DATABASE_URI)
.catch((err) => {
connectionPromise = null; // allow retry on next call
throw err;
});
}
return connectionPromise;
}Call await ensureDBConnection() from your top-level script load. After reload, the connection promise re-resolves (the underlying socket is unaffected) and your handlers can hit the DB immediately.
Schema as a decorator
ward defines its Mongoose models with a small in-house ODM decorator layer. The pattern keeps domain types and persistence schemas co-located.
@model("Group")
export class Group {
@unique()
@field({ type: String, required: true, lowercase: true, trim: true })
name!: string;
@field({ type: String, default: "" })
displayName!: string;
@field({ type: Number, default: 0, index: true })
weight!: number;
@field({ type: Boolean, default: false, index: true })
isDefault!: boolean;
@field({ type: [NodeSchema], default: () => [] })
nodes!: Node[];
}
export function findGroup(name: string): Promise<GroupDoc | null> {
return GroupModel.findOne({ name: name.toLowerCase() }).exec();
}
export function listGroups(): Promise<GroupDoc[]> {
return GroupModel.find().sort({ weight: -1, name: 1 }).exec();
}Picking a layer
- Reload-rebuildable, hot-path read? In-memory cache, primed from a durable source at script load.
- Small, structured config or per-player flags?
rune.store(). - Anything you'd want to query, paginate, or aggregate? A real database.