Merge pull request 'cultnet-ts selection/v1 carriers + error code/details' (#20) from libby/cultnet-ts-selection-v1 into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 6m39s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 41s

This commit is contained in:
Libby 2026-11-07 12:27:32 +00:00
commit f1cdbfdfd7
9 changed files with 1194 additions and 1 deletions

View file

@ -0,0 +1,346 @@
{
"vectors": [
{
"name": "snapshot_request_v1_defaults",
"schemaVersion": "cultnet.snapshot_request.v1",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWShbalzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywKdzaGFyZElkwKpzaGFyZEVwb2NowA==",
"expected": {
"schemaVersion": "cultnet.snapshot_request.v1",
"messageId": "m",
"selection": {
"schemas": null,
"keys": null,
"fields": null,
"cites": null,
"cited": null,
"projection": "header",
"descending": false,
"limit": null,
"cursor": null
},
"shardId": null,
"shardEpoch": null
}
},
{
"name": "snapshot_request_v1_full_selection_and_shard",
"schemaVersion": "cultnet.snapshot_request.v1",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWSmbS1mdWxsqXNlbGVjdGlvbomnc2NoZW1hc5KpbGVhZl9hLnY52UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqRrZXlzkqRhLWVxqPCfmIAtcm93pmZpZWxkc5KEpWluZGV4pGtpbmSib3CmYW55X29mpnZhbHVlc5Kmd2VhcG9upHRvb2ymbnVtYmVywISlaW5kZXikbWFzc6JvcKJnZaZ2YWx1ZXPApm51bWJlcqstMjIzMzc1OS4yNaVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26lY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqhkb2N1bWVudKpkZXNjZW5kaW5nw6VsaW1pdMzIpmN1cnNvcq1vcGFxdWUtY3Vyc29yp3NoYXJkSWSnc2hhcmQtMapzaGFyZEVwb2NoBw==",
"expected": {
"schemaVersion": "cultnet.snapshot_request.v1",
"messageId": "m-full",
"selection": {
"schemas": [
"leaf_a.v9",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"
],
"keys": [
"a-eq",
"\uD83D\uDE00-row"
],
"fields": [
{
"index": "kind",
"op": "any_of",
"values": [
"weapon",
"tool"
],
"number": null
},
{
"index": "mass",
"op": "ge",
"values": null,
"number": "-2233759.25"
}
],
"cites": {
"target": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"role": "Design"
},
"cited": {
"role": "Design",
"exists": false
},
"projection": "document",
"descending": true,
"limit": 200,
"cursor": "opaque-cursor"
},
"shardId": "shard-1",
"shardEpoch": 7
}
},
{
"name": "database_subscribe_v1_defaults",
"schemaVersion": "cultnet.database_subscribe.v1",
"messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKFtrnN1YnNjcmlwdGlvbklko3N1YqlzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywK9pbmNsdWRlU25hcHNob3TDsWNvbnN1bWVyUnVudGltZUlkwKdib2R5SWRzwLdzdXBwb3J0ZWRCb2R5VHJhbnNwb3J0c8A=",
"expected": {
"schemaVersion": "cultnet.database_subscribe.v1",
"messageId": "m",
"subscriptionId": "sub",
"selection": {
"schemas": null,
"keys": null,
"fields": null,
"cites": null,
"cited": null,
"projection": "header",
"descending": false,
"limit": null,
"cursor": null
},
"includeSnapshot": true,
"consumerRuntimeId": null,
"bodyIds": null,
"supportedBodyTransports": null
}
},
{
"name": "database_subscribe_v1_full",
"schemaVersion": "cultnet.database_subscribe.v1",
"messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKZtLWZ1bGyuc3Vic2NyaXB0aW9uSWSoc3ViLWZ1bGypc2VsZWN0aW9uiadzY2hlbWFzkqlsZWFmX2EudjnZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmpGtleXOSpGEtZXGo8J\u002BYgC1yb3emZmllbGRzkoSlaW5kZXika2luZKJvcKZhbnlfb2amdmFsdWVzkqZ3ZWFwb26kdG9vbKZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqy0yMjMzNzU5LjI1pWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqVjaXRlZIKkcm9sZaZEZXNpZ26mZXhpc3Rzwqpwcm9qZWN0aW9uqGRvY3VtZW50qmRlc2NlbmRpbmfDpWxpbWl0zMimY3Vyc29yrW9wYXF1ZS1jdXJzb3KvaW5jbHVkZVNuYXBzaG90wrFjb25zdW1lclJ1bnRpbWVJZKhjb25zdW1lcqdib2R5SWRzkaZib2R5LWG3c3VwcG9ydGVkQm9keVRyYW5zcG9ydHOSpHJ1ZHCkcXVpYw==",
"expected": {
"schemaVersion": "cultnet.database_subscribe.v1",
"messageId": "m-full",
"subscriptionId": "sub-full",
"selection": {
"schemas": [
"leaf_a.v9",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"
],
"keys": [
"a-eq",
"\uD83D\uDE00-row"
],
"fields": [
{
"index": "kind",
"op": "any_of",
"values": [
"weapon",
"tool"
],
"number": null
},
{
"index": "mass",
"op": "ge",
"values": null,
"number": "-2233759.25"
}
],
"cites": {
"target": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"role": "Design"
},
"cited": {
"role": "Design",
"exists": false
},
"projection": "document",
"descending": true,
"limit": 200,
"cursor": "opaque-cursor"
},
"includeSnapshot": false,
"consumerRuntimeId": "consumer",
"bodyIds": [
"body-a"
],
"supportedBodyTransports": [
"rudp",
"quic"
]
}
},
{
"name": "snapshot_response_raw_v1_headers",
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAqRhc09mBaRuZXh0q25leHQtY3Vyc29yp2hlYWRlcnOSiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1lcahzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3PAiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1sb6hzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3OSpWFscGhhpGJldGGpZG9jdW1lbnRzwKVlZGdlc8Cnc2hhcmRJZKdzaGFyZC0xqnNoYXJkRXBvY2gDsHNoYXJkTG9nU2VxdWVuY2Up",
"expected": {
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messageId": "m",
"matched": 2,
"asOf": 5,
"next": "next-cursor",
"headers": [
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": "leaf_a",
"schemaVersion": "1",
"schemaContentHash": "abc",
"recordKey": "a-eq",
"storedAt": "1970-01-01T00:00:00.000Z",
"sourceRuntimeId": "rt",
"sourceAgentId": "agent",
"sourceRole": "role",
"tags": null
},
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": "leaf_a",
"schemaVersion": "1",
"schemaContentHash": "abc",
"recordKey": "a-lo",
"storedAt": "1970-01-01T00:00:00.000Z",
"sourceRuntimeId": "rt",
"sourceAgentId": "agent",
"sourceRole": "role",
"tags": [
"alpha",
"beta"
]
}
],
"documents": null,
"edges": null,
"shardId": "shard-1",
"shardEpoch": 3,
"shardLogSequence": 41
}
},
{
"name": "snapshot_response_raw_v1_documents_and_edges",
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAaRhc09mBaRuZXh0wKdoZWFkZXJzwKlkb2N1bWVudHORjKhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZcCtc2NoZW1hVmVyc2lvbsCxc2NoZW1hQ29udGVudEhhc2jAqXJlY29yZEtleaRhLWVxqHN0b3JlZEF0uDE5NzAtMDEtMDFUMDA6MDA6MDAuMDAwWq9wYXlsb2FkRW5jb2RpbmerbWVzc2FnZXBhY2uncGF5bG9hZMQEgaFrAa9zb3VyY2VSdW50aW1lSWTArXNvdXJjZUFnZW50SWTAqnNvdXJjZVJvbGXApHRhZ3PApWVkZ2VzkoWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nwKdwYXlsb2FkwIWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nq21lc3NhZ2VwYWNrp3BheWxvYWTEAwECA6dzaGFyZElkwKpzaGFyZEVwb2NowLBzaGFyZExvZ1NlcXVlbmNlwA==",
"expected": {
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messageId": "m",
"matched": 1,
"asOf": 5,
"next": null,
"headers": null,
"documents": [
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": null,
"schemaVersion": null,
"schemaContentHash": null,
"recordKey": "a-eq",
"storedAt": "1970-01-01T00:00:00.000Z",
"payloadEncoding": "messagepack",
"payload": "gaFrAQ==",
"sourceRuntimeId": null,
"sourceAgentId": null,
"sourceRole": null,
"tags": null
}
],
"edges": [
{
"from": {
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
"recordKey": "citer-1"
},
"role": "Design",
"to": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"payloadEncoding": null,
"payload": null
},
{
"from": {
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
"recordKey": "citer-1"
},
"role": "Design",
"to": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"payloadEncoding": "messagepack",
"payload": "AQID"
}
],
"shardId": null,
"shardEpoch": null,
"shardLogSequence": null
}
},
{
"name": "error_plain",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KkYm9vbatyb3V0aW5nSGludMCkY29kZcCnZGV0YWlsc8A=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "boom",
"routingHint": null,
"code": null,
"details": null
}
},
{
"name": "error_selection_invalid",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3LZOnNlbGVjdGlvbl9pbnZhbGlkOiBmaWVsZHNbMF0uaW5kZXggImtpbmQiIGlzIG5vdCBkZWNsYXJlZC6rcm91dGluZ0hpbnTApGNvZGWxc2VsZWN0aW9uX2ludmFsaWSnZGV0YWlsc4SlZmllbGSvZmllbGRzWzBdLmluZGV4pXZhbHVlpGtpbmSkYXNPZsCnY3VycmVudMA=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "selection_invalid: fields[0].index \u0022kind\u0022 is not declared.",
"routingHint": null,
"code": "selection_invalid",
"details": {
"field": "fields[0].index",
"value": "kind",
"asOf": null,
"current": null
}
}
},
{
"name": "error_cursor_stale",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX3N0YWxlOiBzdGFsZatyb3V0aW5nSGludMCkY29kZaxjdXJzb3Jfc3RhbGWnZGV0YWlsc4SlZmllbGTApXZhbHVlwKRhc09mA6djdXJyZW50CQ==",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_stale: stale",
"routingHint": null,
"code": "cursor_stale",
"details": {
"field": null,
"value": null,
"asOf": 3,
"current": 9
}
}
},
{
"name": "error_cursor_invalid_no_details",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX2ludmFsaWQ6IGJhZKtyb3V0aW5nSGludMCkY29kZa5jdXJzb3JfaW52YWxpZKdkZXRhaWxzwA==",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_invalid: bad",
"routingHint": null,
"code": "cursor_invalid",
"details": null
}
},
{
"name": "error_write_not_authorized",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3K0d3JpdGVfbm90X2F1dGhvcml6ZWSrcm91dGluZ0hpbnTApGNvZGW0d3JpdGVfbm90X2F1dGhvcml6ZWSnZGV0YWlsc4SlZmllbGSpcmVjb3JkS2V5pXZhbHVloWukYXNPZsCnY3VycmVudMA=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "write_not_authorized",
"routingHint": null,
"code": "write_not_authorized",
"details": {
"field": "recordKey",
"value": "k",
"asOf": null,
"current": null
}
}
}
]
}

