diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs
index ecc79c49..45694e6a 100644
--- a/src/GameCult.Mesh/CultMesh.cs
+++ b/src/GameCult.Mesh/CultMesh.cs
@@ -1675,6 +1675,55 @@ namespace GameCult.Mesh
return new CultMeshDocumentCatalog(documents);
}
+ ///
+ /// Reads two typed document handles and projects them into one caller-defined view.
+ ///
+ public static async Task LatestAsync(
+ CultMeshDocumentHandle first,
+ CultMeshDocumentHandle second,
+ Func project)
+ where TFirst : class
+ where TSecond : class
+ {
+ if (first == null) throw new ArgumentNullException(nameof(first));
+ if (second == null) throw new ArgumentNullException(nameof(second));
+ if (project == null) throw new ArgumentNullException(nameof(project));
+
+ var firstTask = first.LatestAsync();
+ var secondTask = second.LatestAsync();
+ await Task.WhenAll(firstTask, secondTask).ConfigureAwait(false);
+ return project(
+ await firstTask.ConfigureAwait(false),
+ await secondTask.ConfigureAwait(false));
+ }
+
+ ///
+ /// Reads three typed document handles and projects them into one caller-defined view.
+ ///
+ public static async Task LatestAsync(
+ CultMeshDocumentHandle first,
+ CultMeshDocumentHandle second,
+ CultMeshDocumentHandle third,
+ Func project)
+ where TFirst : class
+ where TSecond : class
+ where TThird : class
+ {
+ if (first == null) throw new ArgumentNullException(nameof(first));
+ if (second == null) throw new ArgumentNullException(nameof(second));
+ if (third == null) throw new ArgumentNullException(nameof(third));
+ if (project == null) throw new ArgumentNullException(nameof(project));
+
+ var firstTask = first.LatestAsync();
+ var secondTask = second.LatestAsync();
+ var thirdTask = third.LatestAsync();
+ await Task.WhenAll(firstTask, secondTask, thirdTask).ConfigureAwait(false);
+ return project(
+ await firstTask.ConfigureAwait(false),
+ await secondTask.ConfigureAwait(false),
+ await thirdTask.ConfigureAwait(false));
+ }
+
///
/// Creates a typed collection handle over all local CultCache records assignable to the document type.
///
diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs
index 356b5dec..6f8fae18 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs
@@ -736,6 +736,37 @@ public sealed class CultMeshStreamingTests
catalog.Latest().Text.Should().Be("catalog-primary");
}
+ [Test]
+ public async Task DocumentHelpers_ProjectLatestSnapshotsFromMultipleHandles()
+ {
+ var primary = CultMesh.Document(
+ "mesh.note.primary",
+ CultMesh.Verse("starbridge", "unity-pilot"),
+ _ => Task.FromResult(new MeshNoteDocument
+ {
+ Schema = "tests.mesh_note.v1",
+ Text = "alpha",
+ Revision = 3
+ }),
+ _ => new Subject());
+ var secondary = CultMesh.Document(
+ "mesh.note.secondary",
+ CultMesh.Verse("starbridge", "unity-pilot"),
+ _ => Task.FromResult(new MeshOtherDocument
+ {
+ Schema = "tests.mesh_other.v1",
+ Text = "beta"
+ }),
+ _ => new Subject());
+
+ var projected = await CultMesh.LatestAsync(
+ primary,
+ secondary,
+ (first, second) => $"{first.Text}:{first.Revision}:{second.Text}");
+
+ projected.Should().Be("alpha:3:beta");
+ }
+
[Test]
public async Task DocumentHandle_ReadsWatchesAndReplacesCultCacheRecords()
{