diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs
index 458c7f58..8fe2dfb9 100644
--- a/src/GameCult.Mesh/CultMesh.cs
+++ b/src/GameCult.Mesh/CultMesh.cs
@@ -1,4 +1,5 @@
using System;
+using System.IO;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
@@ -31,6 +32,16 @@ namespace GameCult.Mesh
///
public CultNetDatabaseOptions? DatabaseOptions { get; set; }
+ ///
+ /// Gets or sets whether CultMesh should attach a file-backed authoritative shard-log store when none is supplied.
+ ///
+ public bool EnableDurableShardLogs { get; set; }
+
+ ///
+ /// Gets or sets the directory used for file-backed authoritative shard logs.
+ ///
+ public string? ShardLogPath { get; set; }
+
///
/// Gets or sets the database server bridge options used for shard routing and forwarding.
///
@@ -41,18 +52,54 @@ namespace GameCult.Mesh
///
public Action? ConfigureServer { get; set; }
- internal CultNetHostOptions ToCultNetOptions()
+ internal CultNetHostOptions ToCultNetOptions(string cachePath)
{
return new CultNetHostOptions
{
CacheOptions = CacheOptions,
Security = Security,
StartServer = StartServer,
- DatabaseOptions = DatabaseOptions,
+ DatabaseOptions = CreateDatabaseOptions(cachePath),
DatabaseServerOptions = DatabaseServerOptions,
ConfigureServer = ConfigureServer
};
}
+
+ private CultNetDatabaseOptions? CreateDatabaseOptions(string cachePath)
+ {
+ if (!EnableDurableShardLogs)
+ {
+ return DatabaseOptions;
+ }
+
+ var source = DatabaseOptions ?? new CultNetDatabaseOptions();
+ if (source.MutationLogStore != null)
+ {
+ return source;
+ }
+
+ return new CultNetDatabaseOptions
+ {
+ RuntimeId = source.RuntimeId,
+ Shards = source.Shards,
+ ClientAuthorityScopes = source.ClientAuthorityScopes,
+ DocumentRegistry = source.DocumentRegistry,
+ MutationLogStore = new CultNetFileShardMutationLogStore(ResolveShardLogPath(cachePath))
+ };
+ }
+
+ private string ResolveShardLogPath(string cachePath)
+ {
+ if (!string.IsNullOrWhiteSpace(ShardLogPath))
+ {
+ return ShardLogPath!;
+ }
+
+ var fullPath = Path.GetFullPath(cachePath);
+ var directory = Path.GetDirectoryName(fullPath) ?? Directory.GetCurrentDirectory();
+ var name = Path.GetFileNameWithoutExtension(fullPath);
+ return Path.Combine(directory, name + ".cultmesh", "shard-logs");
+ }
}
///
@@ -127,7 +174,7 @@ namespace GameCult.Mesh
{
var host = await CultNetLocal.CreateHostAsync(
cachePath,
- (options ?? new CultMeshNodeOptions()).ToCultNetOptions()).ConfigureAwait(false);
+ (options ?? new CultMeshNodeOptions()).ToCultNetOptions(cachePath)).ConfigureAwait(false);
return new CultMeshNode(host);
}
diff --git a/src/GameCult.Mesh/README.md b/src/GameCult.Mesh/README.md
index a926c180..d9dc240b 100644
--- a/src/GameCult.Mesh/README.md
+++ b/src/GameCult.Mesh/README.md
@@ -26,6 +26,19 @@ var player = await node.Database.GetAsync(playerKey);
await node.Database.PutAsync(playerKey, player);
```
+Enable durable authoritative shard logs when a node should serve replica
+catch-up after restart:
+
+```csharp
+using var node = await CultMesh.StartNodeAsync("world.ccmp", new CultMeshNodeOptions
+{
+ EnableDurableShardLogs = true
+});
+```
+
+If `ShardLogPath` is omitted, CultMesh stores logs beside the cache under
+`world.cultmesh/shard-logs`.
+
## Verses
A Verse is a rule-bearing consensus graph. Aetheria Main can be a
diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md
index 1c4fff05..21e22ae3 100644
--- a/src/GameCult.Mesh/docs/public-api.md
+++ b/src/GameCult.Mesh/docs/public-api.md
@@ -21,6 +21,11 @@ Namespace: `GameCult.Mesh`
- `Database`: the distributed realtime database facade
- `DatabaseServer`: the schema-v0 database bridge
+`CultMeshNodeOptions.EnableDurableShardLogs` attaches a file-backed
+authoritative shard-log store when `DatabaseOptions.MutationLogStore` is not
+already supplied. `ShardLogPath` can override the location; otherwise CultMesh
+uses a `.cultmesh/shard-logs` directory beside the cache file.
+
The package home and primary entrypoint are `GameCult.Mesh` / `CultMesh`.
Some lower-level database and wire-contract types still retain `CultNet` names
because they are shared with the transport package. Treat those as plumbing,
diff --git a/src/GameCult.Networking/Contracts/cultnet-distributed-database.md b/src/GameCult.Networking/Contracts/cultnet-distributed-database.md
index d620ca3d..c19a13ef 100644
--- a/src/GameCult.Networking/Contracts/cultnet-distributed-database.md
+++ b/src/GameCult.Networking/Contracts/cultnet-distributed-database.md
@@ -67,6 +67,8 @@ Implemented:
- shard-bounded snapshots with shard id, epoch, and represented log sequence
- replica snapshot recovery after compacted history, including local shard
replacement and cursor advancement
+- CultMesh node option for file-backed authoritative shard logs with a default
+ `.cultmesh/shard-logs` path beside the cache file
- client-authority scopes for locally predicted input documents
- predicted and reconciled database change kinds for client-side prediction
- simulation witness observations for mesh-side opinions about frame facts
@@ -119,7 +121,6 @@ Not implemented yet:
Add discovery fanout and operational polish:
-- expose operator-facing defaults for durable shard-log paths in CultMesh nodes
- add Verse discovery over the schema-v0 mesh lane
- add snapshot throttling and size limits for public mesh edges
- after that, add simulation-frame rollback/resimulation helpers around
diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs
index 6c662033..668883ea 100644
--- a/tests/GameCult.Networking.Tests/NetworkingTests.cs
+++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs
@@ -792,6 +792,64 @@ namespace GameCult.Networking.Tests
Assert.That(incompatible.CanTransferFrom(aetheria), Is.False);
}
+ [Test]
+ public async Task CultMeshNode_CanEnable_DurableShardLogs_WithDefaultPath()
+ {
+ var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "cultmesh-node", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(rootPath);
+ var cachePath = Path.Combine(rootPath, "world.ccmp");
+ var cache = new CultCache();
+ var schemaId = cache.Registry.GetRequired().SchemaId;
+ var registry = new CultNetDocumentRegistry(cache.Registry)
+ .Register(CultNetDocumentBinding.ForDocument(
+ cache.Registry,
+ payloadSerializer: SerializePlayerDataPayload,
+ payloadDeserializer: DeserializePlayerDataPayload));
+ var options = new CultMeshNodeOptions
+ {
+ StartServer = false,
+ EnableDurableShardLogs = true,
+ DatabaseOptions = new CultNetDatabaseOptions
+ {
+ DocumentRegistry = registry,
+ Shards =
+ [
+ new CultNetShardDescriptor(
+ "players-mesh-default-log",
+ "mesh-runtime",
+ epoch: 1,
+ isPrimary: true,
+ schemaIds: [schemaId],
+ keyPrefix: "player:")
+ ]
+ }
+ };
+ var key = new CultRecordKey("player:mesh-log");
+ var player = new PlayerData
+ {
+ PlayerId = Guid.NewGuid(),
+ Email = "mesh-log@example.test",
+ PasswordHash = "hash",
+ Username = "MeshLog"
+ };
+
+ using (var node = await CultMesh.CreateNodeAsync(cachePath, options))
+ {
+ await node.Database.PutAsync(key, player);
+ }
+
+ using var restarted = await CultMesh.CreateNodeAsync(cachePath, options);
+ var response = restarted.DatabaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
+ {
+ ShardId = "players-mesh-default-log"
+ });
+
+ Assert.That(response.Entries, Has.Length.EqualTo(1));
+ Assert.That(response.Entries[0].Put, Is.Not.Null);
+ Assert.That(response.Entries[0].Put!.Document.RecordKey, Is.EqualTo(key.Value));
+ Assert.That(Directory.Exists(Path.Combine(rootPath, "world.cultmesh", "shard-logs")), Is.True);
+ }
+
[Test]
public async Task CultNetDatabase_Appends_PerShardMutationLog()
{