diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 5ee82e06..4fe301c2 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -524,11 +524,12 @@ namespace GameCult.Caching // asked. internal bool StoredSchemaReadWhole { get; set; } - // The catalog entry that publishes StoredSchemaId in the store this record was loaded from, so staging can ask WrittenOwns the - // question the store's judge asks, over the catalog the record came with. Null for a record this cache constructed or wrote, - // which is stored under Descriptor's own id and published by Descriptor's entry. Only a loaded record carries it, and a loaded - // record is replaced by a new one when written, never relabelled in place, so it always names the id StoredSchemaId holds. - internal CultSchemaCatalogEntry? StoredPublishedBy { get; set; } + // The catalog entry that publishes StoredSchemaId in the store this record was loaded from, with the id it was published for, + // so staging can ask WrittenOwns the question the store's judge asks, over the catalog the record came with. Null for a record + // this cache constructed or wrote, which is stored under Descriptor's own id and published by Descriptor's entry. A write that + // relabels a loaded record in place (minting its element ids, then a flush) moves StoredSchemaId and leaves this behind, so a + // reader uses it only while the id still matches StoredSchemaId. + internal (string Id, CultSchemaCatalogEntry Entry)? StoredPublishedBy { get; set; } // Names the bytes this record was read from or written as: the SHA-256 of its persisted encoding, set by the store that // read or wrote them. A record no store has read or written has a version of its own that equals nothing else. @@ -3986,8 +3987,8 @@ namespace GameCult.Caching }; } - private static CultSchemaCatalogEntry? PublishedByIn(string schemaId, IReadOnlyCollection catalog) => - CultDocumentRegistry.PublishedBy(schemaId, catalog); + private static (string Id, CultSchemaCatalogEntry Entry)? PublishedByIn(string schemaId, IReadOnlyCollection catalog) => + CultDocumentRegistry.PublishedBy(schemaId, catalog) is { } entry ? (schemaId, entry) : null; protected void SetLastSchemaMigrationReports(IEnumerable reports) { @@ -4035,7 +4036,10 @@ namespace GameCult.Caching // record's id when it was loaded (a record this cache wrote is published by its own type). The one answer for a store-backed // cache's staging and a store-less cache's. internal static bool UpsertOwned(CultStoredDocument held, CultDocumentDescriptor written) => - CultDocumentRegistry.WrittenOwns(written.ToCatalogEntry(), held.StoredSchemaId, new[] { held.StoredPublishedBy ?? held.Descriptor.ToCatalogEntry() }); + CultDocumentRegistry.WrittenOwns( + written.ToCatalogEntry(), + held.StoredSchemaId, + new[] { held.StoredPublishedBy is { } loaded && string.Equals(loaded.Id, held.StoredSchemaId, StringComparison.Ordinal) ? loaded.Entry : held.Descriptor.ToCatalogEntry() }); // The refusal of a store-less cache that would retype a key. internal static CultSchemaConflictException Retyped(CultStoredDocument held, string key, CultDocumentDescriptor written) => new( diff --git a/tests/GameCult.Caching.Tests/ElementIdTests.cs b/tests/GameCult.Caching.Tests/ElementIdTests.cs index aa1d7b57..28edc1b9 100644 --- a/tests/GameCult.Caching.Tests/ElementIdTests.cs +++ b/tests/GameCult.Caching.Tests/ElementIdTests.cs @@ -274,6 +274,36 @@ namespace GameCult.Caching.Tests Assert.That(DiskRecord(path, "a").SchemaId, Is.Not.EqualTo(otherRuntimeId)); } + // Minting rewrites a loaded record in place under this type's id, and the cache keeps that entry. A newer version of the type, + // registered afterwards, reads the entry the record is now published by whole, so it owns the record: staging asks the entry + // that publishes the id the record carries now, not the one that published the id it was loaded under. + [Test] + public void ANewerVersionReadsWholeTheRecordMintingRelabelledInPlace() + { + const string otherRuntimeId = "other.runtime.id_deck"; + var path = PathOf("mint-then-newer.cc"); + WritePreIdStore(path, null, ("a", "a", 1)); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = otherRuntimeId; + entry.ContentHash = otherRuntimeId; + entry.CompatibleSchemaIds = new[] { otherRuntimeId }; + snapshot.Records.Single().SchemaId = otherRuntimeId; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var key = new CultRecordKey("a"); + + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry })) + { + Assert.That(cache.MintElementIds(), Is.EqualTo(1)); + Assert.That(cache.GetStored(key)!.StoredSchemaId, Is.Not.EqualTo(otherRuntimeId), "minting relabelled the loaded entry in place"); + registry.GetRequired(typeof(IdDeckNext)); + Assert.That(cache.Commit(batch => batch.Upsert(typeof(IdDeckNext), new IdDeckNext { Name = "newer" }, key)), Is.True); + } + + Assert.That(DiskRecord(path, "a").SchemaId, Is.EqualTo(registry.GetRequired(typeof(IdDeckNext)).SchemaId)); + } + // A pre-id record this cache reads only by dropping a persisted slot is read-only, so the rewrite that would persist its // minted ids is refused typed, naming it, and the store is left byte for byte. [Test] @@ -1401,6 +1431,16 @@ namespace GameCult.Caching.Tests [Key(2)] public List Moves { get; set; } = new(); } + // The next version of IdDeck: the same slots under a new schema version. + [CultDocument("tests.element_id_deck", "tests.element_id_deck.v2")] + [MessagePackObject] + public sealed class IdDeckNext + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] public List Reels { get; set; } = new(); + [Key(2)] public List Moves { get; set; } = new(); + } + // The shapes before ids: same slots, no id member. Not documents, so never registered. [MessagePackObject] public sealed class OldMark