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>
This commit is contained in:
Meta Vens 2026-09-22 02:31:42 +02:00
commit 17d10e017f
19 changed files with 1976 additions and 477 deletions

View file

@ -0,0 +1,24 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-subscribe.v1.schema.json",
"title": "CultNet Database Subscribe Message v1",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId", "selection"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.database_subscribe.v1" },
"messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 },
"selection": { "$ref": "cultnet.selection.schema.json" },
"includeSnapshot": { "type": "boolean", "default": true },
"consumerRuntimeId": { "type": "string", "minLength": 1 },
"bodyIds": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"supportedBodyTransports": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -0,0 +1,79 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.selection.schema.json",
"title": "CultNet Typed Selection",
"type": "object",
"additionalProperties": false,
"properties": {
"schemas": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"keys": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"fields": {
"type": "array",
"items": { "$ref": "#/$defs/fieldPredicate" }
},
"cites": { "$ref": "#/$defs/citation" },
"cited": { "$ref": "#/$defs/incoming" },
"projection": {
"type": "string",
"enum": ["header", "document"],
"default": "header"
},
"descending": { "type": "boolean", "default": false },
"limit": { "type": "integer", "minimum": 1, "maximum": 200 },
"cursor": { "type": "string" }
},
"$defs": {
"recordRef": {
"type": "object",
"required": ["schemaId", "recordKey"],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "minLength": 1 },
"recordKey": { "type": "string", "minLength": 1 }
}
},
"fieldPredicate": {
"type": "object",
"required": ["index", "op"],
"additionalProperties": false,
"properties": {
"index": { "type": "string", "minLength": 1 },
"op": { "type": "string", "enum": ["any_of", "lt", "le", "ge", "gt"] },
"values": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
},
"number": { "type": "number" }
},
"oneOf": [
{ "required": ["values"], "properties": { "op": { "const": "any_of" } } },
{ "required": ["number"], "properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } } }
]
},
"citation": {
"type": "object",
"required": ["target"],
"additionalProperties": false,
"properties": {
"target": { "$ref": "#/$defs/recordRef" },
"role": { "type": "string", "minLength": 1 }
}
},
"incoming": {
"type": "object",
"required": ["role", "exists"],
"additionalProperties": false,
"properties": {
"role": { "type": "string", "minLength": 1 },
"exists": { "type": "boolean" }
}
}
}
}

View file

@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-request.v1.schema.json",
"title": "CultNet Snapshot Request Message v1",
"type": "object",
"required": ["schemaVersion", "messageId", "selection"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.snapshot_request.v1" },
"messageId": { "type": "string", "minLength": 1 },
"selection": { "$ref": "cultnet.selection.schema.json" },
"shardId": { "type": "string", "minLength": 1 },
"shardEpoch": { "type": "integer" }
}
}

View file

@ -0,0 +1,45 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json",
"title": "CultNet Snapshot Response Raw Message v1",
"type": "object",
"required": ["schemaVersion", "messageId", "matched", "asOf"],
"additionalProperties": false,
"oneOf": [
{ "required": ["headers"] },
{ "required": ["documents"] }
],
"properties": {
"schemaVersion": { "const": "cultnet.snapshot_response_raw.v1" },
"messageId": { "type": "string", "minLength": 1 },
"matched": { "type": "integer", "minimum": 0 },
"asOf": { "type": "integer", "minimum": 0 },
"next": { "type": "string" },
"headers": {
"type": "array",
"items": { "$ref": "cultnet.raw-document-record.schema.json" }
},
"documents": {
"type": "array",
"items": { "$ref": "cultnet.raw-document-record.schema.json" }
},
"edges": {
"type": "array",
"items": {
"type": "object",
"required": ["from", "role", "to"],
"additionalProperties": false,
"properties": {
"from": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
"role": { "type": "string", "minLength": 1 },
"to": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
"payloadEncoding": { "type": "string" },
"payload": { "type": "string" }
}
}
},
"shardId": { "type": "string", "minLength": 1 },
"shardEpoch": { "type": "integer" },
"shardLogSequence": { "type": "integer" }
}
}

View file

@ -0,0 +1,54 @@
# Entries for scripts/mutate-dotnet.ps1: CultNet typed selection, Cut 1, commit 0 - the cache's public
# read surface (docs/cultnet-selection-cut.md, section 6, D8/D10/D11). Target:
# src/GameCult.Caching/CultCache.cs. Test project: tests/GameCult.Caching.Tests.
@(
@{
Id = 'CACHE-D8-Revert'
Rule = 'D8/S22: IsNumeric is derived from the closed CLR numeric set; a decimal-typed member must be numeric.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.IsNumericAgreesForANullableNumericMemberAndADecimalMember'
Old = "typeof(int), typeof(uint), typeof(long), typeof(ulong),`n typeof(float), typeof(double), typeof(decimal)"
New = "typeof(int), typeof(uint), typeof(long), typeof(ulong),`n typeof(float), typeof(double)"
},
@{
Id = 'CACHE-D8-Loosening'
Rule = 'D8/S22: a string-typed member must not be treated as numeric.'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.IsNumericIsFalseForAStringAlias'
Old = "var underlying = Nullable.GetUnderlyingType(type) ?? type;`n return NumericClrTypes.Contains(underlying);"
New = 'return true;'
},
@{
Id = 'CACHE-D10-Revert'
Rule = 'D10: two differently-named members sharing one index alias are refused by name at registration.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName'
Old = " // D10: two differently-named members that independently declare the same index alias. A`n // hiding member sharing one slot is already caught above; this is two distinct members whose`n // alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently.`n foreach (var group in keyed`n .Select(entry => (Entry: entry, Alias: ResolveIndexAlias(entry.Member)))`n .Where(pair => pair.Alias != null)`n .GroupBy(pair => pair.Alias, StringComparer.Ordinal)`n .Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1)`n .OrderBy(group => group.Key, StringComparer.Ordinal))`n {`n var pair = group.Take(2).ToArray();`n rejections.Add(DuplicateIndexAliasMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key!));`n }`n"
New = ''
},
@{
Id = 'CACHE-D10-Loosening'
Rule = 'D10: the collision groups by IndexAlias, not by Member.Name (which would refuse nothing HiddenMemberMessage does not already refuse).'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName'
Old = '.GroupBy(pair => pair.Alias, StringComparer.Ordinal)'
New = '.GroupBy(pair => pair.Entry.Member.Name, StringComparer.Ordinal)'
},
@{
Id = 'CACHE-D11-Revert'
Rule = 'D11: a dictionary reference is walked as edges keyed by CultRecordRef, not skipped as an unrecognised many-shape.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues'
Old = 'if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))'
New = 'if (false && TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))'
},
@{
Id = 'CACHE-D11-Loosening'
Rule = 'D11/S19: a dictionary reference edge carries its value as payload, not null (two entries with different payload bytes must compare unequal).'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues'
Old = 'results.Add((reference.Key, valueProperty.GetValue(entry)));'
New = 'results.Add((reference.Key, (object?)null));'
}
)

View file

@ -1,198 +0,0 @@
# Mutation check for CultNet typed selection, Cut 1, commit 0: the cache's
# public read surface (docs/cultnet-selection-cut.md, section 6, D8/D10/D11).
#
# This is a dedicated runner for src/GameCult.Caching, not the shared
# tools/eureka-mutations.ps1 harness: that harness's Invoke-Cargo parses
# `cargo test` output and appends `-- --exact <test>`, and is not usable
# against `dotnet test`/NUnit. It is also not scripts/mutate-cultmesh.mjs -
# that file is the QUIC campaign's, live and dirty in this tree, and this
# campaign does not edit it (docs/cultnet-selection-cut.md, section 10: "this
# campaign adds its entries in its own file ... and does not edit the QUIC
# campaign's targets - Self decides the exact split at landing").
#
# Same discipline as both: every entry mutates CultCache.cs, is applied as a
# byte-exact splice against one anchor that must match exactly once, restored
# from the original bytes (never from git) with a SHA-256 check, and a
# sidecar (<target>.mutation-original) protects a hard kill mid-mutation. M0
# is a no-op control: the target round-trips through the read/write path
# unchanged and the unmutated suite is green, before any entry runs.
#
# powershell -File scripts/mutate-cultnet-selection-caching.ps1
#
# Exit code: 0 when every entry's revert and loosening (where one is named)
# killed the test named in Killer, non-zero otherwise. Output for a run that
# did not go green names which entry and which mutant survived.
$ErrorActionPreference = 'Stop'
$repo = Resolve-Path (Join-Path $PSScriptRoot '..')
$target = Join-Path $repo 'src\GameCult.Caching\CultCache.cs'
$sidecar = "$target.mutation-original"
$testProject = Join-Path $repo 'tests\GameCult.Caching.Tests\GameCult.Caching.Tests.csproj'
$utf8 = [System.Text.UTF8Encoding]::new($false)
function Read-Bytes([string]$path) { [System.IO.File]::ReadAllBytes($path) }
function Get-BytesHash([byte[]]$bytes) {
([System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join ''
}
function Write-Text([string]$path, [string]$text) {
[System.IO.File]::WriteAllBytes($path, [byte[]]($utf8.GetPreamble() + $utf8.GetBytes($text)))
}
function Run-Tests([string]$filter) {
$args = @('test', $testProject, '-c', 'Debug', '--filter', $filter, '--nologo')
$output = & dotnet @args 2>&1 | Out-String
Write-Host $output
$passedMatch = [regex]::Match($output, 'Passed:\s+(\d+)')
$failedMatch = [regex]::Match($output, 'Failed:\s+(\d+)')
$compiled = -not ($output -match 'error CS\d+')
$passedLine = $output -match 'Passed!'
$failedLine = $output -match 'Failed!'
[pscustomobject]@{
Compiled = $compiled
Green = $compiled -and $passedLine -and -not $failedLine -and $passedMatch.Success -and [int]$passedMatch.Groups[1].Value -ge 1
Passed = if ($passedMatch.Success) { [int]$passedMatch.Groups[1].Value } else { 0 }
Failed = if ($failedMatch.Success) { [int]$failedMatch.Groups[1].Value } else { 0 }
}
}
# --- Repair a run that died mid-mutation ------------------------------------
if (Test-Path -LiteralPath $sidecar) {
$original = Read-Bytes $sidecar
[System.IO.File]::WriteAllBytes($target, $original)
Remove-Item -LiteralPath $sidecar -Force
Write-Host "Repaired: a previous run left $sidecar; $target restored from it and the sidecar removed."
}
# --- M0: no-op control -------------------------------------------------------
Write-Host '--- M0: no-op control'
$originalBytes = Read-Bytes $target
$originalHash = Get-BytesHash $originalBytes
$originalText = $utf8.GetString($originalBytes)
$reencoded = [byte[]]($utf8.GetPreamble() + $utf8.GetBytes($originalText))
if ((Get-BytesHash $reencoded) -ne $originalHash) {
throw "harness broken: $target does not round-trip through the UTF-8 read/write path byte for byte. Nothing was written and no entry ran."
}
[System.IO.File]::WriteAllBytes($sidecar, $originalBytes)
try {
Write-Text $target $originalText
if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) {
throw "harness broken: writing $target back through the harness path changed its bytes."
}
}
finally {
[System.IO.File]::WriteAllBytes($target, $originalBytes)
if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) { throw "RESTORE FAILED after M0; original SHA-256 $originalHash is kept in $sidecar." }
Remove-Item -LiteralPath $sidecar -Force
}
$control = Run-Tests 'FullyQualifiedName~CultDocumentSelectionSurfaceTests'
if (-not $control.Green) {
throw "harness broken: the caching suite is not green unmutated (compiled=$($control.Compiled) passed=$($control.Passed) failed=$($control.Failed)). No entry ran."
}
Write-Host "M0: green, $($control.Passed) passed"
# --- Entries -----------------------------------------------------------------
$entries = @(
[pscustomobject]@{
Id = 'CACHE-D8-Revert'
Rule = 'D8/S22: IsNumeric is derived from the closed CLR numeric set; a decimal-typed member must be numeric.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.IsNumericAgreesForANullableNumericMemberAndADecimalMember'
Old = "typeof(int), typeof(uint), typeof(long), typeof(ulong),`n typeof(float), typeof(double), typeof(decimal)"
New = "typeof(int), typeof(uint), typeof(long), typeof(ulong),`n typeof(float), typeof(double)"
},
[pscustomobject]@{
Id = 'CACHE-D8-Loosening'
Rule = 'D8/S22: a string-typed member must not be treated as numeric.'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.IsNumericIsFalseForAStringAlias'
Old = "var underlying = Nullable.GetUnderlyingType(type) ?? type;`n return NumericClrTypes.Contains(underlying);"
New = "return true;"
},
[pscustomobject]@{
Id = 'CACHE-D10-Revert'
Rule = 'D10: two differently-named members sharing one index alias are refused by name at registration.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName'
Old = " // D10: two differently-named members that independently declare the same index alias. A`n // hiding member sharing one slot is already caught above; this is two distinct members whose`n // alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently.`n foreach (var group in keyed`n .Select(entry => (Entry: entry, Alias: ResolveIndexAlias(entry.Member)))`n .Where(pair => pair.Alias != null)`n .GroupBy(pair => pair.Alias, StringComparer.Ordinal)`n .Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1)`n .OrderBy(group => group.Key, StringComparer.Ordinal))`n {`n var pair = group.Take(2).ToArray();`n rejections.Add(DuplicateIndexAliasMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key!));`n }`n"
New = ""
},
[pscustomobject]@{
Id = 'CACHE-D10-Loosening'
Rule = 'D10: the collision groups by IndexAlias, not by Member.Name (which would refuse nothing HiddenMemberMessage does not already refuse).'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName'
Old = '.GroupBy(pair => pair.Alias, StringComparer.Ordinal)'
New = '.GroupBy(pair => pair.Entry.Member.Name, StringComparer.Ordinal)'
},
[pscustomobject]@{
Id = 'CACHE-D11-Revert'
Rule = 'D11: a dictionary reference is walked as edges keyed by CultRecordRef, not skipped as an unrecognised many-shape.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues'
Old = 'if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))'
New = 'if (false && TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))'
},
[pscustomobject]@{
Id = 'CACHE-D11-Loosening'
Rule = 'D11/S19: a dictionary reference edge carries its value as payload, not null (two entries with different payload bytes must compare unequal).'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues'
Old = 'results.Add((reference.Key, valueProperty.GetValue(entry)));'
New = 'results.Add((reference.Key, (object?)null));'
}
)
$exitCode = 0
foreach ($entry in $entries) {
Write-Host "--- $($entry.Id) ($($entry.Mutant)): $($entry.Rule)"
$text = $utf8.GetString((Read-Bytes $target))
$old = $entry.Old -replace "`r`n", "`n"
$normalized = $text -replace "`r`n", "`n"
$count = 0
$at = $normalized.IndexOf($old, [System.StringComparison]::Ordinal)
while ($at -ge 0) { $count++; $at = $normalized.IndexOf($old, $at + 1, [System.StringComparison]::Ordinal) }
if ($count -ne 1) {
throw "$($entry.Id): anchor matches $count times, expected exactly 1."
}
$mutatedNormalized = $normalized.Replace($old, ($entry.New -replace "`r`n", "`n"))
$eol = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" }
$mutatedText = $mutatedNormalized -replace "`n", $eol
$originalBytes = Read-Bytes $target
[System.IO.File]::WriteAllBytes($sidecar, $originalBytes)
$verdict = 'NO VERDICT'
try {
Write-Text $target $mutatedText
$run = Run-Tests $entry.Killer
if (-not $run.Compiled) {
$verdict = 'NO VERDICT (did not compile)'
}
elseif ($run.Failed -ge 1) {
$verdict = 'KILLED'
}
else {
$verdict = 'SURVIVED'
}
}
finally {
[System.IO.File]::WriteAllBytes($target, $originalBytes)
if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) {
Write-Host "RESTORE FAILED: $target not restored; original SHA-256 $originalHash kept in $sidecar."
throw "restore failed for $($entry.Id)"
}
Remove-Item -LiteralPath $sidecar -Force
}
Write-Host "$($entry.Id): $verdict"
if ($verdict -ne 'KILLED') { $exitCode = 1 }
}
# Restore the suite to green once more as a final sanity check.
$final = Run-Tests 'FullyQualifiedName~CultDocumentSelectionSurfaceTests'
if (-not $final.Green) { throw "post-run sanity check: the caching suite is not green after restore (passed=$($final.Passed) failed=$($final.Failed))." }
Write-Host "Final: green, $($final.Passed) passed"
exit $exitCode

