From 490f7b811ff096b6fbc779091ee0a84f71f974db Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 19:40:36 +0200 Subject: [PATCH] C2a: mark the header by content, refuse an element twice in a list, route CultMesh single-file through the id rule A store is written v3 (v5 for a directory) only when a record in it holds a non-empty element id. CultElementIds.Plan reports that as IdPlan.HoldsIds; the admission stamps it on the stored record and every store reads it. The descriptor's CarriesElementIds is deleted. A file already marked on disk stays marked. CultPersistedStoreSnapshot.FormatFor is the one header decision. Plan refuses one element object sitting twice in a list at write, naming the record and list, instead of writing both ids and refusing the store at load. CultMesh single-file write mints, refuses and marks as a cache write does; the read applies the load-time plan, so a write and a load refuse the same things. Co-Authored-By: Claude Opus 5.5 --- .../DirectoryMessagePackBackingStore.cs | 2 +- src/GameCult.Caching/CultCache.cs | 55 ++++---- src/GameCult.Caching/CultElementIds.cs | 30 ++++- src/GameCult.Caching/GameCult.Caching.csproj | 1 + .../CultMeshSingleFileDocuments.cs | 52 ++++++-- .../GameCult.Caching.Tests/ElementIdTests.cs | 104 +++++++++++++++ .../CultMeshSingleFileElementIdTests.cs | 123 ++++++++++++++++++ 7 files changed, 326 insertions(+), 41 deletions(-) create mode 100644 tests/GameCult.Mesh.Tests/CultMeshSingleFileElementIdTests.cs diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index e18efe70..92fd492a 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -288,7 +288,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore // A manifest already marked stays marked: pages it names may hold ids this cache cannot read. var carriesIds = string.Equals(currentManifest.FormatVersion, IndexedFormatVersionWithIds, StringComparison.Ordinal) || - keysToWrite.Any(key => Entries.TryGetValue(key, out var written) && written.Descriptor.CarriesElementIds); + keysToWrite.Any(key => Entries.TryGetValue(key, out var written) && written.HoldsIds); WriteManifest(targetCatalog, currentIndex.Values .OrderBy(record => record.Key, StringComparer.Ordinal) .ToArray(), carriesIds); diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index cbfb3d57..f9d01e4d 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -67,6 +67,13 @@ namespace GameCult.Caching public const string FormatV3 = "cultcache.store.v3"; public string FormatVersion { get; set; } = FormatV1; + + // The one header decision. v3 when the store holds an element id: a reader older than ids refuses the header, because it + // would skip the id slots and rewrite the elements without them. Otherwise v2 exactly when it holds a variant, and a store + // with neither stays byte-identical v1. A file already marked stays marked; the writer that knows that says so. + internal static string FormatFor(bool holdsIds, bool holdsVariants) => + holdsIds ? FormatV3 : holdsVariants ? FormatV2 : FormatV1; + public CultSchemaCatalogEntry[] SchemaCatalog { get; set; } = Array.Empty(); public CultPersistedRecord[] Records { get; set; } = Array.Empty(); } @@ -211,9 +218,6 @@ namespace GameCult.Caching internal IReadOnlyList Members { get; } internal IReadOnlyList RichMembers { get; } - // True when the type reaches an object list: what it writes can hold element ids, so a store holding it carries the id marker. - public bool CarriesElementIds { get; internal set; } - // The public read surface (CultNet selection cut 1, section 6): declared addressing, and the // three ways to read a value through it. GameCult.Networking has no InternalsVisibleTo here on // purpose - this is the only door, so nothing outside the cache decides what a member means. @@ -383,8 +387,12 @@ namespace GameCult.Caching // True while this record holds element ids minted when it loaded, that no write has persisted (see MintElementIds). internal bool IdsInMemoryOnly { get; set; } - internal CultStoredDocument Resolved(object document, CultVariantDelta delta, bool idsInMemoryOnly) => - new(Key, StoredAt, Descriptor, delta, document) { IdsInMemoryOnly = idsInMemoryOnly }; + // True when some element this record persists holds a non-empty id: decided where ids are planned (an admission), and the + // only thing a store reads to mark its header. A variant holds ids when an override value does; its base's ids are the base's. + public bool HoldsIds { get; internal set; } + + internal CultStoredDocument Resolved(object document, CultVariantDelta delta, bool idsInMemoryOnly, bool holdsIds) => + new(Key, StoredAt, Descriptor, delta, document) { IdsInMemoryOnly = idsInMemoryOnly, HoldsIds = holdsIds }; } public sealed class CultDocumentRegistry @@ -678,10 +686,7 @@ namespace GameCult.Caching nameMember?.GetterNullable, indexAccessors, descriptorMembers, - members) - { - CarriesElementIds = ids.CarriesIds - }; + members); } private static string BuildSemanticFingerprint( @@ -2379,6 +2384,8 @@ namespace GameCult.Caching .Where(stored => stored.Variant == null) .Select(stored => (Stored: stored, Plan: CultElementIds.Plan(stored.Document, stored.Key.Value, deterministic: source != null))) .ToArray(); + foreach (var (stored, idPlan) in idPlans) + stored.HoldsIds = idPlan.HoldsIds; VariantPlan plan; CultCommitOutcome outcome; try @@ -2388,6 +2395,10 @@ namespace GameCult.Caching // The one resolution: a variant admitted, a base admitted or evicted, all resolved here against the post-batch // set, before land and before memory. land receives the admitted records with variants resolved. plan = Resolve(admitted, evicted, source); + // A variant's own record is resolved into a copy; the record a loading store keeps is the original. + var resolvedByKey = plan.Admitted.ToDictionary(resolved => resolved.Key.Value, StringComparer.Ordinal); + foreach (var stored in admitted.Where(stored => stored.Variant != null)) + stored.HoldsIds = resolvedByKey[stored.Key.Value].HoldsIds; outcome = land(home, plan.Admitted); } catch @@ -2665,6 +2676,7 @@ namespace GameCult.Caching var kept = new List>(); var overrides = new List(delta.Overrides.Count); var minted = false; + var holdsIds = false; var seen = new HashSet(); foreach (var entry in delta.Overrides) { @@ -2697,6 +2709,7 @@ namespace GameCult.Caching // decoded copy, and the delta this hold lands carries them. var bytes = entry.Value; var idPlan = CultElementIds.Plan(value, key + "." + slot.ToString(CultureInfo.InvariantCulture), deterministic: source != null); + holdsIds |= idPlan.HoldsIds; if (idPlan.Count > 0) { idPlan.Apply(); @@ -2729,8 +2742,8 @@ namespace GameCult.Caching stack.RemoveAt(stack.Count - 1); return done[key] = isVariant - ? variant.Resolved(document, minted ? new CultVariantDelta(delta.BaseKey, overrides) : delta, variant.IdsInMemoryOnly || (minted && source != null)) - : new CultStoredDocument(variant.Key, variant.StoredAt, variant.Descriptor, document) { Flattens = true }; + ? variant.Resolved(document, minted ? new CultVariantDelta(delta.BaseKey, overrides) : delta, variant.IdsInMemoryOnly || (minted && source != null), holdsIds) + : new CultStoredDocument(variant.Key, variant.StoredAt, variant.Descriptor, document) { Flattens = true, HoldsIds = variant.HoldsIds }; } } @@ -3224,7 +3237,7 @@ namespace GameCult.Caching WriteSnapshot( Entries.Values.Select(entry => ToPersistedRecord(entry, SerializePayload)), Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry()), - Entries.Values.Any(entry => entry.Descriptor.CarriesElementIds)); + Entries.Values.Any(entry => entry.HoldsIds)); Cache?.IdsPersisted(Entries.Keys); } @@ -3268,11 +3281,12 @@ namespace GameCult.Caching records[entry.Key.Value] = ToPersistedRecord(entry, SerializePayload); // A file already marked stays marked: it may hold records this cache cannot read, and a rewrite must not shed the marker. - var carriesIds = request.Upserts.Any(entry => entry.Descriptor.CarriesElementIds) || + var replaced = request.Upserts.Concat(request.Deletes).Select(entry => entry.Key.Value).ToHashSet(StringComparer.Ordinal); + var holdsIds = request.Upserts.Any(entry => entry.HoldsIds) || (ontoDisk ? string.Equals(disk.FormatVersion, CultPersistedStoreSnapshot.FormatV3, StringComparison.Ordinal) - : Entries.Values.Any(entry => entry.Descriptor.CarriesElementIds)); - WriteSnapshot(records.Values, catalog.Concat(request.Upserts.Select(entry => entry.Descriptor.ToCatalogEntry())), carriesIds); + : Entries.Values.Any(entry => entry.HoldsIds && !replaced.Contains(entry.Key.Value))); + WriteSnapshot(records.Values, catalog.Concat(request.Upserts.Select(entry => entry.Descriptor.ToCatalogEntry())), holdsIds); if (!ontoDisk) Cache?.IdsPersisted(records.Keys); foreach (var entry in request.Deletes) @@ -3327,20 +3341,13 @@ namespace GameCult.Caching } } - private void WriteSnapshot(IEnumerable records, IEnumerable catalog, bool carriesElementIds) + private void WriteSnapshot(IEnumerable records, IEnumerable catalog, bool holdsElementIds) { var ordered = records.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray(); var used = new HashSet(ordered.Select(record => record.SchemaId), StringComparer.Ordinal); var snapshot = new CultPersistedStoreSnapshot { - // v3 when the store can hold element ids: a reader older than ids refuses the header, because it would skip the id - // slots and rewrite the elements without them. Otherwise v2 exactly when the store holds a variant, and a store - // with neither stays byte-identical v1. - FormatVersion = carriesElementIds - ? CultPersistedStoreSnapshot.FormatV3 - : ordered.Any(record => record.Variant != null) - ? CultPersistedStoreSnapshot.FormatV2 - : CultPersistedStoreSnapshot.FormatV1, + FormatVersion = CultPersistedStoreSnapshot.FormatFor(holdsElementIds, ordered.Any(record => record.Variant != null)), SchemaCatalog = catalog .GroupBy(entry => entry.SchemaId, StringComparer.Ordinal) .Select(group => group.First()) diff --git a/src/GameCult.Caching/CultElementIds.cs b/src/GameCult.Caching/CultElementIds.cs index c1991ba0..3eacd7d2 100644 --- a/src/GameCult.Caching/CultElementIds.cs +++ b/src/GameCult.Caching/CultElementIds.cs @@ -64,6 +64,10 @@ namespace GameCult.Caching public int Count => _fills.Count; + // True when some element under the root holds a non-empty id once the plan is applied: one already there, or one this + // plan fills. It decides whether the store that writes the root carries the v3 header. + public bool HoldsIds { get; internal set; } + internal void Add(object item, Shape shape, string? before, string id) => _fills.Add((item, shape, before, id)); public void Apply() @@ -95,13 +99,9 @@ namespace GameCult.Caching internal sealed class Survey { public List Problems { get; } = new(); - - // True when the type reaches an object list: what it writes can hold element ids. - public bool CarriesIds { get; set; } } - // Every reason this document type cannot be registered under the id rule, one per offending concrete element type, - // and whether it reaches any element list at all. + // Every reason this document type cannot be registered under the id rule, one per offending concrete element type. internal static Survey Inspect(Type documentType) { var survey = new Survey(); @@ -134,7 +134,6 @@ namespace GameCult.Caching { if (IsObjectType(element)) { - survey.CarriesIds = true; foreach (var concrete in ConcreteTypes(element)) { var problem = IdProblem(concrete); @@ -209,7 +208,8 @@ namespace GameCult.Caching // Decides every unset element id under root, and refuses what the rule refuses, without changing anything: a random id // that is not 12 lowercase hex characters, a duplicate id in one list, a derived id whose source is null or empty. - // rootPath (the record key) seeds deterministic minting and names the record in a refusal. + // rootPath (the record key) seeds deterministic minting and names the record in a refusal. An element object that sits + // twice in one list is refused too: planning would give it two ids and it would then be a duplicate on load. internal static IdPlan Plan(object? root, string rootPath, bool deterministic) { var plan = new IdPlan(); @@ -241,13 +241,19 @@ namespace GameCult.Caching { var items = list.Cast().ToArray(); var taken = new HashSet(StringComparer.Ordinal); + var objects = new HashSet(ReferenceComparer.Instance); foreach (var item in items) { if (item == null || !IsObjectType(item.GetType()) || ShapeOf(item.GetType()) is not { GetId: { } getId } shape) continue; + if (!objects.Add(item)) + throw new CultElementIdException( + $"Record {rootPath}: the same {item.GetType().Name} object appears twice in the list at {path}; an element is one object with one id.", + rootPath, path); var id = getId(item); if (string.IsNullOrEmpty(id)) continue; + plan.HoldsIds = true; if (shape.Derive == null && !IsRandomId(id!)) throw new CultElementIdException( $"Record {rootPath}: element id '{id}' in the list at {path} is not 12 lowercase hex characters.", @@ -279,6 +285,7 @@ namespace GameCult.Caching else id = Mint(itemPath, deterministic, rootPath, taken); plan.Add(item, itemShape, get(item), id); + plan.HoldsIds = true; } Visit(item, itemPath); @@ -290,6 +297,15 @@ namespace GameCult.Caching rootPath, path, id); } + private sealed class ReferenceComparer : IEqualityComparer + { + public static readonly ReferenceComparer Instance = new(); + + bool IEqualityComparer.Equals(object? left, object? right) => ReferenceEquals(left, right); + + int IEqualityComparer.GetHashCode(object value) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(value); + } + private static bool IsRandomId(string id) { if (id.Length != IdBytes * 2) diff --git a/src/GameCult.Caching/GameCult.Caching.csproj b/src/GameCult.Caching/GameCult.Caching.csproj index ad34f11e..51253f8f 100644 --- a/src/GameCult.Caching/GameCult.Caching.csproj +++ b/src/GameCult.Caching/GameCult.Caching.csproj @@ -6,6 +6,7 @@ + diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs index 700b7a50..8b62aaf6 100644 --- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs +++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs @@ -98,12 +98,25 @@ namespace GameCult.Mesh var resolvedRegistry = registry ?? CultDocumentRegistry.Shared; var descriptor = resolvedRegistry.GetRequired(); - WriteSingleFileDocumentPayload( - path, - key, - descriptor.ToCatalogEntry(), - storedAt, - CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument))); + // Element ids are decided as a cache write decides them, before anything is written: an unset id is minted, a + // refused list refuses the write, and the file is marked when it holds an id. A failed write gives the ids back. + var ids = CultElementIds.Plan(document, key.Value, deterministic: false); + ids.Apply(); + try + { + WriteSingleFileDocumentPayload( + path, + key, + descriptor.ToCatalogEntry(), + storedAt, + CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument)), + ids.HoldsIds); + } + catch + { + ids.Undo(); + throw; + } } /// @@ -118,7 +131,10 @@ namespace GameCult.Mesh var resolvedRegistry = registry ?? CultDocumentRegistry.Shared; var descriptor = resolvedRegistry.GetRequired(); var payload = ReadSingleFileDocumentPayload(path, key, descriptor.SchemaId); - return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), payload); + var document = (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), payload); + // A load refuses what a write refuses, and mints the ids a cache load of the same file would mint. + CultElementIds.Plan(document, key.Value, deterministic: true).Apply(); + return document; } /// @@ -132,7 +148,7 @@ namespace GameCult.Mesh byte[] payload) { if (schema == null) throw new ArgumentNullException(nameof(schema)); - WriteSingleFileDocumentPayload(path, key, schema.ToCatalogEntry(payload), storedAt, payload); + WriteSingleFileDocumentPayload(path, key, schema.ToCatalogEntry(payload), storedAt, payload, holdsIds: false); } /// @@ -214,7 +230,8 @@ namespace GameCult.Mesh CultRecordKey key, CultSchemaCatalogEntry catalogEntry, string? storedAt, - byte[] payload) + byte[] payload, + bool holdsIds) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry)); @@ -222,8 +239,11 @@ namespace GameCult.Mesh throw new ArgumentException("Catalog entry must include a schema id.", nameof(catalogEntry)); payload ??= Array.Empty(); + // A raw payload is opaque: whether it holds element ids is the typed caller's to say. A file already marked stays + // marked, as it does in a cache's own store. var snapshot = new CultPersistedStoreSnapshot { + FormatVersion = CultPersistedStoreSnapshot.FormatFor(holdsIds || IsMarkedOnDisk(path), holdsVariants: false), SchemaCatalog = new[] { catalogEntry }, Records = new[] { @@ -244,6 +264,20 @@ namespace GameCult.Mesh WriteFileAtomically(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); } + private static bool IsMarkedOnDisk(string path) + { + try + { + var reader = new MessagePackReader(File.ReadAllBytes(path)); + reader.ReadArrayHeader(); + return string.Equals(reader.ReadString(), CultPersistedStoreSnapshot.FormatV3, StringComparison.Ordinal); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or MessagePackSerializationException or EndOfStreamException or InvalidOperationException) + { + return false; + } + } + private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path) { var bytes = File.ReadAllBytes(path); diff --git a/tests/GameCult.Caching.Tests/ElementIdTests.cs b/tests/GameCult.Caching.Tests/ElementIdTests.cs index a4271f2f..646c4b36 100644 --- a/tests/GameCult.Caching.Tests/ElementIdTests.cs +++ b/tests/GameCult.Caching.Tests/ElementIdTests.cs @@ -647,6 +647,110 @@ namespace GameCult.Caching.Tests Assert.That(cache.Get(new CultRecordKey("plain")), Is.Null); } + // ---- C2a fix batch 3: mark by content, one object twice ---- + + private static IdDeck EmptyDeck(string name) => new() { Name = name }; + + [Test] + public void ASingleFileStoreIsMarkedByTheIdsItHoldsNotByTheTypesItCouldHold() + { + var empty = PathOf("empty-deck.cc"); + using (var cache = Open(empty)) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e"))); + Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v1"), "a deck with no elements holds no id"); + + var flushed = PathOf("flushed.cc"); + using (var cache = Open(flushed)) + { + cache.UpsertAsync(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + } + + Assert.That(HeaderOf(flushed), Is.EqualTo("cultcache.store.v1"), "the whole-store flush decides by content too"); + + using (var cache = Open(flushed)) + { + cache.UpsertAsync(typeof(IdDeck), Deck("d"), new CultRecordKey("d")).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + } + + Assert.That(HeaderOf(flushed), Is.EqualTo("cultcache.store.v3"), "one element with an id marks the flush"); + + var marked = PathOf("marked.cc"); + using (var cache = Open(marked)) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + using (var cache = Open(marked)) + { + Assert.That(cache.Commit(batch => + { + batch.Expect(new CultRecordKey("e"), null); + batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")); + }), Is.True); + } + + Assert.That(HeaderOf(marked), Is.EqualTo("cultcache.store.v3"), "a file already marked on disk stays marked"); + } + + [Test] + public void ADirectoryStoreIsMarkedByTheIdsItWritesAndAnEmptyDeckDoesNotMarkIt() + { + var empty = PathOf("empty-dir.cc"); + using (var cache = OpenWith(empty, true, typeof(IdDeck))) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e"))); + Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages")); + + using (var cache = OpenWith(empty, true, typeof(IdDeck))) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v5.directory-content-addressed-pages")); + } + + [Test] + public void AVariantIsMarkedByItsOverrideIdsAndOtherwiseIsV2() + { + var plain = PathOf("variant-plain.cc"); + using (var cache = Open(plain)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base")); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), new[] { cache.Override(nameof(IdDeck.Name), "v") }); + }); + } + + Assert.That(HeaderOf(plain), Is.EqualTo("cultcache.store.v2")); + + var withIds = PathOf("variant-ids.cc"); + using (var cache = Open(withIds)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base")); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), + new[] { cache.Override(nameof(IdDeck.Reels), new List { new() { Label = "r" } }) }); + }); + } + + Assert.That(HeaderOf(withIds), Is.EqualTo("cultcache.store.v3")); + } + + [Test] + public void AnElementObjectTwiceInOneListIsRefusedAtWriteAndNothingIsPersisted() + { + var path = PathOf("shared.cc"); + using (var cache = Open(path)) + { + var shared = new IdMark { Text = "s" }; + var deck = new IdDeck { Name = "shared", Reels = { new IdReel { Label = "r", Marks = { shared, shared } } } }; + var refusal = Assert.Throws(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("shared"))))!; + Assert.That(refusal.Message, Does.Contain("shared").And.Contain("twice")); + Assert.That(refusal.RecordKey, Is.EqualTo("shared")); + Assert.That(shared.Id, Is.Empty, "the refusal minted nothing"); + Assert.That(cache.Get(new CultRecordKey("shared")), Is.Null); + } + + Assert.That(File.Exists(path), Is.False, "nothing was persisted"); + } + [Test] public void ADerivedIdWhoseSourceIsEmptyIsRefusedAtWriteNamingTheSourceMember() { diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileElementIdTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileElementIdTests.cs new file mode 100644 index 00000000..42e5cc1a --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileElementIdTests.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; +using System.Linq; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +#nullable enable + +namespace GameCult.Mesh.Tests; + +// C2a: a single-file document written or read through CultMesh is judged as a cache judges it: ids minted, a list that +// cannot hold ids refused, the header decided by what the file holds. +public sealed class CultMeshSingleFileElementIdTests +{ + private const string HexA = "aaaaaaaaaaaa"; + private static readonly CultRecordKey Key = new("publication"); + + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "cultmesh-single-file-ids", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) Directory.Delete(_root, true); + } + + private static CultMeshBodyPublicationDocument Publication(params CultMeshBodyDescriptor[] representations) => new() + { + BodyId = "aetheria:entities", + ProducerId = "aetheria", + SchemaId = "eve.entity_soa.v1", + Representations = representations + }; + + private static string HeaderOf(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion; + + private static CultMeshBodyDescriptor Descriptor(string id = "") => new() { Id = id, BodyId = "body" }; + + private string Path_(string name) => Path.Combine(_root, name); + + [Test] + public void TypedWriteMintsTheElementIdsAndMarksTheFileThatHoldsThem() + { + var path = Path_("ids.cc"); + var publication = Publication(Descriptor(), Descriptor()); + + CultMesh.WriteSingleFileDocument(path, Key, publication); + + publication.Representations.Select(entry => entry.Id).Should().OnlyContain(id => id.Length == 12).And.OnlyHaveUniqueItems(); + HeaderOf(path).Should().Be("cultcache.store.v3"); + CultMesh.ReadSingleFileDocument(path, Key).Representations.Select(entry => entry.Id) + .Should().Equal(publication.Representations.Select(entry => entry.Id)); + } + + [Test] + public void TypedWriteOfADocumentHoldingNoIdStaysV1AndAMarkedFileStaysMarked() + { + var path = Path_("none.cc"); + CultMesh.WriteSingleFileDocument(path, Key, Publication()); + HeaderOf(path).Should().Be("cultcache.store.v1"); + + CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); + HeaderOf(path).Should().Be("cultcache.store.v3"); + CultMesh.WriteSingleFileDocument(path, Key, Publication()); + HeaderOf(path).Should().Be("cultcache.store.v3", "a file already marked on disk stays marked"); + } + + [Test] + public void TypedWriteRefusesADuplicateIdAndAnElementObjectTwiceAndLeavesTheFileAlone() + { + var path = Path_("refused.cc"); + CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); + var before = File.ReadAllBytes(path); + + var duplicate = Publication(Descriptor(HexA), Descriptor(HexA)); + var refusal = Assert.Throws(() => CultMesh.WriteSingleFileDocument(path, Key, duplicate))!; + refusal.ElementId.Should().Be(HexA); + + var once = Descriptor(); + var twice = Publication(once, once); + Assert.Throws(() => CultMesh.WriteSingleFileDocument(path, Key, twice))!.Message.Should().Contain("twice"); + once.Id.Should().BeEmpty("a refusal mints nothing"); + + File.ReadAllBytes(path).Should().Equal(before); + } + + [Test] + public void TypedReadRefusesWhatALoadRefusesAndMintsWhatALoadMints() + { + var path = Path_("disk.cc"); + CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + + var duplicate = Publication(Descriptor(HexA), Descriptor(HexA)); + snapshot.Records[0].Payload = MessagePackSerializer.Serialize(duplicate); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var refusal = Assert.Throws(() => CultMesh.ReadSingleFileDocument(path, Key))!; + refusal.ElementId.Should().Be(HexA); + + // A payload written before ids: two reads, and a cache load, agree on the ids minted. + snapshot.Records[0].Payload = MessagePackSerializer.Serialize(Publication(Descriptor(), Descriptor())); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var first = CultMesh.ReadSingleFileDocument(path, Key).Representations.Select(entry => entry.Id).ToArray(); + first.Should().OnlyContain(id => id.Length == 12); + CultMesh.ReadSingleFileDocument(path, Key).Representations.Select(entry => entry.Id).Should().Equal(first); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions + { + Registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultMeshBodyPublicationDocument) }), + ReadOnly = true + }); + cache.Get(Key)!.Representations.Select(entry => entry.Id).Should().Equal(first); + } +}