rune

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.