diff --git a/contracts/cultnet/cultnet.error.schema.json b/contracts/cultnet/cultnet.error.schema.json
index 8653cbd5..15880fa1 100644
--- a/contracts/cultnet/cultnet.error.schema.json
+++ b/contracts/cultnet/cultnet.error.schema.json
@@ -28,7 +28,7 @@
"code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.",
"type": ["string", "null"],
- "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"]
+ "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported"]
},
"details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
@@ -36,8 +36,8 @@
"additionalProperties": false,
"required": ["field", "value", "asOf", "current"],
"properties": {
- "field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] },
- "value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] },
+ "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended.", "type": ["string", "null"] },
+ "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended.", "type": ["string", "null"] },
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
}
diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md
index 240b22d8..2c6f2211 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/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py
index b4168690..6001c905 100644
--- a/packages/cultcache-py/tests/test_cultcache.py
+++ b/packages/cultcache-py/tests/test_cultcache.py
@@ -829,6 +829,12 @@ class CultCacheTests(unittest.TestCase):
message = str(caught.exception)
self.assertTrue("cultcache.store.v2" in message or "item:anvil-big" in message, message)
+ def test_single_file_refuses_the_csharp_written_variant_store_by_version_or_record(self) -> None:
+ with self.assertRaises(ValueError) as caught:
+ self._pull_vector("../document-variants-c1/variant-store.msgpack")
+ message = str(caught.exception)
+ self.assertTrue("cultcache.store.v2" in message or "laser-big" in message, message)
+
def test_v1_store_written_at_the_base_commit_still_reads_byte_for_byte(self) -> None:
envelopes = self._pull_vector("v1-base.msgpack")
self.assertEqual([(e.key, e.type) for e in envelopes], [("alpha", "vectors.item"), ("beta", "vectors.item")])
diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs
index e48ebad8..c9a7d282 100644
--- a/packages/cultcache-rs/src/lib.rs
+++ b/packages/cultcache-rs/src/lib.rs
@@ -4679,6 +4679,16 @@ mod tests {
);
}
+ // The store the C# runtime wrote through its own variant API (tests/vectors/document-variants-c1).
+ #[test]
+ fn single_file_refuses_the_csharp_written_variant_store_by_version_or_record() {
+ let message = refusal("../document-variants-c1/variant-store.msgpack");
+ assert!(
+ message.contains("cultcache.store.v2") || message.contains("laser-big"),
+ "{message}"
+ );
+ }
+
#[test]
fn v1_store_written_at_the_base_commit_still_reads_byte_for_byte() -> Result<()> {
let envelopes = pull_vector("v1-base.msgpack")?;
diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts
index a743ba00..8753af47 100644
--- a/packages/cultcache-ts/test/cult-cache.test.ts
+++ b/packages/cultcache-ts/test/cult-cache.test.ts
@@ -1443,6 +1443,13 @@ test("SingleFileMessagePackBackingStore refuses a variant store by version or re
);
});
+test("SingleFileMessagePackBackingStore refuses the C#-written variant store by version or record", async () => {
+ await assert.rejects(
+ () => pullVector("../document-variants-c1/variant-store.msgpack"),
+ (error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("laser-big"),
+ );
+});
+
test("CultCache inspector refuses the same vectors by name", async () => {
const inspect = async (name: string) => inspectCultCacheBytes(name, await readFile(join(variantVectors, name)));
await assert.rejects(() => inspect("unknown-header.msgpack"), /cultcache\.store\.v9/u);
diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json
index 8653cbd5..15880fa1 100644
--- a/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json
+++ b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json
@@ -28,7 +28,7 @@
"code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.",
"type": ["string", "null"],
- "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"]
+ "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported"]
},
"details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
@@ -36,8 +36,8 @@
"additionalProperties": false,
"required": ["field", "value", "asOf", "current"],
"properties": {
- "field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] },
- "value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] },
+ "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended.", "type": ["string", "null"] },
+ "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended.", "type": ["string", "null"] },
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
}
diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs
index cc50171d..f31ade85 100644
--- a/packages/cultnet-rs/src/contracts.rs
+++ b/packages/cultnet-rs/src/contracts.rs
@@ -250,7 +250,7 @@ impl CultNetShardDescriptor {
}
}
-/// Machine-readable `cultnet.error.v0` refusal code (R-N). Mirrors the four strings
+/// Machine-readable `cultnet.error.v0` refusal code (R-N). Mirrors the five strings
/// `contracts/cultnet/cultnet.error.schema.json` enumerates for `code`; a peer that receives a fifth
/// string it does not know refuses to decode rather than guess a meaning for it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -260,6 +260,7 @@ pub enum CultNetErrorCode {
CursorStale,
CursorInvalid,
ReferenceOutsideTarget,
+ VariantUnsupported,
}
impl CultNetErrorCode {
@@ -269,6 +270,7 @@ impl CultNetErrorCode {
Self::CursorStale => "cursor_stale",
Self::CursorInvalid => "cursor_invalid",
Self::ReferenceOutsideTarget => "reference_outside_target",
+ Self::VariantUnsupported => "variant_unsupported",
}
}
@@ -278,6 +280,7 @@ impl CultNetErrorCode {
"cursor_stale" => Ok(Self::CursorStale),
"cursor_invalid" => Ok(Self::CursorInvalid),
"reference_outside_target" => Ok(Self::ReferenceOutsideTarget),
+ "variant_unsupported" => Ok(Self::VariantUnsupported),
other => Err(anyhow!("ErrorMessage.Code {other:?} is not a recognized cultnet.error.v0 code")),
}
}
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..8244d76a 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!);
@@ -328,11 +339,11 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
var started = tracePages ? Stopwatch.GetTimestamp() : 0L;
var metadata = records[index];
var record = ReadPersistedRecordPage(metadata, out var pagePayload);
- recordReports[index] = Registry.ResolvePersistedSchemaReport(record.SchemaId, catalogEntries);
storedRecords[index] = ToStoredDocument(
record,
catalogEntries,
- (type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry));
+ (type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry),
+ out recordReports[index]);
if (tracePages)
{
pageBytes[index] = pagePayload.LongLength;
@@ -491,6 +502,10 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(pagePayload);
if (!string.Equals(record.Key, metadata.Key, StringComparison.Ordinal))
throw new InvalidDataException($"Record page '{path}' contains key '{record.Key}', expected '{metadata.Key}'.");
+ // The shared decoder reads a variant page; this store does not hold variants, so a load refuses it as a write does.
+ if (record.Variant != null)
+ throw new NotSupportedException(
+ $"Record '{record.Key}' is a variant of '{record.Variant.BaseKey}'; the directory store does not hold document variants yet.");
return record;
}
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..9ae6f553 100644
--- a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md
+++ b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md
@@ -105,3 +105,20 @@ 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 follows the same outcome as the same member on a plain record; there
+is no separate rule for variants, and no separate report.
+
+- An override naming a slot the type no longer has is ignored, exactly as a plain record
+ ignores a dropped member. The drift is reported once, by the store's schema resolution
+ (`LastSchemaMigrationReports`), for the variant's record as for any other record of the
+ schema. The variant inherits the base value.
+- An override holding a value that no longer decodes into the member's type refuses the
+ load, exactly as a plain record holding such a value does; the refusal names the variant
+ and the member.
+
+Drift is for type changes after the fact. A write is validated: an override for a slot the
+type does not have, a value that does not decode as the member, or two overrides of one
+slot is refused when written.
diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs
index 2f8e316a..b2f06659 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;
}
@@ -1728,8 +1799,12 @@ namespace GameCult.Caching
private readonly List<(CacheBackingStore Store, Type[] Homes)> _stores = new();
private readonly Dictionary _entries = new(StringComparer.Ordinal);
private readonly Dictionary> _names = new();
- private readonly Dictionary<(Type Type, string Alias), Dictionary> _indexes = new();
+ // Every key holding a value (unordered): the index is truthful about duplicates, so removing one holder never hides another.
+ private readonly Dictionary<(Type Type, string Alias), Dictionary>> _indexes = new();
private readonly Dictionary _globals = new();
+ private readonly CultCodec? _codec;
+ // Keys holding a variant: derived from the entries by Apply, never decided anywhere else.
+ private readonly HashSet _variantKeys = new(StringComparer.Ordinal);
private readonly ConditionalWeakTable _handles = new();
private readonly Subject _changes = new();
private readonly object _gate = new();
@@ -1738,13 +1813,34 @@ 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;
+ // Read under the gate, by a store judging a merge.
+ internal bool HoldsVariants => _variantKeys.Count > 0;
+
+ public CultCodec? Codec => _codec;
+
+ // 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 +1937,8 @@ 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);
+ store.Judging = (arriving, departing) => { Resolve(arriving, departing, store); };
_stores.Add(candidate);
try
{
@@ -1851,6 +1948,7 @@ namespace GameCult.Caching
{
_stores.Remove(candidate);
store.Loaded = null;
+ store.Judging = null;
store.Cache = null;
throw;
}
@@ -1915,11 +2013,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 +2076,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)
@@ -1994,11 +2127,16 @@ namespace GameCult.Caching
.SelectMany(pair => pair.Value.TryGetValue(name, out var key) ? new[] { key } : Array.Empty()));
}
+ // A value held by one record finds it. Records of one type sharing a value (plain duplicates) find the holder with the
+ // smallest key (ordinal), whatever order they were written or loaded in: the choice is a function of the held set, so
+ // it is the same live, after a flush and after a reopen, and removing that holder finds the next.
public T? GetByIndex(string alias, string value) where T : class
{
return Single($"index {alias}='{value}'", () => _indexes
.Where(pair => pair.Key.Alias == alias && typeof(T).IsAssignableFrom(pair.Key.Type))
- .SelectMany(pair => pair.Value.TryGetValue(value, out var key) ? new[] { key } : Array.Empty()));
+ .SelectMany(pair => pair.Value.TryGetValue(value, out var keys)
+ ? new[] { keys.Aggregate((best, next) => string.CompareOrdinal(next, best) < 0 ? next : best) }
+ : Array.Empty()));
}
public void Dispose()
@@ -2039,7 +2177,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 +2252,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 +2278,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 +2314,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 +2453,264 @@ 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();
+ }
+
+ // 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);
+ // A flatten resolves like the variant it replaces, against the post-batch set, so a base edited in the same
+ // batch is in it; it lands as the plain record of that resolution.
+ var toResolve = new List(admitted.Where(stored => stored.Variant != null || stored.Flattens).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)
+ {
+ var unchanged = new VariantPlan(admitted);
+ RefuseVariantIndexSharing(unchanged, incoming, gone);
+ return unchanged;
+ }
+
+ var pending = new HashSet(toResolve, StringComparer.Ordinal);
+ var done = new Dictionary(StringComparer.Ordinal);
+ var stack = new List();
+ foreach (var key in toResolve)
+ ResolveOne(key);
+
+ var plan = new VariantPlan(admitted.Select(stored => done.TryGetValue(stored.Key.Value, out var resolved) ? resolved : stored).ToList());
+ foreach (var key in toResolve.Where(key => !incoming.ContainsKey(key)))
+ plan.Dependents.Add(done[key]);
+ RefuseVariantIndexSharing(plan, incoming, gone);
+ 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);
+
+ // A flatten carries no delta of its own; the variant it replaces does.
+ var isVariant = variant.Variant != null;
+ var delta = variant.Variant ?? _entries[key].Variant!;
+ var baseStored = Lookup(delta.BaseKey) ?? throw MissingBaseRefusal(key, delta.BaseKey, gone.Contains(delta.BaseKey));
+ if (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 seen = new HashSet();
+ 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;
+ if (!seen.Add(slot))
+ throw new InvalidOperationException($"Variant {key} overrides slot {slot} more than once; a variant has one override per slot.");
+ var member = descriptor.RichMembers.FirstOrDefault(candidate => candidate.Slot == slot);
+ // Drift is the load's business (ToStoredDocument sheds an override of a slot the type lacks), so a delta
+ // reaching here names only slots the type has, unless someone is writing one it never had.
+ if (member == null)
+ throw new InvalidOperationException($"Variant {key} overrides slot {slot}, which {type.Name} does not have.");
+
+ try
+ {
+ _codec.Deserialize(member.MemberType, type, entry.Value);
+ }
+ catch (Exception exception)
+ {
+ // A value that no longer decodes refuses exactly as a plain record holding it does.
+ throw new InvalidOperationException(
+ $"Variant {key} overrides '{member.Member.Name}' (slot {slot}) with a value that does not decode as {member.MemberType.Name}: " +
+ exception.GetBaseException().Message, exception);
+ }
+
+ 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.");
+
+ if (isVariant)
+ {
+ // 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.");
+ }
+ }
+ }
+
+ stack.RemoveAt(stack.Count - 1);
+ return done[key] = isVariant
+ ? variant.Resolved(document)
+ : new CultStoredDocument(variant.Key, variant.StoredAt, variant.Descriptor, document) { Flattens = true };
+ }
+ }
+
+ // The one index rule, judged on the commit's result whatever the write order: a variant may not share an indexed
+ // value with another record. It reads the truthful held index (every holder of a value), minus the records this
+ // commit replaces or removes, plus the records it lands. Plain-vs-plain sharing is not this rule's business.
+ // A unique index maps a value to one key at lookup; a variant sharing it would be found in place of its
+ // holder or lose the value, and the store would refuse to reopen.
+ private void RefuseVariantIndexSharing(
+ VariantPlan plan,
+ IReadOnlyDictionary incoming,
+ HashSet gone)
+ {
+ var landing = plan.Admitted.Concat(plan.Dependents).ToList();
+ if (landing.All(stored => stored.Descriptor.IndexAccessors.Count == 0))
+ return;
+ var replaced = new HashSet(incoming.Keys.Concat(gone).Concat(plan.Dependents.Select(stored => stored.Key.Value)), StringComparer.Ordinal);
+ var landingByKey = landing.ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal);
+
+ bool IsVariant(string key) =>
+ landingByKey.TryGetValue(key, out var landed) ? landed.Variant != null : _variantKeys.Contains(key);
+
+ // One pass over what lands, one lookup per (index, value): the rule is linear in the records landing.
+ var landedValues = new List<(CultStoredDocument Stored, (Type Type, string Alias) Index, string Value)>();
+ var landed = new Dictionary<((Type Type, string Alias) Index, string Value), List>();
+ foreach (var stored in landing)
+ {
+ foreach (var pair in stored.Descriptor.IndexAccessors)
+ {
+ var value = pair.Value(stored.Document);
+ if (string.IsNullOrWhiteSpace(value))
+ continue;
+ var index = (stored.Descriptor.DocumentType, pair.Key);
+ landedValues.Add((stored, index, value));
+ if (!landed.TryGetValue((index, value), out var landedKeys))
+ landed[(index, value)] = landedKeys = new List();
+ landedKeys.Add(stored.Key.Value);
+ }
+ }
+
+ var holdersOf = new Dictionary<((Type Type, string Alias) Index, string Value), (string[] All, string[] Variants)>();
+ foreach (var (stored, index, value) in landedValues)
+ {
+ if (!holdersOf.TryGetValue((index, value), out var known))
+ {
+ var all = (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKeys)
+ ? heldKeys.Where(heldKey => !replaced.Contains(heldKey))
+ : Enumerable.Empty())
+ .Concat(landed[(index, value)])
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ holdersOf[(index, value)] = known = (all, all.Where(IsVariant).ToArray());
+ }
+
+ var self = stored.Key.Value;
+ var holder = known.All.FirstOrDefault(candidate => candidate != self);
+ if (stored.Variant != null && holder != null)
+ throw new InvalidOperationException(
+ $"Variant {self} and {holder} both hold index {index.Alias} '{value}'; a variant must override its indexed member.");
+ var variantHolder = known.Variants.FirstOrDefault(candidate => candidate != self);
+ if (stored.Variant == null && variantHolder != null)
+ throw new InvalidOperationException(
+ $"Record {self} and variant {variantHolder} both hold index {index.Alias} '{value}'; " +
+ "a variant may not share an indexed value with another record.");
+ }
+ }
+
+ 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 +2718,11 @@ namespace GameCult.Caching
Unindex(existing);
_entries.Remove(stored.Key.Value);
_handles.Remove(existing.Document);
+ _variantKeys.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 +2734,10 @@ 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);
changes.Add(new Change(
previous == null ? CultCacheDocumentChangeKind.Added : CultCacheDocumentChangeKind.Updated,
stored,
@@ -2353,7 +2801,7 @@ namespace GameCult.Caching
{
var value = pair.Value(stored.Document);
if (!string.IsNullOrWhiteSpace(value))
- MapOf(_indexes, (type, pair.Key))[value] = stored.Key.Value;
+ AddHolder(_indexes, (type, pair.Key), value, stored.Key.Value);
}
}
@@ -2366,7 +2814,26 @@ namespace GameCult.Caching
if (_names.TryGetValue(type, out var names))
RemoveKey(names, stored.Key.Value);
foreach (var pair in _indexes.Where(pair => pair.Key.Type == type))
- RemoveKey(pair.Value, stored.Key.Value);
+ RemoveHolder(pair.Value, stored.Key.Value);
+ }
+
+ private static void AddHolder(Dictionary<(Type Type, string Alias), Dictionary>> indexes, (Type, string) index, string value, string key)
+ {
+ if (!indexes.TryGetValue(index, out var map))
+ indexes[index] = map = new Dictionary>(StringComparer.Ordinal);
+ if (!map.TryGetValue(value, out var keys))
+ map[value] = keys = new List();
+ keys.Remove(key);
+ keys.Add(key);
+ }
+
+ private static void RemoveHolder(Dictionary> map, string key)
+ {
+ foreach (var pair in map.ToArray())
+ {
+ if (pair.Value.Remove(key) && pair.Value.Count == 0)
+ map.Remove(pair.Key);
+ }
}
private static Dictionary MapOf(Dictionary> maps, TKey key)
@@ -2440,6 +2907,11 @@ namespace GameCult.Caching
// any of it; if the cache refuses a record the call throws and the store keeps its previous view.
protected internal Action, IReadOnlyList>? Loaded;
+ // Set by the cache at attach: the cache's own resolution (variants, their indexed values, their bases) run over a
+ // set of records a store is about to hold, mutating nothing. It throws the refusal the cache would give the same
+ // set arriving as a load.
+ protected internal Action, IReadOnlyList>? Judging;
+
private readonly object _detachedGate = new();
internal CultCache? Cache;
@@ -2486,21 +2958,62 @@ 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
};
}
+ // The one load surface: a persisted record resolved against the current type, with the report of what that
+ // resolution shed. A plain record sheds a member its type dropped by never carrying it into the document; a variant
+ // sheds the override of such a slot by never carrying it into the delta, so a later type reusing the slot number
+ // cannot resurrect it and a flush needs no rule of its own. The report is the schema diff's ignored slots plus the
+ // overrides whose slot the type does not have, including one the persisted schema never listed.
protected CultStoredDocument ToStoredDocument(
CultPersistedRecord record,
IReadOnlyCollection catalog,
- Func deserializePayload)
+ Func deserializePayload,
+ out CultSchemaMigrationReport report)
{
var resolution = Registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog);
+ report = resolution.Report;
+ if (record.Variant != null)
+ {
+ var descriptor = resolution.Descriptor;
+ bool Has(CultVariantOverride entryOverride) =>
+ entryOverride.Path.Count != 1 || descriptor.RichMembers.Any(member => member.Slot == entryOverride.Path[0].Slot);
+ var shed = record.Variant.Overrides
+ .Where(entryOverride => !Has(entryOverride))
+ .Select(entryOverride => entryOverride.Path[0].Slot)
+ .Distinct()
+ .OrderBy(slot => slot)
+ .ToArray();
+ if (shed.Length > 0)
+ {
+ // A slot the schema diff already reported is not reported twice.
+ var unreported = shed.Except(report.IgnoredExtraSlots).ToArray();
+ report.IgnoredExtraSlots = report.IgnoredExtraSlots.Concat(unreported).OrderBy(slot => slot).ToArray();
+ report.Warnings = report.Warnings.Concat(unreported.Select(slot => new CultSchemaMigrationWarning
+ {
+ Code = "ignored_extra_slot",
+ Message = $"Variant {record.Key} overrides slot {slot}, which {descriptor.DocumentType.Name} does not have; the override is ignored and shed on flush."
+ })).ToArray();
+ }
+
+ // The cache resolves it against its base when it admits the load.
+ return new CultStoredDocument(
+ new CultRecordKey(record.Key),
+ record.StoredAt,
+ descriptor,
+ new CultVariantDelta(record.Variant.BaseKey, record.Variant.Overrides.Where(Has).ToArray()),
+ null);
+ }
+
var document = deserializePayload(resolution.Descriptor.DocumentType, record.Payload);
return new CultStoredDocument(
new CultRecordKey(record.Key),
@@ -2556,8 +3069,8 @@ namespace GameCult.Caching
var persisted = new Dictionary(StringComparer.Ordinal);
foreach (var record in snapshot.Records)
{
- reports.Add(Registry.ResolvePersistedSchemaReport(record.SchemaId, snapshot.SchemaCatalog));
- var stored = ToStoredDocument(record, snapshot.SchemaCatalog, DeserializePayload);
+ var stored = ToStoredDocument(record, snapshot.SchemaCatalog, DeserializePayload, out var report);
+ reports.Add(report);
persisted[stored.Key.Value] = stored;
}
@@ -2640,6 +3153,11 @@ namespace GameCult.Caching
var catalog = ontoDisk
? disk.SchemaCatalog
: Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry());
+ // The committer judged the batch against its own view; the file may have moved since. A merge is judged on the
+ // set the file will hold, exactly as that set would be judged loading: what other processes wrote (arriving),
+ // what they removed (departing), and this batch.
+ if (ontoDisk && Judging != null && MergeInvolvesVariants(request, disk))
+ JudgeMerge(request, disk);
foreach (var entry in request.Deletes)
records.Remove(entry.Key.Value);
foreach (var entry in request.Upserts)
@@ -2654,6 +3172,35 @@ namespace GameCult.Caching
return CultCommitOutcome.Committed;
}
+ // Only a variant makes a merge more than the committer's own judgement: a variant it lands, one another writer wrote
+ // (read from the record's header, never its payload), or one this cache holds. A plain-only store never decodes
+ // another writer's records, so a schema this cache does not register cannot fail a commit that involves no variant.
+ private bool MergeInvolvesVariants(CultCommitRequest request, CultPersistedStoreSnapshot disk) =>
+ Cache?.HoldsVariants == true ||
+ request.Upserts.Any(entry => entry.Variant != null) ||
+ disk.Records.Any(record => record.Variant != null);
+
+ private void JudgeMerge(CultCommitRequest request, CultPersistedStoreSnapshot disk)
+ {
+ var landing = request.Upserts.Select(entry => entry.Key.Value).ToHashSet(StringComparer.Ordinal);
+ var removing = request.Deletes.Select(entry => entry.Key.Value).ToHashSet(StringComparer.Ordinal);
+ var onDisk = disk.Records.Select(record => record.Key).ToHashSet(StringComparer.Ordinal);
+ var arriving = new List(request.Upserts);
+ foreach (var record in disk.Records)
+ {
+ if (landing.Contains(record.Key) || removing.Contains(record.Key))
+ continue;
+ if (Entries.TryGetValue(record.Key, out var known) && known.StoredAt == record.StoredAt && known.Descriptor.SchemaId == record.SchemaId)
+ continue;
+ arriving.Add(ToStoredDocument(record, disk.SchemaCatalog, DeserializePayload, out _));
+ }
+
+ var departing = new List(request.Deletes);
+ departing.AddRange(Entries.Values.Where(known =>
+ !onDisk.Contains(known.Key.Value) && !landing.Contains(known.Key.Value) && !removing.Contains(known.Key.Value)));
+ Judging!(arriving, departing);
+ }
+
private CultPersistedStoreSnapshot? ReadSnapshot()
{
FileInfo.Refresh();
@@ -2675,6 +3222,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..ee5cc523
--- /dev/null
+++ b/src/GameCult.Caching/CultVariants.cs
@@ -0,0 +1,91 @@
+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; }
+ }
+}
diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
index 46bfb47e..700b7a50 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(
@@ -198,6 +200,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/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs
index 1a66dc2a..d53315ed 100644
--- a/src/GameCult.Networking/CultNetDatabaseServer.cs
+++ b/src/GameCult.Networking/CultNetDatabaseServer.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
@@ -502,16 +503,48 @@ namespace GameCult.Networking
string subscriptionId,
CultNetServerPeer peer)
{
+ // Keys this subscriber only ever received as a refusal: it was never given them as records.
+ var refused = new HashSet(StringComparer.Ordinal);
return _database.WatchAllChanges().Subscribe(change =>
{
- var outbound = CreateChangeMessage(change, subscriptionId, selection);
+ var outbound = DeliverChange(change, subscriptionId, selection, refused, error => peer.SendCultNet(error));
if (outbound != null)
- {
peer.SendCultNet(outbound);
- }
});
}
+ // One subscriber's view of one change. A variant is not a record CultNet can carry (Q6): the subscriber is told, by key, and
+ // is not later told the variant was removed, because it never held it.
+ internal CultNetDatabaseChangeRawMessage? DeliverChange(
+ object change,
+ string subscriptionId,
+ CultNetSelection selection,
+ HashSet refused,
+ Action sendError)
+ {
+ CultNetDatabaseChangeRawMessage? outbound;
+ try
+ {
+ outbound = CreateChangeMessage(change, subscriptionId, selection);
+ }
+ catch (NotSupportedException refusal)
+ {
+ var refusedKey = change.GetType().GetProperty("Key")?.GetValue(change) is CultRecordKey changed ? changed.Value : string.Empty;
+ lock (refused)
+ refused.Add(refusedKey);
+ sendError(new CultNetErrorMessage { Error = refusal.Message });
+ return null;
+ }
+
+ if (outbound == null)
+ return null;
+ var recordKey = outbound.RecordKey ?? outbound.Document?.RecordKey ?? string.Empty;
+ bool wasRefused;
+ lock (refused)
+ wasRefused = refused.Remove(recordKey);
+ return wasRefused && outbound.ChangeKind == "removed" ? null : outbound;
+ }
+
internal CultNetDatabaseChangeRawMessage? CreateChangeMessage(
object change,
string subscriptionId,
@@ -549,6 +582,7 @@ namespace GameCult.Networking
};
}
+ CultNetDocumentRegistry.RefuseVariantChange(_database.Cache, key);
return new CultNetDatabaseChangeRawMessage
{
MessageId = Guid.NewGuid().ToString("N"),
diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs
index 2ccb031c..f3150740 100644
--- a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs
+++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs
@@ -204,6 +204,13 @@ namespace GameCult.Networking
peer.SendCultNet(CultNetErrorMessage.ForReferenceOutsideTarget(ex));
return Task.CompletedTask;
}
+ catch (NotSupportedException refusal)
+ {
+ // Q6: a snapshot that selects a variant is refused to this subscriber as a typed error.
+ Withdraw(key, sendRemovals: false, forgetRequest: true);
+ peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message, subscriptionId));
+ return Task.CompletedTask;
+ }
catch
{
Withdraw(key, sendRemovals: false, forgetRequest: true);
@@ -315,7 +322,18 @@ namespace GameCult.Networking
}
var sourceRecordKey = ResolveChangeRecordKey(change);
- var matched = CreateMatchedRecord(change, request, peer);
+ CultNetRawDocumentRecord? matched;
+ try
+ {
+ matched = CreateMatchedRecord(change, request, peer);
+ }
+ catch (NotSupportedException refusal)
+ {
+ // Q6: a variant is not a record CultNet can carry; the subscriber that would have received it is told, by key.
+ peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message));
+ return;
+ }
+
ApplyProjectedChange(
projection,
sourceRecordKey,
@@ -331,9 +349,23 @@ namespace GameCult.Networking
if (!_projections.TryGetValue(key, out var projection)) return;
var legacyRequest = request.ToLegacyShape(key.Id);
var authorized = _authorizeRequest?.Invoke(legacyRequest, key.Peer) != false;
- var next = authorized
- ? CreateProjectedSnapshot(request, key.Peer)
- : ProjectedSnapshot.Empty;
+ ProjectedSnapshot next;
+ try
+ {
+ next = authorized
+ ? CreateProjectedSnapshot(request, key.Peer)
+ : ProjectedSnapshot.Empty;
+ }
+ catch (NotSupportedException refusal)
+ {
+ // Q6: the selection selects a variant, which CultNet cannot carry. The subscription is withdrawn, so the peer is
+ // told once, not once per store change; the rows it delivered are withdrawn too (removals first), so the peer
+ // does not keep them as live, and the typed error names the subscription that ended. A change never throws out of
+ // the change handler.
+ Withdraw(key, sendRemovals: true, forgetRequest: true);
+ key.Peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message, key.Id));
+ return;
+ }
// With _projectRecord gone (docs/cultnet-selection-cut.md, S2-2), a projection's dictionary
// key is always the record's own RecordKey/SchemaId, so the two can never disagree here;
@@ -541,6 +573,7 @@ namespace GameCult.Networking
if (kind == CultNetDatabaseChangeKind.Removed || document == null)
return null;
+ CultNetDocumentRegistry.RefuseVariantChange(_database.Cache, key);
return _database.Documents.ToRawRecord(descriptor, key, document, DateTimeOffset.UtcNow.ToString("O"));
}
diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs
index be114002..eb23d503 100644
--- a/src/GameCult.Networking/CultNetDocumentRegistry.cs
+++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs
@@ -391,11 +391,13 @@ namespace GameCult.Networking
var stored = cache.AllStoredDocuments;
if (rowFilter != null)
stored = stored.Where(entry => rowFilter(entry.Descriptor, entry.Key));
- var rows = stored
+ var entries = stored.ToArray();
+ var rows = entries
.Select(entry => new CultNetSelectionEvaluator.Row(
entry.Descriptor, entry.Key, entry.Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt))
.ToArray();
var full = CultNetSelectionEvaluator.EvaluateAll(_documents, rows, selection);
+ RefuseVariants(entries, full.Ordered.Select(row => row.Key));
// R-Q: a page's asOf is exact only when every matched row is committed through one shard's
// log - a caller that knows about shards (CultNetDatabaseServer) supplies shardIdOf; one
// that does not (the raw v0/shard-scoped rowFilter paths, which are already bounded to a
@@ -415,6 +417,7 @@ namespace GameCult.Networking
}
var evaluation = CultNetSelectionEvaluator.Page(full, selection, asOf, cursorKey);
+ RefuseVariants(entries, evaluation.Edges.Select(edge => edge.From.Key));
var records = evaluation.Rows.Select(row => ToRawRecord(row, options)).ToArray();
var wantDocument = selection.Projection == CultNetSelectionProjections.Document;
@@ -485,17 +488,20 @@ namespace GameCult.Networking
var stored = cache.AllStoredDocuments;
if (rowFilter != null)
stored = stored.Where(entry => rowFilter(entry.Descriptor, entry.Key));
- var rows = stored
+ var entries = stored.ToArray();
+ var rows = entries
.Select(entry => new CultNetSelectionEvaluator.Row(
entry.Descriptor, entry.Key, entry.Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt))
.ToArray();
var full = CultNetSelectionEvaluator.EvaluateAll(_documents, rows, selection);
+ RefuseVariants(entries, full.Ordered.Select(row => row.Key));
var records = full.Ordered.Select(row => ToRawRecord(row, options)).ToArray();
var wantDocument = selection.Projection == CultNetSelectionProjections.Document;
- var edges = selection.HasHop
- ? CultNetSelectionEvaluator.EdgesFor(full.Ordered, full).Select(edge => ToEdge(edge, wantDocument)).ToArray()
- : null;
+ var hopEdges = selection.HasHop ? CultNetSelectionEvaluator.EdgesFor(full.Ordered, full) : null;
+ if (hopEdges != null)
+ RefuseVariants(entries, hopEdges.Select(edge => edge.From.Key));
+ var edges = hopEdges?.Select(edge => ToEdge(edge, wantDocument)).ToArray();
return new CultNetSelectionPage
{
@@ -549,6 +555,34 @@ 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.
+ // The refusal follows the selection: a variant is refused only when the selection selects it, never for existing. An
+ // edge carries its citer's reference payload, read from the citer's resolved document, so a variant citing is selected
+ // by the selection's hop exactly as a variant row is.
+ private static void RefuseVariants(IReadOnlyList entries, IEnumerable selected)
+ {
+ if (!entries.Any(entry => entry.Variant != null))
+ return;
+ var variants = entries.Where(entry => entry.Variant != null).ToDictionary(entry => entry.Key.Value, StringComparer.Ordinal);
+ foreach (var key in selected)
+ {
+ if (variants.TryGetValue(key.Value, out var variant))
+ throw VariantRefusal(variant);
+ }
+ }
+
+ // The live-change funnel's refusal: a load or write that lands a variant must not reach a subscriber as a record.
+ internal static void RefuseVariantChange(CultCache cache, CultRecordKey key)
+ {
+ // Recorded, not fixed: a Flatten committed on another thread between the change's admission and this read makes
+ // the record plain here, and its resolved view is delivered. The window is one thread hop wide.
+ if (cache.GetStored(key) is { Variant: not null } entry)
+ throw VariantRefusal(entry);
+ }
+
+ private static NotSupportedException VariantRefusal(CultStoredDocument entry) =>
+ new($"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").
diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs
index f343fad7..4daa76d5 100644
--- a/src/GameCult.Networking/CultNetSchemaMessages.cs
+++ b/src/GameCult.Networking/CultNetSchemaMessages.cs
@@ -546,6 +546,18 @@ namespace GameCult.Networking
: null
};
+ ///
+ /// Builds the wire error for a selection that selects a document variant, which CultNet does not carry yet: code
+ /// variant_unsupported , details { field: "subscriptionId", value } when a subscription was ended by it.
+ ///
+ public static CultNetErrorMessage ForVariantUnsupported(string message, string? subscriptionId = null) =>
+ new CultNetErrorMessage
+ {
+ Error = message,
+ Code = "variant_unsupported",
+ Details = subscriptionId == null ? null : new CultNetErrorDetails { Field = "subscriptionId", Value = subscriptionId }
+ };
+
///
/// Builds the wire error for an out-of-target reference refusal (R-N): code
/// reference_outside_target , no details.
diff --git a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
index 479eb762..181eca56 100644
--- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
+++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
@@ -131,11 +131,30 @@ namespace GameCult.Caching.Tests
Assert.That(message, Does.Contain(ItemSchemaId));
}
+ // C1 reads v2. This vector was written by hand before C1 existed and its variant keeps its base's name, so a
+ // cache with a codec refuses it under R6, naming both records; a cache without one cannot resolve it at all.
[Test]
- public void SingleFileRefusesVariantStoreByVersionOrRecord()
+ public void SingleFileRefusesTheHandWrittenVariantVectorNamingTheRecords()
{
- Assert.That(Refusal("variant-v2.msgpack"),
- Does.Contain("cultcache.store.v2").Or.Contain("item:anvil-big"));
+ var root = Path.Combine(Path.GetTempPath(), $"cultlib-vector-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(root);
+ try
+ {
+ var file = Path.Combine(root, "store.msgpack");
+ File.Copy(VectorPath("variant-v2.msgpack"), file);
+
+ using var withCodec = new CultCache(CultDocumentRegistry.Shared, CultCacheMessagePack.CreateCodec(CultDocumentRegistry.Shared));
+ var named = Assert.Throws(() => withCodec.AddBackingStore(new SingleFileMessagePackBackingStore(file)))!;
+ Assert.That(named.Message, Does.Contain("item:anvil-big").And.Contain("item:anvil"));
+
+ using var without = new CultCache();
+ var codecless = Assert.Throws(() => without.AddBackingStore(new SingleFileMessagePackBackingStore(file)))!;
+ Assert.That(codecless.Message, Does.Contain("item:anvil-big").And.Contain("codec"));
+ }
+ finally
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
+ }
}
[Test]
diff --git a/tests/GameCult.Caching.Tests/VariantTests.cs b/tests/GameCult.Caching.Tests/VariantTests.cs
new file mode 100644
index 00000000..c8d7a8df
--- /dev/null
+++ b/tests/GameCult.Caching.Tests/VariantTests.cs
@@ -0,0 +1,1461 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using MessagePack;
+using NUnit.Framework;
+using R3;
+
+namespace GameCult.Caching.Tests
+{
+ // The contract is src/GameCult.Caching/Contracts/cultcache-persistence-format.md (variant records) and
+ // docs/document-variants-cut.md C1. Every input goes through the cache's own write API and the real single-file store.
+ public class VariantTests
+ {
+ private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[]
+ {
+ typeof(VariantGear), typeof(VariantOther), typeof(VariantGlobal)
+ });
+
+ private static readonly CultRecordKey BaseKey = new("laser");
+ private static readonly CultRecordKey BigKey = new("laser-big");
+
+ private string _directory = string.Empty;
+
+ [SetUp]
+ public void CreateDirectory()
+ {
+ _directory = Path.Combine(Path.GetTempPath(), $"cultlib-variants-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_directory);
+ }
+
+ [TearDown]
+ public void DeleteDirectory()
+ {
+ if (Directory.Exists(_directory))
+ Directory.Delete(_directory, recursive: true);
+ }
+
+ private string PathOf(string name) => Path.Combine(_directory, name);
+
+ private static CultCache Open(string path, bool readOnly = false, CultDocumentRegistry? registry = null) =>
+ CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry ?? Registry, ReadOnly = readOnly });
+
+ private static VariantGear Laser() => new() { Name = "laser", Power = 10, Code = "l1", Tags = { "beam" } };
+
+ private static void SeedBase(CultCache cache) =>
+ cache.Commit(batch => batch.Upsert(typeof(VariantGear), Laser(), BaseKey));
+
+ // laser-big: a variant of laser that overrides its name, power and code (an indexed value must be its own) and inherits the rest.
+ private static void SeedBig(CultCache cache, CultRecordKey? key = null, CultRecordKey? baseKey = null, string name = "laser big", int power = 25) =>
+ cache.Commit(batch => batch.UpsertVariant(key ?? BigKey, baseKey ?? BaseKey, new[]
+ {
+ cache.Override(nameof(VariantGear.Name), name),
+ cache.Override(nameof(VariantGear.Power), power),
+ cache.Override(nameof(VariantGear.Code), name)
+ }));
+
+ private static void EditBase(CultCache cache, Action edit) =>
+ cache.Commit(batch =>
+ {
+ var edited = Laser();
+ edit(edited);
+ batch.Upsert(typeof(VariantGear), edited, BaseKey);
+ });
+
+ private static InvalidOperationException Refused(TestDelegate write) =>
+ Assert.Throws(write)!;
+
+ // ---- resolution ----
+
+ [Test]
+ public void ALoadedVariantResolvesAgainstItsBase()
+ {
+ var path = PathOf("gear.cc");
+ using (var writer = Open(path))
+ {
+ SeedBase(writer);
+ SeedBig(writer);
+ }
+
+ using var cache = Open(path);
+ var big = cache.Get(BigKey)!;
+ Assert.That(big.Name, Is.EqualTo("laser big"));
+ Assert.That(big.Power, Is.EqualTo(25));
+ Assert.That(big.Code, Is.EqualTo("laser big"), "the override the unique index requires");
+ Assert.That(big.Tags, Is.EqualTo(new[] { "beam" }), "an inherited list");
+ Assert.That(cache.Get(BaseKey)!.Power, Is.EqualTo(10));
+ Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(BaseKey.Value));
+ Assert.That(cache.GetStored(BigKey)!.Variant!.Overrides, Has.Count.EqualTo(3));
+ }
+
+ [Test]
+ public void AVariantOfAVariantResolvesBaseFirstAndFollowsTheRoot()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+ SeedBig(cache);
+ var hugeKey = new CultRecordKey("laser-huge");
+ SeedBig(cache, hugeKey, BigKey, name: "laser huge", power: 40);
+
+ Assert.That(cache.Get(hugeKey)!.Tags, Is.EqualTo(new[] { "beam" }));
+ EditBase(cache, gear => gear.Tags.Add("pierce"));
+
+ Assert.That(cache.Get(hugeKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
+ Assert.That(cache.Get(hugeKey)!.Power, Is.EqualTo(40));
+ }
+
+ [Test]
+ public void ABaseEditReachesItsVariantsThroughGetNameAndIndexAndOneWatchPublication()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+ SeedBig(cache);
+ cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("laser-coded"), BaseKey, new[]
+ {
+ cache.Override(nameof(VariantGear.Name), "laser coded"),
+ cache.Override(nameof(VariantGear.Code), "l9")
+ }));
+
+ var seen = new List<(string Key, long Sequence, string BigTagsAtThatMoment, string BigNameLookupTags)>();
+ using var subscription = cache.Watch().Subscribe(change => seen.Add((
+ change.Key.Value,
+ change.Sequence,
+ string.Join(",", cache.Get(BigKey)!.Tags),
+ string.Join(",", cache.GetByName("laser big")!.Tags))));
+
+ EditBase(cache, gear => { gear.Tags.Add("pierce"); gear.Code = "l2"; });
+
+ Assert.That(cache.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
+ Assert.That(cache.GetByName("laser big")!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
+ Assert.That(cache.GetByName("laser big")!.Power, Is.EqualTo(25), "the override survives the base edit");
+ Assert.That(cache.GetByIndex("code", "l9")!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
+ // the base's old code entry must be gone, not shadowing, and its new one must find the base itself.
+ Assert.That(cache.GetByIndex("code", "l1"), Is.Null);
+ Assert.That(cache.GetByIndex("code", "l2")!.Name, Is.EqualTo("laser"));
+ Assert.That(cache.GetByIndex("code", "laser big")!.Name, Is.EqualTo("laser big"));
+
+ Assert.That(seen.Select(entry => entry.Key), Is.EquivalentTo(new[] { BaseKey.Value, BigKey.Value, "laser-coded" }));
+ Assert.That(seen.Select(entry => entry.Sequence), Is.EqualTo(Enumerable.Range((int)seen[0].Sequence, seen.Count).Select(n => (long)n)),
+ "one commit, one run of sequences with no gap");
+ Assert.That(seen.Select(entry => entry.BigTagsAtThatMoment), Is.All.EqualTo("beam,pierce"),
+ "a subscriber observing the base change already reads the new variant");
+ Assert.That(seen.Select(entry => entry.BigNameLookupTags), Is.All.EqualTo("beam,pierce"));
+ }
+
+ [Test]
+ public void AReResolvedDependentKeepsItsStoredAtAndIsAnUpdatedChange()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+ SeedBig(cache);
+ var before = cache.GetStored(BigKey)!;
+ var changes = new List>();
+ using var subscription = cache.WatchRecord(BigKey).Subscribe(changes.Add);
+
+ EditBase(cache, gear => gear.Tags.Add("pierce"));
+
+ Assert.That(cache.GetStored(BigKey)!.StoredAt, Is.EqualTo(before.StoredAt), "a base change stamps no dependent");
+ Assert.That(changes, Has.Count.EqualTo(1));
+ Assert.That(changes[0].Kind, Is.EqualTo(CultCacheDocumentChangeKind.Updated));
+ Assert.That(changes[0].PreviousDocument!.Tags, Is.EqualTo(new[] { "beam" }));
+ Assert.That(changes[0].Document!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
+ }
+
+ [Test]
+ public void ExpectOnAVariantSurvivesABaseChangeButNotAChangeToItsDelta()
+ {
+ var path = PathOf("gear.cc");
+ using (var seed = Open(path))
+ {
+ SeedBase(seed);
+ SeedBig(seed);
+ }
+
+ using var a = Open(path);
+ using var b = Open(path);
+ var seen = a.Get(BigKey)!;
+
+ EditBase(b, gear => gear.Tags.Add("pierce"));
+ Assert.That(a.Commit(batch =>
+ {
+ batch.Expect(BigKey, seen);
+ batch.Upsert(typeof(VariantOther), new VariantOther { Name = "marker" }, new CultRecordKey("marker"));
+ }), Is.True, "the base changed; the variant's stored delta did not");
+
+ a.PullAllBackingStoresAsync();
+ seen = a.Get(BigKey)!;
+ SeedBig(b, power: 26);
+ Assert.That(a.Commit(batch =>
+ {
+ batch.Expect(BigKey, seen);
+ batch.Upsert(typeof(VariantOther), new VariantOther { Name = "second" }, new CultRecordKey("second"));
+ }), Is.False, "another writer changed the variant's own delta");
+ }
+
+ // ---- refusals, each naming the keys ----
+
+ [Test]
+ public void ACycleOfBasesIsRefusedNamingEveryKey()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+ var a = new CultRecordKey("cycle-a");
+ var b = new CultRecordKey("cycle-b");
+
+ var staged = Refused(() => cache.Commit(batch =>
+ {
+ batch.UpsertVariant(a, b);
+ batch.UpsertVariant(b, a);
+ }));
+ Assert.That(staged.Message, Does.Contain("cycle").And.Contain(a.Value).And.Contain(b.Value));
+
+ SeedBig(cache);
+ var huge = new CultRecordKey("laser-huge");
+ SeedBig(cache, huge, BigKey, name: "laser huge");
+ var rebased = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, huge)));
+ Assert.That(rebased.Message, Does.Contain("cycle").And.Contain(BigKey.Value).And.Contain(huge.Value));
+ Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(BaseKey.Value), "nothing landed");
+ }
+
+ [Test]
+ public void AMissingBaseIsRefusedNamingBoth()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ var error = Refused(() => cache.UpsertVariantAsync(BigKey, new CultRecordKey("no-such-base")).GetAwaiter().GetResult());
+ Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("no-such-base"));
+ Assert.That(cache.Get(BigKey), Is.Null);
+ }
+
+ [Test]
+ public void ABaseInAnotherTypeIsRefusedOnRebaseAndOnLoad()
+ {
+ var path = PathOf("gear.cc");
+ using (var cache = Open(path))
+ {
+ SeedBase(cache);
+ SeedBig(cache);
+ cache.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "other" }, new CultRecordKey("other")));
+
+ var rebase = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, new CultRecordKey("other"))));
+ Assert.That(rebase.Message, Does.Contain(BigKey.Value).And.Contain("VariantOther"));
+ }
+
+ // A store whose variant record names another type than its base's: the loader refuses it naming both keys.
+ var bad = PathOf("bad.cc");
+ var gear = Registry.GetRequired();
+ var other = Registry.GetRequired();
+ File.WriteAllBytes(bad, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot
+ {
+ FormatVersion = CultPersistedStoreSnapshot.FormatV2,
+ SchemaCatalog = new[] { gear.ToCatalogEntry(), other.ToCatalogEntry() },
+ Records = new[]
+ {
+ new CultPersistedRecord
+ {
+ Key = "gear-base", SchemaId = gear.SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z",
+ Payload = CultDocumentMessagePackSerialization.SerializeUntyped(Laser(), typeof(VariantGear), Registry)
+ },
+ new CultPersistedRecord
+ {
+ Key = "other-variant", SchemaId = other.SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z",
+ Variant = new CultVariantDelta("gear-base", Array.Empty())
+ }
+ }
+ }));
+ var load = Refused(() => Open(bad).Dispose());
+ Assert.That(load.Message, Does.Contain("other-variant").And.Contain("gear-base"));
+ }
+
+ [Test]
+ public void AVariantOfAGlobalIsRefused()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ var key = cache.UpsertAsync(new VariantGlobal { Value = "one" }).GetAwaiter().GetResult().Key;
+ var error = Refused(() => cache.UpsertVariantAsync(BigKey, key).GetAwaiter().GetResult());
+ Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("global"));
+ }
+
+ [Test]
+ public void RemovingABaseThatKeepsVariantsIsRefusedUnlessTheSameBatchRemovesOrRebasesThem()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+ var spare = new CultRecordKey("spare");
+ cache.Commit(batch => batch.Upsert(typeof(VariantGear), new VariantGear { Name = "spare", Power = 3 }, spare));
+ SeedBig(cache);
+
+ var refused = Refused(() => cache.Commit(batch => batch.Remove(BaseKey)));
+ Assert.That(refused.Message, Does.Contain(BaseKey.Value).And.Contain(BigKey.Value));
+ Assert.That(cache.Get(BaseKey), Is.Not.Null);
+ Assert.That(cache.Get(BigKey), Is.Not.Null);
+
+ Assert.That(cache.Commit(batch =>
+ {
+ batch.UpsertVariant(BigKey, spare, cache.GetStored(BigKey)!.Variant!.Overrides);
+ batch.Remove(BaseKey);
+ }), Is.True, "rebased in the same batch");
+ Assert.That(cache.Get(BigKey)!.Tags, Is.Empty, "now resolved from spare");
+
+ Assert.That(cache.Commit(batch =>
+ {
+ batch.Remove(spare);
+ batch.Remove(BigKey);
+ }), Is.True, "removed in the same batch");
+
+ SeedBase(cache);
+ var removedInTheBatch = Refused(() => cache.Commit(batch =>
+ {
+ batch.Remove(BaseKey);
+ batch.UpsertVariant(BigKey, BaseKey);
+ }));
+ Assert.That(removedInTheBatch.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value));
+ }
+
+ [Test]
+ public void AVariantThatKeepsItsBasesNameIsRefused()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+ var error = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey).GetAwaiter().GetResult());
+ Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("laser"));
+
+ var sameName = Refused(() => SeedBig(cache, name: "laser"));
+ Assert.That(sameName.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value));
+
+ SeedBig(cache);
+ var grand = new CultRecordKey("laser-grand");
+ var grandOfBase = Refused(() => SeedBig(cache, grand, BigKey, name: "laser"));
+ Assert.That(grandOfBase.Message, Does.Contain(grand.Value).And.Contain(BaseKey.Value), "the whole chain, not only the parent");
+ }
+
+ [Test]
+ public void ABaseEditThatWouldGiveAVariantItsBasesNameIsRefused()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+ SeedBig(cache);
+
+ var error = Refused(() => EditBase(cache, gear => gear.Name = "laser big"));
+ Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value));
+ Assert.That(cache.Get(BaseKey)!.Name, Is.EqualTo("laser"));
+ }
+
+ [Test]
+ public void APlainWriteAtAVariantsKeyIsRefusedByEveryPathAndFlattenIsTheExplicitWay()
+ {
+ var path = PathOf("gear.cc");
+ using var cache = Open(path);
+ SeedBase(cache);
+ SeedBig(cache);
+ var plain = new VariantGear { Name = "laser big", Power = 99 };
+
+ Assert.That(Refused(() => cache.UpsertAsync(typeof(VariantGear), plain, BigKey).GetAwaiter().GetResult()).Message,
+ Does.Contain(BigKey.Value));
+ Assert.That(Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), plain, BigKey))).Message,
+ Does.Contain(BigKey.Value));
+ Assert.That(cache.GetStored(BigKey)!.Variant, Is.Not.Null);
+ Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25));
+
+ cache.FlattenAsync(BigKey).GetAwaiter().GetResult();
+ var flat = cache.GetStored(BigKey)!;
+ Assert.That(flat.Variant, Is.Null);
+ Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25), "flatten keeps the resolved values");
+ EditBase(cache, gear => gear.Tags.Add("pierce"));
+ Assert.That(cache.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam" }), "a flattened record no longer follows its base");
+ cache.UpsertAsync(typeof(VariantGear), plain, BigKey).GetAwaiter().GetResult();
+ Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(99), "and is an ordinary record again");
+ cache.FlushAsync().GetAwaiter().GetResult();
+ Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "no variant left in the store");
+ }
+
+ [Test]
+ public void FlattenRefusesARecordThatIsNotAVariantAndAPlainRecordCannotBecomeOne()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+ Assert.That(Refused(() => cache.FlattenAsync(BaseKey).GetAwaiter().GetResult()).Message, Does.Contain(BaseKey.Value));
+ var spare = new CultRecordKey("spare");
+ cache.Commit(batch => batch.Upsert(typeof(VariantGear), new VariantGear { Name = "spare" }, spare));
+ var named = new[] { cache.Override(nameof(VariantGear.Name), "spare variant") };
+ Assert.That(Refused(() => cache.UpsertVariantAsync(spare, BaseKey, named).GetAwaiter().GetResult()).Message, Does.Contain(spare.Value));
+ Assert.That(cache.GetStored(spare)!.Variant, Is.Null, "the plain record is still plain");
+ }
+
+ [Test]
+ public void ACacheWithoutACodecHoldsNoVariants()
+ {
+ using var cache = new CultCache(Registry);
+ cache.Commit(batch => batch.Upsert(typeof(VariantGear), Laser(), BaseKey));
+ var error = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey).GetAwaiter().GetResult());
+ Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("codec"));
+ }
+
+ [Test]
+ public void OnlyTopLevelSetOverridesResolveInThisCut()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+ var element = new CultVariantOverride(
+ CultOverrideOp.Set, new[] { new CultPathStep(3, "el-1"), new CultPathStep(0) }, string.Empty, new byte[] { 0x01 });
+ var error = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey, new[] { element }).GetAwaiter().GetResult());
+ Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("top-level"));
+
+ var insert = new CultVariantOverride(CultOverrideOp.Insert, new[] { new CultPathStep(3) }, "new", new byte[] { 0xa1, 0x78 });
+ Assert.That(Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey, new[] { insert }).GetAwaiter().GetResult()).Message,
+ Does.Contain(BigKey.Value).And.Contain("top-level"));
+ }
+
+ // ---- soft drift: an override drifts exactly as the same member would on a plain record ----
+
+ [Test]
+ public void AnOverrideOfASlotTheTypeNoLongerHasIsIgnoredWithAWarningNamingVariantAndMember()
+ {
+ var path = PathOf("drift.cc");
+ var wide = CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) });
+ using (var writer = Open(path, registry: wide))
+ {
+ writer.Commit(batch =>
+ {
+ batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base"));
+ batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[]
+ {
+ writer.Override(nameof(DriftWide.Name), "drift variant"),
+ writer.Override(nameof(DriftWide.Power), 7),
+ writer.Override(nameof(DriftWide.Wing), "variant wing")
+ });
+ });
+ }
+
+ var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) });
+ using var cache = Open(path, registry: narrow);
+ var variant = cache.Get(new CultRecordKey("drift-variant"))!;
+ Assert.That(variant.Power, Is.EqualTo(7), "the override the type still has applies");
+ Assert.That(variant.Name, Is.EqualTo("drift variant"));
+
+ // The same decision, on the same surface, that the plain base record gets from the store's schema resolution.
+ var reports = cache.BackingStores[0].LastSchemaMigrationReports;
+ Assert.That(reports, Has.Count.EqualTo(2), "one per record: the base and the variant");
+ Assert.That(reports.Select(report => report.Kind), Is.All.EqualTo(CultSchemaMigrationKind.CompatibleDrift));
+ Assert.That(reports.Select(report => string.Join(",", report.IgnoredExtraSlots)), Is.All.EqualTo("2"));
+ Assert.That(reports.Select(report => report.Warnings.Select(warning => warning.Code)), Has.All.Contain("ignored_extra_slot"));
+ }
+
+ private static readonly byte[] NotAnInt = { 0xa3, (byte)'a', (byte)'b', (byte)'c' };
+
+ private static CultVariantOverride TextOverride(int slot, string value)
+ {
+ var bytes = new byte[value.Length + 1];
+ bytes[0] = (byte)(0xa0 | value.Length);
+ for (var index = 0; index < value.Length; index++)
+ bytes[index + 1] = (byte)value[index];
+ return CultVariantOverride.Set(slot, bytes);
+ }
+
+ private static CultPersistedRecord OverridingRecord(string key, string baseKey, params CultVariantOverride[] overrides) => new()
+ {
+ Key = key,
+ SchemaId = Registry.GetRequired().SchemaId,
+ StoredAt = "2026-01-01T00:00:00.0000000Z",
+ Payload = Array.Empty(),
+ Variant = new CultVariantDelta(baseKey, overrides)
+ };
+
+ [Test]
+ public void AnOverrideThatNoLongerDecodesRefusesTheLoadAsAPlainRecordHoldingItDoes()
+ {
+ // VariantGear.Power is [Key(1)] int; a string there is the nested-type-change case with the schema id unchanged.
+ var plainRecord = new CultPersistedRecord
+ {
+ Key = "hand-plain",
+ SchemaId = Registry.GetRequired().SchemaId,
+ StoredAt = "2026-01-01T00:00:00.0000000Z",
+ Payload = new byte[] { 0x94, 0xa1, (byte)'x', 0xa3, (byte)'a', (byte)'b', (byte)'c', 0xa0, 0x90 }
+ };
+ Assert.That(Assert.Catch(() => Open(WriteStore(plainRecord)).Dispose()), Is.Not.Null, "the plain-record outcome is a refusal");
+
+ var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base", CultVariantOverride.Set(1, NotAnInt)));
+ var refused = Assert.Catch(() => Open(path).Dispose())!;
+ Assert.That(refused.Message, Does.Contain("hand-variant").And.Contain("Power"));
+ }
+
+ [Test]
+ public void AnOverrideOfASlotTheTypeNeverHadIsIgnoredOnLoadAsAPlainRecordIgnoresAnExtraSlot()
+ {
+ var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base",
+ CultVariantOverride.Set(99, new byte[] { 0x01 }),
+ TextOverride(0, "hand variant"),
+ TextOverride(2, "hand-code")));
+ using var cache = Open(path);
+ Assert.That(cache.Get(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant"));
+ Assert.That(cache.Get(new CultRecordKey("hand-variant"))!.Power, Is.EqualTo(10), "inherits the base");
+ }
+
+ // ---- a bad write is refused; drift is for type changes after the fact ----
+
+ [Test]
+ public void AnInvalidOverrideIsRefusedAtWriteNamingVariantSlotAndReasonAndNothingLands()
+ {
+ var path = PathOf("gear.cc");
+ using var cache = Open(path);
+ SeedBase(cache);
+
+ var unknown = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
+ {
+ CultVariantOverride.Set(99, new byte[] { 0x01 })
+ })));
+ Assert.That(unknown.Message, Does.Contain(BigKey.Value).And.Contain("99").And.Contain("does not have"));
+
+ var wrongType = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey, new[]
+ {
+ CultVariantOverride.Set(1, NotAnInt)
+ }).GetAwaiter().GetResult());
+ Assert.That(wrongType.Message, Does.Contain(BigKey.Value).And.Contain("Power").And.Contain("slot 1"));
+
+ Assert.That(cache.Get(BigKey), Is.Null);
+ cache.FlushAsync().GetAwaiter().GetResult();
+ Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "no variant was persisted");
+ }
+
+ [Test]
+ public void TwoOverridesOfOneSlotAreRefusedAtWriteAndAnUnknownMemberNameIsAnArgumentException()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+
+ var twice = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
+ {
+ cache.Override(nameof(VariantGear.Power), 1),
+ cache.Override(nameof(VariantGear.Power), 2)
+ })));
+ Assert.That(twice.Message, Does.Contain(BigKey.Value).And.Contain("slot 1").And.Contain("more than once"));
+ Assert.That(cache.Get(BigKey), Is.Null);
+
+ var unknownName = Assert.Throws(() => cache.Override("Nope", 1))!;
+ Assert.That(unknownName.Message, Does.Contain("Nope"));
+ }
+
+ // ---- a variant may not share an indexed value with another record ----
+
+ [Test]
+ public void AVariantThatInheritsAnIndexedValueIsRefusedNamingBothKeysAndTheIndex()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+
+ var inherited = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
+ {
+ cache.Override(nameof(VariantGear.Name), "laser big")
+ })));
+ Assert.That(inherited.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code").And.Contain("l1"));
+
+ var explicitly = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
+ {
+ cache.Override(nameof(VariantGear.Name), "laser big"),
+ cache.Override(nameof(VariantGear.Code), "l1")
+ })));
+ Assert.That(explicitly.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value));
+ Assert.That(cache.Get(BigKey), Is.Null, "nothing landed");
+ Assert.That(cache.GetByIndex("code", "l1")!.Name, Is.EqualTo("laser"));
+
+ SeedBig(cache);
+ Assert.That(cache.GetByIndex("code", "l1")!.Name, Is.EqualTo("laser"));
+ Assert.That(cache.GetByIndex("code", "laser big")!.Name, Is.EqualTo("laser big"));
+ }
+
+ [Test]
+ public void ABaseEditThatWouldGiveItsVariantsIndexedValueToTheBaseIsRefused()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+ SeedBig(cache);
+
+ var refused = Refused(() => EditBase(cache, gear => gear.Code = "laser big"));
+ Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code"));
+ Assert.That(cache.Get(BaseKey)!.Code, Is.EqualTo("l1"), "nothing landed");
+ }
+
+ [Test]
+ public void ALoadedVariantThatInheritsAnIndexedValueRefusesTheLoadNamingBothKeys()
+ {
+ var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base", TextOverride(0, "hand variant")));
+ var refused = Assert.Throws(() => Open(path).Dispose())!;
+ Assert.That(refused.Message, Does.Contain("hand-variant").And.Contain("hand-base").And.Contain("code"));
+ }
+
+ // ---- the index rule holds for the commit's result, in either write order, and the store always reopens ----
+
+ private static VariantGear Other(string code) => new() { Name = "other", Power = 1, Code = code };
+
+ [Test]
+ public async Task APlainWriteThatWouldShareAVariantsIndexedValueIsRefusedNamingBothKeysAndTheStoreReopens()
+ {
+ var path = PathOf("gear.cc");
+ var other = new CultRecordKey("other");
+ using (var cache = Open(path))
+ {
+ SeedBase(cache);
+ SeedBig(cache);
+
+ var refused = Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other)));
+ Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("laser big"));
+
+ var single = Assert.ThrowsAsync(
+ () => cache.UpsertAsync(Other("laser big"), new CultRecordHandle(other)))!;
+ Assert.That(single.Message, Does.Contain(other.Value).And.Contain(BigKey.Value));
+
+ Assert.That(cache.Get(other), Is.Null, "nothing landed");
+ Assert.That(cache.GetByIndex("code", "laser big")!.Name, Is.EqualTo("laser big"), "the variant is still found by its value");
+ EditBase(cache, gear => gear.Power = 11);
+ Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25), "the variant is still editable through its base");
+ await Task.CompletedTask;
+ }
+
+ using var reopened = Open(path);
+ Assert.That(reopened.GetByIndex("code", "laser big")!.Name, Is.EqualTo("laser big"));
+ Assert.That(reopened.Get(other), Is.Null);
+ }
+
+ [Test]
+ public void ABaseEditOrANewPlainRecordFirstThenAVariantOnTheSameValueIsRefusedAndTheStoreReopens()
+ {
+ var path = PathOf("gear.cc");
+ var other = new CultRecordKey("other");
+ using (var cache = Open(path))
+ {
+ SeedBase(cache);
+ cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other));
+
+ var refused = Refused(() => SeedBig(cache));
+ Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(other.Value).And.Contain("code").And.Contain("laser big"));
+ Assert.That(cache.Get(BigKey), Is.Null, "nothing landed");
+
+ // The same batch, either staging order: the result is what is judged.
+ var sameBatchPlainFirst = Refused(() => cache.Commit(batch =>
+ {
+ batch.Upsert(typeof(VariantGear), Other("taken"), new CultRecordKey("taker"));
+ batch.UpsertVariant(BigKey, BaseKey, new[]
+ {
+ cache.Override(nameof(VariantGear.Name), "laser big"),
+ cache.Override(nameof(VariantGear.Code), "taken")
+ });
+ }));
+ Assert.That(sameBatchPlainFirst.Message, Does.Contain(BigKey.Value).And.Contain("taker"));
+ var sameBatchVariantFirst = Refused(() => cache.Commit(batch =>
+ {
+ batch.UpsertVariant(BigKey, BaseKey, new[]
+ {
+ cache.Override(nameof(VariantGear.Name), "laser big"),
+ cache.Override(nameof(VariantGear.Code), "taken")
+ });
+ batch.Upsert(typeof(VariantGear), Other("taken"), new CultRecordKey("taker"));
+ }));
+ Assert.That(sameBatchVariantFirst.Message, Does.Contain(BigKey.Value).And.Contain("taker"));
+
+ // Plain-vs-plain sharing is not this rule's business.
+ Assert.DoesNotThrow(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("l1"), new CultRecordKey("twin"))));
+ }
+
+ using var reopened = Open(path);
+ Assert.That(reopened.Get(BigKey), Is.Null);
+ Assert.That(reopened.Get(other), Is.Not.Null);
+ }
+
+ // ---- a variant sheds overrides its type lacks when it is flushed, as a plain record sheds the member ----
+
+ [Test]
+ public void AFlushShedsAnOverrideOfADroppedSlotSoALaterTypeReusingTheSlotDoesNotResurrectIt()
+ {
+ var path = PathOf("drift.cc");
+ var wide = CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) });
+ using (var writer = Open(path, registry: wide))
+ {
+ writer.Commit(batch =>
+ {
+ batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base"));
+ batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[]
+ {
+ writer.Override(nameof(DriftWide.Name), "drift variant"),
+ writer.Override(nameof(DriftWide.Wing), "stale wing")
+ });
+ });
+ }
+
+ using (var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) })))
+ narrow.BackingStores[0].PushAll();
+
+ Assert.That(OverriddenSlotsOn(path, "drift-variant"), Is.EqualTo(new[] { 0 }), "the dropped slot's override is gone from the flushed record");
+
+ using var reused = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftReused) }));
+ Assert.That(reused.Get(new CultRecordKey("drift-base"))!.Colour, Is.Empty);
+ Assert.That(reused.Get(new CultRecordKey("drift-variant"))!.Colour, Is.Empty, "the variant inherits its base's colour, not the old wing");
+ }
+
+ [Test]
+ public void AnOverrideOfASlotTheTypeNeverHadIsReportedOnLoadAndShedOnTheNextFlush()
+ {
+ var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base",
+ CultVariantOverride.Set(99, new byte[] { 0x01 }),
+ TextOverride(0, "hand variant"),
+ TextOverride(2, "hand-code")));
+ using (var cache = Open(path))
+ {
+ var variantReport = cache.BackingStores[0].LastSchemaMigrationReports.Single(report => report.IgnoredExtraSlots.Length > 0);
+ Assert.That(variantReport.IgnoredExtraSlots, Is.EqualTo(new[] { 99 }));
+ Assert.That(variantReport.Warnings.Select(warning => warning.Code), Has.Some.EqualTo("ignored_extra_slot"));
+ Assert.That(variantReport.Warnings.Select(warning => warning.Message), Has.Some.Contain("hand-variant"));
+ cache.BackingStores[0].PushAll();
+ }
+
+ Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 }));
+ using var reopened = Open(path);
+ Assert.That(reopened.BackingStores[0].LastSchemaMigrationReports.SelectMany(report => report.IgnoredExtraSlots), Is.Empty);
+ Assert.That(reopened.Get(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant"));
+ }
+
+ private static int[] OverriddenSlotsOn(string path, string key) =>
+ CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records
+ .Single(record => record.Key == key).Variant!.Overrides.Select(entry => entry.Path[0].Slot).ToArray();
+
+ // ---- flatten resolves against the same batch ----
+
+ [Test]
+ public void AFlattenAndABaseEditInOneBatchLandTheResolutionOfTheWholeBatch()
+ {
+ var path = PathOf("gear.cc");
+ using (var cache = Open(path))
+ {
+ SeedBase(cache);
+ SeedBig(cache);
+ cache.Commit(batch =>
+ {
+ var edited = Laser();
+ edited.Tags.Add("pierce");
+ batch.Upsert(typeof(VariantGear), edited, BaseKey);
+ batch.Flatten(BigKey);
+ });
+
+ Assert.That(cache.GetStored(BigKey)!.Variant, Is.Null);
+ Assert.That(cache.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }), "the edit made in the same batch");
+ Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25));
+ }
+
+ using var reloaded = Open(path);
+ Assert.That(reloaded.GetStored(BigKey)!.Variant, Is.Null);
+ Assert.That(reloaded.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
+ }
+
+ // ---- the held index is truthful: every holder of a value, so no rule judges a variant against a lossy view ----
+
+ private static VariantGear Named(string name, string code) => new() { Name = name, Power = 1, Code = code };
+
+ private static CultRecordKey[] SharedCodePair(CultCache cache)
+ {
+ var p1 = new CultRecordKey("p1");
+ var p2 = new CultRecordKey("p2");
+ cache.Commit(batch =>
+ {
+ batch.Upsert(typeof(VariantGear), Named("p1", "x"), p1);
+ batch.Upsert(typeof(VariantGear), Named("p2", "x"), p2);
+ });
+ return new[] { p1, p2 };
+ }
+
+ private static void VariantOnCode(CultCache cache, string code) =>
+ cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
+ {
+ cache.Override(nameof(VariantGear.Name), "laser big"),
+ cache.Override(nameof(VariantGear.Code), code)
+ }));
+
+ [Test]
+ public void RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable()
+ {
+ var path = PathOf("gear.cc");
+ using (var cache = Open(path))
+ {
+ var pair = SharedCodePair(cache);
+ Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"), "the smallest key");
+
+ cache.Remove(pair[0]);
+ Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p2"), "removing the holder finds the other one");
+
+ cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p1", "x"), pair[0]));
+ Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"));
+ cache.Remove(pair[1]);
+ Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"), "removing the other changes nothing");
+ cache.Remove(pair[0]);
+ Assert.That(cache.GetByIndex("code", "x"), Is.Null);
+ cache.BackingStores[0].PushAll();
+ }
+
+ using var reopened = Open(path);
+ Assert.That(reopened.GetByIndex("code", "x"), Is.Null);
+ }
+
+ [Test]
+ public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinWasRemovedIsRefusedAndTheStoreReopens()
+ {
+ var path = PathOf("gear.cc");
+ using (var cache = Open(path))
+ {
+ SeedBase(cache);
+ var pair = SharedCodePair(cache);
+ cache.Remove(pair[1]);
+
+ var refused = Refused(() => VariantOnCode(cache, "x"));
+ Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("code").And.Contain("'x'"));
+ Assert.That(cache.Get(BigKey), Is.Null, "nothing landed");
+ cache.BackingStores[0].PushAll();
+ }
+
+ using var reopened = Open(path);
+ Assert.That(reopened.Get(BigKey), Is.Null);
+ Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"));
+ }
+
+ [Test]
+ public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinChangedItsValueIsRefusedAndTheStoreReopens()
+ {
+ var path = PathOf("gear.cc");
+ using (var cache = Open(path))
+ {
+ SeedBase(cache);
+ var pair = SharedCodePair(cache);
+ cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p2", "y"), pair[1]));
+
+ var refused = Refused(() => VariantOnCode(cache, "x"));
+ Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("'x'"));
+ Assert.DoesNotThrow(() => VariantOnCode(cache, "z"), "an unshared value is fine");
+ cache.BackingStores[0].PushAll();
+ }
+
+ using var reopened = Open(path);
+ Assert.That(reopened.GetByIndex("code", "z")!.Name, Is.EqualTo("laser big"));
+ Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"));
+ }
+
+ // ---- a merge onto the file is judged on the set the file will hold ----
+
+ [Test]
+ public void AConditionalCommitThatWouldShareAnIndexedValueWithAnotherWritersVariantIsRefusedAndTheStoreReopens()
+ {
+ var path = PathOf("gear.cc");
+ var other = new CultRecordKey("other");
+ using (var seed = Open(path))
+ SeedBase(seed);
+
+ using (var a = Open(path))
+ using (var b = Open(path))
+ {
+ a.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
+ {
+ a.Override(nameof(VariantGear.Name), "laser big"),
+ a.Override(nameof(VariantGear.Code), "shared")
+ }));
+
+ var refused = Refused(() => b.Commit(batch =>
+ {
+ batch.Expect(other, null);
+ batch.Upsert(typeof(VariantGear), Named("other", "shared"), other);
+ }));
+ Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("shared"));
+ Assert.That(b.Get(other), Is.Null, "nothing landed in memory");
+ }
+
+ using var reopened = Open(path);
+ Assert.That(reopened.Get(other), Is.Null, "nothing landed on the file");
+ Assert.That(reopened.GetByIndex("code", "shared")!.Name, Is.EqualTo("laser big"));
+ }
+
+ [Test]
+ public void AConditionalCommitThatRemovesAnotherWritersVariantsBaseIsRefusedAndTheStoreReopens()
+ {
+ var path = PathOf("gear.cc");
+ using (var seed = Open(path))
+ SeedBase(seed);
+
+ using (var a = Open(path))
+ using (var b = Open(path))
+ {
+ var seen = b.Get(BaseKey)!;
+ SeedBig(a);
+
+ var refused = Refused(() => b.Commit(batch =>
+ {
+ batch.Expect(BaseKey, seen);
+ batch.Remove(BaseKey);
+ }));
+ Assert.That(refused.Message, Does.Contain(BaseKey.Value).And.Contain(BigKey.Value));
+ Assert.That(b.Get(BaseKey), Is.Not.Null, "nothing landed in memory");
+ }
+
+ using var reopened = Open(path);
+ Assert.That(reopened.Get(BaseKey), Is.Not.Null);
+ Assert.That(reopened.Get(BigKey)!.Name, Is.EqualTo("laser big"));
+ }
+
+ [Test]
+ public void AConditionalCommitThatIsSoundOnTheMergedSetStillLands()
+ {
+ var path = PathOf("gear.cc");
+ var other = new CultRecordKey("other");
+ using (var seed = Open(path))
+ SeedBase(seed);
+
+ using (var a = Open(path))
+ using (var b = Open(path))
+ {
+ SeedBig(a);
+ Assert.That(b.Commit(batch =>
+ {
+ batch.Expect(other, null);
+ batch.Upsert(typeof(VariantGear), Named("other", "fine"), other);
+ }), Is.True);
+ }
+
+ using var reopened = Open(path);
+ Assert.That(reopened.Get(other), Is.Not.Null);
+ Assert.That(reopened.Get(BigKey), Is.Not.Null, "the other writer's variant survives the merge");
+ }
+
+ [Test]
+ public void AVariantLoadedUnderANarrowerTypeHoldsOnlyTheOverridesTheTypeHasAndReportsTheRest()
+ {
+ var path = PathOf("drift.cc");
+ using (var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) })))
+ {
+ writer.Commit(batch =>
+ {
+ batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base"));
+ batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[]
+ {
+ writer.Override(nameof(DriftWide.Name), "drift variant"),
+ writer.Override(nameof(DriftWide.Wing), "stale wing")
+ });
+ });
+ }
+
+ using var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }));
+ Assert.That(narrow.GetStored(new CultRecordKey("drift-variant"))!.Variant!.Overrides.Select(entry => entry.Path[0].Slot), Is.EqualTo(new[] { 0 }),
+ "the dropped slot's override is shed when the record is loaded, not decided again at flush");
+ Assert.That(narrow.BackingStores[0].LastSchemaMigrationReports.SelectMany(report => report.IgnoredExtraSlots), Does.Contain(2));
+ }
+
+ // ---- batch 4: linear rule, judge only when a variant is involved, deterministic duplicates, one warning ----
+
+ [CultDocument("tests.variant_counted", "tests.variant_counted.v1")]
+ [MessagePackObject(AllowPrivate = true)]
+ internal sealed class CountedGear
+ {
+ public static long CodeReads;
+ [IgnoreMember]
+ private string _code = string.Empty;
+
+ [Key(0)]
+ [CultName]
+ public string Name = string.Empty;
+
+ [Key(1)]
+ [CultIndex("code")]
+ public string Code
+ {
+ get
+ {
+ System.Threading.Interlocked.Increment(ref CodeReads);
+ return _code;
+ }
+ set => _code = value;
+ }
+ }
+
+ // The pin is a count of index-accessor reads, not a clock: linear work doubles with the records, quadratic work quadruples.
+ private long IndexReadsOpening(int records)
+ {
+ var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CountedGear) });
+ var path = PathOf($"counted-{records}.cc");
+ using (var writer = Open(path, registry: registry))
+ {
+ writer.Commit(batch =>
+ {
+ for (var index = 0; index < records; index++)
+ batch.Upsert(typeof(CountedGear), new CountedGear { Name = $"n{index}", Code = $"c{index}" }, new CultRecordKey($"k{index}"));
+ });
+ writer.Commit(batch => batch.UpsertVariant(new CultRecordKey("kv"), new CultRecordKey("k0"), new[]
+ {
+ writer.Override(nameof(CountedGear.Name), "variant"),
+ writer.Override(nameof(CountedGear.Code), "variant-code")
+ }));
+ }
+
+ System.Threading.Interlocked.Exchange(ref CountedGear.CodeReads, 0);
+ using (Open(path, registry: registry))
+ return System.Threading.Interlocked.Read(ref CountedGear.CodeReads);
+ }
+
+ [Test]
+ public void OpeningAStoreWithAVariantReadsEachIndexedValueAFixedNumberOfTimes()
+ {
+ var small = IndexReadsOpening(1000);
+ var large = IndexReadsOpening(2000);
+ Assert.That(large, Is.LessThan(small * 3), $"index reads: {small} at 1000 records, {large} at 2000; doubling the records must not quadruple the work");
+ }
+
+ [Test]
+ public void ASchemaThisCacheDoesNotRegisterDoesNotFailAConditionalCommitThatInvolvesNoVariant()
+ {
+ var path = PathOf("gear.cc");
+ var other = new CultRecordKey("other");
+ var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(VariantGear) });
+ using (var seed = Open(path))
+ SeedBase(seed);
+
+ using (var a = Open(path))
+ using (var b = Open(path, registry: narrow))
+ {
+ a.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "foreign" }, new CultRecordKey("foreign")));
+ Assert.That(b.Commit(batch =>
+ {
+ batch.Expect(other, null);
+ batch.Upsert(typeof(VariantGear), Named("other", "fine"), other);
+ }), Is.True);
+ }
+
+ using var reopened = Open(path);
+ Assert.That(reopened.Get(other), Is.Not.Null);
+ Assert.That(reopened.Get(new CultRecordKey("foreign")), Is.Not.Null, "the other writer's record survives the merge");
+ }
+
+ [Test]
+ public void AVariantAnotherWriterLandedIsStillJudgedWhenThisCacheHoldsNoVariant()
+ {
+ var path = PathOf("gear.cc");
+ var other = new CultRecordKey("other");
+ using (var seed = Open(path))
+ SeedBase(seed);
+
+ using (var a = Open(path))
+ using (var b = Open(path))
+ {
+ VariantOnCode(a, "shared");
+ Refused(() => b.Commit(batch =>
+ {
+ batch.Expect(other, null);
+ batch.Upsert(typeof(VariantGear), Named("other", "shared"), other);
+ }));
+ }
+ }
+
+ [Test]
+ public void PlainDuplicatesFindTheSmallestKeyLiveAfterAFlushAndAfterAReopen()
+ {
+ var path = PathOf("gear.cc");
+ using (var cache = Open(path))
+ {
+ cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("nb", "x"), new CultRecordKey("b")));
+ cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a")));
+ Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "live");
+ cache.BackingStores[0].PushAll();
+ Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "after a flush");
+ }
+
+ using (var reopened = Open(path))
+ {
+ Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "after a reopen");
+ reopened.Remove(new CultRecordKey("a"));
+ reopened.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a")));
+ Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("na"), "rewriting the smaller key does not change the choice");
+ }
+ }
+
+ [Test]
+ public void ADroppedSlotIsReportedOncePerRecord()
+ {
+ var path = PathOf("drift.cc");
+ using (var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) })))
+ {
+ writer.Commit(batch =>
+ {
+ batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base"));
+ batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[]
+ {
+ writer.Override(nameof(DriftWide.Name), "drift variant"),
+ writer.Override(nameof(DriftWide.Wing), "stale wing")
+ });
+ });
+ }
+
+ using var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }));
+ var variantReport = narrow.BackingStores[0].LastSchemaMigrationReports.Last();
+ Assert.That(variantReport.Warnings.Count(warning => warning.Code == "ignored_extra_slot"), Is.EqualTo(1));
+ Assert.That(variantReport.IgnoredExtraSlots, Is.EqualTo(new[] { 2 }));
+ }
+
+ // ---- the directory store refuses a variant page on load as it does on write ----
+
+ [Test]
+ public void TheDirectoryStoreRefusesToLoadAVariantPage()
+ {
+ var manifest = PathOf("dir-page.cc");
+ var records = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(manifest);
+ Directory.CreateDirectory(records);
+ var page = CultDocumentMessagePackSerialization.SerializePersistedRecord(OverridingRecord("hand-variant", "hand-base", TextOverride(0, "x")));
+ var hash = System.Security.Cryptography.SHA256.HashData(page);
+ File.WriteAllBytes(Path.Combine(records, Convert.ToHexString(hash).ToLowerInvariant() + ".msgpack"), page);
+
+ using var store = new DirectoryMessagePackBackingStore(manifest, records);
+ var read = typeof(DirectoryMessagePackBackingStore).GetMethod(
+ "ReadPersistedRecordPage", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
+ var refused = Assert.Throws(
+ () => read.Invoke(store, new object?[] { new CultPersistedRecord { Key = "hand-variant", Payload = hash }, null }))!;
+
+ Assert.That(refused.InnerException, Is.TypeOf());
+ Assert.That(refused.InnerException!.Message, Does.Contain("hand-variant").And.Contain("directory store"));
+ }
+
+ // ---- persisted format ----
+
+ private static string HeaderOf(string path) =>
+ CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion;
+
+ [Test]
+ public void TheHeaderIsV1WithoutVariantsAndV2WithThem()
+ {
+ var path = PathOf("gear.cc");
+ using var cache = Open(path);
+ SeedBase(cache);
+ Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"));
+
+ SeedBig(cache);
+ Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"));
+ var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
+ var variant = snapshot.Records.Single(record => record.Key == BigKey.Value);
+ Assert.That(variant.Payload, Is.Empty, "the payload is the tripwire old readers refuse on");
+ Assert.That(variant.Variant!.BaseKey, Is.EqualTo(BaseKey.Value));
+ Assert.That(snapshot.Records.Single(record => record.Key == BaseKey.Value).Variant, Is.Null);
+
+ cache.Commit(batch => batch.Remove(BigKey));
+ Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "the last variant gone, the store is v1 again");
+ }
+
+ [Test]
+ public async Task AStoreWithoutVariantsRewritesByteIdenticalToTheOneWrittenBeforeVariantsExisted()
+ {
+ var fixture = Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "pre-cut2", "single-file-v1", "store.msgpack");
+ var copy = PathOf("store.msgpack");
+ File.Copy(fixture, copy);
+ var before = File.ReadAllBytes(copy);
+
+ using (var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(PreCut2FixtureItem), typeof(PreCut2FixtureNote) })))
+ {
+ cache.AddBackingStore(new SingleFileMessagePackBackingStore(copy));
+ await cache.PullAllBackingStoresAsync();
+ cache.BackingStores[0].PushAll();
+ }
+
+ Assert.That(File.ReadAllBytes(copy), Is.EqualTo(before));
+ }
+
+ [Test]
+ public void AVariantStoreRoundTripsByteIdenticalThroughAFlush()
+ {
+ var path = PathOf("gear.cc");
+ using (var cache = Open(path))
+ {
+ SeedBase(cache);
+ SeedBig(cache);
+ }
+
+ var written = File.ReadAllBytes(path);
+ using (var cache = Open(path))
+ {
+ cache.BackingStores[0].PushAll();
+ }
+
+ Assert.That(File.ReadAllBytes(path), Is.EqualTo(written));
+ }
+
+ [Test]
+ public void AReadOnlyStoreResolvesItsVariants()
+ {
+ var path = PathOf("gear.cc");
+ using (var cache = Open(path))
+ {
+ SeedBase(cache);
+ SeedBig(cache);
+ }
+
+ using var readOnly = Open(path, readOnly: true);
+ Assert.That(readOnly.Get(BigKey)!.Power, Is.EqualTo(25));
+ Assert.That(readOnly.Get(BigKey)!.Code, Is.EqualTo("laser big"));
+
+ // A base edit another writer commits reaches the read-only cache's variant when it pulls: a re-resolution is no write.
+ using var writer = Open(path);
+ EditBase(writer, gear => gear.Tags.Add("pierce"));
+ var before = File.ReadAllBytes(path);
+ readOnly.PullAllBackingStoresAsync();
+ Assert.That(readOnly.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
+ Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
+ }
+
+ [Test]
+ public void ABaseEditFromAnotherWriterReachesVariantsWhenTheStoreIsPulled()
+ {
+ var path = PathOf("gear.cc");
+ using var a = Open(path);
+ SeedBase(a);
+ SeedBig(a);
+ using var b = Open(path);
+ Assert.That(b.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam" }));
+
+ EditBase(a, gear => gear.Tags.Add("pierce"));
+ b.PullAllBackingStoresAsync();
+
+ Assert.That(b.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }),
+ "the pull carried only the base; the variant was re-resolved in the admitting hold");
+ }
+
+ [Test]
+ public void AFlattenFromAnotherWriterReachesACacheHoldingTheVariant()
+ {
+ var path = PathOf("gear.cc");
+ using var a = Open(path);
+ SeedBase(a);
+ SeedBig(a);
+ using var b = Open(path);
+ Assert.That(b.GetStored(BigKey)!.Variant, Is.Not.Null);
+
+ a.FlattenAsync(BigKey).GetAwaiter().GetResult();
+ a.FlushAsync().GetAwaiter().GetResult();
+ b.PullAllBackingStoresAsync();
+
+ Assert.That(b.GetStored(BigKey)!.Variant, Is.Null, "a load reports what is on disk; it is not a plain write refused at a variant key");
+ Assert.That(b.Get(BigKey)!.Power, Is.EqualTo(25));
+ }
+
+ [Test]
+ public void ARebaseResolvesFromTheNewBase()
+ {
+ using var cache = Open(PathOf("gear.cc"));
+ SeedBase(cache);
+ SeedBig(cache);
+ var spare = new CultRecordKey("spare");
+ cache.Commit(batch => batch.Upsert(typeof(VariantGear), new VariantGear { Name = "spare", Code = "s1", Tags = { "arc" } }, spare));
+
+ cache.Commit(batch => batch.UpsertVariant(BigKey, spare, cache.GetStored(BigKey)!.Variant!.Overrides));
+
+ Assert.That(cache.Get(BigKey)!.Tags, Is.EqualTo(new[] { "arc" }));
+ Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25));
+ Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(spare.Value));
+ }
+
+ // ---- readers ----
+
+ private string WriteStore(params CultPersistedRecord[] records) => WriteStore(CultPersistedStoreSnapshot.FormatV2, records);
+
+ private string WriteStore(string formatVersion, params CultPersistedRecord[] records)
+ {
+ var path = PathOf($"hand-{Guid.NewGuid():N}.cc");
+ var gear = Registry.GetRequired();
+ File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot
+ {
+ FormatVersion = formatVersion,
+ SchemaCatalog = new[] { gear.ToCatalogEntry() },
+ Records = records
+ }));
+ return path;
+ }
+
+ private static CultPersistedRecord PlainGear(string key) => new()
+ {
+ Key = key,
+ SchemaId = Registry.GetRequired().SchemaId,
+ StoredAt = "2026-01-01T00:00:00.0000000Z",
+ Payload = CultDocumentMessagePackSerialization.SerializeUntyped(Laser(), typeof(VariantGear), Registry)
+ };
+
+ private static CultPersistedRecord VariantGearRecord(string key, string baseKey, byte[]? payload = null, CultOverrideOp op = CultOverrideOp.Set) => new()
+ {
+ Key = key,
+ SchemaId = Registry.GetRequired().SchemaId,
+ StoredAt = "2026-01-01T00:00:00.0000000Z",
+ Payload = payload ?? Array.Empty(),
+ Variant = new CultVariantDelta(baseKey, new[]
+ {
+ new CultVariantOverride(op, new[] { new CultPathStep(0) }, string.Empty, new byte[] { 0xa1, (byte)'x' })
+ })
+ };
+
+ [Test]
+ public void AVariantRecordUnderAV1HeaderIsRefusedNamingTheRecord()
+ {
+ var path = WriteStore(CultPersistedStoreSnapshot.FormatV1, PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base"));
+ var error = Assert.Throws(() => Open(path).Dispose())!;
+ Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("cultcache.store.v2"));
+ }
+
+ [Test]
+ public void AVariantRecordWithAPayloadIsRefusedNamingTheRecord()
+ {
+ var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", payload: new byte[] { 0x90 }));
+ var error = Assert.Throws(() => Open(path).Dispose())!;
+ Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("payload"));
+ }
+
+ [Test]
+ public void AnOverrideWithAnUnknownOpIsRefusedNamingTheRecord()
+ {
+ var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", op: (CultOverrideOp)7));
+ var error = Assert.Throws(() => Open(path).Dispose())!;
+ Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("op 7"));
+ }
+
+ // ---- the other stores refuse loudly until their own cuts ----
+
+ [Test]
+ public void TheDirectoryStoreRefusesAVariantByPushAndByCommit()
+ {
+ using var cache = CultCacheMessagePack.Create(PathOf("dir.cc"), new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true });
+ SeedBase(cache);
+
+ var push = Assert.Throws(() => cache.UpsertVariantAsync(
+ BigKey, BaseKey, new[] { cache.Override(nameof(VariantGear.Name), "laser big"), cache.Override(nameof(VariantGear.Code), "laser big") }).GetAwaiter().GetResult())!;
+ Assert.That(push.Message, Does.Contain(BigKey.Value));
+ var commit = Assert.Throws(() => SeedBig(cache))!;
+ Assert.That(commit.Message, Does.Contain(BigKey.Value));
+ Assert.That(cache.Get(BigKey), Is.Null);
+ }
+
+ // ---- shared vector ----
+
+ // Written once by the code under test through the production path; every runtime's tests read the committed bytes.
+ // CULTLIB_WRITE_VARIANT_VECTOR=1 prints the store as base64 instead of asserting.
+ [Test]
+ public void TheCommittedC1VectorResolvesAndRewritesByteIdentical()
+ {
+ var vector = VectorPath("variant-store.msgpack");
+ var path = PathOf("gear.cc");
+ if (Environment.GetEnvironmentVariable("CULTLIB_WRITE_VARIANT_VECTOR") == "1")
+ {
+ using (var cache = Open(path))
+ {
+ SeedBase(cache);
+ SeedBig(cache);
+ }
+
+ Console.WriteLine("VARIANT-VECTOR-BASE64:" + Convert.ToBase64String(File.ReadAllBytes(path)));
+ Assert.Pass("vector printed");
+ }
+
+ File.Copy(vector, path);
+ using (var cache = Open(path))
+ {
+ Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25));
+ Assert.That(cache.Get(BigKey)!.Code, Is.EqualTo("laser big"));
+ cache.BackingStores[0].PushAll();
+ }
+
+ Assert.That(File.ReadAllBytes(path), Is.EqualTo(File.ReadAllBytes(vector)));
+ Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"));
+ }
+
+ private static string VectorPath(string name)
+ {
+ for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
+ {
+ var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c1", name);
+ if (File.Exists(candidate) || Environment.GetEnvironmentVariable("CULTLIB_WRITE_VARIANT_VECTOR") == "1" && Directory.Exists(Path.GetDirectoryName(candidate)))
+ return candidate;
+ }
+
+ if (Environment.GetEnvironmentVariable("CULTLIB_WRITE_VARIANT_VECTOR") == "1")
+ return name;
+ throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}.");
+ }
+
+ [CultDocument("tests.variant_gear", "tests.variant_gear.v1")]
+ [MessagePackObject(AllowPrivate = true)]
+ internal sealed class VariantGear
+ {
+ [Key(0)]
+ [CultName]
+ public string Name = string.Empty;
+
+ [Key(1)]
+ public int Power;
+
+ [Key(2)]
+ [CultIndex("code")]
+ public string Code = string.Empty;
+
+ [Key(3)]
+ public List Tags = new();
+ }
+
+ [CultDocument("tests.variant_other", "tests.variant_other.v1")]
+ [MessagePackObject(AllowPrivate = true)]
+ internal sealed class VariantOther
+ {
+ [Key(0)]
+ [CultName]
+ public string Name = string.Empty;
+ }
+
+ [CultDocument("tests.variant_global", "tests.variant_global.v1")]
+ [MessagePackObject(AllowPrivate = true)]
+ [CultGlobal]
+ internal sealed class VariantGlobal
+ {
+ [Key(0)]
+ public string Value = string.Empty;
+ }
+
+ // The same schema at two versions of the type: Wide has a member Narrow later dropped.
+ [CultDocument("tests.variant_drift", "tests.variant_drift.v1")]
+ [MessagePackObject(AllowPrivate = true)]
+ internal sealed class DriftWide
+ {
+ [Key(0)]
+ [CultName]
+ public string Name = string.Empty;
+
+ [Key(1)]
+ public int Power;
+
+ [Key(2)]
+ public string Wing = string.Empty;
+ }
+
+ // A later type that reuses the slot Narrow dropped, for another member.
+ [CultDocument("tests.variant_drift", "tests.variant_drift.v3")]
+ [MessagePackObject(AllowPrivate = true)]
+ internal sealed class DriftReused
+ {
+ [Key(0)]
+ [CultName]
+ public string Name = string.Empty;
+
+ [Key(1)]
+ public int Power;
+
+ [Key(2)]
+ public string Colour = string.Empty;
+ }
+
+ [CultDocument("tests.variant_drift", "tests.variant_drift.v2")]
+ [MessagePackObject(AllowPrivate = true)]
+ internal sealed class DriftNarrow
+ {
+ [Key(0)]
+ [CultName]
+ public string Name = string.Empty;
+
+ [Key(1)]
+ public int Power;
+ }
+ }
+}
diff --git a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
index a5be069a..2521aa8a 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
@@ -45,10 +45,16 @@ public sealed class CultMeshVariantRefusalTests
message.Should().Contain("item:anvil").And.Contain(ItemSchemaId);
}
+ // Q6: CultMesh does not resolve variants. It refuses a variant key naming it and its base, and still reads the plain
+ // records of the same v2 store.
[Test]
- public void ReadSingleFileDocument_RefusesVariantStoreByVersionOrRecord()
+ public void ReadSingleFileDocument_RefusesAVariantKeyByNameButReadsItsPlainNeighbours()
{
- var message = Refusal("variant-v2.msgpack").Message;
- (message.Contains("cultcache.store.v2") || message.Contains("item:anvil-big")).Should().BeTrue(message);
+ var refusal = Assert.Throws(() =>
+ CultMesh.ReadSingleFileDocumentPayload(VectorPath("variant-v2.msgpack"), new CultRecordKey("item:anvil-big"), ItemSchemaId))!;
+ refusal.Message.Should().Contain("item:anvil-big").And.Contain("item:anvil'");
+
+ CultMesh.ReadSingleFileDocumentPayload(VectorPath("variant-v2.msgpack"), new CultRecordKey("item:bellows"), ItemSchemaId)
+ .Should().NotBeEmpty();
}
}
diff --git a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs
new file mode 100644
index 00000000..64de1093
--- /dev/null
+++ b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs
@@ -0,0 +1,470 @@
+#nullable enable
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using MessagePack;
+using NUnit.Framework;
+
+namespace GameCult.Networking.Tests
+{
+ // Q6 (docs/document-variants-cut.md C1): CultNet carries no variant deltas yet, so building a row for one is refused
+ // loudly, on the v1 selection path and on the v0 snapshot path alike.
+ public sealed class CultNetDocumentRegistryVariantTests
+ {
+ private static readonly CultRecordKey BaseKey = new("variant-fixture-base");
+ private static readonly CultRecordKey VariantKey = new("variant-fixture-big");
+
+ private static async Task<(CultCache Cache, CultNetDocumentRegistry Documents)> BuildFixtureAsync(bool withVariant)
+ {
+ var registry = CultDocumentRegistry.Shared;
+ var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry));
+ var documents = new CultNetDocumentRegistry(registry)
+ .Register(CultNetDocumentBinding.ForDocument(registry));
+ await cache.UpsertAsync(new VariantWireFixture { Name = "base", Power = 1 }, new CultRecordHandle(BaseKey));
+ if (withVariant)
+ await cache.UpsertVariantAsync(VariantKey, BaseKey, new[]
+ {
+ cache.Override(nameof(VariantWireFixture.Name), "big"),
+ cache.Override(nameof(VariantWireFixture.Power), 9)
+ });
+ return (cache, documents);
+ }
+
+ [Test]
+ public async Task SelectionResponseRefusesAVariantRowNamingIt()
+ {
+ var (cache, documents) = await BuildFixtureAsync(withVariant: true);
+ var selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document };
+
+ var error = Assert.Throws(() =>
+ documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1))!;
+
+ Assert.That(error.Message, Does.Contain(VariantKey.Value).And.Contain(BaseKey.Value));
+ }
+
+ [Test]
+ public async Task RawSnapshotResponseRefusesAVariantRowNamingIt()
+ {
+ var (cache, documents) = await BuildFixtureAsync(withVariant: true);
+
+ var error = Assert.Throws(() => documents.CreateRawSnapshotResponse(cache, "m1"))!;
+
+ Assert.That(error.Message, Does.Contain(VariantKey.Value));
+ }
+
+ [Test]
+ public async Task ACacheWithoutVariantsStillAnswersBothPaths()
+ {
+ var (cache, documents) = await BuildFixtureAsync(withVariant: false);
+ var selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document };
+
+ Assert.That(documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1).Documents, Has.Length.EqualTo(1));
+ Assert.That(documents.CreateRawSnapshotResponse(cache, "m2").Documents, Has.Length.EqualTo(1));
+ }
+
+ // The live-change funnel: a load that lands a variant reaches a subscriber as neither a record nor silence.
+ [Test]
+ public async Task ALoadThatLandsAVariantNeverReachesASubscriberAsARecordAndTheSubscriberIsToldByKey()
+ {
+ var registry = CultDocumentRegistry.Shared;
+ var directory = Path.Combine(Path.GetTempPath(), $"cultlib-variant-live-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(directory);
+ try
+ {
+ var path = Path.Combine(directory, "wire.cc");
+ using (var writer = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }))
+ {
+ writer.Commit(batch => batch.Upsert(typeof(VariantWireFixture), new VariantWireFixture { Name = "base", Power = 1 }, BaseKey));
+ writer.Commit(batch => batch.UpsertVariant(VariantKey, BaseKey, new[]
+ {
+ writer.Override(nameof(VariantWireFixture.Name), "big"),
+ writer.Override(nameof(VariantWireFixture.Power), 9)
+ }));
+ }
+
+ var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry));
+ var documents = new CultNetDocumentRegistry(registry)
+ .Register(CultNetDocumentBinding.ForDocument(registry));
+ var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = documents });
+ var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
+ socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ socket.ReceiveTimeout = 20;
+ using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
+ {
+ RuntimeId = "variant-live-server",
+ Socket = socket
+ });
+ using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
+ using var cancellation = new CancellationTokenSource();
+ var serverThread = new Thread(() =>
+ {
+ while (!cancellation.IsCancellationRequested)
+ {
+ _ = server.PollOnceAsync().GetAwaiter().GetResult();
+ Thread.Sleep(1);
+ }
+ }) { IsBackground = true };
+ serverThread.Start();
+ try
+ {
+ using var client = CultNetSchemaClients.CreateRudp("variant-live-client");
+ var snapshot = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var changes = new ConcurrentQueue