4171 lines
166 KiB
C#
4171 lines
166 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Linq.Expressions;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using MessagePack;
|
|
using R3;
|
|
|
|
namespace GameCult.Mesh
|
|
{
|
|
/// <summary>
|
|
/// Describes the route CultMesh used or should prefer for a typed operation, query, pointer, or native view.
|
|
/// </summary>
|
|
public enum CultMeshLocalityKind
|
|
{
|
|
/// <summary>Let CultMesh choose the fastest valid route.</summary>
|
|
Automatic,
|
|
/// <summary>The target is in the same process.</summary>
|
|
InProcess,
|
|
/// <summary>The target is available through a shared CultCache slab or native shared memory view.</summary>
|
|
SharedMemory,
|
|
/// <summary>The target is reachable through local inter-process transport.</summary>
|
|
Ipc,
|
|
/// <summary>The target is reachable through a network Verse route.</summary>
|
|
Network,
|
|
/// <summary>The target is hosted in or exposed to a WebAssembly runtime.</summary>
|
|
Wasm
|
|
}
|
|
|
|
/// <summary>
|
|
/// Names a CultMesh locality decision without exposing transport plumbing to application code.
|
|
/// </summary>
|
|
public sealed class CultMeshRouteHint
|
|
{
|
|
/// <summary>Creates a route hint.</summary>
|
|
public CultMeshRouteHint(CultMeshLocalityKind kind = CultMeshLocalityKind.Automatic, string? description = null)
|
|
{
|
|
Kind = kind;
|
|
Description = description;
|
|
}
|
|
|
|
/// <summary>Gets the requested or observed locality kind.</summary>
|
|
public CultMeshLocalityKind Kind { get; }
|
|
|
|
/// <summary>Gets optional human-facing route diagnostics.</summary>
|
|
public string? Description { get; }
|
|
|
|
/// <summary>Gets the default automatic route hint.</summary>
|
|
public static CultMeshRouteHint Automatic { get; } = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Flat, transport-friendly fields for a CultMesh route hint.
|
|
/// </summary>
|
|
[MessagePackObject]
|
|
[MessagePackFormatter(typeof(CultMeshRouteRecordFormatter))]
|
|
public sealed class CultMeshRouteRecord
|
|
{
|
|
/// <summary>Creates flat route fields.</summary>
|
|
[SerializationConstructor]
|
|
public CultMeshRouteRecord(string? kind = null, string? description = null)
|
|
{
|
|
Kind = kind ?? "";
|
|
Description = description ?? "";
|
|
}
|
|
|
|
/// <summary>Gets the serialized route locality kind.</summary>
|
|
[Key(0)] public string Kind { get; }
|
|
|
|
/// <summary>Gets the serialized route description.</summary>
|
|
[Key(1)] public string Description { get; }
|
|
|
|
/// <summary>Creates flat route fields from a route hint.</summary>
|
|
public static CultMeshRouteRecord FromRoute(CultMeshRouteHint? routeHint)
|
|
{
|
|
var route = routeHint ?? CultMeshRouteHint.Automatic;
|
|
return new CultMeshRouteRecord(route.Kind.ToString(), route.Description);
|
|
}
|
|
|
|
/// <summary>Rehydrates flat route fields into a route hint.</summary>
|
|
public CultMeshRouteHint ToRoute(CultMeshRouteHint? fallback = null)
|
|
{
|
|
var fallbackRoute = fallback ?? CultMeshRouteHint.Automatic;
|
|
return new CultMeshRouteHint(
|
|
ParseKind(Kind, fallbackRoute.Kind),
|
|
string.IsNullOrWhiteSpace(Description) ? fallbackRoute.Description : Description);
|
|
}
|
|
|
|
private static CultMeshLocalityKind ParseKind(string? value, CultMeshLocalityKind fallback)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return fallback;
|
|
|
|
if (Enum.TryParse<CultMeshLocalityKind>(value, ignoreCase: true, out var parsed))
|
|
return parsed;
|
|
|
|
var normalized = value.Replace("-", "", StringComparison.Ordinal);
|
|
foreach (CultMeshLocalityKind candidate in Enum.GetValues(typeof(CultMeshLocalityKind)))
|
|
{
|
|
if (string.Equals(candidate.ToString(), normalized, StringComparison.OrdinalIgnoreCase))
|
|
return candidate;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared Verse invocation context used by generated domain sugar.
|
|
/// </summary>
|
|
public sealed class CultMeshVerseContext
|
|
{
|
|
/// <summary>Creates a Verse context.</summary>
|
|
public CultMeshVerseContext(
|
|
string verseId,
|
|
string runtimeId,
|
|
CultMeshRouteHint? routeHint = null,
|
|
IEnumerable<CultMeshAuthorityClaim>? claims = null)
|
|
{
|
|
VerseId = RequireNonEmpty(verseId, nameof(verseId));
|
|
RuntimeId = RequireNonEmpty(runtimeId, nameof(runtimeId));
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
Claims = claims?.ToArray() ?? Array.Empty<CultMeshAuthorityClaim>();
|
|
}
|
|
|
|
/// <summary>Gets the semantic Verse id.</summary>
|
|
public string VerseId { get; }
|
|
|
|
/// <summary>Gets the runtime using the Verse.</summary>
|
|
public string RuntimeId { get; }
|
|
|
|
/// <summary>Gets the preferred route for generated operations and queries.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets authority claims carried by generated operations.</summary>
|
|
public IReadOnlyList<CultMeshAuthorityClaim> Claims { get; }
|
|
|
|
/// <summary>Returns a copy with one additional authority claim.</summary>
|
|
public CultMeshVerseContext WithClaim(CultMeshAuthorityClaim claim)
|
|
{
|
|
if (claim == null) throw new ArgumentNullException(nameof(claim));
|
|
return new CultMeshVerseContext(VerseId, RuntimeId, RouteHint, Claims.Concat(new[] { claim }));
|
|
}
|
|
|
|
/// <summary>Returns a copy with the supplied route hint.</summary>
|
|
public CultMeshVerseContext WithRoute(CultMeshRouteHint routeHint)
|
|
{
|
|
if (routeHint == null) throw new ArgumentNullException(nameof(routeHint));
|
|
return new CultMeshVerseContext(VerseId, RuntimeId, routeHint, Claims);
|
|
}
|
|
|
|
/// <summary>Creates a typed operation context from the Verse context.</summary>
|
|
public CultMeshOperationContext OperationContext(string? idempotencyKey = null)
|
|
{
|
|
return new CultMeshOperationContext(RuntimeId, Claims, RouteHint, idempotencyKey);
|
|
}
|
|
|
|
/// <summary>Creates a typed query context from the Verse context.</summary>
|
|
public CultMeshQueryContext QueryContext()
|
|
{
|
|
return new CultMeshQueryContext(RuntimeId, RouteHint);
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Managed Verse handle that generated domain packages can bind to.
|
|
/// </summary>
|
|
public sealed class CultMeshVerse
|
|
{
|
|
/// <summary>Creates a managed Verse handle.</summary>
|
|
public CultMeshVerse(CultMeshVerseContext context)
|
|
{
|
|
Context = context ?? throw new ArgumentNullException(nameof(context));
|
|
}
|
|
|
|
/// <summary>Gets the Verse context shared by generated domain sugar.</summary>
|
|
public CultMeshVerseContext Context { get; }
|
|
|
|
/// <summary>Gets the semantic Verse id.</summary>
|
|
public string VerseId => Context.VerseId;
|
|
|
|
/// <summary>Gets the runtime using the Verse.</summary>
|
|
public string RuntimeId => Context.RuntimeId;
|
|
|
|
/// <summary>Binds this Verse to a generated domain facade.</summary>
|
|
public TSchema Use<TSchema>(Func<CultMeshVerseContext, TSchema> schemaFactory)
|
|
{
|
|
if (schemaFactory == null) throw new ArgumentNullException(nameof(schemaFactory));
|
|
return schemaFactory(Context);
|
|
}
|
|
|
|
/// <summary>Returns a Verse handle with one additional authority claim.</summary>
|
|
public CultMeshVerse WithClaim(CultMeshAuthorityClaim claim)
|
|
{
|
|
return new CultMeshVerse(Context.WithClaim(claim));
|
|
}
|
|
|
|
/// <summary>Returns a Verse handle with the supplied route hint.</summary>
|
|
public CultMeshVerse WithRoute(CultMeshRouteHint routeHint)
|
|
{
|
|
return new CultMeshVerse(Context.WithRoute(routeHint));
|
|
}
|
|
|
|
/// <summary>Creates a typed operation context from this Verse.</summary>
|
|
public CultMeshOperationContext OperationContext(string? idempotencyKey = null)
|
|
{
|
|
return Context.OperationContext(idempotencyKey);
|
|
}
|
|
|
|
/// <summary>Creates a typed query context from this Verse.</summary>
|
|
public CultMeshQueryContext QueryContext()
|
|
{
|
|
return Context.QueryContext();
|
|
}
|
|
|
|
/// <summary>Binds an operation handle to this Verse.</summary>
|
|
public CultMeshBoundOperationHandle<TRequest, TResponse> BindOperation<TRequest, TResponse>(
|
|
CultMeshOperationHandle<TRequest, TResponse> operation)
|
|
{
|
|
return new CultMeshBoundOperationHandle<TRequest, TResponse>(Context, operation);
|
|
}
|
|
|
|
/// <summary>Binds a query surface to this Verse.</summary>
|
|
public CultMeshBoundQuerySurface<TParameters, TResult> BindQuery<TParameters, TResult>(
|
|
CultMeshQuerySurface<TParameters, TResult> query)
|
|
{
|
|
return new CultMeshBoundQuerySurface<TParameters, TResult>(Context, query);
|
|
}
|
|
|
|
/// <summary>Binds a live feed to this Verse.</summary>
|
|
public CultMeshBoundLiveFeed<TParameters, TResult> BindLiveFeed<TParameters, TResult>(
|
|
CultMeshLiveFeed<TParameters, TResult> feed)
|
|
{
|
|
return new CultMeshBoundLiveFeed<TParameters, TResult>(Context, feed);
|
|
}
|
|
|
|
/// <summary>Binds a state pointer to this Verse.</summary>
|
|
public CultMeshBoundStatePointer<TValue> BindStatePointer<TValue>(
|
|
CultMeshStatePointer<TValue> pointer)
|
|
{
|
|
return new CultMeshBoundStatePointer<TValue>(Context, pointer);
|
|
}
|
|
|
|
/// <summary>Binds a mutable state pointer to this Verse.</summary>
|
|
public CultMeshBoundMutableStatePointer<TValue> BindMutableStatePointer<TValue>(
|
|
CultMeshMutableStatePointer<TValue> pointer)
|
|
{
|
|
return new CultMeshBoundMutableStatePointer<TValue>(Context, pointer);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Describes a runtime authority claim attached to a typed operation.
|
|
/// </summary>
|
|
public sealed class CultMeshAuthorityClaim
|
|
{
|
|
/// <summary>Creates an authority claim.</summary>
|
|
public CultMeshAuthorityClaim(string role, string? shardId = null, string? leaseId = null)
|
|
{
|
|
Role = RequireNonEmpty(role, nameof(role));
|
|
ShardId = shardId;
|
|
LeaseId = leaseId;
|
|
}
|
|
|
|
/// <summary>Gets the role or claim kind being asserted.</summary>
|
|
public string Role { get; }
|
|
|
|
/// <summary>Gets the optional shard covered by the claim.</summary>
|
|
public string? ShardId { get; }
|
|
|
|
/// <summary>Gets the optional lease id backing the claim.</summary>
|
|
public string? LeaseId { get; }
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Context supplied when invoking a typed CultMesh operation.
|
|
/// </summary>
|
|
public sealed class CultMeshOperationContext
|
|
{
|
|
/// <summary>Creates an operation context.</summary>
|
|
public CultMeshOperationContext(
|
|
string runtimeId,
|
|
IEnumerable<CultMeshAuthorityClaim>? claims = null,
|
|
CultMeshRouteHint? routeHint = null,
|
|
string? idempotencyKey = null)
|
|
{
|
|
RuntimeId = RequireNonEmpty(runtimeId, nameof(runtimeId));
|
|
Claims = claims?.ToArray() ?? Array.Empty<CultMeshAuthorityClaim>();
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
IdempotencyKey = idempotencyKey;
|
|
}
|
|
|
|
/// <summary>Gets the runtime invoking the operation.</summary>
|
|
public string RuntimeId { get; }
|
|
|
|
/// <summary>Gets authority claims attached to the operation.</summary>
|
|
public IReadOnlyList<CultMeshAuthorityClaim> Claims { get; }
|
|
|
|
/// <summary>Gets the preferred route for the operation.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets an optional operation idempotency key.</summary>
|
|
public string? IdempotencyKey { get; }
|
|
|
|
/// <summary>Creates an operation context for a runtime.</summary>
|
|
public static CultMeshOperationContext ForRuntime(string runtimeId)
|
|
{
|
|
return new CultMeshOperationContext(runtimeId);
|
|
}
|
|
|
|
/// <summary>Returns a copy with one additional authority claim.</summary>
|
|
public CultMeshOperationContext WithClaim(CultMeshAuthorityClaim claim)
|
|
{
|
|
if (claim == null) throw new ArgumentNullException(nameof(claim));
|
|
return new CultMeshOperationContext(RuntimeId, Claims.Concat(new[] { claim }), RouteHint, IdempotencyKey);
|
|
}
|
|
|
|
/// <summary>Returns a copy with the supplied route hint.</summary>
|
|
public CultMeshOperationContext WithRoute(CultMeshRouteHint routeHint)
|
|
{
|
|
if (routeHint == null) throw new ArgumentNullException(nameof(routeHint));
|
|
return new CultMeshOperationContext(RuntimeId, Claims, routeHint, IdempotencyKey);
|
|
}
|
|
|
|
/// <summary>Returns a copy with the supplied idempotency key.</summary>
|
|
public CultMeshOperationContext WithIdempotencyKey(string idempotencyKey)
|
|
{
|
|
return new CultMeshOperationContext(
|
|
RuntimeId,
|
|
Claims,
|
|
RouteHint,
|
|
RequireNonEmpty(idempotencyKey, nameof(idempotencyKey)));
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Receipt returned by a typed CultMesh operation.
|
|
/// </summary>
|
|
public sealed class CultMeshOperationReceipt
|
|
{
|
|
/// <summary>Creates an operation receipt.</summary>
|
|
public CultMeshOperationReceipt(
|
|
string operationId,
|
|
bool accepted,
|
|
CultMeshRouteHint? route = null,
|
|
string? diagnostic = null)
|
|
{
|
|
OperationId = RequireNonEmpty(operationId, nameof(operationId));
|
|
Accepted = accepted;
|
|
Route = route ?? CultMeshRouteHint.Automatic;
|
|
Diagnostic = diagnostic;
|
|
}
|
|
|
|
/// <summary>Gets the semantic operation id.</summary>
|
|
public string OperationId { get; }
|
|
|
|
/// <summary>Gets whether the operation was accepted by the target authority boundary.</summary>
|
|
public bool Accepted { get; }
|
|
|
|
/// <summary>Gets the route used for the operation.</summary>
|
|
public CultMeshRouteHint Route { get; }
|
|
|
|
/// <summary>Gets optional operation diagnostics.</summary>
|
|
public string? Diagnostic { get; }
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Method-shaped handle for a typed CultMesh operation.
|
|
/// </summary>
|
|
public sealed class CultMeshOperationHandle<TRequest, TResponse>
|
|
{
|
|
private readonly Func<TRequest, CultMeshOperationContext, Task<TResponse>> _invoke;
|
|
|
|
/// <summary>Creates an operation handle.</summary>
|
|
public CultMeshOperationHandle(
|
|
string operationId,
|
|
Func<TRequest, CultMeshOperationContext, Task<TResponse>> invoke)
|
|
{
|
|
OperationId = RequireNonEmpty(operationId, nameof(operationId));
|
|
_invoke = invoke ?? throw new ArgumentNullException(nameof(invoke));
|
|
}
|
|
|
|
/// <summary>Gets the semantic operation id.</summary>
|
|
public string OperationId { get; }
|
|
|
|
/// <summary>Invokes the typed operation.</summary>
|
|
public Task<TResponse> InvokeAsync(TRequest request, CultMeshOperationContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
return _invoke(request, context);
|
|
}
|
|
|
|
/// <summary>Invokes the typed operation for one runtime using default context.</summary>
|
|
public Task<TResponse> InvokeAsync(TRequest request, string runtimeId)
|
|
{
|
|
return InvokeAsync(request, CultMeshOperationContext.ForRuntime(runtimeId));
|
|
}
|
|
|
|
/// <summary>Binds this operation to a Verse context.</summary>
|
|
public CultMeshBoundOperationHandle<TRequest, TResponse> Bind(CultMeshVerseContext context)
|
|
{
|
|
return new CultMeshBoundOperationHandle<TRequest, TResponse>(context, this);
|
|
}
|
|
|
|
/// <summary>Binds this operation to a Verse.</summary>
|
|
public CultMeshBoundOperationHandle<TRequest, TResponse> Bind(CultMeshVerse verse)
|
|
{
|
|
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
|
return Bind(verse.Context);
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Operation handle pre-bound to a Verse so generated domain sugar can invoke without context plumbing.
|
|
/// </summary>
|
|
public sealed class CultMeshBoundOperationHandle<TRequest, TResponse>
|
|
{
|
|
/// <summary>Creates a Verse-bound operation handle.</summary>
|
|
public CultMeshBoundOperationHandle(
|
|
CultMeshVerseContext context,
|
|
CultMeshOperationHandle<TRequest, TResponse> operation)
|
|
{
|
|
Context = context ?? throw new ArgumentNullException(nameof(context));
|
|
Operation = operation ?? throw new ArgumentNullException(nameof(operation));
|
|
}
|
|
|
|
/// <summary>Gets the bound Verse context.</summary>
|
|
public CultMeshVerseContext Context { get; }
|
|
|
|
/// <summary>Gets the underlying operation handle.</summary>
|
|
public CultMeshOperationHandle<TRequest, TResponse> Operation { get; }
|
|
|
|
/// <summary>Gets the semantic operation id.</summary>
|
|
public string OperationId => Operation.OperationId;
|
|
|
|
/// <summary>Invokes the operation through the bound Verse.</summary>
|
|
public Task<TResponse> InvokeAsync(TRequest request, string? idempotencyKey = null)
|
|
{
|
|
return Operation.InvokeAsync(request, Context.OperationContext(idempotencyKey));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Context supplied when executing a typed CultMesh query surface.
|
|
/// </summary>
|
|
public sealed class CultMeshQueryContext
|
|
{
|
|
/// <summary>Creates a query context.</summary>
|
|
public CultMeshQueryContext(string runtimeId, CultMeshRouteHint? routeHint = null)
|
|
{
|
|
RuntimeId = RequireNonEmpty(runtimeId, nameof(runtimeId));
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
}
|
|
|
|
/// <summary>Gets the runtime executing the query.</summary>
|
|
public string RuntimeId { get; }
|
|
|
|
/// <summary>Gets the preferred route for the query.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Creates a query context for a runtime.</summary>
|
|
public static CultMeshQueryContext ForRuntime(string runtimeId)
|
|
{
|
|
return new CultMeshQueryContext(runtimeId);
|
|
}
|
|
|
|
/// <summary>Returns a copy with the supplied route hint.</summary>
|
|
public CultMeshQueryContext WithRoute(CultMeshRouteHint routeHint)
|
|
{
|
|
if (routeHint == null) throw new ArgumentNullException(nameof(routeHint));
|
|
return new CultMeshQueryContext(RuntimeId, routeHint);
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fluent builder for typed operation context. This is sugar over <see cref="CultMeshOperationContext"/>,
|
|
/// intended for call sites that should read like native Verse operations instead of transport setup.
|
|
/// </summary>
|
|
public sealed class CultMeshOperationContextBuilder
|
|
{
|
|
private readonly string _runtimeId;
|
|
private readonly List<CultMeshAuthorityClaim> _claims = new();
|
|
private CultMeshRouteHint _routeHint = CultMeshRouteHint.Automatic;
|
|
private string? _idempotencyKey;
|
|
|
|
/// <summary>Creates a builder for one runtime.</summary>
|
|
public CultMeshOperationContextBuilder(string runtimeId)
|
|
{
|
|
_runtimeId = RequireNonEmpty(runtimeId, nameof(runtimeId));
|
|
}
|
|
|
|
/// <summary>Adds an authority claim.</summary>
|
|
public CultMeshOperationContextBuilder Claim(string role, string? shardId = null, string? leaseId = null)
|
|
{
|
|
_claims.Add(new CultMeshAuthorityClaim(role, shardId, leaseId));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>Adds an authority claim.</summary>
|
|
public CultMeshOperationContextBuilder Claim(CultMeshAuthorityClaim claim)
|
|
{
|
|
_claims.Add(claim ?? throw new ArgumentNullException(nameof(claim)));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>Adds many authority claims.</summary>
|
|
public CultMeshOperationContextBuilder Claims(IEnumerable<CultMeshAuthorityClaim> claims)
|
|
{
|
|
if (claims == null) throw new ArgumentNullException(nameof(claims));
|
|
_claims.AddRange(claims);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>Sets the preferred route hint.</summary>
|
|
public CultMeshOperationContextBuilder Route(CultMeshLocalityKind kind, string? description = null)
|
|
{
|
|
_routeHint = new CultMeshRouteHint(kind, description);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>Sets the operation idempotency key.</summary>
|
|
public CultMeshOperationContextBuilder Idempotency(string idempotencyKey)
|
|
{
|
|
_idempotencyKey = RequireNonEmpty(idempotencyKey, nameof(idempotencyKey));
|
|
return this;
|
|
}
|
|
|
|
/// <summary>Creates the immutable operation context.</summary>
|
|
public CultMeshOperationContext Build()
|
|
{
|
|
return new CultMeshOperationContext(_runtimeId, _claims, _routeHint, _idempotencyKey);
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fluent builder for typed query context.
|
|
/// </summary>
|
|
public sealed class CultMeshQueryContextBuilder
|
|
{
|
|
private readonly string _runtimeId;
|
|
private CultMeshRouteHint _routeHint = CultMeshRouteHint.Automatic;
|
|
|
|
/// <summary>Creates a builder for one runtime.</summary>
|
|
public CultMeshQueryContextBuilder(string runtimeId)
|
|
{
|
|
_runtimeId = RequireNonEmpty(runtimeId, nameof(runtimeId));
|
|
}
|
|
|
|
/// <summary>Sets the preferred route hint.</summary>
|
|
public CultMeshQueryContextBuilder Route(CultMeshLocalityKind kind, string? description = null)
|
|
{
|
|
_routeHint = new CultMeshRouteHint(kind, description);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>Creates the immutable query context.</summary>
|
|
public CultMeshQueryContext Build()
|
|
{
|
|
return new CultMeshQueryContext(_runtimeId, _routeHint);
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Typed derived-state query surface.
|
|
/// </summary>
|
|
public sealed class CultMeshQuerySurface<TParameters, TResult>
|
|
{
|
|
private readonly Func<TParameters, CultMeshQueryContext, Task<TResult>> _execute;
|
|
private readonly Func<TParameters, CultMeshQueryContext, Observable<TResult>>? _watch;
|
|
|
|
/// <summary>Creates a query surface.</summary>
|
|
public CultMeshQuerySurface(
|
|
string queryId,
|
|
Func<TParameters, CultMeshQueryContext, Task<TResult>> execute,
|
|
Func<TParameters, CultMeshQueryContext, Observable<TResult>>? watch = null,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null,
|
|
CultMeshRouteHint? routeHint = null)
|
|
{
|
|
QueryId = RequireNonEmpty(queryId, nameof(queryId));
|
|
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
|
_watch = watch;
|
|
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
}
|
|
|
|
/// <summary>Gets the semantic query id.</summary>
|
|
public string QueryId { get; }
|
|
|
|
/// <summary>Gets the typed state sources this query depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for the query.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Executes the query once.</summary>
|
|
public Task<TResult> ExecuteAsync(TParameters parameters, CultMeshQueryContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
return _execute(parameters, context);
|
|
}
|
|
|
|
/// <summary>Executes the query once for one runtime using default context.</summary>
|
|
public Task<TResult> ExecuteAsync(TParameters parameters, string runtimeId)
|
|
{
|
|
return ExecuteAsync(parameters, CultMeshQueryContext.ForRuntime(runtimeId));
|
|
}
|
|
|
|
/// <summary>Watches query results when the surface supports reactive execution.</summary>
|
|
public Observable<TResult> Watch(TParameters parameters, CultMeshQueryContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
if (_watch == null)
|
|
{
|
|
throw new NotSupportedException($"Query surface '{QueryId}' does not support reactive watches.");
|
|
}
|
|
|
|
return _watch(parameters, context);
|
|
}
|
|
|
|
/// <summary>Binds this query surface to a Verse context.</summary>
|
|
public CultMeshBoundQuerySurface<TParameters, TResult> Bind(CultMeshVerseContext context)
|
|
{
|
|
return new CultMeshBoundQuerySurface<TParameters, TResult>(context, this);
|
|
}
|
|
|
|
/// <summary>Binds this query surface to a Verse.</summary>
|
|
public CultMeshBoundQuerySurface<TParameters, TResult> Bind(CultMeshVerse verse)
|
|
{
|
|
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
|
return Bind(verse.Context);
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Query surface pre-bound to a Verse so generated domain sugar can execute without context plumbing.
|
|
/// </summary>
|
|
public sealed class CultMeshBoundQuerySurface<TParameters, TResult>
|
|
{
|
|
/// <summary>Creates a Verse-bound query surface.</summary>
|
|
public CultMeshBoundQuerySurface(
|
|
CultMeshVerseContext context,
|
|
CultMeshQuerySurface<TParameters, TResult> query)
|
|
{
|
|
Context = context ?? throw new ArgumentNullException(nameof(context));
|
|
Query = query ?? throw new ArgumentNullException(nameof(query));
|
|
}
|
|
|
|
/// <summary>Gets the bound Verse context.</summary>
|
|
public CultMeshVerseContext Context { get; }
|
|
|
|
/// <summary>Gets the underlying query surface.</summary>
|
|
public CultMeshQuerySurface<TParameters, TResult> Query { get; }
|
|
|
|
/// <summary>Gets the semantic query id.</summary>
|
|
public string QueryId => Query.QueryId;
|
|
|
|
/// <summary>Gets the typed state sources this query depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources => Query.Sources;
|
|
|
|
/// <summary>Gets the preferred or observed route for the query.</summary>
|
|
public CultMeshRouteHint RouteHint => Query.RouteHint;
|
|
|
|
/// <summary>Executes the query through the bound Verse.</summary>
|
|
public Task<TResult> ExecuteAsync(TParameters parameters)
|
|
{
|
|
return Query.ExecuteAsync(parameters, Context.QueryContext());
|
|
}
|
|
|
|
/// <summary>Watches query results through the bound Verse.</summary>
|
|
public Observable<TResult> Watch(TParameters parameters)
|
|
{
|
|
return Query.Watch(parameters, Context.QueryContext());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Typed live view surface for composed client snapshots.
|
|
/// </summary>
|
|
public sealed class CultMeshLiveFeed<TParameters, TResult>
|
|
{
|
|
private readonly Func<TParameters, CultMeshQueryContext, Task<TResult>> _snapshot;
|
|
private readonly Func<TParameters, CultMeshQueryContext, Observable<TResult>>? _watch;
|
|
|
|
/// <summary>Creates a live feed surface.</summary>
|
|
public CultMeshLiveFeed(
|
|
string feedId,
|
|
Func<TParameters, CultMeshQueryContext, Task<TResult>> snapshot,
|
|
Func<TParameters, CultMeshQueryContext, Observable<TResult>>? watch = null,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null,
|
|
CultMeshRouteHint? routeHint = null)
|
|
{
|
|
FeedId = RequireNonEmpty(feedId, nameof(feedId));
|
|
_snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot));
|
|
_watch = watch;
|
|
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
}
|
|
|
|
/// <summary>Gets the semantic feed id.</summary>
|
|
public string FeedId { get; }
|
|
|
|
/// <summary>Gets the typed state sources this live feed depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for the feed.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Reads one coherent feed snapshot.</summary>
|
|
public Task<TResult> SnapshotAsync(TParameters parameters, CultMeshQueryContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
return _snapshot(parameters, ResolveContext(context));
|
|
}
|
|
|
|
/// <summary>Reads one coherent feed snapshot for one runtime using default context.</summary>
|
|
public Task<TResult> SnapshotAsync(TParameters parameters, string runtimeId)
|
|
{
|
|
return SnapshotAsync(parameters, CultMeshQueryContext.ForRuntime(runtimeId));
|
|
}
|
|
|
|
/// <summary>Watches coherent feed snapshots when the feed supports reactive execution.</summary>
|
|
public Observable<TResult> Watch(TParameters parameters, CultMeshQueryContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
if (_watch == null)
|
|
{
|
|
throw new NotSupportedException($"Live feed '{FeedId}' does not support reactive watches.");
|
|
}
|
|
|
|
return _watch(parameters, ResolveContext(context));
|
|
}
|
|
|
|
/// <summary>Binds this live feed to a Verse context.</summary>
|
|
public CultMeshBoundLiveFeed<TParameters, TResult> Bind(CultMeshVerseContext context)
|
|
{
|
|
return new CultMeshBoundLiveFeed<TParameters, TResult>(context, this);
|
|
}
|
|
|
|
/// <summary>Binds this live feed to a Verse.</summary>
|
|
public CultMeshBoundLiveFeed<TParameters, TResult> Bind(CultMeshVerse verse)
|
|
{
|
|
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
|
return Bind(verse.Context);
|
|
}
|
|
|
|
private CultMeshQueryContext ResolveContext(CultMeshQueryContext context)
|
|
{
|
|
return context.RouteHint.Kind == CultMeshLocalityKind.Automatic &&
|
|
RouteHint.Kind != CultMeshLocalityKind.Automatic
|
|
? context.WithRoute(RouteHint)
|
|
: context;
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Live feed pre-bound to a Verse so generated domain sugar can snapshot or watch without context plumbing.
|
|
/// </summary>
|
|
public sealed class CultMeshBoundLiveFeed<TParameters, TResult>
|
|
{
|
|
/// <summary>Creates a Verse-bound live feed.</summary>
|
|
public CultMeshBoundLiveFeed(
|
|
CultMeshVerseContext context,
|
|
CultMeshLiveFeed<TParameters, TResult> feed)
|
|
{
|
|
Context = context ?? throw new ArgumentNullException(nameof(context));
|
|
Feed = feed ?? throw new ArgumentNullException(nameof(feed));
|
|
}
|
|
|
|
/// <summary>Gets the bound Verse context.</summary>
|
|
public CultMeshVerseContext Context { get; }
|
|
|
|
/// <summary>Gets the underlying live feed.</summary>
|
|
public CultMeshLiveFeed<TParameters, TResult> Feed { get; }
|
|
|
|
/// <summary>Gets the semantic feed id.</summary>
|
|
public string FeedId => Feed.FeedId;
|
|
|
|
/// <summary>Gets the typed state sources this live feed depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources => Feed.Sources;
|
|
|
|
/// <summary>Gets the preferred or observed route for the feed.</summary>
|
|
public CultMeshRouteHint RouteHint => Feed.RouteHint;
|
|
|
|
/// <summary>Reads one coherent feed snapshot through the bound Verse.</summary>
|
|
public Task<TResult> SnapshotAsync(TParameters parameters)
|
|
{
|
|
return Feed.SnapshotAsync(parameters, Context.QueryContext());
|
|
}
|
|
|
|
/// <summary>Watches coherent feed snapshots through the bound Verse.</summary>
|
|
public Observable<TResult> Watch(TParameters parameters)
|
|
{
|
|
return Feed.Watch(parameters, Context.QueryContext());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Empty parameter object for document handles that expose one typed document as a live feed.
|
|
/// </summary>
|
|
public readonly struct CultMeshDocumentQueryParameters
|
|
{
|
|
/// <summary>Gets the singleton empty document query.</summary>
|
|
public static CultMeshDocumentQueryParameters Empty { get; } = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inspectable typed document handle exposed by CultMesh.
|
|
/// </summary>
|
|
public interface ICultMeshDocumentHandle
|
|
{
|
|
/// <summary>Gets the CLR document type presented by this handle.</summary>
|
|
Type DocumentType { get; }
|
|
|
|
/// <summary>Gets the semantic document id.</summary>
|
|
string DocumentId { get; }
|
|
|
|
/// <summary>Gets the stable CultCache schema name.</summary>
|
|
string SchemaName { get; }
|
|
|
|
/// <summary>Gets the stable CultCache schema version.</summary>
|
|
string SchemaVersion { get; }
|
|
|
|
/// <summary>Gets the content-derived schema identifier.</summary>
|
|
string SchemaId { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for document access.</summary>
|
|
CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets typed state sources this document handle depends on, when known.</summary>
|
|
IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
/// <summary>Gets whether this handle can replace the underlying document value.</summary>
|
|
bool CanReplace { get; }
|
|
|
|
/// <summary>Gets whether this handle can submit a client prediction for the underlying document value.</summary>
|
|
bool CanSubmitPrediction { get; }
|
|
|
|
/// <summary>Creates a same-schema alias presentation for another CLR document type.</summary>
|
|
CultMeshDocumentHandle<TAlias> AsSchemaAlias<TAlias>() where TAlias : class;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Typed reactive document handle with schema-aware alias conversion.
|
|
/// </summary>
|
|
public sealed class CultMeshDocumentHandle<TDocument> : ICultMeshDocumentHandle
|
|
where TDocument : class
|
|
{
|
|
private static readonly CultDocumentDescriptor Descriptor =
|
|
CultDocumentRegistry.Shared.GetRequired<TDocument>();
|
|
|
|
private readonly CultMeshBoundLiveFeed<CultMeshDocumentQueryParameters, TDocument> _feed;
|
|
private readonly Func<TDocument, Task>? _replace;
|
|
private readonly Func<TDocument, Task>? _submitPrediction;
|
|
|
|
/// <summary>Creates a document handle from a Verse-bound live feed.</summary>
|
|
public CultMeshDocumentHandle(
|
|
CultMeshBoundLiveFeed<CultMeshDocumentQueryParameters, TDocument> feed,
|
|
Func<TDocument, Task>? replace = null,
|
|
Func<TDocument, Task>? submitPrediction = null)
|
|
{
|
|
_feed = feed ?? throw new ArgumentNullException(nameof(feed));
|
|
_replace = replace;
|
|
_submitPrediction = submitPrediction;
|
|
}
|
|
|
|
/// <summary>Gets the semantic document id.</summary>
|
|
public string DocumentId => _feed.FeedId;
|
|
|
|
/// <summary>Gets the CLR document type presented by this handle.</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 document access.</summary>
|
|
public CultMeshRouteHint RouteHint => _feed.RouteHint;
|
|
|
|
/// <summary>Gets typed state sources this document handle depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources => _feed.Sources;
|
|
|
|
/// <summary>Gets the bound Verse context.</summary>
|
|
public CultMeshVerseContext Context => _feed.Context;
|
|
|
|
/// <summary>Gets the underlying live feed used by this document handle.</summary>
|
|
public CultMeshLiveFeed<CultMeshDocumentQueryParameters, TDocument> Feed => _feed.Feed;
|
|
|
|
/// <summary>Gets whether this handle can replace the underlying document value.</summary>
|
|
public bool CanReplace => _replace != null;
|
|
|
|
/// <summary>Gets whether this handle can submit a client prediction for the underlying document value.</summary>
|
|
public bool CanSubmitPrediction => _submitPrediction != null;
|
|
|
|
/// <summary>Reads one coherent document snapshot.</summary>
|
|
public Task<TDocument> LatestAsync()
|
|
{
|
|
return _feed.SnapshotAsync(CultMeshDocumentQueryParameters.Empty);
|
|
}
|
|
|
|
/// <summary>Reads the latest document snapshot synchronously for host APIs that cannot be async.</summary>
|
|
public TDocument Latest()
|
|
{
|
|
return LatestAsync().ConfigureAwait(false).GetAwaiter().GetResult();
|
|
}
|
|
|
|
/// <summary>Watches coherent document snapshots.</summary>
|
|
public Observable<TDocument> Watch()
|
|
{
|
|
return _feed.Watch(CultMeshDocumentQueryParameters.Empty);
|
|
}
|
|
|
|
/// <summary>Subscribes to coherent document snapshots.</summary>
|
|
public IDisposable Watch(Action<TDocument> onNext)
|
|
{
|
|
if (onNext == null) throw new ArgumentNullException(nameof(onNext));
|
|
return Watch().Subscribe(onNext);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Projects one typed document field into a stable reactive state pointer. The expression is
|
|
/// structural metadata: only member access and constant collection indices are accepted.
|
|
/// </summary>
|
|
public CultMeshStatePointer<TValue> Field<TValue>(Expression<Func<TDocument, TValue>> field)
|
|
{
|
|
if (field == null) throw new ArgumentNullException(nameof(field));
|
|
var path = CultMeshFieldPath.Parse(field.Body, field.Parameters[0]);
|
|
var read = field.Compile();
|
|
return new CultMeshStatePointer<TValue>(
|
|
DocumentId + "#" + path,
|
|
async context => read(await Feed
|
|
.SnapshotAsync(CultMeshDocumentQueryParameters.Empty, context)
|
|
.ConfigureAwait(false)),
|
|
context => Feed
|
|
.Watch(CultMeshDocumentQueryParameters.Empty, context)
|
|
.Select(read),
|
|
RouteHint,
|
|
Sources);
|
|
}
|
|
|
|
/// <summary>Replaces the underlying document when this handle is backed by mutable state.</summary>
|
|
public Task ReplaceAsync(TDocument value)
|
|
{
|
|
if (value == null) throw new ArgumentNullException(nameof(value));
|
|
if (_replace == null)
|
|
{
|
|
throw new NotSupportedException($"Document handle '{DocumentId}' is read-only.");
|
|
}
|
|
|
|
return _replace(value);
|
|
}
|
|
|
|
/// <summary>Submits a locally predicted document value when this handle is backed by client-authoritative state.</summary>
|
|
public Task SubmitPredictionAsync(TDocument value)
|
|
{
|
|
if (value == null) throw new ArgumentNullException(nameof(value));
|
|
if (_submitPrediction == null)
|
|
{
|
|
throw new NotSupportedException($"Document handle '{DocumentId}' does not accept client predictions.");
|
|
}
|
|
|
|
return _submitPrediction(value);
|
|
}
|
|
|
|
/// <summary>Gets an explicit authoritative replacement writer for this document.</summary>
|
|
public CultMeshDocumentWriter<TDocument> AuthoritativeWriter()
|
|
{
|
|
if (_replace == null)
|
|
throw new NotSupportedException($"Document handle '{DocumentId}' is read-only.");
|
|
return new CultMeshDocumentWriter<TDocument>(
|
|
this,
|
|
CultMeshDocumentWriteKind.AuthoritativeReplacement,
|
|
_replace);
|
|
}
|
|
|
|
/// <summary>Gets an explicit client prediction writer for this document.</summary>
|
|
public CultMeshDocumentWriter<TDocument> PredictionWriter()
|
|
{
|
|
if (_submitPrediction == null)
|
|
throw new NotSupportedException($"Document handle '{DocumentId}' does not accept client predictions.");
|
|
return new CultMeshDocumentWriter<TDocument>(
|
|
this,
|
|
CultMeshDocumentWriteKind.Prediction,
|
|
_submitPrediction);
|
|
}
|
|
|
|
/// <summary>Creates a read-only in-memory mirror updated by the document watch.</summary>
|
|
public async Task<CultMeshObservedDocument<TDocument>> ObserveAsync()
|
|
{
|
|
var current = CloneDocument(await LatestAsync().ConfigureAwait(false));
|
|
var observed = new CultMeshObservedDocument<TDocument>(this, current);
|
|
observed.Start();
|
|
return observed;
|
|
}
|
|
|
|
/// <summary>Creates a read-only in-memory mirror synchronously.</summary>
|
|
public CultMeshObservedDocument<TDocument> Observe()
|
|
{
|
|
return ObserveAsync().ConfigureAwait(false).GetAwaiter().GetResult();
|
|
}
|
|
|
|
/// <summary>Creates a same-schema alias presentation for another CLR document type.</summary>
|
|
public CultMeshDocumentHandle<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 handle '{DocumentId}' exposes " +
|
|
$"schema '{SchemaName}' version '{SchemaVersion}'.");
|
|
}
|
|
|
|
var aliasFeed = new CultMeshLiveFeed<CultMeshDocumentQueryParameters, TAlias>(
|
|
DocumentId,
|
|
async (parameters, context) => ConvertDocument<TDocument, TAlias>(
|
|
await Feed.SnapshotAsync(parameters, context).ConfigureAwait(false)),
|
|
(parameters, context) => Feed
|
|
.Watch(parameters, context)
|
|
.Select(ConvertDocument<TDocument, TAlias>),
|
|
Sources,
|
|
RouteHint);
|
|
|
|
Func<TAlias, Task>? replace = _replace == null
|
|
? null
|
|
: value => _replace(ConvertDocument<TAlias, TDocument>(value));
|
|
Func<TAlias, Task>? submitPrediction = _submitPrediction == null
|
|
? null
|
|
: value => _submitPrediction(ConvertDocument<TAlias, TDocument>(value));
|
|
|
|
return new CultMeshDocumentHandle<TAlias>(aliasFeed.Bind(Context), replace, submitPrediction);
|
|
}
|
|
|
|
internal static TDocumentValue CloneDocument<TDocumentValue>(TDocumentValue document)
|
|
where TDocumentValue : class
|
|
{
|
|
if (document == null) throw new ArgumentNullException(nameof(document));
|
|
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocumentValue));
|
|
return (TDocumentValue)CultDocumentMessagePackSerialization.DeserializeUntyped(
|
|
typeof(TDocumentValue),
|
|
payload);
|
|
}
|
|
|
|
private static class CultMeshFieldPath
|
|
{
|
|
public static string Parse(Expression expression, ParameterExpression root)
|
|
{
|
|
var segments = new Stack<string>();
|
|
while (expression != root)
|
|
{
|
|
if (expression is MemberExpression member)
|
|
{
|
|
segments.Push(member.Member.Name);
|
|
expression = member.Expression
|
|
?? throw Invalid();
|
|
continue;
|
|
}
|
|
|
|
if (expression is MethodCallExpression call &&
|
|
call.Method.Name == "get_Item" &&
|
|
call.Object != null &&
|
|
call.Arguments.Count == 1)
|
|
{
|
|
segments.Push("[" + ConstantIndex(call.Arguments[0]) + "]");
|
|
expression = call.Object;
|
|
continue;
|
|
}
|
|
|
|
if (expression is BinaryExpression binary && binary.NodeType == ExpressionType.ArrayIndex)
|
|
{
|
|
segments.Push("[" + ConstantIndex(binary.Right) + "]");
|
|
expression = binary.Left;
|
|
continue;
|
|
}
|
|
|
|
throw Invalid();
|
|
}
|
|
|
|
if (segments.Count == 0) throw Invalid();
|
|
var path = string.Empty;
|
|
foreach (var segment in segments)
|
|
path += segment.StartsWith("[", StringComparison.Ordinal) || path.Length == 0
|
|
? segment
|
|
: "." + segment;
|
|
return path;
|
|
}
|
|
|
|
private static string ConstantIndex(Expression expression)
|
|
{
|
|
if (expression is ConstantExpression constant && constant.Value != null)
|
|
return Convert.ToString(constant.Value, CultureInfo.InvariantCulture)!;
|
|
throw Invalid();
|
|
}
|
|
|
|
private static ArgumentException Invalid() => new(
|
|
"CultMesh field selectors may contain only member access and constant collection indices.",
|
|
"field");
|
|
}
|
|
|
|
internal 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>
|
|
/// A read-only local mirror of a watched typed document. Observation never acquires a write
|
|
/// capability and never polls or serializes the document while idle.
|
|
/// </summary>
|
|
public sealed class CultMeshObservedDocument<TDocument> : IDisposable
|
|
where TDocument : class
|
|
{
|
|
private readonly object _gate = new();
|
|
private IDisposable? _subscription;
|
|
private TDocument _current;
|
|
private bool _disposed;
|
|
|
|
internal CultMeshObservedDocument(
|
|
CultMeshDocumentHandle<TDocument> document,
|
|
TDocument current)
|
|
{
|
|
Document = document ?? throw new ArgumentNullException(nameof(document));
|
|
_current = current ?? throw new ArgumentNullException(nameof(current));
|
|
}
|
|
|
|
/// <summary>Gets the underlying read/watch handle.</summary>
|
|
public CultMeshDocumentHandle<TDocument> Document { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the current borrowed snapshot. Treat the object graph as read-only; local mutation
|
|
/// cannot publish and is replaced by the next canonical snapshot.
|
|
/// </summary>
|
|
public TDocument Current
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
{
|
|
ThrowIfDisposed();
|
|
return _current;
|
|
}
|
|
}
|
|
}
|
|
|
|
internal void Start()
|
|
{
|
|
_subscription = Document.Watch(ApplyCanonicalSnapshot);
|
|
}
|
|
|
|
/// <summary>Reads and adopts a fresh canonical snapshot.</summary>
|
|
public async Task<TDocument> RefreshAsync()
|
|
{
|
|
ThrowIfDisposed();
|
|
var latest = CultMeshDocumentHandle<TDocument>.CloneDocument(
|
|
await Document.LatestAsync().ConfigureAwait(false));
|
|
lock (_gate)
|
|
{
|
|
ThrowIfDisposed();
|
|
_current = latest;
|
|
return _current;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
}
|
|
|
|
_subscription?.Dispose();
|
|
}
|
|
|
|
private void ApplyCanonicalSnapshot(TDocument canonical)
|
|
{
|
|
if (canonical == null)
|
|
return;
|
|
var next = CultMeshDocumentHandle<TDocument>.CloneDocument(canonical);
|
|
lock (_gate)
|
|
{
|
|
if (!_disposed)
|
|
_current = next;
|
|
}
|
|
}
|
|
|
|
private void ThrowIfDisposed()
|
|
{
|
|
if (_disposed)
|
|
throw new ObjectDisposedException(nameof(CultMeshObservedDocument<TDocument>));
|
|
}
|
|
}
|
|
|
|
/// <summary>Names the one immutable authority path selected by a document writer.</summary>
|
|
public enum CultMeshDocumentWriteKind
|
|
{
|
|
/// <summary>The writer replaces canonical state through its owning provider.</summary>
|
|
AuthoritativeReplacement,
|
|
|
|
/// <summary>The writer submits a client prediction or candidate for validation.</summary>
|
|
Prediction
|
|
}
|
|
|
|
/// <summary>
|
|
/// An explicit, immutable write capability for one typed document. A writer never switches
|
|
/// between authoritative replacement and prediction based on runtime availability.
|
|
/// </summary>
|
|
public sealed class CultMeshDocumentWriter<TDocument>
|
|
where TDocument : class
|
|
{
|
|
private readonly Func<TDocument, Task> _write;
|
|
|
|
internal CultMeshDocumentWriter(
|
|
CultMeshDocumentHandle<TDocument> document,
|
|
CultMeshDocumentWriteKind kind,
|
|
Func<TDocument, Task> write)
|
|
{
|
|
Document = document ?? throw new ArgumentNullException(nameof(document));
|
|
Kind = kind;
|
|
_write = write ?? throw new ArgumentNullException(nameof(write));
|
|
}
|
|
|
|
/// <summary>Gets the document read/watch handle paired with this writer.</summary>
|
|
public CultMeshDocumentHandle<TDocument> Document { get; }
|
|
|
|
/// <summary>Gets the immutable authority path selected for this writer.</summary>
|
|
public CultMeshDocumentWriteKind Kind { get; }
|
|
|
|
/// <summary>Writes one value through this writer's named authority path.</summary>
|
|
public Task WriteAsync(TDocument value)
|
|
{
|
|
if (value == null) throw new ArgumentNullException(nameof(value));
|
|
return _write(value);
|
|
}
|
|
|
|
/// <summary>Reads, transforms, and writes through this writer's named authority path.</summary>
|
|
public async Task<TDocument> UpdateAsync(Func<TDocument, TDocument> update)
|
|
{
|
|
if (update == null) throw new ArgumentNullException(nameof(update));
|
|
var next = update(await Document.LatestAsync().ConfigureAwait(false));
|
|
await WriteAsync(next).ConfigureAwait(false);
|
|
return next;
|
|
}
|
|
|
|
/// <summary>Reads, transforms, and writes through this writer's named authority path.</summary>
|
|
public async Task<TDocument> UpdateAsync(Func<TDocument, Task<TDocument>> update)
|
|
{
|
|
if (update == null) throw new ArgumentNullException(nameof(update));
|
|
var next = await update(await Document.LatestAsync().ConfigureAwait(false)).ConfigureAwait(false);
|
|
await WriteAsync(next).ConfigureAwait(false);
|
|
return next;
|
|
}
|
|
|
|
/// <summary>Creates an explicitly writable reactive mirror.</summary>
|
|
public async Task<CultMeshReactiveDocument<TDocument>> ReactiveAsync(
|
|
CultMeshReactiveDocumentOptions? options = null)
|
|
{
|
|
var current = CultMeshDocumentHandle<TDocument>.CloneDocument(
|
|
await Document.LatestAsync().ConfigureAwait(false));
|
|
var reactive = new CultMeshReactiveDocument<TDocument>(this, current, options);
|
|
reactive.Start();
|
|
return reactive;
|
|
}
|
|
|
|
/// <summary>Creates an explicitly writable reactive mirror synchronously.</summary>
|
|
public CultMeshReactiveDocument<TDocument> Reactive(CultMeshReactiveDocumentOptions? options = null)
|
|
{
|
|
return ReactiveAsync(options).ConfigureAwait(false).GetAwaiter().GetResult();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures a managed CultMesh reactive document mirror.
|
|
/// </summary>
|
|
public sealed class CultMeshReactiveDocumentOptions
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets the write coalescing window. The default approximates a frame boundary for non-Unity hosts.
|
|
/// </summary>
|
|
public TimeSpan FlushDelay { get; set; } = TimeSpan.FromMilliseconds(16);
|
|
|
|
/// <summary>
|
|
/// Gets or sets the clock that owns delayed flush scheduling. Tests and deterministic hosts may
|
|
/// replace the system clock without introducing a second scheduler abstraction.
|
|
/// </summary>
|
|
public ICultMeshClock Clock { get; set; } = CultMeshSystemClock.Instance;
|
|
|
|
/// <summary>Gets or sets whether canonical snapshots replace dirty local predictions immediately.</summary>
|
|
public bool ReplaceDirtyCurrentOnCanonicalSnapshot { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures a canonical snapshot that arrived while the local reactive document had an outstanding prediction.
|
|
/// </summary>
|
|
public sealed class CultMeshReactiveDocumentReconciliation<TDocument>
|
|
where TDocument : class
|
|
{
|
|
internal CultMeshReactiveDocumentReconciliation(
|
|
TDocument canonical,
|
|
TDocument predicted,
|
|
IReadOnlyDictionary<string, object?> delta,
|
|
int version,
|
|
DateTimeOffset receivedAt)
|
|
{
|
|
Canonical = canonical;
|
|
Predicted = predicted;
|
|
Delta = delta ?? throw new ArgumentNullException(nameof(delta));
|
|
Version = version;
|
|
ReceivedAt = receivedAt;
|
|
}
|
|
|
|
/// <summary>Gets the monotonically increasing reconciliation version for this reactive document.</summary>
|
|
public int Version { get; }
|
|
|
|
/// <summary>Gets the authoritative canonical document snapshot.</summary>
|
|
public TDocument Canonical { get; }
|
|
|
|
/// <summary>Gets the locally predicted document snapshot that was active when the canonical value arrived.</summary>
|
|
public TDocument Predicted { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the predicted-vs-canonical member delta. Numeric members store predicted minus canonical;
|
|
/// non-numeric members store the predicted value.
|
|
/// </summary>
|
|
public IReadOnlyDictionary<string, object?> Delta { get; }
|
|
|
|
/// <summary>Gets when the canonical snapshot was received.</summary>
|
|
public DateTimeOffset ReceivedAt { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Managed typed document mirror that coalesces explicit local edits through one selected writer.
|
|
/// </summary>
|
|
public sealed class CultMeshReactiveDocument<TDocument> : IDisposable
|
|
where TDocument : class
|
|
{
|
|
private static readonly CultMeshComparableMember[] ComparableMembers =
|
|
GetComparableMembers(typeof(TDocument)).ToArray();
|
|
private readonly CultMeshDocumentWriter<TDocument> _writer;
|
|
private readonly CultMeshDocumentHandle<TDocument> _document;
|
|
private readonly CultMeshReactiveDocumentOptions _options;
|
|
private readonly object _gate = new();
|
|
private IDisposable? _subscription;
|
|
private CancellationTokenSource? _flushDelayCancellation;
|
|
private TDocument _current;
|
|
private bool _dirty;
|
|
private bool _flushQueued;
|
|
private bool _flushing;
|
|
private bool _disposed;
|
|
private int _reconciliationVersion;
|
|
|
|
internal CultMeshReactiveDocument(
|
|
CultMeshDocumentWriter<TDocument> writer,
|
|
TDocument current,
|
|
CultMeshReactiveDocumentOptions? options)
|
|
{
|
|
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
|
|
_document = writer.Document;
|
|
_current = current ?? throw new ArgumentNullException(nameof(current));
|
|
_options = options ?? new CultMeshReactiveDocumentOptions();
|
|
}
|
|
|
|
/// <summary>Gets the immutable write capability selected for this mirror.</summary>
|
|
public CultMeshDocumentWriter<TDocument> Writer => _writer;
|
|
|
|
/// <summary>Gets the underlying document handle.</summary>
|
|
public CultMeshDocumentHandle<TDocument> Document => _document;
|
|
|
|
/// <summary>
|
|
/// Gets a cloned local snapshot. Mutating the returned object cannot mutate or publish the mirror;
|
|
/// use <see cref="Update"/> or <see cref="ReplaceLocal"/> for edits.
|
|
/// </summary>
|
|
public TDocument Snapshot
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
{
|
|
ThrowIfDisposed();
|
|
return CultMeshDocumentHandle<TDocument>.CloneDocument(_current);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Gets whether local edits are waiting to be sent.</summary>
|
|
public bool IsDirty
|
|
{
|
|
get
|
|
{
|
|
lock (_gate)
|
|
return _dirty;
|
|
}
|
|
}
|
|
|
|
/// <summary>Gets the most recent reconciliation snapshot, when a canonical value arrived during local prediction.</summary>
|
|
public CultMeshReactiveDocumentReconciliation<TDocument>? Reconciliation { get; private set; }
|
|
|
|
internal void Start()
|
|
{
|
|
_subscription = _document.Watch(ApplyCanonicalSnapshot);
|
|
}
|
|
|
|
/// <summary>Mutates the current value and schedules a coalesced prediction or replacement.</summary>
|
|
public TDocument Update(Action<TDocument> update)
|
|
{
|
|
if (update == null) throw new ArgumentNullException(nameof(update));
|
|
ThrowIfDisposed();
|
|
lock (_gate)
|
|
{
|
|
update(_current);
|
|
_dirty = true;
|
|
ScheduleFlushLocked();
|
|
return CultMeshDocumentHandle<TDocument>.CloneDocument(_current);
|
|
}
|
|
}
|
|
|
|
/// <summary>Replaces the current local value and schedules a coalesced prediction or replacement.</summary>
|
|
public TDocument ReplaceLocal(TDocument value)
|
|
{
|
|
if (value == null) throw new ArgumentNullException(nameof(value));
|
|
ThrowIfDisposed();
|
|
lock (_gate)
|
|
{
|
|
_current = CultMeshDocumentHandle<TDocument>.CloneDocument(value);
|
|
_dirty = true;
|
|
ScheduleFlushLocked();
|
|
return CultMeshDocumentHandle<TDocument>.CloneDocument(_current);
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads a fresh canonical snapshot and adopts it as the current value.</summary>
|
|
public async Task<TDocument> RefreshAsync()
|
|
{
|
|
ThrowIfDisposed();
|
|
var latest = CultMeshDocumentHandle<TDocument>.CloneDocument(
|
|
await _document.LatestAsync().ConfigureAwait(false));
|
|
lock (_gate)
|
|
{
|
|
_current = latest;
|
|
_dirty = false;
|
|
Reconciliation = null;
|
|
return CultMeshDocumentHandle<TDocument>.CloneDocument(_current);
|
|
}
|
|
}
|
|
|
|
/// <summary>Immediately sends the latest local dirty value, if any, through the document authority shape.</summary>
|
|
public async Task FlushAsync()
|
|
{
|
|
TDocument predicted;
|
|
lock (_gate)
|
|
{
|
|
ThrowIfDisposed();
|
|
if (!_dirty)
|
|
return;
|
|
if (_flushing)
|
|
{
|
|
_flushQueued = true;
|
|
return;
|
|
}
|
|
|
|
CancelScheduledFlushLocked();
|
|
_flushing = true;
|
|
_dirty = false;
|
|
predicted = CultMeshDocumentHandle<TDocument>.CloneDocument(_current);
|
|
}
|
|
|
|
try
|
|
{
|
|
await _writer.WriteAsync(predicted).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
var shouldFlushAgain = false;
|
|
lock (_gate)
|
|
{
|
|
_flushing = false;
|
|
shouldFlushAgain = _flushQueued || _dirty;
|
|
_flushQueued = false;
|
|
}
|
|
|
|
if (shouldFlushAgain)
|
|
await FlushAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>Clears the most recent reconciliation metadata.</summary>
|
|
public void ClearReconciliation()
|
|
{
|
|
ThrowIfDisposed();
|
|
Reconciliation = null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
CancelScheduledFlushLocked();
|
|
}
|
|
|
|
_subscription?.Dispose();
|
|
}
|
|
|
|
private void ApplyCanonicalSnapshot(TDocument canonical)
|
|
{
|
|
if (canonical == null)
|
|
return;
|
|
|
|
lock (_gate)
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
TDocument? replacement = null;
|
|
if (_dirty || _flushing)
|
|
{
|
|
var delta = CreateReconciliationDelta(_current, canonical);
|
|
if (delta.Count == 0)
|
|
{
|
|
Reconciliation = null;
|
|
if (!_options.ReplaceDirtyCurrentOnCanonicalSnapshot)
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
var nextCanonical = CultMeshDocumentHandle<TDocument>.CloneDocument(canonical);
|
|
var predicted = CultMeshDocumentHandle<TDocument>.CloneDocument(_current);
|
|
replacement = nextCanonical;
|
|
Reconciliation = new CultMeshReactiveDocumentReconciliation<TDocument>(
|
|
nextCanonical,
|
|
predicted,
|
|
delta,
|
|
++_reconciliationVersion,
|
|
DateTimeOffset.UtcNow);
|
|
if (!_options.ReplaceDirtyCurrentOnCanonicalSnapshot)
|
|
return;
|
|
}
|
|
}
|
|
|
|
_current = replacement ?? CultMeshDocumentHandle<TDocument>.CloneDocument(canonical);
|
|
Reconciliation = null;
|
|
}
|
|
}
|
|
|
|
private void ScheduleFlushLocked()
|
|
{
|
|
if (_options.FlushDelay <= TimeSpan.Zero)
|
|
{
|
|
_ = Task.Run(FlushAsync);
|
|
return;
|
|
}
|
|
|
|
CancelScheduledFlushLocked();
|
|
var cancellation = new CancellationTokenSource();
|
|
_flushDelayCancellation = cancellation;
|
|
_ = FlushAfterDelayAsync(cancellation);
|
|
}
|
|
|
|
private async Task FlushAfterDelayAsync(CancellationTokenSource cancellation)
|
|
{
|
|
try
|
|
{
|
|
await _options.Clock.DelayAsync(_options.FlushDelay, cancellation.Token).ConfigureAwait(false);
|
|
lock (_gate)
|
|
{
|
|
if (_disposed || cancellation.IsCancellationRequested)
|
|
return;
|
|
}
|
|
|
|
await FlushAsync().ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
|
|
{
|
|
}
|
|
finally
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (ReferenceEquals(_flushDelayCancellation, cancellation))
|
|
_flushDelayCancellation = null;
|
|
}
|
|
|
|
cancellation.Dispose();
|
|
}
|
|
}
|
|
|
|
private void CancelScheduledFlushLocked()
|
|
{
|
|
var cancellation = _flushDelayCancellation;
|
|
_flushDelayCancellation = null;
|
|
if (cancellation == null)
|
|
return;
|
|
|
|
cancellation.Cancel();
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, object?> CreateReconciliationDelta(
|
|
TDocument predicted,
|
|
TDocument canonical)
|
|
{
|
|
var delta = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
foreach (var member in ComparableMembers)
|
|
{
|
|
var predictedValue = member.GetValue(predicted);
|
|
var canonicalValue = member.GetValue(canonical);
|
|
if (CultMeshValuesEqual(predictedValue, canonicalValue))
|
|
continue;
|
|
|
|
delta[member.Name] = TryCreateNumericDelta(predictedValue, canonicalValue, out var numericDelta)
|
|
? numericDelta
|
|
: predictedValue;
|
|
}
|
|
|
|
return delta;
|
|
}
|
|
|
|
private static IEnumerable<CultMeshComparableMember> GetComparableMembers(Type documentType)
|
|
{
|
|
foreach (var property in documentType.GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
|
{
|
|
if (property.GetMethod == null || property.GetIndexParameters().Length != 0)
|
|
continue;
|
|
yield return new CultMeshComparableMember(property.Name, property.GetValue);
|
|
}
|
|
|
|
foreach (var field in documentType.GetFields(BindingFlags.Instance | BindingFlags.Public))
|
|
{
|
|
yield return new CultMeshComparableMember(field.Name, field.GetValue);
|
|
}
|
|
}
|
|
|
|
private static bool TryCreateNumericDelta(object? predicted, object? canonical, out object? delta)
|
|
{
|
|
delta = null;
|
|
if (predicted == null || canonical == null)
|
|
return false;
|
|
var predictedType = Nullable.GetUnderlyingType(predicted.GetType()) ?? predicted.GetType();
|
|
var canonicalType = Nullable.GetUnderlyingType(canonical.GetType()) ?? canonical.GetType();
|
|
if (!IsNumericType(predictedType) || !IsNumericType(canonicalType))
|
|
return false;
|
|
|
|
delta = Convert.ToDouble(predicted, CultureInfo.InvariantCulture) -
|
|
Convert.ToDouble(canonical, CultureInfo.InvariantCulture);
|
|
return true;
|
|
}
|
|
|
|
private static bool IsNumericType(Type type)
|
|
{
|
|
if (type.IsEnum)
|
|
return false;
|
|
return Type.GetTypeCode(type) switch
|
|
{
|
|
TypeCode.Byte or
|
|
TypeCode.SByte or
|
|
TypeCode.UInt16 or
|
|
TypeCode.UInt32 or
|
|
TypeCode.UInt64 or
|
|
TypeCode.Int16 or
|
|
TypeCode.Int32 or
|
|
TypeCode.Int64 or
|
|
TypeCode.Decimal or
|
|
TypeCode.Double or
|
|
TypeCode.Single => true,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
private static bool CultMeshValuesEqual(object? left, object? right)
|
|
{
|
|
if (ReferenceEquals(left, right))
|
|
return true;
|
|
if (left == null || right == null)
|
|
return false;
|
|
if (left.Equals(right))
|
|
return true;
|
|
if (left is string || right is string)
|
|
return false;
|
|
if (left is IEnumerable leftEnumerable && right is IEnumerable rightEnumerable)
|
|
return leftEnumerable.Cast<object?>().SequenceEqual(rightEnumerable.Cast<object?>());
|
|
return false;
|
|
}
|
|
|
|
private void ThrowIfDisposed()
|
|
{
|
|
if (_disposed)
|
|
throw new ObjectDisposedException(nameof(CultMeshReactiveDocument<TDocument>));
|
|
}
|
|
|
|
private readonly struct CultMeshComparableMember
|
|
{
|
|
public CultMeshComparableMember(string name, Func<object, object?> getValue)
|
|
{
|
|
Name = name;
|
|
GetValue = getValue;
|
|
}
|
|
|
|
public string Name { get; }
|
|
|
|
public Func<object, object?> GetValue { get; }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Schema-aware lookup table for typed CultMesh document handles.
|
|
/// </summary>
|
|
public sealed class CultMeshDocumentCatalog
|
|
{
|
|
private readonly IReadOnlyList<ICultMeshDocumentHandle> _documents;
|
|
private readonly IReadOnlyDictionary<Type, ICultMeshDocumentHandle> _documentsByType;
|
|
private readonly IReadOnlyDictionary<string, ICultMeshDocumentHandle> _documentsBySchema;
|
|
|
|
/// <summary>Creates a document catalog from known handles.</summary>
|
|
public CultMeshDocumentCatalog(IEnumerable<ICultMeshDocumentHandle> documents)
|
|
{
|
|
if (documents == null) throw new ArgumentNullException(nameof(documents));
|
|
|
|
var documentList = documents
|
|
.Where(document => document != null)
|
|
.ToArray();
|
|
var byType = new Dictionary<Type, ICultMeshDocumentHandle>();
|
|
var bySchema = new Dictionary<string, ICultMeshDocumentHandle>(StringComparer.Ordinal);
|
|
foreach (var document in documentList)
|
|
{
|
|
byType[document.DocumentType] = document;
|
|
if (!string.IsNullOrWhiteSpace(document.SchemaId))
|
|
bySchema[document.SchemaId] = document;
|
|
if (!string.IsNullOrWhiteSpace(document.SchemaVersion))
|
|
bySchema[document.SchemaVersion] = document;
|
|
if (!string.IsNullOrWhiteSpace(document.SchemaName))
|
|
bySchema[document.SchemaName] = document;
|
|
}
|
|
|
|
_documents = documentList;
|
|
_documentsByType = byType;
|
|
_documentsBySchema = bySchema;
|
|
}
|
|
|
|
/// <summary>Gets the handles in this catalog.</summary>
|
|
public IReadOnlyList<ICultMeshDocumentHandle> Documents => _documents;
|
|
|
|
/// <summary>Looks up a document handle by schema id, shared schema name, or schema version.</summary>
|
|
public bool TryGetDocumentBySchema(
|
|
string schema,
|
|
out ICultMeshDocumentHandle document)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(schema) &&
|
|
_documentsBySchema.TryGetValue(schema, out document!))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
document = null!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Looks up a document handle by schema id, shared schema name, or schema version.</summary>
|
|
public ICultMeshDocumentHandle DocumentBySchema(string schema)
|
|
{
|
|
if (TryGetDocumentBySchema(schema, out var document))
|
|
return document;
|
|
|
|
throw new NotSupportedException(
|
|
$"CultMesh document catalog does not expose a document for schema '{schema}'.");
|
|
}
|
|
|
|
/// <summary>Looks up a typed document handle by CLR type or same-schema alias.</summary>
|
|
public bool TryGetDocument<TDocument>(
|
|
out CultMeshDocumentHandle<TDocument> document)
|
|
where TDocument : class
|
|
{
|
|
if (_documentsByType.TryGetValue(typeof(TDocument), out var untypedDocument) &&
|
|
untypedDocument is CultMeshDocumentHandle<TDocument> typedDocument)
|
|
{
|
|
document = typedDocument;
|
|
return true;
|
|
}
|
|
|
|
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
|
|
if (_documentsBySchema.TryGetValue(descriptor.SchemaVersion, out var schemaDocument) ||
|
|
_documentsBySchema.TryGetValue(descriptor.SchemaName, out schemaDocument!))
|
|
{
|
|
if (schemaDocument is CultMeshDocumentHandle<TDocument> schemaTypedDocument)
|
|
{
|
|
document = schemaTypedDocument;
|
|
return true;
|
|
}
|
|
|
|
document = schemaDocument.AsSchemaAlias<TDocument>();
|
|
return true;
|
|
}
|
|
|
|
document = null!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Looks up a typed document handle by CLR type or same-schema alias.</summary>
|
|
public CultMeshDocumentHandle<TDocument> Document<TDocument>()
|
|
where TDocument : class
|
|
{
|
|
if (TryGetDocument<TDocument>(out var document))
|
|
return document;
|
|
|
|
throw new NotSupportedException(
|
|
$"CultMesh document catalog does not expose a document for {typeof(TDocument).FullName}.");
|
|
}
|
|
|
|
/// <summary>Reads one typed document snapshot by CLR type or same-schema alias.</summary>
|
|
public Task<TDocument> LatestAsync<TDocument>()
|
|
where TDocument : class
|
|
{
|
|
return Document<TDocument>().LatestAsync();
|
|
}
|
|
|
|
/// <summary>Reads one typed document synchronously by CLR type or same-schema alias.</summary>
|
|
public TDocument Latest<TDocument>()
|
|
where TDocument : class
|
|
{
|
|
return Document<TDocument>().Latest();
|
|
}
|
|
|
|
/// <summary>Gets whether one typed document can be replaced by CLR type or same-schema alias.</summary>
|
|
public bool CanReplace<TDocument>()
|
|
where TDocument : class
|
|
{
|
|
return TryGetDocument<TDocument>(out var document) && document.CanReplace;
|
|
}
|
|
|
|
/// <summary>Replaces one typed document by CLR type or same-schema alias.</summary>
|
|
public Task ReplaceAsync<TDocument>(TDocument value)
|
|
where TDocument : class
|
|
{
|
|
return Document<TDocument>().ReplaceAsync(value);
|
|
}
|
|
|
|
/// <summary>Gets whether one typed document can submit client predictions by CLR type or same-schema alias.</summary>
|
|
public bool CanSubmitPrediction<TDocument>()
|
|
where TDocument : class
|
|
{
|
|
return TryGetDocument<TDocument>(out var document) && document.CanSubmitPrediction;
|
|
}
|
|
|
|
/// <summary>Submits a client prediction for one typed document by CLR type or same-schema alias.</summary>
|
|
public Task SubmitPredictionAsync<TDocument>(TDocument value)
|
|
where TDocument : class
|
|
{
|
|
return Document<TDocument>().SubmitPredictionAsync(value);
|
|
}
|
|
|
|
/// <summary>Gets an explicit authoritative writer by CLR type or same-schema alias.</summary>
|
|
public CultMeshDocumentWriter<TDocument> AuthoritativeWriter<TDocument>()
|
|
where TDocument : class
|
|
{
|
|
return Document<TDocument>().AuthoritativeWriter();
|
|
}
|
|
|
|
/// <summary>Gets an explicit prediction writer by CLR type or same-schema alias.</summary>
|
|
public CultMeshDocumentWriter<TDocument> PredictionWriter<TDocument>()
|
|
where TDocument : class
|
|
{
|
|
return Document<TDocument>().PredictionWriter();
|
|
}
|
|
|
|
/// <summary>Watches one typed document by CLR type or same-schema alias.</summary>
|
|
public Observable<TDocument> Watch<TDocument>()
|
|
where TDocument : class
|
|
{
|
|
return Document<TDocument>().Watch();
|
|
}
|
|
|
|
/// <summary>Subscribes to one typed document by CLR type or same-schema alias.</summary>
|
|
public IDisposable Watch<TDocument>(Action<TDocument> onNext)
|
|
where TDocument : class
|
|
{
|
|
return Document<TDocument>().Watch(onNext);
|
|
}
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inspectable typed collection handle exposed by CultMesh.
|
|
/// </summary>
|
|
public interface ICultMeshCollectionHandle
|
|
{
|
|
/// <summary>Gets the CLR document type presented by this collection.</summary>
|
|
Type DocumentType { get; }
|
|
|
|
/// <summary>Gets the semantic collection id.</summary>
|
|
string CollectionId { get; }
|
|
|
|
/// <summary>Gets the stable CultCache schema name.</summary>
|
|
string SchemaName { get; }
|
|
|
|
/// <summary>Gets the stable CultCache schema version.</summary>
|
|
string SchemaVersion { get; }
|
|
|
|
/// <summary>Gets the content-derived schema identifier.</summary>
|
|
string SchemaId { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for collection access.</summary>
|
|
CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets typed state sources this collection handle depends on, when known.</summary>
|
|
IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
/// <summary>Creates a same-schema alias presentation for another CLR document type.</summary>
|
|
CultMeshCollectionHandle<TAlias> AsSchemaAlias<TAlias>() where TAlias : class;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Schema-aware lookup table for typed CultMesh collection handles.
|
|
/// </summary>
|
|
public sealed class CultMeshCollectionCatalog
|
|
{
|
|
private readonly IReadOnlyList<ICultMeshCollectionHandle> _collections;
|
|
private readonly IReadOnlyDictionary<Type, ICultMeshCollectionHandle> _collectionsByType;
|
|
private readonly IReadOnlyDictionary<string, ICultMeshCollectionHandle> _collectionsBySchema;
|
|
|
|
/// <summary>Creates a collection catalog from known handles.</summary>
|
|
public CultMeshCollectionCatalog(IEnumerable<ICultMeshCollectionHandle> collections)
|
|
{
|
|
if (collections == null) throw new ArgumentNullException(nameof(collections));
|
|
|
|
var collectionList = collections
|
|
.Where(collection => collection != null)
|
|
.ToArray();
|
|
var byType = new Dictionary<Type, ICultMeshCollectionHandle>();
|
|
var bySchema = new Dictionary<string, ICultMeshCollectionHandle>(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;
|
|
}
|
|
|
|
/// <summary>Gets the handles in this catalog.</summary>
|
|
public IReadOnlyList<ICultMeshCollectionHandle> Collections => _collections;
|
|
|
|
/// <summary>Looks up a collection handle by schema id, shared schema name, or schema version.</summary>
|
|
public bool TryGetCollectionBySchema(
|
|
string schema,
|
|
out ICultMeshCollectionHandle collection)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(schema) &&
|
|
_collectionsBySchema.TryGetValue(schema, out collection!))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
collection = null!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Looks up a collection handle by schema id, shared schema name, or schema version.</summary>
|
|
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}'.");
|
|
}
|
|
|
|
/// <summary>Looks up a typed collection handle by CLR type or same-schema alias.</summary>
|
|
public bool TryGetCollection<TDocument>(
|
|
out CultMeshCollectionHandle<TDocument> collection)
|
|
where TDocument : class
|
|
{
|
|
if (_collectionsByType.TryGetValue(typeof(TDocument), out var untypedCollection) &&
|
|
untypedCollection is CultMeshCollectionHandle<TDocument> typedCollection)
|
|
{
|
|
collection = typedCollection;
|
|
return true;
|
|
}
|
|
|
|
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
|
|
if (_collectionsBySchema.TryGetValue(descriptor.SchemaId, out var schemaCollection) ||
|
|
_collectionsBySchema.TryGetValue(descriptor.SchemaVersion, out schemaCollection!) ||
|
|
_collectionsBySchema.TryGetValue(descriptor.SchemaName, out schemaCollection!))
|
|
{
|
|
if (schemaCollection is CultMeshCollectionHandle<TDocument> schemaTypedCollection)
|
|
{
|
|
collection = schemaTypedCollection;
|
|
return true;
|
|
}
|
|
|
|
collection = schemaCollection.AsSchemaAlias<TDocument>();
|
|
return true;
|
|
}
|
|
|
|
collection = null!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Looks up a typed collection handle by CLR type or same-schema alias.</summary>
|
|
public CultMeshCollectionHandle<TDocument> Collection<TDocument>()
|
|
where TDocument : class
|
|
{
|
|
if (TryGetCollection<TDocument>(out var collection))
|
|
return collection;
|
|
|
|
throw new NotSupportedException(
|
|
$"CultMesh collection catalog does not expose a collection for {typeof(TDocument).FullName}.");
|
|
}
|
|
|
|
/// <summary>Reads one typed collection snapshot by CLR type or same-schema alias.</summary>
|
|
public Task<IReadOnlyList<TDocument>> LatestAsync<TDocument>()
|
|
where TDocument : class
|
|
{
|
|
return Collection<TDocument>().LatestAsync();
|
|
}
|
|
|
|
/// <summary>Watches one typed collection by CLR type or same-schema alias.</summary>
|
|
public Observable<CultMeshCollectionChange<TDocument>> WatchChanges<TDocument>()
|
|
where TDocument : class
|
|
{
|
|
return Collection<TDocument>().WatchChanges();
|
|
}
|
|
|
|
/// <summary>Subscribes to one typed collection by CLR type or same-schema alias.</summary>
|
|
public IDisposable WatchChanges<TDocument>(Action<CultMeshCollectionChange<TDocument>> onNext)
|
|
where TDocument : class
|
|
{
|
|
return Collection<TDocument>().WatchChanges(onNext);
|
|
}
|
|
}
|
|
|
|
/// <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> : ICultMeshCollectionHandle
|
|
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>
|
|
public sealed class CultMeshLiveFeedDiagnostic
|
|
{
|
|
/// <summary>Creates live feed diagnostics.</summary>
|
|
public CultMeshLiveFeedDiagnostic(
|
|
string feedId,
|
|
CultMeshRouteHint? routeHint = null,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null)
|
|
{
|
|
FeedId = RequireNonEmpty(feedId, nameof(feedId));
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
|
}
|
|
|
|
/// <summary>Gets the semantic feed id.</summary>
|
|
public string FeedId { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for the feed.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets the typed state sources this live feed depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inspectable metadata for a typed query surface.
|
|
/// </summary>
|
|
public sealed class CultMeshQuerySurfaceDiagnostic
|
|
{
|
|
/// <summary>Creates query surface diagnostics.</summary>
|
|
public CultMeshQuerySurfaceDiagnostic(
|
|
string queryId,
|
|
CultMeshRouteHint? routeHint = null,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null)
|
|
{
|
|
QueryId = RequireNonEmpty(queryId, nameof(queryId));
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
|
}
|
|
|
|
/// <summary>Gets the semantic query id.</summary>
|
|
public string QueryId { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for the query.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets the typed state sources this query depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inspectable metadata for a typed operation handle.
|
|
/// </summary>
|
|
public sealed class CultMeshOperationHandleDiagnostic
|
|
{
|
|
/// <summary>Creates operation handle diagnostics.</summary>
|
|
public CultMeshOperationHandleDiagnostic(string operationId)
|
|
{
|
|
OperationId = RequireNonEmpty(operationId, nameof(operationId));
|
|
}
|
|
|
|
/// <summary>Gets the semantic operation id.</summary>
|
|
public string OperationId { get; }
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inspectable metadata for a typed state pointer.
|
|
/// </summary>
|
|
public sealed class CultMeshStatePointerDiagnostic
|
|
{
|
|
/// <summary>Creates state pointer diagnostics.</summary>
|
|
public CultMeshStatePointerDiagnostic(
|
|
string pointerId,
|
|
CultMeshRouteHint? routeHint = null,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null)
|
|
{
|
|
PointerId = RequireNonEmpty(pointerId, nameof(pointerId));
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
|
}
|
|
|
|
/// <summary>Gets the semantic pointer id.</summary>
|
|
public string PointerId { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for pointer resolution.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets typed state sources this pointer depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inspectable metadata for a typed projection recipe.
|
|
/// </summary>
|
|
public sealed class CultMeshProjectionRecipeDiagnostic
|
|
{
|
|
/// <summary>Creates projection recipe diagnostics.</summary>
|
|
public CultMeshProjectionRecipeDiagnostic(
|
|
string projectionId,
|
|
CultMeshRouteHint? routeHint = null,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null)
|
|
{
|
|
ProjectionId = RequireNonEmpty(projectionId, nameof(projectionId));
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
|
}
|
|
|
|
/// <summary>Gets the semantic projection id.</summary>
|
|
public string ProjectionId { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for the projection.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets the typed state sources this projection depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Names the kind of typed surface described in a CultMesh surface catalog.
|
|
/// </summary>
|
|
public enum CultMeshSurfaceKind
|
|
{
|
|
/// <summary>A typed derived-state query surface.</summary>
|
|
Query,
|
|
|
|
/// <summary>A reusable typed projection recipe.</summary>
|
|
ProjectionRecipe,
|
|
|
|
/// <summary>A coherent live view feed.</summary>
|
|
LiveFeed,
|
|
|
|
/// <summary>A typed operation handle.</summary>
|
|
Operation,
|
|
|
|
/// <summary>A typed reactive document handle.</summary>
|
|
Document,
|
|
|
|
/// <summary>A typed reactive collection handle.</summary>
|
|
Collection,
|
|
|
|
/// <summary>A typed pointer to Verse state.</summary>
|
|
StatePointer,
|
|
|
|
/// <summary>A native slice or slab view.</summary>
|
|
NativeSliceView
|
|
}
|
|
|
|
/// <summary>
|
|
/// One inspectable typed surface advertised by a runtime.
|
|
/// </summary>
|
|
public sealed class CultMeshSurfaceDiagnostic
|
|
{
|
|
/// <summary>Creates a surface diagnostic.</summary>
|
|
public CultMeshSurfaceDiagnostic(
|
|
CultMeshSurfaceKind kind,
|
|
string surfaceId,
|
|
CultMeshRouteHint? routeHint = null,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null)
|
|
{
|
|
Kind = kind;
|
|
SurfaceId = RequireNonEmpty(surfaceId, nameof(surfaceId));
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
|
}
|
|
|
|
/// <summary>Gets the kind of surface.</summary>
|
|
public CultMeshSurfaceKind Kind { get; }
|
|
|
|
/// <summary>Gets the semantic surface id.</summary>
|
|
public string SurfaceId { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for the surface.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets typed state sources this surface depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inspectable catalog of typed surfaces exposed by a runtime or generated binding.
|
|
/// </summary>
|
|
public sealed class CultMeshSurfaceCatalogDiagnostic
|
|
{
|
|
/// <summary>Creates a surface catalog diagnostic.</summary>
|
|
public CultMeshSurfaceCatalogDiagnostic(
|
|
string catalogId,
|
|
IEnumerable<CultMeshSurfaceDiagnostic> surfaces)
|
|
{
|
|
CatalogId = RequireNonEmpty(catalogId, nameof(catalogId));
|
|
Surfaces = (surfaces ?? throw new ArgumentNullException(nameof(surfaces))).ToArray();
|
|
}
|
|
|
|
/// <summary>Gets the semantic catalog id.</summary>
|
|
public string CatalogId { get; }
|
|
|
|
/// <summary>Gets the advertised typed surfaces.</summary>
|
|
public IReadOnlyList<CultMeshSurfaceDiagnostic> Surfaces { get; }
|
|
|
|
/// <summary>Finds a surface by semantic id.</summary>
|
|
public CultMeshSurfaceDiagnostic? Find(string surfaceId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(surfaceId)) return null;
|
|
return Surfaces.FirstOrDefault(surface => string.Equals(surface.SurfaceId, surfaceId, StringComparison.Ordinal));
|
|
}
|
|
|
|
/// <summary>Finds surfaces by kind in catalog order.</summary>
|
|
public IReadOnlyList<CultMeshSurfaceDiagnostic> FindByKind(CultMeshSurfaceKind kind)
|
|
{
|
|
return Surfaces.Where(surface => surface.Kind == kind).ToArray();
|
|
}
|
|
|
|
/// <summary>Groups surfaces by kind for generated bindings and tooling.</summary>
|
|
public CultMeshSurfaceCatalogIndexDiagnostic IndexByKind()
|
|
{
|
|
return new CultMeshSurfaceCatalogIndexDiagnostic(this);
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Kind-indexed view of a typed surface catalog for generated bindings, UI runtimes, and tools.
|
|
/// </summary>
|
|
public sealed class CultMeshSurfaceCatalogIndexDiagnostic
|
|
{
|
|
/// <summary>Creates a kind-indexed catalog diagnostic.</summary>
|
|
public CultMeshSurfaceCatalogIndexDiagnostic(CultMeshSurfaceCatalogDiagnostic catalog)
|
|
{
|
|
if (catalog == null) throw new ArgumentNullException(nameof(catalog));
|
|
CatalogId = catalog.CatalogId;
|
|
Queries = catalog.FindByKind(CultMeshSurfaceKind.Query);
|
|
ProjectionRecipes = catalog.FindByKind(CultMeshSurfaceKind.ProjectionRecipe);
|
|
LiveFeeds = catalog.FindByKind(CultMeshSurfaceKind.LiveFeed);
|
|
Operations = catalog.FindByKind(CultMeshSurfaceKind.Operation);
|
|
Documents = catalog.FindByKind(CultMeshSurfaceKind.Document);
|
|
Collections = catalog.FindByKind(CultMeshSurfaceKind.Collection);
|
|
StatePointers = catalog.FindByKind(CultMeshSurfaceKind.StatePointer);
|
|
NativeSliceViews = catalog.FindByKind(CultMeshSurfaceKind.NativeSliceView);
|
|
}
|
|
|
|
/// <summary>Gets the semantic catalog id.</summary>
|
|
public string CatalogId { get; }
|
|
|
|
/// <summary>Gets typed derived-state query surfaces.</summary>
|
|
public IReadOnlyList<CultMeshSurfaceDiagnostic> Queries { get; }
|
|
|
|
/// <summary>Gets reusable typed projection recipes.</summary>
|
|
public IReadOnlyList<CultMeshSurfaceDiagnostic> ProjectionRecipes { get; }
|
|
|
|
/// <summary>Gets coherent live view feeds.</summary>
|
|
public IReadOnlyList<CultMeshSurfaceDiagnostic> LiveFeeds { get; }
|
|
|
|
/// <summary>Gets typed operation handles.</summary>
|
|
public IReadOnlyList<CultMeshSurfaceDiagnostic> Operations { get; }
|
|
|
|
/// <summary>Gets typed reactive document handles.</summary>
|
|
public IReadOnlyList<CultMeshSurfaceDiagnostic> Documents { get; }
|
|
|
|
/// <summary>Gets typed reactive collection handles.</summary>
|
|
public IReadOnlyList<CultMeshSurfaceDiagnostic> Collections { get; }
|
|
|
|
/// <summary>Gets typed pointers to Verse state.</summary>
|
|
public IReadOnlyList<CultMeshSurfaceDiagnostic> StatePointers { get; }
|
|
|
|
/// <summary>Gets native slice or slab views.</summary>
|
|
public IReadOnlyList<CultMeshSurfaceDiagnostic> NativeSliceViews { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Options for a polling watch fallback over a typed query or live feed snapshot.
|
|
/// </summary>
|
|
public sealed class CultMeshPollingWatchOptions<TResult>
|
|
{
|
|
/// <summary>Creates polling watch options.</summary>
|
|
public CultMeshPollingWatchOptions(
|
|
TimeSpan? interval = null,
|
|
bool emitInitial = true,
|
|
IEqualityComparer<TResult>? comparer = null)
|
|
{
|
|
Interval = interval ?? TimeSpan.FromMilliseconds(50);
|
|
if (Interval <= TimeSpan.Zero)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(interval), "Polling interval must be positive.");
|
|
}
|
|
|
|
EmitInitial = emitInitial;
|
|
Comparer = comparer ?? EqualityComparer<TResult>.Default;
|
|
}
|
|
|
|
/// <summary>Gets the polling interval.</summary>
|
|
public TimeSpan Interval { get; }
|
|
|
|
/// <summary>Gets whether the first sampled value should be emitted.</summary>
|
|
public bool EmitInitial { get; }
|
|
|
|
/// <summary>Gets the equality comparer used to suppress unchanged samples.</summary>
|
|
public IEqualityComparer<TResult> Comparer { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Timer-backed fallback watcher for runtimes that do not yet have native reactive transport.
|
|
/// </summary>
|
|
internal sealed class CultMeshPollingWatcher<TParameters, TResult> : IDisposable
|
|
{
|
|
private readonly Func<TParameters, CultMeshQueryContext, Task<TResult>> _sample;
|
|
private readonly CultMeshPollingWatchOptions<TResult> _options;
|
|
private readonly Subject<TResult> _subject = new();
|
|
private readonly Timer _timer;
|
|
private readonly object _gate = new();
|
|
private readonly TParameters _parameters;
|
|
private readonly CultMeshQueryContext _context;
|
|
private TResult? _last;
|
|
private bool _hasLast;
|
|
private bool _running;
|
|
private bool _disposed;
|
|
|
|
public CultMeshPollingWatcher(
|
|
Func<TParameters, CultMeshQueryContext, Task<TResult>> sample,
|
|
TParameters parameters,
|
|
CultMeshQueryContext context,
|
|
CultMeshPollingWatchOptions<TResult> options)
|
|
{
|
|
_sample = sample ?? throw new ArgumentNullException(nameof(sample));
|
|
_parameters = parameters;
|
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
_timer = new Timer(OnTimer, null, TimeSpan.Zero, _options.Interval);
|
|
}
|
|
|
|
public Observable<TResult> Observable => _subject;
|
|
|
|
public void Dispose()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
}
|
|
|
|
_timer.Dispose();
|
|
_subject.Dispose();
|
|
}
|
|
|
|
private void OnTimer(object? _)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_disposed || _running)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_running = true;
|
|
}
|
|
|
|
_ = SampleAsync();
|
|
}
|
|
|
|
private async Task SampleAsync()
|
|
{
|
|
try
|
|
{
|
|
var next = await _sample(_parameters, _context).ConfigureAwait(false);
|
|
var shouldEmit = false;
|
|
lock (_gate)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
shouldEmit = _hasLast
|
|
? !_options.Comparer.Equals(_last!, next)
|
|
: _options.EmitInitial;
|
|
_last = next;
|
|
_hasLast = true;
|
|
}
|
|
|
|
if (shouldEmit)
|
|
{
|
|
_subject.OnNext(next);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
lock (_gate)
|
|
{
|
|
_running = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed class CultMeshCompositeDisposable : IDisposable
|
|
{
|
|
private readonly IDisposable[] _disposables;
|
|
private int _disposed;
|
|
|
|
public CultMeshCompositeDisposable(params IDisposable[] disposables)
|
|
{
|
|
_disposables = disposables ?? throw new ArgumentNullException(nameof(disposables));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var disposable in _disposables)
|
|
{
|
|
disposable.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Typed pointer to Verse state that UI surfaces and tools can resolve and watch.
|
|
/// </summary>
|
|
public sealed class CultMeshStatePointer<T>
|
|
{
|
|
private readonly Func<CultMeshQueryContext, Task<T?>> _resolve;
|
|
private readonly Func<CultMeshQueryContext, Observable<T>> _watch;
|
|
|
|
/// <summary>Creates a state pointer.</summary>
|
|
public CultMeshStatePointer(
|
|
string pointerId,
|
|
Func<Task<T?>> resolve,
|
|
Func<Observable<T>> watch,
|
|
CultMeshRouteHint? routeHint = null,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null)
|
|
: this(
|
|
pointerId,
|
|
_ => (resolve ?? throw new ArgumentNullException(nameof(resolve)))(),
|
|
_ => (watch ?? throw new ArgumentNullException(nameof(watch)))(),
|
|
routeHint,
|
|
sources)
|
|
{
|
|
}
|
|
|
|
/// <summary>Creates a state pointer that can resolve through a Verse query context.</summary>
|
|
public CultMeshStatePointer(
|
|
string pointerId,
|
|
Func<CultMeshQueryContext, Task<T?>> resolve,
|
|
Func<CultMeshQueryContext, Observable<T>> watch,
|
|
CultMeshRouteHint? routeHint = null,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null)
|
|
{
|
|
PointerId = RequireNonEmpty(pointerId, nameof(pointerId));
|
|
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
|
_watch = watch ?? throw new ArgumentNullException(nameof(watch));
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
|
}
|
|
|
|
/// <summary>Gets the semantic pointer id.</summary>
|
|
public string PointerId { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for pointer resolution.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets typed state sources this pointer depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
/// <summary>Resolves the pointer once.</summary>
|
|
public Task<T?> ResolveAsync()
|
|
{
|
|
return ResolveAsync(CultMeshQueryContext.ForRuntime("local"));
|
|
}
|
|
|
|
/// <summary>Resolves the pointer once through a query context.</summary>
|
|
public Task<T?> ResolveAsync(CultMeshQueryContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
return _resolve(ResolveContext(context));
|
|
}
|
|
|
|
/// <summary>Resolves the pointer once for one runtime using default context.</summary>
|
|
public Task<T?> ResolveAsync(string runtimeId)
|
|
{
|
|
return ResolveAsync(CultMeshQueryContext.ForRuntime(runtimeId));
|
|
}
|
|
|
|
/// <summary>Watches resolved values for this pointer.</summary>
|
|
public Observable<T> Watch()
|
|
{
|
|
return Watch(CultMeshQueryContext.ForRuntime("local"));
|
|
}
|
|
|
|
/// <summary>Watches resolved values for this pointer through a query context.</summary>
|
|
public Observable<T> Watch(CultMeshQueryContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
return _watch(ResolveContext(context));
|
|
}
|
|
|
|
/// <summary>Binds this pointer to a Verse context.</summary>
|
|
public CultMeshBoundStatePointer<T> Bind(CultMeshVerseContext context)
|
|
{
|
|
return new CultMeshBoundStatePointer<T>(context, this);
|
|
}
|
|
|
|
/// <summary>Binds this pointer to a Verse.</summary>
|
|
public CultMeshBoundStatePointer<T> Bind(CultMeshVerse verse)
|
|
{
|
|
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
|
return Bind(verse.Context);
|
|
}
|
|
|
|
private CultMeshQueryContext ResolveContext(CultMeshQueryContext context)
|
|
{
|
|
return context.RouteHint.Kind == CultMeshLocalityKind.Automatic &&
|
|
RouteHint.Kind != CultMeshLocalityKind.Automatic
|
|
? context.WithRoute(RouteHint)
|
|
: context;
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// State pointer pre-bound to a Verse so UI/tool runtimes can resolve state without context plumbing.
|
|
/// </summary>
|
|
public sealed class CultMeshBoundStatePointer<T>
|
|
{
|
|
/// <summary>Creates a Verse-bound state pointer.</summary>
|
|
public CultMeshBoundStatePointer(
|
|
CultMeshVerseContext context,
|
|
CultMeshStatePointer<T> pointer)
|
|
{
|
|
Context = context ?? throw new ArgumentNullException(nameof(context));
|
|
Pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
|
|
}
|
|
|
|
/// <summary>Gets the bound Verse context.</summary>
|
|
public CultMeshVerseContext Context { get; }
|
|
|
|
/// <summary>Gets the underlying state pointer.</summary>
|
|
public CultMeshStatePointer<T> Pointer { get; }
|
|
|
|
/// <summary>Gets the semantic pointer id.</summary>
|
|
public string PointerId => Pointer.PointerId;
|
|
|
|
/// <summary>Gets typed state sources this pointer depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources => Pointer.Sources;
|
|
|
|
/// <summary>Gets the preferred or observed route for pointer resolution.</summary>
|
|
public CultMeshRouteHint RouteHint => Pointer.RouteHint;
|
|
|
|
/// <summary>Resolves the pointer through the bound Verse.</summary>
|
|
public Task<T?> ResolveAsync()
|
|
{
|
|
return Pointer.ResolveAsync(Context.QueryContext());
|
|
}
|
|
|
|
/// <summary>Watches the pointer through the bound Verse.</summary>
|
|
public Observable<T> Watch()
|
|
{
|
|
return Pointer.Watch(Context.QueryContext());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Typed pointer to Verse state that UI surfaces and tools can resolve, watch, and replace.
|
|
/// </summary>
|
|
public sealed class CultMeshMutableStatePointer<T>
|
|
{
|
|
private readonly Func<CultMeshQueryContext, Task<T?>> _resolve;
|
|
private readonly Func<CultMeshQueryContext, Observable<T>> _watch;
|
|
private readonly Func<CultMeshQueryContext, T, Task> _replace;
|
|
|
|
/// <summary>Creates a mutable state pointer.</summary>
|
|
public CultMeshMutableStatePointer(
|
|
string pointerId,
|
|
Func<Task<T?>> resolve,
|
|
Func<Observable<T>> watch,
|
|
Func<T, Task> replace,
|
|
CultMeshRouteHint? routeHint = null,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null)
|
|
: this(
|
|
pointerId,
|
|
_ => (resolve ?? throw new ArgumentNullException(nameof(resolve)))(),
|
|
_ => (watch ?? throw new ArgumentNullException(nameof(watch)))(),
|
|
(_, value) => (replace ?? throw new ArgumentNullException(nameof(replace)))(value),
|
|
routeHint,
|
|
sources)
|
|
{
|
|
}
|
|
|
|
/// <summary>Creates a mutable state pointer that can operate through a Verse query context.</summary>
|
|
public CultMeshMutableStatePointer(
|
|
string pointerId,
|
|
Func<CultMeshQueryContext, Task<T?>> resolve,
|
|
Func<CultMeshQueryContext, Observable<T>> watch,
|
|
Func<CultMeshQueryContext, T, Task> replace,
|
|
CultMeshRouteHint? routeHint = null,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null)
|
|
{
|
|
PointerId = RequireNonEmpty(pointerId, nameof(pointerId));
|
|
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
|
_watch = watch ?? throw new ArgumentNullException(nameof(watch));
|
|
_replace = replace ?? throw new ArgumentNullException(nameof(replace));
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
|
}
|
|
|
|
/// <summary>Gets the semantic pointer id.</summary>
|
|
public string PointerId { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for pointer access.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets typed state sources this pointer depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
/// <summary>Resolves the pointer once.</summary>
|
|
public Task<T?> ResolveAsync()
|
|
{
|
|
return ResolveAsync(CultMeshQueryContext.ForRuntime("local"));
|
|
}
|
|
|
|
/// <summary>Resolves the pointer once.</summary>
|
|
public Task<T?> ReadAsync()
|
|
{
|
|
return ResolveAsync();
|
|
}
|
|
|
|
/// <summary>Resolves the pointer once through a query context.</summary>
|
|
public Task<T?> ResolveAsync(CultMeshQueryContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
return _resolve(ResolveContext(context));
|
|
}
|
|
|
|
/// <summary>Resolves the pointer once through a query context.</summary>
|
|
public Task<T?> ReadAsync(CultMeshQueryContext context)
|
|
{
|
|
return ResolveAsync(context);
|
|
}
|
|
|
|
/// <summary>Resolves the pointer once for one runtime using default context.</summary>
|
|
public Task<T?> ResolveAsync(string runtimeId)
|
|
{
|
|
return ResolveAsync(CultMeshQueryContext.ForRuntime(runtimeId));
|
|
}
|
|
|
|
/// <summary>Resolves the pointer once for one runtime using default context.</summary>
|
|
public Task<T?> ReadAsync(string runtimeId)
|
|
{
|
|
return ResolveAsync(runtimeId);
|
|
}
|
|
|
|
/// <summary>Replaces the pointed state value.</summary>
|
|
public Task ReplaceAsync(T value)
|
|
{
|
|
return ReplaceAsync(value, CultMeshQueryContext.ForRuntime("local"));
|
|
}
|
|
|
|
/// <summary>Replaces the pointed state value through a query context.</summary>
|
|
public Task ReplaceAsync(T value, CultMeshQueryContext context)
|
|
{
|
|
if (value == null) throw new ArgumentNullException(nameof(value));
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
return _replace(ResolveContext(context), value);
|
|
}
|
|
|
|
/// <summary>Replaces the pointed state value for one runtime using default context.</summary>
|
|
public Task ReplaceAsync(T value, string runtimeId)
|
|
{
|
|
return ReplaceAsync(value, CultMeshQueryContext.ForRuntime(runtimeId));
|
|
}
|
|
|
|
/// <summary>Watches resolved values for this pointer.</summary>
|
|
public Observable<T> Watch()
|
|
{
|
|
return Watch(CultMeshQueryContext.ForRuntime("local"));
|
|
}
|
|
|
|
/// <summary>Watches resolved values for this pointer through a query context.</summary>
|
|
public Observable<T> Watch(CultMeshQueryContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
return _watch(ResolveContext(context));
|
|
}
|
|
|
|
/// <summary>Views this mutable pointer as a read/watch state pointer.</summary>
|
|
public CultMeshStatePointer<T> AsStatePointer()
|
|
{
|
|
return new CultMeshStatePointer<T>(
|
|
PointerId,
|
|
(Func<CultMeshQueryContext, Task<T?>>)ResolveAsync,
|
|
(Func<CultMeshQueryContext, Observable<T>>)Watch,
|
|
RouteHint,
|
|
Sources);
|
|
}
|
|
|
|
/// <summary>Binds this pointer to a Verse context.</summary>
|
|
public CultMeshBoundMutableStatePointer<T> Bind(CultMeshVerseContext context)
|
|
{
|
|
return new CultMeshBoundMutableStatePointer<T>(context, this);
|
|
}
|
|
|
|
/// <summary>Binds this pointer to a Verse.</summary>
|
|
public CultMeshBoundMutableStatePointer<T> Bind(CultMeshVerse verse)
|
|
{
|
|
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
|
return Bind(verse.Context);
|
|
}
|
|
|
|
private CultMeshQueryContext ResolveContext(CultMeshQueryContext context)
|
|
{
|
|
return context.RouteHint.Kind == CultMeshLocalityKind.Automatic &&
|
|
RouteHint.Kind != CultMeshLocalityKind.Automatic
|
|
? context.WithRoute(RouteHint)
|
|
: context;
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mutable state pointer pre-bound to a Verse so UI/tool runtimes can read, watch, and replace state without context plumbing.
|
|
/// </summary>
|
|
public sealed class CultMeshBoundMutableStatePointer<T>
|
|
{
|
|
/// <summary>Creates a Verse-bound mutable state pointer.</summary>
|
|
public CultMeshBoundMutableStatePointer(
|
|
CultMeshVerseContext context,
|
|
CultMeshMutableStatePointer<T> pointer)
|
|
{
|
|
Context = context ?? throw new ArgumentNullException(nameof(context));
|
|
Pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
|
|
}
|
|
|
|
/// <summary>Gets the bound Verse context.</summary>
|
|
public CultMeshVerseContext Context { get; }
|
|
|
|
/// <summary>Gets the underlying mutable state pointer.</summary>
|
|
public CultMeshMutableStatePointer<T> Pointer { get; }
|
|
|
|
/// <summary>Gets the semantic pointer id.</summary>
|
|
public string PointerId => Pointer.PointerId;
|
|
|
|
/// <summary>Gets typed state sources this pointer depends on, when known.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources => Pointer.Sources;
|
|
|
|
/// <summary>Gets the preferred or observed route for pointer access.</summary>
|
|
public CultMeshRouteHint RouteHint => Pointer.RouteHint;
|
|
|
|
/// <summary>Resolves the pointer through the bound Verse.</summary>
|
|
public Task<T?> ResolveAsync()
|
|
{
|
|
return Pointer.ResolveAsync(Context.QueryContext());
|
|
}
|
|
|
|
/// <summary>Resolves the pointer through the bound Verse.</summary>
|
|
public Task<T?> ReadAsync()
|
|
{
|
|
return ResolveAsync();
|
|
}
|
|
|
|
/// <summary>Replaces the pointer value through the bound Verse.</summary>
|
|
public Task ReplaceAsync(T value)
|
|
{
|
|
return Pointer.ReplaceAsync(value, Context.QueryContext());
|
|
}
|
|
|
|
/// <summary>Watches the pointer through the bound Verse.</summary>
|
|
public Observable<T> Watch()
|
|
{
|
|
return Pointer.Watch(Context.QueryContext());
|
|
}
|
|
|
|
/// <summary>Views this mutable pointer as a read/watch state pointer bound to the same Verse.</summary>
|
|
public CultMeshBoundStatePointer<T> AsStatePointer()
|
|
{
|
|
return new CultMeshBoundStatePointer<T>(Context, Pointer.AsStatePointer());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Describes one typed source consumed by a projection recipe.
|
|
/// </summary>
|
|
public sealed class CultMeshProjectionSource
|
|
{
|
|
/// <summary>Creates a projection source descriptor.</summary>
|
|
public CultMeshProjectionSource(string sourceId, string? schemaId = null, string? description = null)
|
|
{
|
|
SourceId = RequireNonEmpty(sourceId, nameof(sourceId));
|
|
SchemaId = schemaId;
|
|
Description = description;
|
|
}
|
|
|
|
/// <summary>Gets the semantic source id, such as a state pointer, document handle, or native view id.</summary>
|
|
public string SourceId { get; }
|
|
|
|
/// <summary>Gets the optional schema id for the source.</summary>
|
|
public string? SchemaId { get; }
|
|
|
|
/// <summary>Gets optional human-facing source diagnostics.</summary>
|
|
public string? Description { get; }
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves string state references embedded in portable UI/tool surfaces through a named CultMesh resolver.
|
|
/// </summary>
|
|
public sealed class CultMeshStateRefResolver
|
|
{
|
|
private readonly Func<string, CultMeshQueryContext, string?> _resolve;
|
|
|
|
/// <summary>Creates a state-reference resolver.</summary>
|
|
public CultMeshStateRefResolver(
|
|
string resolverId,
|
|
Func<string, CultMeshQueryContext, string?> resolve,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null,
|
|
CultMeshRouteHint? routeHint = null)
|
|
{
|
|
ResolverId = RequireNonEmpty(resolverId, nameof(resolverId));
|
|
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
|
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
}
|
|
|
|
/// <summary>Gets the semantic resolver id.</summary>
|
|
public string ResolverId { get; }
|
|
|
|
/// <summary>Gets the source state read by the resolver.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
/// <summary>Gets the preferred route for resolving state refs.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets an empty resolver that always returns an empty string.</summary>
|
|
public static CultMeshStateRefResolver Empty { get; } =
|
|
new("cultmesh.state_refs.empty", (_stateRef, _context) => "");
|
|
|
|
/// <summary>Resolves a state ref using a local query context.</summary>
|
|
public string Resolve(string stateRef)
|
|
{
|
|
return Resolve(stateRef, CultMeshQueryContext.ForRuntime("local"));
|
|
}
|
|
|
|
/// <summary>Resolves a state ref using a runtime id.</summary>
|
|
public string Resolve(string stateRef, string runtimeId)
|
|
{
|
|
return Resolve(stateRef, CultMeshQueryContext.ForRuntime(runtimeId));
|
|
}
|
|
|
|
/// <summary>Resolves a state ref using an explicit query context.</summary>
|
|
public string Resolve(string stateRef, CultMeshQueryContext context)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(stateRef))
|
|
return "";
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
return _resolve(stateRef, ResolveContext(context)) ?? "";
|
|
}
|
|
|
|
/// <summary>Attempts to resolve a non-empty value.</summary>
|
|
public bool TryResolve(string stateRef, out string value)
|
|
{
|
|
value = Resolve(stateRef);
|
|
return !string.IsNullOrEmpty(value);
|
|
}
|
|
|
|
/// <summary>Attempts to resolve a non-empty value.</summary>
|
|
public bool TryResolve(string stateRef, CultMeshQueryContext context, out string value)
|
|
{
|
|
value = Resolve(stateRef, context);
|
|
return !string.IsNullOrEmpty(value);
|
|
}
|
|
|
|
/// <summary>Creates a resolver that falls back to another resolver when this resolver returns empty.</summary>
|
|
public CultMeshStateRefResolver Or(CultMeshStateRefResolver fallback)
|
|
{
|
|
if (fallback == null) throw new ArgumentNullException(nameof(fallback));
|
|
return new CultMeshStateRefResolver(
|
|
ResolverId + "|" + fallback.ResolverId,
|
|
(stateRef, context) =>
|
|
{
|
|
var value = Resolve(stateRef, context);
|
|
return string.IsNullOrEmpty(value)
|
|
? fallback.Resolve(stateRef, context)
|
|
: value;
|
|
},
|
|
Sources.Concat(fallback.Sources),
|
|
RouteHint.Kind == CultMeshLocalityKind.Automatic ? fallback.RouteHint : RouteHint);
|
|
}
|
|
|
|
/// <summary>Returns the legacy function shape for existing surface renderers.</summary>
|
|
public Func<string, string> AsFunc()
|
|
{
|
|
return Resolve;
|
|
}
|
|
|
|
private CultMeshQueryContext ResolveContext(CultMeshQueryContext context)
|
|
{
|
|
if (context.RouteHint.Kind != CultMeshLocalityKind.Automatic || RouteHint.Kind == CultMeshLocalityKind.Automatic)
|
|
return context;
|
|
|
|
return context.WithRoute(RouteHint);
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Diagnostics for a named CultMesh state-reference resolver.
|
|
/// </summary>
|
|
public sealed class CultMeshStateRefResolverDiagnostic
|
|
{
|
|
/// <summary>Creates a state-reference resolver diagnostic.</summary>
|
|
public CultMeshStateRefResolverDiagnostic(
|
|
string resolverId,
|
|
CultMeshRouteHint routeHint,
|
|
IEnumerable<CultMeshProjectionSource>? sources = null)
|
|
{
|
|
ResolverId = RequireNonEmpty(resolverId, nameof(resolverId));
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
Sources = sources?.ToArray() ?? Array.Empty<CultMeshProjectionSource>();
|
|
}
|
|
|
|
/// <summary>Gets the semantic resolver id.</summary>
|
|
public string ResolverId { get; }
|
|
|
|
/// <summary>Gets the preferred route for resolving refs.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets source state used by the resolver.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Describes one UI/tool surface property bound to a typed CultMesh state pointer.
|
|
/// </summary>
|
|
public sealed class CultMeshStateBindingDescriptor
|
|
{
|
|
/// <summary>Creates a state binding descriptor.</summary>
|
|
public CultMeshStateBindingDescriptor(
|
|
string targetProp,
|
|
string pointerId,
|
|
string? sourceId = null,
|
|
string? schemaId = null,
|
|
CultMeshRouteHint? routeHint = null,
|
|
string? bindingName = null,
|
|
string? documentId = null,
|
|
string? fieldPath = null,
|
|
string? valueKind = null,
|
|
string? accessMode = null,
|
|
string? authority = null,
|
|
string? writeCommand = null)
|
|
{
|
|
TargetProp = RequireNonEmpty(targetProp, nameof(targetProp));
|
|
PointerId = RequireNonEmpty(pointerId, nameof(pointerId));
|
|
SourceId = sourceId;
|
|
SchemaId = schemaId;
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
BindingName = bindingName ?? "";
|
|
DocumentId = documentId ?? "";
|
|
FieldPath = fieldPath ?? "";
|
|
ValueKind = valueKind ?? "";
|
|
AccessMode = accessMode ?? "";
|
|
Authority = authority ?? "";
|
|
WriteCommand = writeCommand ?? "";
|
|
}
|
|
|
|
/// <summary>Gets the component property receiving the resolved state value.</summary>
|
|
public string TargetProp { get; }
|
|
|
|
/// <summary>Gets the semantic CultMesh state pointer id.</summary>
|
|
public string PointerId { get; }
|
|
|
|
/// <summary>Gets the optional source document, cache, native view, or daemon source id.</summary>
|
|
public string? SourceId { get; }
|
|
|
|
/// <summary>Gets the optional schema id for the source state.</summary>
|
|
public string? SchemaId { get; }
|
|
|
|
/// <summary>Gets the preferred route for resolving and watching the binding.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets the stable renderer-facing name used to capture this binding in an operation.</summary>
|
|
public string BindingName { get; }
|
|
|
|
/// <summary>Gets the optional identity of the provider-owned backing document.</summary>
|
|
public string DocumentId { get; }
|
|
|
|
/// <summary>Gets the optional typed field path inside the backing document.</summary>
|
|
public string FieldPath { get; }
|
|
|
|
/// <summary>Gets the optional portable value kind.</summary>
|
|
public string ValueKind { get; }
|
|
|
|
/// <summary>Gets the optional read, write, read-write, or local-draft access mode.</summary>
|
|
public string AccessMode { get; }
|
|
|
|
/// <summary>Gets the authority responsible for accepting writes to this binding.</summary>
|
|
public string Authority { get; }
|
|
|
|
/// <summary>Gets the optional typed operation id used for direct writes.</summary>
|
|
public string WriteCommand { get; }
|
|
|
|
/// <summary>Creates a binding from an existing state pointer.</summary>
|
|
public static CultMeshStateBindingDescriptor FromPointer<TValue>(
|
|
string targetProp,
|
|
CultMeshStatePointer<TValue> pointer)
|
|
{
|
|
if (pointer == null) throw new ArgumentNullException(nameof(pointer));
|
|
var source = pointer.Sources.FirstOrDefault();
|
|
return new CultMeshStateBindingDescriptor(
|
|
targetProp,
|
|
pointer.PointerId,
|
|
source?.SourceId,
|
|
source?.SchemaId,
|
|
pointer.RouteHint);
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transport-friendly fields for a typed state binding descriptor.
|
|
/// </summary>
|
|
[MessagePackObject]
|
|
public sealed class CultMeshStateBindingRecord
|
|
{
|
|
/// <summary>Creates a state binding record.</summary>
|
|
[SerializationConstructor]
|
|
public CultMeshStateBindingRecord(
|
|
string? targetProp = null,
|
|
string? pointerId = null,
|
|
string? sourceId = null,
|
|
string? schemaId = null,
|
|
string? routeKind = null,
|
|
string? routeDescription = null,
|
|
string? bindingName = null,
|
|
string? documentId = null,
|
|
string? fieldPath = null,
|
|
string? valueKind = null,
|
|
string? accessMode = null,
|
|
string? authority = null,
|
|
string? writeCommand = null)
|
|
{
|
|
TargetProp = string.IsNullOrWhiteSpace(targetProp) ? "value" : targetProp!;
|
|
PointerId = pointerId ?? "";
|
|
SourceId = sourceId ?? "";
|
|
SchemaId = schemaId ?? "";
|
|
RouteKind = routeKind ?? "";
|
|
RouteDescription = routeDescription ?? "";
|
|
BindingName = bindingName ?? "";
|
|
DocumentId = documentId ?? "";
|
|
FieldPath = fieldPath ?? "";
|
|
ValueKind = valueKind ?? "";
|
|
AccessMode = accessMode ?? "";
|
|
Authority = authority ?? "";
|
|
WriteCommand = writeCommand ?? "";
|
|
}
|
|
|
|
/// <summary>Gets the component property receiving the resolved state value.</summary>
|
|
[Key(0)] public string TargetProp { get; }
|
|
|
|
/// <summary>Gets the semantic CultMesh state pointer id.</summary>
|
|
[Key(1)] public string PointerId { get; }
|
|
|
|
/// <summary>Gets the source document, cache, native view, or daemon source id.</summary>
|
|
[Key(2)] public string SourceId { get; }
|
|
|
|
/// <summary>Gets the source schema id.</summary>
|
|
[Key(3)] public string SchemaId { get; }
|
|
|
|
/// <summary>Gets the flattened route kind.</summary>
|
|
[Key(4)] public string RouteKind { get; }
|
|
|
|
/// <summary>Gets the flattened route description.</summary>
|
|
[Key(5)] public string RouteDescription { get; }
|
|
|
|
[Key(6)] public string BindingName { get; }
|
|
[Key(7)] public string DocumentId { get; }
|
|
[Key(8)] public string FieldPath { get; }
|
|
[Key(9)] public string ValueKind { get; }
|
|
[Key(10)] public string AccessMode { get; }
|
|
[Key(11)] public string Authority { get; }
|
|
[Key(12)] public string WriteCommand { get; }
|
|
|
|
/// <summary>Creates transport-friendly fields from a state binding descriptor.</summary>
|
|
public static CultMeshStateBindingRecord FromBinding(CultMeshStateBindingDescriptor? binding)
|
|
{
|
|
var route = CultMeshRouteRecord.FromRoute(binding?.RouteHint);
|
|
return new CultMeshStateBindingRecord(
|
|
binding?.TargetProp,
|
|
binding?.PointerId,
|
|
binding?.SourceId,
|
|
binding?.SchemaId,
|
|
route.Kind,
|
|
route.Description,
|
|
binding?.BindingName,
|
|
binding?.DocumentId,
|
|
binding?.FieldPath,
|
|
binding?.ValueKind,
|
|
binding?.AccessMode,
|
|
binding?.Authority,
|
|
binding?.WriteCommand);
|
|
}
|
|
|
|
/// <summary>Rehydrates this record as a state binding descriptor.</summary>
|
|
public CultMeshStateBindingDescriptor ToBinding(
|
|
CultMeshRouteHint? fallbackRouteHint = null,
|
|
string? fallbackTargetProp = null)
|
|
{
|
|
var targetProp = string.IsNullOrWhiteSpace(TargetProp)
|
|
? (string.IsNullOrWhiteSpace(fallbackTargetProp) ? "value" : fallbackTargetProp!)
|
|
: TargetProp;
|
|
var pointerId = string.IsNullOrWhiteSpace(PointerId)
|
|
? $"{targetProp}.unknown"
|
|
: PointerId;
|
|
var route = new CultMeshRouteRecord(RouteKind, RouteDescription)
|
|
.ToRoute(fallbackRouteHint ?? CultMeshRouteHint.Automatic);
|
|
|
|
return new CultMeshStateBindingDescriptor(
|
|
targetProp,
|
|
pointerId,
|
|
SourceId,
|
|
SchemaId,
|
|
route,
|
|
BindingName,
|
|
DocumentId,
|
|
FieldPath,
|
|
ValueKind,
|
|
AccessMode,
|
|
Authority,
|
|
WriteCommand);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Describes one UI/tool command bound to a typed CultMesh operation.
|
|
/// </summary>
|
|
public sealed class CultMeshOperationBindingDescriptor
|
|
{
|
|
/// <summary>Creates an operation binding descriptor.</summary>
|
|
public CultMeshOperationBindingDescriptor(
|
|
string operationId,
|
|
string? label = null,
|
|
string? schemaId = null,
|
|
CultMeshRouteHint? routeHint = null)
|
|
{
|
|
OperationId = RequireNonEmpty(operationId, nameof(operationId));
|
|
Label = label ?? "";
|
|
SchemaId = schemaId ?? "";
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
}
|
|
|
|
/// <summary>Gets the semantic CultMesh operation id.</summary>
|
|
public string OperationId { get; }
|
|
|
|
/// <summary>Gets the human-facing command label.</summary>
|
|
public string Label { get; }
|
|
|
|
/// <summary>Gets the optional request schema id for the operation.</summary>
|
|
public string SchemaId { get; }
|
|
|
|
/// <summary>Gets the preferred route for invoking the operation.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Creates a binding from an existing typed operation handle.</summary>
|
|
public static CultMeshOperationBindingDescriptor FromOperation<TRequest, TResponse>(
|
|
CultMeshOperationHandle<TRequest, TResponse> operation,
|
|
string? label = null,
|
|
string? schemaId = null,
|
|
CultMeshRouteHint? routeHint = null)
|
|
{
|
|
if (operation == null) throw new ArgumentNullException(nameof(operation));
|
|
return new CultMeshOperationBindingDescriptor(
|
|
operation.OperationId,
|
|
label,
|
|
schemaId,
|
|
routeHint);
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transport-friendly fields for a typed operation binding descriptor.
|
|
/// </summary>
|
|
[MessagePackObject]
|
|
public sealed class CultMeshOperationBindingRecord
|
|
{
|
|
/// <summary>Creates an operation binding record.</summary>
|
|
[SerializationConstructor]
|
|
public CultMeshOperationBindingRecord(
|
|
string? operationId = null,
|
|
string? label = null,
|
|
string? schemaId = null,
|
|
string? routeKind = null,
|
|
string? routeDescription = null)
|
|
{
|
|
OperationId = operationId ?? "";
|
|
Label = label ?? "";
|
|
SchemaId = schemaId ?? "";
|
|
RouteKind = routeKind ?? "";
|
|
RouteDescription = routeDescription ?? "";
|
|
}
|
|
|
|
/// <summary>Gets the semantic CultMesh operation id.</summary>
|
|
[Key(0)] public string OperationId { get; }
|
|
|
|
/// <summary>Gets the human-facing command label.</summary>
|
|
[Key(1)] public string Label { get; }
|
|
|
|
/// <summary>Gets the optional request schema id for the operation.</summary>
|
|
[Key(2)] public string SchemaId { get; }
|
|
|
|
/// <summary>Gets the flattened route kind.</summary>
|
|
[Key(3)] public string RouteKind { get; }
|
|
|
|
/// <summary>Gets the flattened route description.</summary>
|
|
[Key(4)] public string RouteDescription { get; }
|
|
|
|
/// <summary>Creates transport-friendly fields from an operation binding descriptor.</summary>
|
|
public static CultMeshOperationBindingRecord FromBinding(CultMeshOperationBindingDescriptor? binding)
|
|
{
|
|
var route = CultMeshRouteRecord.FromRoute(binding?.RouteHint);
|
|
return new CultMeshOperationBindingRecord(
|
|
binding?.OperationId,
|
|
binding?.Label,
|
|
binding?.SchemaId,
|
|
route.Kind,
|
|
route.Description);
|
|
}
|
|
|
|
/// <summary>Rehydrates this record as an operation binding descriptor.</summary>
|
|
public CultMeshOperationBindingDescriptor ToBinding(
|
|
CultMeshRouteHint? fallbackRouteHint = null,
|
|
string? fallbackOperationId = null)
|
|
{
|
|
var operationId = string.IsNullOrWhiteSpace(OperationId)
|
|
? (fallbackOperationId ?? "")
|
|
: OperationId;
|
|
var route = new CultMeshRouteRecord(RouteKind, RouteDescription)
|
|
.ToRoute(fallbackRouteHint ?? CultMeshRouteHint.Automatic);
|
|
|
|
return new CultMeshOperationBindingDescriptor(
|
|
operationId,
|
|
Label,
|
|
SchemaId,
|
|
route);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Describes one concrete invocation of a typed CultMesh operation.
|
|
/// </summary>
|
|
public sealed class CultMeshOperationInvocationDescriptor
|
|
{
|
|
/// <summary>Creates an operation invocation descriptor.</summary>
|
|
public CultMeshOperationInvocationDescriptor(
|
|
string operationId,
|
|
string? schemaId = null,
|
|
CultMeshRouteHint? routeHint = null,
|
|
string? idempotencyKey = null)
|
|
{
|
|
OperationId = RequireNonEmpty(operationId, nameof(operationId));
|
|
SchemaId = schemaId ?? "";
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
IdempotencyKey = idempotencyKey;
|
|
}
|
|
|
|
/// <summary>Gets the semantic CultMesh operation id being invoked.</summary>
|
|
public string OperationId { get; }
|
|
|
|
/// <summary>Gets the optional request schema id for the operation.</summary>
|
|
public string SchemaId { get; }
|
|
|
|
/// <summary>Gets the preferred route for invoking the operation.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Gets an optional caller-provided idempotency key.</summary>
|
|
public string? IdempotencyKey { get; }
|
|
|
|
/// <summary>Creates an invocation from an advertised operation binding.</summary>
|
|
public static CultMeshOperationInvocationDescriptor FromBinding(
|
|
CultMeshOperationBindingDescriptor binding,
|
|
string? idempotencyKey = null)
|
|
{
|
|
if (binding == null) throw new ArgumentNullException(nameof(binding));
|
|
return new CultMeshOperationInvocationDescriptor(
|
|
binding.OperationId,
|
|
binding.SchemaId,
|
|
binding.RouteHint,
|
|
idempotencyKey);
|
|
}
|
|
|
|
/// <summary>Creates an invocation from an existing typed operation handle.</summary>
|
|
public static CultMeshOperationInvocationDescriptor FromOperation<TRequest, TResponse>(
|
|
CultMeshOperationHandle<TRequest, TResponse> operation,
|
|
string? schemaId = null,
|
|
CultMeshRouteHint? routeHint = null,
|
|
string? idempotencyKey = null)
|
|
{
|
|
if (operation == null) throw new ArgumentNullException(nameof(operation));
|
|
return new CultMeshOperationInvocationDescriptor(
|
|
operation.OperationId,
|
|
schemaId,
|
|
routeHint,
|
|
idempotencyKey);
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Flat, transport-friendly fields for a concrete CultMesh operation invocation.
|
|
/// </summary>
|
|
[MessagePackObject]
|
|
[MessagePackFormatter(typeof(CultMeshOperationInvocationRecordFormatter))]
|
|
public sealed class CultMeshOperationInvocationRecord
|
|
{
|
|
/// <summary>Creates flat operation invocation fields.</summary>
|
|
[SerializationConstructor]
|
|
public CultMeshOperationInvocationRecord(
|
|
string? operationId = null,
|
|
string? schemaId = null,
|
|
string? routeKind = null,
|
|
string? routeDescription = null,
|
|
string? idempotencyKey = null)
|
|
{
|
|
OperationId = operationId ?? "";
|
|
SchemaId = schemaId ?? "";
|
|
RouteKind = routeKind ?? "";
|
|
RouteDescription = routeDescription ?? "";
|
|
IdempotencyKey = idempotencyKey ?? "";
|
|
}
|
|
|
|
/// <summary>Gets the semantic CultMesh operation id.</summary>
|
|
[Key(0)]
|
|
public string OperationId { get; }
|
|
|
|
/// <summary>Gets the optional request schema id.</summary>
|
|
[Key(1)]
|
|
public string SchemaId { get; }
|
|
|
|
/// <summary>Gets the serialized route locality kind.</summary>
|
|
[Key(2)]
|
|
public string RouteKind { get; }
|
|
|
|
/// <summary>Gets the serialized route description.</summary>
|
|
[Key(3)]
|
|
public string RouteDescription { get; }
|
|
|
|
/// <summary>Gets the serialized idempotency key.</summary>
|
|
[Key(4)]
|
|
public string IdempotencyKey { get; }
|
|
|
|
/// <summary>Creates flat fields from a typed invocation descriptor.</summary>
|
|
public static CultMeshOperationInvocationRecord FromInvocation(
|
|
CultMeshOperationInvocationDescriptor? invocation,
|
|
string? fallbackOperationId = null,
|
|
string? fallbackSchemaId = null,
|
|
CultMeshRouteHint? fallbackRouteHint = null,
|
|
string? fallbackIdempotencyKey = null)
|
|
{
|
|
var route = CultMeshRouteRecord.FromRoute(invocation?.RouteHint ?? fallbackRouteHint);
|
|
return new CultMeshOperationInvocationRecord(
|
|
string.IsNullOrWhiteSpace(invocation?.OperationId) ? fallbackOperationId : invocation!.OperationId,
|
|
string.IsNullOrWhiteSpace(invocation?.SchemaId) ? fallbackSchemaId : invocation!.SchemaId,
|
|
route.Kind,
|
|
route.Description,
|
|
string.IsNullOrWhiteSpace(invocation?.IdempotencyKey) ? fallbackIdempotencyKey : invocation!.IdempotencyKey);
|
|
}
|
|
|
|
/// <summary>Rehydrates the flat fields into a typed invocation descriptor.</summary>
|
|
public CultMeshOperationInvocationDescriptor ToInvocation(
|
|
string? fallbackOperationId = null,
|
|
string? fallbackSchemaId = null,
|
|
CultMeshRouteHint? fallbackRouteHint = null,
|
|
string? fallbackIdempotencyKey = null)
|
|
{
|
|
var operationId = string.IsNullOrWhiteSpace(OperationId) ? fallbackOperationId : OperationId;
|
|
if (string.IsNullOrWhiteSpace(operationId))
|
|
throw new InvalidOperationException("CultMesh operation invocation fields do not contain an operation id.");
|
|
|
|
var route = new CultMeshRouteRecord(RouteKind, RouteDescription)
|
|
.ToRoute(fallbackRouteHint);
|
|
|
|
return new CultMeshOperationInvocationDescriptor(
|
|
operationId!,
|
|
string.IsNullOrWhiteSpace(SchemaId) ? fallbackSchemaId : SchemaId,
|
|
route,
|
|
string.IsNullOrWhiteSpace(IdempotencyKey) ? fallbackIdempotencyKey : IdempotencyKey);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared payload value carried by one concrete CultMesh operation invocation.
|
|
/// </summary>
|
|
public sealed class CultMeshOperationPayload : IReadOnlyDictionary<string, string>
|
|
{
|
|
private static readonly IReadOnlyDictionary<string, string> EmptyFields =
|
|
new Dictionary<string, string>(0, StringComparer.Ordinal);
|
|
|
|
private readonly IReadOnlyDictionary<string, string> _fields;
|
|
|
|
/// <summary>Creates an empty operation payload.</summary>
|
|
public CultMeshOperationPayload()
|
|
: this(EmptyFields)
|
|
{
|
|
}
|
|
|
|
/// <summary>Creates an operation payload from string-compatible fields.</summary>
|
|
public CultMeshOperationPayload(IEnumerable<KeyValuePair<string, string>>? fields)
|
|
{
|
|
if (fields == null)
|
|
{
|
|
_fields = EmptyFields;
|
|
return;
|
|
}
|
|
|
|
var copy = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var field in fields)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(field.Key))
|
|
continue;
|
|
copy[field.Key] = field.Value ?? "";
|
|
}
|
|
|
|
_fields = copy;
|
|
}
|
|
|
|
/// <summary>Gets the empty operation payload.</summary>
|
|
public static CultMeshOperationPayload Empty { get; } = new();
|
|
|
|
/// <summary>Gets the payload field names.</summary>
|
|
public IEnumerable<string> Keys => _fields.Keys;
|
|
|
|
/// <summary>Gets the payload field values.</summary>
|
|
public IEnumerable<string> Values => _fields.Values;
|
|
|
|
/// <summary>Gets the number of payload fields.</summary>
|
|
public int Count => _fields.Count;
|
|
|
|
/// <summary>Gets a payload field by name.</summary>
|
|
public string this[string key] => _fields[key];
|
|
|
|
/// <summary>Gets whether the payload contains a field.</summary>
|
|
public bool ContainsKey(string key)
|
|
{
|
|
return _fields.ContainsKey(key);
|
|
}
|
|
|
|
/// <summary>Tries to read a raw string field.</summary>
|
|
public bool TryGetValue(string key, out string value)
|
|
{
|
|
return _fields.TryGetValue(key, out value);
|
|
}
|
|
|
|
/// <summary>Reads a raw string field with a default value.</summary>
|
|
public string GetString(string key, string defaultValue = "")
|
|
{
|
|
return _fields.TryGetValue(key, out var value) ? value ?? defaultValue : defaultValue;
|
|
}
|
|
|
|
/// <summary>Reads an integer field using invariant parsing.</summary>
|
|
public int GetInt32(string key, int defaultValue = 0)
|
|
{
|
|
return int.TryParse(GetString(key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
|
? value
|
|
: defaultValue;
|
|
}
|
|
|
|
/// <summary>Reads a floating point field using invariant parsing.</summary>
|
|
public double GetDouble(string key, double defaultValue = 0)
|
|
{
|
|
return double.TryParse(GetString(key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
|
|
? value
|
|
: defaultValue;
|
|
}
|
|
|
|
/// <summary>Reads a boolean field using common CultMesh surface spellings.</summary>
|
|
public bool GetBoolean(string key, bool defaultValue = false)
|
|
{
|
|
var value = GetString(key);
|
|
if (string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(value, "1", StringComparison.Ordinal) ||
|
|
string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(value, "on", StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
if (string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(value, "0", StringComparison.Ordinal) ||
|
|
string.Equals(value, "no", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(value, "off", StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
return defaultValue;
|
|
}
|
|
|
|
/// <summary>Returns a copy with one field overwritten.</summary>
|
|
public CultMeshOperationPayload With(string key, string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(key))
|
|
throw new ArgumentException("Value must be non-empty.", nameof(key));
|
|
var copy = new Dictionary<string, string>(_fields, StringComparer.Ordinal)
|
|
{
|
|
[key] = value ?? ""
|
|
};
|
|
return new CultMeshOperationPayload(copy);
|
|
}
|
|
|
|
/// <summary>Copies payload fields into a mutable dictionary.</summary>
|
|
public Dictionary<string, string> ToDictionary(IEqualityComparer<string>? comparer = null)
|
|
{
|
|
var copy = new Dictionary<string, string>(comparer ?? StringComparer.Ordinal);
|
|
foreach (var field in _fields)
|
|
copy[field.Key] = field.Value ?? "";
|
|
return copy;
|
|
}
|
|
|
|
/// <summary>Creates a payload from string-compatible fields.</summary>
|
|
public static CultMeshOperationPayload FromStrings(IEnumerable<KeyValuePair<string, string>>? fields)
|
|
{
|
|
return new CultMeshOperationPayload(fields);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
|
|
{
|
|
return _fields.GetEnumerator();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
|
|
{
|
|
return GetEnumerator();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Names a reusable projection from typed source state into derived state.
|
|
/// </summary>
|
|
public sealed class CultMeshProjectionRecipe<TParameters, TResult>
|
|
{
|
|
private readonly Func<TParameters, CultMeshQueryContext, Task<TResult>> _project;
|
|
private readonly Func<TParameters, CultMeshQueryContext, Observable<TResult>>? _watch;
|
|
|
|
/// <summary>Creates a projection recipe.</summary>
|
|
public CultMeshProjectionRecipe(
|
|
string projectionId,
|
|
IEnumerable<CultMeshProjectionSource> sources,
|
|
Func<TParameters, CultMeshQueryContext, Task<TResult>> project,
|
|
CultMeshRouteHint? routeHint = null,
|
|
Func<TParameters, CultMeshQueryContext, Observable<TResult>>? watch = null)
|
|
{
|
|
ProjectionId = RequireNonEmpty(projectionId, nameof(projectionId));
|
|
Sources = sources?.ToArray() ?? throw new ArgumentNullException(nameof(sources));
|
|
_project = project ?? throw new ArgumentNullException(nameof(project));
|
|
RouteHint = routeHint ?? CultMeshRouteHint.Automatic;
|
|
_watch = watch;
|
|
}
|
|
|
|
/// <summary>Gets the semantic projection id.</summary>
|
|
public string ProjectionId { get; }
|
|
|
|
/// <summary>Gets the typed state sources this projection depends on.</summary>
|
|
public IReadOnlyList<CultMeshProjectionSource> Sources { get; }
|
|
|
|
/// <summary>Gets the preferred or observed route for the projection.</summary>
|
|
public CultMeshRouteHint RouteHint { get; }
|
|
|
|
/// <summary>Projects derived state once.</summary>
|
|
public Task<TResult> ProjectAsync(TParameters parameters, CultMeshQueryContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
return _project(parameters, ResolveContext(context));
|
|
}
|
|
|
|
/// <summary>Projects derived state once for one runtime using default context.</summary>
|
|
public Task<TResult> ProjectAsync(TParameters parameters, string runtimeId)
|
|
{
|
|
return ProjectAsync(parameters, CultMeshQueryContext.ForRuntime(runtimeId));
|
|
}
|
|
|
|
/// <summary>Watches projection results when the recipe supports reactive execution.</summary>
|
|
public Observable<TResult> Watch(TParameters parameters, CultMeshQueryContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
if (_watch == null)
|
|
{
|
|
throw new NotSupportedException($"Projection recipe '{ProjectionId}' does not support reactive watches.");
|
|
}
|
|
|
|
return _watch(parameters, ResolveContext(context));
|
|
}
|
|
|
|
/// <summary>Views this projection recipe as a typed query surface.</summary>
|
|
public CultMeshQuerySurface<TParameters, TResult> AsQuerySurface()
|
|
{
|
|
return new CultMeshQuerySurface<TParameters, TResult>(
|
|
ProjectionId,
|
|
ProjectAsync,
|
|
_watch == null ? null : Watch,
|
|
Sources,
|
|
RouteHint);
|
|
}
|
|
|
|
private CultMeshQueryContext ResolveContext(CultMeshQueryContext context)
|
|
{
|
|
return context.RouteHint.Kind == CultMeshLocalityKind.Automatic &&
|
|
RouteHint.Kind != CultMeshLocalityKind.Automatic
|
|
? context.WithRoute(RouteHint)
|
|
: context;
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Describes one column in a native CultMesh slice view.
|
|
/// </summary>
|
|
public sealed class CultMeshNativeSliceColumn
|
|
{
|
|
/// <summary>Creates a native slice column descriptor.</summary>
|
|
public CultMeshNativeSliceColumn(string name, string valueType, int elementSizeBytes)
|
|
{
|
|
Name = RequireNonEmpty(name, nameof(name));
|
|
ValueType = RequireNonEmpty(valueType, nameof(valueType));
|
|
if (elementSizeBytes <= 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(elementSizeBytes), "Element size must be positive.");
|
|
}
|
|
|
|
ElementSizeBytes = elementSizeBytes;
|
|
}
|
|
|
|
/// <summary>Gets the column name.</summary>
|
|
public string Name { get; }
|
|
|
|
/// <summary>Gets the schema value type for each element.</summary>
|
|
public string ValueType { get; }
|
|
|
|
/// <summary>Gets the size of one element in bytes.</summary>
|
|
public int ElementSizeBytes { get; }
|
|
|
|
/// <summary>Creates a native slice column descriptor for an unmanaged CLR value type.</summary>
|
|
public static CultMeshNativeSliceColumn For<TValue>(string name) where TValue : unmanaged
|
|
{
|
|
return new CultMeshNativeSliceColumn(name, typeof(TValue).FullName ?? typeof(TValue).Name, SizeOf<TValue>());
|
|
}
|
|
|
|
private static int SizeOf<TValue>() where TValue : unmanaged
|
|
{
|
|
return System.Runtime.InteropServices.Marshal.SizeOf<TValue>();
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Describes a typed native slice view exposed by CultMesh.
|
|
/// </summary>
|
|
public sealed class CultMeshNativeSliceViewDescriptor
|
|
{
|
|
/// <summary>Creates a native slice view descriptor.</summary>
|
|
public CultMeshNativeSliceViewDescriptor(
|
|
string viewId,
|
|
string schemaId,
|
|
int rowCount,
|
|
IEnumerable<CultMeshNativeSliceColumn> columns,
|
|
CultMeshRouteHint? route = null,
|
|
string? nativeHandle = null)
|
|
{
|
|
ViewId = RequireNonEmpty(viewId, nameof(viewId));
|
|
SchemaId = RequireNonEmpty(schemaId, nameof(schemaId));
|
|
if (rowCount < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(rowCount), "Row count cannot be negative.");
|
|
}
|
|
|
|
RowCount = rowCount;
|
|
Columns = columns?.ToArray() ?? throw new ArgumentNullException(nameof(columns));
|
|
Route = route ?? CultMeshRouteHint.Automatic;
|
|
NativeHandle = nativeHandle;
|
|
}
|
|
|
|
/// <summary>Gets the semantic view id.</summary>
|
|
public string ViewId { get; }
|
|
|
|
/// <summary>Gets the schema id for the rows in this view.</summary>
|
|
public string SchemaId { get; }
|
|
|
|
/// <summary>Gets the number of rows in this view.</summary>
|
|
public int RowCount { get; }
|
|
|
|
/// <summary>Gets column descriptors in view order.</summary>
|
|
public IReadOnlyList<CultMeshNativeSliceColumn> Columns { get; }
|
|
|
|
/// <summary>Gets the route that exposes this view.</summary>
|
|
public CultMeshRouteHint Route { get; }
|
|
|
|
/// <summary>Gets an optional runtime-specific native handle.</summary>
|
|
public string? NativeHandle { get; }
|
|
|
|
/// <summary>Gets the estimated byte stride for one row when all columns are densely packed.</summary>
|
|
public int DenseRowStrideBytes => Columns.Sum(column => column.ElementSizeBytes);
|
|
|
|
/// <summary>Finds a column descriptor by name.</summary>
|
|
public CultMeshNativeSliceColumn? FindColumn(string name)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value must be non-empty.", nameof(name));
|
|
return Columns.FirstOrDefault(column => string.Equals(column.Name, name, StringComparison.Ordinal));
|
|
}
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? throw new ArgumentException("Value must be non-empty.", paramName)
|
|
: value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inspectable metadata for a native slice view.
|
|
/// </summary>
|
|
public sealed class CultMeshNativeSliceViewDiagnostic
|
|
{
|
|
/// <summary>Creates native slice view diagnostics.</summary>
|
|
public CultMeshNativeSliceViewDiagnostic(CultMeshNativeSliceViewDescriptor view)
|
|
{
|
|
if (view == null) throw new ArgumentNullException(nameof(view));
|
|
ViewId = view.ViewId;
|
|
SchemaId = view.SchemaId;
|
|
RowCount = view.RowCount;
|
|
Columns = view.Columns.ToArray();
|
|
Route = view.Route;
|
|
NativeHandle = view.NativeHandle;
|
|
DenseRowStrideBytes = view.DenseRowStrideBytes;
|
|
}
|
|
|
|
/// <summary>Gets the semantic view id.</summary>
|
|
public string ViewId { get; }
|
|
|
|
/// <summary>Gets the schema id for rows in this view.</summary>
|
|
public string SchemaId { get; }
|
|
|
|
/// <summary>Gets the row count advertised by this view.</summary>
|
|
public int RowCount { get; }
|
|
|
|
/// <summary>Gets column descriptors in view order.</summary>
|
|
public IReadOnlyList<CultMeshNativeSliceColumn> Columns { get; }
|
|
|
|
/// <summary>Gets the route that exposes this view.</summary>
|
|
public CultMeshRouteHint Route { get; }
|
|
|
|
/// <summary>Gets an optional runtime-specific native handle.</summary>
|
|
public string? NativeHandle { get; }
|
|
|
|
/// <summary>Gets the estimated byte stride for one densely packed row.</summary>
|
|
public int DenseRowStrideBytes { get; }
|
|
}
|
|
}
|