pi-mono/packages/chord/test/data/errors.test.ts
Mario Zechner a5d209afd0 feat(chord): reload restarts every affected object; old work finishes on old code
The pure swap is gone: a reload restarts every live object of a changed plugin
(documents and tables kept). Calls running at the commit and every call they cause
(the ctx call chain; an old flag on call from trusted links) run on the open old
attempt, which seals once its work drained and its successor's recorded requests are
ready, bounded by reloadDrainTimeout (5 s); then the successor's factory runs. New work
waits at the gate; same-host consumers see the draining object live; a reload cycle
fails at once with unavailable/reload_cycle. Example 64, design section 21 rewritten.
2026-11-05 18:07:11 +00:00

145 lines
5.9 KiB
TypeScript

import { describe, expect, expectTypeOf, it } from "vitest";
import { ChordError, type ChordErrorReason, isChordError, PluginError } from "../../src/data/index.ts";
import {
asChordError,
CHORD_ERROR_REASONS,
chordErrorFromJson,
invalid,
PROTOCOL_VERSION,
} from "../../src/data/internal.ts";
describe("ChordError", () => {
it("is a tagged union: a wrong pairing is a type error", () => {
new ChordError({ code: "invalid", reason: "args", message: "m" });
new ChordError({ code: "unavailable", message: "m" });
new ChordError({ code: "unavailable", reason: "locked", message: "m" });
// @ts-expect-error: invalid requires a reason
new ChordError({ code: "invalid", message: "m" });
// @ts-expect-error: gone carries no reason
new ChordError({ code: "gone", reason: "args", message: "m" });
// @ts-expect-error: part belongs to too_large
new ChordError({ code: "unavailable", reason: "part", message: "m" });
});
it("narrowing on code narrows reason", () => {
const error = new ChordError({ code: "too_large", reason: "part", message: "m" }) as ChordError;
if (error.code === "invalid") expectTypeOf(error.reason).toEqualTypeOf<ChordErrorReason<"invalid">>();
if (error.code === "unavailable")
expectTypeOf(error.reason).toEqualTypeOf<
| "locked"
| "park_timeout"
| "busy"
| "storage_failed"
| "undeclared"
| "version"
| "reload_cycle"
| undefined
>();
if (error.code === "gone") expectTypeOf(error.reason).toEqualTypeOf<undefined>();
expect(error).toBeInstanceOf(Error);
expect(error.name).toBe("ChordError");
});
it("round-trips through JSON without stack or cause", () => {
const details = { path: "/text" };
const error = new ChordError(
{ code: "invalid", reason: "args", message: "args/text: bad", details },
{ cause: new Error("x") },
);
const json = JSON.parse(JSON.stringify(error));
expect(json).toEqual({ code: "invalid", reason: "args", message: "args/text: bad", details });
const back = chordErrorFromJson(json);
expect(back).toBeInstanceOf(ChordError);
expect(back.toJSON()).toEqual(json);
expect(new ChordError({ code: "gone", message: "m" }).toJSON()).toEqual({ code: "gone", message: "m" });
});
it("keeps details by reference", () => {
const details = { large: [1, 2, 3] };
expect(chordErrorFromJson({ code: "gone", message: "m", details }).details).toBe(details);
});
it.each([
["not an object", "x", "not an object"],
["null", null, "not an object"],
["an array", [], "not an object"],
["unknown key", { code: "gone", message: "m", stack: "s" }, 'unknown key "stack"'],
["unknown code", { code: "teapot", message: "m" }, "unknown code"],
["inherited name as code", { code: "toString", message: "m" }, "unknown code"],
["code not a string", { code: 1, message: "m" }, "unknown code"],
["invalid without reason", { code: "invalid", message: "m" }, "reason not allowed for invalid"],
["reason not allowed", { code: "gone", reason: "args", message: "m" }, "reason not allowed for gone"],
[
"reason of another code",
{ code: "unavailable", reason: "part", message: "m" },
"reason not allowed for unavailable",
],
["message not a string", { code: "gone", message: 1 }, "message is not a string"],
])("chordErrorFromJson refuses %s", (_case, raw, problem) => {
expect(() => chordErrorFromJson(raw)).toThrow(`Invalid ChordError JSON: ${problem}`);
});
});
describe("protocol table", () => {
it("is pinned together with PROTOCOL_VERSION", () => {
// PROTOCOL_VERSION stays 1 until p3 ships (design §22): the table changes with it unbumped until then.
const pinned = {
version: 1,
reasons: {
invalid: ["args", "params", "member", "host_address", "reentrant", "read_after_bulk", "cycle"],
contract_mismatch: ["updating"],
forbidden: ["revoked"],
gone: [],
not_ready: [],
released: ["ended", "closed", "settled", "version", "discarded"],
too_large: ["part"],
unavailable: ["locked", "park_timeout", "busy", "storage_failed", "undeclared", "version", "reload_cycle"],
aborted: [],
},
};
const changed =
PROTOCOL_VERSION !== pinned.version || JSON.stringify(CHORD_ERROR_REASONS) !== JSON.stringify(pinned.reasons);
expect(changed, "the error table changed: bump PROTOCOL_VERSION and update this pin").toBe(false);
});
});
describe("helpers", () => {
it("isChordError checks the class and optionally the code", () => {
const error = invalid("member", "no method");
expect(isChordError(error)).toBe(true);
expect(isChordError(error, "invalid")).toBe(true);
expect(isChordError(error, "gone")).toBe(false);
expect(isChordError(new Error("x"))).toBe(false);
expect(isChordError({ code: "invalid", reason: "member", message: "m" })).toBe(false);
});
it("invalid builds an invalid error with its reason and details", () => {
const error = invalid("params", "params/a: missing", { path: "/a" });
expect(error.toJSON()).toEqual({
code: "invalid",
reason: "params",
message: "params/a: missing",
details: { path: "/a" },
});
expectTypeOf(error.code).toEqualTypeOf<"invalid">();
});
it("asChordError keeps a ChordError and wraps anything else as unavailable", () => {
const chord = invalid("cycle", "c");
expect(asChordError(chord, "open")).toBe(chord);
const foreign = new TypeError("boom");
const wrapped = asChordError(foreign, "open notes");
expect(wrapped.toJSON()).toEqual({ code: "unavailable", message: "open notes: boom" });
expect(wrapped.cause).toBe(foreign);
expect(asChordError("text", "open").message).toBe("open: text");
});
});
describe("PluginError", () => {
it("carries a plugin's code, message and JSON details", () => {
const error = new PluginError("quota", "too many", { limit: 3 });
expect(error).toBeInstanceOf(Error);
expect(error).toMatchObject({ name: "PluginError", code: "quota", message: "too many", details: { limit: 3 } });
expect(new PluginError("c", "m").details).toBeUndefined();
});
});