diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs
index 083d309f..07336488 100644
--- a/src/GameCult.Networking/CultNetDatabase.cs
+++ b/src/GameCult.Networking/CultNetDatabase.cs
@@ -1922,6 +1922,8 @@ namespace GameCult.Networking
private CultNetShardDescriptor ResolveShardInternal(CultDocumentDescriptor descriptor, CultRecordKey key) =>
FindShard(descriptor, key) ?? throw new CultNetUnownedSchemaException(descriptor.SchemaId, key);
+ internal bool Owns(CultDocumentDescriptor descriptor, CultRecordKey key) => FindShard(descriptor, key) != null;
+
private CultNetShardDescriptor? FindShard(CultDocumentDescriptor descriptor, CultRecordKey key) =>
_shards.FirstOrDefault(shard => shard.Matches(descriptor, key));
diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs
index d53315ed..78124acc 100644
--- a/src/GameCult.Networking/CultNetDatabaseServer.cs
+++ b/src/GameCult.Networking/CultNetDatabaseServer.cs
@@ -135,6 +135,8 @@ namespace GameCult.Networking
// 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.
+ // A row whose schema no shard owns is not part of what this database serves: it is left out, not an error.
+ rowFilter: _database.Owns,
shardIdOf: (schemaId, key) => _database.ResolveShard(schemaId, key).ShardId,
cursorKey: _database.CursorKey,
// S-9: once the matched rows resolve to one shard, asOf is that shard's own watermark,
diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs
index eb23d503..a47f28b3 100644
--- a/src/GameCult.Networking/CultNetDocumentRegistry.cs
+++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs
@@ -338,6 +338,7 @@ namespace GameCult.Networking
/// row's ordinal (the reference: );
/// is the snapshot the page is exact for when the selection is not shard-scoped, or the fallback
/// used only when is null or the selection matches no rows (S-9).
+ /// , when supplied, leaves out the cached rows it rejects before evaluation.
///
public CultNetSnapshotResponseRawV1Message CreateSelectionResponse(
CultCache cache,
@@ -348,13 +349,14 @@ namespace GameCult.Networking
CultNetDocumentMessageOptions? options = null,
Func? shardIdOf = null,
CultNetSelectionCursorKey? cursorKey = null,
- Func? asOfForShard = null)
+ Func? asOfForShard = null,
+ Func? rowFilter = 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, shardIdOf: shardIdOf, cursorKey: cursorKey, asOfForShard: asOfForShard);
+ var page = SelectPage(cache, selection, ordinalOf, asOf, options, rowFilter, shardIdOf, cursorKey, asOfForShard);
return new CultNetSnapshotResponseRawV1Message
{
MessageId = RequireNonEmpty(messageId, nameof(messageId)),
diff --git a/tests/GameCult.Networking.Tests/CultNetJournalTests.cs b/tests/GameCult.Networking.Tests/CultNetJournalTests.cs
index 5e18bcd7..3bbb72ef 100644
--- a/tests/GameCult.Networking.Tests/CultNetJournalTests.cs
+++ b/tests/GameCult.Networking.Tests/CultNetJournalTests.cs
@@ -745,6 +745,27 @@ namespace GameCult.Networking.Tests
Assert.That(cache.Get(Unowned), Is.Null);
}
+ // One cached row of a schema no shard owns is not part of what this database serves: the selection answers with the
+ // rest, it does not fail as a whole.
+ [Test]
+ public async Task ASelectionSkipsACachedRowNoShardOwnsAndAnswersWithTheRest()
+ {
+ var (database, cache, _, _) = OwnedNothingOfNote();
+ await database.PutAsync(One, Note("owned"));
+ await cache.UpsertAsync(new MeshQuickstartNote { NoteId = "u", Body = "unowned" }, new CultRecordHandle(Unowned));
+ using var server = new Server(cache, ServerSecurityOptions.Development());
+ using var databaseServer = new CultNetDatabaseServer(server, database);
+
+ var response = databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message
+ {
+ MessageId = "select-all",
+ Selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document }
+ });
+
+ Assert.That(response.Documents!.Select(document => document.RecordKey), Is.EqualTo(new[] { One.Value }));
+ Assert.That(response.Matched, Is.EqualTo(1u));
+ }
+
private sealed class CapturingLogger : GameCult.Logging.ILogger
{
public List Errors { get; } = new();