diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index df236053..63180a1b 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -342,7 +342,6 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore var indexRecord = ToContentAddressedIndexRecord(record, pagePayload); stored.StoredVersion = VersionOf(indexRecord.Payload); stored.StoredSchemaId = stored.Descriptor.SchemaId; - stored.StoredSchemaReadWhole = true; currentIndex[key] = indexRecord; WriteFileAtomically( ContentAddressedRecordPath(indexRecord), diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index c4616b0e..6c4728c8 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -518,9 +518,10 @@ namespace GameCult.Caching // True when the schema this record was loaded under left none of its persisted slots unread by Descriptor, so the type reads // it whole and owns it (CultDocumentRegistry.Owns). Set by ToStoredDocument from the schema comparison's ignored slots, - // before any variant override is shed; true for a record this cache constructed or wrote. Ownership at the staging half - // asks it; ConditionsHold never reads it. - internal bool StoredSchemaReadWhole { get; set; } = true; + // before any variant override is shed. A record this cache constructed or wrote is stored under Descriptor's own id, which + // the registry declares, so only a loaded record under another id is ever asked. The staging half asks it; + // ConditionsHold never reads it. + internal bool StoredSchemaReadWhole { 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. @@ -2107,7 +2108,6 @@ namespace GameCult.Caching if (!_entries.TryGetValue(record.Key, out var entry)) continue; entry.StoredSchemaId = record.SchemaId; - entry.StoredSchemaReadWhole = true; entry.StoredAt = record.StoredAt; entry.StoredVersion = version; if (entry.Variant == null) @@ -4708,7 +4708,6 @@ namespace GameCult.Caching if (!Entries.TryGetValue(write.Record.Key, out var entry)) continue; entry.StoredSchemaId = write.Record.SchemaId; - entry.StoredSchemaReadWhole = true; entry.StoredAt = write.Record.StoredAt; entry.StoredVersion = version; } diff --git a/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs b/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs index 1d5fcd23..12a4fed0 100644 --- a/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs +++ b/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs @@ -175,6 +175,7 @@ namespace GameCult.Caching.Tests cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 2 }, new CultRecordHandle(Note))))!; Assert.That(upsert.RecordKey, Is.EqualTo("note")); Assert.That(upsert.SchemaId, Is.EqualTo(WideRegistry.GetRequired().SchemaId)); + Assert.That(upsert.Message, Does.Contain("would drop persisted slots").And.Contain("to accept the loss."), "the refusal names the narrowed rule"); var remove = Assert.Throws(() => cache.Commit(batch => batch.Remove(Note)))!; Assert.That(remove.RecordKey, Is.EqualTo("note")); Assert.That(Fingerprint(path), Is.EqualTo(before), "a refused write leaves the store as it was"); @@ -228,11 +229,37 @@ namespace GameCult.Caching.Tests { var path = SeedWide(directory: false); var before = Fingerprint(path); - Assert.Throws(() => - new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrown { Name = "note", Value = 2 }, registry: GrownRegistry)); + var refusal = Assert.Throws(() => + new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrown { Name = "note", Value = 2 }, registry: GrownRegistry))!; + Assert.That(refusal.Message, Does.Contain("reads it whole").And.Contain("to accept the loss of its unread slots."), "the refusal names the narrowed rule"); Assert.That(Fingerprint(path), Is.EqualTo(before)); } + // A cacheless writer's entry that lists the record's id compatible consents to the loss, as a cache's declaration does. + [Test] + public void ACachelessWriteByAnEntryListingTheIdCompatibleOverwritesALossyDriftRecord() + { + var path = SeedWide(directory: false); + new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrownDeclaring { Name = "note", Value = 2 }, registry: DeclaringRegistry); + + Assert.That(StoredIdOf(path, false, DeclaringRegistry, Note), Is.EqualTo(DeclaringRegistry.GetRequired().SchemaId)); + } + + // The type that would read the id cannot be named (two registered versions share the schema name and the id declares neither), + // so the record is not owned and a write over it is refused typed, not as the resolver's own error. + [TestCaseSource(nameof(StoreKinds))] + public void ARecordNoRegisteredTypeCanBeNamedForIsNotOwned(bool directory) + { + var path = SeedNarrow(directory); + var both = CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow), typeof(NoteGrown) }); + using var cache = Open(path, directory, both); + using (var other = Open(path, directory, WideRegistry)) + Assert.That(other.Commit(batch => batch.Upsert(new NoteWide { Name = "note", Value = 3 }, new CultRecordHandle(Note))), Is.True); + + Assert.Throws(() => + cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 4 }, new CultRecordHandle(Note)))); + } + [TestCaseSource(nameof(StoreKinds))] public void ExpectAgreesWithRestamp(bool directory) {