mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(chord): invalid and forbidden exported, page state across plugin reloads, the tutorial app's API a workspace package
This commit is contained in:
parent
d6fb784c84
commit
8f9446a060
41 changed files with 687 additions and 191 deletions
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"apps/*",
|
"apps/*",
|
||||||
|
"packages/chord/test/examples/61-tutorial",
|
||||||
"packages/coding-agent/examples/extensions/with-deps",
|
"packages/coding-agent/examples/extensions/with-deps",
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
||||||
|
|
@ -2606,6 +2607,10 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@todo/app": {
|
||||||
|
"resolved": "packages/chord/test/examples/61-tutorial",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
|
@ -5995,6 +6000,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/chord/test/examples/61-tutorial": {
|
||||||
|
"name": "@todo/app",
|
||||||
|
"version": "0.0.1"
|
||||||
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
"name": "@earendil-works/pi-codemode",
|
"name": "@earendil-works/pi-codemode",
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"apps/*",
|
"apps/*",
|
||||||
|
"packages/chord/test/examples/61-tutorial",
|
||||||
"packages/coding-agent/examples/extensions/with-deps",
|
"packages/coding-agent/examples/extensions/with-deps",
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
- Chord v2 gains web and terminal surfaces (slice 11): `webSurface` and `tuiSurface` (`@earendil-works/chord/surfaces`) build a client kind's base bundle from the app's entry at start (content-hashed, served forever) and publish its plugins' facets as scripts that register themselves (`chordFacet("<plugin>", "<digest>", function (require, module, exports) {…})`) and one combined file, with `WebScripts` and `TuiScripts` showing the current set (a rollout step publishes without waiting for clients) and the page served through the app's own template (`PageAssets`, `pageScripts`). `startPage` (`@earendil-works/chord/web`) runs a page's client host: the connection loop (`keepConnected`: 250 ms doubling to 5 s, wakes, silent sockets, `canRetry`), plugins applied in place on a new set (a failed one keeps its old facet), a reload when the server's base bundle changed, and the active theme as CSS variables. React bindings (`@earendil-works/chord/react`): `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState` (React is an optional peer dependency). The terminal: `startTerminal` (`@earendil-works/chord/tui/launcher`) signs in with a token, a token file or device pairing and runs the app's terminal code always in a sandbox (a child process on pipes with Node's permission model: no file system, terminal, processes, workers, network or SQLite; Node 26.5.1 or later), filtering its output, relaying its link and confirming its actions (copy, open a URL, attach a file the user names); `startTerminalRuntime` and `TuiScope` (`@earendil-works/chord/tui`) inside it. Themes: the `Themes` registry, `ThemeChoice` (one dark and one light pick per principal), `followActiveTheme`, `themeVariables`, `terminalColor`. `startServer` gains `web` and `tui`. Plugin builds scope `*.module.css` files per plugin and file. Ephemeral sessions end with their client's last link (after a minute). The HTTP component's links endpoint gains an `opened` hook.
|
- Chord v2 gains web and terminal surfaces (slice 11): `webSurface` and `tuiSurface` (`@earendil-works/chord/surfaces`) build a client kind's base bundle from the app's entry at start (content-hashed, served forever) and publish its plugins' facets as scripts that register themselves (`chordFacet("<plugin>", "<digest>", function (require, module, exports) {…})`) and one combined file, with `WebScripts` and `TuiScripts` showing the current set (a rollout step publishes without waiting for clients) and the page served through the app's own template (`PageAssets`, `pageScripts`). `startPage` (`@earendil-works/chord/web`) runs a page's client host: the connection loop (`keepConnected`: 250 ms doubling to 5 s, wakes, silent sockets, `canRetry`), plugins applied in place on a new set (a failed one keeps its old facet), a reload when the server's base bundle changed, and the active theme as CSS variables. React bindings (`@earendil-works/chord/react`): `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState` (React is an optional peer dependency). The terminal: `startTerminal` (`@earendil-works/chord/tui/launcher`) signs in with a token, a token file or device pairing and runs the app's terminal code always in a sandbox (a child process on pipes with Node's permission model: no file system, terminal, processes, workers, network or SQLite; Node 26.5.1 or later), filtering its output, relaying its link and confirming its actions (copy, open a URL, attach a file the user names); `startTerminalRuntime` and `TuiScope` (`@earendil-works/chord/tui`) inside it. Themes: the `Themes` registry, `ThemeChoice` (one dark and one light pick per principal), `followActiveTheme`, `themeVariables`, `terminalColor`. `startServer` gains `web` and `tui`. Plugin builds scope `*.module.css` files per plugin and file. Ephemeral sessions end with their client's last link (after a minute). The HTTP component's links endpoint gains an `opened` hook.
|
||||||
- Chord v2's `HttpRequest` gains `remoteAddress`, the peer's address as the listener knows it (for limits per client).
|
- Chord v2's `HttpRequest` gains `remoteAddress`, the peer's address as the listener knows it (for limits per client).
|
||||||
- Chord v2's HTTP component gains `readSignedCookie(request, name, secret)`: `readCookie`'s value with its signed expiry, for renewing a cookie before it expires.
|
- Chord v2's HTTP component gains `readSignedCookie(request, name, secret)`: `readCookie`'s value with its signed expiry, for renewing a cookie before it expires.
|
||||||
|
- Chord v2's root exports `invalid(reason, message, details?)` and the new `forbidden(message, details?)`, the `ChordError`s a plugin throws for arguments or params it refuses (`invalid/args`, `invalid/params`) and for a principal it refuses (`forbidden`); an app's API re-exports them, and the caller gets the same `{ code, reason }` as from Chord's own checks.
|
||||||
|
- Chord v2's React bindings gain `usePageState(key, initial)`, `useState`'s pair with the value kept by the page's host under `key` instead of the component: a component of a reloaded web plugin is a new function and remounts (its `useState` starts over), and page state is what survives (a half-typed message, a scroll position); any value, never on the wire, gone with the page. `TuiScope` gains `pageState(key, initial)` on the same per-host store, its changes calling the scope's `changed`.
|
||||||
- Chord v2 reports mistakes that waited silently through `onError` (decision "Mistakes that wait are reported at once"): `unbound_long { waitingFor, error, since }`, a request waiting unbound (a type or registry not served here, not at its version, a registry whose first `compose` threw) once after 5 s, on one timer per host (it keeps waiting until its `readyTimeout`); `registry_undeclared { registry, plugins }`, a registry with contributions and no provider at a publication (`load`, a reload's commit) or when a host-lifetime object's contributions enter, naming the contributing plugins, again only once they change; `transaction_cancelled { object, error }`, a document change or transaction that did not commit because the context it was started with ended first (a root whose `fn` returned, a caller that cancelled, its object's end), besides rejecting.
|
- Chord v2 reports mistakes that waited silently through `onError` (decision "Mistakes that wait are reported at once"): `unbound_long { waitingFor, error, since }`, a request waiting unbound (a type or registry not served here, not at its version, a registry whose first `compose` threw) once after 5 s, on one timer per host (it keeps waiting until its `readyTimeout`); `registry_undeclared { registry, plugins }`, a registry with contributions and no provider at a publication (`load`, a reload's commit) or when a host-lifetime object's contributions enter, naming the contributing plugins, again only once they change; `transaction_cancelled { object, error }`, a document change or transaction that did not commit because the context it was started with ended first (a root whose `fn` returned, a caller that cancelled, its object's end), besides rejecting.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ The design, the rule book for all of this, is [docs/design.md](docs/design.md).
|
||||||
| `@earendil-works/chord/app` | The app kit: `startServer`, `startWorker`, the start-data objects, sessions and the owner's sign-in, device pairing, files, export and import per address, optional plugins (users, invites, ephemeral login). | Node |
|
| `@earendil-works/chord/app` | The app kit: `startServer`, `startWorker`, the start-data objects, sessions and the owner's sign-in, device pairing, files, export and import per address, optional plugins (users, invites, ephemeral login). | Node |
|
||||||
| `@earendil-works/chord/surfaces` | The client surfaces' server side: `webSurface` (base bundle, plugin scripts, the page) and `tuiSurface` (the terminal surface). | Node |
|
| `@earendil-works/chord/surfaces` | The client surfaces' server side: `webSurface` (base bundle, plugin scripts, the page) and `tuiSurface` (the terminal surface). | Node |
|
||||||
| `@earendil-works/chord/web` | The web surface's browser side: `startPage` in the app's web entry, `followThemes`. | Browsers |
|
| `@earendil-works/chord/web` | The web surface's browser side: `startPage` in the app's web entry, `followThemes`. | Browsers |
|
||||||
| `@earendil-works/chord/react` | Optional React bindings: `HostProvider`, `useHold`, `useWatch`, `useRefState`, `useRegistry`, `watchStore`. | Browsers |
|
| `@earendil-works/chord/react` | Optional React bindings: `HostProvider`, `useHold`, `useWatch`, `useRefState`, `useRegistry`, `usePageState`, `watchStore`. | Browsers |
|
||||||
| `@earendil-works/chord/tui` | The terminal surface's sandbox side: `startTerminalRuntime` in the app's terminal entry, `BridgeTerminal`, `TerminalTheme`, `TuiScope`. | Node (sandboxed) |
|
| `@earendil-works/chord/tui` | The terminal surface's sandbox side: `startTerminalRuntime` in the app's terminal entry, `BridgeTerminal`, `TerminalTheme`, `TuiScope`. | Node (sandboxed) |
|
||||||
| `@earendil-works/chord/tui/launcher` | The terminal launcher, the trusted side of a terminal client: `startTerminal`, `requireSandboxNode`. | Node |
|
| `@earendil-works/chord/tui/launcher` | The terminal launcher, the trusted side of a terminal client: `startTerminal`, `requireSandboxNode`. | Node |
|
||||||
| `@earendil-works/chord/context` | Explicit cancellation, telemetry, and invocation values: `BACKGROUND_CONTEXT`, `withCancel`, `withAbortSignal`, `withoutAbortSignal`, `withTelemetry`, `traceIn`, `withContextValue`, `awaitWithContext`. | Node and browsers |
|
| `@earendil-works/chord/context` | Explicit cancellation, telemetry, and invocation values: `BACKGROUND_CONTEXT`, `withCancel`, `withAbortSignal`, `withoutAbortSignal`, `withTelemetry`, `traceIn`, `withContextValue`, `awaitWithContext`. | Node and browsers |
|
||||||
|
|
|
||||||
|
|
@ -1949,6 +1949,14 @@ type ReloadReport = {
|
||||||
would interleave unchecked. In practice a restart costs little (the Durable harness is base code, terminal PTYs live
|
would interleave unchecked. In practice a restart costs little (the Durable harness is base code, terminal PTYs live
|
||||||
in their own process, model runtimes, sandboxes and the search index rebuild in milliseconds); reloading the plugin
|
in their own process, model runtimes, sandboxes and the search index rebuild in milliseconds); reloading the plugin
|
||||||
that owns SSH connections or the relay socket hurts.
|
that owns SSH connections or the relay socket hurts.
|
||||||
|
- **UI on client hosts** (decision "Slice 11, P4", refined by "Tutorial gaps" (1)): a page or terminal applies a new
|
||||||
|
plugin script with `host.reload`, as any host; the UI re-renders from its registries and keeps what lives outside
|
||||||
|
the changed plugin. A component of a reloaded plugin is a new function, so React (or a terminal scope reopened by the
|
||||||
|
app) mounts it anew and its own state (`useState`) starts over. What must survive a plugin update (a half-typed
|
||||||
|
message, a scroll position) is **page state**: values the client host keeps by key (`usePageState(key, initial)` in
|
||||||
|
`chord/react`, `scope.pageState(key, initial)` on a `TuiScope`, one store per host), any same-host value, never on
|
||||||
|
the wire, untouched by reloads and gone with the page (a page reload, a terminal's new base bundle). Rejected: React
|
||||||
|
Fast Refresh (a Babel or SWC transform in every web build, fragile when hooks change).
|
||||||
- **No factory trial, no migrate dry run, no consumer contract check during staging:** staging records declarations;
|
- **No factory trial, no migrate dry run, no consumer contract check during staging:** staging records declarations;
|
||||||
incompatible consumers wait at bind (`failed { waiting }`, `contract_mismatch`). The one exception is the closure
|
incompatible consumers wait at bind (`failed { waiting }`, `contract_mismatch`). The one exception is the closure
|
||||||
rule above: when a plugin of the reload failed, a changed plugin's recorded requests are checked as a bind would
|
rule above: when a plugin of the reload failed, a changed plugin's recorded requests are checked as a bind would
|
||||||
|
|
@ -2312,7 +2320,9 @@ Decisions "The app kit, point 1" to "point 5". `startServer(options)` on the ser
|
||||||
entry. Opinionated and small: an app is its API, its built-in plugins and a start script.
|
entry. Opinionated and small: an app is its API, its built-in plugins and a start script.
|
||||||
|
|
||||||
- **Shape:** the app's layout is fixed: `src/api/v<n>/` (shared and per-kind modules) and the built-in plugins root
|
- **Shape:** the app's layout is fixed: `src/api/v<n>/` (shared and per-kind modules) and the built-in plugins root
|
||||||
`plugins/`. The data directory is a `startServer` option (no environment variables), default a dot directory named after
|
`plugins/`. The API is a real package (decision "Tutorial gaps" (3)): a workspace package of the app's repository
|
||||||
|
(`"name": "@acme/app"`, `"exports": { "./v1": ..., "./v1/web": ... }`) that npm links, so Node, TypeScript, tests and
|
||||||
|
plugin builds resolve it alike (a test imports a plugin's `contract.ts`). The data directory is a `startServer` option (no environment variables), default a dot directory named after
|
||||||
the app in the home directory. One server; worker kinds are a list the app defines, each a host kind with its own
|
the app in the home directory. One server; worker kinds are a list the app defines, each a host kind with its own
|
||||||
params (sharding is the app's) and how it starts (entry, start data, principal, idle time); zero kinds is a server
|
params (sharding is the app's) and how it starts (entry, start data, principal, idle time); zero kinds is a server
|
||||||
with clients only.
|
with clients only.
|
||||||
|
|
@ -2334,7 +2344,7 @@ entry. Opinionated and small: an app is its API, its built-in plugins and a star
|
||||||
|
|
||||||
### Web and TUI (slice 11)
|
### Web and TUI (slice 11)
|
||||||
|
|
||||||
Decision "Slice 11's points" and "Slice 11, P3" to "P9".
|
Decision "Slice 11's points", "Slice 11, P3" to "P9", and "Tutorial gaps" (1) (page state).
|
||||||
|
|
||||||
- `chord/surfaces` serves the client surfaces (web and terminal) from the server; `chord/web` is a page's client host
|
- `chord/surfaces` serves the client surfaces (web and terminal) from the server; `chord/web` is a page's client host
|
||||||
with its connection loop (p2's `keepConnected`), loading and applying the plugins' web facets. A base bundle per
|
with its connection loop (p2's `keepConnected`), loading and applying the plugins' web facets. A base bundle per
|
||||||
|
|
@ -2342,13 +2352,15 @@ Decision "Slice 11's points" and "Slice 11, P3" to "P9".
|
||||||
one combined file named by its hash, later changed plugins one script each, each a script that registers itself (no
|
one combined file named by its hash, later changed plugins one script each, each a script that registers itself (no
|
||||||
eval); new scripts apply in place (`host.reload` in the page); pages learn of them through an object the web
|
eval); new scripts apply in place (`host.reload` in the page); pages learn of them through an object the web
|
||||||
component serves (the current set); a base code change reloads the page.
|
component serves (the current set); a base code change reloads the page.
|
||||||
- `chord/react`: `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState`.
|
- `chord/react`: `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState`, and `usePageState(key, initial)`:
|
||||||
|
`useState`'s pair kept by the page's host under a key, so a component remounted by a plugin reload (a new function)
|
||||||
|
keeps it (§21, "UI on client hosts").
|
||||||
- `chord/tui/launcher`: a Node client connecting with a token, always running the downloaded code (the app's terminal
|
- `chord/tui/launcher`: a Node client connecting with a token, always running the downloaded code (the app's terminal
|
||||||
entry on `chord/tui`) in a sandbox (decision "The terminal's sandbox is a child process": a child process on pipes
|
entry on `chord/tui`) in a sandbox (decision "The terminal's sandbox is a child process": a child process on pipes
|
||||||
with an empty environment, `--permission` granting nothing and no SQLite; the builtin allowlist and a frozen
|
with an empty environment, `--permission` granting nothing and no SQLite; the builtin allowlist and a frozen
|
||||||
substitute `process` inside; a bridge terminal; the trusted launcher outside, confirming copy, open and attach on its
|
substitute `process` inside; a bridge terminal; the trusted launcher outside, confirming copy, open and attach on its
|
||||||
own prompt); a component scope (`hold`, `watch`, `registry`, released on dispose, one `changed` callback for the
|
own prompt); a component scope (`hold`, `watch`, `registry`, released on dispose, one `changed` callback for the
|
||||||
app's redraw), no pi-tui dependency.
|
app's redraw; `pageState(key, initial)`, the host's page state shared with `usePageState`), no pi-tui dependency.
|
||||||
|
|
||||||
### Messages and languages (after slice 11)
|
### Messages and languages (after slice 11)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -625,6 +625,12 @@ The tab's reference follows the new object and gets one frame with the differenc
|
||||||
is broken (a syntax error, a factory that throws), that plugin keeps its old code on that host and the report says why;
|
is broken (a syntax error, a factory that throws), that plugin keeps its old code on that host and the report says why;
|
||||||
every other plugin of the rollout applies.
|
every other plugin of the rollout applies.
|
||||||
|
|
||||||
|
A tab applies a new web script the same way, without reloading the page, and the UI keeps what lives outside the
|
||||||
|
changed plugin. The changed plugin's own components are new functions, so React mounts them anew and their `useState`
|
||||||
|
starts over. What must survive the update (a half-typed note, a scroll position) goes into **page state**, kept by the
|
||||||
|
tab's host under a key: `usePageState(key, initial)` from `chord/react` instead of `useState`, and
|
||||||
|
`scope.pageState(key, initial)` on a terminal's `TuiScope` (design §21, "UI on client hosts").
|
||||||
|
|
||||||
:::note | Versions
|
:::note | Versions
|
||||||
A change to a contract (a new field in `Notes`'s state) gets a new `version`. A tab built with the old contract keeps
|
A change to a contract (a new field in `Notes`'s state) gets a new `version`. A tab built with the old contract keeps
|
||||||
working if the provider maps the old version (`accepts`); otherwise it waits with "version mismatch" until it reloads.
|
working if the provider maps the old version (`accepts`); otherwise it waits with "version mismatch" until it reloads.
|
||||||
|
|
|
||||||
|
|
@ -583,6 +583,11 @@ then gives every object of the changed plugin its fate:</p>
|
||||||
<p>The tab's reference follows the new object and gets one frame with the difference. Nothing reconnects. If the edit
|
<p>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;
|
is broken (a syntax error, a factory that throws), that plugin keeps its old code on that host and the report says why;
|
||||||
every other plugin of the rollout applies.</p>
|
every other plugin of the rollout applies.</p>
|
||||||
|
<p>A tab applies a new web script the same way, without reloading the page, and the UI keeps what lives outside the
|
||||||
|
changed plugin. The changed plugin's own components are new functions, so React mounts them anew and their <code>useState</code>
|
||||||
|
starts over. What must survive the update (a half-typed note, a scroll position) goes into <strong>page state</strong>, kept by the
|
||||||
|
tab's host under a key: <code>usePageState(key, initial)</code> from <code>chord/react</code> instead of <code>useState</code>, and
|
||||||
|
<code>scope.pageState(key, initial)</code> on a terminal's <code>TuiScope</code> (design §21, "UI on client hosts").</p>
|
||||||
<div class="note"><b class="tag">Versions</b><p>A change to a contract (a new field in <code>Notes</code>'s state) gets a new <code>version</code>. A tab built with the old contract keeps
|
<div class="note"><b class="tag">Versions</b><p>A change to a contract (a new field in <code>Notes</code>'s state) gets a new <code>version</code>. A tab built with the old contract keeps
|
||||||
working if the provider maps the old version (<code>accepts</code>); otherwise it waits with "version mismatch" until it reloads.</p>
|
working if the provider maps the old version (<code>accepts</code>); otherwise it waits with "version mismatch" until it reloads.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,37 @@ Refused at build, each with the file, line and rule:
|
||||||
Contract files mention native things (React components, functions) only through `import type`; at runtime they import
|
Contract files mention native things (React components, functions) only through `import type`; at runtime they import
|
||||||
only the API and the plugin's own data-only files.
|
only the API and the plugin's own data-only files.
|
||||||
|
|
||||||
|
## An app's API is a package
|
||||||
|
|
||||||
|
The app's API is a workspace package of the app's repository: a `package.json` named `@acme/app` with an `exports`
|
||||||
|
entry per module, listed in the repository root's `workspaces`, so `npm install` links it into `node_modules`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@acme/app",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./v3": "./src/api/v3/index.ts",
|
||||||
|
"./v3/web": "./src/api/v3/web.ts",
|
||||||
|
"./v3/worker": "./src/api/v3/worker.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugins, tests and tools resolve it alike: a facet imports `@acme/app/v3`, and so do the app's base code, a test, and
|
||||||
|
a plugin's `contract.ts` that a test or a script imports. In a plugin's build it stays a host package (left out of the
|
||||||
|
script, the process's own instance passed in when the script is evaluated).
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
Throw Chord's own errors where Chord's built-in checks would, from the API (it re-exports them):
|
||||||
|
`invalid("args", "a note needs a title")` for arguments a method refuses (`"params"` for params a factory refuses),
|
||||||
|
`forbidden("viewers only")` for a principal a factory or method refuses. The caller gets the same
|
||||||
|
`{ code: "invalid", reason: "args" }` or `{ code: "forbidden" }` across every link. For codes of the plugin's own throw
|
||||||
|
`new PluginError(code, message, details)`. Only the code, reason, message and JSON details cross a link, never a stack
|
||||||
|
or a cause.
|
||||||
|
|
||||||
## Contracts and versions
|
## Contracts and versions
|
||||||
|
|
||||||
A plugin carries the contract it was written against. Plugins changing together (one root, one repository) import
|
A plugin carries the contract it was written against. Plugins changing together (one root, one repository) import
|
||||||
|
|
@ -114,7 +145,7 @@ No type check, no build hooks, no `dist` needed; generated files are committed.
|
||||||
process of its kind with that process's API modules. A failed build or evaluation leaves the plugin out (at startup)
|
process of its kind with that process's API modules. A failed build or evaluation leaves the plugin out (at startup)
|
||||||
or keeps its old code (at a reload), reported with the error.
|
or keeps its old code (at a reload), reported with the error.
|
||||||
|
|
||||||
## Styles and themes (web and terminal facets)
|
## Styles, themes and state across reloads (web and terminal facets)
|
||||||
|
|
||||||
- A web facet's CSS is served with its script and swapped when the plugin reloads. Scope it with a **CSS module**: a
|
- A web facet's CSS is served with its script and swapped when the plugin reloads. Scope it with a **CSS module**: a
|
||||||
file named `*.module.css` has its class names made unique to the plugin and the file, and its import is the mapping
|
file named `*.module.css` has its class names made unique to the plugin and the file, and its import is the mapping
|
||||||
|
|
@ -123,6 +154,11 @@ or keeps its old code (at a reload), reported with the error.
|
||||||
- Read the app's look from its CSS variables (`var(--accent)`, `var(--bg)`, `var(--fg)`, `var(--muted)`, `var(--error)`,
|
- Read the app's look from its CSS variables (`var(--accent)`, `var(--bg)`, `var(--fg)`, `var(--muted)`, `var(--error)`,
|
||||||
`var(--warning)`, `var(--success)`, and the app's own roles), never fixed colours: the active theme sets them.
|
`var(--warning)`, `var(--success)`, and the app's own roles), never fixed colours: the active theme sets them.
|
||||||
- In a terminal facet, ask the app's theme lookup for a role (`theme.fg("accent", "Notes")`); never write raw colours.
|
- In a terminal facet, ask the app's theme lookup for a role (`theme.fg("accent", "Notes")`); never write raw colours.
|
||||||
|
- A reload of the plugin remounts its components (each is a new function): their `useState` starts over. Keep what
|
||||||
|
must survive an update (a half-typed message, a scroll position) in page state, kept by the page's host under a key
|
||||||
|
prefixed with the plugin's name: `usePageState("stats.draft", "")` instead of `useState("")` (from the app's web
|
||||||
|
API); in a terminal facet `scope.pageState("stats.draft", "")` on the `TuiScope` it was given. Any value, never sent
|
||||||
|
anywhere, gone with the page.
|
||||||
- A theme is data: contribute a `Theme` (`{ id, name, mode, colors, web?, tui? }`) to the kit's `Themes` registry,
|
- A theme is data: contribute a `Theme` (`{ id, name, mode, colors, web?, tui? }`) to the kit's `Themes` registry,
|
||||||
from a web facet and a terminal facet to reach both. Users pick one dark and one light theme.
|
from a web facet and a terminal facet to reach both. Users pick one dark and one light theme.
|
||||||
|
|
||||||
|
|
@ -162,8 +198,9 @@ await t.close(ctx);
|
||||||
```
|
```
|
||||||
|
|
||||||
The plugin is built as production builds it and loaded on an in-memory host (a `:memory:` store). Stubs are ordinary
|
The plugin is built as production builds it and loaded on an in-memory host (a `:memory:` store). Stubs are ordinary
|
||||||
facets with fake data. A test declares the contracts it reads again (contracts are data: the same name, version and
|
facets with fake data. A test imports the API by its package name and the plugin's own contracts from its
|
||||||
schema bind).
|
`contract.ts` (the build bundles its own copy; contracts are data: the same name, version and schema bind); contracts
|
||||||
|
of plugins it has no files of, it declares again, as `Notes` above.
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,13 +115,17 @@ export {
|
||||||
type Ctx,
|
type Ctx,
|
||||||
defineTable,
|
defineTable,
|
||||||
eq,
|
eq,
|
||||||
|
forbidden,
|
||||||
int,
|
int,
|
||||||
|
invalid,
|
||||||
local,
|
local,
|
||||||
method,
|
method,
|
||||||
num,
|
num,
|
||||||
obj,
|
obj,
|
||||||
PluginError,
|
PluginError,
|
||||||
query,
|
query,
|
||||||
|
remote,
|
||||||
|
type Static,
|
||||||
str,
|
str,
|
||||||
type Theme,
|
type Theme,
|
||||||
Themes,
|
Themes,
|
||||||
|
|
@ -129,6 +133,10 @@ export {
|
||||||
export type { TodoPrincipal } from "../../principals.ts";
|
export type { TodoPrincipal } from "../../principals.ts";
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`invalid` and `forbidden` make Chord's own errors: a plugin refusing arguments throws `invalid("args", message)`,
|
||||||
|
one refusing a caller `forbidden(message)`, and the caller gets the same `{ code, reason }` Chord's built-in checks
|
||||||
|
give (chapter VIII). `PluginError` is for codes of the plugin's own.
|
||||||
|
|
||||||
and a `defineFacet` bound to the principal type, so every factory sees a `TodoPrincipal`:
|
and a `defineFacet` bound to the principal type, so every factory sees a `TodoPrincipal`:
|
||||||
|
|
||||||
<!-- quote: src/api/v1/index.ts -->
|
<!-- quote: src/api/v1/index.ts -->
|
||||||
|
|
@ -144,6 +152,31 @@ it evaluates it. The plugin sees the very `Themes` registry and `List` host kind
|
||||||
version in the path lets the app ship `v2` beside `v1` later; one facet uses one version (design §21, "The plugin
|
version in the path lets the app ship `v2` beside `v1` later; one facet uses one version (design §21, "The plugin
|
||||||
model").
|
model").
|
||||||
|
|
||||||
|
The API is also a real package: a **workspace package** of the app's repository, named in its `package.json` with an
|
||||||
|
`exports` entry per module:
|
||||||
|
|
||||||
|
<!-- quote: package.json -->
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@todo/app",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "The tutorial's todo app: its API (v1 and its per-kind modules), base code and built-in plugins",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./v1": "./src/api/v1/index.ts",
|
||||||
|
"./v1/server": "./src/api/v1/server.ts",
|
||||||
|
"./v1/web": "./src/api/v1/web.ts",
|
||||||
|
"./v1/tui": "./src/api/v1/tui.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The repository's root `package.json` lists it in `workspaces` (here Chord's repository lists
|
||||||
|
`packages/chord/test/examples/61-tutorial`), so `npm install` links `node_modules/@todo/app` to it. Then Node,
|
||||||
|
TypeScript, the tests and esbuild resolve `@todo/app/v1` alike, from base code, from a plugin's files and from a test
|
||||||
|
importing a plugin's `contract.ts`; plugin builds still leave it out and pass the process's instance.
|
||||||
|
|
||||||
## The start script
|
## The start script
|
||||||
|
|
||||||
`startServer` takes what the app decides and does the rest. `start.ts` names the API with this process's modules, the
|
`startServer` takes what the app decides and does the rest. `start.ts` names the API with this process's modules, the
|
||||||
|
|
@ -260,14 +293,21 @@ keeps its todos.
|
||||||
|
|
||||||
## Where a list lives
|
## Where a list lives
|
||||||
|
|
||||||
A **host kind** declares addressable hosts with params; `List({ id: "groceries" })` is an **address**. A `remote`
|
A **host kind** declares addressable hosts with params; `List({ id: "groceries" })` is an **address**. The app's
|
||||||
contract says, with `hostAddress`, at which address its objects live. Here is the app's contract for a list's todos,
|
host kinds are base code, in the API:
|
||||||
in the API so that every host kind, the tests and command-line code can name it:
|
|
||||||
|
|
||||||
<!-- quote: src/api/v1/index.ts -->
|
<!-- quote: src/api/v1/index.ts -->
|
||||||
```ts
|
```ts
|
||||||
/** A todo list: a worker process of its own, with its own store. */
|
/** A todo list: a worker process of its own, with its own store. */
|
||||||
export const List = hostKind("list", { id: str() });
|
export const List = hostKind("list", { id: str() });
|
||||||
|
```
|
||||||
|
|
||||||
|
A `remote` contract says, with `hostAddress`, at which address its objects live. The contract for a list's todos is
|
||||||
|
the lists plugin's, in its `contract.ts`, which its facets of every kind import, and so do the steps and its test:
|
||||||
|
|
||||||
|
<!-- quote: plugins/lists/contract.ts -->
|
||||||
|
```ts
|
||||||
|
import { arr, bool, int, List, method, num, obj, remote, type Static, str } from "@todo/app/v1";
|
||||||
|
|
||||||
/** A todo: its number in the list, its text, whether it is done, who added it and when. */
|
/** A todo: its number in the list, its text, whether it is done, who added it and when. */
|
||||||
export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
|
export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
|
||||||
|
|
@ -276,8 +316,8 @@ export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: n
|
||||||
export type Item = Static<typeof Item>;
|
export type Item = Static<typeof Item>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list's todos as one principal sees them, on the list's worker: the newest 50 (live), adding one and ticking one
|
* A list's todos as one principal sees them: the newest 50 (live), adding one and ticking one off (or on again); the
|
||||||
* off (or on again); the owner also clears the done ones.
|
* owner also clears the done ones.
|
||||||
*/
|
*/
|
||||||
export const Todos = remote("todo.todos", {
|
export const Todos = remote("todo.todos", {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|
@ -394,6 +434,8 @@ writer, so it knows every change:
|
||||||
<!-- quote: plugins/lists/worker.ts -->
|
<!-- quote: plugins/lists/worker.ts -->
|
||||||
```ts
|
```ts
|
||||||
add: async ({ text, by }, ctx) => {
|
add: async ({ text, by }, ctx) => {
|
||||||
|
// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
|
||||||
|
if (text === "") throw invalid("args", "a todo needs text");
|
||||||
const item = { id: next++, text, done: false, by, at: Date.now() };
|
const item = { id: next++, text, done: false, by, at: Date.now() };
|
||||||
await self.tx(async (tx) => tx.put(items, item), ctx);
|
await self.tx(async (tx) => tx.put(items, item), ctx);
|
||||||
// In number order: two adds may commit in either order.
|
// In number order: two adds may commit in either order.
|
||||||
|
|
@ -401,6 +443,10 @@ writer, so it knows every change:
|
||||||
return null;
|
return null;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A todo without text is refused with `invalid("args", …)`, from the API: the error crosses every link as it is, so a
|
||||||
|
caller on a page or in a terminal gets `{ code: "invalid", reason: "args" }`, as for arguments that fail the
|
||||||
|
contract's schema.
|
||||||
|
|
||||||
## The view per principal
|
## The view per principal
|
||||||
|
|
||||||
`Todos` is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:
|
`Todos` is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:
|
||||||
|
|
@ -497,8 +543,9 @@ export { Http } from "../../../../../../src/node/http/index.ts";
|
||||||
// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the
|
// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the
|
||||||
// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.
|
// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.
|
||||||
|
|
||||||
import { defineFacet, Todos } from "@todo/app/v1";
|
import { defineFacet } from "@todo/app/v1";
|
||||||
import { Http } from "@todo/app/v1/server";
|
import { Http } from "@todo/app/v1/server";
|
||||||
|
import { Todos } from "./contract.ts";
|
||||||
|
|
||||||
export default defineFacet((_host, self) => {
|
export default defineFacet((_host, self) => {
|
||||||
self.contribute(Http, {
|
self.contribute(Http, {
|
||||||
|
|
@ -566,7 +613,7 @@ for them:
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { defineRegistry } from "../../../../../../src/index.ts";
|
import { defineRegistry } from "../../../../../../src/index.ts";
|
||||||
|
|
||||||
export { useHold, useWatch } from "../../../../../../src/react/index.ts";
|
export { useHold, usePageState, useWatch } from "../../../../../../src/react/index.ts";
|
||||||
|
|
||||||
/** A panel of the page: its title and its React component. */
|
/** A panel of the page: its title and its React component. */
|
||||||
export type Panel = { readonly title: string; readonly Component: ComponentType };
|
export type Panel = { readonly title: string; readonly Component: ComponentType };
|
||||||
|
|
@ -612,9 +659,11 @@ when its items change:
|
||||||
<!-- quote: plugins/lists/web.ts -->
|
<!-- quote: plugins/lists/web.ts -->
|
||||||
```ts
|
```ts
|
||||||
function TodoList() {
|
function TodoList() {
|
||||||
const todos = useHold(Todos, { list: location.hash.slice(1) || "groceries" });
|
const list = location.hash.slice(1) || "groceries";
|
||||||
|
const todos = useHold(Todos, { list });
|
||||||
const items = useWatch(todos?.items) ?? [];
|
const items = useWatch(todos?.items) ?? [];
|
||||||
const [text, setText] = useState("");
|
// Kept by the page, not the component: the new code's component finds the half-typed todo.
|
||||||
|
const [text, setText] = usePageState(`lists.draft.${list}`, "");
|
||||||
const add = (event: { preventDefault(): void }) => {
|
const add = (event: { preventDefault(): void }) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (todos === undefined || text.trim() === "") return;
|
if (todos === undefined || text.trim() === "") return;
|
||||||
|
|
@ -659,7 +708,8 @@ export default defineFacet((_host, self) => self.contribute(Panels, { title: "Gr
|
||||||
```
|
```
|
||||||
|
|
||||||
`useHold` holds a reference while the component is mounted and releases it on unmount; it is `undefined` on the first
|
`useHold` holds a reference while the component is mounted and releases it on unmount; it is `undefined` on the first
|
||||||
render. `useWatch` follows a member. Clicks call methods with `BACKGROUND_CONTEXT`, which never ends, so a click's call
|
render. `useWatch` follows a member. `usePageState` is `useState` whose value the page keeps under a key instead of the
|
||||||
|
component: chapter VIII shows why the half-typed todo needs it. Clicks call methods with `BACKGROUND_CONTEXT`, which never ends, so a click's call
|
||||||
is not cancelled when the handler returns. `todos?.variant === "owner"` narrows the reference: only the owner's has
|
is not cancelled when the handler returns. `todos?.variant === "owner"` narrows the reference: only the owner's has
|
||||||
`clear`, and TypeScript knows it. The panel's look reads the active theme's CSS variables, with class names prefixed
|
`clear`, and TypeScript knows it. The panel's look reads the active theme's CSS variables, with class names prefixed
|
||||||
by the plugin's name (a `*.module.css` file would scope them instead):
|
by the plugin's name (a `*.module.css` file would scope them instead):
|
||||||
|
|
@ -977,16 +1027,14 @@ Her todo is hers: `coffee (ada)`.
|
||||||
|
|
||||||
# Changing Code {#reload lede="An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate."}
|
# Changing Code {#reload lede="An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate."}
|
||||||
|
|
||||||
Ada typed " tea " with spaces, and an empty add should do nothing. The fix is in the lists plugin's worker facet.
|
Ada typed " tea " with spaces, and a blank todo should be refused like an empty one. The fix is one line of the lists
|
||||||
Step 8 makes the edit in the running app's copy of the plugins, as an editor or an agent would, and applies it:
|
plugin's worker facet: the view trims the text before the store sees it. Step 8 makes the edit in the running app's
|
||||||
|
copy of the plugins, as an editor or an agent would, and applies it:
|
||||||
|
|
||||||
<!-- quote: main.ts -->
|
<!-- quote: main.ts -->
|
||||||
```ts
|
```ts
|
||||||
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
|
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
|
||||||
const AFTER = `add: async (text: string, ctx: Ctx) => {
|
const AFTER = "add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),";
|
||||||
if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx);
|
|
||||||
return null;
|
|
||||||
},`;
|
|
||||||
const workerFile = join(root, "lists", "worker.ts");
|
const workerFile = join(root, "lists", "worker.ts");
|
||||||
await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
|
await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
|
||||||
await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
|
await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
|
||||||
|
|
@ -999,9 +1047,11 @@ for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) {
|
||||||
await adas.add(" tea ", context);
|
await adas.add(" tea ", context);
|
||||||
await until(() => groceries.items.value.at(-1)?.text === "tea");
|
await until(() => groceries.items.value.at(-1)?.text === "tea");
|
||||||
console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1)));
|
console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1)));
|
||||||
const count = groceries.items.value.length;
|
const refused = await adas.add(" ", context).then(
|
||||||
await adas.add(" ", context);
|
() => "added",
|
||||||
console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added");
|
(error: ChordError) => ({ code: error.code, reason: error.reason }),
|
||||||
|
);
|
||||||
|
console.log(" ada adds ' ':", refused);
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- output: main -->
|
<!-- output: main -->
|
||||||
|
|
@ -1009,7 +1059,7 @@ console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignore
|
||||||
8. lists changed, rolled out to: server, list, web, tui
|
8. lists changed, rolled out to: server, list, web, tui
|
||||||
on list|groceries: todo.store restarted, todo.todos(groceries) parked
|
on list|groceries: todo.store restarted, todo.todos(groceries) parked
|
||||||
ada adds ' tea ': [ ] tea (ada)
|
ada adds ' tea ': [ ] tea (ada)
|
||||||
ada adds ' ': ignored
|
ada adds ' ': { code: 'invalid', reason: 'args' }
|
||||||
```
|
```
|
||||||
|
|
||||||
The watcher marked `@todo/lists` dirty when the file changed; `applyChanges` rebuilt it (one esbuild call per facet)
|
The watcher marked `@todo/lists` dirty when the file changed; `applyChanges` rebuilt it (one esbuild call per facet)
|
||||||
|
|
@ -1026,7 +1076,9 @@ and rolled it out host by host. On each host Chord stages the new code (it runs
|
||||||
|
|
||||||
The store opens a native (the window's first read), so it restarted and read its window again from the table; the
|
The store opens a native (the window's first read), so it restarted and read its window again from the table; the
|
||||||
views waited for it (parked), then went on with the new code. Ada's reference never noticed more than a moment of
|
views waited for it (parked), then went on with the new code. Ada's reference never noticed more than a moment of
|
||||||
`loading`, and her next add was trimmed. Had the edit broken the plugin, that plugin alone would have kept its old code
|
`loading`, and her next add was trimmed. Her blank one is now empty after trimming, so the store refused it with
|
||||||
|
`invalid("args", …)` (chapter II): her client got `{ code: "invalid", reason: "args" }`, Chord's own error, which
|
||||||
|
crossed the worker's link and the server's as it was. Had the edit broken the plugin, that plugin alone would have kept its old code
|
||||||
and the report would name the error.
|
and the report would name the error.
|
||||||
|
|
||||||
A contract that changes shape needs a new `version`; old consumers keep working when the provider maps their version
|
A contract that changes shape needs a new `version`; old consumers keep working when the provider maps their version
|
||||||
|
|
@ -1049,16 +1101,20 @@ const BEFORE = `h(
|
||||||
wrote docs/tutorial/screens/page.png
|
wrote docs/tutorial/screens/page.png
|
||||||
wrote docs/tutorial/screens/harbor.png
|
wrote docs/tutorial/screens/harbor.png
|
||||||
wrote docs/tutorial/screens/edited.png
|
wrote docs/tutorial/screens/edited.png
|
||||||
typed "oat mi"; after the edit: ""
|
typed "oat mi"; after the edit: "oat mi"
|
||||||
the page did not reload: true
|
the page did not reload: true
|
||||||
```
|
```
|
||||||
|
|
||||||
:::figure | After the edit of the web facet, applied in place: the count appears; the half-typed "oat mi" is gone, because the panel's component is new code and React mounted it anew.
|
:::figure | After the edit of the web facet, applied in place: the count appears, and the half-typed "oat mi" is still in the box: the page kept it under its key while React mounted the new component.
|
||||||

|

|
||||||
:::
|
:::
|
||||||
|
|
||||||
The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel's
|
The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel
|
||||||
own React state did not: a new component function is a new component to React.
|
itself is new code: its component is a new function, which React mounts anew, so its own `useState` would start over
|
||||||
|
(design §21). What must survive a plugin update (a half-typed todo, a scroll position) is **page state**:
|
||||||
|
`usePageState(key, initial)` keeps the value in the page's host under the key, not in the component, so the new
|
||||||
|
component finds it; a terminal's `TuiScope` has the same store as `scope.pageState(key, initial)`. Page state is any
|
||||||
|
value, never sent anywhere, and gone with the page.
|
||||||
|
|
||||||
# Moving a List {#export lede="Export a worker's data while it runs, import it under another address."}
|
# Moving a List {#export lede="Export a worker's data while it runs, import it under another address."}
|
||||||
|
|
||||||
|
|
@ -1100,9 +1156,9 @@ loads it on a new host with an in-memory store, here at a list's address so it m
|
||||||
|
|
||||||
<!-- quote: plugins/lists/worker.test.ts -->
|
<!-- quote: plugins/lists/worker.test.ts -->
|
||||||
```ts
|
```ts
|
||||||
let t: TestedPlugin<TodoPrincipal>;
|
let t: TestedPlugin<v1.TodoPrincipal>;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
t = await testPlugin<TodoPrincipal>(
|
t = await testPlugin<v1.TodoPrincipal>(
|
||||||
fileURLToPath(new URL("./", import.meta.url)),
|
fileURLToPath(new URL("./", import.meta.url)),
|
||||||
{
|
{
|
||||||
kind: "worker",
|
kind: "worker",
|
||||||
|
|
@ -1124,7 +1180,7 @@ Each test runs code as a principal of its choice with `t.root`:
|
||||||
it("lets the owner clear the done todos", async () => {
|
it("lets the owner clear the done todos", async () => {
|
||||||
await t.root(
|
await t.root(
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const todos = await ctx.get(v1.Todos, { list: "groceries" });
|
const todos = await ctx.get(Todos, { list: "groceries" });
|
||||||
if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`);
|
if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`);
|
||||||
await todos.add("milk", ctx);
|
await todos.add("milk", ctx);
|
||||||
await todos.add("eggs", ctx);
|
await todos.add("eggs", ctx);
|
||||||
|
|
@ -1146,10 +1202,27 @@ Run it like any test:
|
||||||
node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts
|
node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
The test imports `Todos` and `List` from the API module by path. It could not import a contract from a plugin's own
|
The test imports the API by its package name and `Todos` from the plugin's own `contract.ts`, as the facets do:
|
||||||
`contract.ts`: that file imports `@todo/app/v1`, a name only plugin builds resolve. A test of such a contract declares
|
|
||||||
it again (contracts are data: the same name, version and schema bind), as `docs/plugin-authoring.md` shows; this app
|
<!-- quote: plugins/lists/worker.test.ts -->
|
||||||
keeps the contract it shares in its API instead.
|
```ts
|
||||||
|
import * as v1 from "@todo/app/v1";
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- quote: plugins/lists/worker.test.ts -->
|
||||||
|
```ts
|
||||||
|
import { Todos } from "./contract.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
Both resolve because the API is a workspace package npm linked (chapter I); the plugin's build bundles its own copy of
|
||||||
|
`contract.ts`, and the two bind, since contracts are data (the same name, version and schema). A third test checks
|
||||||
|
the refusal of chapter II:
|
||||||
|
|
||||||
|
<!-- quote: plugins/lists/worker.test.ts -->
|
||||||
|
```ts
|
||||||
|
const refused = await todos.add("", ctx).catch((error: ChordError) => error);
|
||||||
|
expect(refused).toMatchObject({ code: "invalid", reason: "args" });
|
||||||
|
```
|
||||||
|
|
||||||
# Writing a p2 Plugin {#p2 lede="What is different in p2, and one small plugin: a tool for the agent and a chip for the people."}
|
# Writing a p2 Plugin {#p2 lede="What is different in p2, and one small plugin: a tool for the agent and a chip for the people."}
|
||||||
|
|
||||||
|
|
@ -1295,28 +1368,25 @@ a tutorial.
|
||||||
live; `self.open` is the only place, so the store counts as holding a native and restarts at every reload of its
|
live; `self.open` is the only place, so the store counts as holding a native and restarts at every reload of its
|
||||||
plugin, parking its views, though it holds nothing open. Tables have no watch, so every table-backed live view
|
plugin, parking its views, though it holds nothing open. Tables have no watch, so every table-backed live view
|
||||||
repeats this pattern (example 59's chat does too).
|
repeats this pattern (example 59's chat does too).
|
||||||
- **A reloaded web plugin loses its own React state.** Decision "Slice 11, P4" promises an in-place reload "keeping what
|
|
||||||
is on screen (scroll, a half-typed message)". The page does not reload, but the changed plugin's components are new
|
Three more, found the same way, are fixed: a reloaded web plugin lost its own React state (now `usePageState`,
|
||||||
functions, so React mounts them anew and their state (the half-typed todo) is gone.
|
chapter VIII), a plugin's `contract.ts` could not be imported outside builds (the API is now a workspace package,
|
||||||
- **Plugin contracts are hard to test from outside builds.** A test or script cannot import a plugin's
|
chapter I), and plugins could not throw Chord's `invalid` (Chord exports `invalid` and `forbidden`, chapter II).
|
||||||
`contract.ts` (it imports the app's API by a package name only builds resolve), so contracts are declared twice or
|
|
||||||
moved into the API.
|
|
||||||
- **No public `invalid`.** Plugins throw `PluginError` with codes of their own; refusing bad arguments with Chord's
|
|
||||||
`invalid/args`, as the built-in checks do, is not open to them.
|
|
||||||
|
|
||||||
# The App's Files {.appendix}
|
# The App's Files {.appendix}
|
||||||
|
|
||||||
| File | Kind | What it is |
|
| File | Kind | What it is |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `src/principals.ts` | base | the principal type |
|
| `src/principals.ts` | base | the principal type |
|
||||||
| `src/api/v1/index.ts` | base, every kind | `@todo/app/v1`: Chord's part, `defineFacet`, `List`, `Item`, `Todos` |
|
| `package.json` | base | the API as the workspace package `@todo/app`, its `exports` |
|
||||||
|
| `src/api/v1/index.ts` | base, every kind | `@todo/app/v1`: Chord's part (with `invalid`, `forbidden`), `defineFacet`, `List` |
|
||||||
| `src/api/v1/server.ts` | base, server | `Http`, the ephemeral `login` |
|
| `src/api/v1/server.ts` | base, server | `Http`, the ephemeral `login` |
|
||||||
| `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `useWatch` |
|
| `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `usePageState`, `useWatch` |
|
||||||
| `src/api/v1/tui.ts` | base, tui | `Screens` |
|
| `src/api/v1/tui.ts` | base, tui | `Screens` |
|
||||||
| `start.ts`, `worker.ts` | base | `startServer`, `startWorker` |
|
| `start.ts`, `worker.ts` | base | `startServer`, `startWorker` |
|
||||||
| `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels |
|
| `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels |
|
||||||
| `src/tui/main.ts` | base, terminal entry | `startTerminalRuntime`, the screens |
|
| `src/tui/main.ts` | base, terminal entry | `startTerminalRuntime`, the screens |
|
||||||
| `plugins/lists/` | plugin | worker (table, store, views), server (routes), web (panel), tui (screen), its test |
|
| `plugins/lists/` | plugin | contract (`Item`, `Todos`), worker (table, store, views), server (routes), web (panel), tui (screen), its test |
|
||||||
| `plugins/login.server.ts` | single-file plugin | the kit's ephemeral login |
|
| `plugins/login.server.ts` | single-file plugin | the kit's ephemeral login |
|
||||||
| `later/harbor/` | plugin, added in step 6 | the Harbor theme for pages and terminals |
|
| `later/harbor/` | plugin, added in step 6 | the Harbor theme for pages and terminals |
|
||||||
| `main.ts`, `screenshots.ts`, `tutorial.test.ts` | example | the steps, the screenshots, the checks |
|
| `main.ts`, `screenshots.ts`, `tutorial.test.ts` | example | the steps, the screenshots, the checks |
|
||||||
|
|
@ -1331,9 +1401,11 @@ a tutorial.
|
||||||
| `hostKind(name, params)`, `remote(name, { version, params, hostAddress, variants })`, `local(name, { version, params, members })` | II | host kinds and contracts |
|
| `hostKind(name, params)`, `remote(name, { version, params, hostAddress, variants })`, `local(name, { version, params, members })` | II | host kinds and contracts |
|
||||||
| `host.serve(Type, factory)`; factory `self`: `table`, `writable`, `open`, `derive`, `tx`, `get`, `contribute` | II | an object's parts |
|
| `host.serve(Type, factory)`; factory `self`: `table`, `writable`, `open`, `derive`, `tx`, `get`, `contribute` | II | an object's parts |
|
||||||
| `host.root(fn, ctx, { principal })`, `ctx.get(Type, params)` | II | code outside objects, as a principal |
|
| `host.root(fn, ctx, { principal })`, `ctx.get(Type, params)` | II | code outside objects, as a principal |
|
||||||
|
| `invalid(reason, message, details?)`, `forbidden(message, details?)` | II, VIII | Chord's errors, thrown by a plugin |
|
||||||
| `self.contribute(Http, { routes })` | III | routes, denied by default |
|
| `self.contribute(Http, { routes })` | III | routes, denied by default |
|
||||||
| `startPage`, `HostProvider`, `useHold`, `useWatch`, `useRegistry` | IV | the page |
|
| `startPage`, `HostProvider`, `useHold`, `useWatch`, `useRegistry` | IV | the page |
|
||||||
| `startTerminalRuntime`, `TuiScope`, `startTerminal` | V | the terminal |
|
| `startTerminalRuntime`, `TuiScope`, `startTerminal` | V | the terminal |
|
||||||
|
| `usePageState(key, initial)`, `scope.pageState(key, initial)` | IV, VIII | state the page keeps across plugin reloads |
|
||||||
| `self.contribute(Themes, theme)` | VI | a theme |
|
| `self.contribute(Themes, theme)` | VI | a theme |
|
||||||
| `ephemeralLoginFacet<P>()`, `host.load([], ctx)`, `host.connect(link, { principal }, ctx)` | VII | a second user's client |
|
| `ephemeralLoginFacet<P>()`, `host.load([], ctx)`, `host.connect(link, { principal }, ctx)` | VII | a second user's client |
|
||||||
| `app.plugins.state`, `app.applyChanges(ctx)` | VI, VIII | marks and rollouts |
|
| `app.plugins.state`, `app.applyChanges(ctx)` | VI, VIII | marks and rollouts |
|
||||||
|
|
|
||||||
|
|
@ -216,18 +216,25 @@ stays small: a kind and an id. A user's profile, if the app had one, would b
|
||||||
<span class="tk-k">type</span> <span class="tk-t">Ctx</span>,
|
<span class="tk-k">type</span> <span class="tk-t">Ctx</span>,
|
||||||
defineTable,
|
defineTable,
|
||||||
eq,
|
eq,
|
||||||
|
forbidden,
|
||||||
int,
|
int,
|
||||||
|
invalid,
|
||||||
local,
|
local,
|
||||||
method,
|
method,
|
||||||
num,
|
num,
|
||||||
obj,
|
obj,
|
||||||
<span class="tk-t">PluginError</span>,
|
<span class="tk-t">PluginError</span>,
|
||||||
query,
|
query,
|
||||||
|
remote,
|
||||||
|
<span class="tk-k">type</span> <span class="tk-t">Static</span>,
|
||||||
str,
|
str,
|
||||||
<span class="tk-k">type</span> <span class="tk-t">Theme</span>,
|
<span class="tk-k">type</span> <span class="tk-t">Theme</span>,
|
||||||
<span class="tk-t">Themes</span>,
|
<span class="tk-t">Themes</span>,
|
||||||
} <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/index.ts"</span>;
|
} <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/index.ts"</span>;
|
||||||
<span class="tk-k">export</span> <span class="tk-k">type</span> { <span class="tk-t">TodoPrincipal</span> } <span class="tk-k">from</span> <span class="tk-s">"../../principals.ts"</span>;</code></pre>
|
<span class="tk-k">export</span> <span class="tk-k">type</span> { <span class="tk-t">TodoPrincipal</span> } <span class="tk-k">from</span> <span class="tk-s">"../../principals.ts"</span>;</code></pre>
|
||||||
|
<p><code>invalid</code> and <code>forbidden</code> make Chord's own errors: a plugin refusing arguments throws <code>invalid("args", message)</code>,
|
||||||
|
one refusing a caller <code>forbidden(message)</code>, and the caller gets the same <code>{ code, reason }</code> Chord's built-in checks
|
||||||
|
give (chapter VIII). <code>PluginError</code> is for codes of the plugin's own.</p>
|
||||||
<p>and a <code>defineFacet</code> bound to the principal type, so every factory sees a <code>TodoPrincipal</code>:</p>
|
<p>and a <code>defineFacet</code> bound to the principal type, so every factory sees a <code>TodoPrincipal</code>:</p>
|
||||||
<!-- quote: src/api/v1/index.ts -->
|
<!-- quote: src/api/v1/index.ts -->
|
||||||
<pre><code><span class="tk-c">/** Defines a facet of the todo app: its factories get the app's principals. */</span>
|
<pre><code><span class="tk-c">/** Defines a facet of the todo app: its factories get the app's principals. */</span>
|
||||||
|
|
@ -238,6 +245,26 @@ stays small: a kind and an id. A user's profile, if the app had one, would b
|
||||||
it evaluates it. The plugin sees the very <code>Themes</code> registry and <code>List</code> host kind the process has, not a copy. A
|
it evaluates it. The plugin sees the very <code>Themes</code> registry and <code>List</code> host kind the process has, not a copy. A
|
||||||
version in the path lets the app ship <code>v2</code> beside <code>v1</code> later; one facet uses one version (design §21, "The plugin
|
version in the path lets the app ship <code>v2</code> beside <code>v1</code> later; one facet uses one version (design §21, "The plugin
|
||||||
model").</p>
|
model").</p>
|
||||||
|
<p>The API is also a real package: a <strong>workspace package</strong> of the app's repository, named in its <code>package.json</code> with an
|
||||||
|
<code>exports</code> entry per module:</p>
|
||||||
|
<!-- quote: package.json -->
|
||||||
|
<pre><code>{
|
||||||
|
<span class="tk-s">"name"</span>: <span class="tk-s">"@todo/app"</span>,
|
||||||
|
<span class="tk-s">"version"</span>: <span class="tk-s">"0.0.1"</span>,
|
||||||
|
<span class="tk-s">"private"</span>: <span class="tk-k">true</span>,
|
||||||
|
<span class="tk-s">"description"</span>: <span class="tk-s">"The tutorial's todo app: its API (v1 and its per-kind modules), base code and built-in plugins"</span>,
|
||||||
|
<span class="tk-s">"type"</span>: <span class="tk-s">"module"</span>,
|
||||||
|
<span class="tk-s">"exports"</span>: {
|
||||||
|
<span class="tk-s">"./v1"</span>: <span class="tk-s">"./src/api/v1/index.ts"</span>,
|
||||||
|
<span class="tk-s">"./v1/server"</span>: <span class="tk-s">"./src/api/v1/server.ts"</span>,
|
||||||
|
<span class="tk-s">"./v1/web"</span>: <span class="tk-s">"./src/api/v1/web.ts"</span>,
|
||||||
|
<span class="tk-s">"./v1/tui"</span>: <span class="tk-s">"./src/api/v1/tui.ts"</span>
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
<p>The repository's root <code>package.json</code> lists it in <code>workspaces</code> (here Chord's repository lists
|
||||||
|
<code>packages/chord/test/examples/61-tutorial</code>), so <code>npm install</code> links <code>node_modules/@todo/app</code> to it. Then Node,
|
||||||
|
TypeScript, the tests and esbuild resolve <code>@todo/app/v1</code> alike, from base code, from a plugin's files and from a test
|
||||||
|
importing a plugin's <code>contract.ts</code>; plugin builds still leave it out and pass the process's instance.</p>
|
||||||
<h3 id="the-start-script">The start script</h3>
|
<h3 id="the-start-script">The start script</h3>
|
||||||
<p><code>startServer</code> takes what the app decides and does the rest. <code>start.ts</code> names the API with this process's modules, the
|
<p><code>startServer</code> takes what the app decides and does the rest. <code>start.ts</code> names the API with this process's modules, the
|
||||||
facet kinds (which files a plugin may have), and the principals:</p>
|
facet kinds (which files a plugin may have), and the principals:</p>
|
||||||
|
|
@ -331,12 +358,15 @@ chapter III). Nothing is the owner because it comes from loopback: any local pro
|
||||||
and an idle list costs nothing. This chapter declares where lists live, what a list offers, and the worker code that
|
and an idle list costs nothing. This chapter declares where lists live, what a list offers, and the worker code that
|
||||||
keeps its todos.</p>
|
keeps its todos.</p>
|
||||||
<h3 id="where-a-list-lives">Where a list lives</h3>
|
<h3 id="where-a-list-lives">Where a list lives</h3>
|
||||||
<p>A <strong>host kind</strong> declares addressable hosts with params; <code>List({ id: "groceries" })</code> is an <strong>address</strong>. A <code>remote</code>
|
<p>A <strong>host kind</strong> declares addressable hosts with params; <code>List({ id: "groceries" })</code> is an <strong>address</strong>. The app's
|
||||||
contract says, with <code>hostAddress</code>, at which address its objects live. Here is the app's contract for a list's todos,
|
host kinds are base code, in the API:</p>
|
||||||
in the API so that every host kind, the tests and command-line code can name it:</p>
|
|
||||||
<!-- quote: src/api/v1/index.ts -->
|
<!-- quote: src/api/v1/index.ts -->
|
||||||
<pre><code><span class="tk-c">/** A todo list: a worker process of its own, with its own store. */</span>
|
<pre><code><span class="tk-c">/** A todo list: a worker process of its own, with its own store. */</span>
|
||||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">List</span> = <span class="tk-f">hostKind</span>(<span class="tk-s">"list"</span>, { id: <span class="tk-f">str</span>() });
|
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">List</span> = <span class="tk-f">hostKind</span>(<span class="tk-s">"list"</span>, { id: <span class="tk-f">str</span>() });</code></pre>
|
||||||
|
<p>A <code>remote</code> contract says, with <code>hostAddress</code>, at which address its objects live. The contract for a list's todos is
|
||||||
|
the lists plugin's, in its <code>contract.ts</code>, which its facets of every kind import, and so do the steps and its test:</p>
|
||||||
|
<!-- quote: plugins/lists/contract.ts -->
|
||||||
|
<pre><code><span class="tk-k">import</span> { arr, bool, int, <span class="tk-t">List</span>, method, num, obj, remote, <span class="tk-k">type</span> <span class="tk-t">Static</span>, str } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1"</span>;
|
||||||
|
|
||||||
<span class="tk-c">/** A todo: its number in the list, its text, whether it is done, who added it and when. */</span>
|
<span class="tk-c">/** A todo: its number in the list, its text, whether it is done, who added it and when. */</span>
|
||||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Item</span> = <span class="tk-f">obj</span>({ id: <span class="tk-f">int</span>(), text: <span class="tk-f">str</span>(), done: <span class="tk-f">bool</span>(), by: <span class="tk-f">str</span>(), at: <span class="tk-f">num</span>() });
|
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Item</span> = <span class="tk-f">obj</span>({ id: <span class="tk-f">int</span>(), text: <span class="tk-f">str</span>(), done: <span class="tk-f">bool</span>(), by: <span class="tk-f">str</span>(), at: <span class="tk-f">num</span>() });
|
||||||
|
|
@ -345,8 +375,8 @@ in the API so that every host kind, the tests and command-line code can name it:
|
||||||
<span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Item</span> = <span class="tk-t">Static</span><<span class="tk-k">typeof</span> <span class="tk-t">Item</span>>;
|
<span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Item</span> = <span class="tk-t">Static</span><<span class="tk-k">typeof</span> <span class="tk-t">Item</span>>;
|
||||||
|
|
||||||
<span class="tk-c">/**
|
<span class="tk-c">/**
|
||||||
* A list's todos as one principal sees them, on the list's worker: the newest 50 (live), adding one and ticking one
|
* A list's todos as one principal sees them: the newest 50 (live), adding one and ticking one off (or on again); the
|
||||||
* off (or on again); the owner also clears the done ones.
|
* owner also clears the done ones.
|
||||||
*/</span>
|
*/</span>
|
||||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Todos</span> = <span class="tk-f">remote</span>(<span class="tk-s">"todo.todos"</span>, {
|
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Todos</span> = <span class="tk-f">remote</span>(<span class="tk-s">"todo.todos"</span>, {
|
||||||
version: <span class="tk-n">1</span>,
|
version: <span class="tk-n">1</span>,
|
||||||
|
|
@ -440,11 +470,16 @@ when <code>fn</code> returns, or not at all if it throws. The window follows aft
|
||||||
writer, so it knows every change:</p>
|
writer, so it knows every change:</p>
|
||||||
<!-- quote: plugins/lists/worker.ts -->
|
<!-- quote: plugins/lists/worker.ts -->
|
||||||
<pre><code> add: <span class="tk-k">async</span> ({ text, by }, ctx) => {
|
<pre><code> add: <span class="tk-k">async</span> ({ text, by }, ctx) => {
|
||||||
|
<span class="tk-c">// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.</span>
|
||||||
|
<span class="tk-k">if</span> (text === <span class="tk-s">""</span>) <span class="tk-k">throw</span> <span class="tk-f">invalid</span>(<span class="tk-s">"args"</span>, <span class="tk-s">"a todo needs text"</span>);
|
||||||
<span class="tk-k">const</span> item = { id: next++, text, done: <span class="tk-k">false</span>, by, at: <span class="tk-t">Date</span>.<span class="tk-f">now</span>() };
|
<span class="tk-k">const</span> item = { id: next++, text, done: <span class="tk-k">false</span>, by, at: <span class="tk-t">Date</span>.<span class="tk-f">now</span>() };
|
||||||
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => tx.<span class="tk-f">put</span>(items, item), ctx);
|
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => tx.<span class="tk-f">put</span>(items, item), ctx);
|
||||||
<span class="tk-c">// In number order: two adds may commit in either order.</span>
|
<span class="tk-c">// In number order: two adds may commit in either order.</span>
|
||||||
window.<span class="tk-f">set</span>([...window.value, item].<span class="tk-f">sort</span>((a, b) => a.id - b.id).<span class="tk-f">slice</span>(-WINDOW));
|
window.<span class="tk-f">set</span>([...window.value, item].<span class="tk-f">sort</span>((a, b) => a.id - b.id).<span class="tk-f">slice</span>(-WINDOW));
|
||||||
<span class="tk-k">return</span> <span class="tk-k">null</span>;</code></pre>
|
<span class="tk-k">return</span> <span class="tk-k">null</span>;</code></pre>
|
||||||
|
<p>A todo without text is refused with <code>invalid("args", …)</code>, from the API: the error crosses every link as it is, so a
|
||||||
|
caller on a page or in a terminal gets <code>{ code: "invalid", reason: "args" }</code>, as for arguments that fail the
|
||||||
|
contract's schema.</p>
|
||||||
<h3 id="the-view-per-principal">The view per principal</h3>
|
<h3 id="the-view-per-principal">The view per principal</h3>
|
||||||
<p><code>Todos</code> is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:</p>
|
<p><code>Todos</code> is the remote face of the store, one object per principal. Its factory gets the principal the link stamped:</p>
|
||||||
<!-- quote: plugins/lists/worker.ts -->
|
<!-- quote: plugins/lists/worker.ts -->
|
||||||
|
|
@ -518,8 +553,9 @@ contributes routes to the kit's <code>Http</code> registry, which the API
|
||||||
<pre><code><span class="tk-c">// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the</span>
|
<pre><code><span class="tk-c">// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the</span>
|
||||||
<span class="tk-c">// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.</span>
|
<span class="tk-c">// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.</span>
|
||||||
|
|
||||||
<span class="tk-k">import</span> { defineFacet, <span class="tk-t">Todos</span> } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1"</span>;
|
<span class="tk-k">import</span> { defineFacet } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1"</span>;
|
||||||
<span class="tk-k">import</span> { <span class="tk-t">Http</span> } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1/server"</span>;
|
<span class="tk-k">import</span> { <span class="tk-t">Http</span> } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1/server"</span>;
|
||||||
|
<span class="tk-k">import</span> { <span class="tk-t">Todos</span> } <span class="tk-k">from</span> <span class="tk-s">"./contract.ts"</span>;
|
||||||
|
|
||||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) => {
|
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) => {
|
||||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, {
|
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, {
|
||||||
|
|
@ -573,7 +609,7 @@ for them:</p>
|
||||||
<pre><code><span class="tk-k">import</span> <span class="tk-k">type</span> { <span class="tk-t">ComponentType</span> } <span class="tk-k">from</span> <span class="tk-s">"react"</span>;
|
<pre><code><span class="tk-k">import</span> <span class="tk-k">type</span> { <span class="tk-t">ComponentType</span> } <span class="tk-k">from</span> <span class="tk-s">"react"</span>;
|
||||||
<span class="tk-k">import</span> { defineRegistry } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/index.ts"</span>;
|
<span class="tk-k">import</span> { defineRegistry } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/index.ts"</span>;
|
||||||
|
|
||||||
<span class="tk-k">export</span> { useHold, useWatch } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/react/index.ts"</span>;
|
<span class="tk-k">export</span> { useHold, usePageState, useWatch } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/react/index.ts"</span>;
|
||||||
|
|
||||||
<span class="tk-c">/** A panel of the page: its title and its React component. */</span>
|
<span class="tk-c">/** A panel of the page: its title and its React component. */</span>
|
||||||
<span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Panel</span> = { <span class="tk-k">readonly</span> title: string; <span class="tk-k">readonly</span> <span class="tk-t">Component</span>: <span class="tk-t">ComponentType</span> };
|
<span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Panel</span> = { <span class="tk-k">readonly</span> title: string; <span class="tk-k">readonly</span> <span class="tk-t">Component</span>: <span class="tk-t">ComponentType</span> };
|
||||||
|
|
@ -609,9 +645,11 @@ scripts with the entry's core facets, and keeps them current. <code>HostProv
|
||||||
when its items change:</p>
|
when its items change:</p>
|
||||||
<!-- quote: plugins/lists/web.ts -->
|
<!-- quote: plugins/lists/web.ts -->
|
||||||
<pre><code><span class="tk-k">function</span> <span class="tk-t">TodoList</span>() {
|
<pre><code><span class="tk-k">function</span> <span class="tk-t">TodoList</span>() {
|
||||||
<span class="tk-k">const</span> todos = <span class="tk-f">useHold</span>(<span class="tk-t">Todos</span>, { list: location.hash.<span class="tk-f">slice</span>(<span class="tk-n">1</span>) || <span class="tk-s">"groceries"</span> });
|
<span class="tk-k">const</span> list = location.hash.<span class="tk-f">slice</span>(<span class="tk-n">1</span>) || <span class="tk-s">"groceries"</span>;
|
||||||
|
<span class="tk-k">const</span> todos = <span class="tk-f">useHold</span>(<span class="tk-t">Todos</span>, { list });
|
||||||
<span class="tk-k">const</span> items = <span class="tk-f">useWatch</span>(todos?.items) ?? [];
|
<span class="tk-k">const</span> items = <span class="tk-f">useWatch</span>(todos?.items) ?? [];
|
||||||
<span class="tk-k">const</span> [text, setText] = <span class="tk-f">useState</span>(<span class="tk-s">""</span>);
|
<span class="tk-c">// Kept by the page, not the component: the new code's component finds the half-typed todo.</span>
|
||||||
|
<span class="tk-k">const</span> [text, setText] = <span class="tk-f">usePageState</span>(<span class="tk-s">`lists.draft.${list}`</span>, <span class="tk-s">""</span>);
|
||||||
<span class="tk-k">const</span> add = (event: { <span class="tk-f">preventDefault</span>(): <span class="tk-k">void</span> }) => {
|
<span class="tk-k">const</span> add = (event: { <span class="tk-f">preventDefault</span>(): <span class="tk-k">void</span> }) => {
|
||||||
event.<span class="tk-f">preventDefault</span>();
|
event.<span class="tk-f">preventDefault</span>();
|
||||||
<span class="tk-k">if</span> (todos === <span class="tk-k">undefined</span> || text.<span class="tk-f">trim</span>() === <span class="tk-s">""</span>) <span class="tk-k">return</span>;
|
<span class="tk-k">if</span> (todos === <span class="tk-k">undefined</span> || text.<span class="tk-f">trim</span>() === <span class="tk-s">""</span>) <span class="tk-k">return</span>;
|
||||||
|
|
@ -654,7 +692,8 @@ when its items change:</p>
|
||||||
|
|
||||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) => self.<span class="tk-f">contribute</span>(<span class="tk-t">Panels</span>, { title: <span class="tk-s">"Groceries"</span>, <span class="tk-t">Component</span>: <span class="tk-t">TodoList</span> }));</code></pre>
|
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) => self.<span class="tk-f">contribute</span>(<span class="tk-t">Panels</span>, { title: <span class="tk-s">"Groceries"</span>, <span class="tk-t">Component</span>: <span class="tk-t">TodoList</span> }));</code></pre>
|
||||||
<p><code>useHold</code> holds a reference while the component is mounted and releases it on unmount; it is <code>undefined</code> on the first
|
<p><code>useHold</code> holds a reference while the component is mounted and releases it on unmount; it is <code>undefined</code> on the first
|
||||||
render. <code>useWatch</code> follows a member. Clicks call methods with <code>BACKGROUND_CONTEXT</code>, which never ends, so a click's call
|
render. <code>useWatch</code> follows a member. <code>usePageState</code> is <code>useState</code> whose value the page keeps under a key instead of the
|
||||||
|
component: chapter VIII shows why the half-typed todo needs it. Clicks call methods with <code>BACKGROUND_CONTEXT</code>, which never ends, so a click's call
|
||||||
is not cancelled when the handler returns. <code>todos?.variant === "owner"</code> narrows the reference: only the owner's has
|
is not cancelled when the handler returns. <code>todos?.variant === "owner"</code> narrows the reference: only the owner's has
|
||||||
<code>clear</code>, and TypeScript knows it. The panel's look reads the active theme's CSS variables, with class names prefixed
|
<code>clear</code>, 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 <code>*.module.css</code> file would scope them instead):</p>
|
by the plugin's name (a <code>*.module.css</code> file would scope them instead):</p>
|
||||||
|
|
@ -895,14 +934,12 @@ Her todo is hers: <code>coffee (ada)</code>.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="chapter" id="reload"><header class="opener"><div class="numeral">VIII</div><h2>Changing Code</h2><p class="lede">An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate.</p></header>
|
<section class="chapter" id="reload"><header class="opener"><div class="numeral">VIII</div><h2>Changing Code</h2><p class="lede">An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate.</p></header>
|
||||||
<p class="dropcap">Ada typed " tea " with spaces, and an empty add should do nothing. The fix is in the lists plugin's worker facet.
|
<p class="dropcap">Ada typed " tea " with spaces, and a blank todo should be refused like an empty one. The fix is one line of the lists
|
||||||
Step 8 makes the edit in the running app's copy of the plugins, as an editor or an agent would, and applies it:</p>
|
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:</p>
|
||||||
<!-- quote: main.ts -->
|
<!-- quote: main.ts -->
|
||||||
<pre><code><span class="tk-k">const</span> BEFORE = <span class="tk-s">"add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),"</span>;
|
<pre><code><span class="tk-k">const</span> BEFORE = <span class="tk-s">"add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),"</span>;
|
||||||
<span class="tk-k">const</span> AFTER = <span class="tk-s">`add: async (text: string, ctx: Ctx) => {
|
<span class="tk-k">const</span> AFTER = <span class="tk-s">"add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),"</span>;
|
||||||
if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx);
|
|
||||||
return null;
|
|
||||||
},`</span>;
|
|
||||||
<span class="tk-k">const</span> workerFile = <span class="tk-f">join</span>(root, <span class="tk-s">"lists"</span>, <span class="tk-s">"worker.ts"</span>);
|
<span class="tk-k">const</span> workerFile = <span class="tk-f">join</span>(root, <span class="tk-s">"lists"</span>, <span class="tk-s">"worker.ts"</span>);
|
||||||
<span class="tk-k">await</span> <span class="tk-f">writeFile</span>(workerFile, (<span class="tk-k">await</span> <span class="tk-f">readFile</span>(workerFile, <span class="tk-s">"utf8"</span>)).<span class="tk-f">replace</span>(BEFORE, AFTER));
|
<span class="tk-k">await</span> <span class="tk-f">writeFile</span>(workerFile, (<span class="tk-k">await</span> <span class="tk-f">readFile</span>(workerFile, <span class="tk-s">"utf8"</span>)).<span class="tk-f">replace</span>(BEFORE, AFTER));
|
||||||
<span class="tk-k">await</span> <span class="tk-f">until</span>(() => app.plugins.state.value.dirty.<span class="tk-f">includes</span>(<span class="tk-s">"@todo/lists"</span>));
|
<span class="tk-k">await</span> <span class="tk-f">until</span>(() => app.plugins.state.value.dirty.<span class="tk-f">includes</span>(<span class="tk-s">"@todo/lists"</span>));
|
||||||
|
|
@ -915,14 +952,16 @@ console.<span class="tk-f">log</span>(<span class="tk-s">"8. lists changed, roll
|
||||||
<span class="tk-k">await</span> adas.<span class="tk-f">add</span>(<span class="tk-s">" tea "</span>, context);
|
<span class="tk-k">await</span> adas.<span class="tk-f">add</span>(<span class="tk-s">" tea "</span>, context);
|
||||||
<span class="tk-k">await</span> <span class="tk-f">until</span>(() => groceries.items.value.<span class="tk-f">at</span>(-<span class="tk-n">1</span>)?.text === <span class="tk-s">"tea"</span>);
|
<span class="tk-k">await</span> <span class="tk-f">until</span>(() => groceries.items.value.<span class="tk-f">at</span>(-<span class="tk-n">1</span>)?.text === <span class="tk-s">"tea"</span>);
|
||||||
console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' tea ':"</span>, <span class="tk-f">show</span>(groceries.items.value.<span class="tk-f">slice</span>(-<span class="tk-n">1</span>)));
|
console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' tea ':"</span>, <span class="tk-f">show</span>(groceries.items.value.<span class="tk-f">slice</span>(-<span class="tk-n">1</span>)));
|
||||||
<span class="tk-k">const</span> count = groceries.items.value.length;
|
<span class="tk-k">const</span> refused = <span class="tk-k">await</span> adas.<span class="tk-f">add</span>(<span class="tk-s">" "</span>, context).<span class="tk-f">then</span>(
|
||||||
<span class="tk-k">await</span> adas.<span class="tk-f">add</span>(<span class="tk-s">" "</span>, context);
|
() => <span class="tk-s">"added"</span>,
|
||||||
console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' ':"</span>, groceries.items.value.length === count ? <span class="tk-s">"ignored"</span> : <span class="tk-s">"added"</span>);</code></pre>
|
(error: <span class="tk-t">ChordError</span>) => ({ code: error.code, reason: error.reason }),
|
||||||
|
);
|
||||||
|
console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' ':"</span>, refused);</code></pre>
|
||||||
<!-- output: main -->
|
<!-- output: main -->
|
||||||
<pre class="plain"><code>8. lists changed, rolled out to: server, list, web, tui
|
<pre class="plain"><code>8. lists changed, rolled out to: server, list, web, tui
|
||||||
on list|groceries: todo.store restarted, todo.todos(groceries) parked
|
on list|groceries: todo.store restarted, todo.todos(groceries) parked
|
||||||
ada adds ' tea ': [ ] tea (ada)
|
ada adds ' tea ': [ ] tea (ada)
|
||||||
ada adds ' ': ignored</code></pre>
|
ada adds ' ': { code: 'invalid', reason: 'args' }</code></pre>
|
||||||
<p>The watcher marked <code>@todo/lists</code> dirty when the file changed; <code>applyChanges</code> rebuilt it (one esbuild call per facet)
|
<p>The watcher marked <code>@todo/lists</code> dirty when the file changed; <code>applyChanges</code> rebuilt it (one esbuild call per facet)
|
||||||
and rolled it out host by host. On each host Chord stages the new code (it runs the new factories against a recording
|
and rolled it out host by host. On each host Chord stages the new code (it runs the new factories against a recording
|
||||||
<code>self</code> to see what they declare), commits it in one step, and gives each object of the changed plugin its <strong>fate</strong>
|
<code>self</code> to see what they declare), commits it in one step, and gives each object of the changed plugin its <strong>fate</strong>
|
||||||
|
|
@ -953,7 +992,9 @@ and rolled it out host by host. On each host Chord stages the new code (it runs
|
||||||
</tbody></table>
|
</tbody></table>
|
||||||
<p>The store opens a native (the window's first read), so it restarted and read its window again from the table; the
|
<p>The store opens a native (the window's first read), so it restarted and read its window again from the table; the
|
||||||
views waited for it (parked), then went on with the new code. Ada's reference never noticed more than a moment of
|
views waited for it (parked), then went on with the new code. Ada's reference never noticed more than a moment of
|
||||||
<code>loading</code>, and her next add was trimmed. Had the edit broken the plugin, that plugin alone would have kept its old code
|
<code>loading</code>, and her next add was trimmed. Her blank one is now empty after trimming, so the store refused it with
|
||||||
|
<code>invalid("args", …)</code> (chapter II): her client got <code>{ code: "invalid", reason: "args" }</code>, 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.</p>
|
and the report would name the error.</p>
|
||||||
<p>A contract that changes shape needs a new <code>version</code>; old consumers keep working when the provider maps their version
|
<p>A contract that changes shape needs a new <code>version</code>; old consumers keep working when the provider maps their version
|
||||||
(<code>accepts</code>), otherwise they wait with <code>contract_mismatch</code> until they reload. A stored document or table that changes
|
(<code>accepts</code>), otherwise they wait with <code>contract_mismatch</code> until they reload. A stored document or table that changes
|
||||||
|
|
@ -968,12 +1009,16 @@ page reload. <code>screenshots.ts</code> adds a count of open todos to the panel
|
||||||
<pre class="plain"><code>wrote docs/tutorial/screens/page.png
|
<pre class="plain"><code>wrote docs/tutorial/screens/page.png
|
||||||
wrote docs/tutorial/screens/harbor.png
|
wrote docs/tutorial/screens/harbor.png
|
||||||
wrote docs/tutorial/screens/edited.png
|
wrote docs/tutorial/screens/edited.png
|
||||||
typed "oat mi"; after the edit: ""
|
typed "oat mi"; after the edit: "oat mi"
|
||||||
the page did not reload: true</code></pre>
|
the page did not reload: true</code></pre>
|
||||||
<figure class="fig"><p><img src="screens/edited.png" alt="The page after the edit"></p>
|
<figure class="fig"><p><img src="screens/edited.png" alt="The page after the edit"></p>
|
||||||
<figcaption><span class="fignum">Figure 3.</span> After the edit of the web facet, applied in place: the count appears; the half-typed "oat mi" is gone, because the panel's component is new code and React mounted it anew.</figcaption></figure>
|
<figcaption><span class="fignum">Figure 3.</span> After the edit of the web facet, applied in place: the count appears, and the half-typed "oat mi" is still in the box: the page kept it under its key while React mounted the new component.</figcaption></figure>
|
||||||
<p>The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel's
|
<p>The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel
|
||||||
own React state did not: a new component function is a new component to React.</p>
|
itself is new code: its component is a new function, which React mounts anew, so its own <code>useState</code> would start over
|
||||||
|
(design §21). What must survive a plugin update (a half-typed todo, a scroll position) is <strong>page state</strong>:
|
||||||
|
<code>usePageState(key, initial)</code> keeps the value in the page's host under the key, not in the component, so the new
|
||||||
|
component finds it; a terminal's <code>TuiScope</code> has the same store as <code>scope.pageState(key, initial)</code>. Page state is any
|
||||||
|
value, never sent anywhere, and gone with the page.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="chapter" id="export"><header class="opener"><div class="numeral">IX</div><h2>Moving a List</h2><p class="lede">Export a worker's data while it runs, import it under another address.</p></header>
|
<section class="chapter" id="export"><header class="opener"><div class="numeral">IX</div><h2>Moving a List</h2><p class="lede">Export a worker's data while it runs, import it under another address.</p></header>
|
||||||
|
|
@ -1006,9 +1051,9 @@ an index of the app's, if it had one, is the app's.</p>
|
||||||
production builds it (the same esbuild call, the API's imports checked), evaluates it with the app's real API module and
|
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 <code>Todos</code>:</p>
|
loads it on a new host with an in-memory store, here at a list's address so it may serve <code>Todos</code>:</p>
|
||||||
<!-- quote: plugins/lists/worker.test.ts -->
|
<!-- quote: plugins/lists/worker.test.ts -->
|
||||||
<pre><code> <span class="tk-k">let</span> t: <span class="tk-t">TestedPlugin</span><<span class="tk-t">TodoPrincipal</span>>;
|
<pre><code> <span class="tk-k">let</span> t: <span class="tk-t">TestedPlugin</span><v1.<span class="tk-t">TodoPrincipal</span>>;
|
||||||
<span class="tk-f">beforeEach</span>(<span class="tk-k">async</span> () => {
|
<span class="tk-f">beforeEach</span>(<span class="tk-k">async</span> () => {
|
||||||
t = <span class="tk-k">await</span> <span class="tk-f">testPlugin</span><<span class="tk-t">TodoPrincipal</span>>(
|
t = <span class="tk-k">await</span> <span class="tk-f">testPlugin</span><v1.<span class="tk-t">TodoPrincipal</span>>(
|
||||||
<span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./"</span>, <span class="tk-k">import</span>.meta.url)),
|
<span class="tk-f">fileURLToPath</span>(<span class="tk-k">new</span> <span class="tk-f">URL</span>(<span class="tk-s">"./"</span>, <span class="tk-k">import</span>.meta.url)),
|
||||||
{
|
{
|
||||||
kind: <span class="tk-s">"worker"</span>,
|
kind: <span class="tk-s">"worker"</span>,
|
||||||
|
|
@ -1026,7 +1071,7 @@ loads it on a new host with an in-memory store, here at a list's address so
|
||||||
<pre><code> <span class="tk-f">it</span>(<span class="tk-s">"lets the owner clear the done todos"</span>, <span class="tk-k">async</span> () => {
|
<pre><code> <span class="tk-f">it</span>(<span class="tk-s">"lets the owner clear the done todos"</span>, <span class="tk-k">async</span> () => {
|
||||||
<span class="tk-k">await</span> t.<span class="tk-f">root</span>(
|
<span class="tk-k">await</span> t.<span class="tk-f">root</span>(
|
||||||
<span class="tk-k">async</span> (ctx) => {
|
<span class="tk-k">async</span> (ctx) => {
|
||||||
<span class="tk-k">const</span> todos = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(v1.<span class="tk-t">Todos</span>, { list: <span class="tk-s">"groceries"</span> });
|
<span class="tk-k">const</span> todos = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">Todos</span>, { list: <span class="tk-s">"groceries"</span> });
|
||||||
<span class="tk-k">if</span> (todos.variant !== <span class="tk-s">"owner"</span>) <span class="tk-k">throw</span> <span class="tk-k">new</span> <span class="tk-t">Error</span>(<span class="tk-s">`the owner got ${todos.variant}`</span>);
|
<span class="tk-k">if</span> (todos.variant !== <span class="tk-s">"owner"</span>) <span class="tk-k">throw</span> <span class="tk-k">new</span> <span class="tk-t">Error</span>(<span class="tk-s">`the owner got ${todos.variant}`</span>);
|
||||||
<span class="tk-k">await</span> todos.<span class="tk-f">add</span>(<span class="tk-s">"milk"</span>, ctx);
|
<span class="tk-k">await</span> todos.<span class="tk-f">add</span>(<span class="tk-s">"milk"</span>, ctx);
|
||||||
<span class="tk-k">await</span> todos.<span class="tk-f">add</span>(<span class="tk-s">"eggs"</span>, ctx);
|
<span class="tk-k">await</span> todos.<span class="tk-f">add</span>(<span class="tk-s">"eggs"</span>, ctx);
|
||||||
|
|
@ -1042,10 +1087,17 @@ loads it on a new host with an in-memory store, here at a list's address so
|
||||||
knows which stub facets to add. Registries it contributes to that nobody serves are collected (<code>t.contributions(R)</code>).
|
knows which stub facets to add. Registries it contributes to that nobody serves are collected (<code>t.contributions(R)</code>).
|
||||||
Run it like any test:</p>
|
Run it like any test:</p>
|
||||||
<pre><code>node ../../node_modules/vitest/dist/cli.js --run test/examples/<span class="tk-n">61</span>-tutorial/plugins/lists/worker.test.ts</code></pre>
|
<pre><code>node ../../node_modules/vitest/dist/cli.js --run test/examples/<span class="tk-n">61</span>-tutorial/plugins/lists/worker.test.ts</code></pre>
|
||||||
<p>The test imports <code>Todos</code> and <code>List</code> from the API module by path. It could not import a contract from a plugin's own
|
<p>The test imports the API by its package name and <code>Todos</code> from the plugin's own <code>contract.ts</code>, as the facets do:</p>
|
||||||
<code>contract.ts</code>: that file imports <code>@todo/app/v1</code>, a name only plugin builds resolve. A test of such a contract declares
|
<!-- quote: plugins/lists/worker.test.ts -->
|
||||||
it again (contracts are data: the same name, version and schema bind), as <code>docs/plugin-authoring.md</code> shows; this app
|
<pre><code><span class="tk-k">import</span> * <span class="tk-k">as</span> v1 <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1"</span>;</code></pre>
|
||||||
keeps the contract it shares in its API instead.</p>
|
<!-- quote: plugins/lists/worker.test.ts -->
|
||||||
|
<pre><code><span class="tk-k">import</span> { <span class="tk-t">Todos</span> } <span class="tk-k">from</span> <span class="tk-s">"./contract.ts"</span>;</code></pre>
|
||||||
|
<p>Both resolve because the API is a workspace package npm linked (chapter I); the plugin's build bundles its own copy of
|
||||||
|
<code>contract.ts</code>, and the two bind, since contracts are data (the same name, version and schema). A third test checks
|
||||||
|
the refusal of chapter II:</p>
|
||||||
|
<!-- quote: plugins/lists/worker.test.ts -->
|
||||||
|
<pre><code> <span class="tk-k">const</span> refused = <span class="tk-k">await</span> todos.<span class="tk-f">add</span>(<span class="tk-s">""</span>, ctx).<span class="tk-k">catch</span>((error: <span class="tk-t">ChordError</span>) => error);
|
||||||
|
<span class="tk-f">expect</span>(refused).<span class="tk-f">toMatchObject</span>({ code: <span class="tk-s">"invalid"</span>, reason: <span class="tk-s">"args"</span> });</code></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="chapter" id="p2"><header class="opener"><div class="numeral">XI</div><h2>Writing a p2 Plugin</h2><p class="lede">What is different in p2, and one small plugin: a tool for the agent and a chip for the people.</p></header>
|
<section class="chapter" id="p2"><header class="opener"><div class="numeral">XI</div><h2>Writing a p2 Plugin</h2><p class="lede">What is different in p2, and one small plugin: a tool for the agent and a chip for the people.</p></header>
|
||||||
|
|
@ -1207,15 +1259,10 @@ a tutorial.</p>
|
||||||
live; <code>self.open</code> is the only place, so the store counts as holding a native and restarts at every reload of its
|
live; <code>self.open</code> is the only place, so the store counts as holding a native and restarts at every reload of its
|
||||||
plugin, parking its views, though it holds nothing open. Tables have no watch, so every table-backed live view
|
plugin, parking its views, though it holds nothing open. Tables have no watch, so every table-backed live view
|
||||||
repeats this pattern (example 59's chat does too).</li>
|
repeats this pattern (example 59's chat does too).</li>
|
||||||
<li><strong>A reloaded web plugin loses its own React state.</strong> 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.</li>
|
|
||||||
<li><strong>Plugin contracts are hard to test from outside builds.</strong> A test or script cannot import a plugin's
|
|
||||||
<code>contract.ts</code> (it imports the app's API by a package name only builds resolve), so contracts are declared twice or
|
|
||||||
moved into the API.</li>
|
|
||||||
<li><strong>No public <code>invalid</code>.</strong> Plugins throw <code>PluginError</code> with codes of their own; refusing bad arguments with Chord's
|
|
||||||
<code>invalid/args</code>, as the built-in checks do, is not open to them.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<p>Three more, found the same way, are fixed: a reloaded web plugin lost its own React state (now <code>usePageState</code>,
|
||||||
|
chapter VIII), a plugin's <code>contract.ts</code> could not be imported outside builds (the API is now a workspace package,
|
||||||
|
chapter I), and plugins could not throw Chord's <code>invalid</code> (Chord exports <code>invalid</code> and <code>forbidden</code>, chapter II).</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="chapter appendix" id="the-app-s-files"><header class="opener"><div class="numeral">A</div><h2>The App's Files</h2></header>
|
<section class="chapter appendix" id="the-app-s-files"><header class="opener"><div class="numeral">A</div><h2>The App's Files</h2></header>
|
||||||
|
|
@ -1233,9 +1280,14 @@ moved into the API.</li>
|
||||||
<td>the principal type</td>
|
<td>the principal type</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td><code>package.json</code></td>
|
||||||
|
<td>base</td>
|
||||||
|
<td>the API as the workspace package <code>@todo/app</code>, its <code>exports</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td><code>src/api/v1/index.ts</code></td>
|
<td><code>src/api/v1/index.ts</code></td>
|
||||||
<td>base, every kind</td>
|
<td>base, every kind</td>
|
||||||
<td><code>@todo/app/v1</code>: Chord's part, <code>defineFacet</code>, <code>List</code>, <code>Item</code>, <code>Todos</code></td>
|
<td><code>@todo/app/v1</code>: Chord's part (with <code>invalid</code>, <code>forbidden</code>), <code>defineFacet</code>, <code>List</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>src/api/v1/server.ts</code></td>
|
<td><code>src/api/v1/server.ts</code></td>
|
||||||
|
|
@ -1245,7 +1297,7 @@ moved into the API.</li>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>src/api/v1/web.ts</code></td>
|
<td><code>src/api/v1/web.ts</code></td>
|
||||||
<td>base, web</td>
|
<td>base, web</td>
|
||||||
<td><code>Panels</code>, <code>useHold</code>, <code>useWatch</code></td>
|
<td><code>Panels</code>, <code>useHold</code>, <code>usePageState</code>, <code>useWatch</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>src/api/v1/tui.ts</code></td>
|
<td><code>src/api/v1/tui.ts</code></td>
|
||||||
|
|
@ -1270,7 +1322,7 @@ moved into the API.</li>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>plugins/lists/</code></td>
|
<td><code>plugins/lists/</code></td>
|
||||||
<td>plugin</td>
|
<td>plugin</td>
|
||||||
<td>worker (table, store, views), server (routes), web (panel), tui (screen), its test</td>
|
<td>contract (<code>Item</code>, <code>Todos</code>), worker (table, store, views), server (routes), web (panel), tui (screen), its test</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>plugins/login.server.ts</code></td>
|
<td><code>plugins/login.server.ts</code></td>
|
||||||
|
|
@ -1330,6 +1382,11 @@ moved into the API.</li>
|
||||||
<td>code outside objects, as a principal</td>
|
<td>code outside objects, as a principal</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td><code>invalid(reason, message, details?)</code>, <code>forbidden(message, details?)</code></td>
|
||||||
|
<td>II, VIII</td>
|
||||||
|
<td>Chord's errors, thrown by a plugin</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td><code>self.contribute(Http, { routes })</code></td>
|
<td><code>self.contribute(Http, { routes })</code></td>
|
||||||
<td>III</td>
|
<td>III</td>
|
||||||
<td>routes, denied by default</td>
|
<td>routes, denied by default</td>
|
||||||
|
|
@ -1345,6 +1402,11 @@ moved into the API.</li>
|
||||||
<td>the terminal</td>
|
<td>the terminal</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td><code>usePageState(key, initial)</code>, <code>scope.pageState(key, initial)</code></td>
|
||||||
|
<td>IV, VIII</td>
|
||||||
|
<td>state the page keeps across plugin reloads</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td><code>self.contribute(Themes, theme)</code></td>
|
<td><code>self.contribute(Themes, theme)</code></td>
|
||||||
<td>VI</td>
|
<td>VI</td>
|
||||||
<td>a theme</td>
|
<td>a theme</td>
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -99,7 +99,7 @@ export class PluginError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An `invalid` error with the given reason. */
|
/** An `invalid` error with the given reason: a plugin throws it for arguments or params it refuses. */
|
||||||
export function invalid(
|
export function invalid(
|
||||||
reason: ChordErrorReason<"invalid">,
|
reason: ChordErrorReason<"invalid">,
|
||||||
message: string,
|
message: string,
|
||||||
|
|
@ -109,6 +109,15 @@ export function invalid(
|
||||||
return new ChordError({ code: "invalid", reason, message, details }) as Extract<ChordError, { code: "invalid" }>;
|
return new ChordError({ code: "invalid", reason, message, details }) as Extract<ChordError, { code: "invalid" }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `forbidden` error without a reason: what a plugin or an `authorize` throws for a principal it refuses (`revoked` is
|
||||||
|
* Chord's own, for a revoked authority).
|
||||||
|
*/
|
||||||
|
export function forbidden(message: string, details?: JsonValue): Extract<ChordError, { readonly code: "forbidden" }> {
|
||||||
|
// The constructor's return type cannot narrow on its argument.
|
||||||
|
return new ChordError({ code: "forbidden", message, details }) as Extract<ChordError, { code: "forbidden" }>;
|
||||||
|
}
|
||||||
|
|
||||||
/** `error` itself when it is a ChordError, otherwise an `unavailable` error naming `label`, with `error` as cause. */
|
/** `error` itself when it is a ChordError, otherwise an `unavailable` error naming `label`, with `error` as cause. */
|
||||||
export function asChordError(error: unknown, label: string): ChordError {
|
export function asChordError(error: unknown, label: string): ChordError {
|
||||||
if (isChordError(error)) return error;
|
if (isChordError(error)) return error;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
// Data for apps: Chord's errors and plugin errors, principals, and how a plugin's open reports a broken configuration.
|
// Data for apps: Chord's errors (with `invalid` and `forbidden`, the two a plugin throws), plugin errors, principals,
|
||||||
|
// and how a plugin's open reports a broken configuration.
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ChordError,
|
ChordError,
|
||||||
type ChordErrorCode,
|
type ChordErrorCode,
|
||||||
type ChordErrorReason,
|
type ChordErrorReason,
|
||||||
|
forbidden,
|
||||||
|
invalid,
|
||||||
isChordError,
|
isChordError,
|
||||||
PluginError,
|
PluginError,
|
||||||
} from "./errors.ts";
|
} from "./errors.ts";
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import type { Context } from "../context/index.ts";
|
import type { Context } from "../context/index.ts";
|
||||||
import type { Address, HostKind, KindShape } from "../contracts/index.ts";
|
import type { Address, HostKind, KindShape } from "../contracts/index.ts";
|
||||||
import { kindParams, type ParamsData } from "../contracts/internal.ts";
|
import { kindParams, type ParamsData } from "../contracts/internal.ts";
|
||||||
import { ChordError, type Principal } from "../data/index.ts";
|
import { ChordError, forbidden, type Principal } from "../data/index.ts";
|
||||||
import { asChordError, checkAddress } from "../data/internal.ts";
|
import { asChordError, checkAddress } from "../data/internal.ts";
|
||||||
import type { Codec, Link, PartLimits } from "../link/index.ts";
|
import type { Codec, Link, PartLimits } from "../link/index.ts";
|
||||||
import { type HostLink, Path, type PathEvent, type Upstream } from "../remote/internal.ts";
|
import { type HostLink, Path, type PathEvent, type Upstream } from "../remote/internal.ts";
|
||||||
|
|
@ -87,7 +87,7 @@ export class Route {
|
||||||
path(params: unknown, principal: Principal | undefined, context: Context): Path {
|
path(params: unknown, principal: Principal | undefined, context: Context): Path {
|
||||||
const address = checkAddress(this.kind, this.#params, params, context);
|
const address = checkAddress(this.kind, this.#params, params, context);
|
||||||
if (principal === undefined || !this.#options.authorize(principal, address)) {
|
if (principal === undefined || !this.#options.authorize(principal, address)) {
|
||||||
throw new ChordError({ code: "forbidden", message: `${address.key}: not authorized` });
|
throw forbidden(`${address.key}: not authorized`);
|
||||||
}
|
}
|
||||||
let routed = this.#addresses.get(address.key);
|
let routed = this.#addresses.get(address.key);
|
||||||
if (routed === undefined) {
|
if (routed === undefined) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
import { BACKGROUND_CONTEXT } from "../../context/index.ts";
|
import { BACKGROUND_CONTEXT } from "../../context/index.ts";
|
||||||
import { type Address, method, obj, remote, str } from "../../contracts/index.ts";
|
import { type Address, method, obj, remote, str } from "../../contracts/index.ts";
|
||||||
import { ChordError, type Principal } from "../../data/index.ts";
|
import { forbidden, type Principal } from "../../data/index.ts";
|
||||||
import { canonicalJson } from "../../data/principal.ts";
|
import { canonicalJson } from "../../data/principal.ts";
|
||||||
import { defineFacet, type Facet } from "../../host/index.ts";
|
import { defineFacet, type Facet } from "../../host/index.ts";
|
||||||
import type { JsonValue } from "../../json.ts";
|
import type { JsonValue } from "../../json.ts";
|
||||||
|
|
@ -35,7 +35,7 @@ export function hostDataFacet<P extends Principal>(store: Store, files: string,
|
||||||
return defineFacet<P>((host) =>
|
return defineFacet<P>((host) =>
|
||||||
host.serve(HostData, (_params, principal) => {
|
host.serve(HostData, (_params, principal) => {
|
||||||
if (canonicalJson(principal as JsonValue, BACKGROUND_CONTEXT) !== serverKey) {
|
if (canonicalJson(principal as JsonValue, BACKGROUND_CONTEXT) !== serverKey) {
|
||||||
throw new ChordError({ code: "forbidden", message: "chord.app.data: the server's only" });
|
throw forbidden("chord.app.data: the server's only");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
variant: "main",
|
variant: "main",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ peer), non-React apps never load it. No components, no styling.
|
||||||
- `useWatch(watchable)`: its current value (`undefined` while it has none), re-rendering on change
|
- `useWatch(watchable)`: its current value (`undefined` while it has none), re-rendering on change
|
||||||
(`useSyncExternalStore` over `watchStore`).
|
(`useSyncExternalStore` over `watchStore`).
|
||||||
- `useRegistry(R)`: the registry's composed value; `useRefState(ref)`: the reference's state.
|
- `useRegistry(R)`: the registry's composed value; `useRefState(ref)`: the reference's state.
|
||||||
- A component unmounted by a reload has not left (design §21): its releases are plain releases.
|
- `usePageState(key, initial)`: `useState`'s pair, the value kept by the page (its host's life) under `key`
|
||||||
|
(`surfaces/page-state.ts`, shared with `TuiScope.pageState`), so a component remounted by a plugin reload keeps it.
|
||||||
|
- A component unmounted by a reload has not left (design §21): its releases are plain releases. A component of a
|
||||||
|
reloaded plugin is a new function and remounts, its `useState` starting over: what must survive is page state.
|
||||||
|
|
||||||
Tests: `test/node/react/` (`stores.test.ts` in Node, `hooks.test.ts` in a real browser).
|
Tests: `test/node/react/` (`stores.test.ts` in Node, `hooks.test.ts` in a real browser); the page state alone in
|
||||||
|
`test/surfaces/page-state.test.ts`.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// React bindings (slice 11 §3, decision "Slice 11, P7"; p2's `useObject`, `useReadable`, `useRegistry`): a host in
|
// React bindings (slice 11 §3, decision "Slice 11, P7"; p2's `useObject`, `useReadable`, `useRegistry`): a host in
|
||||||
// context, references held for a component's life, and watchables read with `useSyncExternalStore`. A component
|
// context, references held for a component's life, watchables read with `useSyncExternalStore`, and page state kept
|
||||||
// unmounted by a reload has not left (design §21): its releases are plain releases, presence is the app's. No
|
// for the host's life. A component unmounted by a reload has not left (design §21): its releases are plain releases,
|
||||||
// components, no styling.
|
// presence is the app's. A component of a reloaded plugin is a new function and remounts, its `useState` starting
|
||||||
|
// over; what must survive is page state (decision "Tutorial gaps" (1)). No components, no styling.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
|
@ -19,6 +20,7 @@ import type { Principal } from "../data/index.ts";
|
||||||
import { canonicalJson } from "../data/principal.ts";
|
import { canonicalJson } from "../data/principal.ts";
|
||||||
import type { Held, Host, RefState } from "../host/index.ts";
|
import type { Held, Host, RefState } from "../host/index.ts";
|
||||||
import type { Registry } from "../registries/index.ts";
|
import type { Registry } from "../registries/index.ts";
|
||||||
|
import { pageState } from "../surfaces/page-state.ts";
|
||||||
import type { NativeWatchable, Watchable } from "../watch.ts";
|
import type { NativeWatchable, Watchable } from "../watch.ts";
|
||||||
import { watchStore } from "./stores.ts";
|
import { watchStore } from "./stores.ts";
|
||||||
|
|
||||||
|
|
@ -77,3 +79,13 @@ export function useRegistry<C, V>(R: Registry<C, V>): V | undefined {
|
||||||
export function useRefState(ref: { readonly state: NativeWatchable<RefState> } | undefined): RefState | undefined {
|
export function useRefState(ref: { readonly state: NativeWatchable<RefState> } | undefined): RefState | undefined {
|
||||||
return useWatch(ref?.state);
|
return useWatch(ref?.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value the page keeps under `key` for its host's life, not the component's, with its setter (`useState`'s pair): a
|
||||||
|
* component remounted by a plugin reload, a new function, finds what it had; a new key starts at `initial`. Any value
|
||||||
|
* (it never crosses a link); gone with the page.
|
||||||
|
*/
|
||||||
|
export function usePageState<T>(key: string, initial: T): readonly [T, (value: T) => void] {
|
||||||
|
const state = pageState(useHost(), key, initial);
|
||||||
|
return [useSyncExternalStore(state.subscribe, state.read, state.read), state.set];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Chord v2's React bindings (`@earendil-works/chord/react`; slice 11 §3): optional, non-React apps never load them.
|
// Chord v2's React bindings (`@earendil-works/chord/react`; slice 11 §3): optional, non-React apps never load them.
|
||||||
|
|
||||||
export { HostProvider, useHold, useHost, useRefState, useRegistry, useWatch } from "./hooks.ts";
|
export { HostProvider, useHold, useHost, usePageState, useRefState, useRegistry, useWatch } from "./hooks.ts";
|
||||||
export { type ExternalStore, watchStore } from "./stores.ts";
|
export { type ExternalStore, watchStore } from "./stores.ts";
|
||||||
|
|
|
||||||
56
packages/chord/src/surfaces/page-state.ts
Normal file
56
packages/chord/src/surfaces/page-state.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// Page state (decision "Tutorial gaps" (1), refining "Slice 11, P4"): values a client host keeps for its UI by key,
|
||||||
|
// beside its plugins, so a component of a reloaded plugin (a new function, which React or a terminal scope mounts
|
||||||
|
// anew) finds what it had: a half-typed message, a scroll position. Same-host values of any kind, never on the wire;
|
||||||
|
// gone with the host (a page reload, a terminal's new base bundle). `usePageState` and `TuiScope.pageState` read it.
|
||||||
|
|
||||||
|
import type { Principal } from "../data/index.ts";
|
||||||
|
import type { Host } from "../host/index.ts";
|
||||||
|
|
||||||
|
/** One key's value in a host's page state. Its functions are bound: React's `useSyncExternalStore` takes them as is. */
|
||||||
|
export type PageState<T> = {
|
||||||
|
/** The value now. */
|
||||||
|
readonly read: () => T;
|
||||||
|
/** Sets the value; a new value (by identity) calls every subscriber. */
|
||||||
|
readonly set: (value: T) => void;
|
||||||
|
/** Calls `changed` after every set that changed the value, until the returned unsubscribe. */
|
||||||
|
readonly subscribe: (changed: () => void) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Each client host's page state by key, kept as long as the host. */
|
||||||
|
const PAGE_STATES = new WeakMap<object, Map<string, PageState<unknown>>>();
|
||||||
|
|
||||||
|
/** The page state of `host` at `key`: what was kept there, or `initial` when the key is new. */
|
||||||
|
export function pageState<T, P extends Principal = Principal>(host: Host<P>, key: string, initial: T): PageState<T> {
|
||||||
|
let states = PAGE_STATES.get(host);
|
||||||
|
if (states === undefined) {
|
||||||
|
states = new Map();
|
||||||
|
PAGE_STATES.set(host, states);
|
||||||
|
}
|
||||||
|
let state = states.get(key);
|
||||||
|
if (state === undefined) {
|
||||||
|
state = keptValue<unknown>(initial);
|
||||||
|
states.set(key, state);
|
||||||
|
}
|
||||||
|
// A key is the app's: whoever names it names its type too.
|
||||||
|
return state as PageState<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A value with its subscribers. */
|
||||||
|
function keptValue<T>(initial: T): PageState<T> {
|
||||||
|
let value = initial;
|
||||||
|
const subscribers = new Set<() => void>();
|
||||||
|
return {
|
||||||
|
read: () => value,
|
||||||
|
set: (next) => {
|
||||||
|
if (next === value) return;
|
||||||
|
value = next;
|
||||||
|
for (const changed of subscribers) changed();
|
||||||
|
},
|
||||||
|
subscribe: (changed) => {
|
||||||
|
subscribers.add(changed);
|
||||||
|
return () => {
|
||||||
|
subscribers.delete(changed);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,9 @@ startTerminalRuntime<P>({
|
||||||
- `channel.ts`: the sandbox's side: requests, `RelayLink` (a Chord link whose bytes the launcher relays), input and
|
- `channel.ts`: the sandbox's side: requests, `RelayLink` (a Chord link whose bytes the launcher relays), input and
|
||||||
resizes. `terminal.ts`: the bridged terminal (size, write, input, resize). `theme.ts`: the theme lookup (`fg`, `bg`,
|
resizes. `terminal.ts`: the bridged terminal (size, write, input, resize). `theme.ts`: the theme lookup (`fg`, `bg`,
|
||||||
`onChange`). `runtime.ts`: the client (`startClient` with `TuiScripts`; scripts run with `vm`; no wakes).
|
`onChange`). `runtime.ts`: the client (`startClient` with `TuiScripts`; scripts run with `vm`; no wakes).
|
||||||
- `scope.ts`: `TuiScope(host, changed)`: `hold`, `watch` (a reader, `undefined` while no value), `registry`, `dispose`
|
- `scope.ts`: `TuiScope(host, changed)`: `hold`, `watch` (a reader, `undefined` while no value), `registry`,
|
||||||
(no `changed` after it). References wait and rebind by themselves; no terminal library dependency.
|
`pageState(key, initial)` (the host's value at `key`, shared with React's `usePageState`: a scope opened again after a
|
||||||
|
plugin reload finds it), `dispose` (no `changed` after it). References wait and rebind by themselves; no terminal
|
||||||
|
library dependency.
|
||||||
|
|
||||||
Tests: `test/tui/` and, with the launcher and sandbox, `test/node/tui/`.
|
Tests: `test/tui/` and, with the launcher and sandbox, `test/node/tui/`.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// the app's terminal entry, the bridged terminal, the theme lookup and `TuiScope`. The launcher and the server side are
|
// the app's terminal entry, the bridged terminal, the theme lookup and `TuiScope`. The launcher and the server side are
|
||||||
// `/tui/launcher` and `/surfaces`'s `tuiSurface`.
|
// `/tui/launcher` and `/surfaces`'s `tuiSurface`.
|
||||||
|
|
||||||
|
export type { PageState } from "../surfaces/page-state.ts";
|
||||||
export type { Bridge, BridgeRequest, BridgeStart, LauncherMessage, SandboxMessage } from "./bridge.ts";
|
export type { Bridge, BridgeRequest, BridgeStart, LauncherMessage, SandboxMessage } from "./bridge.ts";
|
||||||
export {
|
export {
|
||||||
type AttachedFile,
|
type AttachedFile,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
// `TuiScope` (slice 11 §4, decision "Slice 11, P9"; p2's `TuiScope`): a terminal component's Chord resources, the
|
// `TuiScope` (slice 11 §4, decision "Slice 11, P9"; p2's `TuiScope`): a terminal component's Chord resources, the
|
||||||
// counterpart of the React bindings for terminal UI libraries. It holds references, follows watchables and registries,
|
// counterpart of the React bindings for terminal UI libraries. It holds references, follows watchables and registries,
|
||||||
// calls one `changed` callback (the app's redraw) on every change, and releases everything at `dispose`. References
|
// calls one `changed` callback (the app's redraw) on every change, and releases everything at `dispose`. Its page state
|
||||||
// wait and rebind by themselves (no retry loop, unlike p2's); no terminal library dependency.
|
// is the host's, shared by every scope (React's `usePageState`): a scope opened again after a plugin reload finds it.
|
||||||
|
// References wait and rebind by themselves (no retry loop, unlike p2's); no terminal library dependency.
|
||||||
|
|
||||||
import { BACKGROUND_CONTEXT, withAbortSignal } from "../context/index.ts";
|
import { BACKGROUND_CONTEXT, withAbortSignal } from "../context/index.ts";
|
||||||
import type { Contract, ParamsOf } from "../contracts/index.ts";
|
import type { Contract, ParamsOf } from "../contracts/index.ts";
|
||||||
import type { Principal } from "../data/index.ts";
|
import type { Principal } from "../data/index.ts";
|
||||||
import type { Held, Host } from "../host/index.ts";
|
import type { Held, Host } from "../host/index.ts";
|
||||||
import type { Registry } from "../registries/index.ts";
|
import type { Registry } from "../registries/index.ts";
|
||||||
|
import { type PageState, pageState } from "../surfaces/page-state.ts";
|
||||||
import type { NativeWatchable, Watchable } from "../watch.ts";
|
import type { NativeWatchable, Watchable } from "../watch.ts";
|
||||||
|
|
||||||
/** A component's scope: what it holds and follows, until `dispose`. */
|
/** A component's scope: what it holds and follows, until `dispose`. */
|
||||||
|
|
@ -68,6 +70,17 @@ export class TuiScope<P extends Principal = Principal> {
|
||||||
return () => read();
|
return () => read();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host's page state at `key`, kept for the terminal's life, not the scope's (a new key starts at `initial`); its
|
||||||
|
* changes call `changed` until `dispose`.
|
||||||
|
*/
|
||||||
|
pageState<T>(key: string, initial: T): PageState<T> {
|
||||||
|
const state = pageState(this.#host, key, initial);
|
||||||
|
// A closure of its own: two scopes may share one `changed`, and subscribers are a set of functions.
|
||||||
|
this.#releases.push(state.subscribe(() => this.#changed()));
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
/** Releases every reference and ends every watch; `changed` is not called after it. */
|
/** Releases every reference and ends every watch; `changed` is not called after it. */
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.#abort.abort();
|
this.#abort.abort();
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,12 @@ import { cp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { List } from "@todo/app/v1";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||||
import { type ChordError, createHost, WebScripts, type WebSocketLike, webSocketLink } from "../../../src/index.ts";
|
import { type ChordError, createHost, WebScripts, type WebSocketLike, webSocketLink } from "../../../src/index.ts";
|
||||||
import { type RunningTerminal, requireSandboxNode, startTerminal } from "../../../src/node/tui/index.ts";
|
import { type RunningTerminal, requireSandboxNode, startTerminal } from "../../../src/node/tui/index.ts";
|
||||||
import { type Item, List, Todos } from "./src/api/v1/index.ts";
|
import { type Item, Todos } from "./plugins/lists/contract.ts";
|
||||||
import type { TodoPrincipal } from "./src/principals.ts";
|
import type { TodoPrincipal } from "./src/principals.ts";
|
||||||
import { startTodo, todoDirectories } from "./start.ts";
|
import { startTodo, todoDirectories } from "./start.ts";
|
||||||
|
|
||||||
|
|
@ -157,17 +158,15 @@ if (adas.variant === "member") {
|
||||||
await until(() => groceries.items.value.some(({ by }) => by === "ada"));
|
await until(() => groceries.items.value.some(({ by }) => by === "ada"));
|
||||||
console.log(" the server's reference sees:", show(groceries.items.value.slice(-1)));
|
console.log(" the server's reference sees:", show(groceries.items.value.slice(-1)));
|
||||||
|
|
||||||
// 8. An edit applied in place: the lists plugin's worker facet trims a todo's text and ignores an empty one. The
|
// 8. An edit applied in place: the lists plugin's worker facet trims a todo's text, so a blank one is empty and the
|
||||||
|
// store refuses it with Chord's `invalid/args`, which reaches Ada as it would a refused argument. The
|
||||||
// watcher marks the plugin dirty; applying the changes rolls it out to the server, the list's worker, the page and
|
// watcher marks the plugin dirty; applying the changes rolls it out to the server, the list's worker, the page and
|
||||||
// the terminal. On the worker each object of the plugin takes its fate: the store opens a native, so it restarts
|
// the terminal. On the worker each object of the plugin takes its fate: the store opens a native, so it restarts
|
||||||
// (its todos are in the store, and it reads them again); the views wait for it, then go on with the new code. Ada's
|
// (its todos are in the store, and it reads them again); the views wait for it, then go on with the new code. Ada's
|
||||||
// reference follows them.
|
// reference follows them.
|
||||||
// The edit: `add` in the `Todos` view, before and after.
|
// The edit: `add` in the `Todos` view, before and after.
|
||||||
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
|
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
|
||||||
const AFTER = `add: async (text: string, ctx: Ctx) => {
|
const AFTER = "add: (text: string, ctx: Ctx) => store.add({ text: text.trim(), by }, ctx),";
|
||||||
if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx);
|
|
||||||
return null;
|
|
||||||
},`;
|
|
||||||
const workerFile = join(root, "lists", "worker.ts");
|
const workerFile = join(root, "lists", "worker.ts");
|
||||||
await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
|
await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
|
||||||
await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
|
await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
|
||||||
|
|
@ -180,9 +179,11 @@ for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) {
|
||||||
await adas.add(" tea ", context);
|
await adas.add(" tea ", context);
|
||||||
await until(() => groceries.items.value.at(-1)?.text === "tea");
|
await until(() => groceries.items.value.at(-1)?.text === "tea");
|
||||||
console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1)));
|
console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1)));
|
||||||
const count = groceries.items.value.length;
|
const refused = await adas.add(" ", context).then(
|
||||||
await adas.add(" ", context);
|
() => "added",
|
||||||
console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added");
|
(error: ChordError) => ({ code: error.code, reason: error.reason }),
|
||||||
|
);
|
||||||
|
console.log(" ada adds ' ':", refused);
|
||||||
|
|
||||||
// 9. Export and import. The list's running worker writes its own store and files as one archive and goes on; importing
|
// 9. Export and import. The list's running worker writes its own store and files as one archive and goes on; importing
|
||||||
// the archive makes a new list with the same todos. An address that has data is never overwritten.
|
// the archive makes a new list with the same todos. An address that has data is never overwritten.
|
||||||
|
|
|
||||||
13
packages/chord/test/examples/61-tutorial/package.json
Normal file
13
packages/chord/test/examples/61-tutorial/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "@todo/app",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "The tutorial's todo app: its API (v1 and its per-kind modules), base code and built-in plugins",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./v1": "./src/api/v1/index.ts",
|
||||||
|
"./v1/server": "./src/api/v1/server.ts",
|
||||||
|
"./v1/web": "./src/api/v1/web.ts",
|
||||||
|
"./v1/tui": "./src/api/v1/tui.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
// The lists plugin's contract: a list's todos as one principal sees them, on the list's worker. Its facets of every
|
||||||
|
// kind import it, and so do code and tests outside the plugin (they resolve `@todo/app/v1` as plugins do).
|
||||||
|
|
||||||
|
import { arr, bool, int, List, method, num, obj, remote, type Static, str } from "@todo/app/v1";
|
||||||
|
|
||||||
|
/** A todo: its number in the list, its text, whether it is done, who added it and when. */
|
||||||
|
export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
|
||||||
|
|
||||||
|
/** A todo (see `Item`). */
|
||||||
|
export type Item = Static<typeof Item>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list's todos as one principal sees them: the newest 50 (live), adding one and ticking one off (or on again); the
|
||||||
|
* owner also clears the done ones.
|
||||||
|
*/
|
||||||
|
export const Todos = remote("todo.todos", {
|
||||||
|
version: 1,
|
||||||
|
params: { list: str() },
|
||||||
|
hostAddress: ({ list }) => List({ id: list }),
|
||||||
|
variants: {
|
||||||
|
member: {
|
||||||
|
state: { items: arr(Item) },
|
||||||
|
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })) },
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
state: { items: arr(Item) },
|
||||||
|
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })), clear: method() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the
|
// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the
|
||||||
// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.
|
// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.
|
||||||
|
|
||||||
import { defineFacet, Todos } from "@todo/app/v1";
|
import { defineFacet } from "@todo/app/v1";
|
||||||
import { Http } from "@todo/app/v1/server";
|
import { Http } from "@todo/app/v1/server";
|
||||||
|
import { Todos } from "./contract.ts";
|
||||||
|
|
||||||
export default defineFacet((_host, self) => {
|
export default defineFacet((_host, self) => {
|
||||||
self.contribute(Http, {
|
self.contribute(Http, {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// The lists plugin in the terminal: the groceries list's todos, live; done ones muted.
|
// The lists plugin in the terminal: the groceries list's todos, live; done ones muted.
|
||||||
|
|
||||||
import { defineFacet, Todos } from "@todo/app/v1";
|
import { defineFacet } from "@todo/app/v1";
|
||||||
import { Screens } from "@todo/app/v1/tui";
|
import { Screens } from "@todo/app/v1/tui";
|
||||||
|
import { Todos } from "./contract.ts";
|
||||||
|
|
||||||
export default defineFacet((_host, self) =>
|
export default defineFacet((_host, self) =>
|
||||||
self.contribute(Screens, {
|
self.contribute(Screens, {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
// The lists plugin on the page: a panel with a list's todos (live), a box to add one, a click to tick one off, and for
|
// The lists plugin on the page: a panel with a list's todos (live), a box to add one, a click to tick one off, and for
|
||||||
// the owner "Clear done". The list is the page's `#hash`, `groceries` without one.
|
// the owner "Clear done". The list is the page's `#hash`, `groceries` without one. The half-typed todo is page state:
|
||||||
|
// a reload of this plugin remounts the panel, and the page keeps what was typed.
|
||||||
|
|
||||||
import { BACKGROUND_CONTEXT as context, defineFacet, Todos } from "@todo/app/v1";
|
import { BACKGROUND_CONTEXT as context, defineFacet } from "@todo/app/v1";
|
||||||
import { Panels, useHold, useWatch } from "@todo/app/v1/web";
|
import { Panels, useHold, usePageState, useWatch } from "@todo/app/v1/web";
|
||||||
import { createElement as h, useState } from "react";
|
import { createElement as h } from "react";
|
||||||
|
import { Todos } from "./contract.ts";
|
||||||
import "./lists.css";
|
import "./lists.css";
|
||||||
|
|
||||||
function TodoList() {
|
function TodoList() {
|
||||||
const todos = useHold(Todos, { list: location.hash.slice(1) || "groceries" });
|
const list = location.hash.slice(1) || "groceries";
|
||||||
|
const todos = useHold(Todos, { list });
|
||||||
const items = useWatch(todos?.items) ?? [];
|
const items = useWatch(todos?.items) ?? [];
|
||||||
const [text, setText] = useState("");
|
// Kept by the page, not the component: the new code's component finds the half-typed todo.
|
||||||
|
const [text, setText] = usePageState(`lists.draft.${list}`, "");
|
||||||
const add = (event: { preventDefault(): void }) => {
|
const add = (event: { preventDefault(): void }) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (todos === undefined || text.trim() === "") return;
|
if (todos === undefined || text.trim() === "") return;
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
// The lists plugin's worker facet, tested alone with `testPlugin`: built as production builds it, evaluated with the
|
// The lists plugin's worker facet, tested alone with `testPlugin`: built as production builds it, evaluated with the
|
||||||
// app's real API module and loaded on a host of its own (an in-memory store) at a list's address. Each test runs code
|
// app's real API module and loaded on a host of its own (an in-memory store) at a list's address. Each test runs code
|
||||||
// as a principal of its choice: a user gets the `member` variant, the owner `owner`; a todo keeps who added it.
|
// as a principal of its choice: a user gets the `member` variant, the owner `owner`; a todo keeps who added it, and one
|
||||||
|
// without text is refused. The test imports the app's API by its package name and the plugin's contract from its
|
||||||
|
// `contract.ts`, as the facets do.
|
||||||
// Run from packages/chord:
|
// Run from packages/chord:
|
||||||
// node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts
|
// node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import * as v1 from "@todo/app/v1";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { BACKGROUND_CONTEXT as context } from "../../../../../src/context/index.ts";
|
import { BACKGROUND_CONTEXT as context } from "../../../../../src/context/index.ts";
|
||||||
|
import type { ChordError } from "../../../../../src/index.ts";
|
||||||
import { type TestedPlugin, testPlugin } from "../../../../../src/node/plugins/index.ts";
|
import { type TestedPlugin, testPlugin } from "../../../../../src/node/plugins/index.ts";
|
||||||
import * as v1 from "../../src/api/v1/index.ts";
|
import { Todos } from "./contract.ts";
|
||||||
import type { TodoPrincipal } from "../../src/principals.ts";
|
|
||||||
|
|
||||||
const ADA = { kind: "user", id: "u1", name: "ada" } as const;
|
const ADA = { kind: "user", id: "u1", name: "ada" } as const;
|
||||||
const OWNER = { kind: "owner" } as const;
|
const OWNER = { kind: "owner" } as const;
|
||||||
|
|
||||||
describe("the lists plugin on a list's worker", () => {
|
describe("the lists plugin on a list's worker", () => {
|
||||||
let t: TestedPlugin<TodoPrincipal>;
|
let t: TestedPlugin<v1.TodoPrincipal>;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
t = await testPlugin<TodoPrincipal>(
|
t = await testPlugin<v1.TodoPrincipal>(
|
||||||
fileURLToPath(new URL("./", import.meta.url)),
|
fileURLToPath(new URL("./", import.meta.url)),
|
||||||
{
|
{
|
||||||
kind: "worker",
|
kind: "worker",
|
||||||
|
|
@ -33,7 +36,7 @@ describe("the lists plugin on a list's worker", () => {
|
||||||
it("adds a todo as the principal that called", async () => {
|
it("adds a todo as the principal that called", async () => {
|
||||||
await t.root(
|
await t.root(
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const todos = await ctx.get(v1.Todos, { list: "groceries" });
|
const todos = await ctx.get(Todos, { list: "groceries" });
|
||||||
expect(todos.variant).toBe("member");
|
expect(todos.variant).toBe("member");
|
||||||
await todos.add("coffee", ctx);
|
await todos.add("coffee", ctx);
|
||||||
expect(todos.items.value.map(({ text, by }) => `${text} (${by})`)).toEqual(["coffee (ada)"]);
|
expect(todos.items.value.map(({ text, by }) => `${text} (${by})`)).toEqual(["coffee (ada)"]);
|
||||||
|
|
@ -43,10 +46,22 @@ describe("the lists plugin on a list's worker", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses a todo without text with invalid/args", async () => {
|
||||||
|
await t.root(
|
||||||
|
async (ctx) => {
|
||||||
|
const todos = await ctx.get(Todos, { list: "groceries" });
|
||||||
|
const refused = await todos.add("", ctx).catch((error: ChordError) => error);
|
||||||
|
expect(refused).toMatchObject({ code: "invalid", reason: "args" });
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
{ principal: ADA },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("lets the owner clear the done todos", async () => {
|
it("lets the owner clear the done todos", async () => {
|
||||||
await t.root(
|
await t.root(
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const todos = await ctx.get(v1.Todos, { list: "groceries" });
|
const todos = await ctx.get(Todos, { list: "groceries" });
|
||||||
if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`);
|
if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`);
|
||||||
await todos.add("milk", ctx);
|
await todos.add("milk", ctx);
|
||||||
await todos.add("eggs", ctx);
|
await todos.add("eggs", ctx);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// The lists plugin on a list's worker. The todos live in a table of the worker's store (every one kept), written and
|
// The lists plugin on a list's worker. The todos live in a table of the worker's store (every one kept), written and
|
||||||
// read by one local object, `Store`. A table has no watch, so the store keeps a live window itself: the newest 50
|
// read by one local object, `Store`. A table has no watch, so the store keeps a live window itself: the newest 50
|
||||||
// todos, read when it goes live and changed after each write (it is the only writer). Each principal's `Todos` view
|
// todos, read when it goes live and changed after each write (it is the only writer). Each principal's `Todos` view
|
||||||
// adds todos as that principal, and gives the owner `clear`.
|
// adds todos as that principal, and gives the owner `clear`. The store refuses a todo without text with Chord's
|
||||||
|
// `invalid/args`.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
arr,
|
arr,
|
||||||
|
|
@ -10,16 +11,16 @@ import {
|
||||||
defineFacet,
|
defineFacet,
|
||||||
defineTable,
|
defineTable,
|
||||||
eq,
|
eq,
|
||||||
Item,
|
|
||||||
int,
|
int,
|
||||||
|
invalid,
|
||||||
local,
|
local,
|
||||||
method,
|
method,
|
||||||
num,
|
num,
|
||||||
obj,
|
obj,
|
||||||
query,
|
query,
|
||||||
str,
|
str,
|
||||||
Todos,
|
|
||||||
} from "@todo/app/v1";
|
} from "@todo/app/v1";
|
||||||
|
import { Item, Todos } from "./contract.ts";
|
||||||
|
|
||||||
/** Every todo of the list, by its number. */
|
/** Every todo of the list, by its number. */
|
||||||
const Items = defineTable("todo.items", {
|
const Items = defineTable("todo.items", {
|
||||||
|
|
@ -63,6 +64,8 @@ export default defineFacet((host) => {
|
||||||
return {
|
return {
|
||||||
items: self.derive([window], ([value]) => value),
|
items: self.derive([window], ([value]) => value),
|
||||||
add: async ({ text, by }, ctx) => {
|
add: async ({ text, by }, ctx) => {
|
||||||
|
// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
|
||||||
|
if (text === "") throw invalid("args", "a todo needs text");
|
||||||
const item = { id: next++, text, done: false, by, at: Date.now() };
|
const item = { id: next++, text, done: false, by, at: Date.now() };
|
||||||
await self.tx(async (tx) => tx.put(items, item), ctx);
|
await self.tx(async (tx) => tx.put(items, item), ctx);
|
||||||
// In number order: two adds may commit in either order.
|
// In number order: two adds may commit in either order.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||||
import { Browser, CHROMIUM } from "../../node/web/browser.ts";
|
import { Browser, CHROMIUM } from "../../node/web/browser.ts";
|
||||||
import { Todos } from "./src/api/v1/index.ts";
|
import { Todos } from "./plugins/lists/contract.ts";
|
||||||
import { startTodo, todoDirectories } from "./start.ts";
|
import { startTodo, todoDirectories } from "./start.ts";
|
||||||
|
|
||||||
const out = resolve(process.argv[2] ?? "screens");
|
const out = resolve(process.argv[2] ?? "screens");
|
||||||
|
|
@ -73,7 +73,8 @@ try {
|
||||||
await browser.wait("getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() === '#12232e'");
|
await browser.wait("getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() === '#12232e'");
|
||||||
await shoot("harbor.png");
|
await shoot("harbor.png");
|
||||||
|
|
||||||
// 3. Half a todo typed, then an edit of the lists plugin's web facet applied: a count of the open todos.
|
// 3. Half a todo typed, then an edit of the lists plugin's web facet applied: a count of the open todos. The panel
|
||||||
|
// remounts (new code), and the half-typed todo is still there: the page keeps it (`usePageState`).
|
||||||
await browser.evaluate("document.querySelector('.lists-panel input').focus()");
|
await browser.evaluate("document.querySelector('.lists-panel input').focus()");
|
||||||
await browser.type("oat mi");
|
await browser.type("oat mi");
|
||||||
// A mark on the page's window: a page that reloaded would have lost it.
|
// A mark on the page's window: a page that reloaded would have lost it.
|
||||||
|
|
@ -84,6 +85,7 @@ try {
|
||||||
while (!app.plugins.state.value.dirty.includes("@todo/lists")) await new Promise((done) => setTimeout(done, 10));
|
while (!app.plugins.state.value.dirty.includes("@todo/lists")) await new Promise((done) => setTimeout(done, 10));
|
||||||
await app.applyChanges(context);
|
await app.applyChanges(context);
|
||||||
await browser.wait("document.querySelector('.lists-open') !== null");
|
await browser.wait("document.querySelector('.lists-open') !== null");
|
||||||
|
await browser.evaluate("document.querySelector('.lists-panel input').focus()");
|
||||||
await shoot("edited.png");
|
await shoot("edited.png");
|
||||||
const after = await browser.evaluate<string>("document.querySelector('.lists-panel input').value");
|
const after = await browser.evaluate<string>("document.querySelector('.lists-panel input').value");
|
||||||
console.log(`typed ${JSON.stringify(typed)}; after the edit: ${JSON.stringify(after)}`);
|
console.log(`typed ${JSON.stringify(typed)}; after the edit: ${JSON.stringify(after)}`);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,12 @@
|
||||||
// The todo app's API, version 1, for every host kind (`@todo/app/v1`): the part of Chord its plugins use, `defineFacet`
|
// The todo app's API, version 1, for every host kind (`@todo/app/v1`): the part of Chord its plugins use, `defineFacet`
|
||||||
// typed with the app's principals, the app's host kind (a list, one worker each) and its contract for a list's todos.
|
// typed with the app's principals, and the app's host kind (a list, one worker each). Plugins import this module, never
|
||||||
// Plugins import this module, never Chord: their builds leave it out and each process passes its own instance in.
|
// Chord: their builds leave it out and each process passes its own instance in.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
arr,
|
|
||||||
bool,
|
|
||||||
defineFacet as chordFacet,
|
defineFacet as chordFacet,
|
||||||
type FacetHost,
|
type FacetHost,
|
||||||
type FacetSelf,
|
type FacetSelf,
|
||||||
hostKind,
|
hostKind,
|
||||||
int,
|
|
||||||
method,
|
|
||||||
num,
|
|
||||||
obj,
|
|
||||||
remote,
|
|
||||||
type Static,
|
|
||||||
str,
|
str,
|
||||||
} from "../../../../../../src/index.ts";
|
} from "../../../../../../src/index.ts";
|
||||||
import type { TodoPrincipal } from "../../principals.ts";
|
import type { TodoPrincipal } from "../../principals.ts";
|
||||||
|
|
@ -26,13 +18,17 @@ export {
|
||||||
type Ctx,
|
type Ctx,
|
||||||
defineTable,
|
defineTable,
|
||||||
eq,
|
eq,
|
||||||
|
forbidden,
|
||||||
int,
|
int,
|
||||||
|
invalid,
|
||||||
local,
|
local,
|
||||||
method,
|
method,
|
||||||
num,
|
num,
|
||||||
obj,
|
obj,
|
||||||
PluginError,
|
PluginError,
|
||||||
query,
|
query,
|
||||||
|
remote,
|
||||||
|
type Static,
|
||||||
str,
|
str,
|
||||||
type Theme,
|
type Theme,
|
||||||
Themes,
|
Themes,
|
||||||
|
|
@ -46,29 +42,3 @@ export function defineFacet(setup: (host: FacetHost<TodoPrincipal>, self: FacetS
|
||||||
|
|
||||||
/** A todo list: a worker process of its own, with its own store. */
|
/** A todo list: a worker process of its own, with its own store. */
|
||||||
export const List = hostKind("list", { id: str() });
|
export const List = hostKind("list", { id: str() });
|
||||||
|
|
||||||
/** A todo: its number in the list, its text, whether it is done, who added it and when. */
|
|
||||||
export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
|
|
||||||
|
|
||||||
/** A todo (see `Item`). */
|
|
||||||
export type Item = Static<typeof Item>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A list's todos as one principal sees them, on the list's worker: the newest 50 (live), adding one and ticking one
|
|
||||||
* off (or on again); the owner also clears the done ones.
|
|
||||||
*/
|
|
||||||
export const Todos = remote("todo.todos", {
|
|
||||||
version: 1,
|
|
||||||
params: { list: str() },
|
|
||||||
hostAddress: ({ list }) => List({ id: list }),
|
|
||||||
variants: {
|
|
||||||
member: {
|
|
||||||
state: { items: arr(Item) },
|
|
||||||
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })) },
|
|
||||||
},
|
|
||||||
owner: {
|
|
||||||
state: { items: arr(Item) },
|
|
||||||
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })), clear: method() },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { defineRegistry } from "../../../../../../src/index.ts";
|
import { defineRegistry } from "../../../../../../src/index.ts";
|
||||||
|
|
||||||
export { useHold, useWatch } from "../../../../../../src/react/index.ts";
|
export { useHold, usePageState, useWatch } from "../../../../../../src/react/index.ts";
|
||||||
|
|
||||||
/** A panel of the page: its title and its React component. */
|
/** A panel of the page: its title and its React component. */
|
||||||
export type Panel = { readonly title: string; readonly Component: ComponentType };
|
export type Panel = { readonly title: string; readonly Component: ComponentType };
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
// terminal modules, a core facet serving the `Screens` registry, and a redraw on every change. The screens are opened in
|
// terminal modules, a core facet serving the `Screens` registry, and a redraw on every change. The screens are opened in
|
||||||
// a scope of their own, opened again when a reload changes them; `q` quits.
|
// a scope of their own, opened again when a reload changes them; `q` quits.
|
||||||
|
|
||||||
|
import * as v1 from "@todo/app/v1";
|
||||||
|
import * as v1tui from "@todo/app/v1/tui";
|
||||||
import { defineFacet } from "../../../../../src/index.ts";
|
import { defineFacet } from "../../../../../src/index.ts";
|
||||||
import { startTerminalRuntime, TuiScope } from "../../../../../src/tui/index.ts";
|
import { startTerminalRuntime, TuiScope } from "../../../../../src/tui/index.ts";
|
||||||
import * as v1 from "../api/v1/index.ts";
|
|
||||||
import * as v1tui from "../api/v1/tui.ts";
|
|
||||||
|
|
||||||
/** The terminal's own facet: it serves `Screens`, composing the contributions in plugin-name order. */
|
/** The terminal's own facet: it serves `Screens`, composing the contributions in plugin-name order. */
|
||||||
const core = defineFacet((host) => host.serve(v1tui.Screens, { compose: (table) => table.map(({ value }) => value) }));
|
const core = defineFacet((host) => host.serve(v1tui.Screens, { compose: (table) => table.map(({ value }) => value) }));
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// The todo app's web entry, the base bundle the server builds at start: `startPage` with the app's web modules and
|
// The todo app's web entry, the base bundle the server builds at start: `startPage` with the app's web modules and
|
||||||
// React, a core facet serving the `Panels` registry, and a page showing every panel the plugins contribute.
|
// React, a core facet serving the `Panels` registry, and a page showing every panel the plugins contribute.
|
||||||
|
|
||||||
|
import * as v1 from "@todo/app/v1";
|
||||||
|
import * as v1web from "@todo/app/v1/web";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { createElement as h } from "react";
|
import { createElement as h } from "react";
|
||||||
import * as jsx from "react/jsx-runtime";
|
import * as jsx from "react/jsx-runtime";
|
||||||
|
|
@ -8,8 +10,6 @@ import { createRoot } from "react-dom/client";
|
||||||
import { defineFacet } from "../../../../../src/index.ts";
|
import { defineFacet } from "../../../../../src/index.ts";
|
||||||
import { HostProvider, useRegistry } from "../../../../../src/react/index.ts";
|
import { HostProvider, useRegistry } from "../../../../../src/react/index.ts";
|
||||||
import { startPage } from "../../../../../src/web/index.ts";
|
import { startPage } from "../../../../../src/web/index.ts";
|
||||||
import * as v1 from "../api/v1/index.ts";
|
|
||||||
import * as v1web from "../api/v1/web.ts";
|
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
/** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */
|
/** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@ import { cp, mkdtemp } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import * as v1 from "@todo/app/v1";
|
||||||
|
import * as v1server from "@todo/app/v1/server";
|
||||||
import type { Context } from "../../../src/context/index.ts";
|
import type { Context } from "../../../src/context/index.ts";
|
||||||
import { type App, startServer } from "../../../src/node/app/index.ts";
|
import { type App, startServer } from "../../../src/node/app/index.ts";
|
||||||
import { type PageAssets, pageScripts } from "../../../src/node/surfaces/index.ts";
|
import { type PageAssets, pageScripts } from "../../../src/node/surfaces/index.ts";
|
||||||
import * as v1 from "./src/api/v1/index.ts";
|
|
||||||
import * as v1server from "./src/api/v1/server.ts";
|
|
||||||
import type { TodoPrincipal } from "./src/principals.ts";
|
import type { TodoPrincipal } from "./src/principals.ts";
|
||||||
|
|
||||||
/** The app's API: its name, its versions (newest first), and this process's instances of its modules. */
|
/** The app's API: its name, its versions (newest first), and this process's instances of its modules. */
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// The web entry of the browser tests (the base bundle): `startPage` with the tests' API modules and React, a core facet
|
// The web entry of the browser tests (the base bundle): `startPage` with the tests' API modules and React, a core facet
|
||||||
// serving `Panels` and the counter, and a React app: the panels (`useRegistry`), a draft input (component state an
|
// serving `Panels` and the counter, and a React app: the panels (`useRegistry`), a draft input (component state an
|
||||||
// in-place reload keeps), and a counter held while shown (`useHold`, `useWatch`, `useRefState`).
|
// in-place reload keeps), a counter held while shown (`useHold`, `useWatch`, `useRefState`), and a note in page state
|
||||||
|
// (`usePageState`) whose component the "remount" button replaces by another function, as a plugin reload does.
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { createElement as h, useState } from "react";
|
import { createElement as h, useState } from "react";
|
||||||
|
|
@ -8,7 +9,14 @@ import * as jsx from "react/jsx-runtime";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts";
|
import { BACKGROUND_CONTEXT } from "../../../../src/context/index.ts";
|
||||||
import * as chord from "../../../../src/index.ts";
|
import * as chord from "../../../../src/index.ts";
|
||||||
import { HostProvider, useHold, useRefState, useRegistry, useWatch } from "../../../../src/react/index.ts";
|
import {
|
||||||
|
HostProvider,
|
||||||
|
useHold,
|
||||||
|
usePageState,
|
||||||
|
useRefState,
|
||||||
|
useRegistry,
|
||||||
|
useWatch,
|
||||||
|
} from "../../../../src/react/index.ts";
|
||||||
import { startPage } from "../../../../src/web/index.ts";
|
import { startPage } from "../../../../src/web/index.ts";
|
||||||
import * as webApi from "./api.ts";
|
import * as webApi from "./api.ts";
|
||||||
|
|
||||||
|
|
@ -49,10 +57,25 @@ function Counter() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An input of the page state at `key`. */
|
||||||
|
function noteInput(key: string) {
|
||||||
|
const [note, setNote] = usePageState(key, "");
|
||||||
|
return h("input", {
|
||||||
|
id: "note",
|
||||||
|
"data-key": key,
|
||||||
|
value: note,
|
||||||
|
onChange: (event: { target: { value: string } }) => setNote(event.target.value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The note's component and the components replacing it: each a new function to React, as a reloaded plugin's. */
|
||||||
|
const NOTES = [() => noteInput("note"), () => noteInput("note"), () => noteInput("other")];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const panels = useRegistry(webApi.Panels) ?? [];
|
const panels = useRegistry(webApi.Panels) ?? [];
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [shown, setShown] = useState(true);
|
const [shown, setShown] = useState(true);
|
||||||
|
const [note, setNote] = useState(0);
|
||||||
return h(
|
return h(
|
||||||
"div",
|
"div",
|
||||||
null,
|
null,
|
||||||
|
|
@ -64,6 +87,8 @@ function App() {
|
||||||
}),
|
}),
|
||||||
h("button", { type: "button", id: "toggle", onClick: () => setShown((value) => !value) }, "toggle"),
|
h("button", { type: "button", id: "toggle", onClick: () => setShown((value) => !value) }, "toggle"),
|
||||||
shown ? h(Counter) : null,
|
shown ? h(Counter) : null,
|
||||||
|
h("button", { type: "button", id: "remount", onClick: () => setNote((value) => value + 1) }, "remount"),
|
||||||
|
h(NOTES[note % NOTES.length]!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Tests of the React bindings (slice 11 §3) in a real browser (Chromium; skipped without one), on the fixture's page:
|
// Tests of the React bindings (slice 11 §3) in a real browser (Chromium; skipped without one), on the fixture's page:
|
||||||
// `useHold` holds while mounted and releases on unmount, `useWatch` re-renders on change, `useRefState` follows the
|
// `useHold` holds while mounted and releases on unmount, `useWatch` re-renders on change, `useRefState` follows the
|
||||||
// reference's state, `useRegistry` the registry (the panels).
|
// reference's state, `useRegistry` the registry (the panels), `usePageState` keeps a value across a remount by another
|
||||||
|
// component function and starts a new key fresh.
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||||
|
|
@ -42,4 +43,29 @@ describe.skipIf(CHROMIUM === undefined)("React bindings", () => {
|
||||||
await app.close(context);
|
await app.close(context);
|
||||||
}
|
}
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
|
it("keeps page state across a remount by a new function, a new key starting fresh", async () => {
|
||||||
|
const { app } = await webApp(await versionedEntry("1"), await freePort());
|
||||||
|
try {
|
||||||
|
await signIn(browser, app.ownerLink());
|
||||||
|
await browser.evaluate('document.getElementById("note").focus()');
|
||||||
|
await browser.type("oat mi");
|
||||||
|
await browser.wait('document.getElementById("note").value === "oat mi"');
|
||||||
|
await browser.evaluate('void (globalThis.mounted = document.getElementById("note"))');
|
||||||
|
// A new component function at the same place: React mounts it anew (a new element), the key keeps the value.
|
||||||
|
await browser.evaluate('document.getElementById("remount").click()');
|
||||||
|
await browser.wait('document.getElementById("note") !== globalThis.mounted');
|
||||||
|
expect(await browser.evaluate<string>('document.getElementById("note").value')).toBe("oat mi");
|
||||||
|
// Another key starts at its initial value.
|
||||||
|
await browser.evaluate('document.getElementById("remount").click()');
|
||||||
|
await browser.wait('document.getElementById("note").dataset.key === "other"');
|
||||||
|
expect(await browser.evaluate<string>('document.getElementById("note").value')).toBe("");
|
||||||
|
// Back to the first key: still kept.
|
||||||
|
await browser.evaluate('document.getElementById("remount").click()');
|
||||||
|
await browser.wait('document.getElementById("note").dataset.key === "note"');
|
||||||
|
expect(await browser.evaluate<string>('document.getElementById("note").value')).toBe("oat mi");
|
||||||
|
} finally {
|
||||||
|
await app.close(context);
|
||||||
|
}
|
||||||
|
}, 120_000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
72
packages/chord/test/surfaces/page-state.test.ts
Normal file
72
packages/chord/test/surfaces/page-state.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// Tests of page state (decision "Tutorial gaps" (1)): a client host's values by key, kept for the host's life, whatever
|
||||||
|
// asks for them (a remounted component, a new scope); a new key starts at its initial value; sets call the subscribers;
|
||||||
|
// the store goes with its host.
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
|
||||||
|
import { createHost } from "../../src/index.ts";
|
||||||
|
import { pageState } from "../../src/surfaces/page-state.ts";
|
||||||
|
import { expectCollected } from "../support.ts";
|
||||||
|
|
||||||
|
/** A client host with nothing to load. */
|
||||||
|
async function client() {
|
||||||
|
const host = createHost({ onError: () => {} });
|
||||||
|
await host.load([], context);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("a host's page state by key", () => {
|
||||||
|
it("keeps a key's value for whoever asks next, its own initial value ignored", async () => {
|
||||||
|
const host = await client();
|
||||||
|
pageState(host, "draft", "").set("oat mi");
|
||||||
|
expect(pageState(host, "draft", "").read()).toBe("oat mi");
|
||||||
|
await host.close(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a new key, or the same key on another host, at its initial value", async () => {
|
||||||
|
const host = await client();
|
||||||
|
const other = await client();
|
||||||
|
pageState(host, "draft", "").set("oat mi");
|
||||||
|
expect(pageState(host, "scroll", 0).read()).toBe(0);
|
||||||
|
expect(pageState(other, "draft", "").read()).toBe("");
|
||||||
|
await host.close(context);
|
||||||
|
await other.close(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds any value as it was set, never copied", async () => {
|
||||||
|
const host = await client();
|
||||||
|
const selection = { start: 2, end: 4, element: () => null };
|
||||||
|
pageState<typeof selection | undefined>(host, "selection", undefined).set(selection);
|
||||||
|
expect(pageState(host, "selection", undefined).read()).toBe(selection);
|
||||||
|
await host.close(context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("following a key", () => {
|
||||||
|
it("calls every subscriber after a set that changed the value, until it unsubscribes", async () => {
|
||||||
|
const host = await client();
|
||||||
|
const state = pageState(host, "draft", "");
|
||||||
|
const calls: string[] = [];
|
||||||
|
const first = state.subscribe(() => calls.push(`first ${state.read()}`));
|
||||||
|
state.subscribe(() => calls.push(`second ${state.read()}`));
|
||||||
|
state.set("a");
|
||||||
|
state.set("a");
|
||||||
|
first();
|
||||||
|
state.set("b");
|
||||||
|
expect(calls).toEqual(["first a", "second a", "second b"]);
|
||||||
|
await host.close(context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the store's memory", () => {
|
||||||
|
it("is collected with its host", async () => {
|
||||||
|
const [host, value] = await (async () => {
|
||||||
|
const created = await client();
|
||||||
|
const kept = { text: "oat mi" };
|
||||||
|
pageState(created, "draft", kept);
|
||||||
|
await created.close(context);
|
||||||
|
return [new WeakRef(created), new WeakRef(kept)];
|
||||||
|
})();
|
||||||
|
await expectCollected(host, value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Tests of `TuiScope` (slice 11 §4, decision "Slice 11, P9"): references held for a component, watchables and
|
// Tests of `TuiScope` (slice 11 §4, decision "Slice 11, P9"): references held for a component, watchables and
|
||||||
// registries followed, one `changed` callback for every change, and everything released at `dispose`.
|
// registries followed, one `changed` callback for every change, everything released at `dispose`, and the host's page
|
||||||
|
// state shared by every scope (decision "Tutorial gaps" (1)).
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
|
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
|
||||||
|
|
@ -78,3 +79,33 @@ describe("a component's scope", () => {
|
||||||
await h.close(context);
|
await h.close(context);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a scope's page state", () => {
|
||||||
|
it("is the host's: a scope opened after another was disposed (a reload) finds its value, a new key starts fresh", async () => {
|
||||||
|
const h = await host();
|
||||||
|
const before = new TuiScope(h, () => {});
|
||||||
|
before.pageState("draft", "").set("oat mi");
|
||||||
|
before.dispose();
|
||||||
|
const after = new TuiScope(h, () => {});
|
||||||
|
expect(after.pageState("draft", "").read()).toBe("oat mi");
|
||||||
|
expect(after.pageState("scroll", 0).read()).toBe(0);
|
||||||
|
after.dispose();
|
||||||
|
await h.close(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls changed on each open scope following the key, and never after dispose", async () => {
|
||||||
|
const h = await host();
|
||||||
|
const calls: string[] = [];
|
||||||
|
const redraw = (name: string) => () => calls.push(name);
|
||||||
|
const first = new TuiScope(h, redraw("first"));
|
||||||
|
const second = new TuiScope(h, redraw("second"));
|
||||||
|
const draft = first.pageState("draft", "");
|
||||||
|
second.pageState("draft", "");
|
||||||
|
draft.set("a");
|
||||||
|
first.dispose();
|
||||||
|
draft.set("b");
|
||||||
|
expect(calls).toEqual(["first", "second", "second"]);
|
||||||
|
second.dispose();
|
||||||
|
await h.close(context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,6 @@
|
||||||
"@notes/app/*": ["./packages/chord/test/examples/53-app/app/*.ts"],
|
"@notes/app/*": ["./packages/chord/test/examples/53-app/app/*.ts"],
|
||||||
"@board/app/*": ["./packages/chord/test/examples/56-web/app/*.ts"],
|
"@board/app/*": ["./packages/chord/test/examples/56-web/app/*.ts"],
|
||||||
"@chat/app/*": ["./packages/chord/test/examples/59-chat/app/*.ts"],
|
"@chat/app/*": ["./packages/chord/test/examples/59-chat/app/*.ts"],
|
||||||
"@todo/app/*": [
|
|
||||||
"./packages/chord/test/examples/61-tutorial/src/api/*.ts",
|
|
||||||
"./packages/chord/test/examples/61-tutorial/src/api/*/index.ts"
|
|
||||||
],
|
|
||||||
"@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"],
|
"@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"],
|
||||||
"@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"],
|
"@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"],
|
||||||
"@earendil-works/pi-telemetry/render": ["./packages/telemetry/src/render.ts"],
|
"@earendil-works/pi-telemetry/render": ["./packages/telemetry/src/render.ts"],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue