From e94cd0973d0b232ab70bdf1b701f52f1dfcb83cb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 27 Jun 2026 23:53:35 +0100 Subject: [PATCH] Add C# publication catalog sync --- src/GameCult.Mesh/CultMesh.cs | 85 +++++++++++++++++++ .../CultMeshStreamingTests.cs | 62 ++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 38fb7954..dab5dd28 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -299,6 +299,14 @@ namespace GameCult.Mesh CultMeshVerseContext context, CultMeshStoreDocumentOptions? storeOptions, CultMeshPeerSnapshotDocumentOptions? peerOptions); + + /// Reads this publication document and hydrates it into a local node. + Task SyncAsync( + CultMeshNode node, + CultMeshDocumentPublicationSource source, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? storeOptions, + CultMeshPeerSnapshotDocumentOptions? peerOptions); } /// @@ -345,6 +353,32 @@ namespace GameCult.Mesh CultMesh.WithPublicationBindingOptions(storeOptions, Key, DocumentId, SourceId), CultMesh.WithPublicationBindingOptions(peerOptions, Key, DocumentId, SourceId)); } + + /// + public async Task SyncAsync( + CultMeshNode node, + CultMeshDocumentPublicationSource source, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? storeOptions, + CultMeshPeerSnapshotDocumentOptions? peerOptions) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + var publication = CultMesh.DocumentFromPublication( + Source ?? source, + Key, + context, + CultMesh.WithPublicationBindingOptions(storeOptions, Key, DocumentId, SourceId), + CultMesh.WithPublicationBindingOptions(peerOptions, Key, DocumentId, SourceId)); + var local = CultMesh.Document( + node, + Key, + context, + string.IsNullOrWhiteSpace(DocumentId) ? Key.Value : DocumentId); + await local.ReplaceAsync(await publication.LatestAsync().ConfigureAwait(false)).ConfigureAwait(false); + return local; + } } /// /// A locally hosted CultMesh node over CultCache, CultNet transport, and the mesh database facade. @@ -1667,6 +1701,57 @@ namespace GameCult.Mesh return DocumentsFromPublication(source, bindings, verse.Context, storeOptions, peerOptions); } /// + /// Reads configured publication documents, hydrates them into a local node, and returns local handles as a catalog. + /// + public static async Task SyncDocumentsFromPublicationAsync( + CultMeshNode node, + CultMeshDocumentPublicationSource source, + IEnumerable bindings, + CultMeshVerseContext context, + CultMeshStoreDocumentOptions? storeOptions = null, + CultMeshPeerSnapshotDocumentOptions? peerOptions = null, + bool flush = false) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (source == null) throw new ArgumentNullException(nameof(source)); + if (bindings == null) throw new ArgumentNullException(nameof(bindings)); + if (context == null) throw new ArgumentNullException(nameof(context)); + + var handles = await Task.WhenAll(bindings.Select(binding => + { + if (binding == null) throw new ArgumentException("CultMesh publication bindings cannot contain null.", nameof(bindings)); + return binding.SyncAsync(node, source, context, storeOptions, peerOptions); + })).ConfigureAwait(false); + + if (flush) + await node.FlushAsync().ConfigureAwait(false); + + return Documents(handles); + } + + /// + /// Reads configured publication documents, hydrates them into a local node, and returns local handles as a catalog. + /// + public static Task SyncDocumentsFromPublicationAsync( + CultMeshNode node, + CultMeshDocumentPublicationSource source, + IEnumerable bindings, + CultMeshVerse verse, + CultMeshStoreDocumentOptions? storeOptions = null, + CultMeshPeerSnapshotDocumentOptions? peerOptions = null, + bool flush = false) + { + if (verse == null) throw new ArgumentNullException(nameof(verse)); + return SyncDocumentsFromPublicationAsync( + node, + source, + bindings, + verse.Context, + storeOptions, + peerOptions, + flush); + } + /// /// Reads one typed document from a configured publication source and hydrates it into a local node. /// public static async Task SyncDocumentFromPublicationAsync( diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index b7db4594..b5f5f308 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -2359,6 +2359,68 @@ public sealed class CultMeshStreamingTests catalog.Document().Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:second"); } + [Test] + public async Task PublicationHelpers_SyncConfiguredDocumentsIntoLocalCatalog() + { + using var directory = new TemporaryDirectory(); + var firstPath = Path.Combine(directory.Path, "sync-first.ccmp"); + var secondPath = Path.Combine(directory.Path, "sync-second.ccmp"); + var firstKey = new CultRecordKey("mesh-note:publication-sync-catalog-first"); + var secondKey = new CultRecordKey("mesh-note:publication-sync-catalog-second"); + var firstWriter = new CultCache(); + firstWriter.AddBackingStore(new SingleFileMessagePackBackingStore(firstPath)); + await firstWriter.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument + { + Schema = "tests.mesh_publication_note.v1", + Text = "first synced source", + Revision = 31 + }, firstKey); + firstWriter.FlushAllBackingStores(); + var secondWriter = new CultCache(); + secondWriter.AddBackingStore(new SingleFileMessagePackBackingStore(secondPath)); + await secondWriter.UpsertAsync(new MeshNoteDocument + { + Schema = "tests.mesh_note.v1", + Text = "second synced source", + Revision = 32 + }, new CultRecordHandle(secondKey)); + secondWriter.FlushAllBackingStores(); + using var node = await CultMesh.CreateNodeAsync( + Path.Combine(directory.Path, "target.ccmp"), + new CultMeshNodeOptions + { + StartServer = false + }); + + var catalog = await CultMesh.SyncDocumentsFromPublicationAsync( + node, + CultMeshDocumentPublicationSource.SingleFile(firstPath), + new ICultMeshPublicationDocumentBinding[] + { + CultMesh.PublicationDocument( + firstKey, + documentId: "local:first"), + CultMesh.PublicationDocument( + secondKey, + documentId: "local:second", + source: CultMeshDocumentPublicationSource.SingleFile(secondPath)) + }, + CultMesh.Verse("starbridge", "unity-sync-catalog"), + new CultMeshStoreDocumentOptions + { + PollInterval = TimeSpan.FromMilliseconds(10) + }); + + catalog.Documents.Select(document => document.DocumentId).Should().Equal("local:first", "local:second"); + (await catalog.LatestAsync()).Text.Should().Be("first synced source"); + (await catalog.LatestAsync()).Text.Should().Be("second synced source"); + node.Cache.Get(firstKey)!.Revision.Should().Be(31); + (await node.Document(secondKey, CultMesh.Verse("starbridge", "unity-sync-catalog")) + .LatestAsync()).Text.Should().Be("second synced source"); + (await node.Document(secondKey, CultMesh.Verse("starbridge", "unity-sync-catalog")) + .LatestAsync()).Revision.Should().Be(32); + } + [Test] public async Task PublicationHelpers_SyncConfiguredPeerSourceIntoLocalNodeAlias() {