diff --git a/packages/chord/docs/next/guide-plugins-hosts/book.md b/packages/chord/docs/next/guide-plugins-hosts/book.md index ab04e2119..d15c8a892 100644 --- a/packages/chord/docs/next/guide-plugins-hosts/book.md +++ b/packages/chord/docs/next/guide-plugins-hosts/book.md @@ -74,7 +74,9 @@ A **host** is one participant of the app, made with `createHost`. A **host kind* addressable hosts; an **address** (`Session({ id: "p1" })`) names one. An **object type** is a contract: `local` (used on its own host only) or `remote` (used from any host). An **object** is one instance of a type, per params. A **facet** is a plugin's declarations for one host kind. A **plugin** is a directory with a `package.json`. A -**reference** is what you get when you ask for an object (`host.get`, `self.get`, `ctx.get`). A **registry** is an +**reference** is what you get when you ask for an object (`host.get`, `self.get`, `ctx.get`). A **document** is data an object +stores, changed in **transactions**; a **derive** is a value computed from others; a **native** is an outside +resource an object opens and closes. A **registry** is an extension point: one plugin defines it, others contribute. A **rollout** applies a build to the hosts in order. # One Host {#host lede="The smallest app: one process, one object type, one call."} @@ -118,9 +120,9 @@ console.log(notes.list.value); // { items: ["buy milk"] ``` Four things happened. The **contract** `Notes` says what the object offers; the **document** says what it stores. -Here they have the same shape, so one schema serves both; they are separate because they change separately (a -derive or a guest's redacted view offers something other than storage, and storage can migrate without users -noticing). +Here they have the same shape, so one schema serves both; they are separate because they change separately (an +object may offer a computed value or a shortened view instead of what it stores, and storage can change its shape +without users noticing). The **facet** says this host serves `Notes`, with a **factory** that declares the object's parts: a document, and a method writing it in a transaction. `load` publishes the facet. `get` returns a **reference** once the object is live; its members are the contract's. @@ -128,6 +130,57 @@ The **facet** says this host serves The factory only declares: it is synchronous and opens nothing. Chord activates the object (loads its documents, opens what it asked to open) and then serves calls. That is why the same factory can run again when the code changes. +# An Object's Parts {#parts lede="Documents, transactions, computed values, watching, and outside resources: the vocabulary of every factory."} + +A factory builds an object from a handful of parts, each declared with one call on `self`. The notes object grows to +show all of them. + +**Documents** hold the object's data: `self.state(NotesDoc)` gives a handle whose `.value` is the current data. Data +changes only in a **transaction**: `self.tx(fn, ctx)` runs `fn` with `tx`, and `await tx.draft(handle)` gives a +mutable copy of the document's data; whatever you change on it is committed at once when `fn` returns, or not at all +if it throws. + +```ts +add: async ({ text }, ctx) => self.tx(async (tx) => { + const draft = await tx.draft(doc); // the document's data, writable inside this transaction only + draft.items.push(text); // recorded as a change, committed when the function returns +}, ctx), +``` + +A **derive** is a value computed from other values, recomputed whenever they change. It is how an object offers +something it does not store: + +```ts +const count = self.derive([doc], ([notes]) => notes.items.length); // 0, 1, 2, … as notes are added +return { list: doc, count, add }; // `count` can be a member like `list` +``` + +**Watching** is how anyone follows a value as it changes: a document handle, a derive, a reference's member. `watch` +gives the current value, then one call per change: + +```ts +const watch = await notes.count.watch(ctx); +watch.start(async (call) => { + if (call.kind === "value") console.log("notes:", call.value); // "failed" calls say a value cannot be computed now +}); +``` + +A **routine** is the object watching on its own behalf: `self.on(source, fn)` runs `fn` with each new value, for as +long as the object lives (a reconciler: "whenever the notes change, rebuild the export file"). + +A **native** is something outside Chord the object opens and must close: a file watcher, a database connection, a +child process. `self.open(open, close)` opens it while the object activates and closes it when the object ends: + +```ts +const inbox = self.open(async (ctx) => watchDirectory(inboxPath, ctx.abortSignal), (watcher) => watcher.close()); +self.onLive(async (ctx) => { // a routine started once the object is live + for await (const file of inbox.value.files(ctx.abortSignal)) await importNote(file, ctx); +}); +``` + +Natives are why some objects restart when their plugin changes: an open connection cannot be moved to new code, so +Chord closes it and opens it again. Objects without natives are swapped in place. + # Several Hosts {#hosts lede="A server, a worker per project, browser tabs: how they find each other's objects."} Now give each project its own process. Declare the kinds of addressable host, and make notes a `remote` type that @@ -145,7 +198,11 @@ export const Notes = remote("notes", { }); ``` -`hostAddress` is the whole routing story. A tab asks for `Notes { project: "p1" }`. Its host computes the address, +Two things are new. A `remote` type can be used from any host, and it has **variants**: different sets of members +for different callers, chosen by the factory (an `editor` variant with `add`, a `reader` variant for guests without +it). And `hostAddress` says where its objects live; it is the whole routing story. + +A tab asks for `Notes { project: "p1" }`. Its host computes the address, `Project(p1)`, and sends the request to its parent, the server. The server has a **route** for `Project`: it starts (or finds) the worker for `p1` and passes the request on. The worker serves `Notes`. The tab gets a reference whose state follows the worker's document, one frame per change. diff --git a/packages/chord/docs/next/guide-plugins-hosts/index.html b/packages/chord/docs/next/guide-plugins-hosts/index.html index 677e54ab6..55719365f 100644 --- a/packages/chord/docs/next/guide-plugins-hosts/index.html +++ b/packages/chord/docs/next/guide-plugins-hosts/index.html @@ -122,12 +122,13 @@ writes, a note says so.
@@ -166,7 +167,9 @@ place; a broken edit keeps the old code running and reports why. addressable hosts; an address (Session({ id: "p1" })) names one. An object type is a contract: local (used
on its own host only) or remote (used from any host). An object is one instance of a type, per params. A
facet is a plugin's declarations for one host kind. A plugin is a directory with a package.json. A
-reference is what you get when you ask for an object (host.get, self.get, ctx.get). A registry is an
+reference is what you get when you ask for an object (host.get, self.get, ctx.get). A document is data an object
+stores, changed in transactions; a derive is a value computed from others; a native is an outside
+resource an object opens and closes. A registry is an
extension point: one plugin defines it, others contribute. A rollout applies a build to the hosts in order.
@@ -206,9 +209,9 @@ extension point: one plugin defines it, others contribute. A rolloutawait notes.add({ text: "buy milk" }, ctx);
console.log(notes.list.value); // { items: ["buy milk"] }
Four things happened. The contract Notes says what the object offers; the document says what it stores.
-Here they have the same shape, so one schema serves both; they are separate because they change separately (a
-derive or a guest's redacted view offers something other than storage, and storage can migrate without users
-noticing).
+Here they have the same shape, so one schema serves both; they are separate because they change separately (an
+object may offer a computed value or a shortened view instead of what it stores, and storage can change its shape
+without users noticing).
The facet says this host serves
Notes, with a factory that declares the object's parts: a document, and a method writing it in a transaction.
load publishes the facet. get returns a reference once the object is live; its members are the contract's.
A server, a worker per project, browser tabs: how they find each other's objects.
Documents, transactions, computed values, watching, and outside resources: the vocabulary of every factory.
A factory builds an object from a handful of parts, each declared with one call on self. The notes object grows to
+show all of them.
Documents hold the object's data: self.state(NotesDoc) gives a handle whose .value is the current data. Data
+changes only in a transaction: self.tx(fn, ctx) runs fn with tx, and await tx.draft(handle) gives a
+mutable copy of the document's data; whatever you change on it is committed at once when fn returns, or not at all
+if it throws.
add: async ({ text }, ctx) => self.tx(async (tx) => {
+ const draft = await tx.draft(doc); // the document's data, writable inside this transaction only
+ draft.items.push(text); // recorded as a change, committed when the function returns
+}, ctx),
+A derive is a value computed from other values, recomputed whenever they change. It is how an object offers +something it does not store:
+const count = self.derive([doc], ([notes]) => notes.items.length); // 0, 1, 2, … as notes are added
+return { list: doc, count, add }; // `count` can be a member like `list`
+Watching is how anyone follows a value as it changes: a document handle, a derive, a reference's member. watch
+gives the current value, then one call per change:
const watch = await notes.count.watch(ctx);
+watch.start(async (call) => {
+ if (call.kind === "value") console.log("notes:", call.value); // "failed" calls say a value cannot be computed now
+});
+A routine is the object watching on its own behalf: self.on(source, fn) runs fn with each new value, for as
+long as the object lives (a reconciler: "whenever the notes change, rebuild the export file").
A native is something outside Chord the object opens and must close: a file watcher, a database connection, a
+child process. self.open(open, close) opens it while the object activates and closes it when the object ends:
const inbox = self.open(async (ctx) => watchDirectory(inboxPath, ctx.abortSignal), (watcher) => watcher.close());
+self.onLive(async (ctx) => { // a routine started once the object is live
+ for await (const file of inbox.value.files(ctx.abortSignal)) await importNote(file, ctx);
+});
+Natives are why some objects restart when their plugin changes: an open connection cannot be moved to new code, so +Chord closes it and opens it again. Objects without natives are swapped in place.
+A server, a worker per project, browser tabs: how they find each other's objects.
Now give each project its own process. Declare the kinds of addressable host, and make notes a remote type that
lives on its project's worker:
export const Server = hostKind("server");
@@ -228,7 +264,10 @@ lives on its project's worker:
hostAddress: ({ project }) => Project({ id: project }), // where an object of these params lives
variants: { editor: { state: { list: NoteList }, methods: { add: method(obj({ text: str() })) } } },
});
-hostAddress is the whole routing story. A tab asks for Notes { project: "p1" }. Its host computes the address,
+
Two things are new. A remote type can be used from any host, and it has variants: different sets of members
+for different callers, chosen by the factory (an editor variant with add, a reader variant for guests without
+it). And hostAddress says where its objects live; it is the whole routing story.
A tab asks for Notes { project: "p1" }. Its host computes the address,
Project(p1), and sends the request to its parent, the server. The server has a route for Project: it starts (or
finds) the worker for p1 and passes the request on. The worker serves Notes. The tab gets a reference whose state
follows the worker's document, one frame per change.
startApp(...) and startWorker().<
Browser tabs and terminal UIs are hosts too: what they can do, and what they cannot.
Browser tabs and terminal UIs are hosts too: what they can do, and what they cannot.
A browser tab and a terminal UI are hosts like the server, with one difference: they have no address. Nobody can send a request to a tab; a tab only connects to the server and asks for things. That is a deliberate rule: a tab runs on a device the server does not control, so nothing on the server ever waits for, or trusts, a tab's state.
@@ -285,7 +324,7 @@ type, be routed to, or keep data others rely on. Data that must outlive a tab be worker.Move the code into a directory that can be added, edited and removed while the app runs.
Move the code into a directory that can be added, edited and removed while the app runs.
So far the facets were part of the app's own code. A plugin is the same code in its own directory:
plugins/notes/
package.json { "name": "@app/notes" }
@@ -311,7 +350,7 @@ out; every host passes its own copy in). It holds what plugins share and never r
type, a defineFacet bound to it.
Discover, build, evaluate, load: what happens between a directory and a running object.
Discover, build, evaluate, load: what happens between a directory and a running object.
The plugins component does the work. You tell it where plugins are and what each host kind needs:
const plugins = createPlugins({
roots: ["./plugins"],
@@ -342,7 +381,7 @@ fails is left out and listed in report.failed; the others load. Its
A worker never builds: the server builds the worker facets too, and each worker evaluates the scripts it is sent.
Two ways one plugin uses another, and the one it may not.
Two ways one plugin uses another, and the one it may not.
A search plugin wants to search the notes. It imports the contract and asks for the object:
// plugins/search/worker.ts
import { Notes } from "../notes/contract.ts"; // copied into search's script: contracts only
@@ -362,7 +401,7 @@ defines Panels, and every plugin with a UI contributes one:
object's state. That rule is what lets the notes plugin change its storage without breaking search.
Edit, build, roll out: how new code takes over while every tab stays connected.
Edit, build, roll out: how new code takes over while every tab stays connected.
You change the notes plugin: notes now have a timestamp. The watcher sees the saved file and marks the plugin dirty. You (or an agent) build and roll out:
const built = await plugins.build(plugins.state.value.dirty, ctx);