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 _handles = new(); private readonly Subject _changes = new(); private readonly object _gate = new(); @@ -1738,13 +1814,42 @@ namespace GameCult.Caching [ThreadStatic] private static List<(Change Change, bool Loaded)>? _held; private bool _dirtyInMemory; - public CultCache(CultDocumentRegistry? registry = null) + // The codec is the store's encoding (CultCacheMessagePack.Create supplies it). A cache without one holds no variants: + // resolving a variant needs to clone documents and decode override values. + public CultCache(CultDocumentRegistry? registry = null, CultCodec? codec = null) { _registry = registry ?? CultDocumentRegistry.Shared; + _codec = codec; } public CultDocumentRegistry Registry => _registry; + public CultCodec? Codec => _codec; + + // Overrides a variant carries that no longer resolve (a slot the type dropped, a value that no longer decodes), + // reported as compatible drift: the override is ignored and the variant inherits the base value. + public IReadOnlyList VariantMigrationReports + { + get + { + lock (_gate) + return _variantReports.Values.ToArray(); + } + } + + // The whole override of one member, encoded with this cache's codec, ready for UpsertVariant. + public CultVariantOverride Override(Type documentType, string member, object? value) + { + if (_codec == null) throw new InvalidOperationException("This cache has no codec; create it with CultCacheMessagePack.Create."); + var found = _registry.GetRequired(documentType).RichMembers.FirstOrDefault(entry => entry.Member.Name == member) + ?? throw new ArgumentException($"{documentType.Name} has no persisted member '{member}'.", nameof(member)); + return CultVariantOverride.Set( + found.Slot, + _codec.Serialize(value!, found.MemberType, documentType)); + } + + public CultVariantOverride Override(string member, object? value) where T : class => Override(typeof(T), member, value); + public bool IsDirty { get @@ -1841,7 +1946,7 @@ namespace GameCult.Caching store.AttachRegistry(_registry); store.Cache = this; - store.Loaded = (loaded, dropped) => Admit(loaded, dropped, store, _ => CultCommitOutcome.Committed); + store.Loaded = (loaded, dropped) => Admit(loaded, dropped, store, (_, _) => CultCommitOutcome.Committed); _stores.Add(candidate); try { @@ -1915,11 +2020,39 @@ namespace GameCult.Caching return Task.FromResult(Write(document, key)); } + // A single-call variant write; a batch stages the same thing with CultCacheBatch.UpsertVariant. + public Task UpsertVariantAsync(CultRecordKey key, CultRecordKey baseKey, IEnumerable? overrides = null) + { + var delta = new CultVariantDelta(baseKey.Value, (overrides ?? Array.Empty()).ToArray()); + return Task.FromResult(Held(() => + { + var stored = StampVariant(key, DescriptorOfBase(key, delta.BaseKey, null), delta); + Admit(new[] { stored }, Array.Empty(), null, (home, docs) => + { + home?.Push(docs[0]); + return CultCommitOutcome.Committed; + }); + return key; + })); + } + + // Replaces a variant with a plain record holding its resolved document (Q1a's explicit operation). + public Task FlattenAsync(CultRecordKey key) => Task.FromResult(Held(() => + { + var stored = StampFlatten(key); + Admit(new[] { stored }, Array.Empty(), null, (home, docs) => + { + home?.Push(docs[0]); + return CultCommitOutcome.Committed; + }); + return key; + })); + public bool Remove(CultRecordKey key) => Held(() => { if (!_entries.TryGetValue(key.Value, out var existing)) return false; - Admit(Array.Empty(), new[] { existing }, null, home => + Admit(Array.Empty(), new[] { existing }, null, (home, _) => { home?.Delete(existing); return CultCommitOutcome.Committed; @@ -1950,6 +2083,13 @@ namespace GameCult.Caching return _handles.TryGetValue(document, out var box) ? new CultRecordHandle(box.Key) : null; } + // The stored form of a record: for a variant, the delta beside the resolved document. + public CultStoredDocument? GetStored(CultRecordKey key) + { + lock (_gate) + return _entries.TryGetValue(key.Value, out var stored) ? stored : null; + } + public object? Get(CultRecordKey key) { lock (_gate) @@ -2039,7 +2179,53 @@ namespace GameCult.Caching // Call under the gate that admits the result, so no load can land a later storedAt in between. private CultStoredDocument Stamp(CultRecordKey key, CultDocumentDescriptor descriptor, object document) => - new(key, MintStoredAt(_entries.TryGetValue(key.Value, out var previous) ? previous.StoredAt : null), descriptor, document); + new(key, MintStoredAt(PreviousStoredAt(key)), descriptor, document); + + // An unresolved variant: the admitting hold resolves it against its base before anything lands. + private CultStoredDocument StampVariant(CultRecordKey key, CultDocumentDescriptor descriptor, CultVariantDelta delta) => + new(key, MintStoredAt(PreviousStoredAt(key)), descriptor, delta, null); + + // The plain record a Flatten stages: the variant's resolved document, under the variant's own key and schema. + private CultStoredDocument StampFlatten(CultRecordKey key) + { + if (!_entries.TryGetValue(key.Value, out var existing) || existing.Variant == null) + throw new InvalidOperationException($"Flatten({key.Value}): the record is not a variant."); + return new CultStoredDocument(key, MintStoredAt(existing.StoredAt), existing.Descriptor, existing.Document) { Flattens = true }; + } + + private string? PreviousStoredAt(CultRecordKey key) => _entries.TryGetValue(key.Value, out var previous) ? previous.StoredAt : null; + + // The concrete type a variant takes from its base, walking bases through what the batch stages and then the cache. + // Refuses a cycle or a base that is not there, naming the keys, exactly as the admitting hold would. + private CultDocumentDescriptor DescriptorOfBase(CultRecordKey variant, string baseKey, CultCacheBatch? batch) + { + var seen = new List { variant.Value }; + var next = baseKey; + while (true) + { + if (seen.Contains(next)) + throw CycleRefusal(seen.Append(next)); + var staged = batch != null && batch.Operations.TryGetValue(next, out var op) ? op : null; + if (staged != null) + { + if (staged.Stored != null) + return staged.Stored.Descriptor; + if (staged.Variant != null) + { + seen.Add(next); + next = staged.Variant.BaseKey; + continue; + } + + if (!staged.Flatten) + throw MissingBaseRefusal(variant.Value, baseKey, removed: true); + } + + if (!_entries.TryGetValue(next, out var existing)) + throw MissingBaseRefusal(variant.Value, baseKey, removed: false); + return existing.Descriptor; + } + } internal CultStoredDocument? Observe(CultRecordKey key, object? current) { @@ -2068,9 +2254,9 @@ namespace GameCult.Caching private CultRecordKey Write(object document, CultRecordKey? key) => Held(() => { var stored = CreateStoredDocument(document, key); - Admit(new[] { stored }, Array.Empty(), null, home => + Admit(new[] { stored }, Array.Empty(), null, (home, docs) => { - home?.Push(stored); + home?.Push(docs[0]); return CultCommitOutcome.Committed; }); return stored.Key; @@ -2094,16 +2280,19 @@ namespace GameCult.Caching { // Staging stamped outside this gate; a pull since then may hold a later storedAt, so stamp again. var upserts = batch.Operations.Values - .Where(op => op.Stored != null) - .Select(op => Stamp(op.Key, op.Stored!.Descriptor, op.Stored.Document)) + .Where(op => !op.IsRemoval) + .Select(op => op.Stored != null ? Stamp(op.Key, op.Stored.Descriptor, op.Stored.Document) + : op.Variant != null ? StampVariant(op.Key, DescriptorOfBase(op.Key, op.Variant.BaseKey, batch), op.Variant) + : StampFlatten(op.Key)) .ToArray(); var deletes = batch.Operations.Values - .Where(op => op.Stored == null && _entries.ContainsKey(op.Key.Value)) + .Where(op => op.IsRemoval && _entries.ContainsKey(op.Key.Value)) .Select(op => _entries[op.Key.Value]) .ToArray(); - var request = new CultCommitRequest(upserts, deletes, batch.Expected.ToArray(), batch.ExpectsUnchanged); - return Admit(upserts, deletes, null, home => + return Admit(upserts, deletes, null, (home, resolved) => { + // What the store holds is what the hold resolved: a variant's delta beside its resolved view. + var request = new CultCommitRequest(resolved, deletes, batch.Expected.ToArray(), batch.ExpectsUnchanged); if (home != null) { if (request.HasConditions && home.IsDirty) @@ -2127,14 +2316,18 @@ namespace GameCult.Caching IReadOnlyList admitted, IReadOnlyList evicted, CacheBackingStore? source, - Func land) => Held(() => + Func, CultCommitOutcome> land) => Held(() => { if (_held == null) throw new InvalidOperationException("An admission reached the cache under a plain lock on its gate; every admission runs in a hold."); - var outcome = land(Validate(admitted, evicted, source)); + var home = Validate(admitted, evicted, source); + // The one resolution: a variant admitted, a base admitted or evicted, all resolved here against the post-batch + // set, before land and before memory. land receives the admitted records with variants resolved. + var plan = Resolve(admitted, evicted, source); + var outcome = land(home, plan.Admitted); if (outcome != CultCommitOutcome.Committed) return outcome; - foreach (var change in Apply(admitted, evicted, source)) + foreach (var change in Apply(plan, evicted, source)) _held.Add((change, source != null)); return CultCommitOutcome.Committed; }); @@ -2262,12 +2455,215 @@ namespace GameCult.Caching } } - private List Apply( + private sealed class VariantPlan + { + public VariantPlan(IReadOnlyList admitted) + { + Admitted = admitted; + } + + // The admitted records, variants resolved. + public IReadOnlyList Admitted { get; } + + // Variants this admission did not write whose base chain it changed: re-resolved, same delta, same storedAt. + public List Dependents { get; } = new(); + + // The drift report of every variant resolved here; null when nothing was ignored. + public Dictionary Reports { get; } = new(StringComparer.Ordinal); + } + + // Resolves every admitted variant and every variant whose base chain this admission changes, base first, against + // the post-admission set. It refuses a broken variant naming the keys, and mutates nothing. + private VariantPlan Resolve( IReadOnlyList admitted, IReadOnlyList evicted, CacheBackingStore? source) { - var changes = new List(admitted.Count + evicted.Count); + if (_variantKeys.Count == 0 && admitted.All(stored => stored.Variant == null)) + return new VariantPlan(admitted); + + var incoming = admitted.ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal); + var gone = evicted.Select(stored => stored.Key.Value).ToHashSet(StringComparer.Ordinal); + CultStoredDocument? Lookup(string key) => + incoming.TryGetValue(key, out var found) ? found + : gone.Contains(key) ? null + : _entries.TryGetValue(key, out var held) ? held : null; + + // A variant is written only through the variant API: a plain record never lands on a variant's key, and the + // reverse is a rebase or a flatten, never an accident. A load reports what is on disk and is not a write. + if (source == null) + { + foreach (var stored in admitted) + { + _entries.TryGetValue(stored.Key.Value, out var present); + if (stored.Variant == null && !stored.Flattens && present?.Variant != null) + throw new InvalidOperationException( + $"Record {stored.Key.Value} is a variant of {present.Variant.BaseKey}; a plain write cannot replace it. " + + "Change it through the variant API, or Flatten it."); + if (stored.Variant != null && present != null && present.Variant == null) + throw new InvalidOperationException( + $"Record {stored.Key.Value} is a plain record; it cannot become a variant of {stored.Variant.BaseKey} in place. Use a new key."); + if (stored.Variant != null && present?.Variant != null && + present.Descriptor.DocumentType != stored.Descriptor.DocumentType) + throw new InvalidOperationException( + $"Variant {stored.Key.Value} is a {present.Descriptor.DocumentType.Name}; " + + $"rebasing it onto {stored.Variant.BaseKey} would make it a {stored.Descriptor.DocumentType.Name}."); + } + } + + var changed = new HashSet(incoming.Keys.Concat(gone), StringComparer.Ordinal); + var toResolve = new List(admitted.Where(stored => stored.Variant != null).Select(stored => stored.Key.Value)); + foreach (var variantKey in _variantKeys) + { + if (incoming.ContainsKey(variantKey) || gone.Contains(variantKey)) + continue; + var next = _entries[variantKey].Variant!.BaseKey; + for (var steps = 0; steps <= _entries.Count; steps++) + { + if (changed.Contains(next)) + { + toResolve.Add(variantKey); + break; + } + + var link = Lookup(next); + if (link?.Variant == null) + break; + next = link.Variant.BaseKey; + } + } + + if (toResolve.Count == 0) + return new VariantPlan(admitted); + + var pending = new HashSet(toResolve, StringComparer.Ordinal); + var done = new Dictionary(StringComparer.Ordinal); + var reports = new Dictionary(StringComparer.Ordinal); + var stack = new List(); + foreach (var key in toResolve) + ResolveOne(key); + + var plan = new VariantPlan(admitted.Select(stored => stored.Variant != null ? done[stored.Key.Value] : stored).ToList()); + foreach (var key in toResolve.Where(key => !incoming.ContainsKey(key))) + plan.Dependents.Add(done[key]); + foreach (var pair in reports) + plan.Reports[pair.Key] = pair.Value; + return plan; + + CultStoredDocument ResolveOne(string key) + { + if (done.TryGetValue(key, out var finished)) + return finished; + var variant = Lookup(key)!; + if (stack.Contains(key)) + throw CycleRefusal(stack.SkipWhile(entry => entry != key).Append(key)); + stack.Add(key); + + var delta = variant.Variant!; + var baseStored = Lookup(delta.BaseKey) ?? throw MissingBaseRefusal(key, delta.BaseKey, gone.Contains(delta.BaseKey)); + if (baseStored.Variant != null && pending.Contains(delta.BaseKey)) + baseStored = ResolveOne(delta.BaseKey); + + var descriptor = variant.Descriptor; + if (_codec == null) + throw new InvalidOperationException( + $"Variant {key} needs a codec to resolve; this cache has none. Create it with CultCacheMessagePack.Create."); + if (baseStored.Descriptor.DocumentType != descriptor.DocumentType) + throw new InvalidOperationException( + $"Variant {key} is a {descriptor.DocumentType.Name} but its base {delta.BaseKey} is a {baseStored.Descriptor.DocumentType.Name}; " + + "a variant has its base's concrete type."); + + var type = descriptor.DocumentType; + var kept = new List>(); + var ignored = new List(); + var warnings = new List(); + foreach (var entry in delta.Overrides) + { + if (entry.Op != CultOverrideOp.Set || entry.Path.Count != 1 || entry.Path[0].ElementId.Length != 0 || entry.Id.Length != 0) + throw new InvalidOperationException( + $"Variant {key} carries {entry.Op} at {entry.PathText}; this runtime resolves top-level Set overrides only."); + var slot = entry.Path[0].Slot; + var member = descriptor.RichMembers.FirstOrDefault(candidate => candidate.Slot == slot); + if (member == null) + { + var name = delta.SlotNames != null && delta.SlotNames.TryGetValue(slot, out var persistedName) + ? $"'{persistedName}'" : "unnamed"; + ignored.Add(slot); + warnings.Add(new CultSchemaMigrationWarning + { + Code = "ignored_extra_slot", + Message = $"Variant {key} overrides slot {slot} ({name}), which {type.Name} no longer has; the override is ignored and the variant inherits the base value." + }); + continue; + } + + try + { + _codec.Deserialize(member.MemberType, type, entry.Value); + } + catch (Exception exception) + { + ignored.Add(slot); + warnings.Add(new CultSchemaMigrationWarning + { + Code = "ignored_undecodable_override", + Message = $"Variant {key} overrides slot {slot} ('{member.Member.Name}') with a value that no longer decodes as {member.MemberType.Name} " + + $"({exception.GetBaseException().Message}); the override is ignored and the variant inherits the base value." + }); + continue; + } + + kept.Add(new KeyValuePair(slot, entry.Value)); + } + + var payload = _codec.Serialize(baseStored.Document, type, type); + var document = _codec.Deserialize(type, type, kept.Count == 0 ? payload : _codec.Overlay(payload, kept)) + ?? throw new InvalidOperationException($"Variant {key} resolved to nothing."); + + // R6: the name index maps one name to one key, so a variant that kept its base's name would shadow it. + if (descriptor.NameAccessor?.Invoke(document) is { Length: > 0 } variantName) + { + for (var ancestor = baseStored; ancestor != null; + ancestor = ancestor.Variant == null ? null : done.TryGetValue(ancestor.Variant.BaseKey, out var above) ? above : Lookup(ancestor.Variant.BaseKey)) + { + if (descriptor.NameAccessor(ancestor.Document) == variantName) + throw new InvalidOperationException( + $"Variant {key} and its base {ancestor.Key.Value} are both named '{variantName}'; a variant must override its name."); + } + } + + reports[key] = ignored.Count == 0 + ? null + : new CultSchemaMigrationReport + { + PersistedSchemaId = descriptor.SchemaId, + LocalSchemaId = descriptor.SchemaId, + PersistedSchemaName = descriptor.SchemaName, + LocalSchemaName = descriptor.SchemaName, + Kind = CultSchemaMigrationKind.CompatibleDrift, + IgnoredExtraSlots = ignored.OrderBy(slot => slot).ToArray(), + Warnings = warnings.ToArray() + }; + stack.RemoveAt(stack.Count - 1); + return done[key] = variant.Resolved(document); + } + } + + private static InvalidOperationException CycleRefusal(IEnumerable keys) => + new($"Variant bases form a cycle: {string.Join(" -> ", keys)}."); + + private static InvalidOperationException MissingBaseRefusal(string variant, string baseKey, bool removed) => + new(removed + ? $"Record {baseKey} cannot go: variant {variant} is based on it. Remove or rebase the variant in the same commit." + : $"Variant {variant} is based on {baseKey}, which does not exist."); + + private List Apply( + VariantPlan plan, + IReadOnlyList evicted, + CacheBackingStore? source) + { + var admitted = plan.Admitted; + var changes = new List(admitted.Count + evicted.Count + plan.Dependents.Count); foreach (var stored in evicted) { if (!_entries.TryGetValue(stored.Key.Value, out var existing)) @@ -2275,10 +2671,12 @@ namespace GameCult.Caching Unindex(existing); _entries.Remove(stored.Key.Value); _handles.Remove(existing.Document); + _variantKeys.Remove(stored.Key.Value); + _variantReports.Remove(stored.Key.Value); changes.Add(new Change(CultCacheDocumentChangeKind.Removed, existing, null, existing.Document, ++_sequence)); } - foreach (var stored in admitted) + foreach (var stored in admitted.Concat(plan.Dependents)) { _entries.TryGetValue(stored.Key.Value, out var previous); if (previous != null) @@ -2290,6 +2688,14 @@ namespace GameCult.Caching _entries[stored.Key.Value] = stored; _handles.AddOrUpdate(stored.Document, new KeyBox(stored.Key)); Index(stored); + if (stored.Variant == null) + _variantKeys.Remove(stored.Key.Value); + else + _variantKeys.Add(stored.Key.Value); + if (plan.Reports.TryGetValue(stored.Key.Value, out var report) && report != null) + _variantReports[stored.Key.Value] = report; + else + _variantReports.Remove(stored.Key.Value); changes.Add(new Change( previous == null ? CultCacheDocumentChangeKind.Added : CultCacheDocumentChangeKind.Updated, stored, @@ -2486,12 +2892,14 @@ namespace GameCult.Caching protected CultPersistedRecord ToPersistedRecord(CultStoredDocument entry, Func serializePayload) { + // A variant persists its delta and an empty payload; the resolved view is never written. return new CultPersistedRecord { Key = entry.Key.Value, SchemaId = entry.Descriptor.SchemaId, StoredAt = entry.StoredAt, - Payload = serializePayload(entry.Document) + Payload = entry.Variant != null ? Array.Empty() : serializePayload(entry.Document), + Variant = entry.Variant == null ? null : new CultVariantDelta(entry.Variant.BaseKey, entry.Variant.Overrides) }; } @@ -2501,6 +2909,21 @@ namespace GameCult.Caching Func deserializePayload) { var resolution = Registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog); + if (record.Variant != null) + { + // The cache resolves it against its base when it admits the load; the catalog names the slots the type may have dropped. + var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, record.SchemaId, StringComparison.Ordinal)); + return new CultStoredDocument( + new CultRecordKey(record.Key), + record.StoredAt, + resolution.Descriptor, + new CultVariantDelta(record.Variant.BaseKey, record.Variant.Overrides) + { + SlotNames = persisted?.Members.ToDictionary(member => member.Slot, member => member.MemberName) + }, + null); + } + var document = deserializePayload(resolution.Descriptor.DocumentType, record.Payload); return new CultStoredDocument( new CultRecordKey(record.Key), @@ -2675,6 +3098,10 @@ namespace GameCult.Caching var used = new HashSet(ordered.Select(record => record.SchemaId), StringComparer.Ordinal); var snapshot = new CultPersistedStoreSnapshot { + // v2 exactly when the store holds a variant: a store without one stays byte-identical v1. + FormatVersion = ordered.Any(record => record.Variant != null) + ? CultPersistedStoreSnapshot.FormatV2 + : CultPersistedStoreSnapshot.FormatV1, SchemaCatalog = catalog .GroupBy(entry => entry.SchemaId, StringComparer.Ordinal) .Select(group => group.First()) diff --git a/src/GameCult.Caching/CultInspectorModel.cs b/src/GameCult.Caching/CultInspectorModel.cs index e5b5581f..99d82205 100644 --- a/src/GameCult.Caching/CultInspectorModel.cs +++ b/src/GameCult.Caching/CultInspectorModel.cs @@ -292,22 +292,24 @@ namespace GameCult.Caching typeof(int), typeof(long), typeof(uint), typeof(short), typeof(ushort), typeof(byte), typeof(sbyte) }; - private readonly Func _serialize; - private readonly Func _deserialize; + private readonly CultCodec _codec; private readonly Dictionary _shapes = new Dictionary(); private readonly Dictionary _metadata = new Dictionary(); - // serialize (value, value type, owning document type) and deserialize are the store's codec - // (CultCacheMessagePack.CreateInspectorModel for .cc stores). Edits clone documents through both; dictionary keys - // compare by serialize under their owning document, exactly as the store writes them inside it, so serialize must - // take any value, not only documents. - public CultInspectorModel(CultDocumentRegistry registry, Func serialize, Func deserialize) + // The codec is the store's (CultCacheMessagePack.CreateInspectorModel for .cc stores), the one a CultCache resolves + // variants with. Edits clone documents through it; dictionary keys compare by its Serialize under their owning + // document, exactly as the store writes them inside it. + public CultInspectorModel(CultDocumentRegistry registry, CultCodec codec) { Registry = registry ?? throw new ArgumentNullException(nameof(registry)); - _serialize = serialize ?? throw new ArgumentNullException(nameof(serialize)); - _deserialize = deserialize ?? throw new ArgumentNullException(nameof(deserialize)); + _codec = codec ?? throw new ArgumentNullException(nameof(codec)); } + private byte[] _serialize(object value, Type type, Type document) => _codec.Serialize(value, type, document); + + private object _deserialize(Type type, byte[] bytes) => + _codec.Deserialize(type, type, bytes) ?? throw new InvalidOperationException($"The codec returned null for {type.FullName}."); + public CultDocumentRegistry Registry { get; } public static string RecordLabel(CultStoredDocument record) diff --git a/src/GameCult.Caching/CultVariants.cs b/src/GameCult.Caching/CultVariants.cs new file mode 100644 index 00000000..08882c79 --- /dev/null +++ b/src/GameCult.Caching/CultVariants.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GameCult.Caching +{ + // The store's encoding, as the cache needs it: the one pair CultInspectorModel and variant resolution share. + // Serialize takes any value, not only documents, under its owning document type's options; Deserialize is its + // inverse (a document type is checked against the registry, a member type is not); Overlay replaces slots of a + // serialized document payload with already-encoded member values and touches nothing else. + public sealed class CultCodec + { + public CultCodec( + Func serialize, + Func deserialize, + Func>, byte[]> overlay) + { + Serialize = serialize ?? throw new ArgumentNullException(nameof(serialize)); + Deserialize = deserialize ?? throw new ArgumentNullException(nameof(deserialize)); + Overlay = overlay ?? throw new ArgumentNullException(nameof(overlay)); + } + + // (value, value type, owning document type) + public Func Serialize { get; } + + // (value type, owning document type, bytes) + public Func Deserialize { get; } + + // (document payload, slot and encoded value pairs) + public Func>, byte[]> Overlay { get; } + } + + public enum CultOverrideOp + { + Set = 0, + Insert = 1, + Remove = 2 + } + + // One step of a member path: the MessagePack slot, and the id of the object-list element the step enters ("" when it + // does not enter one). + public sealed class CultPathStep + { + public CultPathStep(int slot, string elementId = "") + { + Slot = slot; + ElementId = elementId ?? string.Empty; + } + + public int Slot { get; } + public string ElementId { get; } + + public override string ToString() => ElementId.Length == 0 ? $"[{Slot}]" : $"[{Slot}:{ElementId}]"; + } + + // The wire shape [op, path[], id, value]; value is the override's already-encoded MessagePack value. + public sealed class CultVariantOverride + { + public CultVariantOverride(CultOverrideOp op, IReadOnlyList path, string id, byte[] value) + { + Op = op; + Path = path ?? throw new ArgumentNullException(nameof(path)); + Id = id ?? string.Empty; + Value = value ?? throw new ArgumentNullException(nameof(value)); + } + + public CultOverrideOp Op { get; } + public IReadOnlyList Path { get; } + public string Id { get; } + public byte[] Value { get; } + + // A top-level Set: the whole member at slot takes the encoded value. + public static CultVariantOverride Set(int slot, byte[] value) => + new(CultOverrideOp.Set, new[] { new CultPathStep(slot) }, string.Empty, value); + + internal string PathText => string.Concat(Path.Select(step => step.ToString())); + } + + // What a variant record stores: its base and its overrides. Never the resolved document. + public sealed class CultVariantDelta + { + public CultVariantDelta(string baseKey, IReadOnlyList overrides) + { + BaseKey = baseKey ?? throw new ArgumentNullException(nameof(baseKey)); + Overrides = overrides ?? throw new ArgumentNullException(nameof(overrides)); + } + + public string BaseKey { get; } + public IReadOnlyList Overrides { get; } + + // Member names by slot from the catalog the delta was loaded under, for warnings about slots the type dropped. + // Not persisted. + internal IReadOnlyDictionary? SlotNames { get; set; } + } +} diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs index 46bfb47e..67cc4cdf 100644 --- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs +++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs @@ -156,6 +156,8 @@ namespace GameCult.Mesh $"CultCache document '{path}' record '{key.Value}' has schema '{record.SchemaId}', expected '{expectedSchemaId}'."); } + RefuseVariant(path, record); + if (!PublishesSchema(snapshot.SchemaCatalog, expectedSchemaId)) { throw new InvalidDataException( @@ -189,6 +191,8 @@ namespace GameCult.Mesh $"CultCache document '{path}' has schema '{record.SchemaId}', expected '{expectedSchemaId}'."); } + RefuseVariant(path, record); + if (!PublishesSchema(snapshot.SchemaCatalog, expectedSchemaId)) { throw new InvalidDataException( @@ -198,6 +202,15 @@ namespace GameCult.Mesh return record.Payload; } + // A variant record's payload is empty; its document exists only as the cache resolves it against its base. + private static void RefuseVariant(string path, CultPersistedRecord record) + { + if (record.Variant != null) + throw new NotSupportedException( + $"CultCache document '{path}' record '{record.Key}' is a variant of '{record.Variant.BaseKey}'; " + + "CultMesh does not resolve document variants, open the store with a CultCache."); + } + private static void WriteSingleFileDocumentPayload( string path, CultRecordKey key, diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index be114002..60446d31 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -393,7 +393,7 @@ namespace GameCult.Networking stored = stored.Where(entry => rowFilter(entry.Descriptor, entry.Key)); var rows = stored .Select(entry => new CultNetSelectionEvaluator.Row( - entry.Descriptor, entry.Key, entry.Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt)) + entry.Descriptor, entry.Key, RefuseVariant(entry).Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt)) .ToArray(); var full = CultNetSelectionEvaluator.EvaluateAll(_documents, rows, selection); // R-Q: a page's asOf is exact only when every matched row is committed through one shard's @@ -487,7 +487,7 @@ namespace GameCult.Networking stored = stored.Where(entry => rowFilter(entry.Descriptor, entry.Key)); var rows = stored .Select(entry => new CultNetSelectionEvaluator.Row( - entry.Descriptor, entry.Key, entry.Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt)) + entry.Descriptor, entry.Key, RefuseVariant(entry).Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt)) .ToArray(); var full = CultNetSelectionEvaluator.EvaluateAll(_documents, rows, selection); @@ -549,6 +549,13 @@ namespace GameCult.Networking }; } + // CultNet carries no variant deltas yet, and a resolved view sent as a plain record would be a lie about the store. + private static CultStoredDocument RefuseVariant(CultStoredDocument entry) => + entry.Variant == null + ? entry + : throw new NotSupportedException( + $"Record '{entry.Key.Value}' is a variant of '{entry.Variant.BaseKey}'; CultNet does not carry document variants yet."); + /// /// Builds the wire record for one evaluated row. The shared path under snapshot and change /// (docs/cultnet-selection-cut.md, section 8's "shared paths").