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
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:
commit
f1cdbfdfd7
9 changed files with 1194 additions and 1 deletions
346
contracts/cultnet/interop/carrier-vectors.cs-written.json
Normal file
346
contracts/cultnet/interop/carrier-vectors.cs-written.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
178
packages/cultnet-ts/src/selection-carriers.ts
Normal file
178
packages/cultnet-ts/src/selection-carriers.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
115
packages/cultnet-ts/src/selection.ts
Normal file
115
packages/cultnet-ts/src/selection.ts
Normal 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".`);
|
||||
}
|
||||
}
|
||||
171
packages/cultnet-ts/test/carrier-vectors.test.ts
Normal file
171
packages/cultnet-ts/test/carrier-vectors.test.ts
Normal 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 === "",
|
||||
);
|
||||
});
|
||||
178
tests/GameCult.Networking.Tests/CarrierVectorTests.cs
Normal file
178
tests/GameCult.Networking.Tests/CarrierVectorTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue