C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
#nullable enable
using System ;
2026-10-03 09:55:11 +02:00
using System.Buffers ;
2026-10-03 09:48:25 +02:00
using System.Collections.Generic ;
2026-10-03 06:09:21 +02:00
using System.Globalization ;
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
using System.IO ;
using System.Linq ;
using GameCult.Caching.MessagePack ;
using MessagePack ;
using NUnit.Framework ;
namespace GameCult.Caching.Tests
{
// Rule 1 of variants:ruling:write-set-content-version-declared-ownership: a flush, a commit and a single-document write apply
// only what the writer staged to what the store durably holds now, under its lock. Every other record and catalog entry is
// copied forward as stored. Every input goes through the cache's own write API and the real stores.
public class WriteSetTests
{
[CultDocument("tests.write_set_item", "tests.write_set_item.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class WsItem
{
[Key(0)]
[CultName]
public string Name = string . Empty ;
[Key(1)]
public string Note = string . Empty ;
}
[CultDocument("tests.write_set_other", "tests.write_set_other.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class WsOther
{
[Key(0)]
[CultName]
public string Name = string . Empty ;
}
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
[CultDocument("tests.write_set_nested", "tests.write_set_nested.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class WsNested
{
[Key(0)]
[CultName]
public string Name = string . Empty ;
[Key(1)]
public List < string > Tags = new ( ) ;
[Key(2)]
public WsInner Inner = new ( ) ;
}
[MessagePackObject(AllowPrivate = true)]
internal sealed class WsInner
{
[Key(0)]
public int X ;
}
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( WsItem ) , typeof ( WsOther ) , typeof ( WsNested ) } ) ;
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
private static readonly string ItemId = Registry . GetRequired ( typeof ( WsItem ) ) . SchemaId ;
private static readonly CultRecordKey A = new ( "a" ) ;
private static readonly CultRecordKey X = new ( "x" ) ;
private string _directory = string . Empty ;
[SetUp]
public void SetUp ( )
{
_directory = Path . Combine ( Path . GetTempPath ( ) , "cultlib-write-set-" + Guid . NewGuid ( ) . ToString ( "N" ) ) ;
Directory . CreateDirectory ( _directory ) ;
}
[TearDown]
public void TearDown ( )
{
if ( Directory . Exists ( _directory ) )
Directory . Delete ( _directory , true ) ;
}
private string PathOf ( string name ) = > Path . Combine ( _directory , name ) ;
private static CultCache Open ( string path , bool directory ) = >
CultCacheMessagePack . Create ( path , new CultCacheOpenOptions { Registry = Registry , UseDirectoryStore = directory } ) ;
private static CultPersistedStoreSnapshot Read ( string path ) = >
CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( path ) ) ;
private static void Write ( string path , CultPersistedStoreSnapshot snapshot ) = >
File . WriteAllBytes ( path , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
// What a record is on disk: its id, its storedAt and, in a single file, its payload (a directory manifest indexes pages).
private static string Stored ( string path , string key , bool directory )
{
var record = Read ( path ) . Records . Single ( candidate = > candidate . Key = = key ) ;
return $"{record.SchemaId}|{record.StoredAt}|{(directory ? string.Empty : BitConverter.ToString(record.Payload))}" ;
}
private static byte [ ] EntryBytes ( string path , string schemaId ) = >
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
StoreSlices . CatalogEntry ( path , schemaId ) ;
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
private static string [ ] Keys ( string path ) = > Read ( path ) . Records . Select ( record = > record . Key ) . OrderBy ( key = > key , StringComparer . Ordinal ) . ToArray ( ) ;
private static void Seed ( string path , bool directory , params string [ ] keys )
{
using var cache = Open ( path , directory ) ;
cache . Commit ( batch = >
{
foreach ( var key in keys )
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-" + key , Note = "seeded" } , new CultRecordKey ( key ) ) ;
} ) ;
}
// One staged write landed by a plain flush or by an unconditional commit.
private static void Land ( CultCache cache , bool commit , Type type , object document , CultRecordKey key )
{
if ( commit )
{
cache . Commit ( batch = > batch . Upsert ( type , document , key ) ) ;
return ;
}
cache . UpsertAsync ( type , document , key ) . GetAwaiter ( ) . GetResult ( ) ;
cache . FlushAllBackingStores ( ) ;
}
// A loads, B writes x, A stages y and flushes or commits: x keeps B's bytes, id and storedAt. A record B removed stays removed.
[Test]
public void ALoadThenAWriteAfterAnotherWritersWriteKeepsThatWrite ( [ Values ] bool directory , [ Values ] bool commit )
{
var path = PathOf ( "keeps.cc" ) ;
Seed ( path , directory , "a" ) ;
using var a = Open ( path , directory ) ;
using ( var b = Open ( path , directory ) )
b . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-x" , Note = "by-b" } , X ) ) ;
var written = Stored ( path , "x" , directory ) ;
Land ( a , commit , typeof ( WsItem ) , new WsItem { Name = "name-y" , Note = "by-a" } , new CultRecordKey ( "y" ) ) ;
Assert . That ( Keys ( path ) , Is . EqualTo ( new [ ] { "a" , "x" , "y" } ) ) ;
Assert . That ( Stored ( path , "x" , directory ) , Is . EqualTo ( written ) ) ;
}
[Test]
public void ARecordAnotherWriterRemovedStaysRemovedAfterALaterWrite ( [ Values ] bool directory , [ Values ] bool commit )
{
var path = PathOf ( "removed.cc" ) ;
Seed ( path , directory , "a" , "x" ) ;
using var a = Open ( path , directory ) ;
Assert . That ( a . Get < WsItem > ( X ) , Is . Not . Null , "A loaded x" ) ;
using ( var b = Open ( path , directory ) )
b . Commit ( batch = > batch . Remove ( X ) ) ;
Land ( a , commit , typeof ( WsItem ) , new WsItem { Name = "name-y" } , new CultRecordKey ( "y" ) ) ;
Assert . That ( Keys ( path ) , Is . EqualTo ( new [ ] { "a" , "y" } ) , "A's copy of x is not written back" ) ;
}
// A path with nothing at it when the cache opened, and a store there by the time it writes: the write lands on that store.
[Test]
public void AWriteOverAStoreThatAppearedAfterTheOpenKeepsItsRecords ( [ Values ] bool commit )
{
var path = PathOf ( "appeared.cc" ) ;
using var a = Open ( path , false ) ;
Assert . That ( File . Exists ( path ) , Is . False ) ;
using ( var b = Open ( path , false ) )
b . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-x" } , X ) ) ;
Land ( a , commit , typeof ( WsItem ) , new WsItem { Name = "name-y" } , new CultRecordKey ( "y" ) ) ;
Assert . That ( Keys ( path ) , Is . EqualTo ( new [ ] { "x" , "y" } ) ) ;
}
// An in-place change to a loaded document is not a write: it is written when the document is staged.
[Test]
public void AnInPlaceChangeThatWasNeverStagedIsNotWrittenAndAStagedOneIs ( [ Values ] bool directory , [ Values ] bool commit )
{
var path = PathOf ( "in-place.cc" ) ;
Seed ( path , directory , "a" ) ;
using var cache = Open ( path , directory ) ;
var item = cache . Get < WsItem > ( A ) ! ;
item . Note = "edited" ;
cache . FlushAllBackingStores ( ) ;
using ( var check = Open ( path , directory ) )
Assert . That ( check . Get < WsItem > ( A ) ! . Note , Is . EqualTo ( "seeded" ) , "an unstaged change is not written" ) ;
Land ( cache , commit , typeof ( WsItem ) , item , A ) ;
using ( var check = Open ( path , directory ) )
Assert . That ( check . Get < WsItem > ( A ) ! . Note , Is . EqualTo ( "edited" ) ) ;
}
// B changes the durable entry for the id of a record A carries. A writes a record under another id: the entry for the id it
// did not write is B's, byte for byte, not the one A registered or loaded.
[Test]
public void ARecordCopiedForwardKeepsTheCatalogEntryTheStoreHoldsNow ( [ Values ] bool directory , [ Values ] bool commit )
{
var path = PathOf ( "entry.cc" ) ;
Seed ( path , directory , "a" ) ;
using var cache = Open ( path , directory ) ;
var snapshot = Read ( path ) ;
snapshot . SchemaCatalog . Single ( entry = > entry . SchemaId = = ItemId ) . ContentHash = "changed-by-b" ;
Write ( path , snapshot ) ;
var theirs = EntryBytes ( path , ItemId ) ;
Land ( cache , commit , typeof ( WsOther ) , new WsOther { Name = "name-z" } , new CultRecordKey ( "z" ) ) ;
Assert . That ( EntryBytes ( path , ItemId ) , Is . EqualTo ( theirs ) ) ;
Assert . That ( Read ( path ) . SchemaCatalog . Single ( entry = > entry . SchemaId = = ItemId ) . ContentHash , Is . EqualTo ( "changed-by-b" ) ) ;
}
// The runtime decodes seven fields of an entry. Fields after them, and the width a value was written in, are not its to drop.
[Test]
public void ACarriedCatalogEntryKeepsItsExactBytesIncludingWhatThisRuntimeDoesNotDecode ( [ Values ] bool directory , [ Values ] bool commit )
{
var path = PathOf ( "raw.cc" ) ;
Seed ( path , directory , "a" ) ;
var original = EntryBytes ( path , ItemId ) ;
Assert . That ( original [ 0 ] , Is . EqualTo ( 0x97 ) , "a fixarray of the seven fields the runtime knows" ) ;
var crafted = new byte [ ] { 0x99 }
. Concat ( original . Skip ( 1 ) )
. Concat ( new byte [ ] { 0xd3 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 1 } )
. Concat ( new byte [ ] { 0xc0 } )
. ToArray ( ) ;
var bytes = File . ReadAllBytes ( path ) ;
var at = IndexOf ( bytes , original ) ;
Assert . That ( at , Is . GreaterThanOrEqualTo ( 0 ) ) ;
File . WriteAllBytes ( path , bytes . Take ( at ) . Concat ( crafted ) . Concat ( bytes . Skip ( at + original . Length ) ) . ToArray ( ) ) ;
using var cache = Open ( path , directory ) ;
Assert . That ( cache . Get < WsItem > ( A ) , Is . Not . Null , "the entry with two unknown trailing fields reads" ) ;
Land ( cache , commit , typeof ( WsOther ) , new WsOther { Name = "name-z" } , new CultRecordKey ( "z" ) ) ;
Assert . That ( IndexOf ( File . ReadAllBytes ( path ) , crafted ) , Is . GreaterThanOrEqualTo ( 0 ) , "the entry is in the written store byte for byte" ) ;
}
private static int IndexOf ( byte [ ] haystack , byte [ ] needle )
{
for ( var start = 0 ; start + needle . Length < = haystack . Length ; start + + )
{
var same = true ;
for ( var index = 0 ; same & & index < needle . Length ; index + + )
same = haystack [ start + index ] = = needle [ index ] ;
if ( same )
return start ;
}
return - 1 ;
}
2026-10-03 03:20:17 +02:00
// The condition is the only difference between a conditional and an unconditional commit: one batch, one apply.
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
[Test]
2026-10-03 03:20:17 +02:00
public void AConditionalAndAnUnconditionalCommitOfOneBatchOntoOneStoreWriteTheSameBytes ( )
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
{
var first = PathOf ( "unconditional.cc" ) ;
var second = PathOf ( "conditional.cc" ) ;
Seed ( first , false , "a" , "x" ) ;
File . Copy ( first , second ) ;
foreach ( var ( path , conditional ) in new [ ] { ( first , false ) , ( second , true ) } )
{
using var cache = Open ( path , false ) ;
var current = cache . Get < WsItem > ( A ) ! ;
Assert . That ( cache . Commit ( batch = >
{
if ( conditional )
batch . Expect ( A , current ) ;
batch . Upsert ( typeof ( WsOther ) , new WsOther { Name = "name-z" } , new CultRecordKey ( "z" ) ) ;
batch . Remove ( X ) ;
} ) , Is . True ) ;
}
byte [ ] Normalized ( string path )
{
var snapshot = Read ( path ) ;
2026-10-03 03:20:17 +02:00
foreach ( var record in snapshot . Records . Where ( record = > record . Key = = "z" ) )
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
record . StoredAt = string . Empty ;
return CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ;
}
2026-10-03 03:20:17 +02:00
Assert . That ( Keys ( first ) , Is . EqualTo ( new [ ] { "a" , "z" } ) ) ;
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
Assert . That ( Normalized ( second ) , Is . EqualTo ( Normalized ( first ) ) ) ;
}
2026-10-03 03:20:17 +02:00
// An unconditional commit also lands what was staged before it, on the store as it is now.
[Test]
public void AnUnconditionalCommitLandsEarlierStagedWritesWithItsBatchOntoWhatAnotherWriterWrote ( [ Values ] bool directory )
{
var path = PathOf ( "staged.cc" ) ;
Seed ( path , directory , "a" ) ;
using var cache = Open ( path , directory ) ;
using ( var other = Open ( path , directory ) )
other . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-x" } , X ) ) ;
cache . UpsertAsync ( typeof ( WsItem ) , new WsItem { Name = "name-s" } , new CultRecordKey ( "s" ) ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( cache . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-y" } , new CultRecordKey ( "y" ) ) ) , Is . True ) ;
Assert . That ( Keys ( path ) , Is . EqualTo ( new [ ] { "a" , "s" , "x" , "y" } ) ) ;
Assert . That ( cache . IsDirty , Is . False ) ;
}
2026-10-03 03:38:29 +02:00
// The operator's check on a real store: CULTLIB_REAL_STORE names a copy of one. A cache that registers none of its types
// stages one record and flushes or commits: every other record and catalog entry, and the header, are byte for byte as they were.
[Test]
public void ARealStoreSurvivesOneStagedWriteByteForByte ( [ Values ] bool commit )
{
var source = Environment . GetEnvironmentVariable ( "CULTLIB_REAL_STORE" ) ;
if ( string . IsNullOrEmpty ( source ) | | ! File . Exists ( source ) )
Assert . Ignore ( "CULTLIB_REAL_STORE does not name a copy of a real store." ) ;
var path = PathOf ( "real.cc" ) ;
File . Copy ( source ! , path ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var beforePath = PathOf ( "real-before.cc" ) ;
File . Copy ( source ! , beforePath ) ;
2026-10-03 03:38:29 +02:00
var before = Read ( path ) ;
using ( var cache = Open ( path , false ) )
Land ( cache , commit , typeof ( WsItem ) , new WsItem { Name = "name-real-write" } , new CultRecordKey ( "real-write" ) ) ;
var after = Read ( path ) ;
TestContext . Out . WriteLine ( $"real store: {before.Records.Length} records, {before.SchemaCatalog.Length} catalog entries, header {before.FormatVersion}" ) ;
Assert . That ( after . Records . Length , Is . EqualTo ( before . Records . Length + 1 ) ) ;
Assert . That ( after . FormatVersion , Is . EqualTo ( before . FormatVersion ) ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var changed = before . Records . Where ( record = > ! StoreSlices . Record ( beforePath , record . Key ) . SequenceEqual ( StoreSlices . Record ( path , record . Key ) ) ) . Select ( record = > record . Key ) . ToArray ( ) ;
2026-10-03 03:38:29 +02:00
Assert . That ( changed , Is . Empty , "records that are not byte-identical" ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var afterEntries = StoreSlices . CatalogEntries ( path ) . ToList ( ) ;
var missing = StoreSlices . CatalogEntries ( beforePath ) . Where ( raw = > ! afterEntries . Any ( other = > other . SequenceEqual ( raw ) ) ) . Select ( raw = > BitConverter . ToString ( raw ) ) . ToArray ( ) ;
2026-10-03 03:38:29 +02:00
Assert . That ( missing , Is . Empty , "catalog entries that are not byte-identical" ) ;
}
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
private static void MarkAs ( string path , string header )
{
var snapshot = Read ( path ) ;
snapshot . FormatVersion = header ;
Write ( path , snapshot ) ;
}
2026-10-03 07:04:48 +02:00
// A marked header says the store holds an element id, and a record's bytes cannot say whether it does. A write that copies
// any record forward therefore keeps the mark, whatever this cache wrote or read of that record.
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
[Test]
2026-10-03 07:04:48 +02:00
public void AMarkedHeaderIsKeptWhileTheWriteCopiesAnyRecord ( [ Values ] bool anotherWriterAddedARecord )
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
{
var path = PathOf ( "mark.cc" ) ;
Seed ( path , false , "a" ) ;
MarkAs ( path , CultPersistedStoreSnapshot . FormatV3 ) ;
using var cache = Open ( path , false ) ;
if ( anotherWriterAddedARecord )
{
using ( var other = Open ( path , false ) )
other . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-x" } , X ) ) ;
MarkAs ( path , CultPersistedStoreSnapshot . FormatV3 ) ;
}
Land ( cache , commit : false , typeof ( WsItem ) , new WsItem { Name = "name-y" } , new CultRecordKey ( "y" ) ) ;
2026-10-03 07:04:48 +02:00
Assert . That ( Read ( path ) . FormatVersion , Is . EqualTo ( CultPersistedStoreSnapshot . FormatV3 ) ) ;
}
// Only a write that encodes every record the store will hold can say none holds an id.
[Test]
public void AMarkedHeaderIsDroppedOnlyByAWriteThatEncodesEveryRecord ( [ Values ] bool commit )
{
var path = PathOf ( "mark-all.cc" ) ;
Seed ( path , false , "a" ) ;
MarkAs ( path , CultPersistedStoreSnapshot . FormatV3 ) ;
using var cache = Open ( path , false ) ;
Land ( cache , commit , typeof ( WsItem ) , new WsItem { Name = "name-a" , Note = "again" } , A ) ;
Assert . That ( Read ( path ) . FormatVersion , Is . EqualTo ( CultPersistedStoreSnapshot . FormatV1 ) ) ;
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
}
2026-10-03 07:04:48 +02:00
// Another writer changed a copied record after this cache read it, and the mark stays: a copied record is not read to decide it.
2026-10-03 03:38:29 +02:00
[Test]
public void AMarkedHeaderIsKeptWhenAnotherWriterChangedACopiedRecordAfterTheLoad ( )
{
var path = PathOf ( "mark-changed.cc" ) ;
Seed ( path , false , "a" ) ;
MarkAs ( path , CultPersistedStoreSnapshot . FormatV3 ) ;
using var cache = Open ( path , false ) ;
using ( var other = Open ( path , false ) )
other . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-a" , Note = "changed" } , A ) ) ;
MarkAs ( path , CultPersistedStoreSnapshot . FormatV3 ) ;
Land ( cache , commit : false , typeof ( WsItem ) , new WsItem { Name = "name-y" } , new CultRecordKey ( "y" ) ) ;
Assert . That ( Read ( path ) . FormatVersion , Is . EqualTo ( CultPersistedStoreSnapshot . FormatV3 ) ) ;
}
2026-10-03 07:04:48 +02:00
// A copied record stays copied whoever wrote it: what this cache wrote earlier does not make the next write one that decides by content.
2026-10-03 03:38:29 +02:00
[Test]
2026-10-03 07:04:48 +02:00
public void AMarkedHeaderIsKeptWhenEveryCopiedRecordWasWrittenByThisCache ( )
2026-10-03 03:38:29 +02:00
{
var path = PathOf ( "mark-wrote.cc" ) ;
Seed ( path , false , "a" ) ;
using var cache = Open ( path , false ) ;
Land ( cache , commit : false , typeof ( WsItem ) , new WsItem { Name = "name-y" } , new CultRecordKey ( "y" ) ) ;
MarkAs ( path , CultPersistedStoreSnapshot . FormatV3 ) ;
Land ( cache , commit : false , typeof ( WsItem ) , new WsItem { Name = "name-z" } , new CultRecordKey ( "z" ) ) ;
2026-10-03 07:04:48 +02:00
Assert . That ( Read ( path ) . FormatVersion , Is . EqualTo ( CultPersistedStoreSnapshot . FormatV3 ) ) ;
2026-10-03 03:38:29 +02:00
}
// A batch is the write that lands last: it replaces a staged write of the same key.
[Test]
public void ABatchWinsOverAStagedWriteOfTheSameKey ( )
{
var path = PathOf ( "batch-wins.cc" ) ;
Seed ( path , false , "a" ) ;
using ( var cache = Open ( path , false ) )
{
cache . UpsertAsync ( typeof ( WsItem ) , new WsItem { Name = "name-a" , Note = "staged" } , A ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( cache . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-a" , Note = "batch" } , A ) ) , Is . True ) ;
}
using var check = Open ( path , false ) ;
Assert . That ( check . Get < WsItem > ( A ) ! . Note , Is . EqualTo ( "batch" ) ) ;
}
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
// v2 exactly when the written store holds a variant: a copied variant keeps it, and the last one removed drops it.
[Test]
public void TheV2HeaderIsWrittenExactlyWhileTheStoreHoldsAVariant ( )
{
var path = PathOf ( "variant.cc" ) ;
Seed ( path , false , "a" ) ;
var variant = new CultRecordKey ( "v" ) ;
using ( var cache = Open ( path , false ) )
{
cache . Commit ( batch = > batch . UpsertVariant ( variant , A , new [ ]
{
cache . Override < WsItem > ( nameof ( WsItem . Name ) , "name-v" ) ,
cache . Override < WsItem > ( nameof ( WsItem . Note ) , "variant" )
} ) ) ;
}
Assert . That ( Read ( path ) . FormatVersion , Is . EqualTo ( CultPersistedStoreSnapshot . FormatV2 ) ) ;
using ( var cache = Open ( path , false ) )
{
Land ( cache , commit : true , typeof ( WsItem ) , new WsItem { Name = "name-y" } , new CultRecordKey ( "y" ) ) ;
Assert . That ( Read ( path ) . FormatVersion , Is . EqualTo ( CultPersistedStoreSnapshot . FormatV2 ) , "the variant was copied" ) ;
cache . Commit ( batch = > batch . Remove ( variant ) ) ;
}
Assert . That ( Read ( path ) . FormatVersion , Is . EqualTo ( CultPersistedStoreSnapshot . FormatV1 ) ) ;
}
2026-10-03 05:11:17 +02:00
// Another writer adds a record under this cache's schema id whose payload this cache's type cannot decode.
private static void AddUndecodableRecord ( string path , string key )
{
var snapshot = Read ( path ) ;
snapshot . Records = snapshot . Records . Concat ( new [ ]
{
new CultPersistedRecord { Key = key , SchemaId = ItemId , StoredAt = DateTimeOffset . UtcNow . ToString ( "O" ) , Payload = new byte [ ] { 0xC1 } }
} ) . OrderBy ( record = > record . Key , StringComparer . Ordinal ) . ToArray ( ) ;
Write ( path , snapshot ) ;
}
// A flush decodes nothing it did not stage: a copied record this cache's type cannot decode is carried byte for byte.
[Test]
public void AFlushCopiesARecordThisCacheCannotDecode ( [ Values ] bool commit )
{
var path = PathOf ( "undecodable.cc" ) ;
Seed ( path , false , "a" ) ;
using var a = Open ( path , false ) ;
AddUndecodableRecord ( path , "r" ) ;
var carried = Stored ( path , "r" , false ) ;
Land ( a , commit , typeof ( WsItem ) , new WsItem { Name = "name-y" } , new CultRecordKey ( "y" ) ) ;
Assert . That ( Keys ( path ) , Is . EqualTo ( new [ ] { "a" , "r" , "y" } ) ) ;
Assert . That ( Stored ( path , "r" , false ) , Is . EqualTo ( carried ) ) ;
}
// The same record in a store holding a variant cannot be judged without decoding it: the write is refused with the decode
// failure as its cause, and the disk, the cache and its staged change are as they were.
[Test]
public void AWriteIntoAVariantStoreWithARecordThisCacheCannotLoadChangesNothing ( [ Values ] bool commit )
{
var path = PathOf ( "undecodable-variant.cc" ) ;
Seed ( path , false , "a" ) ;
using ( var seed = Open ( path , false ) )
2026-10-03 05:15:42 +02:00
seed . Commit ( batch = > batch . UpsertVariant ( new CultRecordKey ( "v" ) , A , new [ ] { seed . Override < WsItem > ( nameof ( WsItem . Name ) , "name-v" ) , seed . Override < WsItem > ( nameof ( WsItem . Note ) , "variant" ) } ) ) ;
2026-10-03 05:11:17 +02:00
var a = Open ( path , false ) ;
AddUndecodableRecord ( path , "r" ) ;
var before = File . ReadAllBytes ( path ) ;
var y = new CultRecordKey ( "y" ) ;
var conflict = Assert . Throws < CultWriteConflictException > ( ( ) = >
{
if ( commit )
a . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-y" } , y ) ) ;
else
Land ( a , false , typeof ( WsItem ) , new WsItem { Name = "name-y" } , y ) ;
} ) ! ;
Assert . That ( conflict . InnerException , Is . Not . Null , "the decode failure is the cause" ) ;
Assert . That ( conflict . ChangedKeys , Is . EqualTo ( new [ ] { "r" } ) ) ;
Assert . That ( File . ReadAllBytes ( path ) , Is . EqualTo ( before ) ) ;
Assert . That ( a . Get ( new CultRecordKey ( "r" ) ) , Is . Null ) ;
if ( ! commit )
{
Assert . That ( a . IsDirty , Is . True , "a reload that fails changes nothing, the staged write included" ) ;
Assert . That ( a . Get < WsItem > ( y ) , Is . Not . Null ) ;
}
}
2026-10-03 06:09:21 +02:00
// A stages k, B writes k later, A flushes: A's bytes land at a storedAt later than B's in either store kind, so a writer holding B's k is told it moved.
2026-10-03 05:11:17 +02:00
[Test]
2026-10-03 06:09:21 +02:00
public void AWriteOverAnotherWritersLaterRecordStoresALaterStoredAt ( [ Values ] bool directory )
2026-10-03 05:11:17 +02:00
{
var path = PathOf ( "later.cc" ) ;
2026-10-03 06:09:21 +02:00
Seed ( path , directory , "a" ) ;
2026-10-03 05:11:17 +02:00
var k = new CultRecordKey ( "k" ) ;
2026-10-03 06:09:21 +02:00
using var a = Open ( path , directory ) ;
2026-10-03 05:11:17 +02:00
a . UpsertAsync ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "by-a" } , k ) . GetAwaiter ( ) . GetResult ( ) ;
System . Threading . Thread . Sleep ( 30 ) ;
2026-10-03 06:09:21 +02:00
using var b = Open ( path , directory ) ;
2026-10-03 05:11:17 +02:00
b . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "by-b" } , k ) ) ;
var seenByB = b . Get < WsItem > ( k ) ! ;
var bAt = Read ( path ) . Records . Single ( record = > record . Key = = "k" ) . StoredAt ;
a . FlushAllBackingStores ( ) ;
var onDisk = Read ( path ) . Records . Single ( record = > record . Key = = "k" ) ;
Assert . That ( DateTimeOffset . Parse ( onDisk . StoredAt , null , System . Globalization . DateTimeStyles . RoundtripKind ) ,
Is . GreaterThan ( DateTimeOffset . Parse ( bAt , null , System . Globalization . DateTimeStyles . RoundtripKind ) ) ) ;
Assert . That ( a . Commit ( batch = > { batch . Expect ( k , a . Get < WsItem > ( k ) ! ) ; batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "again" } , k ) ; } ) ,
Is . True , "A's own condition holds without a pull" ) ;
Assert . That ( b . Commit ( batch = > { batch . Expect ( k , seenByB ) ; batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "stale" } , k ) ; } ) ,
Is . False , "B's copy of k is no longer what the store holds" ) ;
}
2026-10-03 08:23:31 +02:00
// Rule 2: a record's version is the SHA-256 of its stored bytes. Another writer rewrites the record to other bytes yet leaves
// its storedAt as it was (a peer whose clock equals, or a tool): the bytes are all that tells the two records apart.
private void RewriteAtItsOwnStoredAt ( string path , bool directory , string key , string note )
{
var storedAt = StoredAtOf ( path , key ) ;
using ( var writer = Open ( path , directory ) )
writer . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-" + key , Note = note } , new CultRecordKey ( key ) ) ) ;
var snapshot = Read ( path ) ;
var record = snapshot . Records . Single ( candidate = > candidate . Key = = key ) ;
if ( ! directory )
{
record . StoredAt = storedAt ;
Write ( path , snapshot ) ;
return ;
}
var pages = DirectoryMessagePackBackingStore . DefaultRecordDirectoryPath ( path ) ;
var page = CultDocumentMessagePackSerialization . DeserializePersistedRecord (
File . ReadAllBytes ( Path . Combine ( pages , Convert . ToHexString ( record . Payload ) . ToLowerInvariant ( ) + ".msgpack" ) ) ) ;
page . StoredAt = storedAt ;
var bytes = CultDocumentMessagePackSerialization . SerializePersistedRecord ( page ) ;
var hash = System . Security . Cryptography . SHA256 . HashData ( bytes ) ;
File . WriteAllBytes ( Path . Combine ( pages , Convert . ToHexString ( hash ) . ToLowerInvariant ( ) + ".msgpack" ) , bytes ) ;
record . StoredAt = storedAt ;
record . Payload = hash ;
Write ( path , snapshot ) ;
}
private static string NoteOnDisk ( string path , bool directory )
{
using var fresh = Open ( path , directory ) ;
return fresh . Get < WsItem > ( K ) ! . Note ;
}
private static readonly CultRecordKey K = new ( "k" ) ;
// A reads k; another writer rewrites it at its own storedAt; A's Expect on what it read fails and the disk keeps the rewrite.
[Test]
public void AStaleExpectFailsWhenAnotherWriterRewroteTheRecordAtItsOwnStoredAt ( [ Values ] bool directory )
{
var path = PathOf ( "t1.cc" ) ;
Seed ( path , directory , "k" ) ;
using var a = Open ( path , directory ) ;
var observed = a . Get < WsItem > ( K ) ! ;
RewriteAtItsOwnStoredAt ( path , directory , "k" , "by-c" ) ;
var committed = a . Commit ( batch = >
{
batch . Expect ( K , observed ) ;
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "stale" } , K ) ;
} ) ;
Assert . That ( committed , Is . False ) ;
Assert . That ( NoteOnDisk ( path , directory ) , Is . EqualTo ( "by-c" ) ) ;
}
// A pull serves the rewritten record although nothing about it but its bytes changed.
[Test]
public void APullServesARecordRewrittenAtItsOwnStoredAt ( [ Values ] bool directory )
{
var path = PathOf ( "t2.cc" ) ;
Seed ( path , directory , "k" ) ;
using var a = Open ( path , directory ) ;
RewriteAtItsOwnStoredAt ( path , directory , "k" , "by-c" ) ;
a . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( a . Get < WsItem > ( K ) ! . Note , Is . EqualTo ( "by-c" ) ) ;
}
// ExpectUnchanged compares every record's version: a rewrite at the same storedAt fails it for an unrelated key.
[Test]
public void ExpectUnchangedFailsWhenARecordWasRewrittenAtItsOwnStoredAt ( [ Values ] bool directory )
{
var path = PathOf ( "t3.cc" ) ;
Seed ( path , directory , "k" ) ;
using var a = Open ( path , directory ) ;
RewriteAtItsOwnStoredAt ( path , directory , "k" , "by-c" ) ;
var committed = a . Commit ( batch = >
{
batch . ExpectUnchanged ( ) ;
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-x" } , X ) ;
} ) ;
Assert . That ( committed , Is . False ) ;
Assert . That ( Keys ( path ) , Is . EqualTo ( new [ ] { "k" } ) ) ;
}
// The staleness check of a store holding a variant compares versions: a rewrite at the record's own storedAt refuses the
// write, names the record, leaves the file as it was, and the cache then holds the rewrite so a retry lands.
[Test]
public void AWriteIntoAVariantStoreIsRefusedWhenARecordWasRewrittenAtItsOwnStoredAt ( )
{
var path = PathOf ( "t4.cc" ) ;
Seed ( path , false , "a" ) ;
using var a = Open ( path , false ) ;
AddVariant ( a , "v" , A ) ;
using ( var peer = Open ( path , false ) )
Assert . That ( peer . Get < WsItem > ( A ) , Is . Not . Null ) ;
RewriteAtItsOwnStoredAt ( path , false , "a" , "by-c" ) ;
var before = File . ReadAllBytes ( path ) ;
var y = new CultRecordKey ( "y" ) ;
var conflict = Assert . Throws < CultWriteConflictException > ( ( ) = >
Land ( a , true , typeof ( WsItem ) , new WsItem { Name = "name-y" } , y ) ) ! ;
Assert . That ( conflict . ChangedKeys , Is . EqualTo ( new [ ] { "a" } ) ) ;
Assert . That ( File . ReadAllBytes ( path ) , Is . EqualTo ( before ) ) ;
Assert . That ( a . Get < WsItem > ( A ) ! . Note , Is . EqualTo ( "by-c" ) , "the refusal reloaded the record by version" ) ;
Land ( a , true , typeof ( WsItem ) , new WsItem { Name = "name-y" } , y ) ;
Assert . That ( Keys ( path ) , Is . EqualTo ( new [ ] { "a" , "v" , "y" } ) ) ;
}
// A write teaches the writer's cache the version of the bytes it wrote, by flush or by commit, in either store kind: the
// writer's own Expect holds without a pull, and fails once anyone else writes the record.
[Test]
public void AnOwnWriteTeachesTheCacheItsVersion ( [ Values ] bool directory , [ Values ] bool commit )
{
var path = PathOf ( "t5.cc" ) ;
Seed ( path , directory , "a" ) ;
using var a = Open ( path , directory ) ;
Land ( a , commit , typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "by-a" } , K ) ;
Assert . That ( a . Commit ( batch = >
{
batch . Expect ( K , a . Get < WsItem > ( K ) ! ) ;
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "again" } , K ) ;
} ) , Is . True , "A's own condition holds without a pull" ) ;
var seen = a . Get < WsItem > ( K ) ! ;
using ( var b = Open ( path , directory ) )
b . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "by-b" } , K ) ) ;
Assert . That ( a . Commit ( batch = >
{
batch . Expect ( K , seen ) ;
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "stale" } , K ) ;
} ) , Is . False ) ;
}
2026-10-03 09:48:25 +02:00
// A version is the SHA-256 of the bytes as the file holds them, not of a re-encoding. A writer may legally encode a record
// wider than needed (a str16 for a short string, a bin32, an array16); the file below holds every record that way. Each
// held record's version is the hash of its slice of the file; a pull over the unchanged file replaces nothing; and a write
// copies the records it did not stage byte for byte, so they keep their version and the next write is not refused.
[Test]
public void AVersionIsTheHashOfTheBytesTheFileHoldsWhateverTheirEncoding ( )
{
var path = PathOf ( "wide.cc" ) ;
Seed ( path , false , "a" , "k" ) ;
using ( var seed = Open ( path , false ) )
AddVariant ( seed , "v" , A ) ;
File . WriteAllBytes ( path , WidenEveryRecord ( File . ReadAllBytes ( path ) , out var slices ) ) ;
2026-10-03 09:52:48 +02:00
var wide = Read ( path ) . Records . Single ( record = > record . Key = = "k" ) ;
Assert . That ( slices [ "k" ] , Is . Not . EqualTo ( CultDocumentMessagePackSerialization . SerializePersistedRecord ( new CultPersistedRecord
{
Key = wide . Key ,
SchemaId = wide . SchemaId ,
StoredAt = wide . StoredAt ,
Payload = wide . Payload
} ) ) , "the stored bytes are not the canonical encoding of the record" ) ;
2026-10-03 09:48:25 +02:00
using var cache = Open ( path , false ) ;
foreach ( var ( key , slice ) in slices )
Assert . That ( cache . GetStored ( new CultRecordKey ( key ) ) ! . StoredVersion , Is . EqualTo ( HexOfSha256 ( slice ) ) , key ) ;
var before = cache . GetStored ( K ) ;
cache . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
cache . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( ReferenceEquals ( cache . GetStored ( K ) , before ) , Is . True , "an unchanged file is not read as changed" ) ;
Assert . That ( cache . Commit ( batch = >
{
batch . Expect ( K , cache . Get < WsItem > ( K ) ! ) ;
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-x" } , X ) ;
} ) , Is . True ) ;
var file = File . ReadAllBytes ( path ) ;
foreach ( var key in new [ ] { "a" , "v" } )
Assert . That ( Contains ( file , slices [ key ] ) , Is . True , $"{key} was copied as the file held it" ) ;
Land ( cache , false , typeof ( WsItem ) , new WsItem { Name = "name-x" , Note = "again" } , X ) ;
Assert . That ( Keys ( path ) , Is . EqualTo ( new [ ] { "a" , "k" , "v" , "x" } ) , "a second write into the variant store is not refused" ) ;
}
private static string HexOfSha256 ( byte [ ] bytes ) = >
Convert . ToHexString ( System . Security . Cryptography . SHA256 . HashData ( bytes ) ) . ToLowerInvariant ( ) ;
2026-10-03 09:52:48 +02:00
private static bool Contains ( byte [ ] haystack , byte [ ] needle ) = > IndexOf ( haystack , needle ) > = 0 ;
2026-10-03 09:48:25 +02:00
// The same store with every array, string and byte string written in its widest MessagePack form, and each record's slice.
private static byte [ ] WidenEveryRecord ( byte [ ] bytes , out Dictionary < string , byte [ ] > slices )
{
slices = new Dictionary < string , byte [ ] > ( StringComparer . Ordinal ) ;
var output = new List < byte > ( ) ;
var reader = new MessagePackReader ( bytes ) ;
var top = reader . ReadArrayHeader ( ) ;
output . AddRange ( new byte [ ] { 0xdc , ( byte ) ( top > > 8 ) , ( byte ) top } ) ;
for ( var slot = 0 ; slot < top ; slot + + )
{
if ( slot ! = 2 )
{
var from = ( int ) reader . Consumed ;
reader . Skip ( ) ;
output . AddRange ( bytes . Skip ( from ) . Take ( ( int ) reader . Consumed - from ) ) ;
continue ;
}
var count = reader . ReadArrayHeader ( ) ;
output . AddRange ( new byte [ ] { 0xdc , ( byte ) ( count > > 8 ) , ( byte ) count } ) ;
for ( var index = 0 ; index < count ; index + + )
{
var start = output . Count ;
var fields = reader . ReadArrayHeader ( ) ;
output . AddRange ( new byte [ ] { 0xdc , ( byte ) ( fields > > 8 ) , ( byte ) fields } ) ;
string? key = null ;
for ( var field = 0 ; field < fields ; field + + )
{
if ( reader . NextMessagePackType = = MessagePackType . String )
{
var text = reader . ReadString ( ) ! ;
key ? ? = text ;
var utf8 = System . Text . Encoding . UTF8 . GetBytes ( text ) ;
output . AddRange ( new byte [ ] { 0xda , ( byte ) ( utf8 . Length > > 8 ) , ( byte ) utf8 . Length } ) ;
output . AddRange ( utf8 ) ;
}
else if ( reader . NextMessagePackType = = MessagePackType . Binary )
{
2026-10-03 09:52:48 +02:00
var blob = reader . ReadBytes ( ) ? . ToArray ( ) ? ? Array . Empty < byte > ( ) ;
2026-10-03 09:48:25 +02:00
output . AddRange ( new byte [ ] { 0xc6 , ( byte ) ( blob . Length > > 24 ) , ( byte ) ( blob . Length > > 16 ) , ( byte ) ( blob . Length > > 8 ) , ( byte ) blob . Length } ) ;
output . AddRange ( blob ) ;
}
else
{
var from = ( int ) reader . Consumed ;
reader . Skip ( ) ;
output . AddRange ( bytes . Skip ( from ) . Take ( ( int ) reader . Consumed - from ) ) ;
}
}
slices [ key ! ] = output . Skip ( start ) . ToArray ( ) ;
}
}
return output . ToArray ( ) ;
}
// A condition on a record fails when another writer removed it: the removal stands and the writer does not bring it back.
[Test]
public void AnExpectFailsWhenAnotherWriterRemovedTheRecord ( [ Values ] bool directory )
{
var path = PathOf ( "gone.cc" ) ;
Seed ( path , directory , "k" ) ;
using var a = Open ( path , directory ) ;
var observed = a . Get < WsItem > ( K ) ! ;
using ( var b = Open ( path , directory ) )
Assert . That ( b . Commit ( batch = > batch . Remove ( K ) ) , Is . True ) ;
var committed = a . Commit ( batch = >
{
batch . Expect ( K , observed ) ;
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "resurrected" } , K ) ;
} ) ;
Assert . That ( committed , Is . False ) ;
Assert . That ( Keys ( path ) , Is . Empty ) ;
}
// The cache's own held record, not only the store's, learns the version of the bytes written. A variant staged and then
// re-resolved (its base edited before the flush) is held by the cache as a new object the store never saw, so only the
// cache's own line gives it the version of the bytes it was written as.
[Test]
public void AVariantReResolvedBeforeItsFlushIsKnownByItsWrittenVersion ( )
{
var path = PathOf ( "reresolved.cc" ) ;
Seed ( path , false , "a" ) ;
using var cache = Open ( path , false ) ;
var v = new CultRecordKey ( "v" ) ;
cache . UpsertVariantAsync ( v , A , new [ ] { cache . Override < WsItem > ( nameof ( WsItem . Name ) , "name-v" ) } ) . GetAwaiter ( ) . GetResult ( ) ;
var staged = cache . GetStored ( v ) ;
cache . UpsertAsync ( typeof ( WsItem ) , new WsItem { Name = "name-a" , Note = "edited" } , A ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( ReferenceEquals ( cache . GetStored ( v ) , staged ) , Is . False , "the base edit re-resolved the variant into a new held copy" ) ;
cache . FlushAllBackingStores ( ) ;
var held = cache . GetStored ( v ) ! ;
Assert . That ( held . StoredVersion , Is . EqualTo ( HexOfSha256 ( CultDocumentMessagePackSerialization . SerializePersistedRecord (
Read ( path ) . Records . Single ( record = > record . Key = = "v" ) ) ) ) ) ;
Assert . That ( cache . Commit ( batch = >
{
batch . Expect ( v , cache . Get < WsItem > ( v ) ! ) ;
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-x" } , X ) ;
} ) , Is . True , "the writer's own condition on the variant holds" ) ;
}
// A commit with no condition decides nothing about the other records, so it hashes none of them: only the record it writes
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
// is hashed to be named. A commit with a condition compares every durable record's version.
2026-10-03 09:48:25 +02:00
[Test]
public void AnUnconditionalCommitHashesOnlyTheRecordsItWrites ( )
{
var path = PathOf ( "cost.cc" ) ;
Seed ( path , false , "a" , "b" , "c" , "d" , "e" ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var store = new SingleFileMessagePackBackingStore ( path ) ;
2026-10-03 09:48:25 +02:00
using var cache = new CultCache ( Registry , CultCacheMessagePack . CreateCodec ( Registry ) ) ;
cache . AddBackingStore ( store ) ;
cache . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
store . RecordsHashed = 0 ;
2026-10-03 09:48:25 +02:00
Assert . That ( cache . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-x" } , X ) ) , Is . True ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
Assert . That ( store . RecordsHashed , Is . EqualTo ( 1 ) ) ;
2026-10-03 09:48:25 +02:00
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
store . RecordsHashed = 0 ;
2026-10-03 09:48:25 +02:00
Assert . That ( cache . Commit ( batch = >
{
batch . Expect ( X , cache . Get < WsItem > ( X ) ! ) ;
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-x" , Note = "again" } , X ) ;
} ) , Is . True ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
Assert . That ( store . RecordsHashed , Is . GreaterThanOrEqualTo ( 6 ) , "a condition hashes every durable record, which is how the count proves itself" ) ;
2026-10-03 09:48:25 +02:00
}
// Records no store has read or written are told apart however many are made in one clock tick.
[Test]
public void UnstoredRecordsMadeBackToBackHaveDistinctVersions ( )
{
using var cache = new CultCache ( Registry ) ;
var keys = Enumerable . Range ( 0 , 2000 ) . Select ( index = > new CultRecordKey ( "r" + index ) ) . ToArray ( ) ;
foreach ( var key in keys )
cache . UpsertAsync ( typeof ( WsItem ) , new WsItem { Name = "same" } , key ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( keys . Select ( key = > cache . GetStored ( key ) ! . StoredVersion ) . Distinct ( ) . Count ( ) , Is . EqualTo ( keys . Length ) ) ;
}
// A write into a store holding a variant is refused when any record it read has moved, including one the writer is itself
// writing: it must not overwrite another writer's edit with a copy it read before that edit.
[Test]
public void AWriteIntoAVariantStoreIsRefusedWhenARecordItWritesMovedAfterItRead ( )
{
var path = PathOf ( "own-upsert.cc" ) ;
Seed ( path , false , "a" ) ;
using var a = Open ( path , false ) ;
AddVariant ( a , "v" , A ) ;
using ( var peer = Open ( path , false ) )
Assert . That ( peer . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-a" , Note = "by-peer" } , A ) ) , Is . True ) ;
var before = File . ReadAllBytes ( path ) ;
var conflict = Assert . Throws < CultWriteConflictException > ( ( ) = >
Land ( a , true , typeof ( WsItem ) , new WsItem { Name = "name-a" , Note = "stale" } , A ) ) ! ;
Assert . That ( conflict . ChangedKeys , Is . EqualTo ( new [ ] { "a" } ) ) ;
Assert . That ( File . ReadAllBytes ( path ) , Is . EqualTo ( before ) ) ;
}
2026-10-03 05:11:17 +02:00
// A commit that removes a key this cache staged removes it everywhere: the staged write does not survive the batch.
[Test]
public void ACommitThatRemovesAStagedKeyRemovesIt ( )
{
var path = PathOf ( "staged-removed.cc" ) ;
Seed ( path , false , "a" ) ;
var k = new CultRecordKey ( "k" ) ;
using ( var cache = Open ( path , false ) )
{
cache . UpsertAsync ( typeof ( WsItem ) , new WsItem { Name = "name-k" } , k ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( cache . Commit ( batch = > batch . Remove ( k ) ) , Is . True ) ;
Assert . That ( Keys ( path ) , Is . EqualTo ( new [ ] { "a" } ) ) ;
Assert . That ( cache . Get ( k ) , Is . Null ) ;
}
using var reopened = Open ( path , false ) ;
Assert . That ( reopened . Get ( k ) , Is . Null ) ;
}
2026-10-03 06:09:21 +02:00
private static DateTimeOffset At ( string storedAt ) = > DateTimeOffset . Parse ( storedAt , CultureInfo . InvariantCulture , DateTimeStyles . RoundtripKind ) ;
private static string StoredAtOf ( string path , string key ) = > Read ( path ) . Records . Single ( record = > record . Key = = key ) . StoredAt ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
// The stored bytes belong to the store that read them, never to a record object: a record read through the public snapshot
// API is only a decoded copy, so an edit made to its payload in place is what a write of it stores.
[Test]
public void ARecordReadThroughTheSnapshotApiAndEditedInPlaceIsWrittenAsEdited ( )
{
var path = PathOf ( "edit-payload.cc" ) ;
Seed ( path , false , "k" ) ;
var snapshot = Read ( path ) ;
OverwriteInPlace ( snapshot . Records . Single ( ) . Payload , "seeded" , "SEEDED" ) ;
File . WriteAllBytes ( path , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
Assert . That ( NoteOnDisk ( path , false ) , Is . EqualTo ( "SEEDED" ) ) ;
var record = CultDocumentMessagePackSerialization . DeserializePersistedRecord ( StoreSlices . Record ( path , "k" ) ) ;
OverwriteInPlace ( record . Payload , "SEEDED" , "seeded" ) ;
Assert . That (
CultDocumentMessagePackSerialization . DeserializePersistedRecord ( CultDocumentMessagePackSerialization . SerializePersistedRecord ( record ) ) . Payload ,
Is . EqualTo ( record . Payload ) , "a single record is re-encoded from its fields too" ) ;
}
[Test]
public void AnOverrideReadThroughTheSnapshotApiAndEditedInPlaceIsWrittenAsEdited ( )
{
var path = PathOf ( "edit-override.cc" ) ;
Seed ( path , false , "a" ) ;
using ( var seed = Open ( path , false ) )
AddVariant ( seed , "v" , A ) ;
var snapshot = Read ( path ) ;
var variant = snapshot . Records . Single ( record = > record . Key = = "v" ) . Variant ! ;
OverwriteInPlace ( variant . Overrides . Single ( entry = > entry . Path [ 0 ] . Slot = = 1 ) . Value , "variant-v" , "VARIANT-v" ) ;
File . WriteAllBytes ( path , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
using var fresh = Open ( path , false ) ;
Assert . That ( fresh . Get < WsItem > ( new CultRecordKey ( "v" ) ) ! . Note , Is . EqualTo ( "VARIANT-v" ) ) ;
}
// A held document edited in place (a list, a nested object) and put back as the same instance is written as edited, and a
// later write of a neighbour copies it as the file then holds it.
[Test]
public void AHeldDocumentEditedInPlaceAndPutBackIsWrittenAsEdited ( [ Values ] bool commit )
{
var path = PathOf ( "edit-held.cc" ) ;
var n = new CultRecordKey ( "n" ) ;
using ( var seed = Open ( path , false ) )
seed . Commit ( batch = > batch . Upsert ( typeof ( WsNested ) , new WsNested { Name = "name-n" , Tags = { "t1" } , Inner = { X = 1 } } , n ) ) ;
using ( var cache = Open ( path , false ) )
{
var held = cache . Get < WsNested > ( n ) ! ;
held . Tags . Add ( "t2" ) ;
held . Inner . X = 9 ;
Land ( cache , commit , typeof ( WsNested ) , held , n ) ;
}
using ( var fresh = Open ( path , false ) )
{
var read = fresh . Get < WsNested > ( n ) ! ;
Assert . That ( read . Tags , Is . EqualTo ( new [ ] { "t1" , "t2" } ) ) ;
Assert . That ( read . Inner . X , Is . EqualTo ( 9 ) ) ;
}
var written = StoreSlices . Record ( path , "n" ) ;
using ( var other = Open ( path , false ) )
Land ( other , commit , typeof ( WsItem ) , new WsItem { Name = "name-x" } , X ) ;
Assert . That ( StoreSlices . Record ( path , "n" ) , Is . EqualTo ( written ) , "the edit is copied as the file holds it" ) ;
}
// A record the writer did not stage keeps the bytes and the version its file holds, whatever encoding its writer chose,
// across any number of writes of its neighbours.
[Test]
public void AnUntouchedNonCanonicalRecordKeepsItsBytesAndVersionAcrossAWriteOfANeighbour ( [ Values ] bool commit )
{
var path = PathOf ( "untouched-wide.cc" ) ;
Seed ( path , false , "k" ) ;
File . WriteAllBytes ( path , WidenEveryRecord ( File . ReadAllBytes ( path ) , out var slices ) ) ;
using var cache = Open ( path , false ) ;
var version = cache . GetStored ( K ) ! . StoredVersion ;
Assert . That ( version , Is . EqualTo ( HexOfSha256 ( slices [ "k" ] ) ) ) ;
Land ( cache , commit , typeof ( WsItem ) , new WsItem { Name = "name-x" } , X ) ;
Land ( cache , commit , typeof ( WsItem ) , new WsItem { Name = "name-y" } , new CultRecordKey ( "y" ) ) ;
Assert . That ( StoreSlices . Record ( path , "k" ) , Is . EqualTo ( slices [ "k" ] ) ) ;
Assert . That ( cache . GetStored ( K ) ! . StoredVersion , Is . EqualTo ( version ) ) ;
using var fresh = Open ( path , false ) ;
Assert . That ( fresh . GetStored ( K ) ! . StoredVersion , Is . EqualTo ( version ) ) ;
}
// A record the writer stages is encoded from its object, so a non-canonical record put back unchanged is written canonically
// and, being other bytes, has another version.
[Test]
public void AStagedNonCanonicalRecordPutBackUnchangedIsReEncodedAndGetsANewVersion ( [ Values ] bool commit )
{
var path = PathOf ( "staged-wide.cc" ) ;
Seed ( path , false , "k" ) ;
File . WriteAllBytes ( path , WidenEveryRecord ( File . ReadAllBytes ( path ) , out var slices ) ) ;
using var cache = Open ( path , false ) ;
var held = cache . Get < WsItem > ( K ) ! ;
Land ( cache , commit , typeof ( WsItem ) , held , K ) ;
var written = StoreSlices . Record ( path , "k" ) ;
Assert . That ( written , Is . Not . EqualTo ( slices [ "k" ] ) ) ;
Assert . That ( written , Is . EqualTo ( CultDocumentMessagePackSerialization . SerializePersistedRecord ( Read ( path ) . Records . Single ( ) ) ) ,
"the record is in the canonical encoding" ) ;
Assert . That ( cache . GetStored ( K ) ! . StoredVersion , Is . EqualTo ( HexOfSha256 ( written ) ) ) ;
Assert . That ( cache . GetStored ( K ) ! . StoredVersion , Is . Not . EqualTo ( HexOfSha256 ( slices [ "k" ] ) ) ) ;
}
2026-10-03 10:48:57 +02:00
// A catalog entry the writer did not choose to replace is carried as the bytes the file holds, a slot this runtime does not
// read included, in either store kind. The ItemId entry is carried: a WsItem stays in the store and only a WsOther is staged.
[Test]
public void ACatalogEntryWithASlotThisRuntimeDoesNotReadSurvivesAWriteOfANeighbour ( [ Values ] bool directory , [ Values ] bool commit )
{
var path = PathOf ( "catalog-unknown-slot.cc" ) ;
Seed ( path , directory , "a" ) ;
File . WriteAllBytes ( path , AddAnUnreadSlotToEveryCatalogEntry ( File . ReadAllBytes ( path ) ) ) ;
var entry = StoreSlices . CatalogEntry ( path , ItemId ) ;
using var cache = Open ( path , directory ) ;
Land ( cache , commit , typeof ( WsOther ) , new WsOther { Name = "name-x" } , X ) ;
Assert . That ( StoreSlices . CatalogEntry ( path , ItemId ) , Is . EqualTo ( entry ) ) ;
}
2026-10-03 12:06:32 +02:00
// The catalog entry for a staged record's id is the writer's, encoded from its object, even where the file publishes another
// entry under that id: a type that adds a compatible id keeps its id, and only the writer's entry carries the change. Here the
// file's ItemId entry differs from the writer's by a slot this runtime does not read, and a WsItem is staged.
[Test]
public void ACatalogEntryForAStagedRecordsIdIsTheWritersEncodingNotTheFilesSlice ( [ Values ] bool directory , [ Values ] bool commit )
{
var path = PathOf ( "catalog-writer-entry.cc" ) ;
Seed ( path , directory , "a" ) ;
var writers = StoreSlices . CatalogEntry ( path , ItemId ) ;
File . WriteAllBytes ( path , AddAnUnreadSlotToEveryCatalogEntry ( File . ReadAllBytes ( path ) ) ) ;
Assert . That ( StoreSlices . CatalogEntry ( path , ItemId ) , Is . Not . EqualTo ( writers ) ) ;
using var cache = Open ( path , directory ) ;
Land ( cache , commit , typeof ( WsItem ) , new WsItem { Name = "name-x" } , X ) ;
Assert . That ( StoreSlices . CatalogEntry ( path , ItemId ) , Is . EqualTo ( writers ) ) ;
}
2026-10-03 10:48:57 +02:00
// The same store with every catalog entry one slot longer than this runtime reads, as a newer writer's would be.
private static byte [ ] AddAnUnreadSlotToEveryCatalogEntry ( byte [ ] bytes )
{
var output = new List < byte > ( ) ;
var reader = new MessagePackReader ( bytes ) ;
var top = reader . ReadArrayHeader ( ) ;
output . Add ( ( byte ) ( 0x90 | top ) ) ;
for ( var slot = 0 ; slot < top ; slot + + )
{
if ( slot ! = 1 )
{
var from = ( int ) reader . Consumed ;
reader . Skip ( ) ;
output . AddRange ( bytes . Skip ( from ) . Take ( ( int ) reader . Consumed - from ) ) ;
continue ;
}
var count = reader . ReadArrayHeader ( ) ;
output . AddRange ( new byte [ ] { 0xdc , ( byte ) ( count > > 8 ) , ( byte ) count } ) ;
for ( var index = 0 ; index < count ; index + + )
{
var from = ( int ) reader . Consumed ;
reader . Skip ( ) ;
var entry = bytes . Skip ( from ) . Take ( ( int ) reader . Consumed - from ) . ToArray ( ) ;
Assert . That ( entry [ 0 ] & 0xf0 , Is . EqualTo ( 0x90 ) , "a catalog entry is a fixarray" ) ;
output . Add ( ( byte ) ( entry [ 0 ] + 1 ) ) ;
output . AddRange ( entry . Skip ( 1 ) ) ;
output . AddRange ( new byte [ ] { 0xa3 , ( byte ) 'x' , ( byte ) 'y' , ( byte ) 'z' } ) ;
}
}
return output . ToArray ( ) ;
}
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
private static void OverwriteInPlace ( byte [ ] bytes , string from , string to )
{
var was = System . Text . Encoding . UTF8 . GetBytes ( from ) ;
var now = System . Text . Encoding . UTF8 . GetBytes ( to ) ;
Assert . That ( now . Length , Is . EqualTo ( was . Length ) ) ;
var at = IndexOf ( bytes , was ) ;
Assert . That ( at , Is . GreaterThanOrEqualTo ( 0 ) , $"'{from}' is in the bytes" ) ;
Array . Copy ( now , 0 , bytes , at , now . Length ) ;
}
2026-10-03 06:09:21 +02:00
private static void AddVariant ( CultCache cache , string key , CultRecordKey baseKey ) = >
cache . Commit ( batch = > batch . UpsertVariant ( new CultRecordKey ( key ) , baseKey , new [ ]
{
cache . Override < WsItem > ( nameof ( WsItem . Name ) , "name-" + key ) ,
cache . Override < WsItem > ( nameof ( WsItem . Note ) , "variant-" + key )
} ) ) ;
// The record under a key now belongs to a schema no registered type claims, as if a build that knows more types wrote it.
private static void MakeForeign ( string path , string key )
{
const string foreignId = "sha256:00000000000000000000000000000000000000000000000000000000000050de" ;
var snapshot = Read ( path ) ;
snapshot . Records . Single ( record = > record . Key = = key ) . SchemaId = foreignId ;
snapshot . SchemaCatalog = snapshot . SchemaCatalog . Append ( new CultSchemaCatalogEntry
{
SchemaId = foreignId ,
SchemaName = "tests.write_set_foreign" ,
SchemaVersion = "tests.write_set_foreign.v1" ,
ContentHash = "foreign" ,
CanonicalSchemaJson = "{}"
} ) . ToArray ( ) ;
Write ( path , snapshot ) ;
}
// Two writers whose clocks put a record's storedAt ahead of both mint the same storedAt over it. The second to write
// replaces the first's different bytes, so it is stored later than the first's, and the first's copy is told it moved.
2026-10-03 07:04:48 +02:00
// The equal storedAt is the case, and the mint is from the stored one, not from the writer's clock (which is behind it). Either store kind.
2026-10-03 06:09:21 +02:00
[Test]
2026-10-03 07:04:48 +02:00
public void AWriteOverARecordAtItsOwnStoredAtIsStoredLaterWhateverTheClockSays ( [ Values ] bool directory , [ Values ] bool commit )
2026-10-03 06:09:21 +02:00
{
var path = PathOf ( "skew.cc" ) ;
2026-10-03 07:04:48 +02:00
Seed ( path , directory , "k" ) ;
2026-10-03 06:09:21 +02:00
var k = new CultRecordKey ( "k" ) ;
var ahead = DateTimeOffset . UtcNow . AddHours ( 1 ) . ToString ( "O" , CultureInfo . InvariantCulture ) ;
2026-10-03 07:09:28 +02:00
// A single file holds the record at that storedAt. A directory store loads a record from its page, which the manifest
// does not own, so there both writers are made to hold it ahead: a stage then mints from it, as a skewed clock does.
if ( ! directory )
{
var snapshot = Read ( path ) ;
snapshot . Records . Single ( record = > record . Key = = "k" ) . StoredAt = ahead ;
Write ( path , snapshot ) ;
}
2026-10-03 07:04:48 +02:00
using var a = Open ( path , directory ) ;
using var b = Open ( path , directory ) ;
2026-10-03 07:09:28 +02:00
if ( directory )
{
a . GetStored ( k ) ! . StoredAt = ahead ;
b . GetStored ( k ) ! . StoredAt = ahead ;
}
2026-10-03 06:09:21 +02:00
if ( commit )
b . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "by-b" } , k ) ) ;
else
{
a . UpsertAsync ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "by-a" } , k ) . GetAwaiter ( ) . GetResult ( ) ;
b . UpsertAsync ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "by-b" } , k ) . GetAwaiter ( ) . GetResult ( ) ;
b . FlushAllBackingStores ( ) ;
}
var bAt = StoredAtOf ( path , "k" ) ;
var seenByB = b . Get < WsItem > ( k ) ! ;
if ( ! commit )
Assert . That ( a . GetStored ( k ) ! . StoredAt , Is . EqualTo ( bAt ) , "both writers minted the same storedAt over the record" ) ;
if ( commit )
a . Commit ( batch = > batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "by-a" } , k ) ) ;
else
a . FlushAllBackingStores ( ) ;
Assert . That ( At ( StoredAtOf ( path , "k" ) ) , Is . GreaterThan ( At ( bAt ) ) , "A's different bytes are stored later than B's" ) ;
Assert . That ( b . Commit ( batch = >
{
batch . Expect ( k , seenByB ) ;
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "stale" } , k ) ;
} ) , Is . False , "B's copy of k is not what the store holds" ) ;
2026-10-03 07:04:48 +02:00
Assert . That ( a . Commit ( batch = >
{
batch . Expect ( k , a . Get < WsItem > ( k ) ! ) ;
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-k" , Note = "again" } , k ) ;
} ) , Is . True , "A's own condition holds without a pull: the cache took the storedAt the write gave it" ) ;
2026-10-03 06:09:21 +02:00
}
// Another writer removes a base and its variant together; this cache, still holding both, adds a variant of that base.
// The base and the variant disappeared from the store, which moved it: the write is refused and the cache reloads.
[Test]
public void AVariantWrittenOverABaseAnotherWriterRemovedIsRefusedAndReloaded ( )
{
var path = PathOf ( "gone-base.cc" ) ;
Seed ( path , false , "x" ) ;
using var a = Open ( path , false ) ;
AddVariant ( a , "v" , X ) ;
using ( var b = Open ( path , false ) )
Assert . That ( b . Commit ( batch = > { batch . Remove ( new CultRecordKey ( "v" ) ) ; batch . Remove ( X ) ; } ) , Is . True ) ;
var conflict = Assert . Throws < CultWriteConflictException > ( ( ) = > AddVariant ( a , "w" , X ) ) ! ;
Assert . That ( conflict . ChangedKeys , Is . EqualTo ( new [ ] { "v" , "x" } ) ) ;
Assert . That ( Keys ( path ) , Is . Empty , "nothing was written" ) ;
Assert . That ( a . Get ( X ) , Is . Null , "the cache reloaded what the other writer removed" ) ;
using var fresh = Open ( path , false ) ;
Assert . That ( fresh . Get ( X ) , Is . Null ) ;
}
// A foreign refusal whose own reload is refused (dropping x, which a held variant is based on) carries that refusal as its cause.
[Test]
public void AForeignRefusalWhoseReloadFailsCarriesThatCause ( [ Values ] bool commit )
{
var path = PathOf ( "foreign-reload.cc" ) ;
Seed ( path , false , "x" ) ;
var a = Open ( path , false ) ;
AddVariant ( a , "v" , X ) ;
MakeForeign ( path , "x" ) ;
var before = File . ReadAllBytes ( path ) ;
var refusal = Assert . Throws < CultSchemaConflictException > ( ( ) = >
Land ( a , commit , typeof ( WsItem ) , new WsItem { Name = "name-x" , Note = "edited" } , X ) ) ! ;
Assert . That ( refusal . RecordKey , Is . EqualTo ( "x" ) ) ;
2026-10-03 07:04:48 +02:00
Assert . That ( refusal . InnerException , Is . TypeOf < InvalidOperationException > ( ) . And . Message . Contains ( "variant v is based on it" ) , "the reload that failed is the cause" ) ;
Assert . That ( File . ReadAllBytes ( path ) , Is . EqualTo ( before ) ) ;
Assert . That ( a . Get < WsItem > ( X ) , Is . Not . Null , "the reload failed, so the cache is unchanged" ) ;
}
// A write that finds a record it holds gone foreign is refused as a conflict, and when the reload that refusal owes fails
// (x cannot go while variant v is based on it) the conflict carries that failure, and nothing changes.
[Test]
public void AWriteConflictWhoseReloadFailsCarriesThatCause ( [ Values ] bool commit )
{
var path = PathOf ( "conflict-reload.cc" ) ;
Seed ( path , false , "x" ) ;
var a = Open ( path , false ) ;
AddVariant ( a , "v" , X ) ;
MakeForeign ( path , "x" ) ;
var before = File . ReadAllBytes ( path ) ;
var conflict = Assert . Throws < CultWriteConflictException > ( ( ) = >
Land ( a , commit , typeof ( WsItem ) , new WsItem { Name = "name-y" } , new CultRecordKey ( "y" ) ) ) ! ;
Assert . That ( conflict . InnerException , Is . TypeOf < InvalidOperationException > ( ) . And . Message . Contains ( "variant v is based on it" ) , "the reload that failed is the cause" ) ;
2026-10-03 06:09:21 +02:00
Assert . That ( File . ReadAllBytes ( path ) , Is . EqualTo ( before ) ) ;
Assert . That ( a . Get < WsItem > ( X ) , Is . Not . Null , "the reload failed, so the cache is unchanged" ) ;
}
// A refused foreign record is what the store holds, so the next write into a variant store is not told that it moved.
[Test]
public void AForeignRecordARefusalReloadedIsNotSeenAsMovedByTheNextWrite ( [ Values ] bool commit )
{
var path = PathOf ( "foreign-read.cc" ) ;
Seed ( path , false , "a" , "x" ) ;
using var a = Open ( path , false ) ;
AddVariant ( a , "v" , A ) ;
MakeForeign ( path , "x" ) ;
Assert . Throws < CultSchemaConflictException > ( ( ) = >
Land ( a , commit , typeof ( WsItem ) , new WsItem { Name = "name-x" , Note = "edited" } , X ) ) ;
Assert . DoesNotThrow ( ( ) = > Land ( a , commit , typeof ( WsItem ) , new WsItem { Name = "name-n" } , new CultRecordKey ( "n" ) ) ) ;
Assert . That ( Keys ( path ) , Is . EqualTo ( new [ ] { "a" , "n" , "v" , "x" } ) ) ;
}
// A writer that finds the store file gone has read nothing there: a write into the store it makes anew is not refused as
// moved because of what the writer read from the file that was.
[Test]
public void AWriterThatFindsTheStoreFileGoneWritesItAnewWithoutBeingToldItMoved ( )
{
var path = PathOf ( "gone-store.cc" ) ;
Seed ( path , false , "a" ) ;
using var a = Open ( path , false ) ;
AddVariant ( a , "v" , A ) ;
File . Delete ( path ) ;
a . PullAllBackingStoresAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
Assert . That ( a . Commit ( batch = >
{
batch . Upsert ( typeof ( WsItem ) , new WsItem { Name = "name-a" , Note = "again" } , A ) ;
batch . UpsertVariant ( new CultRecordKey ( "v" ) , A , new [ ]
{
a . Override < WsItem > ( nameof ( WsItem . Name ) , "name-v" ) ,
a . Override < WsItem > ( nameof ( WsItem . Note ) , "variant-again" )
} ) ;
} ) , Is . True ) ;
Assert . That ( Keys ( path ) , Is . EqualTo ( new [ ] { "a" , "v" } ) ) ;
}
C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
}
}