From c8fd765aeffc2b0677c765820ce5e082e8ecebe9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 18:34:45 +0100 Subject: [PATCH] Add synced snapshot endpoint handles --- src/GameCult.Mesh/CultMeshSnapshots.cs | 61 +++++++++++++++++++ .../CultMeshStreamingTests.cs | 24 +++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 62978b35..2515b4e9 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -99,6 +99,9 @@ namespace GameCult.Mesh /// Binds this document request to an endpoint-backed handle. ICultMeshDocumentHandle Bind(CultMeshSnapshotEndpoint endpoint); + + /// Binds this document request to an endpoint-backed handle that syncs into a local node before returning values. + ICultMeshDocumentHandle BindSynced(CultMeshSnapshotEndpoint endpoint, CultMeshNode node, bool flush); } /// @@ -130,6 +133,13 @@ namespace GameCult.Mesh if (endpoint == null) throw new ArgumentNullException(nameof(endpoint)); return endpoint.Document(RecordKey, DocumentId); } + + /// + public ICultMeshDocumentHandle BindSynced(CultMeshSnapshotEndpoint endpoint, CultMeshNode node, bool flush) + { + if (endpoint == null) throw new ArgumentNullException(nameof(endpoint)); + return endpoint.SyncedDocument(node, RecordKey, DocumentId, flush); + } } /// @@ -284,6 +294,34 @@ namespace GameCult.Mesh $"CultNet snapshot endpoint '{Endpoint}' did not return {typeof(TDocument).FullName} record '{recordKey}'."); } + /// Creates a typed document handle that fetches one endpoint record and syncs it into a local node before returning values. + public CultMeshDocumentHandle SyncedDocument( + CultMeshNode node, + string recordKey, + string? documentId = null, + bool flush = false) + where TDocument : class + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey)); + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var sources = new[] + { + CultMesh.ProjectionSource($"{SourceId}:{recordKey}", descriptor.SchemaId, "synced CultNet snapshot endpoint") + }; + var watch = CultMesh.PollingQueryWatcher( + async (_parameters, _context) => await SyncDocumentAsync(node, recordKey, flush).ConfigureAwait(false), + new CultMeshPollingWatchOptions(PollInterval)); + + return CultMesh.Document( + string.IsNullOrWhiteSpace(documentId) ? recordKey : documentId!, + Context, + _ => SyncDocumentAsync(node, recordKey, flush), + queryContext => watch(CultMeshDocumentQueryParameters.Empty, queryContext), + sources, + RouteHint); + } + /// Creates a typed document handle over one remote endpoint record. public CultMeshDocumentHandle Document( string recordKey, @@ -326,6 +364,29 @@ namespace GameCult.Mesh })); } + /// Creates a schema-aware catalog from typed endpoint bindings that sync into a local node before returning values. + public CultMeshDocumentCatalog SyncedDocuments( + CultMeshNode node, + params ICultMeshSnapshotDocumentBinding[] documents) + { + return SyncedDocuments(node, flush: false, documents); + } + + /// Creates a schema-aware catalog from typed endpoint bindings that sync into a local node before returning values. + public CultMeshDocumentCatalog SyncedDocuments( + CultMeshNode node, + bool flush = false, + params ICultMeshSnapshotDocumentBinding[] documents) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (documents == null) throw new ArgumentNullException(nameof(documents)); + return CultMesh.Documents(documents.Select(document => + { + if (document == null) throw new ArgumentNullException(nameof(documents)); + return document.BindSynced(this, node, flush); + })); + } + private CultMeshSnapshotRequestOptions CreateRequest( IReadOnlyList? schemaIds, IReadOnlyList? recordKeys) diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 01443beb..65ce4752 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -1224,14 +1224,34 @@ public sealed class CultMeshStreamingTests var catalog = surface.Documents( CultMesh.SnapshotDocument(key.Value, "daemon:mesh-note:snapshot-endpoint")); var catalogAlias = await catalog.LatestAsync(); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "snapshot-endpoint-synced", + Revision = 20 + }, new CultRecordHandle(key)); + var syncedHandle = surface.SyncedDocument(node, key.Value); + var syncedLatest = await syncedHandle.LatestAsync(); + await sourceCache.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "snapshot-endpoint-catalog-synced", + Revision = 21 + }, new CultRecordHandle(key)); + var syncedCatalog = surface.SyncedDocuments( + node, + CultMesh.SnapshotDocument(key.Value)); + var syncedCatalogAlias = await syncedCatalog.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); catalog.Document().DocumentId.Should().Be("daemon:mesh-note:snapshot-endpoint"); catalogAlias.Text.Should().Be("snapshot-endpoint"); - requests.Should().HaveCount(4); + syncedLatest.Text.Should().Be("snapshot-endpoint-synced"); + node.Cache.Get(key)!.Revision.Should().Be(21); + syncedCatalogAlias.Text.Should().Be("snapshot-endpoint-catalog-synced"); + requests.Should().HaveCount(6); requests.Should().OnlyContain(request => request.SchemaIds!.SequenceEqual(new[] {