diff --git a/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs b/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs
new file mode 100644
index 00000000..7e4a2d24
--- /dev/null
+++ b/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+namespace GameCult.Caching.MessagePack.Generator
+{
+ [Generator]
+ public class CultDocumentMessagePackGenerator : IIncrementalGenerator
+ {
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var documentTypes = context.SyntaxProvider
+ .CreateSyntaxProvider(
+ static (node, _) => node is ClassDeclarationSyntax cds && cds.AttributeLists.Count > 0,
+ static (ctx, _) => ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) as INamedTypeSymbol)
+ .Where(static symbol => symbol is not null)!
+ .Select((symbol, _) => symbol!)
+ .Where(static symbol => symbol.GetAttributes().Any(attribute =>
+ attribute.AttributeClass?.ToDisplayString() == "GameCult.Caching.CultDocumentAttribute"));
+
+ context.RegisterSourceOutput(documentTypes.Collect(), (spc, types) =>
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("// ");
+ builder.AppendLine("#nullable enable");
+ builder.AppendLine("using MessagePack.Formatters;");
+ builder.AppendLine("namespace GameCult.Caching.MessagePack");
+ builder.AppendLine("{");
+ builder.AppendLine(" public static partial class GeneratedCultDocumentResolvers");
+ builder.AppendLine(" {");
+ builder.AppendLine(" public static IMessagePackFormatter? GetFormatter()");
+ builder.AppendLine(" {");
+ builder.AppendLine(" return null;");
+ builder.AppendLine(" }");
+ builder.AppendLine(" }");
+ builder.AppendLine("}");
+ spc.AddSource("GeneratedCultDocumentResolvers.g.cs", SourceText.From(builder.ToString(), Encoding.UTF8));
+ });
+ }
+ }
+}
diff --git a/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs b/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs
deleted file mode 100644
index 5cae7194..00000000
--- a/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs
+++ /dev/null
@@ -1,264 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
-using Microsoft.CodeAnalysis.Text;
-
-namespace GameCult.Caching.MessagePack.Generator
-{
- ///
- /// Generates MessagePack formatters and resolver mappings for concrete DatabaseEntry types.
- ///
- [Generator]
- public class DatabaseEntryMessagePackGenerator : IIncrementalGenerator
- {
- ///
- /// Configures the incremental generator pipeline.
- ///
- /// The generator initialization context.
- public void Initialize(IncrementalGeneratorInitializationContext ctx)
- {
- // Select DatabaseEntry subclass declarations
- var databaseEntryTypes = ctx.SyntaxProvider
- .CreateSyntaxProvider(
- static (s, _) => s is ClassDeclarationSyntax cds && cds.BaseList != null,
- static (ctx, _) => ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) as INamedTypeSymbol)
- .Where(static s => s is not null)!
- .Select((s, _) => s!)
- .Where(symbol =>
- {
- if (symbol.IsAbstract) return false;
- var baseType = symbol.BaseType;
- while (baseType != null)
- {
- if (baseType.ToDisplayString() == "GameCult.Caching.DatabaseEntry")
- return true;
- baseType = baseType.BaseType;
- }
- return false;
- });
-
- // 2. Distinct types
- var collected = databaseEntryTypes.Collect();
-
- // 3. Register output
- ctx.RegisterSourceOutput(collected, (spc, types) =>
- {
- //if (types.Length == 0) return;
-
- var sbResolvers = new StringBuilder();
- var sbFormatters = new StringBuilder();
-
- sbResolvers.AppendLine("// ");
- sbResolvers.AppendLine("#nullable enable");
- sbResolvers.AppendLine("using System;");
- sbResolvers.AppendLine("using MessagePack.Formatters;");
- sbResolvers.AppendLine("namespace GameCult.Caching.MessagePack");
- sbResolvers.AppendLine("{");
- sbResolvers.AppendLine(" static partial class GeneratedDatabaseEntryResolvers");
- sbResolvers.AppendLine(" {");
- sbResolvers.AppendLine(" public static IMessagePackFormatter? GetFormatter()");
- sbResolvers.AppendLine(" {");
- sbResolvers.AppendLine(" var t = typeof(T);");
-
- foreach (var type in types)
- {
- var fullName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- var formatterName = $"{Sanitize(type)}__MessagePackFormatter";
-
- var fmtSrc = GenerateFormatterFor(type, formatterName);
- sbFormatters.AppendLine(fmtSrc);
-
- sbResolvers.AppendLine(
- $" if (t == typeof({fullName})) return (IMessagePackFormatter)(object){formatterName}.Instance;");
- }
-
- sbResolvers.AppendLine(" return null;");
- sbResolvers.AppendLine(" }");
- sbResolvers.AppendLine(" }");
- sbResolvers.AppendLine("}");
-
- spc.AddSource("GeneratedDatabaseEntryFormatters.g.cs", SourceText.From(sbFormatters.ToString(), Encoding.UTF8));
- spc.AddSource("GeneratedDatabaseEntryResolvers.g.cs", SourceText.From(sbResolvers.ToString(), Encoding.UTF8));
- });
- }
-
- private static string Sanitize(ISymbol symbol)
- {
- var s = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- return s.Replace(".", "_")
- .Replace("global::", string.Empty)
- .Replace("<", "_")
- .Replace(">", "_")
- .Replace(",", "_")
- .Replace(" ", "_");
- }
-
- // Collect members, order them, generate the formatter
- private static string GenerateFormatterFor(INamedTypeSymbol type, string formatterName)
- {
- var sb = new StringBuilder();
- sb.AppendLine("// ");
- sb.AppendLine("#nullable enable");
- sb.AppendLine("using System;");
- sb.AppendLine("using MessagePack;");
- sb.AppendLine("using MessagePack.Formatters;");
- sb.AppendLine();
-
- var ns = "GameCult.Caching.MessagePack";
- sb.AppendLine($"namespace {ns}");
- sb.AppendLine("{");
-
- var typeFull = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
-
- sb.AppendLine($" internal sealed class {formatterName} : IMessagePackFormatter<{typeFull}>");
- sb.AppendLine(" {");
- sb.AppendLine($" public static readonly {formatterName} Instance = new {formatterName}();");
- sb.AppendLine($" private {formatterName}() {{ }}");
- sb.AppendLine();
-
- var members = CollectMembers(type);
- var slots = AssignSlots(type, members);
- var maxSlot = slots.Count == 0 ? -1 : slots.Max(m => m.slot);
-
- // Serialize
- sb.AppendLine($" public void Serialize(ref MessagePackWriter writer, {typeFull}? value, MessagePackSerializerOptions options)");
- sb.AppendLine(" {");
- sb.AppendLine(" if (value == null) { writer.WriteNil(); return; }");
- sb.AppendLine($" writer.WriteArrayHeader({maxSlot + 1});");
- for (int i = 0; i <= maxSlot; i++)
- {
- var member = slots.FirstOrDefault(m => m.slot == i);
- if (member.name == null)
- {
- sb.AppendLine(" writer.WriteNil();");
- continue;
- }
- var tname = member.typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- var access = $"value.{member.name}";
- sb.AppendLine(
- tname == "global::System.Guid"
- ? $" options.Resolver.GetFormatterWithVerify().Serialize(ref writer, {access}, options);"
- : $" options.Resolver.GetFormatterWithVerify<{tname}>().Serialize(ref writer, {access}, options);");
- }
- sb.AppendLine(" }");
-
- // Deserialize
- sb.AppendLine();
- sb.AppendLine($" public {typeFull}? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)");
- sb.AppendLine(" {");
- sb.AppendLine(" if (reader.TryReadNil()) return null;");
- sb.AppendLine($" var count = reader.ReadArrayHeader();");
- sb.AppendLine($" var obj = new {typeFull}();");
- sb.AppendLine(" for (var i = 0; i < count; i++)");
- sb.AppendLine(" {");
- sb.AppendLine(" switch (i)");
- sb.AppendLine(" {");
- foreach (var (slot, name, tsym) in slots)
- {
- var tname = tsym.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- sb.AppendLine($" case {slot}:");
- sb.AppendLine(
- tname == "global::System.Guid"
- ? $" obj.{name} = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options);"
- : $" obj.{name} = options.Resolver.GetFormatterWithVerify<{tname}>().Deserialize(ref reader, options);");
- sb.AppendLine(" break;");
- }
- sb.AppendLine(" default:");
- sb.AppendLine(" reader.Skip();");
- sb.AppendLine(" break;");
- sb.AppendLine(" }");
- sb.AppendLine(" }");
- sb.AppendLine(" return obj;");
- sb.AppendLine(" }");
-
- sb.AppendLine(" }");
- sb.AppendLine("}");
- return sb.ToString();
- }
-
- private static List<(string name, ITypeSymbol typeSymbol, int? key, bool ignore)> CollectMembers(INamedTypeSymbol type)
- {
- var members = new List<(string, ITypeSymbol, int?, bool)>();
-
- foreach (var f in type.GetMembers().OfType())
- {
- if (f.IsStatic) continue;
- var ignore = f.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == "MessagePack.IgnoreMemberAttribute");
- var key = GetKeyValue(f);
- members.Add((f.Name, f.Type, key, ignore));
- }
-
- foreach (var p in type.GetMembers().OfType())
- {
- if (p.IsStatic || p.SetMethod == null || p.GetMethod == null) continue;
- var ignore = p.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == "MessagePack.IgnoreMemberAttribute");
- var key = GetKeyValue(p);
- members.Add((p.Name, p.Type, key, ignore));
- }
-
- return members;
- }
-
- private static List<(int slot, string name, ITypeSymbol typeSymbol)> AssignSlots(
- INamedTypeSymbol type,
- List<(string name, ITypeSymbol typeSymbol, int? key, bool ignore)> members)
- {
- members = members.Where(m => !m.ignore).ToList();
-
- var slots = new List<(int, string, ITypeSymbol)>();
-
- var id = FindIdMember(type);
- if (id != null)
- {
- slots.Add((0, id.Value.name, id.Value.typeSymbol));
- members.RemoveAll(m => m.name == id.Value.name);
- }
-
- foreach (var member in members.Where(m => m.key.HasValue).OrderBy(m => m.key!.Value))
- {
- slots.Add((member.key!.Value, member.name, member.typeSymbol));
- }
-
- var nextSlot = slots.Count == 0 ? 0 : slots.Max(s => s.Item1) + 1;
- foreach (var member in members.Where(m => !m.key.HasValue))
- {
- slots.Add((nextSlot, member.name, member.typeSymbol));
- nextSlot++;
- }
-
- return slots.OrderBy(s => s.Item1).ToList();
- }
-
- private static int? GetKeyValue(ISymbol sym)
- {
- foreach (var attr in sym.GetAttributes())
- {
- var n = attr.AttributeClass?.ToDisplayString();
- if (n == "MessagePack.KeyAttribute" || n == "MessagePack.Key")
- {
- if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Value is int i)
- return i;
- }
- }
- return null;
- }
-
- private static (string name, ITypeSymbol typeSymbol)? FindIdMember(INamedTypeSymbol type)
- {
- var cur = type;
- while (cur != null)
- {
- foreach (var f in cur.GetMembers().OfType())
- {
- if ((f.Name == "ID") && f.Type.ToDisplayString() == "System.Guid")
- return (f.Name, f.Type);
- }
- cur = cur.BaseType;
- }
- return null;
- }
- }
-}
diff --git a/src/GameCult.Caching.MessagePack/CultCacheEnvelope.cs b/src/GameCult.Caching.MessagePack/CultCacheEnvelope.cs
deleted file mode 100644
index 838fa32d..00000000
--- a/src/GameCult.Caching.MessagePack/CultCacheEnvelope.cs
+++ /dev/null
@@ -1,95 +0,0 @@
-using System;
-using System.Reflection;
-
-namespace GameCult.Caching.MessagePack;
-
-///
-/// Represents a canonical CultCache payload envelope for cross-runtime exchange.
-///
-public sealed class CultCacheEnvelope
-{
- ///
- /// Gets or sets the logical document key for the serialized entry.
- ///
- public string Key { get; set; } = string.Empty;
-
- ///
- /// Gets or sets the shared document type identifier for the serialized entry.
- ///
- public string Type { get; set; } = string.Empty;
-
- ///
- /// Gets or sets the raw MessagePack payload bytes for the entry.
- ///
- public byte[] Payload { get; set; } = Array.Empty();
-
- ///
- /// Gets or sets the RFC3339 timestamp associated with the serialized payload.
- ///
- public string StoredAt { get; set; } = string.Empty;
-}
-
-///
-/// Exposes canonical MessagePack payload helpers for values.
-///
-public static class CultCacheEnvelopeSerialization
-{
- ///
- /// Serializes a concrete entry to the canonical MessagePack payload bytes used by CultCache.
- ///
- public static byte[] SerializePayload(DatabaseEntry entry)
- {
- if (entry == null) throw new ArgumentNullException(nameof(entry));
- var method = typeof(CultCacheEnvelopeSerialization)
- .GetMethod(nameof(SerializeGeneric), BindingFlags.NonPublic | BindingFlags.Static)
- ?.MakeGenericMethod(entry.GetType())
- ?? throw new InvalidOperationException("Failed to locate typed CultCache envelope serializer.");
- return (byte[])method.Invoke(null, [entry])!;
- }
-
- ///
- /// Deserializes canonical MessagePack payload bytes into a concrete entry type.
- ///
- public static T DeserializePayload(byte[] payload) where T : DatabaseEntry
- {
- if (payload == null) throw new ArgumentNullException(nameof(payload));
- return DatabaseEntrySerialization.Deserialize(payload);
- }
-
- ///
- /// Deserializes canonical MessagePack payload bytes into an abstract .
- ///
- public static DatabaseEntry DeserializePayload(byte[] payload)
- {
- if (payload == null) throw new ArgumentNullException(nameof(payload));
- return DatabaseEntrySerialization.Deserialize(payload);
- }
-
- ///
- /// Creates a full exchange envelope for a concrete entry using the supplied metadata.
- ///
- public static CultCacheEnvelope CreateEnvelope(
- DatabaseEntry entry,
- string key,
- string type,
- string storedAt)
- {
- if (entry == null) throw new ArgumentNullException(nameof(entry));
- if (string.IsNullOrWhiteSpace(key)) throw new ArgumentException("Envelope key must be non-empty.", nameof(key));
- if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Envelope type must be non-empty.", nameof(type));
- if (string.IsNullOrWhiteSpace(storedAt)) throw new ArgumentException("Envelope storedAt must be non-empty.", nameof(storedAt));
-
- return new CultCacheEnvelope
- {
- Key = key,
- Type = type,
- Payload = SerializePayload(entry),
- StoredAt = storedAt
- };
- }
-
- private static byte[] SerializeGeneric(T entry) where T : DatabaseEntry
- {
- return DatabaseEntrySerialization.Serialize(entry);
- }
-}
diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
new file mode 100644
index 00000000..054fce85
--- /dev/null
+++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
@@ -0,0 +1,146 @@
+using System;
+using GameCult.Caching;
+using MessagePack;
+using MessagePack.Formatters;
+using MessagePack.Resolvers;
+
+namespace GameCult.Caching.MessagePack;
+
+public sealed class CultRecordRefFormatter : IMessagePackFormatter>
+{
+ public void Serialize(ref MessagePackWriter writer, CultRecordRef value, MessagePackSerializerOptions options)
+ {
+ options.Resolver.GetFormatterWithVerify().Serialize(ref writer, value.Key.Value, options);
+ }
+
+ public CultRecordRef Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
+ {
+ var key = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options) ?? string.Empty;
+ return new CultRecordRef(new CultRecordKey(key));
+ }
+}
+
+public sealed class CultDocumentResolver : IFormatterResolver
+{
+ public static readonly CultDocumentResolver Instance = new();
+ private CultDocumentResolver() { }
+
+ public IMessagePackFormatter? GetFormatter()
+ {
+ var generated = GeneratedCultDocumentResolvers.GetFormatter();
+ if (generated != null)
+ {
+ return generated;
+ }
+
+ var type = typeof(T);
+ if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
+ {
+ var formatterType = typeof(CultRecordRefFormatter<>).MakeGenericType(type.GetGenericArguments()[0]);
+ return (IMessagePackFormatter)Activator.CreateInstance(formatterType)!;
+ }
+
+ return null;
+ }
+}
+
+public static class CultDocumentMessagePackSerialization
+{
+ public static readonly MessagePackSerializerOptions Options =
+ MessagePackSerializerOptions.Standard
+ .WithResolver(CompositeResolver.Create(
+ CultDocumentResolver.Instance,
+ StandardResolver.Instance))
+ .WithSecurity(MessagePackSecurity.UntrustedData);
+
+ public static byte[] Serialize(T value)
+ {
+ return MessagePackSerializer.Serialize(value, Options);
+ }
+
+ public static T Deserialize(byte[] payload)
+ {
+ return MessagePackSerializer.Deserialize(payload, Options);
+ }
+
+ public static byte[] SerializeUntyped(object value, Type type)
+ {
+ return MessagePackSerializer.Serialize(type, value, Options);
+ }
+
+ public static object DeserializeUntyped(Type type, byte[] payload)
+ {
+ return MessagePackSerializer.Deserialize(type, payload, Options);
+ }
+}
+
+public static class GeneratedCultDocumentResolvers
+{
+ public static IMessagePackFormatter? GetFormatter() => null;
+}
+
+public class SingleFileMessagePackBackingStore : SingleFileBackingStore
+{
+ public SingleFileMessagePackBackingStore(string filePath) : base(filePath)
+ {
+ }
+
+ protected override byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot)
+ {
+ return CultDocumentMessagePackSerialization.Serialize(snapshot);
+ }
+
+ protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data)
+ {
+ return CultDocumentMessagePackSerialization.Deserialize(data);
+ }
+
+ protected override byte[] SerializePayload(object document)
+ {
+ return CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType());
+ }
+
+ protected override object DeserializePayload(Type documentType, byte[] payload)
+ {
+ return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload);
+ }
+}
+
+public class MultiFileMessagePackBackingStore : MultiFileBackingStore
+{
+ public MultiFileMessagePackBackingStore(string path) : base(path)
+ {
+ }
+
+ protected override byte[] SerializeRecord(CultPersistedRecord record)
+ {
+ return CultDocumentMessagePackSerialization.Serialize(record);
+ }
+
+ protected override CultPersistedRecord DeserializeRecord(byte[] data)
+ {
+ return CultDocumentMessagePackSerialization.Deserialize(data);
+ }
+
+ protected override byte[] SerializeCatalog(CultSchemaCatalogEntry[] catalog)
+ {
+ return CultDocumentMessagePackSerialization.Serialize(catalog);
+ }
+
+ protected override CultSchemaCatalogEntry[] DeserializeCatalog(byte[] data)
+ {
+ return CultDocumentMessagePackSerialization.Deserialize(data);
+ }
+
+ protected override byte[] SerializePayload(object document)
+ {
+ return CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType());
+ }
+
+ protected override object DeserializePayload(Type documentType, byte[] payload)
+ {
+ return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload);
+ }
+
+ public override string Extension => "msgpack";
+}
diff --git a/src/GameCult.Caching.MessagePack/DatabaseEntryResolver.cs b/src/GameCult.Caching.MessagePack/DatabaseEntryResolver.cs
deleted file mode 100644
index a1236b1b..00000000
--- a/src/GameCult.Caching.MessagePack/DatabaseEntryResolver.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using MessagePack;
-using MessagePack.Formatters;
-
-namespace GameCult.Caching.MessagePack
-{
- ///
- /// Resolver that knows how to resolve:
- /// 1. Source-generated singleton formatters for DatabaseEntry subclasses
- /// 2. Generic DatabaseLink<T> formatter
- ///
- public sealed class DatabaseEntryResolver : IFormatterResolver
- {
- ///
- /// Shared singleton instance of the resolver.
- ///
- public static readonly DatabaseEntryResolver Instance = new DatabaseEntryResolver();
-
- private readonly ConcurrentDictionary _formatterCache = new();
-
- private DatabaseEntryResolver() { }
-
- ///
- /// Resolves a formatter for a cache entry type or a .
- ///
- /// The requested serialized type.
- /// The formatter if one is available; otherwise, null.
- public IMessagePackFormatter? GetFormatter()
- {
- // 1. Check generated resolver mapping
- var fmt = GeneratedDatabaseEntryResolvers.GetFormatter();
- if (fmt != null) return fmt;
-
- // 2. Provide generic DatabaseLink formatter
- var t = typeof(T);
- if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(DatabaseLink<>))
- {
- return (IMessagePackFormatter)_formatterCache.GetOrAdd(
- t,
- static linkType =>
- {
- var valueType = linkType.GetGenericArguments()[0];
- var formatterType = typeof(DatabaseLinkFormatter<>).MakeGenericType(valueType);
- return Activator.CreateInstance(formatterType)!;
- });
- }
-
- // 3. Nothing found
- return null;
- }
- }
-}
diff --git a/src/GameCult.Caching.MessagePack/DatabaseEntrySerialization.cs b/src/GameCult.Caching.MessagePack/DatabaseEntrySerialization.cs
deleted file mode 100644
index 0e87e930..00000000
--- a/src/GameCult.Caching.MessagePack/DatabaseEntrySerialization.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using MessagePack;
-using MessagePack.Resolvers;
-
-namespace GameCult.Caching.MessagePack;
-
-internal static class DatabaseEntrySerialization
-{
- public static readonly MessagePackSerializerOptions Options =
- MessagePackSerializerOptions.Standard
- .WithResolver(CompositeResolver.Create(
- DatabaseEntryResolver.Instance,
- StandardResolver.Instance))
- .WithSecurity(MessagePackSecurity.UntrustedData);
-
- public static byte[] Serialize(T value)
- {
- return MessagePackSerializer.Serialize(value, Options);
- }
-
- public static T Deserialize(byte[] payload)
- {
- return MessagePackSerializer.Deserialize(payload, Options);
- }
-}
diff --git a/src/GameCult.Caching.MessagePack/DatabaseLinkFormatter.cs b/src/GameCult.Caching.MessagePack/DatabaseLinkFormatter.cs
deleted file mode 100644
index 14c9f661..00000000
--- a/src/GameCult.Caching.MessagePack/DatabaseLinkFormatter.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System;
-using MessagePack;
-using MessagePack.Formatters;
-
-namespace GameCult.Caching.MessagePack
-{
- ///
- /// Serializes values as their linked identifier only.
- ///
- /// The target entry type.
- public sealed class DatabaseLinkFormatter : IMessagePackFormatter?> where T : DatabaseEntry
- {
- ///
- public void Serialize(ref MessagePackWriter writer, DatabaseLink? value, MessagePackSerializerOptions options)
- {
- if (value == null) { writer.WriteNil(); return; }
- writer.WriteArrayHeader(1);
- var fmt = options.Resolver.GetFormatterWithVerify();
- fmt.Serialize(ref writer, value.LinkID, options);
- }
-
- ///
- public DatabaseLink? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
- {
- if (reader.TryReadNil()) return null;
- var c = reader.ReadArrayHeader();
- if (c != 1) throw new MessagePackSerializationException($"Invalid DatabaseLink<{typeof(T).Name}> array length {c}, expected 1");
- var id = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options);
- var link = new DatabaseLink { LinkID = id };
- return link;
- }
- }
-}
diff --git a/src/GameCult.Caching.MessagePack/GeneratedDatabaseEntryResolvers.cs b/src/GameCult.Caching.MessagePack/GeneratedDatabaseEntryResolvers.cs
deleted file mode 100644
index a86a0306..00000000
--- a/src/GameCult.Caching.MessagePack/GeneratedDatabaseEntryResolvers.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-// This stub ensures the project compiles before the source generator runs.
-
-using MessagePack.Formatters;
-
-namespace GameCult.Caching.MessagePack
-{
- static partial class GeneratedDatabaseEntryResolvers
- {
- // The generator will emit the real implementation.
- public static IMessagePackFormatter? GetFormatter() => null;
- }
-}
\ No newline at end of file
diff --git a/src/GameCult.Caching.MessagePack/MessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/MessagePackBackingStore.cs
deleted file mode 100644
index 5fcc1a53..00000000
--- a/src/GameCult.Caching.MessagePack/MessagePackBackingStore.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using MessagePack;
-
-namespace GameCult.Caching.MessagePack;
-
-///
-/// Persists all cache entries to a single MessagePack file.
-///
-public class SingleFileMessagePackBackingStore : SingleFileBackingStore
-{
- ///
- /// Initializes a single-file MessagePack backing store.
- ///
- /// The path to the backing file.
- public SingleFileMessagePackBackingStore(string filePath) : base(filePath) { }
-
- ///
- public override byte[] Serialize(DatabaseEntry[] entries)
- {
- return DatabaseEntrySerialization.Serialize(entries);
- }
-
- ///
- public override DatabaseEntry[] Deserialize(byte[] data)
- {
- return DatabaseEntrySerialization.Deserialize(data);
- }
-}
-
-///
-/// Persists each cache entry to its own MessagePack file.
-///
-public class MultiFileMessagePackBackingStore : MultiFileBackingStore
-{
- ///
- /// Initializes a multi-file MessagePack backing store rooted at the supplied directory.
- ///
- /// The root directory for stored entry files.
- public MultiFileMessagePackBackingStore(string path) : base(path) { }
-
- ///
- public override byte[] Serialize(DatabaseEntry entry)
- {
- return DatabaseEntrySerialization.Serialize(entry);
- }
-
- ///
- public override DatabaseEntry Deserialize(byte[] data)
- {
- return DatabaseEntrySerialization.Deserialize(data);
- }
-
- ///
- public override string Extension => "msgpack";
-}
diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md
index c7837019..44826ce1 100644
--- a/src/GameCult.Caching.MessagePack/README.md
+++ b/src/GameCult.Caching.MessagePack/README.md
@@ -1,39 +1,37 @@
# GameCult.Caching.MessagePack
-`GameCult.Caching.MessagePack` provides MessagePack-backed persistence for `GameCult.Caching`.
-
-## Scope
-
-The package focuses on:
-
-- MessagePack storage for `DatabaseEntry` models
-- single-file and multi-file store implementations
-- support for generated formatters for concrete cache-entry types
-- formatter support for `DatabaseLink`
-- raw persisted-envelope helpers for exact payload relay
+`GameCult.Caching.MessagePack` provides MessagePack persistence for the
+attribute-first `GameCult.Caching` stack.
## Included Types
- `SingleFileMessagePackBackingStore`
- `MultiFileMessagePackBackingStore`
-- `DatabaseEntryResolver`
-- `DatabaseLinkFormatter`
-- `CultCacheEnvelope`
-- `CultCacheEnvelopeSerialization`
+- `CultDocumentMessagePackSerialization`
+- `CultDocumentResolver`
+- `CultRecordRefFormatter`
-## Why The Generator Matters
+## What It Does
-`DatabaseEntry` is abstract. Concrete subclasses need MessagePack formatters to be serializable without hand-writing formatters for each model.
+- stores CultCache snapshots and records in MessagePack
+- serializes plain attributed document payloads
+- keeps explicit `CultRecordRef` values compact on disk/wire
+- preserves the cache/store split where metadata lives outside the domain model
-That is the role of:
+## Example Model
-- `GameCult.Caching.MessagePack.Generator`
-- `GameCult.Caching.MessagePack.Analyzers`
+```csharp
+using GameCult.Caching;
+using MessagePack;
-In practice:
-
-- this package gives you the stores and resolver support
-- the generator package produces the actual concrete formatters
+[CultDocument("gamecult.item_data", "gamecult.item_data.v1")]
+[MessagePackObject]
+public sealed class ItemData
+{
+ [Key(0)] [CultName] public string Name = string.Empty;
+ [Key(1)] public int Value;
+}
+```
## Typical Usage
@@ -48,94 +46,11 @@ cache.AddBackingStore(store);
await cache.PullAllBackingStoresAsync();
```
-## Single-File vs Multi-File
+## Notes
-### `SingleFileMessagePackBackingStore`
-
-Use this when you want one snapshot file containing all entries.
-
-Use cases:
-
-- compact settings data
-- simple local persistence
-- write-all-at-once workflows
-
-```csharp
-var store = new SingleFileMessagePackBackingStore("cache.msgpack");
-cache.AddBackingStore(store);
-```
-
-### `MultiFileMessagePackBackingStore`
-
-Use this when you want one file per entry.
-
-Use cases:
-
-- large datasets
-- debugging persisted entries individually
-- change observation through the multi-file base store
-
-```csharp
-var store = new MultiFileMessagePackBackingStore("Data");
-cache.AddBackingStore(store, typeof(PlayerData));
-```
-
-## `DatabaseLink`
-
-`DatabaseLink` is a lightweight reference type used by the cache layer. The MessagePack formatter serializes only the linked `Guid`, not the fully loaded target object.
-
-That means:
-
-- links stay compact on disk
-- linked objects are resolved through the active `CultCache`
-- deserialized links depend on `DatabaseLinkBase.Cache` pointing at the active cache
-
-## Example Model
-
-```csharp
-using GameCult.Caching;
-using MessagePack;
-
-[MessagePackObject]
-public class ItemData : DatabaseEntry, INamedEntry
-{
- [Key(1)] public string Name = string.Empty;
- [Key(2)] public int Value;
-
- [IgnoreMember]
- public string EntryName
- {
- get => Name;
- set => Name = value;
- }
-}
-```
-
-## Practical Notes
-
-- For polymorphic `DatabaseEntry` serialization, use this package with the generator/analyzer package.
-- The backing stores build on the semantics of `CultCache`, including primary-store routing and type-specific store routing.
-- `MultiFileMessagePackBackingStore` inherits the rename-safe and atomic-write behavior of the hardened `MultiFileBackingStore` base class.
-
-## Raw Envelope Relay
-
-If a neighboring process already shares the same payload contract and just
-needs the exact persisted MessagePack bytes, use `CultCacheEnvelope` and
-`CultCacheEnvelopeSerialization`.
-
-That gives you:
-
-- the stable cache key
-- the explicit entry/document type
-- the exact payload bytes
-- the stored-at timestamp
-
-This is the seam the newer CultNet raw document/snapshot lane uses for
-bit-compatible neighbors. It keeps the transport from decoding bytes into a
-generic sludge object only to re-encode the same thing again five seconds
-later.
-
-One important constraint: concrete `DatabaseEntry` subclasses still need real
-MessagePack formatters. If a consumer assembly has not generated or registered
-formatters for a subtype, supply an explicit payload codec at the integration
-boundary instead of assuming the abstract resolver will become psychic.
+- Payload bytes are MessagePack. Store metadata and schema catalogs are also
+ persisted through MessagePack in this package.
+- The generator package now targets attributed document types instead of
+ `DatabaseEntry` subclasses.
+- The raw CultNet document lane should treat these payload bytes as already
+ blessed, not decode and re-encode them for sport.
diff --git a/src/GameCult.Caching.NewtonsoftJson/GameCult.Caching.NewtonsoftJson.csproj b/src/GameCult.Caching.NewtonsoftJson/GameCult.Caching.NewtonsoftJson.csproj
index 239287cc..72760e16 100644
--- a/src/GameCult.Caching.NewtonsoftJson/GameCult.Caching.NewtonsoftJson.csproj
+++ b/src/GameCult.Caching.NewtonsoftJson/GameCult.Caching.NewtonsoftJson.csproj
@@ -5,6 +5,7 @@
+
diff --git a/src/GameCult.Caching.NewtonsoftJson/NewtonsoftJsonBackingStore.cs b/src/GameCult.Caching.NewtonsoftJson/NewtonsoftJsonBackingStore.cs
index 438f8ee5..84dff1b1 100644
--- a/src/GameCult.Caching.NewtonsoftJson/NewtonsoftJsonBackingStore.cs
+++ b/src/GameCult.Caching.NewtonsoftJson/NewtonsoftJsonBackingStore.cs
@@ -1,238 +1,77 @@
using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using System.Reflection;
-using System.Text;
+using GameCult.Caching.MessagePack;
using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
namespace GameCult.Caching.NewtonsoftJson;
-///
-/// Persists all cache entries to a single JSON file using Newtonsoft.Json.
-///
public class SingleFileNewtonsoftJsonBackingStore : SingleFileBackingStore
{
- private readonly KnownDatabaseEntryTypes _knownTypes = new();
-
- ///
- /// Initializes a single-file Newtonsoft.Json backing store.
- ///
- /// The path to the backing file.
public SingleFileNewtonsoftJsonBackingStore(string filePath) : base(filePath)
{
}
- ///
- /// Registers a concrete type for serialization.
- ///
- /// The entry type to register.
- public void RegisterType() where T : DatabaseEntry
+ protected override byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot)
{
- _knownTypes.RegisterType(typeof(T));
+ return System.Text.Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(snapshot, Formatting.Indented));
}
- ///
- /// Registers all concrete types from the supplied assembly.
- ///
- /// The assembly to scan.
- public void RegisterAssembly(Assembly assembly)
+ protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data)
{
- _knownTypes.RegisterAssembly(assembly);
+ var json = System.Text.Encoding.UTF8.GetString(data);
+ return JsonConvert.DeserializeObject(json)
+ ?? new CultPersistedStoreSnapshot();
}
- ///
- /// Rebuilds the known-type map from the currently loaded assemblies.
- ///
- public void RefreshKnownTypes()
+ protected override byte[] SerializePayload(object document)
{
- _knownTypes.RegisterLoadedAssemblies();
+ return CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType());
}
- ///
- public override byte[] Serialize(DatabaseEntry[] entries)
+ protected override object DeserializePayload(Type documentType, byte[] payload)
{
- var serializer = JsonSerializer.Create(KnownDatabaseEntryTypes.SerializerSettings);
- var payload = entries.Select(entry => _knownTypes.ToEnvelope(entry, serializer)).ToArray();
- var json = JsonConvert.SerializeObject(payload, KnownDatabaseEntryTypes.SerializerSettings);
- return Encoding.UTF8.GetBytes(json);
- }
-
- ///
- public override DatabaseEntry[] Deserialize(byte[] data)
- {
- _knownTypes.RegisterLoadedAssemblies();
-
- var json = Encoding.UTF8.GetString(data);
- var serializer = JsonSerializer.Create(KnownDatabaseEntryTypes.SerializerSettings);
- var payload = JsonConvert.DeserializeObject(json, KnownDatabaseEntryTypes.SerializerSettings) ?? [];
- return payload.Select(envelope => _knownTypes.FromEnvelope(envelope, serializer)).ToArray();
+ return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload);
}
}
-///
-/// Persists each cache entry to its own JSON file using Newtonsoft.Json.
-///
public class MultiFileNewtonsoftJsonBackingStore : MultiFileBackingStore
{
- private readonly KnownDatabaseEntryTypes _knownTypes = new();
-
- ///
- /// Initializes a multi-file Newtonsoft.Json backing store rooted at the supplied directory.
- ///
- /// The root directory for stored entry files.
public MultiFileNewtonsoftJsonBackingStore(string path) : base(path)
{
}
- ///
- /// Registers a concrete type for serialization.
- ///
- /// The entry type to register.
- public void RegisterType() where T : DatabaseEntry
+ protected override byte[] SerializeRecord(CultPersistedRecord record)
{
- _knownTypes.RegisterType(typeof(T));
+ return System.Text.Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(record, Formatting.Indented));
}
- ///
- /// Registers all concrete types from the supplied assembly.
- ///
- /// The assembly to scan.
- public void RegisterAssembly(Assembly assembly)
+ protected override CultPersistedRecord DeserializeRecord(byte[] data)
{
- _knownTypes.RegisterAssembly(assembly);
+ var json = System.Text.Encoding.UTF8.GetString(data);
+ return JsonConvert.DeserializeObject(json)
+ ?? new CultPersistedRecord();
}
- ///
- /// Rebuilds the known-type map from the currently loaded assemblies.
- ///
- public void RefreshKnownTypes()
+ protected override byte[] SerializeCatalog(CultSchemaCatalogEntry[] catalog)
{
- _knownTypes.RegisterLoadedAssemblies();
+ return System.Text.Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(catalog, Formatting.Indented));
}
- ///
- public override byte[] Serialize(DatabaseEntry entry)
+ protected override CultSchemaCatalogEntry[] DeserializeCatalog(byte[] data)
{
- var serializer = JsonSerializer.Create(KnownDatabaseEntryTypes.SerializerSettings);
- var payload = _knownTypes.ToEnvelope(entry, serializer);
- var json = JsonConvert.SerializeObject(payload, KnownDatabaseEntryTypes.SerializerSettings);
- return Encoding.UTF8.GetBytes(json);
+ var json = System.Text.Encoding.UTF8.GetString(data);
+ return JsonConvert.DeserializeObject(json)
+ ?? Array.Empty();
}
- ///
- public override DatabaseEntry Deserialize(byte[] data)
+ protected override byte[] SerializePayload(object document)
{
- _knownTypes.RegisterLoadedAssemblies();
-
- var json = Encoding.UTF8.GetString(data);
- var serializer = JsonSerializer.Create(KnownDatabaseEntryTypes.SerializerSettings);
- var payload = JsonConvert.DeserializeObject(json, KnownDatabaseEntryTypes.SerializerSettings)
- ?? throw new JsonSerializationException("Failed to deserialize DatabaseEntry envelope.");
- return _knownTypes.FromEnvelope(payload, serializer);
+ return CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType());
+ }
+
+ protected override object DeserializePayload(Type documentType, byte[] payload)
+ {
+ return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload);
}
- ///
public override string Extension => "json";
}
-
-internal sealed class KnownDatabaseEntryTypes
-{
- internal static readonly JsonSerializerSettings SerializerSettings = new()
- {
- Formatting = Formatting.Indented
- };
-
- private readonly ConcurrentDictionary _discriminatorToType = new(StringComparer.Ordinal);
- private readonly ConcurrentDictionary _typeToDiscriminator = new();
-
- public KnownDatabaseEntryTypes()
- {
- RegisterLoadedAssemblies();
- }
-
- public void RegisterType(Type type)
- {
- if (!typeof(DatabaseEntry).IsAssignableFrom(type) || type.IsAbstract)
- {
- throw new ArgumentException($"Type '{type.FullName}' must be a non-abstract DatabaseEntry subtype.", nameof(type));
- }
-
- var discriminator = GetDiscriminator(type);
- _discriminatorToType[discriminator] = type;
- _typeToDiscriminator[type] = discriminator;
- }
-
- public void RegisterAssembly(Assembly assembly)
- {
- foreach (var type in SafeGetTypes(assembly).Where(IsConcreteDatabaseEntry))
- {
- RegisterType(type);
- }
- }
-
- public void RegisterLoadedAssemblies()
- {
- foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Where(assembly => !assembly.IsDynamic))
- {
- RegisterAssembly(assembly);
- }
- }
-
- public DatabaseEntryEnvelope ToEnvelope(DatabaseEntry entry, JsonSerializer serializer)
- {
- var type = entry.GetType();
- if (!_typeToDiscriminator.TryGetValue(type, out var discriminator))
- {
- RegisterType(type);
- discriminator = _typeToDiscriminator[type];
- }
-
- return new DatabaseEntryEnvelope
- {
- Type = discriminator,
- Data = JObject.FromObject(entry, serializer)
- };
- }
-
- public DatabaseEntry FromEnvelope(DatabaseEntryEnvelope envelope, JsonSerializer serializer)
- {
- if (!_discriminatorToType.TryGetValue(envelope.Type, out var type))
- {
- throw new JsonSerializationException($"Unknown DatabaseEntry discriminator '{envelope.Type}'. Register the containing assembly or entry type before deserializing.");
- }
-
- return (DatabaseEntry?)envelope.Data.ToObject(type, serializer)
- ?? throw new JsonSerializationException($"Failed to deserialize DatabaseEntry discriminator '{envelope.Type}'.");
- }
-
- private static bool IsConcreteDatabaseEntry(Type type)
- {
- return typeof(DatabaseEntry).IsAssignableFrom(type) && !type.IsAbstract;
- }
-
- private static IEnumerable SafeGetTypes(Assembly assembly)
- {
- try
- {
- return assembly.GetTypes();
- }
- catch (ReflectionTypeLoadException ex)
- {
- return ex.Types.Where(type => type != null)!;
- }
- }
-
- private static string GetDiscriminator(Type type)
- {
- return $"{type.Assembly.GetName().Name}:{type.FullName}";
- }
-}
-
-internal sealed class DatabaseEntryEnvelope
-{
- public string Type { get; set; } = string.Empty;
-
- public JObject Data { get; set; } = new();
-}
diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs
index 78e885ea..f19c8a7c 100644
--- a/src/GameCult.Caching/CultCache.cs
+++ b/src/GameCult.Caching/CultCache.cs
@@ -4,1184 +4,1101 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
-using System.Threading;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
using System.Threading.Tasks;
-using ConcurrentCollections;
using GameCult.Logging;
using R3;
namespace GameCult.Caching
{
- ///
- /// Specifies that the decorated class contains global settings.
- /// Use this attribute to mark classes intended to hold application-wide configuration or settings.
- ///
- [AttributeUsage(AttributeTargets.Class)]
- public class GlobalSettingsAttribute : Attribute { }
-
- ///
- /// Represents an in-memory and persistent cache for objects, supporting global entries,
- /// named entries, generic field indexes, and multiple backing stores. Provides thread-safe operations for adding,
- /// retrieving, indexing, and removing entries, with support for event notifications and logging.
- ///
- ///
- ///
- /// Features:
- ///
- /// - Supports multiple s for persistence and synchronization.
- /// - Handles global settings entries via .
- /// - Provides fast lookup by ID, name (for ), and indexed fields.
- /// - Allows registering generic indexes for any public field of a type.
- /// - Thread-safe via and .
- /// - Notifies on entry updates via event.
- /// - Supports both synchronous and asynchronous add operations.
- /// - Integrates with for error and event logging.
- ///
- ///
- ///
- /// Usage:
- ///
- /// var cache = new CultCache();
- /// cache.RegisterIndex<PlayerData>("Email");
- /// cache.AddAsync(new PlayerData { ... }).GetAwaiter().GetResult();
- /// var player = cache.GetByIndex<PlayerData>("Email", "user@example.com");
- ///
- ///
- ///
- public class CultCache : IDisposable
+ public sealed class CultSchemaCatalogEntry
{
- private readonly object addLock = new object(); // Retained for synchronous Add compatibility
+ public string SchemaId { get; set; } = string.Empty;
+ public string SchemaName { get; set; } = string.Empty;
+ public string SchemaVersion { get; set; } = string.Empty;
+ public string ContentHash { get; set; } = string.Empty;
+ public string CanonicalSchemaJson { get; set; } = string.Empty;
+ public string[] CompatibleSchemaIds { get; set; } = Array.Empty();
+ }
- ///
- /// Occurs when a is updated.
- /// The event provides the old and new instances.
- ///
- ///
- /// The first parameter is the previous entry, and the second parameter is the updated entry.
- /// The first parameter will be null if the entry is newly added,
- /// and the second parameter will be null if the entry is deleted.
- ///
- public event Action? OnUpdate;
+ public sealed class CultPersistedRecord
+ {
+ public string Key { get; set; } = string.Empty;
+ public string SchemaId { get; set; } = string.Empty;
+ public string StoredAt { get; set; } = string.Empty;
+ public byte[] Payload { get; set; } = Array.Empty();
+ }
+ public sealed class CultPersistedStoreSnapshot
+ {
+ public string FormatVersion { get; set; } = "cultcache.store.v1";
+ public CultSchemaCatalogEntry[] SchemaCatalog { get; set; } = Array.Empty();
+ public CultPersistedRecord[] Records { get; set; } = Array.Empty();
+ }
+
+ public sealed class CultDocumentDescriptor
+ {
+ internal CultDocumentDescriptor(
+ Type documentType,
+ string schemaName,
+ string schemaVersion,
+ string schemaId,
+ string contentHash,
+ string canonicalSchemaJson,
+ bool isGlobal,
+ string? nameMember,
+ Func