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
38 KiB
Build a shared todo list with Chord
1. What problem does Chord solve?
A todo list has data, operations on that data, and several people looking at it. You could hand-write object creation, subscription cleanup, messages between processes, and code replacement. Chord connects these pieces for you.
A host is the runtime that owns objects. An object combines state with methods at an address, such as “the family todo list.” A reference is a caller's reference to that object, not the object itself. Several references can reach the same object. References can also reach another host over a connection.
browser host server host
ref + subscribed rows -- link --> Todos view (owner or guest)
|
ref
v
TodoStore: rows + methods
We will start inside one host, add observable state, then share it with a browser. Finally we will add background work, plugins, and hot reload. Live state means subscribers receive new values when data changes, without asking for it again.
const host = createHost({ self: "server", name: "todo-server" });
assert.equal(host.name, "todo-server");
await host.close();
The assertion passes: the host's name is todo-server. Creating a host does
not create a todo object, start a server socket, or persist data. close()
shuts down its objects and connections.
Tip: These are executable TypeScript examples, using assertions to show results. Shared imports are at the end. Later sections reuse earlier contracts and functions, but each section's test starts with fresh hosts. Test-only cleanup closes hosts even when an assertion fails; snippets omit that cleanup.
2. Your first object
Before callers can use an object, they need to agree on its name and members.
That agreement is a contract: a runtime value that also gives TypeScript its
types. A schema describes a value's shape. We use TypeBox's Type to write
schemas. method(args, result) declares a method by its argument and result
schemas: method() takes no arguments and returns nothing, and
method(undefined, Type.String()), used below, takes no arguments and returns a
string.
Start with a label for a named list. local means only code inside this host
can reach it. The input { list: "family" } identifies which list we want;
it is not an argument to label.
const TodoLabel = local("tutorial.todos", {
version: 1,
input: Type.Object({ list: Type.String() }),
members: { label: method(undefined, Type.String()) },
});
A factory is a synchronous function that creates the object's implementation:
the methods and state callers will see. serve registers a contract and its
factory. Registration alone does not activate this lazy object; the first
get does.
A root is one short tree of calls, such as the work for a button click.
host.root starts it. Its context, called ctx, carries cancellation,
caller identity, and the code version for nested calls. Pass it to methods you
call from that root.
const host = createHost({ self: "server" });
host.serve(TodoLabel, ({ list }) => ({ label: () => `Todos for ${list}` }));
const result = await host.root(async (ctx) => {
const todos = await ctx.get(TodoLabel, { list: "family" });
return todos.label(ctx);
});
assert.equal(result, "Todos for family");
The result is "Todos for family". await ctx.get resolves once the object is
live (and, as we will see, has its state). The reference acquired through
ctx.get is released when this root ends. We never receive the factory's
returned object directly. A standalone call without ctx, such as
todos.label(), starts its own root. A method without arguments takes only
the context: todos.label(ctx); its implementation is (ctx) => …. Inside a root or method, pass the
existing ctx so nested calls stay in the same call tree.
The factory may also receive principal (who is asking) and self (the
object's declaration API). We did not need them yet.
Why: The reference separates callers from implementations. Later, replacing code will not require replacing every caller's reference.
3. Give the list live state
A label is not a todo list. We need rows that can change, and a count that always agrees with them. A cell owns an observable JSON value. A derivation computes another observable value from cells or other readable sources.
Extend the contract with rows, openCount, and editing methods. State
members describe values, not methods. This adds members at contract version 1;
older callers that only need label still have everything they need.
self.state(name, schema, initial) declares a cell and returns it. The name
rows is important: on reload, Chord matches that name to the existing cell and
keeps its value. self.derive(sources, fn) declares a derivation; it has no
name, because it holds nothing a reload must keep.
const Todo = Type.Object({ text: Type.String(), done: Type.Boolean() });
const Rows = Type.Array(Todo);
const TodoStore = local("tutorial.todos", {
version: 1,
input: Type.Object({ list: Type.String() }),
members: {
label: method(undefined, Type.String()),
rows: Rows,
openCount: Type.Number(),
add: method(Type.Object({ text: Type.String() })),
finish: method(Type.Object({ index: Type.Integer() })),
reset: method(Rows),
},
});
The factory now declares a cell and a derivation. It is wrapped in todoStore,
which takes the cell's options (section 9 raises its version); for now
todoFactory uses the defaults. Each method receives its arguments and a
context; changing the cell updates its readers and its derived count together.
add, finish and reset declare no result, so they return nothing.
/** The store's factory; `options` versions its rows (section 9). */
const todoStore =
(options?: StateOptions<Static<typeof Rows>>): Factory<typeof TodoStore> =>
({ list }, _principal, self) => {
const rows = self.state("rows", Rows, [], options);
const openCount = self.derive([rows], (todos) => todos.filter((todo) => !todo.done).length);
return {
label: () => `Todos for ${list}`,
rows,
openCount,
add: ({ text }, ctx) => {
rows.change(ctx, (draft) => {
draft.push({ text, done: false });
});
},
finish: ({ index }, ctx) => {
rows.change(ctx, (draft) => {
const todo = draft[index];
if (!todo) throw invalid("args", "No such todo");
todo.done = true;
});
},
reset: (next, ctx) => {
rows.replace(ctx, next, { diff: true });
},
};
};
const todoFactory = todoStore();
change(ctx, fn) gives fn a temporary mutable draft. Here add
pushes a row and finish changes one field. The draft is discarded if the
callback throws. The callback must be synchronous.
replace(ctx, value) replaces the whole value. We use it for resetting a
list we own completely. { diff: true } asks Chord to publish just the
structural changes. Without that option, replacement diffs automatically when
the new value shares containers at the same keys with the old one; unrelated
values normally publish a whole-value replacement. Either way, subscribers
receive the same resulting value.
const host = createHost({ self: "server", dev: true });
host.serve(TodoStore, todoFactory);
const todos = await host.get(TodoStore, { list: "family" });
const counts: (number | undefined)[] = [];
const stop = todos.openCount.subscribe((value) => counts.push(value));
await host.root(async (ctx) => {
await todos.add({ text: "Buy milk" }, ctx);
await todos.finish({ index: 0 }, ctx);
await todos.reset([], ctx);
});
assert.deepEqual(counts, [0, 1, 0]);
assert.deepEqual(todos.rows.value, []);
stop();
todos.release();
The count subscriber sees 0, 1, 0: initially empty, one unfinished todo,
then no unfinished todos. Resetting to empty does not add another count
notification because the derived count is still zero. The final rows are [].
subscribe calls back immediately with the current value and returns an
unsubscribe function. The awaited get makes sure there is a value: a reference
you hold is live, with its state.
Why: Store reloadable state in cells, not closure variables. Treat
cell.value,ref.rows.value, and subscriber values as immutable. Only mutate drafts insidechange; do not keep a draft for later use. A derivation runs synchronously, so use it for projections, not database work.
4. Let objects use objects and resources
Saving a todo may require both the list and a database connection. A
reference keeps the object it refers to alive. self.get declares a
reference owned by this object; it lasts longer than a single method call.
A resource is something the runtime opens and later closes, such as a
database connection. Its open may be asynchronous even though the factory
must be synchronous. The example database is deliberately fake: it just saves
strings in memory.
opaque<T>("name@version") describes a native, host-only member. It is not
JSON state and cannot be sent over a link. self.expose connects that member
to a declared resource, without reading the resource during the factory.
const Database = local("tutorial.database", {
version: 1,
input: Type.Object({}),
members: { db: opaque<{ saved: string[] }>("tutorial.database@1") },
});
const SavedTodos = local("tutorial.saved-todos", {
version: 1,
input: Type.Object({ list: Type.String() }),
members: { add: method(Type.Object({ text: Type.String() })) },
});
const host = createHost({ self: "server" });
let opens = 0;
const closed = Promise.withResolvers<void>();
host.serve(Database, (_input, _principal, self) => {
const db = self.resource("db", {
version: 1,
open: async () => {
opens++;
return { saved: [] as string[] };
},
close: () => closed.resolve(),
});
return { db: self.expose(db) };
});
The factory returns an exposed marker, not an opened connection. Chord runs
open after declarations finish. The promise named closed lets the test
observe eventual cleanup.
Now declare SavedTodos as an object that holds both the store and the database.
Its editing method uses these dependencies only after activation.
host.serve(TodoStore, todoFactory);
host.serve(
SavedTodos,
({ list }, _principal, self) => {
const database = self.get(Database, {});
const todos = self.get(TodoStore, { list });
return {
add: async ({ text }, ctx) => {
database.db!.saved.push(text);
await todos.add({ text }, ctx);
},
};
},
{ linger: 0 },
);
An outside consumer holds a SavedTodos reference. We also hold Database
directly so we can inspect what was saved.
const saved = await host.get(SavedTodos, { list: "family" });
const database = await host.get(Database, {});
await host.root((ctx) => saved.add({ text: "Buy milk" }, ctx));
assert.deepEqual(database.db!.saved, ["Buy milk"]);
assert.equal(opens, 1);
const statuses: string[] = [];
saved.onStatus((status) => statuses.push(status));
database.release();
saved.release();
await closed.promise;
assert.equal(saved.status, "released");
assert.ok(statuses.includes("released"));
After the method, the fake database contains ["Buy milk"]. It opened once,
even though two objects referenced it. SavedTodos and Database become live
only after their resources and declared dependencies are ready. That is why
database.db! is safe inside this live object's method: activation has
already established that dependency. An outside caller awaits its get.
When both external references are released, SavedTodos is disposed and
releases its own database reference. With no references left, the database closes; we
wait for closed.promise to prove it. The released reference stays
released, and onStatus reports that transition. onStatus watches
changes; unlike state subscription, it does not deliver an initial status.
linger is a delay in milliseconds after the last reference is released. Zero disposes
without a grace period; a positive value can avoid reopening an expensive
resource when a screen is briefly hidden. Linger is not persistence.
Tip: A factory only declares dependencies. Never call a referenced object's method or read
resource.valuethere, including during reload. Activation happens afterward. Bump a resource'sversionwhen its configuration should be reopened. Disposal stops tasks and listeners, closes resources, then releases references.
5. Who is asking? One store, two views
Owners may add todos; guests may only read them. A principal is the application-defined identity of a caller. A variant is a named set of members a remote object offers to that principal. It lets the UI discover whether an editing method is available.
Do not create a separate data cell for each viewer: their lists would diverge.
Instead, each view holds the same host-local TodoStore and exposes its rows.
self.get acts as the host's own principal, so owner and guest views share
that store. Remote view objects themselves are separate per principal key.
A remote contract also says where its objects live: host: hostNamed("server")
puts every Todos object on the host named server. Section 6 uses that.
const Todos = remote("tutorial.todo-view", {
version: 1,
input: Type.Object({ list: Type.String() }),
host: hostNamed("server"),
variants: {
viewer: { state: { rows: Rows } },
editor: {
state: { rows: Rows },
methods: { add: method(Type.Object({ text: Type.String() })) },
},
},
});
/** Who may ask: the owner, a guest, or the server's own code. */
type Principal = "owner" | "guest" | "server";
function serveViews(api: Pick<Host<Principal>, "serve">): void {
api.serve(Todos, ({ list }, principal, self) => {
if (list !== "family") throw forbidden("This list is not shared");
const store = self.get(TodoStore, { list });
if (principal === "guest") {
return { variant: "viewer", rows: store.rows };
}
return {
variant: "editor",
rows: store.rows,
add: ({ text }, ctx) => {
if (text.trim().length === 0) throw invalid("args", "A todo needs text");
return store.add({ text }, ctx);
},
};
});
}
Here principals are the strings "owner" and "guest", assigned by
trusted application code, and "server", the host's own code: every host is
created with self, the principal its own roots and objects act as. Chord
invents no principals. The Principal type parameter of createHost and
Host makes principal in the factory that type, so TypeScript checks the
comparisons. principalKey maps identity to object identity. Real
applications should use stable identity, connection provenance, and grant
revision, not display names.
const host = createHost<Principal>({ name: "server", self: "server", principalKey: (principal) => principal });
host.serve(TodoStore, todoFactory);
serveViews(host);
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
const guest = await host.get(Todos, { list: "family" }, { principal: "guest" });
assert.ok(owner.variant === "editor"); // narrows the reference to the editor variant
assert.equal(guest.variant, "viewer");
await host.root((ctx) => owner.add({ text: "Buy milk" }, ctx), { principal: "owner" });
assert.deepEqual(guest.rows.value, [{ text: "Buy milk", done: false }]);
// @ts-expect-error A viewer has no add: TypeScript refuses it, and so does the provider.
await assert.rejects(guest.add({ text: "Not allowed" }), (error) => isChordError(error, "not_in_variant"));
await assert.rejects(owner.add({ text: " " }), (error) => isChordError(error, "invalid"));
The owner gets editor; the guest gets viewer. Both see the same milk
todo. A reference is typed as the union of its variants: add is reachable only
after narrowing on variant (assert.ok(owner.variant === "editor") does that).
The guest's add is refused by TypeScript, and, called anyway, rejects with
not_in_variant. Whitespace-only text
rejects with invalid: it is a string, as the schema demands, but it breaks a
rule only the method knows. Use invalid for bad input and forbidden only
for callers who may not do something.
Why: A variant is not all of authorization. The factory checks which list may be opened; the method checks the operation's arguments. Production code must also enforce grants that can change. Use
host.invalidatePrincipal(match)to revoke matching objects; held references becomegone. A UI watchesonStatusand gets a new reference when appropriate. Guest-provided names or message text must never establish identity.
6. Reach the list from another process
A browser cannot call a host-only contract. remote contracts describe only
schema-checked state and methods that can travel as JSON. Our Todos view is
remote; the underlying TodoStore and database stay local. A caller never
names a host: browser.get(Todos, …) goes to server because the contract
says so, and the server refuses to serve, or to be asked for, a type it is not
the place of. A type that lives on one host per session says
host: hostFamily("session:", "session"): its input's session field picks
the host ({ session: "S1" } lives on session:S1).
A link carries ordered messages between hosts. A memory link runs both ends
in one test process, but uses the remote protocol. The accepting host stamps
the caller's principal from its authenticator, not from method arguments or a
client's claimed identity. The connecting side names the principal its parent's
own requests act as (upstream: { link, principal }, or connect(link, { principal })).
const server = createHost<Principal>({ self: "server", name: "server" });
server.serve(TodoStore, todoFactory);
serveViews(server);
const [down, up] = memoryLinkPair();
server.accept(down, { principal: "owner", kind: "client" });
const browser = createHost({
self: "browser",
name: "browser",
kind: "client",
upstream: { link: up, principal: "server" },
});
const todos = await browser.get(Todos, { list: "family" });
assert.ok(todos.variant === "editor");
assert.deepEqual(todos.rows.value, []);
const updated = Promise.withResolvers<void>();
const stop = todos.rows.subscribe((rows) => {
if (rows.length === 1) updated.resolve();
});
await browser.root((ctx) => todos.add({ text: "Buy milk" }, ctx));
await updated.promise;
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
// Deliberately bypass TypeScript to simulate a malformed caller.
await assert.rejects(todos.add({ text: 123 } as never), (error) => isChordError(error, "invalid"));
stop();
// A held reference keeps its state without listeners.
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
todos.release();
The browser sees an empty snapshot, then the new milk todo. ready means
the remote reference has bound to a live object and has its state: every
state member of its variant has a snapshot. A held reference stays subscribed,
with or without listeners, until it is released.
What crosses the wire: the input and consumer's contract description, the
provider's shape (variant and members), method arguments and results, and the
state of the reference's variant. State starts with a snapshot, the whole current value;
subsequent deltas describe changes. Rebinds and reloads can send a
reset, a fresh whole value. Functions, resource connections, and cell
instances do not cross the wire.
The deliberate bad argument rejects with invalid. Remote arguments,
results, and outgoing state are validated at the provider boundary. Local
methods do not get per-call schema validation; they still need app-level checks.
dev: true additionally checks cell writes while developing.
Tip: Replace
memoryLinkPairwithwebSocketLinkfor a WebSocket oripcLinkfor a child-process IPC channel; object contracts stay the same. Those adapters wrap an existing connection, not an authenticator. Configureacceptwith an authenticated principal. A browser iskind: "client"; a trusted child host is accepted withkind: "host"and its configured name.
7. React to changes and run background work
Not all work comes from method calls. The app may update an activity indicator when rows change, or run a background reminder. A listener reacts to an observable source. A task is background execution owned by an object, which is aborted when the object is disposed or its code is reloaded.
Our activity object mirrors the row count. Its task inserts a sample todo, then waits for shutdown. The promises make those events deterministic in a test; they are not stored application state.
const Activity = local("tutorial.activity", {
version: 1,
input: Type.Object({}),
members: { seen: Type.Number() },
});
const host = createHost({ self: "server" });
host.serve(TodoStore, todoFactory);
const observed = Promise.withResolvers<void>();
const seeded = Promise.withResolvers<void>();
const stopped = Promise.withResolvers<void>();
host.serve(Activity, (_input, _principal, self) => {
const todos = self.get(TodoStore, { list: "family" });
const seen = self.state("seen", Type.Number(), 0);
self.on("rows", todos.rows, (rows, ctx) => {
seen.replace(ctx, rows?.length ?? 0);
if (rows?.length === 1) observed.resolve();
});
self.task(async (ctx) => {
await todos.add({ text: "Review the list" }, ctx);
seeded.resolve();
await new Promise<void>((resolve) => {
if (ctx.signal.aborted) resolve();
else ctx.signal.addEventListener("abort", () => resolve(), { once: true });
});
stopped.resolve();
});
return { seen };
});
const activity = await host.get(Activity, {});
await Promise.all([seeded.promise, observed.promise]);
assert.equal(activity.seen.value, 1);
activity.release();
await stopped.promise;
The final seen is 1. Each listener event receives a fresh root's
ctx; its write belongs to that short call tree. A task is not one enormous
root: Chord method calls made with its task context start fresh short roots.
We pass that context into todos.add so cancellation follows the task.
Releasing the activity reference aborts its task and resolves stopped.
self.on(name, source, handler) declares a listener; self.task(fn) a task.
A listener on state, like rows here, gets the current value when it starts,
then every change. It gets the current value again whenever a reload replaced
its source (a derivation is new at every reload), so its handler must
reconcile (seen is set from the rows, not counted up). Listeners can also
subscribe to a resource's event stream with self.on(name, resource, select, handler): select gets the opened resource and returns something with
subscribe; an event stream delivers no current value. Kept subscriptions
survive reload while the resource and selector remain unchanged; the handler
switches to new code. A selector that captures changing configuration should
get a new listener name. Tasks have no names: a reload restarts them all.
Why: Do not wait for a person, sleep for minutes, or run an endless loop inside a method. That pins a root and can block state migration. Store a pending request in a cell and answer in another call, or use a task that obeys
ctx.signal. Never retain a completed method'sctxfor background work.
8. Package features as plugins
Now a plugin can contribute a menu action without editing a central switch. A registry is a named table of contributions. A facet is a plugin's code for one kind of host: it serves object types. A plugin can have server, worker, and browser facets, each installing that part of the feature.
Registrations belong to objects of lifetime "host": the host creates such
an object when its facet loads and keeps it until the host closes, even if no
screen asks for it. That keeps our menu contribution present. Objects of the
default lifetime, "referenced", are still created only when referenced.
const Actions = defineRegistry<(ctx: Ctx) => Promise<number>>("tutorial.actions");
const Menu = local("tutorial.menu", { version: 1, input: Type.Object({}) });
let click: (() => Promise<number>) | undefined;
const storeFacet: Facet = {
name: "todo-store",
...defineFacet((api) => api.serve(TodoStore, todoFactory)),
};
const menuFacet: Facet = {
name: "todo-menu",
...defineFacet(
(api) => {
api.serve(
Menu,
(_input, _principal, self) => {
const todos = self.get(TodoStore, { list: "family" });
self.register(Actions, {
addSample: async (ctx) => {
await todos.add({ text: "Try plugins" }, ctx);
return todos.openCount.value;
},
});
return {};
},
{ lifetime: "host" },
);
click = () => api.root((ctx) => api.registry(Actions).get("addSample", ctx)!(ctx));
},
{ uses: [TodoStore] },
),
};
const host = createHost({ self: "server" });
await host.load([menuFacet, storeFacet], { awaitHostObjects: false });
await host.ready();
assert.equal(await click!(), 1);
assert.equal(host.registry(Actions).table().size, 1);
Clicking returns 1, and the registry has one entry. The menu's dependency
can come from a later facet in the load batch: load installs the whole
batch before waiting for host objects to activate. The facet declares uses so
reload can also check the contract it consumes.
The facet API supplies serve, name, root, and registry.
Our ordinary UI callback uses its saved api.root, not a process-global
host. Looking up a registry entry with ctx uses the table belonging to that
root's code version. Without ctx, lookup uses the current table. Duplicate
keys fail by default; subscribe can bridge tables to an imperative consumer.
Normally use await host.load(facets) (or await host.install(facet)),
which waits for those host objects. This example explicitly defers that barrier
with awaitHostObjects: false, then awaits host.ready(). That waits for a
snapshot of the host's objects, not for task completion or later loads.
Failures reject the readiness promise; abandoning startup requires closing the
host, because installation is not a rollback transaction.
Tip: Use deferred readiness only when needed. A worker may have to await an independent local bootstrap reference, announce control readiness so its parent admits its link, and only then await
host.ready(). Waiting for upstream-dependent host objects before admission would deadlock.
For real plugin files, default-export defineFacet(...). Node hosts configure
createHost({ loader: nodeLoader }), with nodeLoader imported from
@earendil-works/chord/node, then pass module URLs to load and reload.
The in-memory facet objects here avoid filesystem setup. Built-in facets are
trusted in-process code, not a sandbox; the current Node loader keeps old module
copies in memory, so repeated loading has that limitation.
9. Change running code without losing the list
A reference is useful precisely because callers keep it while implementations change. An code version identifies one committed set of code and registry tables. A root keeps the code version it started with. New roots use the latest one.
Compatible code change: no waiting
Give the same named facet a new factory. It changes the label but keeps the cell declaration. This test deliberately pauses one caller to show what an old root sees; application methods should not pause like this.
const host = createHost({ self: "server" });
const facet = (label: string): Facet => ({
name: "todos",
setup: (api) =>
api.serve(TodoStore, (input, principal, self) => ({
...todoFactory(input, principal, self),
label: () => label,
})),
});
await host.load([facet("Original")]);
const todos = await host.get(TodoStore, { list: "family" });
await todos.add({ text: "Buy milk" });
const entered = Promise.withResolvers<void>();
const resume = Promise.withResolvers<void>();
const oldRoot = host.root(async (ctx) => {
const first = await todos.label(ctx);
entered.resolve();
await resume.promise; // Test-only pause: pin an old caller during reload.
return [first, await todos.label(ctx)];
});
await entered.promise;
const compatible = await host.reload([facet("Updated")]);
assert.deepEqual(compatible.gated, []);
assert.equal(await todos.label(), "Updated");
resume.resolve();
assert.deepEqual(await oldRoot, ["Original", "Original"]);
assert.equal(todos.rows.value?.[0]?.text, "Buy milk");
Reload commits while the old root is paused: gated is empty. New calls see
"Updated"; the resumed old root sees "Original" twice. The milk row
survives. Compatible code changes do not wait for that old root to finish.
State change: bump its version and migrate
Suppose a new policy converts existing todo text to uppercase. The schema is
unchanged, but the data's meaning changes. We still bump the cell's state
version (self.state's version option, default 1). It belongs to one cell,
independently of the contract version. A migration (migrate) converts an
old value of that cell to the new representation.
const events: string[] = [];
const stopEvents = todos.rows.events((event) => events.push(event.type));
const migrated = await host.reload([
{
name: "todos",
setup: (api) =>
api.serve(
TodoStore,
todoStore({
version: 2,
migrate: (old, from) => {
assert.equal(from, 1);
return (old as Static<typeof Rows>).map((todo) => ({ ...todo, text: todo.text.toUpperCase() }));
},
}),
),
},
]);
assert.equal(migrated.gated.length, 1);
assert.deepEqual(todos.rows.value, [{ text: "BUY MILK", done: false }]);
assert.deepEqual(events, ["snap", "reset"]);
stopEvents();
The migration receives the old rows and their state version, 1. It returns
new rows; the reference now reads [{ text: "BUY MILK", done: false }].
Subscribers get a reset event containing the migrated value. The event
assertion sees an initial snap, then that reset.
One object is gated. A gate waits for calls already inside that object to
finish before migrating a private copy, validating it, and committing. Old
roots that had not entered it cannot later use old code over migrated data:
they get upgrade_conflict and should retry in a new root.
Changing a cell's schema also requires a version bump, and a migration unless the old value already fits the new schema. A change of meaning with the same schema, like this example, cannot be detected automatically. Real migrations must reference each stored version they support, not assume every installation is exactly one version behind.
Contract change: keep an adapter for old callers
Rename label to heading. That is a breaking contract change, so use
contract version 2. The provider's accepts[1] adapter builds the implementation
that version-1 consumers still expect.
// Remove label from the new provider's contract: it is now called heading.
const { label: _label, ...members } = TodoStore.def.members;
const RenamedStore = local("tutorial.todos", {
version: 2,
input: TodoStore.def.input,
members: { ...members, heading: method(undefined, Type.String()) },
});
await host.reload([
{
name: "todos",
setup: (api) =>
api.serve(
RenamedStore,
(input, principal, self) => {
const { label, ...implementation } = todoStore({ version: 2 })(input, principal, self);
return { ...implementation, heading: label };
},
{
accepts: {
1: (current) => ({ ...current, label: current.heading }),
},
},
),
},
]);
assert.equal(await todos.label(), "Todos for family");
const newer = await host.get(RenamedStore, { list: "family" });
assert.equal(await newer.heading(), "Todos for family");
assert.equal(todos.rows.value?.[0]?.text, "BUY MILK");
The old reference's label and the new reference's heading both return
"Todos for family". Both still reach the migrated rows. The adapter maps
implementations, not references; forwarding the existing method preserves its context.
We keep the cell at version 2 here so this code-only change does not repeat
migration.
Why: Contract versions protect callers; state versions protect stored data; resource versions control reopening. Do not use one number for all three. Additive compatible contracts can keep their version; breaking shape or meaning changes need a contract bump and adapters for old consumers. Reload prepares candidates first; preparation failure leaves the old code running. A migration gate that misses its deadline aborts rather than switching data underneath active old code.
10. Test the objects, not a whole deployment
Most implementation needs only a bare host. Use real factories for state tests, and a fake facet when you want to test delegation without a real database or service. Here the fake records what the editing view sends to the store.
const host = createHost<Principal>({ name: "server", self: "server", dev: true });
const received: string[] = [];
const fake: Facet = {
name: "fake-store",
...defineFacet((api: FacetApi) => {
api.serve(TodoStore, (input, principal, self) => ({
...todoFactory(input, principal, self),
add: ({ text }) => {
received.push(text);
},
}));
}),
};
await host.load([fake]);
serveViews(host);
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
assert.ok(owner.variant === "editor");
await owner.add({ text: "Test delegation" });
assert.deepEqual(received, ["Test delegation"]);
await assert.rejects(owner.add({ text: "" }), (error) => isChordError(error, "invalid"));
owner.release();
await assert.rejects(owner.add({ text: "Too late" }), (error) => isChordError(error, "released"));
The fake received ["Test delegation"]. Empty text failed before reaching
the fake. The released reference rejects further calls with released.
isChordError(error, code) checks a stable error code; avoid testing only a
message's wording.
Our other tests exercise the real cells (section 3), resource cleanup (4),
principal variants (5), a client over a memory link (6), and callers kept through
reload (9). After an awaited get the state is there; for later changes, await
the value you need. Always unsubscribe and close hosts; leaked references can hide
lifetime bugs or leave background tasks running.
Tip: A fake must honor the contract it serves. This fake delegates all ordinary members to
todoFactoryand replaces onlyadd; it deliberately objects calls rather than updating rows. Do not confuse that with persistence.
11. Keep these habits
A temporary call reference should not accidentally become a permanent one. A
screen should not lose its data merely because a button click finishes. Use
ctx.get for the first lifetime and host.get for the second.
const host = createHost({ self: "server" });
host.serve(TodoStore, todoFactory);
const screen = await host.get(TodoStore, { list: "family" });
await host.root(async (ctx) => {
const temporary = await ctx.get(TodoStore, { list: "family" });
await temporary.add({ text: "Keep roots short" }, ctx);
}); // The temporary reference is released here; screen still holds the store.
assert.equal(screen.openCount.value, 1);
screen.release(); // No reference remains; without persistence, disposal loses these rows.
The screen still sees count 1 after the root finishes, because it owns a
separate reference. When the screen releases it, no reference remains. This tutorial's cells
are in memory: reload preserves a live object, but disposal or process restart
loses its rows. Real persistence belongs in a database resource, with an
explicit load/save policy.
Checklist and common mistakes
- Keep reloadable state in cells, not closure variables: factories rerun.
- Change drafts, not published values: mutation outside cells bypasses updates.
- Use
changefor field edits andreplacefor owned snapshots: replacing can erase fields your code does not know about. - Declare dependencies in a synchronous factory, not during methods: activation and reload need a complete declaration list.
- Use
ctx.getfor temporary method dependencies: their references end with the call. - Pass
ctxinto nested calls: cancellation and code versions must stay together. - Keep roots short and tasks cancellable: migration and shutdown need work to settle.
- Check grants and arguments, not just variants: available methods are not a full policy.
- Stamp principals at trusted ingress, not from client payloads: names are not identities.
- Await
getbefore reading state: a reference you hold is live and hydrated, a loading one (host.hold) throwsnot_ready. - Release references and unsubscribe: otherwise unused objects remain referenced or observed.
- Bump state versions, contract versions, and resource versions for their own changes: each protects a different boundary.
- Await host object readiness before reporting startup success: declared is not yet live.
- Keep reference dependencies acyclic: an object cannot wait for itself through another object.
Imports and running the examples
These are the shared imports from test/tutorial.test.ts. The relative
path is correct from that test directory; applications import the same names
from @earendil-works/chord. assert expresses expected values; it and
onTestFinished come from the test runner; readFileSync is used by the
tutorial's source-matching check, not by the application.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { type Static, Type } from "typebox";
import { it, onTestFinished } from "vitest";
import {
type Ctx,
createHost,
defineFacet,
defineRegistry,
type Facet,
type FacetApi,
type Factory,
forbidden,
type Host,
hostNamed,
invalid,
isChordError,
local,
memoryLinkPair,
method,
opaque,
remote,
type StateOptions,
} from "../src/index.ts";
From packages/chord, run:
node ../../node_modules/vitest/dist/cli.js --run test/tutorial.test.ts
There is one test per numbered section. Every TypeScript block is matched against its runnable source, so changing either copy without the other fails a test. The test harness adds teardown and drift checks; it never uses a real database, network service, or provider API.
Next read the usage guide for the API reference and
the specification for compatibility, lifetime, and reload
rules, including its known limitations. If a reference disagrees with
src, the current implementation wins.