using System; using System.IO; using System.Linq; using System.Security.Cryptography; using GameCult.Caching; using GameCult.Caching.MessagePack; namespace GameCult.Mesh { /// /// Schema metadata used when publishing one raw document payload as a CultCache single-file snapshot. /// public sealed class CultMeshSingleFileDocumentSchema { /// /// Creates schema metadata for a single-file document publication. /// public CultMeshSingleFileDocumentSchema(string schemaId, string schemaName, string schemaVersion) { if (string.IsNullOrWhiteSpace(schemaId)) throw new ArgumentException("Value must be non-empty.", nameof(schemaId)); if (string.IsNullOrWhiteSpace(schemaName)) throw new ArgumentException("Value must be non-empty.", nameof(schemaName)); if (string.IsNullOrWhiteSpace(schemaVersion)) throw new ArgumentException("Value must be non-empty.", nameof(schemaVersion)); SchemaId = schemaId; SchemaName = schemaName; SchemaVersion = schemaVersion; } /// /// Gets the content-derived schema identifier. /// public string SchemaId { get; } /// /// Gets the stable schema name. /// public string SchemaName { get; } /// /// Gets the schema version. /// public string SchemaVersion { get; } /// /// Gets or sets an optional canonical schema description. /// public string CanonicalSchemaJson { get; set; } = string.Empty; /// /// Gets or sets compatible schema identifiers accepted for this document. /// public string[] CompatibleSchemaIds { get; set; } = Array.Empty(); internal CultSchemaCatalogEntry ToCatalogEntry(byte[] payload) { return new CultSchemaCatalogEntry { SchemaId = SchemaId, SchemaName = SchemaName, SchemaVersion = SchemaVersion, ContentHash = StableHash(payload), CanonicalSchemaJson = CanonicalSchemaJson, CompatibleSchemaIds = CompatibleSchemaIds .Append(SchemaId) .Where(candidate => !string.IsNullOrWhiteSpace(candidate)) .Distinct(StringComparer.Ordinal) .ToArray(), Members = Array.Empty() }; } private static string StableHash(byte[] payload) { if (payload == null || payload.Length == 0) return "empty"; using var sha = SHA256.Create(); return BitConverter.ToString(sha.ComputeHash(payload)).Replace("-", "", StringComparison.Ordinal).ToLowerInvariant(); } } public static partial class CultMesh { /// /// Writes one typed document as a canonical single-file MessagePack CultCache snapshot. /// public static void WriteSingleFileDocument( string path, CultRecordKey key, TDocument document, string? storedAt = null, CultDocumentRegistry? registry = null) where TDocument : class { if (document == null) throw new ArgumentNullException(nameof(document)); if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); // The store decides element ids, the header and the copy of every other record, as a cache write does. StoreAt(path).WriteDocument(key, document, storedAt, registry); } /// /// Reads one typed document from a single-file MessagePack CultCache snapshot. /// public static TDocument ReadSingleFileDocument( string path, CultRecordKey key, CultDocumentRegistry? registry = null) where TDocument : class { var resolvedRegistry = registry ?? CultDocumentRegistry.Shared; var descriptor = resolvedRegistry.GetRequired(); var payload = ReadSingleFileDocumentPayload(path, key, descriptor.SchemaId); // A load refuses what a write refuses, and mints the ids a cache load of the same file would mint. return CultCacheMessagePack.CreateCodec(resolvedRegistry).DecodeDocument(key, payload); } /// /// Writes one raw document payload as a canonical single-file MessagePack CultCache snapshot. /// public static void WriteSingleFileDocumentPayload( string path, CultRecordKey key, CultMeshSingleFileDocumentSchema schema, string? storedAt, byte[] payload) { if (schema == null) throw new ArgumentNullException(nameof(schema)); WriteSingleFileDocumentPayload(path, key, schema.ToCatalogEntry(payload), storedAt, payload, holdsIds: null, registry: null); } /// /// Reads one raw document payload from a single-file MessagePack CultCache snapshot. /// public static byte[] ReadSingleFileDocumentPayload( string path, CultRecordKey key, string expectedSchemaId) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); if (string.IsNullOrWhiteSpace(expectedSchemaId)) throw new ArgumentException("Value must be non-empty.", nameof(expectedSchemaId)); var snapshot = StoreAt(path).ReadStoreSnapshot() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path); var record = snapshot.Records.SingleOrDefault(candidate => string.Equals(candidate.Key, key.Value, StringComparison.Ordinal)) ?? throw new InvalidDataException($"CultCache document '{path}' does not contain record '{key.Value}'."); if (!string.Equals(record.SchemaId, expectedSchemaId, StringComparison.Ordinal)) { throw new InvalidDataException( $"CultCache document '{path}' record '{key.Value}' has schema '{record.SchemaId}', expected '{expectedSchemaId}'."); } RefuseVariant(path, record); if (!PublishesSchema(snapshot.SchemaCatalog, expectedSchemaId)) { throw new InvalidDataException( $"CultCache document '{path}' does not publish schema '{expectedSchemaId}' in its catalog."); } return record.Payload; } /// /// Reads the only raw document payload from a single-file MessagePack CultCache snapshot. /// public static byte[] ReadSingleFileDocumentPayload( string path, string expectedSchemaId) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); if (string.IsNullOrWhiteSpace(expectedSchemaId)) throw new ArgumentException("Value must be non-empty.", nameof(expectedSchemaId)); var snapshot = StoreAt(path).ReadStoreSnapshot() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path); if (snapshot.Records.Length != 1) { throw new InvalidDataException( $"CultCache document '{path}' must contain exactly one record."); } var record = snapshot.Records[0]; if (!string.Equals(record.SchemaId, expectedSchemaId, StringComparison.Ordinal)) { throw new InvalidDataException( $"CultCache document '{path}' has schema '{record.SchemaId}', expected '{expectedSchemaId}'."); } if (!PublishesSchema(snapshot.SchemaCatalog, expectedSchemaId)) { throw new InvalidDataException( $"CultCache document '{path}' does not publish schema '{expectedSchemaId}' in its catalog."); } return record.Payload; } // A variant record's payload is empty; its document exists only as the cache resolves it against its base. private static void RefuseVariant(string path, CultPersistedRecord record) { if (record.Variant != null) throw new NotSupportedException( $"CultCache document '{path}' record '{record.Key}' is a variant of '{record.Variant.BaseKey}'; " + "CultMesh does not resolve document variants, open the store with a CultCache."); } private static void WriteSingleFileDocumentPayload( string path, CultRecordKey key, CultSchemaCatalogEntry catalogEntry, string? storedAt, byte[] payload, bool? holdsIds, CultDocumentRegistry? registry) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry)); if (string.IsNullOrWhiteSpace(catalogEntry.SchemaId)) throw new ArgumentException("Catalog entry must include a schema id.", nameof(catalogEntry)); payload ??= Array.Empty(); // The write replaces this one key and copies every other record and catalog entry forward as the store holds them, under // the store's lock. A typed write sees its document, so its ids decide the header; a raw payload is opaque (holdsIds null), // so a file already marked stays marked. A typed write attaches the registry its payload was encoded with, so the write // decides for copied records that registry can read; a raw payload attaches none and decides nothing. A store this runtime // cannot read refuses the write instead of being overwritten. StoreAt(path).WriteRecord( new CultPersistedRecord { Key = key.Value, SchemaId = catalogEntry.SchemaId, StoredAt = string.IsNullOrWhiteSpace(storedAt) ? DateTimeOffset.UtcNow.ToString("O") : storedAt!, Payload = payload }, catalogEntry, holdsIds, registry); } private static SingleFileMessagePackBackingStore StoreAt(string path) => new SingleFileMessagePackBackingStore(path); private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId) { return catalog.Any(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal) || entry.CompatibleSchemaIds.Any(candidate => string.Equals(candidate, schemaId, StringComparison.Ordinal))); } } }