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? nameAccessor, + IReadOnlyDictionary> indexAccessors, + IReadOnlyList members) + { + DocumentType = documentType; + SchemaName = schemaName; + SchemaVersion = schemaVersion; + SchemaId = schemaId; + ContentHash = contentHash; + CanonicalSchemaJson = canonicalSchemaJson; + IsGlobal = isGlobal; + NameMember = nameMember; + NameAccessor = nameAccessor; + IndexAccessors = indexAccessors; + Members = members; + } + + public Type DocumentType { get; } + public string SchemaName { get; } + public string SchemaVersion { get; } + public string SchemaId { get; } + public string ContentHash { get; } + public string CanonicalSchemaJson { get; } + public bool IsGlobal { get; } + public string? NameMember { get; } + internal Func? NameAccessor { get; } + internal IReadOnlyDictionary> IndexAccessors { get; } + internal IReadOnlyList Members { get; } + + public CultSchemaCatalogEntry ToCatalogEntry() + { + return new CultSchemaCatalogEntry + { + SchemaId = SchemaId, + SchemaName = SchemaName, + SchemaVersion = SchemaVersion, + ContentHash = ContentHash, + CanonicalSchemaJson = CanonicalSchemaJson, + CompatibleSchemaIds = [SchemaId] + }; + } + + internal string GetPreferredFileStem(object document) + { + var preferred = NameAccessor?.Invoke(document); + return string.IsNullOrWhiteSpace(preferred) + ? SchemaName + : preferred!; + } + } + + internal sealed class CultDocumentMemberDescriptor + { + public string MemberName { get; set; } = string.Empty; + public int Slot { get; set; } + public string TypeName { get; set; } = string.Empty; + public bool IsReference { get; set; } + public bool IsMany { get; set; } + public string? TargetSchemaName { get; set; } + } + + public sealed class CultStoredDocument + { + public CultStoredDocument( + CultRecordKey key, + string storedAt, + CultDocumentDescriptor descriptor, + object document) + { + Key = key; + StoredAt = storedAt; + Descriptor = descriptor; + Document = document; + } + + public CultRecordKey Key { get; } + public string StoredAt { get; } + public CultDocumentDescriptor Descriptor { get; } + public object Document { get; } + } + + public sealed class CultDocumentRegistry + { + private static readonly Lazy SharedRegistry = + new(() => new CultDocumentRegistry()); + + private readonly ConcurrentDictionary _byType = new(); + private readonly ConcurrentDictionary _bySchemaId = + new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _bySchemaName = + new(StringComparer.Ordinal); + + public static CultDocumentRegistry Shared => SharedRegistry.Value; + + public CultDocumentRegistry() + { + Refresh(); + } + + public IEnumerable AllDescriptors => _byType.Values.OrderBy(d => d.SchemaName, StringComparer.Ordinal); + + public void Refresh() + { + _byType.Clear(); + _bySchemaId.Clear(); + _bySchemaName.Clear(); + + foreach (var type in ReflectionExtensions.GetAttributedDocumentTypes()) + { + var descriptor = BuildDescriptor(type); + _byType[type] = descriptor; + _bySchemaId[descriptor.SchemaId] = descriptor; + _bySchemaName[descriptor.SchemaName] = descriptor; + } + } + + public CultDocumentDescriptor GetRequired(Type type) + { + if (_byType.TryGetValue(type, out var descriptor)) + { + return descriptor; + } + + var attribute = type.GetCustomAttribute(); + if (attribute == null) + { + throw new InvalidOperationException( + $"Type {type.FullName} is not marked with {nameof(CultDocumentAttribute)}."); + } + + descriptor = BuildDescriptor(type); + _byType[type] = descriptor; + _bySchemaId[descriptor.SchemaId] = descriptor; + _bySchemaName[descriptor.SchemaName] = descriptor; + return descriptor; + } + + public CultDocumentDescriptor GetRequired() where T : class + { + return GetRequired(typeof(T)); + } + + public CultDocumentDescriptor GetRequiredBySchemaId(string schemaId) + { + if (_bySchemaId.TryGetValue(schemaId, out var descriptor)) + { + return descriptor; + } + + throw new InvalidOperationException($"Unknown CultCache schema id '{schemaId}'."); + } + + public CultDocumentDescriptor ResolvePersistedSchema(string schemaId, IReadOnlyCollection catalog) + { + if (_bySchemaId.TryGetValue(schemaId, out var exact)) + { + return exact; + } + + var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal)); + if (persisted == null) + { + throw new InvalidOperationException($"Persisted schema '{schemaId}' is not present in the embedded catalog."); + } + + if (_bySchemaName.TryGetValue(persisted.SchemaName, out var local)) + { + return local; + } + + throw new InvalidOperationException( + $"No local CultCache schema matches persisted schema '{persisted.SchemaName}' ({schemaId})."); + } + + private static CultDocumentDescriptor BuildDescriptor(Type type) + { + var attribute = type.GetCustomAttribute() + ?? throw new InvalidOperationException( + $"Type {type.FullName} is not marked with {nameof(CultDocumentAttribute)}."); + var members = DiscoverMembers(type); + var nameMember = members.FirstOrDefault(member => member.IsName); + var indexAccessors = members + .Where(member => member.IndexAlias != null) + .ToDictionary( + member => member.IndexAlias!, + member => member.Getter, + StringComparer.Ordinal); + + var schemaJson = BuildCanonicalSchemaJson(attribute, members); + var contentHash = Sha256(schemaJson); + var semanticFingerprint = BuildSemanticFingerprint(attribute, members); + var schemaId = Sha256(semanticFingerprint); + + return new CultDocumentDescriptor( + type, + attribute.SchemaName, + attribute.SchemaVersion, + schemaId, + contentHash, + schemaJson, + type.GetCustomAttribute() != null, + nameMember?.Member.Name, + nameMember?.GetterNullable, + indexAccessors, + members.Select(member => new CultDocumentMemberDescriptor + { + MemberName = member.Member.Name, + Slot = member.Slot, + TypeName = member.MemberType.FullName ?? member.MemberType.Name, + IsReference = member.IsReference, + IsMany = member.IsMany, + TargetSchemaName = member.TargetSchemaName + }).ToArray()); + } + + private static string BuildSemanticFingerprint( + CultDocumentAttribute attribute, + IReadOnlyList members) + { + var builder = new StringBuilder(); + builder.Append(attribute.SchemaName) + .Append('|') + .Append(attribute.SchemaVersion); + foreach (var member in members.OrderBy(member => member.Slot)) + { + builder.Append('|') + .Append(member.Slot) + .Append(':') + .Append(member.Member.Name) + .Append(':') + .Append(member.MemberType.FullName ?? member.MemberType.Name) + .Append(':') + .Append(member.IsReference ? "ref" : "value") + .Append(':') + .Append(member.TargetSchemaName ?? string.Empty) + .Append(':') + .Append(member.IsMany ? "many" : "one"); + } + + return builder.ToString(); + } + + private static string BuildCanonicalSchemaJson( + CultDocumentAttribute attribute, + IReadOnlyList members) + { + var payload = new + { + schemaName = attribute.SchemaName, + schemaVersion = attribute.SchemaVersion, + members = members + .OrderBy(member => member.Slot) + .Select(member => new + { + slot = member.Slot, + name = member.Member.Name, + type = member.MemberType.FullName ?? member.MemberType.Name, + isReference = member.IsReference, + many = member.IsMany, + targetSchemaName = member.TargetSchemaName, + indexAlias = member.IndexAlias, + isName = member.IsName + }) + .ToArray() + }; + + var builder = new StringBuilder(); + builder.Append("{\"schemaName\":\"") + .Append(attribute.SchemaName) + .Append("\",\"schemaVersion\":\"") + .Append(attribute.SchemaVersion) + .Append("\",\"members\":["); + for (var index = 0; index < members.Count; index++) + { + var member = members[index]; + if (index > 0) + { + builder.Append(','); + } + + builder.Append("{\"slot\":") + .Append(member.Slot) + .Append(",\"name\":\"") + .Append(member.Member.Name) + .Append("\",\"type\":\"") + .Append(member.MemberType.FullName ?? member.MemberType.Name) + .Append("\",\"isReference\":") + .Append(member.IsReference ? "true" : "false") + .Append(",\"many\":") + .Append(member.IsMany ? "true" : "false") + .Append(",\"targetSchemaName\":"); + if (member.TargetSchemaName == null) + { + builder.Append("null"); + } + else + { + builder.Append('"').Append(member.TargetSchemaName).Append('"'); + } + + builder.Append(",\"indexAlias\":"); + if (member.IndexAlias == null) + { + builder.Append("null"); + } + else + { + builder.Append('"').Append(member.IndexAlias).Append('"'); + } + + builder.Append(",\"isName\":") + .Append(member.IsName ? "true" : "false") + .Append('}'); + } + + builder.Append("]}"); + return builder.ToString(); + } + + private static IReadOnlyList DiscoverMembers(Type type) + { + var members = new List(); + + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public)) + { + if (IsIgnored(field)) + { + continue; + } + + members.Add(PersistedMember.FromMember(field, field.FieldType, value => field.GetValue(value), GetKeyValue(field))); + } + + foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (property.GetMethod == null || property.SetMethod == null || IsIgnored(property)) + { + continue; + } + + members.Add(PersistedMember.FromMember(property, property.PropertyType, value => property.GetValue(value), GetKeyValue(property))); + } + + var explicitMembers = members.Where(member => member.ExplicitSlot.HasValue).OrderBy(member => member.ExplicitSlot.Value).ToArray(); + var implicitMembers = members.Where(member => !member.ExplicitSlot.HasValue).OrderBy(member => member.MetadataToken).ToArray(); + var assigned = new List(members.Count); + var nextSlot = explicitMembers.Length == 0 ? 0 : explicitMembers.Max(member => member.ExplicitSlot.Value) + 1; + + foreach (var member in explicitMembers) + { + member.Slot = member.ExplicitSlot.Value; + assigned.Add(member); + } + + foreach (var member in implicitMembers) + { + member.Slot = nextSlot++; + assigned.Add(member); + } + + return assigned.OrderBy(member => member.Slot).ToArray(); + } + + private static bool IsIgnored(MemberInfo member) + { + return member.GetCustomAttributes().Any(attribute => + { + var name = attribute.GetType().FullName; + return name == "MessagePack.IgnoreMemberAttribute"; + }); + } + + private static int? GetKeyValue(MemberInfo member) + { + foreach (var attribute in member.GetCustomAttributes()) + { + var name = attribute.GetType().FullName; + if (name == "MessagePack.KeyAttribute") + { + var property = attribute.GetType().GetProperty("IntKey"); + if (property?.GetValue(attribute) is int intKey) + { + return intKey; + } + + var ctorArg = attribute.GetType().GetProperty("StringKey"); + if (ctorArg?.GetValue(attribute) is string) + { + return null; + } + } + } + + return null; + } + + private static string Sha256(string input) + { + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input)); + return "sha256:" + BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant(); + } + + private sealed class PersistedMember + { + public MemberInfo Member { get; set; } = default!; + public Type MemberType { get; set; } = default!; + public int MetadataToken { get; set; } + public int? ExplicitSlot { get; set; } + public int Slot { get; set; } + public bool IsName { get; set; } + public string? IndexAlias { get; set; } + public bool IsReference { get; set; } + public bool IsMany { get; set; } + public string? TargetSchemaName { get; set; } + public Func Getter { get; set; } = default!; + public Func GetterNullable { get; set; } = default!; + + public static PersistedMember FromMember( + MemberInfo member, + Type memberType, + Func getValue, + int? explicitSlot) + { + var referenceAttribute = member.GetCustomAttribute(); + var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType); + var targetSchemaName = targetType?.GetCustomAttribute()?.SchemaName; + return new PersistedMember + { + Member = member, + MemberType = memberType, + MetadataToken = member.MetadataToken, + ExplicitSlot = explicitSlot, + Slot = explicitSlot ?? -1, + IsName = member.GetCustomAttribute() != null, + IndexAlias = ResolveIndexAlias(member), + IsReference = referenceAttribute != null, + IsMany = referenceAttribute?.Many ?? false, + TargetSchemaName = targetSchemaName, + Getter = document => getValue(document)?.ToString() ?? string.Empty, + GetterNullable = document => getValue(document)?.ToString() + }; + } + + private static string? ResolveIndexAlias(MemberInfo member) + { + var indexAttribute = member.GetCustomAttribute(); + if (indexAttribute == null) + { + return null; + } + + return string.IsNullOrWhiteSpace(indexAttribute.Alias) + ? member.Name + : indexAttribute.Alias; + } + + private static Type? ResolveReferenceTarget(Type memberType, Type? explicitTarget) + { + if (explicitTarget != null) + { + return explicitTarget; + } + + if (memberType.IsGenericType && + memberType.GetGenericTypeDefinition() == typeof(CultRecordRef<>)) + { + return memberType.GetGenericArguments()[0]; + } + + return null; + } + } + } + + public sealed class CultCache : IDisposable + { + private readonly CultDocumentRegistry _registry; private readonly List _backingStores = new(); - private readonly ConcurrentDictionary _typeStores = new(); - private readonly ConcurrentDictionary _storeTypes = new(); - private readonly ConcurrentDictionary _entries = new(); - private readonly ConcurrentDictionary _globals = new(); - private readonly ConcurrentDictionary> _types = new(); - private readonly ConcurrentDictionary> _nameToIdMap = new(); - private readonly ConcurrentDictionary _pendingStoreOperations = new(); - // Generic indexes: Map (Type, FieldName) -> (Value -> ID). Allows registering indexes for any field - private readonly ConcurrentDictionary<(Type Type, string FieldName), ConcurrentDictionary> _indexes = new(); - // Cache getters per type (list of (field, getter) for that type only). Avoids scanning all indexes. - private readonly ConcurrentDictionary Getter)>> _typeToGetters = new(); + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> _nameMaps = new(); + private readonly ConcurrentDictionary<(Type Type, string Alias), ConcurrentDictionary> _indexMaps = new(); + private readonly ConcurrentDictionary _globalKeys = new(); + private readonly ConditionalWeakTable _documentHandles = new(); private ILogger _logger = new NullLogger(); - private long _storeOperationId; - - /// - /// Gets or sets the instance used for logging within the cache. - /// If set to null, a will be used as a fallback. - /// + + public CultCache(CultDocumentRegistry? registry = null) + { + _registry = registry ?? CultDocumentRegistry.Shared; + InitializeGlobals(); + } + public ILogger Logger { get => _logger; set => _logger = value ?? new NullLogger(); } - /// - /// Gets all objects currently stored in the cache. - /// - public IEnumerable AllEntries => _entries.Values; + public event Action? OnUpdate; - /// - /// Initializes a new instance of the class. - /// Sets up the cache by registering all child classes of . - /// For each child type: - /// - /// Adds a concurrent hash set for caching entries of that type. - /// If the type is marked with , adds it to the global settings and creates an instance. - /// If the type implements , initializes a name-to-ID mapping dictionary. - /// - /// Also sets the static cache reference in . - /// - public CultCache() + public IEnumerable AllEntries => _entries.Values.Select(entry => entry.Document); + + public CultDocumentRegistry Registry => _registry; + + public void AddBackingStore(CacheBackingStore store) { - DatabaseLinkBase.Cache = this; - - foreach (var type in typeof(DatabaseEntry).GetAllChildClasses()) - { - _types.TryAdd(type, new ConcurrentHashSet()); - if (type.GetCustomAttribute() != null) - { - _globals.TryAdd(type, null); - try - { - var global = (DatabaseEntry)Activator.CreateInstance(type)!; - AddAsync(global).GetAwaiter().GetResult(); - _globals[type] = global; // Update after successful add - } - catch (Exception ex) - { - Logger.LogError($"Failed to initialize global {type.Name}: {ex.Message}"); - _globals[type] = null; - } - } - // Initialize name-to-ID map for INamedEntry types - if (typeof(INamedEntry).IsAssignableFrom(type)) - { - _nameToIdMap.TryAdd(type, new ConcurrentDictionary()); - } - } - } - - /// - /// Adds a backing store to the cache system and configures domain type associations and event subscriptions. - /// - /// The instance to add. - /// - /// Optional array of objects representing domain types associated with the backing store. - /// If provided, the store is mapped to these types; otherwise, the store is added to the general backing store list and subscribes to existing stores. - /// - /// - /// - /// If no domain types are specified, the store subscribes to all existing backing stores to receive their updates, - /// ensuring synchronization across stores, with the first store added acting as the primary source of truth. - /// - /// - /// Specifying domain types allows for targeted storage and retrieval of specific types, - /// for example, using a database store for user data and a file-based store for configuration data or game assets. - /// - /// - public void AddBackingStore(CacheBackingStore store, params Type[] domain) - { - if (domain.Length > 0) - { - _storeTypes.TryAdd(store, domain); - foreach (var t in domain) _typeStores.TryAdd(t, store); - } - else - { - foreach (var existingStore in _backingStores) store.SubscribeTo(existingStore); - _backingStores.Add(store); - } - store.EntryAdded.Subscribe(entry => TrackStoreOperation(async () => - { - try // Try-catch logs without swallowing. Added to EntryDeleted for consistency (even though sync) - { - await AddAsync(entry, store); - OnUpdate?.Invoke(null, entry); - } - catch (Exception ex) - { - Logger.LogError($"EntryAdded processing failed for {entry?.ID}: {ex.Message}"); - } - })); - store.EntryUpdated.Subscribe(entry => TrackStoreOperation(async () => - { - try - { - await AddAsync(entry, store); - OnUpdate?.Invoke(entry, entry); - } - catch (Exception ex) - { - Logger.LogError($"EntryUpdated processing failed for {entry?.ID}: {ex.Message}"); - } - })); - store.EntryDeleted.Subscribe(entry => TrackStoreOperation(() => - { - try - { - Remove(entry, store); - OnUpdate?.Invoke(entry, null); - } - catch (Exception ex) - { - Logger.LogError($"EntryDeleted processing failed for {entry?.ID}: {ex.Message}"); - } - - return Task.CompletedTask; - })); + if (store == null) throw new ArgumentNullException(nameof(store)); + store.AttachRegistry(_registry); store.Logger = Logger; + store.EntryAdded.Subscribe(entry => AddStoredDocumentInternal(entry, store, raiseUpdate: true).GetAwaiter().GetResult()); + store.EntryUpdated.Subscribe(entry => AddStoredDocumentInternal(entry, store, raiseUpdate: true).GetAwaiter().GetResult()); + store.EntryDeleted.Subscribe(entry => RemoveStoredDocumentInternal(entry, store, raiseUpdate: true)); + _backingStores.Add(store); } - - /// - /// Asynchronously pulls all data from the backing stores. - /// Initiates parallel tasks to execute the PullAll method on each backing store and store type, - /// and waits for all tasks to complete. - /// - /// A task that represents the asynchronous operation. + public async Task PullAllBackingStoresAsync() { - var tasks = _backingStores.Select(store => Task.Run(store.PullAll)) - .Concat(_storeTypes.Keys.Select(store => Task.Run(store.PullAll))) - .ToArray(); - await Task.WhenAll(tasks); - await WaitForPendingStoreOperationsAsync(); - } - - /// - /// Adds a to the cache asynchronously, updating all relevant maps and indexes. - /// If the entry already exists, cleans up old mappings before updating. - /// Also persists the entry to the backing store if available. - /// - /// The to add or update in the cache. - /// - /// The source from which the entry originated, used internally to avoid redundant writes. - /// Optional; defaults to null. - /// - /// A representing the asynchronous operation. - public async Task AddAsync(DatabaseEntry entry, CacheBackingStore? source = null) - { - if (entry == null) return; - - var type = entry.GetType(); - - try + foreach (var store in _backingStores) { - if (_typeStores.TryGetValue(type, out var store)) - { - if (store != source) - await Task.Run(() => store.Push(entry)); - } - else if (_backingStores.Any() && _backingStores.First() != source) - { - await Task.Run(() => _backingStores.First().Push(entry)); - } - } - catch (Exception ex) - { - Logger.LogError($"Failed to persist entry {entry.ID}: {ex.Message}"); - throw; - } - - // Pre-fetch existing to detect updates (for map/index cleanup) - if (!_entries.TryGetValue(entry.ID, out var existing)) existing = null; - - // Cleanup old maps/indexes BEFORE updating _entries - if (existing != null) - { - // For name map (if updating existing) - if (existing is INamedEntry existingNamed && _nameToIdMap.TryGetValue(type, out var nameMap)) - { - nameMap.TryRemove(existingNamed.EntryName, out _); - } - - // For generic indexes (if registered) - if (_typeToGetters.TryGetValue(type, out var getters)) - { - foreach (var (fieldName, getter) in getters) - { - var oldValue = getter(existing); - if (!string.IsNullOrEmpty(oldValue)) - { - var indexKey = (type, fieldName); - _indexes[indexKey].TryRemove(oldValue, out _); - } - } - } - - // For type sets - _types[existing.GetType()].TryRemove(existing); - foreach (var parentType in existing.GetType().GetParentTypes()) - { - if (_types.TryGetValue(parentType, out var typeSet)) - typeSet.TryRemove(existing); - } - } - - _entries[entry.ID] = entry; - _types[type].Add(entry); // Assumes exists; throws if not (edge case) - foreach (var parentType in type.GetParentTypes()) - { - if (_types.TryGetValue(parentType, out var typeSet)) - typeSet.Add(entry); - } - - if (_globals.ContainsKey(type)) - { - _globals.AddOrUpdate(type, entry, (_, old) => entry); - } - - // Add new values (always, for both new and updates) - // Update name-to-ID map if INamedEntry - if (entry is INamedEntry namedEntry && _nameToIdMap.TryGetValue(type, out var nameMap2)) - { - nameMap2[namedEntry.EntryName] = entry.ID; - } - - // Update generic indexes if registered for this type - if (_typeToGetters.TryGetValue(type, out var getters2)) - { - foreach (var (fieldName, getter) in getters2) - { - var value = getter(entry); - if (!string.IsNullOrEmpty(value)) - { - var indexKey = (type, fieldName); - _indexes[indexKey][value] = entry.ID; - } - } + store.PullAll(); } + await Task.CompletedTask; } - /// - /// Determines whether the specified is a global settings entry. - /// - /// The to check. - /// true if the entry is global; otherwise, false. - public bool IsGlobal(DatabaseEntry entry) => _globals.ContainsKey(entry.GetType()); - - /// - /// Adds all objects from the specified collection to the cache asynchronously. - /// - /// The collection of objects to add. - /// - /// The source from which the entries originated, used to avoid redundant writes. - /// Optional; defaults to null. - /// - /// A representing the asynchronous operation. - public Task AddAllAsync(IEnumerable entries, CacheBackingStore? source = null) + public async Task> AddAsync(T document, CultRecordHandle? handle = null) { - var tasks = entries.Select(entry => AddAsync(entry, source)).ToArray(); - return Task.WhenAll(tasks); + if (document == null) throw new ArgumentNullException(nameof(document)); + var stored = await AddStoredDocumentInternal( + CreateStoredDocument(typeof(T), document, handle?.Key), + source: null, + raiseUpdate: false); + return new CultRecordHandle(stored.Key); } - /// - /// Retrieves a from the cache by its unique identifier. - /// - /// The unique identifier of the entry to retrieve. - /// The associated with the specified , or null if not found. - public DatabaseEntry Get(Guid guid) + public CultRecordHandle? TryGetHandle(T document) { - _entries.TryGetValue(guid, out var entry); - return entry; + if (document == null) throw new ArgumentNullException(nameof(document)); + return _documentHandles.TryGetValue(document, out var box) + ? new CultRecordHandle(box.Key) + : null; } - /// - /// Retrieves a of type from the cache by its unique identifier. - /// - /// The type of to retrieve. - /// The unique identifier of the entry to retrieve. - /// The entry of type associated with the specified , or null if not found or not of type . - public T? Get(Guid guid) where T : DatabaseEntry + public object? Get(CultRecordKey key) { - return Get(guid) as T; + return _entries.TryGetValue(key.Value, out var stored) + ? stored.Document + : null; } - /// - /// Retrieves the global instance for the specified type, if it exists. - /// - /// The type of the global entry to retrieve. - /// The global instance associated with the specified type, or null if not found. - public DatabaseEntry? GetGlobal(Type type) + public T? Get(CultRecordKey key) where T : class { - _globals.TryGetValue(type, out var entry); - return entry; + return Get(key) as T; } - /// - /// Retrieves the global instance of type , if it exists. - /// - /// The type of the global entry to retrieve. - /// The global entry of type , or null if not found. - public T? GetGlobal() where T : DatabaseEntry - { - return GetGlobal(typeof(T)) as T; - } - - /// - /// Retrieves all objects of the specified type from the cache. - /// - /// The type of to retrieve. - /// An enumerable collection of objects of the specified type. - public IEnumerable GetAll(Type type) - { - return _types.TryGetValue(type, out var entries) ? entries : Enumerable.Empty(); - } - - /// - /// Retrieves all objects of type from the cache. - /// - /// The type of to retrieve. - /// An enumerable collection of objects from the cache. - public IEnumerable GetAll() where T : DatabaseEntry - { - return _types.TryGetValue(typeof(T), out var entries) ? entries.Cast() : Enumerable.Empty(); - } - - /// - /// Retrieves the unique identifier () of an entry of type by its name. - /// - /// The type of that implements . - /// The name of the entry to look up. - /// The of the entry if found; otherwise, null. - public Guid? GetIdByName(string name) where T : DatabaseEntry, INamedEntry + public IEnumerable GetAll() where T : class { var type = typeof(T); - if (_nameToIdMap.TryGetValue(type, out var nameMap) && nameMap.TryGetValue(name, out var id)) - return id; + return _entries.Values + .Where(entry => type.IsAssignableFrom(entry.Descriptor.DocumentType)) + .Select(entry => (T)entry.Document); + } + + public T? GetGlobal() where T : class + { + return _globalKeys.TryGetValue(typeof(T), out var key) + ? Get(new CultRecordKey(key)) + : null; + } + + public T? GetByName(string name) where T : class + { + var type = typeof(T); + if (_nameMaps.TryGetValue(type, out var map) && + map.TryGetValue(name, out var key)) + { + return Get(new CultRecordKey(key)); + } + return null; } - /// - /// Retrieves an entry of type by its name from the cache. - /// - /// The type of that implements . - /// The name of the entry to retrieve. - /// The entry of type if found; otherwise, null. - public T? GetByName(string name) where T : DatabaseEntry, INamedEntry + public T? GetByIndex(string alias, string value) where T : class { - return GetIdByName(name) is { } id ? Get(id) : null; - } - - /// - /// Registers an index for a public member (field or property) of the specified type. - /// This enables fast lookup of entries by the value of the indexed member. - /// Call this method once per member to be indexed. - /// - /// The type of for which to register the index. - /// The name of the public member to index. - /// Thrown if the specified member does not exist on the type. - public void RegisterIndex(string memberName) where T : DatabaseEntry - { - var type = typeof(T); - var key = (type, memberName); - if (_indexes.ContainsKey(key)) - return; // Already registered; ignore - - _indexes[key] = new ConcurrentDictionary(); - - // Cache a getter func for perf. - if (type.GetField(memberName) is { } field) + if (_indexMaps.TryGetValue((typeof(T), alias), out var map) && + map.TryGetValue(value, out var key)) { - var getter = (Func)(entry => field.GetValue(entry)?.ToString() ?? string.Empty); - _typeToGetters.AddOrUpdate(type, - _ => new List<(string, Func)> { (memberName, getter) }, - (_, list) => { list.Add((memberName, getter)); return list; }); + return Get(new CultRecordKey(key)); } - else if (type.GetProperty(memberName) is { } property) - { - var getter = (Func)(entry => property.GetValue(entry)?.ToString() ?? string.Empty); - _typeToGetters.AddOrUpdate(type, - _ => new List<(string, Func)> { (memberName, getter) }, - (_, list) => { list.Add((memberName, getter)); return list; }); - } - else - { - throw new ArgumentException($"No public field or property '{memberName}' on {type.Name}"); - } - } - /// - /// Retrieves the unique identifier () of an entry of type by the value of an indexed field. - /// - /// The type of for which the index is registered. - /// The name of the indexed field. - /// The value of the indexed field to look up. - /// The of the entry if found; otherwise, null. - public Guid? GetIdByIndex(string fieldName, string value) where T : DatabaseEntry - { - var key = (typeof(T), fieldName); - if (_indexes.TryGetValue(key, out var indexMap) && indexMap.TryGetValue(value, out var id)) - return id; return null; } - /// - /// Retrieves an entry of type by the value of an indexed field from the cache. - /// - /// The type of for which the index is registered. - /// The name of the indexed field. - /// The value of the indexed field to look up. - /// The entry of type if found; otherwise, null. - public T? GetByIndex(string fieldName, string value) where T : DatabaseEntry + public T? Resolve(CultRecordRef reference) where T : class { - return GetIdByIndex(fieldName, value) is { } id ? Get(id) : null; + return Get(reference.Key); } - - /// - /// Removes the specified from the cache, including all associated indexes, type stores, - /// global references, parent type sets, and name-to-ID mappings. - /// - /// The to remove from the cache. - /// - /// The to exclude from deletion. Used internally to avoid redundant deletions when the entry originates from a specific store. - /// - public void Remove(DatabaseEntry entry, CacheBackingStore? source = null) + + public void Remove(CultRecordHandle handle) { - var type = entry.GetType(); - if (!_entries.TryRemove(entry.ID, out _)) return; - - // Clean up type sets - _types[type].TryRemove(entry); - foreach (var parentType in type.GetParentTypes()) + if (_entries.TryGetValue(handle.Key.Value, out var stored)) { - if (_types.TryGetValue(parentType, out var typeSet)) - typeSet.TryRemove(entry); - } - - // Clean up global if applicable - if (_globals.TryGetValue(type, out var global) && global != null && global.Equals(entry)) _globals[type] = null!; - - // Propagate delete to backing stores (except source) - if (_typeStores.TryGetValue(type, out var typeStore)) - typeStore.Delete(entry); - else - { - foreach (var store in _backingStores) - { - if (store != source) - store.Delete(entry); - } - } - - // Clean up name-to-ID map if INamedEntry - if (entry is INamedEntry namedEntry && _nameToIdMap.TryGetValue(type, out var nameMap)) - { - nameMap.TryRemove(namedEntry.EntryName, out _); - } - - // Clean up generic indexes if registered for this type - if (_typeToGetters.TryGetValue(type, out var getters)) - { - foreach (var (fieldName, getter) in getters) - { - var value = getter(entry); - if (!string.IsNullOrEmpty(value)) - { - var indexKey = (type, fieldName); - _indexes[indexKey].TryRemove(value, out _); - } - } + RemoveStoredDocumentInternal(stored, source: null, raiseUpdate: false); } } - - /// + public void Dispose() { - foreach (var store in _backingStores) - if (store is IDisposable d) d.Dispose(); - foreach (var store in _storeTypes.Keys) - if (store is IDisposable d) d.Dispose(); - } - - private void TrackStoreOperation(Func operationFactory) - { - var operationId = Interlocked.Increment(ref _storeOperationId); - var operationTask = Task.Run(operationFactory); - _pendingStoreOperations[operationId] = operationTask; - - _ = operationTask.ContinueWith( - (_, state) => _pendingStoreOperations.TryRemove((long)state!, out _), - operationId, - TaskScheduler.Default); - } - - private async Task WaitForPendingStoreOperationsAsync() - { - while (true) + foreach (var store in _backingStores.OfType()) { - var pendingOperations = _pendingStoreOperations.Values.ToArray(); - if (pendingOperations.Length == 0) - { - return; - } - - await Task.WhenAll(pendingOperations); + store.Dispose(); } } + + internal CultStoredDocument CreateStoredDocument(Type documentType, object document, CultRecordKey? key = null, string? storedAt = null) + { + var descriptor = _registry.GetRequired(documentType); + var resolvedKey = key ?? ResolveKey(document, descriptor); + return new CultStoredDocument( + resolvedKey, + storedAt ?? DateTimeOffset.UtcNow.ToString("O"), + descriptor, + document); + } + + private async Task AddStoredDocumentInternal( + CultStoredDocument stored, + CacheBackingStore? source, + bool raiseUpdate) + { + CultStoredDocument? existing = null; + _entries.TryGetValue(stored.Key.Value, out existing); + if (existing != null) + { + RemoveIndexes(existing); + } + + _entries[stored.Key.Value] = stored; + _documentHandles.Remove(stored.Document); + _documentHandles.Add(stored.Document, new DocumentHandleBox(stored.Key)); + AddIndexes(stored); + + foreach (var store in _backingStores) + { + if (store != source) + { + store.Push(stored); + } + } + + if (raiseUpdate) + { + OnUpdate?.Invoke(existing?.Document, stored.Document); + } + + await Task.CompletedTask; + return stored; + } + + private void RemoveStoredDocumentInternal( + CultStoredDocument stored, + CacheBackingStore? source, + bool raiseUpdate) + { + if (!_entries.TryRemove(stored.Key.Value, out var existing)) + { + return; + } + + RemoveIndexes(existing); + _documentHandles.Remove(existing.Document); + + foreach (var store in _backingStores) + { + if (store != source) + { + store.Delete(existing); + } + } + + if (raiseUpdate) + { + OnUpdate?.Invoke(existing.Document, null); + } + } + + private void InitializeGlobals() + { + foreach (var descriptor in _registry.AllDescriptors.Where(candidate => candidate.IsGlobal)) + { + if (descriptor.DocumentType.GetConstructor(Type.EmptyTypes) == null) + { + continue; + } + + var instance = Activator.CreateInstance(descriptor.DocumentType); + if (instance == null) + { + continue; + } + + AddStoredDocumentInternal( + new CultStoredDocument( + new CultRecordKey($"global:{descriptor.SchemaId}"), + DateTimeOffset.UtcNow.ToString("O"), + descriptor, + instance), + source: null, + raiseUpdate: false).GetAwaiter().GetResult(); + } + } + + private CultRecordKey ResolveKey(object document, CultDocumentDescriptor descriptor) + { + if (_documentHandles.TryGetValue(document, out var existing)) + { + return existing.Key; + } + + if (descriptor.IsGlobal) + { + return new CultRecordKey($"global:{descriptor.SchemaId}"); + } + + return new CultRecordKey(Guid.NewGuid().ToString("N")); + } + + private void AddIndexes(CultStoredDocument stored) + { + if (stored.Descriptor.IsGlobal) + { + _globalKeys[stored.Descriptor.DocumentType] = stored.Key.Value; + } + + if (stored.Descriptor.NameAccessor?.Invoke(stored.Document) is { Length: > 0 } name) + { + var map = _nameMaps.GetOrAdd( + stored.Descriptor.DocumentType, + _ => new ConcurrentDictionary(StringComparer.Ordinal)); + map[name] = stored.Key.Value; + } + + foreach (var pair in stored.Descriptor.IndexAccessors) + { + var value = pair.Value(stored.Document); + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + var map = _indexMaps.GetOrAdd( + (stored.Descriptor.DocumentType, pair.Key), + _ => new ConcurrentDictionary(StringComparer.Ordinal)); + map[value] = stored.Key.Value; + } + } + + private void RemoveIndexes(CultStoredDocument stored) + { + if (stored.Descriptor.IsGlobal) + { + _globalKeys.TryRemove(stored.Descriptor.DocumentType, out _); + } + + if (stored.Descriptor.NameAccessor?.Invoke(stored.Document) is { Length: > 0 } name && + _nameMaps.TryGetValue(stored.Descriptor.DocumentType, out var nameMap)) + { + nameMap.TryRemove(name, out _); + } + + foreach (var pair in stored.Descriptor.IndexAccessors) + { + var value = pair.Value(stored.Document); + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + if (_indexMaps.TryGetValue((stored.Descriptor.DocumentType, pair.Key), out var map)) + { + map.TryRemove(value, out _); + } + } + } + + private sealed class DocumentHandleBox + { + public DocumentHandleBox(CultRecordKey key) + { + Key = key; + } + + public CultRecordKey Key { get; } + } } - /// - /// Represents a backing store that supports observing real-time changes. - /// public interface IRealtimeBackingStore { - /// - /// Begins observing real-time changes in the backing store and triggers appropriate events when changes occur. - /// - public void ObserveChanges(); + void ObserveChanges(); } - /// - /// Represents an abstract backing store for caching objects. - /// Provides mechanisms for pulling, pushing, deleting, and synchronizing entries, as well as event notifications for entry changes. - /// public abstract class CacheBackingStore : IDisposable { + private CultDocumentRegistry? _registry; private ILogger _logger = new NullLogger(); - - /// - /// Gets or sets the instance used for logging within the cache. - /// If set to null, a will be used as a fallback. - /// + + protected CultDocumentRegistry Registry => + _registry ?? throw new InvalidOperationException("Backing store is not attached to a CultDocumentRegistry."); + + protected ConcurrentDictionary Entries { get; } = + new(StringComparer.Ordinal); + public ILogger Logger { get => _logger; set => _logger = value ?? new NullLogger(); } - - /// - /// Initializes a new instance of the class. - /// Sets up subjects for entry added, deleted, and updated events. - /// - protected CacheBackingStore() + + public Subject EntryAdded { get; } = new(); + public Subject EntryUpdated { get; } = new(); + public Subject EntryDeleted { get; } = new(); + + internal void AttachRegistry(CultDocumentRegistry registry) { - EntryAdded = new Subject(); - EntryDeleted = new Subject(); - EntryUpdated = new Subject(); + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); } - /// - /// Pulls all entries from the backing store into the cache. - /// Implementations should load all persisted objects and notify via . - /// public abstract void PullAll(); - /// - /// Persists the specified to the backing store. - /// Implementations should save or update the entry in the underlying storage. - /// - /// The to persist. - public abstract void Push(DatabaseEntry entry); - /// - /// Deletes the specified from the backing store. - /// Implementations should remove the entry from the underlying storage. - /// - /// The to delete. - public abstract void Delete(DatabaseEntry entry); - /// - /// Persists all objects to the backing store. - /// Implementations should save or update all entries in the underlying storage. - /// - /// - /// If true, performs a soft push (implementation-specific); otherwise, performs a full push. - /// + public abstract void Push(CultStoredDocument entry); + public abstract void Delete(CultStoredDocument entry); public abstract void PushAll(bool soft = false); - - /// - /// Gets the subject that notifies when a is added to the backing store. - /// - public Subject EntryAdded { get; } - /// - /// Gets the subject that notifies when a is deleted from the backing store. - /// - public Subject EntryDeleted { get; } - /// - /// Gets the subject that notifies when a is updated in the backing store. - /// - public Subject EntryUpdated { get; } - - /// - /// Stores all objects managed by this backing store, keyed by their unique identifier. - /// - protected ConcurrentDictionary Entries = new(); - - /// - /// Subscribes this backing store to the entry events of another , - /// so that added, deleted, and updated entries in the target store are automatically pushed or deleted in this store. - /// - /// The target to subscribe to. - public void SubscribeTo(CacheBackingStore targetStore) - { - targetStore.EntryAdded.Subscribe(Push); - targetStore.EntryDeleted.Subscribe(Delete); - targetStore.EntryUpdated.Subscribe(Push); - } - - /// public virtual void Dispose() { - EntryAdded?.Dispose(); - EntryDeleted?.Dispose(); - EntryUpdated?.Dispose(); + EntryAdded.Dispose(); + EntryUpdated.Dispose(); + EntryDeleted.Dispose(); + } + + protected CultPersistedRecord ToPersistedRecord(CultStoredDocument entry, Func serializePayload) + { + return new CultPersistedRecord + { + Key = entry.Key.Value, + SchemaId = entry.Descriptor.SchemaId, + StoredAt = entry.StoredAt, + Payload = serializePayload(entry.Document) + }; + } + + protected CultStoredDocument ToStoredDocument( + CultPersistedRecord record, + IReadOnlyCollection catalog, + Func deserializePayload) + { + var descriptor = Registry.ResolvePersistedSchema(record.SchemaId, catalog); + var document = deserializePayload(descriptor.DocumentType, record.Payload); + return new CultStoredDocument( + new CultRecordKey(record.Key), + record.StoredAt, + descriptor, + document); } } - - /// - /// An abstract cache backing store that persists objects as individual files, - /// organizing them by entry type in subdirectories under a root directory. Supports real-time change observation - /// via and provides serialization/deserialization hooks for custom formats. - /// - /// - /// - /// Each entry type is mapped to its own directory, and entries are stored as files with a configurable extension. - /// The class maintains a mapping between file paths and entry IDs for efficient lookup and change tracking. - /// - /// - /// Real-time changes (creation, modification, deletion) to files are observed and propagated to upstream subscribers - /// using Rx subjects and debounced event handling. - /// - /// - /// Subclasses must implement , , and - /// to define the file format. - /// - /// - public abstract class MultiFileBackingStore : CacheBackingStore, IRealtimeBackingStore, IDisposable + + public abstract class MultiFileBackingStore : CacheBackingStore, IRealtimeBackingStore { - /// - /// Gets the root directory where all entry files are stored. - /// - public DirectoryInfo RootDirectory { get; } - /// - /// Maps each type to its corresponding subdirectory. - /// - protected readonly Dictionary EntryTypeDirectories = new(); - /// - /// Maps file paths to their corresponding IDs for quick lookup during change detection. - /// - protected readonly ConcurrentDictionary _filePathToIdMap = new(); - private FileSystemWatcher? watcher; - private Subject? _changedSubject; - private IDisposable? _changedSubscription; + private readonly ConcurrentDictionary _pathsByKey = new(StringComparer.Ordinal); - /// - /// Serializes a into a byte array for file storage. - /// - /// The to serialize. - /// A byte array representing the serialized entry. - public abstract byte[] Serialize(DatabaseEntry entry); - /// - /// Deserializes a byte array into a object. - /// - /// The byte array containing the serialized entry. - /// The deserialized object. - public abstract DatabaseEntry Deserialize(byte[] data); - /// - /// Gets the file extension used for storing entries (e.g., ".json", ".msgpack"). - /// + protected MultiFileBackingStore(string rootPath) + { + RootDirectory = new DirectoryInfo(rootPath); + } + + protected DirectoryInfo RootDirectory { get; } + protected abstract byte[] SerializeRecord(CultPersistedRecord record); + protected abstract CultPersistedRecord DeserializeRecord(byte[] data); + protected abstract byte[] SerializeCatalog(CultSchemaCatalogEntry[] catalog); + protected abstract CultSchemaCatalogEntry[] DeserializeCatalog(byte[] data); + protected abstract byte[] SerializePayload(object document); + protected abstract object DeserializePayload(Type documentType, byte[] payload); public abstract string Extension { get; } + protected virtual string CatalogFileName => $"__cultcache.catalog.{Extension}"; - /// - /// Initializes a new instance of the class. - /// Creates the root directory if it does not exist and sets up subdirectories for each child type. - /// - /// The path to the root directory for storing entry files. - public MultiFileBackingStore(string path) - { - RootDirectory = new DirectoryInfo(path); - foreach (var type in typeof(DatabaseEntry).GetAllChildClasses()) - { - EntryTypeDirectories[type] = GetDirectoryForType(type); - } - } - - private DirectoryInfo GetDirectoryForType(Type type) - { - var stack = new Stack(); - var t = type; - while (t != null && t != typeof(DatabaseEntry)) - { - stack.Push(t.Name); - t = t.BaseType; - } - stack.Push(RootDirectory.FullName); - // ToArray() copies in LIFO order (top/first: root, then parents, deepest child last) - // Ensures Path.Combine yields: Root\Parent\...\Child - return Directory.CreateDirectory(Path.Combine(stack.ToArray())); - } - - /// - /// Loads all objects from individual files in the root directory and its subdirectories, - /// organized by entry type. Each file is deserialized using , - /// added to the local dictionary, and notifies upstream via . - /// Skips files that cannot be deserialized, logging errors without failing the entire operation. - /// Only processes files matching the . - /// public override void PullAll() { - if (!RootDirectory.Exists) return; - - foreach (var directory in EntryTypeDirectories.Values) + if (!RootDirectory.Exists) { - foreach (var file in directory.EnumerateFiles($"*.{Extension}")) - { - try - { - var entry = Deserialize(File.ReadAllBytes(file.FullName)); - Entries[entry.ID] = entry; - EntryAdded.OnNext(entry); - _filePathToIdMap[file.FullName] = entry.ID; - } - catch (Exception ex) - { - Logger.LogError($"Failed to pull entry from {file.FullName}: {ex.Message}"); - } - } + return; + } + + var catalog = LoadCatalog(); + foreach (var file in RootDirectory.EnumerateFiles($"*.{Extension}", SearchOption.AllDirectories) + .Where(file => !string.Equals(file.Name, CatalogFileName, StringComparison.OrdinalIgnoreCase))) + { + var stored = ToStoredDocument( + DeserializeRecord(File.ReadAllBytes(file.FullName)), + catalog, + DeserializePayload); + Entries[stored.Key.Value] = stored; + _pathsByKey[stored.Key.Value] = file.FullName; + EntryAdded.OnNext(stored); } } - private string GetFileName(DatabaseEntry entry) + public override void Push(CultStoredDocument entry) { - string name = entry is INamedEntry namedEntry ? namedEntry.EntryName : entry.ID.ToString(); - // Sanitize to prevent invalid chars/path traversal - foreach (char invalid in Path.GetInvalidFileNameChars()) - name = name.Replace(invalid, '_'); - return $"{name}.{Extension}"; - } - - /// - /// Persists the specified to a file in the appropriate type subdirectory. - /// The file name is generated using , which prefers the entry name if available. - /// The entry is serialized via and written to disk. - /// Updates the local dictionary and the for change tracking. - /// Logs errors if the write fails but re-throws to allow caller handling. - /// - /// The to persist to a file. - public override void Push(DatabaseEntry entry) - { - var type = entry.GetType(); - Entries[entry.ID] = entry; - var directory = EntryTypeDirectories[type]; - var filePath = Path.Combine(directory.FullName, GetFileName(entry)); - if (TryGetExistingPath(entry.ID, out var existingPath) && + Entries[entry.Key.Value] = entry; + var filePath = GetFilePath(entry); + if (_pathsByKey.TryGetValue(entry.Key.Value, out var existingPath) && !string.Equals(existingPath, filePath, StringComparison.OrdinalIgnoreCase)) { TryDeleteFile(existingPath); - _filePathToIdMap.TryRemove(existingPath, out _); } - WriteAllBytesAtomic(filePath, Serialize(entry)); - _filePathToIdMap[filePath] = entry.ID; + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllBytes(filePath, SerializeRecord(ToPersistedRecord(entry, SerializePayload))); + _pathsByKey[entry.Key.Value] = filePath; + PersistCatalog(); } - /// - /// Removes the specified from the local dictionary - /// and deletes its corresponding file in the type subdirectory. - /// Updates the and notifies upstream via . - /// File deletion failures are logged but do not prevent local removal or notification. - /// - /// The to delete from storage. - public override void Delete(DatabaseEntry entry) + public override void Delete(CultStoredDocument entry) { - if (Entries.ContainsKey(entry.ID)) + Entries.TryRemove(entry.Key.Value, out _); + if (_pathsByKey.TryRemove(entry.Key.Value, out var existingPath)) { - Entries.TryRemove(entry.ID, out _); - var filePath = TryGetExistingPath(entry.ID, out var existingPath) - ? existingPath - : Path.Combine(EntryTypeDirectories[entry.GetType()].FullName, GetFileName(entry)); - _filePathToIdMap.TryRemove(filePath, out _); - TryDeleteFile(filePath); + TryDeleteFile(existingPath); } + + PersistCatalog(); } - /// - /// Persists all entries in to their respective files by invoking for each. - /// The parameter is ignored in this implementation, as all pushes are full writes. - /// Errors during individual pushes are logged but do not halt the process. - /// Suitable for batch synchronization after bulk changes. - /// - /// If true, performs a soft push (not supported; treated as full push). public override void PushAll(bool soft = false) { - foreach (var entry in Entries.Values.ToArray()) Push(entry); - } - - /// - /// Starts monitoring the root directory and its subdirectories for file system changes related to entry files. - /// Initializes a with filters for creation, modification, and deletion events on files matching . - /// Uses debouncing for change events to handle bursty writes (100ms delay via and R3). - /// Disposes any existing watcher before creating a new one to prevent duplicates. - /// Logs a warning if the root directory does not exist and skips observation. - /// Logs info when observation starts successfully. - /// Call this method to enable real-time synchronization with external file modifications. - /// - public void ObserveChanges() - { - if (watcher != null) // Guard against re-creation - StopObservingChanges(); - - watcher = new FileSystemWatcher(RootDirectory.FullName, $"*.{Extension}") + foreach (var entry in Entries.Values.ToArray()) { - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName, - EnableRaisingEvents = true, - IncludeSubdirectories = true - }; - - // Use R3 to throttle events if file writes are bursty - _changedSubject = new Subject(); // For paths - _changedSubscription = _changedSubject.Debounce(TimeSpan.FromMilliseconds(100)).Subscribe(HandleFileChange); - - watcher.Changed += (sender, args) => - { - _changedSubject.OnNext(args.FullPath); // Debounced handling - }; - watcher.Created += (sender, args) => - { - HandleFileCreation(args.FullPath); - }; - watcher.Deleted += (sender, args) => - { - HandleFileRemoval(args.FullPath); - }; - } - - private void HandleFileCreation(string fullPath) - { - try - { - var entry = Deserialize(File.ReadAllBytes(fullPath)); - Entries[entry.ID] = entry; - _filePathToIdMap[fullPath] = entry.ID; - EntryAdded.OnNext(entry); // Upstream notify - } - catch (Exception ex) - { - Logger.LogError($"Failed to handle creation of {fullPath}: {ex.Message}"); + Push(entry); } } - private void HandleFileChange(string fullPath) + public virtual void ObserveChanges() { - try - { - var entry = Deserialize(File.ReadAllBytes(fullPath)); - Entries[entry.ID] = entry; - _filePathToIdMap[fullPath] = entry.ID; - EntryUpdated.OnNext(entry); // Upstream notify - } - catch (Exception ex) - { - Logger.LogError($"Failed to handle change in {fullPath}: {ex.Message}"); - } } - private void HandleFileRemoval(string fullPath) + private CultSchemaCatalogEntry[] LoadCatalog() { - try + var catalogFile = Path.Combine(RootDirectory.FullName, CatalogFileName); + if (!File.Exists(catalogFile)) { - // External deletion detected—handle without deserializing - if (_filePathToIdMap.TryGetValue(fullPath, out var id)) - { - _filePathToIdMap.TryRemove(fullPath, out _); // Clean map immediately - - // Emit delete if we have the entry locally - if (Entries.TryGetValue(id, out var entry)) - { - Entries.TryRemove(id, out _); // Sync local dict - EntryDeleted.OnNext(entry); - } - else - { - // Fallback: Stub if race/removed already (rare) - var type = GetTypeFromPath(fullPath); // See helper below - var stub = CreateStubEntry(id, type); - EntryDeleted.OnNext(stub); - Logger.LogWarning($"External deletion of {fullPath} (ID: {id}) with no local entry; emitted stub for cleanup."); - } - } - } - catch (Exception ex) - { - Logger.LogError($"Failed to handle deletion of {fullPath}: {ex.Message}"); - } - } - - // Helper: Infer type from path (e.g., based on directory name matching EntryTypeDirectories keys) - private Type GetTypeFromPath(string filePath) - { - var currentDir = new DirectoryInfo(Path.GetDirectoryName(filePath) ?? string.Empty); - // Walk up the directory tree to find matching EntryTypeDirectories (handles sub-subdirs) - while (currentDir is { Exists: true }) - { - var matchingType = EntryTypeDirectories.FirstOrDefault(kvp => kvp.Value.FullName == currentDir.FullName).Key; - if (matchingType != null) - return matchingType; - currentDir = currentDir.Parent; // Walk up (e.g., from "PlayerData/backup" to "PlayerData") - } - return typeof(DatabaseEntry); // Fallback if no match (e.g., manual file) - } - - // Stub creator (minimal; only for rare fallback—extend if Deletion events need more data) - private DatabaseEntry CreateStubEntry(Guid id, Type type) - { - var entry = type == typeof(DatabaseEntry) - ? new DeletedDatabaseEntry() - : (DatabaseEntry)Activator.CreateInstance(type)!; - typeof(DatabaseEntry).GetField("ID")?.SetValue(entry, id); // Set ID via reflection - return entry; - } - - /// - public override void Dispose() - { - StopObservingChanges(); - base.Dispose(); - } - - private bool TryGetExistingPath(Guid id, out string path) - { - foreach (var pair in _filePathToIdMap) - { - if (pair.Value == id) - { - path = pair.Key; - return true; - } + return Array.Empty(); } - path = string.Empty; - return false; + return DeserializeCatalog(File.ReadAllBytes(catalogFile)); } - private static void WriteAllBytesAtomic(string filePath, byte[] data) + private void PersistCatalog() { - var directory = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - var tempFilePath = Path.Combine(directory ?? string.Empty, $"{Path.GetFileName(filePath)}.{Guid.NewGuid():N}.tmp"); - File.WriteAllBytes(tempFilePath, data); - - if (File.Exists(filePath)) - { - File.Replace(tempFilePath, filePath, null); - } - else - { - File.Move(tempFilePath, filePath); - } + Directory.CreateDirectory(RootDirectory.FullName); + var catalogFile = Path.Combine(RootDirectory.FullName, CatalogFileName); + var catalog = Entries.Values + .Select(entry => entry.Descriptor.ToCatalogEntry()) + .GroupBy(entry => entry.SchemaId, StringComparer.Ordinal) + .Select(group => group.First()) + .OrderBy(entry => entry.SchemaName, StringComparer.Ordinal) + .ToArray(); + File.WriteAllBytes(catalogFile, SerializeCatalog(catalog)); } - private static void TryDeleteFile(string filePath) + private string GetFilePath(CultStoredDocument entry) { - var file = new FileInfo(filePath); - if (file.Exists) + var directory = Path.Combine(RootDirectory.FullName, entry.Descriptor.SchemaName); + var fileName = SanitizeFileName(entry.Descriptor.GetPreferredFileStem(entry.Document)); + return Path.Combine(directory, $"{fileName}.{Extension}"); + } + + private static string SanitizeFileName(string value) + { + foreach (var invalid in Path.GetInvalidFileNameChars()) { - file.Delete(); + value = value.Replace(invalid, '_'); } + + return value; } - private void StopObservingChanges() - { - _changedSubscription?.Dispose(); - _changedSubscription = null; - _changedSubject?.Dispose(); - _changedSubject = null; - watcher?.Dispose(); - watcher = null; - } - - private sealed class DeletedDatabaseEntry : DatabaseEntry + private static void TryDeleteFile(string path) { + if (File.Exists(path)) + { + File.Delete(path); + } } } - /// - /// An abstract cache backing store that persists all objects in a single file. - /// Supports serialization of the entire collection and provides hooks for custom binary formats. - /// Suitable for smaller datasets or when atomic updates are preferred over individual file management. - /// - /// - /// - /// All entries are loaded and saved as a single array in the specified file. - /// Changes do not trigger a write, must manually call PushAll to persist. - /// - /// - /// Subclasses must implement and - /// to define the storage format (e.g., JSON array, MessagePack array). - /// - /// public abstract class SingleFileBackingStore : CacheBackingStore { - /// - /// Gets the file where all entries are stored. - /// - public FileInfo FileInfo { get; } - - /// - /// Serializes an array of objects into a byte array for single-file storage. - /// - /// The array of objects to serialize. - /// A byte array representing the serialized entries. - public abstract byte[] Serialize(DatabaseEntry[] entries); - /// - /// Deserializes a byte array into an array of objects. - /// - /// The byte array containing the serialized entries. - /// An array of deserialized objects. - public abstract DatabaseEntry[] Deserialize(byte[] data); - - /// - /// Initializes a new instance of the class. - /// - /// The path to the single file for storing all entries. - public SingleFileBackingStore(string filePath) + protected SingleFileBackingStore(string filePath) { FileInfo = new FileInfo(filePath); } - - /// - /// Loads all objects from the single storage file by reading its contents, - /// deserializing via into an array, and adding each to the local dictionary. - /// Notifies upstream via for each entry. - /// If the file does not exist or deserialization fails, logs an error and returns without adding entries. - /// Logs the number of loaded entries for debugging. - /// + + protected FileInfo FileInfo { get; } + protected abstract byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot); + protected abstract CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data); + protected abstract byte[] SerializePayload(object document); + protected abstract object DeserializePayload(Type documentType, byte[] payload); + public override void PullAll() { - if (!FileInfo.Exists) return; - - foreach (var entry in Deserialize(File.ReadAllBytes(FileInfo.FullName))) + if (!FileInfo.Exists) { - Entries[entry.ID] = entry; - EntryAdded.OnNext(entry); + return; + } + + var snapshot = DeserializeSnapshot(File.ReadAllBytes(FileInfo.FullName)); + foreach (var record in snapshot.Records) + { + var stored = ToStoredDocument(record, snapshot.SchemaCatalog, DeserializePayload); + Entries[stored.Key.Value] = stored; + EntryAdded.OnNext(stored); } } - /// - /// Adds or updates the specified in the local dictionary. - /// Does not immediately persist to the single file; call to save changes. - /// - /// The to persist. - public override void Push(DatabaseEntry entry) + public override void Push(CultStoredDocument entry) { - var type = entry.GetType(); - Entries[entry.ID] = entry; + Entries[entry.Key.Value] = entry; } - /// - /// Removes the specified from the local dictionary. - /// Does not immediately persist to the single file; call to save changes. - /// - /// The to delete. - public override void Delete(DatabaseEntry entry) + public override void Delete(CultStoredDocument entry) { - if (Entries.TryGetValue(entry.ID, out _)) - { - Entries.TryRemove(entry.ID, out _); - } + Entries.TryRemove(entry.Key.Value, out _); } - /// - /// Serializes all entries in to a byte array using - /// and writes it to the single storage file, overwriting any existing content. - /// The parameter is ignored in this implementation, as saves are always full rewrites. - /// Logs the number of saved entries and errors if the write fails, re-throwing for caller handling. - /// - /// If true, performs a soft push (not supported; treated as full push). public override void PushAll(bool soft = false) { - var entriesArray = Entries.Values.ToArray(); - var directory = FileInfo.DirectoryName; - if (!string.IsNullOrEmpty(directory)) + var snapshot = new CultPersistedStoreSnapshot { - Directory.CreateDirectory(directory); - } + SchemaCatalog = Entries.Values + .Select(entry => entry.Descriptor.ToCatalogEntry()) + .GroupBy(entry => entry.SchemaId, StringComparer.Ordinal) + .Select(group => group.First()) + .OrderBy(entry => entry.SchemaName, StringComparer.Ordinal) + .ToArray(), + Records = Entries.Values + .OrderBy(entry => entry.Key.Value, StringComparer.Ordinal) + .Select(entry => ToPersistedRecord(entry, SerializePayload)) + .ToArray() + }; - var tempFilePath = Path.Combine(directory ?? string.Empty, $"{Path.GetFileName(FileInfo.FullName)}.{Guid.NewGuid():N}.tmp"); - File.WriteAllBytes(tempFilePath, Serialize(entriesArray)); - if (FileInfo.Exists) - { - File.Replace(tempFilePath, FileInfo.FullName, null); - } - else - { - File.Move(tempFilePath, FileInfo.FullName); - } + Directory.CreateDirectory(FileInfo.DirectoryName!); + File.WriteAllBytes(FileInfo.FullName, SerializeSnapshot(snapshot)); } } } diff --git a/src/GameCult.Caching/CultDocumentContracts.cs b/src/GameCult.Caching/CultDocumentContracts.cs new file mode 100644 index 00000000..d770b0f1 --- /dev/null +++ b/src/GameCult.Caching/CultDocumentContracts.cs @@ -0,0 +1,101 @@ +using System; + +namespace GameCult.Caching +{ + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class CultDocumentAttribute : Attribute + { + public CultDocumentAttribute(string schemaName, string schemaVersion) + { + SchemaName = string.IsNullOrWhiteSpace(schemaName) + ? throw new ArgumentException("SchemaName must be non-empty.", nameof(schemaName)) + : schemaName; + SchemaVersion = string.IsNullOrWhiteSpace(schemaVersion) + ? throw new ArgumentException("SchemaVersion must be non-empty.", nameof(schemaVersion)) + : schemaVersion; + } + + public string SchemaName { get; } + public string SchemaVersion { get; } + } + + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class CultGlobalAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] + public sealed class CultNameAttribute : Attribute + { + } + + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + public sealed class CultIndexAttribute : Attribute + { + public CultIndexAttribute(string? alias = null) + { + Alias = alias; + } + + public string? Alias { get; } + } + + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] + public sealed class CultReferenceAttribute : Attribute + { + public CultReferenceAttribute(Type? targetType = null, bool many = false) + { + TargetType = targetType; + Many = many; + } + + public Type? TargetType { get; } + public bool Many { get; } + } + + public readonly struct CultRecordKey : IEquatable + { + public CultRecordKey(string value) + { + Value = value ?? string.Empty; + } + + public string Value { get; } + + public override string ToString() => Value; + + public bool Equals(CultRecordKey other) => string.Equals(Value, other.Value, StringComparison.Ordinal); + public override bool Equals(object? obj) => obj is CultRecordKey other && Equals(other); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); + } + + public readonly struct CultRecordHandle + { + public CultRecordHandle(CultRecordKey key) + { + Key = key; + } + + public CultRecordKey Key { get; } + + public override string ToString() => Key.ToString(); + } + + public readonly struct CultRecordRef + { + public CultRecordRef(CultRecordKey key) + { + Key = key; + } + + public CultRecordKey Key { get; } + + public T? Resolve(CultCache cache) + { + if (cache == null) throw new ArgumentNullException(nameof(cache)); + return cache.Get(Key) is T typed ? typed : default; + } + + public override string ToString() => Key.ToString(); + } +} diff --git a/src/GameCult.Caching/DatabaseEntry.cs b/src/GameCult.Caching/DatabaseEntry.cs deleted file mode 100644 index e278c9ad..00000000 --- a/src/GameCult.Caching/DatabaseEntry.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; - -namespace GameCult.Caching -{ - /// - /// Interface for database entries that have a human-readable name. - /// - public interface INamedEntry - { - /// - /// Human-readable name of the entry. - /// - string EntryName { get; set; } - } - - /// - /// Base abstract class for all database entries in the cache. - /// For optional MessagePack integration, define concrete subclasses with [MessagePackObject] and [Key(n)] on fields. - /// The ID field can be serialized via a custom formatter in MessagePack stores. - /// - public abstract class DatabaseEntry - { - /// - /// Unique identifier for the entry. Handled by custom formatters in MessagePack integration. - /// - public Guid ID = Guid.NewGuid(); - - /// - public override int GetHashCode() - { - return ID.GetHashCode(); - } - - /// - public override bool Equals(object obj) - { - if (obj is DatabaseEntry entry) return entry.ID == ID; - return false; - } - } - - /// - /// Generic link to a in the cache. - /// Value is computed from the cache; serializes only LinkID. - /// - public class DatabaseLink : DatabaseLinkBase where T : DatabaseEntry - { - /// - /// Computed value from the cache (not serialized; regenerated post-deserialize). - /// - public T? Value => Cache?.Get(LinkID); - } - - /// - /// Base for database links. LinkID is serialized directly; Value is computed in subclasses. - /// - public class DatabaseLinkBase - { - /// - /// ID of the linked entry. - /// - public Guid LinkID; - - /// - /// Static reference to the cache (not serialized). - /// - public static CultCache? Cache; - } -} \ No newline at end of file diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index 8e03d8a5..bb5ecd08 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -1,241 +1,130 @@ # GameCult.Caching -`GameCult.Caching` provides a typed in-memory cache for `DatabaseEntry` models and a persistence abstraction for backing stores. +`GameCult.Caching` is an attribute-driven typed cache with pluggable stores. +Domain models stay clean. The cache owns record keys, schema ids, timestamps, +and persistence metadata instead of smearing that sludge across every class. -## Scope +## Authoring Model -The library is designed for applications that need: - -- fast in-memory lookup by `Guid` -- optional lookup by human-readable name -- optional lookup by indexed fields or properties -- persistence through one or more pluggable stores -- a common model base type for runtime and storage code - -It is a domain cache with persistence adapters. - -## Main Concepts - -### `DatabaseEntry` - -All cacheable models inherit from `DatabaseEntry`. +Cacheable models are plain classes annotated with cache intent. ```csharp using GameCult.Caching; +using MessagePack; -public class ItemData : DatabaseEntry, INamedEntry +[CultDocument("gamecult.item_data", "gamecult.item_data.v1")] +[MessagePackObject] +public sealed class ItemData { + [Key(0)] + [CultName] public string Name = string.Empty; - public string Category = string.Empty; - public int Value; - public string EntryName - { - get => Name; - set => Name = value; - } + [Key(1)] + [CultIndex] + public string Category = string.Empty; + + [Key(2)] + public int Value; } ``` -### `INamedEntry` +Available cache attributes: -If a type implements `INamedEntry`, `CultCache` maintains a name-to-id lookup for it. That enables: +- `CultDocument(schemaName, schemaVersion)` +- `CultName` +- `CultIndex(alias?)` +- `CultGlobal` +- `CultReference(targetType?, many: false)` -- `GetIdByName(...)` -- `GetByName(...)` +## Main Concepts -### Registered Indexes +### Record keys and handles -Indexes are opt-in. Register an index once for a field or property, and the cache maintains a value-to-id lookup map for that member. +`CultCache` gives each stored document a cache-owned `CultRecordKey`. You work +with typed handles instead of forcing storage identity into the domain object. ```csharp var cache = new CultCache(); -cache.RegisterIndex("Category"); -``` - -After registration: - -```csharp -var consumable = cache.GetByIndex("Category", "Consumable"); -``` - -### Global Entries - -If a `DatabaseEntry` type is marked with `GlobalSettingsAttribute`, the cache treats it as a singleton-style global entry and exposes it through `GetGlobal()`. - -## Core Features - -- ID-based lookup -- optional name-based lookup -- optional field/property indexes -- global singleton-style entries -- pluggable backing stores -- event propagation from backing stores into the cache -- support for both single-file and multi-file persistence strategies - -## Basic Usage - -```csharp -using GameCult.Caching; - -var cache = new CultCache(); -cache.RegisterIndex("Category"); - -await cache.AddAsync(new ItemData +var handle = await cache.AddAsync(new ItemData { - ID = Guid.NewGuid(), Name = "Potion", Category = "Consumable", Value = 50 }); -var potion = cache.GetByName("Potion"); -var samePotion = cache.GetByIndex("Category", "Consumable"); +var potion = cache.Get(handle.Key); ``` -## Important Concept: Backing Store Routing +### Name and index lookups -`CultCache` can have: - -- generic backing stores -- type/domain-specific backing stores - -### Domain-Specific Stores - -If you register a store with one or more domain types: +Name and index lookups are driven from attributes, not manual registration. ```csharp -cache.AddBackingStore(playerStore, typeof(PlayerData)); -cache.AddBackingStore(settingsStore, typeof(AppSettings)); +var byName = cache.GetByName("Potion"); +var byIndex = cache.GetByIndex("Category", "Consumable"); ``` -then writes for those types are routed directly to those stores. +### Explicit references -### Generic Stores - -If you register stores without domains: +References stay explicit in the domain. The cache will resolve them on demand, +but it will not quietly build an entire haunted object graph behind your back. ```csharp -cache.AddBackingStore(primaryStore); -cache.AddBackingStore(mirrorStore); -``` - -the first generic store becomes the primary write target for entries that do not have a type-specific store. - -The later generic stores subscribe to earlier ones and mirror their changes. - -That means: - -- generic store registration order matters -- the first generic store is effectively the source of truth for generic writes -- later generic stores behave like downstream replicas - -### How Multiple Backing Stores Behave - -With this setup: - -```csharp -cache.AddBackingStore(primaryStore); -cache.AddBackingStore(reportingMirror); -cache.AddBackingStore(playerStore, typeof(PlayerData)); -``` - -the behavior is: - -- `PlayerData` writes go to `playerStore` -- other entry types go to `primaryStore` -- `reportingMirror` mirrors change events from `primaryStore` -- `PullAllBackingStoresAsync()` pulls from all registered stores - -This is not a multi-master design. If you need symmetric writes to multiple stores, implement that outside `CultCache`. - -## Persistence Semantics - -`AddAsync` persists to the appropriate backing store before the cache treats the write as committed. If persistence fails, the call throws instead of updating only the in-memory state. - -Effects: - -- memory and disk are less likely to diverge silently -- callers can react to persistence failures explicitly - -## Backing Store Base Types - -### `CacheBackingStore` - -Abstract base for persistence implementations. It exposes: - -- `PullAll()` -- `Push(DatabaseEntry entry)` -- `Delete(DatabaseEntry entry)` -- `PushAll(bool soft = false)` - -### `SingleFileBackingStore` - -Stores the entire set of entries in one file. - -Use cases: - -- small datasets -- settings-style data -- atomic snapshot persistence - -### `MultiFileBackingStore` - -Stores one file per entry in type-specific directories. - -Use cases: - -- large sets of independent entries -- change observation with `FileSystemWatcher` -- workflows where per-entry files are useful - -## Rename Behavior In `MultiFileBackingStore` - -For `INamedEntry` types, the filename is derived from the entry name. The backing store removes the stale old file before committing the new one. - -## Example: Custom Backing Store - -```csharp -using System.IO; -using System.Text; -using GameCult.Caching; - -public sealed class TextItemStore : MultiFileBackingStore +[CultDocument("gamecult.quest_data", "gamecult.quest_data.v1")] +[MessagePackObject] +public sealed class QuestData { - public TextItemStore(string path) : base(path) - { - } - - public override byte[] Serialize(DatabaseEntry entry) - { - var item = (ItemData)entry; - return Encoding.UTF8.GetBytes($"{item.ID}|{item.Name}|{item.Category}|{item.Value}"); - } - - public override DatabaseEntry Deserialize(byte[] data) - { - var parts = Encoding.UTF8.GetString(data).Split('|'); - return new ItemData - { - ID = Guid.Parse(parts[0]), - Name = parts[1], - Category = parts[2], - Value = int.Parse(parts[3]) - }; - } - - public override string Extension => "item"; + [Key(0)] public string Title = string.Empty; + [Key(1)] [CultReference] public CultRecordRef Reward; } ``` -## Typical Startup Sequence +## Persistence Shape + +CultCache persistence is store-shaped, not entry-shaped: + +1. store header +2. embedded schema catalog +3. records + +Each persisted record carries: + +- `key` +- `schemaId` +- `storedAt` +- `payload` + +The domain payload stays free of storage metadata. + +## Backing Stores + +Base abstractions: + +- `CacheBackingStore` +- `SingleFileBackingStore` +- `MultiFileBackingStore` + +Included implementations in sibling packages: + +- `SingleFileMessagePackBackingStore` +- `MultiFileMessagePackBackingStore` +- `SingleFileNewtonsoftJsonBackingStore` +- `MultiFileNewtonsoftJsonBackingStore` + +`MultiFileBackingStore` derives filenames from the `CultName` member when one +exists, so renames replace stale files instead of leaving little corpses +behind. + +## Typical Startup ```csharp +using GameCult.Caching; +using GameCult.Caching.MessagePack; + var cache = new CultCache(); -cache.RegisterIndex("Email"); - -var store = new TextItemStore("Data"); -cache.AddBackingStore(store, typeof(ItemData)); +var store = new MultiFileMessagePackBackingStore("Data"); +cache.AddBackingStore(store); await cache.PullAllBackingStoresAsync(); ``` diff --git a/src/GameCult.Caching/ReflectionExtensions.cs b/src/GameCult.Caching/ReflectionExtensions.cs index 1002878c..3959a4ce 100644 --- a/src/GameCult.Caching/ReflectionExtensions.cs +++ b/src/GameCult.Caching/ReflectionExtensions.cs @@ -5,55 +5,27 @@ using System.Reflection; namespace GameCult.Caching { - /// - /// Provides cached reflection helpers used by the cache infrastructure. - /// - public static class ReflectionExtensions + internal static class ReflectionExtensions { - private static Dictionary ParentTypes = new Dictionary(); - - /// - /// Gets all base types for the supplied type, caching the result. - /// - /// The type whose parent types should be returned. - /// An array of base types ordered from immediate parent upward. - public static Type[] GetParentTypes(this Type type) + public static IEnumerable GetAttributedDocumentTypes() { - if (ParentTypes.ContainsKey(type)) - return ParentTypes[type]; - return ParentTypes[type] = type.GetParents().ToArray(); + return AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic) + .SelectMany(SafeGetTypes) + .Where(type => type is { IsAbstract: false, IsInterface: false }) + .Where(type => type.GetCustomAttribute() != null); } - private static IEnumerable GetParents(this Type type) + public static IEnumerable SafeGetTypes(Assembly assembly) { - // is there any base type? - if (type == null) + try { - yield break; + return assembly.GetTypes(); } - - // return all inherited types - var currentBaseType = type.BaseType; - while (currentBaseType != null) + catch (ReflectionTypeLoadException ex) { - yield return currentBaseType; - currentBaseType = currentBaseType.BaseType; + return ex.Types.Where(type => type != null)!; } } - - private static Dictionary ChildClasses = new Dictionary(); - - /// - /// Gets all loaded types assignable to the supplied type, caching the result. - /// - /// The base type or interface to search from. - /// An array of matching loaded types. - public static Type[] GetAllChildClasses(this Type type) - { - if (ChildClasses.ContainsKey(type)) - return ChildClasses[type]; - return ChildClasses[type] = AppDomain.CurrentDomain.GetAssemblies() - .SelectMany(ass => ass.GetTypes()).Where(type.IsAssignableFrom).ToArray(); - } } } diff --git a/src/GameCult.Networking/Contracts/cultnet.document-delete.schema.json b/src/GameCult.Networking/Contracts/cultnet.document-delete.schema.json index 146fd6d3..265bcaf2 100644 --- a/src/GameCult.Networking/Contracts/cultnet.document-delete.schema.json +++ b/src/GameCult.Networking/Contracts/cultnet.document-delete.schema.json @@ -6,14 +6,14 @@ "required": [ "schemaVersion", "messageId", - "documentType", - "documentKey" + "schemaId", + "recordKey" ], "additionalProperties": false, "properties": { "schemaVersion": { "const": "cultnet.document_delete.v0" }, "messageId": { "type": "string", "minLength": 1 }, - "documentType": { "type": "string", "minLength": 1 }, - "documentKey": { "type": "string", "minLength": 1 } + "schemaId": { "type": "string", "minLength": 1 }, + "recordKey": { "type": "string", "minLength": 1 } } } diff --git a/src/GameCult.Networking/Contracts/cultnet.document-record.schema.json b/src/GameCult.Networking/Contracts/cultnet.document-record.schema.json index 139d1fd5..94301dc5 100644 --- a/src/GameCult.Networking/Contracts/cultnet.document-record.schema.json +++ b/src/GameCult.Networking/Contracts/cultnet.document-record.schema.json @@ -4,17 +4,16 @@ "title": "CultNet Document Record", "type": "object", "required": [ - "documentType", - "documentKey", + "schemaId", + "recordKey", "storedAt", "payload" ], "additionalProperties": false, "properties": { - "documentType": { "type": "string", "minLength": 1 }, - "documentKey": { "type": "string", "minLength": 1 }, + "schemaId": { "type": "string", "minLength": 1 }, + "recordKey": { "type": "string", "minLength": 1 }, "storedAt": { "type": "string", "minLength": 1 }, - "payloadSchemaVersion": { "type": "string", "minLength": 1 }, "payload": {}, "sourceRuntimeId": { "type": "string", "minLength": 1 }, "sourceAgentId": { "type": "string", "minLength": 1 }, diff --git a/src/GameCult.Networking/Contracts/cultnet.raw-document-record.schema.json b/src/GameCult.Networking/Contracts/cultnet.raw-document-record.schema.json index 93e3108d..3348a5fa 100644 --- a/src/GameCult.Networking/Contracts/cultnet.raw-document-record.schema.json +++ b/src/GameCult.Networking/Contracts/cultnet.raw-document-record.schema.json @@ -4,18 +4,17 @@ "title": "CultNet Raw Document Record", "type": "object", "required": [ - "documentType", - "documentKey", + "schemaId", + "recordKey", "storedAt", "payloadEncoding", "payload" ], "additionalProperties": false, "properties": { - "documentType": { "type": "string", "minLength": 1 }, - "documentKey": { "type": "string", "minLength": 1 }, + "schemaId": { "type": "string", "minLength": 1 }, + "recordKey": { "type": "string", "minLength": 1 }, "storedAt": { "type": "string", "minLength": 1 }, - "payloadSchemaVersion": { "type": "string", "minLength": 1 }, "payloadEncoding": { "const": "messagepack" }, "payload": { "type": "string", diff --git a/src/GameCult.Networking/Contracts/cultnet.snapshot-request.schema.json b/src/GameCult.Networking/Contracts/cultnet.snapshot-request.schema.json index 47f0c146..712d7059 100644 --- a/src/GameCult.Networking/Contracts/cultnet.snapshot-request.schema.json +++ b/src/GameCult.Networking/Contracts/cultnet.snapshot-request.schema.json @@ -11,11 +11,11 @@ "properties": { "schemaVersion": { "const": "cultnet.snapshot_request.v0" }, "messageId": { "type": "string", "minLength": 1 }, - "documentTypes": { + "schemaIds": { "type": "array", "items": { "type": "string", "minLength": 1 } }, - "documentKeys": { + "recordKeys": { "type": "array", "items": { "type": "string", "minLength": 1 } } diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index fb283e97..e67cb90b 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -8,9 +8,6 @@ using GameCult.Caching.MessagePack; namespace GameCult.Networking { - /// - /// Optional metadata attached when CultNet emits a document message. - /// public sealed class CultNetDocumentMessageOptions { public string? StoredAt { get; set; } @@ -20,90 +17,60 @@ namespace GameCult.Networking public string[]? Tags { get; set; } } - /// - /// Maps a concrete CultCache entry type to a shared CultNet document contract. - /// public sealed class CultNetDocumentBinding { internal CultNetDocumentBinding( - Type entryType, - string documentType, - string? payloadSchemaVersion, - Func documentKeySelector, - Func payloadSerializer, - Func payloadDeserializer) + Type documentType, + string schemaId, + Func payloadSerializer, + Func payloadDeserializer) { - EntryType = entryType ?? throw new ArgumentNullException(nameof(entryType)); - DocumentType = string.IsNullOrWhiteSpace(documentType) - ? throw new ArgumentException("DocumentType must be non-empty.", nameof(documentType)) - : documentType; - PayloadSchemaVersion = payloadSchemaVersion; - DocumentKeySelector = documentKeySelector ?? throw new ArgumentNullException(nameof(documentKeySelector)); - PayloadSerializer = payloadSerializer ?? throw new ArgumentNullException(nameof(payloadSerializer)); - PayloadDeserializer = payloadDeserializer ?? throw new ArgumentNullException(nameof(payloadDeserializer)); + DocumentType = documentType; + SchemaId = schemaId; + PayloadSerializer = payloadSerializer; + PayloadDeserializer = payloadDeserializer; } - public Type EntryType { get; } - public string DocumentType { get; } - public string? PayloadSchemaVersion { get; } - public Func DocumentKeySelector { get; } - public Func PayloadSerializer { get; } - public Func PayloadDeserializer { get; } + public Type DocumentType { get; } + public string SchemaId { get; } + public Func PayloadSerializer { get; } + public Func PayloadDeserializer { get; } - public static CultNetDocumentBinding ForEntry( - string documentType, - string? payloadSchemaVersion = null, - Func? documentKeySelector = null, + public static CultNetDocumentBinding ForDocument( + CultDocumentRegistry? registry = null, Func? payloadSerializer = null, Func? payloadDeserializer = null) - where T : DatabaseEntry + where T : class { + var descriptor = (registry ?? CultDocumentRegistry.Shared).GetRequired(); return new CultNetDocumentBinding( typeof(T), - documentType, - payloadSchemaVersion, - entry => + descriptor.SchemaId, + document => { - if (entry is not T typedEntry) - { - throw new InvalidOperationException( - $"Document binding for {typeof(T).Name} received {entry.GetType().Name}."); - } - - return documentKeySelector != null - ? documentKeySelector(typedEntry) - : typedEntry.ID.ToString("D", CultureInfo.InvariantCulture); - }, - entry => - { - if (entry is not T typedEntry) - { - throw new InvalidOperationException( - $"Document binding for {typeof(T).Name} received {entry.GetType().Name}."); - } - + var typed = (T)document; return payloadSerializer != null - ? payloadSerializer(typedEntry) - : CultCacheEnvelopeSerialization.SerializePayload(typedEntry); + ? payloadSerializer(typed) + : CultDocumentMessagePackSerialization.Serialize(typed); }, payload => payloadDeserializer != null ? payloadDeserializer(payload) - : CultCacheEnvelopeSerialization.DeserializePayload(payload)); + : CultDocumentMessagePackSerialization.Deserialize(payload)); } } - /// - /// Creates and applies CultNet raw document/snapshot messages for bound CultCache entry types. - /// public sealed class CultNetDocumentRegistry { - private readonly Dictionary _bindingsByDocumentType = - new Dictionary(StringComparer.Ordinal); - private readonly Dictionary _bindingsByEntryType = - new Dictionary(); + private readonly CultDocumentRegistry _documents; + private readonly Dictionary _bindingsBySchemaId = + new(StringComparer.Ordinal); + private readonly Dictionary _bindingsByType = new(); - public CultNetDocumentRegistry(IEnumerable? bindings = null) + public CultNetDocumentRegistry( + CultDocumentRegistry? documents = null, + IEnumerable? bindings = null) { + _documents = documents ?? CultDocumentRegistry.Shared; if (bindings == null) { return; @@ -117,86 +84,73 @@ namespace GameCult.Networking public CultNetDocumentRegistry Register(CultNetDocumentBinding binding) { - if (binding == null) throw new ArgumentNullException(nameof(binding)); - _bindingsByDocumentType[binding.DocumentType] = binding; - _bindingsByEntryType[binding.EntryType] = binding; + _bindingsBySchemaId[binding.SchemaId] = binding; + _bindingsByType[binding.DocumentType] = binding; return this; } - public CultNetDocumentBinding? GetByDocumentType(string documentType) + public CultNetDocumentBinding? GetBySchemaId(string schemaId) { - if (documentType == null) throw new ArgumentNullException(nameof(documentType)); - return _bindingsByDocumentType.TryGetValue(documentType, out var binding) ? binding : null; + return _bindingsBySchemaId.TryGetValue(schemaId, out var binding) ? binding : null; } - public CultNetDocumentBinding? GetByEntryType(Type entryType) + public CultNetDocumentBinding? GetByDocumentType(Type documentType) { - if (entryType == null) throw new ArgumentNullException(nameof(entryType)); - return _bindingsByEntryType.TryGetValue(entryType, out var binding) ? binding : null; + return _bindingsByType.TryGetValue(documentType, out var binding) ? binding : null; } public CultNetDocumentDeleteMessage CreateDocumentDeleteMessage( string messageId, - string documentType, - string documentKey) + string schemaId, + string recordKey) { return new CultNetDocumentDeleteMessage { MessageId = RequireNonEmpty(messageId, nameof(messageId)), - DocumentType = RequireNonEmpty(documentType, nameof(documentType)), - DocumentKey = RequireNonEmpty(documentKey, nameof(documentKey)) + SchemaId = RequireNonEmpty(schemaId, nameof(schemaId)), + RecordKey = RequireNonEmpty(recordKey, nameof(recordKey)) }; } - public CultNetDocumentPutRawMessage CreateRawDocumentPutMessage( + public CultNetDocumentPutRawMessage CreateRawDocumentPutMessage( string messageId, - DatabaseEntry entry, + CultRecordHandle handle, + T document, CultNetDocumentMessageOptions? options = null) + where T : class { - if (entry == null) throw new ArgumentNullException(nameof(entry)); - var binding = RequireBinding(entry.GetType()); + var descriptor = _documents.GetRequired(); + var binding = GetByDocumentType(typeof(T)) ?? CultNetDocumentBinding.ForDocument(_documents); var storedAt = ResolveStoredAt(options); - var documentKey = binding.DocumentKeySelector(entry); - - return new CultNetDocumentPutRawMessage - { - MessageId = RequireNonEmpty(messageId, nameof(messageId)), - Document = CreateRawDocumentRecord(binding, entry, documentKey, storedAt, options) - }; - } - - public CultNetDocumentPutRawMessage CreateRawDocumentPutMessageFromEnvelope( - string messageId, - CultCacheEnvelope envelope) - { - if (envelope == null) throw new ArgumentNullException(nameof(envelope)); - var binding = RequireBinding(envelope.Type); return new CultNetDocumentPutRawMessage { MessageId = RequireNonEmpty(messageId, nameof(messageId)), Document = new CultNetRawDocumentRecord { - DocumentType = envelope.Type, - DocumentKey = envelope.Key, - StoredAt = envelope.StoredAt, - PayloadSchemaVersion = binding.PayloadSchemaVersion, + SchemaId = descriptor.SchemaId, + RecordKey = handle.Key.Value, + StoredAt = storedAt, PayloadEncoding = "messagepack", - Payload = envelope.Payload.ToArray() + Payload = binding.PayloadSerializer(document), + SourceRuntimeId = options?.SourceRuntimeId, + SourceAgentId = options?.SourceAgentId, + SourceRole = options?.SourceRole, + Tags = options?.Tags } }; } public CultNetSnapshotRequestMessage CreateSnapshotRequest( string messageId, - IEnumerable? documentTypes = null, - IEnumerable? documentKeys = null) + IEnumerable? schemaIds = null, + IEnumerable? recordKeys = null) { return new CultNetSnapshotRequestMessage { MessageId = RequireNonEmpty(messageId, nameof(messageId)), - DocumentTypes = documentTypes?.ToArray(), - DocumentKeys = documentKeys?.ToArray() + SchemaIds = schemaIds?.ToArray(), + RecordKeys = recordKeys?.ToArray() }; } @@ -206,42 +160,62 @@ namespace GameCult.Networking CultNetSnapshotRequestMessage? filter = null, CultNetDocumentMessageOptions? options = null) { - if (cache == null) throw new ArgumentNullException(nameof(cache)); - - var requestedDocumentTypes = filter?.DocumentTypes != null - ? new HashSet(filter.DocumentTypes, StringComparer.Ordinal) + var requestedSchemaIds = filter?.SchemaIds != null + ? new HashSet(filter.SchemaIds, StringComparer.Ordinal) : null; - var requestedDocumentKeys = filter?.DocumentKeys != null - ? new HashSet(filter.DocumentKeys, StringComparer.Ordinal) + var requestedRecordKeys = filter?.RecordKeys != null + ? new HashSet(filter.RecordKeys, StringComparer.Ordinal) : null; var storedAt = ResolveStoredAt(options); var documents = new List(); - foreach (var entry in cache.AllEntries) + foreach (var document in cache.AllEntries) { - var binding = GetByEntryType(entry.GetType()); - if (binding == null) + var descriptor = _documents.GetRequired(document.GetType()); + if (requestedSchemaIds != null && !requestedSchemaIds.Contains(descriptor.SchemaId)) { continue; } - if (requestedDocumentTypes != null && !requestedDocumentTypes.Contains(binding.DocumentType)) + var handleMethod = typeof(CultCache) + .GetMethod(nameof(CultCache.TryGetHandle))! + .MakeGenericMethod(document.GetType()); + var handleObject = handleMethod.Invoke(cache, new[] { document }); + if (handleObject == null) { continue; } - var documentKey = binding.DocumentKeySelector(entry); - if (requestedDocumentKeys != null && !requestedDocumentKeys.Contains(documentKey)) + var keyProperty = handleObject.GetType().GetProperty("Value"); + var handleValue = keyProperty?.GetValue(handleObject) ?? handleObject; + var recordKeyProperty = handleValue.GetType().GetProperty("Key"); + var recordKey = recordKeyProperty?.GetValue(handleValue); + var valueProperty = recordKey?.GetType().GetProperty("Value"); + var key = (string?)(valueProperty?.GetValue(recordKey)) ?? string.Empty; + if (requestedRecordKeys != null && !requestedRecordKeys.Contains(key)) { continue; } - documents.Add(CreateRawDocumentRecord( - binding, - entry, - documentKey, - storedAt, - options)); + var binding = GetByDocumentType(document.GetType()) ?? + new CultNetDocumentBinding( + document.GetType(), + descriptor.SchemaId, + value => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType()), + payload => CultDocumentMessagePackSerialization.DeserializeUntyped(document.GetType(), payload)); + + documents.Add(new CultNetRawDocumentRecord + { + SchemaId = descriptor.SchemaId, + RecordKey = key, + StoredAt = storedAt, + PayloadEncoding = "messagepack", + Payload = binding.PayloadSerializer(document), + SourceRuntimeId = options?.SourceRuntimeId, + SourceAgentId = options?.SourceAgentId, + SourceRole = options?.SourceRole, + Tags = options?.Tags + }); } return new CultNetSnapshotResponseRawMessage @@ -251,68 +225,56 @@ namespace GameCult.Networking }; } - public async Task ApplyRawDocumentPutMessageAsync( + public async Task ApplyRawDocumentPutMessageAsync( CultCache cache, CultNetDocumentPutRawMessage message) { - if (cache == null) throw new ArgumentNullException(nameof(cache)); - if (message == null) throw new ArgumentNullException(nameof(message)); - if (message.Document == null) throw new ArgumentException("CultNet raw document message is missing its document payload.", nameof(message)); + if (message.Document == null) + { + throw new ArgumentException("CultNet raw document message is missing its document payload.", nameof(message)); + } - var binding = RequireBinding(message.Document.DocumentType); ValidateRawDocumentRecord(message.Document); + var descriptor = _documents.GetRequiredBySchemaId(message.Document.SchemaId); + var binding = GetBySchemaId(message.Document.SchemaId) ?? + new CultNetDocumentBinding( + descriptor.DocumentType, + descriptor.SchemaId, + value => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType()), + payload => CultDocumentMessagePackSerialization.DeserializeUntyped(descriptor.DocumentType, payload)); + var document = binding.PayloadDeserializer(message.Document.Payload); - var entry = binding.PayloadDeserializer(message.Document.Payload); - if (!binding.EntryType.IsInstanceOfType(entry)) - { - throw new InvalidOperationException( - $"CultNet raw document type \"{message.Document.DocumentType}\" expected {binding.EntryType.Name} but decoded {entry.GetType().Name}."); - } - - var expectedKey = binding.DocumentKeySelector(entry); - if (!string.Equals(expectedKey, message.Document.DocumentKey, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"CultNet raw document key mismatch for \"{message.Document.DocumentType}\": payload resolved to \"{expectedKey}\" but wire metadata said \"{message.Document.DocumentKey}\"."); - } - - await cache.AddAsync(entry); - return entry; + var addMethod = typeof(CultCache).GetMethod(nameof(CultCache.AddAsync))! + .MakeGenericMethod(descriptor.DocumentType); + var handleType = typeof(CultRecordHandle<>).MakeGenericType(descriptor.DocumentType); + var optionalHandle = Activator.CreateInstance(typeof(Nullable<>).MakeGenericType(handleType), new object[] { Activator.CreateInstance(handleType, new object[] { new CultRecordKey(message.Document.RecordKey) })! }); + var task = (Task)addMethod.Invoke(cache, [document, optionalHandle])!; + await task.ConfigureAwait(false); + return document; } public async Task ApplyRawDocumentPutMessageAsync( CultCache cache, CultNetDocumentPutRawMessage message) - where T : DatabaseEntry + where T : class { - var entry = await ApplyRawDocumentPutMessageAsync(cache, message); - if (entry is not T typedEntry) - { - throw new InvalidOperationException( - $"CultNet raw document message resolved to {entry.GetType().Name}, not {typeof(T).Name}."); - } - - return typedEntry; + return (T)await ApplyRawDocumentPutMessageAsync(cache, message).ConfigureAwait(false); } - public async Task> ApplyRawSnapshotResponseAsync( + public async Task> ApplyRawSnapshotResponseAsync( CultCache cache, CultNetSnapshotResponseRawMessage response) { - if (cache == null) throw new ArgumentNullException(nameof(cache)); - if (response == null) throw new ArgumentNullException(nameof(response)); - - var applied = new List(response.Documents.Length); + var applied = new List(response.Documents.Length); foreach (var document in response.Documents) { - var entry = await ApplyRawDocumentPutMessageAsync( + applied.Add(await ApplyRawDocumentPutMessageAsync( cache, new CultNetDocumentPutRawMessage { MessageId = response.MessageId, Document = document - }); - applied.Add(entry); + }).ConfigureAwait(false)); } return applied; @@ -321,54 +283,9 @@ namespace GameCult.Networking public async Task> ApplyRawSnapshotResponseAsync( CultCache cache, CultNetSnapshotResponseRawMessage response) - where T : DatabaseEntry + where T : class { - var applied = await ApplyRawSnapshotResponseAsync(cache, response); - return applied.OfType().ToArray(); - } - - private CultNetRawDocumentRecord CreateRawDocumentRecord( - CultNetDocumentBinding binding, - DatabaseEntry entry, - string documentKey, - string storedAt, - CultNetDocumentMessageOptions? options) - { - var envelope = new CultCacheEnvelope - { - Key = documentKey, - Type = binding.DocumentType, - StoredAt = storedAt, - Payload = binding.PayloadSerializer(entry) - }; - - return new CultNetRawDocumentRecord - { - DocumentType = envelope.Type, - DocumentKey = envelope.Key, - StoredAt = envelope.StoredAt, - PayloadSchemaVersion = binding.PayloadSchemaVersion, - PayloadEncoding = "messagepack", - Payload = envelope.Payload, - SourceRuntimeId = options?.SourceRuntimeId, - SourceAgentId = options?.SourceAgentId, - SourceRole = options?.SourceRole, - Tags = options?.Tags - }; - } - - private CultNetDocumentBinding RequireBinding(Type entryType) - { - return GetByEntryType(entryType) - ?? throw new InvalidOperationException( - $"No CultNet document binding is registered for {entryType.FullName}."); - } - - private CultNetDocumentBinding RequireBinding(string documentType) - { - return GetByDocumentType(documentType) - ?? throw new InvalidOperationException( - $"No CultNet document binding is registered for \"{documentType}\"."); + return (await ApplyRawSnapshotResponseAsync(cache, response).ConfigureAwait(false)).OfType().ToArray(); } private static void ValidateRawDocumentRecord(CultNetRawDocumentRecord document) @@ -379,14 +296,13 @@ namespace GameCult.Networking $"CultNet raw document payloadEncoding must be \"messagepack\", not \"{document.PayloadEncoding}\"."); } + RequireNonEmpty(document.SchemaId, nameof(document.SchemaId)); + RequireNonEmpty(document.RecordKey, nameof(document.RecordKey)); + RequireNonEmpty(document.StoredAt, nameof(document.StoredAt)); if (document.Payload == null || document.Payload.Length == 0) { throw new InvalidOperationException("CultNet raw document payload must be non-empty."); } - - RequireNonEmpty(document.DocumentType, nameof(document.DocumentType)); - RequireNonEmpty(document.DocumentKey, nameof(document.DocumentKey)); - RequireNonEmpty(document.StoredAt, nameof(document.StoredAt)); } private static string ResolveStoredAt(CultNetDocumentMessageOptions? options) diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index 3323c92a..23ae0b3e 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -114,10 +114,9 @@ namespace GameCult.Networking [MessagePackObject] public class CultNetRawDocumentRecord { - [Key("documentType")] public string DocumentType { get; set; } = string.Empty; - [Key("documentKey")] public string DocumentKey { get; set; } = string.Empty; + [Key("schemaId")] public string SchemaId { get; set; } = string.Empty; + [Key("recordKey")] public string RecordKey { get; set; } = string.Empty; [Key("storedAt")] public string StoredAt { get; set; } = string.Empty; - [Key("payloadSchemaVersion")] public string? PayloadSchemaVersion { get; set; } [Key("payloadEncoding")] public string PayloadEncoding { get; set; } = "messagepack"; [Key("payload")] public byte[] Payload { get; set; } = Array.Empty(); [Key("sourceRuntimeId")] public string? SourceRuntimeId { get; set; } @@ -131,8 +130,8 @@ namespace GameCult.Networking { [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.DocumentDelete; [Key("messageId")] public string MessageId { get; set; } = string.Empty; - [Key("documentType")] public string DocumentType { get; set; } = string.Empty; - [Key("documentKey")] public string DocumentKey { get; set; } = string.Empty; + [Key("schemaId")] public string SchemaId { get; set; } = string.Empty; + [Key("recordKey")] public string RecordKey { get; set; } = string.Empty; } [MessagePackObject] @@ -148,8 +147,8 @@ namespace GameCult.Networking { [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SnapshotRequest; [Key("messageId")] public string MessageId { get; set; } = string.Empty; - [Key("documentTypes")] public string[]? DocumentTypes { get; set; } - [Key("documentKeys")] public string[]? DocumentKeys { get; set; } + [Key("schemaIds")] public string[]? SchemaIds { get; set; } + [Key("recordKeys")] public string[]? RecordKeys { get; set; } } [MessagePackObject] diff --git a/src/GameCult.Networking/PlayerData.cs b/src/GameCult.Networking/PlayerData.cs index 4205eea9..3c17d635 100644 --- a/src/GameCult.Networking/PlayerData.cs +++ b/src/GameCult.Networking/PlayerData.cs @@ -1,39 +1,26 @@ +using System; using GameCult.Caching; using MessagePack; namespace GameCult.Networking { - /// - /// Player data stored in the database. - /// For MessagePack serialization, uses [MessagePackObject] and [Key(n)] attributes. - /// - public class PlayerData : DatabaseEntry, INamedEntry + [CultDocument("gamecult.player_data", "gamecult.player_data.v1")] + [MessagePackObject] + public class PlayerData { + [Key(0)] + [CultIndex] + public Guid PlayerId = Guid.NewGuid(); - /// - /// Email address of the player. - /// [Key(1)] + [CultIndex] public string Email = string.Empty; - /// - /// Password hash of the player. - /// [Key(2)] public string PasswordHash = string.Empty; - /// - /// Username of the player. - /// [Key(3)] + [CultName] public string Username = string.Empty; - - /// - [IgnoreMember] - public string EntryName - { - get => Username; - set => Username = value; - } } } diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md index 27985ad2..8aa17e77 100644 --- a/src/GameCult.Networking/README.md +++ b/src/GameCult.Networking/README.md @@ -84,7 +84,8 @@ Schema-v0 message families include: The raw document/snapshot lane is meant for neighbors that already share the same payload schema and MessagePack semantics. It carries exact payload bytes -plus explicit type metadata; it does not guess what a blob "probably" means. +plus `schemaId`/record-key metadata; it does not guess what a blob "probably" +means. ## Authentication Model diff --git a/src/GameCult.Networking/Server.cs b/src/GameCult.Networking/Server.cs index 2f197edb..f83d488e 100644 --- a/src/GameCult.Networking/Server.cs +++ b/src/GameCult.Networking/Server.cs @@ -59,7 +59,6 @@ namespace GameCult.Networking { _database = cache; _security = security ?? ServerSecurityOptions.FromEnvironment(); - _database.RegisterIndex("Email"); _cleanupSubscription = Observable.Timer(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)) .Subscribe(_ => CleanupExpiredSessions()); } @@ -256,7 +255,7 @@ namespace GameCult.Networking return; } - if (_database.GetIdByName(message.Name) != null) + if (_database.GetByName(message.Name) != null) { message.Peer?.Send(new ErrorMessage { Error = "Username Taken" }); return; @@ -310,13 +309,13 @@ namespace GameCult.Networking return; } - if (_database.GetIdByIndex("Email", email) != null) + if (_database.GetByIndex("Email", email) != null) { peer.Send(new ErrorMessage { Error = "Email Taken" }); return; } - if (_database.GetIdByName(name) != null) + if (_database.GetByName(name) != null) { peer.Send(new ErrorMessage { Error = "Username Taken" }); return; @@ -330,15 +329,15 @@ namespace GameCult.Networking var newUserData = new PlayerData { - ID = Guid.NewGuid(), + PlayerId = Guid.NewGuid(), Email = email, PasswordHash = Argon2.Hash(password, memoryCost: 16384), Username = name }; await _database.AddAsync(newUserData); - AttachUser(user, newUserData.ID); - SendSessionToken(peer, newUserData.ID); + AttachUser(user, newUserData.PlayerId); + SendSessionToken(peer, newUserData.PlayerId); } private void HandleVerify(NetPeer peer, User user, VerifyMessage verify) @@ -350,7 +349,7 @@ namespace GameCult.Networking return; } - if (_database.Get(playerId) == null) + if (_database.GetByIndex("PlayerId", playerId.ToString("D")) == null) { peer.Send(new ErrorMessage { Error = "Session Not Found" }); return; @@ -387,18 +386,18 @@ namespace GameCult.Networking return; } - AttachUser(user, userData.ID); - SendSessionToken(peer, userData.ID); + AttachUser(user, userData.PlayerId); + SendSessionToken(peer, userData.PlayerId); } private bool IsVerified(User? user) => user != null && user.PlayerId != Guid.Empty && user.SessionExpiresAt > DateTimeOffset.UtcNow && - _database.Get(user.PlayerId) != null; + _database.GetByIndex("PlayerId", user.PlayerId.ToString("D")) != null; private PlayerData? SessionData(User user) => - IsVerified(user) ? _database.Get(user.PlayerId) : null; + IsVerified(user) ? _database.GetByIndex("PlayerId", user.PlayerId.ToString("D")) : null; private bool CheckRateLimit(string ip) { diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 67fd58db..7c2c671a 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -1,13 +1,11 @@ using System; using System.IO; using System.Linq; -using System.Reflection; -using System.Text; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; using GameCult.Caching.NewtonsoftJson; -using Newtonsoft.Json; +using MessagePack; using NUnit.Framework; namespace GameCult.Caching.Tests @@ -23,13 +21,11 @@ namespace GameCult.Caching.Tests try { var store = new MultiFileNewtonsoftJsonBackingStore(rootPath); - store.RegisterType(); var cache = new CultCache(); cache.AddBackingStore(store); var entry = new NamedTestEntry { - ID = Guid.NewGuid(), Name = "OriginalName", Value = "value" }; @@ -64,29 +60,26 @@ namespace GameCult.Caching.Tests try { var writeStore = new MultiFileNewtonsoftJsonBackingStore(rootPath); - writeStore.RegisterType(); var writeCache = new CultCache(); writeCache.AddBackingStore(writeStore); var entry = new NamedTestEntry { - ID = Guid.NewGuid(), Name = "RoundTrip", Value = "payload" }; - await writeCache.AddAsync(entry); + var handle = await writeCache.AddAsync(entry); var readStore = new MultiFileNewtonsoftJsonBackingStore(rootPath); - readStore.RegisterType(); var readCache = new CultCache(); readCache.AddBackingStore(readStore); await readCache.PullAllBackingStoresAsync(); - var loaded = readCache.Get(entry.ID); + var loaded = readCache.Get(handle.Key); Assert.That(loaded, Is.Not.Null); - Assert.That(GetEntryValue(loaded!), Is.EqualTo("payload")); + Assert.That(loaded!.Value, Is.EqualTo("payload")); } finally { @@ -105,30 +98,27 @@ namespace GameCult.Caching.Tests try { var writeStore = new SingleFileNewtonsoftJsonBackingStore(filePath); - writeStore.RegisterType(); var writeCache = new CultCache(); writeCache.AddBackingStore(writeStore); var entry = new NamedTestEntry { - ID = Guid.NewGuid(), Name = "SingleFile", Value = "payload" }; - await writeCache.AddAsync(entry); + var handle = await writeCache.AddAsync(entry); writeStore.PushAll(); var readStore = new SingleFileNewtonsoftJsonBackingStore(filePath); - readStore.RegisterType(); var readCache = new CultCache(); readCache.AddBackingStore(readStore); await readCache.PullAllBackingStoresAsync(); - var loaded = readCache.Get(entry.ID); + var loaded = readCache.Get(handle.Key); Assert.That(loaded, Is.Not.Null); - Assert.That(GetEntryValue(loaded!), Is.EqualTo("payload")); + Assert.That(loaded!.Value, Is.EqualTo("payload")); } finally { @@ -140,123 +130,34 @@ namespace GameCult.Caching.Tests } [Test] - public void MultiFileNewtonsoftJsonBackingStore_Rejects_UnknownDiscriminator() + public void MessagePackSerialization_RoundTrips_CultRecordRef() { - var rootPath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}"); - Directory.CreateDirectory(rootPath); + var reference = new CultRecordRef(new CultRecordKey("record-1")); - try - { - var store = new MultiFileNewtonsoftJsonBackingStore(rootPath); - store.RegisterType(); - var payload = """ - { - "Type": "Unknown.Assembly:Unknown.Type", - "Data": { - "ID": "00000000-0000-0000-0000-000000000001", - "Name": "Bad", - "Value": "Bad" - } - } - """; + var payload = CultDocumentMessagePackSerialization.Serialize(reference); + var roundTrip = CultDocumentMessagePackSerialization.Deserialize>(payload); - Assert.That( - () => store.Deserialize(Encoding.UTF8.GetBytes(payload)), - Throws.TypeOf().With.Message.Contains("Unknown DatabaseEntry discriminator")); - } - finally - { - if (Directory.Exists(rootPath)) - { - Directory.Delete(rootPath, true); - } - } + Assert.That(roundTrip.Key.Value, Is.EqualTo("record-1")); } [Test] - public async Task MultiFileNewtonsoftJsonBackingStore_Delete_Removes_File() + public void MessagePackSerialization_Rejects_InvalidPayload() { - var rootPath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}"); - Directory.CreateDirectory(rootPath); - - try - { - var store = new MultiFileNewtonsoftJsonBackingStore(rootPath); - store.RegisterType(); - var cache = new CultCache(); - cache.AddBackingStore(store); - - var entry = new NamedTestEntry - { - ID = Guid.NewGuid(), - Name = "DeleteMe", - Value = "payload" - }; - - await cache.AddAsync(entry); - cache.Remove(entry); - - var files = Directory.GetFiles(rootPath, "*.json", SearchOption.AllDirectories) - .Select(Path.GetFileName) - .ToArray(); - - Assert.That(files, Does.Not.Contain("DeleteMe.json")); - } - finally - { - if (Directory.Exists(rootPath)) - { - Directory.Delete(rootPath, true); - } - } - } - - [Test] - public void MessagePackDatabaseEntrySerialization_RoundTrips_DatabaseLink() - { - var serializationType = typeof(MultiFileMessagePackBackingStore).Assembly - .GetType("GameCult.Caching.MessagePack.DatabaseEntrySerialization", throwOnError: true)!; - var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)! - .MakeGenericMethod(typeof(DatabaseLink)); - var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)! - .MakeGenericMethod(typeof(DatabaseLink)); - var link = new DatabaseLink { LinkID = Guid.NewGuid() }; - - var payload = (byte[])serialize.Invoke(null, [link])!; - var roundTrip = (DatabaseLink)deserialize.Invoke(null, [payload])!; - - Assert.That(roundTrip.LinkID, Is.EqualTo(link.LinkID)); - } - - [Test] - public void MessagePackDatabaseEntrySerialization_Rejects_InvalidPayload() - { - var serializationType = typeof(MultiFileMessagePackBackingStore).Assembly - .GetType("GameCult.Caching.MessagePack.DatabaseEntrySerialization", throwOnError: true)!; - var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)! - .MakeGenericMethod(typeof(DatabaseEntry)); - Assert.That( - () => deserialize.Invoke(null, [new byte[] { 0xC1 }]), - Throws.TypeOf() - .With.InnerException.InstanceOf()); + () => CultDocumentMessagePackSerialization.Deserialize>(new byte[] { 0xC1 }), + Throws.TypeOf()); } - private sealed class NamedTestEntry : DatabaseEntry, INamedEntry + [CultDocument("tests.named_entry", "tests.named_entry.v1")] + [MessagePackObject] + internal sealed class NamedTestEntry { + [Key(0)] + [CultName] public string Name = string.Empty; + + [Key(1)] public string Value = string.Empty; - - public string EntryName - { - get => Name; - set => Name = value; - } - } - - private static string GetEntryValue(DatabaseEntry entry) - { - return entry.GetType().GetField("Value")?.GetValue(entry) as string ?? string.Empty; } } } diff --git a/tests/GameCult.Networking.InteropPeer/Program.cs b/tests/GameCult.Networking.InteropPeer/Program.cs index 82b05d51..e107ae60 100644 --- a/tests/GameCult.Networking.InteropPeer/Program.cs +++ b/tests/GameCult.Networking.InteropPeer/Program.cs @@ -55,16 +55,14 @@ static async Task ServeAsync(ServeConfig config) { var schemaRegistration = LoadSchemaRegistration(config.SchemaPath); var documentRegistry = new CultNetDocumentRegistry() - .Register(CultNetDocumentBinding.ForEntry( - InteropPeerShared.InteropDocumentType, - payloadSchemaVersion: InteropPeerShared.InteropSchemaVersion, - documentKeySelector: note => note.DocumentId, + .Register(CultNetDocumentBinding.ForDocument( payloadSerializer: SerializeInteropNotePayload, payloadDeserializer: DeserializeInteropNotePayload)); var customSchemaRegistry = new CultNetSchemaRegistry().Register(schemaRegistration); var cache = new CultCache(); - await cache.AddAsync(BuildInteropNote(config.RuntimeId, config.DisplayName)); + var localNote = BuildInteropNote(config.RuntimeId, config.DisplayName); + await cache.AddAsync(localNote, new CultRecordHandle(new CultRecordKey(localNote.DocumentId))); using var udpSocket = CreateDiscoverySocket(config.DiscoveryPort, config.DiscoveryGroup); using var tcpListener = new TcpListener(IPAddress.Parse(config.BindHost), config.TcpPort); @@ -173,10 +171,7 @@ static async Task DialAsync(DialConfig config) var schemaRegistration = LoadSchemaRegistration(config.SchemaPath); var cache = new CultCache(); var documentRegistry = new CultNetDocumentRegistry() - .Register(CultNetDocumentBinding.ForEntry( - InteropPeerShared.InteropDocumentType, - payloadSchemaVersion: InteropPeerShared.InteropSchemaVersion, - documentKeySelector: note => note.DocumentId, + .Register(CultNetDocumentBinding.ForDocument( payloadSerializer: SerializeInteropNotePayload, payloadDeserializer: DeserializeInteropNotePayload)); @@ -209,8 +204,8 @@ static async Task DialAsync(DialConfig config) await SendMessageAsync(stream, new CultNetSnapshotRequestMessage { MessageId = $"{config.RuntimeId}-snapshot", - DocumentTypes = [InteropPeerShared.InteropDocumentType], - DocumentKeys = [$"note:{remoteHello.RuntimeId}"] + SchemaIds = [schemaRegistration.SchemaId], + RecordKeys = [$"note:{remoteHello.RuntimeId}"] }); var snapshot = await ExpectMessageAsync(stream, CultNetSchemaVersions.SnapshotResponseRaw); await documentRegistry.ApplyRawSnapshotResponseAsync(cache, snapshot); @@ -504,7 +499,6 @@ static CultNetInteropNote BuildInteropNote(string runtimeId, string displayName) var documentId = $"note:{runtimeId}"; return new CultNetInteropNote { - ID = CreateDeterministicGuid(documentId), SchemaVersion = InteropPeerShared.InteropSchemaVersion, DocumentId = documentId, AuthorRuntimeId = runtimeId, @@ -532,7 +526,6 @@ static CultNetInteropNote DeserializeInteropNotePayload(byte[] payload) var decoded = MessagePackSerializer.Deserialize(payload, CultNetSchemaMessageSerialization.Options); return new CultNetInteropNote { - ID = CreateDeterministicGuid(decoded.DocumentId), SchemaVersion = decoded.SchemaVersion, DocumentId = decoded.DocumentId, AuthorRuntimeId = decoded.AuthorRuntimeId, @@ -542,12 +535,6 @@ static CultNetInteropNote DeserializeInteropNotePayload(byte[] payload) }; } -static Guid CreateDeterministicGuid(string value) -{ - var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(value)); - return new Guid(bytes); -} - static async Task WaitForeverAsync(CancellationToken cancellationToken) { try @@ -678,14 +665,16 @@ static class InteropPeerShared }; } -public sealed class CultNetInteropNote : DatabaseEntry +[CultDocument(InteropPeerShared.InteropDocumentType, InteropPeerShared.InteropSchemaVersion)] +[MessagePackObject] +public sealed class CultNetInteropNote { - public string SchemaVersion { get; set; } = InteropPeerShared.InteropSchemaVersion; - public string DocumentId { get; set; } = string.Empty; - public string AuthorRuntimeId { get; set; } = string.Empty; - public string Title { get; set; } = string.Empty; - public string Body { get; set; } = string.Empty; - public string[] Tags { get; set; } = Array.Empty(); + [Key(0)] public string SchemaVersion { get; set; } = InteropPeerShared.InteropSchemaVersion; + [Key(1)] [CultName] public string DocumentId { get; set; } = string.Empty; + [Key(2)] public string AuthorRuntimeId { get; set; } = string.Empty; + [Key(3)] public string Title { get; set; } = string.Empty; + [Key(4)] public string Body { get; set; } = string.Empty; + [Key(5)] public string[] Tags { get; set; } = Array.Empty(); } [MessagePackObject] diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index d741809c..df331540 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -174,10 +174,9 @@ namespace GameCult.Networking.Tests [ new CultNetRawDocumentRecord { - DocumentType = "ghostlight.agent-state", - DocumentKey = "world/main", + SchemaId = "sha256:ghostlight-agent-state", + RecordKey = "world/main", StoredAt = "2026-05-06T12:34:56.0000000+00:00", - PayloadSchemaVersion = "ghostlight.agent_state.v0", PayloadEncoding = "messagepack", Payload = [0x91, 0xA3, 0x66, 0x6F, 0x6F], SourceRuntimeId = "voidbot", @@ -193,7 +192,7 @@ namespace GameCult.Networking.Tests Assert.That(roundTrip.MessageId, Is.EqualTo("snapshot-1")); Assert.That(roundTrip.Documents, Has.Length.EqualTo(1)); - Assert.That(roundTrip.Documents[0].DocumentType, Is.EqualTo("ghostlight.agent-state")); + Assert.That(roundTrip.Documents[0].SchemaId, Is.EqualTo("sha256:ghostlight-agent-state")); Assert.That(roundTrip.Documents[0].PayloadEncoding, Is.EqualTo("messagepack")); Assert.That(roundTrip.Documents[0].Payload, Is.EqualTo(message.Documents[0].Payload)); Assert.That(roundTrip.Documents[0].Tags, Is.EqualTo(["swarm", "dream"])); @@ -205,27 +204,25 @@ namespace GameCult.Networking.Tests var sourceCache = new CultCache(); var targetCache = new CultCache(); var registry = new CultNetDocumentRegistry() - .Register(CultNetDocumentBinding.ForEntry( - "gamecult.player_data", - payloadSchemaVersion: "gamecult.player_data.v0", + .Register(CultNetDocumentBinding.ForDocument( payloadSerializer: SerializePlayerDataPayload, payloadDeserializer: DeserializePlayerDataPayload)); var sourceEntry = new PlayerData { - ID = Guid.NewGuid(), + PlayerId = Guid.NewGuid(), Email = "cult@example.test", PasswordHash = "not-a-real-hash", Username = "CultGhost" }; - await sourceCache.AddAsync(sourceEntry); + var handle = await sourceCache.AddAsync(sourceEntry); var expectedPayload = SerializePlayerDataPayload(sourceEntry); var request = registry.CreateSnapshotRequest( "request-1", - documentTypes: ["gamecult.player_data"], - documentKeys: [sourceEntry.ID.ToString("D")]); + schemaIds: [sourceCache.Registry.GetRequired().SchemaId], + recordKeys: [handle.Key.Value]); var response = registry.CreateRawSnapshotResponse(sourceCache, "snapshot-1", request); var serializedResponse = CultNetSchemaMessageSerialization.Serialize(response); var roundTrip = (CultNetSnapshotResponseRawMessage)CultNetSchemaMessageSerialization.Deserialize(serializedResponse); @@ -234,7 +231,7 @@ namespace GameCult.Networking.Tests Assert.That(roundTrip.Documents[0].Payload, Is.EqualTo(expectedPayload)); await registry.ApplyRawSnapshotResponseAsync(targetCache, roundTrip); - var replicated = targetCache.Get(sourceEntry.ID); + var replicated = targetCache.GetByIndex("PlayerId", sourceEntry.PlayerId.ToString("D")); Assert.That(replicated, Is.Not.Null); Assert.That(SerializePlayerDataPayload(replicated!), Is.EqualTo(expectedPayload)); @@ -296,7 +293,7 @@ namespace GameCult.Networking.Tests [MessagePack.MessagePackObject] public sealed class PlayerDataPayload { - [MessagePack.Key(0)] public Guid Id { get; set; } + [MessagePack.Key(0)] public Guid PlayerId { get; set; } [MessagePack.Key(1)] public string Email { get; set; } = string.Empty; [MessagePack.Key(2)] public string PasswordHash { get; set; } = string.Empty; [MessagePack.Key(3)] public string Username { get; set; } = string.Empty; @@ -306,7 +303,7 @@ namespace GameCult.Networking.Tests { return MessagePack.MessagePackSerializer.Serialize(new PlayerDataPayload { - Id = entry.ID, + PlayerId = entry.PlayerId, Email = entry.Email, PasswordHash = entry.PasswordHash, Username = entry.Username @@ -318,7 +315,7 @@ namespace GameCult.Networking.Tests var decoded = MessagePack.MessagePackSerializer.Deserialize(payload); return new PlayerData { - ID = decoded.Id, + PlayerId = decoded.PlayerId, Email = decoded.Email, PasswordHash = decoded.PasswordHash, Username = decoded.Username