diff --git a/contracts/cultnet/cultnet-distributed-database.md b/contracts/cultnet/cultnet-distributed-database.md index 1ddc80e8..8d35fbb6 100644 --- a/contracts/cultnet/cultnet-distributed-database.md +++ b/contracts/cultnet/cultnet-distributed-database.md @@ -146,6 +146,9 @@ A malformed put or delete is `message_invalid`, not a per-operation code: the pe commit's changes as one commit, so the replica judges the result (a swap of two unique-index values) and not the state between records. A replica whose cache refuses a commit is faulted, never silent. `commitId` is opaque and valid only within the subscription. The C# subscription server and database server tag commits; other runtimes adopt next. +- The C# writer sets every key of a `database_change_raw` message, nil where unset: a removed change carries `document` + nil, an added or updated one `recordKey` and `schemaId` nil. The schema accepts those nils; an added or updated change + still needs a document, and a removed one a `recordKey` and `schemaId`. - Client-owned input documents may be predicted locally only inside explicit `CultNetClientAuthorityScope` declarations. - Predicted input state is not authoritative shared state. The shard log still diff --git a/contracts/cultnet/cultnet.database-change-raw.schema.json b/contracts/cultnet/cultnet.database-change-raw.schema.json index f1cd97eb..badada4a 100644 --- a/contracts/cultnet/cultnet.database-change-raw.schema.json +++ b/contracts/cultnet/cultnet.database-change-raw.schema.json @@ -10,17 +10,29 @@ "messageId": { "type": "string", "minLength": 1 }, "subscriptionId": { "type": "string", "minLength": 1 }, "changeKind": { "enum": ["added", "updated", "removed"] }, - "document": { "$ref": "./cultnet.raw-document-record.schema.json" }, - "recordKey": { "type": "string", "minLength": 1 }, - "schemaId": { "type": "string", "minLength": 1 }, + "document": { + "oneOf": [{ "type": "null" }, { "$ref": "./cultnet.raw-document-record.schema.json" }] + }, + "recordKey": { "type": ["string", "null"], "minLength": 1 }, + "schemaId": { "type": ["string", "null"], "minLength": 1 }, "commitId": { "type": ["string", "null"], "minLength": 1 }, "commitCount": { "type": ["integer", "null"], "minimum": 2 } }, - "allOf": [ + "oneOf": [ { - "if": { "properties": { "changeKind": { "const": "removed" } } }, - "then": { "required": ["recordKey", "schemaId"] }, - "else": { "required": ["document"] } + "properties": { + "changeKind": { "const": "removed" }, + "recordKey": { "type": "string" }, + "schemaId": { "type": "string" } + }, + "required": ["recordKey", "schemaId"] + }, + { + "properties": { + "changeKind": { "enum": ["added", "updated"] }, + "document": { "$ref": "./cultnet.raw-document-record.schema.json" } + }, + "required": ["document"] } ] } diff --git a/packages/cultnet-py/src/cultnet_py/schema_catalog.py b/packages/cultnet-py/src/cultnet_py/schema_catalog.py index 85ab799d..b640b753 100644 --- a/packages/cultnet-py/src/cultnet_py/schema_catalog.py +++ b/packages/cultnet-py/src/cultnet_py/schema_catalog.py @@ -429,9 +429,9 @@ def wire_message_schema_properties(schema_version: str) -> dict[str, Any]: "messageId": common["messageId"], "subscriptionId": common["subscriptionId"], "changeKind": {"enum": ["added", "updated", "removed"]}, - "document": {"$ref": "#/$defs/rawDocumentRecord"}, - "schemaId": common["schemaId"], - "recordKey": common["recordKey"], + "document": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/rawDocumentRecord"}]}, + "schemaId": {"type": ["string", "null"], "minLength": 1}, + "recordKey": {"type": ["string", "null"], "minLength": 1}, "commitId": {"type": ["string", "null"], "minLength": 1}, "commitCount": {"type": ["integer", "null"], "minimum": 2}, }, diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 0c8bcbf3..68f8f8ea 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -2315,9 +2315,10 @@ class CultNetTests(unittest.TestCase): change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"]) self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"]) - self.assertIn("document", change_schema["properties"]) - self.assertIn("schemaId", change_schema["properties"]) - self.assertIn("recordKey", change_schema["properties"]) + # The C# writer sets every key, nil where unset (a removed change's document, an added change's recordKey and schemaId). + self.assertEqual(change_schema["properties"]["document"]["oneOf"][0], {"type": "null"}) + self.assertEqual(change_schema["properties"]["schemaId"], {"type": ["string", "null"], "minLength": 1}) + self.assertEqual(change_schema["properties"]["recordKey"], {"type": ["string", "null"], "minLength": 1}) self.assertEqual(change_schema["properties"]["commitId"], {"type": ["string", "null"], "minLength": 1}) self.assertEqual(change_schema["properties"]["commitCount"], {"type": ["integer", "null"], "minimum": 2}) diff --git a/packages/cultnet-ts/test/database-change-commit-vector.test.ts b/packages/cultnet-ts/test/database-change-commit-vector.test.ts new file mode 100644 index 00000000..fea121c0 --- /dev/null +++ b/packages/cultnet-ts/test/database-change-commit-vector.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import Ajv2020 from "ajv/dist/2020.js"; +import { decodeMulti } from "@msgpack/msgpack"; + +import { cultNetSchemas, parseCultNetMessage } from "../src/index"; + +// tests/vectors/database-change-commit/two-change-commit.msgpack is written only by the C# reference: two removed +// database_change_raw messages of one commit, every key present, nil where unset (document included). +const vector = join(__dirname, "..", "..", "..", "..", "tests", "vectors", "database-change-commit", "two-change-commit.msgpack"); +const changes = () => [...decodeMulti(readFileSync(vector))] as Record[]; + +test("a two-change commit written by C# decodes with its commitId, commitCount and changes", () => { + const messages = changes().map((message) => parseCultNetMessage(structuredClone(message))); + assert.equal(messages.length, 2); + for (const message of messages) { + assert.equal(message.schemaVersion, "cultnet.database_change_raw.v0"); + assert.equal((message as { commitId?: unknown }).commitId, "9f1c0f0e4b7a4c1f9d2e"); + assert.equal((message as { commitCount?: unknown }).commitCount, 2); + assert.equal((message as { changeKind?: unknown }).changeKind, "removed"); + assert.equal((message as { subscriptionId?: unknown }).subscriptionId, "sub-1"); + assert.equal((message as { schemaId?: unknown }).schemaId, "schema-note"); + } + assert.deepEqual(messages.map((message) => (message as { recordKey?: unknown }).recordKey), ["note:a", "note:b"]); +}); + +test("the database_change_raw schema accepts the C# vector's removed changes (document: nil) and still refuses an added change without a document", () => { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + ajv.addSchema(cultNetSchemas.rawDocumentRecordSchema); + const validate = ajv.compile(cultNetSchemas.databaseChangeRawSchema); + for (const change of changes()) { + assert.equal(change.document, null, "the C# writer wrote the key, nil"); + assert.equal(validate(change), true, JSON.stringify(validate.errors)); + assert.equal(validate({ ...change, changeKind: "added" }), false, "an added change needs a document"); + } +}); diff --git a/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs index 4172c83b..ec9dde5b 100644 --- a/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs @@ -1,5 +1,7 @@ #nullable enable using System; +using System.Buffers; +using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Threading.Tasks; @@ -246,6 +248,39 @@ namespace GameCult.Networking.Tests Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0")); } + // The C# writer sets every key (nil where unset), so a removed change carries document: nil, and an added change + // carries recordKey/schemaId: nil. The schema describes those bytes; it still refuses an added change with no document. + private static List CommitVectorChanges() + { + var path = Path.Combine(RepoRoot(), "tests", "vectors", "database-change-commit", "two-change-commit.msgpack"); + var reader = new MessagePackReader(File.ReadAllBytes(path)); + var changes = new List(); + while (!reader.End) + changes.Add(JsonDocument.Parse(MessagePackSerializer.ConvertToJson(reader.ReadRaw().ToArray())).RootElement); + return changes; + } + + [Test] + public void DatabaseChangeRaw_TheCommitVectorsRemovedChangesWithANilDocument_MatchTheSchema() + { + var validator = new MiniJsonSchemaValidator(SchemaDir()); + var changes = CommitVectorChanges(); + Assert.That(changes, Has.Count.EqualTo(2)); + foreach (var change in changes) + { + Assert.That(change.GetProperty("document").ValueKind, Is.EqualTo(JsonValueKind.Null), "the C# writer wrote the key, nil"); + validator.AssertValid(change, "cultnet.database-change-raw.schema.json"); + } + } + + [Test] + public void DatabaseChangeRaw_AnAddedChangeWithANilDocument_IsRefused() + { + var validator = new MiniJsonSchemaValidator(SchemaDir()); + var removed = CommitVectorChanges()[0].GetRawText().Replace("\"removed\"", "\"added\""); + Assert.Throws(() => validator.AssertValid(JsonDocument.Parse(removed).RootElement, "cultnet.database-change-raw.schema.json")); + } + private static void AssertErrorMatchesSchema(CultNetErrorMessage message) { var validator = new MiniJsonSchemaValidator(SchemaDir());