From 39e13fdb3c1e307c48d52fbf6710396e4fadf79b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 17:50:02 +0100 Subject: [PATCH] Add typed snapshot sync helpers --- src/GameCult.Mesh/CultMeshSnapshots.cs | 81 ++++++++++++++++++- .../CultMeshStreamingTests.cs | 20 ++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index bd595799..eb60a8d6 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -83,6 +83,32 @@ namespace GameCult.Mesh public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(250); } + /// + /// Result from syncing a remote snapshot into a local CultMesh node. + /// + public sealed class CultMeshSnapshotSyncResult + { + internal CultMeshSnapshotSyncResult( + CultNetSnapshotResponseRawMessage snapshot, + IReadOnlyList appliedDocuments) + { + Snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot)); + AppliedDocuments = appliedDocuments ?? throw new ArgumentNullException(nameof(appliedDocuments)); + } + + /// Gets the raw snapshot that was fetched from the endpoint. + public CultNetSnapshotResponseRawMessage Snapshot { get; } + + /// Gets the documents applied into the local node cache. + public IReadOnlyList AppliedDocuments { get; } + + /// Gets the number of documents applied into the local node cache. + public int AppliedCount => AppliedDocuments.Count; + + /// Gets the source shard log sequence reported by the snapshot, when present. + public long ShardLogSequence => Snapshot.ShardLogSequence ?? 0L; + } + /// /// Typed snapshot surface for one remote CultNet endpoint. /// @@ -132,6 +158,24 @@ namespace GameCult.Mesh return CultMesh.FetchSnapshotAsync(Endpoint, CreateRequest(schemaIds, recordKeys)); } + /// Fetches one snapshot and applies it into the node's cache. + public async Task SyncSnapshotAsync( + CultMeshNode node, + IReadOnlyList? schemaIds = null, + IReadOnlyList? recordKeys = null, + bool flush = false) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + + var snapshot = await FetchSnapshotAsync(schemaIds, recordKeys).ConfigureAwait(false); + var applied = await node.Database.Documents.ApplyRawSnapshotResponseAsync(node.Cache, snapshot) + .ConfigureAwait(false); + if (flush) + await node.FlushAsync().ConfigureAwait(false); + + return new CultMeshSnapshotSyncResult(snapshot, applied); + } + /// Fetches and decodes documents assignable to the requested type or matching its schema. public Task> FetchDocumentsAsync( IReadOnlyList? recordKeys = null, @@ -145,6 +189,26 @@ namespace GameCult.Mesh DocumentRegistry); } + /// Fetches typed documents and syncs their raw snapshot into the node's cache. + public async Task> SyncDocumentsAsync( + CultMeshNode node, + IReadOnlyList? recordKeys = null, + IReadOnlyList? schemaIds = null, + bool flush = false) + where TDocument : class + { + if (node == null) throw new ArgumentNullException(nameof(node)); + + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var result = await SyncSnapshotAsync( + node, + schemaIds ?? new[] { descriptor.SchemaId }, + recordKeys, + flush) + .ConfigureAwait(false); + return CultMesh.DecodeSnapshotDocuments(result.Snapshot, DocumentRegistry); + } + /// Fetches one typed document by record key. public async Task FetchDocumentAsync(string recordKey) where TDocument : class @@ -156,6 +220,21 @@ namespace GameCult.Mesh $"CultNet snapshot endpoint '{Endpoint}' did not return {typeof(TDocument).FullName} record '{recordKey}'."); } + /// Fetches one typed document by record key and syncs it into the node's cache. + public async Task SyncDocumentAsync( + CultMeshNode node, + string recordKey, + bool flush = false) + where TDocument : class + { + if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); + var documents = await SyncDocumentsAsync(node, new[] { recordKey }, flush: flush) + .ConfigureAwait(false); + return documents.FirstOrDefault() + ?? throw new InvalidOperationException( + $"CultNet snapshot endpoint '{Endpoint}' did not return {typeof(TDocument).FullName} record '{recordKey}'."); + } + /// Creates a typed document handle over one remote endpoint record. public CultMeshDocumentHandle Document( string recordKey, @@ -402,7 +481,7 @@ namespace GameCult.Mesh return filtered.Length == 0 ? null : filtered; } - private static IReadOnlyList DecodeSnapshotDocuments( + internal static IReadOnlyList DecodeSnapshotDocuments( CultNetSnapshotResponseRawMessage snapshot, CultNetDocumentRegistry registry) where TDocument : class diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index c93f5ee7..bad9afb0 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1135,15 +1135,33 @@ public sealed class CultMeshStreamingTests }); var fetchedAlias = await surface.FetchDocumentAsync(key.Value); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(Path.GetTempPath(), $"cultmesh-snapshot-endpoint-{Guid.NewGuid():N}.ccmp"), + new CultMeshNodeOptions + { + StartServer = false, + CacheOptions = new CultCacheOpenOptions + { + Registry = sourceCache.Registry, + PullOnOpen = false + }, + DatabaseOptions = new CultNetDatabaseOptions + { + DocumentRegistry = registry + } + }); + var syncedAlias = await surface.SyncDocumentAsync(node, key.Value); var aliasHandle = surface.Document(key.Value); var aliasLatest = await aliasHandle.LatestAsync(); var catalog = surface.Documents(surface.Document(key.Value)); var catalogAlias = await catalog.LatestAsync(); fetchedAlias.Text.Should().Be("snapshot-endpoint"); + syncedAlias.Text.Should().Be("snapshot-endpoint"); + node.Cache.Get(key)!.Revision.Should().Be(19); aliasLatest.Revision.Should().Be(19); catalogAlias.Text.Should().Be("snapshot-endpoint"); - requests.Should().HaveCount(3); + requests.Should().HaveCount(4); requests.Should().OnlyContain(request => request.SchemaIds!.SequenceEqual(new[] {