Skip to content

ADR-0003 — Dual persistence backend

Status: ✅ Accepted Date: 2026-07-27 Supersedes:Superseded by:


Context

The original uses three unrelated persistence mechanisms with no shared versioning, schema or migration story (D-15):

  1. Players → MySQL via the tmysql binary module, one column per ply.cider key, with statements built by string concatenation and only two columns escaped (D-01).
  2. Per-map plugin data (doors, spawn points, prison points) → flat text files encoded with glon.
  3. Prop protection → local SQLite.

There is no schema file, so the database shape is implicitly whatever keys happened to exist at runtime; unknown columns are type-sniffed on every login; and adding a field means altering the table by hand.

Two engine facts constrain the replacement. S&box addons are sandboxed: the supported local store is FileSystem.Data.WriteJson / ReadJson, and outbound network access is Http.RequestAsync. There is no direct database driver. Note also that S&box serialises properties with both a getter and a setter — public fields are skipped.

Two deployment shapes need supporting, and they have genuinely different requirements. A single server run by one person should work with zero infrastructure. A multi-server community — which the original had, evidenced by the crossserverchat plugin — needs shared character data, real queries and operational tooling.

Decision

We will define a single IPersistenceBackend abstraction and ship two implementations from day one: a JSON backend over FileSystem.Data, and an HTTP backend that talks to an external service the operator runs.

/// <summary>
/// Stores and retrieves versioned documents. Implementations may fail -- callers must
/// handle <see cref="PersistenceResult.Failed"/> rather than assuming success.
/// </summary>
public interface IPersistenceBackend
{
    Task<PersistenceResult<T>> LoadAsync<T>(DocumentKey key, CancellationToken ct = default);
    Task<PersistenceResult> SaveAsync<T>(DocumentKey key, T document, CancellationToken ct = default);
    Task<PersistenceResult> DeleteAsync(DocumentKey key, CancellationToken ct = default);
    Task<PersistenceResult<IReadOnlyList<DocumentKey>>> ListAsync(string prefix, CancellationToken ct = default);
}

Everything persisted goes through it, in a versioned envelope:

public sealed class SaveDocument<T>
{
    /// <summary>Schema version of <see cref="Payload"/>. Drives the migration chain.</summary>
    public int SchemaVersion { get; set; }
    public DateTimeOffset SavedAt { get; set; }
    public T Payload { get; set; }
}

Both backends ship together, deliberately. Building the interface against one implementation produces an interface shaped like that implementation. In particular, an interface designed only against local file I/O will be synchronous-in-spirit and will assume saving cannot fail — and both assumptions are wrong for the HTTP case and would have to be unpicked from every call site later. Two implementations from the start force the API to be honest: async, and failure-carrying.

Details, including the REST contract the external service must satisfy and the retry and offline-degradation policy, are in Architecture/06_PERSISTENCE.md.

Alternatives considered

JSON only, add HTTP later

Ship the local backend now; introduce the interface when a second implementation is needed.

The strongest alternative, and the usual right answer to "do we need an abstraction yet". Rejected for the specific reason above: this abstraction's shape — async everywhere, explicit failure results, batching, idempotency — is dictated almost entirely by the remote case. Retrofitting it means touching every call site in every module, at exactly the point where there is the most code to touch. The abstraction is cheap now and expensive later.

HTTP only, always

One code path, and the local case is served by running a small local service.

Rejected: it makes "run a server" require standing up a web service and a database, which excludes the single-operator case entirely and makes contributing to the project harder.

Direct database access

Rejected as impossible — the sandbox provides no database driver, by design.

A binary format

Faster and smaller. Rejected: JSON is inspectable, diffable, hand-repairable and trivially migratable, and save I/O is not on any hot path. When an operator's world is corrupt at 2am, being able to open the file matters more than the milliseconds.

Consequences

Positive

  • Zero-infrastructure operation for the common case.
  • Multi-server and cross-server characters are possible without redesign.
  • One versioning and migration story for all persistent state, replacing three.
  • No query strings anywhere, so D-01 cannot recur.
  • Saves are human-readable and repairable.
  • Backends are substitutable in tests, so persistence logic is unit-testable with an in-memory fake.

Negative

  • Two implementations to build, test and maintain, before either is strictly needed.
  • The HTTP backend's server side is out of scope for this project — we specify the REST contract; the operator implements or deploys it. That is a real gap in the deliverable and it must be stated plainly in the operator documentation.
  • Async persistence complicates gameplay code that wants a value now. Mitigated by a write-through cache: gameplay reads from memory, the backend is written behind it.
  • The HTTP backend can fail in ways the JSON one cannot — partial writes, timeouts, the service being down. Every caller must handle failure, which is more code but is the honest cost.

Neutral

  • JSON is the wire format for both backends, so a save can be moved between them.
  • Every persisted type must expose get/set properties, per the engine's serialiser.

Compliance

  • No IPersistenceBackend method returns a bare value — all return PersistenceResult, so failure cannot be ignored silently.
  • A conformance test suite runs against every backend: round-trip, missing key, corrupt payload, concurrent write, large document.
  • Every schema version has a migration test from the previous version.
  • Analyzer or review check: no gameplay code calls a backend directly; it goes through the owning module's store.

Notes

Revisit if the HTTP backend goes unused across two milestones — at that point it is speculative generality and should be moved out of the main tree, though the interface shape it forced would remain correct and should stay.