Persistence¶
[KEY] — how anything is saved, and how it survives a schema change. The decision to build one abstraction with two implementations from day one is ADR-0003; this document is the contract, the migration mechanics, and the two backends themselves.
1. The abstraction¶
namespace Applejack.Core.Persistence;
/// <summary>
/// Stores and retrieves versioned documents. Implementations may fail -- callers must handle
/// <see cref="PersistenceResult.Failed"/> rather than assuming success. See
/// Documentation/Architecture/06_PERSISTENCE.md.
/// </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);
}
/// <summary>
/// Identifies a document. <c>Collection</c> is a namespace ("characters", "doors"),
/// <c>Id</c> is unique within it. Both are validated: no path separators, no empty segments
/// -- a key is a logical name, never a filesystem or URL path constructed by hand.
/// </summary>
public readonly record struct DocumentKey(string Collection, string Id);
PersistenceResult / PersistenceResult<T> are discriminated success/failure result types —
see 13_ERROR_HANDLING.md for the general pattern. No method on this
interface returns a bare value or throws for an expected failure (file not found, network
timeout); every caller is forced to look at the result.
2. The envelope¶
Every saved document is wrapped, never saved as a bare payload:
public sealed class SaveDocument<T>
{
/// <summary>Schema version of <see cref="Payload"/>. Drives the migration chain, §4.</summary>
public int SchemaVersion { get; set; }
public DateTimeOffset SavedAt { get; set; }
public T Payload { get; set; } = default!;
}
Only get/set properties serialize. This is an engine-wide rule
(Documentation/Architecture/00_OVERVIEW.md §4), not a persistence-specific one, but it bears
repeating here because a payload type with a public field instead of a property fails
silently — the field is skipped, not rejected — unless it is caught by a test. Every
persisted type gets a round-trip test for exactly this reason (§6).
3. Two backends, one interface¶
graph LR
G[Gameplay code] --> C[Write-through cache]
C --> I[IPersistenceBackend]
I --> J[JsonPersistenceBackend]
I --> H[HttpPersistenceBackend]
J --> FS["FileSystem.Data"]
H --> API["Operator's HTTP service"]
JSON backend¶
Local, zero-infrastructure, the default for a single-operator server.
public sealed class JsonPersistenceBackend : IPersistenceBackend
{
private readonly ISchemaRegistry _schemas;
// ISchemaRegistry is injected, not a static SchemaVersions.Current<T>() lookup -- a
// static holding per-type registration state would be static mutable state, which
// Standards/00_CODING_STANDARDS.md §2 forbids outright. See §4 below.
public JsonPersistenceBackend(ISchemaRegistry schemas) => _schemas = schemas;
public async Task<PersistenceResult<T>> LoadAsync<T>(DocumentKey key, CancellationToken ct = default)
{
var path = PathFor(key);
if (!FileSystem.Data.FileExists(path))
return PersistenceResult<T>.NotFound(key);
try
{
var envelope = FileSystem.Data.ReadJson<SaveDocument<T>>(path);
var payload = _schemas.Migrate(envelope.SchemaVersion, envelope.Payload);
return PersistenceResult<T>.Success(payload);
}
catch (JsonException ex)
{
// A corrupt file is not an empty one -- surfaced distinctly so an operator
// can tell "never saved" from "something is wrong" in their logs.
return PersistenceResult<T>.Corrupt(key, ex.Message);
}
}
public Task<PersistenceResult> SaveAsync<T>(DocumentKey key, T document, CancellationToken ct = default)
{
var envelope = new SaveDocument<T> { SchemaVersion = _schemas.CurrentVersion<T>(), SavedAt = DateTimeOffset.UtcNow, Payload = document };
FileSystem.Data.WriteJson(PathFor(key), envelope);
return Task.FromResult(PersistenceResult.Success());
}
private static string PathFor(DocumentKey key) => $"{key.Collection}/{key.Id}.json";
}
Built on FileSystem.Data.WriteJson / ReadJson
— the sandbox's only supported local store; there is no direct database driver
(ADR-0003). Despite being local I/O, the
interface methods stay async: consistency with the HTTP backend matters more here than the
(negligible) overhead of an unnecessary Task.FromResult, because a caller that special-cased
"the JSON backend is actually synchronous" would break the moment an operator switched
backends.
HTTP backend¶
For multi-server communities sharing character data.
public sealed class HttpPersistenceBackend : IPersistenceBackend
{
private readonly ISchemaRegistry _schemas;
private readonly string _baseUrl;
public HttpPersistenceBackend(ISchemaRegistry schemas, string baseUrl)
{
_schemas = schemas;
_baseUrl = baseUrl;
}
public async Task<PersistenceResult<T>> LoadAsync<T>(DocumentKey key, CancellationToken ct = default)
{
try
{
var response = await Http.RequestAsync(
$"{_baseUrl}/documents/{key.Collection}/{key.Id}", "GET", ct: ct);
if (response.StatusCode == HttpStatusCode.NotFound) return PersistenceResult<T>.NotFound(key);
if (!response.IsSuccessStatusCode) return PersistenceResult<T>.TransportFailure(key, $"HTTP {(int)response.StatusCode}");
var envelope = await response.Content.ReadFromJsonAsync<SaveDocument<T>>(cancellationToken: ct);
var payload = _schemas.Migrate(envelope!.SchemaVersion, envelope.Payload);
return PersistenceResult<T>.Success(payload);
}
catch (TaskCanceledException)
{
return PersistenceResult<T>.Timeout(key);
}
}
// SaveAsync mirrors this with a PUT and Http.CreateJsonContent(envelope); DeleteAsync -> DELETE.
}
Built on Http.RequestAsync, which
permits only http/https domains — no bare IP addresses, and localhost only on ports
80/443/8080/8443. The server side of this contract is out of scope for this project —
ADR-0003 states this plainly. This
document specifies the REST contract; the operator deploys or writes the service that answers
it.
REST contract:
| Method | Path | Body | Response |
|---|---|---|---|
GET |
/documents/{collection}/{id} |
— | 200 + SaveDocument<T> JSON, or 404 |
PUT |
/documents/{collection}/{id} |
SaveDocument<T> JSON |
200/201 |
DELETE |
/documents/{collection}/{id} |
— | 204/404 |
GET |
/documents/{collection} |
— | 200 + JSON array of ids in that collection |
Authentication and rate limiting are the operator's deployment concern and are documented in Guides/00_GETTING_STARTED.md, not specified here.
4. Migration¶
namespace Applejack.Core.Persistence;
/// <summary>
/// Upgrades a payload from one schema version to the next. Chained: version 1 -> 2 -> 3,
/// never a direct 1 -> 3 migration, so each step stays small and independently testable.
/// </summary>
public interface ISchemaMigration<T>
{
int FromVersion { get; }
T Migrate(T previous);
}
/// <summary>
/// Per-type current schema version and migration chain, registered once by the owning
/// module -- typically from its RegisterServices, alongside the type's own store. Not a
/// static lookup: a static holding per-type registration state would be static mutable
/// state, forbidden by Standards/00_CODING_STANDARDS.md §2, so this is a registered
/// service like any other.
/// </summary>
public interface ISchemaRegistry
{
/// <summary>
/// Declares <paramref name="currentVersion"/> as current for <typeparamref name="T"/>,
/// with the migrations needed to reach it from any earlier version.
/// </summary>
void Register<T>(int currentVersion, params ISchemaMigration<T>[] migrations);
/// <summary>The current schema version for <typeparamref name="T"/>.</summary>
int CurrentVersion<T>();
/// <summary>
/// Applies every migration needed to bring <paramref name="payload"/> from
/// <paramref name="fromVersion"/> up to the current version. A no-op if
/// <paramref name="fromVersion"/> already equals the current version.
/// </summary>
T Migrate<T>(int fromVersion, T payload);
}
ISchemaRegistry.Migrate walks the chain from a document's saved SchemaVersion to the
type's current version, applying each registered ISchemaMigration<T> in order. A document
at a version with no forward path is a named load failure — not silently loaded as-is,
which would let a stale field shape reach gameplay code that assumes the current one.
Every schema version bump requires, per Standards/02_DOCUMENTATION_STANDARDS.md §8: a migration, a migration test from the previous version, and an update to this document's change log for the affected type (kept per-module in that module's README, not centrally — see Standards/02_DOCUMENTATION_STANDARDS.md §2).
5. The write-through cache¶
Persistence is async and can fail; gameplay code frequently wants a value now
(Standards/00_CODING_STANDARDS.md §5 forbids
.Result/.Wait() to fake synchronicity). The resolution is a cache owned by each module's
store, not by gameplay code directly:
sequenceDiagram
participant G as Gameplay code
participant S as CharacterStore
participant B as IPersistenceBackend
G->>S: GetCharacter(id)
S-->>G: from memory, synchronously
G->>S: Save(character)
S->>S: update memory immediately
S-)B: SaveAsync (fire-and-forget, logged on failure)
Reads are synchronous against memory; writes update memory immediately and persist behind it. A failed background save is logged loudly (§ 12_LOGGING_AND_DIAGNOSTICS.md) and retried with backoff — an operator needs to know their disk or remote service is failing, not have it silently swallowed.
6. Conformance testing¶
Because both backends implement the same interface, one test suite runs against both, parameterised over the implementation:
- Round-trip: save, load, assert equality.
- Missing key returns
NotFound, not an exception. - A corrupted/malformed stored document returns
Corrupt, not a crash. - Concurrent writes to the same key do not corrupt the document (last-write-wins is acceptable; a torn write is not).
- A large document (representative of a character with a full inventory) round-trips.
- Every registered migration is exercised: a fixture at each historical schema version loads and produces the current shape.
This is the direct answer to D-15 — three backends, never tested identically, is how the original ended up with three different failure modes.
7. What goes through persistence¶
Everything persisted, without exception, per
Standards/00_CODING_STANDARDS.md §10: no
hand-built query strings, and — reviewed specifically — no gameplay code calls a backend
directly. A module owns a store (CharacterStore, DoorStore, …) that wraps
IPersistenceBackend for its own document shapes; gameplay code calls the store, never the
backend.