2026-09-29 03:52:35 +02:00
|
|
|
#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));
|
|
|
|
|
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
// laser-big: a variant of laser that overrides its name, power and code (an indexed value must be its own) and inherits the rest.
|
2026-09-29 03:52:35 +02:00
|
|
|
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<VariantGear>(nameof(VariantGear.Name), name),
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
cache.Override<VariantGear>(nameof(VariantGear.Power), power),
|
|
|
|
|
cache.Override<VariantGear>(nameof(VariantGear.Code), name)
|
2026-09-29 03:52:35 +02:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
private static void EditBase(CultCache cache, Action<VariantGear> edit) =>
|
|
|
|
|
cache.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
var edited = Laser();
|
|
|
|
|
edit(edited);
|
|
|
|
|
batch.Upsert(typeof(VariantGear), edited, BaseKey);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
private static InvalidOperationException Refused(TestDelegate write) =>
|
|
|
|
|
Assert.Throws<InvalidOperationException>(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<VariantGear>(BigKey)!;
|
|
|
|
|
Assert.That(big.Name, Is.EqualTo("laser big"));
|
|
|
|
|
Assert.That(big.Power, Is.EqualTo(25));
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
Assert.That(big.Code, Is.EqualTo("laser big"), "the override the unique index requires");
|
2026-09-29 03:52:35 +02:00
|
|
|
Assert.That(big.Tags, Is.EqualTo(new[] { "beam" }), "an inherited list");
|
|
|
|
|
Assert.That(cache.Get<VariantGear>(BaseKey)!.Power, Is.EqualTo(10));
|
|
|
|
|
Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(BaseKey.Value));
|
2026-09-29 07:04:01 +02:00
|
|
|
Assert.That(cache.GetStored(BigKey)!.Variant!.Overrides, Has.Count.EqualTo(3));
|
2026-09-29 03:52:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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<VariantGear>(hugeKey)!.Tags, Is.EqualTo(new[] { "beam" }));
|
|
|
|
|
EditBase(cache, gear => gear.Tags.Add("pierce"));
|
|
|
|
|
|
|
|
|
|
Assert.That(cache.Get<VariantGear>(hugeKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
|
|
|
|
|
Assert.That(cache.Get<VariantGear>(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<VariantGear>(nameof(VariantGear.Name), "laser coded"),
|
|
|
|
|
cache.Override<VariantGear>(nameof(VariantGear.Code), "l9")
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
var seen = new List<(string Key, long Sequence, string BigTagsAtThatMoment, string BigNameLookupTags)>();
|
|
|
|
|
using var subscription = cache.Watch<VariantGear>().Subscribe(change => seen.Add((
|
|
|
|
|
change.Key.Value,
|
|
|
|
|
change.Sequence,
|
|
|
|
|
string.Join(",", cache.Get<VariantGear>(BigKey)!.Tags),
|
|
|
|
|
string.Join(",", cache.GetByName<VariantGear>("laser big")!.Tags))));
|
|
|
|
|
|
|
|
|
|
EditBase(cache, gear => { gear.Tags.Add("pierce"); gear.Code = "l2"; });
|
|
|
|
|
|
|
|
|
|
Assert.That(cache.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
|
|
|
|
|
Assert.That(cache.GetByName<VariantGear>("laser big")!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
|
|
|
|
|
Assert.That(cache.GetByName<VariantGear>("laser big")!.Power, Is.EqualTo(25), "the override survives the base edit");
|
|
|
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "l9")!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
// the base's old code entry must be gone, not shadowing, and its new one must find the base itself.
|
2026-09-29 03:52:35 +02:00
|
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "l1"), Is.Null);
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "l2")!.Name, Is.EqualTo("laser"));
|
|
|
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "laser big")!.Name, Is.EqualTo("laser big"));
|
2026-09-29 03:52:35 +02:00
|
|
|
|
|
|
|
|
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<CultCacheDocumentChange<VariantGear>>();
|
|
|
|
|
using var subscription = cache.WatchRecord<VariantGear>(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<VariantGear>(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<VariantGear>(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<VariantGear>();
|
|
|
|
|
var other = Registry.GetRequired<VariantOther>();
|
|
|
|
|
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<CultVariantOverride>())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
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");
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
Assert.That(cache.Get<VariantGear>(BigKey)!.Tags, Is.Empty, "now resolved from spare");
|
2026-09-29 03:52:35 +02:00
|
|
|
|
|
|
|
|
Assert.That(cache.Commit(batch =>
|
|
|
|
|
{
|
|
|
|
|
batch.Remove(spare);
|
|
|
|
|
batch.Remove(BigKey);
|
|
|
|
|
}), Is.True, "removed in the same batch");
|
2026-09-29 04:21:59 +02:00
|
|
|
|
|
|
|
|
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));
|
2026-09-29 03:52:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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<VariantGear>(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<VariantGear>(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<VariantGear>(BigKey)!.Power, Is.EqualTo(25), "flatten keeps the resolved values");
|
|
|
|
|
EditBase(cache, gear => gear.Tags.Add("pierce"));
|
|
|
|
|
Assert.That(cache.Get<VariantGear>(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<VariantGear>(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));
|
2026-09-29 04:21:59 +02:00
|
|
|
var named = new[] { cache.Override<VariantGear>(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");
|
2026-09-29 03:52:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[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<DriftWide>(nameof(DriftWide.Name), "drift variant"),
|
|
|
|
|
writer.Override<DriftWide>(nameof(DriftWide.Power), 7),
|
|
|
|
|
writer.Override<DriftWide>(nameof(DriftWide.Wing), "variant wing")
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) });
|
|
|
|
|
using var cache = Open(path, registry: narrow);
|
|
|
|
|
var variant = cache.Get<DriftNarrow>(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"));
|
|
|
|
|
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
// 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"));
|
2026-09-29 07:04:01 +02:00
|
|
|
Assert.That(reports.Select(report => report.Warnings.Select(warning => warning.Code)), Has.All.Contain("ignored_extra_slot"));
|
2026-09-29 03:52:35 +02:00
|
|
|
}
|
|
|
|
|
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
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<VariantGear>().SchemaId,
|
|
|
|
|
StoredAt = "2026-01-01T00:00:00.0000000Z",
|
|
|
|
|
Payload = Array.Empty<byte>(),
|
|
|
|
|
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<VariantGear>().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<VariantGear>(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant"));
|
|
|
|
|
Assert.That(cache.Get<VariantGear>(new CultRecordKey("hand-variant"))!.Power, Is.EqualTo(10), "inherits the base");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- a bad write is refused; drift is for type changes after the fact ----
|
|
|
|
|
|
2026-09-29 03:52:35 +02:00
|
|
|
[Test]
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
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<VariantGear>(nameof(VariantGear.Power), 1),
|
|
|
|
|
cache.Override<VariantGear>(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<ArgumentException>(() => cache.Override<VariantGear>("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<VariantGear>(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<VariantGear>(nameof(VariantGear.Name), "laser big"),
|
|
|
|
|
cache.Override<VariantGear>(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<VariantGear>("code", "l1")!.Name, Is.EqualTo("laser"));
|
|
|
|
|
|
|
|
|
|
SeedBig(cache);
|
|
|
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "l1")!.Name, Is.EqualTo("laser"));
|
|
|
|
|
Assert.That(cache.GetByIndex<VariantGear>("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<VariantGear>(BaseKey)!.Code, Is.EqualTo("l1"), "nothing landed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ALoadedVariantThatInheritsAnIndexedValueRefusesTheLoadNamingBothKeys()
|
|
|
|
|
{
|
2026-09-29 07:04:01 +02:00
|
|
|
var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base", TextOverride(0, "hand variant")));
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
var refused = Assert.Throws<InvalidOperationException>(() => Open(path).Dispose())!;
|
|
|
|
|
Assert.That(refused.Message, Does.Contain("hand-variant").And.Contain("hand-base").And.Contain("code"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 08:28:24 +02:00
|
|
|
// ---- 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<InvalidOperationException>(
|
|
|
|
|
() => cache.UpsertAsync(Other("laser big"), new CultRecordHandle<VariantGear>(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<VariantGear>("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<VariantGear>(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<VariantGear>("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<VariantGear>(nameof(VariantGear.Name), "laser big"),
|
|
|
|
|
cache.Override<VariantGear>(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<VariantGear>(nameof(VariantGear.Name), "laser big"),
|
|
|
|
|
cache.Override<VariantGear>(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<DriftWide>(nameof(DriftWide.Name), "drift variant"),
|
|
|
|
|
writer.Override<DriftWide>(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<DriftReused>(new CultRecordKey("drift-base"))!.Colour, Is.Empty);
|
|
|
|
|
Assert.That(reused.Get<DriftReused>(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<VariantGear>(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();
|
|
|
|
|
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
// ---- flatten resolves against the same batch ----
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void AFlattenAndABaseEditInOneBatchLandTheResolutionOfTheWholeBatch()
|
2026-09-29 03:52:35 +02:00
|
|
|
{
|
|
|
|
|
var path = PathOf("gear.cc");
|
|
|
|
|
using (var cache = Open(path))
|
|
|
|
|
{
|
|
|
|
|
SeedBase(cache);
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
SeedBig(cache);
|
|
|
|
|
cache.Commit(batch =>
|
2026-09-29 03:52:35 +02:00
|
|
|
{
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
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<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }), "the edit made in the same batch");
|
|
|
|
|
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
|
2026-09-29 03:52:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
using var reloaded = Open(path);
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
Assert.That(reloaded.GetStored(BigKey)!.Variant, Is.Null);
|
|
|
|
|
Assert.That(reloaded.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
|
|
|
|
|
}
|
|
|
|
|
|
Variants C1: the held index tells the truth; a merge is judged on what the file will hold
The held index kept one key per value, last writer winning, and RemoveKey dropped
a value outright. Two consequences: removing or changing one of two plain
holders left the other unfindable, and the variant index rule judged a variant
against that lossy view, so a variant could land on a value a plain record still
held and the store then refused to reopen.
- _indexes maps a value to the keys holding it, oldest first. Index, Unindex and
the removal maintain the set. GetByIndex finds the most recently indexed
holder of a value, so removing the winner finds the other (behaviour change for
plain duplicates: it used to find nothing).
- One rule, RefuseVariantIndexSharing, serves both write orders. It reads the
truthful index minus what the commit replaces or removes, plus what it lands.
ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants
and RefusePlainSharingWithVariant are gone.
- A conditional commit merged onto the file is judged by the cache's own
resolution (CacheBackingStore.Judging) on the merged set: records other writers
added, records they removed, and the batch. Another writer's variant sharing a
value with the batch, or a base removal that strands another writer's variant,
is refused with the named refusal instead of writing an unopenable store.
- Shedding is one decision at load. ToStoredDocument resolves the schema once and
returns the report with it; a variant's override of a slot the type lacks is
dropped from the delta there, so flush needs no predicate and every store
reports through the same surface (ReportVariantSheds and OverridesTheTypeHas
are gone). The directory store refuses variants and loads through the same call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
|
|
|
// ---- 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<VariantGear>(nameof(VariantGear.Name), "laser big"),
|
|
|
|
|
cache.Override<VariantGear>(nameof(VariantGear.Code), code)
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
[Test]
|
Variants C1: the index rule is linear, a merge judges only when a variant is involved, duplicates are deterministic
- RefuseVariantIndexSharing built its holder list per landing record by scanning
every landing record, so opening a store with one variant was quadratic. It now
groups what lands once and looks each (index, value) up once. Pinned by a count
of index-accessor reads at two sizes, not a clock.
- JudgeMerge decoded every unseen disk record before Resolve could exit early,
so a plain-only store failed a conditional commit on another writer's schema it
does not register. It now judges only when the batch, the disk (by the record's
variant header, never its payload) or this cache holds a variant.
- Plain duplicates find the holder with the smallest key (ordinal), so the choice
is the same live, after a flush and after a reopen. The comment claiming "most
recently indexed" was wrong across reload.
- A dropped slot is reported once per record, not once by the schema diff and
again by the variant.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:12:20 +02:00
|
|
|
public void RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable()
|
Variants C1: the held index tells the truth; a merge is judged on what the file will hold
The held index kept one key per value, last writer winning, and RemoveKey dropped
a value outright. Two consequences: removing or changing one of two plain
holders left the other unfindable, and the variant index rule judged a variant
against that lossy view, so a variant could land on a value a plain record still
held and the store then refused to reopen.
- _indexes maps a value to the keys holding it, oldest first. Index, Unindex and
the removal maintain the set. GetByIndex finds the most recently indexed
holder of a value, so removing the winner finds the other (behaviour change for
plain duplicates: it used to find nothing).
- One rule, RefuseVariantIndexSharing, serves both write orders. It reads the
truthful index minus what the commit replaces or removes, plus what it lands.
ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants
and RefusePlainSharingWithVariant are gone.
- A conditional commit merged onto the file is judged by the cache's own
resolution (CacheBackingStore.Judging) on the merged set: records other writers
added, records they removed, and the batch. Another writer's variant sharing a
value with the batch, or a base removal that strands another writer's variant,
is refused with the named refusal instead of writing an unopenable store.
- Shedding is one decision at load. ToStoredDocument resolves the schema once and
returns the report with it; a variant's override of a slot the type lacks is
dropped from the delta there, so flush needs no predicate and every store
reports through the same surface (ReportVariantSheds and OverridesTheTypeHas
are gone). The directory store refuses variants and loads through the same call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
|
|
|
{
|
|
|
|
|
var path = PathOf("gear.cc");
|
|
|
|
|
using (var cache = Open(path))
|
|
|
|
|
{
|
|
|
|
|
var pair = SharedCodePair(cache);
|
Variants C1: the index rule is linear, a merge judges only when a variant is involved, duplicates are deterministic
- RefuseVariantIndexSharing built its holder list per landing record by scanning
every landing record, so opening a store with one variant was quadratic. It now
groups what lands once and looks each (index, value) up once. Pinned by a count
of index-accessor reads at two sizes, not a clock.
- JudgeMerge decoded every unseen disk record before Resolve could exit early,
so a plain-only store failed a conditional commit on another writer's schema it
does not register. It now judges only when the batch, the disk (by the record's
variant header, never its payload) or this cache holds a variant.
- Plain duplicates find the holder with the smallest key (ordinal), so the choice
is the same live, after a flush and after a reopen. The comment claiming "most
recently indexed" was wrong across reload.
- A dropped slot is reported once per record, not once by the schema diff and
again by the variant.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:12:20 +02:00
|
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"), "the smallest key");
|
Variants C1: the held index tells the truth; a merge is judged on what the file will hold
The held index kept one key per value, last writer winning, and RemoveKey dropped
a value outright. Two consequences: removing or changing one of two plain
holders left the other unfindable, and the variant index rule judged a variant
against that lossy view, so a variant could land on a value a plain record still
held and the store then refused to reopen.
- _indexes maps a value to the keys holding it, oldest first. Index, Unindex and
the removal maintain the set. GetByIndex finds the most recently indexed
holder of a value, so removing the winner finds the other (behaviour change for
plain duplicates: it used to find nothing).
- One rule, RefuseVariantIndexSharing, serves both write orders. It reads the
truthful index minus what the commit replaces or removes, plus what it lands.
ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants
and RefusePlainSharingWithVariant are gone.
- A conditional commit merged onto the file is judged by the cache's own
resolution (CacheBackingStore.Judging) on the merged set: records other writers
added, records they removed, and the batch. Another writer's variant sharing a
value with the batch, or a base removal that strands another writer's variant,
is refused with the named refusal instead of writing an unopenable store.
- Shedding is one decision at load. ToStoredDocument resolves the schema once and
returns the report with it; a variant's override of a slot the type lacks is
dropped from the delta there, so flush needs no predicate and every store
reports through the same surface (ReportVariantSheds and OverridesTheTypeHas
are gone). The directory store refuses variants and loads through the same call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
|
|
|
|
|
|
|
|
cache.Remove(pair[0]);
|
Variants C1: the index rule is linear, a merge judges only when a variant is involved, duplicates are deterministic
- RefuseVariantIndexSharing built its holder list per landing record by scanning
every landing record, so opening a store with one variant was quadratic. It now
groups what lands once and looks each (index, value) up once. Pinned by a count
of index-accessor reads at two sizes, not a clock.
- JudgeMerge decoded every unseen disk record before Resolve could exit early,
so a plain-only store failed a conditional commit on another writer's schema it
does not register. It now judges only when the batch, the disk (by the record's
variant header, never its payload) or this cache holds a variant.
- Plain duplicates find the holder with the smallest key (ordinal), so the choice
is the same live, after a flush and after a reopen. The comment claiming "most
recently indexed" was wrong across reload.
- A dropped slot is reported once per record, not once by the schema diff and
again by the variant.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:12:20 +02:00
|
|
|
Assert.That(cache.GetByIndex<VariantGear>("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<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"));
|
Variants C1: the held index tells the truth; a merge is judged on what the file will hold
The held index kept one key per value, last writer winning, and RemoveKey dropped
a value outright. Two consequences: removing or changing one of two plain
holders left the other unfindable, and the variant index rule judged a variant
against that lossy view, so a variant could land on a value a plain record still
held and the store then refused to reopen.
- _indexes maps a value to the keys holding it, oldest first. Index, Unindex and
the removal maintain the set. GetByIndex finds the most recently indexed
holder of a value, so removing the winner finds the other (behaviour change for
plain duplicates: it used to find nothing).
- One rule, RefuseVariantIndexSharing, serves both write orders. It reads the
truthful index minus what the commit replaces or removes, plus what it lands.
ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants
and RefusePlainSharingWithVariant are gone.
- A conditional commit merged onto the file is judged by the cache's own
resolution (CacheBackingStore.Judging) on the merged set: records other writers
added, records they removed, and the batch. Another writer's variant sharing a
value with the batch, or a base removal that strands another writer's variant,
is refused with the named refusal instead of writing an unopenable store.
- Shedding is one decision at load. ToStoredDocument resolves the schema once and
returns the report with it; a variant's override of a slot the type lacks is
dropped from the delta there, so flush needs no predicate and every store
reports through the same surface (ReportVariantSheds and OverridesTheTypeHas
are gone). The directory store refuses variants and loads through the same call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
|
|
|
cache.Remove(pair[1]);
|
Variants C1: the index rule is linear, a merge judges only when a variant is involved, duplicates are deterministic
- RefuseVariantIndexSharing built its holder list per landing record by scanning
every landing record, so opening a store with one variant was quadratic. It now
groups what lands once and looks each (index, value) up once. Pinned by a count
of index-accessor reads at two sizes, not a clock.
- JudgeMerge decoded every unseen disk record before Resolve could exit early,
so a plain-only store failed a conditional commit on another writer's schema it
does not register. It now judges only when the batch, the disk (by the record's
variant header, never its payload) or this cache holds a variant.
- Plain duplicates find the holder with the smallest key (ordinal), so the choice
is the same live, after a flush and after a reopen. The comment claiming "most
recently indexed" was wrong across reload.
- A dropped slot is reported once per record, not once by the schema diff and
again by the variant.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:12:20 +02:00
|
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"), "removing the other changes nothing");
|
|
|
|
|
cache.Remove(pair[0]);
|
Variants C1: the held index tells the truth; a merge is judged on what the file will hold
The held index kept one key per value, last writer winning, and RemoveKey dropped
a value outright. Two consequences: removing or changing one of two plain
holders left the other unfindable, and the variant index rule judged a variant
against that lossy view, so a variant could land on a value a plain record still
held and the store then refused to reopen.
- _indexes maps a value to the keys holding it, oldest first. Index, Unindex and
the removal maintain the set. GetByIndex finds the most recently indexed
holder of a value, so removing the winner finds the other (behaviour change for
plain duplicates: it used to find nothing).
- One rule, RefuseVariantIndexSharing, serves both write orders. It reads the
truthful index minus what the commit replaces or removes, plus what it lands.
ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants
and RefusePlainSharingWithVariant are gone.
- A conditional commit merged onto the file is judged by the cache's own
resolution (CacheBackingStore.Judging) on the merged set: records other writers
added, records they removed, and the batch. Another writer's variant sharing a
value with the batch, or a base removal that strands another writer's variant,
is refused with the named refusal instead of writing an unopenable store.
- Shedding is one decision at load. ToStoredDocument resolves the schema once and
returns the report with it; a variant's override of a slot the type lacks is
dropped from the delta there, so flush needs no predicate and every store
reports through the same surface (ReportVariantSheds and OverridesTheTypeHas
are gone). The directory store refuses variants and loads through the same call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
|
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "x"), Is.Null);
|
|
|
|
|
cache.BackingStores[0].PushAll();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
using var reopened = Open(path);
|
|
|
|
|
Assert.That(reopened.GetByIndex<VariantGear>("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<VariantGear>("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<VariantGear>("code", "z")!.Name, Is.EqualTo("laser big"));
|
|
|
|
|
Assert.That(reopened.GetByIndex<VariantGear>("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<VariantGear>(nameof(VariantGear.Name), "laser big"),
|
|
|
|
|
a.Override<VariantGear>(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<VariantGear>("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<VariantGear>(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<VariantGear>(BaseKey), Is.Not.Null);
|
|
|
|
|
Assert.That(reopened.Get<VariantGear>(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<VariantGear>(other), Is.Not.Null);
|
|
|
|
|
Assert.That(reopened.Get<VariantGear>(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<DriftWide>(nameof(DriftWide.Name), "drift variant"),
|
|
|
|
|
writer.Override<DriftWide>(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));
|
|
|
|
|
}
|
|
|
|
|
|
Variants C1: the index rule is linear, a merge judges only when a variant is involved, duplicates are deterministic
- RefuseVariantIndexSharing built its holder list per landing record by scanning
every landing record, so opening a store with one variant was quadratic. It now
groups what lands once and looks each (index, value) up once. Pinned by a count
of index-accessor reads at two sizes, not a clock.
- JudgeMerge decoded every unseen disk record before Resolve could exit early,
so a plain-only store failed a conditional commit on another writer's schema it
does not register. It now judges only when the batch, the disk (by the record's
variant header, never its payload) or this cache holds a variant.
- Plain duplicates find the holder with the smallest key (ordinal), so the choice
is the same live, after a flush and after a reopen. The comment claiming "most
recently indexed" was wrong across reload.
- A dropped slot is reported once per record, not once by the schema diff and
again by the variant.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 10:12:20 +02:00
|
|
|
// ---- 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<CountedGear>(nameof(CountedGear.Name), "variant"),
|
|
|
|
|
writer.Override<CountedGear>(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<VariantGear>(other), Is.Not.Null);
|
|
|
|
|
Assert.That(reopened.Get<VariantOther>(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<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "live");
|
|
|
|
|
cache.BackingStores[0].PushAll();
|
|
|
|
|
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "after a flush");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
using (var reopened = Open(path))
|
|
|
|
|
{
|
|
|
|
|
Assert.That(reopened.GetByIndex<VariantGear>("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<VariantGear>("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<DriftWide>(nameof(DriftWide.Name), "drift variant"),
|
|
|
|
|
writer.Override<DriftWide>(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 }));
|
|
|
|
|
}
|
|
|
|
|
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
// ---- 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<System.Reflection.TargetInvocationException>(
|
|
|
|
|
() => read.Invoke(store, new object?[] { new CultPersistedRecord { Key = "hand-variant", Payload = hash }, null }))!;
|
|
|
|
|
|
|
|
|
|
Assert.That(refused.InnerException, Is.TypeOf<NotSupportedException>());
|
|
|
|
|
Assert.That(refused.InnerException!.Message, Does.Contain("hand-variant").And.Contain("directory store"));
|
2026-09-29 03:52:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- 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<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
Assert.That(readOnly.Get<VariantGear>(BigKey)!.Code, Is.EqualTo("laser big"));
|
2026-09-29 04:21:59 +02:00
|
|
|
|
|
|
|
|
// 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<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
|
2026-09-29 03:52:35 +02:00
|
|
|
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<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam" }));
|
|
|
|
|
|
|
|
|
|
EditBase(a, gear => gear.Tags.Add("pierce"));
|
|
|
|
|
b.PullAllBackingStoresAsync();
|
|
|
|
|
|
|
|
|
|
Assert.That(b.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }),
|
|
|
|
|
"the pull carried only the base; the variant was re-resolved in the admitting hold");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 04:21:59 +02:00
|
|
|
[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<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 03:52:35 +02:00
|
|
|
[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<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "arc" }));
|
|
|
|
|
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
|
|
|
|
|
Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(spare.Value));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 04:21:59 +02:00
|
|
|
// ---- 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<VariantGear>();
|
|
|
|
|
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<VariantGear>().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<VariantGear>().SchemaId,
|
|
|
|
|
StoredAt = "2026-01-01T00:00:00.0000000Z",
|
|
|
|
|
Payload = payload ?? Array.Empty<byte>(),
|
|
|
|
|
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<NotSupportedException>(() => 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<NotSupportedException>(() => 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<NotSupportedException>(() => Open(path).Dispose())!;
|
|
|
|
|
Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("op 7"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 03:52:35 +02:00
|
|
|
// ---- 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<NotSupportedException>(() => cache.UpsertVariantAsync(
|
2026-09-29 07:04:01 +02:00
|
|
|
BigKey, BaseKey, new[] { cache.Override<VariantGear>(nameof(VariantGear.Name), "laser big"), cache.Override<VariantGear>(nameof(VariantGear.Code), "laser big") }).GetAwaiter().GetResult())!;
|
2026-09-29 03:52:35 +02:00
|
|
|
Assert.That(push.Message, Does.Contain(BigKey.Value));
|
|
|
|
|
var commit = Assert.Throws<NotSupportedException>(() => 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<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
|
Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.
F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.
F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.
F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.
The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
|
|
|
Assert.That(cache.Get<VariantGear>(BigKey)!.Code, Is.EqualTo("laser big"));
|
2026-09-29 03:52:35 +02:00
|
|
|
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<string> 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 08:28:24 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 03:52:35 +02:00
|
|
|
[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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|