From 8f9446a06027b075bf885189ceb0983565662174 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 3 Nov 2026 19:02:56 +0000 Subject: [PATCH] feat(chord): invalid and forbidden exported, page state across plugin reloads, the tutorial app's API a workspace package --- package-lock.json | 9 + package.json | 1 + packages/chord/CHANGELOG.md | 2 + packages/chord/README.md | 2 +- packages/chord/docs/design.md | 20 ++- .../chord/docs/guide-plugins-hosts/book.md | 6 + .../chord/docs/guide-plugins-hosts/index.html | 5 + packages/chord/docs/plugin-authoring.md | 43 ++++- packages/chord/docs/tutorial/book.md | 158 +++++++++++++----- packages/chord/docs/tutorial/index.html | 150 ++++++++++++----- .../chord/docs/tutorial/screens/edited.png | Bin 14440 -> 14165 bytes packages/chord/src/data/errors.ts | 11 +- packages/chord/src/data/index.ts | 5 +- packages/chord/src/host/route.ts | 4 +- packages/chord/src/node/app/data.ts | 4 +- packages/chord/src/react/README.md | 8 +- packages/chord/src/react/hooks.ts | 18 +- packages/chord/src/react/index.ts | 2 +- packages/chord/src/surfaces/page-state.ts | 56 +++++++ packages/chord/src/tui/README.md | 6 +- packages/chord/src/tui/index.ts | 1 + packages/chord/src/tui/scope.ts | 17 +- .../chord/test/examples/61-tutorial/main.ts | 19 ++- .../test/examples/61-tutorial/package.json | 13 ++ .../61-tutorial/plugins/lists/contract.ts | 30 ++++ .../61-tutorial/plugins/lists/server.ts | 3 +- .../examples/61-tutorial/plugins/lists/tui.ts | 3 +- .../examples/61-tutorial/plugins/lists/web.ts | 16 +- .../61-tutorial/plugins/lists/worker.test.ts | 29 +++- .../61-tutorial/plugins/lists/worker.ts | 9 +- .../test/examples/61-tutorial/screenshots.ts | 6 +- .../examples/61-tutorial/src/api/v1/index.ts | 42 +---- .../examples/61-tutorial/src/api/v1/web.ts | 2 +- .../test/examples/61-tutorial/src/tui/main.ts | 4 +- .../test/examples/61-tutorial/src/web/main.ts | 4 +- .../chord/test/examples/61-tutorial/start.ts | 4 +- packages/chord/test/node/fixtures/web/main.ts | 29 +++- packages/chord/test/node/react/hooks.test.ts | 28 +++- .../chord/test/surfaces/page-state.test.ts | 72 ++++++++ packages/chord/test/tui/scope.test.ts | 33 +++- tsconfig.json | 4 - 41 files changed, 687 insertions(+), 191 deletions(-) create mode 100644 packages/chord/src/surfaces/page-state.ts create mode 100644 packages/chord/test/examples/61-tutorial/package.json create mode 100644 packages/chord/test/examples/61-tutorial/plugins/lists/contract.ts create mode 100644 packages/chord/test/surfaces/page-state.test.ts diff --git a/package-lock.json b/package-lock.json index 06e7dd2e9..84587bc00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "workspaces": [ "packages/*", "apps/*", + "packages/chord/test/examples/61-tutorial", "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", @@ -2606,6 +2607,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@todo/app": { + "resolved": "packages/chord/test/examples/61-tutorial", + "link": true + }, "node_modules/@types/chai": { "version": "5.2.3", "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": { "name": "@earendil-works/pi-codemode", "version": "1.5.0", diff --git a/package.json b/package.json index eaaddc4c7..badb6e965 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "workspaces": [ "packages/*", "apps/*", + "packages/chord/test/examples/61-tutorial", "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index c6a64e36b..5e17d46e6 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -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("", "", 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 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. ### Changed diff --git a/packages/chord/README.md b/packages/chord/README.md index c5aa13d1e..d73439b99 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -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/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/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/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 | diff --git a/packages/chord/docs/design.md b/packages/chord/docs/design.md index 437c13651..e327e2ff0 100644 --- a/packages/chord/docs/design.md +++ b/packages/chord/docs/design.md @@ -1949,6 +1949,14 @@ type ReloadReport = { 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 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; 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 @@ -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. - **Shape:** the app's layout is fixed: `src/api/v/` (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 params (sharding is the app's) and how it starts (entry, start data, principal, idle time); zero kinds is a server 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) -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 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 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. -- `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 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 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 - 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) diff --git a/packages/chord/docs/guide-plugins-hosts/book.md b/packages/chord/docs/guide-plugins-hosts/book.md index 33ec92d8a..1a6802c0a 100644 --- a/packages/chord/docs/guide-plugins-hosts/book.md +++ b/packages/chord/docs/guide-plugins-hosts/book.md @@ -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; 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 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. diff --git a/packages/chord/docs/guide-plugins-hosts/index.html b/packages/chord/docs/guide-plugins-hosts/index.html index 452e583a9..349975217 100644 --- a/packages/chord/docs/guide-plugins-hosts/index.html +++ b/packages/chord/docs/guide-plugins-hosts/index.html @@ -583,6 +583,11 @@ then gives every object of the changed plugin its fate:

The tab'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; 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").

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 working if the provider maps the old version (accepts); otherwise it waits with "version mismatch" until it reloads.

diff --git a/packages/chord/docs/plugin-authoring.md b/packages/chord/docs/plugin-authoring.md index 45a5cc924..1598dcbe1 100644 --- a/packages/chord/docs/plugin-authoring.md +++ b/packages/chord/docs/plugin-authoring.md @@ -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 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 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) 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 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)`, `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. +- 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, 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 -facets with fake data. A test declares the contracts it reads again (contracts are data: the same name, version and -schema bind). +facets with fake data. A test imports the API by its package name and the plugin's own contracts from its +`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 diff --git a/packages/chord/docs/tutorial/book.md b/packages/chord/docs/tutorial/book.md index 65556ca0c..762ae170d 100644 --- a/packages/chord/docs/tutorial/book.md +++ b/packages/chord/docs/tutorial/book.md @@ -115,13 +115,17 @@ export { type Ctx, defineTable, eq, + forbidden, int, + invalid, local, method, num, obj, PluginError, query, + remote, + type Static, str, type Theme, Themes, @@ -129,6 +133,10 @@ export { 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`: @@ -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 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: + + +```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 `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 -A **host kind** declares addressable hosts with params; `List({ id: "groceries" })` is an **address**. A `remote` -contract says, with `hostAddress`, at which address its objects live. Here is the app's contract for a list's todos, -in the API so that every host kind, the tests and command-line code can name it: +A **host kind** declares addressable hosts with params; `List({ id: "groceries" })` is an **address**. The app's +host kinds are base code, in the API: ```ts /** A todo list: a worker process of its own, with its own store. */ 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: + + +```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. */ 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; /** - * 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. + * 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, @@ -394,6 +434,8 @@ writer, so it knows every change: ```ts add: async ({ text, by }, ctx) => { + // Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`. + if (text === "") throw invalid("args", "a todo needs text"); const item = { id: next++, text, done: false, by, at: Date.now() }; await self.tx(async (tx) => tx.put(items, item), ctx); // In number order: two adds may commit in either order. @@ -401,6 +443,10 @@ writer, so it knows every change: 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 `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 // 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 { Todos } from "./contract.ts"; export default defineFacet((_host, self) => { self.contribute(Http, { @@ -566,7 +613,7 @@ for them: import type { ComponentType } from "react"; 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. */ export type Panel = { readonly title: string; readonly Component: ComponentType }; @@ -612,9 +659,11 @@ when its items change: ```ts 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 [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 }) => { event.preventDefault(); 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 -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 `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): @@ -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."} -Ada typed " tea " with spaces, and an empty add should do nothing. The fix is in the lists plugin's worker facet. -Step 8 makes the edit in the running app's copy of the plugins, as an editor or an agent would, and applies it: +Ada typed " tea " with spaces, and a blank todo should be refused like an empty one. The fix is one line of the lists +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: ```ts const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),"; -const AFTER = `add: async (text: string, ctx: Ctx) => { - if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx); - return null; - },`; +const AFTER = "add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),"; const workerFile = join(root, "lists", "worker.ts"); await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER)); 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 until(() => groceries.items.value.at(-1)?.text === "tea"); console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1))); -const count = groceries.items.value.length; -await adas.add(" ", context); -console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added"); +const refused = await adas.add(" ", context).then( + () => "added", + (error: ChordError) => ({ code: error.code, reason: error.reason }), +); +console.log(" ada adds ' ':", refused); ``` @@ -1009,7 +1059,7 @@ console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignore 8. lists changed, rolled out to: server, list, web, tui on list|groceries: todo.store restarted, todo.todos(groceries) parked 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) @@ -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 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. 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/harbor.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 ``` -:::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 did not reload, and everything outside the changed plugin's components kept its state. The changed panel's -own React state did not: a new component function is a new component to React. +The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel +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."} @@ -1100,9 +1156,9 @@ loads it on a new host with an in-memory store, here at a list's address so it m ```ts - let t: TestedPlugin; + let t: TestedPlugin; beforeEach(async () => { - t = await testPlugin( + t = await testPlugin( fileURLToPath(new URL("./", import.meta.url)), { 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 () => { await t.root( 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}`); await todos.add("milk", 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 ``` -The test imports `Todos` and `List` from the API module by path. It could not import a contract from a plugin's own -`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 -keeps the contract it shares in its API instead. +The test imports the API by its package name and `Todos` from the plugin's own `contract.ts`, as the facets do: + + +```ts +import * as v1 from "@todo/app/v1"; +``` + + +```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: + + +```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."} @@ -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 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). -- **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 - functions, so React mounts them anew and their state (the half-typed todo) is gone. -- **Plugin contracts are hard to test from outside builds.** A test or script cannot import a plugin's - `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. + +Three more, found the same way, are fixed: a reloaded web plugin lost its own React state (now `usePageState`, +chapter VIII), a plugin's `contract.ts` could not be imported outside builds (the API is now a workspace package, +chapter I), and plugins could not throw Chord's `invalid` (Chord exports `invalid` and `forbidden`, chapter II). # The App's Files {.appendix} | File | Kind | What it is | | --- | --- | --- | | `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/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` | | `start.ts`, `worker.ts` | base | `startServer`, `startWorker` | | `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels | | `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 | | `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 | @@ -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 | | `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 | +| `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 | | `startPage`, `HostProvider`, `useHold`, `useWatch`, `useRegistry` | IV | the page | | `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 | | `ephemeralLoginFacet

