Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap
R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte
random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key
instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not
verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body
(asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no
delimiter) instead of joined with a bare "\x01"-separated string, so a record key
carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/
Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a
process-wide key generated once so existing callers that never wire up a database still
get a keyed digest. CultNetDatabase gets a CursorKey property (random unless
CultNetDatabaseOptions.CursorKey is supplied) and passes it through
CultNetDatabaseServer.CreateSelectionResponse.
Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching
the reference: its own doc comment says a cursor is never handed across runtimes, so only
collision-freedom and keying are load-bearing, not the wire bytes. That is a real
divergence from this pass's instruction to match Rust's wire encoding exactly - reported
in full in the batch report.
R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded
the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position
looked like "never written" until it was written again locally. Both now set it from the
entry's own sequence, matching AppendMutationLogEntry's local-commit path.
InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also
rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence
order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard
resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional
shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid,
R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is
a single watermark and is not exact for a shard-spanning page.
CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the
database.
Also fixes a defect the Rust Hands found while matching wire bytes:
CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: "
- the exception's own message already carries it. Now prefixed once, matching Rust's
ForReferenceOutsideTarget.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
eff62e59fb
commit
a4cf54f4a9
8 changed files with 541 additions and 41 deletions
|
|
@ -305,6 +305,12 @@ namespace GameCult.Networking
|
|||
/// Gets or sets the durable store for accepted shard mutation logs.
|
||||
/// </summary>
|
||||
public ICultNetShardMutationLogStore? MutationLogStore { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the process cursor key (R-O). Omitted in production, where a fresh random key
|
||||
/// per <see cref="CultNetDatabase"/> is exactly the point; a test that must mint a cursor under a
|
||||
/// known key, or reuse the pre-restart key deliberately, sets this explicitly.
|
||||
/// </summary>
|
||||
public CultNetSelectionCursorKey? CursorKey { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -494,10 +500,25 @@ namespace GameCult.Networking
|
|||
: options.Shards)
|
||||
.ToList();
|
||||
_clientAuthorityScopes = (options.ClientAuthorityScopes ?? Array.Empty<CultNetClientAuthorityScope>()).ToList();
|
||||
CursorKey = options.CursorKey ?? CultNetSelectionCursorKey.Random();
|
||||
InitializeLogSequencesFromStore();
|
||||
_cache.OnUpdate += PublishCacheUpdate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This process's cursor key (R-O): random per instance unless <see cref="CultNetDatabaseOptions.CursorKey"/>
|
||||
/// supplies one, so a page minted by this database cannot be answered by a differently-keyed one,
|
||||
/// and a fresh <see cref="CultNetDatabase"/> - a real process restart, or a simulated one in tests -
|
||||
/// mints under a fresh key that a prior cursor's digest does not verify against.
|
||||
/// </summary>
|
||||
public CultNetSelectionCursorKey CursorKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the shard that owns a record key (R-Q: whether a matched selection's rows span more
|
||||
/// than one shard's log, and so whether the page's <c>asOf</c> is well defined).
|
||||
/// </summary>
|
||||
public CultNetShardDescriptor ResolveShard(string schemaId, CultRecordKey key) => ResolveShardInternal(schemaId, key);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the local cache backing this database surface.
|
||||
/// </summary>
|
||||
|
|
@ -1337,10 +1358,26 @@ namespace GameCult.Networking
|
|||
|
||||
foreach (var shard in _shards)
|
||||
{
|
||||
var highest = _mutationLogStore.Read(shard.ShardId)
|
||||
.Select(entry => entry.Sequence)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
// R-Q: the log is the durable record of every commit, replica-applied ones included, so
|
||||
// the last-write sequence a restart resumes with is rebuilt from it exactly as
|
||||
// AppendMutationLogEntry/the replica apply paths maintain it live - a row's ordinal for
|
||||
// the selection evaluator's order and cursor must not reset to "never written" on restart.
|
||||
var entries = _mutationLogStore.Read(shard.ShardId);
|
||||
var highest = 0L;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.Sequence > highest)
|
||||
{
|
||||
highest = entry.Sequence;
|
||||
}
|
||||
|
||||
var (schemaId, recordKey) = KeyOf(entry);
|
||||
if (schemaId != null && recordKey != null)
|
||||
{
|
||||
_lastWriteSequence[(schemaId, recordKey)] = entry.Sequence;
|
||||
}
|
||||
}
|
||||
|
||||
if (highest > 0)
|
||||
{
|
||||
_nextLogSequences[shard.ShardId] = highest + 1;
|
||||
|
|
@ -1348,6 +1385,21 @@ namespace GameCult.Networking
|
|||
}
|
||||
}
|
||||
|
||||
private static (string? SchemaId, string? RecordKey) KeyOf(CultNetShardLogEntryMessage entry)
|
||||
{
|
||||
if (entry.Put?.Document != null)
|
||||
{
|
||||
return (entry.Put.Document.SchemaId, entry.Put.Document.RecordKey);
|
||||
}
|
||||
|
||||
if (entry.Delete != null)
|
||||
{
|
||||
return (entry.Delete.SchemaId, entry.Delete.RecordKey);
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
private async Task ApplyCommittedShardLogEntryAsync(
|
||||
CultNetShardDescriptor shard,
|
||||
CultNetShardLogEntryMessage entry)
|
||||
|
|
@ -1416,6 +1468,11 @@ namespace GameCult.Networking
|
|||
document,
|
||||
previous),
|
||||
entry);
|
||||
// R-Q: a replica apply is a commit path too - the last-write sequence this row now carries
|
||||
// is the entry's own sequence, exactly like AppendMutationLogEntry's local-commit path sets
|
||||
// it, so the selection evaluator's order and cursor agree whether a row's most recent write
|
||||
// landed locally or arrived through replication.
|
||||
_lastWriteSequence[(descriptor.SchemaId, key.Value)] = entry.Sequence;
|
||||
PublishUntyped(
|
||||
descriptor.DocumentType,
|
||||
kind,
|
||||
|
|
@ -1459,6 +1516,8 @@ namespace GameCult.Networking
|
|||
document: null,
|
||||
previousDocument: previous),
|
||||
entry);
|
||||
// R-Q: see ApplyCommittedPutAsync - a replica-applied delete is a commit too.
|
||||
_lastWriteSequence[(descriptor.SchemaId, key.Value)] = entry.Sequence;
|
||||
PublishUntyped(
|
||||
descriptor.DocumentType,
|
||||
CultNetDatabaseChangeKind.Removed,
|
||||
|
|
|
|||
|
|
@ -130,7 +130,12 @@ namespace GameCult.Networking
|
|||
string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId,
|
||||
request.Selection,
|
||||
ordinalOf: (schemaId, key) => _database.LastWriteSequence(schemaId, key) ?? 0,
|
||||
asOf: _database.CurrentAsOf());
|
||||
asOf: _database.CurrentAsOf(),
|
||||
// R-Q: asOf is one shard-log watermark, so a selection whose matched rows span more than
|
||||
// one shard's log is refused rather than answered against a watermark that is not
|
||||
// exact for all of them.
|
||||
shardIdOf: (schemaId, key) => _database.ResolveShard(schemaId, key).ShardId,
|
||||
cursorKey: _database.CursorKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -344,13 +344,15 @@ namespace GameCult.Networking
|
|||
CultNetSelection selection,
|
||||
Func<string, CultRecordKey, long> ordinalOf,
|
||||
ulong asOf,
|
||||
CultNetDocumentMessageOptions? options = null)
|
||||
CultNetDocumentMessageOptions? options = null,
|
||||
Func<string, CultRecordKey, string>? shardIdOf = null,
|
||||
CultNetSelectionCursorKey? cursorKey = 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, options);
|
||||
var page = SelectPage(cache, selection, ordinalOf, asOf, options, shardIdOf: shardIdOf, cursorKey: cursorKey);
|
||||
return new CultNetSnapshotResponseRawV1Message
|
||||
{
|
||||
MessageId = RequireNonEmpty(messageId, nameof(messageId)),
|
||||
|
|
@ -374,7 +376,9 @@ namespace GameCult.Networking
|
|||
Func<string, CultRecordKey, long> ordinalOf,
|
||||
ulong asOf,
|
||||
CultNetDocumentMessageOptions? options,
|
||||
Func<CultDocumentDescriptor, CultRecordKey, bool>? rowFilter = null)
|
||||
Func<CultDocumentDescriptor, CultRecordKey, bool>? rowFilter = null,
|
||||
Func<string, CultRecordKey, string>? shardIdOf = null,
|
||||
CultNetSelectionCursorKey? cursorKey = null)
|
||||
{
|
||||
selection = ExpandSchemaBindingAliases(selection);
|
||||
// R-F: CultNetSelectionEvaluator.Select validates first, every time - there is no separate
|
||||
|
|
@ -388,7 +392,17 @@ namespace GameCult.Networking
|
|||
.Select(entry => new CultNetSelectionEvaluator.Row(
|
||||
entry.Descriptor, entry.Key, entry.Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt))
|
||||
.ToArray();
|
||||
var evaluation = CultNetSelectionEvaluator.Select(_documents, rows, selection, asOf);
|
||||
var full = CultNetSelectionEvaluator.EvaluateAll(_documents, rows, selection);
|
||||
// R-Q: a page's asOf is exact only when every matched row is committed through one shard's
|
||||
// log - a caller that knows about shards (CultNetDatabaseServer) supplies shardIdOf; one
|
||||
// that does not (the raw v0/shard-scoped rowFilter paths, which are already bounded to a
|
||||
// single shard by construction) passes null and skips the check rather than pay for it.
|
||||
if (shardIdOf != null)
|
||||
{
|
||||
EnsureSingleShard(full.Ordered, shardIdOf);
|
||||
}
|
||||
|
||||
var evaluation = CultNetSelectionEvaluator.Page(full, selection, asOf, cursorKey);
|
||||
var records = evaluation.Rows.Select(row => ToRawRecord(row, options)).ToArray();
|
||||
var wantDocument = selection.Projection == CultNetSelectionProjections.Document;
|
||||
|
||||
|
|
@ -404,6 +418,38 @@ namespace GameCult.Networking
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses a selection whose matched rows come from more than one shard's mutation log (R-Q):
|
||||
/// <c>asOf</c> is one shard-log watermark (<see cref="CultNetDatabase.CurrentAsOf"/> is a single
|
||||
/// number today, not one per shard), so a page spanning two shards' logs cannot honestly claim
|
||||
/// to be exact as of it. Refused door-side, the same as an unreachable index or an empty
|
||||
/// <c>keys</c> list (R-F), rather than answered with an <c>asOf</c> that is not really defined -
|
||||
/// reusing <see cref="CultNetSelectionInvalidException"/>/<c>selection_invalid</c> (R-N) rather
|
||||
/// than adding a second refusal shape for what is, from the wire's point of view, the same
|
||||
/// "this selection cannot be answered as asked" refusal a bad field or an empty list gets.
|
||||
/// </summary>
|
||||
private static void EnsureSingleShard(
|
||||
IReadOnlyList<CultNetSelectionEvaluator.Row> rows,
|
||||
Func<string, CultRecordKey, string> shardIdOf)
|
||||
{
|
||||
string? shardId = null;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var rowShardId = shardIdOf(row.Descriptor.SchemaId, row.Key);
|
||||
if (shardId == null)
|
||||
{
|
||||
shardId = rowShardId;
|
||||
}
|
||||
else if (!string.Equals(shardId, rowShardId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new CultNetSelectionInvalidException(
|
||||
"asOf",
|
||||
null,
|
||||
$"The selection matches rows from more than one shard's log (at least '{shardId}' and '{rowShardId}'); asOf is not defined for a shard-spanning page.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a selection over every row the cache holds and projects every matched row - no
|
||||
/// cursor, no 200-row cap. The v0 lowering and the shard-bounded snapshot both answer their
|
||||
|
|
|
|||
|
|
@ -553,7 +553,11 @@ namespace GameCult.Networking
|
|||
public static CultNetErrorMessage ForReferenceOutsideTarget(CultNetSelectionReferenceOutsideTargetException ex) =>
|
||||
new CultNetErrorMessage
|
||||
{
|
||||
Error = $"reference_outside_target: {ex.Message}",
|
||||
// ex.Message already begins "reference_outside_target: " (the exception's own
|
||||
// constructor prefixes it) - this used to prefix it a second time, so a peer received
|
||||
// "reference_outside_target: reference_outside_target: ...". Rust's ForReferenceOutsideTarget
|
||||
// (packages/cultnet-rs) emits the single-prefixed form; this now matches it.
|
||||
Error = ex.Message,
|
||||
Code = "reference_outside_target"
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
|
@ -221,10 +221,11 @@ namespace GameCult.Networking
|
|||
CultDocumentRegistry registry,
|
||||
IReadOnlyList<Row> allRows,
|
||||
CultNetSelection selection,
|
||||
ulong asOf)
|
||||
ulong asOf,
|
||||
CultNetSelectionCursorKey? cursorKey = null)
|
||||
{
|
||||
var full = EvaluateAll(registry, allRows, selection);
|
||||
return Page(full, selection, asOf);
|
||||
return Page(full, selection, asOf, cursorKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -232,17 +233,25 @@ namespace GameCult.Networking
|
|||
/// v0/shard full walk calls this once per 200-row chunk of a single <see cref="EvaluateAll"/>
|
||||
/// result instead of re-evaluating per page.
|
||||
/// </summary>
|
||||
public static Evaluation Page(FullEvaluation full, CultNetSelection selection, ulong asOf)
|
||||
/// <param name="cursorKey">
|
||||
/// The answering process's cursor key (R-O). Defaults to a key generated once per process the
|
||||
/// first time this method runs, so a caller that never sets up its own <see cref="CultNetDatabase"/>
|
||||
/// still gets a keyed digest; <see cref="CultNetDatabase"/> holds and passes its own instance so a
|
||||
/// fresh database (a simulated restart in tests, a real process restart in production) mints
|
||||
/// under a fresh key and a prior cursor's digest stops verifying.
|
||||
/// </param>
|
||||
public static Evaluation Page(FullEvaluation full, CultNetSelection selection, ulong asOf, CultNetSelectionCursorKey? cursorKey = null)
|
||||
{
|
||||
if (full == null) throw new ArgumentNullException(nameof(full));
|
||||
if (selection == null) throw new ArgumentNullException(nameof(selection));
|
||||
|
||||
var key = cursorKey ?? CultNetSelectionCursorKey.ProcessDefault;
|
||||
var ordered = full.Ordered;
|
||||
var startIndex = 0;
|
||||
if (!string.IsNullOrEmpty(selection.Cursor))
|
||||
{
|
||||
var cursor = CultNetSelectionCursor.Parse(selection.Cursor!);
|
||||
if (cursor.Digest != CultNetSelectionCursor.ComputeDigest(selection))
|
||||
if (!cursor.VerifyDigest(selection, key))
|
||||
throw new CultNetSelectionCursorException("cursor_invalid", "The cursor's selection digest does not match this selection.");
|
||||
if (cursor.AsOf != asOf)
|
||||
throw new CultNetSelectionCursorException(
|
||||
|
|
@ -257,7 +266,7 @@ namespace GameCult.Networking
|
|||
var page = ordered.Skip(startIndex).Take(limit).ToArray();
|
||||
var hasNext = startIndex + page.Length < ordered.Count;
|
||||
var nextCursor = hasNext && page.Length > 0
|
||||
? CultNetSelectionCursor.Mint(asOf, page[^1], selection)
|
||||
? CultNetSelectionCursor.Mint(asOf, page[^1], selection, key)
|
||||
: null;
|
||||
|
||||
var pageEdges = selection.HasHop
|
||||
|
|
@ -439,8 +448,49 @@ namespace GameCult.Networking
|
|||
}
|
||||
|
||||
/// <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.
|
||||
/// The answering process's key for cursor digests (R-O, F6: "cursors are keyed"). A digest is an
|
||||
/// HMAC-SHA256 under this key, so a cursor cannot be forged without it and cannot be verified by
|
||||
/// a different key. <see cref="Random"/> is never persisted or serialized; the answering server
|
||||
/// holds one instance for its process lifetime (<see cref="CultNetDatabase.CursorKey"/>), so a
|
||||
/// cursor minted before a restart carries a digest the new process's key does not produce, and
|
||||
/// the restart's first page for it answers <c>cursor_invalid</c> - acceptable, because cursors are
|
||||
/// short-lived and section 2 already calls them "minted by the answering server".
|
||||
/// </summary>
|
||||
public sealed class CultNetSelectionCursorKey
|
||||
{
|
||||
/// <summary>
|
||||
/// The key <see cref="CultNetSelectionEvaluator.Page"/>/<see cref="CultNetSelectionEvaluator.Select"/>
|
||||
/// fall back to when no caller-owned key is supplied - generated once, the first time this type
|
||||
/// is touched in the process, so a caller that evaluates selections without wiring up its own
|
||||
/// <see cref="CultNetDatabase"/> (most unit tests) still gets a keyed, per-process digest instead
|
||||
/// of an unkeyed one.
|
||||
/// </summary>
|
||||
internal static readonly CultNetSelectionCursorKey ProcessDefault = Random();
|
||||
|
||||
private readonly byte[] _key;
|
||||
|
||||
public CultNetSelectionCursorKey(byte[] key)
|
||||
{
|
||||
if (key == null) throw new ArgumentNullException(nameof(key));
|
||||
if (key.Length == 0) throw new ArgumentException("Value must be non-empty.", nameof(key));
|
||||
_key = key;
|
||||
}
|
||||
|
||||
/// <summary>A fresh 32-byte key from the OS RNG.</summary>
|
||||
public static CultNetSelectionCursorKey Random()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return new CultNetSelectionCursorKey(bytes);
|
||||
}
|
||||
|
||||
internal byte[] KeyBytes => _key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The opaque cursor: asOf, the last (ordinal, schemaId, recordKey), and an HMAC digest of the
|
||||
/// selection with cursor and limit cleared, keyed under the answering process's
|
||||
/// <see cref="CultNetSelectionCursorKey"/> (R-O). Minted only by the answering server.
|
||||
/// </summary>
|
||||
public readonly struct CultNetSelectionCursor
|
||||
{
|
||||
|
|
@ -459,50 +509,67 @@ namespace GameCult.Networking
|
|||
public string RecordKey { get; }
|
||||
public string Digest { get; }
|
||||
|
||||
public static string Mint(ulong asOf, CultNetSelectionEvaluator.Row lastRow, CultNetSelection selection)
|
||||
public static string Mint(ulong asOf, CultNetSelectionEvaluator.Row lastRow, CultNetSelection selection, CultNetSelectionCursorKey key)
|
||||
{
|
||||
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));
|
||||
var digest = ComputeDigest(selection, key);
|
||||
// R-O: the cursor body is length-prefixed fields, not a delimiter-joined string - a record
|
||||
// key carrying any character at all, including whatever delimiter an earlier scheme would
|
||||
// have chosen (Soul found a key containing U+241F broke a delimiter split), round-trips.
|
||||
var body = new StringBuilder();
|
||||
AppendString(body, asOf.ToString(CultureInfo.InvariantCulture));
|
||||
AppendString(body, lastRow.Ordinal.ToString(CultureInfo.InvariantCulture));
|
||||
AppendString(body, lastRow.Descriptor.SchemaId);
|
||||
AppendString(body, lastRow.Key.Value);
|
||||
AppendString(body, digest);
|
||||
return Base64UrlEncode(Encoding.UTF8.GetBytes(body.ToString()));
|
||||
}
|
||||
|
||||
public static CultNetSelectionCursor Parse(string cursor)
|
||||
{
|
||||
string raw;
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
raw = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
|
||||
bytes = Base64UrlDecode(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))
|
||||
var fields = ReadLengthPrefixedFields(bytes, 5);
|
||||
if (fields == null ||
|
||||
!ulong.TryParse(fields[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var asOf) ||
|
||||
!long.TryParse(fields[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]);
|
||||
return new CultNetSelectionCursor(asOf, ordinal, fields[2], fields[3], fields[4]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A digest of the selection with cursor and limit cleared, so a cursor is bound to the
|
||||
/// selection that minted it. R-H: every string and list is length-prefixed, so no delimiter
|
||||
/// choice can make two different selections collide - the prior delimiter-joined form digested
|
||||
/// Verifies this cursor's digest against <paramref name="selection"/> under <paramref name="key"/>
|
||||
/// in fixed time (R-O): a cursor forged without the key, or minted under a different process's
|
||||
/// key (a restart), does not verify.
|
||||
/// </summary>
|
||||
public bool VerifyDigest(CultNetSelection selection, CultNetSelectionCursorKey key)
|
||||
{
|
||||
var expected = ComputeDigest(selection, key);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(Digest),
|
||||
Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An HMAC-SHA256 digest, keyed under <paramref name="key"/>, of the selection with cursor and
|
||||
/// limit cleared, so a cursor is bound both to the selection that minted it and to the process
|
||||
/// that minted it (R-O). R-H: every string and list is length-prefixed, so no delimiter choice
|
||||
/// can make two different selections collide - a delimiter-joined form used to digest
|
||||
/// values ["a|b"] the same as ["a","b"], and keys ["a,b"] the same as ["a","b"].
|
||||
/// </summary>
|
||||
public static string ComputeDigest(CultNetSelection selection)
|
||||
public static string ComputeDigest(CultNetSelection selection, CultNetSelectionCursorKey? key = null)
|
||||
{
|
||||
key ??= CultNetSelectionCursorKey.ProcessDefault;
|
||||
var sb = new StringBuilder();
|
||||
AppendList(sb, selection.Schemas);
|
||||
AppendList(sb, selection.Keys);
|
||||
|
|
@ -545,8 +612,8 @@ namespace GameCult.Networking
|
|||
AppendString(sb, selection.Projection);
|
||||
sb.Append(selection.Descending ? '1' : '0');
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
using var hmac = new HMACSHA256(key.KeyBytes);
|
||||
var bytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
var hex = new StringBuilder(bytes.Length * 2);
|
||||
foreach (var b in bytes) hex.Append(b.ToString("x2", CultureInfo.InvariantCulture));
|
||||
return hex.ToString();
|
||||
|
|
@ -566,6 +633,49 @@ namespace GameCult.Networking
|
|||
foreach (var value in ordered)
|
||||
AppendString(sb, value);
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(byte[] bytes) =>
|
||||
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private static byte[] Base64UrlDecode(string value)
|
||||
{
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
switch (padded.Length % 4)
|
||||
{
|
||||
case 2: padded += "=="; break;
|
||||
case 3: padded += "="; break;
|
||||
case 0: break;
|
||||
default: throw new FormatException("Invalid base64url string length.");
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads exactly <paramref name="count"/> length-prefixed fields written by
|
||||
/// <see cref="AppendString"/> back out of <paramref name="bytes"/>, in order. Null on any
|
||||
/// malformed length, a truncated value, a non-UTF-8 value, or leftover bytes after the last
|
||||
/// field - the cursor body has no field this reader may skip.
|
||||
/// </summary>
|
||||
private static List<string>? ReadLengthPrefixedFields(byte[] bytes, int count)
|
||||
{
|
||||
var fields = new List<string>(count);
|
||||
var pos = 0;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var colon = Array.IndexOf(bytes, (byte)':', pos);
|
||||
if (colon < 0) return null;
|
||||
var lengthText = Encoding.UTF8.GetString(bytes, pos, colon - pos);
|
||||
if (!int.TryParse(lengthText, NumberStyles.None, CultureInfo.InvariantCulture, out var length))
|
||||
return null;
|
||||
pos = colon + 1;
|
||||
if (length < 0 || pos + length > bytes.Length) return null;
|
||||
fields.Add(Encoding.UTF8.GetString(bytes, pos, length));
|
||||
pos += length;
|
||||
}
|
||||
|
||||
return pos == bytes.Length ? fields : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -246,6 +246,11 @@ namespace GameCult.Networking.Tests
|
|||
|
||||
Assert.That(decoded.Code, Is.EqualTo("reference_outside_target"));
|
||||
Assert.That(decoded.Details, Is.Null);
|
||||
// ForReferenceOutsideTarget used to prefix "reference_outside_target: " a second time on
|
||||
// top of the exception's own message, which already carries it - a peer received the code
|
||||
// name twice. One prefix only, on the wire bytes a peer actually decodes.
|
||||
Assert.That(decoded.Error, Does.StartWith("reference_outside_target: "));
|
||||
Assert.That(decoded.Error, Does.Not.Contain("reference_outside_target: reference_outside_target:"));
|
||||
}
|
||||
|
||||
// S6: the hop follows one declared reference by role; a second role is not followed.
|
||||
|
|
@ -441,6 +446,46 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(evaluation.Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal), Is.EqualTo(new[] { "1", "2", "3" }));
|
||||
}
|
||||
|
||||
// S1-Loose (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 3" -> "Surviving
|
||||
// mutants"): ReachableSchemas narrows to the schemas selection.schemas actually names; a mutant
|
||||
// that widens it back to every registered descriptor lets a predicate on an index only an
|
||||
// EXCLUDED schema declares reach the door silently (matching nothing, never refusing), instead
|
||||
// of the typed "not declared by any schema this selection can reach" refusal. "kind" is declared
|
||||
// on SelLeafA/SelLeafB, not on SelCiter; Schemas here names only SelCiter.
|
||||
[Test]
|
||||
public void ValidationRefusesAnIndexDeclaredOnlyOnASchemaTheSelectionDoesNotReach()
|
||||
{
|
||||
var registry = Registry();
|
||||
var descriptors = registry.AllDescriptors.ToArray();
|
||||
var selection = new CultNetSelection
|
||||
{
|
||||
Schemas = new[] { registry.GetRequired<SelCiter>().SchemaId },
|
||||
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "x" } } }
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
|
||||
Assert.That(ex!.Field, Is.EqualTo("fields[0].index"));
|
||||
}
|
||||
|
||||
// S2-AllOf (fix batch 3 "Surviving mutants"): any_of matches when the row's declared value
|
||||
// equals ANY one of the predicate's values, not when it equals every one of them - impossible
|
||||
// for a single-valued index whenever Values carries more than one entry, so a mutant that
|
||||
// requires all of them makes a real multi-value any_of predicate match nothing.
|
||||
[Test]
|
||||
public void EvaluatorAnyOfMatchesWhenTheRowsValueIsAnyOneOfSeveralPredicateValues()
|
||||
{
|
||||
var registry = Registry();
|
||||
var row = Row(registry, new SelLeafA { Name = "a", Kind = "weapon", Mass = 1 }, "a", 1);
|
||||
var selection = new CultNetSelection
|
||||
{
|
||||
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "armor", "weapon" } } }
|
||||
};
|
||||
|
||||
var evaluation = CultNetSelectionEvaluator.Select(registry, new[] { row }, selection, asOf: 1);
|
||||
|
||||
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" }));
|
||||
}
|
||||
|
||||
public abstract class SelFixtureMiddle
|
||||
{
|
||||
[Key(0)]
|
||||
|
|
|
|||
|
|
@ -190,6 +190,95 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(CultNetSelectionCursor.ComputeDigest(valuesOne), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(valuesTwo)));
|
||||
}
|
||||
|
||||
// S5-DigestNoFields (fix batch 3 "Surviving mutants"): the digest reads selection.Fields - a
|
||||
// mutant that drops the fields loop would digest two selections identically as long as their
|
||||
// schemas/keys/hop/projection agree, letting a cursor minted for one selection page a
|
||||
// differently-fielded one.
|
||||
[Test]
|
||||
public void ComputeDigest_DiffersWhenOnlyFieldsDiffer()
|
||||
{
|
||||
var withFieldA = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "a" } } } };
|
||||
var withFieldB = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "b" } } } };
|
||||
var noFields = new CultNetSelection();
|
||||
|
||||
Assert.That(CultNetSelectionCursor.ComputeDigest(withFieldA), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(withFieldB)));
|
||||
Assert.That(CultNetSelectionCursor.ComputeDigest(withFieldA), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(noFields)));
|
||||
}
|
||||
|
||||
// R-O (fix batch 3, F6 "cursors are keyed"): a cursor's digest is an HMAC under the answering
|
||||
// process's key - a page request carrying a cursor minted under a different key (forged, or
|
||||
// minted by a different process) is refused cursor_invalid, never answered.
|
||||
[Test]
|
||||
public void Page_RefusesACursorMintedUnderADifferentKey()
|
||||
{
|
||||
var registry = Registry();
|
||||
var rows = Enumerable.Range(0, 3)
|
||||
.Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i))
|
||||
.ToArray();
|
||||
var selection = new CultNetSelection { Limit = 1 };
|
||||
var mintingKey = CultNetSelectionCursorKey.Random();
|
||||
var otherKey = CultNetSelectionCursorKey.Random();
|
||||
|
||||
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: mintingKey);
|
||||
Assert.That(first.NextCursor, Is.Not.Null);
|
||||
|
||||
selection.Cursor = first.NextCursor;
|
||||
var ex = Assert.Throws<CultNetSelectionCursorException>(
|
||||
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: otherKey));
|
||||
Assert.That(ex!.Code, Is.EqualTo("cursor_invalid"));
|
||||
}
|
||||
|
||||
// R-O: a cursor minted before a (simulated) restart does not verify against the restarted
|
||||
// process's fresh key - the same refusal as a forged cursor, because from the answering
|
||||
// process's point of view they are indistinguishable (F6: "not surviving a restart is the
|
||||
// accepted cost, not a bug").
|
||||
[Test]
|
||||
public void Page_RefusesACursorMintedBeforeARestart()
|
||||
{
|
||||
var registry = Registry();
|
||||
var rows = Enumerable.Range(0, 3)
|
||||
.Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i))
|
||||
.ToArray();
|
||||
var selection = new CultNetSelection { Limit = 1 };
|
||||
var preRestartKey = CultNetSelectionCursorKey.Random();
|
||||
|
||||
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: preRestartKey);
|
||||
Assert.That(first.NextCursor, Is.Not.Null);
|
||||
|
||||
// A restart is a fresh process key, exactly as CultNetDatabase's constructor mints a fresh
|
||||
// CursorKey per instance unless one is supplied.
|
||||
var postRestartKey = CultNetSelectionCursorKey.Random();
|
||||
selection.Cursor = first.NextCursor;
|
||||
var ex = Assert.Throws<CultNetSelectionCursorException>(
|
||||
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: postRestartKey));
|
||||
Assert.That(ex!.Code, Is.EqualTo("cursor_invalid"));
|
||||
}
|
||||
|
||||
// R-O: the cursor body is length-prefixed, not delimiter-joined, so a record key carrying any
|
||||
// character at all - including U+241F, which broke the pre-fix delimiter-split parse - round-trips.
|
||||
[Test]
|
||||
public void Cursor_RoundTripsARecordKeyContainingU241F()
|
||||
{
|
||||
var registry = Registry();
|
||||
var weirdKey = "row-␟-two";
|
||||
var rows = new[]
|
||||
{
|
||||
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 0 }, "a-first", 0),
|
||||
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "b", Kind = "k", Mass = 1 }, weirdKey, 1)
|
||||
};
|
||||
var selection = new CultNetSelection { Limit = 1 };
|
||||
var key = CultNetSelectionCursorKey.Random();
|
||||
|
||||
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key);
|
||||
Assert.That(first.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a-first" }));
|
||||
Assert.That(first.NextCursor, Is.Not.Null);
|
||||
|
||||
selection.Cursor = first.NextCursor;
|
||||
var second = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key);
|
||||
Assert.That(second.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { weirdKey }));
|
||||
Assert.That(second.NextCursor, Is.Null);
|
||||
}
|
||||
|
||||
// R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the
|
||||
// string getter's culture-dependent ToString() - a float large enough that .NET's default
|
||||
// ToString() renders exponent notation ("1E+21") must still match its canonical decimal form.
|
||||
|
|
|
|||
|
|
@ -5493,6 +5493,148 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(replicated, Is.Not.Null);
|
||||
Assert.That(replicated!.Username, Is.EqualTo("Replica"));
|
||||
Assert.That(targetDatabase.GetMutationLog("players-replica"), Has.Count.EqualTo(1));
|
||||
// R-Q (docs/cultnet-selection-cut.md, fix batch 3): a replica apply is a commit path too -
|
||||
// it must set LastWriteSequence exactly like a local commit does (CultNetDatabase.cs, the
|
||||
// ApplyCommittedPutAsync/ApplyCommittedDelete gap), or the selection evaluator's order and
|
||||
// cursor treat a replicated row as never written.
|
||||
Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CultNetDatabase_ReplicaApply_DeleteSetsLastWriteSequence()
|
||||
{
|
||||
var sourceCache = new CultCache();
|
||||
var targetCache = new CultCache();
|
||||
var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry)
|
||||
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
|
||||
sourceCache.Registry,
|
||||
payloadSerializer: SerializePlayerDataPayload,
|
||||
payloadDeserializer: DeserializePlayerDataPayload));
|
||||
var targetRegistry = new CultNetDocumentRegistry(targetCache.Registry)
|
||||
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
|
||||
targetCache.Registry,
|
||||
payloadSerializer: SerializePlayerDataPayload,
|
||||
payloadDeserializer: DeserializePlayerDataPayload));
|
||||
var schemaId = sourceCache.Registry.GetRequired<PlayerData>().SchemaId;
|
||||
CultNetShardDescriptor Shard(bool isPrimary) => new(
|
||||
"players-replica-delete",
|
||||
"runtime-a",
|
||||
epoch: 1,
|
||||
isPrimary: isPrimary,
|
||||
schemaIds: [schemaId],
|
||||
keyPrefix: "player:");
|
||||
var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions
|
||||
{
|
||||
DocumentRegistry = sourceRegistry,
|
||||
Shards = [Shard(true)]
|
||||
});
|
||||
var targetDatabase = new CultNetDatabase(targetCache, new CultNetDatabaseOptions
|
||||
{
|
||||
DocumentRegistry = targetRegistry,
|
||||
Shards = [Shard(false)]
|
||||
});
|
||||
using var server = new Server(sourceCache, DevelopmentServerSecurity);
|
||||
using var databaseServer = new CultNetDatabaseServer(server, sourceDatabase);
|
||||
var key = new CultRecordKey("player:replica-delete");
|
||||
var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "d@example.test", PasswordHash = "hash", Username = "D" };
|
||||
|
||||
await sourceDatabase.PutAsync(key, player);
|
||||
await sourceDatabase.DeleteAsync<PlayerData>(key);
|
||||
var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage
|
||||
{
|
||||
ShardId = "players-replica-delete",
|
||||
ShardEpoch = 1
|
||||
});
|
||||
|
||||
await targetDatabase.ApplyShardLogResponseAsync(response);
|
||||
|
||||
Assert.That(targetCache.Get<PlayerData>(key), Is.Null);
|
||||
Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CultNetDatabase_RebuildsLastWriteSequence_FromTheDurableLogAfterRestart()
|
||||
{
|
||||
var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "shard-logs", Guid.NewGuid().ToString("N"));
|
||||
var schemaId = new CultCache().Registry.GetRequired<PlayerData>().SchemaId;
|
||||
var shard = new CultNetShardDescriptor(
|
||||
"players-durable-sequence",
|
||||
"runtime-a",
|
||||
epoch: 1,
|
||||
isPrimary: true,
|
||||
schemaIds: [schemaId],
|
||||
keyPrefix: "player:");
|
||||
|
||||
var sourceCache = new CultCache();
|
||||
var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry)
|
||||
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
|
||||
sourceCache.Registry,
|
||||
payloadSerializer: SerializePlayerDataPayload,
|
||||
payloadDeserializer: DeserializePlayerDataPayload));
|
||||
var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions
|
||||
{
|
||||
DocumentRegistry = sourceRegistry,
|
||||
MutationLogStore = new CultNetFileShardMutationLogStore(rootPath),
|
||||
Shards = [shard]
|
||||
});
|
||||
var key = new CultRecordKey("player:durable-sequence");
|
||||
var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "r@example.test", PasswordHash = "hash", Username = "R" };
|
||||
await sourceDatabase.PutAsync(key, player);
|
||||
Assert.That(sourceDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1));
|
||||
sourceDatabase.Dispose();
|
||||
|
||||
// R-Q: a restart is a fresh CultNetDatabase instance over the same durable log - the
|
||||
// sequence InitializeLogSequencesFromStore used to rebuild only _nextLogSequences
|
||||
// (what the next local commit gets), never _lastWriteSequence (what a row's own last
|
||||
// write was), so a selection evaluated right after restart would order/page the row as
|
||||
// "never written" until it was written again.
|
||||
var restartedCache = new CultCache();
|
||||
var restartedDatabase = new CultNetDatabase(restartedCache, new CultNetDatabaseOptions
|
||||
{
|
||||
MutationLogStore = new CultNetFileShardMutationLogStore(rootPath),
|
||||
Shards = [shard]
|
||||
});
|
||||
|
||||
Assert.That(restartedDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CultNetDatabaseServer_RefusesASelectionSpanningMoreThanOneShardsLog()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var registry = new CultNetDocumentRegistry(cache.Registry)
|
||||
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
|
||||
cache.Registry,
|
||||
payloadSerializer: SerializePlayerDataPayload,
|
||||
payloadDeserializer: DeserializePlayerDataPayload));
|
||||
var schemaId = cache.Registry.GetRequired<PlayerData>().SchemaId;
|
||||
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
||||
{
|
||||
DocumentRegistry = registry,
|
||||
Shards =
|
||||
[
|
||||
new CultNetShardDescriptor("shard-a", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "a:"),
|
||||
new CultNetShardDescriptor("shard-b", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "b:")
|
||||
]
|
||||
});
|
||||
using var server = new Server(cache, DevelopmentServerSecurity);
|
||||
using var databaseServer = new CultNetDatabaseServer(server, database);
|
||||
|
||||
await database.PutAsync(
|
||||
new CultRecordKey("a:one"),
|
||||
new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "A" });
|
||||
await database.PutAsync(
|
||||
new CultRecordKey("b:one"),
|
||||
new PlayerData { PlayerId = Guid.NewGuid(), Email = "b@example.test", PasswordHash = "hash", Username = "B" });
|
||||
|
||||
// R-Q: asOf is one shard-log watermark - a selection whose matched rows span shard-a's log
|
||||
// and shard-b's log cannot honestly claim to be exact as of it, so it is refused rather than
|
||||
// answered, reusing the selection_invalid door refusal (R-N) rather than a second shape.
|
||||
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
|
||||
{
|
||||
Selection = new CultNetSelection { Schemas = [schemaId] }
|
||||
}));
|
||||
Assert.That(ex!.Field, Is.EqualTo("asOf"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue