docs(chord): tutorial: an object's parts before they are used

This commit is contained in:
Mario Zechner 2026-10-31 14:07:01 +00:00
parent 436d99e04b
commit dae7bd8fcb
2 changed files with 118 additions and 22 deletions

View file

@ -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.

View file

@ -122,12 +122,13 @@ writes, a note says so.</p>
<nav class="toc"><h2>Contents</h2><ol>
<li><a href="#before-we-begin">Before we begin</a></li>
<li><a href="#host">I. One Host</a></li>
<li><a href="#hosts">II. Several Hosts</a></li>
<li><a href="#clients">III. Client Hosts</a></li>
<li><a href="#plugins">IV. Plugins</a></li>
<li><a href="#loading">V. Loading Plugins</a></li>
<li><a href="#together">VI. Plugins Using Plugins</a></li>
<li><a href="#reload">VII. Changing a Plugin</a></li>
<li><a href="#parts">II. An Object&#39;s Parts</a></li>
<li><a href="#hosts">III. Several Hosts</a></li>
<li><a href="#clients">IV. Client Hosts</a></li>
<li><a href="#plugins">V. Plugins</a></li>
<li><a href="#loading">VI. Loading Plugins</a></li>
<li><a href="#together">VII. Plugins Using Plugins</a></li>
<li><a href="#reload">VIII. Changing a Plugin</a></li>
<li><a href="#base-code-and-plugins">Base Code and Plugins</a></li>
<li><a href="#reference">Appendix A: Reference</a></li>
</ol></nav>
@ -166,7 +167,9 @@ place; a broken edit keeps the old code running and reports why.</li>
addressable hosts; an <strong>address</strong> (<code>Session({ id: &quot;p1&quot; })</code>) names one. An <strong>object type</strong> is a contract: <code>local</code> (used
on its own host only) or <code>remote</code> (used from any host). An <strong>object</strong> is one instance of a type, per params. A
<strong>facet</strong> is a plugin&#39;s declarations for one host kind. A <strong>plugin</strong> is a directory with a <code>package.json</code>. A
<strong>reference</strong> is what you get when you ask for an object (<code>host.get</code>, <code>self.get</code>, <code>ctx.get</code>). A <strong>registry</strong> is an
<strong>reference</strong> is what you get when you ask for an object (<code>host.get</code>, <code>self.get</code>, <code>ctx.get</code>). A <strong>document</strong> is data an object
stores, changed in <strong>transactions</strong>; a <strong>derive</strong> is a value computed from others; a <strong>native</strong> is an outside
resource an object opens and closes. A <strong>registry</strong> is an
extension point: one plugin defines it, others contribute. A <strong>rollout</strong> applies a build to the hosts in order.</p>
</section>
@ -206,9 +209,9 @@ extension point: one plugin defines it, others contribute. A <strong>rollout</st
<span class="tk-k">await</span> notes.<span class="tk-f">add</span>({ text: <span class="tk-s">"buy milk"</span> }, ctx);
console.<span class="tk-f">log</span>(notes.list.value); <span class="tk-c">// { items: ["buy milk"] }</span></code></pre>
<p>Four things happened. The <strong>contract</strong> <code>Notes</code> says what the object offers; the <strong>document</strong> 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&#39;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 <strong>facet</strong> says this host serves
<code>Notes</code>, with a <strong>factory</strong> that declares the object&#39;s parts: a document, and a method writing it in a transaction.
<code>load</code> publishes the facet. <code>get</code> returns a <strong>reference</strong> once the object is live; its members are the contract&#39;s.</p>
@ -216,7 +219,40 @@ The <strong>facet</strong> says this host serves
what it asked to open) and then serves calls. That is why the same factory can run again when the code changes.</p>
</section>
<section class="chapter" id="hosts"><header class="opener"><div class="numeral">II</div><h2>Several Hosts</h2><p class="lede">A server, a worker per project, browser tabs: how they find each other&#39;s objects.</p></header>
<section class="chapter" id="parts"><header class="opener"><div class="numeral">II</div><h2>An Object&#39;s Parts</h2><p class="lede">Documents, transactions, computed values, watching, and outside resources: the vocabulary of every factory.</p></header>
<p class="dropcap">A factory builds an object from a handful of parts, each declared with one call on <code>self</code>. The notes object grows to
show all of them.</p>
<p><strong>Documents</strong> hold the object&#39;s data: <code>self.state(NotesDoc)</code> gives a handle whose <code>.value</code> is the current data. Data
changes only in a <strong>transaction</strong>: <code>self.tx(fn, ctx)</code> runs <code>fn</code> with <code>tx</code>, and <code>await tx.draft(handle)</code> gives a
mutable copy of the document&#39;s data; whatever you change on it is committed at once when <code>fn</code> returns, or not at all
if it throws.</p>
<pre><code>add: <span class="tk-k">async</span> ({ text }, ctx) =&gt; self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) =&gt; {
<span class="tk-k">const</span> draft = <span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(doc); <span class="tk-c">// the document's data, writable inside this transaction only</span>
draft.items.<span class="tk-f">push</span>(text); <span class="tk-c">// recorded as a change, committed when the function returns</span>
}, ctx),</code></pre>
<p>A <strong>derive</strong> is a value computed from other values, recomputed whenever they change. It is how an object offers
something it does not store:</p>
<pre><code><span class="tk-k">const</span> count = self.<span class="tk-f">derive</span>([doc], ([notes]) =&gt; notes.items.length); <span class="tk-c">// 0, 1, 2, … as notes are added</span>
<span class="tk-k">return</span> { list: doc, count, add }; <span class="tk-c">// `count` can be a member like `list`</span></code></pre>
<p><strong>Watching</strong> is how anyone follows a value as it changes: a document handle, a derive, a reference&#39;s member. <code>watch</code>
gives the current value, then one call per change:</p>
<pre><code><span class="tk-k">const</span> watch = <span class="tk-k">await</span> notes.count.<span class="tk-f">watch</span>(ctx);
watch.<span class="tk-f">start</span>(<span class="tk-k">async</span> (call) =&gt; {
<span class="tk-k">if</span> (call.kind === <span class="tk-s">"value"</span>) console.<span class="tk-f">log</span>(<span class="tk-s">"notes:"</span>, call.value); <span class="tk-c">// "failed" calls say a value cannot be computed now</span>
});</code></pre>
<p>A <strong>routine</strong> is the object watching on its own behalf: <code>self.on(source, fn)</code> runs <code>fn</code> with each new value, for as
long as the object lives (a reconciler: &quot;whenever the notes change, rebuild the export file&quot;).</p>
<p>A <strong>native</strong> is something outside Chord the object opens and must close: a file watcher, a database connection, a
child process. <code>self.open(open, close)</code> opens it while the object activates and closes it when the object ends:</p>
<pre><code><span class="tk-k">const</span> inbox = self.<span class="tk-f">open</span>(<span class="tk-k">async</span> (ctx) =&gt; <span class="tk-f">watchDirectory</span>(inboxPath, ctx.abortSignal), (watcher) =&gt; watcher.<span class="tk-f">close</span>());
self.<span class="tk-f">onLive</span>(<span class="tk-k">async</span> (ctx) =&gt; { <span class="tk-c">// a routine started once the object is live</span>
<span class="tk-k">for</span> <span class="tk-k">await</span> (<span class="tk-k">const</span> file <span class="tk-k">of</span> inbox.value.<span class="tk-f">files</span>(ctx.abortSignal)) <span class="tk-k">await</span> <span class="tk-f">importNote</span>(file, ctx);
});</code></pre>
<p>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.</p>
</section>
<section class="chapter" id="hosts"><header class="opener"><div class="numeral">III</div><h2>Several Hosts</h2><p class="lede">A server, a worker per project, browser tabs: how they find each other&#39;s objects.</p></header>
<p class="dropcap">Now give each project its own process. Declare the kinds of addressable host, and make notes a <code>remote</code> type that
lives on its project&#39;s worker:</p>
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Server</span> = <span class="tk-f">hostKind</span>(<span class="tk-s">"server"</span>);
@ -228,7 +264,10 @@ lives on its project&#39;s worker:</p>
hostAddress: ({ project }) =&gt; <span class="tk-t">Project</span>({ id: project }), <span class="tk-c">// where an object of these params lives</span>
variants: { editor: { state: { list: <span class="tk-t">NoteList</span> }, methods: { add: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ text: <span class="tk-f">str</span>() })) } } },
});</code></pre>
<p><code>hostAddress</code> is the whole routing story. A tab asks for <code>Notes { project: &quot;p1&quot; }</code>. Its host computes the address,
<p>Two things are new. A <code>remote</code> type can be used from any host, and it has <strong>variants</strong>: different sets of members
for different callers, chosen by the factory (an <code>editor</code> variant with <code>add</code>, a <code>reader</code> variant for guests without
it). And <code>hostAddress</code> says where its objects live; it is the whole routing story.</p>
<p>A tab asks for <code>Notes { project: &quot;p1&quot; }</code>. Its host computes the address,
<code>Project(p1)</code>, and sends the request to its parent, the server. The server has a <strong>route</strong> for <code>Project</code>: it starts (or
finds) the worker for <code>p1</code> and passes the request on. The worker serves <code>Notes</code>. The tab gets a reference whose state
follows the worker&#39;s document, one frame per change.</p>
@ -252,7 +291,7 @@ this wiring for you: <code>startApp(...)</code> and <code>startWorker()</code>.<
</div>
</section>
<section class="chapter" id="clients"><header class="opener"><div class="numeral">III</div><h2>Client Hosts</h2><p class="lede">Browser tabs and terminal UIs are hosts too: what they can do, and what they cannot.</p></header>
<section class="chapter" id="clients"><header class="opener"><div class="numeral">IV</div><h2>Client Hosts</h2><p class="lede">Browser tabs and terminal UIs are hosts too: what they can do, and what they cannot.</p></header>
<p class="dropcap">A browser tab and a terminal UI are hosts like the server, with one difference: they have <strong>no address</strong>. Nobody can
send a request <em>to</em> 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&#39;s state.</p>
@ -285,7 +324,7 @@ type, be routed to, or keep data others rely on. Data that must outlive a tab be
worker.</p>
</section>
<section class="chapter" id="plugins"><header class="opener"><div class="numeral">IV</div><h2>Plugins</h2><p class="lede">Move the code into a directory that can be added, edited and removed while the app runs.</p></header>
<section class="chapter" id="plugins"><header class="opener"><div class="numeral">V</div><h2>Plugins</h2><p class="lede">Move the code into a directory that can be added, edited and removed while the app runs.</p></header>
<p class="dropcap">So far the facets were part of the app&#39;s own code. A <strong>plugin</strong> is the same code in its own directory:</p>
<pre class="plain"><code>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 <code>defineFacet</code> bound to it.</p>
</section>
<section class="chapter" id="loading"><header class="opener"><div class="numeral">V</div><h2>Loading Plugins</h2><p class="lede">Discover, build, evaluate, load: what happens between a directory and a running object.</p></header>
<section class="chapter" id="loading"><header class="opener"><div class="numeral">VI</div><h2>Loading Plugins</h2><p class="lede">Discover, build, evaluate, load: what happens between a directory and a running object.</p></header>
<p class="dropcap">The <strong>plugins component</strong> does the work. You tell it where plugins are and what each host kind needs:</p>
<pre><code><span class="tk-k">const</span> plugins = <span class="tk-f">createPlugins</span>({
roots: [<span class="tk-s">"./plugins"</span>],
@ -342,7 +381,7 @@ fails is left out and listed in <code>report.failed</code>; the others load. Its
<p>A worker never builds: the server builds the worker facets too, and each worker evaluates the scripts it is sent.</p>
</section>
<section class="chapter" id="together"><header class="opener"><div class="numeral">VI</div><h2>Plugins Using Plugins</h2><p class="lede">Two ways one plugin uses another, and the one it may not.</p></header>
<section class="chapter" id="together"><header class="opener"><div class="numeral">VII</div><h2>Plugins Using Plugins</h2><p class="lede">Two ways one plugin uses another, and the one it may not.</p></header>
<p class="dropcap">A search plugin wants to search the notes. It <strong>imports the contract</strong> and asks for the object:</p>
<pre><code><span class="tk-c">// plugins/search/worker.ts</span>
<span class="tk-k">import</span> { <span class="tk-t">Notes</span> } <span class="tk-k">from</span> <span class="tk-s">"../notes/contract.ts"</span>; <span class="tk-c">// copied into search's script: contracts only</span>
@ -362,7 +401,7 @@ defines <code>Panels</code>, and every plugin with a UI contributes one:</p>
object&#39;s state. That rule is what lets the notes plugin change its storage without breaking search.</p>
</section>
<section class="chapter" id="reload"><header class="opener"><div class="numeral">VII</div><h2>Changing a Plugin</h2><p class="lede">Edit, build, roll out: how new code takes over while every tab stays connected.</p></header>
<section class="chapter" id="reload"><header class="opener"><div class="numeral">VIII</div><h2>Changing a Plugin</h2><p class="lede">Edit, build, roll out: how new code takes over while every tab stays connected.</p></header>
<p class="dropcap">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:</p>
<pre><code><span class="tk-k">const</span> built = <span class="tk-k">await</span> plugins.<span class="tk-f">build</span>(plugins.state.value.dirty, ctx);