View file

@ -0,0 +1,83 @@
# Entries for scripts/mutate-dotnet.ps1: CultNet typed selection, Cut 1, commit 1 - the evaluator
# (docs/cultnet-selection-cut.md, section 10, S3/S5/S7/S16/S18). Target:
# src/GameCult.Networking/CultNetSelectionEvaluator.cs. Test project: tests/GameCult.Networking.Tests.
#
# Not exhaustive of S1-S23: this covers order, cursor freshness, the one negation, numeric boundaries
# and the reference_outside_target refusal. S1/S2/S6/S23 are pinned by tests but not yet mutation-run
# here; S9/S10-S15 (subscription reconcile, v0 lowering, cross-runtime) and S19/S21/S22 (cache layer)
# are covered elsewhere or not yet (see the Hands report for commit 1).
@(
@{
Id = 'NET-S3-Revert'
Rule = 'S3: order reverses under descending.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionEvaluatorTests.EvaluatorOrdersByOrdinalThenIdentityAndReverses'
Old = 'var ordered = (selection.Descending'
New = 'var ordered = (false'
},
@{
Id = 'NET-S5-Stale-Revert'
Rule = 'S5: a cursor from a moved asOf is refused cursor_stale.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionEvaluatorTests.EvaluatorRefusesAStaleCursorAndAMismatchedSelection'
Old = 'if (cursor.AsOf != asOf)'
New = 'if (false)'
},
@{
Id = 'NET-S5-Stale-Loosening'
Rule = 'S5: staleness compares equality, not only "cursor is newer than the current asOf".'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionEvaluatorTests.EvaluatorRefusesAStaleCursorAndAMismatchedSelection'
Old = 'if (cursor.AsOf != asOf)'
New = 'if (cursor.AsOf > asOf)'
},
@{
Id = 'NET-S5-Invalid-Revert'
Rule = 'S5: a cursor whose digest does not match this selection is refused cursor_invalid.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionEvaluatorTests.EvaluatorRefusesAStaleCursorAndAMismatchedSelection'
Old = "if (cursor.Digest != CultNetSelectionCursor.ComputeDigest(selection))`n throw new CultNetSelectionCursorException(`"cursor_invalid`", `"The cursor's selection digest does not match this selection.`");"
New = ''
},
@{
Id = 'NET-S7-Revert'
Rule = 'S7: cited { exists } is honoured, not ignored.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionEvaluatorTests.EvaluatorNegatedIncomingEdgeIsTheOnlyNegation'
Old = 'candidates = candidates.Where(row => incoming.Contains(row.Key.Value) == exists);'
New = 'candidates = candidates.Where(row => incoming.Contains(row.Key.Value) || true);'
},
@{
Id = 'NET-S7-Loosening'
Rule = 'S7: exists:true and exists:false must answer opposite sets, not the same test inverted twice.'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionEvaluatorTests.EvaluatorNegatedIncomingEdgeIsTheOnlyNegation'
Old = 'candidates = candidates.Where(row => incoming.Contains(row.Key.Value) == exists);'
New = 'candidates = candidates.Where(row => incoming.Contains(row.Key.Value) != exists);'
},
@{
Id = 'NET-S16-Le-Revert'
Rule = 'S16: le includes the equal boundary.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionEvaluatorTests.EvaluatorComparesNumbersAtTheBoundaryForEachOfTheFourOperators'
Old = 'CultNetSelectionOperator.Le => left <= right,'
New = 'CultNetSelectionOperator.Le => left < right,'
},
@{
Id = 'NET-S16-Ge-Revert'
Rule = 'S16: ge includes the equal boundary.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionEvaluatorTests.EvaluatorComparesNumbersAtTheBoundaryForEachOfTheFourOperators'
Old = 'CultNetSelectionOperator.Ge => left >= right,'
New = 'CultNetSelectionOperator.Ge => left > right,'
},
@{
Id = 'NET-S18-Revert'
Rule = 'D9/S18: an edge naming a row outside its reference''s declared target refuses the selection.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionEvaluatorTests.EvaluatorRefusesAnEdgeOutsideItsDeclaredTarget'
Old = 'throw new CultNetSelectionReferenceOutsideTargetException(from.Descriptor.SchemaId, from.Key.Value, role, to.Descriptor.SchemaId, to.Key.Value);'
New = 'return;'
}
)

194
scripts/mutate-dotnet.ps1 Normal file
View file

