2026-07-12 12:05:31 +01:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Concurrent;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Reflection;
|
|
|
|
|
using System.Runtime.CompilerServices;
|
2026-07-19 03:17:11 +01:00
|
|
|
using System.Net;
|
2026-07-12 12:05:31 +01:00
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using GameCult.Caching;
|
|
|
|
|
using R3;
|
|
|
|
|
|
|
|
|
|
namespace GameCult.Networking
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Publishes live database changes through any schema-v0 server transport.
|
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher
_projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production
caller and ran after authorization with no wire consumer. Removed the field,
constructor parameter, and both call sites in Watch() and
CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a
delegate. The two tests that used projectRecord to simulate a record's
identity changing for the reconcile diff
(DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords,
…ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten
to drive a real rename through the database (delete the old key, put the new
one) instead, so they still prove authorization filtering and the Reconcile()
diff/body-demand behavior.
Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth
schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse
and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate
InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one
matcher landed in commit 1.
Networking tests: 162/162 (was 162/162 after commit 1; no count change, two
tests rewritten not added). Negative grep: _projectRecord|projectRecord empty;
MatchesRequestedSchema gone (one remaining substring hit is
RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this
commit does not touch — collapses in the next commit).
Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs,
CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this
commit; it is large enough to build on its own and follows as commit 2b.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
|
|
|
/// Selection match runs after authorization and owns both initial
|
|
|
|
|
/// snapshot and live-update delivery for a peer. Projection (header vs.
|
|
|
|
|
/// document) is a value on the request's selection, not a delegate.
|
2026-07-12 12:05:31 +01:00
|
|
|
/// </summary>
|
|
|
|
|
public sealed class CultNetDatabaseSubscriptionServer : IDisposable
|
|
|
|
|
{
|
|
|
|
|
private readonly ICultNetSchemaServer _server;
|
|
|
|
|
private readonly CultNetDatabase _database;
|
|
|
|
|
private readonly Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, Task> _subscribe;
|
|
|
|
|
private readonly Func<CultNetDatabaseUnsubscribeMessage, ICultNetSchemaServerPeer, Task> _unsubscribe;
|
2026-08-19 12:12:52 +02:00
|
|
|
private readonly Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, bool>? _authorizeRequest;
|
|
|
|
|
private readonly Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, string, string, bool>? _authorizeRecord;
|
2026-07-19 06:40:05 +01:00
|
|
|
private readonly ICultNetSchemaServerPeerLifecycle? _peerLifecycle;
|
2026-07-12 12:05:31 +01:00
|
|
|
private readonly ConcurrentDictionary<SubscriptionKey, IDisposable> _subscriptions =
|
|
|
|
|
new ConcurrentDictionary<SubscriptionKey, IDisposable>(SubscriptionKeyComparer.Instance);
|
2026-07-19 03:17:11 +01:00
|
|
|
private readonly ConcurrentDictionary<SubscriptionKey, CultNetDatabaseSubscribeMessage> _requests =
|
|
|
|
|
new ConcurrentDictionary<SubscriptionKey, CultNetDatabaseSubscribeMessage>(SubscriptionKeyComparer.Instance);
|
2026-08-19 13:55:03 +02:00
|
|
|
private readonly ConcurrentDictionary<SubscriptionKey, SubscriptionProjectionState> _projections =
|
|
|
|
|
new ConcurrentDictionary<SubscriptionKey, SubscriptionProjectionState>(SubscriptionKeyComparer.Instance);
|
|
|
|
|
private readonly object _lifecycleGate = new();
|
2026-07-12 12:05:31 +01:00
|
|
|
private bool _disposed;
|
|
|
|
|
|
|
|
|
|
/// <summary>Attaches live database subscription handlers to a schema server.</summary>
|
2026-08-19 12:12:52 +02:00
|
|
|
public CultNetDatabaseSubscriptionServer(
|
|
|
|
|
ICultNetSchemaServer server,
|
|
|
|
|
CultNetDatabase database,
|
|
|
|
|
Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, bool>? authorizeRequest = null,
|
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher
_projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production
caller and ran after authorization with no wire consumer. Removed the field,
constructor parameter, and both call sites in Watch() and
CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a
delegate. The two tests that used projectRecord to simulate a record's
identity changing for the reconcile diff
(DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords,
…ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten
to drive a real rename through the database (delete the old key, put the new
one) instead, so they still prove authorization filtering and the Reconcile()
diff/body-demand behavior.
Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth
schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse
and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate
InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one
matcher landed in commit 1.
Networking tests: 162/162 (was 162/162 after commit 1; no count change, two
tests rewritten not added). Negative grep: _projectRecord|projectRecord empty;
MatchesRequestedSchema gone (one remaining substring hit is
RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this
commit does not touch — collapses in the next commit).
Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs,
CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this
commit; it is large enough to build on its own and follows as commit 2b.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
|
|
|
Func<CultNetDatabaseSubscribeMessage, ICultNetSchemaServerPeer, string, string, bool>? authorizeRecord = null)
|
2026-07-12 12:05:31 +01:00
|
|
|
{
|
|
|
|
|
_server = server ?? throw new ArgumentNullException(nameof(server));
|
|
|
|
|
_database = database ?? throw new ArgumentNullException(nameof(database));
|
2026-08-19 12:12:52 +02:00
|
|
|
_authorizeRequest = authorizeRequest;
|
|
|
|
|
_authorizeRecord = authorizeRecord;
|
2026-07-12 12:05:31 +01:00
|
|
|
_subscribe = HandleSubscribeAsync;
|
|
|
|
|
_unsubscribe = HandleUnsubscribeAsync;
|
|
|
|
|
_server.OnCultNet(_subscribe);
|
|
|
|
|
_server.OnCultNet(_unsubscribe);
|
2026-07-19 06:40:05 +01:00
|
|
|
_peerLifecycle = server as ICultNetSchemaServerPeerLifecycle;
|
|
|
|
|
if (_peerLifecycle != null)
|
|
|
|
|
_peerLifecycle.PeerDisconnected += HandlePeerDisconnected;
|
2026-07-12 12:05:31 +01:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 12:33:03 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Raised when an exact subscription is activated or withdrawn. Providers can use
|
|
|
|
|
/// the requested records and schemas to materialize reactive state only while a
|
|
|
|
|
/// consumer needs it; body publishers additionally use the negotiated body route.
|
|
|
|
|
/// </summary>
|
2026-07-19 03:17:11 +01:00
|
|
|
public event Action<CultNetDatabaseSubscriptionDemand>? DemandChanged;
|
|
|
|
|
|
2026-08-19 13:55:03 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Re-evaluates every live peer projection against current authorization and source state.
|
|
|
|
|
/// Records that lost visibility are explicitly removed and body demand is withdrawn while
|
|
|
|
|
/// the request is unauthorized. The subscription intent remains available for a later
|
|
|
|
|
/// authorized generation on the same established peer.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public void Reconcile()
|
|
|
|
|
{
|
|
|
|
|
lock (_lifecycleGate)
|
|
|
|
|
{
|
|
|
|
|
if (_disposed) return;
|
|
|
|
|
foreach (var entry in _requests.ToArray())
|
|
|
|
|
Reconcile(entry.Key, entry.Value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 12:05:31 +01:00
|
|
|
/// <summary>Detaches handlers and releases all active watches.</summary>
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
2026-08-19 13:55:03 +02:00
|
|
|
lock (_lifecycleGate)
|
2026-07-19 03:17:11 +01:00
|
|
|
{
|
2026-08-19 13:55:03 +02:00
|
|
|
if (_disposed) return;
|
|
|
|
|
_disposed = true;
|
|
|
|
|
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_subscribe);
|
|
|
|
|
_server.RemoveCultNetMessageListener<CultNetDatabaseUnsubscribeMessage>(_unsubscribe);
|
|
|
|
|
if (_peerLifecycle != null)
|
|
|
|
|
_peerLifecycle.PeerDisconnected -= HandlePeerDisconnected;
|
|
|
|
|
foreach (var entry in _subscriptions.ToArray())
|
|
|
|
|
Withdraw(entry.Key, sendRemovals: false, forgetRequest: true);
|
|
|
|
|
_subscriptions.Clear();
|
|
|
|
|
_requests.Clear();
|
|
|
|
|
_projections.Clear();
|
2026-07-19 03:17:11 +01:00
|
|
|
}
|
2026-07-12 12:05:31 +01:00
|
|
|
}
|
|
|
|
|
|
2026-07-19 06:40:05 +01:00
|
|
|
private void HandlePeerDisconnected(ICultNetSchemaServerPeer peer)
|
|
|
|
|
{
|
2026-08-19 13:55:03 +02:00
|
|
|
lock (_lifecycleGate)
|
2026-07-19 06:40:05 +01:00
|
|
|
{
|
2026-08-19 13:55:03 +02:00
|
|
|
foreach (var key in _subscriptions.Keys.Where(candidate =>
|
|
|
|
|
SubscriptionKeyComparer.SamePeer(candidate.Peer, peer)).ToArray())
|
|
|
|
|
Withdraw(key, sendRemovals: false, forgetRequest: true);
|
2026-07-19 06:40:05 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 12:05:31 +01:00
|
|
|
private Task HandleSubscribeAsync(CultNetDatabaseSubscribeMessage request, ICultNetSchemaServerPeer peer)
|
|
|
|
|
{
|
|
|
|
|
var subscriptionId = string.IsNullOrWhiteSpace(request.SubscriptionId)
|
|
|
|
|
? request.MessageId
|
|
|
|
|
: request.SubscriptionId;
|
|
|
|
|
if (string.IsNullOrWhiteSpace(subscriptionId))
|
|
|
|
|
{
|
|
|
|
|
peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." });
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
2026-08-19 12:12:52 +02:00
|
|
|
if (_authorizeRequest?.Invoke(request, peer) == false)
|
|
|
|
|
{
|
|
|
|
|
peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription is not authorized for this peer." });
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
2026-07-12 12:05:31 +01:00
|
|
|
|
2026-08-19 13:55:03 +02:00
|
|
|
lock (_lifecycleGate)
|
2026-07-12 12:05:31 +01:00
|
|
|
{
|
2026-08-19 13:55:03 +02:00
|
|
|
var key = new SubscriptionKey(peer, subscriptionId);
|
|
|
|
|
Withdraw(key, sendRemovals: true, forgetRequest: true);
|
|
|
|
|
var projection = new SubscriptionProjectionState();
|
|
|
|
|
_projections[key] = projection;
|
|
|
|
|
_requests[key] = request;
|
|
|
|
|
try
|
2026-08-19 12:12:52 +02:00
|
|
|
{
|
2026-08-19 13:55:03 +02:00
|
|
|
_subscriptions[key] = Watch(request, subscriptionId, peer, key);
|
|
|
|
|
if (request.IncludeSnapshot)
|
|
|
|
|
{
|
|
|
|
|
var snapshot = CreateProjectedSnapshot(request, peer);
|
|
|
|
|
foreach (var entry in snapshot.BySourceRecordKey)
|
|
|
|
|
projection.DeliveredBySourceRecordKey[entry.Key] = entry.Value;
|
|
|
|
|
peer.SendCultNet(new CultNetSnapshotResponseRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = request.MessageId,
|
|
|
|
|
Documents = snapshot.Documents
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
peer.SendCultNet(new CultNetSnapshotResponseRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = request.MessageId,
|
|
|
|
|
Documents = Array.Empty<CultNetRawDocumentRecord>()
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
PublishDemand(request, key, active: true);
|
|
|
|
|
projection.DemandActive = true;
|
2026-08-19 12:12:52 +02:00
|
|
|
}
|
2026-08-19 13:55:03 +02:00
|
|
|
catch
|
2026-08-19 13:22:36 +02:00
|
|
|
{
|
2026-08-19 13:55:03 +02:00
|
|
|
Withdraw(key, sendRemovals: false, forgetRequest: true);
|
|
|
|
|
throw;
|
2026-08-19 13:22:36 +02:00
|
|
|
}
|
2026-07-13 04:19:54 +01:00
|
|
|
}
|
2026-07-12 12:05:31 +01:00
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private Task HandleUnsubscribeAsync(CultNetDatabaseUnsubscribeMessage request, ICultNetSchemaServerPeer peer)
|
|
|
|
|
{
|
|
|
|
|
var subscriptionId = string.IsNullOrWhiteSpace(request.SubscriptionId)
|
|
|
|
|
? request.MessageId
|
|
|
|
|
: request.SubscriptionId;
|
2026-07-19 03:17:11 +01:00
|
|
|
if (!string.IsNullOrWhiteSpace(subscriptionId))
|
|
|
|
|
{
|
|
|
|
|
var key = new SubscriptionKey(peer, subscriptionId);
|
2026-08-19 13:55:03 +02:00
|
|
|
lock (_lifecycleGate)
|
|
|
|
|
Withdraw(key, sendRemovals: false, forgetRequest: true);
|
2026-07-19 03:17:11 +01:00
|
|
|
}
|
2026-07-12 12:05:31 +01:00
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 03:17:11 +01:00
|
|
|
private void PublishDemand(
|
|
|
|
|
CultNetDatabaseSubscribeMessage request,
|
|
|
|
|
SubscriptionKey key,
|
|
|
|
|
bool active)
|
|
|
|
|
{
|
|
|
|
|
DemandChanged?.Invoke(new CultNetDatabaseSubscriptionDemand(
|
|
|
|
|
string.IsNullOrWhiteSpace(request.ConsumerRuntimeId) ? key.Id : request.ConsumerRuntimeId!,
|
|
|
|
|
key.Id,
|
2026-07-19 12:33:03 +01:00
|
|
|
(request.RecordKeys ?? Array.Empty<string>())
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Distinct(StringComparer.Ordinal)
|
|
|
|
|
.ToArray(),
|
|
|
|
|
(request.SchemaIds ?? Array.Empty<string>())
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Distinct(StringComparer.Ordinal)
|
|
|
|
|
.ToArray(),
|
|
|
|
|
(request.BodyIds ?? Array.Empty<string>())
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Distinct(StringComparer.Ordinal)
|
|
|
|
|
.ToArray(),
|
2026-07-19 03:17:11 +01:00
|
|
|
(request.SupportedBodyTransports ?? Array.Empty<string>())
|
|
|
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
|
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
.ToArray(),
|
|
|
|
|
IsSameMachine(key.Peer),
|
|
|
|
|
active));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool IsSameMachine(ICultNetSchemaServerPeer peer)
|
|
|
|
|
{
|
2026-07-19 18:50:22 +01:00
|
|
|
if (peer is ICultNetSchemaServerPeerLocation located && located.RemoteEndPoint is IPEndPoint ip)
|
2026-07-19 03:17:11 +01:00
|
|
|
return IPAddress.IsLoopback(ip.Address);
|
2026-07-19 18:50:22 +01:00
|
|
|
return false;
|
2026-07-19 03:17:11 +01:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 12:05:31 +01:00
|
|
|
private IDisposable Watch(
|
|
|
|
|
CultNetDatabaseSubscribeMessage request,
|
|
|
|
|
string subscriptionId,
|
2026-08-19 13:55:03 +02:00
|
|
|
ICultNetSchemaServerPeer peer,
|
|
|
|
|
SubscriptionKey key)
|
2026-07-12 12:05:31 +01:00
|
|
|
{
|
|
|
|
|
return _database.WatchAllChanges().Subscribe(change =>
|
|
|
|
|
{
|
2026-08-19 13:55:03 +02:00
|
|
|
lock (_lifecycleGate)
|
|
|
|
|
{
|
|
|
|
|
if (_disposed || !_requests.ContainsKey(key) || !_projections.TryGetValue(key, out var projection))
|
|
|
|
|
return;
|
|
|
|
|
if (_authorizeRequest?.Invoke(request, peer) == false)
|
|
|
|
|
{
|
|
|
|
|
Reconcile(key, request);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var sourceRecordKey = ResolveChangeRecordKey(change);
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
var matched = CreateMatchedRecord(change, request, peer);
|
2026-08-19 13:55:03 +02:00
|
|
|
ApplyProjectedChange(
|
|
|
|
|
projection,
|
|
|
|
|
sourceRecordKey,
|
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher
_projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production
caller and ran after authorization with no wire consumer. Removed the field,
constructor parameter, and both call sites in Watch() and
CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a
delegate. The two tests that used projectRecord to simulate a record's
identity changing for the reconcile diff
(DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords,
…ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten
to drive a real rename through the database (delete the old key, put the new
one) instead, so they still prove authorization filtering and the Reconcile()
diff/body-demand behavior.
Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth
schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse
and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate
InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one
matcher landed in commit 1.
Networking tests: 162/162 (was 162/162 after commit 1; no count change, two
tests rewritten not added). Negative grep: _projectRecord|projectRecord empty;
MatchesRequestedSchema gone (one remaining substring hit is
RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this
commit does not touch — collapses in the next commit).
Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs,
CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this
commit; it is large enough to build on its own and follows as commit 2b.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
|
|
|
matched,
|
2026-08-19 13:55:03 +02:00
|
|
|
peer,
|
|
|
|
|
subscriptionId);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void Reconcile(SubscriptionKey key, CultNetDatabaseSubscribeMessage request)
|
|
|
|
|
{
|
|
|
|
|
if (!_projections.TryGetValue(key, out var projection)) return;
|
|
|
|
|
var authorized = _authorizeRequest?.Invoke(request, key.Peer) != false;
|
|
|
|
|
var next = authorized
|
|
|
|
|
? CreateProjectedSnapshot(request, key.Peer)
|
|
|
|
|
: ProjectedSnapshot.Empty;
|
|
|
|
|
|
|
|
|
|
foreach (var previous in projection.DeliveredBySourceRecordKey.ToArray())
|
|
|
|
|
{
|
|
|
|
|
if (!next.BySourceRecordKey.TryGetValue(previous.Key, out var current))
|
|
|
|
|
{
|
|
|
|
|
SendRemoval(key.Peer, key.Id, previous.Value);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!string.Equals(previous.Value.RecordKey, current.RecordKey, StringComparison.Ordinal) ||
|
|
|
|
|
!string.Equals(previous.Value.SchemaId, current.SchemaId, StringComparison.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
SendRemoval(key.Peer, key.Id, previous.Value);
|
|
|
|
|
SendUpsert(key.Peer, key.Id, current, added: true);
|
|
|
|
|
}
|
|
|
|
|
else if (!Equivalent(previous.Value, current))
|
|
|
|
|
{
|
|
|
|
|
SendUpsert(key.Peer, key.Id, current, added: false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
foreach (var current in next.BySourceRecordKey)
|
|
|
|
|
{
|
|
|
|
|
if (!projection.DeliveredBySourceRecordKey.ContainsKey(current.Key))
|
|
|
|
|
SendUpsert(key.Peer, key.Id, current.Value, added: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
projection.DeliveredBySourceRecordKey.Clear();
|
|
|
|
|
foreach (var current in next.BySourceRecordKey)
|
|
|
|
|
projection.DeliveredBySourceRecordKey[current.Key] = current.Value;
|
|
|
|
|
|
|
|
|
|
if (projection.DemandActive != authorized)
|
|
|
|
|
{
|
|
|
|
|
projection.DemandActive = authorized;
|
|
|
|
|
PublishDemand(request, key, authorized);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private ProjectedSnapshot CreateProjectedSnapshot(
|
|
|
|
|
CultNetDatabaseSubscribeMessage request,
|
|
|
|
|
ICultNetSchemaServerPeer peer)
|
|
|
|
|
{
|
|
|
|
|
var snapshot = _database.Documents.CreateRawSnapshotResponse(
|
|
|
|
|
_database.Cache,
|
|
|
|
|
request.MessageId,
|
|
|
|
|
new CultNetSnapshotRequestMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = request.MessageId,
|
|
|
|
|
SchemaIds = request.SchemaIds,
|
|
|
|
|
RecordKeys = request.RecordKeys
|
|
|
|
|
});
|
|
|
|
|
var bySourceRecordKey = new Dictionary<string, CultNetRawDocumentRecord>(StringComparer.Ordinal);
|
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher
_projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production
caller and ran after authorization with no wire consumer. Removed the field,
constructor parameter, and both call sites in Watch() and
CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a
delegate. The two tests that used projectRecord to simulate a record's
identity changing for the reconcile diff
(DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords,
…ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten
to drive a real rename through the database (delete the old key, put the new
one) instead, so they still prove authorization filtering and the Reconcile()
diff/body-demand behavior.
Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth
schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse
and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate
InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one
matcher landed in commit 1.
Networking tests: 162/162 (was 162/162 after commit 1; no count change, two
tests rewritten not added). Negative grep: _projectRecord|projectRecord empty;
MatchesRequestedSchema gone (one remaining substring hit is
RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this
commit does not touch — collapses in the next commit).
Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs,
CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this
commit; it is large enough to build on its own and follows as commit 2b.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
|
|
|
var matchedRecordKeys = new HashSet<string>(StringComparer.Ordinal);
|
2026-08-19 13:55:03 +02:00
|
|
|
foreach (var source in snapshot.Documents)
|
|
|
|
|
{
|
|
|
|
|
var sourceRecordKey = source.RecordKey;
|
|
|
|
|
if (_authorizeRecord?.Invoke(request, peer, sourceRecordKey, source.SchemaId) == false)
|
|
|
|
|
continue;
|
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher
_projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production
caller and ran after authorization with no wire consumer. Removed the field,
constructor parameter, and both call sites in Watch() and
CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a
delegate. The two tests that used projectRecord to simulate a record's
identity changing for the reconcile diff
(DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords,
…ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten
to drive a real rename through the database (delete the old key, put the new
one) instead, so they still prove authorization filtering and the Reconcile()
diff/body-demand behavior.
Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth
schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse
and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate
InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one
matcher landed in commit 1.
Networking tests: 162/162 (was 162/162 after commit 1; no count change, two
tests rewritten not added). Negative grep: _projectRecord|projectRecord empty;
MatchesRequestedSchema gone (one remaining substring hit is
RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this
commit does not touch — collapses in the next commit).
Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs,
CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this
commit; it is large enough to build on its own and follows as commit 2b.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
|
|
|
if (!matchedRecordKeys.Add(source.RecordKey))
|
2026-08-19 13:55:03 +02:00
|
|
|
throw new InvalidOperationException(
|
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher
_projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production
caller and ran after authorization with no wire consumer. Removed the field,
constructor parameter, and both call sites in Watch() and
CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a
delegate. The two tests that used projectRecord to simulate a record's
identity changing for the reconcile diff
(DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords,
…ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten
to drive a real rename through the database (delete the old key, put the new
one) instead, so they still prove authorization filtering and the Reconcile()
diff/body-demand behavior.
Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth
schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse
and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate
InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one
matcher landed in commit 1.
Networking tests: 162/162 (was 162/162 after commit 1; no count change, two
tests rewritten not added). Negative grep: _projectRecord|projectRecord empty;
MatchesRequestedSchema gone (one remaining substring hit is
RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this
commit does not touch — collapses in the next commit).
Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs,
CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this
commit; it is large enough to build on its own and follows as commit 2b.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
|
|
|
$"Database subscription snapshot produced duplicate record key '{source.RecordKey}'.");
|
|
|
|
|
bySourceRecordKey[sourceRecordKey] = source;
|
2026-08-19 13:55:03 +02:00
|
|
|
}
|
|
|
|
|
return new ProjectedSnapshot(bySourceRecordKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ApplyProjectedChange(
|
|
|
|
|
SubscriptionProjectionState projection,
|
|
|
|
|
string sourceRecordKey,
|
|
|
|
|
CultNetRawDocumentRecord? current,
|
|
|
|
|
ICultNetSchemaServerPeer peer,
|
|
|
|
|
string subscriptionId)
|
|
|
|
|
{
|
|
|
|
|
projection.DeliveredBySourceRecordKey.TryGetValue(sourceRecordKey, out var previous);
|
|
|
|
|
if (current == null)
|
|
|
|
|
{
|
|
|
|
|
if (previous != null)
|
2026-08-19 13:22:36 +02:00
|
|
|
{
|
2026-08-19 13:55:03 +02:00
|
|
|
projection.DeliveredBySourceRecordKey.Remove(sourceRecordKey);
|
|
|
|
|
SendRemoval(peer, subscriptionId, previous);
|
2026-08-19 13:22:36 +02:00
|
|
|
}
|
2026-08-19 13:55:03 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (previous == null)
|
|
|
|
|
{
|
|
|
|
|
projection.DeliveredBySourceRecordKey[sourceRecordKey] = current;
|
|
|
|
|
SendUpsert(peer, subscriptionId, current, added: true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!string.Equals(previous.RecordKey, current.RecordKey, StringComparison.Ordinal) ||
|
|
|
|
|
!string.Equals(previous.SchemaId, current.SchemaId, StringComparison.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
SendRemoval(peer, subscriptionId, previous);
|
|
|
|
|
projection.DeliveredBySourceRecordKey[sourceRecordKey] = current;
|
|
|
|
|
SendUpsert(peer, subscriptionId, current, added: true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!Equivalent(previous, current))
|
|
|
|
|
{
|
|
|
|
|
projection.DeliveredBySourceRecordKey[sourceRecordKey] = current;
|
|
|
|
|
SendUpsert(peer, subscriptionId, current, added: false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string ResolveChangeRecordKey(object change)
|
|
|
|
|
{
|
|
|
|
|
var value = change.GetType().GetProperty("Key")?.GetValue(change);
|
|
|
|
|
return value is CultRecordKey key ? key.Value : string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool Equivalent(CultNetRawDocumentRecord left, CultNetRawDocumentRecord right) =>
|
|
|
|
|
string.Equals(left.SchemaId, right.SchemaId, StringComparison.Ordinal) &&
|
|
|
|
|
string.Equals(left.SchemaName, right.SchemaName, StringComparison.Ordinal) &&
|
|
|
|
|
string.Equals(left.SchemaVersion, right.SchemaVersion, StringComparison.Ordinal) &&
|
|
|
|
|
string.Equals(left.SchemaContentHash, right.SchemaContentHash, StringComparison.Ordinal) &&
|
|
|
|
|
string.Equals(left.RecordKey, right.RecordKey, StringComparison.Ordinal) &&
|
|
|
|
|
string.Equals(left.PayloadEncoding, right.PayloadEncoding, StringComparison.Ordinal) &&
|
|
|
|
|
left.Payload.SequenceEqual(right.Payload) &&
|
|
|
|
|
string.Equals(left.SourceRuntimeId, right.SourceRuntimeId, StringComparison.Ordinal) &&
|
|
|
|
|
string.Equals(left.SourceAgentId, right.SourceAgentId, StringComparison.Ordinal) &&
|
|
|
|
|
string.Equals(left.SourceRole, right.SourceRole, StringComparison.Ordinal);
|
|
|
|
|
|
|
|
|
|
private static void SendUpsert(
|
|
|
|
|
ICultNetSchemaServerPeer peer,
|
|
|
|
|
string subscriptionId,
|
|
|
|
|
CultNetRawDocumentRecord document,
|
|
|
|
|
bool added) =>
|
|
|
|
|
peer.SendCultNet(new CultNetDatabaseChangeRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = Guid.NewGuid().ToString("N"),
|
|
|
|
|
SubscriptionId = subscriptionId,
|
|
|
|
|
ChangeKind = added ? "added" : "updated",
|
|
|
|
|
Document = document
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
private static void SendRemoval(
|
|
|
|
|
ICultNetSchemaServerPeer peer,
|
|
|
|
|
string subscriptionId,
|
|
|
|
|
CultNetRawDocumentRecord document) =>
|
|
|
|
|
peer.SendCultNet(new CultNetDatabaseChangeRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = Guid.NewGuid().ToString("N"),
|
|
|
|
|
SubscriptionId = subscriptionId,
|
|
|
|
|
ChangeKind = "removed",
|
|
|
|
|
RecordKey = document.RecordKey,
|
|
|
|
|
SchemaId = document.SchemaId
|
2026-07-12 12:05:31 +01:00
|
|
|
});
|
2026-08-19 13:55:03 +02:00
|
|
|
|
|
|
|
|
private void Withdraw(SubscriptionKey key, bool sendRemovals, bool forgetRequest)
|
|
|
|
|
{
|
|
|
|
|
if (_subscriptions.TryRemove(key, out var subscription))
|
|
|
|
|
subscription.Dispose();
|
|
|
|
|
if (_projections.TryRemove(key, out var projection))
|
|
|
|
|
{
|
|
|
|
|
if (sendRemovals)
|
|
|
|
|
{
|
|
|
|
|
foreach (var delivered in projection.DeliveredBySourceRecordKey.Values)
|
|
|
|
|
SendRemoval(key.Peer, key.Id, delivered);
|
|
|
|
|
}
|
|
|
|
|
if (projection.DemandActive && _requests.TryGetValue(key, out var activeRequest))
|
|
|
|
|
PublishDemand(activeRequest, key, active: false);
|
|
|
|
|
}
|
|
|
|
|
if (forgetRequest)
|
|
|
|
|
_requests.TryRemove(key, out _);
|
2026-07-12 12:05:31 +01:00
|
|
|
}
|
|
|
|
|
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
// One evaluator (docs/cultnet-selection-cut.md, D3/D4): a v0 subscribe request lowers into a
|
|
|
|
|
// Selection with no engine of its own, matched and projected exactly as a v1 request would be.
|
|
|
|
|
// Record-level authorization (_authorizeRecord) is not selection and stays layered on top.
|
|
|
|
|
private CultNetRawDocumentRecord? CreateMatchedRecord(
|
2026-08-19 12:12:52 +02:00
|
|
|
object change,
|
|
|
|
|
CultNetDatabaseSubscribeMessage request,
|
|
|
|
|
ICultNetSchemaServerPeer peer)
|
2026-07-12 12:05:31 +01:00
|
|
|
{
|
|
|
|
|
var changeType = change.GetType();
|
|
|
|
|
var key = (CultRecordKey)(changeType.GetProperty("Key")?.GetValue(change) ?? new CultRecordKey(""));
|
|
|
|
|
var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated);
|
|
|
|
|
var document = changeType.GetProperty("Document")?.GetValue(change);
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
var forDescriptor = document ?? changeType.GetProperty("PreviousDocument")?.GetValue(change);
|
|
|
|
|
if (forDescriptor == null)
|
2026-08-19 12:12:52 +02:00
|
|
|
return null;
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
|
|
|
|
|
var descriptor = _database.Cache.Registry.GetRequired(forDescriptor.GetType());
|
|
|
|
|
var selection = new CultNetSelection
|
2026-07-12 12:05:31 +01:00
|
|
|
{
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
Schemas = request.SchemaIds,
|
|
|
|
|
Keys = request.RecordKeys,
|
|
|
|
|
Projection = CultNetSelectionProjections.Document
|
2026-07-12 12:05:31 +01:00
|
|
|
};
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
var binding = _database.Documents.GetByDocumentType(descriptor.DocumentType);
|
|
|
|
|
var effectiveSelection = CultNetSchemaAliasMatching.WithBindingSchemaAlias(selection, descriptor, binding?.SchemaId);
|
|
|
|
|
if (!CultNetSelectionEvaluator.Matches(descriptor, key, document, effectiveSelection))
|
|
|
|
|
return null;
|
|
|
|
|
if (_authorizeRecord?.Invoke(request, peer, key.Value, descriptor.SchemaId) == false)
|
|
|
|
|
return null;
|
2026-07-19 05:54:24 +01:00
|
|
|
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
if (kind == CultNetDatabaseChangeKind.Removed || document == null)
|
|
|
|
|
return null;
|
2026-07-19 05:54:24 +01:00
|
|
|
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
return _database.Documents.ToRawRecord(descriptor, key, document, DateTimeOffset.UtcNow.ToString("O"));
|
2026-07-12 12:05:31 +01:00
|
|
|
}
|
|
|
|
|
|
2026-08-19 13:55:03 +02:00
|
|
|
private sealed class SubscriptionProjectionState
|
|
|
|
|
{
|
|
|
|
|
public Dictionary<string, CultNetRawDocumentRecord> DeliveredBySourceRecordKey { get; } =
|
|
|
|
|
new(StringComparer.Ordinal);
|
|
|
|
|
public bool DemandActive { get; set; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class ProjectedSnapshot
|
|
|
|
|
{
|
|
|
|
|
public ProjectedSnapshot(Dictionary<string, CultNetRawDocumentRecord> bySourceRecordKey)
|
|
|
|
|
{
|
|
|
|
|
BySourceRecordKey = bySourceRecordKey;
|
|
|
|
|
Documents = bySourceRecordKey.Values.ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static ProjectedSnapshot Empty { get; } =
|
|
|
|
|
new(new Dictionary<string, CultNetRawDocumentRecord>(StringComparer.Ordinal));
|
|
|
|
|
public Dictionary<string, CultNetRawDocumentRecord> BySourceRecordKey { get; }
|
|
|
|
|
public CultNetRawDocumentRecord[] Documents { get; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 12:05:31 +01:00
|
|
|
private sealed class SubscriptionKey
|
|
|
|
|
{
|
|
|
|
|
public SubscriptionKey(ICultNetSchemaServerPeer peer, string id)
|
|
|
|
|
{
|
|
|
|
|
Peer = peer;
|
|
|
|
|
Id = id;
|
|
|
|
|
}
|
|
|
|
|
public ICultNetSchemaServerPeer Peer { get; }
|
|
|
|
|
public string Id { get; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class SubscriptionKeyComparer : IEqualityComparer<SubscriptionKey>
|
|
|
|
|
{
|
|
|
|
|
public static SubscriptionKeyComparer Instance { get; } = new SubscriptionKeyComparer();
|
|
|
|
|
public bool Equals(SubscriptionKey? x, SubscriptionKey? y) =>
|
2026-07-19 03:17:11 +01:00
|
|
|
x != null && y != null && SamePeer(x.Peer, y.Peer) && string.Equals(x.Id, y.Id, StringComparison.Ordinal);
|
2026-07-12 12:05:31 +01:00
|
|
|
public int GetHashCode(SubscriptionKey value) =>
|
2026-07-19 03:17:11 +01:00
|
|
|
(PeerHash(value.Peer) * 397) ^ StringComparer.Ordinal.GetHashCode(value.Id);
|
|
|
|
|
|
2026-07-19 06:40:05 +01:00
|
|
|
internal static bool SamePeer(ICultNetSchemaServerPeer left, ICultNetSchemaServerPeer right)
|
2026-07-19 03:17:11 +01:00
|
|
|
{
|
|
|
|
|
if (ReferenceEquals(left, right)) return true;
|
|
|
|
|
if (left is RudpCultNetSchemaServerPeer leftRudp && right is RudpCultNetSchemaServerPeer rightRudp)
|
|
|
|
|
return ReferenceEquals(leftRudp.TransportPeer, rightRudp.TransportPeer);
|
|
|
|
|
if (left is CultNetServerPeer leftLite && right is CultNetServerPeer rightLite)
|
|
|
|
|
return ReferenceEquals(leftLite.Peer, rightLite.Peer);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int PeerHash(ICultNetSchemaServerPeer peer)
|
|
|
|
|
{
|
|
|
|
|
if (peer is RudpCultNetSchemaServerPeer rudp)
|
|
|
|
|
return RuntimeHelpers.GetHashCode(rudp.TransportPeer);
|
|
|
|
|
if (peer is CultNetServerPeer lite)
|
|
|
|
|
return RuntimeHelpers.GetHashCode(lite.Peer);
|
|
|
|
|
return RuntimeHelpers.GetHashCode(peer);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 12:33:03 +01:00
|
|
|
/// <summary>One active or withdrawn exact state subscription and its optional hot-body route.</summary>
|
2026-07-19 03:17:11 +01:00
|
|
|
public sealed class CultNetDatabaseSubscriptionDemand
|
|
|
|
|
{
|
|
|
|
|
public CultNetDatabaseSubscriptionDemand(
|
|
|
|
|
string consumerRuntimeId,
|
|
|
|
|
string subscriptionId,
|
|
|
|
|
IReadOnlyList<string> bodyIds,
|
|
|
|
|
IReadOnlyList<string> supportedBodyTransports,
|
|
|
|
|
bool sameMachine,
|
|
|
|
|
bool active)
|
2026-07-19 12:33:03 +01:00
|
|
|
: this(
|
|
|
|
|
consumerRuntimeId,
|
|
|
|
|
subscriptionId,
|
|
|
|
|
Array.Empty<string>(),
|
|
|
|
|
Array.Empty<string>(),
|
|
|
|
|
bodyIds,
|
|
|
|
|
supportedBodyTransports,
|
|
|
|
|
sameMachine,
|
|
|
|
|
active)
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public CultNetDatabaseSubscriptionDemand(
|
|
|
|
|
string consumerRuntimeId,
|
|
|
|
|
string subscriptionId,
|
|
|
|
|
IReadOnlyList<string> recordKeys,
|
|
|
|
|
IReadOnlyList<string> schemaIds,
|
|
|
|
|
IReadOnlyList<string> bodyIds,
|
|
|
|
|
IReadOnlyList<string> supportedBodyTransports,
|
|
|
|
|
bool sameMachine,
|
|
|
|
|
bool active)
|
2026-07-19 03:17:11 +01:00
|
|
|
{
|
|
|
|
|
ConsumerRuntimeId = consumerRuntimeId;
|
|
|
|
|
SubscriptionId = subscriptionId;
|
2026-07-19 12:33:03 +01:00
|
|
|
RecordKeys = recordKeys;
|
|
|
|
|
SchemaIds = schemaIds;
|
2026-07-19 03:17:11 +01:00
|
|
|
BodyIds = bodyIds;
|
|
|
|
|
SupportedBodyTransports = supportedBodyTransports;
|
|
|
|
|
SameMachine = sameMachine;
|
|
|
|
|
Active = active;
|
2026-07-12 12:05:31 +01:00
|
|
|
}
|
2026-07-19 03:17:11 +01:00
|
|
|
|
|
|
|
|
public string ConsumerRuntimeId { get; }
|
|
|
|
|
public string SubscriptionId { get; }
|
2026-07-19 12:33:03 +01:00
|
|
|
public IReadOnlyList<string> RecordKeys { get; }
|
|
|
|
|
public IReadOnlyList<string> SchemaIds { get; }
|
2026-07-19 03:17:11 +01:00
|
|
|
public IReadOnlyList<string> BodyIds { get; }
|
|
|
|
|
public IReadOnlyList<string> SupportedBodyTransports { get; }
|
|
|
|
|
public bool SameMachine { get; }
|
|
|
|
|
public bool Active { get; }
|
2026-07-12 12:05:31 +01:00
|
|
|
}
|
|
|
|
|
}
|