View file

@ -9,8 +9,12 @@ All notable changes to `cultnet-ts` are recorded here, following
- `CultNetRudpSocketTransportServer`: one UDP socket serving many peers, each remote address and port with its own reliable session (`peerConnected`, `frame`, `peerDisconnected` events; `send(peer, ...)` answers on the peer a frame came from). A second client no longer replaces the first; Connects beyond `maxPeers` (default 256) are dropped, and `close()` resolves once every peer's goodbye has left the socket. The shape follows the Rust `CultNetRudpServerHub` and C# `CultNetRudpSocketTransportServer`, and the wire format is unchanged. The optional `peerTimeoutMs` (idle peers ended and told so; default off) exists only in TypeScript. `CultNetRudpSocketTransportConnection` in `server` mode still serves exactly one peer.
- The `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1` and `cultnet.snapshot_response_raw.v1` carriers and the selection payload types (`CultNetSelection`, `CultNetRawDocumentHeader`, `CultNetEdge`). `parseCultNetMessage` accepts them (absent members arrive as absent keys; a page carries exactly one of `headers`/`documents`; an empty or blank `schemas`/`keys` entry throws `CultNetSelectionInvalidError` naming its field), and `encodeCultNetMessageForWire` writes the reference's nil-keyed, declaration-ordered map. Judged against bytes the C# reference wrote (`contracts/cultnet/interop/carrier-vectors.cs-written.json`) and the selection vectors C# and Rust wrote. The selection engine (evaluation, cursors, the declaration-dependent door checks) is not ported.
### Breaking
- `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types.
- `CultMeshCdnChunkRef` carries `id` (slot 4), the reference's element id. `encodeManifest` writes five-slot chunk references and `packContent` sets the decimal offset as the id, as the reference does; a manifest written before ids reads `""` and is rewritten with the slot. Content vectors regenerated from the reference.
- Needs the cultcache-ts release that ships with this change (its manifest range moves with that release): the Idunn runtime authority reader calls `readSingleFileStore`, which 0.14.x does not export. An absent authority file is now a plain `Error` without a code.

View file

@ -65,6 +65,7 @@ Current message families:
- `cultnet.snapshot_request.v0`
- `cultnet.snapshot_response.v0`
- `cultnet.snapshot_response_raw.v0`
- `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1`, `cultnet.snapshot_response_raw.v1` (typed-selection carriers; no selection engine yet)
- `cultnet.sample.change_name.v0`
- `cultnet.sample.chat.v0`

View file

@ -33,8 +33,25 @@ import verseCatalogRequestSchema from "../contracts/cultnet/cultmesh.verse-catal
import verseCatalogResponseSchema from "../contracts/cultnet/cultmesh.verse-catalog-response.schema.json";
import sessionOpenSchema from "../contracts/cultnet/cultmesh.session-open.schema.json";
import sessionAcceptedSchema from "../contracts/cultnet/cultmesh.session-accepted.schema.json";
import selectionSchema from "../contracts/cultnet/cultnet.selection.schema.json";
import rawDocumentHeaderSchema from "../contracts/cultnet/cultnet.raw-document-header.schema.json";
import snapshotRequestV1Schema from "../contracts/cultnet/cultnet.snapshot-request.v1.schema.json";
import snapshotResponseRawV1Schema from "../contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json";
import databaseSubscribeV1Schema from "../contracts/cultnet/cultnet.database-subscribe.v1.schema.json";
import ghostlightAgentStateSchema from "../contracts/cultnet/ghostlight.agent-state.schema.json";
import {
encodeSelectionCarrierForWire,
normalizeSelectionCarrierNulls,
type CultNetSelectionCarrier,
} from "./selection-carriers";
import {
validateSelectionShape,
type CultNetEdge,
type CultNetRawDocumentHeader,
type CultNetSelection,
} from "./selection";
export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0";
export type CultNetSchemaVersion =
@ -53,6 +70,9 @@ export type CultNetSchemaVersion =
| "cultnet.snapshot_response.v0"
| "cultnet.snapshot_response_raw.v0"
| "cultnet.database_subscribe.v0"
| "cultnet.snapshot_request.v1"
| "cultnet.snapshot_response_raw.v1"
| "cultnet.database_subscribe.v1"
| "cultnet.database_unsubscribe.v0"
| "cultnet.database_change_raw.v0"
| "cultnet.schema_catalog_request.v0"
@ -276,6 +296,46 @@ export interface CultNetDatabaseSubscribeMessage {
supportedBodyTransports?: string[];
}
/** A document snapshot through a typed selection (docs/cultnet-selection-cut.md). */
export interface CultNetSnapshotRequestV1Message {
schemaVersion: "cultnet.snapshot_request.v1";
messageId: string;
selection: CultNetSelection;
shardId?: string;
shardEpoch?: number;
}
/** A live subscription through a typed selection. */
export interface CultNetDatabaseSubscribeV1Message {
schemaVersion: "cultnet.database_subscribe.v1";
messageId: string;
subscriptionId: string;
selection: CultNetSelection;
/** Default true. */
includeSnapshot?: boolean;
consumerRuntimeId?: string;
bodyIds?: string[];
supportedBodyTransports?: string[];
}
/** One selection page: exactly one of `headers` / `documents` is present, by projection. */
export interface CultNetSnapshotResponseRawV1Message {
schemaVersion: "cultnet.snapshot_response_raw.v1";
messageId: string;
/** Rows on this page. */
matched: number;
/** The snapshot this page is exact as of. */
asOf: number;
/** Cursor for the next page, absent on the last page. */
next?: string;
headers?: CultNetRawDocumentHeader[];
documents?: CultNetRawDocumentRecord[];
edges?: CultNetEdge[];
shardId?: string;
shardEpoch?: number;
shardLogSequence?: number;
}
export interface CultNetDatabaseUnsubscribeMessage {
schemaVersion: "cultnet.database_unsubscribe.v0";
messageId: string;
@ -688,6 +748,9 @@ export type CultNetMessage =
| CultNetSnapshotResponseMessage
| CultNetSnapshotResponseRawMessage
| CultNetDatabaseSubscribeMessage
| CultNetSnapshotRequestV1Message
| CultNetSnapshotResponseRawV1Message
| CultNetDatabaseSubscribeV1Message
| CultNetDatabaseUnsubscribeMessage
| CultNetDatabaseChangeRawMessage
| CultNetSchemaCatalogRequestMessage
@ -722,6 +785,9 @@ const CULTNET_MESSAGE_SCHEMAS = [
snapshotResponseSchema,
snapshotResponseRawSchema,
databaseSubscribeSchema,
snapshotRequestV1Schema,
snapshotResponseRawV1Schema,
databaseSubscribeV1Schema,
databaseUnsubscribeSchema,
databaseChangeRawSchema,
schemaCatalogRequestSchema,
@ -740,6 +806,8 @@ const cultNetValidators = new Map<CultNetSchemaVersion, ValidateFunction>();
for (const schema of [
documentRecordSchema,
rawDocumentRecordSchema,
rawDocumentHeaderSchema,
selectionSchema,
documentMutationContractSchema,
transportProfileSchema,
schemaDescriptorSchema,
@ -786,6 +854,20 @@ export function parseCultNetMessage(input: unknown, wireContract: CultNetWireCon
return input as CultNetSnapshotResponseRawMessage;
}
if (schemaVersion === "cultnet.snapshot_response_raw.v1") {
validateSnapshotResponseRawV1Message(input);
return input as CultNetSnapshotResponseRawV1Message;
}
if (schemaVersion === "cultnet.snapshot_request.v1" || schemaVersion === "cultnet.database_subscribe.v1") {
// The door first, so an empty or blank schemas/keys entry is a typed refusal naming its field
// rather than a generic schema-validation failure.
const selection = (input as { selection?: unknown }).selection;
if (selection && typeof selection === "object") {
validateSelectionShape(selection as CultNetSelection);
}
}
if (schemaVersion === "cultnet.database_change_raw.v0") {
validateDatabaseChangeRawMessage(input);
return input as CultNetDatabaseChangeRawMessage;
@ -807,7 +889,14 @@ export function encodeCultNetMessageForWire(
return encodeGameCultNetworkingMessage(message);
}
return message;
switch (message.schemaVersion) {
case "cultnet.snapshot_request.v1":
case "cultnet.database_subscribe.v1":
case "cultnet.snapshot_response_raw.v1":
return encodeSelectionCarrierForWire(message as CultNetSelectionCarrier);
default:
return message;
}
}
export function validateGhostlightAgentState(input: unknown): GhostlightAgentStateDocument {
@ -931,6 +1020,7 @@ function encodeGameCultNetworkingMessage(message: CultNetMessage): [number, unkn
decodeBase64Url(message.session, "LoginSuccessMessage.Session"),
]];
case "cultnet.error.v0":
// The gamecult.networking.v0 union's ErrorMessage carries only the text.
return [4, [message.error]];
case "cultnet.sample.change_name.v0":
return [5, [message.name]];
@ -1135,6 +1225,89 @@ function validateSnapshotResponseRawMessage(
});
}
function validateSnapshotResponseRawV1Message(
input: unknown,
): asserts input is CultNetSnapshotResponseRawV1Message {
const candidate = validateBinaryMessageRoot(input, "cultnet.snapshot_response_raw.v1");
const fail = (path: string, problem: string): never => {
throw new Error(`Validation failed for cultnet.snapshot_response_raw.v1: ${path}: ${problem}`);
};
for (const counter of ["matched", "asOf"] as const) {
const value = candidate[counter];
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
fail(`/${counter}`, "must be a non-negative integer");
}
}
if (typeof candidate.next !== "undefined" && typeof candidate.next !== "string") {
fail("/next", "must be a string");
}
const hasHeaders = typeof candidate.headers !== "undefined";
const hasDocuments = typeof candidate.documents !== "undefined";
if (hasHeaders === hasDocuments) {
fail("/", "exactly one of headers and documents must be present");
}
if (hasHeaders) {
if (!Array.isArray(candidate.headers)) fail("/headers", "must be an array");
(candidate.headers as unknown[]).forEach((header, index) => validateRawDocumentHeader(header, `/headers/${index}`));
} else {
if (!Array.isArray(candidate.documents)) fail("/documents", "must be an array");
(candidate.documents as unknown[]).forEach((document, index) => validateRawDocumentRecord(document, `/documents/${index}`));
}
if (typeof candidate.edges !== "undefined") {
if (!Array.isArray(candidate.edges)) fail("/edges", "must be an array");
(candidate.edges as unknown[]).forEach((edge, index) => validateEdge(edge, `/edges/${index}`));
}
requireOptionalNonEmptyString(candidate.shardId, "/shardId");
for (const counter of ["shardEpoch", "shardLogSequence"] as const) {
const value = candidate[counter];
if (typeof value !== "undefined" && (typeof value !== "number" || !Number.isSafeInteger(value))) {
fail(`/${counter}`, "must be an integer");
}
}
}
function validateRawDocumentHeader(input: unknown, instancePath: string): void {
if (!input || typeof input !== "object") {
throw new Error(`Validation failed for raw CultNet document header: ${instancePath}: must be an object`);
}
const candidate = input as Record<string, unknown>;
requireNonEmptyString(candidate.schemaId, `${instancePath}/schemaId`);
requireOptionalNonEmptyString(candidate.schemaName, `${instancePath}/schemaName`);
requireOptionalNonEmptyString(candidate.schemaVersion, `${instancePath}/schemaVersion`);
requireOptionalNonEmptyString(candidate.schemaContentHash, `${instancePath}/schemaContentHash`);
requireNonEmptyString(candidate.recordKey, `${instancePath}/recordKey`);
requireNonEmptyString(candidate.storedAt, `${instancePath}/storedAt`);
requireOptionalNonEmptyString(candidate.sourceRuntimeId, `${instancePath}/sourceRuntimeId`);
requireOptionalNonEmptyString(candidate.sourceAgentId, `${instancePath}/sourceAgentId`);
requireOptionalNonEmptyString(candidate.sourceRole, `${instancePath}/sourceRole`);
requireOptionalStringArray(candidate.tags, `${instancePath}/tags`);
}
function validateEdge(input: unknown, instancePath: string): void {
const fail = (path: string, problem: string): never => {
throw new Error(`Validation failed for cultnet.snapshot_response_raw.v1: ${path}: ${problem}`);
};
if (!input || typeof input !== "object") {
fail(instancePath, "must be an object");
}
const candidate = input as Record<string, unknown>;
for (const end of ["from", "to"] as const) {
const ref = candidate[end] as Record<string, unknown> | undefined;
if (!ref || typeof ref !== "object") {
fail(`${instancePath}/${end}`, "must be an object");
}
requireNonEmptyString(ref!.schemaId, `${instancePath}/${end}/schemaId`);
requireNonEmptyString(ref!.recordKey, `${instancePath}/${end}/recordKey`);
}
requireNonEmptyString(candidate.role, `${instancePath}/role`);
if (typeof candidate.payloadEncoding !== "undefined" && candidate.payloadEncoding !== "messagepack") {
fail(`${instancePath}/payloadEncoding`, "must equal messagepack");
}
if (typeof candidate.payload !== "undefined" && !(candidate.payload instanceof Uint8Array)) {
fail(`${instancePath}/payload`, "must be a Uint8Array");
}
}
function validateDatabaseChangeRawMessage(
input: unknown,
): asserts input is CultNetDatabaseChangeRawMessage {
@ -1167,6 +1340,7 @@ function validateBinaryMessageRoot(
schemaVersion:
| "cultnet.document_put_raw.v0"
| "cultnet.snapshot_response_raw.v0"
| "cultnet.snapshot_response_raw.v1"
| "cultnet.database_change_raw.v0",
): Record<string, unknown> {
if (!input || typeof input !== "object") {
@ -1269,6 +1443,11 @@ function normalizeCultNetOptionalNulls(
"supportedBodyTransports",
]);
return;
case "cultnet.snapshot_request.v1":
case "cultnet.database_subscribe.v1":
case "cultnet.snapshot_response_raw.v1":
normalizeSelectionCarrierNulls(schemaVersion, candidate);
return;
case "cultnet.operation_request.v0":
stripNullProperties(candidate, ["payloadEncoding", "sourceRuntimeId", "targetRuntimeId"]);
return;
@ -1338,6 +1517,11 @@ export const cultNetSchemas = {
snapshotResponseSchema,
snapshotResponseRawSchema,
databaseSubscribeSchema,
selectionSchema,
rawDocumentHeaderSchema,
snapshotRequestV1Schema,
snapshotResponseRawV1Schema,
databaseSubscribeV1Schema,
databaseUnsubscribeSchema,
databaseChangeRawSchema,
schemaDescriptorSchema,

View file

@ -3,6 +3,9 @@ export {
encodeCultNetMessageForWire,
parseCultNetMessage,
validateGhostlightAgentState,
type CultNetDatabaseSubscribeV1Message,
type CultNetSnapshotRequestV1Message,
type CultNetSnapshotResponseRawV1Message,
type CultNetDocumentDeleteMessage,
type CultNetDocumentMutationContract,
type CultNetDocumentPutMessage,
@ -66,6 +69,19 @@ export {
type GhostlightVariableMap,
type GhostlightWorldState,
} from "./contracts";
export {
CultNetSelectionInvalidError,
validateSelectionShape,
type CultNetCitation,
type CultNetEdge,
type CultNetFieldPredicate,
type CultNetIncoming,
type CultNetRawDocumentHeader,
type CultNetRecordRef,
type CultNetSelection,
type CultNetSelectionOperator,
type CultNetSelectionProjection,
} from "./selection";
export {
base64ToBytes,
bytesToBase64,

View file

@ -0,0 +1,178 @@
// The two shape conversions the selection/v1 carriers need between the reference's wire and TypeScript:
// the reference writes every declared key, nil included, in declaration order (MessagePack-CSharp map
// mode; schema notes R-S/R-X), so a received message arrives with explicit nulls that are normalised
// to absent keys, and a sent message is rebuilt with every key in the reference's order.
import type {
CultNetDatabaseSubscribeV1Message,
CultNetRawDocumentRecord,
CultNetSnapshotRequestV1Message,
CultNetSnapshotResponseRawV1Message,
} from "./contracts";
import type { CultNetEdge, CultNetRawDocumentHeader, CultNetRecordRef, CultNetSelection } from "./selection";
export type CultNetSelectionCarrier =
| CultNetSnapshotRequestV1Message
| CultNetDatabaseSubscribeV1Message
| CultNetSnapshotResponseRawV1Message;
type Bag = Record<string, unknown>;
const isBag = (value: unknown): value is Bag => !!value && typeof value === "object" && !Array.isArray(value);
function stripNulls(candidate: unknown, keys: readonly string[]): void {
if (!isBag(candidate)) return;
for (const key of keys) {
if (candidate[key] === null) delete candidate[key];
}
}
function eachBag(list: unknown, visit: (item: Bag) => void): void {
if (!Array.isArray(list)) return;
for (const item of list) {
if (isBag(item)) visit(item);
}
}
/** Drops the nil an absent selection member travels as, at every depth the selection carries one. */
export function normalizeSelectionNulls(selection: unknown): void {
stripNulls(selection, ["schemas", "keys", "fields", "cites", "cited", "limit", "cursor"]);
if (!isBag(selection)) return;
eachBag(selection.fields, field => stripNulls(field, ["values", "number"]));
stripNulls(selection.cites, ["role"]);
}
/** Drops the nil an absent member travels as, for one selection/v1 carrier message. */
export function normalizeSelectionCarrierNulls(
schemaVersion: CultNetSelectionCarrier["schemaVersion"],
candidate: Bag,
): void {
switch (schemaVersion) {
case "cultnet.snapshot_request.v1":
stripNulls(candidate, ["shardId", "shardEpoch"]);
normalizeSelectionNulls(candidate.selection);
return;
case "cultnet.database_subscribe.v1":
stripNulls(candidate, ["consumerRuntimeId", "bodyIds", "supportedBodyTransports"]);
normalizeSelectionNulls(candidate.selection);
return;
case "cultnet.snapshot_response_raw.v1":
stripNulls(candidate, ["next", "headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence"]);
eachBag(candidate.headers, header => stripNulls(header, HEADER_OPTIONAL));
eachBag(candidate.documents, document => stripNulls(document, HEADER_OPTIONAL));
eachBag(candidate.edges, edge => stripNulls(edge, ["payloadEncoding", "payload"]));
return;
}
}
const HEADER_OPTIONAL = ["schemaName", "schemaVersion", "schemaContentHash", "sourceRuntimeId", "sourceAgentId", "sourceRole", "tags"] as const;
const orNull = <T>(value: T | undefined | null): T | null => value ?? null;
function recordRefWire(ref: CultNetRecordRef) {
return { schemaId: ref.schemaId, recordKey: ref.recordKey };
}
function selectionWire(selection: CultNetSelection) {
return {
schemas: orNull(selection.schemas),
keys: orNull(selection.keys),
fields: selection.fields
? selection.fields.map(field => ({
index: field.index,
op: field.op,
values: orNull(field.values),
number: orNull(field.number),
}))
: null,
cites: selection.cites
? { target: recordRefWire(selection.cites.target), role: orNull(selection.cites.role) }
: null,
cited: selection.cited ? { role: selection.cited.role, exists: selection.cited.exists } : null,
projection: selection.projection ?? "header",
descending: selection.descending ?? false,
limit: orNull(selection.limit),
cursor: orNull(selection.cursor),
};
}
function headerWire(header: CultNetRawDocumentHeader) {
return {
schemaId: header.schemaId,
schemaName: orNull(header.schemaName),
schemaVersion: orNull(header.schemaVersion),
schemaContentHash: orNull(header.schemaContentHash),
recordKey: header.recordKey,
storedAt: header.storedAt,
sourceRuntimeId: orNull(header.sourceRuntimeId),
sourceAgentId: orNull(header.sourceAgentId),
sourceRole: orNull(header.sourceRole),
tags: orNull(header.tags),
};
}
function documentWire(document: CultNetRawDocumentRecord) {
return {
schemaId: document.schemaId,
schemaName: orNull(document.schemaName),
schemaVersion: orNull(document.schemaVersion),
schemaContentHash: orNull(document.schemaContentHash),
recordKey: document.recordKey,
storedAt: document.storedAt,
payloadEncoding: document.payloadEncoding,
payload: document.payload,
sourceRuntimeId: orNull(document.sourceRuntimeId),
sourceAgentId: orNull(document.sourceAgentId),
sourceRole: orNull(document.sourceRole),
tags: orNull(document.tags),
};
}
function edgeWire(edge: CultNetEdge) {
return {
from: recordRefWire(edge.from),
role: edge.role,
to: recordRefWire(edge.to),
payloadEncoding: orNull(edge.payloadEncoding),
payload: orNull(edge.payload),
};
}
/** The reference's nil-keyed, declaration-ordered shape for one selection/v1 carrier message. */
export function encodeSelectionCarrierForWire(message: CultNetSelectionCarrier): unknown {
switch (message.schemaVersion) {
case "cultnet.snapshot_request.v1":
return {
schemaVersion: message.schemaVersion,
messageId: message.messageId,
selection: selectionWire(message.selection),
shardId: orNull(message.shardId),
shardEpoch: orNull(message.shardEpoch),
};
case "cultnet.database_subscribe.v1":
return {
schemaVersion: message.schemaVersion,
messageId: message.messageId,
subscriptionId: message.subscriptionId,
selection: selectionWire(message.selection),
includeSnapshot: message.includeSnapshot ?? true,
consumerRuntimeId: orNull(message.consumerRuntimeId),
bodyIds: orNull(message.bodyIds),
supportedBodyTransports: orNull(message.supportedBodyTransports),
};
case "cultnet.snapshot_response_raw.v1":
return {
schemaVersion: message.schemaVersion,
messageId: message.messageId,
matched: message.matched,
asOf: message.asOf,
next: orNull(message.next),
headers: message.headers ? message.headers.map(headerWire) : null,
documents: message.documents ? message.documents.map(documentWire) : null,
edges: message.edges ? message.edges.map(edgeWire) : null,
shardId: orNull(message.shardId),
shardEpoch: orNull(message.shardEpoch),
shardLogSequence: orNull(message.shardLogSequence),
};
}
}

View file

@ -0,0 +1,115 @@
// cultnet.selection.v1 payload shapes (docs/cultnet-selection-cut.md, section 2), the carriers' common
// vocabulary: one typed selection, the header/edge rows a page answers with, and the door refusal.
// The messages that carry them live in contracts.ts; the nil-keyed wire encoding, null normalization and
// response validation live in selection-carriers.ts.
export type CultNetSelectionOperator = "any_of" | "lt" | "le" | "ge" | "gt";
export type CultNetSelectionProjection = "header" | "document";
/** One predicate over a single declared index alias. `values` iff any_of; `number` (canonical decimal string) iff a comparison. */
export interface CultNetFieldPredicate {
index: string;
op: CultNetSelectionOperator;
values?: string[];
number?: string;
}
/** Identifies one row on the wire by schema and record key. */
export interface CultNetRecordRef {
schemaId: string;
recordKey: string;
}
/** Selects rows whose declared reference names `target`; an absent `role` matches any declared reference. */
export interface CultNetCitation {
target: CultNetRecordRef;
role?: string;
}
/** Selects rows some other row does (`exists`) or does not name in `role`. */
export interface CultNetIncoming {
role: string;
exists: boolean;
}
/** Absent `schemas`/`keys` mean every schema/key; a present empty list is refused at the door. */
export interface CultNetSelection {
schemas?: string[];
keys?: string[];
fields?: CultNetFieldPredicate[];
cites?: CultNetCitation;
cited?: CultNetIncoming;
/** Default "header". */
projection?: CultNetSelectionProjection;
descending?: boolean;
/** Clamped to 1..=200 by the answering server. */
limit?: number;
/** Opaque; minted by the answering server. */
cursor?: string;
}
/** A raw document record minus its payload and payloadEncoding. */
export interface CultNetRawDocumentHeader {
schemaId: string;
schemaName?: string;
schemaVersion?: string;
schemaContentHash?: string;
recordKey: string;
storedAt: string;
sourceRuntimeId?: string;
sourceAgentId?: string;
sourceRole?: string;
tags?: string[];
}
/** One edge a hop traversed. `payload` is present under document projection only. */
export interface CultNetEdge {
from: CultNetRecordRef;
role: string;
to: CultNetRecordRef;
payloadEncoding?: "messagepack";
payload?: Uint8Array;
}
/**
* Refused typed at the door: a selection the declared shape cannot support. Carried back as
* `cultnet.error.v0 { code: "selection_invalid", details: { field, value } }`.
*/
export class CultNetSelectionInvalidError extends Error {
constructor(
readonly field: string,
readonly value: string | null,
message: string,
) {
super(message);
this.name = "CultNetSelectionInvalidError";
}
}
/**
* The declaration-independent half of the door, matching the reference's `ValidateShape`: an empty or
* blank `schemas`/`keys` entry or list, and an unrecognised `projection`. Checks that need the
* declared schemas (index aliases, reference roles, cites target) belong to the selection engine.
*/
export function validateSelectionShape(selection: CultNetSelection): void {
const list = (field: "schemas" | "keys") => {
const entries = selection[field];
if (entries === undefined || entries === null) return;
if (entries.length === 0) {
throw new CultNetSelectionInvalidError(
field, null, `selection.${field} is present and empty; omit it to reach every ${field === "schemas" ? "schema" : "key"}.`);
}
for (const entry of entries) {
if (typeof entry !== "string" || entry.trim().length === 0) {
throw new CultNetSelectionInvalidError(field, entry, `selection.${field} carries an empty or whitespace entry.`);
}
}
};
list("schemas");
list("keys");
const projection = selection.projection ?? "header";
if (projection !== "header" && projection !== "document") {
throw new CultNetSelectionInvalidError(
"projection", String(projection), `selection.projection "${String(projection)}" is neither "header" nor "document".`);
}
}

View file

@ -0,0 +1,171 @@
// The reference's own bytes for the cultnet.error.v0 code/details shape and the selection/v1
// carriers (`contracts/cultnet/interop/carrier-vectors.cs-written.json`, written by the C#
// CarrierVectorTests). Each vector is decoded, parsed, and re-encoded: the re-encoding must be the
// reference's exact bytes, and the decoded message must equal the reference's own JSON of the object.
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { decode, encode } from "@msgpack/msgpack";
import { encodeCultNetMessageForWire, parseCultNetMessage } from "../src/contracts";
const FILE = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "carrier-vectors.cs-written.json");
type Vector = { name: string; schemaVersion: string; messagePackBase64: string; expected: unknown };
const vectors: Vector[] = JSON.parse(readFileSync(FILE, "utf8")).vectors;
const vectorsFor = (prefix: string) => vectors.filter(v => v.name.startsWith(prefix));
const bytesOf = (v: Vector) => new Uint8Array(Buffer.from(v.messagePackBase64, "base64"));
/** Uint8Array to base64, recursively, so a decoded message compares with the reference's JSON. */
function jsonShape(value: unknown): unknown {
if (value instanceof Uint8Array) return Buffer.from(value).toString("base64");
if (Array.isArray(value)) return value.map(jsonShape);
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, jsonShape(v)]));
}
return value;
}
const reencode = (message: Parameters<typeof encodeCultNetMessageForWire>[0]) =>
new Uint8Array(encode(encodeCultNetMessageForWire(message), { ignoreUndefined: true }));
test("every error vector keeps the reference's code and details through parse and re-encode", () => {
const errors = vectorsFor("error_");
assert.ok(errors.length >= 5);
for (const v of errors) {
const raw = decode(bytesOf(v));
assert.deepEqual(jsonShape(raw), v.expected, `${v.name}: decoded bytes`);
const message = parseCultNetMessage(raw);
assert.equal(message.schemaVersion, "cultnet.error.v0");
assert.deepEqual(jsonShape(message), v.expected, `${v.name}: parsed message`);
assert.deepEqual(reencode(message), bytesOf(v), `${v.name}: re-encoded bytes`);
}
});
test("an error whose code is outside the schema's closed enum is refused", () => {
const raw = decode(bytesOf(vectors.find(v => v.name === "error_cursor_stale")!)) as Record<string, unknown>;
assert.throws(() => parseCultNetMessage({ ...raw, code: "made_up_code" }), /cultnet\.error\.v0/);
});
// ---- selection/v1 carriers ----
for (const prefix of ["snapshot_request_v1", "database_subscribe_v1", "snapshot_response_raw_v1"]) {
test(`${prefix} vectors: decode, parse, and re-encode to the reference's exact bytes`, () => {
const group = vectorsFor(prefix);
assert.ok(group.length >= 2, `${prefix}: vectors present`);
for (const v of group) {
const raw = decode(bytesOf(v));
assert.deepEqual(jsonShape(raw), v.expected, `${v.name}: decoded bytes`);
const message = parseCultNetMessage(raw);
assert.equal(message.schemaVersion, v.schemaVersion);
assert.deepEqual(reencode(message), bytesOf(v), `${v.name}: re-encoded bytes`);
}
});
}
test("a parsed v1 message drops the nil an absent member travels as", () => {
const full = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_request_v1_defaults")!))) as unknown as
{ selection: Record<string, unknown>; shardId?: unknown };
assert.deepEqual(Object.keys(full.selection).sort(), ["descending", "projection"]);
assert.equal("shardId" in full, false);
const page = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_headers")!))) as unknown as
{ headers: Record<string, unknown>[]; documents?: unknown };
assert.equal("documents" in page, false);
assert.equal("tags" in page.headers[0], false);
assert.deepEqual(page.headers[1].tags, ["alpha", "beta"]);
});
test("edge payloads and document payloads arrive as bytes, not text", () => {
const page = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_documents_and_edges")!))) as unknown as
{ edges: { payload?: Uint8Array }[]; documents: { payload: Uint8Array }[] };
assert.deepEqual(Array.from(page.edges[1].payload!), [1, 2, 3]);
assert.equal("payload" in page.edges[0], false);
assert.deepEqual(Array.from(page.documents[0].payload), [0x81, 0xa1, 0x6b, 0x01]);
});
const request = () => decode(bytesOf(vectors.find(v => v.name === "snapshot_request_v1_full_selection_and_shard")!)) as
{ selection: Record<string, unknown> } & Record<string, unknown>;
const refusedField = (message: unknown): string | undefined => {
try {
parseCultNetMessage(message);
} catch (error) {
return (error as { field?: string }).field;
}
return "accepted";
};
test("the door refuses an empty or blank schemas/keys entry with a typed field, never as every-schema", () => {
const withSelection = (patch: Record<string, unknown>) => ({ ...request(), selection: { ...request().selection, ...patch } });
assert.equal(refusedField(withSelection({ schemas: [] })), "schemas");
assert.equal(refusedField(withSelection({ keys: ["a-eq", " "] })), "keys");
assert.equal(refusedField(withSelection({ projection: "everything" })), "projection");
});
test("the schema refuses a comparison whose number is not a canonical decimal, and a predicate that mixes values and number", () => {
const withField = (field: Record<string, unknown>) => {
const message = request();
return { ...message, selection: { ...message.selection, fields: [field] } };
};
for (const number of ["05", "1.50", "+1", "1e3", "-0", " 1"]) {
assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "ge", values: null, number })), /snapshot_request\.v1/, number);
}
assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "ge", values: ["1"], number: "1" })), /snapshot_request\.v1/);
assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "near", values: null, number: "1" })), /snapshot_request\.v1/);
});
test("a selection page with both or neither of headers and documents is refused", () => {
const page = decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_headers")!)) as Record<string, unknown>;
assert.throws(() => parseCultNetMessage({ ...page, documents: [] }), /exactly one of headers and documents/);
assert.throws(() => parseCultNetMessage({ ...page, headers: null }), /exactly one of headers and documents/);
});
// ---- selection vectors: the reference's and Rust's selection bytes ----
const INTEROP = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop");
type SelectionVector = { name: string; selectionMessagePackBase64: string; expectedRefusalField?: string | null };
const selectionVectors = (file: string): SelectionVector[] =>
JSON.parse(readFileSync(join(INTEROP, file), "utf8")).vectors;
import { CultNetSelectionInvalidError, validateSelectionShape, type CultNetSelection } from "../src/selection";
for (const file of ["selection-vectors.cs-written.json", "selection-vectors.rs-written.json"]) {
test(`${file}: every selection decodes inside a v1 request and the schema door agrees with the vector`, () => {
const all = selectionVectors(file);
assert.ok(all.length >= 20);
for (const v of all) {
const selection = decode(new Uint8Array(Buffer.from(v.selectionMessagePackBase64, "base64")));
const message = { schemaVersion: "cultnet.snapshot_request.v1", messageId: "m", selection, shardId: null, shardEpoch: null };
if (v.expectedRefusalField === "schemas" || v.expectedRefusalField === "keys") {
assert.equal(refusedField(message), v.expectedRefusalField, `${v.name}: shape refusal`);
continue;
}
if (v.expectedRefusalField) {
// Declaration-dependent refusals (cites target, index aliases) need the selection engine's schemas.
const parsed = parseCultNetMessage(message) as unknown as { selection: CultNetSelection };
validateSelectionShape(parsed.selection);
continue;
}
const parsed = parseCultNetMessage(message) as unknown as { selection: CultNetSelection };
validateSelectionShape(parsed.selection);
const rewritten = new Uint8Array(encode(
(encodeCultNetMessageForWire(parsed as never) as { selection: unknown }).selection));
if (file.includes("cs-written")) {
// The reference's bytes are the nil-keyed shape: our encoder must reproduce them exactly.
assert.deepEqual(rewritten, new Uint8Array(Buffer.from(v.selectionMessagePackBase64, "base64")),
`${v.name}: selection re-encodes to the reference's bytes`);
} else {
// Rust omits absent members; what it means must survive our nil-keyed rewrite unchanged.
const again = parseCultNetMessage({ ...message, selection: decode(rewritten) }) as unknown as { selection: CultNetSelection };
assert.deepEqual(again.selection, parsed.selection, `${v.name}: selection meaning survives re-encode`);
}
}
});
}
test("CultNetSelectionInvalidError carries the field and offending value", () => {
assert.throws(
() => validateSelectionShape({ keys: ["ok", ""] }),
(error: unknown) => error instanceof CultNetSelectionInvalidError && error.field === "keys" && error.value === "",
);
});

