pi-mono/packages/chord/test/examples/30-host-root.ts
Mario Zechner 5bd014b1c7 feat(chord): mistakes that wait are reported at once
- get, hold and registry on a host that has not loaded throw not_ready at once.
- A registry with contributions and no provider is reported (registry_undeclared) at publication.
- A request waiting unbound is reported once after 5 s (unbound_long; one clock per host), and keeps waiting.
- An invalid plugin name makes load and reload throw before any change.
- A transaction cancelled by the end of its context is reported (transaction_cancelled) besides rejecting.
- Docs: who a reference acts as, no per-link call limit, await what you start in a root or a handler.
2026-11-03 07:19:52 +00:00

106 lines
4.8 KiB
TypeScript

// `host.root(fn, context)`: a call scope for code outside objects (a Durable tool, a CLI command). `fn` gets a ctx like a
// method handler's: the host's principal (or one a trusted caller names), this host's incarnation, the context's
// telemetry and signal, and `ctx.get` for references. When `fn` settles, or the context aborts, or the host closes, the
// scope is sealed: its signal aborts, its references are released, and a late `ctx.get` is refused `released`; so code
// that ignores its signal fails at its next use of Chord. The rule: await what you start in a root or a handler; a
// change left running when the scope seals is cancelled, rejects and is reported (`transaction_cancelled`). (p3 wraps
// it as `chord(fn, context)` for Durable's tools.)
// Run from packages/chord:
// node --conditions=source --experimental-strip-types test/examples/30-host-root.ts
import { BACKGROUND_CONTEXT, withCancel } from "../../src/context/index.ts";
import { arr, local, method, obj, str } from "../../src/contracts/index.ts";
import { defineState } from "../../src/documents/index.ts";
import { type Ctx, createHost, defineFacet, sleep } from "../../src/host/index.ts";
const context = BACKGROUND_CONTEXT;
type Principal = { readonly kind: "server" } | { readonly kind: "owner"; readonly name: string };
const Notes = local("example.notes", {
version: 1,
params: { session: str() },
members: { list: arr(str()), add: method(obj({ text: str() })) },
});
const List = defineState("example.list", {
schema: obj({ list: arr(str()) }),
version: 1,
initial: () => ({ list: [] }),
});
const facet = defineFacet<Principal>((host) => {
host.serve(Notes, (_, self) => {
const notes = self.state(List);
return {
list: self.derive([notes], ([n]) => n.list),
add: async ({ text }, ctx) => {
await notes.change((draft) => {
draft.list.push(`${text} (by ${ctx.principal?.kind})`);
}, ctx);
return null;
},
};
});
});
const host = createHost<Principal>({
principal: { kind: "server" },
onError: (error) =>
console.log(
"onError:",
error.kind,
error.kind === "transaction_cancelled" ? `${error.object}: ${error.error}` : "",
),
});
// 1. A scope made before `load`: its `ctx.get` throws at once (`not_ready`, "call host.load first").
const early = host.root(async (ctx) => (await ctx.get(Notes, { session: "s1" })).list.value, context);
await early.catch((error: Error) => console.log("before load:", error.message));
await host.load([{ name: "@example/notes", facet }], context);
// 2. A tool's call: get, call, return plain data. The reference is released when fn settles.
const addNote = (text: string) =>
host.root(async (ctx) => {
const notes = await ctx.get(Notes, { session: "s1" });
await notes.add({ text }, ctx);
return notes.list.value;
}, context);
console.log("after a tool call:", await addNote("first"));
// 3. Base code acting for an authenticated user names the principal.
const owner: Principal = { kind: "owner", name: "ada" };
console.log("as the owner:", await host.root(async (ctx) => ctx.principal, context, { principal: owner }));
// 4. A kept ctx is sealed once fn settled.
let kept: Ctx<Principal> | undefined;
await host.root(async (ctx) => {
kept = ctx;
}, context);
console.log("kept ctx aborted:", kept!.abortSignal?.aborted);
await kept!.get(Notes, { session: "s1" }).catch((error: Error) => console.log("kept ctx.get:", error.message));
// 5. Cancelling the context rejects at once and seals the scope; an fn that ignores its signal fails at its next
// use of Chord.
const { context: cancellable, cancel } = withCancel(context);
const stubborn = host.root(async (ctx) => {
await new Promise((resolve) => setTimeout(resolve, 20));
await ctx.get(Notes, { session: "s1" });
}, cancellable);
cancel(new Error("user pressed stop"));
await stubborn.catch((error: Error) => console.log("cancelled:", error.message));
// 6. The rule: await what you start in a root or a handler. A call left running when fn returns is cancelled with the
// scope, and the change it was about to make never commits: it rejects, and onError gets `transaction_cancelled`
// naming the object and the scope that ended (an empty `catch` would hide the rejection, not the report).
let unawaited: Promise<unknown> | undefined;
await host.root(async (ctx) => {
const notes = await ctx.get(Notes, { session: "s1" });
unawaited = notes.add({ text: "lost" }, ctx);
}, context);
await unawaited!.catch((error: Error) => console.log("the unawaited call:", error.message));
await new Promise((resolve) => setTimeout(resolve, 0));
// 7. Closing the host seals the scopes still open and rejects their callers at once: an fn sleeping on its ctx ends.
const sleeping = host.root((ctx) => sleep(60_000, ctx), context).catch((error: Error) => `closed: ${error.message}`);
await host.close(context);
console.log(await sleeping);