diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs
index cb3d2b75..b7945177 100644
--- a/src/GameCult.Caching/CultCache.cs
+++ b/src/GameCult.Caching/CultCache.cs
@@ -4218,15 +4218,33 @@ namespace GameCult.Caching
}
///
- /// The one write of this store: under its lock, the durable store read there with the upserts written and the removals
- /// and the upserts' keys taken out of what is copied, every other record copied forward as stored. CultMesh's
- /// single-file document writes call this, with no cache attached.
+ /// Writes one record to the store file without a cache. Under the store's lock the file is read by the same reader a cache's
+ /// store uses, replaces the record at its key, and every other record and catalog entry is copied
+ /// forward as stored. The file is replaced atomically, and its format header is decided as a flush decides it:
+ /// true marks the store as holding element ids, false leaves it unmarked, and null (a payload
+ /// the caller cannot read) keeps whatever the file already says. A write that finds the file unreadable, or a record at
+ /// the key under a schema id does not declare, is refused and leaves the file as it was.
///
- internal void ApplyWriteSet(IReadOnlyCollection upserts, IReadOnlyCollection removals) => Held(() =>
+ /// The record to store.
+ /// The schema catalog entry for the record's schema id.
+ /// Whether the record's payload holds element ids, or null when the caller cannot tell.
+ /// The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read; omitted, it decides nothing for them.
+ /// The store is attached to a cache; write through the cache.
+ /// The file at the path is not a store this runtime can read.
+ public void WriteRecord(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds = null, CultDocumentRegistry? registry = null)
{
- using (AcquireLock(wait: true))
- _ = ApplyWriteSet(ReadSnapshot(), upserts, removals, Array.Empty());
- });
+ if (record == null) throw new ArgumentNullException(nameof(record));
+ if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry));
+ if (Cache != null)
+ throw new InvalidOperationException("The store is attached to a cache, write through the cache.");
+ if (registry != null)
+ AttachRegistry(registry);
+ Held(() =>
+ {
+ using (AcquireLock(wait: true))
+ _ = ApplyWriteSet(ReadSnapshot(), new[] { new CultStagedRecord(record, catalogEntry, holdsIds) }, Array.Empty(), Array.Empty());
+ });
+ }
// Flush, commit and Mesh's write all end here, with the durable store they read under the lock they hold. First, a key the write
// names or checks whose durable record is under an id the writer does not declare refuses the write (RefuseUndeclaredOnDisk); a
@@ -4506,9 +4524,13 @@ namespace GameCult.Caching
}
}
- // CultMesh's single-file document helpers reach this store file only through this read and ApplyWriteSet, so they share
- // its one reader, its lock and its atomic replace.
- internal CultPersistedStoreSnapshot? ReadDurable() => Held(() => ReadSnapshot()?.Snapshot);
+ ///
+ /// Reads the store file once, under the store's gate, with the reader a cache's store opens with, and returns what is
+ /// durable at the path: the records, the schema catalog and the format stamp. Returns null when nothing is at the path.
+ /// CultMesh's single-file document reads are built on this, so they share the store's one reader and its stamp rule.
+ ///
+ /// The file is empty, truncated, or stamped with a format this runtime does not read.
+ public CultPersistedStoreSnapshot? ReadStoreSnapshot() => Held(() => ReadSnapshot()?.Snapshot);
private byte[]? ReadDisk()
{
diff --git a/src/GameCult.Caching/GameCult.Caching.csproj b/src/GameCult.Caching/GameCult.Caching.csproj
index dddad783..9819a079 100644
--- a/src/GameCult.Caching/GameCult.Caching.csproj
+++ b/src/GameCult.Caching/GameCult.Caching.csproj
@@ -6,7 +6,6 @@
-
@@ -14,7 +13,5 @@
-
-
\ No newline at end of file
diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
index 40480895..0d1dd4b4 100644
--- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
+++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
@@ -161,7 +161,7 @@ namespace GameCult.Mesh
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path));
if (string.IsNullOrWhiteSpace(expectedSchemaId)) throw new ArgumentException("Value must be non-empty.", nameof(expectedSchemaId));
- var snapshot = StoreAt(path).ReadDurable() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path);
+ var snapshot = StoreAt(path).ReadStoreSnapshot() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path);
var record = snapshot.Records.SingleOrDefault(candidate => string.Equals(candidate.Key, key.Value, StringComparison.Ordinal))
?? throw new InvalidDataException($"CultCache document '{path}' does not contain record '{key.Value}'.");
@@ -192,7 +192,7 @@ namespace GameCult.Mesh
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path));
if (string.IsNullOrWhiteSpace(expectedSchemaId)) throw new ArgumentException("Value must be non-empty.", nameof(expectedSchemaId));
- var snapshot = StoreAt(path).ReadDurable() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path);
+ var snapshot = StoreAt(path).ReadStoreSnapshot() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path);
if (snapshot.Records.Length != 1)
{
throw new InvalidDataException(
@@ -244,24 +244,17 @@ namespace GameCult.Mesh
// so a file already marked stays marked. A typed write attaches the registry its payload was encoded with, so the write
// decides for copied records that registry can read; a raw payload attaches none and decides nothing. A store this runtime
// cannot read refuses the write instead of being overwritten.
- var store = StoreAt(path);
- if (registry != null)
- store.AttachRegistry(registry);
- store.ApplyWriteSet(
- new[]
+ StoreAt(path).WriteRecord(
+ new CultPersistedRecord
{
- new CultStagedRecord(
- new CultPersistedRecord
- {
- Key = key.Value,
- SchemaId = catalogEntry.SchemaId,
- StoredAt = string.IsNullOrWhiteSpace(storedAt) ? DateTimeOffset.UtcNow.ToString("O") : storedAt!,
- Payload = payload
- },
- catalogEntry,
- holdsIds)
+ Key = key.Value,
+ SchemaId = catalogEntry.SchemaId,
+ StoredAt = string.IsNullOrWhiteSpace(storedAt) ? DateTimeOffset.UtcNow.ToString("O") : storedAt!,
+ Payload = payload
},
- Array.Empty());
+ catalogEntry,
+ holdsIds,
+ registry);
}
private static SingleFileMessagePackBackingStore StoreAt(string path) => new SingleFileMessagePackBackingStore(path);
diff --git a/tests/GameCult.Caching.ConsumerBuild/Program.cs b/tests/GameCult.Caching.ConsumerBuild/Program.cs
index 417da1a6..1405cfd3 100644
--- a/tests/GameCult.Caching.ConsumerBuild/Program.cs
+++ b/tests/GameCult.Caching.ConsumerBuild/Program.cs
@@ -16,6 +16,23 @@ var committed = outside.Commit(batch =>
});
if (!committed)
return 1;
+
+// A store file is written and read without a cache through the two public members: this project has no InternalsVisibleTo, so
+// building it pins that ReadStoreSnapshot and WriteRecord are public API.
+var storePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "consumer-build-" + System.Guid.NewGuid().ToString("N") + ".cc");
+try
+{
+ var entry = new CultSchemaCatalogEntry { SchemaId = "outside.written", SchemaName = "outside.written" };
+ var store = new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(storePath);
+ store.WriteRecord(new CultPersistedRecord { Key = "r", SchemaId = entry.SchemaId, StoredAt = "t", Payload = new byte[] { 0x90 } }, entry);
+ var snapshot = store.ReadStoreSnapshot();
+ if (snapshot == null || snapshot.Records.Length != 1 || snapshot.Records[0].Key != "r")
+ return 1;
+}
+finally
+{
+ System.IO.File.Delete(storePath);
+}
return 0;
// Lists one record under an id no type claims, at version "v1", and commits only when the conditions hold over it.
diff --git a/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs b/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs
new file mode 100644
index 00000000..bc30b718
--- /dev/null
+++ b/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs
@@ -0,0 +1,96 @@
+#nullable enable
+using System;
+using System.IO;
+using GameCult.Caching.MessagePack;
+using NUnit.Framework;
+
+namespace GameCult.Caching.Tests
+{
+ // A single-file store file is read and written without a cache through ReadStoreSnapshot and WriteRecord: the same reader, lock,
+ // atomic replace and header rule a cache's store uses. CultMesh's single-file document helpers are built on these two members.
+ public class CachelessSingleFileTests
+ {
+ private static readonly CultSchemaCatalogEntry One = new() { SchemaId = "tests.cacheless.one", SchemaName = "tests.cacheless.one" };
+ private static readonly CultSchemaCatalogEntry Two = new() { SchemaId = "tests.cacheless.two", SchemaName = "tests.cacheless.two" };
+ private string _directory = "";
+ private string _path = "";
+
+ [SetUp]
+ public void SetUp()
+ {
+ _directory = Path.Combine(Path.GetTempPath(), "cultlib-cacheless-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_directory);
+ _path = Path.Combine(_directory, "store.cc");
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(_directory))
+ Directory.Delete(_directory, true);
+ }
+
+ private static CultPersistedRecord Record(string key, CultSchemaCatalogEntry entry, params byte[] payload) =>
+ new() { Key = key, SchemaId = entry.SchemaId, StoredAt = "2026-10-22T00:00:00.0000000+00:00", Payload = payload };
+
+ [Test]
+ public void WriteRecordKeepsAnotherWritersRecord()
+ {
+ new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One);
+ new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("b", Two, 2), Two);
+ new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 3), One);
+
+ var snapshot = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!;
+
+ Assert.That(snapshot.Records, Has.Length.EqualTo(2));
+ Assert.That(Array.Find(snapshot.Records, record => record.Key == "a")!.Payload, Is.EqualTo(new byte[] { 3 }));
+ Assert.That(Array.Find(snapshot.Records, record => record.Key == "b")!.Payload, Is.EqualTo(new byte[] { 2 }));
+ Assert.That(snapshot.SchemaCatalog, Has.Length.EqualTo(2));
+ }
+
+ [Test]
+ public void WriteRecordRefusesOnAStoreAttachedToACache()
+ {
+ new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One);
+ var before = File.ReadAllBytes(_path);
+ var store = new SingleFileMessagePackBackingStore(_path);
+ using var cache = new CultCache();
+ cache.AddBackingStore(store);
+
+ var error = Assert.Throws(() => store.WriteRecord(Record("b", Two, 2), Two))!;
+
+ Assert.That(error.Message, Does.Contain("write through the cache"));
+ Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before));
+ }
+
+ [Test]
+ public void ReadStoreSnapshotOfNothingIsNull()
+ {
+ Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot(), Is.Null);
+ }
+
+ [Test]
+ public void ReadStoreSnapshotRefusesAV4Stamp()
+ {
+ new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One);
+ var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path));
+ snapshot.FormatVersion = "cultcache.store.v4";
+ File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
+
+ var error = Assert.Throws(() => new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot())!;
+
+ Assert.That(error.Message, Does.Contain("cultcache.store.v4"));
+ }
+
+ [Test]
+ public void WriteRecordWithIdsMarksV3()
+ {
+ new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One);
+ Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.FormatVersion, Is.Not.EqualTo(CultPersistedStoreSnapshot.FormatV3));
+
+ new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("b", Two, 2), Two, holdsIds: true);
+
+ Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3));
+ }
+ }
+}