feat(chord): invalid and forbidden exported, page state across plugin reloads, the tutorial app's API a workspace package

This commit is contained in:
Mario Zechner 2026-11-03 19:02:56 +00:00
parent d6fb784c84
commit 8f9446a060
41 changed files with 687 additions and 191 deletions

9
package-lock.json generated
View file

@ -10,6 +10,7 @@
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"apps/*", "apps/*",
"packages/chord/test/examples/61-tutorial",
"packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-anthropic",
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
@ -2606,6 +2607,10 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@todo/app": {
"resolved": "packages/chord/test/examples/61-tutorial",
"link": true
},
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@ -5995,6 +6000,10 @@
} }
} }
}, },
"packages/chord/test/examples/61-tutorial": {
"name": "@todo/app",
"version": "0.0.1"
},
"packages/codemode": { "packages/codemode": {
"name": "@earendil-works/pi-codemode", "name": "@earendil-works/pi-codemode",
"version": "1.5.0", "version": "1.5.0",

View file

@ -5,6 +5,7 @@
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"apps/*", "apps/*",
"packages/chord/test/examples/61-tutorial",
"packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/with-deps",
"packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-anthropic",
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",

View file

@ -15,6 +15,8 @@
- Chord v2 gains web and terminal surfaces (slice 11): `webSurface` and `tuiSurface` (`@earendil-works/chord/surfaces`) build a client kind's base bundle from the app's entry at start (content-hashed, served forever) and publish its plugins' facets as scripts that register themselves (`chordFacet("<plugin>", "<digest>", function (require, module, exports) {…})`) and one combined file, with `WebScripts` and `TuiScripts` showing the current set (a rollout step publishes without waiting for clients) and the page served through the app's own template (`PageAssets`, `pageScripts`). `startPage` (`@earendil-works/chord/web`) runs a page's client host: the connection loop (`keepConnected`: 250 ms doubling to 5 s, wakes, silent sockets, `canRetry`), plugins applied in place on a new set (a failed one keeps its old facet), a reload when the server's base bundle changed, and the active theme as CSS variables. React bindings (`@earendil-works/chord/react`): `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState` (React is an optional peer dependency). The terminal: `startTerminal` (`@earendil-works/chord/tui/launcher`) signs in with a token, a token file or device pairing and runs the app's terminal code always in a sandbox (a child process on pipes with Node's permission model: no file system, terminal, processes, workers, network or SQLite; Node 26.5.1 or later), filtering its output, relaying its link and confirming its actions (copy, open a URL, attach a file the user names); `startTerminalRuntime` and `TuiScope` (`@earendil-works/chord/tui`) inside it. Themes: the `Themes` registry, `ThemeChoice` (one dark and one light pick per principal), `followActiveTheme`, `themeVariables`, `terminalColor`. `startServer` gains `web` and `tui`. Plugin builds scope `*.module.css` files per plugin and file. Ephemeral sessions end with their client's last link (after a minute). The HTTP component's links endpoint gains an `opened` hook. - Chord v2 gains web and terminal surfaces (slice 11): `webSurface` and `tuiSurface` (`@earendil-works/chord/surfaces`) build a client kind's base bundle from the app's entry at start (content-hashed, served forever) and publish its plugins' facets as scripts that register themselves (`chordFacet("<plugin>", "<digest>", function (require, module, exports) {…})`) and one combined file, with `WebScripts` and `TuiScripts` showing the current set (a rollout step publishes without waiting for clients) and the page served through the app's own template (`PageAssets`, `pageScripts`). `startPage` (`@earendil-works/chord/web`) runs a page's client host: the connection loop (`keepConnected`: 250 ms doubling to 5 s, wakes, silent sockets, `canRetry`), plugins applied in place on a new set (a failed one keeps its old facet), a reload when the server's base bundle changed, and the active theme as CSS variables. React bindings (`@earendil-works/chord/react`): `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState` (React is an optional peer dependency). The terminal: `startTerminal` (`@earendil-works/chord/tui/launcher`) signs in with a token, a token file or device pairing and runs the app's terminal code always in a sandbox (a child process on pipes with Node's permission model: no file system, terminal, processes, workers, network or SQLite; Node 26.5.1 or later), filtering its output, relaying its link and confirming its actions (copy, open a URL, attach a file the user names); `startTerminalRuntime` and `TuiScope` (`@earendil-works/chord/tui`) inside it. Themes: the `Themes` registry, `ThemeChoice` (one dark and one light pick per principal), `followActiveTheme`, `themeVariables`, `terminalColor`. `startServer` gains `web` and `tui`. Plugin builds scope `*.module.css` files per plugin and file. Ephemeral sessions end with their client's last link (after a minute). The HTTP component's links endpoint gains an `opened` hook.
- Chord v2's `HttpRequest` gains `remoteAddress`, the peer's address as the listener knows it (for limits per client). - Chord v2's `HttpRequest` gains `remoteAddress`, the peer's address as the listener knows it (for limits per client).
- Chord v2's HTTP component gains `readSignedCookie(request, name, secret)`: `readCookie`'s value with its signed expiry, for renewing a cookie before it expires. - Chord v2's HTTP component gains `readSignedCookie(request, name, secret)`: `readCookie`'s value with its signed expiry, for renewing a cookie before it expires.
- Chord v2's root exports `invalid(reason, message, details?)` and the new `forbidden(message, details?)`, the `ChordError`s a plugin throws for arguments or params it refuses (`invalid/args`, `invalid/params`) and for a principal it refuses (`forbidden`); an app's API re-exports them, and the caller gets the same `{ code, reason }` as from Chord's own checks.
- Chord v2's React bindings gain `usePageState(key, initial)`, `useState`'s pair with the value kept by the page's host under `key` instead of the component: a component of a reloaded web plugin is a new function and remounts (its `useState` starts over), and page state is what survives (a half-typed message, a scroll position); any value, never on the wire, gone with the page. `TuiScope` gains `pageState(key, initial)` on the same per-host store, its changes calling the scope's `changed`.
- Chord v2 reports mistakes that waited silently through `onError` (decision "Mistakes that wait are reported at once"): `unbound_long { waitingFor, error, since }`, a request waiting unbound (a type or registry not served here, not at its version, a registry whose first `compose` threw) once after 5 s, on one timer per host (it keeps waiting until its `readyTimeout`); `registry_undeclared { registry, plugins }`, a registry with contributions and no provider at a publication (`load`, a reload's commit) or when a host-lifetime object's contributions enter, naming the contributing plugins, again only once they change; `transaction_cancelled { object, error }`, a document change or transaction that did not commit because the context it was started with ended first (a root whose `fn` returned, a caller that cancelled, its object's end), besides rejecting. - Chord v2 reports mistakes that waited silently through `onError` (decision "Mistakes that wait are reported at once"): `unbound_long { waitingFor, error, since }`, a request waiting unbound (a type or registry not served here, not at its version, a registry whose first `compose` threw) once after 5 s, on one timer per host (it keeps waiting until its `readyTimeout`); `registry_undeclared { registry, plugins }`, a registry with contributions and no provider at a publication (`load`, a reload's commit) or when a host-lifetime object's contributions enter, naming the contributing plugins, again only once they change; `transaction_cancelled { object, error }`, a document change or transaction that did not commit because the context it was started with ended first (a root whose `fn` returned, a caller that cancelled, its object's end), besides rejecting.
### Changed ### Changed

View file

@ -23,7 +23,7 @@ The design, the rule book for all of this, is [docs/design.md](docs/design.md).
| `@earendil-works/chord/app` | The app kit: `startServer`, `startWorker`, the start-data objects, sessions and the owner's sign-in, device pairing, files, export and import per address, optional plugins (users, invites, ephemeral login). | Node | | `@earendil-works/chord/app` | The app kit: `startServer`, `startWorker`, the start-data objects, sessions and the owner's sign-in, device pairing, files, export and import per address, optional plugins (users, invites, ephemeral login). | Node |
| `@earendil-works/chord/surfaces` | The client surfaces' server side: `webSurface` (base bundle, plugin scripts, the page) and `tuiSurface` (the terminal surface). | Node | | `@earendil-works/chord/surfaces` | The client surfaces' server side: `webSurface` (base bundle, plugin scripts, the page) and `tuiSurface` (the terminal surface). | Node |
| `@earendil-works/chord/web` | The web surface's browser side: `startPage` in the app's web entry, `followThemes`. | Browsers | | `@earendil-works/chord/web` | The web surface's browser side: `startPage` in the app's web entry, `followThemes`. | Browsers |
| `@earendil-works/chord/react` | Optional React bindings: `HostProvider`, `useHold`, `useWatch`, `useRefState`, `useRegistry`, `watchStore`. | Browsers | | `@earendil-works/chord/react` | Optional React bindings: `HostProvider`, `useHold`, `useWatch`, `useRefState`, `useRegistry`, `usePageState`, `watchStore`. | Browsers |
| `@earendil-works/chord/tui` | The terminal surface's sandbox side: `startTerminalRuntime` in the app's terminal entry, `BridgeTerminal`, `TerminalTheme`, `TuiScope`. | Node (sandboxed) | | `@earendil-works/chord/tui` | The terminal surface's sandbox side: `startTerminalRuntime` in the app's terminal entry, `BridgeTerminal`, `TerminalTheme`, `TuiScope`. | Node (sandboxed) |
| `@earendil-works/chord/tui/launcher` | The terminal launcher, the trusted side of a terminal client: `startTerminal`, `requireSandboxNode`. | Node | | `@earendil-works/chord/tui/launcher` | The terminal launcher, the trusted side of a terminal client: `startTerminal`, `requireSandboxNode`. | Node |
| `@earendil-works/chord/context` | Explicit cancellation, telemetry, and invocation values: `BACKGROUND_CONTEXT`, `withCancel`, `withAbortSignal`, `withoutAbortSignal`, `withTelemetry`, `traceIn`, `withContextValue`, `awaitWithContext`. | Node and browsers | | `@earendil-works/chord/context` | Explicit cancellation, telemetry, and invocation values: `BACKGROUND_CONTEXT`, `withCancel`, `withAbortSignal`, `withoutAbortSignal`, `withTelemetry`, `traceIn`, `withContextValue`, `awaitWithContext`. | Node and browsers |

View file