()`, `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 | diff --git a/packages/chord/docs/tutorial/index.html b/packages/chord/docs/tutorial/index.html index 4a74485f3..9f0f5cae9 100644 --- a/packages/chord/docs/tutorial/index.html +++ b/packages/chord/docs/tutorial/index.html @@ -216,18 +216,25 @@ stays small: a kind and an id. A user's profile, if the app had one, would b type Ctx, defineTable, eq, + forbidden, int, + invalid, local, method, num, obj, PluginError, query, + remote, + type Static, str, type Theme, Themes, } from "../../../../../../src/index.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:

/** Defines a facet of the todo app: its factories get the app's principals. */
@@ -238,6 +245,26 @@ stays small: a kind and an id. A user's profile, if the app had one, would b
 it evaluates it. The plugin sees the very Themes registry and List host kind the process has, not a copy. A
 version in the path lets the app ship v2 beside v1 later; one facet uses one version (design §21, "The plugin
 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:

+ +
{
+	"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

startServer takes what the app decides and does the rest. start.ts names the API with this process's modules, the facet kinds (which files a plugin may have), and the principals:

@@ -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 keeps its todos.

Where a list lives

-

A host kind declares addressable hosts with params; List({ id: "groceries" }) is an address. A remote -contract says, with hostAddress, at which address its objects live. Here is the app's contract for a list's todos, -in the API so that every host kind, the tests and command-line code can name it:

+

A host kind declares addressable hosts with params; List({ id: "groceries" }) is an address. The app's +host kinds are base code, in the API:

/** 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:

+ +
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() });
@@ -345,8 +375,8 @@ in the API so that every host kind, the tests and command-line code can name it:
 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.
+ * 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,
@@ -440,11 +470,16 @@ when fn returns, or not at all if it throws. The window follows aft
 writer, so it knows every change:

			add: async ({ text, by }, ctx) => {
+				// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
+				if (text === "") throw invalid("args", "a todo needs text");
 				const item = { id: next++, text, done: false, by, at: Date.now() };
 				await self.tx(async (tx) => tx.put(items, item), ctx);
 				// In number order: two adds may commit in either order.
 				window.set([...window.value, item].sort((a, b) => a.id - b.id).slice(-WINDOW));
 				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

Todos is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:

@@ -518,8 +553,9 @@ contributes routes to the kit's Http registry, which the API
// 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.
 
-import { defineFacet, Todos } from "@todo/app/v1";
+import { defineFacet } from "@todo/app/v1";
 import { Http } from "@todo/app/v1/server";
+import { Todos } from "./contract.ts";
 
 export default defineFacet((_host, self) => {
 	self.contribute(Http, {
@@ -573,7 +609,7 @@ for them:

import type { ComponentType } from "react";
 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. */
 export type Panel = { readonly title: string; readonly Component: ComponentType };
@@ -609,9 +645,11 @@ scripts with the entry's core facets, and keeps them current. HostProv
 when its items change:

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 [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 }) => {
 		event.preventDefault();
 		if (todos === undefined || text.trim() === "") return;
@@ -654,7 +692,8 @@ when its items change:

export default defineFacet((_host, self) => self.contribute(Panels, { title: "Groceries", Component: TodoList }));

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 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):

@@ -895,14 +934,12 @@ Her todo is hers: coffee (ada).

VIII

Changing Code

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. -Step 8 makes the edit in the running app's copy of the plugins, as an editor or an agent would, and applies it:

+

Ada typed " tea " with spaces, and a blank todo should be refused like an empty one. The fix is one line of the lists +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:

const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
-const AFTER = `add: async (text: string, ctx: Ctx) => {
-				if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx);
-				return null;
-			},`;
+const AFTER = "add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),";
 const workerFile = join(root, "lists", "worker.ts");
 await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
 await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
@@ -915,14 +952,16 @@ console.log("8. lists changed, roll
 await adas.add("  tea  ", context);
 await until(() => groceries.items.value.at(-1)?.text === "tea");
 console.log("   ada adds '  tea  ':", show(groceries.items.value.slice(-1)));
-const count = groceries.items.value.length;
-await adas.add(" ", context);
-console.log("   ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added");
+const refused = await adas.add(" ", context).then( + () => "added", + (error: ChordError) => ({ code: error.code, reason: error.reason }), +); +console.log(" ada adds ' ':", refused);
8. lists changed, rolled out to: server, list, web, tui
    on list|groceries: todo.store restarted, todo.todos(groceries) parked
    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) and rolled it out host by host. On each host Chord stages the new code (it runs the new factories against a recording self to see what they declare), commits it in one step, and gives each object of the changed plugin its fate @@ -953,7 +992,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 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.

A contract that changes shape needs a new version; old consumers keep working when the provider maps their version (accepts), otherwise they wait with contract_mismatch until they reload. A stored document or table that changes @@ -968,12 +1009,16 @@ page reload. screenshots.ts adds a count of open todos to the panel

wrote docs/tutorial/screens/page.png
 wrote docs/tutorial/screens/harbor.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 after the edit

-
Figure 3. 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.
-

The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel's -own React state did not: a new component function is a new component to React.

+
Figure 3. 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 did not reload, and everything outside the changed plugin's components kept its state. The changed panel +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.

IX

Moving a List

Export a worker's data while it runs, import it under another address.

@@ -1006,9 +1051,9 @@ an index of the app's, if it had one, is the app's.

production builds it (the same esbuild call, the API's imports checked), evaluates it with the app's real API module and loads it on a new host with an in-memory store, here at a list's address so it may serve Todos:

-
	let t: TestedPlugin<TodoPrincipal>;
+
	let t: TestedPlugin<v1.TodoPrincipal>;
 	beforeEach(async () => {
-		t = await testPlugin<TodoPrincipal>(
+		t = await testPlugin<v1.TodoPrincipal>(
 			fileURLToPath(new URL("./", import.meta.url)),
 			{
 				kind: "worker",
@@ -1026,7 +1071,7 @@ loads it on a new host with an in-memory store, here at a list's address so
 
	it("lets the owner clear the done todos", async () => {
 		await t.root(
 			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}`);
 				await todos.add("milk", ctx);
 				await todos.add("eggs", ctx);
@@ -1042,10 +1087,17 @@ loads it on a new host with an in-memory store, here at a list's address so
 knows which stub facets to add. Registries it contributes to that nobody serves are collected (t.contributions(R)).
 Run it like any test:

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 -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 -keeps the contract it shares in its API instead.

+

The test imports the API by its package name and Todos from the plugin's own contract.ts, as the facets do:

+ +
import * as v1 from "@todo/app/v1";
+ +
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:

+ +
				const refused = await todos.add("", ctx).catch((error: ChordError) => error);
+				expect(refused).toMatchObject({ code: "invalid", reason: "args" });
XI

Writing a p2 Plugin

What is different in p2, and one small plugin: a tool for the agent and a chip for the people.

@@ -1207,15 +1259,10 @@ a tutorial.

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 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 -functions, so React mounts them anew and their state (the half-typed todo) is gone.
  • -
  • Plugin contracts are hard to test from outside builds. A test or script cannot import a plugin's -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.
  • +

    Three more, found the same way, are fixed: a reloaded web plugin lost its own React state (now usePageState, +chapter VIII), a plugin's contract.ts could not be imported outside builds (the API is now a workspace package, +chapter I), and plugins could not throw Chord's invalid (Chord exports invalid and forbidden, chapter II).

    A

    The App's Files

    @@ -1233,9 +1280,14 @@ moved into the API. the principal type +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, defineFacet, List, Item, Todos +@todo/app/v1: Chord's part (with invalid, forbidden), defineFacet, List src/api/v1/server.ts @@ -1245,7 +1297,7 @@ moved into the API. src/api/v1/web.ts base, web -Panels, useHold, useWatch +Panels, useHold, usePageState, useWatch src/api/v1/tui.ts @@ -1270,7 +1322,7 @@ moved into the API. plugins/lists/ plugin -worker (table, store, views), server (routes), web (panel), tui (screen), its test +contract (Item, Todos), worker (table, store, views), server (routes), web (panel), tui (screen), its test plugins/login.server.ts @@ -1330,6 +1382,11 @@ moved into the API. 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 @@ -1345,6 +1402,11 @@ moved into the API. 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 diff --git a/packages/chord/docs/tutorial/screens/edited.png b/packages/chord/docs/tutorial/screens/edited.png index b04a94b9b434c074e49f482cced74876dff27a06..382e4f78067294ab03ad1d5a1f81ff82dd325588 100644 GIT binary patch literal 14165 zcmc(GXH-*NyKNAa7ZDIdP`XI3BE7405Rk4^1*JEI5Nc3Ss`MsZLI*>n*MK5Ly3`Ot z5~R1#2@u-der25V=bU@Kd&fB6zl=Rr_Fi>9^O^Hmk&pE?sjo0!0f9i&+FEKxAkc-o zAP`x_Ul)KUWK89$Aka0Cw%P*|zpQmEg`bHju6tWS1bk1MHwi~cc{}p{GLq7W@fukn zfBU;=*kr0TYTVqqudI1>s<|TS(gSO{1f1*gHLZtNC|8gN>5mqbdYq(ToDCI$jYrTN z|B9uv%|^(#t$CTHECmS*do?ji(3^89_(jm0tJkzZpr@SiAW+qPPBPGoxA!lAeqMNc z5fp#v0wsu(>LNYpKI329pf|$*n+-cJ8|893Lih$g#7bLQUaw*Y@^UMO}Ixr?))>m&7a`m=J>1m_@8!RRnj~v)ouAuC71hgO|0O z-TU)*+1jxW*k7ppkdn2?mauLahDZjwlVD6?&$(QAy8P?F0VmCC zYP$gjftXN*kNOl-WfasM+zu9Vn+>&+ z$B%gl{Qadg1!A%0Xtz3laz&-A^HcFJ5+pxnYp@VaDrRYZBBzWVqoCohuqGd-R)^6H z`1`<$UrCd6bWnUh{U0S~-t1l{ooVu!NIzleufumV(q8cvLDXLil>NBALHc&_e7?P_ zS!Kx6-5~LuOLTqa;wpPsrJ;wQ_V^Ffc7?bybfc-=B|6^>Is2@W$A$vr^~(A*cE0`~ z#=IOblDsZ1&^lVTqJY5_N0 zc&W`7o1U3T2-f(RJGoulRE7uP-+YT5&1A_Wx?!C>k4Y4J>*L+?OZX?YAty6$-%Yzw zf*3S=-ZI=|W@N&jFTX_=cB*5!0zanjWe(T|qbDDW(KI$S)mm648L+y#vRpk4y9#;j zt8bv6ICV#JufIpJ*>UZ1XmjQHS2{uM?4*?EZHpz8Ng0`$^> zQOh;#8VIDNG*~fkAM)FAXIr7gv;HT&cK#|Zf2m{5<4HX!X~L{rc79>5-1`^0xiK)n z%-qE1eQs@SCvJC@Ha20do%yqu_0xT@f^9H~l4 zd>$1+O)DlQ=9$~FE*u!XSkwi3*HUNpwqSJSVf&r+w+uG|Q*Pdjd>ic~GbR%uw8wT& zSV2gYNBcqE?|{(fOREO(iMkrus*s^WX9*(%zNjZI&9U%(4At5Fr`m?@;94h!rGu3r4Q9k zPb>a#hw3ke-SFV>t4b}9`ir1m*rY&nRF#n4f0+xDsQ8XMQY$7V zkE}G2Rj_G{%4d7j{G6kQbo4meFe8-;l$~BoIP8I4Ru_Lhd_tpwwAK5%T+EMmQru|RPX)6`quqc;b;)gEH77X=ZRoG)DHqdH(Mdi<;k_nj z;~x)I6OD{oE$UlKN}e#x*}(#d5u(DP84|7zrr>=U78y2Kze${J`RHJ-M6)@fA#pZw z0OQN51bxU5N=}ZrrQ(Y}3yo$Pg!sOehu+)9(kdr|KxrDk?-abU@4iTJ`NX?ZlWFD7 z$T9(CLqkI|UFF_9Ji0rOX1h#x18qi2+Xh|SDgX8{uJ#z#s0YoQVn^C>?&4eR1y=6SisH$~V?&&8MkQ*8`1& zoYx2>aouTm9I`dBG&i?0f_eWEX>&!Nz0XwV2>tGk#bmZx9K<-WC@F=}FmwOygXAj) zeVgc-m#!`rq|W9KVHY>2k@a_K5{(8wawtsUD)5V3I4k+a+gDIi}DtgHXhYe zc~qgOJv`3Npl`{}h*-FS?<5OXg_Tt{59nK=p&MsRTR{I-UTE8s>Ir2}#&M-~?6Yfx zj*d}G&vP zAeBT4i*2$PH{B?scypd*M_yW4`D(vy!b)doHgV+hn>Vpo_5|!M)s=O32&6tXpzY8v zLB=jtwC(F%VPUm?EP8ElP#@X~wNnJfdqy5bU4T5k-jQz)`F-Ngn}pIuv~K^%vFlkn zfZ)-01dSeTbatxQ0=v-OK*?TZerCpRH^$pMsO27SQ+IODbqK+o#R6_!4_z&wlo@FJ zoo`^C+0w!uygXz#;c+%=2@F5vO&m?)gy$3&7GZ#tOzhB*#z&h88XDEsGKaHV?*$JrN$lCRu6lO#m=4#0?QNq=Lo;!mXnw5 z>LflIlC?CpbVWQm9Voc8yExdi?LbR=5dV3`SX+Au3+D?nmTNp>J0%01Q)+18U8H3& zcsxlJnB>{*AEcFe33|{B6YLK~JQSyqcehqOjqET6p0&jZC;(Z}-Io;q-c+e}toD|o zqGHP;PI;VP8vOM(tP0iZ7YGO@rF z;#7;WQcyhsX!rS>&of(FRMy{lwSRQQLS&fBg@kxO4KKuzbC$v)l+BQJbd_Ptz1WD= z^bk?q_erwz91QEu_ae=EG{iiQmo-m}%J_xksyv5q?wR>>0g*axLAHiyiAcV}x=OD%H$lNi+Z6DhM_md3)B+|ZU@g<> zGGg@SoV`)eqEOOjx_n9IfsKJopj)iG9Qgi^l7Y=%3Oc$~I8Pt##8=L#(?p^gfJzo@TVFW9%0?v11 z{WNdgHhFl2rsgZ6XPQq81@ztBsRl7IVsl_AC)=weapaw}namxJR zNle{@bmD-S(I<{uY?j8RDY@RF!dxRiUg#+MPveU1^|atFmYzoCrp2*rb;Tx*sf?FI z*}#w8IN?D{F)WN{9vBj$S68Q?u6ZSZBJ91#u><_Hi6Sum z#qE2egD(oWA)B{GA-}%%MpeyGQfL@t)%fn~NKk2kL^Tbe74^9xW&s#Z0pWYAR>w{P zo70VFs^J{Cw&H>kubGJ9eQ>Rah)OF%a+(OeP%pbyf_NqMKKKZYP8F@6G@$o3I*08Mq{7lD6^JK)ob4gmTBGS z=yga;$o`6EB24@tiBifmrE%#Vur|VE-(I>n1v+E+PpxLBt2ReUgdX3z6QB(Oogl>j zHH7*Qa(WTOKhz7s#V1F^(fm<^jD7#*-12j3Q(a}gX6xycZUXj>JX3ad_8Dne^#JGJ zd=Yd`#$=9cE*|!E&PJY|WLaCuo9|r?Elyi$@wg13jnTLxkkGEFb-fFSC-Z-~mV@zU z@Aot7CZjbB;?|1v2w+nyupU_J@h_rvV3e+odhqhh1AC3QE#keW!l@3`{`j|{FsHjW z83%KjZVkip^0t>Nua}?i7hVLZs23VhHwOeoqha1YV=F!iGQp#cGx1YpWqZj5pMPz6 z7%KUCqWPQjJ}G;TW{3fBqq1q0ssrEIAiC|`7=I%7y`OA6x-O+62>|6>5!b-`x{Se?dQLy~bx!sVE-oC!W z$)pfnSwGotc+Ue|u#}Y4_3pU9ErT}vSLZ%&v6l7a<;lrOzvZ8X!{>#Lxf?h*rbq-s z!>XtlH~~&p$siNYaKIvLIW8 z`ufz=^T*ASQ&`dUj+=D@!>`;q{McCV<|b-RY}3g6mYN35K*REJCpMooWy{i^l8Fq8 z53f*DwjOmg9!x_*cG68Mk7sOio}4eNQfJnOg@3qs@uF~(d-}M`4f~=s$44BK2z=vu zRys=0a(AC(-)#^z=-O%nK@r^XHosR_>2IJ89HbQRHV&~b;jsltDYU6VP( z%rOBSU-M?4BR+{NLbrpBjEu8c*;@39V6%1xw6sY*W0TX|v8ie1hkk*WKwucswM=Vk zEc$$firZ=7Mx!IZbSOaWx;K#Ypc96aPd>*Uh20ZV8dYeWE4z2{?On^tcDx5asH;WU z-tXay!yHGX{UnB26uMx_{sY^}{{SgBtpP-w;5$3tW~&$BYi8!_0X z-JKl~Po`=eb7e^+%w~n(lAelfZduvtb5#oDR__|I+5@?p^IY|^9xG(OK|J5lO2JfE z_7rc+4y-vWYR4NmFx4ZN%i4D1OaCRrrE(}KiCJ=+B`d{q+1s(KuI*G;^AsApaV{*} zXlQCGDtgI~mOLq>1mmKLis~@?n1r*f9GTgu10PqYhR031xz~T2mW0%OO2FiLJuV(c z?sQ>@)iv_&CTbnHQ_9)fsr;KQrxR!i#Pu{d0s(Za-H6_R=9ZiT0FxD$Rh^$d2zZ^y z4`k458ZiR@gh?BV*LkDH20Tr-runQCMyFidot@Fu{-I-+{>CZj7v>jZ*u@ojc>{)U z*Vgomz)tlV-O9dei$N0plz z+1n?f-C_yo-2w9)L_-VXK_bFHWys) zo+{d$Vv6b2jUwzPcU&ii9V&SPGEQJKr;KL*x@mGsr13f;)7XHo<zeW$`@X?!opp zbhR>E_1(LSjDZj8ow-3x0ZTbZ)yvd=Ckf$I-c^>C?slotvUk72%2sn!6ut0Bh=$tx zwB$PMHVm~!etL=vI5>?{wpMz5_=_i+iCIZeO-)WgLFsV$dba_;zabsy%siP@%a`m7 zYWGT9Mr^3{xOaD`AWJ)Ve*hvcAGC+F6c8xQ*Npunx4D>+!5sK%_VCq#u%-O=R5}s6 z^*F)xEuVf)6>Tvffy*w07^yyZZm7wuP1rPJx#u_Yne0^kjGoqbmE3y^+F`KR>Z&Sl;=00pW`H2Z?PzK(MJ_W!QG|NxWhSvHuJ9eI zD>U4!B(H_CPO8PBEamYZKU9U;78VyA9hX|pw|hTUr~hG@!wY{r1cRU{ng7m2cxOlM zydhGr4f?Dmg!fl9;=+ahu^)>5rwR4rb@NXO^0Kk4%46srZIUMmoO21(bODgd!o!b8 z`4^kGJhB zDk{E2tltWhmg-dDf6+H3*4fBa39}F6S;xt_w9hj}H)vFAeP&~6^>B7hSM>HBM1TDH zv&0=AG(P!@Qy{jT+_?vKsnq#a-~rnw@!%<7LxhB!hr(p%rb+{l<)NWJ!Uo*Y)qdZD z=(IxoX9b3BnqItm{A|YZJjk}@+;7u*VfP4dTGz%-|qX)#-f@c}(O8hw3Zhk>4fK?coJ z@=#O-si3x2s>(4dy1X^7RrE{XSY!1{<#w=8uZ}*e&b43?4g1uc3iFXjdkeV6+GooH zw2pQj1G^RmvT^1x=P;}_@bhps=|(68wU(^klXU>yMVk-eV06s%K|mV1*|>Q&FHvfD z@V!m3@~m&1McQ*}e7N*ALRTK=yRy& z{m8ftyK|31vl|Lf^T|s1t^f=-Vn>jAHdFaN*iJ_5 z*y@Se-vd{ti4D}ZuE6ICZNUb%X(2L=CGo}Gn?b?I>fN2ewfHpinblRniFAMhuBWgY zCR|ci2QU)=Vk_u*D8-bfiAMl36K5)^qoeaNhke!sU|B}TA9F8)@aBdV%$Q<8mS>-p zstgUC!@)Bw_*cGmA(nqobpvA%iNcd81*#tuBJtdP)%;742!=GLyb!OG8WD=(<9mIlxPH zpN}!x|Cof>za096Ur*mjnrgOS;j56M4gzZ&w4#B7JKKIC@ZJ1 zM!f-e8)d{^^IRymKt>|@%GR)bk3gxbS%`_HC1TVC0IMF3u<#P$^WAf${mCErpvJON zwAB-nlf$H;h@W)fIMPxe?fH?xuEWs1fPmKh4T|5-Z&!#84lFJLddno9{am*{bGWrt zS*&G;L>^)#O7@L*43M2_4GMj(H5!>2`K*{bxwtXVri_`Ee56BitM6`a)6iU1%ueR2 zn&-lHux4xI51gK5<$TJfqM}kt_iNgYQej)U!g8b<&VBGAbzfWJI+jgAhtRyG-nB@3SW@$ex4dyRlfypf}UDFhRV zyWYO9R;-3ut^&bk7a%@LlFTs$YRr!Lhyt|$bCeuvX!5K7ntQ!)hInvU_;)W}5v+sj zarmp_L`xkV-SWN*Vjln00?J+0FsN?F?e-(hShn+1Yse#3nZfRE$)M$HK$roz62o=u zox69v)|V45UHb4rUELzv_j`;tQ-3b19)jrV;x(izK0Z1c%#q9g{B=3jl1WojolPa3 zpX?2H>3<=>MvSZt^M;+eA7#w0 zam+m5i(0vno0L90H6`!A=%%Szj3cLr#xh2$8?kAGb|N2g@KKx=QL;B@Nwr*N-n)!x;*W(LseE+j(Q(0Pho2$(6 ztntemqBhh2B|FL$WB!vJ0ePUG*Fo&g$SgZS9d4En{sljFdugv4TA+5NtFI4ZA8ar+J4#gZT1!+^miDv?8S&lY3L>7U z*Mh;FJUsFg*q!E7f(}eSudDBM)|KXcFAbg;?CVCM(Vg>ad{eRd`lbsDMwk-a?9_sJ z=bIlWMpot-0`p5s8emm|T#D-t-}=0IB`&TvFwkc0l@yGcSzO%QWV$&cHYK0!OK75| z2l>G0lRWV7y-5pd5si>qmEn+e?x z71fXShwGz|ch%KNxrp3PtpMxlF5u2#z4yBRP+-)WI&7r<-QGO6q43Y1i5O<%Pe~#g zx^|HzEj9BGi;Ht~d?XqS8hndt$EQX{svN!3K7|>+=8VjpJz;U=P)L7bYa6me(2a`L zrDap*T0Oo_6MIX_Zf?RI+umMekUcW2GPmCN8FW7L?f!8DpMnD9q@gmFCKiCy+O-LT zY{}n6axy`CD=tZCsC?Sfr~Oj{wn629UZK(QNpBMQLw%F?LSbp;unogpp6Mpi-E$M) zTARPyrW`bcyOtzx3vFq~DOY*MB~@4BW^631UW7QGj2ClgM-|OCuhVcS)t!IKlPXss zp^7=EStPg6Og43R;^}tTkXlI)arjv5!FwUn^E|(a{1T0z#rkxcI02!}}}2ruzCe{^}pbl%<$367oD!dfdP29Xr*Q z<`+?86FQ(bw#omn)ka&%{)Nh<&y)8v0{h$n7jO7q6|f`+HEbyfCqcoUt*<=%oR%B2 ztfD*b`L`beGS*Z01Z^;AUShKG&Fdl(XJB}eZ7N~C<^N3kb!agk2YPQ z;juwS<4q3Uy9KUYv@D8`^UbiW^Ql!;uf^}K-e7Uv+iO{i#k3yol%Kq72P6`SVuM}D z0Ctsx!_JJ_{r!ESu5}3wNTmlmT8)qYEj4ahXO`K6f~=uzpTb2{yQ~N^GfsfcW$a6Q zLK^l+q!;k--8gDf5tw*OBpCdZ%{*EkHBeLcWl}fvCzf_w0_g6JmkatilXkuh0fN#O zZ!}kh#TPiAK5vmjLA1N7z5R2;N~f`jaawlCnGcRRIOOKH>Y=vzHT!^_;qcm~I?lGo zY#`AOe@yv2azOQz3wY+K8jOs~4SDR;koi3rRkTmx>rq}>*7R5gNC1wheCD_Eu+3o0 z7N2yjJC!WhJFibBhn-h$=`x=C&ZW8=C>^)(Z%kEtj)$MOc&vIVlYDC9;!4L|ut-ST z>E6-q;ye?>BxDo0OEge9wp-dzf}Qs7iiI2w)fYX=YFJzYGnMIryeM_3sO$r}HEqW+ zJ!tgI@0I2CFAy1q#f7Q3REEVh>M#xhuRyXLxxn4Kcll&cIT}WR&m!QNHk)(qQ!Otu zkxwkmA)JPvS36$`$2kQJ;oG+b6zj*nf* z#N1fP>(EJ?XYtFJUC|)a1U_t4l4&F$9luv0D!6~7B9oM6=mxI~K`@$`n!DCA9p}yv z2qJaG`g}|&3>YChb_5JI`w&bCn4{r63Ow6tdM^tzo15c8?i)U^c@Ew9mXO~e##8xa zhJ*EWo=4(C;LQM$25KxiVX>zbjexi#VQC@lfwQZu0bE=_8Z%-r6*PTOAK~8YYdtUT ze{{*Ax)2Dg_R?aC!ZkoP2%k7{vcB*}Hz^@Y((CZ&=lHuZW|^7(X8FUZj5i!|2R`{> z_kqlBJY|7t_0Md1x=c0(daBxwj#x5Aj#1KFArS^-q-12Iq@~=atM*ChsHjGTVaGx0 zYJi9Vw)GJgsuY2+ZF2?;Rhh%1I%KHd&in1nwtdpq@1bHQfC@G8qA2@m^Uhqsbx7Ch`>f3w6cQ6wOY@7XB90n)fvlf4GdnP#K(_Qzl)9HHKKxh2h_XjE`YBf^K z1F8q-uR6AFo2HCQXYP$Rc`92qu5y7v--bS)AnpRl*V8L+Zj1lbIO$vd@Ud5Kx=D3s zP$Wvcq4C4}%el(gL6Jb)mxfTF`m*A$mW1p7O3~#%Y#XvS^njm2tsQx|%=!Q5NnWAl zwsb9rmv%Wgr+#5ffty}JE;A7q=Ef=Q z!Y+c$tGYMNPuc2)`GrJQFo+PiX}+(tR#bj!@=pin92w6Xt$ZcQaIHI_D_dKm=aY?M z-{%Puj)SbFg-?yRUS3XoAw|n7`}pL51JNo%_J*s4v~i`9P0}fT%q}%qD-VLMwZK3f z5`9igs(@0Z_#j?AfTsXrB-e-7cB` z88fx~sX@?0Wmb7pvsZ3aV8V~yTA(}-`mu{)Ju8cUJ&YiIKdK=Lzha7Yw zyj7W$z3`LQ5KR;|;?Tn_l{$u}QwFG`*ucZ|LYl~8HZIu1cQK2}`?(y$v>a?qv*ifg zr~AFtKmjXN`q6y(=xe}JURZXy!;N}EClUPGw!v|`zfqS%xgfXIZDszi?AD=^<3(^E z^!%-WM5=O&IdhBG+0SR*BgY7YPKB1`@qATVaGC}oV}Pb>qk_Wr=*M*C`POswA@ptl z4|040TF-AstU1%jz6d(J)in86kCff?{h{`l%s8g#_m$l4R%w#2UIj(G8i-9>)IY7$ z#p06NBgr4e&=?JWsIaT(lYK2*Y*RJQyI?Y=6WM)Ftyxy7*eFVT73lTQaOVZ>O@Vhs z6%}U~h+O8H>p)^A?)&#z%uhgojU%v?)r;L+U~OA|*}vGdS;3jX=1vLPss>jIG&l8L zsV+b@Lj!Z)g%)RqHrDC0G;YN~x*6KM`oOB)RuUwlfr0rGZoqCYkG^DdiiU>^o_5<@ z-z-n^$=SY+h)j7+>!6!=J7*D=w89nc^F6ePI%aH*b?Q#Jd;i>wS2_eu27<;`_!_GQ zj~pAEDajq?FyT%~w&gb23t#tBWa=Cid4gK26&K?;zW6PjqR#GC%x3P(b*=Pmxa=gE$#K**CFb#kqpXfPjE9 z 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 diff --git a/packages/chord/src/data/errors.ts b/packages/chord/src/data/errors.ts index a47461581..d992fca0e 100644 --- a/packages/chord/src/data/errors.ts +++ b/packages/chord/src/data/errors.ts @@ -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( reason: ChordErrorReason<"invalid">, message: string, @@ -109,6 +109,15 @@ export function invalid( return new ChordError({ code: "invalid", reason, message, details }) as Extract; } +/** + * 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 { + // The constructor's return type cannot narrow on its argument. + return new ChordError({ code: "forbidden", message, details }) as Extract; +} + /** `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 { if (isChordError(error)) return error; diff --git a/packages/chord/src/data/index.ts b/packages/chord/src/data/index.ts index 3eb2015e8..4eadb7810 100644 --- a/packages/chord/src/data/index.ts +++ b/packages/chord/src/data/index.ts @@ -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 { ChordError, type ChordErrorCode, type ChordErrorReason, + forbidden, + invalid, isChordError, PluginError, } from "./errors.ts"; diff --git a/packages/chord/src/host/route.ts b/packages/chord/src/host/route.ts index 7a1fb2a8c..6f3fc5f4f 100644 --- a/packages/chord/src/host/route.ts +++ b/packages/chord/src/host/route.ts @@ -6,7 +6,7 @@ import type { Context } from "../context/index.ts"; import type { Address, HostKind, KindShape } from "../contracts/index.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 type { Codec, Link, PartLimits } from "../link/index.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 { const address = checkAddress(this.kind, this.#params, params, context); 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); if (routed === undefined) { diff --git a/packages/chord/src/node/app/data.ts b/packages/chord/src/node/app/data.ts index 0bad28c67..a2b5511e1 100644 --- a/packages/chord/src/node/app/data.ts +++ b/packages/chord/src/node/app/data.ts @@ -5,7 +5,7 @@ import { BACKGROUND_CONTEXT } from "../../context/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 { defineFacet, type Facet } from "../../host/index.ts"; import type { JsonValue } from "../../json.ts"; @@ -35,7 +35,7 @@ export function hostDataFacet

    (store: Store, files: string, return defineFacet

    ((host) => host.serve(HostData, (_params, principal) => { 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 { variant: "main", diff --git a/packages/chord/src/react/README.md b/packages/chord/src/react/README.md index a140a3a56..68209cae5 100644 --- a/packages/chord/src/react/README.md +++ b/packages/chord/src/react/README.md @@ -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 (`useSyncExternalStore` over `watchStore`). - `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`. diff --git a/packages/chord/src/react/hooks.ts b/packages/chord/src/react/hooks.ts index 3cfc08707..8abdf1ac9 100644 --- a/packages/chord/src/react/hooks.ts +++ b/packages/chord/src/react/hooks.ts @@ -1,7 +1,8 @@ // 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 -// unmounted by a reload has not left (design §21): its releases are plain releases, presence is the app's. No -// components, no styling. +// context, references held for a component's life, watchables read with `useSyncExternalStore`, and page state kept +// for the host's life. A component unmounted by a reload has not left (design §21): its releases are plain releases, +// 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 { createContext, @@ -19,6 +20,7 @@ import type { Principal } from "../data/index.ts"; import { canonicalJson } from "../data/principal.ts"; import type { Held, Host, RefState } from "../host/index.ts"; import type { Registry } from "../registries/index.ts"; +import { pageState } from "../surfaces/page-state.ts"; import type { NativeWatchable, Watchable } from "../watch.ts"; import { watchStore } from "./stores.ts"; @@ -77,3 +79,13 @@ export function useRegistry(R: Registry): V | undefined { export function useRefState(ref: { readonly state: NativeWatchable } | undefined): RefState | undefined { 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(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]; +} diff --git a/packages/chord/src/react/index.ts b/packages/chord/src/react/index.ts index eac53ffdf..b97aad21a 100644 --- a/packages/chord/src/react/index.ts +++ b/packages/chord/src/react/index.ts @@ -1,4 +1,4 @@ // 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"; diff --git a/packages/chord/src/surfaces/page-state.ts b/packages/chord/src/surfaces/page-state.ts new file mode 100644 index 000000000..6980fed85 --- /dev/null +++ b/packages/chord/src/surfaces/page-state.ts @@ -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 = { + /** 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>>(); + +/** The page state of `host` at `key`: what was kept there, or `initial` when the key is new. */ +export function pageState(host: Host

    , key: string, initial: T): PageState { + 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(initial); + states.set(key, state); + } + // A key is the app's: whoever names it names its type too. + return state as PageState; +} + +/** A value with its subscribers. */ +function keptValue(initial: T): PageState { + 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); + }; + }, + }; +} diff --git a/packages/chord/src/tui/README.md b/packages/chord/src/tui/README.md index d1daf7592..5bdc17a33 100644 --- a/packages/chord/src/tui/README.md +++ b/packages/chord/src/tui/README.md @@ -23,7 +23,9 @@ startTerminalRuntime

    ({ - `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`, `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` - (no `changed` after it). References wait and rebind by themselves; no terminal library dependency. +- `scope.ts`: `TuiScope(host, changed)`: `hold`, `watch` (a reader, `undefined` while no value), `registry`, + `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/`. diff --git a/packages/chord/src/tui/index.ts b/packages/chord/src/tui/index.ts index d8d14e275..dba8fa5a1 100644 --- a/packages/chord/src/tui/index.ts +++ b/packages/chord/src/tui/index.ts @@ -2,6 +2,7 @@ // 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`. +export type { PageState } from "../surfaces/page-state.ts"; export type { Bridge, BridgeRequest, BridgeStart, LauncherMessage, SandboxMessage } from "./bridge.ts"; export { type AttachedFile, diff --git a/packages/chord/src/tui/scope.ts b/packages/chord/src/tui/scope.ts index ba357ef14..f222a0cb4 100644 --- a/packages/chord/src/tui/scope.ts +++ b/packages/chord/src/tui/scope.ts @@ -1,13 +1,15 @@ // `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, -// calls one `changed` callback (the app's redraw) on every change, and releases everything at `dispose`. References -// wait and rebind by themselves (no retry loop, unlike p2's); no terminal library dependency. +// calls one `changed` callback (the app's redraw) on every change, and releases everything at `dispose`. Its page state +// 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 type { Contract, ParamsOf } from "../contracts/index.ts"; import type { Principal } from "../data/index.ts"; import type { Held, Host } from "../host/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"; /** A component's scope: what it holds and follows, until `dispose`. */ @@ -68,6 +70,17 @@ export class TuiScope

    { 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(key: string, initial: T): PageState { + 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. */ dispose(): void { this.#abort.abort(); diff --git a/packages/chord/test/examples/61-tutorial/main.ts b/packages/chord/test/examples/61-tutorial/main.ts index 20b69b895..6685caf2f 100644 --- a/packages/chord/test/examples/61-tutorial/main.ts +++ b/packages/chord/test/examples/61-tutorial/main.ts @@ -9,11 +9,12 @@ import { cp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { PassThrough } from "node:stream"; import { fileURLToPath } from "node:url"; +import { List } from "@todo/app/v1"; import { WebSocket } from "ws"; import { BACKGROUND_CONTEXT as context } from "../../../src/context/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 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 { startTodo, todoDirectories } from "./start.ts"; @@ -157,17 +158,15 @@ if (adas.variant === "member") { await until(() => groceries.items.value.some(({ by }) => by === "ada")); 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 // 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 // reference follows them. // The edit: `add` in the `Todos` view, before and after. const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),"; -const AFTER = `add: async (text: string, ctx: Ctx) => { - if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx); - return null; - },`; +const AFTER = "add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),"; const workerFile = join(root, "lists", "worker.ts"); await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER)); 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 until(() => groceries.items.value.at(-1)?.text === "tea"); console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1))); -const count = groceries.items.value.length; -await adas.add(" ", context); -console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added"); +const refused = await adas.add(" ", context).then( + () => "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 // the archive makes a new list with the same todos. An address that has data is never overwritten. diff --git a/packages/chord/test/examples/61-tutorial/package.json b/packages/chord/test/examples/61-tutorial/package.json new file mode 100644 index 000000000..306e2b30d --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/package.json @@ -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" + } +} diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/contract.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/contract.ts new file mode 100644 index 000000000..6434861a3 --- /dev/null +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/contract.ts @@ -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; + +/** + * 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() }, + }, + }, +}); diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/server.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/server.ts index 784346692..9ccb71d5c 100644 --- a/packages/chord/test/examples/61-tutorial/plugins/lists/server.ts +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/server.ts @@ -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 // 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 { Todos } from "./contract.ts"; export default defineFacet((_host, self) => { self.contribute(Http, { diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/tui.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/tui.ts index 957fe472d..f89ccf644 100644 --- a/packages/chord/test/examples/61-tutorial/plugins/lists/tui.ts +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/tui.ts @@ -1,7 +1,8 @@ // 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 { Todos } from "./contract.ts"; export default defineFacet((_host, self) => self.contribute(Screens, { diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/web.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/web.ts index ec2f3dadc..5e6d39b7a 100644 --- a/packages/chord/test/examples/61-tutorial/plugins/lists/web.ts +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/web.ts @@ -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 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 { Panels, useHold, useWatch } from "@todo/app/v1/web"; -import { createElement as h, useState } from "react"; +import { BACKGROUND_CONTEXT as context, defineFacet } from "@todo/app/v1"; +import { Panels, useHold, usePageState, useWatch } from "@todo/app/v1/web"; +import { createElement as h } from "react"; +import { Todos } from "./contract.ts"; import "./lists.css"; 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 [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 }) => { event.preventDefault(); if (todos === undefined || text.trim() === "") return; diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/worker.test.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/worker.test.ts index f1126478c..125c5201d 100644 --- a/packages/chord/test/examples/61-tutorial/plugins/lists/worker.test.ts +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/worker.test.ts @@ -1,22 +1,25 @@ // 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 -// 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: // node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts import { fileURLToPath } from "node:url"; +import * as v1 from "@todo/app/v1"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; 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 * as v1 from "../../src/api/v1/index.ts"; -import type { TodoPrincipal } from "../../src/principals.ts"; +import { Todos } from "./contract.ts"; const ADA = { kind: "user", id: "u1", name: "ada" } as const; const OWNER = { kind: "owner" } as const; describe("the lists plugin on a list's worker", () => { - let t: TestedPlugin; + let t: TestedPlugin; beforeEach(async () => { - t = await testPlugin( + t = await testPlugin( fileURLToPath(new URL("./", import.meta.url)), { 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 () => { await t.root( async (ctx) => { - const todos = await ctx.get(v1.Todos, { list: "groceries" }); + const todos = await ctx.get(Todos, { list: "groceries" }); expect(todos.variant).toBe("member"); await todos.add("coffee", ctx); 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 () => { await t.root( 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}`); await todos.add("milk", ctx); await todos.add("eggs", ctx); diff --git a/packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts b/packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts index cec65d57e..57acd38e1 100644 --- a/packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts +++ b/packages/chord/test/examples/61-tutorial/plugins/lists/worker.ts @@ -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 // 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 -// 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 { arr, @@ -10,16 +11,16 @@ import { defineFacet, defineTable, eq, - Item, int, + invalid, local, method, num, obj, query, str, - Todos, } from "@todo/app/v1"; +import { Item, Todos } from "./contract.ts"; /** Every todo of the list, by its number. */ const Items = defineTable("todo.items", { @@ -63,6 +64,8 @@ export default defineFacet((host) => { return { items: self.derive([window], ([value]) => value), add: async ({ text, by }, ctx) => { + // Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`. + if (text === "") throw invalid("args", "a todo needs text"); const item = { id: next++, text, done: false, by, at: Date.now() }; await self.tx(async (tx) => tx.put(items, item), ctx); // In number order: two adds may commit in either order. diff --git a/packages/chord/test/examples/61-tutorial/screenshots.ts b/packages/chord/test/examples/61-tutorial/screenshots.ts index 869e1b87c..7f467aea8 100644 --- a/packages/chord/test/examples/61-tutorial/screenshots.ts +++ b/packages/chord/test/examples/61-tutorial/screenshots.ts @@ -8,7 +8,7 @@ import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.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"; const out = resolve(process.argv[2] ?? "screens"); @@ -73,7 +73,8 @@ try { await browser.wait("getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() === '#12232e'"); 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.type("oat mi"); // 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)); await app.applyChanges(context); await browser.wait("document.querySelector('.lists-open') !== null"); + await browser.evaluate("document.querySelector('.lists-panel input').focus()"); await shoot("edited.png"); const after = await browser.evaluate("document.querySelector('.lists-panel input').value"); console.log(`typed ${JSON.stringify(typed)}; after the edit: ${JSON.stringify(after)}`); diff --git a/packages/chord/test/examples/61-tutorial/src/api/v1/index.ts b/packages/chord/test/examples/61-tutorial/src/api/v1/index.ts index 6eaff52a9..87065321f 100644 --- a/packages/chord/test/examples/61-tutorial/src/api/v1/index.ts +++ b/packages/chord/test/examples/61-tutorial/src/api/v1/index.ts @@ -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` -// typed with the app's principals, the app's host kind (a list, one worker each) and its contract for a list's todos. -// Plugins import this module, never Chord: their builds leave it out and each process passes its own instance in. +// typed with the app's principals, and the app's host kind (a list, one worker each). Plugins import this module, never +// Chord: their builds leave it out and each process passes its own instance in. import { - arr, - bool, defineFacet as chordFacet, type FacetHost, type FacetSelf, hostKind, - int, - method, - num, - obj, - remote, - type Static, str, } from "../../../../../../src/index.ts"; import type { TodoPrincipal } from "../../principals.ts"; @@ -26,13 +18,17 @@ export { type Ctx, defineTable, eq, + forbidden, int, + invalid, local, method, num, obj, PluginError, query, + remote, + type Static, str, type Theme, Themes, @@ -46,29 +42,3 @@ export function defineFacet(setup: (host: FacetHost, self: FacetS /** A todo list: a worker process of its own, with its own store. */ 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; - -/** - * 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() }, - }, - }, -}); diff --git a/packages/chord/test/examples/61-tutorial/src/api/v1/web.ts b/packages/chord/test/examples/61-tutorial/src/api/v1/web.ts index d68103d9f..4ddb54c66 100644 --- a/packages/chord/test/examples/61-tutorial/src/api/v1/web.ts +++ b/packages/chord/test/examples/61-tutorial/src/api/v1/web.ts @@ -4,7 +4,7 @@ import type { ComponentType } from "react"; 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. */ export type Panel = { readonly title: string; readonly Component: ComponentType }; diff --git a/packages/chord/test/examples/61-tutorial/src/tui/main.ts b/packages/chord/test/examples/61-tutorial/src/tui/main.ts index 0d8a4eaf8..fdd45fe8f 100644 --- a/packages/chord/test/examples/61-tutorial/src/tui/main.ts +++ b/packages/chord/test/examples/61-tutorial/src/tui/main.ts @@ -2,10 +2,10 @@ // 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. +import * as v1 from "@todo/app/v1"; +import * as v1tui from "@todo/app/v1/tui"; import { defineFacet } from "../../../../../src/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. */ const core = defineFacet((host) => host.serve(v1tui.Screens, { compose: (table) => table.map(({ value }) => value) })); diff --git a/packages/chord/test/examples/61-tutorial/src/web/main.ts b/packages/chord/test/examples/61-tutorial/src/web/main.ts index 53d0d42c1..44a312575 100644 --- a/packages/chord/test/examples/61-tutorial/src/web/main.ts +++ b/packages/chord/test/examples/61-tutorial/src/web/main.ts @@ -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 // 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 { createElement as h } from "react"; import * as jsx from "react/jsx-runtime"; @@ -8,8 +10,6 @@ import { createRoot } from "react-dom/client"; import { defineFacet } from "../../../../../src/index.ts"; import { HostProvider, useRegistry } from "../../../../../src/react/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"; /** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */ diff --git a/packages/chord/test/examples/61-tutorial/start.ts b/packages/chord/test/examples/61-tutorial/start.ts index 4715e1d10..c13b0f739 100644 --- a/packages/chord/test/examples/61-tutorial/start.ts +++ b/packages/chord/test/examples/61-tutorial/start.ts @@ -7,11 +7,11 @@ import { cp, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; 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 App, startServer } from "../../../src/node/app/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"; /** The app's API: its name, its versions (newest first), and this process's instances of its modules. */ diff --git a/packages/chord/test/node/fixtures/web/main.ts b/packages/chord/test/node/fixtures/web/main.ts index 41fce16df..1ca566199 100644 --- a/packages/chord/test/node/fixtures/web/main.ts +++ b/packages/chord/test/node/fixtures/web/main.ts @@ -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 // 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 { createElement as h, useState } from "react"; @@ -8,7 +9,14 @@ import * as jsx from "react/jsx-runtime"; import { createRoot } from "react-dom/client"; import { BACKGROUND_CONTEXT } from "../../../../src/context/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 * 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() { const panels = useRegistry(webApi.Panels) ?? []; const [draft, setDraft] = useState(""); const [shown, setShown] = useState(true); + const [note, setNote] = useState(0); return h( "div", null, @@ -64,6 +87,8 @@ function App() { }), h("button", { type: "button", id: "toggle", onClick: () => setShown((value) => !value) }, "toggle"), shown ? h(Counter) : null, + h("button", { type: "button", id: "remount", onClick: () => setNote((value) => value + 1) }, "remount"), + h(NOTES[note % NOTES.length]!), ); } diff --git a/packages/chord/test/node/react/hooks.test.ts b/packages/chord/test/node/react/hooks.test.ts index 01f60197f..4d13ec786 100644 --- a/packages/chord/test/node/react/hooks.test.ts +++ b/packages/chord/test/node/react/hooks.test.ts @@ -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: // `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 { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; @@ -42,4 +43,29 @@ describe.skipIf(CHROMIUM === undefined)("React bindings", () => { await app.close(context); } }, 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('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('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('document.getElementById("note").value')).toBe("oat mi"); + } finally { + await app.close(context); + } + }, 120_000); }); diff --git a/packages/chord/test/surfaces/page-state.test.ts b/packages/chord/test/surfaces/page-state.test.ts new file mode 100644 index 000000000..91cec0ffa --- /dev/null +++ b/packages/chord/test/surfaces/page-state.test.ts @@ -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(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); + }); +}); diff --git a/packages/chord/test/tui/scope.test.ts b/packages/chord/test/tui/scope.test.ts index d9de9a0f4..39a63abce 100644 --- a/packages/chord/test/tui/scope.test.ts +++ b/packages/chord/test/tui/scope.test.ts @@ -1,5 +1,6 @@ // 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 { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts"; @@ -78,3 +79,33 @@ describe("a component's scope", () => { 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); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 611472c26..80f031c70 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,10 +29,6 @@ "@notes/app/*": ["./packages/chord/test/examples/53-app/app/*.ts"], "@board/app/*": ["./packages/chord/test/examples/56-web/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/testing": ["./packages/telemetry/src/testing/index.ts"], "@earendil-works/pi-telemetry/render": ["./packages/telemetry/src/render.ts"],