diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index 31188790..dcaff6f2 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -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 | 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]]; diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index c321bd07..e45a87f3 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -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, diff --git a/packages/cultnet-ts/test/carrier-vectors.test.ts b/packages/cultnet-ts/test/carrier-vectors.test.ts new file mode 100644 index 00000000..891e9575 --- /dev/null +++ b/packages/cultnet-ts/test/carrier-vectors.test.ts @@ -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[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; + assert.throws(() => parseCultNetMessage({ ...raw, code: "made_up_code" }), /cultnet\.error\.v0/); +});