cultnet-ts: cultnet.error.v0 keeps routingHint, code and details, judged against the reference's bytes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7b62cf4ddf
commit
1d21a93a2d
3 changed files with 84 additions and 0 deletions
|
|
@ -165,9 +165,37 @@ export interface CultNetLoginSuccessMessage {
|
|||
session: string;
|
||||
}
|
||||
|
||||
/** The closed `code` enum of `cultnet.error.schema.json`; the schema refuses any other string. */
|
||||
export type CultNetErrorCode =
|
||||
| "selection_invalid"
|
||||
| "cursor_stale"
|
||||
| "cursor_invalid"
|
||||
| "reference_outside_target"
|
||||
| "variant_unsupported"
|
||||
| "unowned_schema"
|
||||
| "write_not_authorized";
|
||||
|
||||
/** Code-specific detail; each field is populated only for the code that defines it, null otherwise. */
|
||||
export interface CultNetErrorDetails {
|
||||
field: string | null;
|
||||
value: string | null;
|
||||
asOf: number | null;
|
||||
current: number | null;
|
||||
}
|
||||
|
||||
/** Shard routing detail for an authority failure. */
|
||||
export interface CultNetShardRoutingHint {
|
||||
shard: Record<string, unknown> | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** The reference writes all five keys, nil included, so a received error always carries them. */
|
||||
export interface CultNetErrorMessage {
|
||||
schemaVersion: "cultnet.error.v0";
|
||||
error: string;
|
||||
routingHint: CultNetShardRoutingHint | null;
|
||||
code: CultNetErrorCode | null;
|
||||
details: CultNetErrorDetails | null;
|
||||
}
|
||||
|
||||
export interface CultNetSampleChangeNameMessage {
|
||||
|
|
@ -842,6 +870,9 @@ function parseGameCultNetworkingMessage(input: unknown): CultNetMessage {
|
|||
return {
|
||||
schemaVersion: "cultnet.error.v0",
|
||||
error: requireLegacyString(payload[0], "ErrorMessage.Error"),
|
||||
routingHint: null,
|
||||
code: null,
|
||||
details: null,
|
||||
};
|
||||
case 5:
|
||||
return {
|
||||
|
|
@ -898,6 +929,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]];
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ export {
|
|||
type CultNetDatabaseChangeRawMessage,
|
||||
type CultNetRawDocumentRecord,
|
||||
type CultNetRawPayloadEncoding,
|
||||
type CultNetErrorCode,
|
||||
type CultNetErrorDetails,
|
||||
type CultNetErrorMessage,
|
||||
type CultNetShardRoutingHint,
|
||||
type CultNetHelloMessage,
|
||||
type CultNetLoginMessage,
|
||||
type CultNetLoginSuccessMessage,
|
||||
|
|
|
|||
49
packages/cultnet-ts/test/carrier-vectors.test.ts
Normal file
49
packages/cultnet-ts/test/carrier-vectors.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// 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/);
|
||||
});
|
||||
Loading…
Reference in a new issue