2026-06-02 01:13:08 +01:00
|
|
|
using System;
|
2026-06-27 17:28:15 +01:00
|
|
|
using System.Buffers;
|
2026-06-27 14:30:59 +01:00
|
|
|
using System.Collections.Generic;
|
2026-06-12 20:38:41 +01:00
|
|
|
using System.IO;
|
2026-06-27 14:30:59 +01:00
|
|
|
using System.Linq;
|
2026-08-18 00:44:41 +02:00
|
|
|
using System.Threading;
|
2026-06-12 20:38:41 +01:00
|
|
|
using System.Threading.Tasks;
|
2026-06-02 01:13:08 +01:00
|
|
|
using FluentAssertions;
|
2026-06-12 20:38:41 +01:00
|
|
|
using GameCult.Caching;
|
2026-06-27 16:40:42 +01:00
|
|
|
using GameCult.Caching.MessagePack;
|
2026-06-27 14:43:00 +01:00
|
|
|
using GameCult.Networking;
|
2026-06-27 14:30:59 +01:00
|
|
|
using MessagePack;
|
2026-06-02 01:13:08 +01:00
|
|
|
using NUnit.Framework;
|
2026-06-13 01:44:05 +01:00
|
|
|
using R3;
|
2026-06-02 01:13:08 +01:00
|
|
|
|
|
|
|
|
namespace GameCult.Mesh.Tests;
|
|
|
|
|
|
|
|
|
|
public sealed class CultMeshStreamingTests
|
|
|
|
|
{
|
2026-06-27 14:30:59 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task TypedOperationHandle_CarriesAuthorityAndRouteContext()
|
|
|
|
|
{
|
|
|
|
|
var handle = new CultMeshOperationHandle<MeshMoveRequest, CultMeshOperationReceipt>(
|
|
|
|
|
"aetheria.entity.pilot.move",
|
|
|
|
|
(request, context) => Task.FromResult(new CultMeshOperationReceipt(
|
|
|
|
|
"aetheria.entity.pilot.move",
|
|
|
|
|
accepted: request.EntityId == 7 &&
|
|
|
|
|
context.Claims.Count == 1 &&
|
|
|
|
|
context.Claims[0].Role == "pilot-control" &&
|
|
|
|
|
context.RouteHint.Kind == CultMeshLocalityKind.InProcess,
|
|
|
|
|
context.RouteHint)));
|
|
|
|
|
|
|
|
|
|
var receipt = await handle.InvokeAsync(
|
|
|
|
|
new MeshMoveRequest(7, 1, -1),
|
|
|
|
|
CultMeshOperationContext
|
|
|
|
|
.ForRuntime("unity-raven")
|
|
|
|
|
.WithClaim(new CultMeshAuthorityClaim("pilot-control", shardId: "zone:local-rts"))
|
|
|
|
|
.WithRoute(new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "co-located daemon"))
|
|
|
|
|
.WithIdempotencyKey("move-7-001"));
|
|
|
|
|
|
|
|
|
|
receipt.Accepted.Should().BeTrue();
|
|
|
|
|
receipt.Route.Kind.Should().Be(CultMeshLocalityKind.InProcess);
|
|
|
|
|
|
|
|
|
|
var diagnostic = CultMesh.DescribeOperationHandle(handle);
|
|
|
|
|
diagnostic.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ContextBuilders_MakeTypedHandleCallsReadLikeVerseOperations()
|
|
|
|
|
{
|
|
|
|
|
var operationContext = CultMesh.OperationContextFor("unity-raven")
|
|
|
|
|
.Claim("pilot-control", shardId: "zone:local-rts", leaseId: "lease:raven")
|
|
|
|
|
.Route(CultMeshLocalityKind.SharedMemory, "co-located daemon")
|
|
|
|
|
.Idempotency("move-7-001")
|
|
|
|
|
.Build();
|
|
|
|
|
|
|
|
|
|
operationContext.RuntimeId.Should().Be("unity-raven");
|
|
|
|
|
operationContext.Claims.Should().ContainSingle();
|
|
|
|
|
operationContext.Claims[0].Role.Should().Be("pilot-control");
|
|
|
|
|
operationContext.Claims[0].ShardId.Should().Be("zone:local-rts");
|
|
|
|
|
operationContext.Claims[0].LeaseId.Should().Be("lease:raven");
|
|
|
|
|
operationContext.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
operationContext.IdempotencyKey.Should().Be("move-7-001");
|
|
|
|
|
|
|
|
|
|
var queryContext = CultMesh.QueryContextFor("browser-starfire")
|
|
|
|
|
.Route(CultMeshLocalityKind.Wasm, "browser-local projection")
|
|
|
|
|
.Build();
|
|
|
|
|
|
|
|
|
|
queryContext.RuntimeId.Should().Be("browser-starfire");
|
|
|
|
|
queryContext.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Wasm);
|
|
|
|
|
queryContext.RouteHint.Description.Should().Be("browser-local projection");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task VerseContext_LetsGeneratedDomainSugarUseSharedTypedContexts()
|
|
|
|
|
{
|
|
|
|
|
var verse = await CultMesh.ConnectVerseAsync(
|
|
|
|
|
"starbridge",
|
|
|
|
|
"unity-raven",
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located Verse"),
|
|
|
|
|
new[] { new CultMeshAuthorityClaim("pilot-control", shardId: "zone:raven", leaseId: "lease:raven") });
|
|
|
|
|
var commandVerse = verse.WithRoute(new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote command route"));
|
|
|
|
|
|
|
|
|
|
var aetheria = commandVerse.Use(context => new MeshAetheriaDomain(context));
|
|
|
|
|
var receipt = await aetheria.Entity(7).Pilot.MoveAsync(new MeshVec2(1, 0), "move:raven:1");
|
|
|
|
|
var viewport = await aetheria.Zone("zone:raven").Objects.VisibleWithinAsync(new MeshViewportRequest(-16, 16));
|
|
|
|
|
|
|
|
|
|
verse.VerseId.Should().Be("starbridge");
|
|
|
|
|
verse.RuntimeId.Should().Be("unity-raven");
|
|
|
|
|
verse.QueryContext().RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
commandVerse.OperationContext("move:raven:2").RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
|
|
|
commandVerse.OperationContext("move:raven:2").IdempotencyKey.Should().Be("move:raven:2");
|
|
|
|
|
receipt.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
|
|
|
receipt.Accepted.Should().BeTrue();
|
|
|
|
|
receipt.Route.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
|
|
|
viewport.Should().Equal("unity-raven:zone:raven:-16:16:Network");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task QuerySurfaceAndStatePointer_ExposeTypedReactiveState()
|
|
|
|
|
{
|
|
|
|
|
var query = new CultMeshQuerySurface<MeshViewportRequest, string[]>(
|
|
|
|
|
"aetheria.zone.objects.visible",
|
|
|
|
|
(request, context) => Task.FromResult(new[] { $"{context.RuntimeId}:{request.MinX}:{request.MaxX}" }));
|
|
|
|
|
|
|
|
|
|
var queryResult = await query.ExecuteAsync(
|
|
|
|
|
new MeshViewportRequest(-8, 8),
|
|
|
|
|
CultMeshQueryContext.ForRuntime("browser-starfire"));
|
|
|
|
|
|
|
|
|
|
queryResult.Should().Equal("browser-starfire:-8:8");
|
|
|
|
|
|
|
|
|
|
var queryDiagnostic = CultMesh.DescribeQuerySurface(query);
|
|
|
|
|
queryDiagnostic.QueryId.Should().Be("aetheria.zone.objects.visible");
|
|
|
|
|
queryDiagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Automatic);
|
|
|
|
|
queryDiagnostic.Sources.Should().BeEmpty();
|
|
|
|
|
|
|
|
|
|
var subject = new Subject<string>();
|
|
|
|
|
var pointer = CultMesh.StatePointer(
|
|
|
|
|
"aetheria.selection.current",
|
|
|
|
|
() => Task.FromResult("entity:station:0"),
|
|
|
|
|
() => subject,
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located selection cache"),
|
|
|
|
|
new[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.ProjectionSource(
|
|
|
|
|
"daemon:aetheria.selection.current.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.selection.v1")
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
string observed = null!;
|
|
|
|
|
using var subscription = pointer.Watch().Subscribe(value => observed = value);
|
|
|
|
|
|
|
|
|
|
(await pointer.ResolveAsync()).Should().Be("entity:station:0");
|
|
|
|
|
subject.OnNext("entity:pawn:7");
|
|
|
|
|
observed.Should().Be("entity:pawn:7");
|
|
|
|
|
|
|
|
|
|
var pointerDiagnostic = CultMesh.DescribeStatePointer(pointer);
|
|
|
|
|
pointerDiagnostic.PointerId.Should().Be("aetheria.selection.current");
|
|
|
|
|
pointerDiagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
pointerDiagnostic.Sources.Should().ContainSingle().Which.SchemaId.Should().Be("gamecult.aetheria.selection.v1");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task StatePointer_CanBindToVerseContextForUiAndTools()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<string>();
|
|
|
|
|
var pointer = CultMesh.StatePointer(
|
|
|
|
|
"aetheria.daemon.frame.latest",
|
|
|
|
|
context => Task.FromResult($"{context.RuntimeId}:{context.RouteHint.Kind}"),
|
|
|
|
|
context => subject.Select(value => $"{context.RuntimeId}:{value}:{context.RouteHint.Kind}"),
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located daemon frame"),
|
|
|
|
|
new[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.ProjectionSource(
|
|
|
|
|
"daemon:aetheria.frame.latest.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1")
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var verse = CultMesh.Verse(
|
|
|
|
|
"aetheria.local",
|
|
|
|
|
"bifrost-tool",
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "tool bridge"));
|
|
|
|
|
var bound = verse.BindStatePointer(pointer);
|
|
|
|
|
|
|
|
|
|
(await bound.ResolveAsync()).Should().Be("bifrost-tool:Ipc");
|
|
|
|
|
|
|
|
|
|
string observed = null!;
|
|
|
|
|
using var subscription = bound.Watch().Subscribe(value => observed = value);
|
|
|
|
|
subject.OnNext("frame:12");
|
|
|
|
|
|
|
|
|
|
bound.PointerId.Should().Be("aetheria.daemon.frame.latest");
|
|
|
|
|
bound.Sources.Should().ContainSingle().Which.SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
|
|
|
observed.Should().Be("bifrost-tool:frame:12:Ipc");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task MutableStatePointer_HoistsReadWatchReplaceDocumentHandles()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<string>();
|
|
|
|
|
var stored = "frame:0";
|
|
|
|
|
var observedContexts = new List<string>();
|
|
|
|
|
var pointer = CultMesh.MutableStatePointer(
|
|
|
|
|
"aetheria.daemon.frame.latest",
|
|
|
|
|
context =>
|
|
|
|
|
{
|
|
|
|
|
observedContexts.Add($"read:{context.RuntimeId}:{context.RouteHint.Kind}");
|
|
|
|
|
return Task.FromResult<string?>(stored);
|
|
|
|
|
},
|
|
|
|
|
context => subject.Select(value => $"{context.RuntimeId}:{value}:{context.RouteHint.Kind}"),
|
|
|
|
|
(context, value) =>
|
|
|
|
|
{
|
|
|
|
|
observedContexts.Add($"replace:{context.RuntimeId}:{context.RouteHint.Kind}");
|
|
|
|
|
stored = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located daemon frame"),
|
|
|
|
|
new[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.ProjectionSource(
|
|
|
|
|
"daemon:aetheria.frame.latest.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1")
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var verse = CultMesh.Verse(
|
|
|
|
|
"aetheria.local",
|
|
|
|
|
"unity-raven",
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "tool bridge"));
|
|
|
|
|
var bound = verse.BindMutableStatePointer(pointer);
|
|
|
|
|
|
|
|
|
|
string observed = null!;
|
|
|
|
|
using var subscription = bound.Watch().Subscribe(value => observed = value);
|
|
|
|
|
|
|
|
|
|
(await bound.ReadAsync()).Should().Be("frame:0");
|
|
|
|
|
await bound.ReplaceAsync("frame:1");
|
|
|
|
|
|
|
|
|
|
stored.Should().Be("frame:1");
|
|
|
|
|
observed.Should().Be("unity-raven:frame:1:Ipc");
|
|
|
|
|
observedContexts.Should().Equal("read:unity-raven:Ipc", "replace:unity-raven:Ipc");
|
|
|
|
|
|
|
|
|
|
var diagnostic = CultMesh.DescribeStatePointer(pointer);
|
|
|
|
|
diagnostic.PointerId.Should().Be("aetheria.daemon.frame.latest");
|
|
|
|
|
diagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
|
|
|
|
|
var binding = CultMesh.StateBinding("frame", pointer);
|
|
|
|
|
binding.PointerId.Should().Be("aetheria.daemon.frame.latest");
|
|
|
|
|
binding.SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void StateBindingDescriptor_BindsUiPropsToTypedStatePointers()
|
|
|
|
|
{
|
|
|
|
|
var pointer = CultMesh.StatePointer(
|
|
|
|
|
"aetheria.selection.current",
|
|
|
|
|
() => Task.FromResult("entity:station:0"),
|
|
|
|
|
() => new Subject<string>(),
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located selection cache"),
|
|
|
|
|
new[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.ProjectionSource(
|
|
|
|
|
"daemon:aetheria.selection.current.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.selection.v1")
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var binding = CultMesh.StateBinding("value", pointer);
|
|
|
|
|
var explicitBinding = CultMesh.StateBinding(
|
|
|
|
|
"label",
|
|
|
|
|
"aetheria.selection.label",
|
|
|
|
|
sourceId: "daemon:aetheria.selection.label.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.selection_label.v1",
|
|
|
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "tool bridge"));
|
|
|
|
|
|
|
|
|
|
binding.TargetProp.Should().Be("value");
|
|
|
|
|
binding.PointerId.Should().Be("aetheria.selection.current");
|
|
|
|
|
binding.SourceId.Should().Be("daemon:aetheria.selection.current.v1");
|
|
|
|
|
binding.SchemaId.Should().Be("gamecult.aetheria.selection.v1");
|
|
|
|
|
binding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
binding.RouteHint.Description.Should().Be("co-located selection cache");
|
|
|
|
|
|
|
|
|
|
explicitBinding.TargetProp.Should().Be("label");
|
|
|
|
|
explicitBinding.PointerId.Should().Be("aetheria.selection.label");
|
|
|
|
|
explicitBinding.SourceId.Should().Be("daemon:aetheria.selection.label.v1");
|
|
|
|
|
explicitBinding.SchemaId.Should().Be("gamecult.aetheria.selection_label.v1");
|
|
|
|
|
explicitBinding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Ipc);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void StateBindingRecord_FlattensAndRehydratesPointerBindingFields()
|
|
|
|
|
{
|
|
|
|
|
var binding = CultMesh.StateBinding(
|
|
|
|
|
"status",
|
|
|
|
|
"aetheria.current.status",
|
|
|
|
|
sourceId: "daemon:aetheria.frame.latest.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1",
|
|
|
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located frame slab"));
|
|
|
|
|
|
|
|
|
|
var record = CultMesh.StateBindingRecord(binding);
|
|
|
|
|
record.TargetProp.Should().Be("status");
|
|
|
|
|
record.PointerId.Should().Be("aetheria.current.status");
|
|
|
|
|
record.SourceId.Should().Be("daemon:aetheria.frame.latest.v1");
|
|
|
|
|
record.SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
|
|
|
record.RouteKind.Should().Be(nameof(CultMeshLocalityKind.SharedMemory));
|
|
|
|
|
record.RouteDescription.Should().Be("co-located frame slab");
|
|
|
|
|
|
|
|
|
|
var fromTypescript = CultMesh.StateBindingRecord(
|
|
|
|
|
"value",
|
|
|
|
|
"aetheria.selection.current",
|
|
|
|
|
"daemon:aetheria.selection.current.v1",
|
|
|
|
|
"gamecult.aetheria.selection.v1",
|
|
|
|
|
"shared-memory",
|
|
|
|
|
"browser-adjacent cache").ToBinding();
|
|
|
|
|
|
|
|
|
|
fromTypescript.TargetProp.Should().Be("value");
|
|
|
|
|
fromTypescript.PointerId.Should().Be("aetheria.selection.current");
|
|
|
|
|
fromTypescript.SourceId.Should().Be("daemon:aetheria.selection.current.v1");
|
|
|
|
|
fromTypescript.SchemaId.Should().Be("gamecult.aetheria.selection.v1");
|
|
|
|
|
fromTypescript.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
fromTypescript.RouteHint.Description.Should().Be("browser-adjacent cache");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
2026-08-22 00:05:57 +02:00
|
|
|
public void StateBindingRecord_PreservesEditableFieldIdentityAndAuthority()
|
|
|
|
|
{
|
|
|
|
|
var binding = CultMesh.StateBinding(
|
|
|
|
|
"value",
|
|
|
|
|
"ghostlight.session-zero.composer",
|
|
|
|
|
sourceId: "session-zero:draft-7",
|
|
|
|
|
schemaId: "session_zero.v1",
|
|
|
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "Ghostlight provider"),
|
|
|
|
|
bindingName: "composer.message",
|
|
|
|
|
documentId: "session-zero:draft-7",
|
|
|
|
|
fieldPath: "channels.private.messages.pending",
|
|
|
|
|
valueKind: "string",
|
|
|
|
|
accessMode: "local-draft",
|
|
|
|
|
authority: "SessionZeroKernel",
|
|
|
|
|
writeCommand: "edit.value");
|
|
|
|
|
|
|
|
|
|
var record = CultMesh.StateBindingRecord(binding);
|
|
|
|
|
record.BindingName.Should().Be("composer.message");
|
|
|
|
|
record.FieldPath.Should().Be("channels.private.messages.pending");
|
|
|
|
|
record.AccessMode.Should().Be("local-draft");
|
|
|
|
|
var restored = record.ToBinding();
|
|
|
|
|
restored.BindingName.Should().Be(binding.BindingName);
|
|
|
|
|
restored.DocumentId.Should().Be(binding.DocumentId);
|
|
|
|
|
restored.FieldPath.Should().Be(binding.FieldPath);
|
|
|
|
|
restored.ValueKind.Should().Be(binding.ValueKind);
|
|
|
|
|
restored.AccessMode.Should().Be(binding.AccessMode);
|
|
|
|
|
restored.Authority.Should().Be(binding.Authority);
|
|
|
|
|
restored.WriteCommand.Should().Be(binding.WriteCommand);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
2026-06-27 14:30:59 +01:00
|
|
|
public void OperationBindingDescriptor_BindsUiCommandsToTypedOperations()
|
|
|
|
|
{
|
|
|
|
|
var operation = new CultMeshOperationHandle<MeshMoveRequest, CultMeshOperationReceipt>(
|
|
|
|
|
"aetheria.entity.pilot.move",
|
|
|
|
|
(_request, _context) => Task.FromResult(new CultMeshOperationReceipt("aetheria.entity.pilot.move", true)));
|
|
|
|
|
|
|
|
|
|
var binding = CultMesh.OperationBinding(
|
|
|
|
|
operation,
|
|
|
|
|
label: "Move",
|
|
|
|
|
schemaId: "gamecult.aetheria.pilot_move.v1",
|
|
|
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote Verse peer"));
|
|
|
|
|
var explicitBinding = CultMesh.OperationBinding(
|
|
|
|
|
"aetheria.surface.refresh",
|
|
|
|
|
label: "Refresh");
|
|
|
|
|
|
|
|
|
|
binding.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
|
|
|
binding.Label.Should().Be("Move");
|
|
|
|
|
binding.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1");
|
|
|
|
|
binding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
|
|
|
binding.RouteHint.Description.Should().Be("remote Verse peer");
|
|
|
|
|
|
|
|
|
|
explicitBinding.OperationId.Should().Be("aetheria.surface.refresh");
|
|
|
|
|
explicitBinding.Label.Should().Be("Refresh");
|
|
|
|
|
explicitBinding.SchemaId.Should().Be("");
|
|
|
|
|
explicitBinding.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Automatic);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void OperationBindingRecord_FlattensAndRehydratesSurfaceCommandFields()
|
|
|
|
|
{
|
|
|
|
|
var binding = CultMesh.OperationBinding(
|
|
|
|
|
"aetheria.entity.pilot.move",
|
|
|
|
|
label: "Move",
|
|
|
|
|
schemaId: "gamecult.aetheria.pilot_move.v1",
|
|
|
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote Verse peer"));
|
|
|
|
|
|
|
|
|
|
var record = CultMesh.OperationBindingRecord(binding);
|
|
|
|
|
record.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
|
|
|
record.Label.Should().Be("Move");
|
|
|
|
|
record.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1");
|
|
|
|
|
record.RouteKind.Should().Be(nameof(CultMeshLocalityKind.Network));
|
|
|
|
|
record.RouteDescription.Should().Be("remote Verse peer");
|
|
|
|
|
|
|
|
|
|
var fromTypescript = CultMesh.OperationBindingRecord(
|
|
|
|
|
"aetheria.surface.refresh",
|
|
|
|
|
"Refresh",
|
|
|
|
|
"gamecult.aetheria.refresh.v1",
|
|
|
|
|
"in-process",
|
|
|
|
|
"embedded UI runtime").ToBinding();
|
|
|
|
|
|
|
|
|
|
fromTypescript.OperationId.Should().Be("aetheria.surface.refresh");
|
|
|
|
|
fromTypescript.Label.Should().Be("Refresh");
|
|
|
|
|
fromTypescript.SchemaId.Should().Be("gamecult.aetheria.refresh.v1");
|
|
|
|
|
fromTypescript.RouteHint.Kind.Should().Be(CultMeshLocalityKind.InProcess);
|
|
|
|
|
fromTypescript.RouteHint.Description.Should().Be("embedded UI runtime");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void OperationInvocationDescriptor_CarriesTypedOperationIdentityThroughUiRequests()
|
|
|
|
|
{
|
|
|
|
|
var binding = CultMesh.OperationBinding(
|
|
|
|
|
"aetheria.entity.pilot.move",
|
|
|
|
|
label: "Move",
|
|
|
|
|
schemaId: "gamecult.aetheria.pilot_move.v1",
|
|
|
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "local Verse node"));
|
|
|
|
|
|
|
|
|
|
var invocation = CultMesh.OperationInvocation(binding, idempotencyKey: "move:42");
|
|
|
|
|
var explicitInvocation = CultMesh.OperationInvocation(
|
|
|
|
|
"aetheria.surface.refresh",
|
|
|
|
|
routeHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "remote Verse peer"));
|
|
|
|
|
|
|
|
|
|
invocation.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
|
|
|
invocation.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1");
|
|
|
|
|
invocation.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Ipc);
|
|
|
|
|
invocation.RouteHint.Description.Should().Be("local Verse node");
|
|
|
|
|
invocation.IdempotencyKey.Should().Be("move:42");
|
|
|
|
|
|
|
|
|
|
explicitInvocation.OperationId.Should().Be("aetheria.surface.refresh");
|
|
|
|
|
explicitInvocation.SchemaId.Should().Be("");
|
|
|
|
|
explicitInvocation.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
|
|
|
explicitInvocation.IdempotencyKey.Should().BeNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void OperationPayload_ReadsCommonSurfaceScalarFields()
|
|
|
|
|
{
|
|
|
|
|
var payload = CultMesh.OperationPayload(
|
|
|
|
|
("value", "42.5"),
|
|
|
|
|
("tierIndex", "3"),
|
|
|
|
|
("enabled", "on"),
|
|
|
|
|
("name", "Starfire"));
|
|
|
|
|
var updated = payload.With("enabled", "false");
|
|
|
|
|
|
|
|
|
|
payload.GetString("name").Should().Be("Starfire");
|
|
|
|
|
payload.GetString("missing", "fallback").Should().Be("fallback");
|
|
|
|
|
payload.GetInt32("tierIndex", -1).Should().Be(3);
|
|
|
|
|
payload.GetInt32("missing", -1).Should().Be(-1);
|
|
|
|
|
payload.GetDouble("value", -1).Should().Be(42.5);
|
|
|
|
|
payload.GetBoolean("enabled").Should().BeTrue();
|
|
|
|
|
updated.GetBoolean("enabled", true).Should().BeFalse();
|
|
|
|
|
updated.GetString("name").Should().Be("Starfire");
|
|
|
|
|
payload.GetBoolean("missing", true).Should().BeTrue();
|
|
|
|
|
payload.Should().ContainKey("value");
|
|
|
|
|
payload.Count.Should().Be(4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void OperationInvocationRecord_FlattensAndRehydratesTransportFields()
|
|
|
|
|
{
|
|
|
|
|
var invocation = CultMesh.OperationInvocation(
|
|
|
|
|
"aetheria.entity.pilot.move",
|
|
|
|
|
"gamecult.aetheria.pilot_move.v1",
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "local Verse node"),
|
|
|
|
|
"move:42");
|
|
|
|
|
|
|
|
|
|
var record = CultMesh.OperationInvocationRecord(invocation);
|
|
|
|
|
var restored = record.ToInvocation(
|
|
|
|
|
fallbackOperationId: "fallback.operation",
|
|
|
|
|
fallbackSchemaId: "fallback.schema",
|
|
|
|
|
fallbackRouteHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "fallback route"));
|
|
|
|
|
var fallback = CultMesh.OperationInvocationRecord(
|
|
|
|
|
operationId: "",
|
|
|
|
|
schemaId: "",
|
|
|
|
|
routeKind: "not-a-route",
|
|
|
|
|
routeDescription: "",
|
|
|
|
|
idempotencyKey: "")
|
|
|
|
|
.ToInvocation(
|
|
|
|
|
fallbackOperationId: "fallback.operation",
|
|
|
|
|
fallbackSchemaId: "fallback.schema",
|
|
|
|
|
fallbackRouteHint: new CultMeshRouteHint(CultMeshLocalityKind.Network, "fallback route"),
|
|
|
|
|
fallbackIdempotencyKey: "fallback-key");
|
|
|
|
|
var payloadFields = CultMesh.OperationPayload(("value", "Raven")).ToDictionary();
|
|
|
|
|
|
|
|
|
|
record.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
|
|
|
record.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1");
|
|
|
|
|
record.RouteKind.Should().Be("Ipc");
|
|
|
|
|
record.RouteDescription.Should().Be("local Verse node");
|
|
|
|
|
record.IdempotencyKey.Should().Be("move:42");
|
|
|
|
|
restored.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
|
|
|
restored.SchemaId.Should().Be("gamecult.aetheria.pilot_move.v1");
|
|
|
|
|
restored.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Ipc);
|
|
|
|
|
restored.IdempotencyKey.Should().Be("move:42");
|
|
|
|
|
fallback.OperationId.Should().Be("fallback.operation");
|
|
|
|
|
fallback.SchemaId.Should().Be("fallback.schema");
|
|
|
|
|
fallback.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
|
|
|
fallback.RouteHint.Description.Should().Be("fallback route");
|
|
|
|
|
fallback.IdempotencyKey.Should().Be("fallback-key");
|
|
|
|
|
payloadFields["value"].Should().Be("Raven");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 16:53:22 +01:00
|
|
|
[Test]
|
|
|
|
|
public void OperationInvocationRecord_ReadsLegacyStringAndWritesStructuredRecord()
|
|
|
|
|
{
|
|
|
|
|
var legacyPayload = MessagePackSerializer.Serialize(
|
|
|
|
|
"aetheria.entity.pilot.move",
|
|
|
|
|
CultDocumentMessagePackSerialization.Options);
|
|
|
|
|
var restored = MessagePackSerializer.Deserialize<CultMeshOperationInvocationRecord>(
|
|
|
|
|
legacyPayload,
|
|
|
|
|
CultDocumentMessagePackSerialization.Options);
|
|
|
|
|
var currentPayload = MessagePackSerializer.Serialize(
|
|
|
|
|
restored,
|
|
|
|
|
CultDocumentMessagePackSerialization.Options);
|
|
|
|
|
var reader = new MessagePackReader(currentPayload);
|
|
|
|
|
|
|
|
|
|
restored.OperationId.Should().Be("aetheria.entity.pilot.move");
|
|
|
|
|
restored.SchemaId.Should().BeEmpty();
|
|
|
|
|
restored.RouteKind.Should().BeEmpty();
|
|
|
|
|
restored.IdempotencyKey.Should().BeEmpty();
|
|
|
|
|
reader.ReadArrayHeader().Should().Be(5);
|
|
|
|
|
reader.ReadString().Should().Be("aetheria.entity.pilot.move");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void RouteRecord_ReadsLegacyStringAndWritesStructuredRecord()
|
|
|
|
|
{
|
|
|
|
|
var legacyPayload = MessagePackSerializer.Serialize(
|
|
|
|
|
"SharedMemory",
|
|
|
|
|
CultDocumentMessagePackSerialization.Options);
|
|
|
|
|
var restored = MessagePackSerializer.Deserialize<CultMeshRouteRecord>(
|
|
|
|
|
legacyPayload,
|
|
|
|
|
CultDocumentMessagePackSerialization.Options);
|
|
|
|
|
var currentPayload = MessagePackSerializer.Serialize(
|
|
|
|
|
restored,
|
|
|
|
|
CultDocumentMessagePackSerialization.Options);
|
|
|
|
|
var reader = new MessagePackReader(currentPayload);
|
|
|
|
|
|
|
|
|
|
restored.Kind.Should().Be("SharedMemory");
|
|
|
|
|
restored.Description.Should().BeEmpty();
|
|
|
|
|
reader.ReadArrayHeader().Should().Be(2);
|
|
|
|
|
reader.ReadString().Should().Be("SharedMemory");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 14:30:59 +01:00
|
|
|
[Test]
|
|
|
|
|
public void RouteRecord_FlattensAndParsesCrossRuntimeRouteKinds()
|
|
|
|
|
{
|
|
|
|
|
var record = CultMesh.RouteRecord(new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located slab"));
|
|
|
|
|
var restored = record.ToRoute();
|
|
|
|
|
var fromTypescript = CultMesh.RouteRecord("in-process", "").ToRoute(
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.Network, "fallback route"));
|
|
|
|
|
var fallback = CultMesh.RouteRecord("not-real", "").ToRoute(
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.Ipc, "fallback route"));
|
|
|
|
|
|
|
|
|
|
record.Kind.Should().Be("SharedMemory");
|
|
|
|
|
record.Description.Should().Be("co-located slab");
|
|
|
|
|
restored.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
restored.Description.Should().Be("co-located slab");
|
|
|
|
|
fromTypescript.Kind.Should().Be(CultMeshLocalityKind.InProcess);
|
|
|
|
|
fromTypescript.Description.Should().Be("fallback route");
|
|
|
|
|
fallback.Kind.Should().Be(CultMeshLocalityKind.Ipc);
|
|
|
|
|
fallback.Description.Should().Be("fallback route");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void StateRefResolver_ComposesNamedSurfaceResolution()
|
|
|
|
|
{
|
|
|
|
|
var route = new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "embedded renderer");
|
|
|
|
|
var daemon = CultMesh.StateRefResolver(
|
|
|
|
|
"aetheria.daemon.refs",
|
|
|
|
|
(stateRef, context) => stateRef == "aetheria.daemon/frame/id"
|
|
|
|
|
? $"{context.RuntimeId}:{context.RouteHint.Kind}:42"
|
|
|
|
|
: "",
|
|
|
|
|
new[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.ProjectionSource(
|
|
|
|
|
"daemon:aetheria.frame.latest.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1")
|
|
|
|
|
},
|
|
|
|
|
route);
|
|
|
|
|
var itemStats = CultMesh.StateRefResolver(
|
|
|
|
|
"aetheria.item_stats.refs",
|
|
|
|
|
stateRef => stateRef == "aetheria.item_stats/laser/damage" ? "12.5" : "");
|
|
|
|
|
var resolver = daemon.Or(itemStats);
|
|
|
|
|
|
|
|
|
|
resolver.Resolve(
|
|
|
|
|
"aetheria.daemon/frame/id",
|
|
|
|
|
CultMesh.QueryContextFor("unity-raven")
|
|
|
|
|
.Route(CultMeshLocalityKind.Network, "remote peer")
|
|
|
|
|
.Build())
|
|
|
|
|
.Should()
|
|
|
|
|
.Be("unity-raven:Network:42");
|
|
|
|
|
resolver.Resolve("aetheria.item_stats/laser/damage").Should().Be("12.5");
|
|
|
|
|
resolver.TryResolve("missing", out var missing).Should().BeFalse();
|
|
|
|
|
missing.Should().Be("");
|
|
|
|
|
resolver.AsFunc()("aetheria.item_stats/laser/damage").Should().Be("12.5");
|
|
|
|
|
|
|
|
|
|
var diagnostic = CultMesh.DescribeStateRefResolver(resolver);
|
|
|
|
|
diagnostic.ResolverId.Should().Be("aetheria.daemon.refs|aetheria.item_stats.refs");
|
|
|
|
|
diagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.InProcess);
|
|
|
|
|
diagnostic.Sources.Should().ContainSingle();
|
|
|
|
|
diagnostic.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task ProjectionRecipe_NamesSourcesAndCanBecomeQuerySurface()
|
|
|
|
|
{
|
|
|
|
|
var recipe = CultMesh.ProjectionRecipe<MeshViewportRequest, string[]>(
|
|
|
|
|
"aetheria.zone.objects.visible",
|
|
|
|
|
new[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.ProjectionSource(
|
|
|
|
|
"daemon:aetheria.frame.latest.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1",
|
|
|
|
|
description: "latest daemon frame"),
|
|
|
|
|
CultMesh.ProjectionSource(
|
|
|
|
|
"daemon:aetheria.authority.policy.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.authority_policy.v1")
|
|
|
|
|
},
|
|
|
|
|
(request, context) => Task.FromResult(new[]
|
|
|
|
|
{
|
|
|
|
|
$"{context.RuntimeId}:{request.MinX}:{request.MaxX}:{context.RouteHint.Kind}"
|
|
|
|
|
}),
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located frame slab"));
|
|
|
|
|
|
|
|
|
|
recipe.Sources.Should().HaveCount(2);
|
|
|
|
|
recipe.Sources[0].SourceId.Should().Be("daemon:aetheria.frame.latest.v1");
|
|
|
|
|
recipe.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
|
|
|
|
|
var recipeDiagnostic = CultMesh.DescribeProjectionRecipe(recipe);
|
|
|
|
|
recipeDiagnostic.ProjectionId.Should().Be("aetheria.zone.objects.visible");
|
|
|
|
|
recipeDiagnostic.RouteHint.Description.Should().Be("co-located frame slab");
|
|
|
|
|
recipeDiagnostic.Sources.Should().HaveCount(2);
|
|
|
|
|
recipeDiagnostic.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
|
|
|
recipeDiagnostic.Sources.Should().NotBeSameAs(recipe.Sources);
|
|
|
|
|
|
|
|
|
|
var result = await recipe.ProjectAsync(
|
|
|
|
|
new MeshViewportRequest(-16, 16),
|
|
|
|
|
CultMesh.QueryContextFor("browser-starfire")
|
|
|
|
|
.Route(CultMeshLocalityKind.Wasm, "browser-local projection")
|
|
|
|
|
.Build());
|
|
|
|
|
|
|
|
|
|
result.Should().Equal("browser-starfire:-16:16:Wasm");
|
|
|
|
|
|
|
|
|
|
var query = recipe.AsQuerySurface();
|
|
|
|
|
var queryResult = await query.ExecuteAsync(
|
|
|
|
|
new MeshViewportRequest(-4, 4),
|
|
|
|
|
"unity-raven");
|
|
|
|
|
|
|
|
|
|
query.QueryId.Should().Be(recipe.ProjectionId);
|
|
|
|
|
query.Sources.Should().HaveCount(2);
|
|
|
|
|
query.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
|
|
|
query.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
queryResult.Should().Equal("unity-raven:-4:4:SharedMemory");
|
|
|
|
|
|
|
|
|
|
var queryDiagnostic = CultMesh.DescribeQuerySurface(query);
|
|
|
|
|
queryDiagnostic.QueryId.Should().Be("aetheria.zone.objects.visible");
|
|
|
|
|
queryDiagnostic.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
queryDiagnostic.Sources.Should().HaveCount(2);
|
|
|
|
|
queryDiagnostic.Sources.Should().NotBeSameAs(query.Sources);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task LiveFeed_DescribesAndWatchesCoherentClientSnapshots()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<string>();
|
|
|
|
|
var feed = CultMesh.LiveFeed<MeshViewportRequest, string>(
|
|
|
|
|
"aetheria.rts.viewport.feed",
|
|
|
|
|
(request, context) => Task.FromResult(
|
|
|
|
|
$"{context.RuntimeId}:{request.MinX}:{request.MaxX}:{context.RouteHint.Kind}"),
|
|
|
|
|
(_request, context) => subject.Select(value => $"{context.RuntimeId}:{value}:{context.RouteHint.Kind}"),
|
|
|
|
|
new[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.ProjectionSource(
|
|
|
|
|
"daemon:aetheria.frame.latest.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1"),
|
|
|
|
|
CultMesh.ProjectionSource(
|
|
|
|
|
"daemon:aetheria.health.latest.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.daemon_health.v1")
|
|
|
|
|
},
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located RTS cache"));
|
|
|
|
|
|
|
|
|
|
feed.FeedId.Should().Be("aetheria.rts.viewport.feed");
|
|
|
|
|
feed.Sources.Should().HaveCount(2);
|
|
|
|
|
feed.Sources[0].SchemaId.Should().Be("gamecult.aetheria.daemon_frame.v1");
|
|
|
|
|
feed.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
|
|
|
|
|
var diagnostic = CultMesh.DescribeLiveFeed(feed);
|
|
|
|
|
diagnostic.FeedId.Should().Be("aetheria.rts.viewport.feed");
|
|
|
|
|
diagnostic.RouteHint.Description.Should().Be("co-located RTS cache");
|
|
|
|
|
diagnostic.Sources.Should().HaveCount(2);
|
|
|
|
|
diagnostic.Sources[1].SourceId.Should().Be("daemon:aetheria.health.latest.v1");
|
|
|
|
|
diagnostic.Sources.Should().NotBeSameAs(feed.Sources);
|
|
|
|
|
|
|
|
|
|
var inherited = await feed.SnapshotAsync(
|
|
|
|
|
new MeshViewportRequest(-32, 32),
|
|
|
|
|
"browser-starfire");
|
|
|
|
|
|
|
|
|
|
inherited.Should().Be("browser-starfire:-32:32:SharedMemory");
|
|
|
|
|
|
|
|
|
|
var explicitRoute = await feed.SnapshotAsync(
|
|
|
|
|
new MeshViewportRequest(-32, 32),
|
|
|
|
|
CultMesh.QueryContextFor("unity-raven")
|
|
|
|
|
.Route(CultMeshLocalityKind.InProcess, "embedded Verse")
|
|
|
|
|
.Build());
|
|
|
|
|
|
|
|
|
|
explicitRoute.Should().Be("unity-raven:-32:32:InProcess");
|
|
|
|
|
|
|
|
|
|
string observed = null!;
|
|
|
|
|
using var subscription = feed
|
|
|
|
|
.Watch(new MeshViewportRequest(-1, 1), CultMeshQueryContext.ForRuntime("browser-starfire"))
|
|
|
|
|
.Subscribe(value => observed = value);
|
|
|
|
|
|
|
|
|
|
subject.OnNext("frame:42");
|
|
|
|
|
observed.Should().Be("browser-starfire:frame:42:SharedMemory");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task DocumentHandle_ExposesTypedSnapshotsAndSameSchemaAliases()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var route = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located document slab");
|
|
|
|
|
var sources = new[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.ProjectionSource(
|
|
|
|
|
"daemon:mesh.note.current",
|
|
|
|
|
schemaId: "tests.mesh_note.v1")
|
|
|
|
|
};
|
|
|
|
|
var context = CultMesh.Verse("starbridge", "unity-pilot", route).Context;
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "primary",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.current",
|
|
|
|
|
context,
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
sources,
|
|
|
|
|
route);
|
|
|
|
|
|
|
|
|
|
handle.DocumentId.Should().Be("mesh.note.current");
|
|
|
|
|
handle.DocumentType.Should().Be(typeof(MeshNoteDocument));
|
|
|
|
|
handle.SchemaName.Should().Be("tests.mesh_note");
|
|
|
|
|
handle.SchemaVersion.Should().Be("tests.mesh_note.v1");
|
|
|
|
|
handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:mesh.note.current");
|
|
|
|
|
|
|
|
|
|
var snapshot = await handle.LatestAsync();
|
|
|
|
|
snapshot.Text.Should().Be("primary");
|
2026-06-27 16:03:10 +01:00
|
|
|
handle.Latest().Text.Should().Be("primary");
|
2026-06-27 14:30:59 +01:00
|
|
|
|
|
|
|
|
var alias = handle.AsSchemaAlias<MeshNoteAliasDocument>();
|
|
|
|
|
var aliasSnapshot = await alias.LatestAsync();
|
2026-06-27 16:03:10 +01:00
|
|
|
alias.Latest().Text.Should().Be("primary");
|
2026-06-27 14:30:59 +01:00
|
|
|
alias.DocumentId.Should().Be(handle.DocumentId);
|
|
|
|
|
alias.SchemaName.Should().Be(handle.SchemaName);
|
|
|
|
|
alias.SchemaVersion.Should().Be(handle.SchemaVersion);
|
|
|
|
|
aliasSnapshot.Text.Should().Be("primary");
|
|
|
|
|
aliasSnapshot.Revision.Should().Be(1);
|
|
|
|
|
|
|
|
|
|
MeshNoteAliasDocument observed = null!;
|
|
|
|
|
using var subscription = alias.Watch(value => observed = value);
|
|
|
|
|
subject.OnNext(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "reactive",
|
|
|
|
|
Revision = 2
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
observed.Text.Should().Be("reactive");
|
|
|
|
|
observed.Revision.Should().Be(2);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 03:17:11 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task DocumentHandle_Field_ProjectsTypedReactiveStateWithoutRepublishingSurfaceTrees()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var route = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located document slab");
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.current",
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-pilot", route),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
routeHint: route);
|
|
|
|
|
|
|
|
|
|
var text = handle.Field(value => value.Text);
|
|
|
|
|
|
|
|
|
|
text.PointerId.Should().Be("mesh.note.current#Text");
|
|
|
|
|
text.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
(await text.ResolveAsync()).Should().Be("initial");
|
|
|
|
|
string observed = null!;
|
|
|
|
|
using var subscription = text.Watch().Subscribe(value => observed = value);
|
|
|
|
|
subject.OnNext(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "reactive",
|
|
|
|
|
Revision = 2
|
|
|
|
|
});
|
|
|
|
|
observed.Should().Be("reactive");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 14:30:59 +01:00
|
|
|
[Test]
|
|
|
|
|
public void DocumentHandle_RejectsAliasTypesWithDifferentSchemaIdentity()
|
|
|
|
|
{
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.current",
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-pilot"),
|
|
|
|
|
_ => Task.FromResult(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "primary",
|
|
|
|
|
Revision = 1
|
|
|
|
|
}),
|
|
|
|
|
_ => new Subject<MeshNoteDocument>());
|
|
|
|
|
|
|
|
|
|
Action act = () => handle.AsSchemaAlias<MeshOtherDocument>();
|
|
|
|
|
|
|
|
|
|
act.Should()
|
|
|
|
|
.Throw<InvalidOperationException>()
|
|
|
|
|
.WithMessage("*tests.mesh_other*tests.mesh_note*");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 15:01:23 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task DocumentCatalog_IndexesHandlesByTypeAndSchemaAlias()
|
|
|
|
|
{
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "catalog-primary",
|
|
|
|
|
Revision = 3
|
|
|
|
|
};
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.current",
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-pilot"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => new Subject<MeshNoteDocument>());
|
|
|
|
|
var catalog = CultMesh.Documents(handle);
|
|
|
|
|
|
|
|
|
|
catalog.Documents.Should().ContainSingle().Which.Should().BeSameAs(handle);
|
|
|
|
|
catalog.TryGetDocument<MeshNoteDocument>(out var exact).Should().BeTrue();
|
|
|
|
|
exact.Should().BeSameAs(handle);
|
|
|
|
|
catalog.TryGetDocumentBySchema("tests.mesh_note.v1", out var byVersion).Should().BeTrue();
|
|
|
|
|
byVersion.Should().BeSameAs(handle);
|
|
|
|
|
catalog.DocumentBySchema("tests.mesh_note").Should().BeSameAs(handle);
|
2026-06-27 22:16:26 +01:00
|
|
|
catalog.DocumentBySchema(CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId)
|
|
|
|
|
.Should()
|
|
|
|
|
.BeSameAs(handle);
|
2026-06-27 15:01:23 +01:00
|
|
|
|
|
|
|
|
catalog.TryGetDocument<MeshNoteAliasDocument>(out var alias).Should().BeTrue();
|
|
|
|
|
alias.DocumentId.Should().Be(handle.DocumentId);
|
|
|
|
|
alias.SchemaName.Should().Be(handle.SchemaName);
|
|
|
|
|
alias.SchemaVersion.Should().Be(handle.SchemaVersion);
|
|
|
|
|
|
|
|
|
|
var aliasSnapshot = await catalog.LatestAsync<MeshNoteAliasDocument>();
|
|
|
|
|
aliasSnapshot.Text.Should().Be("catalog-primary");
|
|
|
|
|
aliasSnapshot.Revision.Should().Be(3);
|
2026-06-27 16:03:10 +01:00
|
|
|
catalog.Latest<MeshNoteAliasDocument>().Text.Should().Be("catalog-primary");
|
2026-06-27 15:01:23 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-27 18:23:26 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task DocumentHelpers_ProjectLatestSnapshotsFromMultipleHandles()
|
|
|
|
|
{
|
|
|
|
|
var primary = CultMesh.Document(
|
|
|
|
|
"mesh.note.primary",
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-pilot"),
|
|
|
|
|
_ => Task.FromResult(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "alpha",
|
|
|
|
|
Revision = 3
|
|
|
|
|
}),
|
|
|
|
|
_ => new Subject<MeshNoteDocument>());
|
|
|
|
|
var secondary = CultMesh.Document(
|
|
|
|
|
"mesh.note.secondary",
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-pilot"),
|
|
|
|
|
_ => Task.FromResult(new MeshOtherDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_other.v1",
|
|
|
|
|
Text = "beta"
|
|
|
|
|
}),
|
|
|
|
|
_ => new Subject<MeshOtherDocument>());
|
|
|
|
|
|
|
|
|
|
var projected = await CultMesh.LatestAsync(
|
|
|
|
|
primary,
|
|
|
|
|
secondary,
|
|
|
|
|
(first, second) => $"{first.Text}:{first.Revision}:{second.Text}");
|
|
|
|
|
|
|
|
|
|
projected.Should().Be("alpha:3:beta");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 14:43:00 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task DocumentHandle_ReadsWatchesAndReplacesCultCacheRecords()
|
|
|
|
|
{
|
|
|
|
|
var cache = new CultCache();
|
|
|
|
|
var key = new CultRecordKey("mesh-note:cache");
|
|
|
|
|
await cache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "cache-initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
|
|
|
|
|
var handle = CultMesh.Document<MeshNoteDocument>(
|
|
|
|
|
cache,
|
|
|
|
|
key,
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-pilot"));
|
|
|
|
|
var alias = handle.AsSchemaAlias<MeshNoteAliasDocument>();
|
|
|
|
|
|
|
|
|
|
handle.CanReplace.Should().BeTrue();
|
|
|
|
|
alias.CanReplace.Should().BeTrue();
|
2026-06-27 16:20:19 +01:00
|
|
|
handle.CanSubmitPrediction.Should().BeFalse();
|
|
|
|
|
alias.CanSubmitPrediction.Should().BeFalse();
|
2026-06-27 14:43:00 +01:00
|
|
|
handle.DocumentId.Should().Be(key.Value);
|
|
|
|
|
handle.Sources.Should().ContainSingle().Which.SchemaId.Should().Be(handle.SchemaId);
|
|
|
|
|
|
|
|
|
|
var snapshot = await alias.LatestAsync();
|
|
|
|
|
snapshot.Text.Should().Be("cache-initial");
|
|
|
|
|
|
|
|
|
|
MeshNoteAliasDocument observed = null!;
|
|
|
|
|
using var subscription = alias.Watch(value => observed = value);
|
|
|
|
|
await alias.ReplaceAsync(new MeshNoteAliasDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "cache-replaced",
|
|
|
|
|
Revision = 2
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("cache-replaced");
|
|
|
|
|
observed.Text.Should().Be("cache-replaced");
|
|
|
|
|
observed.Revision.Should().Be(2);
|
2026-06-27 16:20:19 +01:00
|
|
|
|
2026-06-27 16:28:42 +01:00
|
|
|
var catalog = CultMesh.Documents(handle);
|
|
|
|
|
catalog.CanReplace<MeshNoteAliasDocument>().Should().BeTrue();
|
|
|
|
|
await catalog.ReplaceAsync(new MeshNoteAliasDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "cache-catalog-replaced",
|
|
|
|
|
Revision = 3
|
|
|
|
|
});
|
|
|
|
|
cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("cache-catalog-replaced");
|
|
|
|
|
|
2026-06-27 16:20:19 +01:00
|
|
|
Func<Task> act = () => alias.SubmitPredictionAsync(new MeshNoteAliasDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "cache-predicted",
|
2026-06-27 16:28:42 +01:00
|
|
|
Revision = 4
|
2026-06-27 16:20:19 +01:00
|
|
|
});
|
|
|
|
|
await act.Should().ThrowAsync<NotSupportedException>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task DocumentHandle_SubmitsPredictionsThroughCultNetDatabase()
|
|
|
|
|
{
|
|
|
|
|
var cache = new CultCache();
|
|
|
|
|
var schemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId;
|
|
|
|
|
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
RuntimeId = "pilot-a",
|
|
|
|
|
Shards = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetShardDescriptor(
|
|
|
|
|
"pilot-inputs",
|
|
|
|
|
"server",
|
|
|
|
|
epoch: 1,
|
|
|
|
|
isPrimary: false,
|
|
|
|
|
schemaIds: new[] { schemaId },
|
|
|
|
|
keyPrefix: "input:")
|
|
|
|
|
},
|
|
|
|
|
ClientAuthorityScopes = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetClientAuthorityScope(
|
|
|
|
|
"pilot-a",
|
|
|
|
|
schemaIds: new[] { schemaId },
|
|
|
|
|
keyPrefix: "input:pilot-a")
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
var key = new CultRecordKey("input:pilot-a:thermal");
|
|
|
|
|
|
|
|
|
|
var handle = CultMesh.Document<MeshNoteDocument>(
|
|
|
|
|
database,
|
|
|
|
|
key,
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"));
|
|
|
|
|
var alias = handle.AsSchemaAlias<MeshNoteAliasDocument>();
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
handle.CanReplace.Should().BeFalse();
|
|
|
|
|
alias.CanReplace.Should().BeFalse();
|
2026-06-27 16:20:19 +01:00
|
|
|
alias.CanSubmitPrediction.Should().BeTrue();
|
2026-06-27 16:28:42 +01:00
|
|
|
var catalog = CultMesh.Documents(handle);
|
|
|
|
|
catalog.CanSubmitPrediction<MeshNoteAliasDocument>().Should().BeTrue();
|
2026-08-17 19:47:10 +02:00
|
|
|
var predictionWriter = catalog.PredictionWriter<MeshNoteAliasDocument>();
|
|
|
|
|
predictionWriter.Kind.Should().Be(CultMeshDocumentWriteKind.Prediction);
|
2026-06-27 16:20:19 +01:00
|
|
|
|
|
|
|
|
MeshNoteDocument observed = null!;
|
|
|
|
|
using var subscription = handle.Watch(value => observed = value);
|
2026-08-17 19:47:10 +02:00
|
|
|
await predictionWriter.WriteAsync(new MeshNoteAliasDocument
|
2026-06-27 16:20:19 +01:00
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "predicted-thermal",
|
|
|
|
|
Revision = 5
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("predicted-thermal");
|
|
|
|
|
observed.Text.Should().Be("predicted-thermal");
|
|
|
|
|
observed.Revision.Should().Be(5);
|
|
|
|
|
(await handle.LatestAsync()).Text.Should().Be("predicted-thermal");
|
2026-06-27 18:58:19 +01:00
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
var updated = await alias.PredictionWriter().UpdateAsync(value => new MeshNoteAliasDocument
|
2026-06-27 18:58:19 +01:00
|
|
|
{
|
|
|
|
|
Schema = value.Schema,
|
|
|
|
|
Text = "updated-as-prediction",
|
|
|
|
|
Revision = value.Revision + 1
|
|
|
|
|
});
|
|
|
|
|
updated.Text.Should().Be("updated-as-prediction");
|
|
|
|
|
cache.Get<MeshNoteDocument>(key)!.Revision.Should().Be(6);
|
2026-06-27 14:43:00 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:19:09 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_CoalescesLocalEditsIntoOnePrediction()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var predictions = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
predictions.Add(value);
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
var catalog = CultMesh.Documents(handle);
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await catalog.PredictionWriter<MeshNoteAliasDocument>().ReactiveAsync(
|
2026-06-27 19:19:09 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
reactive.Snapshot.Text.Should().Be("initial");
|
2026-06-27 19:19:09 +01:00
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "first-local-edit";
|
|
|
|
|
document.Revision++;
|
|
|
|
|
});
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "second-local-edit";
|
|
|
|
|
document.Revision++;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
predictions.Should().BeEmpty();
|
|
|
|
|
|
|
|
|
|
await reactive.FlushAsync();
|
|
|
|
|
|
|
|
|
|
predictions.Should().ContainSingle();
|
|
|
|
|
predictions[0].Text.Should().Be("second-local-edit");
|
|
|
|
|
predictions[0].Revision.Should().Be(3);
|
|
|
|
|
current.Text.Should().Be("second-local-edit");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_TracksCanonicalSnapshotDuringDirtyPrediction()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reconciliation",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.AuthoritativeWriter().ReactiveAsync(
|
2026-06-27 19:19:09 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "local-prediction";
|
|
|
|
|
document.Revision = 2;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
subject.OnNext(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "canonical-correction",
|
|
|
|
|
Revision = 7
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
reactive.Snapshot.Text.Should().Be("local-prediction");
|
2026-06-27 19:19:09 +01:00
|
|
|
reactive.Reconciliation.Should().NotBeNull();
|
2026-06-27 19:56:08 +01:00
|
|
|
reactive.Reconciliation!.Version.Should().Be(1);
|
2026-06-27 19:19:09 +01:00
|
|
|
reactive.Reconciliation!.Canonical.Text.Should().Be("canonical-correction");
|
|
|
|
|
reactive.Reconciliation.Predicted.Text.Should().Be("local-prediction");
|
2026-06-27 19:56:08 +01:00
|
|
|
reactive.Reconciliation.Delta.Should().ContainKey(nameof(MeshNoteDocument.Text));
|
|
|
|
|
reactive.Reconciliation.Delta[nameof(MeshNoteDocument.Text)].Should().Be("local-prediction");
|
|
|
|
|
reactive.Reconciliation.Delta.Should().ContainKey(nameof(MeshNoteDocument.Revision));
|
|
|
|
|
reactive.Reconciliation.Delta[nameof(MeshNoteDocument.Revision)].Should().Be(-5.0);
|
2026-06-27 19:19:09 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:25:10 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_AutoFlushesOnceAfterDebounceWindow()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var predictions = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.debounce",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
predictions.Add(value);
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
2026-06-27 19:25:10 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMilliseconds(25)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "coalesced-one";
|
|
|
|
|
document.Revision++;
|
|
|
|
|
});
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "coalesced-two";
|
|
|
|
|
document.Revision++;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await WaitForAsync(() => predictions.Count == 1);
|
|
|
|
|
|
|
|
|
|
predictions[0].Text.Should().Be("coalesced-two");
|
|
|
|
|
predictions[0].Revision.Should().Be(3);
|
|
|
|
|
reactive.IsDirty.Should().BeFalse();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 00:04:48 +01:00
|
|
|
[Test]
|
2026-08-17 19:47:10 +02:00
|
|
|
public async Task ReactiveDocument_CoalescesExplicitMemberUpdates()
|
2026-06-28 00:04:48 +01:00
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var predictions = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.direct-members",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
predictions.Add(value);
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
2026-06-28 00:04:48 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMilliseconds(25)
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
reactive.Update(document => document.Text = "explicit-member-one");
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "explicit-member-two";
|
|
|
|
|
document.Revision = 2;
|
|
|
|
|
});
|
2026-06-28 00:04:48 +01:00
|
|
|
|
|
|
|
|
await WaitForAsync(() => predictions.Count == 1);
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
predictions[0].Text.Should().Be("explicit-member-two");
|
2026-06-28 00:04:48 +01:00
|
|
|
predictions[0].Revision.Should().Be(2);
|
2026-08-17 19:47:10 +02:00
|
|
|
current.Text.Should().Be("explicit-member-two");
|
2026-06-28 00:04:48 +01:00
|
|
|
reactive.IsDirty.Should().BeFalse();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
2026-08-17 19:47:10 +02:00
|
|
|
public async Task ReactiveDocument_QueuesExplicitEditsWhileFlushIsInFlight()
|
2026-06-28 00:04:48 +01:00
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var firstPredictionStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
|
var releaseFirstPrediction = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
|
var predictions = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.in-flight",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
async value =>
|
|
|
|
|
{
|
|
|
|
|
predictions.Add(value);
|
|
|
|
|
if (predictions.Count == 1)
|
|
|
|
|
{
|
|
|
|
|
firstPredictionStarted.SetResult();
|
|
|
|
|
await releaseFirstPrediction.Task.ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
2026-06-28 00:04:48 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "first-in-flight";
|
|
|
|
|
document.Revision = 2;
|
|
|
|
|
});
|
|
|
|
|
var flushTask = reactive.FlushAsync();
|
|
|
|
|
await firstPredictionStarted.Task;
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "second-after-flight-started";
|
|
|
|
|
document.Revision = 3;
|
|
|
|
|
});
|
2026-06-28 00:04:48 +01:00
|
|
|
releaseFirstPrediction.SetResult();
|
|
|
|
|
|
|
|
|
|
await flushTask;
|
|
|
|
|
|
|
|
|
|
predictions.Should().HaveCount(2);
|
|
|
|
|
predictions.Select(document => document.Text).Should().Equal(
|
|
|
|
|
"first-in-flight",
|
|
|
|
|
"second-after-flight-started");
|
|
|
|
|
current.Text.Should().Be("second-after-flight-started");
|
|
|
|
|
current.Revision.Should().Be(3);
|
|
|
|
|
reactive.IsDirty.Should().BeFalse();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:25:10 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_UsesReplacementWhenPredictionIsUnavailable()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var replacements = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.replace",
|
|
|
|
|
CultMesh.Verse("starbridge", "daemon"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
replacements.Add(value);
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.AuthoritativeWriter().ReactiveAsync(
|
2026-06-27 19:25:10 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "authoritative-replace";
|
|
|
|
|
document.Revision = 10;
|
|
|
|
|
});
|
|
|
|
|
await reactive.FlushAsync();
|
|
|
|
|
|
|
|
|
|
replacements.Should().ContainSingle();
|
|
|
|
|
replacements[0].Text.Should().Be("authoritative-replace");
|
|
|
|
|
current.Revision.Should().Be(10);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
2026-08-17 19:47:10 +02:00
|
|
|
public void ReactiveDocument_ReadOnlyHandleRejectsWriterSelection()
|
2026-06-27 19:25:10 +01:00
|
|
|
{
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.readonly",
|
|
|
|
|
CultMesh.Verse("starbridge", "observer"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => new Subject<MeshNoteDocument>());
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
Action authoritative = () => handle.AuthoritativeWriter();
|
|
|
|
|
Action prediction = () => handle.PredictionWriter();
|
2026-06-27 19:25:10 +01:00
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
authoritative.Should().Throw<NotSupportedException>();
|
|
|
|
|
prediction.Should().Throw<NotSupportedException>();
|
|
|
|
|
}
|
2026-06-27 19:25:10 +01:00
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
[Test]
|
|
|
|
|
public async Task ObservedDocument_WatchesWithoutWriteCapabilityOrIdleTimer()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.observed.readonly",
|
|
|
|
|
CultMesh.Verse("starbridge", "observer"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject);
|
|
|
|
|
|
|
|
|
|
using var observed = await handle.ObserveAsync();
|
|
|
|
|
observed.Current.Text.Should().Be("initial");
|
|
|
|
|
typeof(CultMeshObservedDocument<MeshNoteDocument>)
|
|
|
|
|
.GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
|
|
|
|
|
.Should()
|
|
|
|
|
.NotContain(field => field.FieldType == typeof(System.Threading.Timer));
|
|
|
|
|
|
|
|
|
|
subject.OnNext(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "canonical-update",
|
|
|
|
|
Revision = 2
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
observed.Current.Text.Should().Be("canonical-update");
|
|
|
|
|
observed.Current.Revision.Should().Be(2);
|
2026-06-27 19:25:10 +01:00
|
|
|
}
|
|
|
|
|
|
Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
|
|
|
// The older of two racing writes is parked in an earlier observer until the newer one has been applied.
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task WatchRecordIgnoresStaleSequence()
|
|
|
|
|
{
|
|
|
|
|
var cache = new CultCache();
|
|
|
|
|
var key = new CultRecordKey("mesh-note:stale-sequence");
|
|
|
|
|
var handle = new CultRecordHandle<MeshNoteDocument>(key);
|
|
|
|
|
MeshNoteDocument Note(string text, int revision) =>
|
|
|
|
|
new() { Schema = "tests.mesh_note.v1", Text = text, Revision = revision };
|
|
|
|
|
await cache.UpsertAsync(Note("initial", 1), handle);
|
|
|
|
|
using var release = new System.Threading.ManualResetEventSlim();
|
|
|
|
|
using var parked = new System.Threading.ManualResetEventSlim();
|
|
|
|
|
using var park = cache.WatchRecord<MeshNoteDocument>(key).Subscribe(change =>
|
|
|
|
|
{
|
|
|
|
|
if (change.Document?.Revision != 2) return;
|
|
|
|
|
parked.Set();
|
|
|
|
|
release.Wait(TimeSpan.FromSeconds(30));
|
|
|
|
|
});
|
|
|
|
|
using var observed = await CultMesh.Document<MeshNoteDocument>(cache, key).ObserveAsync();
|
|
|
|
|
|
|
|
|
|
var older = Task.Run(() => cache.UpsertAsync(Note("older", 2), handle));
|
|
|
|
|
parked.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue();
|
|
|
|
|
var newer = Task.Run(() => cache.UpsertAsync(Note("newer", 3), handle));
|
|
|
|
|
var newerFinished = newer.Wait(TimeSpan.FromSeconds(10));
|
|
|
|
|
release.Set();
|
|
|
|
|
|
|
|
|
|
newerFinished.Should().BeTrue("a writer waited on another thread's delivery");
|
|
|
|
|
older.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue();
|
|
|
|
|
observed.Current.Text.Should().Be("newer");
|
|
|
|
|
cache.Dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 21:02:49 +02:00
|
|
|
// A batch parks in an observer of another key before publishing its (older) target-key change; the mirror is
|
|
|
|
|
// created after a newer write landed, then the straggler is delivered.
|
|
|
|
|
[TestCase(false)]
|
|
|
|
|
[TestCase(true)]
|
|
|
|
|
public async Task ObserveAfterNewerSnapshotIgnoresStraggler(bool reactive)
|
|
|
|
|
{
|
|
|
|
|
var cache = new CultCache();
|
|
|
|
|
var key = new CultRecordKey("mesh-note:straggler");
|
|
|
|
|
var other = new CultRecordKey("mesh-note:straggler-other");
|
|
|
|
|
await cache.UpsertAsync(Note("initial", 1), new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
using var release = new ManualResetEventSlim();
|
|
|
|
|
using var parked = new ManualResetEventSlim();
|
|
|
|
|
using var park = cache.WatchRecord<MeshNoteDocument>(other).Subscribe(_ =>
|
|
|
|
|
{
|
|
|
|
|
parked.Set();
|
|
|
|
|
release.Wait(TimeSpan.FromSeconds(30));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var batch = Task.Run(() => cache.Commit(stage =>
|
|
|
|
|
{
|
|
|
|
|
stage.Upsert(Note("other", 1), new CultRecordHandle<MeshNoteDocument>(other));
|
|
|
|
|
stage.Upsert(Note("older", 2), new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
}));
|
|
|
|
|
parked.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue();
|
|
|
|
|
await cache.UpsertAsync(Note("newer", 3), new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
var (mirror, text, _) = await Mirror(cache, key, reactive);
|
|
|
|
|
release.Set();
|
|
|
|
|
|
|
|
|
|
batch.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue();
|
|
|
|
|
text().Should().Be("newer");
|
|
|
|
|
mirror.Dispose();
|
|
|
|
|
cache.Dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The mirror's own delivery of the newer write is parked too, so only the refresh saw it before the straggler.
|
|
|
|
|
[TestCase(false)]
|
|
|
|
|
[TestCase(true)]
|
|
|
|
|
public async Task RefreshIgnoresStragglerOlderThanSnapshot(bool reactive)
|
|
|
|
|
{
|
|
|
|
|
var cache = new CultCache();
|
|
|
|
|
var key = new CultRecordKey("mesh-note:refresh-straggler");
|
|
|
|
|
var other = new CultRecordKey("mesh-note:refresh-straggler-other");
|
|
|
|
|
await cache.UpsertAsync(Note("initial", 1), new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
using var releaseOlder = new ManualResetEventSlim();
|
|
|
|
|
using var olderParked = new ManualResetEventSlim();
|
|
|
|
|
using var releaseNewer = new ManualResetEventSlim();
|
|
|
|
|
using var newerParked = new ManualResetEventSlim();
|
|
|
|
|
using var parkOlder = cache.WatchRecord<MeshNoteDocument>(other).Subscribe(_ =>
|
|
|
|
|
{
|
|
|
|
|
olderParked.Set();
|
|
|
|
|
releaseOlder.Wait(TimeSpan.FromSeconds(30));
|
|
|
|
|
});
|
|
|
|
|
using var parkNewer = cache.WatchRecord<MeshNoteDocument>(key).Subscribe(change =>
|
|
|
|
|
{
|
|
|
|
|
if (change.Document?.Text != "newer") return;
|
|
|
|
|
newerParked.Set();
|
|
|
|
|
releaseNewer.Wait(TimeSpan.FromSeconds(30));
|
|
|
|
|
});
|
|
|
|
|
var (mirror, text, refresh) = await Mirror(cache, key, reactive);
|
|
|
|
|
|
|
|
|
|
var batch = Task.Run(() => cache.Commit(stage =>
|
|
|
|
|
{
|
|
|
|
|
stage.Upsert(Note("other", 1), new CultRecordHandle<MeshNoteDocument>(other));
|
|
|
|
|
stage.Upsert(Note("older", 2), new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
}));
|
|
|
|
|
olderParked.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue();
|
|
|
|
|
var newer = Task.Run(() => cache.UpsertAsync(Note("newer", 3), new CultRecordHandle<MeshNoteDocument>(key)));
|
|
|
|
|
newerParked.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue();
|
|
|
|
|
await refresh();
|
|
|
|
|
releaseOlder.Set();
|
|
|
|
|
batch.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue();
|
|
|
|
|
text().Should().Be("newer");
|
|
|
|
|
releaseNewer.Set();
|
|
|
|
|
|
|
|
|
|
newer.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue();
|
|
|
|
|
text().Should().Be("newer");
|
|
|
|
|
mirror.Dispose();
|
|
|
|
|
cache.Dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static MeshNoteDocument Note(string text, int revision) =>
|
|
|
|
|
new() { Schema = "tests.mesh_note.v1", Text = text, Revision = revision };
|
|
|
|
|
|
|
|
|
|
private static async Task<(IDisposable Mirror, Func<string> Text, Func<Task> Refresh)> Mirror(
|
|
|
|
|
CultCache cache, CultRecordKey key, bool reactive)
|
|
|
|
|
{
|
|
|
|
|
var handle = CultMesh.Document<MeshNoteDocument>(cache, key);
|
|
|
|
|
if (reactive)
|
|
|
|
|
{
|
|
|
|
|
var mirror = await handle.AuthoritativeWriter().ReactiveAsync();
|
|
|
|
|
return (mirror, () => mirror.Snapshot.Text, () => mirror.RefreshAsync());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var observed = await handle.ObserveAsync();
|
|
|
|
|
return (observed, () => observed.Current.Text, () => observed.RefreshAsync());
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:25:10 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_DisposeSuppressesScheduledFlush()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var predictions = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.dispose",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
predictions.Add(value);
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
var reactive = await handle.PredictionWriter().ReactiveAsync(
|
2026-06-27 19:25:10 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMilliseconds(25)
|
|
|
|
|
});
|
|
|
|
|
reactive.Update(document => document.Text = "disposed-before-flush");
|
|
|
|
|
reactive.Dispose();
|
|
|
|
|
|
|
|
|
|
await Task.Delay(100);
|
|
|
|
|
|
|
|
|
|
predictions.Should().BeEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_CanAdoptCanonicalSnapshotWhileDirty()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.adopt-canonical",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.AuthoritativeWriter().ReactiveAsync(
|
2026-06-27 19:25:10 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1),
|
|
|
|
|
ReplaceDirtyCurrentOnCanonicalSnapshot = true
|
|
|
|
|
});
|
|
|
|
|
reactive.Update(document => document.Text = "local-prediction");
|
|
|
|
|
|
|
|
|
|
subject.OnNext(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "canonical-wins",
|
|
|
|
|
Revision = 9
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
reactive.Snapshot.Text.Should().Be("canonical-wins");
|
|
|
|
|
reactive.Snapshot.Revision.Should().Be(9);
|
2026-06-27 19:25:10 +01:00
|
|
|
reactive.Reconciliation.Should().BeNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_RefreshClearsDirtyLocalState()
|
|
|
|
|
{
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var replacements = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.refresh",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => new Subject<MeshNoteDocument>(),
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
replacements.Add(value);
|
|
|
|
|
current = value;
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.AuthoritativeWriter().ReactiveAsync(
|
2026-06-27 19:25:10 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
reactive.Update(document => document.Text = "local-dirty");
|
|
|
|
|
current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "fresh-canonical",
|
|
|
|
|
Revision = 5
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await reactive.RefreshAsync();
|
|
|
|
|
await reactive.FlushAsync();
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
reactive.Snapshot.Text.Should().Be("fresh-canonical");
|
2026-06-27 19:25:10 +01:00
|
|
|
reactive.IsDirty.Should().BeFalse();
|
|
|
|
|
replacements.Should().BeEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_SyncsSameSchemaAliasesAcrossRuntimeHandles()
|
|
|
|
|
{
|
|
|
|
|
var cache = new CultCache();
|
|
|
|
|
var schemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId;
|
|
|
|
|
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
RuntimeId = "pilot-a",
|
|
|
|
|
Shards = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetShardDescriptor(
|
|
|
|
|
"pilot-inputs",
|
|
|
|
|
"server",
|
|
|
|
|
epoch: 1,
|
|
|
|
|
isPrimary: false,
|
|
|
|
|
schemaIds: new[] { schemaId },
|
|
|
|
|
keyPrefix: "input:")
|
|
|
|
|
},
|
|
|
|
|
ClientAuthorityScopes = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetClientAuthorityScope(
|
|
|
|
|
"pilot-a",
|
|
|
|
|
schemaIds: new[] { schemaId },
|
|
|
|
|
keyPrefix: "input:pilot-a")
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
var key = new CultRecordKey("input:pilot-a:reactive");
|
|
|
|
|
await cache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
|
|
|
|
|
var pilotHandle = CultMesh.Document<MeshNoteDocument>(
|
|
|
|
|
database,
|
|
|
|
|
key,
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"));
|
|
|
|
|
var commanderHandle = CultMesh.Document<MeshNoteDocument>(
|
|
|
|
|
database,
|
|
|
|
|
key,
|
|
|
|
|
CultMesh.Verse("starbridge", "commander-rts"));
|
|
|
|
|
var observedByCommander = new List<MeshNoteAliasDocument>();
|
|
|
|
|
using var commanderSubscription = commanderHandle
|
|
|
|
|
.AsSchemaAlias<MeshNoteAliasDocument>()
|
|
|
|
|
.Watch(observedByCommander.Add);
|
|
|
|
|
using var pilotReactive = await CultMesh
|
|
|
|
|
.Documents(pilotHandle)
|
2026-08-17 19:47:10 +02:00
|
|
|
.PredictionWriter<MeshNoteAliasDocument>()
|
|
|
|
|
.ReactiveAsync(
|
2026-06-27 19:25:10 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
pilotReactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "pilot-predicted";
|
|
|
|
|
document.Revision = 2;
|
|
|
|
|
});
|
|
|
|
|
await pilotReactive.FlushAsync();
|
|
|
|
|
|
|
|
|
|
await WaitForAsync(() => observedByCommander.Any(document => document.Text == "pilot-predicted"));
|
|
|
|
|
|
|
|
|
|
commanderHandle.Context.RuntimeId.Should().Be("commander-rts");
|
|
|
|
|
observedByCommander.Last().Revision.Should().Be(2);
|
|
|
|
|
(await commanderHandle.AsSchemaAlias<MeshNoteAliasDocument>().LatestAsync()).Text.Should().Be("pilot-predicted");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:31:59 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_TwoRuntimeManagedDocumentsSyncThroughDatabase()
|
|
|
|
|
{
|
|
|
|
|
var cache = new CultCache();
|
|
|
|
|
var schemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId;
|
|
|
|
|
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
RuntimeId = "pilot-a",
|
|
|
|
|
Shards = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetShardDescriptor(
|
|
|
|
|
"pilot-inputs",
|
|
|
|
|
"server",
|
|
|
|
|
epoch: 1,
|
|
|
|
|
isPrimary: false,
|
|
|
|
|
schemaIds: new[] { schemaId },
|
|
|
|
|
keyPrefix: "input:")
|
|
|
|
|
},
|
|
|
|
|
ClientAuthorityScopes = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetClientAuthorityScope(
|
|
|
|
|
"pilot-a",
|
|
|
|
|
schemaIds: new[] { schemaId },
|
|
|
|
|
keyPrefix: "input:pilot-a")
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
var key = new CultRecordKey("input:pilot-a:co-op");
|
|
|
|
|
await cache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
|
|
|
|
|
var pilotHandle = CultMesh.Document<MeshNoteDocument>(
|
|
|
|
|
database,
|
|
|
|
|
key,
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"));
|
|
|
|
|
var commanderHandle = CultMesh.Document<MeshNoteDocument>(
|
|
|
|
|
database,
|
|
|
|
|
key,
|
|
|
|
|
CultMesh.Verse("starbridge", "commander-rts"));
|
2026-08-17 19:47:10 +02:00
|
|
|
MeshNoteAliasDocument commanderSnapshot = null!;
|
|
|
|
|
using var commanderSubscription = commanderHandle
|
2026-06-27 19:31:59 +01:00
|
|
|
.AsSchemaAlias<MeshNoteAliasDocument>()
|
2026-08-17 19:47:10 +02:00
|
|
|
.Watch(value => commanderSnapshot = value);
|
2026-06-27 19:31:59 +01:00
|
|
|
using var pilotReactive = await pilotHandle
|
|
|
|
|
.AsSchemaAlias<MeshNoteAliasDocument>()
|
2026-08-17 19:47:10 +02:00
|
|
|
.PredictionWriter()
|
2026-06-27 19:31:59 +01:00
|
|
|
.ReactiveAsync(new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
pilotReactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "pilot-first-touch";
|
|
|
|
|
document.Revision++;
|
|
|
|
|
});
|
|
|
|
|
pilotReactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "pilot-coalesced-order";
|
|
|
|
|
document.Revision++;
|
|
|
|
|
});
|
|
|
|
|
await pilotReactive.FlushAsync();
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
await WaitForAsync(() => commanderSnapshot?.Text == "pilot-coalesced-order");
|
2026-06-27 19:31:59 +01:00
|
|
|
|
|
|
|
|
pilotReactive.IsDirty.Should().BeFalse();
|
2026-08-17 19:47:10 +02:00
|
|
|
commanderHandle.Context.RuntimeId.Should().Be("commander-rts");
|
|
|
|
|
commanderSnapshot.Revision.Should().Be(3);
|
2026-06-27 19:31:59 +01:00
|
|
|
cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("pilot-coalesced-order");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 20:35:59 +01:00
|
|
|
[Test]
|
2026-08-17 19:47:10 +02:00
|
|
|
public async Task NodeDocument_OpensExplicitPredictionWriterForSameSchemaAlias()
|
2026-06-27 20:35:59 +01:00
|
|
|
{
|
|
|
|
|
var schemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId;
|
|
|
|
|
using var temp = new TemporaryDirectory();
|
|
|
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(temp.Path, "node-reactive-alias.ccmp"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
StartServer = false,
|
|
|
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
RuntimeId = "pilot-a",
|
|
|
|
|
Shards = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetShardDescriptor(
|
|
|
|
|
"pilot-inputs",
|
|
|
|
|
"server",
|
|
|
|
|
epoch: 1,
|
|
|
|
|
isPrimary: false,
|
|
|
|
|
schemaIds: new[] { schemaId },
|
|
|
|
|
keyPrefix: "input:")
|
|
|
|
|
},
|
|
|
|
|
ClientAuthorityScopes = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetClientAuthorityScope(
|
|
|
|
|
"pilot-a",
|
|
|
|
|
schemaIds: new[] { schemaId },
|
|
|
|
|
keyPrefix: "input:pilot-a")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
var key = new CultRecordKey("input:pilot-a:node-reactive");
|
|
|
|
|
await node.Cache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "node-initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var pilotReactive = await node
|
|
|
|
|
.Document<MeshNoteAliasDocument>(key, CultMesh.Verse("starbridge", "pilot-a"))
|
|
|
|
|
.PredictionWriter()
|
|
|
|
|
.ReactiveAsync(new CultMeshReactiveDocumentOptions
|
2026-06-27 20:35:59 +01:00
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
var commanderHandle = node.Document<MeshNoteDocument>(
|
|
|
|
|
key.Value,
|
|
|
|
|
CultMesh.Verse("starbridge", "commander-rts"));
|
|
|
|
|
var observedByCommander = new List<MeshNoteDocument>();
|
|
|
|
|
using var commanderSubscription = commanderHandle.Watch(observedByCommander.Add);
|
|
|
|
|
|
|
|
|
|
pilotReactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "node-one-call-prediction";
|
|
|
|
|
document.Revision = 2;
|
|
|
|
|
});
|
|
|
|
|
await pilotReactive.FlushAsync();
|
|
|
|
|
|
|
|
|
|
await WaitForAsync(() => observedByCommander.Any(document => document.Text == "node-one-call-prediction"));
|
|
|
|
|
pilotReactive.Document.DocumentId.Should().Be(key.Value);
|
|
|
|
|
pilotReactive.Document.Context.RuntimeId.Should().Be("pilot-a");
|
|
|
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("node-one-call-prediction");
|
|
|
|
|
(await commanderHandle.AsSchemaAlias<MeshNoteAliasDocument>().LatestAsync()).Revision.Should().Be(2);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:31:59 +01:00
|
|
|
[Test]
|
2026-08-17 19:47:10 +02:00
|
|
|
public async Task SyncedSnapshotEndpointObservesCanonicalCorrectionAcrossRuntimeBoundary()
|
2026-06-27 19:31:59 +01:00
|
|
|
{
|
|
|
|
|
var sourceCache = new CultCache();
|
|
|
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
|
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
|
|
|
var key = new CultRecordKey("mesh-note:remote-correction");
|
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
var endpoint = "cultnet://remote-correction.test:3080";
|
|
|
|
|
var surface = CultMesh.SnapshotEndpoint(
|
|
|
|
|
endpoint,
|
|
|
|
|
new CultMeshSnapshotEndpointOptions
|
|
|
|
|
{
|
|
|
|
|
Context = CultMesh.Verse("starbridge", "unity-pilot").Context,
|
|
|
|
|
DocumentRegistry = registry,
|
|
|
|
|
PollInterval = TimeSpan.FromMilliseconds(10),
|
|
|
|
|
Request = new CultMeshSnapshotRequestOptions
|
|
|
|
|
{
|
|
|
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request))
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
using var temp = new TemporaryDirectory();
|
|
|
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(temp.Path, "pilot-runtime.ccmp"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
StartServer = false,
|
|
|
|
|
CacheOptions = new CultCacheOpenOptions
|
|
|
|
|
{
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
Registry = sourceCache.Registry
|
2026-06-27 19:31:59 +01:00
|
|
|
},
|
|
|
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentRegistry = registry
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-17 19:47:10 +02:00
|
|
|
var handle = surface.SyncTo(node).Document<MeshNoteAliasDocument>(key.Value);
|
|
|
|
|
MeshNoteAliasDocument observed = null!;
|
|
|
|
|
using var subscription = handle.Watch(value => observed = value);
|
2026-06-27 19:31:59 +01:00
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "remote-authority-correction",
|
|
|
|
|
Revision = 7
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
await WaitForAsync(() => observed?.Text == "remote-authority-correction");
|
2026-06-27 19:31:59 +01:00
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
observed.Revision.Should().Be(7);
|
2026-06-27 19:31:59 +01:00
|
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("remote-authority-correction");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_FreshRuntimeReconstructsFromSyncedSnapshotEndpoint()
|
|
|
|
|
{
|
|
|
|
|
var sourceCache = new CultCache();
|
|
|
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
|
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
|
|
|
var key = new CultRecordKey("mesh-note:runtime-reconnect");
|
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "before-crash",
|
|
|
|
|
Revision = 1
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
var endpoint = "cultnet://runtime-reconnect.test:3081";
|
|
|
|
|
var surface = CultMesh.SnapshotEndpoint(
|
|
|
|
|
endpoint,
|
|
|
|
|
new CultMeshSnapshotEndpointOptions
|
|
|
|
|
{
|
|
|
|
|
Context = CultMesh.Verse("starbridge", "unity-reconnected").Context,
|
|
|
|
|
DocumentRegistry = registry,
|
|
|
|
|
Request = new CultMeshSnapshotRequestOptions
|
|
|
|
|
{
|
|
|
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request))
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
using var temp = new TemporaryDirectory();
|
|
|
|
|
using (var crashedRuntime = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(temp.Path, "crashed-runtime.ccmp"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
StartServer = false,
|
|
|
|
|
CacheOptions = new CultCacheOpenOptions
|
|
|
|
|
{
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
Registry = sourceCache.Registry
|
2026-06-27 19:31:59 +01:00
|
|
|
},
|
|
|
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentRegistry = registry
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
{
|
|
|
|
|
var stale = await surface.SyncTo(crashedRuntime).Document<MeshNoteAliasDocument>(key.Value).LatestAsync();
|
|
|
|
|
stale.Text.Should().Be("before-crash");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "after-reboot-authority",
|
|
|
|
|
Revision = 4
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
using var freshRuntime = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(temp.Path, "fresh-runtime.ccmp"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
StartServer = false,
|
|
|
|
|
CacheOptions = new CultCacheOpenOptions
|
|
|
|
|
{
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
Registry = sourceCache.Registry
|
2026-06-27 19:31:59 +01:00
|
|
|
},
|
|
|
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentRegistry = registry
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-17 19:47:10 +02:00
|
|
|
var reconstructed = await surface
|
2026-06-27 19:31:59 +01:00
|
|
|
.SyncTo(freshRuntime)
|
|
|
|
|
.Documents(CultMesh.SnapshotDocument<MeshNoteDocument>(key.Value))
|
2026-08-17 19:47:10 +02:00
|
|
|
.LatestAsync<MeshNoteAliasDocument>();
|
2026-06-27 19:31:59 +01:00
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
reconstructed.Text.Should().Be("after-reboot-authority");
|
|
|
|
|
reconstructed.Revision.Should().Be(4);
|
2026-06-27 19:31:59 +01:00
|
|
|
freshRuntime.Cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("after-reboot-authority");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_ReadOnlySnapshotEndpointRefreshRecoversAfterRejectedLocalEdit()
|
|
|
|
|
{
|
|
|
|
|
var sourceCache = new CultCache();
|
|
|
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
|
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
|
|
|
var key = new CultRecordKey("mesh-note:readonly-refresh");
|
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
var endpoint = "cultnet://readonly-refresh.test:3082";
|
|
|
|
|
var surface = CultMesh.SnapshotEndpoint(
|
|
|
|
|
endpoint,
|
|
|
|
|
new CultMeshSnapshotEndpointOptions
|
|
|
|
|
{
|
|
|
|
|
Context = CultMesh.Verse("starbridge", "observer").Context,
|
|
|
|
|
DocumentRegistry = registry,
|
|
|
|
|
Request = new CultMeshSnapshotRequestOptions
|
|
|
|
|
{
|
|
|
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request))
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
using var temp = new TemporaryDirectory();
|
|
|
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(temp.Path, "observer-runtime.ccmp"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
StartServer = false,
|
|
|
|
|
CacheOptions = new CultCacheOpenOptions
|
|
|
|
|
{
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
Registry = sourceCache.Registry
|
2026-06-27 19:31:59 +01:00
|
|
|
},
|
|
|
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentRegistry = registry
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-17 19:47:10 +02:00
|
|
|
var readOnlyHandle = surface
|
2026-06-27 19:31:59 +01:00
|
|
|
.SyncTo(node)
|
2026-08-17 19:47:10 +02:00
|
|
|
.Document<MeshNoteAliasDocument>(key.Value);
|
|
|
|
|
readOnlyHandle.CanReplace.Should().BeFalse();
|
|
|
|
|
readOnlyHandle.CanSubmitPrediction.Should().BeFalse();
|
2026-06-27 19:31:59 +01:00
|
|
|
|
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "fresh-authority",
|
|
|
|
|
Revision = 5
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
2026-08-17 19:47:10 +02:00
|
|
|
var refreshed = await readOnlyHandle.LatestAsync();
|
2026-06-27 19:31:59 +01:00
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
refreshed.Text.Should().Be("fresh-authority");
|
2026-06-27 19:31:59 +01:00
|
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Revision.Should().Be(5);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:25:10 +01:00
|
|
|
[Test]
|
2026-08-17 19:47:10 +02:00
|
|
|
public async Task ReactiveDocument_SnapshotMutationCannotPublish()
|
2026-06-27 19:25:10 +01:00
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var predictions = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.mark-dirty",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
predictions.Add(value);
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
2026-06-27 19:25:10 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
var escapedSnapshot = reactive.Snapshot;
|
|
|
|
|
escapedSnapshot.Text = "must-not-publish";
|
|
|
|
|
escapedSnapshot.Revision = 12;
|
2026-06-27 19:25:10 +01:00
|
|
|
await reactive.FlushAsync();
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
predictions.Should().BeEmpty();
|
|
|
|
|
reactive.Snapshot.Text.Should().Be("initial");
|
|
|
|
|
reactive.IsDirty.Should().BeFalse();
|
2026-06-27 19:25:10 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:50:30 +01:00
|
|
|
[Test]
|
2026-08-17 19:47:10 +02:00
|
|
|
public async Task ReactiveDocument_IdleMirrorDoesNotPublishUntilExplicitUpdate()
|
2026-06-27 19:50:30 +01:00
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var predictions = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.direct-member-auto",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
predictions.Add(value);
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
2026-06-27 19:50:30 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMilliseconds(10)
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
await Task.Delay(75);
|
|
|
|
|
predictions.Should().BeEmpty();
|
|
|
|
|
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "explicit-member-edit";
|
|
|
|
|
document.Revision = 3;
|
|
|
|
|
});
|
2026-06-27 19:50:30 +01:00
|
|
|
|
|
|
|
|
await WaitForAsync(() => predictions.Count == 1);
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
predictions[0].Text.Should().Be("explicit-member-edit");
|
2026-06-27 19:50:30 +01:00
|
|
|
predictions[0].Revision.Should().Be(3);
|
|
|
|
|
reactive.IsDirty.Should().BeFalse();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 00:44:41 +02:00
|
|
|
[TestCase(1)]
|
|
|
|
|
[TestCase(100)]
|
|
|
|
|
[TestCase(1000)]
|
|
|
|
|
public async Task ReactiveDocument_SchedulingScalesWithChangedDocumentsOnly(int documentCount)
|
|
|
|
|
{
|
|
|
|
|
var clock = new BlockingCountClock();
|
|
|
|
|
var reactiveDocuments = new List<CultMeshReactiveDocument<MeshNoteDocument>>(documentCount);
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
for (var index = 0; index < documentCount; index++)
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = $"initial-{index}",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
$"mesh.note.reactive.scale.{index}",
|
|
|
|
|
CultMesh.Verse("scale", "test"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
reactiveDocuments.Add(await handle.AuthoritativeWriter().ReactiveAsync(
|
|
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
Clock = clock,
|
|
|
|
|
FlushDelay = TimeSpan.FromSeconds(1)
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clock.DelayCount.Should().Be(0, "idle reactive documents must not schedule background work");
|
|
|
|
|
|
|
|
|
|
var changedDocumentCount = Math.Max(1, documentCount / 100);
|
|
|
|
|
for (var index = 0; index < changedDocumentCount; index++)
|
|
|
|
|
{
|
|
|
|
|
reactiveDocuments[index].Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = $"changed-{index}";
|
|
|
|
|
document.Revision++;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clock.DelayCount.Should().Be(
|
|
|
|
|
changedDocumentCount,
|
|
|
|
|
"flush scheduling must be proportional to explicitly changed documents");
|
|
|
|
|
reactiveDocuments.Count(document => document.IsDirty).Should().Be(changedDocumentCount);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
foreach (var reactiveDocument in reactiveDocuments)
|
|
|
|
|
reactiveDocument.Dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:50:30 +01:00
|
|
|
[Test]
|
2026-08-17 19:47:10 +02:00
|
|
|
public async Task ReactiveDocument_ReplaceLocalFlushesWholeDocument()
|
2026-06-27 19:50:30 +01:00
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var predictions = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.direct-member-flush",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
predictions.Add(value);
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
2026-06-27 19:50:30 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
reactive.ReplaceLocal(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "whole-document-replacement",
|
|
|
|
|
Revision = 4
|
|
|
|
|
});
|
2026-06-27 19:50:30 +01:00
|
|
|
await reactive.FlushAsync();
|
|
|
|
|
|
|
|
|
|
predictions.Should().ContainSingle();
|
2026-08-17 19:47:10 +02:00
|
|
|
predictions[0].Text.Should().Be("whole-document-replacement");
|
2026-06-27 19:50:30 +01:00
|
|
|
predictions[0].Revision.Should().Be(4);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:25:10 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_CleanFlushDoesNotWrite()
|
|
|
|
|
{
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var writes = 0;
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.clean-flush",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => new Subject<MeshNoteDocument>(),
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
writes++;
|
|
|
|
|
current = value;
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.AuthoritativeWriter().ReactiveAsync();
|
2026-06-27 19:25:10 +01:00
|
|
|
|
|
|
|
|
await reactive.FlushAsync();
|
|
|
|
|
|
|
|
|
|
writes.Should().Be(0);
|
|
|
|
|
reactive.IsDirty.Should().BeFalse();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_FlushesAgainWhenEditedDuringInFlightFlush()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var allowFirstWrite = new TaskCompletionSource<bool>();
|
|
|
|
|
var firstWriteStarted = new TaskCompletionSource<bool>();
|
|
|
|
|
var predictions = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.in-flight",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
async value =>
|
|
|
|
|
{
|
|
|
|
|
predictions.Add(value);
|
|
|
|
|
if (predictions.Count == 1)
|
|
|
|
|
{
|
|
|
|
|
firstWriteStarted.SetResult(true);
|
|
|
|
|
await allowFirstWrite.Task.ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
2026-06-27 19:25:10 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "first";
|
|
|
|
|
document.Revision = 2;
|
|
|
|
|
});
|
|
|
|
|
var flush = reactive.FlushAsync();
|
|
|
|
|
await firstWriteStarted.Task;
|
|
|
|
|
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "second";
|
|
|
|
|
document.Revision = 3;
|
|
|
|
|
});
|
|
|
|
|
allowFirstWrite.SetResult(true);
|
|
|
|
|
await flush;
|
|
|
|
|
|
|
|
|
|
predictions.Should().HaveCount(2);
|
|
|
|
|
predictions[0].Text.Should().Be("first");
|
|
|
|
|
predictions[1].Text.Should().Be("second");
|
|
|
|
|
current.Revision.Should().Be(3);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 20:05:56 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_DebouncesBurstUpdatesIntoOnePrediction()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var predictions = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.burst",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
predictions.Add(value);
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
2026-06-27 20:05:56 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMilliseconds(35)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
|
|
|
{
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = $"burst-{i}";
|
|
|
|
|
document.Revision++;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await WaitForAsync(() => predictions.Count == 1);
|
|
|
|
|
|
|
|
|
|
predictions[0].Text.Should().Be("burst-4");
|
|
|
|
|
predictions[0].Revision.Should().Be(6);
|
|
|
|
|
reactive.IsDirty.Should().BeFalse();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
2026-08-17 19:47:10 +02:00
|
|
|
public async Task ReactiveDocument_ExplicitEditsDuringInFlightFlushQueueLatestPrediction()
|
2026-06-27 20:05:56 +01:00
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var allowFirstWrite = new TaskCompletionSource<bool>();
|
|
|
|
|
var firstWriteStarted = new TaskCompletionSource<bool>();
|
|
|
|
|
var predictions = new List<MeshNoteDocument>();
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.direct-in-flight",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
async value =>
|
|
|
|
|
{
|
|
|
|
|
predictions.Add(value);
|
|
|
|
|
if (predictions.Count == 1)
|
|
|
|
|
{
|
|
|
|
|
firstWriteStarted.SetResult(true);
|
|
|
|
|
await allowFirstWrite.Task.ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
2026-06-27 20:05:56 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
2026-08-17 19:47:10 +02:00
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "first-explicit";
|
|
|
|
|
document.Revision = 2;
|
|
|
|
|
});
|
2026-06-27 20:05:56 +01:00
|
|
|
var flush = reactive.FlushAsync();
|
|
|
|
|
await firstWriteStarted.Task;
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "second-explicit";
|
|
|
|
|
document.Revision = 3;
|
|
|
|
|
});
|
2026-06-27 20:05:56 +01:00
|
|
|
allowFirstWrite.SetResult(true);
|
|
|
|
|
await flush;
|
|
|
|
|
|
|
|
|
|
predictions.Should().HaveCount(2);
|
2026-08-17 19:47:10 +02:00
|
|
|
predictions[0].Text.Should().Be("first-explicit");
|
|
|
|
|
predictions[1].Text.Should().Be("second-explicit");
|
2026-06-27 20:05:56 +01:00
|
|
|
reactive.IsDirty.Should().BeFalse();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task ReactiveDocument_CanonicalEchoClearsOutstandingPredictionWithoutDelta()
|
|
|
|
|
{
|
|
|
|
|
var subject = new Subject<MeshNoteDocument>();
|
|
|
|
|
var current = new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "initial",
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
var handle = CultMesh.Document(
|
|
|
|
|
"mesh.note.reactive.canonical-echo",
|
|
|
|
|
CultMesh.Verse("starbridge", "pilot-a"),
|
|
|
|
|
_ => Task.FromResult(current),
|
|
|
|
|
_ => subject,
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
},
|
|
|
|
|
value =>
|
|
|
|
|
{
|
|
|
|
|
current = value;
|
|
|
|
|
subject.OnNext(value);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
using var reactive = await handle.PredictionWriter().ReactiveAsync(
|
2026-06-27 20:05:56 +01:00
|
|
|
new CultMeshReactiveDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
FlushDelay = TimeSpan.FromMinutes(1)
|
|
|
|
|
});
|
|
|
|
|
reactive.Update(document =>
|
|
|
|
|
{
|
|
|
|
|
document.Text = "accepted";
|
|
|
|
|
document.Revision = 2;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await reactive.FlushAsync();
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
reactive.Snapshot.Text.Should().Be("accepted");
|
2026-06-27 20:05:56 +01:00
|
|
|
reactive.Reconciliation.Should().BeNull();
|
|
|
|
|
reactive.IsDirty.Should().BeFalse();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 17:03:10 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task DocumentHandle_ReadsSchemaPublicationsFromSingleFileStores()
|
|
|
|
|
{
|
|
|
|
|
using var directory = new TemporaryDirectory();
|
|
|
|
|
var filePath = Path.Combine(directory.Path, "publication.ccmp");
|
|
|
|
|
var key = new CultRecordKey("mesh-note:publication");
|
|
|
|
|
|
|
|
|
|
var writerCache = new CultCache();
|
|
|
|
|
writerCache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath));
|
|
|
|
|
await writerCache.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
|
|
|
Text = "published",
|
|
|
|
|
Revision = 1
|
|
|
|
|
}, key);
|
|
|
|
|
writerCache.FlushAllBackingStores();
|
|
|
|
|
File.Exists(filePath).Should().BeTrue();
|
|
|
|
|
|
|
|
|
|
var directReadCache = new CultCache();
|
|
|
|
|
directReadCache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath));
|
|
|
|
|
await directReadCache.PullAllBackingStoresAsync();
|
|
|
|
|
directReadCache.Get<MeshPublicationNoteDocument>(key)!.Text.Should().Be("published");
|
|
|
|
|
|
2026-06-27 17:10:52 +01:00
|
|
|
var handle = CultMesh.DocumentFromPublication<MeshPublicationNoteDocument>(
|
|
|
|
|
CultMeshDocumentPublicationSource.SingleFile(filePath),
|
2026-06-27 17:03:10 +01:00
|
|
|
key,
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-pilot"),
|
|
|
|
|
new CultMeshStoreDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentId = "daemon:tests.mesh_note.latest",
|
|
|
|
|
SourceId = "daemon:tests.mesh_note.latest.v1",
|
|
|
|
|
PollInterval = TimeSpan.FromMilliseconds(10)
|
|
|
|
|
});
|
|
|
|
|
var observed = new List<MeshPublicationNoteDocument>();
|
|
|
|
|
using var subscription = handle.Watch(value => observed.Add(value));
|
|
|
|
|
|
|
|
|
|
(await handle.LatestAsync()).Text.Should().Be("published");
|
|
|
|
|
handle.CanReplace.Should().BeFalse();
|
|
|
|
|
handle.CanSubmitPrediction.Should().BeFalse();
|
|
|
|
|
handle.DocumentId.Should().Be("daemon:tests.mesh_note.latest");
|
|
|
|
|
handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:tests.mesh_note.latest.v1");
|
|
|
|
|
|
|
|
|
|
var republisher = new CultCache();
|
|
|
|
|
republisher.AddBackingStore(new SingleFileMessagePackBackingStore(filePath));
|
|
|
|
|
await republisher.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
|
|
|
Text = "republished",
|
|
|
|
|
Revision = 2
|
|
|
|
|
}, key);
|
|
|
|
|
republisher.FlushAllBackingStores();
|
|
|
|
|
|
|
|
|
|
await WaitForAsync(() => observed.Any(value => value.Text == "republished"));
|
|
|
|
|
(await handle.LatestAsync()).Revision.Should().Be(2);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 17:28:15 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task SingleFileDocumentHelpers_RoundTripTypedDocuments()
|
|
|
|
|
{
|
|
|
|
|
using var directory = new TemporaryDirectory();
|
|
|
|
|
var filePath = Path.Combine(directory.Path, "single-document.ccmp");
|
|
|
|
|
var key = new CultRecordKey("mesh-note:single-document");
|
|
|
|
|
|
|
|
|
|
CultMesh.WriteSingleFileDocument(
|
|
|
|
|
filePath,
|
|
|
|
|
key,
|
|
|
|
|
new MeshPublicationNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
|
|
|
Text = "published-directly",
|
|
|
|
|
Revision = 3
|
|
|
|
|
},
|
|
|
|
|
storedAt: "2026-06-27T12:00:00.0000000Z");
|
|
|
|
|
|
|
|
|
|
var document = CultMesh.ReadSingleFileDocument<MeshPublicationNoteDocument>(filePath, key);
|
|
|
|
|
|
|
|
|
|
document.Text.Should().Be("published-directly");
|
|
|
|
|
document.Revision.Should().Be(3);
|
|
|
|
|
|
|
|
|
|
var cache = new CultCache();
|
|
|
|
|
cache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath));
|
|
|
|
|
await cache.PullAllBackingStoresAsync();
|
|
|
|
|
cache.Get<MeshPublicationNoteDocument>(key)!.Text.Should().Be("published-directly");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void SingleFileDocumentHelpers_ReadLegacySingleDocumentSnapshots()
|
|
|
|
|
{
|
|
|
|
|
using var directory = new TemporaryDirectory();
|
|
|
|
|
var filePath = Path.Combine(directory.Path, "legacy-single-document.ccmp");
|
|
|
|
|
var key = new CultRecordKey("mesh-note:legacy-single-document");
|
|
|
|
|
var descriptor = CultDocumentRegistry.Shared.GetRequired<MeshPublicationNoteDocument>();
|
Make generated and reflective document members one rule
The generator and CultDocumentRegistry now discover members by the same
steps: most-derived declaration first, override chain collapsed to one
member, attributes read up the override chain, rejections in one order
with one text (GCC001 / InvalidOperationException).
- Accessors are named per document, so documents sharing a [CultName] or
[CultIndex] base member no longer emit duplicate methods (CS0111).
- Duplicate [Key] slots are refused, including new-hidden fields and
properties; a hidden persisted member with a different slot is refused.
- String [Key("x")] gets its own message instead of "has no [Key]".
- A persisted property whose set accessor is private, protected or init,
or a readonly field, is refused instead of CS0272 in generated code.
- Overrides: MessagePack's DynamicObjectResolver reads the base
declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a
re-keyed override and serializes an override marked [IgnoreMember]).
An override that changes them is refused in both paths; an override
that repeats or omits them is accepted and byte-pinned.
- Typed Serialize<T>/Deserialize<T> used static Options and skipped
generated codecs and declared resolvers; deleted. CultNetDocumentBinding
and CultMeshSnapshots route through the untyped path with their registry.
DocumentShapeSweepTests compiles each shape through the generator and
compares generated vs reflective schema id, catalog, name/index values
and payload against MessagePack's own bytes, or equal rejection text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:00:16 +02:00
|
|
|
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(new MeshPublicationNoteDocument
|
2026-06-27 17:28:15 +01:00
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
|
|
|
Text = "legacy-published",
|
|
|
|
|
Revision = 4
|
Make generated and reflective document members one rule
The generator and CultDocumentRegistry now discover members by the same
steps: most-derived declaration first, override chain collapsed to one
member, attributes read up the override chain, rejections in one order
with one text (GCC001 / InvalidOperationException).
- Accessors are named per document, so documents sharing a [CultName] or
[CultIndex] base member no longer emit duplicate methods (CS0111).
- Duplicate [Key] slots are refused, including new-hidden fields and
properties; a hidden persisted member with a different slot is refused.
- String [Key("x")] gets its own message instead of "has no [Key]".
- A persisted property whose set accessor is private, protected or init,
or a readonly field, is refused instead of CS0272 in generated code.
- Overrides: MessagePack's DynamicObjectResolver reads the base
declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a
re-keyed override and serializes an override marked [IgnoreMember]).
An override that changes them is refused in both paths; an override
that repeats or omits them is accepted and byte-pinned.
- Typed Serialize<T>/Deserialize<T> used static Options and skipped
generated codecs and declared resolvers; deleted. CultNetDocumentBinding
and CultMeshSnapshots route through the untyped path with their registry.
DocumentShapeSweepTests compiles each shape through the generator and
compares generated vs reflective schema id, catalog, name/index values
and payload against MessagePack's own bytes, or equal rejection text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:00:16 +02:00
|
|
|
}, typeof(MeshPublicationNoteDocument));
|
2026-06-27 17:28:15 +01:00
|
|
|
|
|
|
|
|
File.WriteAllBytes(filePath, WriteLegacySingleDocumentSnapshot(
|
|
|
|
|
key.Value,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
descriptor.SchemaName,
|
|
|
|
|
descriptor.SchemaVersion,
|
|
|
|
|
"2026-06-27T12:00:00.0000000Z",
|
|
|
|
|
payload));
|
|
|
|
|
|
|
|
|
|
var document = CultMesh.ReadSingleFileDocument<MeshPublicationNoteDocument>(filePath, key);
|
|
|
|
|
|
|
|
|
|
document.Text.Should().Be("legacy-published");
|
|
|
|
|
document.Revision.Should().Be(4);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 16:37:44 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments()
|
|
|
|
|
{
|
|
|
|
|
var cache = new CultCache();
|
|
|
|
|
var key = new CultRecordKey("mesh-note:remote");
|
|
|
|
|
await cache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "remote-snapshot",
|
|
|
|
|
Revision = 9
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared)
|
|
|
|
|
.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(CultDocumentRegistry.Shared));
|
|
|
|
|
var requests = new List<CultNetSnapshotRequestMessage>();
|
|
|
|
|
|
2026-06-27 22:53:45 +01:00
|
|
|
var handle = CultMesh.DocumentFromPublication<MeshNoteAliasDocument>(
|
2026-06-27 17:10:52 +01:00
|
|
|
CultMeshDocumentPublicationSource.PeerSnapshot(
|
|
|
|
|
() => new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
{
|
|
|
|
|
requests.Add(request);
|
|
|
|
|
return registry.CreateRawSnapshotResponse(cache, request.MessageId, request);
|
|
|
|
|
}),
|
|
|
|
|
"cultnet://snapshot.test:3075"),
|
|
|
|
|
key,
|
2026-06-27 16:37:44 +01:00
|
|
|
CultMesh.Verse("starbridge", "unity-pilot"),
|
2026-06-27 17:10:52 +01:00
|
|
|
peerOptions: new CultMeshPeerSnapshotDocumentOptions
|
2026-06-27 16:37:44 +01:00
|
|
|
{
|
|
|
|
|
PollInterval = TimeSpan.FromMilliseconds(10),
|
|
|
|
|
MessageIdPrefix = "mesh-test-snapshot"
|
|
|
|
|
});
|
|
|
|
|
var catalog = CultMesh.Documents(handle);
|
|
|
|
|
|
2026-06-27 22:53:45 +01:00
|
|
|
var snapshot = await handle.LatestAsync();
|
2026-06-27 16:37:44 +01:00
|
|
|
|
|
|
|
|
snapshot.Text.Should().Be("remote-snapshot");
|
|
|
|
|
snapshot.Revision.Should().Be(9);
|
2026-06-27 22:53:45 +01:00
|
|
|
handle.DocumentType.Should().Be(typeof(MeshNoteAliasDocument));
|
2026-06-27 16:37:44 +01:00
|
|
|
handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network);
|
|
|
|
|
handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be(key.Value);
|
|
|
|
|
requests.Should().ContainSingle();
|
2026-06-27 17:45:18 +01:00
|
|
|
requests[0].SchemaIds.Should().ContainSingle().Which
|
|
|
|
|
.Should().Be(CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId);
|
2026-06-27 16:37:44 +01:00
|
|
|
requests[0].RecordKeys.Should().ContainSingle().Which.Should().Be(key.Value);
|
|
|
|
|
(await catalog.LatestAsync<MeshNoteAliasDocument>()).Text.Should().Be("remote-snapshot");
|
2026-06-27 16:40:42 +01:00
|
|
|
|
2026-06-27 22:53:45 +01:00
|
|
|
var aliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot<MeshNoteAliasDocument>(
|
2026-06-27 16:40:42 +01:00
|
|
|
_ => Task.FromResult(new CultNetSnapshotResponseRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = "alias-schema",
|
|
|
|
|
Documents = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetRawDocumentRecord
|
|
|
|
|
{
|
|
|
|
|
SchemaId = "remote.generated.mesh_note_alias.v99",
|
|
|
|
|
RecordKey = key.Value,
|
|
|
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
|
|
|
PayloadEncoding = "messagepack",
|
|
|
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
|
|
|
new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "record-key-fallback",
|
|
|
|
|
Revision = 10
|
|
|
|
|
},
|
|
|
|
|
typeof(MeshNoteDocument))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
key.Value,
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-pilot"));
|
|
|
|
|
|
|
|
|
|
(await aliasSchemaHandle.LatestAsync()).Text.Should().Be("record-key-fallback");
|
2026-06-27 22:25:15 +01:00
|
|
|
|
2026-06-27 22:53:45 +01:00
|
|
|
var mixedAliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot<MeshNoteAliasDocument>(
|
2026-06-27 22:25:15 +01:00
|
|
|
_ => Task.FromResult(new CultNetSnapshotResponseRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = "alias-schema-with-neighbor",
|
|
|
|
|
Documents = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetRawDocumentRecord
|
|
|
|
|
{
|
|
|
|
|
SchemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId,
|
|
|
|
|
RecordKey = "mesh-note:other",
|
|
|
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
|
|
|
PayloadEncoding = "messagepack",
|
|
|
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
|
|
|
new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "wrong-schema-neighbor",
|
|
|
|
|
Revision = 11
|
|
|
|
|
},
|
|
|
|
|
typeof(MeshNoteDocument))
|
|
|
|
|
},
|
|
|
|
|
new CultNetRawDocumentRecord
|
|
|
|
|
{
|
|
|
|
|
SchemaId = "remote.generated.mesh_note_alias.v100",
|
|
|
|
|
RecordKey = key.Value,
|
|
|
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
|
|
|
PayloadEncoding = "messagepack",
|
|
|
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
|
|
|
new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "compatible-record-key",
|
|
|
|
|
Revision = 12
|
|
|
|
|
},
|
|
|
|
|
typeof(MeshNoteDocument))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
key.Value,
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-pilot"));
|
|
|
|
|
|
|
|
|
|
(await mixedAliasSchemaHandle.LatestAsync()).Text.Should().Be("compatible-record-key");
|
2026-06-27 16:37:44 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-27 23:48:53 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task PublicationHelpers_BindConfiguredDocumentsIntoCatalog()
|
|
|
|
|
{
|
|
|
|
|
using var directory = new TemporaryDirectory();
|
|
|
|
|
var firstPath = Path.Combine(directory.Path, "first.ccmp");
|
|
|
|
|
var secondPath = Path.Combine(directory.Path, "second.ccmp");
|
|
|
|
|
var firstKey = new CultRecordKey("mesh-note:publication-catalog-first");
|
|
|
|
|
var secondKey = new CultRecordKey("mesh-note:publication-catalog-second");
|
|
|
|
|
var firstWriter = new CultCache();
|
|
|
|
|
firstWriter.AddBackingStore(new SingleFileMessagePackBackingStore(firstPath));
|
|
|
|
|
await firstWriter.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
|
|
|
Text = "first source",
|
|
|
|
|
Revision = 21
|
|
|
|
|
}, firstKey);
|
|
|
|
|
firstWriter.FlushAllBackingStores();
|
|
|
|
|
var secondWriter = new CultCache();
|
|
|
|
|
secondWriter.AddBackingStore(new SingleFileMessagePackBackingStore(secondPath));
|
|
|
|
|
await secondWriter.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "second source",
|
|
|
|
|
Revision = 22
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(secondKey));
|
|
|
|
|
secondWriter.FlushAllBackingStores();
|
|
|
|
|
|
|
|
|
|
var catalog = CultMesh.DocumentsFromPublication(
|
|
|
|
|
CultMeshDocumentPublicationSource.SingleFile(firstPath),
|
|
|
|
|
new ICultMeshPublicationDocumentBinding[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.PublicationDocument<MeshPublicationNoteDocument>(
|
|
|
|
|
firstKey,
|
|
|
|
|
documentId: "daemon:first"),
|
|
|
|
|
CultMesh.PublicationDocument<MeshNoteAliasDocument>(
|
|
|
|
|
secondKey,
|
|
|
|
|
documentId: "daemon:second",
|
|
|
|
|
source: CultMeshDocumentPublicationSource.SingleFile(secondPath))
|
|
|
|
|
},
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-catalog"),
|
|
|
|
|
new CultMeshStoreDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
RouteHint = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "publication catalog"),
|
|
|
|
|
PollInterval = TimeSpan.FromMilliseconds(10)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
catalog.Documents.Select(document => document.DocumentId).Should().Equal("daemon:first", "daemon:second");
|
|
|
|
|
(await catalog.LatestAsync<MeshPublicationNoteDocument>()).Text.Should().Be("first source");
|
|
|
|
|
(await catalog.LatestAsync<MeshNoteAliasDocument>()).Text.Should().Be("second source");
|
|
|
|
|
catalog.Document<MeshNoteDocument>().DocumentId.Should().Be("daemon:second");
|
|
|
|
|
catalog.Document<MeshPublicationNoteDocument>().RouteHint.Description.Should().Be("publication catalog");
|
|
|
|
|
catalog.Document<MeshNoteAliasDocument>().Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:second");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 23:53:35 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task PublicationHelpers_SyncConfiguredDocumentsIntoLocalCatalog()
|
|
|
|
|
{
|
|
|
|
|
using var directory = new TemporaryDirectory();
|
|
|
|
|
var firstPath = Path.Combine(directory.Path, "sync-first.ccmp");
|
|
|
|
|
var secondPath = Path.Combine(directory.Path, "sync-second.ccmp");
|
|
|
|
|
var firstKey = new CultRecordKey("mesh-note:publication-sync-catalog-first");
|
|
|
|
|
var secondKey = new CultRecordKey("mesh-note:publication-sync-catalog-second");
|
|
|
|
|
var firstWriter = new CultCache();
|
|
|
|
|
firstWriter.AddBackingStore(new SingleFileMessagePackBackingStore(firstPath));
|
|
|
|
|
await firstWriter.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
|
|
|
Text = "first synced source",
|
|
|
|
|
Revision = 31
|
|
|
|
|
}, firstKey);
|
|
|
|
|
firstWriter.FlushAllBackingStores();
|
|
|
|
|
var secondWriter = new CultCache();
|
|
|
|
|
secondWriter.AddBackingStore(new SingleFileMessagePackBackingStore(secondPath));
|
|
|
|
|
await secondWriter.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "second synced source",
|
|
|
|
|
Revision = 32
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(secondKey));
|
|
|
|
|
secondWriter.FlushAllBackingStores();
|
|
|
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(directory.Path, "target.ccmp"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
StartServer = false
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var catalog = await CultMesh.SyncDocumentsFromPublicationAsync(
|
|
|
|
|
node,
|
|
|
|
|
CultMeshDocumentPublicationSource.SingleFile(firstPath),
|
|
|
|
|
new ICultMeshPublicationDocumentBinding[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.PublicationDocument<MeshPublicationNoteDocument>(
|
|
|
|
|
firstKey,
|
|
|
|
|
documentId: "local:first"),
|
|
|
|
|
CultMesh.PublicationDocument<MeshNoteAliasDocument>(
|
|
|
|
|
secondKey,
|
|
|
|
|
documentId: "local:second",
|
|
|
|
|
source: CultMeshDocumentPublicationSource.SingleFile(secondPath))
|
|
|
|
|
},
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-sync-catalog"),
|
|
|
|
|
new CultMeshStoreDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
PollInterval = TimeSpan.FromMilliseconds(10)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
catalog.Documents.Select(document => document.DocumentId).Should().Equal("local:first", "local:second");
|
|
|
|
|
(await catalog.LatestAsync<MeshPublicationNoteDocument>()).Text.Should().Be("first synced source");
|
|
|
|
|
(await catalog.LatestAsync<MeshNoteAliasDocument>()).Text.Should().Be("second synced source");
|
|
|
|
|
node.Cache.Get<MeshPublicationNoteDocument>(firstKey)!.Revision.Should().Be(31);
|
|
|
|
|
(await node.Document<MeshNoteDocument>(secondKey, CultMesh.Verse("starbridge", "unity-sync-catalog"))
|
|
|
|
|
.LatestAsync()).Text.Should().Be("second synced source");
|
|
|
|
|
(await node.Document<MeshNoteAliasDocument>(secondKey, CultMesh.Verse("starbridge", "unity-sync-catalog"))
|
|
|
|
|
.LatestAsync()).Revision.Should().Be(32);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 23:37:15 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task PublicationHelpers_SyncConfiguredPeerSourceIntoLocalNodeAlias()
|
|
|
|
|
{
|
|
|
|
|
var sourceCache = new CultCache();
|
|
|
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
|
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
|
|
|
var key = new CultRecordKey("mesh-note:publication-sync-alias");
|
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "publication-synced",
|
|
|
|
|
Revision = 51
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
var requests = new List<CultNetSnapshotRequestMessage>();
|
|
|
|
|
var verse = CultMesh.Verse("starbridge", "unity-support");
|
|
|
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(Path.GetTempPath(), $"cultmesh-publication-sync-alias-{Guid.NewGuid():N}.ccmp"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
StartServer = false,
|
|
|
|
|
CacheOptions = new CultCacheOpenOptions
|
|
|
|
|
{
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
Registry = sourceCache.Registry
|
2026-06-27 23:37:15 +01:00
|
|
|
},
|
|
|
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentRegistry = registry
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var synced = await CultMesh.SyncDocumentFromPublicationAsync<MeshNoteAliasDocument>(
|
|
|
|
|
node,
|
|
|
|
|
CultMeshDocumentPublicationSource.PeerSnapshot(
|
|
|
|
|
() => new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
{
|
|
|
|
|
requests.Add(request);
|
|
|
|
|
return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request);
|
|
|
|
|
}),
|
|
|
|
|
"cultnet://publication-sync.test:3075"),
|
|
|
|
|
key,
|
|
|
|
|
verse,
|
|
|
|
|
peerOptions: new CultMeshPeerSnapshotDocumentOptions
|
|
|
|
|
{
|
|
|
|
|
MessageIdPrefix = "mesh-test-publication-sync",
|
|
|
|
|
PollInterval = TimeSpan.FromMilliseconds(10)
|
|
|
|
|
});
|
|
|
|
|
var localAlias = await node.Document<MeshNoteAliasDocument>(key, verse).LatestAsync();
|
|
|
|
|
|
|
|
|
|
synced.Text.Should().Be("publication-synced");
|
|
|
|
|
synced.Revision.Should().Be(51);
|
|
|
|
|
localAlias.Text.Should().Be("publication-synced");
|
|
|
|
|
localAlias.Revision.Should().Be(51);
|
|
|
|
|
requests.Should().ContainSingle().Which.RecordKeys.Should().Equal(key.Value);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 17:36:35 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task SnapshotHelpers_FetchApplyAndDecodeScopedSnapshots()
|
|
|
|
|
{
|
|
|
|
|
var sourceCache = new CultCache();
|
|
|
|
|
var targetCache = new CultCache();
|
|
|
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
|
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshPublicationNoteDocument>(sourceCache.Registry));
|
|
|
|
|
var key = new CultRecordKey("mesh-note:snapshot-helper");
|
|
|
|
|
await sourceCache.UpsertAsync(new MeshPublicationNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_publication_note.v1",
|
|
|
|
|
Text = "snapshot-helper",
|
|
|
|
|
Revision = 11
|
|
|
|
|
}, new CultRecordHandle<MeshPublicationNoteDocument>(key));
|
|
|
|
|
var requests = new List<CultNetSnapshotRequestMessage>();
|
|
|
|
|
var endpoint = "cultnet://snapshot-helper.test:3075";
|
|
|
|
|
var options = new CultMeshSnapshotRequestOptions
|
|
|
|
|
{
|
|
|
|
|
RecordKeys = new[] { key.Value },
|
|
|
|
|
ShardId = "primary",
|
|
|
|
|
ShardEpoch = 7,
|
|
|
|
|
MessageIdPrefix = "mesh-test-scoped-snapshot",
|
|
|
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
{
|
|
|
|
|
requests.Add(request);
|
|
|
|
|
return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request);
|
|
|
|
|
})
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var snapshot = await CultMesh.FetchSnapshotAsync(endpoint, options);
|
|
|
|
|
var decoded = await CultMesh.FetchSnapshotDocumentsAsync<MeshPublicationNoteDocument>(
|
|
|
|
|
endpoint,
|
|
|
|
|
options,
|
|
|
|
|
registry);
|
|
|
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(Path.GetTempPath(), $"cultmesh-snapshot-helper-{Guid.NewGuid():N}.ccmp"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
StartServer = false,
|
|
|
|
|
CacheOptions = new CultCacheOpenOptions
|
|
|
|
|
{
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
Registry = targetCache.Registry
|
2026-06-27 17:36:35 +01:00
|
|
|
},
|
|
|
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentRegistry = registry
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
var applied = await CultMesh.ApplySnapshotAsync(node, endpoint, options);
|
|
|
|
|
|
|
|
|
|
snapshot.Documents.Should().ContainSingle().Which.RecordKey.Should().Be(key.Value);
|
|
|
|
|
decoded.Should().ContainSingle().Which.Text.Should().Be("snapshot-helper");
|
|
|
|
|
applied.Should().ContainSingle();
|
|
|
|
|
node.Cache.Get<MeshPublicationNoteDocument>(key)!.Revision.Should().Be(11);
|
|
|
|
|
requests.Should().HaveCount(3);
|
|
|
|
|
requests.Should().OnlyContain(request =>
|
|
|
|
|
request.RecordKeys!.SequenceEqual(new[] { key.Value }) &&
|
|
|
|
|
request.ShardId == "primary" &&
|
|
|
|
|
request.ShardEpoch == 7 &&
|
|
|
|
|
request.MessageId.StartsWith("mesh-test-scoped-snapshot:", StringComparison.Ordinal));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 10:33:02 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task SnapshotHelpers_SurfaceBackgroundClientFailureBeforeTimeout()
|
|
|
|
|
{
|
|
|
|
|
var options = new CultMeshSnapshotRequestOptions
|
|
|
|
|
{
|
|
|
|
|
ResponseTimeout = TimeSpan.FromSeconds(30),
|
|
|
|
|
RecordKeys = new[] { "eve:surface:test" },
|
|
|
|
|
CreateClient = () => new FailedBackgroundSchemaClient(
|
|
|
|
|
new FormatException("malformed fragment"))
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Func<Task> act = async () => await CultMesh.FetchSnapshotAsync(
|
|
|
|
|
"rudp://127.0.0.1:3075",
|
|
|
|
|
options);
|
|
|
|
|
|
|
|
|
|
var failure = await act.Should().ThrowAsync<InvalidOperationException>();
|
|
|
|
|
failure.Which.InnerException.Should().BeOfType<FormatException>();
|
|
|
|
|
failure.Which.Message.Should().Contain("127.0.0.1:3075");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 10:38:45 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task SnapshotHelpers_SurfaceBackgroundClientFailureWhileConnecting()
|
|
|
|
|
{
|
|
|
|
|
var options = new CultMeshSnapshotRequestOptions
|
|
|
|
|
{
|
|
|
|
|
ConnectTimeout = TimeSpan.FromSeconds(30),
|
|
|
|
|
CreateClient = () => new FailedBackgroundSchemaClient(
|
|
|
|
|
new FormatException("malformed handshake"),
|
|
|
|
|
connected: false)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Func<Task> act = async () => await CultMesh.FetchSnapshotAsync(
|
|
|
|
|
"rudp://127.0.0.1:3075",
|
|
|
|
|
options);
|
|
|
|
|
|
|
|
|
|
var failure = await act.Should().ThrowAsync<InvalidOperationException>();
|
|
|
|
|
failure.Which.InnerException.Should().BeOfType<FormatException>();
|
|
|
|
|
failure.Which.Message.Should().Contain("connecting");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 21:00:39 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task SnapshotHelpers_DecodeSameSchemaAliasFromForeignSchemaId()
|
|
|
|
|
{
|
|
|
|
|
var key = new CultRecordKey("mesh-note:snapshot-alias-foreign-schema");
|
|
|
|
|
var snapshot = new CultNetSnapshotResponseRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = "foreign-schema",
|
|
|
|
|
Documents = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetRawDocumentRecord
|
|
|
|
|
{
|
|
|
|
|
SchemaId = "runtime.generated.mesh-note.ui.42",
|
|
|
|
|
RecordKey = key.Value,
|
|
|
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
|
|
|
PayloadEncoding = "messagepack",
|
|
|
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
|
|
|
new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "foreign-schema-alias",
|
|
|
|
|
Revision = 44
|
|
|
|
|
},
|
|
|
|
|
typeof(MeshNoteDocument))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var documents = await CultMesh.FetchSnapshotDocumentsAsync<MeshNoteAliasDocument>(
|
|
|
|
|
"cultnet://foreign-schema.test:3075",
|
|
|
|
|
new CultMeshSnapshotRequestOptions
|
|
|
|
|
{
|
|
|
|
|
RecordKeys = new[] { key.Value },
|
|
|
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
{
|
|
|
|
|
snapshot.MessageId = request.MessageId;
|
|
|
|
|
return snapshot;
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
new CultNetDocumentRegistry());
|
|
|
|
|
|
|
|
|
|
documents.Should().ContainSingle().Which.Text.Should().Be("foreign-schema-alias");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 21:12:05 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task SnapshotEndpoint_SyncsCompatibleForeignSchemaIdIntoLocalCache()
|
|
|
|
|
{
|
|
|
|
|
var key = new CultRecordKey("mesh-note:snapshot-sync-foreign-schema");
|
|
|
|
|
var snapshot = new CultNetSnapshotResponseRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = "foreign-schema-sync",
|
|
|
|
|
Documents = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetRawDocumentRecord
|
|
|
|
|
{
|
|
|
|
|
SchemaId = "runtime.generated.mesh-note.ui.84",
|
|
|
|
|
RecordKey = key.Value,
|
|
|
|
|
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
|
|
|
|
PayloadEncoding = "messagepack",
|
|
|
|
|
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
|
|
|
|
new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "foreign-schema-synced",
|
|
|
|
|
Revision = 45
|
|
|
|
|
},
|
|
|
|
|
typeof(MeshNoteDocument))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(
|
|
|
|
|
typeof(MeshNoteDocument),
|
|
|
|
|
typeof(MeshNoteAliasDocument));
|
|
|
|
|
var registry = CultMesh.CreateCultNetDocumentRegistry(
|
|
|
|
|
new[] { typeof(MeshNoteDocument), typeof(MeshNoteAliasDocument) },
|
|
|
|
|
cacheRegistry);
|
|
|
|
|
var endpoint = CultMesh.SnapshotEndpoint(
|
|
|
|
|
"cultnet://foreign-schema.test:3075",
|
|
|
|
|
new CultMeshSnapshotEndpointOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentRegistry = registry,
|
|
|
|
|
Request = new CultMeshSnapshotRequestOptions
|
|
|
|
|
{
|
|
|
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
{
|
|
|
|
|
snapshot.MessageId = request.MessageId;
|
|
|
|
|
return snapshot;
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(Path.GetTempPath(), $"cultmesh-foreign-schema-sync-{Guid.NewGuid():N}.ccmp"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
StartServer = false,
|
|
|
|
|
CacheOptions = new CultCacheOpenOptions
|
|
|
|
|
{
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
Registry = cacheRegistry
|
2026-06-27 21:12:05 +01:00
|
|
|
},
|
|
|
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentRegistry = registry
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var synced = await endpoint.SyncDocumentAsync<MeshNoteAliasDocument>(node, key.Value);
|
|
|
|
|
|
|
|
|
|
synced.Text.Should().Be("foreign-schema-synced");
|
|
|
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("foreign-schema-synced");
|
|
|
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Revision.Should().Be(45);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 23:18:48 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task SnapshotFacade_SyncsRemoteDocumentIntoLocalNodeWithSameSchemaAlias()
|
|
|
|
|
{
|
|
|
|
|
var sourceCache = new CultCache();
|
|
|
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
|
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
|
|
|
var key = new CultRecordKey("mesh-note:facade-sync-alias");
|
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "facade-synced",
|
|
|
|
|
Revision = 46
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
var requests = new List<CultNetSnapshotRequestMessage>();
|
|
|
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(Path.GetTempPath(), $"cultmesh-facade-sync-alias-{Guid.NewGuid():N}.ccmp"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
StartServer = false,
|
|
|
|
|
CacheOptions = new CultCacheOpenOptions
|
|
|
|
|
{
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
Registry = sourceCache.Registry
|
2026-06-27 23:18:48 +01:00
|
|
|
},
|
|
|
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentRegistry = registry
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var synced = await CultMesh.SyncDocumentFromPeerSnapshotAsync<MeshNoteAliasDocument>(
|
|
|
|
|
node,
|
|
|
|
|
() => new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
{
|
|
|
|
|
requests.Add(request);
|
|
|
|
|
return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request);
|
|
|
|
|
}),
|
|
|
|
|
"cultnet://facade-sync.test:3075",
|
|
|
|
|
key.Value,
|
|
|
|
|
new CultMeshSnapshotEndpointOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentRegistry = registry,
|
|
|
|
|
Context = CultMesh.Verse("starbridge", "unity-raven").Context
|
|
|
|
|
});
|
|
|
|
|
var localAlias = await node.Document<MeshNoteAliasDocument>(key.Value, CultMesh.Verse("starbridge", "unity-raven"))
|
|
|
|
|
.LatestAsync();
|
|
|
|
|
|
|
|
|
|
synced.Text.Should().Be("facade-synced");
|
|
|
|
|
synced.Revision.Should().Be(46);
|
|
|
|
|
localAlias.Text.Should().Be("facade-synced");
|
|
|
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Revision.Should().Be(46);
|
|
|
|
|
requests.Should().ContainSingle().Which.RecordKeys.Should().Equal(key.Value);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 06:37:10 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task SnapshotSession_ReusesOneConnectedClientAcrossOrderedRequests()
|
|
|
|
|
{
|
|
|
|
|
var sourceCache = new CultCache();
|
|
|
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
|
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
|
|
|
var firstKey = new CultRecordKey("mesh-note:session-first");
|
|
|
|
|
var secondKey = new CultRecordKey("mesh-note:session-second");
|
|
|
|
|
await sourceCache.UpsertAsync(
|
|
|
|
|
new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "first", Revision = 1 },
|
|
|
|
|
new CultRecordHandle<MeshNoteDocument>(firstKey));
|
|
|
|
|
await sourceCache.UpsertAsync(
|
|
|
|
|
new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "second", Revision = 2 },
|
|
|
|
|
new CultRecordHandle<MeshNoteDocument>(secondKey));
|
|
|
|
|
var client = new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request));
|
|
|
|
|
|
|
|
|
|
using var session = CultMesh.SnapshotSession(
|
|
|
|
|
"cultnet://session.test:3075",
|
|
|
|
|
new CultMeshSnapshotRequestOptions { CreateClient = () => client },
|
|
|
|
|
registry);
|
|
|
|
|
var first = await session.FetchDocumentsAsync<MeshNoteDocument>(new[] { firstKey.Value });
|
|
|
|
|
var second = await session.FetchDocumentsAsync<MeshNoteDocument>(new[] { secondKey.Value });
|
|
|
|
|
|
|
|
|
|
client.ConnectCount.Should().Be(1);
|
|
|
|
|
first.Should().ContainSingle().Which.Text.Should().Be("first");
|
|
|
|
|
second.Should().ContainSingle().Which.Text.Should().Be("second");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 18:01:12 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task DocumentRegistryHelpers_CreateTypedNetworkRegistriesForAliases()
|
|
|
|
|
{
|
|
|
|
|
var sourceCache = new CultCache();
|
|
|
|
|
var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry(
|
|
|
|
|
typeof(MeshNoteDocument),
|
|
|
|
|
typeof(MeshNoteAliasDocument));
|
|
|
|
|
var networkRegistry = CultMesh.CreateCultNetDocumentRegistry(
|
|
|
|
|
new[] { typeof(MeshNoteDocument), typeof(MeshNoteAliasDocument) },
|
|
|
|
|
cacheRegistry);
|
|
|
|
|
var key = new CultRecordKey("mesh-note:registry-helper");
|
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "registry-helper",
|
|
|
|
|
Revision = 23
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
var endpoint = "cultnet://registry-helper.test:3077";
|
|
|
|
|
|
|
|
|
|
var documents = await CultMesh.FetchSnapshotDocumentsAsync<MeshNoteAliasDocument>(
|
|
|
|
|
endpoint,
|
|
|
|
|
new CultMeshSnapshotRequestOptions
|
|
|
|
|
{
|
|
|
|
|
RecordKeys = new[] { key.Value },
|
|
|
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
networkRegistry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request))
|
|
|
|
|
},
|
|
|
|
|
networkRegistry);
|
|
|
|
|
|
|
|
|
|
var schemaId = cacheRegistry.GetRequired<MeshNoteDocument>().SchemaId;
|
|
|
|
|
cacheRegistry.GetRequired<MeshNoteAliasDocument>().SchemaId.Should().Be(schemaId);
|
|
|
|
|
networkRegistry.GetByDocumentType(typeof(MeshNoteDocument)).Should().NotBeNull();
|
|
|
|
|
networkRegistry.GetByDocumentType(typeof(MeshNoteAliasDocument)).Should().NotBeNull();
|
|
|
|
|
networkRegistry.GetBySchemaId(schemaId)!.DocumentType.Should().Be(typeof(MeshNoteDocument));
|
|
|
|
|
documents.Should().ContainSingle().Which.Text.Should().Be("registry-helper");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 17:45:18 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task SnapshotEndpoint_ProvidesTypedHandlesAndSchemaAliases()
|
|
|
|
|
{
|
|
|
|
|
var sourceCache = new CultCache();
|
|
|
|
|
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared);
|
|
|
|
|
registry.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(sourceCache.Registry));
|
|
|
|
|
var key = new CultRecordKey("mesh-note:snapshot-endpoint");
|
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "snapshot-endpoint",
|
|
|
|
|
Revision = 19
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
|
|
|
|
var requests = new List<CultNetSnapshotRequestMessage>();
|
|
|
|
|
var endpoint = "cultnet://snapshot-endpoint.test:3076";
|
|
|
|
|
var surface = CultMesh.SnapshotEndpoint(
|
|
|
|
|
endpoint,
|
|
|
|
|
new CultMeshSnapshotEndpointOptions
|
|
|
|
|
{
|
|
|
|
|
Context = CultMesh.Verse("starbridge", "browser-starfire").Context,
|
|
|
|
|
DocumentRegistry = registry,
|
|
|
|
|
Request = new CultMeshSnapshotRequestOptions
|
|
|
|
|
{
|
|
|
|
|
ShardId = "primary",
|
|
|
|
|
ShardEpoch = 9,
|
|
|
|
|
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
|
|
|
|
{
|
|
|
|
|
requests.Add(request);
|
|
|
|
|
return registry.CreateRawSnapshotResponse(sourceCache, request.MessageId, request);
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var fetchedAlias = await surface.FetchDocumentAsync<MeshNoteAliasDocument>(key.Value);
|
2026-06-27 17:50:02 +01:00
|
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(Path.GetTempPath(), $"cultmesh-snapshot-endpoint-{Guid.NewGuid():N}.ccmp"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
StartServer = false,
|
|
|
|
|
CacheOptions = new CultCacheOpenOptions
|
|
|
|
|
{
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
Registry = sourceCache.Registry
|
2026-06-27 17:50:02 +01:00
|
|
|
},
|
|
|
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
DocumentRegistry = registry
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
var syncedAlias = await surface.SyncDocumentAsync<MeshNoteAliasDocument>(node, key.Value);
|
2026-06-27 17:45:18 +01:00
|
|
|
var aliasHandle = surface.Document<MeshNoteAliasDocument>(key.Value);
|
|
|
|
|
var aliasLatest = await aliasHandle.LatestAsync();
|
2026-06-27 18:30:17 +01:00
|
|
|
var catalog = surface.Documents(
|
|
|
|
|
CultMesh.SnapshotDocument<MeshNoteDocument>(key.Value, "daemon:mesh-note:snapshot-endpoint"));
|
2026-06-27 17:45:18 +01:00
|
|
|
var catalogAlias = await catalog.LatestAsync<MeshNoteAliasDocument>();
|
2026-06-27 18:34:45 +01:00
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "snapshot-endpoint-synced",
|
|
|
|
|
Revision = 20
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
2026-06-27 18:37:56 +01:00
|
|
|
var syncedEndpoint = surface.SyncTo(node);
|
|
|
|
|
var syncedHandle = syncedEndpoint.Document<MeshNoteAliasDocument>(key.Value);
|
2026-06-27 18:34:45 +01:00
|
|
|
var syncedLatest = await syncedHandle.LatestAsync();
|
|
|
|
|
await sourceCache.UpsertAsync(new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "snapshot-endpoint-catalog-synced",
|
|
|
|
|
Revision = 21
|
|
|
|
|
}, new CultRecordHandle<MeshNoteDocument>(key));
|
2026-06-27 18:37:56 +01:00
|
|
|
var syncedCatalog = syncedEndpoint.Documents(
|
2026-06-27 18:34:45 +01:00
|
|
|
CultMesh.SnapshotDocument<MeshNoteDocument>(key.Value));
|
|
|
|
|
var syncedCatalogAlias = await syncedCatalog.LatestAsync<MeshNoteAliasDocument>();
|
2026-06-27 17:45:18 +01:00
|
|
|
|
|
|
|
|
fetchedAlias.Text.Should().Be("snapshot-endpoint");
|
2026-06-27 17:50:02 +01:00
|
|
|
syncedAlias.Text.Should().Be("snapshot-endpoint");
|
2026-06-27 17:45:18 +01:00
|
|
|
aliasLatest.Revision.Should().Be(19);
|
2026-06-27 18:30:17 +01:00
|
|
|
catalog.Document<MeshNoteDocument>().DocumentId.Should().Be("daemon:mesh-note:snapshot-endpoint");
|
2026-06-27 17:45:18 +01:00
|
|
|
catalogAlias.Text.Should().Be("snapshot-endpoint");
|
2026-06-27 18:34:45 +01:00
|
|
|
syncedLatest.Text.Should().Be("snapshot-endpoint-synced");
|
|
|
|
|
node.Cache.Get<MeshNoteDocument>(key)!.Revision.Should().Be(21);
|
|
|
|
|
syncedCatalogAlias.Text.Should().Be("snapshot-endpoint-catalog-synced");
|
|
|
|
|
requests.Should().HaveCount(6);
|
2026-06-27 17:45:18 +01:00
|
|
|
requests.Should().OnlyContain(request =>
|
2026-06-27 21:03:35 +01:00
|
|
|
request.SchemaIds == null &&
|
2026-06-27 17:45:18 +01:00
|
|
|
request.RecordKeys!.SequenceEqual(new[] { key.Value }) &&
|
|
|
|
|
request.ShardId == "primary" &&
|
|
|
|
|
request.ShardEpoch == 9 &&
|
|
|
|
|
request.MessageId.StartsWith("cultmesh:browser-starfire:snapshot:", StringComparison.Ordinal));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 15:09:36 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task CollectionHandle_ReadsAndWatchesDatabaseCollectionsByIndex()
|
|
|
|
|
{
|
|
|
|
|
var cache = new CultCache();
|
|
|
|
|
var database = new CultNetDatabase(cache);
|
|
|
|
|
var teamId = Guid.NewGuid().ToString("D");
|
|
|
|
|
var collection = CultMesh.CollectionByIndex<MeshIndexedPlayer>(
|
|
|
|
|
database,
|
|
|
|
|
"TeamId",
|
|
|
|
|
teamId);
|
|
|
|
|
var changes = new List<CultMeshCollectionChange<MeshIndexedPlayer>>();
|
|
|
|
|
using var subscription = collection.WatchChanges(change => changes.Add(change));
|
|
|
|
|
|
|
|
|
|
await database.PutAsync(
|
|
|
|
|
new CultRecordKey("player:one"),
|
|
|
|
|
new MeshIndexedPlayer
|
|
|
|
|
{
|
|
|
|
|
Name = "One",
|
|
|
|
|
TeamId = teamId,
|
|
|
|
|
Score = 10
|
|
|
|
|
});
|
|
|
|
|
await database.PutAsync(
|
|
|
|
|
new CultRecordKey("player:two"),
|
|
|
|
|
new MeshIndexedPlayer
|
|
|
|
|
{
|
|
|
|
|
Name = "Two",
|
|
|
|
|
TeamId = Guid.NewGuid().ToString("D"),
|
|
|
|
|
Score = 20
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
collection.CollectionId.Should().Contain(teamId);
|
|
|
|
|
collection.SchemaName.Should().Be("tests.mesh_indexed_player");
|
|
|
|
|
collection.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Automatic);
|
|
|
|
|
collection.Sources.Should().ContainSingle().Which.SchemaId.Should().Be(collection.SchemaId);
|
|
|
|
|
|
|
|
|
|
var snapshot = await collection.LatestAsync();
|
|
|
|
|
snapshot.Should().ContainSingle().Which.Name.Should().Be("One");
|
|
|
|
|
changes.Should().ContainSingle();
|
|
|
|
|
changes[0].Kind.Should().Be(CultMeshCollectionChangeKind.Added);
|
|
|
|
|
changes[0].Document!.Name.Should().Be("One");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task CollectionHandle_SupportsSameSchemaAliases()
|
|
|
|
|
{
|
|
|
|
|
var cache = new CultCache();
|
|
|
|
|
await cache.UpsertAsync(
|
|
|
|
|
new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "collection-alias",
|
|
|
|
|
Revision = 4
|
|
|
|
|
},
|
|
|
|
|
new CultRecordHandle<MeshNoteDocument>(new CultRecordKey("mesh-note:collection")));
|
|
|
|
|
|
|
|
|
|
var collection = CultMesh.Collection<MeshNoteDocument>(cache);
|
|
|
|
|
var alias = collection.AsSchemaAlias<MeshNoteAliasDocument>();
|
2026-06-27 22:33:50 +01:00
|
|
|
var catalog = CultMesh.Collections(collection);
|
2026-06-27 15:09:36 +01:00
|
|
|
|
|
|
|
|
alias.CollectionId.Should().Be(collection.CollectionId);
|
|
|
|
|
alias.SchemaName.Should().Be(collection.SchemaName);
|
2026-06-27 22:33:50 +01:00
|
|
|
catalog.Collections.Should().ContainSingle().Which.Should().BeSameAs(collection);
|
|
|
|
|
catalog.TryGetCollection<MeshNoteAliasDocument>(out var catalogAlias).Should().BeTrue();
|
|
|
|
|
catalogAlias.CollectionId.Should().Be(collection.CollectionId);
|
|
|
|
|
catalog.TryGetCollectionBySchema("tests.mesh_note.v1", out var byVersion).Should().BeTrue();
|
|
|
|
|
byVersion.Should().BeSameAs(collection);
|
|
|
|
|
catalog.CollectionBySchema("tests.mesh_note").Should().BeSameAs(collection);
|
|
|
|
|
catalog.CollectionBySchema(CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId)
|
|
|
|
|
.Should()
|
|
|
|
|
.BeSameAs(collection);
|
2026-06-27 15:09:36 +01:00
|
|
|
|
|
|
|
|
var snapshot = await alias.LatestAsync();
|
|
|
|
|
snapshot.Should().ContainSingle().Which.Text.Should().Be("collection-alias");
|
2026-06-27 22:33:50 +01:00
|
|
|
var catalogSnapshot = await catalog.LatestAsync<MeshNoteAliasDocument>();
|
|
|
|
|
catalogSnapshot.Should().ContainSingle().Which.Revision.Should().Be(4);
|
|
|
|
|
|
|
|
|
|
CultMeshCollectionChange<MeshNoteAliasDocument> observed = null!;
|
|
|
|
|
using var subscription = catalog.WatchChanges<MeshNoteAliasDocument>(change => observed = change);
|
|
|
|
|
await cache.UpsertAsync(
|
|
|
|
|
new MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "collection-alias-updated",
|
|
|
|
|
Revision = 5
|
|
|
|
|
},
|
|
|
|
|
new CultRecordHandle<MeshNoteDocument>(new CultRecordKey("mesh-note:collection")));
|
|
|
|
|
|
|
|
|
|
observed.Kind.Should().Be(CultMeshCollectionChangeKind.Updated);
|
|
|
|
|
observed.Document!.Text.Should().Be("collection-alias-updated");
|
|
|
|
|
observed.Document.Revision.Should().Be(5);
|
|
|
|
|
observed.PreviousDocument!.Text.Should().Be("collection-alias");
|
2026-06-27 15:09:36 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-27 14:43:00 +01:00
|
|
|
[Test]
|
|
|
|
|
public async Task DocumentHandle_ReplacesThroughCultMeshNodeDatabase()
|
|
|
|
|
{
|
|
|
|
|
using var directory = new TemporaryDirectory();
|
|
|
|
|
using var node = await CultMesh.CreateNodeAsync(
|
|
|
|
|
Path.Combine(directory.Path, "mesh-node.cc"),
|
|
|
|
|
new CultMeshNodeOptions
|
|
|
|
|
{
|
|
|
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
RuntimeId = "node-runtime",
|
|
|
|
|
Shards = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetShardDescriptor(
|
|
|
|
|
"mesh",
|
|
|
|
|
"node-runtime",
|
|
|
|
|
epoch: 1,
|
|
|
|
|
isPrimary: true,
|
|
|
|
|
schemaIds: new[] { CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId })
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
StartServer = false
|
|
|
|
|
});
|
|
|
|
|
var key = new CultRecordKey("mesh-note:node");
|
|
|
|
|
|
|
|
|
|
var handle = CultMesh.Document<MeshNoteDocument>(
|
|
|
|
|
node,
|
|
|
|
|
key,
|
|
|
|
|
CultMesh.Verse("starbridge", "unity-pilot"));
|
|
|
|
|
var alias = handle.AsSchemaAlias<MeshNoteAliasDocument>();
|
|
|
|
|
|
|
|
|
|
MeshNoteDocument observed = null!;
|
|
|
|
|
using var subscription = handle.Watch(value => observed = value);
|
|
|
|
|
await alias.ReplaceAsync(new MeshNoteAliasDocument
|
|
|
|
|
{
|
|
|
|
|
Schema = "tests.mesh_note.v1",
|
|
|
|
|
Text = "node-replaced",
|
|
|
|
|
Revision = 3
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var snapshot = await handle.LatestAsync();
|
|
|
|
|
snapshot.Text.Should().Be("node-replaced");
|
|
|
|
|
observed.Text.Should().Be("node-replaced");
|
|
|
|
|
observed.Revision.Should().Be(3);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 14:30:59 +01:00
|
|
|
[Test]
|
|
|
|
|
public void SurfaceCatalog_DescribesTypedRuntimeSurfaces()
|
|
|
|
|
{
|
|
|
|
|
var route = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located frame slab");
|
|
|
|
|
var sources = new[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.ProjectionSource(
|
|
|
|
|
"daemon:aetheria.frame.latest.v1",
|
|
|
|
|
schemaId: "gamecult.aetheria.daemon_frame.v1")
|
|
|
|
|
};
|
|
|
|
|
var query = new CultMeshQuerySurface<MeshViewportRequest, string[]>(
|
|
|
|
|
"aetheria.zone.objects.visible",
|
|
|
|
|
(_request, _context) => Task.FromResult(Array.Empty<string>()),
|
|
|
|
|
sources: sources,
|
|
|
|
|
routeHint: route);
|
|
|
|
|
var feed = CultMesh.LiveFeed<MeshViewportRequest, string>(
|
|
|
|
|
"aetheria.rts.viewport.feed",
|
|
|
|
|
(_request, _context) => Task.FromResult("frame"),
|
|
|
|
|
sources: sources,
|
|
|
|
|
routeHint: route);
|
|
|
|
|
var operation = new CultMeshOperationHandle<MeshMoveRequest, CultMeshOperationReceipt>(
|
|
|
|
|
"aetheria.entity.pilot.move",
|
|
|
|
|
(_request, _context) => Task.FromResult(new CultMeshOperationReceipt("aetheria.entity.pilot.move", true)));
|
|
|
|
|
var pointer = CultMesh.StatePointer(
|
|
|
|
|
"aetheria.selection.current",
|
|
|
|
|
async () => "entity:ship:1",
|
|
|
|
|
() => new Subject<string>(),
|
|
|
|
|
route,
|
|
|
|
|
sources);
|
2026-06-27 15:35:12 +01:00
|
|
|
var document = CultMesh.Document(
|
|
|
|
|
"daemon:aetheria.frame.latest.v1",
|
|
|
|
|
CultMesh.Verse("aetheria.local", "unity-raven").Context,
|
|
|
|
|
_context => Task.FromResult(new MeshNoteDocument { Schema = "frame", Text = "current", Revision = 1 }),
|
|
|
|
|
_context => new Subject<MeshNoteDocument>(),
|
|
|
|
|
sources,
|
|
|
|
|
route);
|
|
|
|
|
var collection = new CultMeshCollectionHandle<MeshNoteDocument>(
|
|
|
|
|
"daemon:aetheria.contacts.v1",
|
|
|
|
|
() => Task.FromResult<IReadOnlyList<MeshNoteDocument>>(Array.Empty<MeshNoteDocument>()),
|
|
|
|
|
() => new Subject<CultMeshCollectionChange<MeshNoteDocument>>(),
|
|
|
|
|
sources,
|
|
|
|
|
route);
|
2026-06-27 14:30:59 +01:00
|
|
|
var nativeView = new CultMeshNativeSliceViewDescriptor(
|
|
|
|
|
"aetheria.zone.render",
|
|
|
|
|
"gamecult.aetheria.render_body.v1",
|
|
|
|
|
rowCount: 128,
|
|
|
|
|
new[] { CultMeshNativeSliceColumn.For<MeshVec2>("position") },
|
|
|
|
|
route);
|
|
|
|
|
|
|
|
|
|
var catalog = CultMesh.DescribeSurfaceCatalog(
|
|
|
|
|
"gamecult.aetheria.rts.surfaces.v1",
|
|
|
|
|
new[]
|
|
|
|
|
{
|
|
|
|
|
CultMesh.DescribeSurface(query),
|
|
|
|
|
CultMesh.DescribeSurface(feed),
|
|
|
|
|
CultMesh.DescribeSurface(operation),
|
2026-06-27 15:35:12 +01:00
|
|
|
CultMesh.DescribeSurface(document),
|
|
|
|
|
CultMesh.DescribeSurface(collection),
|
2026-06-27 14:30:59 +01:00
|
|
|
CultMesh.DescribeSurface(pointer),
|
|
|
|
|
CultMesh.DescribeSurface(nativeView)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
catalog.CatalogId.Should().Be("gamecult.aetheria.rts.surfaces.v1");
|
2026-06-27 15:35:12 +01:00
|
|
|
catalog.Surfaces.Should().HaveCount(7);
|
2026-06-27 14:30:59 +01:00
|
|
|
catalog.Surfaces[0].Kind.Should().Be(CultMeshSurfaceKind.Query);
|
|
|
|
|
catalog.Surfaces[0].SurfaceId.Should().Be("aetheria.zone.objects.visible");
|
|
|
|
|
catalog.Surfaces[0].RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
catalog.Surfaces[0].Sources.Should().HaveCount(1);
|
|
|
|
|
catalog.Find("aetheria.rts.viewport.feed")!.Kind.Should().Be(CultMeshSurfaceKind.LiveFeed);
|
|
|
|
|
catalog.Find("aetheria.entity.pilot.move")!.Kind.Should().Be(CultMeshSurfaceKind.Operation);
|
2026-06-27 15:35:12 +01:00
|
|
|
catalog.Find("daemon:aetheria.frame.latest.v1")!.Kind.Should().Be(CultMeshSurfaceKind.Document);
|
|
|
|
|
catalog.Find("daemon:aetheria.contacts.v1")!.Kind.Should().Be(CultMeshSurfaceKind.Collection);
|
2026-06-27 14:30:59 +01:00
|
|
|
catalog.Find("aetheria.selection.current")!.Kind.Should().Be(CultMeshSurfaceKind.StatePointer);
|
|
|
|
|
catalog.Find("aetheria.selection.current")!.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
catalog.Find("aetheria.selection.current")!.Sources.Should().ContainSingle();
|
|
|
|
|
catalog.Find("aetheria.zone.render")!.Kind.Should().Be(CultMeshSurfaceKind.NativeSliceView);
|
|
|
|
|
catalog.Find("aetheria.zone.render")!.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
catalog.Find("missing").Should().BeNull();
|
|
|
|
|
catalog.FindByKind(CultMeshSurfaceKind.Operation)
|
|
|
|
|
.Should()
|
|
|
|
|
.ContainSingle()
|
|
|
|
|
.Which
|
|
|
|
|
.SurfaceId
|
|
|
|
|
.Should()
|
|
|
|
|
.Be("aetheria.entity.pilot.move");
|
|
|
|
|
catalog.FindByKind(CultMeshSurfaceKind.Query).Should().ContainSingle();
|
|
|
|
|
|
|
|
|
|
var index = CultMesh.DescribeSurfaceCatalogIndex(catalog);
|
|
|
|
|
index.CatalogId.Should().Be(catalog.CatalogId);
|
|
|
|
|
index.Queries.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.zone.objects.visible");
|
|
|
|
|
index.LiveFeeds.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.rts.viewport.feed");
|
|
|
|
|
index.Operations.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.entity.pilot.move");
|
2026-06-27 15:35:12 +01:00
|
|
|
index.Documents.Should().ContainSingle().Which.SurfaceId.Should().Be("daemon:aetheria.frame.latest.v1");
|
|
|
|
|
index.Collections.Should().ContainSingle().Which.SurfaceId.Should().Be("daemon:aetheria.contacts.v1");
|
2026-06-27 14:30:59 +01:00
|
|
|
index.StatePointers.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.selection.current");
|
|
|
|
|
index.NativeSliceViews.Should().ContainSingle().Which.SurfaceId.Should().Be("aetheria.zone.render");
|
|
|
|
|
index.ProjectionRecipes.Should().BeEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task PollingQueryWatcher_TurnsSnapshotsIntoReactiveFeed()
|
|
|
|
|
{
|
|
|
|
|
var frameId = 0;
|
|
|
|
|
var observed = new List<int>();
|
|
|
|
|
var feed = CultMesh.LiveFeed<MeshViewportRequest, int>(
|
|
|
|
|
"aetheria.rts.viewport.feed",
|
|
|
|
|
(_request, _context) => Task.FromResult(frameId),
|
|
|
|
|
CultMesh.PollingQueryWatcher<MeshViewportRequest, int>(
|
|
|
|
|
(_request, _context) => Task.FromResult(frameId),
|
|
|
|
|
new CultMeshPollingWatchOptions<int>(TimeSpan.FromMilliseconds(5))));
|
|
|
|
|
|
|
|
|
|
using (feed
|
|
|
|
|
.Watch(new MeshViewportRequest(-1, 1), CultMeshQueryContext.ForRuntime("browser-starfire"))
|
|
|
|
|
.Subscribe(value => observed.Add(value)))
|
|
|
|
|
{
|
|
|
|
|
await Task.Delay(35);
|
|
|
|
|
frameId = 1;
|
|
|
|
|
await Task.Delay(35);
|
|
|
|
|
frameId = 1;
|
|
|
|
|
await Task.Delay(25);
|
|
|
|
|
frameId = 2;
|
|
|
|
|
await Task.Delay(35);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
frameId = 3;
|
|
|
|
|
await Task.Delay(25);
|
|
|
|
|
|
|
|
|
|
observed.Should().Equal(0, 1, 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void NativeSliceDescriptor_DescribesSharedColumnsWithoutOpeningTransport()
|
|
|
|
|
{
|
|
|
|
|
var descriptor = new CultMeshNativeSliceViewDescriptor(
|
|
|
|
|
"aetheria.zone.render",
|
|
|
|
|
"gamecult.aetheria.render_body.v1",
|
|
|
|
|
rowCount: 128,
|
|
|
|
|
new[]
|
|
|
|
|
{
|
|
|
|
|
CultMeshNativeSliceColumn.For<MeshVec2>("position"),
|
|
|
|
|
CultMeshNativeSliceColumn.For<MeshVec2>("velocity")
|
|
|
|
|
},
|
|
|
|
|
new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "CultCache slab"),
|
|
|
|
|
nativeHandle: "cultcache-slab:aetheria-zone-render");
|
|
|
|
|
|
|
|
|
|
descriptor.Route.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
descriptor.Columns.Should().HaveCount(2);
|
|
|
|
|
descriptor.Columns[0].Name.Should().Be("position");
|
|
|
|
|
descriptor.DenseRowStrideBytes.Should().Be(16);
|
|
|
|
|
descriptor.FindColumn("velocity")!.ElementSizeBytes.Should().Be(8);
|
|
|
|
|
|
|
|
|
|
var diagnostic = CultMesh.DescribeNativeSliceView(descriptor);
|
|
|
|
|
diagnostic.ViewId.Should().Be("aetheria.zone.render");
|
|
|
|
|
diagnostic.SchemaId.Should().Be("gamecult.aetheria.render_body.v1");
|
|
|
|
|
diagnostic.RowCount.Should().Be(128);
|
|
|
|
|
diagnostic.Route.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
|
|
|
|
diagnostic.NativeHandle.Should().Be("cultcache-slab:aetheria-zone-render");
|
|
|
|
|
diagnostic.DenseRowStrideBytes.Should().Be(16);
|
|
|
|
|
diagnostic.Columns.Should().HaveCount(2);
|
|
|
|
|
diagnostic.Columns.Should().NotBeSameAs(descriptor.Columns);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 01:13:08 +01:00
|
|
|
[Test]
|
|
|
|
|
public void NegotiatesGpuTextureStreamsWithoutForcingCopies()
|
|
|
|
|
{
|
|
|
|
|
var catalog = CultMesh.CreateStreamCatalog();
|
|
|
|
|
var stream = new CultMeshStreamDescriptor(
|
|
|
|
|
"mimir:kiyo-pro:rgba",
|
|
|
|
|
"mimir-live",
|
|
|
|
|
"starfire",
|
|
|
|
|
CultMeshStreamKind.Video,
|
|
|
|
|
new CultMeshStreamClock("mimir:clock", "kiyo-pro", sampleRate: 90_000, confidence: 0.92d),
|
|
|
|
|
new[]
|
|
|
|
|
{
|
|
|
|
|
CultMeshStreamBodyTransport.SharedD3D12Texture,
|
|
|
|
|
CultMeshStreamBodyTransport.SharedMemory,
|
|
|
|
|
CultMeshStreamBodyTransport.CultCachePage
|
|
|
|
|
},
|
|
|
|
|
video: new CultMeshVideoStreamFormat(1920, 1080, "rgba8", framesPerSecond: 60),
|
|
|
|
|
maxInFlightFrames: 4);
|
|
|
|
|
|
|
|
|
|
catalog.Declare(stream);
|
|
|
|
|
|
|
|
|
|
var negotiation = catalog.Negotiate(
|
|
|
|
|
stream.StreamId,
|
|
|
|
|
new CultMeshStreamConsumerProfile(
|
|
|
|
|
"fensalir",
|
|
|
|
|
"mimir-live",
|
|
|
|
|
new[] { CultMeshStreamBodyTransport.SharedD3D12Texture, CultMeshStreamBodyTransport.CultCachePage },
|
|
|
|
|
acceptedKinds: new[] { CultMeshStreamKind.Video },
|
|
|
|
|
canImportGpuHandles: true,
|
|
|
|
|
maxInFlightFrames: 2));
|
|
|
|
|
|
|
|
|
|
negotiation.Transport.Should().Be(CultMeshStreamBodyTransport.SharedD3D12Texture);
|
|
|
|
|
negotiation.CopyBudget.Should().Be(CultMeshStreamCopyBudget.ZeroCopyTarget);
|
|
|
|
|
negotiation.MaxInFlightFrames.Should().Be(2);
|
|
|
|
|
|
|
|
|
|
var handle = new CultMeshStreamFrameHandle(
|
|
|
|
|
stream.StreamId,
|
|
|
|
|
sequence: 42,
|
|
|
|
|
timestampNs: 123_456_789,
|
|
|
|
|
CultMeshStreamBodyTransport.SharedD3D12Texture,
|
|
|
|
|
nativeHandle: "shared-handle:0xfeedbeef",
|
|
|
|
|
fenceHandle: "fence:0x1234",
|
|
|
|
|
fenceValue: 7);
|
|
|
|
|
|
|
|
|
|
catalog.PublishFrame(handle);
|
|
|
|
|
|
|
|
|
|
catalog.LatestFrame(stream.StreamId).Should().BeSameAs(handle);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void NegotiationRejectsStreamsWithoutACommonTransport()
|
|
|
|
|
{
|
|
|
|
|
var catalog = CultMesh.CreateStreamCatalog();
|
|
|
|
|
catalog.Declare(new CultMeshStreamDescriptor(
|
|
|
|
|
"mimir:kiyo-pro:gpu-only",
|
|
|
|
|
"mimir-live",
|
|
|
|
|
"starfire",
|
|
|
|
|
CultMeshStreamKind.Video,
|
|
|
|
|
new CultMeshStreamClock("mimir:clock", "kiyo-pro"),
|
|
|
|
|
new[] { CultMeshStreamBodyTransport.SharedD3D12Texture }));
|
|
|
|
|
|
|
|
|
|
var consumer = new CultMeshStreamConsumerProfile(
|
|
|
|
|
"cpu-recorder",
|
|
|
|
|
"mimir-live",
|
|
|
|
|
new[] { CultMeshStreamBodyTransport.SharedMemory },
|
|
|
|
|
acceptedKinds: new[] { CultMeshStreamKind.Video });
|
|
|
|
|
|
|
|
|
|
Action act = () => catalog.Negotiate("mimir:kiyo-pro:gpu-only", consumer);
|
|
|
|
|
|
|
|
|
|
act.Should().Throw<InvalidOperationException>()
|
|
|
|
|
.WithMessage("Stream and consumer have no compatible body transport.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void SharedMemoryRingPublishesWritableSlotsWithoutInternalCopies()
|
|
|
|
|
{
|
|
|
|
|
var catalog = CatalogWithByteStream();
|
2026-07-13 15:49:53 +01:00
|
|
|
using var ring = catalog.CreateSharedMemoryRing("mimir:leap:depth", slotCount: 3, slotByteLength: 16);
|
2026-06-02 01:13:08 +01:00
|
|
|
|
|
|
|
|
ring.TryAcquireWriteSlot(out var write).Should().BeTrue();
|
|
|
|
|
ReadOnlySpan<byte> seed = stackalloc byte[] { 1, 2, 3, 4 };
|
|
|
|
|
seed.CopyTo(write.Span);
|
|
|
|
|
|
|
|
|
|
var handle = ring.CommitWriteSlot(write, timestampNs: 99, byteLength: 4);
|
|
|
|
|
catalog.PublishFrame(handle);
|
|
|
|
|
|
|
|
|
|
ring.TryAcquireLatestRead(out var read).Should().BeTrue();
|
|
|
|
|
using (read)
|
|
|
|
|
{
|
|
|
|
|
read.Handle.Sequence.Should().Be(0);
|
|
|
|
|
read.Handle.UnavoidableCopyCount.Should().Be(0);
|
|
|
|
|
read.Span.ToArray().Should().Equal(1, 2, 3, 4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var stats = ring.Stats();
|
|
|
|
|
stats.PublishedFrames.Should().Be(1);
|
|
|
|
|
stats.UnavoidableCopyCount.Should().Be(0);
|
|
|
|
|
catalog.LatestFrame("mimir:leap:depth")!.ResourceKey.Should().Be("mimir:leap:depth:slot:0");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void SharedMemoryRingDoesNotOverwriteSlotsHeldByReaders()
|
|
|
|
|
{
|
|
|
|
|
var catalog = CatalogWithByteStream();
|
2026-07-13 15:49:53 +01:00
|
|
|
using var ring = catalog.CreateSharedMemoryRing("mimir:leap:depth", slotCount: 3, slotByteLength: 8);
|
2026-06-02 01:13:08 +01:00
|
|
|
|
|
|
|
|
ring.TryAcquireWriteSlot(out var firstWrite).Should().BeTrue();
|
|
|
|
|
firstWrite.Span[0] = 11;
|
|
|
|
|
ring.CommitWriteSlot(firstWrite, timestampNs: 1, byteLength: 1);
|
|
|
|
|
|
|
|
|
|
ring.TryAcquireLatestRead(out var read).Should().BeTrue();
|
|
|
|
|
|
2026-07-13 15:49:53 +01:00
|
|
|
ring.TryAcquireWriteSlot(out var secondWrite).Should().BeTrue();
|
|
|
|
|
ring.CommitWriteSlot(secondWrite, timestampNs: 2, byteLength: 1);
|
|
|
|
|
ring.TryAcquireWriteSlot(out var thirdWrite).Should().BeTrue();
|
|
|
|
|
ring.CommitWriteSlot(thirdWrite, timestampNs: 3, byteLength: 1);
|
|
|
|
|
ring.TryAcquireLatestRead(out var thirdRead).Should().BeTrue();
|
|
|
|
|
ring.TryAcquireWriteSlot(out var overwriteSecond).Should().BeTrue();
|
|
|
|
|
ring.CommitWriteSlot(overwriteSecond, timestampNs: 4, byteLength: 1);
|
|
|
|
|
ring.TryAcquireLatestRead(out var secondRead).Should().BeTrue();
|
2026-06-02 01:13:08 +01:00
|
|
|
ring.TryAcquireWriteSlot(out _).Should().BeFalse();
|
|
|
|
|
ring.Stats().BlockedWrites.Should().Be(1);
|
|
|
|
|
|
|
|
|
|
read.Dispose();
|
2026-07-13 15:49:53 +01:00
|
|
|
thirdRead.Dispose();
|
|
|
|
|
secondRead.Dispose();
|
2026-06-02 01:13:08 +01:00
|
|
|
|
2026-07-13 15:49:53 +01:00
|
|
|
ring.TryAcquireWriteSlot(out var fifthWrite).Should().BeTrue();
|
|
|
|
|
fifthWrite.Span[0] = 12;
|
|
|
|
|
ring.CommitWriteSlot(fifthWrite, timestampNs: 5, byteLength: 1);
|
2026-06-02 01:13:08 +01:00
|
|
|
|
|
|
|
|
var stats = ring.Stats();
|
2026-07-13 15:49:53 +01:00
|
|
|
stats.PublishedFrames.Should().Be(5);
|
|
|
|
|
stats.DroppedFrames.Should().Be(2);
|
|
|
|
|
stats.LatestSequence.Should().Be(4);
|
2026-06-02 01:13:08 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void CopyPublishMarksFallbackCopiesExplicitly()
|
|
|
|
|
{
|
|
|
|
|
var catalog = CatalogWithByteStream();
|
2026-07-13 15:49:53 +01:00
|
|
|
using var ring = catalog.CreateSharedMemoryRing("mimir:leap:depth", slotCount: 3, slotByteLength: 8);
|
2026-06-02 01:13:08 +01:00
|
|
|
|
|
|
|
|
ring.TryPublishCopy(stackalloc byte[] { 5, 6, 7 }, timestampNs: 10, durationNs: 2, out var handle)
|
|
|
|
|
.Should()
|
|
|
|
|
.BeTrue();
|
|
|
|
|
|
|
|
|
|
handle.UnavoidableCopyCount.Should().Be(1);
|
|
|
|
|
ring.Stats().UnavoidableCopyCount.Should().Be(1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 17:28:15 +01:00
|
|
|
private static byte[] WriteLegacySingleDocumentSnapshot(
|
|
|
|
|
string key,
|
|
|
|
|
string schemaId,
|
|
|
|
|
string schemaName,
|
|
|
|
|
string schemaVersion,
|
|
|
|
|
string storedAt,
|
|
|
|
|
byte[] payload)
|
|
|
|
|
{
|
|
|
|
|
var buffer = new ArrayBufferWriter<byte>();
|
|
|
|
|
var writer = new MessagePackWriter(buffer);
|
|
|
|
|
|
|
|
|
|
writer.WriteArrayHeader(3);
|
|
|
|
|
writer.Write("cultcache.store.v1");
|
|
|
|
|
writer.WriteArrayHeader(1);
|
|
|
|
|
writer.WriteArrayHeader(7);
|
|
|
|
|
writer.Write(schemaId);
|
|
|
|
|
writer.Write(schemaName);
|
|
|
|
|
writer.Write(schemaVersion);
|
|
|
|
|
writer.Write("Legacy Aetheria single document");
|
|
|
|
|
writer.Write(storedAt);
|
|
|
|
|
writer.Write("legacy-hash");
|
|
|
|
|
writer.WriteArrayHeader(0);
|
|
|
|
|
writer.WriteArrayHeader(1);
|
|
|
|
|
writer.WriteArrayHeader(4);
|
|
|
|
|
writer.Write(key);
|
|
|
|
|
writer.Write(schemaId);
|
|
|
|
|
writer.Write(storedAt);
|
|
|
|
|
writer.Write(payload);
|
|
|
|
|
writer.Flush();
|
|
|
|
|
|
|
|
|
|
return buffer.WrittenSpan.ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 01:13:08 +01:00
|
|
|
private static CultMeshStreamCatalog CatalogWithByteStream()
|
|
|
|
|
{
|
|
|
|
|
var catalog = CultMesh.CreateStreamCatalog();
|
|
|
|
|
catalog.Declare(new CultMeshStreamDescriptor(
|
|
|
|
|
"mimir:leap:depth",
|
|
|
|
|
"mimir-live",
|
|
|
|
|
"starfire",
|
|
|
|
|
CultMeshStreamKind.Tensor,
|
|
|
|
|
new CultMeshStreamClock("mimir:clock", "leap", confidence: 0.8d),
|
|
|
|
|
new[] { CultMeshStreamBodyTransport.SharedMemory, CultMeshStreamBodyTransport.CultCachePage }));
|
|
|
|
|
return catalog;
|
|
|
|
|
}
|
2026-06-13 01:44:05 +01:00
|
|
|
|
2026-06-27 17:03:10 +01:00
|
|
|
private static async Task WaitForAsync(Func<bool> predicate)
|
|
|
|
|
{
|
|
|
|
|
var deadline = DateTimeOffset.UtcNow.AddSeconds(2);
|
|
|
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
|
|
|
{
|
|
|
|
|
if (predicate())
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await Task.Delay(20);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
predicate().Should().BeTrue();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 14:43:00 +01:00
|
|
|
private sealed class TemporaryDirectory : IDisposable
|
|
|
|
|
{
|
|
|
|
|
public TemporaryDirectory()
|
|
|
|
|
{
|
|
|
|
|
Path = System.IO.Path.Combine(
|
|
|
|
|
System.IO.Path.GetTempPath(),
|
|
|
|
|
"cultmesh-tests-" + Guid.NewGuid().ToString("N"));
|
|
|
|
|
Directory.CreateDirectory(Path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string Path { get; }
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
if (Directory.Exists(Path))
|
|
|
|
|
{
|
|
|
|
|
Directory.Delete(Path, recursive: true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 15:09:36 +01:00
|
|
|
[CultDocument("tests.mesh_indexed_player", "tests.mesh_indexed_player.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
internal sealed class MeshIndexedPlayer
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
public string Name { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
[CultIndex]
|
|
|
|
|
public string TeamId { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(2)]
|
|
|
|
|
public int Score { get; set; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 14:30:59 +01:00
|
|
|
[CultDocument("tests.mesh_note", "tests.mesh_note.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
internal sealed class MeshNoteDocument
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
public string Schema { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
public string Text { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(2)]
|
|
|
|
|
public int Revision { get; set; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[CultDocument("tests.mesh_note", "tests.mesh_note.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
internal sealed class MeshNoteAliasDocument
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
public string Schema { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
public string Text { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(2)]
|
|
|
|
|
public int Revision { get; set; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 17:03:10 +01:00
|
|
|
[CultDocument("tests.mesh_publication_note", "tests.mesh_publication_note.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
internal sealed class MeshPublicationNoteDocument
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
public string Schema { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
public string Text { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(2)]
|
|
|
|
|
public int Revision { get; set; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 14:30:59 +01:00
|
|
|
[CultDocument("tests.mesh_other", "tests.mesh_other.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
internal sealed class MeshOtherDocument
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
public string Schema { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
public string Text { get; set; } = string.Empty;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public readonly struct MeshMoveRequest
|
|
|
|
|
{
|
|
|
|
|
public MeshMoveRequest(int entityId, float x, float y)
|
|
|
|
|
{
|
|
|
|
|
EntityId = entityId;
|
|
|
|
|
X = x;
|
|
|
|
|
Y = y;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public int EntityId { get; }
|
|
|
|
|
public float X { get; }
|
|
|
|
|
public float Y { get; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public readonly struct MeshViewportRequest
|
|
|
|
|
{
|
|
|
|
|
public MeshViewportRequest(float minX, float maxX)
|
|
|
|
|
{
|
|
|
|
|
MinX = minX;
|
|
|
|
|
MaxX = maxX;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public float MinX { get; }
|
|
|
|
|
public float MaxX { get; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public readonly struct MeshVec2
|
|
|
|
|
{
|
|
|
|
|
public MeshVec2(float x, float y)
|
|
|
|
|
{
|
|
|
|
|
X = x;
|
|
|
|
|
Y = y;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public float X { get; }
|
|
|
|
|
public float Y { get; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed class MeshAetheriaDomain
|
|
|
|
|
{
|
|
|
|
|
private readonly CultMeshVerseContext _context;
|
|
|
|
|
|
|
|
|
|
public MeshAetheriaDomain(CultMeshVerseContext context)
|
|
|
|
|
{
|
|
|
|
|
_context = context;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public MeshEntityFacade Entity(int entityId)
|
|
|
|
|
{
|
|
|
|
|
return new MeshEntityFacade(_context, entityId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public MeshZoneFacade Zone(string zoneId)
|
|
|
|
|
{
|
|
|
|
|
return new MeshZoneFacade(_context, zoneId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed class MeshEntityFacade
|
|
|
|
|
{
|
|
|
|
|
public MeshEntityFacade(CultMeshVerseContext context, int entityId)
|
|
|
|
|
{
|
|
|
|
|
Pilot = new MeshPilotFacade(context, entityId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public MeshPilotFacade Pilot { get; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed class MeshPilotFacade
|
|
|
|
|
{
|
|
|
|
|
private readonly int _entityId;
|
|
|
|
|
private readonly CultMeshBoundOperationHandle<MeshMoveRequest, CultMeshOperationReceipt> _move;
|
|
|
|
|
|
|
|
|
|
public MeshPilotFacade(CultMeshVerseContext context, int entityId)
|
|
|
|
|
{
|
|
|
|
|
_entityId = entityId;
|
|
|
|
|
_move = CultMesh.BindOperation(
|
|
|
|
|
context,
|
|
|
|
|
new CultMeshOperationHandle<MeshMoveRequest, CultMeshOperationReceipt>(
|
|
|
|
|
"aetheria.entity.pilot.move",
|
|
|
|
|
(request, context) => Task.FromResult(new CultMeshOperationReceipt(
|
|
|
|
|
"aetheria.entity.pilot.move",
|
|
|
|
|
accepted: request.EntityId == _entityId &&
|
|
|
|
|
Math.Abs(request.X) > 0 &&
|
|
|
|
|
context.Claims.Any(claim => claim.Role == "pilot-control"),
|
|
|
|
|
context.RouteHint))));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task<CultMeshOperationReceipt> MoveAsync(MeshVec2 direction, string idempotencyKey)
|
|
|
|
|
{
|
|
|
|
|
return _move.InvokeAsync(
|
|
|
|
|
new MeshMoveRequest(_entityId, direction.X, direction.Y),
|
|
|
|
|
idempotencyKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed class MeshZoneFacade
|
|
|
|
|
{
|
|
|
|
|
public MeshZoneFacade(CultMeshVerseContext context, string zoneId)
|
|
|
|
|
{
|
|
|
|
|
Objects = new MeshObjectsFacade(context, zoneId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public MeshObjectsFacade Objects { get; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed class MeshObjectsFacade
|
|
|
|
|
{
|
|
|
|
|
private readonly string _zoneId;
|
|
|
|
|
private readonly CultMeshBoundQuerySurface<MeshViewportRequest, string[]> _visibleObjects;
|
|
|
|
|
|
|
|
|
|
public MeshObjectsFacade(CultMeshVerseContext context, string zoneId)
|
|
|
|
|
{
|
|
|
|
|
_zoneId = zoneId;
|
|
|
|
|
_visibleObjects = CultMesh.BindQuery(
|
|
|
|
|
context,
|
|
|
|
|
new CultMeshQuerySurface<MeshViewportRequest, string[]>(
|
|
|
|
|
"aetheria.zone.objects.visible",
|
|
|
|
|
(parameters, context) => Task.FromResult(new[]
|
|
|
|
|
{
|
|
|
|
|
$"{context.RuntimeId}:{_zoneId}:{parameters.MinX}:{parameters.MaxX}:{context.RouteHint.Kind}"
|
|
|
|
|
})));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task<string[]> VisibleWithinAsync(MeshViewportRequest request)
|
|
|
|
|
{
|
|
|
|
|
return _visibleObjects.ExecuteAsync(request);
|
|
|
|
|
}
|
2026-06-02 01:13:08 +01:00
|
|
|
}
|
2026-06-27 16:37:44 +01:00
|
|
|
|
|
|
|
|
internal sealed class MeshSnapshotSchemaClient : ICultNetSchemaClient
|
|
|
|
|
{
|
|
|
|
|
private readonly Func<CultNetSnapshotRequestMessage, CultNetSnapshotResponseRawMessage> _respond;
|
|
|
|
|
private readonly List<Action<CultNetSnapshotResponseRawMessage>> _snapshotHandlers = new();
|
|
|
|
|
private readonly List<Action<CultNetErrorMessage>> _errorHandlers = new();
|
|
|
|
|
|
|
|
|
|
public MeshSnapshotSchemaClient(
|
|
|
|
|
Func<CultNetSnapshotRequestMessage, CultNetSnapshotResponseRawMessage> respond)
|
|
|
|
|
{
|
|
|
|
|
_respond = respond;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public bool Connected { get; private set; }
|
2026-07-10 06:37:10 +01:00
|
|
|
public int ConnectCount { get; private set; }
|
2026-06-27 16:37:44 +01:00
|
|
|
|
|
|
|
|
public void Connect(string host, int port)
|
|
|
|
|
{
|
2026-07-10 06:37:10 +01:00
|
|
|
ConnectCount++;
|
2026-06-27 16:37:44 +01:00
|
|
|
Connected = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
|
|
|
|
|
{
|
|
|
|
|
if (message is not CultNetSnapshotRequestMessage request)
|
|
|
|
|
{
|
|
|
|
|
foreach (var handler in _errorHandlers)
|
|
|
|
|
{
|
|
|
|
|
handler(new CultNetErrorMessage { Error = $"Unexpected message {typeof(T).Name}." });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var response = _respond(request);
|
|
|
|
|
foreach (var handler in _snapshotHandlers)
|
|
|
|
|
{
|
|
|
|
|
handler(response);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
|
|
|
|
{
|
|
|
|
|
if (typeof(T) == typeof(CultNetSnapshotResponseRawMessage))
|
|
|
|
|
{
|
|
|
|
|
_snapshotHandlers.Add(message => callback((T)(object)message));
|
|
|
|
|
}
|
|
|
|
|
else if (typeof(T) == typeof(CultNetErrorMessage))
|
|
|
|
|
{
|
|
|
|
|
_errorHandlers.Add(message => callback((T)(object)message));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-12 10:33:02 +01:00
|
|
|
|
|
|
|
|
internal sealed class FailedBackgroundSchemaClient : ICultNetSchemaClient, ICultNetSchemaClientHealth
|
|
|
|
|
{
|
2026-07-12 10:38:45 +01:00
|
|
|
private readonly bool _connected;
|
|
|
|
|
|
|
|
|
|
public FailedBackgroundSchemaClient(Exception error, bool connected = true)
|
2026-07-12 10:33:02 +01:00
|
|
|
{
|
2026-07-12 10:38:45 +01:00
|
|
|
_connected = connected;
|
2026-07-12 10:33:02 +01:00
|
|
|
BackgroundFailure = Task.FromResult(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public bool Connected { get; private set; }
|
|
|
|
|
public Task<Exception> BackgroundFailure { get; }
|
|
|
|
|
|
2026-07-12 10:38:45 +01:00
|
|
|
public void Connect(string host, int port) => Connected = _connected;
|
2026-07-12 10:33:02 +01:00
|
|
|
|
|
|
|
|
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-18 00:44:41 +02:00
|
|
|
|
|
|
|
|
internal sealed class BlockingCountClock : ICultMeshClock
|
|
|
|
|
{
|
|
|
|
|
private int _delayCount;
|
|
|
|
|
|
|
|
|
|
public DateTimeOffset UtcNow => DateTimeOffset.UnixEpoch;
|
|
|
|
|
|
|
|
|
|
public int DelayCount => Volatile.Read(ref _delayCount);
|
|
|
|
|
|
|
|
|
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
Interlocked.Increment(ref _delayCount);
|
|
|
|
|
return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
}
|