@ -1949,6 +1949,14 @@ type ReloadReport = {
would interleave unchecked. In practice a restart costs little (the Durable harness is base code, terminal PTYs live would interleave unchecked. In practice a restart costs little (the Durable harness is base code, terminal PTYs live
in their own process, model runtimes, sandboxes and the search index rebuild in milliseconds); reloading the plugin in their own process, model runtimes, sandboxes and the search index rebuild in milliseconds); reloading the plugin
that owns SSH connections or the relay socket hurts. that owns SSH connections or the relay socket hurts.
- **UI on client hosts** (decision "Slice 11, P4", refined by "Tutorial gaps" (1)): a page or terminal applies a new
plugin script with `host.reload`, as any host; the UI re-renders from its registries and keeps what lives outside
the changed plugin. A component of a reloaded plugin is a new function, so React (or a terminal scope reopened by the
app) mounts it anew and its own state (`useState`) starts over. What must survive a plugin update (a half-typed
message, a scroll position) is **page state**: values the client host keeps by key (`usePageState(key, initial)` in
`chord/react`, `scope.pageState(key, initial)` on a `TuiScope`, one store per host), any same-host value, never on
the wire, untouched by reloads and gone with the page (a page reload, a terminal's new base bundle). Rejected: React
Fast Refresh (a Babel or SWC transform in every web build, fragile when hooks change).
- **No factory trial, no migrate dry run, no consumer contract check during staging:** staging records declarations; - **No factory trial, no migrate dry run, no consumer contract check during staging:** staging records declarations;
incompatible consumers wait at bind (`failed { waiting }`, `contract_mismatch`). The one exception is the closure incompatible consumers wait at bind (`failed { waiting }`, `contract_mismatch`). The one exception is the closure
rule above: when a plugin of the reload failed, a changed plugin's recorded requests are checked as a bind would rule above: when a plugin of the reload failed, a changed plugin's recorded requests are checked as a bind would
@ -2312,7 +2320,9 @@ Decisions "The app kit, point 1" to "point 5". `startServer(options)` on the ser
entry. Opinionated and small: an app is its API, its built-in plugins and a start script. entry. Opinionated and small: an app is its API, its built-in plugins and a start script.
- **Shape:** the app's layout is fixed: `src/api/v<n>/` (shared and per-kind modules) and the built-in plugins root - **Shape:** the app's layout is fixed: `src/api/v<n>/` (shared and per-kind modules) and the built-in plugins root
`plugins/`. The data directory is a `startServer` option (no environment variables), default a dot directory named after `plugins/`. The API is a real package (decision "Tutorial gaps" (3)): a workspace package of the app's repository
(`"name": "@acme/app"`, `"exports": { "./v1": ..., "./v1/web": ... }`) that npm links, so Node, TypeScript, tests and
plugin builds resolve it alike (a test imports a plugin's `contract.ts`). The data directory is a `startServer` option (no environment variables), default a dot directory named after
the app in the home directory. One server; worker kinds are a list the app defines, each a host kind with its own the app in the home directory. One server; worker kinds are a list the app defines, each a host kind with its own
params (sharding is the app's) and how it starts (entry, start data, principal, idle time); zero kinds is a server params (sharding is the app's) and how it starts (entry, start data, principal, idle time); zero kinds is a server
with clients only. with clients only.
@ -2334,7 +2344,7 @@ entry. Opinionated and small: an app is its API, its built-in plugins and a star
### Web and TUI (slice 11) ### Web and TUI (slice 11)
Decision "Slice 11's points" and "Slice 11, P3" to "P9". Decision "Slice 11's points", "Slice 11, P3" to "P9", and "Tutorial gaps" (1) (page state).
- `chord/surfaces` serves the client surfaces (web and terminal) from the server; `chord/web` is a page's client host - `chord/surfaces` serves the client surfaces (web and terminal) from the server; `chord/web` is a page's client host
with its connection loop (p2's `keepConnected`), loading and applying the plugins' web facets. A base bundle per with its connection loop (p2's `keepConnected`), loading and applying the plugins' web facets. A base bundle per
@ -2342,13 +2352,15 @@ Decision "Slice 11's points" and "Slice 11, P3" to "P9".
one combined file named by its hash, later changed plugins one script each, each a script that registers itself (no one combined file named by its hash, later changed plugins one script each, each a script that registers itself (no
eval); new scripts apply in place (`host.reload` in the page); pages learn of them through an object the web eval); new scripts apply in place (`host.reload` in the page); pages learn of them through an object the web
component serves (the current set); a base code change reloads the page. component serves (the current set); a base code change reloads the page.
- `chord/react`: `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState`. - `chord/react`: `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState`, and `usePageState(key, initial)`:
`useState`'s pair kept by the page's host under a key, so a component remounted by a plugin reload (a new function)
keeps it (§21, "UI on client hosts").
- `chord/tui/launcher`: a Node client connecting with a token, always running the downloaded code (the app's terminal - `chord/tui/launcher`: a Node client connecting with a token, always running the downloaded code (the app's terminal
entry on `chord/tui`) in a sandbox (decision "The terminal's sandbox is a child process": a child process on pipes entry on `chord/tui`) in a sandbox (decision "The terminal's sandbox is a child process": a child process on pipes
with an empty environment, `--permission` granting nothing and no SQLite; the builtin allowlist and a frozen with an empty environment, `--permission` granting nothing and no SQLite; the builtin allowlist and a frozen
substitute `process` inside; a bridge terminal; the trusted launcher outside, confirming copy, open and attach on its substitute `process` inside; a bridge terminal; the trusted launcher outside, confirming copy, open and attach on its
own prompt); a component scope (`hold`, `watch`, `registry`, released on dispose, one `changed` callback for the own prompt); a component scope (`hold`, `watch`, `registry`, released on dispose, one `changed` callback for the
app's redraw), no pi-tui dependency. app's redraw; `pageState(key, initial)`, the host's page state shared with `usePageState`), no pi-tui dependency.
### Messages and languages (after slice 11) ### Messages and languages (after slice 11)

View file

@ -625,6 +625,12 @@ The tab's reference follows the new object and gets one frame with the differenc
is broken (a syntax error, a factory that throws), that plugin keeps its old code on that host and the report says why; is broken (a syntax error, a factory that throws), that plugin keeps its old code on that host and the report says why;
every other plugin of the rollout applies. every other plugin of the rollout applies.
A tab applies a new web script the same way, without reloading the page, and the UI keeps what lives outside the
changed plugin. The changed plugin's own components are new functions, so React mounts them anew and their `useState`
starts over. What must survive the update (a half-typed note, a scroll position) goes into **page state**, kept by the
tab's host under a key: `usePageState(key, initial)` from `chord/react` instead of `useState`, and
`scope.pageState(key, initial)` on a terminal's `TuiScope` (design §21, "UI on client hosts").
:::note | Versions :::note | Versions
A change to a contract (a new field in `Notes`'s state) gets a new `version`. A tab built with the old contract keeps A change to a contract (a new field in `Notes`'s state) gets a new `version`. A tab built with the old contract keeps
working if the provider maps the old version (`accepts`); otherwise it waits with "version mismatch" until it reloads. working if the provider maps the old version (`accepts`); otherwise it waits with "version mismatch" until it reloads.

View file

@ -583,6 +583,11 @@ then gives every object of the changed plugin its fate:</p>
<p>The tab&#39;s reference follows the new object and gets one frame with the difference. Nothing reconnects. If the edit <p>The tab&#39;s reference follows the new object and gets one frame with the difference. Nothing reconnects. If the edit
is broken (a syntax error, a factory that throws), that plugin keeps its old code on that host and the report says why; is broken (a syntax error, a factory that throws), that plugin keeps its old code on that host and the report says why;
every other plugin of the rollout applies.</p> every other plugin of the rollout applies.</p>
<p>A tab applies a new web script the same way, without reloading the page, and the UI keeps what lives outside the
changed plugin. The changed plugin&#39;s own components are new functions, so React mounts them anew and their <code>useState</code>
starts over. What must survive the update (a half-typed note, a scroll position) goes into <strong>page state</strong>, kept by the
tab&#39;s host under a key: <code>usePageState(key, initial)</code> from <code>chord/react</code> instead of <code>useState</code>, and
<code>scope.pageState(key, initial)</code> on a terminal&#39;s <code>TuiScope</code> (design §21, &quot;UI on client hosts&quot;).</p>
<div class="note"><b class="tag">Versions</b><p>A change to a contract (a new field in <code>Notes</code>&#39;s state) gets a new <code>version</code>. A tab built with the old contract keeps <div class="note"><b class="tag">Versions</b><p>A change to a contract (a new field in <code>Notes</code>&#39;s state) gets a new <code>version</code>. A tab built with the old contract keeps
working if the provider maps the old version (<code>accepts</code>); otherwise it waits with &quot;version mismatch&quot; until it reloads.</p> working if the provider maps the old version (<code>accepts</code>); otherwise it waits with &quot;version mismatch&quot; until it reloads.</p>
</div> </div>

View file

@ -97,6 +97,37 @@ Refused at build, each with the file, line and rule:
Contract files mention native things (React components, functions) only through `import type`; at runtime they import Contract files mention native things (React components, functions) only through `import type`; at runtime they import
only the API and the plugin's own data-only files. only the API and the plugin's own data-only files.
## An app's API is a package
The app's API is a workspace package of the app's repository: a `package.json` named `@acme/app` with an `exports`
entry per module, listed in the repository root's `workspaces`, so `npm install` links it into `node_modules`:
```json
{
"name": "@acme/app",
"private": true,
"type": "module",
"exports": {
"./v3": "./src/api/v3/index.ts",
"./v3/web": "./src/api/v3/web.ts",
"./v3/worker": "./src/api/v3/worker.ts"
}
}
```
Plugins, tests and tools resolve it alike: a facet imports `@acme/app/v3`, and so do the app's base code, a test, and
a plugin's `contract.ts` that a test or a script imports. In a plugin's build it stays a host package (left out of the
script, the process's own instance passed in when the script is evaluated).
## Errors
Throw Chord's own errors where Chord's built-in checks would, from the API (it re-exports them):
`invalid("args", "a note needs a title")` for arguments a method refuses (`"params"` for params a factory refuses),
`forbidden("viewers only")` for a principal a factory or method refuses. The caller gets the same
`{ code: "invalid", reason: "args" }` or `{ code: "forbidden" }` across every link. For codes of the plugin's own throw
`new PluginError(code, message, details)`. Only the code, reason, message and JSON details cross a link, never a stack
or a cause.
## Contracts and versions ## Contracts and versions
A plugin carries the contract it was written against. Plugins changing together (one root, one repository) import A plugin carries the contract it was written against. Plugins changing together (one root, one repository) import
@ -114,7 +145,7 @@ No type check, no build hooks, no `dist` needed; generated files are committed.
process of its kind with that process's API modules. A failed build or evaluation leaves the plugin out (at startup) process of its kind with that process's API modules. A failed build or evaluation leaves the plugin out (at startup)
or keeps its old code (at a reload), reported with the error. or keeps its old code (at a reload), reported with the error.
## Styles and themes (web and terminal facets) ## Styles, themes and state across reloads (web and terminal facets)
- A web facet's CSS is served with its script and swapped when the plugin reloads. Scope it with a **CSS module**: a - A web facet's CSS is served with its script and swapped when the plugin reloads. Scope it with a **CSS module**: a
file named `*.module.css` has its class names made unique to the plugin and the file, and its import is the mapping file named `*.module.css` has its class names made unique to the plugin and the file, and its import is the mapping
@ -123,6 +154,11 @@ or keeps its old code (at a reload), reported with the error.
- Read the app's look from its CSS variables (`var(--accent)`, `var(--bg)`, `var(--fg)`, `var(--muted)`, `var(--error)`, - Read the app's look from its CSS variables (`var(--accent)`, `var(--bg)`, `var(--fg)`, `var(--muted)`, `var(--error)`,
`var(--warning)`, `var(--success)`, and the app's own roles), never fixed colours: the active theme sets them. `var(--warning)`, `var(--success)`, and the app's own roles), never fixed colours: the active theme sets them.
- In a terminal facet, ask the app's theme lookup for a role (`theme.fg("accent", "Notes")`); never write raw colours. - In a terminal facet, ask the app's theme lookup for a role (`theme.fg("accent", "Notes")`); never write raw colours.
- A reload of the plugin remounts its components (each is a new function): their `useState` starts over. Keep what
must survive an update (a half-typed message, a scroll position) in page state, kept by the page's host under a key
prefixed with the plugin's name: `usePageState("stats.draft", "")` instead of `useState("")` (from the app's web
API); in a terminal facet `scope.pageState("stats.draft", "")` on the `TuiScope` it was given. Any value, never sent
anywhere, gone with the page.
- A theme is data: contribute a `Theme` (`{ id, name, mode, colors, web?, tui? }`) to the kit's `Themes` registry, - A theme is data: contribute a `Theme` (`{ id, name, mode, colors, web?, tui? }`) to the kit's `Themes` registry,
from a web facet and a terminal facet to reach both. Users pick one dark and one light theme. from a web facet and a terminal facet to reach both. Users pick one dark and one light theme.
@ -162,8 +198,9 @@ await t.close(ctx);
``` ```
The plugin is built as production builds it and loaded on an in-memory host (a `:memory:` store). Stubs are ordinary The plugin is built as production builds it and loaded on an in-memory host (a `:memory:` store). Stubs are ordinary
facets with fake data. A test declares the contracts it reads again (contracts are data: the same name, version and facets with fake data. A test imports the API by its package name and the plugin's own contracts from its
schema bind). `contract.ts` (the build bundles its own copy; contracts are data: the same name, version and schema bind); contracts
of plugins it has no files of, it declares again, as `Notes` above.
## Checklist ## Checklist

View file

@ -115,13 +115,17 @@ export {
type Ctx, type Ctx,
defineTable, defineTable,
eq, eq,
forbidden,
int, int,
invalid,
local, local,
method, method,
num, num,
obj, obj,
PluginError, PluginError,
query, query,
remote,
type Static,
str, str,
type Theme, type Theme,
Themes, Themes,
@ -129,6 +133,10 @@ export {
export type { TodoPrincipal } from "../../principals.ts"; export type { TodoPrincipal } from "../../principals.ts";
``` ```
`invalid` and `forbidden` make Chord's own errors: a plugin refusing arguments throws `invalid("args", message)`,
one refusing a caller `forbidden(message)`, and the caller gets the same `{ code, reason }` Chord's built-in checks
give (chapter VIII). `PluginError` is for codes of the plugin's own.
and a `defineFacet` bound to the principal type, so every factory sees a `TodoPrincipal`: and a `defineFacet` bound to the principal type, so every factory sees a `TodoPrincipal`:
<!-- quote: src/api/v1/index.ts --> <!-- quote: src/api/v1/index.ts -->
@ -144,6 +152,31 @@ it evaluates it. The plugin sees the very `Themes` registry and `List` host kind
version in the path lets the app ship `v2` beside `v1` later; one facet uses one version (design §21, "The plugin version in the path lets the app ship `v2` beside `v1` later; one facet uses one version (design §21, "The plugin
model"). model").
The API is also a real package: a **workspace package** of the app's repository, named in its `package.json` with an
`exports` entry per module:
<!-- quote: package.json -->
```json
{
"name": "@todo/app",
"version": "0.0.1",
"private": true,
"description": "The tutorial's todo app: its API (v1 and its per-kind modules), base code and built-in plugins",
"type": "module",
"exports": {
"./v1": "./src/api/v1/index.ts",
"./v1/server": "./src/api/v1/server.ts",
"./v1/web": "./src/api/v1/web.ts",
"./v1/tui": "./src/api/v1/tui.ts"
}
}
```
The repository's root `package.json` lists it in `workspaces` (here Chord's repository lists
`packages/chord/test/examples/61-tutorial`), so `npm install` links `node_modules/@todo/app` to it. Then Node,
TypeScript, the tests and esbuild resolve `@todo/app/v1` alike, from base code, from a plugin's files and from a test
importing a plugin's `contract.ts`; plugin builds still leave it out and pass the process's instance.
## The start script ## The start script
`startServer` takes what the app decides and does the rest. `start.ts` names the API with this process's modules, the `startServer` takes what the app decides and does the rest. `start.ts` names the API with this process's modules, the
@ -260,14 +293,21 @@ keeps its todos.
## Where a list lives ## Where a list lives
A **host kind** declares addressable hosts with params; `List({ id: "groceries" })` is an **address**. A `remote` A **host kind** declares addressable hosts with params; `List({ id: "groceries" })` is an **address**. The app's
contract says, with `hostAddress`, at which address its objects live. Here is the app's contract for a list's todos, host kinds are base code, in the API:
in the API so that every host kind, the tests and command-line code can name it:
<!-- quote: src/api/v1/index.ts --> <!-- quote: src/api/v1/index.ts -->
```ts ```ts
/** A todo list: a worker process of its own, with its own store. */ /** A todo list: a worker process of its own, with its own store. */
export const List = hostKind("list", { id: str() }); export const List = hostKind("list", { id: str() });
```
A `remote` contract says, with `hostAddress`, at which address its objects live. The contract for a list's todos is
the lists plugin's, in its `contract.ts`, which its facets of every kind import, and so do the steps and its test:
<!-- quote: plugins/lists/contract.ts -->
```ts
import { arr, bool, int, List, method, num, obj, remote, type Static, str } from "@todo/app/v1";
/** A todo: its number in the list, its text, whether it is done, who added it and when. */ /** A todo: its number in the list, its text, whether it is done, who added it and when. */
export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() }); export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
@ -276,8 +316,8 @@ export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: n
export type Item = Static<typeof Item>; export type Item = Static<typeof Item>;
/** /**
* A list's todos as one principal sees them, on the list's worker: the newest 50 (live), adding one and ticking one * A list's todos as one principal sees them: the newest 50 (live), adding one and ticking one off (or on again); the
* off (or on again); the owner also clears the done ones. * owner also clears the done ones.
*/ */
export const Todos = remote("todo.todos", { export const Todos = remote("todo.todos", {
version: 1, version: 1,
@ -394,6 +434,8 @@ writer, so it knows every change:
<!-- quote: plugins/lists/worker.ts --> <!-- quote: plugins/lists/worker.ts -->
```ts ```ts
add: async ({ text, by }, ctx) => { 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() }; const item = { id: next++, text, done: false, by, at: Date.now() };
await self.tx(async (tx) => tx.put(items, item), ctx); await self.tx(async (tx) => tx.put(items, item), ctx);
// In number order: two adds may commit in either order. // In number order: two adds may commit in either order.
@ -401,6 +443,10 @@ writer, so it knows every change:
return null; return null;
``` ```
A todo without text is refused with `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
contract's schema.
## The view per principal ## The view per principal
`Todos` is the remote face of the store, one object per principal. Its factory gets the principal the link stamped: `Todos` is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:
@ -497,8 +543,9 @@ export { Http } from "../../../../../../src/node/http/index.ts";
// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the // The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the
// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them. // kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.
import { defineFacet, Todos } from "@todo/app/v1"; import { defineFacet } from "@todo/app/v1";
import { Http } from "@todo/app/v1/server"; import { Http } from "@todo/app/v1/server";
import { Todos } from "./contract.ts";
export default defineFacet((_host, self) => { export default defineFacet((_host, self) => {
self.contribute(Http, { self.contribute(Http, {
@ -566,7 +613,7 @@ for them:
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { defineRegistry } from "../../../../../../src/index.ts"; import { defineRegistry } from "../../../../../../src/index.ts";
export { useHold, useWatch } from "../../../../../../src/react/index.ts"; export { useHold, usePageState, useWatch } from "../../../../../../src/react/index.ts";
/** A panel of the page: its title and its React component. */ /** A panel of the page: its title and its React component. */
export type Panel = { readonly title: string; readonly Component: ComponentType }; export type Panel = { readonly title: string; readonly Component: ComponentType };
@ -612,9 +659,11 @@ when its items change:
<!-- quote: plugins/lists/web.ts --> <!-- quote: plugins/lists/web.ts -->
```ts ```ts
function TodoList() { function TodoList() {
const todos = useHold(Todos, { list: location.hash.slice(1) || "groceries" }); const list = location.hash.slice(1) || "groceries";
const todos = useHold(Todos, { list });
const items = useWatch(todos?.items) ?? []; const items = useWatch(todos?.items) ?? [];
const [text, setText] = useState(""); // Kept by the page, not the component: the new code's component finds the half-typed todo.
const [text, setText] = usePageState(`lists.draft.${list}`, "");
const add = (event: { preventDefault(): void }) => { const add = (event: { preventDefault(): void }) => {
event.preventDefault(); event.preventDefault();
if (todos === undefined || text.trim() === "") return; if (todos === undefined || text.trim() === "") return;
@ -659,7 +708,8 @@ export default defineFacet((_host, self) => self.contribute(Panels, { title: "Gr
``` ```
`useHold` holds a reference while the component is mounted and releases it on unmount; it is `undefined` on the first `useHold` holds a reference while the component is mounted and releases it on unmount; it is `undefined` on the first
render. `useWatch` follows a member. Clicks call methods with `BACKGROUND_CONTEXT`, which never ends, so a click's call render. `useWatch` follows a member. `usePageState` is `useState` whose value the page keeps under a key instead of the
component: chapter VIII shows why the half-typed todo needs it. Clicks call methods with `BACKGROUND_CONTEXT`, which never ends, so a click's call
is not cancelled when the handler returns. `todos?.variant === "owner"` narrows the reference: only the owner's has is not cancelled when the handler returns. `todos?.variant === "owner"` narrows the reference: only the owner's has
`clear`, and TypeScript knows it. The panel's look reads the active theme's CSS variables, with class names prefixed `clear`, and TypeScript knows it. The panel's look reads the active theme's CSS variables, with class names prefixed
by the plugin's name (a `*.module.css` file would scope them instead): by the plugin's name (a `*.module.css` file would scope them instead):
@ -977,16 +1027,14 @@ Her todo is hers: `coffee (ada)`.
# Changing Code {#reload lede="An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate."} # Changing Code {#reload lede="An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate."}
Ada typed " tea " with spaces, and an empty add should do nothing. The fix is in the lists plugin's worker facet. Ada typed " tea " with spaces, and a blank todo should be refused like an empty one. The fix is one line of the lists
Step 8 makes the edit in the running app's copy of the plugins, as an editor or an agent would, and applies it: plugin's worker facet: the view trims the text before the store sees it. Step 8 makes the edit in the running app's
copy of the plugins, as an editor or an agent would, and applies it:
<!-- quote: main.ts --> <!-- quote: main.ts -->
```ts ```ts
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),"; const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
const AFTER = `add: async (text: string, ctx: Ctx) => { const AFTER = "add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),";
if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx);
return null;
},`;
const workerFile = join(root, "lists", "worker.ts"); const workerFile = join(root, "lists", "worker.ts");
await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER)); await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
await until(() => app.plugins.state.value.dirty.includes("@todo/lists")); await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
@ -999,9 +1047,11 @@ for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) {
await adas.add(" tea ", context); await adas.add(" tea ", context);
await until(() => groceries.items.value.at(-1)?.text === "tea"); await until(() => groceries.items.value.at(-1)?.text === "tea");
console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1))); console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1)));
const count = groceries.items.value.length; const refused = await adas.add(" ", context).then(
await adas.add(" ", context); () => "added",
console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added"); (error: ChordError) => ({ code: error.code, reason: error.reason }),
);
console.log(" ada adds ' ':", refused);
``` ```
<!-- output: main --> <!-- output: main -->
@ -1009,7 +1059,7 @@ console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignore
8. lists changed, rolled out to: server, list, web, tui 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 restarted, todo.todos(groceries) parked
ada adds ' tea ': [ ] tea (ada) ada adds ' tea ': [ ] tea (ada)
ada adds ' ': ignored ada adds ' ': { code: 'invalid', reason: 'args' }
``` ```
The watcher marked `@todo/lists` dirty when the file changed; `applyChanges` rebuilt it (one esbuild call per facet) The watcher marked `@todo/lists` dirty when the file changed; `applyChanges` rebuilt it (one esbuild call per facet)
@ -1026,7 +1076,9 @@ 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 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 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. Had the edit broken the plugin, that plugin alone would have kept its old code `loading`, and her next add was trimmed. Her blank one 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. and the report would name the error.
A contract that changes shape needs a new `version`; old consumers keep working when the provider maps their version A contract that changes shape needs a new `version`; old consumers keep working when the provider maps their version
@ -1049,16 +1101,20 @@ const BEFORE = `h(
wrote docs/tutorial/screens/page.png wrote docs/tutorial/screens/page.png
wrote docs/tutorial/screens/harbor.png wrote docs/tutorial/screens/harbor.png
wrote docs/tutorial/screens/edited.png wrote docs/tutorial/screens/edited.png
typed "oat mi"; after the edit: "" typed "oat mi"; after the edit: "oat mi"
the page did not reload: true the page did not reload: true
``` ```
:::figure | After the edit of the web facet, applied in place: the count appears; the half-typed "oat mi" is gone, because the panel's component is new code and React mounted it anew. :::figure | After the edit of the web facet, applied in place: the count appears, and the half-typed "oat mi" is still in the box: the page kept it under its key while React mounted the new component.
![The page after the edit](screens/edited.png) ![The page after the edit](screens/edited.png)
::: :::
The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel's The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel
own React state did not: a new component function is a new component to React. itself is new code: its component is a new function, which React mounts anew, so its own `useState` would start over
(design §21). What must survive a plugin update (a half-typed todo, a scroll position) is **page state**:
`usePageState(key, initial)` keeps the value in the page's host under the key, not in the component, so the new
component finds it; a terminal's `TuiScope` has the same store as `scope.pageState(key, initial)`. Page state is any
value, never sent anywhere, and gone with the page.
# Moving a List {#export lede="Export a worker's data while it runs, import it under another address."} # Moving a List {#export lede="Export a worker's data while it runs, import it under another address."}
@ -1100,9 +1156,9 @@ loads it on a new host with an in-memory store, here at a list's address so it m
<!-- quote: plugins/lists/worker.test.ts --> <!-- quote: plugins/lists/worker.test.ts -->
```ts ```ts
let t: TestedPlugin<TodoPrincipal>; let t: TestedPlugin<v1.TodoPrincipal>;
beforeEach(async () => { beforeEach(async () => {
t = await testPlugin<TodoPrincipal>( t = await testPlugin<v1.TodoPrincipal>(
fileURLToPath(new URL("./", import.meta.url)), fileURLToPath(new URL("./", import.meta.url)),
{ {
kind: "worker", kind: "worker",
@ -1124,7 +1180,7 @@ Each test runs code as a principal of its choice with `t.root`:
it("lets the owner clear the done todos", async () => { it("lets the owner clear the done todos", async () => {
await t.root( await t.root(
async (ctx) => { async (ctx) => {
const todos = await ctx.get(v1.Todos, { list: "groceries" }); const todos = await ctx.get(Todos, { list: "groceries" });
if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`); if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`);
await todos.add("milk", ctx); await todos.add("milk", ctx);
await todos.add("eggs", ctx); await todos.add("eggs", ctx);
@ -1146,10 +1202,27 @@ Run it like any test:
node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts
``` ```
The test imports `Todos` and `List` from the API module by path. It could not import a contract from a plugin's own The test imports the API by its package name and `Todos` from the plugin's own `contract.ts`, as the facets do:
`contract.ts`: that file imports `@todo/app/v1`, a name only plugin builds resolve. A test of such a contract declares
it again (contracts are data: the same name, version and schema bind), as `docs/plugin-authoring.md` shows; this app <!-- quote: plugins/lists/worker.test.ts -->
keeps the contract it shares in its API instead. ```ts
import * as v1 from "@todo/app/v1";
```
<!-- quote: plugins/lists/worker.test.ts -->
```ts
import { Todos } from "./contract.ts";
```
Both resolve because the API is a workspace package npm linked (chapter I); the plugin's build bundles its own copy of
`contract.ts`, and the two bind, since contracts are data (the same name, version and schema). A third test checks
the refusal of chapter II:
<!-- quote: plugins/lists/worker.test.ts -->
```ts
const refused = await todos.add("", ctx).catch((error: ChordError) => error);
expect(refused).toMatchObject({ code: "invalid", reason: "args" });
```
# Writing a p2 Plugin {#p2 lede="What is different in p2, and one small plugin: a tool for the agent and a chip for the people."} # Writing a p2 Plugin {#p2 lede="What is different in p2, and one small plugin: a tool for the agent and a chip for the people."}
@ -1295,28 +1368,25 @@ a tutorial.
live; `self.open` is the only place, so the store counts as holding a native and restarts at every reload of its live; `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 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). repeats this pattern (example 59's chat does too).
- **A reloaded web plugin loses its own React state.** Decision "Slice 11, P4" promises an in-place reload "keeping what
is on screen (scroll, a half-typed message)". The page does not reload, but the changed plugin's components are new Three more, found the same way, are fixed: a reloaded web plugin lost its own React state (now `usePageState`,
functions, so React mounts them anew and their state (the half-typed todo) is gone. chapter VIII), a plugin's `contract.ts` could not be imported outside builds (the API is now a workspace package,
- **Plugin contracts are hard to test from outside builds.** A test or script cannot import a plugin's chapter I), and plugins could not throw Chord's `invalid` (Chord exports `invalid` and `forbidden`, chapter II).
`contract.ts` (it imports the app's API by a package name only builds resolve), so contracts are declared twice or
moved into the API.
- **No public `invalid`.** Plugins throw `PluginError` with codes of their own; refusing bad arguments with Chord's
`invalid/args`, as the built-in checks do, is not open to them.
# The App's Files {.appendix} # The App's Files {.appendix}
| File | Kind | What it is | | File | Kind | What it is |
| --- | --- | --- | | --- | --- | --- |
| `src/principals.ts` | base | the principal type | | `src/principals.ts` | base | the principal type |
| `src/api/v1/index.ts` | base, every kind | `@todo/app/v1`: Chord's part, `defineFacet`, `List`, `Item`, `Todos` | | `package.json` | base | the API as the workspace package `@todo/app`, its `exports` |
| `src/api/v1/index.ts` | base, every kind | `@todo/app/v1`: Chord's part (with `invalid`, `forbidden`), `defineFacet`, `List` |
| `src/api/v1/server.ts` | base, server | `Http`, the ephemeral `login` | | `src/api/v1/server.ts` | base, server | `Http`, the ephemeral `login` |
| `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `useWatch` | | `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `usePageState`, `useWatch` |
| `src/api/v1/tui.ts` | base, tui | `Screens` | | `src/api/v1/tui.ts` | base, tui | `Screens` |
| `start.ts`, `worker.ts` | base | `startServer`, `startWorker` | | `start.ts`, `worker.ts` | base | `startServer`, `startWorker` |
| `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels | | `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels |
| `src/tui/main.ts` | base, terminal entry | `startTerminalRuntime`, the screens | | `src/tui/main.ts` | base, terminal entry | `startTerminalRuntime`, the screens |
| `plugins/lists/` | plugin | worker (table, store, views), server (routes), web (panel), tui (screen), its test | | `plugins/lists/` | plugin | contract (`Item`, `Todos`), worker (table, store, views), server (routes), web (panel), tui (screen), its test |
| `plugins/login.server.ts` | single-file plugin | the kit's ephemeral login | | `plugins/login.server.ts` | single-file plugin | the kit's ephemeral login |
| `later/harbor/` | plugin, added in step 6 | the Harbor theme for pages and terminals | | `later/harbor/` | plugin, added in step 6 | the Harbor theme for pages and terminals |
| `main.ts`, `screenshots.ts`, `tutorial.test.ts` | example | the steps, the screenshots, the checks | | `main.ts`, `screenshots.ts`, `tutorial.test.ts` | example | the steps, the screenshots, the checks |
@ -1331,9 +1401,11 @@ a tutorial.
| `hostKind(name, params)`, `remote(name, { version, params, hostAddress, variants })`, `local(name, { version, params, members })` | II | host kinds and contracts | | `hostKind(name, params)`, `remote(name, { version, params, hostAddress, variants })`, `local(name, { version, params, members })` | II | host kinds and contracts |
| `host.serve(Type, factory)`; factory `self`: `table`, `writable`, `open`, `derive`, `tx`, `get`, `contribute` | II | an object's parts | | `host.serve(Type, factory)`; factory `self`: `table`, `writable`, `open`, `derive`, `tx`, `get`, `contribute` | II | an object's parts |
| `host.root(fn, ctx, { principal })`, `ctx.get(Type, params)` | II | code outside objects, as a principal | | `host.root(fn, ctx, { principal })`, `ctx.get(Type, params)` | II | code outside objects, as a principal |
| `invalid(reason, message, details?)`, `forbidden(message, details?)` | II, VIII | Chord's errors, thrown by a plugin |
| `self.contribute(Http, { routes })` | III | routes, denied by default | | `self.contribute(Http, { routes })` | III | routes, denied by default |
| `startPage`, `HostProvider`, `useHold`, `useWatch`, `useRegistry` | IV | the page | | `startPage`, `HostProvider`, `useHold`, `useWatch`, `useRegistry` | IV | the page |
| `startTerminalRuntime`, `TuiScope`, `startTerminal` | V | the terminal | | `startTerminalRuntime`, `TuiScope`, `startTerminal` | V | the terminal |
| `usePageState(key, initial)`, `scope.pageState(key, initial)` | IV, VIII | state the page keeps across plugin reloads |
| `self.contribute(Themes, theme)` | VI | a theme | | `self.contribute(Themes, theme)` | VI | a theme |
| `ephemeralLoginFacet<P>()`, `host.load([], ctx)`, `host.connect(link, { principal }, ctx)` | VII | a second user's client | | `ephemeralLoginFacet<P>()`, `host.load([], ctx)`, `host.connect(link, { principal }, ctx)` | VII | a second user's client |
| `app.plugins.state`, `app.applyChanges(ctx)` | VI, VIII | marks and rollouts | | `app.plugins.state`, `app.applyChanges(ctx)` | VI, VIII | marks and rollouts |

View file

@ -216,18 +216,25 @@ stays small: a kind and an id. A user&#39;s profile, if the app had one, would b
<span class="tk-k">type</span> <span class="tk-t">Ctx</span>, <span class="tk-k">type</span> <span class="tk-t">Ctx</span>,
defineTable, defineTable,
eq, eq,
forbidden,
int, int,
invalid,
local, local,
method, method,
num, num,
obj, obj,
<span class="tk-t">PluginError</span>, <span class="tk-t">PluginError</span>,
query, query,
remote,
<span class="tk-k">type</span> <span class="tk-t">Static</span>,
str, str,
<span class="tk-k">type</span> <span class="tk-t">Theme</span>, <span class="tk-k">type</span> <span class="tk-t">Theme</span>,
<span class="tk-t">Themes</span>, <span class="tk-t">Themes</span>,
} <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/index.ts"</span>; } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/index.ts"</span>;
<span class="tk-k">export</span> <span class="tk-k">type</span> { <span class="tk-t">TodoPrincipal</span> } <span class="tk-k">from</span> <span class="tk-s">"../../principals.ts"</span>;</code></pre> <span class="tk-k">export</span> <span class="tk-k">type</span> { <span class="tk-t">TodoPrincipal</span> } <span class="tk-k">from</span> <span class="tk-s">"../../principals.ts"</span>;</code></pre>
<p><code>invalid</code> and <code>forbidden</code> make Chord&#39;s own errors: a plugin refusing arguments throws <code>invalid(&quot;args&quot;, message)</code>,
one refusing a caller <code>forbidden(message)</code>, and the caller gets the same <code>{ code, reason }</code> Chord&#39;s built-in checks
give (chapter VIII). <code>PluginError</code> is for codes of the plugin&#39;s own.</p>
<p>and a <code>defineFacet</code> bound to the principal type, so every factory sees a <code>TodoPrincipal</code>:</p> <p>and a <code>defineFacet</code> bound to the principal type, so every factory sees a <code>TodoPrincipal</code>:</p>
<!-- quote: src/api/v1/index.ts --> <!-- quote: src/api/v1/index.ts -->
<pre><code><span class="tk-c">/** Defines a facet of the todo app: its factories get the app's principals. */</span> <pre><code><span class="tk-c">/** Defines a facet of the todo app: its factories get the app's principals. */</span>
@ -238,6 +245,26 @@ stays small: a kind and an id. A user&#39;s profile, if the app had one, would b
it evaluates it. The plugin sees the very <code>Themes</code> registry and <code>List</code> host kind the process has, not a copy. A it evaluates it. The plugin sees the very <code>Themes</code> registry and <code>List</code> host kind the process has, not a copy. A
version in the path lets the app ship <code>v2</code> beside <code>v1</code> later; one facet uses one version (design §21, &quot;The plugin version in the path lets the app ship <code>v2</code> beside <code>v1</code> later; one facet uses one version (design §21, &quot;The plugin
model&quot;).</p> model&quot;).</p>
<p>The API is also a real package: a <strong>workspace package</strong> of the app&#39;s repository, named in its <code>package.json</code> with an
<code>exports</code> entry per module:</p>
<!-- quote: package.json -->
<pre><code>{
<span class="tk-s">"name"</span>: <span class="tk-s">"@todo/app"</span>,
<span class="tk-s">"version"</span>: <span class="tk-s">"0.0.1"</span>,
<span class="tk-s">"private"</span>: <span class="tk-k">true</span>,
<span class="tk-s">"description"</span>: <span class="tk-s">"The tutorial's todo app: its API (v1 and its per-kind modules), base code and built-in plugins"</span>,
<span class="tk-s">"type"</span>: <span class="tk-s">"module"</span>,
<span class="tk-s">"exports"</span>: {
<span class="tk-s">"./v1"</span>: <span class="tk-s">"./src/api/v1/index.ts"</span>,
<span class="tk-s">"./v1/server"</span>: <span class="tk-s">"./src/api/v1/server.ts"</span>,
<span class="tk-s">"./v1/web"</span>: <span class="tk-s">"./src/api/v1/web.ts"</span>,
<span class="tk-s">"./v1/tui"</span>: <span class="tk-s">"./src/api/v1/tui.ts"</span>
}
}</code></pre>
<p>The repository&#39;s root <code>package.json</code> lists it in <code>workspaces</code> (here Chord&#39;s repository lists
<code>packages/chord/test/examples/61-tutorial</code>), so <code>npm install</code> links <code>node_modules/@todo/app</code> to it. Then Node,
TypeScript, the tests and esbuild resolve <code>@todo/app/v1</code> alike, from base code, from a plugin&#39;s files and from a test
importing a plugin&#39;s <code>contract.ts</code>; plugin builds still leave it out and pass the process&#39;s instance.</p>
<h3 id="the-start-script">The start script</h3> <h3 id="the-start-script">The start script</h3>
<p><code>startServer</code> takes what the app decides and does the rest. <code>start.ts</code> names the API with this process&#39;s modules, the <p><code>startServer</code> takes what the app decides and does the rest. <code>start.ts</code> names the API with this process&#39;s modules, the
facet kinds (which files a plugin may have), and the principals:</p> facet kinds (which files a plugin may have), and the principals:</p>
@ -331,12 +358,15 @@ chapter III). Nothing is the owner because it comes from loopback: any local pro
and an idle list costs nothing. This chapter declares where lists live, what a list offers, and the worker code that and an idle list costs nothing. This chapter declares where lists live, what a list offers, and the worker code that
keeps its todos.</p> keeps its todos.</p>
<h3 id="where-a-list-lives">Where a list lives</h3> <h3 id="where-a-list-lives">Where a list lives</h3>
<p>A <strong>host kind</strong> declares addressable hosts with params; <code>List({ id: &quot;groceries&quot; })</code> is an <strong>address</strong>. A <code>remote</code> <p>A <strong>host kind</strong> declares addressable hosts with params; <code>List({ id: &quot;groceries&quot; })</code> is an <strong>address</strong>. The app&#39;s
contract says, with <code>hostAddress</code>, at which address its objects live. Here is the app&#39;s contract for a list&#39;s todos, host kinds are base code, in the API:</p>
in the API so that every host kind, the tests and command-line code can name it:</p>
<!-- quote: src/api/v1/index.ts --> <!-- quote: src/api/v1/index.ts -->
<pre><code><span class="tk-c">/** A todo list: a worker process of its own, with its own store. */</span> <pre><code><span class="tk-c">/** A todo list: a worker process of its own, with its own store. */</span>
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">List</span> = <span class="tk-f">hostKind</span>(<span class="tk-s">"list"</span>, { id: <span class="tk-f">str</span>() }); <span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">List</span> = <span class="tk-f">hostKind</span>(<span class="tk-s">"list"</span>, { id: <span class="tk-f">str</span>() });</code></pre>
<p>A <code>remote</code> contract says, with <code>hostAddress</code>, at which address its objects live. The contract for a list&#39;s todos is
the lists plugin&#39;s, in its <code>contract.ts</code>, which its facets of every kind import, and so do the steps and its test:</p>
<!-- quote: plugins/lists/contract.ts -->
<pre><code><span class="tk-k">import</span> { arr, bool, int, <span class="tk-t">List</span>, method, num, obj, remote, <span class="tk-k">type</span> <span class="tk-t">Static</span>, str } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1"</span>;
<span class="tk-c">/** A todo: its number in the list, its text, whether it is done, who added it and when. */</span> <span class="tk-c">/** A todo: its number in the list, its text, whether it is done, who added it and when. */</span>
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Item</span> = <span class="tk-f">obj</span>({ id: <span class="tk-f">int</span>(), text: <span class="tk-f">str</span>(), done: <span class="tk-f">bool</span>(), by: <span class="tk-f">str</span>(), at: <span class="tk-f">num</span>() }); <span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Item</span> = <span class="tk-f">obj</span>({ id: <span class="tk-f">int</span>(), text: <span class="tk-f">str</span>(), done: <span class="tk-f">bool</span>(), by: <span class="tk-f">str</span>(), at: <span class="tk-f">num</span>() });
@ -345,8 +375,8 @@ in the API so that every host kind, the tests and command-line code can name it:
<span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Item</span> = <span class="tk-t">Static</span>&lt;<span class="tk-k">typeof</span> <span class="tk-t">Item</span>&gt;; <span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Item</span> = <span class="tk-t">Static</span>&lt;<span class="tk-k">typeof</span> <span class="tk-t">Item</span>&gt;;
<span class="tk-c">/** <span class="tk-c">/**
* A list's todos as one principal sees them, on the list's worker: the newest 50 (live), adding one and ticking one * A list's todos as one principal sees them: the newest 50 (live), adding one and ticking one off (or on again); the
* off (or on again); the owner also clears the done ones. * owner also clears the done ones.
*/</span> */</span>
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Todos</span> = <span class="tk-f">remote</span>(<span class="tk-s">"todo.todos"</span>, { <span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Todos</span> = <span class="tk-f">remote</span>(<span class="tk-s">"todo.todos"</span>, {
version: <span class="tk-n">1</span>, version: <span class="tk-n">1</span>,
@ -440,11 +470,16 @@ when <code>fn</code> returns, or not at all if it throws. The window follows aft
writer, so it knows every change:</p> writer, so it knows every change:</p>
<!-- quote: plugins/lists/worker.ts --> <!-- quote: plugins/lists/worker.ts -->
<pre><code> add: <span class="tk-k">async</span> ({ text, by }, ctx) =&gt; { <pre><code> add: <span class="tk-k">async</span> ({ text, by }, ctx) =&gt; {
<span class="tk-c">// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.</span>
<span class="tk-k">if</span> (text === <span class="tk-s">""</span>) <span class="tk-k">throw</span> <span class="tk-f">invalid</span>(<span class="tk-s">"args"</span>, <span class="tk-s">"a todo needs text"</span>);
<span class="tk-k">const</span> item = { id: next++, text, done: <span class="tk-k">false</span>, by, at: <span class="tk-t">Date</span>.<span class="tk-f">now</span>() }; <span class="tk-k">const</span> item = { id: next++, text, done: <span class="tk-k">false</span>, by, at: <span class="tk-t">Date</span>.<span class="tk-f">now</span>() };
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) =&gt; tx.<span class="tk-f">put</span>(items, item), ctx); <span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) =&gt; tx.<span class="tk-f">put</span>(items, item), ctx);
<span class="tk-c">// In number order: two adds may commit in either order.</span> <span class="tk-c">// In number order: two adds may commit in either order.</span>
window.<span class="tk-f">set</span>([...window.value, item].<span class="tk-f">sort</span>((a, b) =&gt; a.id - b.id).<span class="tk-f">slice</span>(-WINDOW)); window.<span class="tk-f">set</span>([...window.value, item].<span class="tk-f">sort</span>((a, b) =&gt; a.id - b.id).<span class="tk-f">slice</span>(-WINDOW));
<span class="tk-k">return</span> <span class="tk-k">null</span>;</code></pre> <span class="tk-k">return</span> <span class="tk-k">null</span>;</code></pre>
<p>A todo without text is refused with <code>invalid(&quot;args&quot;, …)</code>, from the API: the error crosses every link as it is, so a
caller on a page or in a terminal gets <code>{ code: &quot;invalid&quot;, reason: &quot;args&quot; }</code>, as for arguments that fail the
contract&#39;s schema.</p>
<h3 id="the-view-per-principal">The view per principal</h3> <h3 id="the-view-per-principal">The view per principal</h3>
<p><code>Todos</code> is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:</p> <p><code>Todos</code> is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:</p>
<!-- quote: plugins/lists/worker.ts --> <!-- quote: plugins/lists/worker.ts -->
@ -518,8 +553,9 @@ contributes routes to the kit&#39;s <code>Http</code> registry, which the API&#3
<pre><code><span class="tk-c">// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the</span> <pre><code><span class="tk-c">// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the</span>
<span class="tk-c">// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.</span> <span class="tk-c">// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.</span>
<span class="tk-k">import</span> { defineFacet, <span class="tk-t">Todos</span> } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1"</span>; <span class="tk-k">import</span> { defineFacet } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1"</span>;
<span class="tk-k">import</span> { <span class="tk-t">Http</span> } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1/server"</span>; <span class="tk-k">import</span> { <span class="tk-t">Http</span> } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1/server"</span>;
<span class="tk-k">import</span> { <span class="tk-t">Todos</span> } <span class="tk-k">from</span> <span class="tk-s">"./contract.ts"</span>;
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) =&gt; { <span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) =&gt; {
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, { self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, {
@ -573,7 +609,7 @@ for them:</p>
<pre><code><span class="tk-k">import</span> <span class="tk-k">type</span> { <span class="tk-t">ComponentType</span> } <span class="tk-k">from</span> <span class="tk-s">"react"</span>; <pre><code><span class="tk-k">import</span> <span class="tk-k">type</span> { <span class="tk-t">ComponentType</span> } <span class="tk-k">from</span> <span class="tk-s">"react"</span>;
<span class="tk-k">import</span> { defineRegistry } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/index.ts"</span>; <span class="tk-k">import</span> { defineRegistry } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/index.ts"</span>;
<span class="tk-k">export</span> { useHold, useWatch } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/react/index.ts"</span>; <span class="tk-k">export</span> { useHold, usePageState, useWatch } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/react/index.ts"</span>;
<span class="tk-c">/** A panel of the page: its title and its React component. */</span> <span class="tk-c">/** A panel of the page: its title and its React component. */</span>
<span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Panel</span> = { <span class="tk-k">readonly</span> title: string; <span class="tk-k">readonly</span> <span class="tk-t">Component</span>: <span class="tk-t">ComponentType</span> }; <span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Panel</span> = { <span class="tk-k">readonly</span> title: string; <span class="tk-k">readonly</span> <span class="tk-t">Component</span>: <span class="tk-t">ComponentType</span> };
@ -609,9 +645,11 @@ scripts with the entry&#39;s core facets, and keeps them current. <code>HostProv
when its items change:</p> when its items change:</p>
<!-- quote: plugins/lists/web.ts --> <!-- quote: plugins/lists/web.ts -->
<pre><code><span class="tk-k">function</span> <span class="tk-t">TodoList</span>() { <pre><code><span class="tk-k">function</span> <span class="tk-t">TodoList</span>() {
<span class="tk-k">const</span> todos = <span class="tk-f">useHold</span>(<span class="tk-t">Todos</span>, { list: location.hash.<span class="tk-f">slice</span>(<span class="tk-n">1</span>) || <span class="tk-s">"groceries"</span> }); <span class="tk-k">const</span> list = location.hash.<span class="tk-f">slice</span>(<span class="tk-n">1</span>) || <span class="tk-s">"groceries"</span>;
<span class="tk-k">const</span> todos = <span class="tk-f">useHold</span>(<span class="tk-t">Todos</span>, { list });
<span class="tk-k">const</span> items = <span class="tk-f">useWatch</span>(todos?.items) ?? []; <span class="tk-k">const</span> items = <span class="tk-f">useWatch</span>(todos?.items) ?? [];
<span class="tk-k">const</span> [text, setText] = <span class="tk-f">useState</span>(<span class="tk-s">""</span>); <span class="tk-c">// Kept by the page, not the component: the new code's component finds the half-typed todo.</span>
<span class="tk-k">const</span> [text, setText] = <span class="tk-f">usePageState</span>(<span class="tk-s">`lists.draft.${list}`</span>, <span class="tk-s">""</span>);
<span class="tk-k">const</span> add = (event: { <span class="tk-f">preventDefault</span>(): <span class="tk-k">void</span> }) =&gt; { <span class="tk-k">const</span> add = (event: { <span class="tk-f">preventDefault</span>(): <span class="tk-k">void</span> }) =&gt; {
event.<span class="tk-f">preventDefault</span>(); event.<span class="tk-f">preventDefault</span>();
<span class="tk-k">if</span> (todos === <span class="tk-k">undefined</span> || text.<span class="tk-f">trim</span>() === <span class="tk-s">""</span>) <span class="tk-k">return</span>; <span class="tk-k">if</span> (todos === <span class="tk-k">undefined</span> || text.<span class="tk-f">trim</span>() === <span class="tk-s">""</span>) <span class="tk-k">return</span>;
@ -654,7 +692,8 @@ when its items change:</p>
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) =&gt; self.<span class="tk-f">contribute</span>(<span class="tk-t">Panels</span>, { title: <span class="tk-s">"Groceries"</span>, <span class="tk-t">Component</span>: <span class="tk-t">TodoList</span> }));</code></pre> <span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) =&gt; self.<span class="tk-f">contribute</span>(<span class="tk-t">Panels</span>, { title: <span class="tk-s">"Groceries"</span>, <span class="tk-t">Component</span>: <span class="tk-t">TodoList</span> }));</code></pre>
<p><code>useHold</code> holds a reference while the component is mounted and releases it on unmount; it is <code>undefined</code> on the first <p><code>useHold</code> holds a reference while the component is mounted and releases it on unmount; it is <code>undefined</code> on the first
render. <code>useWatch</code> follows a member. Clicks call methods with <code>BACKGROUND_CONTEXT</code>, which never ends, so a click&#39;s call render. <code>useWatch</code> follows a member. <code>usePageState</code> is <code>useState</code> whose value the page keeps under a key instead of the
component: chapter VIII shows why the half-typed todo needs it. Clicks call methods with <code>BACKGROUND_CONTEXT</code>, which never ends, so a click&#39;s call
is not cancelled when the handler returns. <code>todos?.variant === &quot;owner&quot;</code> narrows the reference: only the owner&#39;s has is not cancelled when the handler returns. <code>todos?.variant === &quot;owner&quot;</code> narrows the reference: only the owner&#39;s has
<code>clear</code>, and TypeScript knows it. The panel&#39;s look reads the active theme&#39;s CSS variables, with class names prefixed <code>clear</code>, and TypeScript knows it. The panel&#39;s look reads the active theme&#39;s CSS variables, with class names prefixed
by the plugin&#39;s name (a <code>*.module.css</code> file would scope them instead):</p> by the plugin&#39;s name (a <code>*.module.css</code> file would scope them instead):</p>
@ -895,14 +934,12 @@ Her todo is hers: <code>coffee (ada)</code>.</p>
</section> </section>
<section class="chapter" id="reload"><header class="opener"><div class="numeral">VIII</div><h2>Changing Code</h2><p class="lede">An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate.</p></header> <section class="chapter" id="reload"><header class="opener"><div class="numeral">VIII</div><h2>Changing Code</h2><p class="lede">An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate.</p></header>
<p class="dropcap">Ada typed &quot; tea &quot; with spaces, and an empty add should do nothing. The fix is in the lists plugin&#39;s worker facet. <p class="dropcap">Ada typed &quot; tea &quot; with spaces, and a blank todo should be refused like an empty one. The fix is one line of the lists
Step 8 makes the edit in the running app&#39;s copy of the plugins, as an editor or an agent would, and applies it:</p> plugin&#39;s worker facet: the view trims the text before the store sees it. Step 8 makes the edit in the running app&#39;s
copy of the plugins, as an editor or an agent would, and applies it:</p>
<!-- quote: main.ts --> <!-- quote: main.ts -->
<pre><code><span class="tk-k">const</span> BEFORE = <span class="tk-s">"add: (text: string, ctx: Ctx) =&gt; store.add({ text, by }, ctx),"</span>; <pre><code><span class="tk-k">const</span> BEFORE = <span class="tk-s">"add: (text: string, ctx: Ctx) =&gt; store.add({ text, by }, ctx),"</span>;
<span class="tk-k">const</span> AFTER = <span class="tk-s">`add: async (text: string, ctx: Ctx) =&gt; { <span class="tk-k">const</span> AFTER = <span class="tk-s">"add: (text: string, ctx: Ctx) =&gt; store.add({ text: text.trim(), by }, ctx),"</span>;
if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx);
return null;
},`</span>;
<span class="tk-k">const</span> workerFile = <span class="tk-f">join</span>(root, <span class="tk-s">"lists"</span>, <span class="tk-s">"worker.ts"</span>); <span class="tk-k">const</span> workerFile = <span class="tk-f">join</span>(root, <span class="tk-s">"lists"</span>, <span class="tk-s">"worker.ts"</span>);
<span class="tk-k">await</span> <span class="tk-f">writeFile</span>(workerFile, (<span class="tk-k">await</span> <span class="tk-f">readFile</span>(workerFile, <span class="tk-s">"utf8"</span>)).<span class="tk-f">replace</span>(BEFORE, AFTER)); <span class="tk-k">await</span> <span class="tk-f">writeFile</span>(workerFile, (<span class="tk-k">await</span> <span class="tk-f">readFile</span>(workerFile, <span class="tk-s">"utf8"</span>)).<span class="tk-f">replace</span>(BEFORE, AFTER));
<span class="tk-k">await</span> <span class="tk-f">until</span>(() =&gt; app.plugins.state.value.dirty.<span class="tk-f">includes</span>(<span class="tk-s">"@todo/lists"</span>)); <span class="tk-k">await</span> <span class="tk-f">until</span>(() =&gt; app.plugins.state.value.dirty.<span class="tk-f">includes</span>(<span class="tk-s">"@todo/lists"</span>));
@ -915,14 +952,16 @@ console.<span class="tk-f">log</span>(<span class="tk-s">"8. lists changed, roll
<span class="tk-k">await</span> adas.<span class="tk-f">add</span>(<span class="tk-s">" tea "</span>, context); <span class="tk-k">await</span> adas.<span class="tk-f">add</span>(<span class="tk-s">" tea "</span>, context);
<span class="tk-k">await</span> <span class="tk-f">until</span>(() =&gt; groceries.items.value.<span class="tk-f">at</span>(-<span class="tk-n">1</span>)?.text === <span class="tk-s">"tea"</span>); <span class="tk-k">await</span> <span class="tk-f">until</span>(() =&gt; groceries.items.value.<span class="tk-f">at</span>(-<span class="tk-n">1</span>)?.text === <span class="tk-s">"tea"</span>);
console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' tea ':"</span>, <span class="tk-f">show</span>(groceries.items.value.<span class="tk-f">slice</span>(-<span class="tk-n">1</span>))); console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' tea ':"</span>, <span class="tk-f">show</span>(groceries.items.value.<span class="tk-f">slice</span>(-<span class="tk-n">1</span>)));
<span class="tk-k">const</span> count = groceries.items.value.length; <span class="tk-k">const</span> refused = <span class="tk-k">await</span> adas.<span class="tk-f">add</span>(<span class="tk-s">" "</span>, context).<span class="tk-f">then</span>(
<span class="tk-k">await</span> adas.<span class="tk-f">add</span>(<span class="tk-s">" "</span>, context); () =&gt; <span class="tk-s">"added"</span>,
console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' ':"</span>, groceries.items.value.length === count ? <span class="tk-s">"ignored"</span> : <span class="tk-s">"added"</span>);</code></pre> (error: <span class="tk-t">ChordError</span>) =&gt; ({ code: error.code, reason: error.reason }),
);
console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' ':"</span>, refused);</code></pre>
<!-- output: main --> <!-- output: main -->
<pre class="plain"><code>8. lists changed, rolled out to: server, list, web, tui <pre class="plain"><code>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 restarted, todo.todos(groceries) parked
ada adds ' tea ': [ ] tea (ada) ada adds ' tea ': [ ] tea (ada)
ada adds ' ': ignored</code></pre> ada adds ' ': { code: 'invalid', reason: 'args' }</code></pre>
<p>The watcher marked <code>@todo/lists</code> dirty when the file changed; <code>applyChanges</code> rebuilt it (one esbuild call per facet) <p>The watcher marked <code>@todo/lists</code> dirty when the file changed; <code>applyChanges</code> rebuilt it (one esbuild call per facet)
and rolled it out host by host. On each host Chord stages the new code (it runs the new factories against a recording and rolled it out host by host. On each host Chord stages the new code (it runs the new factories against a recording
<code>self</code> to see what they declare), commits it in one step, and gives each object of the changed plugin its <strong>fate</strong> <code>self</code> to see what they declare), commits it in one step, and gives each object of the changed plugin its <strong>fate</strong>
@ -953,7 +992,9 @@ and rolled it out host by host. On each host Chord stages the new code (it runs
</tbody></table> </tbody></table>
<p>The store opens a native (the window&#39;s first read), so it restarted and read its window again from the table; the <p>The store opens a native (the window&#39;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&#39;s reference never noticed more than a moment of views waited for it (parked), then went on with the new code. Ada&#39;s reference never noticed more than a moment of
<code>loading</code>, and her next add was trimmed. Had the edit broken the plugin, that plugin alone would have kept its old code <code>loading</code>, and her next add was trimmed. Her blank one is now empty after trimming, so the store refused it with
<code>invalid(&quot;args&quot;, …)</code> (chapter II): her client got <code>{ code: &quot;invalid&quot;, reason: &quot;args&quot; }</code>, Chord&#39;s own error, which
crossed the worker&#39;s link and the server&#39;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.</p> and the report would name the error.</p>
<p>A contract that changes shape needs a new <code>version</code>; old consumers keep working when the provider maps their version <p>A contract that changes shape needs a new <code>version</code>; old consumers keep working when the provider maps their version
(<code>accepts</code>), otherwise they wait with <code>contract_mismatch</code> until they reload. A stored document or table that changes (<code>accepts</code>), otherwise they wait with <code>contract_mismatch</code> until they reload. A stored document or table that changes
@ -968,12 +1009,16 @@ page reload. <code>screenshots.ts</code> adds a count of open todos to the panel
<pre class="plain"><code>wrote docs/tutorial/screens/page.png <pre class="plain"><code>wrote docs/tutorial/screens/page.png
wrote docs/tutorial/screens/harbor.png wrote docs/tutorial/screens/harbor.png
wrote docs/tutorial/screens/edited.png wrote docs/tutorial/screens/edited.png
typed "oat mi"; after the edit: "" typed "oat mi"; after the edit: "oat mi"
the page did not reload: true</code></pre> the page did not reload: true</code></pre>
<figure class="fig"><p><img src="screens/edited.png" alt="The page after the edit"></p> <figure class="fig"><p><img src="screens/edited.png" alt="The page after the edit"></p>
<figcaption><span class="fignum">Figure 3.</span> After the edit of the web facet, applied in place: the count appears; the half-typed &quot;oat mi&quot; is gone, because the panel&#39;s component is new code and React mounted it anew.</figcaption></figure> <figcaption><span class="fignum">Figure 3.</span> After the edit of the web facet, applied in place: the count appears, and the half-typed &quot;oat mi&quot; is still in the box: the page kept it under its key while React mounted the new component.</figcaption></figure>
<p>The page did not reload, and everything outside the changed plugin&#39;s components kept its state. The changed panel&#39;s <p>The page did not reload, and everything outside the changed plugin&#39;s components kept its state. The changed panel
own React state did not: a new component function is a new component to React.</p> itself is new code: its component is a new function, which React mounts anew, so its own <code>useState</code> would start over
(design §21). What must survive a plugin update (a half-typed todo, a scroll position) is <strong>page state</strong>:
<code>usePageState(key, initial)</code> keeps the value in the page&#39;s host under the key, not in the component, so the new
component finds it; a terminal&#39;s <code>TuiScope</code> has the same store as <code>scope.pageState(key, initial)</code>. Page state is any
value, never sent anywhere, and gone with the page.</p>
</section> </section>
<section class="chapter" id="export"><header class="opener"><div class="numeral">IX</div><h2>Moving a List</h2><p class="lede">Export a worker&#39;s data while it runs, import it under another address.</p></header> <section class="chapter" id="export"><header class="opener"><div class="numeral">IX</div><h2>Moving a List</h2><p class="lede">Export a worker&#39;s data while it runs, import it under another address.</p></header>
@ -1006,9 +1051,9 @@ an index of the app&#39;s, if it had one, is the app&#39;s.</p>
production builds it (the same esbuild call, the API&#39;s imports checked), evaluates it with the app&#39;s real API module and production builds it (the same esbuild call, the API&#39;s imports checked), evaluates it with the app&#39;s real API module and
loads it on a new host with an in-memory store, here at a list&#39;s address so it may serve <code>Todos</code>:</p> loads it on a new host with an in-memory store, here at a list&#39;s address so it may serve <code>Todos</code>:</p>
<!-- quote: plugins/lists/worker.test.ts --> <!-- quote: plugins/lists/worker.test.ts -->
<pre><code> <span class="tk-k">let</span> t: <span class="tk-t">TestedPlugin</span>&lt;<span class="tk-t">TodoPrincipal</span>&gt;; <pre><code> <span class="tk-k">let</span> t: <span class="tk-t">TestedPlugin</span>&lt;v1.<span class="tk-t">TodoPrincipal</span>&gt;;
<span class="tk-f">beforeEach</span>(<span class="tk-k">async</span> () =&gt; { <span class="tk-f">beforeEach</span>(<span class="tk-k">async</span> () =&gt; {
t = <span class="tk-k">await</span> <span class="tk-f">testPlugin</span>&lt;<span class="tk-t">TodoPrincipal</span>&gt;( t = <span class="tk-k">await</span> <span class="tk-f">testPlugin</span>&lt;v1.<span class="tk-t">TodoPrincipal</span>&gt;(
<span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./"</span>, <span class="tk-k">import</span>.meta.url)), <span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./"</span>, <span class="tk-k">import</span>.meta.url)),
{ {
kind: <span class="tk-s">"worker"</span>, kind: <span class="tk-s">"worker"</span>,
@ -1026,7 +1071,7 @@ loads it on a new host with an in-memory store, here at a list&#39;s address so
<pre><code> <span class="tk-f">it</span>(<span class="tk-s">"lets the owner clear the done todos"</span>, <span class="tk-k">async</span> () =&gt; { <pre><code> <span class="tk-f">it</span>(<span class="tk-s">"lets the owner clear the done todos"</span>, <span class="tk-k">async</span> () =&gt; {
<span class="tk-k">await</span> t.<span class="tk-f">root</span>( <span class="tk-k">await</span> t.<span class="tk-f">root</span>(
<span class="tk-k">async</span> (ctx) =&gt; { <span class="tk-k">async</span> (ctx) =&gt; {
<span class="tk-k">const</span> todos = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(v1.<span class="tk-t">Todos</span>, { list: <span class="tk-s">"groceries"</span> }); <span class="tk-k">const</span> todos = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">Todos</span>, { list: <span class="tk-s">"groceries"</span> });
<span class="tk-k">if</span> (todos.variant !== <span class="tk-s">"owner"</span>) <span class="tk-k">throw</span> <span class="tk-k">new</span> <span class="tk-t">Error</span>(<span class="tk-s">`the owner got ${todos.variant}`</span>); <span class="tk-k">if</span> (todos.variant !== <span class="tk-s">"owner"</span>) <span class="tk-k">throw</span> <span class="tk-k">new</span> <span class="tk-t">Error</span>(<span class="tk-s">`the owner got ${todos.variant}`</span>);
<span class="tk-k">await</span> todos.<span class="tk-f">add</span>(<span class="tk-s">"milk"</span>, ctx); <span class="tk-k">await</span> todos.<span class="tk-f">add</span>(<span class="tk-s">"milk"</span>, ctx);
<span class="tk-k">await</span> todos.<span class="tk-f">add</span>(<span class="tk-s">"eggs"</span>, ctx); <span class="tk-k">await</span> todos.<span class="tk-f">add</span>(<span class="tk-s">"eggs"</span>, ctx);
@ -1042,10 +1087,17 @@ loads it on a new host with an in-memory store, here at a list&#39;s address so
knows which stub facets to add. Registries it contributes to that nobody serves are collected (<code>t.contributions(R)</code>). knows which stub facets to add. Registries it contributes to that nobody serves are collected (<code>t.contributions(R)</code>).
Run it like any test:</p> Run it like any test:</p>
<pre><code>node ../../node_modules/vitest/dist/cli.js --run test/examples/<span class="tk-n">61</span>-tutorial/plugins/lists/worker.test.ts</code></pre> <pre><code>node ../../node_modules/vitest/dist/cli.js --run test/examples/<span class="tk-n">61</span>-tutorial/plugins/lists/worker.test.ts</code></pre>
<p>The test imports <code>Todos</code> and <code>List</code> from the API module by path. It could not import a contract from a plugin&#39;s own <p>The test imports the API by its package name and <code>Todos</code> from the plugin&#39;s own <code>contract.ts</code>, as the facets do:</p>
<code>contract.ts</code>: that file imports <code>@todo/app/v1</code>, a name only plugin builds resolve. A test of such a contract declares <!-- quote: plugins/lists/worker.test.ts -->
it again (contracts are data: the same name, version and schema bind), as <code>docs/plugin-authoring.md</code> shows; this app <pre><code><span class="tk-k">import</span> * <span class="tk-k">as</span> v1 <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1"</span>;</code></pre>
keeps the contract it shares in its API instead.</p> <!-- quote: plugins/lists/worker.test.ts -->
<pre><code><span class="tk-k">import</span> { <span class="tk-t">Todos</span> } <span class="tk-k">from</span> <span class="tk-s">"./contract.ts"</span>;</code></pre>
<p>Both resolve because the API is a workspace package npm linked (chapter I); the plugin&#39;s build bundles its own copy of
<code>contract.ts</code>, and the two bind, since contracts are data (the same name, version and schema). A third test checks
the refusal of chapter II:</p>
<!-- quote: plugins/lists/worker.test.ts -->
<pre><code> <span class="tk-k">const</span> refused = <span class="tk-k">await</span> todos.<span class="tk-f">add</span>(<span class="tk-s">""</span>, ctx).<span class="tk-k">catch</span>((error: <span class="tk-t">ChordError</span>) =&gt; error);
<span class="tk-f">expect</span>(refused).<span class="tk-f">toMatchObject</span>({ code: <span class="tk-s">"invalid"</span>, reason: <span class="tk-s">"args"</span> });</code></pre>
</section> </section>
<section class="chapter" id="p2"><header class="opener"><div class="numeral">XI</div><h2>Writing a p2 Plugin</h2><p class="lede">What is different in p2, and one small plugin: a tool for the agent and a chip for the people.</p></header> <section class="chapter" id="p2"><header class="opener"><div class="numeral">XI</div><h2>Writing a p2 Plugin</h2><p class="lede">What is different in p2, and one small plugin: a tool for the agent and a chip for the people.</p></header>
@ -1207,15 +1259,10 @@ a tutorial.</p>
live; <code>self.open</code> is the only place, so the store counts as holding a native and restarts at every reload of its live; <code>self.open</code> 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 plugin, parking its views, though it holds nothing open. Tables have no watch, so every table-backed live view
repeats this pattern (example 59&#39;s chat does too).</li> repeats this pattern (example 59&#39;s chat does too).</li>
<li><strong>A reloaded web plugin loses its own React state.</strong> Decision &quot;Slice 11, P4&quot; promises an in-place reload &quot;keeping what
is on screen (scroll, a half-typed message)&quot;. The page does not reload, but the changed plugin&#39;s components are new
functions, so React mounts them anew and their state (the half-typed todo) is gone.</li>
<li><strong>Plugin contracts are hard to test from outside builds.</strong> A test or script cannot import a plugin&#39;s
<code>contract.ts</code> (it imports the app&#39;s API by a package name only builds resolve), so contracts are declared twice or
moved into the API.</li>
<li><strong>No public <code>invalid</code>.</strong> Plugins throw <code>PluginError</code> with codes of their own; refusing bad arguments with Chord&#39;s
<code>invalid/args</code>, as the built-in checks do, is not open to them.</li>
</ul> </ul>
<p>Three more, found the same way, are fixed: a reloaded web plugin lost its own React state (now <code>usePageState</code>,
chapter VIII), a plugin&#39;s <code>contract.ts</code> could not be imported outside builds (the API is now a workspace package,
chapter I), and plugins could not throw Chord&#39;s <code>invalid</code> (Chord exports <code>invalid</code> and <code>forbidden</code>, chapter II).</p>
</section> </section>
<section class="chapter appendix" id="the-app-s-files"><header class="opener"><div class="numeral">A</div><h2>The App&#39;s Files</h2></header> <section class="chapter appendix" id="the-app-s-files"><header class="opener"><div class="numeral">A</div><h2>The App&#39;s Files</h2></header>
@ -1233,9 +1280,14 @@ moved into the API.</li>
<td>the principal type</td> <td>the principal type</td>
</tr> </tr>
<tr> <tr>
<td><code>package.json</code></td>
<td>base</td>
<td>the API as the workspace package <code>@todo/app</code>, its <code>exports</code></td>
</tr>
<tr>
<td><code>src/api/v1/index.ts</code></td> <td><code>src/api/v1/index.ts</code></td>
<td>base, every kind</td> <td>base, every kind</td>
<td><code>@todo/app/v1</code>: Chord&#39;s part, <code>defineFacet</code>, <code>List</code>, <code>Item</code>, <code>Todos</code></td> <td><code>@todo/app/v1</code>: Chord&#39;s part (with <code>invalid</code>, <code>forbidden</code>), <code>defineFacet</code>, <code>List</code></td>
</tr> </tr>
<tr> <tr>
<td><code>src/api/v1/server.ts</code></td> <td><code>src/api/v1/server.ts</code></td>
@ -1245,7 +1297,7 @@ moved into the API.</li>
<tr> <tr>
<td><code>src/api/v1/web.ts</code></td> <td><code>src/api/v1/web.ts</code></td>
<td>base, web</td> <td>base, web</td>
<td><code>Panels</code>, <code>useHold</code>, <code>useWatch</code></td> <td><code>Panels</code>, <code>useHold</code>, <code>usePageState</code>, <code>useWatch</code></td>
</tr> </tr>
<tr> <tr>
<td><code>src/api/v1/tui.ts</code></td> <td><code>src/api/v1/tui.ts</code></td>
@ -1270,7 +1322,7 @@ moved into the API.</li>
<tr> <tr>
<td><code>plugins/lists/</code></td> <td><code>plugins/lists/</code></td>
<td>plugin</td> <td>plugin</td>
<td>worker (table, store, views), server (routes), web (panel), tui (screen), its test</td> <td>contract (<code>Item</code>, <code>Todos</code>), worker (table, store, views), server (routes), web (panel), tui (screen), its test</td>
</tr> </tr>
<tr> <tr>
<td><code>plugins/login.server.ts</code></td> <td><code>plugins/login.server.ts</code></td>
@ -1330,6 +1382,11 @@ moved into the API.</li>
<td>code outside objects, as a principal</td> <td>code outside objects, as a principal</td>
</tr> </tr>
<tr> <tr>
<td><code>invalid(reason, message, details?)</code>, <code>forbidden(message, details?)</code></td>
<td>II, VIII</td>
<td>Chord&#39;s errors, thrown by a plugin</td>
</tr>
<tr>
<td><code>self.contribute(Http, { routes })</code></td> <td><code>self.contribute(Http, { routes })</code></td>
<td>III</td> <td>III</td>
<td>routes, denied by default</td> <td>routes, denied by default</td>
@ -1345,6 +1402,11 @@ moved into the API.</li>
<td>the terminal</td> <td>the terminal</td>
</tr> </tr>
<tr> <tr>
<td><code>usePageState(key, initial)</code>, <code>scope.pageState(key, initial)</code></td>
<td>IV, VIII</td>
<td>state the page keeps across plugin reloads</td>
</tr>
<tr>
<td><code>self.contribute(Themes, theme)</code></td> <td><code>self.contribute(Themes, theme)</code></td>
<td>VI</td> <td>VI</td>
<td>a theme</td> <td>a theme</td>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -99,7 +99,7 @@ export class PluginError extends Error {
} }
} }
/** An `invalid` error with the given reason. */ /** An `invalid` error with the given reason: a plugin throws it for arguments or params it refuses. */
export function invalid( export function invalid(
reason: ChordErrorReason<"invalid">, reason: ChordErrorReason<"invalid">,
message: string, message: string,
@ -109,6 +109,15 @@ export function invalid(
return new ChordError({ code: "invalid", reason, message, details }) as Extract<ChordError, { code: "invalid" }>; return new ChordError({ code: "invalid", reason, message, details }) as Extract<ChordError, { code: "invalid" }>;
} }
/**
* A `forbidden` error without a reason: what a plugin or an `authorize` throws for a principal it refuses (`revoked` is
* Chord's own, for a revoked authority).
*/
export function forbidden(message: string, details?: JsonValue): Extract<ChordError, { readonly code: "forbidden" }> {
// The constructor's return type cannot narrow on its argument.
return new ChordError({ code: "forbidden", message, details }) as Extract<ChordError, { code: "forbidden" }>;
}
/** `error` itself when it is a ChordError, otherwise an `unavailable` error naming `label`, with `error` as cause. */ /** `error` itself when it is a ChordError, otherwise an `unavailable` error naming `label`, with `error` as cause. */
export function asChordError(error: unknown, label: string): ChordError { export function asChordError(error: unknown, label: string): ChordError {
if (isChordError(error)) return error; if (isChordError(error)) return error;

View file

@ -1,9 +1,12 @@
// Data for apps: Chord's errors and plugin errors, principals, and how a plugin's open reports a broken configuration. // Data for apps: Chord's errors (with `invalid` and `forbidden`, the two a plugin throws), plugin errors, principals,
// and how a plugin's open reports a broken configuration.
export { export {
ChordError, ChordError,
type ChordErrorCode, type ChordErrorCode,
type ChordErrorReason, type ChordErrorReason,
forbidden,
invalid,
isChordError, isChordError,
PluginError, PluginError,
} from "./errors.ts"; } from "./errors.ts";

View file

@ -6,7 +6,7 @@
import type { Context } from "../context/index.ts"; import type { Context } from "../context/index.ts";
import type { Address, HostKind, KindShape } from "../contracts/index.ts"; import type { Address, HostKind, KindShape } from "../contracts/index.ts";
import { kindParams, type ParamsData } from "../contracts/internal.ts"; import { kindParams, type ParamsData } from "../contracts/internal.ts";
import { ChordError, type Principal } from "../data/index.ts"; import { ChordError, forbidden, type Principal } from "../data/index.ts";
import { asChordError, checkAddress } from "../data/internal.ts"; import { asChordError, checkAddress } from "../data/internal.ts";
import type { Codec, Link, PartLimits } from "../link/index.ts"; import type { Codec, Link, PartLimits } from "../link/index.ts";
import { type HostLink, Path, type PathEvent, type Upstream } from "../remote/internal.ts"; import { type HostLink, Path, type PathEvent, type Upstream } from "../remote/internal.ts";
@ -87,7 +87,7 @@ export class Route {
path(params: unknown, principal: Principal | undefined, context: Context): Path { path(params: unknown, principal: Principal | undefined, context: Context): Path {
const address = checkAddress(this.kind, this.#params, params, context); const address = checkAddress(this.kind, this.#params, params, context);
if (principal === undefined || !this.#options.authorize(principal, address)) { if (principal === undefined || !this.#options.authorize(principal, address)) {
throw new ChordError({ code: "forbidden", message: `${address.key}: not authorized` }); throw forbidden(`${address.key}: not authorized`);
} }
let routed = this.#addresses.get(address.key); let routed = this.#addresses.get(address.key);
if (routed === undefined) { if (routed === undefined) {

View file

@ -5,7 +5,7 @@
import { BACKGROUND_CONTEXT } from "../../context/index.ts"; import { BACKGROUND_CONTEXT } from "../../context/index.ts";
import { type Address, method, obj, remote, str } from "../../contracts/index.ts"; import { type Address, method, obj, remote, str } from "../../contracts/index.ts";
import { ChordError, type Principal } from "../../data/index.ts"; import { forbidden, type Principal } from "../../data/index.ts";
import { canonicalJson } from "../../data/principal.ts"; import { canonicalJson } from "../../data/principal.ts";
import { defineFacet, type Facet } from "../../host/index.ts"; import { defineFacet, type Facet } from "../../host/index.ts";
import type { JsonValue } from "../../json.ts"; import type { JsonValue } from "../../json.ts";
@ -35,7 +35,7 @@ export function hostDataFacet<P extends Principal>(store: Store, files: string,
return defineFacet<P>((host) => return defineFacet<P>((host) =>
host.serve(HostData, (_params, principal) => { host.serve(HostData, (_params, principal) => {
if (canonicalJson(principal as JsonValue, BACKGROUND_CONTEXT) !== serverKey) { if (canonicalJson(principal as JsonValue, BACKGROUND_CONTEXT) !== serverKey) {
throw new ChordError({ code: "forbidden", message: "chord.app.data: the server's only" }); throw forbidden("chord.app.data: the server's only");
} }
return { return {
variant: "main", variant: "main",

View file

@ -9,6 +9,10 @@ peer), non-React apps never load it. No components, no styling.
- `useWatch(watchable)`: its current value (`undefined` while it has none), re-rendering on change - `useWatch(watchable)`: its current value (`undefined` while it has none), re-rendering on change
(`useSyncExternalStore` over `watchStore`). (`useSyncExternalStore` over `watchStore`).
- `useRegistry(R)`: the registry's composed value; `useRefState(ref)`: the reference's state. - `useRegistry(R)`: the registry's composed value; `useRefState(ref)`: the reference's state.
- A component unmounted by a reload has not left (design §21): its releases are plain releases. - `usePageState(key, initial)`: `useState`'s pair, the value kept by the page (its host's life) under `key`
(`surfaces/page-state.ts`, shared with `TuiScope.pageState`), so a component remounted by a plugin reload keeps it.
- A component unmounted by a reload has not left (design §21): its releases are plain releases. A component of a
reloaded plugin is a new function and remounts, its `useState` starting over: what must survive is page state.
Tests: `test/node/react/` (`stores.test.ts` in Node, `hooks.test.ts` in a real browser). Tests: `test/node/react/` (`stores.test.ts` in Node, `hooks.test.ts` in a real browser); the page state alone in
`test/surfaces/page-state.test.ts`.

View file

@ -1,7 +1,8 @@
// React bindings (slice 11 §3, decision "Slice 11, P7"; p2's `useObject`, `useReadable`, `useRegistry`): a host in // React bindings (slice 11 §3, decision "Slice 11, P7"; p2's `useObject`, `useReadable`, `useRegistry`): a host in
// context, references held for a component's life, and watchables read with `useSyncExternalStore`. A component // context, references held for a component's life, watchables read with `useSyncExternalStore`, and page state kept
// unmounted by a reload has not left (design §21): its releases are plain releases, presence is the app's. No // for the host's life. A component unmounted by a reload has not left (design §21): its releases are plain releases,
// components, no styling. // presence is the app's. A component of a reloaded plugin is a new function and remounts, its `useState` starting
// over; what must survive is page state (decision "Tutorial gaps" (1)). No components, no styling.
import { import {
createContext, createContext,
@ -19,6 +20,7 @@ import type { Principal } from "../data/index.ts";
import { canonicalJson } from "../data/principal.ts"; import { canonicalJson } from "../data/principal.ts";
import type { Held, Host, RefState } from "../host/index.ts"; import type { Held, Host, RefState } from "../host/index.ts";
import type { Registry } from "../registries/index.ts"; import type { Registry } from "../registries/index.ts";
import { pageState } from "../surfaces/page-state.ts";
import type { NativeWatchable, Watchable } from "../watch.ts"; import type { NativeWatchable, Watchable } from "../watch.ts";
import { watchStore } from "./stores.ts"; import { watchStore } from "./stores.ts";
@ -77,3 +79,13 @@ export function useRegistry<C, V>(R: Registry<C, V>): V | undefined {
export function useRefState(ref: { readonly state: NativeWatchable<RefState> } | undefined): RefState | undefined { export function useRefState(ref: { readonly state: NativeWatchable<RefState> } | undefined): RefState | undefined {
return useWatch(ref?.state); return useWatch(ref?.state);
} }
/**
* A value the page keeps under `key` for its host's life, not the component's, with its setter (`useState`'s pair): a
* component remounted by a plugin reload, a new function, finds what it had; a new key starts at `initial`. Any value
* (it never crosses a link); gone with the page.
*/
export function usePageState<T>(key: string, initial: T): readonly [T, (value: T) => void] {
const state = pageState(useHost(), key, initial);
return [useSyncExternalStore(state.subscribe, state.read, state.read), state.set];
}

View file

@ -1,4 +1,4 @@
// Chord v2's React bindings (`@earendil-works/chord/react`; slice 11 §3): optional, non-React apps never load them. // Chord v2's React bindings (`@earendil-works/chord/react`; slice 11 §3): optional, non-React apps never load them.
export { HostProvider, useHold, useHost, useRefState, useRegistry, useWatch } from "./hooks.ts"; export { HostProvider, useHold, useHost, usePageState, useRefState, useRegistry, useWatch } from "./hooks.ts";
export { type ExternalStore, watchStore } from "./stores.ts"; export { type ExternalStore, watchStore } from "./stores.ts";

View file

@ -0,0 +1,56 @@
// Page state (decision "Tutorial gaps" (1), refining "Slice 11, P4"): values a client host keeps for its UI by key,
// beside its plugins, so a component of a reloaded plugin (a new function, which React or a terminal scope mounts
// anew) finds what it had: a half-typed message, a scroll position. Same-host values of any kind, never on the wire;
// gone with the host (a page reload, a terminal's new base bundle). `usePageState` and `TuiScope.pageState` read it.
import type { Principal } from "../data/index.ts";
import type { Host } from "../host/index.ts";
/** One key's value in a host's page state. Its functions are bound: React's `useSyncExternalStore` takes them as is. */
export type PageState<T> = {
/** The value now. */
readonly read: () => T;
/** Sets the value; a new value (by identity) calls every subscriber. */
readonly set: (value: T) => void;
/** Calls `changed` after every set that changed the value, until the returned unsubscribe. */
readonly subscribe: (changed: () => void) => () => void;
};
/** Each client host's page state by key, kept as long as the host. */
const PAGE_STATES = new WeakMap<object, Map<string, PageState<unknown>>>();
/** The page state of `host` at `key`: what was kept there, or `initial` when the key is new. */
export function pageState<T, P extends Principal = Principal>(host: Host<P>, key: string, initial: T): PageState<T> {
let states = PAGE_STATES.get(host);
if (states === undefined) {
states = new Map();
PAGE_STATES.set(host, states);
}
let state = states.get(key);
if (state === undefined) {
state = keptValue<unknown>(initial);
states.set(key, state);
}
// A key is the app's: whoever names it names its type too.
return state as PageState<T>;
}
/** A value with its subscribers. */
function keptValue<T>(initial: T): PageState<T> {
let value = initial;
const subscribers = new Set<() => void>();
return {
read: () => value,
set: (next) => {
if (next === value) return;
value = next;
for (const changed of subscribers) changed();
},
subscribe: (changed) => {
subscribers.add(changed);
return () => {
subscribers.delete(changed);
};
},
};
}

View file

@ -23,7 +23,9 @@ startTerminalRuntime<P>({
- `channel.ts`: the sandbox's side: requests, `RelayLink` (a Chord link whose bytes the launcher relays), input and - `channel.ts`: the sandbox's side: requests, `RelayLink` (a Chord link whose bytes the launcher relays), input and
resizes. `terminal.ts`: the bridged terminal (size, write, input, resize). `theme.ts`: the theme lookup (`fg`, `bg`, resizes. `terminal.ts`: the bridged terminal (size, write, input, resize). `theme.ts`: the theme lookup (`fg`, `bg`,
`onChange`). `runtime.ts`: the client (`startClient` with `TuiScripts`; scripts run with `vm`; no wakes). `onChange`). `runtime.ts`: the client (`startClient` with `TuiScripts`; scripts run with `vm`; no wakes).
- `scope.ts`: `TuiScope(host, changed)`: `hold`, `watch` (a reader, `undefined` while no value), `registry`, `dispose` - `scope.ts`: `TuiScope(host, changed)`: `hold`, `watch` (a reader, `undefined` while no value), `registry`,
(no `changed` after it). References wait and rebind by themselves; no terminal library dependency. `pageState(key, initial)` (the host's value at `key`, shared with React's `usePageState`: a scope opened again after a
plugin reload finds it), `dispose` (no `changed` after it). References wait and rebind by themselves; no terminal
library dependency.
Tests: `test/tui/` and, with the launcher and sandbox, `test/node/tui/`. Tests: `test/tui/` and, with the launcher and sandbox, `test/node/tui/`.

View file

@ -2,6 +2,7 @@
// the app's terminal entry, the bridged terminal, the theme lookup and `TuiScope`. The launcher and the server side are // the app's terminal entry, the bridged terminal, the theme lookup and `TuiScope`. The launcher and the server side are
// `/tui/launcher` and `/surfaces`'s `tuiSurface`. // `/tui/launcher` and `/surfaces`'s `tuiSurface`.
export type { PageState } from "../surfaces/page-state.ts";
export type { Bridge, BridgeRequest, BridgeStart, LauncherMessage, SandboxMessage } from "./bridge.ts"; export type { Bridge, BridgeRequest, BridgeStart, LauncherMessage, SandboxMessage } from "./bridge.ts";
export { export {
type AttachedFile, type AttachedFile,

View file

@ -1,13 +1,15 @@
// `TuiScope` (slice 11 §4, decision "Slice 11, P9"; p2's `TuiScope`): a terminal component's Chord resources, the // `TuiScope` (slice 11 §4, decision "Slice 11, P9"; p2's `TuiScope`): a terminal component's Chord resources, the
// counterpart of the React bindings for terminal UI libraries. It holds references, follows watchables and registries, // counterpart of the React bindings for terminal UI libraries. It holds references, follows watchables and registries,
// calls one `changed` callback (the app's redraw) on every change, and releases everything at `dispose`. References // calls one `changed` callback (the app's redraw) on every change, and releases everything at `dispose`. Its page state
// wait and rebind by themselves (no retry loop, unlike p2's); no terminal library dependency. // is the host's, shared by every scope (React's `usePageState`): a scope opened again after a plugin reload finds it.
// References wait and rebind by themselves (no retry loop, unlike p2's); no terminal library dependency.
import { BACKGROUND_CONTEXT, withAbortSignal } from "../context/index.ts"; import { BACKGROUND_CONTEXT, withAbortSignal } from "../context/index.ts";
import type { Contract, ParamsOf } from "../contracts/index.ts"; import type { Contract, ParamsOf } from "../contracts/index.ts";
import type { Principal } from "../data/index.ts"; import type { Principal } from "../data/index.ts";
import type { Held, Host } from "../host/index.ts"; import type { Held, Host } from "../host/index.ts";
import type { Registry } from "../registries/index.ts"; import type { Registry } from "../registries/index.ts";
import { type PageState, pageState } from "../surfaces/page-state.ts";
import type { NativeWatchable, Watchable } from "../watch.ts"; import type { NativeWatchable, Watchable } from "../watch.ts";
/** A component's scope: what it holds and follows, until `dispose`. */ /** A component's scope: what it holds and follows, until `dispose`. */
@ -68,6 +70,17 @@ export class TuiScope<P extends Principal = Principal> {
return () => read(); return () => read();
} }
/**
* The host's page state at `key`, kept for the terminal's life, not the scope's (a new key starts at `initial`); its
* changes call `changed` until `dispose`.
*/
pageState<T>(key: string, initial: T): PageState<T> {
const state = pageState(this.#host, key, initial);
// A closure of its own: two scopes may share one `changed`, and subscribers are a set of functions.
this.#releases.push(state.subscribe(() => this.#changed()));
return state;
}
/** Releases every reference and ends every watch; `changed` is not called after it. */ /** Releases every reference and ends every watch; `changed` is not called after it. */
dispose(): void { dispose(): void {
this.#abort.abort(); this.#abort.abort();

View file

@ -9,11 +9,12 @@ import { cp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { List } from "@todo/app/v1";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
import { type ChordError, createHost, WebScripts, type WebSocketLike, webSocketLink } from "../../../src/index.ts"; import { type ChordError, createHost, WebScripts, type WebSocketLike, webSocketLink } from "../../../src/index.ts";
import { type RunningTerminal, requireSandboxNode, startTerminal } from "../../../src/node/tui/index.ts"; import { type RunningTerminal, requireSandboxNode, startTerminal } from "../../../src/node/tui/index.ts";
import { type Item, List, Todos } from "./src/api/v1/index.ts"; import { type Item, Todos } from "./plugins/lists/contract.ts";
import type { TodoPrincipal } from "./src/principals.ts"; import type { TodoPrincipal } from "./src/principals.ts";
import { startTodo, todoDirectories } from "./start.ts"; import { startTodo, todoDirectories } from "./start.ts";
@ -157,17 +158,15 @@ if (adas.variant === "member") {
await until(() => groceries.items.value.some(({ by }) => by === "ada")); await until(() => groceries.items.value.some(({ by }) => by === "ada"));
console.log(" the server's reference sees:", show(groceries.items.value.slice(-1))); console.log(" the server's reference sees:", show(groceries.items.value.slice(-1)));
// 8. An edit applied in place: the lists plugin's worker facet trims a todo's text and ignores an empty one. The // 8. An edit applied in place: the lists plugin's worker facet trims a todo's text, so a blank one is empty and the
// store refuses it with Chord's `invalid/args`, which reaches Ada as it would a refused argument. The
// watcher marks the plugin dirty; applying the changes rolls it out to the server, the list's worker, the page and // watcher marks the plugin dirty; applying the changes rolls it out to the server, the list's worker, the page and
// the terminal. On the worker each object of the plugin takes its fate: the store opens a native, so it restarts // the terminal. On the worker each object of the plugin takes its fate: the store opens a native, so it restarts
// (its todos are in the store, and it reads them again); the views wait for it, then go on with the new code. Ada's // (its todos are in the store, and it reads them again); the views wait for it, then go on with the new code. Ada's
// reference follows them. // reference follows them.
// The edit: `add` in the `Todos` view, before and after. // The edit: `add` in the `Todos` view, before and after.
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),"; const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
const AFTER = `add: async (text: string, ctx: Ctx) => { const AFTER = "add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),";
if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx);
return null;
},`;
const workerFile = join(root, "lists", "worker.ts"); const workerFile = join(root, "lists", "worker.ts");
await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER)); await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
await until(() => app.plugins.state.value.dirty.includes("@todo/lists")); await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
@ -180,9 +179,11 @@ for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) {
await adas.add(" tea ", context); await adas.add(" tea ", context);
await until(() => groceries.items.value.at(-1)?.text === "tea"); await until(() => groceries.items.value.at(-1)?.text === "tea");
console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1))); console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1)));
const count = groceries.items.value.length; const refused = await adas.add(" ", context).then(
await adas.add(" ", context); () => "added",
console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added"); (error: ChordError) => ({ code: error.code, reason: error.reason }),
);
console.log(" ada adds ' ':", refused);
// 9. Export and import. The list's running worker writes its own store and files as one archive and goes on; importing // 9. Export and import. The list's running worker writes its own store and files as one archive and goes on; importing
// the archive makes a new list with the same todos. An address that has data is never overwritten. // the archive makes a new list with the same todos. An address that has data is never overwritten.

View file

@ -0,0 +1,13 @@
{
"name": "@todo/app",
"version": "0.0.1",
"private": true,
"description": "The tutorial's todo app: its API (v1 and its per-kind modules), base code and built-in plugins",
"type": "module",
"exports": {
"./v1": "./src/api/v1/index.ts",
"./v1/server": "./src/api/v1/server.ts",
"./v1/web": "./src/api/v1/web.ts",
"./v1/tui": "./src/api/v1/tui.ts"
}
}

View file

@ -0,0 +1,30 @@
// The lists plugin's contract: a list's todos as one principal sees them, on the list's worker. Its facets of every
// kind import it, and so do code and tests outside the plugin (they resolve `@todo/app/v1` as plugins do).
import { arr, bool, int, List, method, num, obj, remote, type Static, str } from "@todo/app/v1";
/** A todo: its number in the list, its text, whether it is done, who added it and when. */
export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
/** A todo (see `Item`). */
export type Item = Static<typeof Item>;
/**
* A list's todos as one principal sees them: the newest 50 (live), adding one and ticking one off (or on again); the
* owner also clears the done ones.
*/
export const Todos = remote("todo.todos", {
version: 1,
params: { list: str() },
hostAddress: ({ list }) => List({ id: list }),
variants: {
member: {
state: { items: arr(Item) },
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })) },
},
owner: {
state: { items: arr(Item) },
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })), clear: method() },
},
},
});

View file

@ -1,8 +1,9 @@
// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the // The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the
// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them. // kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.
import { defineFacet, Todos } from "@todo/app/v1"; import { defineFacet } from "@todo/app/v1";
import { Http } from "@todo/app/v1/server"; import { Http } from "@todo/app/v1/server";
import { Todos } from "./contract.ts";
export default defineFacet((_host, self) => { export default defineFacet((_host, self) => {
self.contribute(Http, { self.contribute(Http, {

View file

@ -1,7 +1,8 @@
// The lists plugin in the terminal: the groceries list's todos, live; done ones muted. // The lists plugin in the terminal: the groceries list's todos, live; done ones muted.
import { defineFacet, Todos } from "@todo/app/v1"; import { defineFacet } from "@todo/app/v1";
import { Screens } from "@todo/app/v1/tui"; import { Screens } from "@todo/app/v1/tui";
import { Todos } from "./contract.ts";
export default defineFacet((_host, self) => export default defineFacet((_host, self) =>
self.contribute(Screens, { self.contribute(Screens, {

View file

@ -1,15 +1,19 @@
// The lists plugin on the page: a panel with a list's todos (live), a box to add one, a click to tick one off, and for // The lists plugin on the page: a panel with a list's todos (live), a box to add one, a click to tick one off, and for
// the owner "Clear done". The list is the page's `#hash`, `groceries` without one. // the owner "Clear done". The list is the page's `#hash`, `groceries` without one. The half-typed todo is page state:
// a reload of this plugin remounts the panel, and the page keeps what was typed.
import { BACKGROUND_CONTEXT as context, defineFacet, Todos } from "@todo/app/v1"; import { BACKGROUND_CONTEXT as context, defineFacet } from "@todo/app/v1";
import { Panels, useHold, useWatch } from "@todo/app/v1/web"; import { Panels, useHold, usePageState, useWatch } from "@todo/app/v1/web";
import { createElement as h, useState } from "react"; import { createElement as h } from "react";
import { Todos } from "./contract.ts";
import "./lists.css"; import "./lists.css";
function TodoList() { function TodoList() {
const todos = useHold(Todos, { list: location.hash.slice(1) || "groceries" }); const list = location.hash.slice(1) || "groceries";
const todos = useHold(Todos, { list });
const items = useWatch(todos?.items) ?? []; const items = useWatch(todos?.items) ?? [];
const [text, setText] = useState(""); // Kept by the page, not the component: the new code's component finds the half-typed todo.
const [text, setText] = usePageState(`lists.draft.${list}`, "");
const add = (event: { preventDefault(): void }) => { const add = (event: { preventDefault(): void }) => {
event.preventDefault(); event.preventDefault();
if (todos === undefined || text.trim() === "") return; if (todos === undefined || text.trim() === "") return;

View file

@ -1,22 +1,25 @@
// The lists plugin's worker facet, tested alone with `testPlugin`: built as production builds it, evaluated with the // The lists plugin's worker facet, tested alone with `testPlugin`: built as production builds it, evaluated with the
// app's real API module and loaded on a host of its own (an in-memory store) at a list's address. Each test runs code // app's real API module and loaded on a host of its own (an in-memory store) at a list's address. Each test runs code
// as a principal of its choice: a user gets the `member` variant, the owner `owner`; a todo keeps who added it. // as a principal of its choice: a user gets the `member` variant, the owner `owner`; a todo keeps who added it, and one
// without text is refused. The test imports the app's API by its package name and the plugin's contract from its
// `contract.ts`, as the facets do.
// Run from packages/chord: // Run from packages/chord:
// node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts // node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as v1 from "@todo/app/v1";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BACKGROUND_CONTEXT as context } from "../../../../../src/context/index.ts"; import { BACKGROUND_CONTEXT as context } from "../../../../../src/context/index.ts";
import type { ChordError } from "../../../../../src/index.ts";
import { type TestedPlugin, testPlugin } from "../../../../../src/node/plugins/index.ts"; import { type TestedPlugin, testPlugin } from "../../../../../src/node/plugins/index.ts";
import * as v1 from "../../src/api/v1/index.ts"; import { Todos } from "./contract.ts";
import type { TodoPrincipal } from "../../src/principals.ts";
const ADA = { kind: "user", id: "u1", name: "ada" } as const; const ADA = { kind: "user", id: "u1", name: "ada" } as const;
const OWNER = { kind: "owner" } as const; const OWNER = { kind: "owner" } as const;
describe("the lists plugin on a list's worker", () => { describe("the lists plugin on a list's worker", () => {
let t: TestedPlugin<TodoPrincipal>; let t: TestedPlugin<v1.TodoPrincipal>;
beforeEach(async () => { beforeEach(async () => {
t = await testPlugin<TodoPrincipal>( t = await testPlugin<v1.TodoPrincipal>(
fileURLToPath(new URL("./", import.meta.url)), fileURLToPath(new URL("./", import.meta.url)),
{ {
kind: "worker", kind: "worker",
@ -33,7 +36,7 @@ describe("the lists plugin on a list's worker", () => {
it("adds a todo as the principal that called", async () => { it("adds a todo as the principal that called", async () => {
await t.root( await t.root(
async (ctx) => { async (ctx) => {
const todos = await ctx.get(v1.Todos, { list: "groceries" }); const todos = await ctx.get(Todos, { list: "groceries" });
expect(todos.variant).toBe("member"); expect(todos.variant).toBe("member");
await todos.add("coffee", ctx); await todos.add("coffee", ctx);
expect(todos.items.value.map(({ text, by }) => `${text} (${by})`)).toEqual(["coffee (ada)"]); expect(todos.items.value.map(({ text, by }) => `${text} (${by})`)).toEqual(["coffee (ada)"]);
@ -43,10 +46,22 @@ describe("the lists plugin on a list's worker", () => {
); );
}); });
it("refuses a todo without text with invalid/args", async () => {
await t.root(
async (ctx) => {
const todos = await ctx.get(Todos, { list: "groceries" });
const refused = await todos.add("", ctx).catch((error: ChordError) => error);
expect(refused).toMatchObject({ code: "invalid", reason: "args" });
},
context,
{ principal: ADA },
);
});
it("lets the owner clear the done todos", async () => { it("lets the owner clear the done todos", async () => {
await t.root( await t.root(
async (ctx) => { async (ctx) => {
const todos = await ctx.get(v1.Todos, { list: "groceries" }); const todos = await ctx.get(Todos, { list: "groceries" });
if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`); if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`);
await todos.add("milk", ctx); await todos.add("milk", ctx);
await todos.add("eggs", ctx); await todos.add("eggs", ctx);

View file

@ -1,7 +1,8 @@
// The lists plugin on a list's worker. The todos live in a table of the worker's store (every one kept), written and // The lists plugin on a list's worker. The todos live in a table of the worker's store (every one kept), written and
// read by one local object, `Store`. A table has no watch, so the store keeps a live window itself: the newest 50 // read by one local object, `Store`. A table has no watch, so the store keeps a live window itself: the newest 50
// todos, read when it goes live and changed after each write (it is the only writer). Each principal's `Todos` view // todos, read when it goes live and changed after each write (it is the only writer). Each principal's `Todos` view
// adds todos as that principal, and gives the owner `clear`. // adds todos as that principal, and gives the owner `clear`. The store refuses a todo without text with Chord's
// `invalid/args`.
import { import {
arr, arr,
@ -10,16 +11,16 @@ import {
defineFacet, defineFacet,
defineTable, defineTable,
eq, eq,
Item,
int, int,
invalid,
local, local,
method, method,
num, num,
obj, obj,
query, query,
str, str,
Todos,
} from "@todo/app/v1"; } from "@todo/app/v1";
import { Item, Todos } from "./contract.ts";
/** Every todo of the list, by its number. */ /** Every todo of the list, by its number. */
const Items = defineTable("todo.items", { const Items = defineTable("todo.items", {
@ -63,6 +64,8 @@ export default defineFacet((host) => {
return { return {
items: self.derive([window], ([value]) => value), items: self.derive([window], ([value]) => value),
add: async ({ text, by }, ctx) => { 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() }; const item = { id: next++, text, done: false, by, at: Date.now() };
await self.tx(async (tx) => tx.put(items, item), ctx); await self.tx(async (tx) => tx.put(items, item), ctx);
// In number order: two adds may commit in either order. // In number order: two adds may commit in either order.

View file

@ -8,7 +8,7 @@ import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
import { Browser, CHROMIUM } from "../../node/web/browser.ts"; import { Browser, CHROMIUM } from "../../node/web/browser.ts";
import { Todos } from "./src/api/v1/index.ts"; import { Todos } from "./plugins/lists/contract.ts";
import { startTodo, todoDirectories } from "./start.ts"; import { startTodo, todoDirectories } from "./start.ts";
const out = resolve(process.argv[2] ?? "screens"); const out = resolve(process.argv[2] ?? "screens");
@ -73,7 +73,8 @@ try {
await browser.wait("getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() === '#12232e'"); await browser.wait("getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() === '#12232e'");
await shoot("harbor.png"); await shoot("harbor.png");
// 3. Half a todo typed, then an edit of the lists plugin's web facet applied: a count of the open todos. // 3. Half a todo typed, then an edit of the lists plugin's web facet applied: a count of the open todos. The panel
// remounts (new code), and the half-typed todo is still there: the page keeps it (`usePageState`).
await browser.evaluate("document.querySelector('.lists-panel input').focus()"); await browser.evaluate("document.querySelector('.lists-panel input').focus()");
await browser.type("oat mi"); await browser.type("oat mi");
// A mark on the page's window: a page that reloaded would have lost it. // A mark on the page's window: a page that reloaded would have lost it.
@ -84,6 +85,7 @@ try {
while (!app.plugins.state.value.dirty.includes("@todo/lists")) await new Promise((done) => setTimeout(done, 10)); while (!app.plugins.state.value.dirty.includes("@todo/lists")) await new Promise((done) => setTimeout(done, 10));
await app.applyChanges(context); await app.applyChanges(context);
await browser.wait("document.querySelector('.lists-open') !== null"); await browser.wait("document.querySelector('.lists-open') !== null");
await browser.evaluate("document.querySelector('.lists-panel input').focus()");
await shoot("edited.png"); await shoot("edited.png");
const after = await browser.evaluate<string>("document.querySelector('.lists-panel input').value"); const after = await browser.evaluate<string>("document.querySelector('.lists-panel input').value");
console.log(`typed ${JSON.stringify(typed)}; after the edit: ${JSON.stringify(after)}`); console.log(`typed ${JSON.stringify(typed)}; after the edit: ${JSON.stringify(after)}`);

View file

@ -1,20 +1,12 @@
// The todo app's API, version 1, for every host kind (`@todo/app/v1`): the part of Chord its plugins use, `defineFacet` // The todo app's API, version 1, for every host kind (`@todo/app/v1`): the part of Chord its plugins use, `defineFacet`
// typed with the app's principals, the app's host kind (a list, one worker each) and its contract for a list's todos. // typed with the app's principals, and the app's host kind (a list, one worker each). Plugins import this module, never
// Plugins import this module, never Chord: their builds leave it out and each process passes its own instance in. // Chord: their builds leave it out and each process passes its own instance in.
import { import {
arr,
bool,
defineFacet as chordFacet, defineFacet as chordFacet,
type FacetHost, type FacetHost,
type FacetSelf, type FacetSelf,
hostKind, hostKind,
int,
method,
num,
obj,
remote,
type Static,
str, str,
} from "../../../../../../src/index.ts"; } from "../../../../../../src/index.ts";
import type { TodoPrincipal } from "../../principals.ts"; import type { TodoPrincipal } from "../../principals.ts";
@ -26,13 +18,17 @@ export {
type Ctx, type Ctx,
defineTable, defineTable,
eq, eq,
forbidden,
int, int,
invalid,
local, local,
method, method,
num, num,
obj, obj,
PluginError, PluginError,
query, query,
remote,
type Static,
str, str,
type Theme, type Theme,
Themes, Themes,
@ -46,29 +42,3 @@ export function defineFacet(setup: (host: FacetHost<TodoPrincipal>, self: FacetS
/** A todo list: a worker process of its own, with its own store. */ /** A todo list: a worker process of its own, with its own store. */
export const List = hostKind("list", { id: str() }); export const List = hostKind("list", { id: str() });
/** A todo: its number in the list, its text, whether it is done, who added it and when. */
export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
/** A todo (see `Item`). */
export type Item = Static<typeof Item>;
/**
* A list's todos as one principal sees them, on the list's worker: the newest 50 (live), adding one and ticking one
* off (or on again); the owner also clears the done ones.
*/
export const Todos = remote("todo.todos", {
version: 1,
params: { list: str() },
hostAddress: ({ list }) => List({ id: list }),
variants: {
member: {
state: { items: arr(Item) },
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })) },
},
owner: {
state: { items: arr(Item) },
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })), clear: method() },
},
},
});

View file

@ -4,7 +4,7 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { defineRegistry } from "../../../../../../src/index.ts"; import { defineRegistry } from "../../../../../../src/index.ts";
export { useHold, useWatch } from "../../../../../../src/react/index.ts"; export { useHold, usePageState, useWatch } from "../../../../../../src/react/index.ts";
/** A panel of the page: its title and its React component. */ /** A panel of the page: its title and its React component. */
export type Panel = { readonly title: string; readonly Component: ComponentType }; export type Panel = { readonly title: string; readonly Component: ComponentType };

View file

@ -2,10 +2,10 @@
// terminal modules, a core facet serving the `Screens` registry, and a redraw on every change. The screens are opened in // terminal modules, a core facet serving the `Screens` registry, and a redraw on every change. The screens are opened in
// a scope of their own, opened again when a reload changes them; `q` quits. // a scope of their own, opened again when a reload changes them; `q` quits.
import * as v1 from "@todo/app/v1";
import * as v1tui from "@todo/app/v1/tui";
import { defineFacet } from "../../../../../src/index.ts"; import { defineFacet } from "../../../../../src/index.ts";
import { startTerminalRuntime, TuiScope } from "../../../../../src/tui/index.ts"; import { startTerminalRuntime, TuiScope } from "../../../../../src/tui/index.ts";
import * as v1 from "../api/v1/index.ts";
import * as v1tui from "../api/v1/tui.ts";
/** The terminal's own facet: it serves `Screens`, composing the contributions in plugin-name order. */ /** The terminal's own facet: it serves `Screens`, composing the contributions in plugin-name order. */
const core = defineFacet((host) => host.serve(v1tui.Screens, { compose: (table) => table.map(({ value }) => value) })); const core = defineFacet((host) => host.serve(v1tui.Screens, { compose: (table) => table.map(({ value }) => value) }));

View file

@ -1,6 +1,8 @@
// The todo app's web entry, the base bundle the server builds at start: `startPage` with the app's web modules and // The todo app's web entry, the base bundle the server builds at start: `startPage` with the app's web modules and
// React, a core facet serving the `Panels` registry, and a page showing every panel the plugins contribute. // React, a core facet serving the `Panels` registry, and a page showing every panel the plugins contribute.
import * as v1 from "@todo/app/v1";
import * as v1web from "@todo/app/v1/web";
import * as React from "react"; import * as React from "react";
import { createElement as h } from "react"; import { createElement as h } from "react";
import * as jsx from "react/jsx-runtime"; import * as jsx from "react/jsx-runtime";
@ -8,8 +10,6 @@ import { createRoot } from "react-dom/client";
import { defineFacet } from "../../../../../src/index.ts"; import { defineFacet } from "../../../../../src/index.ts";
import { HostProvider, useRegistry } from "../../../../../src/react/index.ts"; import { HostProvider, useRegistry } from "../../../../../src/react/index.ts";
import { startPage } from "../../../../../src/web/index.ts"; import { startPage } from "../../../../../src/web/index.ts";
import * as v1 from "../api/v1/index.ts";
import * as v1web from "../api/v1/web.ts";
import "./style.css"; import "./style.css";
/** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */ /** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */

View file

@ -7,11 +7,11 @@ import { cp, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as v1 from "@todo/app/v1";
import * as v1server from "@todo/app/v1/server";
import type { Context } from "../../../src/context/index.ts"; import type { Context } from "../../../src/context/index.ts";
import { type App, startServer } from "../../../src/node/app/index.ts"; import { type App, startServer } from "../../../src/node/app/index.ts";
import { type PageAssets, pageScripts } from "../../../src/node/surfaces/index.ts"; import { type PageAssets, pageScripts } from "../../../src/node/surfaces/index.ts";
import * as v1 from "./src/api/v1/index.ts";
import * as v1server from "./src/api/v1/server.ts";
import type { TodoPrincipal } from "./src/principals.ts"; import type { TodoPrincipal } from "./src/principals.ts";
/** The app's API: its name, its versions (newest first), and this process's instances of its modules. */ /** The app's API: its name, its versions (newest first), and this process's instances of its modules. */

View file

@ -1,6 +1,7 @@
// The web entry of the browser tests (the base bundle): `startPage` with the tests' API modules and React, a core facet // The web entry of the browser tests (the base bundle): `startPage` with the tests' API modules and React, a core facet
// serving `Panels` and the counter, and a React app: the panels (`useRegistry`), a draft input (component state an // serving `Panels` and the counter, and a React app: the panels (`useRegistry`), a draft input (component state an
// in-place reload keeps), and a counter held while shown (`useHold`, `useWatch`, `useRefState`). // in-place reload keeps), a counter held while shown (`useHold`, `useWatch`, `useRefState`), and a note in page state
// (`usePageState`) whose component the "remount" button replaces by another function, as a plugin reload does.
import * as React from "react"; import * as React from "react";
import { createElement as h, useState } from "react"; import { createElement as h, useState } from "react";
@ -8,7 +9,14 @@ import * as jsx from "react/jsx-runtime";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts"; import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts";
import * as chord from "../../../../src/index.ts"; import * as chord from "../../../../src/index.ts";
import { HostProvider, useHold, useRefState, useRegistry, useWatch } from "../../../../src/react/index.ts"; import {
HostProvider,
useHold,
usePageState,
useRefState,
useRegistry,
useWatch,
} from "../../../../src/react/index.ts";
import { startPage } from "../../../../src/web/index.ts"; import { startPage } from "../../../../src/web/index.ts";
import * as webApi from "./api.ts"; import * as webApi from "./api.ts";
@ -49,10 +57,25 @@ function Counter() {
); );
} }
/** An input of the page state at `key`. */
function noteInput(key: string) {
const [note, setNote] = usePageState(key, "");
return h("input", {
id: "note",
"data-key": key,
value: note,
onChange: (event: { target: { value: string } }) => setNote(event.target.value),
});
}
/** The note's component and the components replacing it: each a new function to React, as a reloaded plugin's. */
const NOTES = [() => noteInput("note"), () => noteInput("note"), () => noteInput("other")];
function App() { function App() {
const panels = useRegistry(webApi.Panels) ?? []; const panels = useRegistry(webApi.Panels) ?? [];
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [shown, setShown] = useState(true); const [shown, setShown] = useState(true);
const [note, setNote] = useState(0);
return h( return h(
"div", "div",
null, null,
@ -64,6 +87,8 @@ function App() {
}), }),
h("button", { type: "button", id: "toggle", onClick: () => setShown((value) => !value) }, "toggle"), h("button", { type: "button", id: "toggle", onClick: () => setShown((value) => !value) }, "toggle"),
shown ? h(Counter) : null, shown ? h(Counter) : null,
h("button", { type: "button", id: "remount", onClick: () => setNote((value) => value + 1) }, "remount"),
h(NOTES[note % NOTES.length]!),
); );
} }

View file

@ -1,6 +1,7 @@
// Tests of the React bindings (slice 11 §3) in a real browser (Chromium; skipped without one), on the fixture's page: // Tests of the React bindings (slice 11 §3) in a real browser (Chromium; skipped without one), on the fixture's page:
// `useHold` holds while mounted and releases on unmount, `useWatch` re-renders on change, `useRefState` follows the // `useHold` holds while mounted and releases on unmount, `useWatch` re-renders on change, `useRefState` follows the
// reference's state, `useRegistry` the registry (the panels). // reference's state, `useRegistry` the registry (the panels), `usePageState` keeps a value across a remount by another
// component function and starts a new key fresh.
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
@ -42,4 +43,29 @@ describe.skipIf(CHROMIUM === undefined)("React bindings", () => {
await app.close(context); await app.close(context);
} }
}, 120_000); }, 120_000);
it("keeps page state across a remount by a new function, a new key starting fresh", async () => {
const { app } = await webApp(await versionedEntry("1"), await freePort());
try {
await signIn(browser, app.ownerLink());
await browser.evaluate('document.getElementById("note").focus()');
await browser.type("oat mi");
await browser.wait('document.getElementById("note").value === "oat mi"');
await browser.evaluate('void (globalThis.mounted = document.getElementById("note"))');
// A new component function at the same place: React mounts it anew (a new element), the key keeps the value.
await browser.evaluate('document.getElementById("remount").click()');
await browser.wait('document.getElementById("note") !== globalThis.mounted');
expect(await browser.evaluate<string>('document.getElementById("note").value')).toBe("oat mi");
// Another key starts at its initial value.
await browser.evaluate('document.getElementById("remount").click()');
await browser.wait('document.getElementById("note").dataset.key === "other"');
expect(await browser.evaluate<string>('document.getElementById("note").value')).toBe("");
// Back to the first key: still kept.
await browser.evaluate('document.getElementById("remount").click()');
await browser.wait('document.getElementById("note").dataset.key === "note"');
expect(await browser.evaluate<string>('document.getElementById("note").value')).toBe("oat mi");
} finally {
await app.close(context);
}
}, 120_000);
}); });

View file

@ -0,0 +1,72 @@
// Tests of page state (decision "Tutorial gaps" (1)): a client host's values by key, kept for the host's life, whatever
// asks for them (a remounted component, a new scope); a new key starts at its initial value; sets call the subscribers;
// the store goes with its host.
import { describe, expect, it } from "vitest";
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
import { createHost } from "../../src/index.ts";
import { pageState } from "../../src/surfaces/page-state.ts";
import { expectCollected } from "../support.ts";
/** A client host with nothing to load. */
async function client() {
const host = createHost({ onError: () => {} });
await host.load([], context);
return host;
}
describe("a host's page state by key", () => {
it("keeps a key's value for whoever asks next, its own initial value ignored", async () => {
const host = await client();
pageState(host, "draft", "").set("oat mi");
expect(pageState(host, "draft", "").read()).toBe("oat mi");
await host.close(context);
});
it("starts a new key, or the same key on another host, at its initial value", async () => {
const host = await client();
const other = await client();
pageState(host, "draft", "").set("oat mi");
expect(pageState(host, "scroll", 0).read()).toBe(0);
expect(pageState(other, "draft", "").read()).toBe("");
await host.close(context);
await other.close(context);
});
it("holds any value as it was set, never copied", async () => {
const host = await client();
const selection = { start: 2, end: 4, element: () => null };
pageState<typeof selection | undefined>(host, "selection", undefined).set(selection);
expect(pageState(host, "selection", undefined).read()).toBe(selection);
await host.close(context);
});
});
describe("following a key", () => {
it("calls every subscriber after a set that changed the value, until it unsubscribes", async () => {
const host = await client();
const state = pageState(host, "draft", "");
const calls: string[] = [];
const first = state.subscribe(() => calls.push(`first ${state.read()}`));
state.subscribe(() => calls.push(`second ${state.read()}`));
state.set("a");
state.set("a");
first();
state.set("b");
expect(calls).toEqual(["first a", "second a", "second b"]);
await host.close(context);
});
});
describe("the store's memory", () => {
it("is collected with its host", async () => {
const [host, value] = await (async () => {
const created = await client();
const kept = { text: "oat mi" };
pageState(created, "draft", kept);
await created.close(context);
return [new WeakRef(created), new WeakRef(kept)];
})();
await expectCollected(host, value);
});
});

View file

@ -1,5 +1,6 @@
// Tests of `TuiScope` (slice 11 §4, decision "Slice 11, P9"): references held for a component, watchables and // Tests of `TuiScope` (slice 11 §4, decision "Slice 11, P9"): references held for a component, watchables and
// registries followed, one `changed` callback for every change, and everything released at `dispose`. // registries followed, one `changed` callback for every change, everything released at `dispose`, and the host's page
// state shared by every scope (decision "Tutorial gaps" (1)).
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts"; import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
@ -78,3 +79,33 @@ describe("a component's scope", () => {
await h.close(context); await h.close(context);
}); });
}); });
describe("a scope's page state", () => {
it("is the host's: a scope opened after another was disposed (a reload) finds its value, a new key starts fresh", async () => {
const h = await host();
const before = new TuiScope(h, () => {});
before.pageState("draft", "").set("oat mi");
before.dispose();
const after = new TuiScope(h, () => {});
expect(after.pageState("draft", "").read()).toBe("oat mi");
expect(after.pageState("scroll", 0).read()).toBe(0);
after.dispose();
await h.close(context);
});
it("calls changed on each open scope following the key, and never after dispose", async () => {
const h = await host();
const calls: string[] = [];
const redraw = (name: string) => () => calls.push(name);
const first = new TuiScope(h, redraw("first"));
const second = new TuiScope(h, redraw("second"));
const draft = first.pageState("draft", "");
second.pageState("draft", "");
draft.set("a");
first.dispose();
draft.set("b");
expect(calls).toEqual(["first", "second", "second"]);
second.dispose();
await h.close(context);
});
});

View file

@ -29,10 +29,6 @@
"@notes/app/*": ["./packages/chord/test/examples/53-app/app/*.ts"], "@notes/app/*": ["./packages/chord/test/examples/53-app/app/*.ts"],
"@board/app/*": ["./packages/chord/test/examples/56-web/app/*.ts"], "@board/app/*": ["./packages/chord/test/examples/56-web/app/*.ts"],
"@chat/app/*": ["./packages/chord/test/examples/59-chat/app/*.ts"], "@chat/app/*": ["./packages/chord/test/examples/59-chat/app/*.ts"],
"@todo/app/*": [
"./packages/chord/test/examples/61-tutorial/src/api/*.ts",
"./packages/chord/test/examples/61-tutorial/src/api/*/index.ts"
],
"@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"], "@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"],
"@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"], "@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"],
"@earendil-works/pi-telemetry/render": ["./packages/telemetry/src/render.ts"], "@earendil-works/pi-telemetry/render": ["./packages/telemetry/src/render.ts"],