Add CultMesh collection handles
This commit is contained in:
parent
df079f2cab
commit
eb52b56088
5 changed files with 593 additions and 0 deletions
|
|
@ -1136,6 +1136,215 @@ namespace GameCult.Mesh
|
|||
return new CultMeshDocumentCatalog(documents);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed collection handle over all local CultCache records assignable to the document type.
|
||||
/// </summary>
|
||||
public static CultMeshCollectionHandle<TDocument> Collection<TDocument>(
|
||||
CultCache cache,
|
||||
string? collectionId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (cache == null) throw new ArgumentNullException(nameof(cache));
|
||||
|
||||
var descriptor = cache.Registry.GetRequired<TDocument>();
|
||||
var sourceList = sources?.ToArray()
|
||||
?? new[] { ProjectionSource(CollectionId(collectionId, descriptor), descriptor.SchemaId, "CultCache collection") };
|
||||
var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "CultCache collection");
|
||||
return new CultMeshCollectionHandle<TDocument>(
|
||||
CollectionId(collectionId, descriptor),
|
||||
() => Task.FromResult<IReadOnlyList<TDocument>>(cache.GetAll<TDocument>().ToArray()),
|
||||
() => cache.Watch<TDocument>().Select(change => ToCollectionChange(change, descriptor.SchemaId)),
|
||||
sourceList,
|
||||
route);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed collection handle over the local CultCache record with the supplied CultName.
|
||||
/// </summary>
|
||||
public static CultMeshCollectionHandle<TDocument> CollectionByName<TDocument>(
|
||||
CultCache cache,
|
||||
string name,
|
||||
string? collectionId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (cache == null) throw new ArgumentNullException(nameof(cache));
|
||||
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value must be non-empty.", nameof(name));
|
||||
|
||||
var descriptor = cache.Registry.GetRequired<TDocument>();
|
||||
var resolvedId = collectionId ?? $"{descriptor.SchemaId}:name:{name}";
|
||||
var sourceList = sources?.ToArray()
|
||||
?? new[] { ProjectionSource(resolvedId, descriptor.SchemaId, "CultCache named collection") };
|
||||
var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "CultCache named collection");
|
||||
return new CultMeshCollectionHandle<TDocument>(
|
||||
resolvedId,
|
||||
() => Task.FromResult<IReadOnlyList<TDocument>>(Optional(cache.GetByName<TDocument>(name))),
|
||||
() => cache.Watch<TDocument>()
|
||||
.Where(change => MatchesName(cache, name, change))
|
||||
.Select(change => ToCollectionChange(change, descriptor.SchemaId)),
|
||||
sourceList,
|
||||
route);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed collection handle over local CultCache records matched by an indexed value.
|
||||
/// </summary>
|
||||
public static CultMeshCollectionHandle<TDocument> CollectionByIndex<TDocument>(
|
||||
CultCache cache,
|
||||
string alias,
|
||||
string value,
|
||||
string? collectionId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (cache == null) throw new ArgumentNullException(nameof(cache));
|
||||
if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentException("Value must be non-empty.", nameof(alias));
|
||||
if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Value must be non-empty.", nameof(value));
|
||||
|
||||
var descriptor = cache.Registry.GetRequired<TDocument>();
|
||||
var resolvedId = collectionId ?? $"{descriptor.SchemaId}:index:{alias}:{value}";
|
||||
var sourceList = sources?.ToArray()
|
||||
?? new[] { ProjectionSource(resolvedId, descriptor.SchemaId, "CultCache indexed collection") };
|
||||
var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "CultCache indexed collection");
|
||||
return new CultMeshCollectionHandle<TDocument>(
|
||||
resolvedId,
|
||||
() => Task.FromResult<IReadOnlyList<TDocument>>(Optional(cache.GetByIndex<TDocument>(alias, value))),
|
||||
() => cache.Watch<TDocument>()
|
||||
.Where(change => MatchesIndex(cache, alias, value, change))
|
||||
.Select(change => ToCollectionChange(change, descriptor.SchemaId)),
|
||||
sourceList,
|
||||
route);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed collection handle over all distributed CultNet database records assignable to the document type.
|
||||
/// </summary>
|
||||
public static CultMeshCollectionHandle<TDocument> Collection<TDocument>(
|
||||
CultNetDatabase database,
|
||||
string? collectionId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (database == null) throw new ArgumentNullException(nameof(database));
|
||||
|
||||
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
|
||||
var sourceList = sources?.ToArray()
|
||||
?? new[] { ProjectionSource(CollectionId(collectionId, descriptor), descriptor.SchemaId, "CultNet database collection") };
|
||||
var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.Automatic, "CultNet database collection");
|
||||
return new CultMeshCollectionHandle<TDocument>(
|
||||
CollectionId(collectionId, descriptor),
|
||||
() => Task.FromResult<IReadOnlyList<TDocument>>(database.GetAll<TDocument>().ToArray()),
|
||||
() => database.Watch<TDocument>().Select(ToCollectionChange),
|
||||
sourceList,
|
||||
route);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed collection handle over the distributed CultNet database record with the supplied CultName.
|
||||
/// </summary>
|
||||
public static CultMeshCollectionHandle<TDocument> CollectionByName<TDocument>(
|
||||
CultNetDatabase database,
|
||||
string name,
|
||||
string? collectionId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (database == null) throw new ArgumentNullException(nameof(database));
|
||||
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value must be non-empty.", nameof(name));
|
||||
|
||||
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
|
||||
var resolvedId = collectionId ?? $"{descriptor.SchemaId}:name:{name}";
|
||||
var sourceList = sources?.ToArray()
|
||||
?? new[] { ProjectionSource(resolvedId, descriptor.SchemaId, "CultNet database named collection") };
|
||||
var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.Automatic, "CultNet database named collection");
|
||||
return new CultMeshCollectionHandle<TDocument>(
|
||||
resolvedId,
|
||||
() => Task.FromResult<IReadOnlyList<TDocument>>(Optional(database.GetByName<TDocument>(name))),
|
||||
() => database.WatchByName<TDocument>(name).Select(ToCollectionChange),
|
||||
sourceList,
|
||||
route);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed collection handle over distributed CultNet database records matched by an indexed value.
|
||||
/// </summary>
|
||||
public static CultMeshCollectionHandle<TDocument> CollectionByIndex<TDocument>(
|
||||
CultNetDatabase database,
|
||||
string alias,
|
||||
string value,
|
||||
string? collectionId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (database == null) throw new ArgumentNullException(nameof(database));
|
||||
if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentException("Value must be non-empty.", nameof(alias));
|
||||
if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Value must be non-empty.", nameof(value));
|
||||
|
||||
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
|
||||
var resolvedId = collectionId ?? $"{descriptor.SchemaId}:index:{alias}:{value}";
|
||||
var sourceList = sources?.ToArray()
|
||||
?? new[] { ProjectionSource(resolvedId, descriptor.SchemaId, "CultNet database indexed collection") };
|
||||
var route = routeHint ?? new CultMeshRouteHint(CultMeshLocalityKind.Automatic, "CultNet database indexed collection");
|
||||
return new CultMeshCollectionHandle<TDocument>(
|
||||
resolvedId,
|
||||
() => Task.FromResult<IReadOnlyList<TDocument>>(Optional(database.GetByIndex<TDocument>(alias, value))),
|
||||
() => database.WatchByIndex<TDocument>(alias, value).Select(ToCollectionChange),
|
||||
sourceList,
|
||||
route);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed collection handle over all CultMesh node database records assignable to the document type.
|
||||
/// </summary>
|
||||
public static CultMeshCollectionHandle<TDocument> Collection<TDocument>(
|
||||
CultMeshNode node,
|
||||
string? collectionId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (node == null) throw new ArgumentNullException(nameof(node));
|
||||
return Collection<TDocument>(node.Database, collectionId, sources, routeHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed collection handle over the CultMesh node database record with the supplied CultName.
|
||||
/// </summary>
|
||||
public static CultMeshCollectionHandle<TDocument> CollectionByName<TDocument>(
|
||||
CultMeshNode node,
|
||||
string name,
|
||||
string? collectionId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (node == null) throw new ArgumentNullException(nameof(node));
|
||||
return CollectionByName<TDocument>(node.Database, name, collectionId, sources, routeHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed collection handle over CultMesh node database records matched by an indexed value.
|
||||
/// </summary>
|
||||
public static CultMeshCollectionHandle<TDocument> CollectionByIndex<TDocument>(
|
||||
CultMeshNode node,
|
||||
string alias,
|
||||
string value,
|
||||
string? collectionId = null,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (node == null) throw new ArgumentNullException(nameof(node));
|
||||
return CollectionByIndex<TDocument>(node.Database, alias, value, collectionId, sources, routeHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a typed live feed surface for tooling, UI, and diagnostics.
|
||||
/// </summary>
|
||||
|
|
@ -1639,6 +1848,86 @@ namespace GameCult.Mesh
|
|||
: documentId!;
|
||||
}
|
||||
|
||||
private static string CollectionId(string? collectionId, CultDocumentDescriptor descriptor)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(collectionId)
|
||||
? descriptor.SchemaId
|
||||
: collectionId!;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TDocument> Optional<TDocument>(TDocument? document)
|
||||
where TDocument : class
|
||||
{
|
||||
return document == null
|
||||
? Array.Empty<TDocument>()
|
||||
: new[] { document };
|
||||
}
|
||||
|
||||
private static bool MatchesName<TDocument>(
|
||||
CultCache cache,
|
||||
string name,
|
||||
CultCacheDocumentChange<TDocument> change)
|
||||
where TDocument : class
|
||||
{
|
||||
var current = cache.GetByName<TDocument>(name);
|
||||
return (change.Document != null && ReferenceEquals(current, change.Document)) ||
|
||||
(change.PreviousDocument != null && ReferenceEquals(current, change.PreviousDocument));
|
||||
}
|
||||
|
||||
private static bool MatchesIndex<TDocument>(
|
||||
CultCache cache,
|
||||
string alias,
|
||||
string value,
|
||||
CultCacheDocumentChange<TDocument> change)
|
||||
where TDocument : class
|
||||
{
|
||||
var current = cache.GetByIndex<TDocument>(alias, value);
|
||||
return (change.Document != null && ReferenceEquals(current, change.Document)) ||
|
||||
(change.PreviousDocument != null && ReferenceEquals(current, change.PreviousDocument));
|
||||
}
|
||||
|
||||
private static CultMeshCollectionChange<TDocument> ToCollectionChange<TDocument>(
|
||||
CultCacheDocumentChange<TDocument> change,
|
||||
string schemaId)
|
||||
where TDocument : class
|
||||
{
|
||||
return new CultMeshCollectionChange<TDocument>(
|
||||
change.Kind switch
|
||||
{
|
||||
CultCacheDocumentChangeKind.Added => CultMeshCollectionChangeKind.Added,
|
||||
CultCacheDocumentChangeKind.Updated => CultMeshCollectionChangeKind.Updated,
|
||||
CultCacheDocumentChangeKind.Removed => CultMeshCollectionChangeKind.Removed,
|
||||
_ => CultMeshCollectionChangeKind.Updated
|
||||
},
|
||||
change.Key,
|
||||
schemaId,
|
||||
change.Document,
|
||||
change.PreviousDocument);
|
||||
}
|
||||
|
||||
private static CultMeshCollectionChange<TDocument> ToCollectionChange<TDocument>(
|
||||
CultNetDatabaseChange<TDocument> change)
|
||||
where TDocument : class
|
||||
{
|
||||
return new CultMeshCollectionChange<TDocument>(
|
||||
change.Kind switch
|
||||
{
|
||||
CultNetDatabaseChangeKind.Added => CultMeshCollectionChangeKind.Added,
|
||||
CultNetDatabaseChangeKind.Updated => CultMeshCollectionChangeKind.Updated,
|
||||
CultNetDatabaseChangeKind.Removed => CultMeshCollectionChangeKind.Removed,
|
||||
CultNetDatabaseChangeKind.Predicted => CultMeshCollectionChangeKind.Predicted,
|
||||
CultNetDatabaseChangeKind.Reconciled => CultMeshCollectionChangeKind.Reconciled,
|
||||
CultNetDatabaseChangeKind.SchemaMigrated => CultMeshCollectionChangeKind.SchemaMigrated,
|
||||
CultNetDatabaseChangeKind.Rejected => CultMeshCollectionChangeKind.Rejected,
|
||||
_ => CultMeshCollectionChangeKind.Updated
|
||||
},
|
||||
change.Key,
|
||||
change.SchemaId,
|
||||
change.Document,
|
||||
change.PreviousDocument,
|
||||
change.Message);
|
||||
}
|
||||
|
||||
private static TDocument ReadRequired<TDocument>(CultCache cache, CultRecordKey key)
|
||||
where TDocument : class
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1155,6 +1155,182 @@ namespace GameCult.Mesh
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes how a CultMesh collection document changed.
|
||||
/// </summary>
|
||||
public enum CultMeshCollectionChangeKind
|
||||
{
|
||||
/// <summary>A document was added.</summary>
|
||||
Added,
|
||||
/// <summary>A document was updated.</summary>
|
||||
Updated,
|
||||
/// <summary>A document was removed.</summary>
|
||||
Removed,
|
||||
/// <summary>A local prediction was published before authority accepted it.</summary>
|
||||
Predicted,
|
||||
/// <summary>A predicted document was reconciled with authority.</summary>
|
||||
Reconciled,
|
||||
/// <summary>A document was accepted through schema migration.</summary>
|
||||
SchemaMigrated,
|
||||
/// <summary>A document change was rejected.</summary>
|
||||
Rejected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One typed document change observed through a CultMesh collection handle.
|
||||
/// </summary>
|
||||
public sealed class CultMeshCollectionChange<TDocument>
|
||||
where TDocument : class
|
||||
{
|
||||
/// <summary>Creates a collection change.</summary>
|
||||
public CultMeshCollectionChange(
|
||||
CultMeshCollectionChangeKind kind,
|
||||
CultRecordKey key,
|
||||
string schemaId,
|
||||
TDocument? document,
|
||||
TDocument? previousDocument,
|
||||
string? message = null)
|
||||
{
|
||||
Kind = kind;
|
||||
Key = key;
|
||||
SchemaId = schemaId ?? "";
|
||||
Document = document;
|
||||
PreviousDocument = previousDocument;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
/// <summary>Gets the change kind.</summary>
|
||||
public CultMeshCollectionChangeKind Kind { get; }
|
||||
|
||||
/// <summary>Gets the changed record key.</summary>
|
||||
public CultRecordKey Key { get; }
|
||||
|
||||
/// <summary>Gets the schema id of the changed document.</summary>
|
||||
public string SchemaId { get; }
|
||||
|
||||
/// <summary>Gets the current document, when present.</summary>
|
||||
public TDocument? Document { get; }
|
||||
|
||||
/// <summary>Gets the previous document, when present.</summary>
|
||||
public TDocument? PreviousDocument { get; }
|
||||
|
||||
/// <summary>Gets an optional rejection or diagnostic message.</summary>
|
||||
public string? Message { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed reactive collection handle with schema-aware alias conversion.
|
||||
/// </summary>
|
||||
public sealed class CultMeshCollectionHandle<TDocument>
|
||||
where TDocument : class
|
||||
{
|
||||
private static readonly CultDocumentDescriptor Descriptor =
|
||||
CultDocumentRegistry.Shared.GetRequired<TDocument>();
|
||||
|
||||
private readonly Func<Task<IReadOnlyList<TDocument>>> _latest;
|
||||
private readonly Func<Observable<CultMeshCollectionChange<TDocument>>> _watchChanges;
|
||||
|
||||
/// <summary>Creates a collection handle.</summary>
|
||||
public CultMeshCollectionHandle(
|
||||
string collectionId,
|
||||
Func<Task<IReadOnlyList<TDocument>>> latest,
|
||||
Func<Observable<CultMeshCollectionChange<TDocument>>> watchChanges,
|
||||
IEnumerable<CultMeshProjectionSource>? sources = null,
|
||||
CultMeshRouteHint? routeHint = null)
|
||||
{
|
||||
CollectionId = string.IsNullOrWhiteSpace(collectionId)
|
||||
? throw new ArgumentException("Value must be non-empty.", nameof(collectionId))
|
||||
: collectionId;
|
||||
_latest = latest ?? throw new ArgumentNullException(nameof(latest));
|
||||
_watchChanges = watchChanges ?? throw new ArgumentNullException(nameof(watchChanges));
|
||||
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
||||
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
||||
}
|
||||
|
||||
/// <summary>Gets the semantic collection id.</summary>
|
||||
public string CollectionId { get; }
|
||||
|
||||
/// <summary>Gets the CLR document type presented by this collection.</summary>
|
||||
public Type DocumentType => typeof(TDocument);
|
||||
|
||||
/// <summary>Gets the stable CultCache schema name.</summary>
|
||||
public string SchemaName => Descriptor.SchemaName;
|
||||
|
||||
/// <summary>Gets the stable CultCache schema version.</summary>
|
||||
public string SchemaVersion => Descriptor.SchemaVersion;
|
||||
|
||||
/// <summary>Gets the content-derived schema identifier.</summary>
|
||||
public string SchemaId => Descriptor.SchemaId;
|
||||
|
||||
/// <summary>Gets the preferred or observed route for collection access.</summary>
|
||||
public CultMeshRouteHint RouteHint { get; }
|
||||
|
||||
/// <summary>Gets typed state sources this collection handle depends on, when known.</summary>
|
||||
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
||||
|
||||
/// <summary>Reads one coherent collection snapshot.</summary>
|
||||
public Task<IReadOnlyList<TDocument>> LatestAsync()
|
||||
{
|
||||
return _latest();
|
||||
}
|
||||
|
||||
/// <summary>Watches typed collection changes.</summary>
|
||||
public Observable<CultMeshCollectionChange<TDocument>> WatchChanges()
|
||||
{
|
||||
return _watchChanges();
|
||||
}
|
||||
|
||||
/// <summary>Subscribes to typed collection changes.</summary>
|
||||
public IDisposable WatchChanges(Action<CultMeshCollectionChange<TDocument>> onNext)
|
||||
{
|
||||
if (onNext == null) throw new ArgumentNullException(nameof(onNext));
|
||||
return WatchChanges().Subscribe(onNext);
|
||||
}
|
||||
|
||||
/// <summary>Creates a same-schema alias presentation for another CLR document type.</summary>
|
||||
public CultMeshCollectionHandle<TAlias> AsSchemaAlias<TAlias>() where TAlias : class
|
||||
{
|
||||
var aliasDescriptor = CultDocumentRegistry.Shared.GetRequired<TAlias>();
|
||||
if (!string.Equals(Descriptor.SchemaName, aliasDescriptor.SchemaName, StringComparison.Ordinal) ||
|
||||
!string.Equals(Descriptor.SchemaVersion, aliasDescriptor.SchemaVersion, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Document type {typeof(TAlias).FullName} uses schema '{aliasDescriptor.SchemaName}' " +
|
||||
$"version '{aliasDescriptor.SchemaVersion}', but collection '{CollectionId}' exposes " +
|
||||
$"schema '{SchemaName}' version '{SchemaVersion}'.");
|
||||
}
|
||||
|
||||
return new CultMeshCollectionHandle<TAlias>(
|
||||
CollectionId,
|
||||
async () => (await LatestAsync().ConfigureAwait(false))
|
||||
.Select(ConvertDocument<TDocument, TAlias>)
|
||||
.ToArray(),
|
||||
() => WatchChanges().Select(change => new CultMeshCollectionChange<TAlias>(
|
||||
change.Kind,
|
||||
change.Key,
|
||||
change.SchemaId,
|
||||
change.Document == null ? null : ConvertDocument<TDocument, TAlias>(change.Document),
|
||||
change.PreviousDocument == null ? null : ConvertDocument<TDocument, TAlias>(change.PreviousDocument),
|
||||
change.Message)),
|
||||
Sources,
|
||||
RouteHint);
|
||||
}
|
||||
|
||||
private static TTarget ConvertDocument<TSource, TTarget>(TSource document)
|
||||
where TSource : class
|
||||
where TTarget : class
|
||||
{
|
||||
if (document == null) throw new ArgumentNullException(nameof(document));
|
||||
if (document is TTarget alreadyTyped)
|
||||
{
|
||||
return alreadyTyped;
|
||||
}
|
||||
|
||||
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TSource));
|
||||
return (TTarget)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TTarget), payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inspectable metadata for a typed live feed surface.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -176,6 +176,13 @@ portable machinery that makes it feel native.
|
|||
`CultMeshDocumentHandle<TDocument>.AsSchemaAlias<TAlias>()`. Domain facades
|
||||
can expose `Document<T>()`, `DocumentBySchema(...)`, `LatestAsync<T>()`, and
|
||||
`Watch<T>()` without owning schema dictionaries or alias serializers.
|
||||
- `CultMeshCollectionHandle<TDocument>` is the typed reactive edge for
|
||||
multi-record state. It exposes one `LatestAsync()` collection snapshot and a
|
||||
`WatchChanges()` stream while hiding whether the backing source is an
|
||||
in-process `CultCache`, a distributed `CultNetDatabase`, or a `CultMeshNode`.
|
||||
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.
|
||||
- `CultMeshProjectionRecipe<TParameters, TResult>` 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
|
||||
|
|
@ -273,6 +280,20 @@ var uiDocking = documents.Document<CurrentDockingUiDocument>();
|
|||
var bySchema = documents.DocumentBySchema("gamecult.aetheria.station_refit.v1");
|
||||
```
|
||||
|
||||
Typed collections use the same one-call style:
|
||||
|
||||
```csharp
|
||||
var allies = CultMesh.CollectionByIndex<PlayerShipDocument>(
|
||||
node,
|
||||
"Faction",
|
||||
"au");
|
||||
|
||||
var currentAllies = await allies.LatestAsync();
|
||||
using var changes = allies.WatchChanges(change => UpdateRoster(change));
|
||||
|
||||
var uiAllies = allies.AsSchemaAlias<PlayerShipUiDocument>();
|
||||
```
|
||||
|
||||
```ts
|
||||
const queryVerse = verse.withRoute("shared-memory", statePath);
|
||||
const commandVerse = verse
|
||||
|
|
|
|||
|
|
@ -686,6 +686,33 @@ namespace GameCult.Networking
|
|||
return Task.FromResult(_cache.Get<T>(key));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all documents assignable to the requested type.
|
||||
/// </summary>
|
||||
public IEnumerable<T> GetAll<T>() where T : class
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _cache.GetAll<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a typed document by its CultName value.
|
||||
/// </summary>
|
||||
public T? GetByName<T>(string name) where T : class
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _cache.GetByName<T>(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a typed document by an indexed value.
|
||||
/// </summary>
|
||||
public T? GetByIndex<T>(string alias, string value) where T : class
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _cache.GetByIndex<T>(alias, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a reactive POCO presentation for one distributed CultCache document.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -771,6 +771,71 @@ public sealed class CultMeshStreamingTests
|
|||
observed.Revision.Should().Be(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CollectionHandle_ReadsAndWatchesDatabaseCollectionsByIndex()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var database = new CultNetDatabase(cache);
|
||||
var teamId = Guid.NewGuid().ToString("D");
|
||||
var collection = CultMesh.CollectionByIndex<MeshIndexedPlayer>(
|
||||
database,
|
||||
"TeamId",
|
||||
teamId);
|
||||
var changes = new List<CultMeshCollectionChange<MeshIndexedPlayer>>();
|
||||
using var subscription = collection.WatchChanges(change => changes.Add(change));
|
||||
|
||||
await database.PutAsync(
|
||||
new CultRecordKey("player:one"),
|
||||
new MeshIndexedPlayer
|
||||
{
|
||||
Name = "One",
|
||||
TeamId = teamId,
|
||||
Score = 10
|
||||
});
|
||||
await database.PutAsync(
|
||||
new CultRecordKey("player:two"),
|
||||
new MeshIndexedPlayer
|
||||
{
|
||||
Name = "Two",
|
||||
TeamId = Guid.NewGuid().ToString("D"),
|
||||
Score = 20
|
||||
});
|
||||
|
||||
collection.CollectionId.Should().Contain(teamId);
|
||||
collection.SchemaName.Should().Be("tests.mesh_indexed_player");
|
||||
collection.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Automatic);
|
||||
collection.Sources.Should().ContainSingle().Which.SchemaId.Should().Be(collection.SchemaId);
|
||||
|
||||
var snapshot = await collection.LatestAsync();
|
||||
snapshot.Should().ContainSingle().Which.Name.Should().Be("One");
|
||||
changes.Should().ContainSingle();
|
||||
changes[0].Kind.Should().Be(CultMeshCollectionChangeKind.Added);
|
||||
changes[0].Document!.Name.Should().Be("One");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CollectionHandle_SupportsSameSchemaAliases()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
await cache.UpsertAsync(
|
||||
new MeshNoteDocument
|
||||
{
|
||||
Schema = "tests.mesh_note.v1",
|
||||
Text = "collection-alias",
|
||||
Revision = 4
|
||||
},
|
||||
new CultRecordHandle<MeshNoteDocument>(new CultRecordKey("mesh-note:collection")));
|
||||
|
||||
var collection = CultMesh.Collection<MeshNoteDocument>(cache);
|
||||
var alias = collection.AsSchemaAlias<MeshNoteAliasDocument>();
|
||||
|
||||
alias.CollectionId.Should().Be(collection.CollectionId);
|
||||
alias.SchemaName.Should().Be(collection.SchemaName);
|
||||
|
||||
var snapshot = await alias.LatestAsync();
|
||||
snapshot.Should().ContainSingle().Which.Text.Should().Be("collection-alias");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentHandle_ReplacesThroughCultMeshNodeDatabase()
|
||||
{
|
||||
|
|
@ -1193,6 +1258,21 @@ public sealed class CultMeshStreamingTests
|
|||
public int Health;
|
||||
}
|
||||
|
||||
[CultDocument("tests.mesh_indexed_player", "tests.mesh_indexed_player.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class MeshIndexedPlayer
|
||||
{
|
||||
[Key(0)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
public string TeamId { get; set; } = string.Empty;
|
||||
|
||||
[Key(2)]
|
||||
public int Score { get; set; }
|
||||
}
|
||||
|
||||
[CultDocument("tests.mesh_note", "tests.mesh_note.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class MeshNoteDocument
|
||||
|
|
|
|||
Loading…
Reference in a new issue