View file

@ -0,0 +1,178 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// The C# reference's own bytes for the selection/v1 message carriers and the cultnet.error.v0
// code/details shape, written to contracts/cultnet/interop/carrier-vectors.cs-written.json for
// the runtimes that decode and re-encode them (packages/cultnet-ts reads it; a Rust or Python
// reader may follow). Each vector carries the whole message's MessagePack bytes plus `expected`,
// the reference's own camelCase JSON of the same object (byte[] as base64), so a reader judges
// against what the reference serialized rather than a second hand-written spec.
//
// Gated like SelectionParityVectorTests: CULTNET_WRITE_VECTORS=1 rewrites the committed file;
// otherwise the committed bytes are compared with a fresh serialization so reference drift fails
// here instead of in a reader.
public sealed class CarrierVectorTests
{
private const string LeafA = "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff";
private const string CiterId = "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc";
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private static CultNetSelection FullSelection() => new()
{
Schemas = new[] { "leaf_a.v9", LeafA },
Keys = new[] { "a-eq", "\U0001F600-row" },
Fields = new[]
{
new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon", "tool" } },
new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "-2233759.25" }
},
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" }, Role = "Design" },
Cited = new CultNetIncoming { Role = "Design", Exists = false },
Projection = CultNetSelectionProjections.Document,
Descending = true,
Limit = 200,
Cursor = "opaque-cursor"
};
private static CultNetRawDocumentHeader Header(string key, params string[] tags) => new()
{
SchemaId = LeafA,
SchemaName = "leaf_a",
SchemaVersion = "1",
SchemaContentHash = "abc",
RecordKey = key,
StoredAt = "1970-01-01T00:00:00.000Z",
SourceRuntimeId = "rt",
SourceAgentId = "agent",
SourceRole = "role",
Tags = tags.Length == 0 ? null : tags
};
private static IEnumerable<(string Name, ICultNetSchemaMessage Message)> Cases()
{
yield return ("snapshot_request_v1_defaults", new CultNetSnapshotRequestV1Message { MessageId = "m" });
yield return ("snapshot_request_v1_full_selection_and_shard", new CultNetSnapshotRequestV1Message
{
MessageId = "m-full", Selection = FullSelection(), ShardId = "shard-1", ShardEpoch = 7
});
yield return ("database_subscribe_v1_defaults", new CultNetDatabaseSubscribeV1Message { MessageId = "m", SubscriptionId = "sub" });
yield return ("database_subscribe_v1_full", new CultNetDatabaseSubscribeV1Message
{
MessageId = "m-full", SubscriptionId = "sub-full", Selection = FullSelection(), IncludeSnapshot = false,
ConsumerRuntimeId = "consumer", BodyIds = new[] { "body-a" }, SupportedBodyTransports = new[] { "rudp", "quic" }
});
yield return ("snapshot_response_raw_v1_headers", new CultNetSnapshotResponseRawV1Message
{
MessageId = "m", Matched = 2, AsOf = 5, Next = "next-cursor",
Headers = new[] { Header("a-eq"), Header("a-lo", "alpha", "beta") },
ShardId = "shard-1", ShardEpoch = 3, ShardLogSequence = 41
});
yield return ("snapshot_response_raw_v1_documents_and_edges", new CultNetSnapshotResponseRawV1Message
{
MessageId = "m", Matched = 1, AsOf = 5,
Documents = new[]
{
new CultNetRawDocumentRecord { SchemaId = LeafA, RecordKey = "a-eq", StoredAt = "1970-01-01T00:00:00.000Z", Payload = new byte[] { 0x81, 0xA1, 0x6B, 0x01 } }
},
Edges = new[]
{
new CultNetEdge
{
From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, Role = "Design",
To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" }
},
new CultNetEdge
{
From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, Role = "Design",
To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" },
PayloadEncoding = "messagepack", Payload = new byte[] { 1, 2, 3 }
}
}
});
yield return ("error_plain", new CultNetErrorMessage { Error = "boom" });
yield return ("error_selection_invalid", CultNetErrorMessage.ForSelectionInvalid(
new CultNetSelectionInvalidException("fields[0].index", "kind", "fields[0].index \"kind\" is not declared.")));
yield return ("error_cursor_stale", new CultNetErrorMessage
{
Error = "cursor_stale: stale", Code = "cursor_stale", Details = new CultNetErrorDetails { AsOf = 3, Current = 9 }
});
yield return ("error_cursor_invalid_no_details", new CultNetErrorMessage { Error = "cursor_invalid: bad", Code = "cursor_invalid" });
yield return ("error_write_not_authorized", new CultNetErrorMessage
{
Error = "write_not_authorized", Code = "write_not_authorized", Details = new CultNetErrorDetails { Field = "recordKey", Value = "k" }
});
}
private static object Vector(string name, ICultNetSchemaMessage message)
{
var bytes = MessagePackSerializer.Serialize(message.GetType(), message, CultNetSchemaMessageSerialization.Options);
return new
{
name,
schemaVersion = message.SchemaVersion,
messagePackBase64 = Convert.ToBase64String(bytes),
expected = WithoutDerivedMembers(JsonSerializer.SerializeToNode(message, message.GetType(), JsonOptions))
};
}
// CultNetSelection.HasHop is an [IgnoreMember] convenience, not wire; System.Text.Json would emit it.
private static JsonNode? WithoutDerivedMembers(JsonNode? node)
{
switch (node)
{
case JsonObject obj:
obj.Remove("hasHop");
foreach (var child in obj.Select(pair => pair.Value).ToArray()) WithoutDerivedMembers(child);
break;
case JsonArray array:
foreach (var child in array) WithoutDerivedMembers(child);
break;
}
return node;
}
private static string VectorPath()
{
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
directory = directory.Parent;
var root = directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
return Path.Combine(root, "contracts", "cultnet", "interop", "carrier-vectors.cs-written.json");
}
[Test]
public void WriteCarrierVectors()
{
if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1")
Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file.");
var vectors = Cases().Select(c => Vector(c.Name, c.Message)).ToArray();
File.WriteAllText(VectorPath(), JsonSerializer.Serialize(new { vectors }, new JsonSerializerOptions { WriteIndented = true }));
}
[Test]
public void CommittedCarrierVectorsMatchAFreshSerialization()
{
using var committed = JsonDocument.Parse(File.ReadAllText(VectorPath()));
var byName = committed.RootElement.GetProperty("vectors").EnumerateArray()
.ToDictionary(v => v.GetProperty("name").GetString()!, v => v.GetProperty("messagePackBase64").GetString()!);
Assert.That(byName.Count, Is.EqualTo(Cases().Count()), "the committed file must carry exactly the cases this writer produces");
foreach (var (name, message) in Cases())
{
var fresh = Convert.ToBase64String(MessagePackSerializer.Serialize(message.GetType(), message, CultNetSchemaMessageSerialization.Options));
Assert.That(byName[name], Is.EqualTo(fresh), $"{name}: the committed bytes drifted from the reference serializer");
}
}
}
}