@ -0,0 +1,194 @@
# Generic mutation runner for dotnet/NUnit suites - the CultNet selection campaign's counterpart to
# tools/eureka-mutations.ps1, whose Invoke-Cargo parses `cargo test` output and appends `-- --exact
# <test>`, and is therefore not usable against `dotnet test`. This is not scripts/mutate-cultmesh.mjs
# either - that file is the QUIC campaign's, live and dirty in this tree, and per
# docs/cultnet-selection-cut.md section 10 this campaign adds its entries in its own file rather than
# editing the QUIC campaign's targets.
#
# One copy serves every dotnet target in this campaign (and, once proven out, any future one): pass
# -Target, -TestProject and -Entries. Same discipline as the cargo harness: every entry is a byte-exact
# splice against one anchor that must match exactly once, restored from the original bytes (never from
# git) with a SHA-256 check per target, a sidecar (<target>.mutation-original) protects a hard kill
# mid-mutation, and M0 proves the read/write path round-trips and the unmutated suite is green before
# any entry runs.
#
# powershell -File scripts/mutate-dotnet.ps1 `
# -Target src\GameCult.Caching\CultCache.cs `
# -TestProject tests\GameCult.Caching.Tests\GameCult.Caching.Tests.csproj `
# -Entries scripts\mutate-cultnet-selection-caching.entries.ps1 `
# -ControlFilter 'FullyQualifiedName~CultDocumentSelectionSurfaceTests'
#
# -Target one or more files entries mutate, relative to the repo root. An entry with no `File`
# uses the sole target; an entry with more than one target names each edit's `File`.
# -TestProject the .csproj `dotnet test` runs. One project per invocation; entries needing another
# project are a separate run (as networking and caching already are).
# -Entries a .ps1 that returns an array of `@{ Id; Rule; Mutant; Killer; Old; New; File? }` (or
# `Edits = @(@{ File; Old; New }, ...)` for a multi-file entry). `Killer` is the
# `dotnet test --filter` value for the one test that must fail under this mutant.
# -ControlFilter the `dotnet test --filter` M0 runs to prove the suite is green unmutated. Defaults to
# running the whole project.
# -Repo repo root -Target/-TestProject/-Entries are relative to. Defaults to this script's
# parent's parent (scripts/..).
#
# Exit code: 0 when every entry's mutant was KILLED, non-zero otherwise (SURVIVED or NO VERDICT).
param(
[Parameter(Mandatory = $true)] [string[]] $Target,
[Parameter(Mandatory = $true)] [string] $TestProject,
[Parameter(Mandatory = $true)] [string] $Entries,
[string] $ControlFilter,
[string] $Repo
)
$ErrorActionPreference = 'Stop'
$repo = if ($Repo) { (Resolve-Path -LiteralPath $Repo).Path } else { (Resolve-Path (Join-Path $PSScriptRoot '..')).Path }
$testProjectPath = Join-Path $repo $TestProject
$utf8 = [System.Text.UTF8Encoding]::new($false)
function Read-Bytes([string]$path) { [System.IO.File]::ReadAllBytes($path) }
function Get-BytesHash([byte[]]$bytes) {
([System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join ''
}
function Write-Text([string]$path, [string]$text) {
[System.IO.File]::WriteAllBytes($path, [byte[]]($utf8.GetPreamble() + $utf8.GetBytes($text)))
}
function Get-SidecarPath([string]$path) { "$path.mutation-original" }
function Run-Tests([string]$filter) {
$args = @('test', $testProjectPath, '-c', 'Debug', '--nologo')
if ($filter) { $args += @('--filter', $filter) }
$output = & dotnet @args 2>&1 | Out-String
Write-Host $output
$passedMatch = [regex]::Match($output, 'Passed:\s+(\d+)')
$failedMatch = [regex]::Match($output, 'Failed:\s+(\d+)')
$compiled = -not ($output -match 'error CS\d+')
$passedLine = $output -match 'Passed!'
$failedLine = $output -match 'Failed!'
[pscustomobject]@{
Compiled = $compiled
Green = $compiled -and $passedLine -and -not $failedLine -and $passedMatch.Success -and [int]$passedMatch.Groups[1].Value -ge 1
Passed = if ($passedMatch.Success) { [int]$passedMatch.Groups[1].Value } else { 0 }
Failed = if ($failedMatch.Success) { [int]$failedMatch.Groups[1].Value } else { 0 }
}
}
$targets = @{}
foreach ($file in $Target) {
$path = Join-Path $repo $file
if (-not (Test-Path -LiteralPath $path)) { throw "Target $file does not exist." }
$targets[$file] = $path
}
# --- Repair a run that died mid-mutation, for every target -------------------
foreach ($file in $Target) {
$sidecar = Get-SidecarPath $targets[$file]
if (Test-Path -LiteralPath $sidecar) {
$original = Read-Bytes $sidecar
[System.IO.File]::WriteAllBytes($targets[$file], $original)
Remove-Item -LiteralPath $sidecar -Force
Write-Host "Repaired: a previous run left $sidecar; $($targets[$file]) restored from it and the sidecar removed."
}
}
# --- M0: no-op control, every target -----------------------------------------
Write-Host '--- M0: no-op control'
$originalBytes = @{}
$originalHash = @{}
foreach ($file in $Target) {
$path = $targets[$file]
$bytes = Read-Bytes $path
$hash = Get-BytesHash $bytes
$text = $utf8.GetString($bytes)
$reencoded = [byte[]]($utf8.GetPreamble() + $utf8.GetBytes($text))
if ((Get-BytesHash $reencoded) -ne $hash) {
throw "harness broken: $file does not round-trip through the UTF-8 read/write path byte for byte. Nothing was written and no entry ran."
}
$sidecar = Get-SidecarPath $path
[System.IO.File]::WriteAllBytes($sidecar, $bytes)
try {
Write-Text $path $text
if ((Get-BytesHash (Read-Bytes $path)) -ne $hash) {
throw "harness broken: writing $file back through the harness path changed its bytes."
}
}
finally {
[System.IO.File]::WriteAllBytes($path, $bytes)
if ((Get-BytesHash (Read-Bytes $path)) -ne $hash) { throw "RESTORE FAILED after M0 for $file; original SHA-256 $hash is kept in $sidecar." }
Remove-Item -LiteralPath $sidecar -Force
}
$originalBytes[$file] = $bytes
$originalHash[$file] = $hash
Write-Host "M0: $file decoded, re-encoded and rewritten, bytes unchanged (SHA-256 $hash)"
}
$control = Run-Tests $ControlFilter
if (-not $control.Green) {
throw "harness broken: the suite is not green unmutated (compiled=$($control.Compiled) passed=$($control.Passed) failed=$($control.Failed)). No entry ran."
}
Write-Host "M0: green, $($control.Passed) passed"
# --- Load entries --------------------------------------------------------------
$entriesPath = Join-Path $repo $Entries
$mutations = & $entriesPath
if (-not $mutations -or @($mutations).Count -lt 1) { throw "$Entries`: no mutations." }
# --- Entries -----------------------------------------------------------------
$exitCode = 0
foreach ($mutation in $mutations) {
if (-not $mutation.Id) { throw 'An entry has no Id.' }
if (-not $mutation.Killer) { throw "$($mutation.Id): no Killer." }
$edits = if ($mutation.ContainsKey('Edits')) { @($mutation.Edits) } else { @(@{ File = $mutation.File; Old = $mutation.Old; New = $mutation.New }) }
if ($edits.Count -lt 1) { throw "$($mutation.Id): no edits." }
Write-Host "--- $($mutation.Id) ($($mutation.Mutant)): $($mutation.Rule)"
$texts = @{}
$files = @($edits | ForEach-Object { if ($_.File) { $_.File } elseif ($Target.Count -eq 1) { $Target[0] } else { throw "$($mutation.Id): an edit names no File and there is more than one target." } } | Select-Object -Unique)
foreach ($file in $files) { $texts[$file] = $utf8.GetString($originalBytes[$file]) }
foreach ($edit in $edits) {
$file = if ($edit.File) { $edit.File } else { $Target[0] }
$text = $texts[$file]
$eol = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" }
$old = ($edit.Old -replace "`r`n", "`n")
$normalized = $text -replace "`r`n", "`n"
$count = 0
$at = $normalized.IndexOf($old, [System.StringComparison]::Ordinal)
while ($at -ge 0) { $count++; $at = $normalized.IndexOf($old, $at + 1, [System.StringComparison]::Ordinal) }
if ($count -ne 1) { throw "$($mutation.Id): anchor matches $count times in $file, expected exactly 1." }
$newValue = if ($null -ne $edit.New) { $edit.New } else { '' }
$mutatedNormalized = $normalized.Replace($old, ($newValue -replace "`r`n", "`n"))
$texts[$file] = $mutatedNormalized -replace "`n", $eol
}
foreach ($file in $files) { [System.IO.File]::WriteAllBytes((Get-SidecarPath $targets[$file]), $originalBytes[$file]) }
$verdict = 'NO VERDICT'
try {
foreach ($file in $files) { Write-Text $targets[$file] $texts[$file] }
$run = Run-Tests $mutation.Killer
if (-not $run.Compiled) { $verdict = 'NO VERDICT (did not compile)' }
elseif ($run.Failed -ge 1) { $verdict = 'KILLED' }
else { $verdict = 'SURVIVED' }
}
finally {
foreach ($file in $files) {
[System.IO.File]::WriteAllBytes($targets[$file], $originalBytes[$file])
if ((Get-BytesHash (Read-Bytes $targets[$file])) -ne $originalHash[$file]) {
Write-Host "RESTORE FAILED: $($targets[$file]) not restored; original SHA-256 $($originalHash[$file]) kept in $(Get-SidecarPath $targets[$file])."
throw "restore failed for $($mutation.Id)"
}
Remove-Item -LiteralPath (Get-SidecarPath $targets[$file]) -Force
}
}
Write-Host "$($mutation.Id): $verdict"
if ($verdict -ne 'KILLED') { $exitCode = 1 }
}
$final = Run-Tests $ControlFilter
if (-not $final.Green) { throw "post-run sanity check: the suite is not green after restore (passed=$($final.Passed) failed=$($final.Failed))." }
Write-Host "Final: green, $($final.Passed) passed"
exit $exitCode

View file

@ -44,7 +44,10 @@ namespace GameCult.Networking
Reconciled
}
internal static class CultNetSchemaAliasMatching
/// <summary>
/// The one schema-alias matcher for every runtime-side schema match (docs/cultnet-selection-cut.md, D4).
/// </summary>
public static class CultNetSchemaAliasMatching
{
public static bool MatchesAny(IReadOnlyList<string> candidates, string schemaId)
{
@ -94,6 +97,37 @@ namespace GameCult.Networking
? schemaId.Substring(0, marker)
: null;
}
/// <summary>
/// A selection whose schema filter also matches a document's wire binding id, when it carries
/// one different from its descriptor's own. <see cref="CultDocumentDescriptor"/> knows nothing
/// of <c>CultNetDocumentBinding</c>'s optional schema-id override, so a caller that has both in
/// hand (the two subscription servers) reconciles them here rather than in the evaluator, which
/// stays decoupled from CultNetDocumentRegistry by design.
/// </summary>
public static CultNetSelection WithBindingSchemaAlias(CultNetSelection selection, CultDocumentDescriptor descriptor, string? bindingSchemaId)
{
if (selection.Schemas is not { Length: > 0 } ||
string.IsNullOrEmpty(bindingSchemaId) ||
!selection.Schemas.Contains(bindingSchemaId, StringComparer.Ordinal) ||
MatchesAny(selection.Schemas, descriptor))
{
return selection;
}
return new CultNetSelection
{
Schemas = null,
Keys = selection.Keys,
Fields = selection.Fields,
Cites = selection.Cites,
Cited = selection.Cited,
Projection = selection.Projection,
Descending = selection.Descending,
Limit = selection.Limit,
Cursor = selection.Cursor
};
}
}
/// <summary>
@ -464,6 +498,11 @@ namespace GameCult.Networking
new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _nextLogSequences = new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _appliedShardSequences = new(StringComparer.Ordinal);
// CultNet typed selection, section 2: "ordinal is the sequence of the commit that last wrote
// the row". Kept on every append (AppendMutationLogEntry is the one site), keyed by (schemaId,
// recordKey) so the evaluator's order and cursor never read a clock.
private readonly Dictionary<(string SchemaId, string RecordKey), long> _lastWriteSequence =
new();
private readonly Subject<object> _changes = new();
private bool _disposed;
@ -1254,8 +1293,17 @@ namespace GameCult.Networking
? null
: wireEntry ?? ToLogEntryMessage(entry);
RecordMutationLogEntry(entry, storedWireEntry);
_lastWriteSequence[(schemaId, key.Value)] = sequence;
}
/// <summary>
/// The shard-log sequence of the commit that last wrote this row, or null when the row has
/// never been committed through this database. The order the CultNet selection evaluator
/// pages by (docs/cultnet-selection-cut.md, section 2).
/// </summary>
public long? LastWriteSequence(string schemaId, CultRecordKey key) =>
_lastWriteSequence.TryGetValue((schemaId, key.Value), out var sequence) ? sequence : null;
private void RecordMutationLogEntry(
CultNetShardMutationLogEntry entry,
CultNetShardLogEntryMessage? wireEntry = null)

View file

@ -334,9 +334,17 @@ namespace GameCult.Networking
string subscriptionId,
CultNetServerPeer peer)
{
// v0 has no engine of its own (docs/cultnet-selection-cut.md, D3/D4): it lowers into a
// Selection and is matched by the one evaluator, same as a v1 subscribe would be.
var selection = new CultNetSelection
{
Schemas = request.SchemaIds,
Keys = request.RecordKeys,
Projection = CultNetSelectionProjections.Document
};
return _database.WatchAllChanges().Subscribe(change =>
{
var outbound = CreateChangeMessage(change, subscriptionId, request);
var outbound = CreateChangeMessage(change, subscriptionId, selection);
if (outbound != null)
{
peer.SendCultNet(outbound);
@ -347,21 +355,28 @@ namespace GameCult.Networking
internal CultNetDatabaseChangeRawMessage? CreateChangeMessage(
object change,
string subscriptionId,
CultNetDatabaseSubscribeMessage request)
CultNetSelection selection)
{
var changeType = change.GetType();
var key = (CultRecordKey)(changeType.GetProperty("Key")?.GetValue(change) ?? new CultRecordKey(string.Empty));
var schemaId = (string?)changeType.GetProperty("SchemaId")?.GetValue(change) ?? string.Empty;
var documentType = changeType.IsGenericType ? changeType.GetGenericArguments()[0] : null;
var descriptor = documentType == null ? null : _database.Cache.Registry.GetRequired(documentType);
var binding = documentType == null ? null : _database.Documents.GetByDocumentType(documentType);
if (!Matches(request, schemaId, key, descriptor, binding))
var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated);
var document = changeType.GetProperty("Document")?.GetValue(change);
if (descriptor == null)
{
return null;
}
var binding = _database.Documents.GetByDocumentType(descriptor.DocumentType);
var effectiveSelection = CultNetSchemaAliasMatching.WithBindingSchemaAlias(selection, descriptor, binding?.SchemaId);
if (!CultNetSelectionEvaluator.Matches(descriptor, key, document, effectiveSelection))
{
return null;
}
var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated);
var document = changeType.GetProperty("Document")?.GetValue(change);
if (kind == CultNetDatabaseChangeKind.Removed || document == null)
{
return new CultNetDatabaseChangeRawMessage
@ -379,94 +394,10 @@ namespace GameCult.Networking
MessageId = Guid.NewGuid().ToString("N"),
SubscriptionId = subscriptionId,
ChangeKind = kind == CultNetDatabaseChangeKind.Added ? "added" : "updated",
Document = CreateRawDocumentRecord(key, document)
Document = _database.Documents.ToRawRecord(descriptor, key, document, DateTimeOffset.UtcNow.ToString("O"))
};
}
private CultNetRawDocumentRecord CreateRawDocumentRecord(CultRecordKey key, object document)
{
var method = typeof(CultNetDocumentRegistry)
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Single(candidate => candidate.Name == nameof(CultNetDocumentRegistry.CreateRawDocumentPutMessage) &&
candidate.IsGenericMethodDefinition);
var documentType = document.GetType();
var handleType = typeof(CultRecordHandle<>).MakeGenericType(documentType);
var handle = Activator.CreateInstance(handleType, new object[] { key });
var message = method
.MakeGenericMethod(documentType)
.Invoke(_database.Documents, new[] { Guid.NewGuid().ToString("N"), handle, document, null });
return ((CultNetDocumentPutRawMessage)message!).Document;
}
private static bool Matches(
CultNetDatabaseSubscribeMessage request,
string schemaId,
CultRecordKey key,
CultDocumentDescriptor? descriptor,
CultNetDocumentBinding? binding)
{
var schemaMatches = MatchesSchema(request.SchemaIds, schemaId, descriptor, binding);
var keyMatches = request.RecordKeys == null ||
request.RecordKeys.Length == 0 ||
request.RecordKeys.Contains(key.Value, StringComparer.Ordinal);
return schemaMatches && keyMatches;
}
private static bool MatchesSchema(
string[]? requestedSchemaIds,
string schemaId,
CultDocumentDescriptor? descriptor,
CultNetDocumentBinding? binding)
{
if (requestedSchemaIds == null || requestedSchemaIds.Length == 0)
{
return true;
}
var requested = requestedSchemaIds.ToHashSet(StringComparer.Ordinal);
if (requested.Contains(schemaId))
{
return true;
}
if (descriptor == null)
{
return false;
}
if (requested.Contains(descriptor.SchemaId) ||
requested.Contains(descriptor.SchemaName) ||
requested.Contains(descriptor.SchemaVersion) ||
(binding != null && requested.Contains(binding.SchemaId)))
{
return true;
}
if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(requested.Contains))
{
return true;
}
return requested
.Select(InferSchemaName)
.Where(schemaName => !string.IsNullOrWhiteSpace(schemaName))
.Any(schemaName => string.Equals(schemaName, descriptor.SchemaName, StringComparison.Ordinal));
}
private static string? InferSchemaName(string schemaVersion)
{
var marker = schemaVersion.LastIndexOf(".v", StringComparison.Ordinal);
if (marker <= 0 || marker + 2 >= schemaVersion.Length)
{
return null;
}
var version = schemaVersion.Substring(marker + 2);
return version.All(char.IsDigit)
? schemaVersion.Substring(0, marker)
: null;
}
private static string SubscriptionKey(NetPeer peer, string subscriptionId)
{
return $"{peer.Id}:{subscriptionId}";

View file

@ -234,8 +234,8 @@ namespace GameCult.Networking
}
var sourceRecordKey = ResolveChangeRecordKey(change);
var message = CreateAuthorizedChange(change, request, subscriptionId, peer);
var projected = message?.Document;
var matched = CreateMatchedRecord(change, request, peer);
var projected = matched;
if (projected != null && _projectRecord != null)
projected = _projectRecord(request, peer, projected);
ApplyProjectedChange(
@ -421,97 +421,40 @@ namespace GameCult.Networking
_requests.TryRemove(key, out _);
}
private CultNetDatabaseChangeRawMessage? CreateAuthorizedChange(
// 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(
object change,
CultNetDatabaseSubscribeMessage request,
string subscriptionId,
ICultNetSchemaServerPeer peer)
{
return CreateChangeCore(change, request, subscriptionId, (recordKey, schemaId) =>
_authorizeRecord?.Invoke(request, peer, recordKey, schemaId) != false);
}
private CultNetDatabaseChangeRawMessage? CreateChange(
object change,
CultNetDatabaseSubscribeMessage request,
string subscriptionId)
{
return CreateChangeCore(change, request, subscriptionId, (_, _) => true);
}
private CultNetDatabaseChangeRawMessage? CreateChangeCore(
object change,
CultNetDatabaseSubscribeMessage request,
string subscriptionId,
Func<string, string, bool> authorizeRecord)
{
var changeType = change.GetType();
var key = (CultRecordKey)(changeType.GetProperty("Key")?.GetValue(change) ?? new CultRecordKey(""));
if (request.RecordKeys is { Length: > 0 } && !request.RecordKeys.Contains(key.Value, StringComparer.Ordinal))
return null;
var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated);
var document = changeType.GetProperty("Document")?.GetValue(change);
if (kind == CultNetDatabaseChangeKind.Removed || document == null)
{
var previous = changeType.GetProperty("PreviousDocument")?.GetValue(change);
var schemaId = ResolveWireSchemaId(previous, (string?)changeType.GetProperty("SchemaId")?.GetValue(change) ?? "");
if (!MatchesRequestedSchema(request.SchemaIds, previous, schemaId))
return null;
if (!authorizeRecord(key.Value, schemaId))
return null;
return new CultNetDatabaseChangeRawMessage
{
MessageId = Guid.NewGuid().ToString("N"),
SubscriptionId = subscriptionId,
ChangeKind = "removed",
RecordKey = key.Value,
SchemaId = schemaId
};
}
var forDescriptor = document ?? changeType.GetProperty("PreviousDocument")?.GetValue(change);
if (forDescriptor == null)
return null;
var raw = CreateRawRecord(key, document);
if (!MatchesRequestedSchema(request.SchemaIds, document, raw.SchemaId))
return null;
if (!authorizeRecord(key.Value, raw.SchemaId))
return null;
return new CultNetDatabaseChangeRawMessage
var descriptor = _database.Cache.Registry.GetRequired(forDescriptor.GetType());
var selection = new CultNetSelection
{
MessageId = Guid.NewGuid().ToString("N"),
SubscriptionId = subscriptionId,
ChangeKind = kind == CultNetDatabaseChangeKind.Added ? "added" : "updated",
Document = raw
Schemas = request.SchemaIds,
Keys = request.RecordKeys,
Projection = CultNetSelectionProjections.Document
};
}
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;
private string ResolveWireSchemaId(object? document, string fallback)
{
if (document == null) return fallback;
return _database.Documents.GetByDocumentType(document.GetType())?.SchemaId ?? fallback;
}
if (kind == CultNetDatabaseChangeKind.Removed || document == null)
return null;
private bool MatchesRequestedSchema(string[]? requestedSchemaIds, object? document, string wireSchemaId)
{
if (requestedSchemaIds is not { Length: > 0 }) return true;
if (requestedSchemaIds.Contains(wireSchemaId, StringComparer.Ordinal)) return true;
return document != null && CultNetSchemaAliasMatching.MatchesAny(
requestedSchemaIds,
_database.Cache.Registry.GetRequired(document.GetType()));
}
private CultNetRawDocumentRecord CreateRawRecord(CultRecordKey key, object document)
{
var method = typeof(CultNetDocumentRegistry)
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Single(candidate => candidate.Name == nameof(CultNetDocumentRegistry.CreateRawDocumentPutMessage) &&
candidate.IsGenericMethodDefinition);
var documentType = document.GetType();
var handle = Activator.CreateInstance(
typeof(CultRecordHandle<>).MakeGenericType(documentType),
new object[] { key });
var put = method.MakeGenericMethod(documentType)
.Invoke(_database.Documents, new[] { Guid.NewGuid().ToString("N"), handle, document, null });
return ((CultNetDocumentPutRawMessage)put!).Document;
return _database.Documents.ToRawRecord(descriptor, key, document, DateTimeOffset.UtcNow.ToString("O"));
}
private sealed class SubscriptionProjectionState

