diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index fa9d4899..3ef36be2 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -502,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-schema-compatibility.md b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md index 818840ee..9ae6f553 100644 --- a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md +++ b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md @@ -108,7 +108,17 @@ This is not negotiable. Better a loud refusal than quiet bit-rot. ## Variant overrides -A variant's override drifts exactly as the same member would on a plain record: an -override naming a slot the type no longer has, or holding a value that no longer decodes -into the member's type, is ignored with a warning naming the variant and the member, and -the variant inherits the base value. There is no separate rule for variants. +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 00b849a0..f17d5b93 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -1802,10 +1802,8 @@ namespace GameCult.Caching private readonly Dictionary<(Type Type, string Alias), Dictionary> _indexes = new(); private readonly Dictionary _globals = new(); private readonly CultCodec? _codec; - // Keys holding a variant, and the drift report of each variant whose overrides were partly ignored: both derived - // from the entries by Apply, never decided anywhere else. + // Keys holding a variant: derived from the entries by Apply, never decided anywhere else. private readonly HashSet _variantKeys = new(StringComparer.Ordinal); - private readonly Dictionary _variantReports = new(StringComparer.Ordinal); private readonly ConditionalWeakTable _handles = new(); private readonly Subject _changes = new(); private readonly object _gate = new(); @@ -1826,17 +1824,6 @@ namespace GameCult.Caching public CultCodec? Codec => _codec; - // Overrides a variant carries that no longer resolve (a slot the type dropped, a value that no longer decodes), - // reported as compatible drift: the override is ignored and the variant inherits the base value. - public IReadOnlyList VariantMigrationReports - { - get - { - lock (_gate) - return _variantReports.Values.ToArray(); - } - } - // The whole override of one member, encoded with this cache's codec, ready for UpsertVariant. public CultVariantOverride Override(Type documentType, string member, object? value) { @@ -2467,9 +2454,6 @@ namespace GameCult.Caching // Variants this admission did not write whose base chain it changed: re-resolved, same delta, same storedAt. public List Dependents { get; } = new(); - - // The drift report of every variant resolved here; null when nothing was ignored. - public Dictionary Reports { get; } = new(StringComparer.Ordinal); } // Resolves every admitted variant and every variant whose base chain this admission changes, base first, against @@ -2512,7 +2496,9 @@ namespace GameCult.Caching } var changed = new HashSet(incoming.Keys.Concat(gone), StringComparer.Ordinal); - var toResolve = new List(admitted.Where(stored => stored.Variant != null).Select(stored => stored.Key.Value)); + // 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)) @@ -2538,16 +2524,14 @@ namespace GameCult.Caching var pending = new HashSet(toResolve, StringComparer.Ordinal); var done = new Dictionary(StringComparer.Ordinal); - var reports = new Dictionary(StringComparer.Ordinal); var stack = new List(); + Dictionary<(Type Type, string Alias), Dictionary>? claimed = null; foreach (var key in toResolve) ResolveOne(key); - var plan = new VariantPlan(admitted.Select(stored => stored.Variant != null ? done[stored.Key.Value] : stored).ToList()); + 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]); - foreach (var pair in reports) - plan.Reports[pair.Key] = pair.Value; return plan; CultStoredDocument ResolveOne(string key) @@ -2559,9 +2543,11 @@ namespace GameCult.Caching throw CycleRefusal(stack.SkipWhile(entry => entry != key).Append(key)); stack.Add(key); - var delta = variant.Variant!; + // 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 (baseStored.Variant != null && pending.Contains(delta.BaseKey)) + if (pending.Contains(delta.BaseKey)) baseStored = ResolveOne(delta.BaseKey); var descriptor = variant.Descriptor; @@ -2575,25 +2561,25 @@ namespace GameCult.Caching var type = descriptor.DocumentType; var kept = new List>(); - var ignored = new List(); - var warnings = new List(); + var seen = new HashSet(); + // Bad writes are refused; drift is for type changes after the fact. A load, a re-resolution and a flatten + // carry what was already stored, so they follow the plain-record outcome instead. + var writing = source == null && isVariant && incoming.ContainsKey(key); 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); if (member == null) { - var name = delta.SlotNames != null && delta.SlotNames.TryGetValue(slot, out var persistedName) - ? $"'{persistedName}'" : "unnamed"; - ignored.Add(slot); - warnings.Add(new CultSchemaMigrationWarning - { - Code = "ignored_extra_slot", - Message = $"Variant {key} overrides slot {slot} ({name}), which {type.Name} no longer has; the override is ignored and the variant inherits the base value." - }); + if (writing) + throw new InvalidOperationException($"Variant {key} overrides slot {slot}, which {type.Name} does not have."); + // A slot the type dropped soft-drifts exactly as the same member does on a plain record: ignored, + // reported by the store's schema resolution (LastSchemaMigrationReports), the variant inherits the base. continue; } @@ -2603,14 +2589,10 @@ namespace GameCult.Caching } catch (Exception exception) { - ignored.Add(slot); - warnings.Add(new CultSchemaMigrationWarning - { - Code = "ignored_undecodable_override", - Message = $"Variant {key} overrides slot {slot} ('{member.Member.Name}') with a value that no longer decodes as {member.MemberType.Name} " + - $"({exception.GetBaseException().Message}); the override is ignored and the variant inherits the base value." - }); - continue; + // 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)); @@ -2620,32 +2602,73 @@ namespace GameCult.Caching var document = _codec.Deserialize(type, type, kept.Count == 0 ? payload : _codec.Overlay(payload, kept)) ?? throw new InvalidOperationException($"Variant {key} resolved to nothing."); - // R6: the name index maps one name to one key, so a variant that kept its base's name would shadow it. - if (descriptor.NameAccessor?.Invoke(document) is { Length: > 0 } variantName) + if (isVariant) { - for (var ancestor = baseStored; ancestor != null; - ancestor = ancestor.Variant == null ? null : done.TryGetValue(ancestor.Variant.BaseKey, out var above) ? above : Lookup(ancestor.Variant.BaseKey)) + // 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) { - if (descriptor.NameAccessor(ancestor.Document) == 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."); + } + } + + // The same rule for an indexed value: a unique index maps one value to one key, so a variant that + // inherited its base's value (or any other record's) would be found in place of it or lose it. + foreach (var pair in descriptor.IndexAccessors) + { + var value = pair.Value(document); + if (string.IsNullOrWhiteSpace(value)) + continue; + claimed ??= ClaimIncomingIndexValues(); + var claim = (type, pair.Key); + string? other = null; + if (claimed.TryGetValue(claim, out var claims) && claims.TryGetValue(value, out var claimant) && claimant != key) + other = claimant; + else if (_indexes.TryGetValue(claim, out var held) && held.TryGetValue(value, out var holder) && holder != key && + !incoming.ContainsKey(holder) && !gone.Contains(holder) && !pending.Contains(holder)) + other = holder; + if (other != null) throw new InvalidOperationException( - $"Variant {key} and its base {ancestor.Key.Value} are both named '{variantName}'; a variant must override its name."); + $"Variant {key} and {other} both hold index {pair.Key} '{value}'; a variant must override its indexed member."); } } - reports[key] = ignored.Count == 0 - ? null - : new CultSchemaMigrationReport + if (descriptor.IndexAccessors.Count > 0) + { + claimed ??= ClaimIncomingIndexValues(); + foreach (var pair in descriptor.IndexAccessors) { - PersistedSchemaId = descriptor.SchemaId, - LocalSchemaId = descriptor.SchemaId, - PersistedSchemaName = descriptor.SchemaName, - LocalSchemaName = descriptor.SchemaName, - Kind = CultSchemaMigrationKind.CompatibleDrift, - IgnoredExtraSlots = ignored.OrderBy(slot => slot).ToArray(), - Warnings = warnings.ToArray() - }; + var value = pair.Value(document); + if (!string.IsNullOrWhiteSpace(value)) + MapOf(claimed, (type, pair.Key))[value] = key; + } + } + stack.RemoveAt(stack.Count - 1); - return done[key] = variant.Resolved(document); + return done[key] = isVariant + ? variant.Resolved(document) + : new CultStoredDocument(variant.Key, variant.StoredAt, variant.Descriptor, document) { Flattens = true }; + } + + // The indexed values the plain records of this admission will hold, by (type, index) then value. + Dictionary<(Type Type, string Alias), Dictionary> ClaimIncomingIndexValues() + { + var claims = new Dictionary<(Type Type, string Alias), Dictionary>(); + foreach (var stored in incoming.Values.Where(stored => stored.Variant == null && !stored.Flattens)) + { + foreach (var pair in stored.Descriptor.IndexAccessors) + { + var value = pair.Value(stored.Document); + if (!string.IsNullOrWhiteSpace(value)) + MapOf(claims, (stored.Descriptor.DocumentType, pair.Key))[value] = stored.Key.Value; + } + } + + return claims; } } @@ -2672,7 +2695,6 @@ namespace GameCult.Caching _entries.Remove(stored.Key.Value); _handles.Remove(existing.Document); _variantKeys.Remove(stored.Key.Value); - _variantReports.Remove(stored.Key.Value); changes.Add(new Change(CultCacheDocumentChangeKind.Removed, existing, null, existing.Document, ++_sequence)); } @@ -2692,10 +2714,6 @@ namespace GameCult.Caching _variantKeys.Remove(stored.Key.Value); else _variantKeys.Add(stored.Key.Value); - if (plan.Reports.TryGetValue(stored.Key.Value, out var report) && report != null) - _variantReports[stored.Key.Value] = report; - else - _variantReports.Remove(stored.Key.Value); changes.Add(new Change( previous == null ? CultCacheDocumentChangeKind.Added : CultCacheDocumentChangeKind.Updated, stored, @@ -2911,16 +2929,12 @@ namespace GameCult.Caching var resolution = Registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog); if (record.Variant != null) { - // The cache resolves it against its base when it admits the load; the catalog names the slots the type may have dropped. - var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, record.SchemaId, StringComparison.Ordinal)); + // The cache resolves it against its base when it admits the load. return new CultStoredDocument( new CultRecordKey(record.Key), record.StoredAt, resolution.Descriptor, - new CultVariantDelta(record.Variant.BaseKey, record.Variant.Overrides) - { - SlotNames = persisted?.Members.GroupBy(member => member.Slot).ToDictionary(group => group.Key, group => group.First().MemberName) - }, + new CultVariantDelta(record.Variant.BaseKey, record.Variant.Overrides), null); } diff --git a/src/GameCult.Caching/CultVariants.cs b/src/GameCult.Caching/CultVariants.cs index 08882c79..ee5cc523 100644 --- a/src/GameCult.Caching/CultVariants.cs +++ b/src/GameCult.Caching/CultVariants.cs @@ -87,9 +87,5 @@ namespace GameCult.Caching public string BaseKey { get; } public IReadOnlyList Overrides { get; } - - // Member names by slot from the catalog the delta was loaded under, for warnings about slots the type dropped. - // Not persisted. - internal IReadOnlyDictionary? SlotNames { get; set; } } } diff --git a/tests/GameCult.Caching.Tests/VariantTests.cs b/tests/GameCult.Caching.Tests/VariantTests.cs index fec33c96..f8544e50 100644 --- a/tests/GameCult.Caching.Tests/VariantTests.cs +++ b/tests/GameCult.Caching.Tests/VariantTests.cs @@ -50,12 +50,13 @@ namespace GameCult.Caching.Tests 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 and power and inherits the rest. + // 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.Power), power), + cache.Override(nameof(VariantGear.Code), name) })); private static void EditBase(CultCache cache, Action edit) => @@ -85,7 +86,7 @@ namespace GameCult.Caching.Tests 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("l1"), "an inherited member"); + 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)); @@ -133,9 +134,10 @@ namespace GameCult.Caching.Tests 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" })); - // big inherited the old code; its stale index entry must be gone, not shadowing. + // 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.Not.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)), @@ -297,7 +299,7 @@ namespace GameCult.Caching.Tests batch.UpsertVariant(BigKey, spare, cache.GetStored(BigKey)!.Variant!.Overrides); batch.Remove(BaseKey); }), Is.True, "rebased in the same batch"); - Assert.That(cache.Get(BigKey)!.Code, Is.EqualTo(string.Empty), "now resolved from spare"); + Assert.That(cache.Get(BigKey)!.Tags, Is.Empty, "now resolved from spare"); Assert.That(cache.Commit(batch => { @@ -435,36 +437,204 @@ namespace GameCult.Caching.Tests Assert.That(variant.Power, Is.EqualTo(7), "the override the type still has applies"); Assert.That(variant.Name, Is.EqualTo("drift variant")); - var report = cache.VariantMigrationReports.Single(); - Assert.That(report.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); - Assert.That(report.IgnoredExtraSlots, Is.EqualTo(new[] { 2 })); - var warning = report.Warnings.Single(); - Assert.That(warning.Code, Is.EqualTo("ignored_extra_slot"), "the code a dropped member gets on a plain record"); - Assert.That(warning.Message, Does.Contain("drift-variant").And.Contain("Wing")); + // 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.SelectMany(report => report.Warnings).Select(warning => warning.Code), Is.All.EqualTo("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 AnOverrideThatNoLongerDecodesIsIgnoredWithAWarningAndTheBaseValueApplies() + 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")); + var refused = Assert.Throws(() => Open(path).Dispose())!; + Assert.That(refused.Message, Does.Contain("hand-variant").And.Contain("hand-base").And.Contain("code")); + } + + // ---- flatten resolves against the same batch ---- + + [Test] + public void AFlattenAndABaseEditInOneBatchLandTheResolutionOfTheWholeBatch() { var path = PathOf("gear.cc"); - const int notAnInt = 1; // VariantGear.Power, [Key(1)] using (var cache = Open(path)) { SeedBase(cache); - cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] + SeedBig(cache); + cache.Commit(batch => { - cache.Override(nameof(VariantGear.Name), "laser big"), - CultVariantOverride.Set(notAnInt, new byte[] { 0xa3, (byte)'a', (byte)'b', (byte)'c' }) - })); - var warning = cache.VariantMigrationReports.Single().Warnings.Single(); - Assert.That(warning.Message, Does.Contain(BigKey.Value).And.Contain("Power")); - Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(10), "inherits the base value"); - Assert.That(cache.Get(BigKey)!.Name, Is.EqualTo("laser big")); + 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.Get(BigKey)!.Power, Is.EqualTo(10)); - Assert.That(reloaded.VariantMigrationReports.Single().Warnings.Single().Message, Does.Contain("Power")); + Assert.That(reloaded.GetStored(BigKey)!.Variant, Is.Null); + Assert.That(reloaded.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" })); + } + + // ---- 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 ---- @@ -541,7 +711,7 @@ namespace GameCult.Caching.Tests using var readOnly = Open(path, readOnly: true); Assert.That(readOnly.Get(BigKey)!.Power, Is.EqualTo(25)); - Assert.That(readOnly.Get(BigKey)!.Code, Is.EqualTo("l1")); + 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); @@ -705,7 +875,7 @@ namespace GameCult.Caching.Tests using (var cache = Open(path)) { Assert.That(cache.Get(BigKey)!.Power, Is.EqualTo(25)); - Assert.That(cache.Get(BigKey)!.Code, Is.EqualTo("l1")); + Assert.That(cache.Get(BigKey)!.Code, Is.EqualTo("laser big")); cache.BackingStores[0].PushAll(); } diff --git a/tests/vectors/document-variants-c0/generate.py b/tests/vectors/document-variants-c0/generate.py index e3d2547d..c6e5d2da 100644 --- a/tests/vectors/document-variants-c0/generate.py +++ b/tests/vectors/document-variants-c0/generate.py @@ -11,9 +11,11 @@ at test time. Run: python generate.py (needs msgpack). v1-base.msgpack NOT generated here: written once by the Python runtime at CultLib 69a21bb (SingleFileMessagePackBackingStore.push of alpha and beta, type vectors.item, before any C0 code existed) - variant-v2.msgpack what C1 writes for a variant: header v2, record slot 4 = - [baseKey, overrides[]], empty payload (override op Set = 0, - path steps are [slot, elementId]) + variant-v2.msgpack the wire shape of a variant record, hand-built: header v2, record slot 4 = + [baseKey, overrides[]], empty payload (override op Set = 0, path steps + are [slot, elementId]). C1 does not write this store and refuses to load + it: the variant resolves to its base's name (R6). It exercises readers + that refuse a variant by version or record. """ import pathlib