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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 19:40:36 +02:00
commit 490f7b811f
7 changed files with 326 additions and 41 deletions

View file

@ -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);

View file

@ -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<CultSchemaCatalogEntry>();
public CultPersistedRecord[] Records { get; set; } = Array.Empty<CultPersistedRecord>();
}
@ -211,9 +218,6 @@ namespace GameCult.Caching
internal IReadOnlyList<CultDocumentMemberDescriptor> Members { get; }
internal IReadOnlyList<CultDocumentRegistry.PersistedMember> 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<KeyValuePair<int, byte[]>>();
var overrides = new List<CultVariantOverride>(delta.Overrides.Count);
var minted = false;
var holdsIds = false;
var seen = new HashSet<int>();
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<CultPersistedRecord> records, IEnumerable<CultSchemaCatalogEntry> catalog, bool carriesElementIds)
private void WriteSnapshot(IEnumerable<CultPersistedRecord> records, IEnumerable<CultSchemaCatalogEntry> catalog, bool holdsElementIds)
{
var ordered = records.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray();
var used = new HashSet<string>(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())

View file

@ -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<string> 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<object?>().ToArray();
var taken = new HashSet<string>(StringComparer.Ordinal);
var objects = new HashSet<object>(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<object>
{
public static readonly ReferenceComparer Instance = new();
bool IEqualityComparer<object>.Equals(object? left, object? right) => ReferenceEquals(left, right);
int IEqualityComparer<object>.GetHashCode(object value) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(value);
}
private static bool IsRandomId(string id)
{
if (id.Length != IdBytes * 2)

View file

@ -6,6 +6,7 @@
<ItemGroup>
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
<InternalsVisibleTo Include="GameCult.Mesh" />
</ItemGroup>
<ItemGroup>

View file

@ -98,12 +98,25 @@ namespace GameCult.Mesh
var resolvedRegistry = registry ?? CultDocumentRegistry.Shared;
var descriptor = resolvedRegistry.GetRequired<TDocument>();
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;
}
}
/// <summary>
@ -118,7 +131,10 @@ namespace GameCult.Mesh
var resolvedRegistry = registry ?? CultDocumentRegistry.Shared;
var descriptor = resolvedRegistry.GetRequired<TDocument>();
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;
}
/// <summary>
@ -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);
}
/// <summary>
@ -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<byte>();
// 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);

View file

@ -647,6 +647,110 @@ namespace GameCult.Caching.Tests
Assert.That(cache.Get<IdDeck>(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<IdDeck>(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<IdDeck>(nameof(IdDeck.Reels), new List<IdReel> { 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<CultElementIdException>(() => 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<IdDeck>(new CultRecordKey("shared")), Is.Null);
}
Assert.That(File.Exists(path), Is.False, "nothing was persisted");
}
[Test]
public void ADerivedIdWhoseSourceIsEmptyIsRefusedAtWriteNamingTheSourceMember()
{

View file

@ -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<CultMeshBodyPublicationDocument>(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<CultElementIdException>(() => CultMesh.WriteSingleFileDocument(path, Key, duplicate))!;
refusal.ElementId.Should().Be(HexA);
var once = Descriptor();
var twice = Publication(once, once);
Assert.Throws<CultElementIdException>(() => 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<CultElementIdException>(() => CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(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<CultMeshBodyPublicationDocument>(path, Key).Representations.Select(entry => entry.Id).ToArray();
first.Should().OnlyContain(id => id.Length == 12);
CultMesh.ReadSingleFileDocument<CultMeshBodyPublicationDocument>(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<CultMeshBodyPublicationDocument>(Key)!.Representations.Select(entry => entry.Id).Should().Equal(first);
}
}