View file

@ -258,7 +258,23 @@ namespace GameCult.Networking
}
/// <summary>
/// Creates a raw snapshot response from the cache.
/// Creates a typed-selection snapshot request message (docs/cultnet-selection-cut.md).
/// </summary>
public CultNetSnapshotRequestV1Message CreateSnapshotRequestV1(string messageId, CultNetSelection selection)
{
if (selection == null) throw new ArgumentNullException(nameof(selection));
return new CultNetSnapshotRequestV1Message
{
MessageId = RequireNonEmpty(messageId, nameof(messageId)),
Selection = selection
};
}
/// <summary>
/// Creates a raw snapshot response from the cache. v0 has no selector engine of its own
/// (docs/cultnet-selection-cut.md, D3/D4): the filter lowers into a <see cref="CultNetSelection"/>
/// and is answered by <see cref="CultNetSelectionEvaluator"/>, walking every page it returns so a
/// v0 caller still sees its whole matching set in one message.
/// </summary>
public CultNetSnapshotResponseRawMessage CreateRawSnapshotResponse(
CultCache cache,
@ -266,80 +282,24 @@ namespace GameCult.Networking
CultNetSnapshotRequestMessage? filter = null,
CultNetDocumentMessageOptions? options = null)
{
var requestedSchemaIds = filter?.SchemaIds != null
? new HashSet<string>(filter.SchemaIds, StringComparer.Ordinal)
: null;
var requestedRecordKeys = filter?.RecordKeys != null
? new HashSet<string>(filter.RecordKeys, StringComparer.Ordinal)
: null;
var storedAt = ResolveStoredAt(options);
if (cache == null) throw new ArgumentNullException(nameof(cache));
var selection = new CultNetSelection
{
Schemas = filter?.SchemaIds,
Keys = filter?.RecordKeys,
Projection = CultNetSelectionProjections.Document,
Limit = CultNetSelectionEvaluator.LimitMax
};
var documents = new List<CultNetRawDocumentRecord>();
void AddDocument(object document, string key)
string? cursor = null;
do
{
var descriptor = _documents.GetRequired(document.GetType());
var binding = GetByDocumentType(document.GetType()) ??
new CultNetDocumentBinding(
document.GetType(),
descriptor.SchemaId,
value => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType()),
payload => CultDocumentMessagePackSerialization.DeserializeUntyped(document.GetType(), payload));
if (requestedSchemaIds != null && !MatchesRequestedSchema(descriptor, binding, requestedSchemaIds))
{
return;
}
if (requestedRecordKeys != null && !requestedRecordKeys.Contains(key))
{
return;
}
documents.Add(new CultNetRawDocumentRecord
{
SchemaId = binding.SchemaId,
SchemaName = descriptor.SchemaName,
SchemaVersion = descriptor.SchemaVersion,
SchemaContentHash = descriptor.ContentHash,
RecordKey = key,
StoredAt = storedAt,
PayloadEncoding = "messagepack",
Payload = binding.PayloadSerializer(document),
SourceRuntimeId = options?.SourceRuntimeId,
SourceAgentId = options?.SourceAgentId,
SourceRole = options?.SourceRole,
Tags = options?.Tags
});
}
if (requestedRecordKeys != null)
{
foreach (var key in requestedRecordKeys)
{
var document = cache.Get(new CultRecordKey(key));
if (document != null)
AddDocument(document, key);
}
}
else
{
foreach (var document in cache.AllEntries)
{
var handleMethod = typeof(CultCache)
.GetMethod(nameof(CultCache.TryGetHandle))!
.MakeGenericMethod(document.GetType());
var handleObject = handleMethod.Invoke(cache, new[] { document });
if (handleObject == null)
continue;
var keyProperty = handleObject.GetType().GetProperty("Value");
var handleValue = keyProperty?.GetValue(handleObject) ?? handleObject;
var recordKeyProperty = handleValue.GetType().GetProperty("Key");
var recordKey = recordKeyProperty?.GetValue(handleValue);
var valueProperty = recordKey?.GetType().GetProperty("Value");
var key = (string?)(valueProperty?.GetValue(recordKey)) ?? string.Empty;
AddDocument(document, key);
}
}
selection.Cursor = cursor;
var page = SelectPage(cache, selection, ordinalOf: static (_, _) => 0, asOf: 0, validate: false, options);
documents.AddRange(page.Documents ?? Array.Empty<CultNetRawDocumentRecord>());
cursor = page.Next;
} while (cursor != null);
return new CultNetSnapshotResponseRawMessage
{
@ -348,6 +308,168 @@ namespace GameCult.Networking
};
}
/// <summary>
/// Evaluates a typed selection against the cache and returns its page
/// (docs/cultnet-selection-cut.md, section 2/6). <paramref name="ordinalOf"/> supplies each
/// row's ordinal (the reference: <see cref="CultNetDatabase.LastWriteSequence"/>); <paramref name="asOf"/>
/// is the snapshot the page is exact for.
/// </summary>
public CultNetSnapshotResponseRawV1Message CreateSelectionResponse(
CultCache cache,
string messageId,
CultNetSelection selection,
Func<string, CultRecordKey, long> ordinalOf,
ulong asOf,
CultNetDocumentMessageOptions? options = null)
{
if (cache == null) throw new ArgumentNullException(nameof(cache));
if (selection == null) throw new ArgumentNullException(nameof(selection));
if (ordinalOf == null) throw new ArgumentNullException(nameof(ordinalOf));
var page = SelectPage(cache, selection, ordinalOf, asOf, validate: true, options);
return new CultNetSnapshotResponseRawV1Message
{
MessageId = RequireNonEmpty(messageId, nameof(messageId)),
Matched = page.Matched,
AsOf = page.AsOf,
Next = page.Next,
Headers = page.Headers,
Documents = page.Documents,
Edges = page.Edges
};
}
/// <summary>
/// Evaluates a selection over every row the cache holds and projects the matched page to wire
/// records. The one evaluator (<see cref="CultNetSelectionEvaluator"/>) owns matching, order,
/// the hop and the cursor; this owns turning its result into <see cref="CultNetRawDocumentRecord"/>s.
/// </summary>
internal CultNetSelectionPage SelectPage(
CultCache cache,
CultNetSelection selection,
Func<string, CultRecordKey, long> ordinalOf,
ulong asOf,
bool validate,
CultNetDocumentMessageOptions? options)
{
selection = ExpandSchemaBindingAliases(selection);
if (validate)
selection.Validate(_documents.AllDescriptors.ToArray());
var rows = cache.AllStoredDocuments
.Select(stored => new CultNetSelectionEvaluator.Row(
stored.Descriptor, stored.Key, stored.Document, ordinalOf(stored.Descriptor.SchemaId, stored.Key), stored.StoredAt))
.ToArray();
var evaluation = CultNetSelectionEvaluator.Select(_documents, rows, selection, asOf);
var records = evaluation.Rows.Select(row => ToRawRecord(row, options)).ToArray();
var wantDocument = selection.Projection == CultNetSelectionProjections.Document;
return new CultNetSelectionPage
{
Matched = (uint)records.Length,
AsOf = asOf,
Next = evaluation.NextCursor,
Documents = wantDocument ? records : null,
Headers = wantDocument ? null : records.Select(CultNetRawDocumentHeader.FromRecord).ToArray(),
Edges = selection.HasHop ? evaluation.Edges.Select(edge => ToEdge(edge, wantDocument)).ToArray() : null
};
}
// A CultDocumentDescriptor knows nothing of CultNetDocumentBinding's optional schema-id
// override; a caller filtering by that wire id (rather than the descriptor's own) needs the
// descriptor's own id present in the selection too, since CultNetSelectionEvaluator only ever
// reads descriptors. Mirrors CultNetSchemaAliasMatching.WithBindingSchemaAlias, at the layer
// that owns every binding rather than one row's.
private CultNetSelection ExpandSchemaBindingAliases(CultNetSelection selection)
{
if (selection.Schemas is not { Length: > 0 } || _bindingsByType.Count == 0)
return selection;
List<string>? expanded = null;
foreach (var binding in _bindingsByType.Values)
{
if (!selection.Schemas.Contains(binding.SchemaId, StringComparer.Ordinal))
continue;
var descriptor = _documents.GetRequired(binding.DocumentType);
if (selection.Schemas.Contains(descriptor.SchemaId, StringComparer.Ordinal))
continue;
expanded ??= new List<string>(selection.Schemas);
if (!expanded.Contains(descriptor.SchemaId, StringComparer.Ordinal))
expanded.Add(descriptor.SchemaId);
}
if (expanded == null)
return selection;
return new CultNetSelection
{
Schemas = expanded.ToArray(),
Keys = selection.Keys,
Fields = selection.Fields,
Cites = selection.Cites,
Cited = selection.Cited,
Projection = selection.Projection,
Descending = selection.Descending,
Limit = selection.Limit,
Cursor = selection.Cursor
};
}
/// <summary>
/// Builds the wire record for one evaluated row. The shared path under snapshot and change
/// (docs/cultnet-selection-cut.md, section 8's "shared paths").
/// </summary>
internal CultNetRawDocumentRecord ToRawRecord(CultNetSelectionEvaluator.Row row, CultNetDocumentMessageOptions? options = null) =>
ToRawRecord(row.Descriptor, row.Key, row.Document, string.IsNullOrEmpty(row.StoredAt) ? ResolveStoredAt(options) : row.StoredAt, options);
internal CultNetRawDocumentRecord ToRawRecord(
CultDocumentDescriptor descriptor,
CultRecordKey key,
object document,
string storedAt,
CultNetDocumentMessageOptions? options = null)
{
var binding = GetByDocumentType(descriptor.DocumentType) ??
new CultNetDocumentBinding(
descriptor.DocumentType,
descriptor.SchemaId,
value => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType(), _documents),
payload => CultDocumentMessagePackSerialization.DeserializeUntyped(descriptor.DocumentType, payload, _documents));
return new CultNetRawDocumentRecord
{
SchemaId = binding.SchemaId,
SchemaName = descriptor.SchemaName,
SchemaVersion = descriptor.SchemaVersion,
SchemaContentHash = descriptor.ContentHash,
RecordKey = key.Value,
StoredAt = storedAt,
PayloadEncoding = "messagepack",
Payload = binding.PayloadSerializer(document),
SourceRuntimeId = options?.SourceRuntimeId,
SourceAgentId = options?.SourceAgentId,
SourceRole = options?.SourceRole,
Tags = options?.Tags
};
}
private CultNetEdge ToEdge(CultNetSelectionEvaluator.EdgeMatch edge, bool wantDocument)
{
var wire = new CultNetEdge
{
From = new CultNetRecordRef { SchemaId = edge.From.Descriptor.SchemaId, RecordKey = edge.From.Key.Value },
Role = edge.Role,
To = new CultNetRecordRef { SchemaId = edge.To.Descriptor.SchemaId, RecordKey = edge.To.Key.Value }
};
if (wantDocument && edge.Payload != null)
{
wire.PayloadEncoding = "messagepack";
wire.Payload = CultDocumentMessagePackSerialization.SerializeUntyped(edge.Payload, edge.Payload.GetType(), _documents);
}
return wire;
}
/// <summary>
/// Applies a raw document put message to a cache.
/// </summary>
@ -502,27 +624,6 @@ namespace GameCult.Networking
.Select(group => group.First());
}
private static bool MatchesRequestedSchema(
CultDocumentDescriptor descriptor,
CultNetDocumentBinding binding,
ISet<string> requestedSchemaIds)
{
if (requestedSchemaIds.Count == 0)
return true;
if (requestedSchemaIds.Contains(descriptor.SchemaId) ||
requestedSchemaIds.Contains(binding.SchemaId))
return true;
if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(requestedSchemaIds.Contains))
return true;
return requestedSchemaIds
.Select(InferSchemaName)
.Where(schemaName => !string.IsNullOrWhiteSpace(schemaName))
.Any(schemaName => string.Equals(schemaName, descriptor.SchemaName, StringComparison.Ordinal));
}
private static string? TryReadSchemaVersion(byte[] payload)
{
try

View file

@ -61,6 +61,9 @@ namespace GameCult.Networking
CultNetSchemaVersions.DocumentPutRaw => Deserialize<CultNetDocumentPutRawMessage>(payload),
CultNetSchemaVersions.SnapshotRequest => Deserialize<CultNetSnapshotRequestMessage>(payload),
CultNetSchemaVersions.SnapshotResponseRaw => Deserialize<CultNetSnapshotResponseRawMessage>(payload),
CultNetSchemaVersions.SnapshotRequestV1 => Deserialize<CultNetSnapshotRequestV1Message>(payload),
CultNetSchemaVersions.DatabaseSubscribeV1 => Deserialize<CultNetDatabaseSubscribeV1Message>(payload),
CultNetSchemaVersions.SnapshotResponseRawV1 => Deserialize<CultNetSnapshotResponseRawV1Message>(payload),
CultNetSchemaVersions.DatabaseSubscribe => Deserialize<CultNetDatabaseSubscribeMessage>(payload),
CultNetSchemaVersions.DatabaseUnsubscribe => Deserialize<CultNetDatabaseUnsubscribeMessage>(payload),
CultNetSchemaVersions.DatabaseChangeRaw => Deserialize<CultNetDatabaseChangeRawMessage>(payload),

View file

@ -80,6 +80,18 @@ namespace GameCult.Networking
/// </summary>
public const string SnapshotResponseRaw = "cultnet.snapshot_response_raw.v0";
/// <summary>
/// typed-selection snapshot request contract identifier (docs/cultnet-selection-cut.md).
/// </summary>
public const string SnapshotRequestV1 = "cultnet.snapshot_request.v1";
/// <summary>
/// typed-selection database subscription request contract identifier (docs/cultnet-selection-cut.md).
/// </summary>
public const string DatabaseSubscribeV1 = "cultnet.database_subscribe.v1";
/// <summary>
/// typed-selection snapshot response contract identifier (docs/cultnet-selection-cut.md).
/// </summary>
public const string SnapshotResponseRawV1 = "cultnet.snapshot_response_raw.v1";
/// <summary>
/// schema catalog request contract identifier.
/// </summary>
public const string SchemaCatalogRequest = "cultnet.schema_catalog_request.v0";
@ -825,6 +837,78 @@ namespace GameCult.Networking
[Key("supportedBodyTransports")] public string[]? SupportedBodyTransports { get; set; }
}
/// <summary>
/// CultNet message requesting a document snapshot through a typed selection (docs/cultnet-selection-cut.md).
/// </summary>
[MessagePackObject]
public class CultNetSnapshotRequestV1Message : ICultNetSchemaMessage
{
/// <summary>Gets or sets the schema version.</summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SnapshotRequestV1;
/// <summary>Gets or sets the message id.</summary>
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
/// <summary>Gets or sets the selection.</summary>
[Key("selection")] public CultNetSelection Selection { get; set; } = new CultNetSelection();
/// <summary>Gets or sets the shard id requested for a shard-bounded snapshot.</summary>
[Key("shardId")] public string? ShardId { get; set; }
/// <summary>Gets or sets the expected shard epoch.</summary>
[Key("shardEpoch")] public long? ShardEpoch { get; set; }
}
/// <summary>
/// Requests a live database subscription through a typed selection (docs/cultnet-selection-cut.md).
/// </summary>
[MessagePackObject]
public class CultNetDatabaseSubscribeV1Message : ICultNetSchemaMessage
{
/// <summary>Gets or sets the schema version.</summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.DatabaseSubscribeV1;
/// <summary>Gets or sets the request id.</summary>
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
/// <summary>Gets or sets the live subscription id.</summary>
[Key("subscriptionId")] public string SubscriptionId { get; set; } = string.Empty;
/// <summary>Gets or sets the selection.</summary>
[Key("selection")] public CultNetSelection Selection { get; set; } = new CultNetSelection();
/// <summary>Gets or sets whether the server should send a matching snapshot before live changes.</summary>
[Key("includeSnapshot")] public bool IncludeSnapshot { get; set; } = true;
/// <summary>Gets or sets the stable consumer runtime identity used for subscription diagnostics and body-plane demand.</summary>
[Key("consumerRuntimeId")] public string? ConsumerRuntimeId { get; set; }
/// <summary>Gets or sets logical hot-body identities needed by this exact state subscription.</summary>
[Key("bodyIds")] public string[]? BodyIds { get; set; }
/// <summary>Gets or sets body transports the consumer can open, ordered by consumer preference.</summary>
[Key("supportedBodyTransports")] public string[]? SupportedBodyTransports { get; set; }
}
/// <summary>
/// CultNet message returning a typed selection's page (docs/cultnet-selection-cut.md).
/// </summary>
[MessagePackObject]
public class CultNetSnapshotResponseRawV1Message : ICultNetSchemaMessage
{
/// <summary>Gets or sets the schema version.</summary>
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.SnapshotResponseRawV1;
/// <summary>Gets or sets the message id.</summary>
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
/// <summary>Gets or sets the number of rows on this page.</summary>
[Key("matched")] public uint Matched { get; set; }
/// <summary>Gets or sets the snapshot this page is exact as of.</summary>
[Key("asOf")] public ulong AsOf { get; set; }
/// <summary>Gets or sets the cursor for the next page, absent on the last page.</summary>
[Key("next")] public string? Next { get; set; }
/// <summary>Gets or sets the headers, present under header projection.</summary>
[Key("headers")] public CultNetRawDocumentHeader[]? Headers { get; set; }
/// <summary>Gets or sets the documents, present under document projection.</summary>
[Key("documents")] public CultNetRawDocumentRecord[]? Documents { get; set; }
/// <summary>Gets or sets the edges a hop-bearing selection traversed.</summary>
[Key("edges")] public CultNetEdge[]? Edges { get; set; }
/// <summary>Gets or sets the shard id represented by this snapshot.</summary>
[Key("shardId")] public string? ShardId { get; set; }
/// <summary>Gets or sets the shard epoch represented by this snapshot.</summary>
[Key("shardEpoch")] public long? ShardEpoch { get; set; }
/// <summary>Gets or sets the shard-log sequence represented by this snapshot.</summary>
[Key("shardLogSequence")] public long? ShardLogSequence { get; set; }
}
/// <summary>
/// Cancels a live database subscription.
/// </summary>

View file

@ -377,6 +377,33 @@ namespace GameCult.Networking
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.selection.schema.json",
Kind = "shared_contract",
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.snapshot-request.v1.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.SnapshotRequestV1,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.database-subscribe.v1.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.DatabaseSubscribeV1,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.snapshot-response-raw.v1.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.SnapshotResponseRawV1,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.database-unsubscribe.schema.json",
Kind = "wire_message",

View file

@ -0,0 +1,317 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
using MessagePack;
namespace GameCult.Networking
{
/// <summary>
/// The four comparison and one any-of operator a field predicate may carry. Serialised on the wire
/// as the <c>op</c> string (<see cref="CultNetSelectionOperators"/>), never as a union: the C#
/// enum exists for evaluator and validation ergonomics only.
/// </summary>
public enum CultNetSelectionOperator
{
/// <summary>Matches when the row's declared value is one of <c>values</c>.</summary>
AnyOf,
/// <summary>Matches when the row's declared numeric value is less than <c>number</c>.</summary>
Lt,
/// <summary>Matches when the row's declared numeric value is less than or equal to <c>number</c>.</summary>
Le,
/// <summary>Matches when the row's declared numeric value is greater than or equal to <c>number</c>.</summary>
Ge,
/// <summary>Matches when the row's declared numeric value is greater than <c>number</c>.</summary>
Gt
}
/// <summary>Converts <see cref="CultNetSelectionOperator"/> to and from its wire spelling.</summary>
public static class CultNetSelectionOperators
{
/// <summary>Returns the wire spelling of an operator.</summary>
public static string ToWireString(this CultNetSelectionOperator op) => op switch
{
CultNetSelectionOperator.AnyOf => "any_of",
CultNetSelectionOperator.Lt => "lt",
CultNetSelectionOperator.Le => "le",
CultNetSelectionOperator.Ge => "ge",
CultNetSelectionOperator.Gt => "gt",
_ => throw new ArgumentOutOfRangeException(nameof(op))
};
/// <summary>Parses a wire operator string; returns false for anything else.</summary>
public static bool TryParse(string? wire, out CultNetSelectionOperator op)
{
switch (wire)
{
case "any_of": op = CultNetSelectionOperator.AnyOf; return true;
case "lt": op = CultNetSelectionOperator.Lt; return true;
case "le": op = CultNetSelectionOperator.Le; return true;
case "ge": op = CultNetSelectionOperator.Ge; return true;
case "gt": op = CultNetSelectionOperator.Gt; return true;
default: op = default; return false;
}
}
/// <summary>True for the four comparison operators (everything but any_of).</summary>
public static bool IsComparison(this CultNetSelectionOperator op) => op != CultNetSelectionOperator.AnyOf;
}
/// <summary>One predicate over a single declared index alias (docs/cultnet-selection-cut.md, section 2).</summary>
[MessagePackObject]
public sealed class CultNetFieldPredicate
{
/// <summary>A declared index alias, reachable on some schema the selection can reach.</summary>
[Key("index")] public string Index { get; set; } = string.Empty;
/// <summary>"any_of", "lt", "le", "ge", or "gt".</summary>
[Key("op")] public string Op { get; set; } = string.Empty;
/// <summary>Present iff op = any_of; non-empty.</summary>
[Key("values")] public string[]? Values { get; set; }
/// <summary>Present iff op is a comparison.</summary>
[Key("number")] public double? Number { get; set; }
}
/// <summary>Identifies one row on the wire by schema and record key.</summary>
[MessagePackObject]
public sealed class CultNetRecordRef
{
/// <summary>Gets or sets the schema id.</summary>
[Key("schemaId")] public string SchemaId { get; set; } = string.Empty;
/// <summary>Gets or sets the record key.</summary>
[Key("recordKey")] public string RecordKey { get; set; } = string.Empty;
}
/// <summary>Selects rows whose declared reference names a target row (docs/cultnet-selection-cut.md, section 2).</summary>
[MessagePackObject]
public sealed class CultNetCitation
{
/// <summary>The row the citer must name.</summary>
[Key("target")] public CultNetRecordRef Target { get; set; } = new();
/// <summary>The reference's declared alias; absent matches any declared reference.</summary>
[Key("role")] public string? Role { get; set; }
}
/// <summary>Selects rows some other row does or does not name in <c>role</c> - the one negation.</summary>
[MessagePackObject]
public sealed class CultNetIncoming
{
/// <summary>The declared reference alias to check for an incoming edge.</summary>
[Key("role")] public string Role { get; set; } = string.Empty;
/// <summary>True to select rows with an incoming edge, false to select rows without one.</summary>
[Key("exists")] public bool Exists { get; set; }
}
/// <summary>
/// One typed selection, carried by <c>cultnet.snapshot_request.v1</c> and
/// <c>cultnet.database_subscribe.v1</c>. docs/cultnet-selection-cut.md, section 2.
/// </summary>
[MessagePackObject]
public sealed class CultNetSelection
{
/// <summary>Kinds: schema ids or aliases, matched by <see cref="CultNetSchemaAliasMatching"/>. Absent = every schema.</summary>
[Key("schemas")] public string[]? Schemas { get; set; }
/// <summary>Record-key allowlist. Absent = every key.</summary>
[Key("keys")] public string[]? Keys { get; set; }
/// <summary>Conjunction; each is any-of over one declared index alias.</summary>
[Key("fields")] public CultNetFieldPredicate[]? Fields { get; set; }
/// <summary>Rows whose declared reference names <c>target</c>.</summary>
[Key("cites")] public CultNetCitation? Cites { get; set; }
/// <summary>Rows some row does or does not name in <c>role</c>.</summary>
[Key("cited")] public CultNetIncoming? Cited { get; set; }
/// <summary>"header" or "document"; default "header".</summary>
[Key("projection")] public string Projection { get; set; } = CultNetSelectionProjections.Header;
/// <summary>Reverses the one order.</summary>
[Key("descending")] public bool Descending { get; set; }
/// <summary>Clamped 1..=200.</summary>
[Key("limit")] public uint? Limit { get; set; }
/// <summary>Opaque; minted by the answering server.</summary>
[Key("cursor")] public string? Cursor { get; set; }
/// <summary>True when this selection follows an edge (section 2: "Edges in the answer").</summary>
[IgnoreMember]
public bool HasHop => Cites != null || Cited != null;
}
/// <summary>The two wire values <see cref="CultNetSelection.Projection"/> may carry.</summary>
public static class CultNetSelectionProjections
{
/// <summary>The record without its payload.</summary>
public const string Header = "header";
/// <summary>The full record.</summary>
public const string Document = "document";
}
/// <summary>One edge a hop traversed, carried on <see cref="CultNetSelectionPage"/>.</summary>
[MessagePackObject]
public sealed class CultNetEdge
{
/// <summary>The citing row.</summary>
[Key("from")] public CultNetRecordRef From { get; set; } = new();
/// <summary>The declared reference alias the citer used.</summary>
[Key("role")] public string Role { get; set; } = string.Empty;
/// <summary>The cited row, its schema resolved to the leaf actually stored.</summary>
[Key("to")] public CultNetRecordRef To { get; set; } = new();
/// <summary>"messagepack" when <see cref="Payload"/> is present.</summary>
[Key("payloadEncoding")] public string? PayloadEncoding { get; set; }
/// <summary>The value attached to the edge (a dictionary reference's value); present under document projection only.</summary>
[Key("payload")] public byte[]? Payload { get; set; }
}
/// <summary>
/// A header record: <see cref="CultNetRawDocumentRecord"/> minus its payload and payloadEncoding.
/// </summary>
[MessagePackObject]
public sealed class CultNetRawDocumentHeader
{
[Key("schemaId")] public string SchemaId { get; set; } = string.Empty;
[Key("schemaName")] public string? SchemaName { get; set; }
[Key("schemaVersion")] public string? SchemaVersion { get; set; }
[Key("schemaContentHash")] public string? SchemaContentHash { get; set; }
[Key("recordKey")] public string RecordKey { get; set; } = string.Empty;
[Key("storedAt")] public string StoredAt { get; set; } = string.Empty;
[Key("sourceRuntimeId")] public string? SourceRuntimeId { get; set; }
[Key("sourceAgentId")] public string? SourceAgentId { get; set; }
[Key("sourceRole")] public string? SourceRole { get; set; }
[Key("tags")] public string[]? Tags { get; set; }
/// <summary>Builds a header by dropping a raw record's payload.</summary>
public static CultNetRawDocumentHeader FromRecord(CultNetRawDocumentRecord record) => new()
{
SchemaId = record.SchemaId,
SchemaName = record.SchemaName,
SchemaVersion = record.SchemaVersion,
SchemaContentHash = record.SchemaContentHash,
RecordKey = record.RecordKey,
StoredAt = record.StoredAt,
SourceRuntimeId = record.SourceRuntimeId,
SourceAgentId = record.SourceAgentId,
SourceRole = record.SourceRole,
Tags = record.Tags
};
}
/// <summary>
/// The result of evaluating a selection, before it is carried by a wire message. Exactly one of
/// <see cref="Headers"/> / <see cref="Documents"/> is populated, by projection.
/// </summary>
public sealed class CultNetSelectionPage
{
public uint Matched { get; set; }
public ulong AsOf { get; set; }
public string? Next { get; set; }
public CultNetRawDocumentHeader[]? Headers { get; set; }
public CultNetRawDocumentRecord[]? Documents { get; set; }
public CultNetEdge[]? Edges { get; set; }
}
/// <summary>
/// Refused typed at the door (section 2/3): a name, list, or comparison the declared shape cannot
/// support. Carried as <c>cultnet.error.v0 { code: "selection_invalid", details: { field, value } }</c>.
/// </summary>
public sealed class CultNetSelectionInvalidException : Exception
{
public CultNetSelectionInvalidException(string field, string? value, string message)
: base(message)
{
Field = field;
Value = value;
}
/// <summary>The selection field that failed ("schemas", "keys", "fields[i].index", "cites.role", "cited.role", ...).</summary>
public string Field { get; }
/// <summary>The offending value, when there is one string worth naming.</summary>
public string? Value { get; }
}
/// <summary>
/// Validates a <see cref="CultNetSelection"/> against the declared shape of the schemas it can
/// reach - the door refusals of docs/cultnet-selection-cut.md section 2/3 (S1).
/// </summary>
public static class CultNetSelectionValidation
{
public static void Validate(this CultNetSelection selection, IReadOnlyList<CultDocumentDescriptor> allDescriptors)
{
if (selection == null) throw new ArgumentNullException(nameof(selection));
if (allDescriptors == null) throw new ArgumentNullException(nameof(allDescriptors));
if (selection.Schemas != null && selection.Schemas.Length == 0)
throw new CultNetSelectionInvalidException("schemas", null, "selection.schemas is present and empty; omit it to reach every schema.");
if (selection.Keys != null && selection.Keys.Length == 0)
throw new CultNetSelectionInvalidException("keys", null, "selection.keys is present and empty; omit it to reach every key.");
if (selection.Projection is not (CultNetSelectionProjections.Header or CultNetSelectionProjections.Document))
throw new CultNetSelectionInvalidException("projection", selection.Projection, $"selection.projection \"{selection.Projection}\" is neither \"header\" nor \"document\".");
var reachable = ReachableSchemas(selection, allDescriptors);
if (selection.Fields != null)
{
for (var i = 0; i < selection.Fields.Length; i++)
{
ValidateField(selection.Fields[i], i, reachable);
}
}
if (selection.Cites != null)
{
if (string.IsNullOrEmpty(selection.Cites.Target?.SchemaId) || string.IsNullOrEmpty(selection.Cites.Target?.RecordKey))
throw new CultNetSelectionInvalidException("cites.target", null, "selection.cites.target requires schemaId and recordKey.");
if (selection.Cites.Role != null && !AnySchemaDeclaresRole(allDescriptors, selection.Cites.Role))
throw new CultNetSelectionInvalidException("cites.role", selection.Cites.Role, $"selection.cites.role \"{selection.Cites.Role}\" is not declared by any schema.");
}
if (selection.Cited != null)
{
if (string.IsNullOrEmpty(selection.Cited.Role))
throw new CultNetSelectionInvalidException("cited.role", null, "selection.cited.role must be non-empty.");
if (!AnySchemaDeclaresRole(allDescriptors, selection.Cited.Role))
throw new CultNetSelectionInvalidException("cited.role", selection.Cited.Role, $"selection.cited.role \"{selection.Cited.Role}\" is not declared by any schema.");
}
}
private static void ValidateField(CultNetFieldPredicate field, int index, IReadOnlyList<CultDocumentDescriptor> reachable)
{
var prefix = $"fields[{index}]";
if (string.IsNullOrEmpty(field.Index))
throw new CultNetSelectionInvalidException($"{prefix}.index", null, $"{prefix}.index must be non-empty.");
if (!CultNetSelectionOperators.TryParse(field.Op, out var op))
throw new CultNetSelectionInvalidException($"{prefix}.op", field.Op, $"{prefix}.op \"{field.Op}\" is not any_of/lt/le/ge/gt.");
if (op == CultNetSelectionOperator.AnyOf)
{
if (field.Values == null || field.Values.Length == 0)
throw new CultNetSelectionInvalidException($"{prefix}.values", null, $"{prefix}.values must be non-empty for op any_of.");
if (field.Number.HasValue)
throw new CultNetSelectionInvalidException($"{prefix}.number", null, $"{prefix} carries both values and number; any_of takes only values.");
}
else
{
if (!field.Number.HasValue)
throw new CultNetSelectionInvalidException($"{prefix}.number", null, $"{prefix}.number is required for op {field.Op}.");
if (field.Values != null)
throw new CultNetSelectionInvalidException($"{prefix}.values", null, $"{prefix} carries both values and number; a comparison takes only number.");
}
var declaring = reachable
.Select(descriptor => descriptor.DeclaredMembers.FirstOrDefault(member => member.IndexAlias == field.Index))
.Where(member => member.MemberName != null)
.ToArray();
if (declaring.Length == 0)
throw new CultNetSelectionInvalidException($"{prefix}.index", field.Index, $"{prefix}.index \"{field.Index}\" is not declared by any schema this selection can reach.");
if (op.IsComparison() && declaring.Any(member => !member.IsNumeric))
throw new CultNetSelectionInvalidException($"{prefix}.index", field.Index, $"{prefix}.index \"{field.Index}\" is declared non-numeric on a schema this selection can reach; comparisons need a numeric declaration everywhere the alias is reachable.");
}
private static bool AnySchemaDeclaresRole(IReadOnlyList<CultDocumentDescriptor> allDescriptors, string role) =>
allDescriptors.Any(descriptor => descriptor.DeclaredMembers.Any(member =>
member.IsReference && (member.IndexAlias ?? member.MemberName) == role));
/// <summary>The schemas <see cref="CultNetSelection.Schemas"/> reaches - every schema when absent.</summary>
public static IReadOnlyList<CultDocumentDescriptor> ReachableSchemas(
CultNetSelection selection, IReadOnlyList<CultDocumentDescriptor> allDescriptors)
{
if (selection.Schemas == null || selection.Schemas.Length == 0)
return allDescriptors;
return allDescriptors.Where(descriptor => CultNetSchemaAliasMatching.MatchesAny(selection.Schemas, descriptor)).ToArray();
}
}
}

View file

@ -0,0 +1,397 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using GameCult.Caching;
namespace GameCult.Networking
{
/// <summary>
/// The one evaluator for CultNet typed selections (docs/cultnet-selection-cut.md, section 6/8).
/// Owns matching, order, snapshot, hop and cursor. Reflects over nothing: every declared value it
/// reads comes through <see cref="CultDocumentDescriptor"/>'s public read surface (section 1's cache
/// commit). Replaces the three selector engines this cut deletes from
/// <c>CultNetDocumentRegistry</c>/<c>CultNetDatabaseServer</c>/<c>CultNetDatabaseSubscriptionServer</c>.
/// </summary>
public static class CultNetSelectionEvaluator
{
/// <summary>The lowest and highest limit a page may carry (section 2).</summary>
public const uint LimitMin = 1;
public const uint LimitMax = 200;
/// <summary>One row available to the evaluator: what the cache already knows about it.</summary>
public readonly struct Row
{
public Row(CultDocumentDescriptor descriptor, CultRecordKey key, object document, long ordinal, string storedAt = "")
{
Descriptor = descriptor;
Key = key;
Document = document;
Ordinal = ordinal;
StoredAt = storedAt;
}
public CultDocumentDescriptor Descriptor { get; }
public CultRecordKey Key { get; }
public object Document { get; }
public long Ordinal { get; }
public string StoredAt { get; }
}
/// <summary>One edge a hop traversed, resolved against the row set in hand.</summary>
public readonly struct EdgeMatch
{
public EdgeMatch(Row from, string role, Row to, object? payload)
{
From = from;
Role = role;
To = to;
Payload = payload;
}
public Row From { get; }
public string Role { get; }
public Row To { get; }
public object? Payload { get; }
}
/// <summary>Evaluated, ordered, paged rows plus the edges a hop traversed.</summary>
public sealed class Evaluation
{
public IReadOnlyList<Row> Rows { get; set; } = Array.Empty<Row>();
public IReadOnlyList<EdgeMatch> Edges { get; set; } = Array.Empty<EdgeMatch>();
public string? NextCursor { get; set; }
}
/// <summary>
/// The single-row fast path (D6): schemas, keys and fields only. A hop-bearing selection
/// (<see cref="CultNetSelection.HasHop"/>) is set-dependent and must not use this path - the
/// caller reconciles the full set instead.
/// </summary>
public static bool Matches(CultDocumentDescriptor descriptor, CultRecordKey key, object? document, CultNetSelection selection)
{
if (selection.HasHop)
throw new InvalidOperationException("A hop-bearing selection (cites/cited) is set-dependent and cannot use the single-row Matches fast path; reconcile the full row set instead.");
return MatchesSchemaKeysFields(descriptor, key, document, selection);
}
private static bool MatchesSchemaKeysFields(CultDocumentDescriptor descriptor, CultRecordKey key, object? document, CultNetSelection selection)
{
if (selection.Schemas is { Length: > 0 } && !CultNetSchemaAliasMatching.MatchesAny(selection.Schemas, descriptor))
return false;
if (selection.Keys is { Length: > 0 } && !selection.Keys.Contains(key.Value, StringComparer.Ordinal))
return false;
if (selection.Fields == null)
return true;
foreach (var field in selection.Fields)
{
if (!CultNetSelectionOperators.TryParse(field.Op, out var op))
return false;
if (op == CultNetSelectionOperator.AnyOf)
{
if (!descriptor.TryGetIndexValue(document, field.Index, out var value) ||
value == null ||
field.Values == null ||
!field.Values.Contains(value, StringComparer.Ordinal))
return false;
}
else
{
if (!descriptor.TryGetIndexNumber(document, field.Index, out var number) || !field.Number.HasValue)
return false;
if (!CompareNumber(number, op, field.Number.Value))
return false;
}
}
return true;
}
private static bool CompareNumber(double left, CultNetSelectionOperator op, double right) => op switch
{
CultNetSelectionOperator.Lt => left < right,
CultNetSelectionOperator.Le => left <= right,
CultNetSelectionOperator.Ge => left >= right,
CultNetSelectionOperator.Gt => left > right,
_ => throw new ArgumentOutOfRangeException(nameof(op))
};
/// <summary>
/// Evaluates a selection over the full row set: schemas/keys/fields, the hop (cites/cited,
/// with its edges and the reference_outside_target refusal), order, cursor and paging. The
/// caller projects the result to wire records (<see cref="CultNetDocumentRegistry"/> owns that).
/// </summary>
public static Evaluation Select(
CultDocumentRegistry registry,
IReadOnlyList<Row> allRows,
CultNetSelection selection,
ulong asOf)
{
if (registry == null) throw new ArgumentNullException(nameof(registry));
if (allRows == null) throw new ArgumentNullException(nameof(allRows));
if (selection == null) throw new ArgumentNullException(nameof(selection));
var byKey = new Dictionary<string, Row>(StringComparer.Ordinal);
foreach (var row in allRows)
byKey[row.Key.Value] = row;
var candidates = allRows.Where(row => MatchesSchemaKeysFields(row.Descriptor, row.Key, row.Document, selection));
var edges = new List<EdgeMatch>();
if (selection.Cited != null)
{
var incoming = BuildIncomingIndex(registry, byKey, selection.Cited.Role, edges);
var exists = selection.Cited.Exists;
candidates = candidates.Where(row => incoming.Contains(row.Key.Value) == exists);
}
if (selection.Cites != null)
{
candidates = candidates.Where(row => MatchesCitation(registry, byKey, row, selection.Cites, edges));
}
var matched = candidates.ToArray();
var ordered = (selection.Descending
? matched.OrderByDescending(row => row.Ordinal).ThenByDescending(row => row.Descriptor.SchemaId, StringComparer.Ordinal).ThenByDescending(row => row.Key.Value, StringComparer.Ordinal)
: matched.OrderBy(row => row.Ordinal).ThenBy(row => row.Descriptor.SchemaId, StringComparer.Ordinal).ThenBy(row => row.Key.Value, StringComparer.Ordinal))
.ToArray();
var startIndex = 0;
if (!string.IsNullOrEmpty(selection.Cursor))
{
var cursor = CultNetSelectionCursor.Parse(selection.Cursor!);
if (cursor.Digest != CultNetSelectionCursor.ComputeDigest(selection))
throw new CultNetSelectionCursorException("cursor_invalid", "The cursor's selection digest does not match this selection.");
if (cursor.AsOf != asOf)
throw new CultNetSelectionCursorException("cursor_stale", $"The cursor was minted at asOf {cursor.AsOf}; this server answers as of {asOf}.");
startIndex = FindCursorPosition(ordered, cursor, selection.Descending);
}
var limit = (int)Math.Clamp(selection.Limit ?? LimitMax, LimitMin, LimitMax);
var page = ordered.Skip(startIndex).Take(limit).ToArray();
var hasNext = startIndex + page.Length < ordered.Length;
var nextCursor = hasNext && page.Length > 0
? CultNetSelectionCursor.Mint(asOf, page[^1], selection)
: null;
var pageKeys = new HashSet<string>(page.Select(row => row.Key.Value), StringComparer.Ordinal);
var pageEdges = selection.HasHop
? edges.Where(edge => pageKeys.Contains(edge.From.Key.Value)).ToArray()
: Array.Empty<EdgeMatch>();
return new Evaluation { Rows = page, Edges = pageEdges, NextCursor = nextCursor };
}
private static int FindCursorPosition(IReadOnlyList<Row> ordered, CultNetSelectionCursor cursor, bool descending)
{
for (var i = 0; i < ordered.Count; i++)
{
var row = ordered[i];
var comparesAfter = descending
? ComparePosition(row, cursor) < 0
: ComparePosition(row, cursor) > 0;
if (comparesAfter)
return i;
}
return ordered.Count;
}
private static int ComparePosition(Row row, CultNetSelectionCursor cursor)
{
var ordinal = row.Ordinal.CompareTo(cursor.Ordinal);
if (ordinal != 0) return ordinal;
var schema = string.CompareOrdinal(row.Descriptor.SchemaId, cursor.SchemaId);
return schema != 0 ? schema : string.CompareOrdinal(row.Key.Value, cursor.RecordKey);
}
// D9: a reference's target set is every registered leaf assignable to its declared target type.
// A stored edge naming a row whose schema is outside that set refuses the selection (S18).
private static void EnsureWithinDeclaredTarget(CultDocumentRegistry registry, Type? targetType, Row from, string role, Row to)
{
if (targetType == null) return;
var leaves = registry.ResolveTargetLeaves(targetType);
if (leaves.Any(leaf => leaf.SchemaId == to.Descriptor.SchemaId)) return;
throw new CultNetSelectionReferenceOutsideTargetException(from.Descriptor.SchemaId, from.Key.Value, role, to.Descriptor.SchemaId, to.Key.Value);
}
private static bool MatchesCitation(
CultDocumentRegistry registry,
IReadOnlyDictionary<string, Row> byKey,
Row citer,
CultNetCitation citation,
List<EdgeMatch> edgeSink)
{
if (!byKey.TryGetValue(citation.Target.RecordKey, out var target))
return false;
var found = false;
foreach (var member in ReferenceMembers(citer.Descriptor, citation.Role))
{
var role = member.IndexAlias ?? member.MemberName;
foreach (var (targetKey, payload) in citer.Descriptor.ReferencesOf(citer.Document, role))
{
if (!string.Equals(targetKey.Value, citation.Target.RecordKey, StringComparison.Ordinal))
continue;
if (!byKey.TryGetValue(targetKey.Value, out var resolved))
continue;
EnsureWithinDeclaredTarget(registry, member.TargetType, citer, role, resolved);
if (resolved.Descriptor.SchemaId != citation.Target.SchemaId)
continue;
edgeSink.Add(new EdgeMatch(citer, role, resolved, payload));
found = true;
}
}
return found;
}
private static HashSet<string> BuildIncomingIndex(
CultDocumentRegistry registry,
IReadOnlyDictionary<string, Row> byKey,
string role,
List<EdgeMatch> edgeSink)
{
var incoming = new HashSet<string>(StringComparer.Ordinal);
foreach (var citer in byKey.Values)
{
foreach (var member in ReferenceMembers(citer.Descriptor, role))
{
var memberRole = member.IndexAlias ?? member.MemberName;
foreach (var (targetKey, payload) in citer.Descriptor.ReferencesOf(citer.Document, memberRole))
{
if (!byKey.TryGetValue(targetKey.Value, out var resolved))
continue;
EnsureWithinDeclaredTarget(registry, member.TargetType, citer, memberRole, resolved);
incoming.Add(targetKey.Value);
edgeSink.Add(new EdgeMatch(citer, memberRole, resolved, payload));
}
}
}
return incoming;
}
private static IEnumerable<CultDocumentMemberView> ReferenceMembers(CultDocumentDescriptor descriptor, string? role)
{
foreach (var member in descriptor.DeclaredMembers)
{
if (!member.IsReference) continue;
if (role != null && (member.IndexAlias ?? member.MemberName) != role) continue;
yield return member;
}
}
}
/// <summary>The typed refusal for an edge naming a row outside its reference's declared target (S18).</summary>
public sealed class CultNetSelectionReferenceOutsideTargetException : Exception
{
public CultNetSelectionReferenceOutsideTargetException(string fromSchemaId, string fromKey, string role, string toSchemaId, string toKey)
: base($"reference_outside_target: {fromSchemaId}/{fromKey} names {toSchemaId}/{toKey} through role \"{role}\", which is outside the reference's declared target.")
{
FromSchemaId = fromSchemaId;
FromKey = fromKey;
Role = role;
ToSchemaId = toSchemaId;
ToKey = toKey;
}
public string FromSchemaId { get; }
public string FromKey { get; }
public string Role { get; }
public string ToSchemaId { get; }
public string ToKey { get; }
}
/// <summary>The typed refusal for a cursor the server cannot answer (<c>cursor_stale</c>/<c>cursor_invalid</c>).</summary>
public sealed class CultNetSelectionCursorException : Exception
{
public CultNetSelectionCursorException(string code, string message) : base(message)
{
Code = code;
}
public string Code { get; }
}
/// <summary>
/// The opaque cursor: asOf, the last (ordinal, schemaId, recordKey), and a digest of the selection
/// with cursor and limit cleared (section 2). Minted only by the answering server.
/// </summary>
public readonly struct CultNetSelectionCursor
{
private CultNetSelectionCursor(ulong asOf, long ordinal, string schemaId, string recordKey, string digest)
{
AsOf = asOf;
Ordinal = ordinal;
SchemaId = schemaId;
RecordKey = recordKey;
Digest = digest;
}
public ulong AsOf { get; }
public long Ordinal { get; }
public string SchemaId { get; }
public string RecordKey { get; }
public string Digest { get; }
public static string Mint(ulong asOf, CultNetSelectionEvaluator.Row lastRow, CultNetSelection selection)
{
var digest = ComputeDigest(selection);
var raw = string.Join(
"",
asOf.ToString(CultureInfo.InvariantCulture),
lastRow.Ordinal.ToString(CultureInfo.InvariantCulture),
lastRow.Descriptor.SchemaId,
lastRow.Key.Value,
digest);
return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw));
}
public static CultNetSelectionCursor Parse(string cursor)
{
string raw;
try
{
raw = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
}
catch (FormatException)
{
throw new CultNetSelectionCursorException("cursor_invalid", "The cursor does not decode.");
}
var parts = raw.Split('');
if (parts.Length != 5 ||
!ulong.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var asOf) ||
!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ordinal))
{
throw new CultNetSelectionCursorException("cursor_invalid", "The cursor does not decode.");
}
return new CultNetSelectionCursor(asOf, ordinal, parts[2], parts[3], parts[4]);
}
/// <summary>A digest of the selection with cursor and limit cleared, so a cursor is bound to the selection that minted it.</summary>
public static string ComputeDigest(CultNetSelection selection)
{
var canonical = string.Join(
"",
string.Join(",", (selection.Schemas ?? Array.Empty<string>()).OrderBy(v => v, StringComparer.Ordinal)),
string.Join(",", (selection.Keys ?? Array.Empty<string>()).OrderBy(v => v, StringComparer.Ordinal)),
string.Join(";", (selection.Fields ?? Array.Empty<CultNetFieldPredicate>())
.Select(f => $"{f.Index}:{f.Op}:{string.Join("|", f.Values ?? Array.Empty<string>())}:{f.Number}")),
selection.Cites == null ? "" : $"{selection.Cites.Target.SchemaId}/{selection.Cites.Target.RecordKey}:{selection.Cites.Role}",
selection.Cited == null ? "" : $"{selection.Cited.Role}:{selection.Cited.Exists}",
selection.Projection,
selection.Descending.ToString());
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(canonical));
var hex = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes) hex.Append(b.ToString("x2", CultureInfo.InvariantCulture));
return hex.ToString();
}
}
}

