From 2439933cf2e3fbcda812874ec3829770e0959897 Mon Sep 17 00:00:00 2001
From: Mario Zechner Session({ id: "p1&quo
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 document is data an object
-stores, changed in transactions; a table holds many records with queries, in the host's store; a derive is a value computed from others; a native is an outside
+stores, changed in transactions; a table holds many records with queries, in the host's store, and a live query keeps some of its rows current; 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.
Persisted data lives only in local objects with empty params (one per host and type), so its address never depends
on who asked or with which params; an object with params (a view per project) reads from such a store. Writes go
through the same transactions as documents: a note row and a document can change in one commit.
A table has no watch of its own. To show rows live (the newest notes in a tab), the object declares a live query +over its own table, with a limit:
+const newest = self.query(rows, recent, 50); // the 50 newest rows, a value like a document's
+return { newest, add, page }; // a member: tabs watch it and get only the changes
+Chord runs the query before the object goes live, and again in every commit of the object that writes rows, and
+sends what changed. Only the object's own tables: it is their only writer, so its commits are exactly when the rows
+can change, and a commit that writes no such table costs nothing. Each writing commit runs the query once more, at
+most the limit's rows: right for data people edit, wrong for a table written hundreds of times a second (there may be
+dragons; keep what the tab shows in a document the object writes with each change instead). The limit is required.
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
@@ -277,7 +286,8 @@ self.onLive(async (ctx) =>
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.
+Chord closes it and opens it again. Objects without natives are swapped in place. A first read of a table is no +native: declare it as a live query, not withself.open, or the object restarts at every change of its plugin.
Await what you start in a root or a handler. What is started with a context ends with it. Code outside objects
gets its ctx from host.root(fn, ctx), and that scope seals when fn returns; a handler's context ends when its
caller cancels or its object ends. A change still waiting when its context ends is cancelled before it commits:
local for its own
host only, remote for any host) names its params (which instance) and members. A remote contract's variants
are the member sets different principals get. A factory declares an object's parts: documents and tables
-(stored data, changed in transactions), derives (values computed from others), natives (things opened and
-closed, such as a file or a database cursor). A reference is what asking for an object returns. A facet is a
+(stored data, changed in transactions), live queries (a table's rows Chord keeps current), derives
+(values computed from others), natives (things opened and closed, such as a file or a database cursor). A reference is what asking for an object returns. A facet is a
plugin's code for one host kind; a registry is an extension point plugins contribute to. A rollout applies
new plugin code to the hosts in order.
@@ -353,7 +353,7 @@ it and receives a session cookie. A command-line client on the same machine read
chapter III). Nothing is the owner because it comes from loopback: any local process could.
-A host kind, a remote contract, a worker facet with a table and a live window, and the principal that adds a todo.
A host kind, a remote contract, a worker facet with a table and a live query, and the principal that adds a todo.
Each list gets its own process, a worker, with its own store: a list that misbehaves cannot take the others down, and an idle list costs nothing. This chapter declares where lists live, what a list offers, and the worker code that keeps its todos.
@@ -425,7 +425,7 @@ worker's store: /** The newest todos first. */ const NEWEST = query(Items).orderBy((item) => item.id, "desc"); -/** How many todos the live window holds. */ +/** How many todos the live query holds. */ const WINDOW = 50;A table holds many records with queries and pages; it lives in an object, never in a facet. Persisted data lives only
in local objects with empty params (one per host and type), so its address never depends on who asked (design §9,
@@ -447,35 +447,32 @@ of references. Its factory declares its parts and returns its members:
host.serve(Store, (_params, self) => {
const items = self.table(Items);
- // The newest todos, oldest first, and the next todo's number: read once when the store goes live, then kept here.
- const window = self.writable<Item[]>([]);
- let next = 1;
- self.open(
- async (ctx) => {
- const { items: newest } = await items.page(NEWEST, WINDOW, ctx);
- window.set([...newest].reverse());
- next = (newest[0]?.id ?? 0) + 1;
- },
- () => {},
- );
+ // The newest todos, live: read before the store goes live, read again after every commit writing the table.
+ const newest = self.query(items, NEWEST, WINDOW);
return {
- items: self.derive([window], ([value]) => value),
+ // Oldest first, as the list shows them.
+ items: self.derive([newest], ([rows]) => [...rows].reverse()),
A factory is synchronous and only declares; Chord activates the object afterwards. self.table declares the
-table. A table has no watch, so the store keeps a live window itself: self.writable holds the newest 50 todos,
-filled by self.open, which runs during activation, and exposed through self.derive (a writable is an in-process
-value, not a state member; a derive over it is). self.open is meant for natives, things opened and closed; the
-window's first read is one, and chapter VIII shows its price.
self.query(items, NEWEST, WINDOW) declares a live query over it: the rows of NEWEST, at most 50, as a
+value the object can expose or derive from. Chord runs it before the store goes live, and again after every commit
+of the store that writes items, and sends watchers only what changed. The store's derive turns the rows around,
+oldest first, for the list.
+A live query reads only its own object's tables: only the store writes them, so its own commits are exactly the
+moments the rows can change, and a commit that writes no table with a query costs nothing. The limit is required:
+each such commit runs the query once more, at most WINDOW rows. That is cheap for a list a person edits and wrong
+for a table written hundreds of times a second (design §11, "there may be dragons").
Writes go through transactions: self.tx(fn, ctx) runs fn with tx, and what it stages is committed at once
-when fn returns, or not at all if it throws. The window follows after the commit; the store is the table's only
-writer, so it knows every change:
fn returns, or not at all if it throws. The live query follows in the same commit; the store keeps nothing
+else in memory, not even the next todo's number, which it reads in the transaction:
add: async ({ text, by }, ctx) => {
// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
if (text === "") throw invalid("args", "a todo needs text");
- const item = { id: next++, text, done: false, by, at: Date.now() };
- await self.tx(async (tx) => tx.put(items, item), ctx);
- // In number order: two adds may commit in either order.
- window.set([...window.value, item].sort((a, b) => a.id - b.id).slice(-WINDOW));
+ await self.tx(async (tx) => {
+ // The number after the newest todo's: the store's transactions run one after another.
+ const { items: last } = await tx.page(items, NEWEST, 1);
+ tx.put(items, { id: (last[0]?.id ?? 0) + 1, text, done: false, by, at: Date.now() });
+ }, ctx);
return null;
A todo without text is refused with The watcher marked invalid("args", …), from the API: the error crosses every link as it is, so a
caller on a page or in a terminal gets { code: "invalid", reason: "args" }, as for arguments that fail the
@@ -959,7 +956,7 @@ console.log(" ada adds ' tea ':
console.log(" ada adds ' ':", refused);
8. lists changed, rolled out to: server, list, web, tui
- on list|groceries: todo.store restarted, todo.todos(groceries) parked
+ on list|groceries: todo.store swapped, todo.todos(groceries) parked
ada adds ' tea ': [ ] tea (ada)
ada adds ' ': { code: 'invalid', reason: 'args' }@todo/lists dirty when the file changed; applyChanges rebuilt it (one esbuild call per facet)
@@ -990,9 +987,20 @@ and rolled it out host by host. On each host Chord stages the new code (it runs
The store opens a native (the window's first read), so it restarted and read its window again from the table; the
-views waited for it (parked), then went on with the new code. Ada's reference never noticed more than a moment of
-loading, and her next add was trimmed. Her blank one is now empty after trimming, so the store refused it with
+
The store opens no natives, and neither does its new factory: its live query is not a native, Chord runs it. So the +store was swapped, not restarted. The sequence on the list's worker:
+The views' new attempts need the store, which was still between steps 1 and 2 when they checked it, so they
+parked for that moment and went live with the new code right after. Ada's reference waited a moment, and her next add was trimmed.
+Had the store kept its window as a one-time read in self.open, it would count as holding a native and restart
+at every reload of its plugin: running calls drained, natives closed and opened again, a new instance.
Ada's blank todo is now empty after trimming, so the store refused it with
invalid("args", …) (chapter II): her client got { code: "invalid", reason: "args" }, Chord's own error, which
crossed the worker's link and the server's as it was. Had the edit broken the plugin, that plugin alone would have kept its old code
and the report would name the error.
KeyOf in src/store/table.ts still tests TypeBox's
"~kind": "Integer", which pi-schema's int() lacks, so the key of an int key column is typed string:
tx.get(items, { id }) with a number fails tsc. The store here uses updateWhere with eq instead of
-tx.update.self.open is the only place, so the store counts as holding a native and restarts at every reload of its
-plugin, parking its views, though it holds nothing open. Tables have no watch, so every table-backed live view
-repeats this pattern (example 59's chat does too).Three more, found the same way, are fixed: a reloaded web plugin lost its own React state (now usePageState,
+tx.update.
+Four more, found the same way, are fixed: a reloaded web plugin lost its own React state (now usePageState,
chapter VIII), a plugin's contract.ts could not be imported outside builds (the API is now a workspace package,
-chapter I), and plugins could not throw Chord's invalid (Chord exports invalid and forbidden, chapter II).
invalid (Chord exports invalid and forbidden, chapter II), and a
+live window over a table needed a one-time read in self.open, which made the store restart at every reload of its
+plugin (now self.query, a live query Chord runs, chapters II and VIII).
+
invalid (Chord
host.serve(Type, factory); factory self: table, writable, open, derive, tx, get, contributehost.serve(Type, factory); factory self: table, query, derive, tx, get, contributeextends RoutineDeclarations
{ /** This object's documents of `definition`, in memory for its life (across its attempts). */ @@ -146,6 +148,18 @@ export interface StoreSelf
extends Self
{
state