CultLib/src/GameCult.Caching/README.md
2026-06-13 01:44:05 +01:00

233 lines
5.9 KiB
Markdown

# GameCult.Caching
`GameCult.Caching` is an attribute-driven typed cache with a canonical
single-file MessagePack store. Domain models stay clean. The cache owns record
keys, schema ids, timestamps, and persistence metadata instead of smearing
that sludge across every class.
## Authoring Model
Cacheable models are plain classes annotated with cache intent.
```csharp
using GameCult.Caching;
using MessagePack;
[CultDocument("gamecult.item_data", "gamecult.item_data.v1")]
public sealed class ItemData
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultIndex]
public string Category = string.Empty;
[Key(2)]
public int Value;
}
```
Available cache attributes:
- `CultDocument(schemaName, schemaVersion)`
- `CultName`
- `CultIndex(alias?)`
- `CultGlobal`
- `CultReference(targetType?, many: false)`
If you want compile-time document metadata instead of runtime assembly
spelunking, add the `GameCult.Caching.MessagePack.Generator` analyzer to the
project that declares your `[CultDocument]` types. It emits a metadata provider
the cache registry can load directly, while reflection stays behind as fallback
for projects that have not taken the hint yet.
## Main Concepts
### Record keys and handles
`CultCache` gives each stored document a cache-owned `CultRecordKey`. You work
with typed handles instead of forcing storage identity into the domain object.
```csharp
var cache = new CultCache();
var handle = await cache.AddAsync(new ItemData
{
Name = "Potion",
Category = "Consumable",
Value = 50
});
var potion = cache.Get<ItemData>(handle.Key);
```
### Name and index lookups
Name and index lookups are driven from attributes, not manual registration.
```csharp
var byName = cache.GetByName<ItemData>("Potion");
var byIndex = cache.GetByIndex<ItemData>("Category", "Consumable");
```
### Explicit references
References stay explicit in the domain. The cache will resolve them on demand,
but it will not quietly build an entire haunted object graph behind your back.
```csharp
[CultDocument("gamecult.quest_data", "gamecult.quest_data.v1")]
public sealed class QuestData
{
[Key(0)] public string Title = string.Empty;
[Key(1)] [CultReference] public CultRecordRef<ItemData> Reward;
}
```
## Persistence Shape
CultCache persistence is store-shaped, not entry-shaped:
1. store header
2. embedded schema catalog
3. records
Each persisted record carries:
- `key`
- `schemaId`
- `storedAt`
- `payload`
The domain payload stays free of storage metadata.
## Backing Store
CultCache now has one sanctioned persistence format:
- `SingleFileMessagePackBackingStore`
It persists the whole store as:
1. store format version
2. embedded schema catalog
3. persisted records
Each persisted record carries:
- `key`
- `schemaId`
- `storedAt`
- `payload`
## Happy Path
```csharp
using GameCult.Caching;
using GameCult.Caching.MessagePack;
var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack");
var handle = await cache.UpsertAsync(new ItemData
{
Name = "Potion",
Category = "Consumable",
Value = 50
});
await cache.FlushAsync();
```
If you want the explicit assembly rite, the lower-level `CultCache` +
`SingleFileMessagePackBackingStore` path is still there. The helper is just the
blessed front door.
## Managed Documents And SoA Storage
Gameplay code keeps authoring normal typed documents. CultCache owns the record
identity, reactive presentation, and CPU-local structure-of-arrays table behind
that POCO surface.
```csharp
[CultDocument("game.player_transform", "game.player_transform.v1")]
public sealed class PlayerTransform
{
[CultName] public string Name = string.Empty;
public float PositionX;
public float PositionY;
public int Health;
}
var player = cache.Document<PlayerTransform>(new CultRecordKey("player:alice"));
await player.ReplaceAsync(new PlayerTransform
{
Name = "Alice",
PositionX = 4,
PositionY = 8,
Health = 100
});
using var subscription = player.Watch().Subscribe(current => Render(current));
player.Value!.Health = 90;
await player.CommitAsync();
var table = cache.Soa<PlayerTransform>();
Span<float> x = table.Column<float>(nameof(PlayerTransform.PositionX)).Span;
Span<float> y = table.Column<float>(nameof(PlayerTransform.PositionY)).Span;
Span<int> health = table.Column<int>(nameof(PlayerTransform.Health)).Span;
for (var row = 0; row < table.Count; row++)
{
x[row] += 0.016f;
}
```
`Document<T>()` is the standard presentation. `Soa<T>()` exposes the
cache-owned physical table for hot loops and diagnostics; it is not a second
domain model callers have to maintain.
## Persistence discipline
Persistence stays manual by default. Mutations mark the cache and attached
backing stores dirty; nothing silently flushes every object write just because
that felt convenient in the moment.
Useful surfaces:
- `CultCacheMessagePack.OpenAsync(path)`
- `cache.IsDirty`
- `store.IsDirty`
- `cache.Document<T>(key)`
- `cache.Soa<T>()`
- `cache.UpsertAsync(document)`
- `cache.FlushAllBackingStores()`
- `cache.FlushAsync()`
- `cache.FlushBackingStore(store)`
- `cache.PrepareForReloadOrShutdown()`
- `cache.PrepareForReloadOrShutdownAsync()`
- `cache.FlushAttachedStoresOnDispose`
- `store.FlushOnDispose`
This is built for callers like Aquarium that want durable runtime state while
still choosing *when* to pay the write cost.
Single-file MessagePack remains a single-writer format. The atomic replace path
protects against partial writes, not competing writers trampling each other.
## Schema migration diagnostics
CultCache does not quietly fall back from exact `schemaId` resolution to
"close enough, probably." When a persisted schema drifts, the cache now emits a
typed migration report describing:
- exact vs compatible-drift resolution
- ignored extra slots
- defaulted missing slots
- warning codes/messages
Compatibility rules and canonical fixture receipts live in
`Contracts/cultcache-schema-compatibility.md`.