diff --git a/contracts/cultnet/cultnet.database-change-raw.schema.json b/contracts/cultnet/cultnet.database-change-raw.schema.json index d214ed6b..f1cd97eb 100644 --- a/contracts/cultnet/cultnet.database-change-raw.schema.json +++ b/contracts/cultnet/cultnet.database-change-raw.schema.json @@ -13,8 +13,8 @@ "document": { "$ref": "./cultnet.raw-document-record.schema.json" }, "recordKey": { "type": "string", "minLength": 1 }, "schemaId": { "type": "string", "minLength": 1 }, - "commitId": { "type": "string", "minLength": 1 }, - "commitCount": { "type": "integer", "minimum": 2 } + "commitId": { "type": ["string", "null"], "minLength": 1 }, + "commitCount": { "type": ["integer", "null"], "minimum": 2 } }, "allOf": [ { diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index 113d485d..b733c959 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -425,9 +425,9 @@ export interface CultNetDatabaseChangeRawMessage { recordKey?: string; schemaId?: string; /** Opaque, one subscription: changes sharing a commitId are the changes of one commit. */ - commitId?: string; - /** How many changes the commit carries, at least two. */ - commitCount?: number; + commitId?: string | null; + /** How many changes the commit carries, at least two. The C# server sends null on a change outside a grouped commit. */ + commitCount?: number | null; } export interface CultNetSchemaDescriptor { diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 3779a72b..ac4f539d 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -1712,6 +1712,29 @@ test("database subscription contracts match the C# live document lane", () => { schemaId: "sample.counter_state.v1", ...extra, }); + // The C# server writes commitId and commitCount as nil on every change outside a grouped commit, so null is absent. + const ungrouped = changeWith({ commitId: null, commitCount: null })() as CultNetDatabaseChangeRawMessage; + assert.equal(ungrouped.commitId, null); + assert.equal(ungrouped.commitCount, null); + assert.doesNotThrow(changeWith({ commitId: null })); + assert.doesNotThrow(changeWith({ commitCount: null })); + assert.doesNotThrow(() => parseCultNetMessage({ + schemaVersion: "cultnet.database_change_raw.v0", + messageId: "change-counter-5", + subscriptionId: "counter:browser-1", + changeKind: "added", + document: { + schemaId: "sample.counter_state.v1", + recordKey: "counter:main", + storedAt: "2026-08-17T00:00:00Z", + payloadEncoding: "messagepack", + payload: new Uint8Array([0x81, 0xa5, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x01]), + }, + recordKey: null, + schemaId: null, + commitId: null, + commitCount: null, + })); for (const commitId of ["", 5]) assert.throws(changeWith({ commitId }), /\/commitId/, `commitId ${JSON.stringify(commitId)}`); for (const commitCount of [1, 0, 2.5, "two", -3]) assert.throws(changeWith({ commitCount }), /\/commitCount/, `commitCount ${commitCount}`);