diff --git a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs
index 3a57d960..16bbd6c9 100644
--- a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs
+++ b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs
@@ -70,15 +70,39 @@ namespace GameCult.Caching.MessagePack
/// Creates a cache with the canonical single-file MessagePack backing store attached.
///
public static CultCache Create(string filePath, CultCacheOpenOptions? options = null)
+ {
+ options ??= new CultCacheOpenOptions();
+ return CreateCore(filePath, options, initializeGlobals: true);
+ }
+
+ ///
+ /// Creates a cache with the canonical single-file MessagePack backing store attached and optionally pulls the on-disk snapshot.
+ ///
+ public static async Task OpenAsync(string filePath, CultCacheOpenOptions? options = null)
+ {
+ options ??= new CultCacheOpenOptions();
+ var cache = CreateCore(filePath, options, initializeGlobals: false);
+ if (options.PullOnOpen)
+ {
+ await cache.PullAllBackingStoresAsync().ConfigureAwait(false);
+ }
+
+ cache.MaterializeMissingGlobals();
+
+ return cache;
+ }
+
+ private static CultCache CreateCore(
+ string filePath,
+ CultCacheOpenOptions options,
+ bool initializeGlobals)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("File path must be non-empty.", nameof(filePath));
}
- options ??= new CultCacheOpenOptions();
-
- var cache = new CultCache(options.Registry)
+ var cache = new CultCache(options.Registry, initializeGlobals)
{
FlushAttachedStoresOnDispose = options.FlushOnDispose
};
@@ -106,20 +130,5 @@ namespace GameCult.Caching.MessagePack
return cache;
}
-
- ///
- /// Creates a cache with the canonical single-file MessagePack backing store attached and optionally pulls the on-disk snapshot.
- ///
- public static async Task OpenAsync(string filePath, CultCacheOpenOptions? options = null)
- {
- options ??= new CultCacheOpenOptions();
- var cache = Create(filePath, options);
- if (options.PullOnOpen)
- {
- await cache.PullAllBackingStoresAsync().ConfigureAwait(false);
- }
-
- return cache;
- }
}
}
diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
index 69f6fb26..003b4863 100644
--- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
+++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
@@ -154,6 +154,12 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
IsDirty = !_dirtyKeys.IsEmpty || !_deletedKeys.IsEmpty;
}
+ ///
+ public override bool ContainsDurableRecord(CultRecordKey key)
+ {
+ return _durableIndex.ContainsKey(key.Value) || base.ContainsDurableRecord(key);
+ }
+
///
public override void Push(CultStoredDocument entry)
{
diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs
index a580604b..d3da3096 100644
--- a/src/GameCult.Caching/CultCache.cs
+++ b/src/GameCult.Caching/CultCache.cs
@@ -1284,8 +1284,29 @@ namespace GameCult.Caching
/// Creates a cache using the supplied document registry or the shared registry.
///
public CultCache(CultDocumentRegistry? registry = null)
+ : this(registry, initializeGlobals: true)
+ {
+ }
+
+ ///
+ /// Creates a cache using the supplied document registry, optionally deferring global defaults
+ /// until a durable backing store has been hydrated.
+ ///
+ public CultCache(CultDocumentRegistry? registry, bool initializeGlobals)
{
_registry = registry ?? CultDocumentRegistry.Shared;
+ if (initializeGlobals)
+ {
+ MaterializeMissingGlobals();
+ }
+ }
+
+ ///
+ /// Creates default instances for global document types that do not already have a record.
+ /// Durable open paths call this after hydration so persisted globals remain authoritative.
+ ///
+ public void MaterializeMissingGlobals()
+ {
InitializeGlobals();
}
@@ -1800,6 +1821,13 @@ namespace GameCult.Caching
{
foreach (var descriptor in _registry.AllDescriptors.Where(candidate => candidate.IsGlobal))
{
+ var key = new CultRecordKey($"global:{descriptor.SchemaId}");
+ if (_entries.ContainsKey(key.Value) ||
+ _backingStores.Any(store => store.ContainsDurableRecord(key)))
+ {
+ continue;
+ }
+
if (descriptor.DocumentType.GetConstructor(Type.EmptyTypes) == null)
{
continue;
@@ -1813,7 +1841,7 @@ namespace GameCult.Caching
AddStoredDocumentInternal(
new CultStoredDocument(
- new CultRecordKey($"global:{descriptor.SchemaId}"),
+ key,
DateTimeOffset.UtcNow.ToString("O"),
descriptor,
instance),
@@ -2037,6 +2065,13 @@ namespace GameCult.Caching
PullAll();
}
///
+ /// Returns whether the backing store knows a record is durable, including records left cold by selective hydration.
+ ///
+ public virtual bool ContainsDurableRecord(CultRecordKey key)
+ {
+ return Entries.ContainsKey(key.Value);
+ }
+ ///
/// Pushes one stored document into the backing store.
///
public abstract void Push(CultStoredDocument entry);
diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs
index 23e37cb4..15d6d718 100644
--- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs
+++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs
@@ -2,6 +2,7 @@
using System;
using System.IO;
using System.Linq;
+using System.Reflection;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
@@ -321,6 +322,54 @@ namespace GameCult.Caching.Tests
}
}
+ [Test]
+ public async Task CultCacheMessagePack_OpenAsync_HydratesPersistedGlobalBeforeDefault()
+ {
+ var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc");
+ var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath);
+ var registry = CultDocumentRegistry.ForTypes(new[] { typeof(GlobalTestEntry) });
+ typeof(CultDocumentDescriptor)
+ .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .SetValue(registry.GetRequired(), true);
+
+ try
+ {
+ using (var seed = await CultCacheMessagePack.OpenAsync(
+ filePath,
+ new CultCacheOpenOptions { Registry = registry, UseDirectoryStore = true }))
+ {
+ var global = seed.GetGlobal();
+ Assert.That(global, Is.Not.Null);
+ global!.Value = "durable authority";
+ await seed.UpsertAsync(global);
+ await seed.FlushAsync();
+ }
+
+ using var reopened = await CultCacheMessagePack.OpenAsync(
+ filePath,
+ new CultCacheOpenOptions
+ {
+ Registry = registry,
+ UseDirectoryStore = true,
+ DirectoryStoreHydrationFilter = _ => false
+ });
+
+ Assert.That(reopened.GetGlobal(), Is.Null,
+ "the hydration filter owns whether the durable global becomes hot");
+ Assert.That(reopened.IsDirty, Is.False,
+ "a cold durable global must not stage its schema default as a write");
+
+ var globalKey = "global:" + registry.GetRequired().SchemaId;
+ await reopened.PullBackingStoreRecordsAsync(metadata => metadata.Key == globalKey);
+ Assert.That(reopened.GetGlobal()?.Value, Is.EqualTo("durable authority"));
+ }
+ finally
+ {
+ if (File.Exists(filePath)) File.Delete(filePath);
+ if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true);
+ }
+ }
+
[Test]
public async Task DirectoryMessagePackBackingStore_CleanFlush_DoesNotRewriteManifest()
{
@@ -989,6 +1038,13 @@ namespace GameCult.Caching.Tests
public string Value = string.Empty;
}
+ [CultDocument("tests.global_entry", "tests.global_entry.v1")]
+ internal sealed class GlobalTestEntry
+ {
+ [Key(0)]
+ public string Value = "schema default";
+ }
+
[CultDocument("tests.reference_holder", "tests.reference_holder.v1")]
internal sealed class ReferenceHolderEntry
{