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]