diff --git a/contracts/cultnet/cultnet.error.schema.json b/contracts/cultnet/cultnet.error.schema.json index 8653cbd5..15880fa1 100644 --- a/contracts/cultnet/cultnet.error.schema.json +++ b/contracts/cultnet/cultnet.error.schema.json @@ -28,7 +28,7 @@ "code": { "description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.", "type": ["string", "null"], - "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"] + "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported"] }, "details": { "description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.", @@ -36,8 +36,8 @@ "additionalProperties": false, "required": ["field", "value", "asOf", "current"], "properties": { - "field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] }, - "value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] }, + "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended.", "type": ["string", "null"] }, + "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended.", "type": ["string", "null"] }, "asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 }, "current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 } } diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json index 8653cbd5..15880fa1 100644 --- a/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json @@ -28,7 +28,7 @@ "code": { "description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.", "type": ["string", "null"], - "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"] + "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported"] }, "details": { "description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.", @@ -36,8 +36,8 @@ "additionalProperties": false, "required": ["field", "value", "asOf", "current"], "properties": { - "field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] }, - "value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] }, + "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended.", "type": ["string", "null"] }, + "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended.", "type": ["string", "null"] }, "asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 }, "current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 } } diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index cc50171d..f31ade85 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -250,7 +250,7 @@ impl CultNetShardDescriptor { } } -/// Machine-readable `cultnet.error.v0` refusal code (R-N). Mirrors the four strings +/// Machine-readable `cultnet.error.v0` refusal code (R-N). Mirrors the five strings /// `contracts/cultnet/cultnet.error.schema.json` enumerates for `code`; a peer that receives a fifth /// string it does not know refuses to decode rather than guess a meaning for it. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -260,6 +260,7 @@ pub enum CultNetErrorCode { CursorStale, CursorInvalid, ReferenceOutsideTarget, + VariantUnsupported, } impl CultNetErrorCode { @@ -269,6 +270,7 @@ impl CultNetErrorCode { Self::CursorStale => "cursor_stale", Self::CursorInvalid => "cursor_invalid", Self::ReferenceOutsideTarget => "reference_outside_target", + Self::VariantUnsupported => "variant_unsupported", } } @@ -278,6 +280,7 @@ impl CultNetErrorCode { "cursor_stale" => Ok(Self::CursorStale), "cursor_invalid" => Ok(Self::CursorInvalid), "reference_outside_target" => Ok(Self::ReferenceOutsideTarget), + "variant_unsupported" => Ok(Self::VariantUnsupported), other => Err(anyhow!("ErrorMessage.Code {other:?} is not a recognized cultnet.error.v0 code")), } } diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs index 60bb66a5..f3150740 100644 --- a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs @@ -208,7 +208,7 @@ namespace GameCult.Networking { // Q6: a snapshot that selects a variant is refused to this subscriber as a typed error. Withdraw(key, sendRemovals: false, forgetRequest: true); - peer.SendCultNet(new CultNetErrorMessage { Error = refusal.Message }); + peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message, subscriptionId)); return Task.CompletedTask; } catch @@ -330,7 +330,7 @@ namespace GameCult.Networking catch (NotSupportedException refusal) { // Q6: a variant is not a record CultNet can carry; the subscriber that would have received it is told, by key. - peer.SendCultNet(new CultNetErrorMessage { Error = refusal.Message }); + peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message)); return; } @@ -358,11 +358,12 @@ namespace GameCult.Networking } catch (NotSupportedException refusal) { - // Q6: the selection selects a variant, which CultNet cannot carry. Refused as the subscribe path refuses it: the - // subscription is withdrawn, so the peer is told once, not once per store change; a change never throws out of + // Q6: the selection selects a variant, which CultNet cannot carry. The subscription is withdrawn, so the peer is + // told once, not once per store change; the rows it delivered are withdrawn too (removals first), so the peer + // does not keep them as live, and the typed error names the subscription that ended. A change never throws out of // the change handler. - Withdraw(key, sendRemovals: false, forgetRequest: true); - key.Peer.SendCultNet(new CultNetErrorMessage { Error = refusal.Message }); + Withdraw(key, sendRemovals: true, forgetRequest: true); + key.Peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message, key.Id)); return; } diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index f343fad7..4daa76d5 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -546,6 +546,18 @@ namespace GameCult.Networking : null }; + /// + /// Builds the wire error for a selection that selects a document variant, which CultNet does not carry yet: code + /// variant_unsupported, details { field: "subscriptionId", value } when a subscription was ended by it. + /// + public static CultNetErrorMessage ForVariantUnsupported(string message, string? subscriptionId = null) => + new CultNetErrorMessage + { + Error = message, + Code = "variant_unsupported", + Details = subscriptionId == null ? null : new CultNetErrorDetails { Field = "subscriptionId", Value = subscriptionId } + }; + /// /// Builds the wire error for an out-of-target reference refusal (R-N): code /// reference_outside_target, no details. diff --git a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs index a4658117..64de1093 100644 --- a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs @@ -272,6 +272,10 @@ namespace GameCult.Networking.Tests await database.PutAsync(new CultRecordKey("citer-four"), Citer("citer-four", VariantKey)); await WaitUntilAsync(() => errors.Any(error => error.Error.Contains(VariantKey.Value)), Diagnostic); + var refusal = errors.First(error => error.Error.Contains(VariantKey.Value)); + Assert.That(refusal.Code, Is.EqualTo("variant_unsupported")); + Assert.That(refusal.Details?.Value, Is.EqualTo("cited"), "the peer can tell which subscription ended"); + await WaitUntilAsync(() => changes.Any(change => change.SubscriptionId == "cited" && change.ChangeKind == "removed" && change.RecordKey == BaseKey.Value), Diagnostic); Assert.That(changes.Where(change => change.SubscriptionId == "cited").Select(change => change.Document?.RecordKey), Does.Not.Contain(VariantKey.Value), "a variant never reaches a subscriber as a record");