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):
- Players → MySQL via the
tmysqlbinary module, one column perply.ciderkey, with statements built by string concatenation and only two columns escaped (D-01). - Per-map plugin data (doors, spawn points, prison points) → flat text files encoded with
glon. - 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
IPersistenceBackendabstraction and ship two implementations from day one: a JSON backend overFileSystem.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
IPersistenceBackendmethod returns a bare value — all returnPersistenceResult, 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.