View file

@ -0,0 +1,352 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// CultNet typed selection, Cut 1, commit 1 (docs/cultnet-selection-cut.md): the shape, its
// validation door and the one evaluator. Independent of any live server/client transport - these
// exercise CultNetSelection/CultNetSelectionEvaluator directly against a registry and a hand-built
// row set, matching section 10's S1-S23 naming.
public sealed class CultNetSelectionEvaluatorTests
{
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[]
{
typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiter)
});
private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) =>
new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal);
// S1: an index no reachable schema declares, an empty values list, and an empty schemas/keys
// list are refused typed at the door, never answered as an empty page.
[Test]
public void ValidationRefusesUndeclaredIndexRoleAndEmptyLists()
{
var descriptors = Registry().AllDescriptors.ToArray();
AssertInvalid(new CultNetSelection { Schemas = Array.Empty<string>() }, descriptors, "schemas");
AssertInvalid(new CultNetSelection { Keys = Array.Empty<string>() }, descriptors, "keys");
AssertInvalid(
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "not_declared", Op = "any_of", Values = new[] { "x" } } } },
descriptors,
"fields[0].index");
AssertInvalid(
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = Array.Empty<string>() } } },
descriptors,
"fields[0].values");
AssertInvalid(
new CultNetSelection { Cited = new CultNetIncoming { Role = "no_such_role", Exists = true } },
descriptors,
"cited.role");
}
private static void AssertInvalid(CultNetSelection selection, IReadOnlyList<CultDocumentDescriptor> descriptors, string expectedField)
{
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
Assert.That(ex!.Field, Is.EqualTo(expectedField));
}
// S2: fields conjoin; any_of matches on membership.
[Test]
public void EvaluatorConjoinsAnyOfPredicatesOverDeclaredIndexes()
{
var registry = Registry();
var a = new SelLeafA { Name = "sword", Kind = "weapon", Mass = 3 };
var b = new SelLeafA { Name = "shield", Kind = "armor", Mass = 3 };
var rows = new[] { Row(registry, a, "a", 1), Row(registry, b, "b", 2) };
var selection = new CultNetSelection
{
Fields = new[]
{
new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } },
new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = 3 }
}
};
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" }));
}
// S3: order is (ordinal, schemaId, recordKey) ascending, reversed under descending.
[Test]
public void EvaluatorOrdersByOrdinalThenIdentityAndReverses()
{
var registry = Registry();
var rows = new[]
{
Row(registry, new SelLeafA { Name = "c", Kind = "k", Mass = 1 }, "c", 3),
Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1),
Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2)
};
var ascending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection(), asOf: 1);
Assert.That(ascending.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a", "b", "c" }));
var descending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection { Descending = true }, asOf: 1);
Assert.That(descending.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "c", "b", "a" }));
}
// S4: a page walk visits every row exactly once and the last page carries no cursor.
[Test]
public void EvaluatorPagesExactlyOnceAndTheLastPageSaysSo()
{
var registry = Registry();
var rows = Enumerable.Range(0, 5)
.Select(i => Row(registry, new SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i))
.ToArray();
var selection = new CultNetSelection { Limit = 2 };
var seen = new List<string>();
string? cursor = null;
for (var guard = 0; guard < 10; guard++)
{
selection.Cursor = cursor;
var page = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
seen.AddRange(page.Rows.Select(r => r.Key.Value));
if (page.NextCursor == null) break;
cursor = page.NextCursor;
}
Assert.That(seen, Is.EqualTo(new[] { "k0", "k1", "k2", "k3", "k4" }));
}
// S5: a cursor answers only the asOf and the selection it was minted for.
[Test]
public void EvaluatorRefusesAStaleCursorAndAMismatchedSelection()
{
var registry = Registry();
var rows = new[]
{
Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1),
Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2)
};
var selection = new CultNetSelection { Limit = 1 };
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
Assert.That(first.NextCursor, Is.Not.Null);
selection.Cursor = first.NextCursor;
Assert.That(
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 2),
Throws.TypeOf<CultNetSelectionCursorException>()
.With.Property(nameof(CultNetSelectionCursorException.Code)).EqualTo("cursor_stale"));
var otherSelection = new CultNetSelection { Limit = 1, Cursor = first.NextCursor, Descending = true };
Assert.That(
() => CultNetSelectionEvaluator.Select(registry, rows, otherSelection, asOf: 1),
Throws.TypeOf<CultNetSelectionCursorException>()
.With.Property(nameof(CultNetSelectionCursorException.Code)).EqualTo("cursor_invalid"));
}
// S6: the hop follows one declared reference by role; a second role is not followed.
[Test]
public void EvaluatorHopsOneEdgeByDeclaredReferenceAndRole()
{
var registry = Registry();
var target = new SelLeafA { Name = "target", Kind = "k", Mass = 1 };
var citer = new SelCiter { Name = "citer", Design = new CultRecordRef<SelFixtureMiddle>(new CultRecordKey("target")) };
var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) };
var selection = new CultNetSelection
{
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = registry.GetRequired<SelLeafA>().SchemaId, RecordKey = "target" }, Role = "Design" }
};
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" }));
Assert.That(evaluation.Edges.Single().Role, Is.EqualTo("Design"));
var wrongRole = new CultNetSelection
{
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = registry.GetRequired<SelLeafA>().SchemaId, RecordKey = "target" }, Role = "NotARole" }
};
// Validate refuses an undeclared role before evaluation ever runs (S1).
Assert.Throws<CultNetSelectionInvalidException>(() => wrongRole.Validate(registry.AllDescriptors.ToArray()));
}
// S7: cited { exists: false } is the one negation.
[Test]
public void EvaluatorNegatedIncomingEdgeIsTheOnlyNegation()
{
var registry = Registry();
var cited = new SelLeafA { Name = "cited", Kind = "k", Mass = 1 };
var uncited = new SelLeafA { Name = "uncited", Kind = "k", Mass = 1 };
var citer = new SelCiter { Name = "citer", Design = new CultRecordRef<SelFixtureMiddle>(new CultRecordKey("cited")) };
var rows = new[] { Row(registry, cited, "cited", 1), Row(registry, uncited, "uncited", 2), Row(registry, citer, "citer", 3) };
var exists = CultNetSelectionEvaluator.Select(registry, rows,
new CultNetSelection { Schemas = new[] { registry.GetRequired<SelLeafA>().SchemaId }, Cited = new CultNetIncoming { Role = "Design", Exists = true } },
asOf: 1);
Assert.That(exists.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "cited" }));
var absent = CultNetSelectionEvaluator.Select(registry, rows,
new CultNetSelection { Schemas = new[] { registry.GetRequired<SelLeafA>().SchemaId }, Cited = new CultNetIncoming { Role = "Design", Exists = false } },
asOf: 1);
Assert.That(absent.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "uncited" }));
}
// S8: header projection carries no payload; edges under header carry no payload either (S20).
[Test]
public void HeaderProjectionCarriesNoPayload()
{
var record = new CultNetRawDocumentRecord
{
SchemaId = "s", RecordKey = "k", StoredAt = "now", PayloadEncoding = "messagepack", Payload = new byte[] { 1, 2, 3 }
};
var header = CultNetRawDocumentHeader.FromRecord(record);
Assert.That(header.SchemaId, Is.EqualTo("s"));
// CultNetRawDocumentHeader has no Payload/PayloadEncoding members at all - a compile-time
// guarantee, not merely a null check; this test exists to name the rule.
}
// S16: the four comparisons at the boundary, including equal-to-the-compared-number rows.
[Test]
public void EvaluatorComparesNumbersAtTheBoundaryForEachOfTheFourOperators()
{
var registry = Registry();
var rows = new[]
{
Row(registry, new SelLeafA { Name = "lo", Kind = "k", Mass = 4 }, "lo", 1),
Row(registry, new SelLeafA { Name = "eq", Kind = "k", Mass = 5 }, "eq", 2),
Row(registry, new SelLeafA { Name = "hi", Kind = "k", Mass = 6 }, "hi", 3)
};
string[] Matches(string op) => CultNetSelectionEvaluator.Select(
registry, rows,
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = op, Number = 5 } } },
asOf: 1).Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal).ToArray();
Assert.That(Matches("lt"), Is.EqualTo(new[] { "lo" }));
Assert.That(Matches("le"), Is.EqualTo(new[] { "eq", "lo" }));
Assert.That(Matches("ge"), Is.EqualTo(new[] { "eq", "hi" }));
Assert.That(Matches("gt"), Is.EqualTo(new[] { "hi" }));
}
// S17: an alias declared once on the abstract middle reaches every leaf that inherits it.
[Test]
public void EvaluatorAppliesAnInheritedAliasToEveryReachableLeaf()
{
var registry = Registry();
var a = new SelLeafA { Name = "a", Kind = "k", Mass = 20 };
var b = new SelLeafB { Name = "b", Kind = "k", Mass = 20 };
var rows = new[] { Row(registry, a, "a", 1), Row(registry, b, "b", 2) };
var evaluation = CultNetSelectionEvaluator.Select(
registry, rows,
new CultNetSelection
{
Schemas = new[] { registry.GetRequired<SelLeafA>().SchemaId, registry.GetRequired<SelLeafB>().SchemaId },
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = 10 } }
},
asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal), Is.EqualTo(new[] { "a", "b" }));
}
// S18: an edge naming a row outside the reference's declared target refuses the selection.
[Test]
public void EvaluatorRefusesAnEdgeOutsideItsDeclaredTarget()
{
var registry = Registry();
var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 };
// Design's declared target is SelFixtureMiddle (an abstract type with no schema of its own),
// and both leaves are within that target, so this citer is a legitimate in-target edge.
var okCiter = new SelCiter { Name = "ok", Design = new CultRecordRef<SelFixtureMiddle>(new CultRecordKey("b")) };
var refA = registry.GetRequired<SelLeafA>();
var offTargetCiter = new SelCiterNarrow { Name = "bad", NarrowRef = new CultRecordRef<SelLeafA>(new CultRecordKey("b")) };
var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterNarrow) });
var rows = new[]
{
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelCiterNarrow>(), new CultRecordKey("bad"), offTargetCiter, 2)
};
var selection = new CultNetSelection
{
Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true }
};
Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
() => CultNetSelectionEvaluator.Select(narrowRegistry, rows, selection, asOf: 1));
}
// S23: the evaluator matches every row sharing an index value, never the cache's own
// last-writer-wins unique-index map. Three rows share "shared" here.
[Test]
public void EvaluatorMatchesEveryRowSharingAnIndexValueNotAWinner()
{
var registry = Registry();
var rows = new[]
{
Row(registry, new SelLeafA { Name = "1", Kind = "shared", Mass = 1 }, "1", 1),
Row(registry, new SelLeafA { Name = "2", Kind = "shared", Mass = 1 }, "2", 2),
Row(registry, new SelLeafA { Name = "3", Kind = "shared", Mass = 1 }, "3", 3)
};
var evaluation = CultNetSelectionEvaluator.Select(
registry, rows,
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "shared" } } } },
asOf: 1);
Assert.That(evaluation.Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal), Is.EqualTo(new[] { "1", "2", "3" }));
}
public abstract class SelFixtureMiddle
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultIndex("mass")]
public float Mass;
[Key(2)]
[CultIndex("kind")]
public string Kind = string.Empty;
}
[CultDocument("cultnet.selection-tests.leaf_a", "cultnet.selection-tests.leaf_a.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SelLeafA : SelFixtureMiddle
{
}
[CultDocument("cultnet.selection-tests.leaf_b", "cultnet.selection-tests.leaf_b.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SelLeafB : SelFixtureMiddle
{
}
[CultDocument("cultnet.selection-tests.citer", "cultnet.selection-tests.citer.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SelCiter
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultReference(typeof(SelFixtureMiddle))]
public CultRecordRef<SelFixtureMiddle> Design;
}
[CultDocument("cultnet.selection-tests.citer_narrow", "cultnet.selection-tests.citer_narrow.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class SelCiterNarrow
{
[Key(0)]
[CultName]
public string Name = string.Empty;
// Declared target is the single leaf SelLeafA, not the abstract middle - a value pointing at
// a SelLeafB row is outside this reference's declared target set (S18).
[Key(1)]
[CultReference(typeof(SelLeafA))]
public CultRecordRef<SelLeafA> NarrowRef;
}
}
}

View file

@ -2331,10 +2331,10 @@ namespace GameCult.Networking.Tests
new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "alias-live" },
previousDocument: null);
var method = typeof(CultNetDatabaseSubscriptionServer).GetMethod(
"CreateChange",
"CreateMatchedRecord",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var message = (CultNetDatabaseChangeRawMessage?)method.Invoke(subscriptions, new object[]
var record = (CultNetRawDocumentRecord?)method.Invoke(subscriptions, new object?[]
{
change,
new CultNetDatabaseSubscribeMessage
@ -2343,14 +2343,14 @@ namespace GameCult.Networking.Tests
SchemaIds = ["tests.networking_note.v1"],
RecordKeys = ["tests:subscription-client:alias-note"]
},
"alias-notes"
null
});
Assert.That(message, Is.Not.Null);
Assert.That(message!.Document!.SchemaId, Is.EqualTo(descriptor.SchemaId));
Assert.That(message.Document.SchemaName, Is.EqualTo(descriptor.SchemaName));
Assert.That(message.Document.SchemaVersion, Is.EqualTo(descriptor.SchemaVersion));
Assert.That(message.Document.SchemaContentHash, Is.EqualTo(descriptor.ContentHash));
Assert.That(record, Is.Not.Null);
Assert.That(record!.SchemaId, Is.EqualTo(descriptor.SchemaId));
Assert.That(record.SchemaName, Is.EqualTo(descriptor.SchemaName));
Assert.That(record.SchemaVersion, Is.EqualTo(descriptor.SchemaVersion));
Assert.That(record.SchemaContentHash, Is.EqualTo(descriptor.ContentHash));
}
[Test]
@ -5582,11 +5582,11 @@ namespace GameCult.Networking.Tests
{
change,
"sub-1",
new CultNetDatabaseSubscribeMessage
new CultNetSelection
{
SubscriptionId = "sub-1",
SchemaIds = [schemaId],
RecordKeys = [key.Value]
Schemas = [schemaId],
Keys = [key.Value],
Projection = CultNetSelectionProjections.Document
}
});
@ -5632,11 +5632,11 @@ namespace GameCult.Networking.Tests
{
change,
"sub-alias",
new CultNetDatabaseSubscribeMessage
new CultNetSelection
{
SubscriptionId = "sub-alias",
SchemaIds = ["tests.networking_note.v1"],
RecordKeys = [key.Value]
Schemas = ["tests.networking_note.v1"],
Keys = [key.Value],
Projection = CultNetSelectionProjections.Document
}
});