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[]
{