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
510 lines
18 KiB
TypeScript
510 lines
18 KiB
TypeScript
// Regression tests for resolved application requests (#1-#10), numbered as in their test names.
|
|
import assert from "node:assert/strict";
|
|
import { Type } from "typebox";
|
|
import { describe, it } from "vitest";
|
|
import { applyImmutable } from "../src/delta/index.ts";
|
|
import {
|
|
type Ctx,
|
|
createHost,
|
|
defineFacet,
|
|
defineRegistry,
|
|
type Facet,
|
|
type FacetApi,
|
|
type FacetLoader,
|
|
hostNamed,
|
|
isChordError,
|
|
local,
|
|
memoryLinkPair,
|
|
method,
|
|
opaque,
|
|
remote,
|
|
type StateEvent,
|
|
} from "../src/index.ts";
|
|
|
|
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
async function until(check: () => boolean, attempts = 100): Promise<void> {
|
|
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
|
assert.ok(check(), "condition not reached");
|
|
}
|
|
|
|
function latch() {
|
|
let open = () => {};
|
|
const promise = new Promise<void>((resolve) => {
|
|
open = resolve;
|
|
});
|
|
return { promise, open };
|
|
}
|
|
|
|
const View = Type.Object({ text: Type.String() });
|
|
|
|
describe("application requests", () => {
|
|
it("#1 a held object's state member can be returned as this object's state member", async () => {
|
|
const Conv = remote("t.conv", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
host: hostNamed("server"),
|
|
variants: { main: { state: { view: View }, methods: { set: method(Type.String()) } } },
|
|
});
|
|
const Chat = remote("t.chat", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
host: hostNamed("server"),
|
|
variants: { viewer: { state: { view: View } } },
|
|
});
|
|
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
|
server.serve(Conv, (_input, _principal, self) => {
|
|
const view = self.state("view", View, { text: "" });
|
|
return {
|
|
variant: "main",
|
|
view,
|
|
set: (text, ctx) => {
|
|
view.replace(ctx, { text });
|
|
},
|
|
};
|
|
});
|
|
// No derivation, no cast: the reference member is the implementation member.
|
|
server.serve(Chat, (_input, _principal, self) => {
|
|
const conv = self.get(Conv, {});
|
|
return { variant: "viewer", view: conv.view };
|
|
});
|
|
const chat = server.hold(Chat, {});
|
|
const values: unknown[] = [];
|
|
chat.view.subscribe((value) => values.push(value));
|
|
await chat.ready;
|
|
await server.hold(Conv, {}).set("hi");
|
|
await tick();
|
|
assert.deepEqual(chat.view.value, { text: "hi" });
|
|
assert.deepEqual(values.at(-1), { text: "hi" });
|
|
|
|
// Across a link: the snapshot and its ops reach a browser.
|
|
const [down, up] = memoryLinkPair();
|
|
server.accept(down, { principal: { guest: "g" } });
|
|
const browser = createHost({
|
|
self: { kind: "host" },
|
|
name: "browser",
|
|
kind: "client",
|
|
upstream: { link: up, principal: { kind: "server" } },
|
|
onError: () => {},
|
|
});
|
|
const remoteChat = browser.hold(Chat, {});
|
|
await remoteChat.ready;
|
|
await server.hold(Conv, {}).set("there");
|
|
await until(() => (remoteChat.view.value as { text: string } | undefined)?.text === "there");
|
|
});
|
|
|
|
it("#2 facets reach their own host through FacetApi.root and FacetApi.registry", async () => {
|
|
const Config = defineRegistry<string>("t.config");
|
|
const Settings = local("t.settings", { version: 1, input: Type.Object({}) });
|
|
const Reader = local("t.reader", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
members: { read: method(undefined, Type.String()) },
|
|
});
|
|
const captured: FacetApi[] = [];
|
|
const facet = (value: string): Facet => ({
|
|
name: "config",
|
|
...defineFacet((api) => {
|
|
captured.push(api);
|
|
api.serve(
|
|
Settings,
|
|
(_input, _principal, self) => {
|
|
self.register(Config, { greeting: value });
|
|
return {};
|
|
},
|
|
{ lifetime: "host" },
|
|
);
|
|
api.serve(Reader, () => ({
|
|
read: (ctx: Ctx) => api.registry(Config).get("greeting", ctx) ?? "",
|
|
}));
|
|
}),
|
|
});
|
|
const host = createHost({ self: { kind: "host" }, name: "worker", onError: () => {} });
|
|
host.install(facet("v1"));
|
|
await sleep(5);
|
|
const api = captured[0]!;
|
|
assert.equal(api.name, "worker");
|
|
// A callback outside Chord (a Durable tool, say) starts its own root.
|
|
assert.equal(await api.root(async (ctx) => (await ctx.get(Reader, {})).read(ctx)), "v1");
|
|
await host.reload([facet("v2")]);
|
|
await sleep(5);
|
|
assert.equal(captured[1]!.registry(Config).get("greeting"), "v2");
|
|
assert.equal(await api.root(async (ctx) => (await ctx.get(Reader, {})).read(ctx)), "v2");
|
|
});
|
|
|
|
it("#3 a listener keeps its subscription across reloads when resource and select are unchanged", async () => {
|
|
const Watcher = local("t.watcher", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
members: { seen: Type.Array(Type.String()) },
|
|
});
|
|
// A source that accepts one subscriber at a time (like a Durable watch).
|
|
let subscribers = 0;
|
|
let emit = (_event: string) => {};
|
|
const single = {
|
|
start: (fn: (event: string) => void) => {
|
|
if (subscribers > 0) throw new Error("Watch is already started");
|
|
subscribers++;
|
|
emit = fn;
|
|
return () => {
|
|
subscribers--;
|
|
};
|
|
},
|
|
};
|
|
const facet = (tag: string): Facet => ({
|
|
name: "watcher",
|
|
setup: (api) =>
|
|
api.serve(Watcher, (_input, _principal, self) => {
|
|
const seen = self.state("seen", Type.Array(Type.String()), []);
|
|
const watchResource = self.resource("watch", { version: 1, open: () => single });
|
|
// A new adapter object per call, and a new arrow per factory run.
|
|
self.on(
|
|
"events",
|
|
watchResource,
|
|
(watch) => ({ subscribe: (fn: (event: string) => void) => watch.start(fn) }),
|
|
(event, ctx) => seen.change(ctx, (draft) => void draft.push(`${tag}:${event}`)),
|
|
);
|
|
return { seen };
|
|
}),
|
|
});
|
|
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
|
host.install(facet("v1"));
|
|
const ref = host.hold(Watcher, {});
|
|
await ref.ready;
|
|
emit("a");
|
|
await host.reload([facet("v2")]);
|
|
await host.reload([facet("v3")]);
|
|
emit("b");
|
|
await sleep(5);
|
|
assert.equal(subscribers, 1);
|
|
assert.deepEqual(ref.seen.value, ["v1:a", "v3:b"]);
|
|
});
|
|
|
|
it("#3 a changed select resubscribes", async () => {
|
|
const Watcher = local("t.watcher2", { version: 1, input: Type.Object({}) });
|
|
let subscribes = 0;
|
|
const source = {
|
|
subscribe: () => {
|
|
subscribes++;
|
|
return () => {};
|
|
},
|
|
};
|
|
const facet = (selectText: boolean): Facet => ({
|
|
name: "watcher2",
|
|
setup: (api) =>
|
|
api.serve(Watcher, (_input, _principal, self) => {
|
|
const sResource = self.resource("s", { version: 1, open: () => source });
|
|
self.on("s", sResource, selectText ? (s) => s : (value) => value, () => {});
|
|
return {};
|
|
}),
|
|
});
|
|
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
|
host.install(facet(true));
|
|
await host.hold(Watcher, {}).ready;
|
|
await host.reload([facet(true)]);
|
|
assert.equal(subscribes, 1);
|
|
await host.reload([facet(false)]);
|
|
assert.equal(subscribes, 2);
|
|
});
|
|
|
|
it("#4 replace diffs a new revision that shares structure; unrelated values replace", async () => {
|
|
const Mirror = local("t.mirror", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
members: { doc: Type.Unknown(), set: method(Type.Unknown()) },
|
|
});
|
|
const Doc = Type.Object({ entries: Type.Array(Type.String()), status: Type.String() });
|
|
let lastOptions: { diff?: boolean } | undefined;
|
|
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
|
host.serve(Mirror, (_input, _principal, self) => {
|
|
const doc = self.state("doc", Doc, { entries: ["a", "b"], status: "idle" });
|
|
return {
|
|
doc,
|
|
set: (value: unknown, ctx: Ctx) => {
|
|
doc.replace(ctx, value as never, lastOptions);
|
|
},
|
|
};
|
|
});
|
|
const ref = host.hold(Mirror, {});
|
|
await ref.ready;
|
|
const events: StateEvent<unknown>[] = [];
|
|
let replica: unknown;
|
|
ref.doc.events((event) => {
|
|
replica = event.type === "ops" ? applyImmutable(replica, event.ops) : event.value;
|
|
events.push(event);
|
|
});
|
|
const opsOf = (event: StateEvent<unknown> | undefined) => (event?.type === "ops" ? event.ops : []);
|
|
const current = ref.doc.value as { entries: string[]; status: string };
|
|
// A new revision reusing `entries`: a diff.
|
|
await ref.set({ entries: current.entries, status: "running" });
|
|
assert.deepEqual(opsOf(events.at(-1)), [["s", ["status"], "running"]]);
|
|
// Unrelated containers: one replacement op.
|
|
await ref.set({ entries: ["a", "b", "c"], status: "running" });
|
|
assert.equal(opsOf(events.at(-1))[0]?.[0], "r");
|
|
// Forced either way.
|
|
lastOptions = { diff: true };
|
|
await ref.set({ entries: ["a", "b", "c", "d"], status: "running" });
|
|
assert.notEqual(opsOf(events.at(-1))[0]?.[0], "r");
|
|
lastOptions = { diff: false };
|
|
const shared = ref.doc.value as { entries: string[] };
|
|
await ref.set({ entries: shared.entries, status: "done" });
|
|
assert.equal(opsOf(events.at(-1))[0]?.[0], "r");
|
|
await tick();
|
|
assert.deepEqual(replica, ref.doc.value);
|
|
});
|
|
|
|
it("#5 roots of a child that connected behind the server are admitted in the server's code version", async () => {
|
|
const Echo = remote("t.echo", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
host: hostNamed("server"),
|
|
variants: { main: { methods: { codeVersion: method(undefined, Type.Number()) } } },
|
|
});
|
|
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
|
const echoFacet: Facet = {
|
|
name: "echo",
|
|
setup: (api) => api.serve(Echo, () => ({ variant: "main", codeVersion: (ctx: Ctx) => ctx.codeVersion })),
|
|
};
|
|
server.install(echoFacet);
|
|
await server.reload([echoFacet]);
|
|
assert.equal(server.codeVersion, 2);
|
|
assert.equal(server.previousCodeVersion, undefined);
|
|
const loader: FacetLoader = async () => [];
|
|
const worker = createHost({ self: { kind: "host" }, name: "session:S1", loader, onError: () => {} });
|
|
const go = latch();
|
|
// A worker root of code version 1 (an eager object's listener, say) calls the server after connecting.
|
|
const call = worker.root(async (ctx) => {
|
|
await go.promise;
|
|
return (await ctx.get(Echo, {})).codeVersion(ctx);
|
|
});
|
|
const [down, up] = memoryLinkPair();
|
|
server.accept(down, { principal: { worker: "S1" }, kind: "host", name: "session:S1" });
|
|
worker.connect(up, { principal: { kind: "server" } });
|
|
go.open();
|
|
assert.equal(await call, 2);
|
|
await until(() => worker.codeVersion === 2);
|
|
// Unknown code versions from in-step peers stay superseded.
|
|
await assert.rejects(
|
|
server.root(() => 0, { codeVersion: 1 }),
|
|
(error) => isChordError(error, "superseded"),
|
|
);
|
|
});
|
|
|
|
it("#6 self.expose: an opaque member backed by a resource, readable once live", async () => {
|
|
const Store = local("t.store", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
members: { db: opaque<{ readonly queries: string[] }>("t.db@1") },
|
|
});
|
|
const gate = latch();
|
|
const native = { queries: [] as string[] };
|
|
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
|
host.serve(Store, (_input, _principal, self) => {
|
|
const db = self.resource("db", {
|
|
version: 1,
|
|
open: async () => {
|
|
await gate.promise;
|
|
return native;
|
|
},
|
|
});
|
|
return { db: self.expose(db) };
|
|
});
|
|
const ref = host.hold(Store, {});
|
|
assert.equal(ref.db, undefined);
|
|
gate.open();
|
|
await ref.ready;
|
|
assert.equal(ref.db, native);
|
|
|
|
const Other = local("t.other", { version: 1, input: Type.Object({}) });
|
|
host.serve(Other, (_input, _principal, self) => {
|
|
self.expose({ key: "x", value: 1 });
|
|
return {};
|
|
});
|
|
await assert.rejects(host.hold(Other, {}).ready, /expose takes a resource declared by this object/);
|
|
});
|
|
|
|
it("#7 a cluster reload reports what each child prepared", async () => {
|
|
const Greeter = remote("t.greeter", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
host: hostNamed("session:S1"),
|
|
variants: { main: { methods: { greet: method(Type.String(), Type.String()) } } },
|
|
});
|
|
const greeter = (text: string): Facet => ({
|
|
name: "greeter",
|
|
setup: (api) => api.serve(Greeter, () => ({ variant: "main", greet: (name: string) => `${text} ${name}` })),
|
|
});
|
|
const modules = new Map([["greeter", greeter("v1")]]);
|
|
const loader: FacetLoader = async (names) => names.map((name) => modules.get(String(name))!);
|
|
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
|
const [down, up] = memoryLinkPair();
|
|
server.accept(down, { principal: { worker: "S1" }, kind: "host", name: "session:S1" });
|
|
const worker = createHost({
|
|
self: { kind: "host" },
|
|
name: "session:S1",
|
|
upstream: { link: up, principal: { kind: "server" } },
|
|
loader,
|
|
onError: () => {},
|
|
});
|
|
worker.install(greeter("v1"));
|
|
await tick();
|
|
const ref = server.hold(Greeter, {});
|
|
assert.equal(await ref.greet("a"), "v1 a");
|
|
modules.set("greeter", greeter("v2"));
|
|
const report = await server.reload([], { remote: { "session:": ["greeter"] } });
|
|
assert.deepEqual(report.changed, []);
|
|
const child = report.children?.["session:S1"];
|
|
assert.deepEqual(child?.changed, ["t.greeter"]);
|
|
assert.equal(child?.prepared.length, 1);
|
|
assert.equal(child?.codeVersion, report.codeVersion);
|
|
});
|
|
|
|
it("#8 a held remote reference keeps its state without listeners; a later subscriber sees the current value", async () => {
|
|
const Counter = remote("t.count8", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
host: hostNamed("server"),
|
|
variants: { main: { state: { n: Type.Number() }, methods: { set: method(Type.Number()) } } },
|
|
});
|
|
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
|
server.serve(Counter, (_input, _principal, self) => {
|
|
const n = self.state("n", Type.Number(), 0);
|
|
return {
|
|
variant: "main",
|
|
n,
|
|
set: (value, ctx) => {
|
|
n.replace(ctx, value);
|
|
},
|
|
};
|
|
});
|
|
const [down, up] = memoryLinkPair();
|
|
server.accept(down, { principal: { guest: "g" } });
|
|
const browser = createHost({
|
|
self: { kind: "host" },
|
|
name: "browser",
|
|
kind: "client",
|
|
upstream: { link: up, principal: { kind: "server" } },
|
|
onError: () => {},
|
|
});
|
|
const counter = browser.hold(Counter, {});
|
|
await counter.ready;
|
|
assert.equal(counter.n.value, 0);
|
|
const stop = counter.n.subscribe(() => {});
|
|
await counter.set(1);
|
|
await until(() => counter.n.value === 1);
|
|
stop();
|
|
await counter.set(2);
|
|
await until(() => counter.n.value === 2);
|
|
const seen: unknown[] = [];
|
|
const again = counter.n.subscribe((value) => seen.push(value));
|
|
assert.deepEqual(seen, [2]);
|
|
again();
|
|
});
|
|
|
|
it("#9 references report status changes: live, loading while a link reconnects, gone, released", async () => {
|
|
const Thing = remote("t.thing9", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
host: hostNamed("server"),
|
|
variants: { main: {} },
|
|
});
|
|
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
|
server.serve(Thing, () => ({ variant: "main" }));
|
|
const localThing = server.hold(Thing, {}, { principal: { user: "u" } });
|
|
const localStatus: unknown[] = [];
|
|
localThing.onStatus((status, error) =>
|
|
localStatus.push(status === "gone" ? [status, (error as { code: string }).code] : status),
|
|
);
|
|
await localThing.ready;
|
|
await tick();
|
|
await server.invalidatePrincipal({ user: "u" });
|
|
await tick();
|
|
assert.deepEqual(localStatus, ["live", ["gone", "gone"]]);
|
|
|
|
const [down, up] = memoryLinkPair();
|
|
server.accept(down, { principal: { guest: "g" } });
|
|
const browser = createHost({
|
|
self: { kind: "host" },
|
|
name: "browser",
|
|
kind: "client",
|
|
upstream: { link: up, principal: { kind: "server" } },
|
|
onError: () => {},
|
|
});
|
|
const remoteThing = browser.hold(Thing, {});
|
|
const remoteStatus: string[] = [];
|
|
remoteThing.onStatus((status) => remoteStatus.push(status));
|
|
await remoteThing.ready;
|
|
up.close();
|
|
await until(() => remoteStatus.length === 2);
|
|
const [down2, up2] = memoryLinkPair();
|
|
server.accept(down2, { principal: { guest: "g" } });
|
|
browser.connect(up2, { principal: { kind: "server" } });
|
|
await until(() => remoteStatus.length === 3);
|
|
remoteThing.release();
|
|
await tick();
|
|
assert.deepEqual(remoteStatus, ["live", "loading", "live", "released"]);
|
|
});
|
|
|
|
it("#10 host close disposes holders before what they hold, and before closing links", async () => {
|
|
const log: string[] = [];
|
|
const Remote = remote("t.remote10", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
host: hostNamed("server"),
|
|
variants: { main: { methods: { note: method(Type.String()) } } },
|
|
});
|
|
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
|
server.serve(Remote, () => ({
|
|
variant: "main",
|
|
note: (text) => {
|
|
log.push(`server: ${text}`);
|
|
},
|
|
}));
|
|
const Proc = local("t.proc", {
|
|
version: 1,
|
|
input: Type.Object({}),
|
|
members: { tracker: opaque<Set<string>>("t.tracker@1") },
|
|
});
|
|
const Chat = local("t.chat10", { version: 1, input: Type.Object({}) });
|
|
const [down, up] = memoryLinkPair();
|
|
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "w" });
|
|
const worker = createHost({
|
|
self: { kind: "host" },
|
|
name: "w",
|
|
upstream: { link: up, principal: { kind: "server" } },
|
|
onError: (error) => log.push(`error: ${String(error)}`),
|
|
});
|
|
// Eager: activated (and registered with the host) before the object that holds it.
|
|
worker.serve(
|
|
Proc,
|
|
(_input, _principal, self) => {
|
|
const tracker = self.resource("tracker", {
|
|
version: 1,
|
|
open: () => new Set<string>(),
|
|
close: () => void log.push("proc closed"),
|
|
});
|
|
return { tracker: self.expose(tracker) };
|
|
},
|
|
{ lifetime: "host" },
|
|
);
|
|
await sleep(5);
|
|
worker.serve(Chat, (_input, _principal, self) => {
|
|
const proc = self.get(Proc, {});
|
|
const upstream = self.get(Remote, {});
|
|
self.resource("session", {
|
|
version: 1,
|
|
open: () => proc.tracker?.add("chat"),
|
|
close: async () => {
|
|
proc.tracker?.delete("chat");
|
|
log.push(`chat closed, proc ${proc.status}, tracker ${proc.tracker === undefined ? "gone" : "ok"}`);
|
|
await upstream.note("bye");
|
|
},
|
|
});
|
|
return {};
|
|
});
|
|
await worker.hold(Chat, {}).ready;
|
|
await worker.close();
|
|
assert.deepEqual(log, ["chat closed, proc live, tracker ok", "server: bye", "proc closed"]);
|
|
});
|
|
});
|