diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs
index 27c0aa0c..29b47fb6 100644
--- a/src/GameCult.Mesh/CultMesh.cs
+++ b/src/GameCult.Mesh/CultMesh.cs
@@ -1782,6 +1782,22 @@ namespace GameCult.Mesh
return new CultMeshDocumentCatalog(documents);
}
+ ///
+ /// Creates a schema-aware catalog over typed collection handles.
+ ///
+ public static CultMeshCollectionCatalog Collections(IEnumerable collections)
+ {
+ return new CultMeshCollectionCatalog(collections);
+ }
+
+ ///
+ /// Creates a schema-aware catalog over typed collection handles.
+ ///
+ public static CultMeshCollectionCatalog Collections(params ICultMeshCollectionHandle[] collections)
+ {
+ return new CultMeshCollectionCatalog(collections);
+ }
+
///
/// Reads two typed document handles and projects them into one caller-defined view.
///
diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs
index f45a9ae8..b6705f20 100644
--- a/src/GameCult.Mesh/CultMeshPrimitives.cs
+++ b/src/GameCult.Mesh/CultMeshPrimitives.cs
@@ -1763,6 +1763,163 @@ namespace GameCult.Mesh
}
}
+ ///
+ /// Inspectable typed collection handle exposed by CultMesh.
+ ///
+ public interface ICultMeshCollectionHandle
+ {
+ /// Gets the CLR document type presented by this collection.
+ Type DocumentType { get; }
+
+ /// Gets the semantic collection id.
+ string CollectionId { get; }
+
+ /// Gets the stable CultCache schema name.
+ string SchemaName { get; }
+
+ /// Gets the stable CultCache schema version.
+ string SchemaVersion { get; }
+
+ /// Gets the content-derived schema identifier.
+ string SchemaId { get; }
+
+ /// Gets the preferred or observed route for collection access.
+ CultMeshRouteHint RouteHint { get; }
+
+ /// Gets typed state sources this collection handle depends on, when known.
+ IReadOnlyList Sources { get; }
+
+ /// Creates a same-schema alias presentation for another CLR document type.
+ CultMeshCollectionHandle AsSchemaAlias() where TAlias : class;
+ }
+
+ ///
+ /// Schema-aware lookup table for typed CultMesh collection handles.
+ ///
+ public sealed class CultMeshCollectionCatalog
+ {
+ private readonly IReadOnlyList _collections;
+ private readonly IReadOnlyDictionary _collectionsByType;
+ private readonly IReadOnlyDictionary _collectionsBySchema;
+
+ /// Creates a collection catalog from known handles.
+ public CultMeshCollectionCatalog(IEnumerable collections)
+ {
+ if (collections == null) throw new ArgumentNullException(nameof(collections));
+
+ var collectionList = collections
+ .Where(collection => collection != null)
+ .ToArray();
+ var byType = new Dictionary();
+ var bySchema = new Dictionary(StringComparer.Ordinal);
+ foreach (var collection in collectionList)
+ {
+ byType[collection.DocumentType] = collection;
+ if (!string.IsNullOrWhiteSpace(collection.SchemaId))
+ bySchema[collection.SchemaId] = collection;
+ if (!string.IsNullOrWhiteSpace(collection.SchemaVersion))
+ bySchema[collection.SchemaVersion] = collection;
+ if (!string.IsNullOrWhiteSpace(collection.SchemaName))
+ bySchema[collection.SchemaName] = collection;
+ }
+
+ _collections = collectionList;
+ _collectionsByType = byType;
+ _collectionsBySchema = bySchema;
+ }
+
+ /// Gets the handles in this catalog.
+ public IReadOnlyList Collections => _collections;
+
+ /// Looks up a collection handle by schema id, shared schema name, or schema version.
+ public bool TryGetCollectionBySchema(
+ string schema,
+ out ICultMeshCollectionHandle collection)
+ {
+ if (!string.IsNullOrWhiteSpace(schema) &&
+ _collectionsBySchema.TryGetValue(schema, out collection!))
+ {
+ return true;
+ }
+
+ collection = null!;
+ return false;
+ }
+
+ /// Looks up a collection handle by schema id, shared schema name, or schema version.
+ public ICultMeshCollectionHandle CollectionBySchema(string schema)
+ {
+ if (TryGetCollectionBySchema(schema, out var collection))
+ return collection;
+
+ throw new NotSupportedException(
+ $"CultMesh collection catalog does not expose a collection for schema '{schema}'.");
+ }
+
+ /// Looks up a typed collection handle by CLR type or same-schema alias.
+ public bool TryGetCollection(
+ out CultMeshCollectionHandle collection)
+ where TDocument : class
+ {
+ if (_collectionsByType.TryGetValue(typeof(TDocument), out var untypedCollection) &&
+ untypedCollection is CultMeshCollectionHandle typedCollection)
+ {
+ collection = typedCollection;
+ return true;
+ }
+
+ var descriptor = CultDocumentRegistry.Shared.GetRequired();
+ if (_collectionsBySchema.TryGetValue(descriptor.SchemaId, out var schemaCollection) ||
+ _collectionsBySchema.TryGetValue(descriptor.SchemaVersion, out schemaCollection!) ||
+ _collectionsBySchema.TryGetValue(descriptor.SchemaName, out schemaCollection!))
+ {
+ if (schemaCollection is CultMeshCollectionHandle schemaTypedCollection)
+ {
+ collection = schemaTypedCollection;
+ return true;
+ }
+
+ collection = schemaCollection.AsSchemaAlias();
+ return true;
+ }
+
+ collection = null!;
+ return false;
+ }
+
+ /// Looks up a typed collection handle by CLR type or same-schema alias.
+ public CultMeshCollectionHandle Collection()
+ where TDocument : class
+ {
+ if (TryGetCollection(out var collection))
+ return collection;
+
+ throw new NotSupportedException(
+ $"CultMesh collection catalog does not expose a collection for {typeof(TDocument).FullName}.");
+ }
+
+ /// Reads one typed collection snapshot by CLR type or same-schema alias.
+ public Task> LatestAsync()
+ where TDocument : class
+ {
+ return Collection().LatestAsync();
+ }
+
+ /// Watches one typed collection by CLR type or same-schema alias.
+ public Observable> WatchChanges()
+ where TDocument : class
+ {
+ return Collection().WatchChanges();
+ }
+
+ /// Subscribes to one typed collection by CLR type or same-schema alias.
+ public IDisposable WatchChanges(Action> onNext)
+ where TDocument : class
+ {
+ return Collection().WatchChanges(onNext);
+ }
+ }
+
///
/// Describes how a CultMesh collection document changed.
///
@@ -1829,7 +1986,7 @@ namespace GameCult.Mesh
///
/// Typed reactive collection handle with schema-aware alias conversion.
///
- public sealed class CultMeshCollectionHandle
+ public sealed class CultMeshCollectionHandle : ICultMeshCollectionHandle
where TDocument : class
{
private static readonly CultDocumentDescriptor Descriptor =
diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md
index 1b592748..3ff86524 100644
--- a/src/GameCult.Mesh/docs/public-api.md
+++ b/src/GameCult.Mesh/docs/public-api.md
@@ -191,6 +191,12 @@ portable machinery that makes it feel native.
Collection handles can represent all records of a document type, a named
document view, or an indexed value view, and they support the same
same-schema CLR alias conversion as document handles.
+- `CultMeshCollectionCatalog` is the collection sibling of
+ `CultMeshDocumentCatalog`. It indexes typed collection handles by CLR
+ document type, schema id, schema name, and schema version, then exposes
+ `Collection()`, `LatestAsync()`, and `WatchChanges()` so domain
+ facades can publish typed multi-record state without maintaining parallel
+ schema lookup tables.
- `CultMeshProjectionRecipe` names a reusable projection
from typed source state into derived state. It records source handles,
route hints, and projection execution, and can be exposed as a typed query
@@ -321,6 +327,10 @@ var currentAllies = await allies.LatestAsync();
using var changes = allies.WatchChanges(change => UpdateRoster(change));
var uiAllies = allies.AsSchemaAlias();
+
+var collections = CultMesh.Collections(allies);
+var uiRoster = await collections.LatestAsync();
+using var uiRosterChanges = collections.WatchChanges(RenderRoster);
```
```ts
diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs
index 1ba030f3..bfdd3f0e 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs
@@ -2665,12 +2665,40 @@ public sealed class CultMeshStreamingTests
var collection = CultMesh.Collection(cache);
var alias = collection.AsSchemaAlias();
+ var catalog = CultMesh.Collections(collection);
alias.CollectionId.Should().Be(collection.CollectionId);
alias.SchemaName.Should().Be(collection.SchemaName);
+ catalog.Collections.Should().ContainSingle().Which.Should().BeSameAs(collection);
+ catalog.TryGetCollection(out var catalogAlias).Should().BeTrue();
+ catalogAlias.CollectionId.Should().Be(collection.CollectionId);
+ catalog.TryGetCollectionBySchema("tests.mesh_note.v1", out var byVersion).Should().BeTrue();
+ byVersion.Should().BeSameAs(collection);
+ catalog.CollectionBySchema("tests.mesh_note").Should().BeSameAs(collection);
+ catalog.CollectionBySchema(CultDocumentRegistry.Shared.GetRequired().SchemaId)
+ .Should()
+ .BeSameAs(collection);
var snapshot = await alias.LatestAsync();
snapshot.Should().ContainSingle().Which.Text.Should().Be("collection-alias");
+ var catalogSnapshot = await catalog.LatestAsync();
+ catalogSnapshot.Should().ContainSingle().Which.Revision.Should().Be(4);
+
+ CultMeshCollectionChange observed = null!;
+ using var subscription = catalog.WatchChanges(change => observed = change);
+ await cache.UpsertAsync(
+ new MeshNoteDocument
+ {
+ Schema = "tests.mesh_note.v1",
+ Text = "collection-alias-updated",
+ Revision = 5
+ },
+ new CultRecordHandle(new CultRecordKey("mesh-note:collection")));
+
+ observed.Kind.Should().Be(CultMeshCollectionChangeKind.Updated);
+ observed.Document!.Text.Should().Be("collection-alias-updated");
+ observed.Document.Revision.Should().Be(5);
+ observed.PreviousDocument!.Text.Should().Be("collection-alias");
}
[Test]