Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
199 lines
8.3 KiB
TypeScript
199 lines
8.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { Type } from "typebox";
|
|
import { describe, it } from "vitest";
|
|
import {
|
|
bind,
|
|
createHost,
|
|
type Facet,
|
|
type Frame,
|
|
type Host,
|
|
hostFamily,
|
|
hostNamed,
|
|
isChordError,
|
|
type Link,
|
|
memoryLinkPair,
|
|
method,
|
|
PROTOCOL_VERSION,
|
|
remote,
|
|
} from "../src/index.ts";
|
|
|
|
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
/** Lives on `session:` + its input's `session`. */
|
|
const Note = remote("t.note", {
|
|
version: 1,
|
|
input: Type.Object({ session: Type.String(), id: Type.String() }),
|
|
host: hostFamily("session:", "session"),
|
|
variants: { main: { methods: { where: method(undefined, Type.String()) } } },
|
|
});
|
|
|
|
/** Lives on `server`. */
|
|
const Catalog = remote("t.catalog", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
host: hostNamed("server"),
|
|
variants: { main: { methods: { where: method(undefined, Type.String()) } } },
|
|
});
|
|
|
|
/** A server with two session hosts as children; every host serves what it may and reports its name. */
|
|
function cluster() {
|
|
const server = createHost({ name: "server", self: "host", onError: () => {} });
|
|
server.serve(Catalog, () => ({ variant: "main", where: () => "server" }));
|
|
const children = new Map<string, Host>();
|
|
const links = new Map<string, Link>();
|
|
const child = (name: string) => {
|
|
const [down, up] = memoryLinkPair();
|
|
links.set(name, down);
|
|
server.accept(down, { kind: "host", name, principal: "server-side" });
|
|
const host = createHost({ name, self: "host", upstream: { link: up, principal: "server" }, onError: () => {} });
|
|
host.serve(Note, ({ id }) => ({ variant: "main", where: () => `${host.name} ${id}` }));
|
|
children.set(name, host);
|
|
return { host, down };
|
|
};
|
|
const s1 = child("session:S1");
|
|
const s2 = child("session:S2");
|
|
const close = async () => {
|
|
for (const host of children.values()) await host.close();
|
|
await server.close();
|
|
};
|
|
// Relays need a route (where authorization lives); it finds the connected child.
|
|
server.route("session:", {
|
|
authorize: () => true,
|
|
resolve: async (name) => links.get(name)!,
|
|
principal: (name) => name,
|
|
});
|
|
return { server, s1: s1.host, s2: s2.host, close };
|
|
}
|
|
|
|
describe("placement", () => {
|
|
it("a get goes where the contract places the input: here, a child, or the parent, never named by the caller", async () => {
|
|
const { server, s1, close } = cluster();
|
|
await tick();
|
|
assert.equal(await server.hold(Note, { session: "S1", id: "a" }).where(), "session:S1 a");
|
|
assert.equal(await server.hold(Note, { session: "S2", id: "b" }).where(), "session:S2 b");
|
|
// From a session host: its own notes locally, the other session's through the server, the catalog upstream.
|
|
assert.equal(await s1.hold(Note, { session: "S1", id: "c" }).where(), "session:S1 c");
|
|
assert.equal(await s1.hold(Catalog, {}).where(), "server");
|
|
await close();
|
|
});
|
|
|
|
it("serve refuses a type the host is not the place of, also at a reload", async () => {
|
|
const host = createHost({ name: "session:S1", self: "host", onError: () => {} });
|
|
assert.throws(
|
|
() => host.serve(Catalog, () => ({ variant: "main", where: () => "" })),
|
|
/t\.catalog is declared for server, but session:S1 serves it/,
|
|
);
|
|
const server = createHost({ name: "server", self: "host", onError: () => {} });
|
|
assert.throws(
|
|
() => server.serve(Note, () => ({ variant: "main", where: () => "" })),
|
|
/t\.note is declared for session:<session>, but server serves it/,
|
|
);
|
|
const misplaced: Facet = {
|
|
name: "misplaced",
|
|
setup: (api) => api.serve(Note, () => ({ variant: "main", where: () => "" })),
|
|
};
|
|
await assert.rejects(server.reload([misplaced]), /declared for session:<session>, but server serves it/);
|
|
await host.close();
|
|
await server.close();
|
|
});
|
|
|
|
it("a host refuses a hold its own contract places elsewhere, and a relay one whose target disagrees", async () => {
|
|
const { server, close } = cluster();
|
|
await tick();
|
|
const holds = async (target: string, input: unknown): Promise<Frame | undefined> => {
|
|
const [down, raw] = memoryLinkPair();
|
|
server.accept(down, { principal: "guest" });
|
|
const frames: Frame[] = [];
|
|
raw.receive((frame) => frames.push(frame));
|
|
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
|
raw.send({ t: "hold", id: 1, at: target, token: Note.description, input });
|
|
for (let i = 0; i < 50 && !frames.some((frame) => frame.t === "shape" || frame.t === "gone"); i++)
|
|
await tick();
|
|
return frames.find((frame) => frame.t === "shape" || frame.t === "gone");
|
|
};
|
|
// A client picks session:S2 for a note of S1: the relay refuses, nothing reaches S2.
|
|
const relayed = await holds("session:S2", { session: "S1", id: "x" });
|
|
assert.equal(relayed?.t, "gone");
|
|
assert.match(relayed?.t === "gone" ? relayed.error.message : "", /placement|does not live on session:S2/);
|
|
// The right target is served.
|
|
assert.equal((await holds("session:S1", { session: "S1", id: "x" }))?.t, "shape");
|
|
// Addressed to the server itself, a note is refused by the server's own contract check.
|
|
const direct = await holds("server", { session: "S1", id: "x" });
|
|
assert.equal(direct?.t, "gone");
|
|
await close();
|
|
});
|
|
|
|
it("the host comes from the normalized input: unknown fields and defaults do not change it", async () => {
|
|
const Doc = remote("t.doc-default", {
|
|
version: 1,
|
|
input: Type.Object({ session: Type.String({ default: "S1" }) }),
|
|
host: hostFamily("session:", "session"),
|
|
variants: { main: { methods: { where: method(undefined, Type.String()) } } },
|
|
});
|
|
const { server, s1, close } = cluster();
|
|
s1.serve(Doc, () => ({ variant: "main", where: () => "session:S1" }));
|
|
await tick();
|
|
// An untyped caller leaves the field out: the default places it.
|
|
assert.equal(await server.hold(Doc, {} as never).where(), "session:S1");
|
|
assert.throws(() => server.hold(Note, { session: "", id: "x" }), /input field session names no host/);
|
|
await close();
|
|
});
|
|
|
|
it("placement is part of the contract: a moved type is a breaking change, never bridged by an adapter", async () => {
|
|
const at = (host: string) =>
|
|
remote("t.moved", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
host: hostNamed(host),
|
|
variants: { main: { methods: { ping: method() } } },
|
|
});
|
|
assert.notEqual(at("server").fingerprint, at("session:S1").fingerprint);
|
|
const host = createHost({ name: "server", self: "host", onError: () => {} });
|
|
const facet = (version: number, place: string): Facet => ({
|
|
name: "moved",
|
|
setup: (api) =>
|
|
api.serve(
|
|
remote("t.moved", {
|
|
version,
|
|
input: Type.Object({}),
|
|
host: hostNamed(place),
|
|
variants: { main: { methods: { ping: method() } } },
|
|
}),
|
|
() => ({ variant: "main", ping: () => {} }),
|
|
{ accepts: { 1: (current) => current } },
|
|
),
|
|
});
|
|
await host.install(facet(1, "server"));
|
|
await host.get(at("server"), {});
|
|
// Same version, other placement: a forgotten bump.
|
|
await assert.rejects(host.reload([facet(1, "session:S1")]), /declared for session:S1|without a version bump/);
|
|
// A new version that `accepts` version 1 but lives elsewhere: no adapter makes version 1 consumers bind.
|
|
const moved = remote("t.moved", {
|
|
version: 2,
|
|
input: Type.Object({}),
|
|
host: hostNamed("session:S1"),
|
|
variants: { main: { methods: { ping: method() } } },
|
|
});
|
|
assert.throws(
|
|
() => bind(moved, { 1: (current) => current }, at("server")),
|
|
(error) => isChordError(error, "contract_mismatch") && /lives on session:S1/.test(error.message),
|
|
);
|
|
await host.close();
|
|
});
|
|
|
|
it("types: the family's field is a required string of the input, and remote contracts need a host", () => {
|
|
const input = Type.Object({ session: Type.String(), count: Type.Number(), note: Type.Optional(Type.String()) });
|
|
const variants = { main: {} };
|
|
remote("t.ok", { version: 1, input, host: hostFamily("session:", "session"), variants });
|
|
// @ts-expect-error: no such field
|
|
remote("t.typo", { version: 1, input, host: hostFamily("session:", "sesion"), variants });
|
|
// @ts-expect-error: not a string
|
|
remote("t.number", { version: 1, input, host: hostFamily("session:", "count"), variants });
|
|
// @ts-expect-error: optional
|
|
remote("t.optional", { version: 1, input, host: hostFamily("session:", "note"), variants });
|
|
// @ts-expect-error: a remote contract says where it lives
|
|
assert.throws(() => remote("t.nowhere", { version: 1, input, variants }), /needs `host`/);
|
|
assert.throws(() => hostFamily("", "session"), /prefix/);
|
|
});
|
|
});
|