mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins, /plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch; Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/, test/old, docs/old). Durable and env import the foundations from their subpaths.
234 lines
9.2 KiB
TypeScript
234 lines
9.2 KiB
TypeScript
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
|
import { BACKGROUND_CONTEXT, type Context } from "../../src/context/index.ts";
|
|
import {
|
|
type AcceptsTable,
|
|
arr,
|
|
bytes,
|
|
evolve,
|
|
int,
|
|
local,
|
|
method,
|
|
nul,
|
|
obj,
|
|
remote,
|
|
str,
|
|
} from "../../src/contracts/index.ts";
|
|
import { isChordError } from "../../src/data/index.ts";
|
|
import { bindProvider, type CheckedArgs, checkArgs, type Value } from "../../src/data/internal.ts";
|
|
import { counted, expectCollected, Server } from "../support.ts";
|
|
|
|
const Note = obj({ text: str() });
|
|
const NoteV2 = obj({ text: str(), tag: str() });
|
|
|
|
const NotesV1 = remote("notes", {
|
|
version: 1,
|
|
params: { session: str() },
|
|
hostAddress: () => Server(),
|
|
variants: {
|
|
editor: {
|
|
state: { notes: arr(Note) },
|
|
methods: { add: method(Note, str()), clear: method(), attach: method(obj({ data: bytes({ max: 4 }) })) },
|
|
},
|
|
reader: { state: { notes: arr(Note) } },
|
|
},
|
|
});
|
|
const NotesV2 = evolve(NotesV1, (e) => e.args("add", NoteV2).result("add", int()));
|
|
const NotesV3 = evolve(NotesV2, (e) => e.removeMethod("attach"));
|
|
|
|
const toV2 = vi.fn((old: { text: string }) => ({ ...old, tag: "v1" }));
|
|
const resultToOld = (current: number) => String(current);
|
|
const attach = async () => null;
|
|
const accepts: AcceptsTable<typeof NotesV3> = {
|
|
1: { methods: { add: { args: toV2, result: resultToOld }, attach: { call: attach } } },
|
|
2: { methods: { attach: "refuse" } },
|
|
};
|
|
const provider = bindProvider(NotesV3, accepts);
|
|
|
|
/** The error a refused check throws, as JSON. */
|
|
function refusal(run: () => unknown): unknown {
|
|
try {
|
|
run();
|
|
} catch (error) {
|
|
if (isChordError(error)) return error.toJSON();
|
|
throw error;
|
|
}
|
|
throw new Error("expected a refusal");
|
|
}
|
|
|
|
describe("bindProvider", () => {
|
|
it("checks the accepts table once and lists the accepted versions", () => {
|
|
expect(provider.contract).toBe(NotesV3);
|
|
expect(provider.accepted).toEqual([
|
|
{ version: 1, fingerprint: NotesV1.fingerprint },
|
|
{ version: 2, fingerprint: NotesV2.fingerprint },
|
|
]);
|
|
expect(() => bindProvider(NotesV3, { 1: {} } as never)).toThrow("notes accepts[1].methods.add: missing mapping");
|
|
});
|
|
|
|
it("compiles nothing and calls no mapping", async () => {
|
|
const counts = await counted(() => bindProvider(NotesV3, accepts));
|
|
expect(counts).toEqual({ walks: 0, validations: 0, compilations: 0 });
|
|
expect(toV2).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("checkArgs", () => {
|
|
it("returns current-version arguments as passed: no copy, never mapped", () => {
|
|
const raw = { text: "t", tag: "x" };
|
|
const checked = checkArgs(provider, "add", raw, 3, BACKGROUND_CONTEXT);
|
|
expect(checked).toEqual({ kind: "method", args: raw, result: undefined });
|
|
expect(checked.args).toBe(raw);
|
|
expect(toV2).not.toHaveBeenCalled();
|
|
expectTypeOf(checked).toEqualTypeOf<CheckedArgs<typeof NotesV3, "add">>();
|
|
if (checked.kind === "method") expectTypeOf(checked.args).toEqualTypeOf<{ text: string; tag: string }>();
|
|
});
|
|
|
|
it("checks an old version against the old schema, then maps directly to the current shape", () => {
|
|
const checked = checkArgs(provider, "add", { text: "t" }, 1, BACKGROUND_CONTEXT);
|
|
expect(checked).toEqual({ kind: "method", args: { text: "t", tag: "v1" }, result: resultToOld });
|
|
expect(refusal(() => checkArgs(provider, "add", { text: "t", tag: "x" }, 1, BACKGROUND_CONTEXT))).toMatchObject({
|
|
code: "invalid",
|
|
reason: "args",
|
|
details: { path: "/tag" },
|
|
});
|
|
// Version 2 already has the current args: checked with that schema, not mapped.
|
|
expect(checkArgs(provider, "add", { text: "t", tag: "x" }, 2, BACKGROUND_CONTEXT)).toEqual({
|
|
kind: "method",
|
|
args: { text: "t", tag: "x" },
|
|
result: undefined,
|
|
});
|
|
});
|
|
|
|
it("returns the old arguments with the call replacement; never invokes it", () => {
|
|
const raw = { data: new Uint8Array(2) };
|
|
expect(checkArgs(provider, "attach", raw, 1, BACKGROUND_CONTEXT)).toEqual({
|
|
kind: "call",
|
|
args: raw,
|
|
call: attach,
|
|
});
|
|
});
|
|
|
|
it("refuses unknown fields, nested ones too, and checks bytes by instanceof and length", () => {
|
|
expect(
|
|
refusal(() => checkArgs(provider, "add", { text: "t", tag: "x", extra: 1 }, 3, BACKGROUND_CONTEXT)),
|
|
).toMatchObject({
|
|
details: { path: "/extra" },
|
|
});
|
|
expect(refusal(() => checkArgs(provider, "attach", { data: [1, 2] }, 1, BACKGROUND_CONTEXT))).toMatchObject({
|
|
details: { path: "/data" },
|
|
});
|
|
expect(
|
|
refusal(() => checkArgs(provider, "attach", { data: new Uint8Array(5) }, 1, BACKGROUND_CONTEXT)),
|
|
).toMatchObject({
|
|
details: { path: "/data" },
|
|
});
|
|
const Nested = local("nested", { version: 1, params: {}, members: { m: method(obj({ a: obj({ b: str() }) })) } });
|
|
const nested = bindProvider(Nested, {});
|
|
expect(refusal(() => checkArgs(nested, "m", { a: { b: "x", c: 1 } }, 1, BACKGROUND_CONTEXT))).toMatchObject({
|
|
details: { path: "/a/c" },
|
|
});
|
|
});
|
|
|
|
it("takes null for no-argument methods", () => {
|
|
expect(checkArgs(provider, "clear", null, 3, BACKGROUND_CONTEXT)).toEqual({
|
|
kind: "method",
|
|
args: null,
|
|
result: undefined,
|
|
});
|
|
expect(refusal(() => checkArgs(provider, "clear", {}, 3, BACKGROUND_CONTEXT))).toMatchObject({
|
|
code: "invalid",
|
|
reason: "args",
|
|
details: { path: "/" },
|
|
});
|
|
});
|
|
|
|
it("checks local callers too", () => {
|
|
const Info = local("info", { version: 1, params: {}, members: { save: method(obj({ title: str() }), nul()) } });
|
|
const local1 = bindProvider(Info, {});
|
|
expect(checkArgs(local1, "save", { title: "t" }, 1, BACKGROUND_CONTEXT).args).toEqual({ title: "t" });
|
|
expect(refusal(() => checkArgs(local1, "save", { title: 1 }, 1, BACKGROUND_CONTEXT))).toMatchObject({
|
|
message: "args/title: expected string, got integer",
|
|
});
|
|
});
|
|
|
|
it("treats `format` as an annotation: a contract with format email accepts any string", () => {
|
|
const Mail = local("mail", {
|
|
version: 1,
|
|
params: {},
|
|
members: { send: method(obj({ to: { type: "string", format: "email" } }), nul()) },
|
|
});
|
|
const mail = bindProvider(Mail, {});
|
|
expect(checkArgs(mail, "send", { to: "nope" }, 1, BACKGROUND_CONTEXT).args).toEqual({ to: "nope" });
|
|
});
|
|
|
|
it("refuses an unknown method, an unaccepted version and a refused method", () => {
|
|
expect(refusal(() => checkArgs(provider, "missing", null, 3, BACKGROUND_CONTEXT))).toEqual({
|
|
code: "invalid",
|
|
reason: "member",
|
|
message: 'notes v3 has no method "missing"',
|
|
});
|
|
expect(refusal(() => checkArgs(provider, "attach", null, 3, BACKGROUND_CONTEXT))).toMatchObject({
|
|
code: "invalid",
|
|
reason: "member",
|
|
});
|
|
expect(refusal(() => checkArgs(provider, "add", { text: "t" }, 4, BACKGROUND_CONTEXT))).toEqual({
|
|
code: "contract_mismatch",
|
|
message: "notes v4 is not accepted",
|
|
});
|
|
expect(
|
|
refusal(() => checkArgs(bindProvider(NotesV3, {}), "add", { text: "t" }, 1, BACKGROUND_CONTEXT)),
|
|
).toMatchObject({
|
|
code: "contract_mismatch",
|
|
});
|
|
expect(refusal(() => checkArgs(provider, "attach", { data: new Uint8Array() }, 2, BACKGROUND_CONTEXT))).toEqual({
|
|
code: "contract_mismatch",
|
|
message: 'notes v2 refuses "attach"',
|
|
});
|
|
});
|
|
|
|
it("compiles lazily, once per accepted version and method; equal schema objects share a validator", async () => {
|
|
const v1 = local("lazy", { version: 1, params: {}, members: { m: method(obj({ a: str() })), clear: method() } });
|
|
const v2 = evolve(v1, (e) => e.args("m", obj({ a: str(), b: str() })));
|
|
const fresh = bindProvider(v2, { 1: { methods: { m: { args: (old) => ({ ...old, b: "" }) } } } });
|
|
const calls = [
|
|
(context: Context) => checkArgs(fresh, "clear", null, 2, context),
|
|
(context: Context) => checkArgs(fresh, "clear", null, 2, context),
|
|
// Version 1 shares the unchanged `clear` schema object with version 2.
|
|
(context: Context) => checkArgs(fresh, "clear", null, 1, context),
|
|
(context: Context) => checkArgs(fresh, "m", { a: "x" }, 1, context),
|
|
(context: Context) => checkArgs(fresh, "m", { a: "x" }, 1, context),
|
|
(context: Context) => checkArgs(fresh, "m", { a: "x", b: "y" }, 2, context),
|
|
];
|
|
const compilations = [];
|
|
for (const call of calls) compilations.push((await counted(call)).compilations);
|
|
expect(compilations).toEqual([1, 0, 0, 1, 0, 1]);
|
|
});
|
|
|
|
it("counts one validation per call and no walk", async () => {
|
|
const counts = await counted((context) => checkArgs(provider, "add", { text: "t" }, 1, context));
|
|
expect(counts).toEqual({ walks: 0, validations: 1, compilations: 0 });
|
|
});
|
|
|
|
it("types a call replacement's arguments as values", () => {
|
|
expectTypeOf<Extract<CheckedArgs<typeof NotesV3, "attach">, { kind: "call" }>["call"]>().toEqualTypeOf<
|
|
(args: Value, context: Context) => Promise<Value>
|
|
>();
|
|
});
|
|
});
|
|
|
|
describe("memory", () => {
|
|
it("a dropped binding releases its dispatch table, mappings, schemas and validators after the last call", async () => {
|
|
const refs = await (async () => {
|
|
const contract = evolve(
|
|
local("gc", { version: 1, params: {}, members: { m: method(obj({ a: str() })) } }),
|
|
(e) => e.args("m", obj({ a: str(), b: str() })),
|
|
);
|
|
const mapping = (old: { a: string }) => ({ ...old, b: "" });
|
|
const bound = bindProvider(contract, { 1: { methods: { m: { args: mapping } } } });
|
|
const pending = Promise.resolve(checkArgs(bound, "m", { a: "x" }, 1, BACKGROUND_CONTEXT));
|
|
await pending;
|
|
return [new WeakRef(contract), new WeakRef(bound), new WeakRef(mapping)];
|
|
})();
|
|
await expectCollected(...refs);
|
|
});
|
|
});
|