2026-10-27 03:39:02 +01:00
#nullable enable
using System ;
using System.IO ;
using System.Linq ;
using System.Security.Cryptography ;
using System.Threading.Tasks ;
using GameCult.Caching.MessagePack ;
using MessagePack ;
using NUnit.Framework ;
namespace GameCult.Caching.Tests
{
// variants:ruling:drift-record-write-ownership: a writer owns a record stored under id X when it declares X, or when the type
// that reads X reads it whole; it then writes, relabels and removes it, and a write stores it under the type's own id. A record
// a type reads only by dropping persisted slots stays read-only until its id is declared compatible.
public class RestampOnWriteTests
{
// One schema name at three versions of its type and nothing declared: Grown reads Narrow's records whole (it adds a slot),
// and reads Wide's records only by dropping slot 3.
[CultDocument("tests.restamp_note", "tests.restamp_note.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NoteNarrow
{
[Key(0)] [ CultName ] public string Name = string . Empty ;
[Key(1)] public int Value ;
}
[CultDocument("tests.restamp_note", "tests.restamp_note.v2")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NoteGrown
{
[Key(0)] [ CultName ] public string Name = string . Empty ;
[Key(1)] public int Value ;
[Key(2)] public string Colour = string . Empty ;
}
[CultDocument("tests.restamp_note", "tests.restamp_note.v3")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NoteWide
{
[Key(0)] [ CultName ] public string Name = string . Empty ;
[Key(1)] public int Value ;
[Key(2)] public string Colour = string . Empty ;
[Key(3)] public string Extra = string . Empty ;
}
2026-10-27 06:14:11 +01:00
// Another schema with Narrow's exact shape: never the same type, whatever the members say.
[CultDocument("tests.restamp_other", "tests.restamp_other.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NoteOther
{
[Key(0)] [ CultName ] public string Name = string . Empty ;
[Key(1)] public int Value ;
}
2026-10-27 03:39:02 +01:00
// Grown with the lossy id declared: declaration is the consent to the loss.
[CultDocument("tests.restamp_note", "tests.restamp_note.v4", CompatibleSchemaIds = new[] { WideId } ) ]
[MessagePackObject(AllowPrivate = true)]
internal sealed class NoteGrownDeclaring
{
[Key(0)] [ CultName ] public string Name = string . Empty ;
[Key(1)] public int Value ;
[Key(2)] public string Colour = string . Empty ;
}
2026-10-27 03:49:16 +01:00
private const string WideId = "sha256:82c2b721aceb56e00288baab590fabcdd82c3bda44400f7530fa9e29c4e96252" ;
2026-10-27 03:39:02 +01:00
private static readonly CultDocumentRegistry NarrowRegistry = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( NoteNarrow ) } ) ;
private static readonly CultDocumentRegistry GrownRegistry = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( NoteGrown ) } ) ;
private static readonly CultDocumentRegistry WideRegistry = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( NoteWide ) } ) ;
private static readonly CultDocumentRegistry DeclaringRegistry = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( NoteGrownDeclaring ) } ) ;
private static readonly CultRecordKey Note = new ( "note" ) ;
private static readonly CultRecordKey Other = new ( "other" ) ;
private string _directory = string . Empty ;
[SetUp]
public void CreateDirectory ( )
{
_directory = Path . Combine ( Path . GetTempPath ( ) , $"cultlib-restamp-{Guid.NewGuid():N}" ) ;
Directory . CreateDirectory ( _directory ) ;
}
[TearDown]
public void DeleteDirectory ( )
{
if ( Directory . Exists ( _directory ) )
Directory . Delete ( _directory , recursive : true ) ;
}
public static readonly object [ ] StoreKinds = { false , true } ;
private CacheBackingStore Store ( string path , bool directory ) = >
directory ? new DirectoryMessagePackBackingStore ( path ) : new SingleFileMessagePackBackingStore ( path ) ;
private CultCache Open ( string path , bool directory , CultDocumentRegistry registry )
{
var cache = new CultCache ( registry ) ;
cache . AddBackingStore ( Store ( path , directory ) ) ;
return cache ;
}
// The schema id each key is stored under, read back through a fresh cache that declares everything it sees.
private string StoredIdOf ( string path , bool directory , CultDocumentRegistry registry , CultRecordKey key )
{
using var cache = Open ( path , directory , registry ) ;
return cache . GetStored ( key ) ! . StoredSchemaId ;
}
private static string Fingerprint ( string path )
{
using var sha = SHA256 . Create ( ) ;
if ( File . Exists ( path ) )
return Convert . ToHexString ( sha . ComputeHash ( File . ReadAllBytes ( path ) ) ) ;
var parts = Directory . GetFiles ( path , "*" , SearchOption . AllDirectories )
. OrderBy ( file = > file , StringComparer . Ordinal )
. Select ( file = > Path . GetRelativePath ( path , file ) + ":" + Convert . ToHexString ( sha . ComputeHash ( File . ReadAllBytes ( file ) ) ) ) ;
return string . Join ( "|" , parts ) ;
}
// Two records written by a writer that knew Narrow: the one under test and a bystander.
private string SeedNarrow ( bool directory )
{
var path = Path . Combine ( _directory , directory ? "store" : "store.cc" ) ;
using var cache = Open ( path , directory , NarrowRegistry ) ;
Assert . That ( cache . Commit ( batch = >
{
batch . Upsert ( new NoteNarrow { Name = "note" , Value = 1 } , new CultRecordHandle < NoteNarrow > ( Note ) ) ;
batch . Upsert ( new NoteNarrow { Name = "other" , Value = 7 } , new CultRecordHandle < NoteNarrow > ( Other ) ) ;
} ) , Is . True ) ;
return path ;
}
private string SeedWide ( bool directory )
{
var path = Path . Combine ( _directory , directory ? "store" : "store.cc" ) ;
using var cache = Open ( path , directory , WideRegistry ) ;
Assert . That ( cache . Commit ( batch = > batch . Upsert ( new NoteWide { Name = "note" , Value = 1 , Extra = "kept?" } , new CultRecordHandle < NoteWide > ( Note ) ) ) , Is . True ) ;
return path ;
}
[TestCaseSource(nameof(StoreKinds))]
public void ADriftRecordReadWholeIsWrittenUnderItsTypesId ( bool directory )
{
var path = SeedNarrow ( directory ) ;
var narrowId = NarrowRegistry . GetRequired < NoteNarrow > ( ) . SchemaId ;
var grownId = GrownRegistry . GetRequired < NoteGrown > ( ) . SchemaId ;
Assert . That ( StoredIdOf ( path , directory , NarrowRegistry , Note ) , Is . EqualTo ( narrowId ) ) ;
using ( var cache = Open ( path , directory , GrownRegistry ) )
{
Assert . That ( cache . Get < NoteGrown > ( Note ) ! . Value , Is . EqualTo ( 1 ) ) ;
Assert . That ( cache . Commit ( batch = > batch . Upsert ( new NoteGrown { Name = "note" , Value = 2 , Colour = "red" } , new CultRecordHandle < NoteGrown > ( Note ) ) ) , Is . True ) ;
}
Assert . That ( StoredIdOf ( path , directory , GrownRegistry , Note ) , Is . EqualTo ( grownId ) , "the written record moved to its type's id" ) ;
Assert . That ( StoredIdOf ( path , directory , NarrowRegistry , Other ) , Is . EqualTo ( narrowId ) , "the bystander kept its persisted id (write set)" ) ;
using var reopened = Open ( path , directory , GrownRegistry ) ;
Assert . That ( reopened . Get < NoteGrown > ( Note ) ! . Colour , Is . EqualTo ( "red" ) ) ;
Assert . That ( reopened . Get < NoteGrown > ( Other ) ! . Value , Is . EqualTo ( 7 ) ) ;
}
[TestCaseSource(nameof(StoreKinds))]
public void RemovingADriftRecordReadWholeSucceeds ( bool directory )
{
var path = SeedNarrow ( directory ) ;
using ( var cache = Open ( path , directory , GrownRegistry ) )
Assert . That ( cache . Commit ( batch = > batch . Remove ( Note ) ) , Is . True ) ;
using var reopened = Open ( path , directory , GrownRegistry ) ;
Assert . That ( reopened . Get < NoteGrown > ( Note ) , Is . Null ) ;
Assert . That ( reopened . Get < NoteGrown > ( Other ) , Is . Not . Null ) ;
}
[TestCaseSource(nameof(StoreKinds))]
public void ADriftRecordReadOnlyByDroppingASlotStaysReadOnly ( bool directory )
{
var path = SeedWide ( directory ) ;
var before = Fingerprint ( path ) ;
using var cache = Open ( path , directory , GrownRegistry ) ;
Assert . That ( cache . Get < NoteGrown > ( Note ) , Is . Not . Null , "a read-only record is still served" ) ;
var upsert = Assert . Throws < CultSchemaConflictException > ( ( ) = >
cache . Commit ( batch = > batch . Upsert ( new NoteGrown { Name = "note" , Value = 2 } , new CultRecordHandle < NoteGrown > ( Note ) ) ) ) ! ;
Assert . That ( upsert . RecordKey , Is . EqualTo ( "note" ) ) ;
Assert . That ( upsert . SchemaId , Is . EqualTo ( WideRegistry . GetRequired < NoteWide > ( ) . SchemaId ) ) ;
2026-10-27 05:34:43 +01:00
Assert . That ( upsert . Message , Does . Contain ( "would drop persisted slots" ) . And . Contain ( "to accept the loss." ) , "the refusal names the narrowed rule" ) ;
2026-10-27 03:39:02 +01:00
var remove = Assert . Throws < CultSchemaConflictException > ( ( ) = > 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" ) ;
}
2026-10-27 05:43:02 +01:00
// A held record read only by dropping a slot is refused before anything is staged: with the store's lock held by another
// writer, the write is refused typed instead of waiting on the lock and reporting contention.
[TestCaseSource(nameof(StoreKinds))]
public void ALossyDriftRecordIsRefusedBeforeTheStoresLockIsAsked ( bool directory )
{
var path = SeedWide ( directory ) ;
using var cache = Open ( path , directory , GrownRegistry ) ;
using var held = directory
? new FileStream ( Path . Combine ( DirectoryMessagePackBackingStore . DefaultRecordDirectoryPath ( path ) , ".commit.lock" ) , FileMode . OpenOrCreate , FileAccess . ReadWrite , FileShare . None )
: new FileStream ( path + ".lock" , FileMode . OpenOrCreate , FileAccess . ReadWrite , FileShare . None ) ;
Assert . Throws < CultSchemaConflictException > ( ( ) = >
cache . TryCommit ( batch = > batch . Upsert ( new NoteGrown { Name = "note" , Value = 2 } , new CultRecordHandle < NoteGrown > ( Note ) ) ) ) ;
}
2026-10-27 03:39:02 +01:00
[TestCaseSource(nameof(StoreKinds))]
public void ADeclaredLossyDriftRecordIsWritable ( bool directory )
{
var path = SeedWide ( directory ) ;
2026-10-27 03:46:27 +01:00
var wideId = WideRegistry . GetRequired < NoteWide > ( ) . SchemaId ;
Assert . That ( wideId , Is . EqualTo ( WideId ) , "the declaration names the lossy record's id: " + wideId ) ;
2026-10-27 03:39:02 +01:00
using ( var cache = Open ( path , directory , DeclaringRegistry ) )
Assert . That ( cache . Commit ( batch = > batch . Upsert ( new NoteGrownDeclaring { Name = "note" , Value = 2 } , new CultRecordHandle < NoteGrownDeclaring > ( Note ) ) ) , Is . True ) ;
Assert . That ( StoredIdOf ( path , directory , DeclaringRegistry , Note ) , Is . EqualTo ( DeclaringRegistry . GetRequired < NoteGrownDeclaring > ( ) . SchemaId ) ) ;
}
// Another writer rewrites the record after this cache loaded it: ownership is judged again under the store's lock.
[TestCaseSource(nameof(StoreKinds))]
public void AnotherWritersDriftRecordIsJudgedUnderTheLock ( bool directory )
{
var path = SeedNarrow ( directory ) ;
using var cache = Open ( path , directory , GrownRegistry ) ;
Assert . That ( cache . Get < NoteGrown > ( Note ) , Is . Not . Null ) ;
// A writer that knows Wide replaces the record: this cache's loaded copy was read whole, the durable one is not.
using ( var other = Open ( path , directory , WideRegistry ) )
{
Assert . That ( other . Commit ( batch = > batch . Upsert ( new NoteWide { Name = "note" , Value = 3 , Extra = "x" } , new CultRecordHandle < NoteWide > ( Note ) ) ) , Is . True ) ;
}
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = >
cache . Commit ( batch = > batch . Upsert ( new NoteGrown { Name = "note" , Value = 4 } , new CultRecordHandle < NoteGrown > ( Note ) ) ) ) ! ;
Assert . That ( refusal . RecordKey , Is . EqualTo ( "note" ) ) ;
Assert . That ( StoredIdOf ( path , directory , WideRegistry , Note ) , Is . EqualTo ( WideRegistry . GetRequired < NoteWide > ( ) . SchemaId ) ) ;
}
[Test]
public void ACachelessWriteRestampsADriftRecordOfItsSchema ( )
{
var path = SeedNarrow ( directory : false ) ;
new SingleFileMessagePackBackingStore ( path ) . WriteDocument ( Note , new NoteGrown { Name = "note" , Value = 2 } , registry : GrownRegistry ) ;
Assert . That ( StoredIdOf ( path , false , GrownRegistry , Note ) , Is . EqualTo ( GrownRegistry . GetRequired < NoteGrown > ( ) . SchemaId ) ) ;
Assert . That ( StoredIdOf ( path , false , NarrowRegistry , Other ) , Is . EqualTo ( NarrowRegistry . GetRequired < NoteNarrow > ( ) . SchemaId ) ) ;
}
[Test]
public void ACachelessWriteOverALossyDriftRecordIsRefusedAndTheFileIsUntouched ( )
{
var path = SeedWide ( directory : false ) ;
var before = Fingerprint ( path ) ;
2026-10-27 05:34:43 +01:00
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = >
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" ) ;
2026-10-27 03:39:02 +01:00
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
}
2026-10-27 05:34:43 +01:00
// 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 < NoteGrownDeclaring > ( ) . 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 < NoteWide > ( Note ) ) ) , Is . True ) ;
Assert . Throws < CultSchemaConflictException > ( ( ) = >
cache . Commit ( batch = > batch . Upsert ( new NoteGrown { Name = "note" , Value = 4 } , new CultRecordHandle < NoteGrown > ( Note ) ) ) ) ;
}
2026-10-27 03:39:02 +01:00
[TestCaseSource(nameof(StoreKinds))]
public void ExpectAgreesWithRestamp ( bool directory )
{
var path = SeedNarrow ( directory ) ;
2026-10-27 03:46:27 +01:00
var third = new CultRecordKey ( "third" ) ;
2026-10-27 03:39:02 +01:00
using var cache = Open ( path , directory , GrownRegistry ) ;
Assert . That ( cache . Commit ( batch = >
{
2026-10-27 03:46:27 +01:00
batch . Expect ( Note , cache . Get ( Note ) ) ;
batch . ExpectUnchanged ( ) ;
batch . Upsert ( new NoteGrown { Name = "third" } , new CultRecordHandle < NoteGrown > ( third ) ) ;
} ) , Is . True , "an unchanged drift record satisfies Expect and ExpectUnchanged" ) ;
using var second = Open ( path , directory , GrownRegistry ) ;
Assert . That ( cache . Commit ( batch = >
{
batch . Expect ( Note , cache . Get ( Note ) ) ;
2026-10-27 03:39:02 +01:00
batch . Upsert ( new NoteGrown { Name = "note" , Value = 2 } , new CultRecordHandle < NoteGrown > ( Note ) ) ;
2026-10-27 03:46:27 +01:00
} ) , Is . True , "a batch that Expects the drift record and upserts it commits" ) ;
Assert . That ( StoredIdOf ( path , directory , GrownRegistry , Note ) , Is . EqualTo ( GrownRegistry . GetRequired < NoteGrown > ( ) . SchemaId ) , "and restamps it" ) ;
Assert . That ( cache . Commit ( batch = >
{
batch . ExpectUnchanged ( ) ;
batch . Upsert ( new NoteGrown { Name = "fourth" } , new CultRecordHandle < NoteGrown > ( new CultRecordKey ( "fourth" ) ) ) ;
} ) , Is . True , "the writer's later ExpectUnchanged holds" ) ;
Assert . That ( second . Commit ( batch = >
{
batch . Expect ( Note , second . Get ( Note ) ) ;
batch . Upsert ( new NoteGrown { Name = "note" , Value = 3 } , new CultRecordHandle < NoteGrown > ( Note ) ) ;
} ) , Is . False , "a cache loaded before the restamp fails its Expect: the bytes changed" ) ;
2026-10-27 03:39:02 +01:00
}
2026-10-27 06:14:11 +01:00
private static readonly CultRecordKey Base = new ( "base" ) ;
private static readonly CultRecordKey Delta = new ( "delta" ) ;
private CultCache OpenWithCodec ( string path , bool directory , CultDocumentRegistry registry )
{
var cache = new CultCache ( registry , CultCacheMessagePack . CreateCodec ( registry ) ) ;
cache . AddBackingStore ( Store ( path , directory ) ) ;
return cache ;
}
2026-10-27 06:15:10 +01:00
private string StoredIdOfVariantStore ( string path , CultDocumentRegistry registry , CultRecordKey key )
{
using var cache = OpenWithCodec ( path , false , registry ) ;
return cache . GetStored ( key ) ! . StoredSchemaId ;
}
2026-10-27 06:14:11 +01:00
// A base and a variant of it, written by a writer that knew Narrow; Grown then reads the variant whole as compatible drift.
2026-10-27 06:15:10 +01:00
private string SeedNarrowVariant ( )
2026-10-27 06:14:11 +01:00
{
2026-10-27 06:15:10 +01:00
var path = Path . Combine ( _directory , "store.cc" ) ;
using var cache = OpenWithCodec ( path , false , NarrowRegistry ) ;
2026-10-27 06:14:11 +01:00
Assert . That ( cache . Commit ( batch = > batch . Upsert ( new NoteNarrow { Name = "base" , Value = 1 } , new CultRecordHandle < NoteNarrow > ( Base ) ) ) , Is . True ) ;
Assert . That ( cache . Commit ( batch = > batch . UpsertVariant ( Delta , Base , new [ ] { cache . Override < NoteNarrow > ( "Name" , "delta" ) , cache . Override < NoteNarrow > ( "Value" , 5 ) } ) ) , Is . True ) ;
return path ;
}
2026-10-27 06:15:10 +01:00
// Variants live in a single file (the directory store refuses them). The staging half asks the flag ToStoredDocument set on a loaded variant, and Resolved and Flatten carry it on: an owned
2026-10-27 06:14:11 +01:00
// drift variant is rewritten, flattened and removed like a plain drift record, restamping only what is written.
2026-10-27 06:15:10 +01:00
[Test]
public async Task AnOwnedDriftVariantIsRewritten ( )
2026-10-27 06:14:11 +01:00
{
2026-10-27 06:15:10 +01:00
var path = SeedNarrowVariant ( ) ;
using ( var cache = OpenWithCodec ( path , false , GrownRegistry ) )
2026-10-27 06:14:11 +01:00
{
await cache . PullAllBackingStoresAsync ( ) ;
Assert . That ( cache . Commit ( batch = > batch . UpsertVariant ( Delta , Base , new [ ] { cache . Override < NoteGrown > ( "Name" , "delta" ) , cache . Override < NoteGrown > ( "Value" , 6 ) } ) ) , Is . True ) ;
}
2026-10-27 06:15:10 +01:00
Assert . That ( StoredIdOfVariantStore ( path , GrownRegistry , Delta ) , Is . EqualTo ( GrownRegistry . GetRequired < NoteGrown > ( ) . SchemaId ) ) ;
Assert . That ( StoredIdOfVariantStore ( path , NarrowRegistry , Base ) , Is . EqualTo ( NarrowRegistry . GetRequired < NoteNarrow > ( ) . SchemaId ) , "the base was not written" ) ;
2026-10-27 06:14:11 +01:00
}
2026-10-27 06:15:10 +01:00
[Test]
public async Task AnOwnedDriftVariantIsFlattened ( )
2026-10-27 06:14:11 +01:00
{
2026-10-27 06:15:10 +01:00
var path = SeedNarrowVariant ( ) ;
using ( var cache = OpenWithCodec ( path , false , GrownRegistry ) )
2026-10-27 06:14:11 +01:00
{
await cache . PullAllBackingStoresAsync ( ) ;
Assert . That ( cache . Commit ( batch = > batch . Flatten ( Delta ) ) , Is . True ) ;
}
2026-10-27 06:15:10 +01:00
Assert . That ( StoredIdOfVariantStore ( path , GrownRegistry , Delta ) , Is . EqualTo ( GrownRegistry . GetRequired < NoteGrown > ( ) . SchemaId ) ) ;
2026-10-27 06:14:11 +01:00
}
2026-10-27 06:15:10 +01:00
[Test]
public async Task AnOwnedDriftVariantIsRemoved ( )
2026-10-27 06:14:11 +01:00
{
2026-10-27 06:15:10 +01:00
var path = SeedNarrowVariant ( ) ;
using ( var cache = OpenWithCodec ( path , false , GrownRegistry ) )
2026-10-27 06:14:11 +01:00
{
await cache . PullAllBackingStoresAsync ( ) ;
Assert . That ( cache . Commit ( batch = > batch . Remove ( Delta ) ) , Is . True ) ;
}
2026-10-27 06:15:10 +01:00
using var reopened = OpenWithCodec ( path , false , GrownRegistry ) ;
2026-10-27 06:14:11 +01:00
await reopened . PullAllBackingStoresAsync ( ) ;
Assert . That ( reopened . GetStored ( Delta ) , Is . Null ) ;
Assert . That ( reopened . GetStored ( Base ) , Is . Not . Null ) ;
}
// A store whose entry is a newer writer's rename of Narrow's schema, with identical members and the given compatible ids.
private string SeedRenamed ( bool listsNarrow )
{
var path = SeedNarrow ( directory : false ) ;
var snapshot = Snapshot ( path ) ;
var narrowId = NarrowRegistry . GetRequired < NoteNarrow > ( ) . SchemaId ;
var entry = snapshot . SchemaCatalog . Single ( ) ;
entry . SchemaId = entry . ContentHash = "tests.restamp.renamed" ;
entry . SchemaName = "tests.restamp_note_renamed" ;
entry . SchemaVersion = "tests.restamp_note_renamed.v2" ;
entry . CompatibleSchemaIds = listsNarrow ? new [ ] { "tests.restamp.renamed" , narrowId } : new [ ] { "tests.restamp.renamed" } ;
foreach ( var record in snapshot . Records )
record . SchemaId = entry . SchemaId ;
File . WriteAllBytes ( path , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
return path ;
}
// One writer of NoteNarrow, with a cache or without one: the same Owns answer for the same record.
private void WriteNarrow ( string path , bool cacheless )
{
if ( cacheless )
{
new SingleFileMessagePackBackingStore ( path ) . WriteDocument ( Note , new NoteNarrow { Name = "note" , Value = 2 } , registry : NarrowRegistry ) ;
return ;
}
using var cache = Open ( path , false , NarrowRegistry ) ;
cache . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( cache . Commit ( batch = > batch . Upsert ( new NoteNarrow { Name = "note" , Value = 2 } , new CultRecordHandle < NoteNarrow > ( Note ) ) ) , Is . True ) ;
}
[TestCase(false)]
[TestCase(true)]
public void ARenamedEntryListingTheWritersIdIsOwnedByCacheAndCachelessWriterAlike ( bool cacheless )
{
var path = SeedRenamed ( listsNarrow : true ) ;
WriteNarrow ( path , cacheless ) ;
Assert . That ( Snapshot ( path ) . Records . Single ( record = > record . Key = = "note" ) . SchemaId , Is . EqualTo ( NarrowRegistry . GetRequired < NoteNarrow > ( ) . SchemaId ) ) ;
}
[TestCase(false)]
[TestCase(true)]
public void ARenamedEntryNotListingTheWritersIdIsRefusedByCacheAndCachelessWriterAlike ( bool cacheless )
{
var path = SeedRenamed ( listsNarrow : false ) ;
var before = Fingerprint ( path ) ;
Assert . Throws < CultSchemaConflictException > ( ( ) = > WriteNarrow ( path , cacheless ) ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
}
// A writer of one schema never overwrites and retypes a record of another schema, however alike their members.
[TestCase(false)]
[TestCase(true)]
public void AWriterOfOneSchemaNeverRetypesASameShapeRecordOfAnother ( bool cacheless )
{
var path = Path . Combine ( _directory , "store.cc" ) ;
var otherRegistry = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( NoteOther ) } ) ;
using ( var seed = Open ( path , false , otherRegistry ) )
Assert . That ( seed . Commit ( batch = > batch . Upsert ( new NoteOther { Name = "note" , Value = 1 } , new CultRecordHandle < NoteOther > ( Note ) ) ) , Is . True ) ;
var before = Fingerprint ( path ) ;
Assert . Throws < CultSchemaConflictException > ( ( ) = > WriteNarrow ( path , cacheless ) ) ;
Assert . That ( Fingerprint ( path ) , Is . EqualTo ( before ) ) ;
}
2026-10-27 03:39:02 +01:00
private static string VectorPath ( string name )
{
for ( var dir = new DirectoryInfo ( TestContext . CurrentContext . TestDirectory ) ; dir ! = null ; dir = dir . Parent )
{
var vectors = Path . Combine ( dir . FullName , "tests" , "vectors" , "ref-list-drift" ) ;
if ( Directory . Exists ( vectors ) ) return Path . Combine ( vectors , name ) ;
}
throw new DirectoryNotFoundException ( $"tests/vectors/ref-list-drift not found above {TestContext.CurrentContext.TestDirectory}." ) ;
}
// The store the 1.0.60 writer wrote: its 'list' holder compares compatible with RefListHolder, gaining reference semantics
// on a slot, and ignores no persisted slot, so RefListHolder reads it whole and may write it.
[Test]
public async Task TheCommitted1060VectorsListRecordIsWrittenUnderTheHoldersOwnId ( )
{
var path = Path . Combine ( _directory , "vector.cc" ) ;
File . Copy ( VectorPath ( "v1-0-60-written.cc" ) , path ) ;
var registry = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( RefListDriftTests . RefListTarget ) , typeof ( RefListDriftTests . RefListHolder ) } ) ;
var holderId = registry . GetRequired < RefListDriftTests . RefListHolder > ( ) . SchemaId ;
var before = Snapshot ( path ) ;
Assert . That ( before . Records . Single ( record = > record . Key = = "list" ) . SchemaId , Is . Not . EqualTo ( holderId ) , "the vector's list is under an older id" ) ;
using ( var cache = new CultCache ( registry ) )
{
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( path ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
var holder = cache . GetAll < RefListDriftTests . RefListHolder > ( ) . Single ( ) ;
Assert . That ( cache . Commit ( batch = > batch . Upsert (
new RefListDriftTests . RefListHolder { Name = holder . Name , Items = holder . Items . Reverse ( ) . ToArray ( ) } ,
new CultRecordHandle < RefListDriftTests . RefListHolder > ( new CultRecordKey ( "list" ) ) ) ) , Is . True ) ;
}
var after = Snapshot ( path ) ;
Assert . That ( after . Records . Single ( record = > record . Key = = "list" ) . SchemaId , Is . EqualTo ( holderId ) ) ;
Assert . That ( after . SchemaCatalog . Single ( entry = > entry . SchemaId = = holderId ) . Members . Single ( member = > member . Slot = = 1 ) . IsReference , Is . True ) ;
foreach ( var kept in new [ ] { "alpha" , "beta" } )
{
Assert . That ( after . Records . Single ( record = > record . Key = = kept ) . SchemaId , Is . EqualTo ( before . Records . Single ( record = > record . Key = = kept ) . SchemaId ) , kept + " keeps its persisted id" ) ;
Assert . That ( after . Records . Single ( record = > record . Key = = kept ) . Payload , Is . EqualTo ( before . Records . Single ( record = > record . Key = = kept ) . Payload ) , kept + " keeps its bytes" ) ;
}
using var reopened = new CultCache ( registry ) ;
reopened . AddBackingStore ( new SingleFileMessagePackBackingStore ( path ) ) ;
await reopened . PullAllBackingStoresAsync ( ) ;
Assert . That ( reopened . GetAll < RefListDriftTests . RefListHolder > ( ) . Single ( ) . Items . Select ( item = > item . Key . Value ) . ToArray ( ) , Is . EqualTo ( new [ ] { "beta" , "alpha" } ) ) ;
}
private static CultPersistedStoreSnapshot Snapshot ( string path ) = >
CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( path ) ) ;
}
}