diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md
index 46b1e77b..97fa1b48 100644
--- a/docs/runtime-parity-scope.md
+++ b/docs/runtime-parity-scope.md
@@ -15,6 +15,9 @@ Cross-runtime parity has three layers:
without translation folklore.
Every store reader refuses, by name, a header or record slot it does not
understand (vectors: `tests/vectors/document-variants-c0`).
+ Document variants: C# resolves them (`cultcache.store.v2`); every other runtime,
+ CultMesh single-file reads, the directory store and CultNet refuse a variant by name
+ (vector written by C#: `tests/vectors/document-variants-c1`).
- Feature parity: each runtime must expose the CultCache/CultNet/CultMesh
surfaces needed for its intended role, with unsupported production-server
ownership called out plainly.
diff --git a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs
index e1a68c12..4fa55076 100644
--- a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs
+++ b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs
@@ -19,13 +19,19 @@ namespace GameCult.Caching.MessagePack
public static class CultCacheMessagePack
{
- // The inspection model over this codec. Values serialize without the document guard but with their owning document's
- // options, resolvers included, so dictionary keys compare by the bytes the store writes; clones deserialize as
- // registered documents.
- public static CultInspectorModel CreateInspectorModel(CultDocumentRegistry registry) => new CultInspectorModel(
- registry,
+ // The store's encoding as one port. Values serialize without the document guard but with their owning document's
+ // options, resolvers included, so dictionary keys compare by the bytes the store writes; a document type
+ // deserializes as a registered document, a member type as itself.
+ public static CultCodec CreateCodec(CultDocumentRegistry registry) => new CultCodec(
(value, type, document) => global::MessagePack.MessagePackSerializer.Serialize(type, value, CultDocumentMessagePackSerialization.OptionsFor(document.Assembly)),
- (type, bytes) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, registry));
+ (type, document, bytes) => type == document
+ ? CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, registry)
+ : global::MessagePack.MessagePackSerializer.Deserialize(type, bytes, CultDocumentMessagePackSerialization.OptionsFor(document.Assembly)),
+ CultDocumentMessagePackSerialization.OverlaySlots);
+
+ // The inspection model over that codec.
+ public static CultInspectorModel CreateInspectorModel(CultDocumentRegistry registry) =>
+ new CultInspectorModel(registry, CreateCodec(registry));
// Opening hydrates. A consumer that wants a fresh store removes the records it does not keep, upserts, and
// flushes; the atomic replace does the rest.
@@ -37,7 +43,8 @@ namespace GameCult.Caching.MessagePack
}
options ??= new CultCacheOpenOptions();
- var cache = new CultCache(options.Registry)
+ var registry = options.Registry ?? CultDocumentRegistry.Shared;
+ var cache = new CultCache(registry, CreateCodec(registry))
{
FlushAttachedStoresOnDispose = options.FlushOnDispose
};
diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
index bb62d2ed..ef1b4092 100644
--- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
+++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
@@ -1,6 +1,7 @@
using System;
using System.Buffers;
using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using GameCult.Caching;
@@ -30,8 +31,8 @@ public sealed class CultDocumentResolver : IFormatterResolver
public static class CultDocumentMessagePackSerialization
{
- private const string SingleFileFormatVersion = "cultcache.store.v1";
private const int PersistedRecordFieldCount = 4;
+ private const int VariantRecordFieldCount = 5;
private const int SchemaCatalogEntryFieldCount = 7;
private const int SchemaCatalogMemberFieldCount = 8;
private const int StoreSnapshotFieldCount = 3;
@@ -95,6 +96,49 @@ public static class CultDocumentMessagePackSerialization
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
}
+ ///
+ /// Replaces slots of a serialized document payload (a MessagePack array indexed by slot) with already-encoded member
+ /// values, leaving every other slot byte for byte as it was. A slot past the end extends the array with nil.
+ ///
+ public static byte[] OverlaySlots(byte[] payload, IReadOnlyList> slots)
+ {
+ var reader = new MessagePackReader(payload);
+ if (reader.NextMessagePackType != MessagePackType.Array)
+ {
+ throw new InvalidOperationException("A document payload is a MessagePack array; this one is not, so its slots cannot be overridden.");
+ }
+
+ var count = reader.ReadArrayHeader();
+ var elements = new List(count);
+ for (var index = 0; index < count; index++)
+ {
+ var start = reader.Position;
+ reader.Skip();
+ elements.Add(reader.Sequence.Slice(start, reader.Position).ToArray());
+ }
+
+ foreach (var (slot, value) in slots)
+ {
+ while (elements.Count <= slot)
+ {
+ elements.Add(new byte[] { 0xc0 });
+ }
+
+ elements[slot] = value;
+ }
+
+ var buffer = new ArrayBufferWriter();
+ var writer = new MessagePackWriter(buffer);
+ writer.WriteArrayHeader(elements.Count);
+ foreach (var element in elements)
+ {
+ writer.WriteRaw(element);
+ }
+
+ writer.Flush();
+ return buffer.WrittenSpan.ToArray();
+ }
+
public static byte[] SerializePersistedRecord(CultPersistedRecord record)
{
var buffer = new global::System.Buffers.ArrayBufferWriter();
@@ -141,7 +185,7 @@ public static class CultDocumentMessagePackSerialization
if (fieldCount > 0)
{
snapshot.FormatVersion = reader.ReadString()
- ?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + SingleFileFormatVersion + " only.");
+ ?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + CultPersistedStoreSnapshot.FormatV1 + " and " + CultPersistedStoreSnapshot.FormatV2 + ".");
}
if (fieldCount > 1)
@@ -172,24 +216,110 @@ public static class CultDocumentMessagePackSerialization
return snapshot;
}
- /// Refuses a single-file snapshot this runtime cannot read, naming the format it found.
+ /// Refuses a single-file snapshot this runtime cannot read, naming the format or record it found.
public static void RequireSingleFileFormat(CultPersistedStoreSnapshot snapshot)
{
- if (!string.Equals(snapshot.FormatVersion, SingleFileFormatVersion, StringComparison.Ordinal))
+ var version = snapshot.FormatVersion;
+ if (!string.Equals(version, CultPersistedStoreSnapshot.FormatV1, StringComparison.Ordinal) &&
+ !string.Equals(version, CultPersistedStoreSnapshot.FormatV2, StringComparison.Ordinal))
{
throw new NotSupportedException(
- $"Store format {snapshot.FormatVersion} is not readable; this runtime reads {SingleFileFormatVersion} only. " +
- "The store needs a runtime that resolves document variants.");
+ $"Store format {version} is not readable; this runtime reads {CultPersistedStoreSnapshot.FormatV1} and {CultPersistedStoreSnapshot.FormatV2}.");
+ }
+
+ // Only a v2 store may hold a variant: a v1 header over a variant record is a store no reader could trust.
+ if (string.Equals(version, CultPersistedStoreSnapshot.FormatV1, StringComparison.Ordinal))
+ {
+ var variant = snapshot.Records.FirstOrDefault(record => record.Variant != null);
+ if (variant != null)
+ {
+ throw new NotSupportedException(
+ $"Record '{variant.Key}' (schema '{variant.SchemaId}') is a variant but the store declares {version}; variants need {CultPersistedStoreSnapshot.FormatV2}.");
+ }
}
}
private static void WritePersistedRecord(ref MessagePackWriter writer, CultPersistedRecord record)
{
- writer.WriteArrayHeader(PersistedRecordFieldCount);
+ writer.WriteArrayHeader(record.Variant == null ? PersistedRecordFieldCount : VariantRecordFieldCount);
writer.Write(record.Key);
writer.Write(record.SchemaId);
writer.Write(record.StoredAt);
writer.Write(record.Payload);
+ if (record.Variant != null)
+ {
+ // Slot 4: [baseKey, overrides[]]; an override is [op, path[], id, value], a path step [slot, elementId],
+ // and the value is the member's own MessagePack encoding, inline.
+ writer.WriteArrayHeader(2);
+ writer.Write(record.Variant.BaseKey);
+ writer.WriteArrayHeader(record.Variant.Overrides.Count);
+ foreach (var entry in record.Variant.Overrides)
+ {
+ writer.WriteArrayHeader(4);
+ writer.Write((int)entry.Op);
+ writer.WriteArrayHeader(entry.Path.Count);
+ foreach (var step in entry.Path)
+ {
+ writer.WriteArrayHeader(2);
+ writer.Write(step.Slot);
+ writer.Write(step.ElementId);
+ }
+
+ writer.Write(entry.Id);
+ writer.WriteRaw(entry.Value);
+ }
+ }
+ }
+
+ private static CultVariantDelta ReadVariant(ref MessagePackReader reader, string key, string schemaId)
+ {
+ var slots = reader.ReadArrayHeader();
+ if (slots != 2)
+ {
+ throw new NotSupportedException(
+ $"Record '{key}' (schema '{schemaId}') has a variant slot of {slots} entries; this runtime reads 2 (base key, overrides).");
+ }
+
+ var baseKey = reader.ReadString() ?? string.Empty;
+ var count = reader.ReadArrayHeader();
+ var overrides = new CultVariantOverride[count];
+ for (var index = 0; index < count; index++)
+ {
+ var fields = reader.ReadArrayHeader();
+ if (fields != 4)
+ {
+ throw new NotSupportedException(
+ $"Record '{key}' (schema '{schemaId}') has an override of {fields} entries; this runtime reads 4 (op, path, id, value).");
+ }
+
+ var op = reader.ReadInt32();
+ if (op is < 0 or > 2)
+ {
+ throw new NotSupportedException($"Record '{key}' (schema '{schemaId}') has an override with unknown op {op}.");
+ }
+
+ var stepCount = reader.ReadArrayHeader();
+ var path = new CultPathStep[stepCount];
+ for (var step = 0; step < stepCount; step++)
+ {
+ var stepFields = reader.ReadArrayHeader();
+ if (stepFields != 2)
+ {
+ throw new NotSupportedException(
+ $"Record '{key}' (schema '{schemaId}') has a path step of {stepFields} entries; this runtime reads 2 (slot, elementId).");
+ }
+
+ path[step] = new CultPathStep(reader.ReadInt32(), reader.ReadString() ?? string.Empty);
+ }
+
+ var id = reader.ReadString() ?? string.Empty;
+ var start = reader.Position;
+ reader.Skip();
+ var value = reader.Sequence.Slice(start, reader.Position).ToArray();
+ overrides[index] = new CultVariantOverride((CultOverrideOp)op, path, id, value);
+ }
+
+ return new CultVariantDelta(baseKey, overrides);
}
public static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader)
@@ -217,11 +347,21 @@ public static class CultDocumentMessagePackSerialization
record.Payload = reader.ReadBytes()?.ToArray() ?? Array.Empty();
}
- if (fieldCount > PersistedRecordFieldCount)
+ if (fieldCount > VariantRecordFieldCount)
{
throw new NotSupportedException(
- $"Record '{record.Key}' (schema '{record.SchemaId}') has {fieldCount} slots; this runtime reads {PersistedRecordFieldCount}. " +
- "The store needs a runtime that resolves document variants.");
+ $"Record '{record.Key}' (schema '{record.SchemaId}') has {fieldCount} slots; this runtime reads {PersistedRecordFieldCount} " +
+ $"(or {VariantRecordFieldCount} for a variant).");
+ }
+
+ if (fieldCount == VariantRecordFieldCount)
+ {
+ record.Variant = ReadVariant(ref reader, record.Key, record.SchemaId);
+ if (record.Payload.Length != 0)
+ {
+ throw new NotSupportedException(
+ $"Record '{record.Key}' (schema '{record.SchemaId}') is a variant and must carry an empty payload.");
+ }
}
return record;
diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
index 9e185202..fa9d4899 100644
--- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
+++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
@@ -135,9 +135,18 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
Trace("publish");
}
+ // Variants wait for the directory store's own cut; until then it refuses one rather than persist a resolved view as a plain page.
+ private static void RefuseVariant(CultStoredDocument entry)
+ {
+ if (entry.Variant != null)
+ throw new NotSupportedException(
+ $"Record '{entry.Key.Value}' is a variant of '{entry.Variant.BaseKey}'; the directory store does not hold document variants yet.");
+ }
+
public override void Push(CultStoredDocument entry)
{
ThrowIfReadOnly();
+ RefuseVariant(entry);
Held(() =>
{
Entries[entry.Key.Value] = entry;
@@ -162,6 +171,8 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait)
{
ThrowIfReadOnly();
+ foreach (var entry in request.Upserts)
+ RefuseVariant(entry);
return Held(() =>
{
Directory.CreateDirectory(_manifestFile.DirectoryName!);
diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
index 4511f3c5..1205d904 100644
--- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
+++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
@@ -213,9 +213,39 @@ The payload is the domain object only. It does not repeat the key, schema id,
or stored timestamp.
Under `cultcache.store.v1` a record has exactly these four slots. A reader refuses any
-other `cultcache.store.*` header and any record with more than four slots, naming the
-version or the record's key and schema id. It never skips a slot it does not
-understand.
+`cultcache.store.*` header it does not know and any record with more slots than its
+header allows, naming the version or the record's key and schema id. It never skips a
+slot it does not understand.
+
+## Variant Records
+
+A variant record is a record whose document is a base record's document with some
+members replaced. It stores the difference, never the resolved document. It carries a
+fifth slot:
+
+```
+[key, schemaId, storedAt, payload, [baseKey, overrides[]]]
+```
+
+- `payload` is empty. An older reader that ignores the fifth slot reads an empty
+ payload as a corrupt document and refuses, which is why the delta lives outside it.
+- `schemaId` is the type's schema id, the same as the base's. A variant has its base's
+ concrete type, and lives in the same store (one home store per type).
+- An override is `[op, path[], id, value]`. `op` is `0` Set, `1` Insert or `2` Remove.
+ A path step is `[slot, elementId]`: `slot` is the member's MessagePack `[Key]` and
+ `elementId` is `""` unless the step enters an object-list element. `value` is the
+ member's own MessagePack encoding, inline. A top-level Set has one step and `id` `""`;
+ a reader that resolves only that shape refuses the others, naming the variant.
+- A store holding at least one variant declares `cultcache.store.v2`; a store holding
+ none declares `cultcache.store.v1` and is byte-identical to what a runtime without
+ variants writes. A `v1` header over a variant record is refused.
+- A variant's resolved document is derived in memory when the cache admits the record
+ or any record on its base chain, and is never written back. A plain write at a
+ variant's key is refused; `Flatten` replaces a variant with a plain record holding its
+ resolved document.
+- The C# runtime resolves variants. Every other runtime refuses a `v2` store by name.
+ CultMesh single-file reads, the directory store and CultNet refuse a variant record by
+ name until each has its own cut.
## Canonical Semantic Schema Hash
diff --git a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md
index 297c3184..818840ee 100644
--- a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md
+++ b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md
@@ -105,3 +105,10 @@ lie about the payload:
- no compatible local schema candidate
This is not negotiable. Better a loud refusal than quiet bit-rot.
+
+## Variant overrides
+
+A variant's override drifts exactly as the same member would on a plain record: an
+override naming a slot the type no longer has, or holding a value that no longer decodes
+into the member's type, is ignored with a warning naming the variant and the member, and
+the variant inherits the base value. There is no separate rule for variants.
diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs
index 2f8e316a..24c20cfc 100644
--- a/src/GameCult.Caching/CultCache.cs
+++ b/src/GameCult.Caching/CultCache.cs
@@ -55,11 +55,17 @@ namespace GameCult.Caching
public string SchemaId { get; set; } = string.Empty;
public string StoredAt { get; set; } = string.Empty;
public byte[] Payload { get; set; } = Array.Empty();
+
+ // Non-null for a variant record (slot 4): its base and overrides. Its payload is empty.
+ public CultVariantDelta? Variant { get; set; }
}
public sealed class CultPersistedStoreSnapshot
{
- public string FormatVersion { get; set; } = "cultcache.store.v1";
+ public const string FormatV1 = "cultcache.store.v1";
+ public const string FormatV2 = "cultcache.store.v2";
+
+ public string FormatVersion { get; set; } = FormatV1;
public CultSchemaCatalogEntry[] SchemaCatalog { get; set; } = Array.Empty();
public CultPersistedRecord[] Records { get; set; } = Array.Empty();
}
@@ -336,13 +342,43 @@ namespace GameCult.Caching
Key = key;
StoredAt = storedAt;
Descriptor = descriptor;
- Document = document;
+ _document = document;
}
+ // A variant: the delta is what is stored; the cache resolves it against its base in the hold that admits it.
+ internal CultStoredDocument(
+ CultRecordKey key,
+ string storedAt,
+ CultDocumentDescriptor descriptor,
+ CultVariantDelta variant,
+ object? resolved)
+ {
+ Key = key;
+ StoredAt = storedAt;
+ Descriptor = descriptor;
+ Variant = variant;
+ _document = resolved;
+ }
+
+ private readonly object? _document;
+
public CultRecordKey Key { get; }
public string StoredAt { get; }
public CultDocumentDescriptor Descriptor { get; }
- public object Document { get; }
+
+ // The complete document. For a variant, the resolved view: derived, never written back.
+ public object Document => _document ??
+ throw new InvalidOperationException($"Variant {Key.Value} has not been resolved; only the cache resolves a variant.");
+
+ // The base and overrides a variant record stores; null for a plain record.
+ public CultVariantDelta? Variant { get; }
+
+ // A plain record staged by Flatten: the one way a plain record may replace a variant at its key.
+ internal bool Flattens { get; set; }
+
+ internal bool IsResolved => _document != null;
+
+ internal CultStoredDocument Resolved(object document) => new(Key, StoredAt, Descriptor, Variant!, document);
}
public sealed class CultDocumentRegistry
@@ -1662,11 +1698,29 @@ namespace GameCult.Caching
private static string Identity(string key, string schemaId, string storedAt) => $"{key}\n{schemaId}\n{storedAt}";
}
+ // One staged write: a plain record (Stored), a variant (Variant), a flatten, or, with none of those, a removal.
+ internal sealed class StagedOperation
+ {
+ public StagedOperation(CultRecordKey key, CultStoredDocument? stored, CultVariantDelta? variant, bool flatten)
+ {
+ Key = key;
+ Stored = stored;
+ Variant = variant;
+ Flatten = flatten;
+ }
+
+ public CultRecordKey Key { get; }
+ public CultStoredDocument? Stored { get; }
+ public CultVariantDelta? Variant { get; }
+ public bool Flatten { get; }
+ public bool IsRemoval => Stored == null && Variant == null && !Flatten;
+ }
+
// An explicit value: nothing staged here is visible to anyone until Commit returns.
public sealed class CultCacheBatch
{
private readonly CultCache _cache;
- internal readonly Dictionary Operations = new(StringComparer.Ordinal);
+ internal readonly Dictionary Operations = new(StringComparer.Ordinal);
internal readonly List<(CultRecordKey Key, string? SchemaId, string? StoredAt)> Expected = new();
internal bool ExpectsUnchanged;
internal bool Sealed;
@@ -1691,7 +1745,24 @@ namespace GameCult.Caching
public void Remove(CultRecordKey key)
{
ThrowIfSealed();
- Operations[key.Value] = (key, null);
+ Operations[key.Value] = new StagedOperation(key, null, null, false);
+ }
+
+ // Stages a variant record: its base and a sparse set of overrides, resolved by the cache when the batch commits.
+ // A variant changes only through here; rebasing is this call with another base.
+ public CultRecordKey UpsertVariant(CultRecordKey key, CultRecordKey baseKey, IEnumerable? overrides = null)
+ {
+ ThrowIfSealed();
+ Operations[key.Value] = new StagedOperation(
+ key, null, new CultVariantDelta(baseKey.Value, (overrides ?? Array.Empty()).ToArray()), false);
+ return key;
+ }
+
+ // Replaces a variant with a plain record holding its resolved document: the explicit, separate operation (Q1a).
+ public void Flatten(CultRecordKey key)
+ {
+ ThrowIfSealed();
+ Operations[key.Value] = new StagedOperation(key, null, null, true);
}
public void Expect(CultRecordKey key, object? current)
@@ -1711,7 +1782,7 @@ namespace GameCult.Caching
{
ThrowIfSealed();
var stored = _cache.CreateStoredDocument(document, key);
- Operations[stored.Key.Value] = (stored.Key, stored);
+ Operations[stored.Key.Value] = new StagedOperation(stored.Key, stored, null, false);
return stored.Key;
}
@@ -1730,6 +1801,11 @@ namespace GameCult.Caching
private readonly Dictionary> _names = new();
private readonly Dictionary<(Type Type, string Alias), Dictionary> _indexes = new();
private readonly Dictionary _globals = new();
+ private readonly CultCodec? _codec;
+ // Keys holding a variant, and the drift report of each variant whose overrides were partly ignored: both derived
+ // from the entries by Apply, never decided anywhere else.
+ private readonly HashSet _variantKeys = new(StringComparer.Ordinal);
+ private readonly Dictionary _variantReports = new(StringComparer.Ordinal);
private readonly ConditionalWeakTable