mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(chord): Chord v2 is the package's root
src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins, /plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch; Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/, test/old, docs/old). Durable and env import the foundations from their subpaths.
This commit is contained in:
parent
0a4fad5489
commit
e9c0ff4d84
966 changed files with 2968 additions and 2804 deletions
|
|
@ -2,11 +2,16 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Chord v2 is the package's root: `@earendil-works/chord` exports v2 (was `@earendil-works/chord/next`). Chord v1 moved unchanged to `@earendil-works/chord/old` (was the root) and `@earendil-works/chord/old/node` (was `/node`); it stays until p2 runs on v2. The foundations v1's root re-exported have their own subpaths: `@earendil-works/chord/json` (`JsonValue`, `JsonRepresentation`, `copyJson`, `isJsonValue`) and `@earendil-works/chord/watch` (`Watch`, `NativeWatch` and the watch types), beside `/context` and `/delta`. Migration: imports of `@earendil-works/chord/next` drop `/next`; v1's imports of `@earendil-works/chord` become `@earendil-works/chord/old`, and `/node` becomes `/old/node`; imports of `Context`, `Draft`, `JsonValue`, `copyJson` or the watch types from the root move to `/context`, `/delta`, `/json` and `/watch`. In the repository, v2's `src/next` is now `src`, `src/next-node` is `src/node`, `src/next-web`, `src/next-react` and `src/next-tui` are `src/web`, `src/react` and `src/tui`, v1 is in `src/old`, and tests and docs moved likewise (`test/next` to `test`, `docs/next` to `docs`, v1's to `test/old` and `docs/old`).
|
||||
|
||||
### Added
|
||||
|
||||
- Chord v2's plugins component gains the plugin model (slice 12): `createPlugins({ api: { name, versions } })` checks every import of the app's versioned API (`@acme/app/v3`, per-kind modules such as `@acme/app/v3/web`): an unlisted version, another kind's module, two versions in one facet and a direct import of Chord are build errors naming the file and line. Roots may be `{ path, required }`: a required root's plugin failing at `load` rejects it with a `PluginError` after the load settled. Files named `<name>.<kind>.<ext>` (`contract.web.tsx`) are of that kind, refused in facets of another kind with the chain of imports named. Single-file plugins (`hello.server.ts` at a root's top, grouped by name, host packages only). `installPlugin({ git, ref } | { npm, version }, root, ctx)` installs plugins from git (a shallow clone of a branch, tag or commit; a repository of several plugins too) or npm; a package lists the plugin folders it provides in its `package.json` (`"chord": { "plugins": ["./notes", "plugins/*", "!plugins/draft"] }`), used by discovery and `installPlugin` (without it: one plugin when the package's top has facet files, else its subfolders with a `package.json`). `testPlugin(dir, { kind, modules, stubs, principal }, ctx)` builds a plugin as production does and loads it on an in-memory host beside stub facets, collecting its contributions to registries nobody serves and listing the contracts it waits for. Agents' guide: `docs/next/plugin-authoring.md`.
|
||||
- Chord v2 gains the app kit (slice 13, `src/next-node/app`, to be exported as `@earendil-works/chord/next/app`): `startServer<P>({ name, dataDir?, root?, listen?, api, kinds, principal, workers?, files?, install?, onError }, ctx)` lays out the data directory (`server/store.sqlite`, `workers/<kind>/<address>/`, `plugins/`, `logs/`, `owner-token`; one server per data directory), creates the server's store and host, HTTP, one workers component per worker kind and the plugins component over the app's required root and the user's root, and loads the server's plugins with the kit's objects; `startWorker({ api, kinds, busy?, onError })` is a worker's entry (its directory and store, the link to the server first, its plugins). The start-data objects `AppInfo` and `WorkerInfo` serve process values to plugins. Sessions (`Sessions`: `issue`, `verify`, `revoke`, `list`; tokens kept as SHA-256 hashes, compared in constant time; persisted ones end after 400 days unused and their browser cookie is renewed daily while used, ephemeral ones live in memory, at most `limits.ephemeralSessions`) authenticate a signed `HttpOnly`, `SameSite=Strict` cookie or `Authorization: Bearer`; a one-time owner link per start (`app.ownerLink()`, the token in the URL's fragment, single use, 10 minutes), an owner token file (mode 0600, written atomically) and device pairing (`POST /_chord/pair`, owner approval, a poll returning the token once; bounded in all and per client address) sign the owner in. The data directory, its stores and secret files are made private (0700, 0600) at every start; the home directory, a file system's root and another user's directory are refused. `Files` per host: small and chunked writes at a stored offset, ranged reads, a watchable index, caps, at most `limits.uploads` uploads under way. `app.exportData(address, path, ctx)` and `app.importData(archive, address, ctx)` move the data a worker created (its store and files) as a tar without stopping anything: a running worker writes its own store at one consistent point, else the server reads it under the worker's process lock (import refusing an existing address, another app's archive, every path leaving the target, and archives past `limits.importBytes` or `limits.importEntries`). `app.applyChanges(ctx)` and `app.plugins.install(source, ctx)`. Optional plugins as exported facets: `defineUsers(profile)`, `inviteFacet()`, `ephemeralLoginFacet()`.
|
||||
- Chord v2 gains web and terminal surfaces (slice 11): `webSurface` and `tuiSurface` (`src/next-node/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` (`src/next-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 (`src/next-react`): `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState` (React is an optional peer dependency). The terminal: `startTerminal` (`src/next-node/tui`) 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` (`src/next-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 Node components and client surfaces are exported: `@earendil-works/chord/http`, `/workers`, `/plugins`, `/plugins/runtime` and `/app`; `/surfaces` (the web and terminal surfaces' server side), `/web`, `/react`, `/tui` and `/tui/launcher`.
|
||||
- Chord v2's plugins component gains the plugin model (slice 12): `createPlugins({ api: { name, versions } })` checks every import of the app's versioned API (`@acme/app/v3`, per-kind modules such as `@acme/app/v3/web`): an unlisted version, another kind's module, two versions in one facet and a direct import of Chord are build errors naming the file and line. Roots may be `{ path, required }`: a required root's plugin failing at `load` rejects it with a `PluginError` after the load settled. Files named `<name>.<kind>.<ext>` (`contract.web.tsx`) are of that kind, refused in facets of another kind with the chain of imports named. Single-file plugins (`hello.server.ts` at a root's top, grouped by name, host packages only). `installPlugin({ git, ref } | { npm, version }, root, ctx)` installs plugins from git (a shallow clone of a branch, tag or commit; a repository of several plugins too) or npm; a package lists the plugin folders it provides in its `package.json` (`"chord": { "plugins": ["./notes", "plugins/*", "!plugins/draft"] }`), used by discovery and `installPlugin` (without it: one plugin when the package's top has facet files, else its subfolders with a `package.json`). `testPlugin(dir, { kind, modules, stubs, principal }, ctx)` builds a plugin as production does and loads it on an in-memory host beside stub facets, collecting its contributions to registries nobody serves and listing the contracts it waits for. Agents' guide: `docs/plugin-authoring.md`.
|
||||
- Chord v2 gains the app kit (slice 13, `@earendil-works/chord/app`): `startServer<P>({ name, dataDir?, root?, listen?, api, kinds, principal, workers?, files?, install?, onError }, ctx)` lays out the data directory (`server/store.sqlite`, `workers/<kind>/<address>/`, `plugins/`, `logs/`, `owner-token`; one server per data directory), creates the server's store and host, HTTP, one workers component per worker kind and the plugins component over the app's required root and the user's root, and loads the server's plugins with the kit's objects; `startWorker({ api, kinds, busy?, onError })` is a worker's entry (its directory and store, the link to the server first, its plugins). The start-data objects `AppInfo` and `WorkerInfo` serve process values to plugins. Sessions (`Sessions`: `issue`, `verify`, `revoke`, `list`; tokens kept as SHA-256 hashes, compared in constant time; persisted ones end after 400 days unused and their browser cookie is renewed daily while used, ephemeral ones live in memory, at most `limits.ephemeralSessions`) authenticate a signed `HttpOnly`, `SameSite=Strict` cookie or `Authorization: Bearer`; a one-time owner link per start (`app.ownerLink()`, the token in the URL's fragment, single use, 10 minutes), an owner token file (mode 0600, written atomically) and device pairing (`POST /_chord/pair`, owner approval, a poll returning the token once; bounded in all and per client address) sign the owner in. The data directory, its stores and secret files are made private (0700, 0600) at every start; the home directory, a file system's root and another user's directory are refused. `Files` per host: small and chunked writes at a stored offset, ranged reads, a watchable index, caps, at most `limits.uploads` uploads under way. `app.exportData(address, path, ctx)` and `app.importData(archive, address, ctx)` move the data a worker created (its store and files) as a tar without stopping anything: a running worker writes its own store at one consistent point, else the server reads it under the worker's process lock (import refusing an existing address, another app's archive, every path leaving the target, and archives past `limits.importBytes` or `limits.importEntries`). `app.applyChanges(ctx)` and `app.plugins.install(source, ctx)`. Optional plugins as exported facets: `defineUsers(profile)`, `inviteFacet()`, `ephemeralLoginFacet()`.
|
||||
- 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 HTTP component gains `readSignedCookie(request, name, secret)`: `readCookie`'s value with its signed expiry, for renewing a cookie before it expires.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,74 +1,83 @@
|
|||
# @earendil-works/chord
|
||||
|
||||
Chord builds applications out of **objects** that live in several processes (a server, workers, browser tabs) and can be **hot reloaded** while they run.
|
||||
Chord builds applications out of **objects** that live in several processes (a server, workers, browser tabs) and keep running while their plugins are **reloaded**.
|
||||
|
||||
- An **object** is state plus code at an address. It is created on demand and lives while someone references it.
|
||||
- Everyone gets a **reference**, never the object. References work the same locally and across processes, and survive reloads.
|
||||
- Every object knows its **principal**: an owner and a guest get different objects, with different methods.
|
||||
- **State** is observable JSON. Remote viewers get a snapshot followed by deltas.
|
||||
- **Hot reload** swaps code while it runs. Work in flight finishes on the code it started with, state is kept, and incompatible changes migrate at a safe point.
|
||||
- An **object type** is a contract: a name, params that select the instance, the host it lives on, its members (state and methods) and a version. Contracts are runtime values described with JSON Schema, written with the builders of `@earendil-works/pi-schema`.
|
||||
- A **facet** is a plugin's declarations for one host: the types it serves and its contributions to registries. A factory only declares; Chord activates.
|
||||
- Everyone gets a **reference**, never the object. A reference is live and hydrated once its `get` resolves, works the same locally and across links, and keeps its object alive.
|
||||
- **Documents** are named JSON values an object owns, written in transactions and persisted in Chord's store; **derives** compute values from watchables; **jobs** are owned work that outlives the call that started it.
|
||||
- Every call has a **principal**: links stamp it, the app types it, and objects are kept per principal key.
|
||||
- A **reload** swaps a plugin's code inside the running app, rolled out host by host: hosts keep running, data, connections and running work survive where they can.
|
||||
|
||||
Contracts are runtime values described with JSON Schema, written with the builders of `@earendil-works/pi-schema` (any JSON Schema object works; `format` is an annotation, never checked). Hosts exchange JSON frames over links: in memory, a Node IPC channel, or a WebSocket.
|
||||
The design, the rule book for all of this, is [docs/design.md](docs/design.md). Chord v1, which p2 still runs on, stays available as `@earendil-works/chord/old` until p2 moves to v2 ([docs/old](docs/old/README.md)).
|
||||
|
||||
## Entry points
|
||||
|
||||
| Import | Contents | Runs in |
|
||||
|--------|----------|---------|
|
||||
| `@earendil-works/chord` | Contracts (`local`, `remote`, `method`, `opaque`), declarations (through the factory's `self`: `state`, `derive`, `resource`, `on`, `task`), hosts (`createHost`, `defineFacet`, `defineRegistry`), links (`memoryLinkPair`, `ipcLink`, `webSocketLink`, `acceptWebSockets`), errors (`ChordError`, `isChordError`, `forbidden`, `invalid`). Also the shared base: `Context`, `JsonValue`, `JsonRepresentation`, `Draft`, `copyJson`, `isJsonValue`, `replicatedState` with its types, and the watch types `WatchHandle`, `NativeWatchHandle`, `WatchCall`, `NativeWatchCall`, and `WatchEnd` with their implementations `Watch` and `NativeWatch`. | Node and browsers |
|
||||
| `@earendil-works/chord/node` | Child host processes: `createHostManager`, `forkDriver`, `childControl`, `processLock`; the facet module loader `nodeLoader`. | Node |
|
||||
| `@earendil-works/chord` | Contracts (`local`, `remote`, `method`, the schema builders), errors (`ChordError`, `isChordError`), hosts and facets (`createHost`, `defineFacet`), documents (`defineState`), derives and natives (`constant`, `mapShared`), routines and jobs (`defineJob`), links (`memoryLinkPair`, `ipcLink`, `webSocketLink`), registries (`defineRegistry`), the store (`openStore`, `defineTable`, queries, export and import), and the client surfaces' shared part (`Themes`, `ThemeChoice`, `WebScripts`, `TuiScripts`). | Node and browsers |
|
||||
| `@earendil-works/chord/http` | HTTP: a Web-standard `fetch` handler (`createHttp`) built from the routes, wraps and sockets plugins contribute to the registry `Http`, authenticators (`Authenticators`), responses as descriptions, a security baseline on by default, Chord's Node listener and `nodeRequest`, the links endpoint. | Node |
|
||||
| `@earendil-works/chord/workers` | Workers: `createWorkers` runs a forked worker per address of a host kind, started on demand and stopped when idle; `workerMain` is a worker's entry; `processLock`. | Node |
|
||||
| `@earendil-works/chord/plugins` | Plugins: discovery, builds with esbuild (`buildFacet`), evaluation, the watcher, rollouts, `createPlugins`, `installPlugin`, `testPlugin`, `pluginFile`. | Node |
|
||||
| `@earendil-works/chord/plugins/runtime` | `createPluginRuntime`: loads built plugins without esbuild (a worker's side). | 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/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/tui` | The terminal surface's sandbox side: `startTerminalRuntime` in the app's terminal entry, `BridgeTerminal`, `TerminalTheme`, `TuiScope`. | Node (sandboxed) |
|
||||
| `@earendil-works/chord/tui/launcher` | The terminal launcher, the trusted side of a terminal client: `startTerminal`, `requireSandboxNode`. | Node |
|
||||
| `@earendil-works/chord/context` | Explicit cancellation, telemetry, and invocation values: `BACKGROUND_CONTEXT`, `withCancel`, `withAbortSignal`, `withoutAbortSignal`, `withTelemetry`, `traceIn`, `withContextValue`, `awaitWithContext`. | Node and browsers |
|
||||
| `@earendil-works/chord/delta` | The transactional JSON tracker, structural diff, immutable apply, and the wire encoder/decoder for operation batches. | Node and browsers |
|
||||
| `@earendil-works/chord/json` | `JsonValue`, `JsonRepresentation`, `copyJson`, `isJsonValue`. | Node and browsers |
|
||||
| `@earendil-works/chord/watch` | The watch types `Watchable`, `WatchHandle`, `NativeWatchable`, `NativeWatchHandle`, `WatchCall`, `NativeWatchCall`, and `WatchEnd`, with their implementations `Watch` and `NativeWatch`. | Node and browsers |
|
||||
| `@earendil-works/chord/output` | Bounded output streams and paced progress: `OutputBuffer` (head or tail window of a stream, `OutputLimits`, `OutputSkip`), `boundOutput`, `sanitizeOutput`, `utf8ByteLength`, and `Progress` (adaptive commits of a changing value). | Node and browsers |
|
||||
| `@earendil-works/chord/telemetry-sqlite` | `createSqliteTelemetry(db, { retentionMs?, maxQueuedSpans? })`: buffered recording and backend-independent `TelemetryStore` queries. | Any SQLite adapter |
|
||||
| `@earendil-works/chord/sqlite` | Portable `SqliteDatabase`, `SqliteExecutor`, and `SqliteValue`, atomic `applySqliteMigrations(database, migrations, { table })`, and `validateSqliteSchemaPrefix`. The metadata table must be a valid, non-reserved SQLite identifier; migration versions start at 1 and are contiguous. | Any SQLite adapter |
|
||||
| `@earendil-works/chord/sqlite/node` | `NodeSqliteDatabase`, `openNodeSqliteDatabase`, and `NodeSqliteDatabaseOptions`. | Node |
|
||||
| `@earendil-works/chord/sqlite/cloudflare` | `DurableObjectSqliteDatabase` and the structural `DurableObjectSqliteStorage` binding type. | Cloudflare Durable Objects |
|
||||
| `@earendil-works/chord/old`, `@earendil-works/chord/old/node` | Chord v1, unchanged, kept for p2 until it runs on v2 ([docs/old](docs/old/README.md)). | Node and browsers; `/old/node` Node |
|
||||
|
||||
`esbuild` (for `/plugins` and `/surfaces`), `ws` (for `/http` and `/tui/launcher`) and `react` (for `/react`) are optional peer dependencies, imported only there; `/app` uses all but `react`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { createHost, hostNamed, method, remote } from "@earendil-works/chord";
|
||||
import { createHost, defineFacet, local, method, obj, str, constant } from "@earendil-works/chord";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
|
||||
// contract.ts: shared by provider and consumers
|
||||
export const Counter = remote("demo.counter", {
|
||||
const context = BACKGROUND_CONTEXT; // every call takes a context, used for cancellation
|
||||
|
||||
// A contract: params select the instance (one object per language), members are state and methods.
|
||||
const Greeter = local("example.greeter", {
|
||||
version: 1,
|
||||
input: obj({ name: str() }),
|
||||
host: hostNamed("server"), // where its objects live; callers never name a host
|
||||
variants: {
|
||||
default: {
|
||||
state: { count: num() },
|
||||
methods: { increment: method(obj({ by: num() })) },
|
||||
},
|
||||
},
|
||||
params: { language: str() },
|
||||
members: { language: str(), greet: method(obj({ name: str() }), str()) },
|
||||
});
|
||||
|
||||
const host = createHost({ name: "server", self: { kind: "server" } });
|
||||
|
||||
host.serve(Counter, ({ name }, _principal, self) => {
|
||||
const count = self.state("count", num(), 0);
|
||||
return {
|
||||
variant: "default",
|
||||
count,
|
||||
increment: async ({ by }, ctx) => {
|
||||
count.change(ctx, (n) => n + by);
|
||||
},
|
||||
};
|
||||
// A facet serves types with factories; a factory only declares and returns the implementation.
|
||||
const facet = defineFacet((host) => {
|
||||
host.serve(Greeter, ({ language }) => ({
|
||||
language: constant(language),
|
||||
greet: async ({ name }) => (language === "de" ? `Hallo ${name}` : `Hello ${name}`),
|
||||
}));
|
||||
});
|
||||
|
||||
await host.root(async (ctx) => {
|
||||
const clicks = await ctx.get(Counter, { name: "clicks" }); // ready, with its state; released when this root ends
|
||||
clicks.count.subscribe((n) => console.log("count", n));
|
||||
await clicks.increment({ by: 2 }, ctx); // logs 0, then 2
|
||||
});
|
||||
const host = createHost({ onError: (error) => console.error(error) });
|
||||
await host.load([{ name: "@example/greeter", facet }], context);
|
||||
|
||||
const german = await host.get(Greeter, { language: "de" }, context); // live: state has values, methods can be called
|
||||
console.log(german.language.value, await german.greet({ name: "Mario" }, context)); // de Hallo Mario
|
||||
german.release(); // the last reference released ends the object
|
||||
await host.close(context);
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Tutorial](docs/tutorial.md): a shared todo list, built up section by section. Every example runs as `test/tutorial.test.ts`.
|
||||
- [Usage guide](docs/guide.md): API reference for contracts, objects, state, roots, lifetime, plugins, hot reload, hosts, and links.
|
||||
- [Specification](docs/spec.md): compatibility, lifetime, and reload semantics, plus known limitations and implementation decisions.
|
||||
- [Delta guide](src/delta/README.md): ownership, lifecycle, operation, and replica contracts of `@earendil-works/chord/delta`.
|
||||
- [Design](docs/design.md): the target design, the one current statement of every rule; [decisions](docs/decisions.md): the decision log; [slices](docs/slices.md): the build plan and its carry-over.
|
||||
- [Plugins and hosts](docs/guide-plugins-hosts/book.md): a tutorial building a notes app with plugins, HTTP and workers; [plugin authoring](docs/plugin-authoring.md): the guide for agents writing plugins.
|
||||
- [Examples](test/examples/): runnable references, one topic each. Run from `packages/chord`: `node --conditions=source --experimental-strip-types test/examples/01-objects.ts`.
|
||||
- Each layer's README in `src/*/README.md` and `src/node/*/README.md`; the [delta guide](src/delta/README.md): ownership, lifecycle, operation, and replica contracts of `@earendil-works/chord/delta`.
|
||||
- [Chord v1](docs/old/README.md) (`@earendil-works/chord/old`): its tutorial, usage guide and specification.
|
||||
|
||||
## Explicit Context telemetry
|
||||
|
||||
|
|
@ -155,55 +164,6 @@ the draft changes. Published values are not frozen. In-process consumers may sha
|
|||
their containers with the provider; mutating a consumed value violates the contract
|
||||
and can corrupt authority. Clone or serialize at any mutable trust boundary.
|
||||
|
||||
Replicated state provides the same model through a callback. `replicatedState` is a
|
||||
standalone observable value, independent of hosts:
|
||||
|
||||
```ts
|
||||
import { replicatedState } from "@earendil-works/chord";
|
||||
|
||||
const initial = { output: "", count: 0 };
|
||||
const status = replicatedState(initial); // transfers ownership of initial
|
||||
status.change(context, (draft) => {
|
||||
draft.output += "done\n";
|
||||
draft.count += 1;
|
||||
});
|
||||
```
|
||||
|
||||
A successful `change()` publishes exactly one atomic revision. If its callback
|
||||
throws, the original value and sequence remain unchanged. Draft references become
|
||||
unusable when the callback returns. The tracker emits string append and
|
||||
front-truncate operations, array splices and permutations, sets, and deletes; large
|
||||
edit sets may fold into a complete replacement.
|
||||
|
||||
`replicatedState(source, { onError })` instead attaches a publication-only state to
|
||||
an authoritative revision source (`ReplicatedStateSource`): it captures a snapshot,
|
||||
then publishes every committed frame in cursor order.
|
||||
|
||||
### Public state subscriptions
|
||||
|
||||
`state.subscribe(async (value, context, delivery) => { ... })` serializes callbacks
|
||||
independently for each subscription. Hydration starts immediately when a value is
|
||||
available, and its returned promise must settle before updates start. Synchronous
|
||||
callbacks still run synchronously. Read the captured `value` inside an asynchronous
|
||||
callback: `state.value` may already refer to a later revision.
|
||||
|
||||
Each subscription retains at most 100 pending complete-value deliveries, excluding
|
||||
the running callback. On overflow, only the newest pending value, context, and
|
||||
delivery metadata are retained; a not-yet-started initial hydration is preserved.
|
||||
Thus public delivery sequences may skip. This is a frame-count policy, not a byte
|
||||
limit.
|
||||
|
||||
The returned unsubscribe function immediately discards pending work and prevents
|
||||
new callbacks. It neither aborts nor waits for the running callback. Synchronous
|
||||
throws and promise rejections are observed, reported, and do not stop other
|
||||
subscriptions or subsequent callbacks. Attached sources use their `onError`
|
||||
handler; mutable states (and attached sources without a handler) report failures
|
||||
by throwing in a microtask. Unsubscribing does not hide a later rejection from the
|
||||
running callback.
|
||||
|
||||
Cells of Chord objects and their remote readables have their own subscription
|
||||
model; see [State](docs/guide.md#state) in the usage guide.
|
||||
|
||||
### Watches
|
||||
|
||||
`WatchHandle<T>` is the one watch interface, implemented by watch sources such as
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ Light benchmarks of hot paths, run by hand (not in CI), recorded per slice for l
|
|||
|
||||
## Slice 3a: remote objects over one link
|
||||
|
||||
Script: `test/next/bench/links.ts`, run from `packages/chord` with
|
||||
`node --conditions=source --experimental-strip-types --expose-gc test/next/bench/links.ts` (about 15 s). Node 24.4.1,
|
||||
Script: `test/bench/links.ts`, run from `packages/chord` with
|
||||
`node --conditions=source --experimental-strip-types --expose-gc test/bench/links.ts` (about 15 s). Node 24.4.1,
|
||||
Intel Core i9-13900, one process: server and client host on one memory link (`queueMicrotask`), every case once per
|
||||
codec, side by side.
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ Observations:
|
|||
|
||||
## Slice 3b: surviving drops
|
||||
|
||||
Script and machine as for slice 3a (`test/next/bench/links.ts` now runs the 3b cases after the 3a ones, about 37 s).
|
||||
Script and machine as for slice 3a (`test/bench/links.ts` now runs the 3b cases after the 3a ones, about 37 s).
|
||||
|
||||
New cases, both on the 1,000-item notes list view (133.7 KiB snapshot):
|
||||
|
||||
|
|
@ -174,8 +174,8 @@ Observations:
|
|||
|
||||
## Slice 4a: links and relays
|
||||
|
||||
Script: `test/next/bench/relay.ts`, run from `packages/chord` with
|
||||
`node --conditions=source --experimental-strip-types --expose-gc --no-flush-bytecode test/next/bench/relay.ts` (about
|
||||
Script: `test/bench/relay.ts`, run from `packages/chord` with
|
||||
`node --conditions=source --experimental-strip-types --expose-gc --no-flush-bytecode test/bench/relay.ts` (about
|
||||
5 s; the forked workers are the same file run with the argument `worker`). Machine as for slice 3a. Binary codec
|
||||
throughout. The topology is the one of slice 4a's example: a tab (a client host), a server with an address and a
|
||||
route for `Session`, and per session a worker host whose `Store` (a local document) backs a remote `View` placed on
|
||||
|
|
@ -268,8 +268,8 @@ Observations:
|
|||
|
||||
## Slice 4b: authority and housekeeping
|
||||
|
||||
Script: `test/next/bench/compression.ts`, run from `packages/chord` with
|
||||
`node --conditions=source --experimental-strip-types --expose-gc test/next/bench/compression.ts` (about 5 s). Node
|
||||
Script: `test/bench/compression.ts`, run from `packages/chord` with
|
||||
`node --conditions=source --experimental-strip-types --expose-gc test/bench/compression.ts` (about 5 s). Node
|
||||
24.4.1, Intel Core i9-13900, one process.
|
||||
|
||||
Payloads: the 500 KB document of slice 3a (500 entries `{ id, text, author, at }` of 960 characters), but with
|
||||
|
|
@ -317,8 +317,8 @@ Observations:
|
|||
|
||||
## Slice 6a: the store
|
||||
|
||||
`test/next/bench/store.ts` (run by hand: `node --conditions=source --experimental-strip-types --expose-gc
|
||||
test/next/bench/store.ts` from `packages/chord`): Node 24.4.1, `chord/sqlite/node` on a fresh SQLite file per burst
|
||||
`test/bench/store.ts` (run by hand: `node --conditions=source --experimental-strip-types --expose-gc
|
||||
test/bench/store.ts` from `packages/chord`): Node 24.4.1, `chord/sqlite/node` on a fresh SQLite file per burst
|
||||
for both sides (the same pragmas and file system), 7 bursts of 1,000 commits after a warm-up burst; per commit, the
|
||||
median and p95 of single commits and the burst's mean.
|
||||
|
||||
|
|
@ -456,12 +456,12 @@ The forwarded-link gap is not work or retention in 6a's code. It is a V8 decisio
|
|||
- pi a92283056 ("Delta's tracker holds its one open change without a WeakRef") removed the cause: the numbers above
|
||||
it show the forwarded link at HEAD's level with 6-7 MB promoted and 2 mark-compacts on both.
|
||||
|
||||
A retirement under a diamond of derives (`test/next/bench/derives.ts`: 40 derives in 13 layers of two derives joined
|
||||
A retirement under a diamond of derives (`test/bench/derives.ts`: 40 derives in 13 layers of two derives joined
|
||||
by a third, 2^13 paths from top to bottom, the bottom watched; one step retires and recreates the document in one
|
||||
transaction): 9.8 µs median, 17.7 µs p95 per step with one visit per derive per batch; 349 µs without the visited set
|
||||
(one visit per path).
|
||||
|
||||
Binary `state` messages (`test/next/bench/links.ts`, against HEAD, alternating, pinned): an entry without retirements
|
||||
Binary `state` messages (`test/bench/links.ts`, against HEAD, alternating, pinned): an entry without retirements
|
||||
costs no extra byte (165 / 27 / 23 B for one appended item, one changed field, one removed item, as at HEAD); encode
|
||||
and decode are at HEAD's level (one changed field, 200,000 runs, best of 9: encode 1,167-1,203 ns against
|
||||
1,172-1,275 ns, decode 692 against 685-705 ns); heap per encode and decode 1,592 / 1,631 / 1,363 B against 1,684 /
|
||||
|
|
@ -547,7 +547,7 @@ delta take 4 each (`SqliteStorage`: 5 and about 7).
|
|||
a fixed cost of the store (catalog, compiled statements, claims, the store's code), the same at both scales; heap after
|
||||
reads 0.13 → 0.16-0.18 MiB. Disk 0.61 → 0.58 MiB (1k), 4.34 → 4.38 MiB (10k).
|
||||
|
||||
**Chord's commit gate** (`test/next/bench/store.ts`), HEAD → build, two alternating runs each, medians:
|
||||
**Chord's commit gate** (`test/bench/store.ts`), HEAD → build, two alternating runs each, medians:
|
||||
|
||||
| Case | HEAD | build |
|
||||
| --- | --- | --- |
|
||||
|
|
@ -579,7 +579,7 @@ each; the head lookup and scans under it are at parity or faster in the micro-be
|
|||
(one invocation per call of `run`, held state folded in a `PendingOps`): start 55.5 → 55.2 µs, immediate state change
|
||||
21.3 → 21.2 µs, deferred 3.2 → 3.5 µs (the fold), in memory unchanged.
|
||||
|
||||
**Persisted jobs** (`test/next/bench/jobs.ts`, 7 bursts of 500, medians): a start (resolved after its commit) 8.4 µs in
|
||||
**Persisted jobs** (`test/bench/jobs.ts`, 7 bursts of 500, medians): a start (resolved after its commit) 8.4 µs in
|
||||
memory, 55.5 µs persisted (two commits: the record and state created, then the reservation); a state change inside
|
||||
`run` 2.7 µs in memory, 21.3 µs persisted (one delta commit, as the gate's (c)), 3.2 µs deferred (stored with the
|
||||
outcome).
|
||||
|
|
@ -590,7 +590,7 @@ Node 24.4.1, one CPU (`taskset -c 6`), runs alternating. HEAD is `391e83fb1`, ex
|
|||
(`/home/badlogic/pi-h8`); the build was measured in the worktree and, where the worktree itself skewed a result (below),
|
||||
in an extract of the same sources at `/home/badlogic/pi-x8`.
|
||||
|
||||
**Paths under every object** (`test/next/bench/calls.ts`, new: runs on HEAD as it is), median per operation over nine
|
||||
**Paths under every object** (`test/bench/calls.ts`, new: runs on HEAD as it is), median per operation over nine
|
||||
bursts, heap allocated per operation, three alternating runs each, HEAD → build:
|
||||
|
||||
| Case | HEAD | build |
|
||||
|
|
@ -617,7 +617,7 @@ unchanged; its round trips are one cold burst each and vary as much between HEAD
|
|||
µs, text 7.66-7.72 → 7.70-7.83 µs in the extracts; the worktree itself runs the same sources at 7.8-7.9 µs (binary),
|
||||
an effect of the tree, not the code.
|
||||
|
||||
**Registries and writables** (`test/next/bench/registries.ts`, new), median over nine bursts: `writable.set` delivered
|
||||
**Registries and writables** (`test/bench/registries.ts`, new), median over nine bursts: `writable.set` delivered
|
||||
to one watch 0.81-0.91 µs (2.9 KB allocated: the batch and the watch's call); a set of the same value 0.008 µs, 0 B
|
||||
(one comparison: its batch work is a method of its own, so `set` allocates no closure context); one of 10 contributions
|
||||
changed while the registry is watched 0.74-0.77 µs, of 100 1.52-1.59 µs (the table built again over every entry, one
|
||||
|
|
@ -639,7 +639,7 @@ in six 3.4 µs on either variant tried: JIT).
|
|||
Node 24.4.1, one CPU (`taskset -c 6`), runs alternating. HEAD is `babe30a7e`, extracted at a path of the same length
|
||||
(`/home/badlogic/pi-h7`); the build in the worktree (`/home/badlogic/pi-7a`).
|
||||
|
||||
**Paths under every object** (`test/next/bench/calls.ts`), median per operation over nine bursts, heap allocated per
|
||||
**Paths under every object** (`test/bench/calls.ts`), median per operation over nine bursts, heap allocated per
|
||||
operation, three alternating runs each, HEAD → build:
|
||||
|
||||
| Case | HEAD | build |
|
||||
|
|
@ -679,7 +679,7 @@ retry's operation id is made per `ref.retry()`, and an object keeps its handled
|
|||
Node 24.4.1, one CPU (`taskset -c 6`), runs alternating. HEAD is `0c3675e64`, extracted at a path of the same length
|
||||
(`/home/badlogic/pi-hb`); the build in the worktree (`/home/badlogic/pi-7b`).
|
||||
|
||||
**Paths under every object** (`test/next/bench/calls.ts`), median per operation over nine bursts, heap allocated per
|
||||
**Paths under every object** (`test/bench/calls.ts`), median per operation over nine bursts, heap allocated per
|
||||
operation, alternating runs (the last two after the fixes below), HEAD → build:
|
||||
|
||||
| Case | HEAD | build |
|
||||
|
|
@ -710,7 +710,7 @@ contributions set 0.77-1.05 → 0.69-0.72 µs; of 100 1.57-1.58 → 1.54-3.30 µ
|
|||
µs. The store (`store.ts`): every median within 0.3 µs. The relay (`relay.ts`, three runs): tab B alone 0.014 ms both;
|
||||
10,000 calls at once 530-723 → 491-785 ms (the spread of either side).
|
||||
|
||||
**Reload** (`test/next/bench/reload.ts`, new): 20 plugins, 1,000 live objects (with params, a document, a derive and a
|
||||
**Reload** (`test/bench/reload.ts`, new): 20 plugins, 1,000 live objects (with params, a document, a derive and a
|
||||
method each), one plugin reloaded 20 times; medians after two warm-up reloads, two runs:
|
||||
|
||||
| Case | reload resolves | all live again | heap after 20 reloads, above after the first |
|
||||
|
|
@ -799,7 +799,7 @@ The binary append differs unpinned (median +11%, p90 +31%) and is equal pinned:
|
|||
section, not per-append work. To compare commit-to-frame across two copies, run both at paths of equal length, pinned
|
||||
to one CPU, alternating, with the young generation pinned:
|
||||
|
||||
taskset -c 22 node --min-semi-space-size=16 --max-semi-space-size=16 --conditions=source --expose-gc test/next/bench/links.ts
|
||||
taskset -c 22 node --min-semi-space-size=16 --max-semi-space-size=16 --conditions=source --expose-gc test/bench/links.ts
|
||||
|
||||
Fix round 3 hot paths, `/tmp/s7b-hd-astra` → a build copy at a path of equal length, CPU 22, three alternating runs
|
||||
(`calls.ts`): local call 3.69-3.71 → 3.56-3.63 µs (6,894 → 6,660 B), watched derive commit 17.39-17.50 → 17.41-17.53
|
||||
|
|
@ -883,7 +883,7 @@ not. With text measured first the gap is 22.4-22.5 → 23.0-23.3 µs (+2-3%). V8
|
|||
|
||||
## Slice 7c: plugins (2026-10-30)
|
||||
|
||||
**The plugins component** (`test/next/bench/plugins.ts`, new): 20 plugins, one worker facet each (a store object over a
|
||||
**The plugins component** (`test/bench/plugins.ts`, new): 20 plugins, one worker facet each (a store object over a
|
||||
document, a derive, a method, a helper module and a copied contract; 80.9 KiB of scripts in all), Node 24.4.1. Medians
|
||||
of seven runs; three runs pinned to CPU 8, one unpinned:
|
||||
|
||||
|
|
@ -917,7 +917,7 @@ derive commit 10.34-10.36 → 10.43-10.49 µs (14,899 → 14,901 B), activation
|
|||
|
||||
## Slice 10: HTTP and workers (2026-10-31)
|
||||
|
||||
`test/next/bench/http.ts` and `test/next/bench/workers.ts`, Node 24.4.1, CPU 6 (the forked workers inherit the pin).
|
||||
`test/bench/http.ts` and `test/bench/workers.ts`, Node 24.4.1, CPU 6 (the forked workers inherit the pin).
|
||||
|
||||
| Measurement | Result |
|
||||
| --- | --- |
|
||||
|
|
@ -939,7 +939,7 @@ it.
|
|||
the standard run grew from +4.5 µs to +20-21 µs (+41 µs before the Node fast path: the listener reads Node's parsed
|
||||
headers and makes a Web `Request` only when something reads `request.headers` or the body or a relay verifies; whole
|
||||
bodies are written as strings, in one write with the head). Warmed, +12-13 µs; a CPU profile of the warmed route path
|
||||
(per request, `test/next/bench/http.ts`'s route): the listener's request function 13.6 µs inclusive against the plain
|
||||
(per request, `test/bench/http.ts`'s route): the listener's request function 13.6 µs inclusive against the plain
|
||||
handler's 8.9 µs, of which writing the answer 8.3 µs (the plain handler's `writeHead` and `end`: 9.1 µs); `serve` 4.2
|
||||
µs (the root scope 2.1, the URL parse 0.9, default headers 0.6, admission and routing 1.1, the answer 0.7); the root
|
||||
scope's seal in a later microtask 0.9 µs. The baseline's extra headers cost 0.4 µs (a plain handler sending them).
|
||||
|
|
@ -960,7 +960,7 @@ to frame and encode/decode alike; reconnect of 1 / 100 references 0.94-1.15 / 80
|
|||
|
||||
## Slice 12: the plugin model (2026-11-01)
|
||||
|
||||
**The build's checks** (`test/next/bench/plugins.ts`, the 7c set: 20 plugins, one worker facet each, a copied contract
|
||||
**The build's checks** (`test/bench/plugins.ts`, the 7c set: 20 plugins, one worker facet each, a copied contract
|
||||
by relative path), Node 24.4.1, CPU 8, medians of seven runs, three alternating runs. HEAD `ebce9debd` (its bench
|
||||
importing the contract by plugin name, which HEAD resolves) → this build: cold build of the 20 facets 257.6-257.8 →
|
||||
257.2-258.8 ms, rebuild of one facet 13.2-13.5 → 13.3-13.9 ms. With the app's API set and the kinds named (every
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# Redesign decisions (input for target design v2)
|
||||
|
||||
Decisions made with the owner after the plugin review round on `target-design.md` (6559589bd), in the order they were
|
||||
made. Later entries supersede earlier ones; superseded entries are marked inline and point to their replacement. The target design v2 is `packages/chord/docs/next/design.md`, written from this log; new decisions land here
|
||||
made. Later entries supersede earlier ones; superseded entries are marked inline and point to their replacement. The target design v2 is `packages/chord/docs/design.md`, written from this log; new decisions land here
|
||||
first and win until the design is updated.
|
||||
|
||||
## Plan
|
||||
|
|
@ -13,7 +13,7 @@ first and win until the design is updated.
|
|||
(owner override of the "pi first" rule for this directory) and upstreamed to pi once stable; then `next/` replaces
|
||||
today's Chord and p2 is deleted. `next/` imports `../context`, `../delta`, `../json`, `../output` directly, so
|
||||
Durable's `Context`, `Draft` and `JsonValue` are the same modules and types in v2 (no copies, no type conflicts).
|
||||
(Replaces "Chord v2 lives inside p3".)
|
||||
(Replaces "Chord v2 lives inside p3". Now `src` and the package's root: "Chord v2 becomes the package's root".)
|
||||
- **Build a playground before p3:** a small system with p3's shape (server, worker processes over IPC, clients over
|
||||
WebSockets) and toy plugins, one per concept; it proves the concepts and reload and is the e2e test ground.
|
||||
Location `apps/chord-playground` (private). Client: a web page first (a CLI client possibly later). Order: kernel
|
||||
|
|
@ -444,7 +444,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
no view algebra, no `memo` API.
|
||||
- **Diffing and derived values come from Delta and today's Chord, not new code:** Delta's `diffRevisions` (tested,
|
||||
identity short-circuit) gets bytes as atomic leaves (compared by identity, replaced when different), and v2 calls
|
||||
it directly; `next/diff.ts` is removed. Derives reuse today's derived cells (`chord/src/cell.ts`) on top of the
|
||||
it directly; v2's `diff.ts` is removed. Derives reuse today's derived cells (`chord/src/cell.ts`) on top of the
|
||||
readable. Delta gets a small, pure, tested helper `mapShared(f)` (an identity cache per source element: a new object
|
||||
per changed element, the previous result for unchanged ones), used by derives that build a new object per source
|
||||
element; the cookbook documents it alongside the patterns.
|
||||
|
|
@ -461,7 +461,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
encodes, schema validations and materializations; per hop a test asserts the counts, e.g. one appended note to a
|
||||
500 KB document with 10 viewers: no walks, no copies, one encode per link, no validations) and a `vitest bench`
|
||||
suite outside CI (500 KB and 1 MB values, 0/1/10 viewers, memory/SQLite, 1 MiB bytes) with results recorded
|
||||
in `packages/chord/docs/next/benchmarks.md`.
|
||||
in `packages/chord/docs/benchmarks.md`.
|
||||
- **Naming:** the pattern of a `local` object holding shared data behind per-principal views is the **shared store**
|
||||
(not "core"); base code and plugins use exactly the same primitives.
|
||||
|
||||
|
|
@ -659,7 +659,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
schema-aware data-only normal form (`NORMAL_FORM_VERSION`; union and intersection order kept; `required`/`enum`
|
||||
sorted; TypeBox keyword whitelist; bytes as a `{ type: "bytes" }` data marker); history private, deep-copied and
|
||||
frozen (**superseded in part** by "L1 rewrite review": nothing is frozen). Schema helpers `str num int bool nul lit oneOf union obj arr map opt bytes unknown` + `type Static`.
|
||||
Implemented and reviewed (Fable, Astra: thumbs up) in `packages/chord/src/next/contracts/` (`7d2bb8ea8`).
|
||||
Implemented and reviewed (Fable, Astra: thumbs up) in `packages/chord/src/contracts/` (`7d2bb8ea8`).
|
||||
- **Q11, absent singletons (owner, 2026-10-21):** a singleton always has a value: `handle.value` is the written value
|
||||
or `initial()`, never `undefined` (an exposed singleton is typed `T`); `undefined` only for absent keyed documents
|
||||
(`handle.get(key)`); singleton existence has no API. A deliberate difference from Durable's `snapshot()`.
|
||||
|
|
@ -1076,7 +1076,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
step) is a later pi change, with L3d.
|
||||
- **L2 spec decisions (owner, 2026-10-22):** watchable state is JSON only (no bytes in documents, derives, reference
|
||||
members or any watchable; bytes only in method arguments and results and chunked transfers; Delta stays JSON-only;
|
||||
supersedes W4's "JSON plus bytes" for watchables). `mapShared` lives in `next/watch/` until v2 goes upstream. C10 is
|
||||
supersedes W4's "JSON plus bytes" for watchables). `mapShared` lives in v2's `watch/` until v2 goes upstream. C10 is
|
||||
rejected (`constant` and `writable` stay two kinds, Q14). One publication object per host, passed to its sources;
|
||||
no module-level singleton. No CBOR: the wire stays JSON plus raw byte segments (R1, Q4), revisited only if L6
|
||||
benchmarks show encoding as a bottleneck.
|
||||
|
|
@ -1087,7 +1087,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
publication resets its phase and queues in a `finally`, so a throwing sink cannot leave it stuck; L2's size (about
|
||||
680 lines) is accepted.
|
||||
- **Carry-over ledger (owner, 2026-10-22):** obligations deferred to later layers are recorded in
|
||||
`packages/chord/docs/next/carry-over.md`, under the layer that must do them, by whoever defers them, in the same
|
||||
`packages/chord/docs/next/carry-over.md` (later folded into `slices.md`), under the layer that must do them, by whoever defers them, in the same
|
||||
change; a layer's spec includes its section; done entries are deleted. The AGENTS.md style section states the rule.
|
||||
- **Vertical slices instead of layers (owner, 2026-10-22):** building strictly bottom-up made L2 grow generic machinery
|
||||
for callers that did not exist yet (nodes, sinks, deliveries, "publications"), hard to follow; its code was deleted,
|
||||
|
|
@ -1098,7 +1098,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
contexts and an `index.ts`/`internal.ts` split, `ff4f4456d`). Kept from L2: Chord's shared watch handle
|
||||
(`src/watch.ts`) and the decisions (JSON-only state, one batch per host, no CBOR, C10 rejected). Plain names from
|
||||
the design's vocabulary or common usage (a signals library's `batch`), never invented internal jargon in APIs.
|
||||
Plan: `packages/chord/docs/next/slices.md`.
|
||||
Plan: `packages/chord/docs/slices.md`.
|
||||
- **`Implementation` maps state members to watchables (owner, 2026-10-22; slice 1):** L1's `Implementation<C>` types a
|
||||
state member as `Watchable<Static<schema>>` (the document handle, derive or reference member the factory returns),
|
||||
not a getter `() => value`: a getter cannot tell Chord when a value changed (design §11).
|
||||
|
|
@ -1187,7 +1187,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
(option a, end-to-end acks, rejected for that). About 40 more lines than (a).
|
||||
- Open for the slice-4 spec: Astra's circuit breaker (a hard transport-buffer cap plus a stall or heartbeat
|
||||
deadline that closes the link).
|
||||
Explainer: `packages/chord/docs/next/flow-control/` (Radius artifact 01m5n82vsbe5rtn4nm6wzaq0mp).
|
||||
Explainer: `packages/chord/docs/flow-control/` (Radius artifact 01m5n82vsbe5rtn4nm6wzaq0mp).
|
||||
- **One pending queue for slow consumers of state (owner, 2026-10-23):** refines the snapshot fallback of "Flow control:
|
||||
an ack-clocked window" and supersedes "Watchable and one watch API"'s "after 100 pending frames the backlog becomes
|
||||
one root replacement" for state watches. Measured by Astra and Fable (`/tmp/fc2-*.md`, `/tmp/fc3-*.md`; real p2 entries plus synthetic stress
|
||||
|
|
@ -1212,11 +1212,11 @@ validation and copies the v2 kernel added around Delta.
|
|||
Chord's shared `src/watch.ts` (pi first); tests and `examples/04-watches` change with the contract.
|
||||
- Unchanged: Durable's event stream (`watchEvents` batches are events: they keep their own queue until removed),
|
||||
`NativeWatch` (newest value), relays (drop + `reset { hold }`: parts are opaque).
|
||||
Animation: `packages/chord/docs/next/flow-control/fold/` (Radius 01m5nc9s24e9kscppen2fxgwye).
|
||||
Animation: `packages/chord/docs/flow-control/fold/` (Radius 01m5nc9s24e9kscppen2fxgwye).
|
||||
- **Slice 4 split and first decisions (owner, 2026-10-23):** slice 4 is split into 4a (links and relays: IPC and
|
||||
WebSocket links, trusted links, parts as opaque bytes, flow control, host kinds and placement, routes, relayed holds
|
||||
and calls) and 4b (revocation, cycles across hosts, `host.references`, `host.activity`, size limits, compression);
|
||||
draft with structural changes and races: `packages/chord/docs/next/slice-4.md`. Decided: trusted links are an
|
||||
draft with structural changes and races: `packages/chord/docs/slice-4.md`. Decided: trusted links are an
|
||||
explicit option (`trusted: true` on `accept` and in `resolve`'s result; default untrusted; stamping only from the
|
||||
parent link). ~~Resets and queue overflow travel as `[["r", value]]` ops in an ordinary `state` message~~ (**superseded** by
|
||||
"Slice 4a review decisions": resets are a list-`bound`; overflow stays an `r` op): a relay's
|
||||
|
|
@ -1252,7 +1252,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
`(value, reason, context)`; a local object's handlers act as the host's principal. Still open, listed in design
|
||||
§26 (#24-#29): the timeouts of later mechanisms, `host.root`'s signature, how a migration commit reaches consumers,
|
||||
compression on browser links, the circuit breaker, cuts C3/C4/C11.
|
||||
- **Simplification pass after 4a (owner, 2026-10-24):** two cutters (Opus 5.5, GPT-6.1 Sol) read all of `src/next`
|
||||
- **Simplification pass after 4a (owner, 2026-10-24):** two cutters (Opus 5.5, GPT-6.1 Sol) read all of v2 (`src/next`)
|
||||
without a line target (a target is not meaningful); the merged small cuts, two bug fixes (relays strip a client's
|
||||
principal fields on every path; a closed link delivers no queued message) and D1-D4 are applied by a fresh builder.
|
||||
D1: the unreachable "binding back to pending" branch of a served hold is cut from 4a; slice 7 builds it with reload
|
||||
|
|
@ -1335,7 +1335,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
queued). **No Durable special cases in Chord's store** (owner): the store never knows or waits for Durable's
|
||||
continuation.
|
||||
**Decided (owner, 2026-10-26), after four checks of the proposal (`/tmp/observers-proposal-*.md`, explainer
|
||||
`packages/chord/docs/next/durable-observers/`):** publication at adoption in Opus's variant. Inside `adopt`, in
|
||||
`packages/chord/docs/durable-observers/`):** publication at adoption in Opus's variant. Inside `adopt`, in
|
||||
HEAD's order: the trackers, the telemetry events, the queueing of one microtask that dispatches the host
|
||||
`subscribeCommits` listeners (the listener list read inside that microtask), the internal observers. Microtasks run
|
||||
in order, so host listeners still run before every wait continuation, abort and `onWake` of the commit (before
|
||||
|
|
@ -1625,13 +1625,13 @@ validation and copies the v2 kernel added around Delta.
|
|||
- **Chord v2 and its docs live in pi upstream (owner, 2026-10-27: "we can put chord v2 in upstream", "the docs can
|
||||
all be public, and in upstream"):** `packages/chord/src/next`, `src/next-node`, `test/next`, `test/next-node` and
|
||||
`docs/next` moved to pi `main` (`93029a0f1`), not exported until v2 has an entry point (experimental, noted in the
|
||||
changelog); the decision log moved from `apps/p2/docs/redesign-decisions.md` to `packages/chord/docs/next/decisions.md`;
|
||||
changelog); the decision log moved from `apps/p2/docs/redesign-decisions.md` to `packages/chord/docs/decisions.md`;
|
||||
the root `AGENTS.md` carries the "Chord v2 Code Style" section. From now on every slice and every design doc change
|
||||
goes to pi first (worktree of `upstream/main`, check, tests, push, CI, merge into `private`); only `apps/` stays
|
||||
private.
|
||||
- **Slice 6b D1: `StoreStorage` and `durableView` live in Durable (owner, 2026-10-27: "they are only used if durable
|
||||
is used"):** in Durable's own `src` (an ordinary pi module, now that Chord v2 is upstream), importing
|
||||
`@earendil-works/chord/next` (Chord's `./next` export comes with them). No package cycle: Durable already depends on
|
||||
`@earendil-works/chord/next` (Chord's `./next` export comes with them; now the root export). No package cycle: Durable already depends on
|
||||
Chord, Chord imports nothing of Durable (S13). Durable's conformance suite runs in Durable's tests and CI.
|
||||
- **Slice 6b D2: Durable's records are ordinary declared tables of the store (owner, 2026-10-27: "a, that's the whole
|
||||
purpose of this slice"):** `StoreStorage` declares conversations, entries, tasks, submissions and `record_ids` as
|
||||
|
|
@ -1861,8 +1861,8 @@ validation and copies the v2 kernel added around Delta.
|
|||
- **Slice 7c's points (owner, 2026-10-30; `slice-7c.md` §5):** (1) esbuild is an optional peer dependency of Chord,
|
||||
imported only by the plugins component. (2) **No dev/prod split** (owner: "chord apps are supposed to not have a
|
||||
prod/dev split. they are supposed to be self modifying applications"): watching, rebuilding and rolling out are core
|
||||
machinery; one component `chord/next/plugins` (discovery, build, evaluation, `pluginFile`, the watcher, `rollout`) in
|
||||
`src/next-node/plugins/`, exported with v2's entry point; it turns V8's compilation cache off when created; reloads
|
||||
machinery; one component `chord/plugins` (discovery, build, evaluation, `pluginFile`, the watcher, `rollout`) in
|
||||
`src/node/plugins/`, exported with v2's entry point; it turns V8's compilation cache off when created; reloads
|
||||
stay explicit (the watcher marks, never applies). Supersedes the separate `loader` and `dev` subpaths of "Slice 7b's
|
||||
open points" (5), (6). (3) `installDependencies(plugin, ctx)` as coding-agent installs package dependencies (`npm
|
||||
install --omit=dev --legacy-peer-deps --ignore-scripts --no-audit --no-fund` in the plugin's real directory, the
|
||||
|
|
@ -1901,7 +1901,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
and hands the plugin a `ws` WebSocket for its own protocol; patterns as routes; wraps do not apply; the links path is
|
||||
the component's (`host.accept`). The design's channel component (acknowledged byte channels) waits for a second user
|
||||
and would be built on top of it.
|
||||
(D7, "a") `ws` is an optional peer dependency of Chord (pinned exactly), imported only by `next-node/http`, as
|
||||
(D7, "a") `ws` is an optional peer dependency of Chord (pinned exactly), imported only by `node/http`, as
|
||||
esbuild for plugins.
|
||||
(D8, "a") Route patterns: exact segments, `:name` (one non-empty segment, percent-decoded, `params.name`) and a
|
||||
trailing `/*` (the rest, possibly empty, `params["*"]`); the most specific match wins, segment by segment from the
|
||||
|
|
@ -1990,10 +1990,10 @@ validation and copies the v2 kernel added around Delta.
|
|||
`/chord` link or socket whose `Host` is no longer allowed closes (1008) when the allowed hosts change.
|
||||
- **Slice 11's points (owner, 2026-10-30; `slice-11.md`):** the surfaces are generic plumbing; the app keeps
|
||||
everything visible (owner: "this one needs to be generic, so apps can do whatever they want, css, tui components,
|
||||
themes"). (P1, "a") `chord/next/web` ships serving the surface (the base bundle, the plugins' web scripts,
|
||||
stylesheets and assets, the page through the app's template, all as `Http` contributions), the browser's client host
|
||||
with its connection loop, and loading and applying the plugins' web facets; not HTML, CSS, components, themes,
|
||||
navigation, slots or layout.
|
||||
themes"). (P1, "a") `chord/web` (its server side now `/surfaces`) ships serving the surface (the base bundle, the
|
||||
plugins' web scripts, stylesheets and assets, the page through the app's template, all as `Http` contributions), the
|
||||
browser's client host with its connection loop, and loading and applying the plugins' web facets; not HTML, CSS,
|
||||
components, themes, navigation, slots or layout.
|
||||
(P2, "script tag") A plugin's web script is served as a file that registers itself
|
||||
(`chordFacet("<plugin>", "<digest>", function (require, module, exports) { … })`) and is loaded with `<script src>`
|
||||
under a `'self'` CSP: no `eval`, no `new Function`.
|
||||
|
|
@ -2032,7 +2032,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
- **The HTTP component's security baseline (owner, 2026-10-31: "this mechanism should come out of the box in http/app kit;
|
||||
basically encode all the best practices so users never have to think about it again"; "yes"; p2's rules from
|
||||
`hosts/principals.ts`, `hosts/server.ts`, `loopback-cookie.ts`, devices, sharing):** on by default in
|
||||
`chord/next/http`. (1) Provenance: local only when the socket's local address is loopback, `Host` is a loopback
|
||||
`chord/http`. (1) Provenance: local only when the socket's local address is loopback, `Host` is a loopback
|
||||
form with the port, and no `Forwarded`/`X-Forwarded-*` header; otherwise only an allowed host (https); else 403;
|
||||
duplicate `Host` and absolute-form targets 403; `request.provenance` `{ kind: "local" } | { kind: "remote", host }`.
|
||||
(2) Foreign requests (Fetch Metadata: `Sec-Fetch-Site` `cross-site`/`same-site`, or a frame another site embeds)
|
||||
|
|
@ -2058,7 +2058,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
same object at every reload, so the listener never closes before the process ends. The port opens right after
|
||||
`load`'s publication (a few hundred milliseconds later than listening before `load`; a reconnecting tab retries).
|
||||
- **The app kit is opinionated (owner, 2026-10-31: "the app kit should be super duper opinionated and make for small
|
||||
app code"; "i suppose"):** after slice 10's fix round and before slice 11, a design pass on `chord/next/app`: one
|
||||
app code"; "i suppose"):** after slice 10's fix round and before slice 11, a design pass on `chord/app`: one
|
||||
entry (`startApp`, `startWorker`), conventions for layout, hosts, principals, authentication, storage, plugins, web
|
||||
and TUI, decided point by point; the slice 10 components are its inside. Inputs from the owner: user management is
|
||||
part of it ("user management will also need to be a thing we support"), and app shapes without workers (server and
|
||||
|
|
@ -2105,10 +2105,10 @@ validation and copies the v2 kernel added around Delta.
|
|||
- **API versions in the import path (owner, 2026-10-31: "this sounds about right"; "must be declared by the plugin in
|
||||
code, we do not want to dig around package.json files anymore"):** the app's base code is internal except its API
|
||||
modules, which it exposes as host packages with a version in the path (`@acme/app/v3`; Chord's
|
||||
`@earendil-works/chord/next/v2`); the app lists the versions it still provides (an older one as the same module, or a
|
||||
`@earendil-works/chord/v2`); the app lists the versions it still provides (an older one as the same module, or a
|
||||
thin layer); an import of an unlisted version is a build error naming the available ones. No `package.json` field
|
||||
and no npm ranges for this. Contracts and registry tokens inside an API keep their own versions. The rest of the
|
||||
plugin model is a proposal (`docs/next/plugin-model/`).
|
||||
plugin model is a proposal (`docs/plugin-model/`).
|
||||
- **A schema package beneath pi-ai and Chord (owner, 2026-10-31: "i want the schema package"; "if we keep it a
|
||||
dependency in pi-ai, we must be able to rip it out"; TypeBox is "horrible to treeshake"):** a small package with no
|
||||
dependencies (working name `@earendil-works/schema`) under both pi-ai and Chord (siblings: neither depends on the
|
||||
|
|
@ -2141,7 +2141,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
formats on tool arguments). **Chord's envelope check costs +10 to +100 ns per message** from untrusted links
|
||||
(pi-schema reads fields by name from a list; no eval): accepted ("negligible"), no hand-written check.
|
||||
- **Contracts come from where the plugin's code came from (owner, 2026-11-01: "ok"; plugin model point 1, primer
|
||||
`docs/next/plugin-primer/`; supersedes "Plugins import each other by name"):** a plugin carries the contract it was
|
||||
`docs/plugin-primer/`; supersedes "Plugins import each other by name"):** a plugin carries the contract it was
|
||||
written against, never the running app's newest file. The problem: by name, a third-party plugin's next build copied
|
||||
the provider's newest `contract.ts` (version 2), so its version-1 code claimed version 2, `accepts` was never used
|
||||
and it computed wrong results silently. Rule: (A) plugins that change together, the app's own in its repository or a
|
||||
|
|
@ -2166,7 +2166,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
(checked at build). Only base code imports Chord directly. Bundling is slice 10's host packages: the version is part
|
||||
of the name (`kinds.hostPackages` lists `@acme/app/v3`, `@acme/app/v2`), each process provides its own modules.
|
||||
- **The plugin model, points 3, 4, 5 and 8, imports and builds (owner, 2026-11-01, by voice: "yeah that checks out"; "lock the decisions on the
|
||||
points which was discussed"; primer `docs/next/plugin-primer/`):**
|
||||
points which was discussed"; primer `docs/plugin-primer/`):**
|
||||
(3) **Split by host kind, for the app and for plugins alike.** The app's API per version has a shared module
|
||||
(`@acme/app/v3`: Chord's re-exported surface, host kinds, the principal type, `defineFacet` bound to it, contracts
|
||||
and registries that cross hosts) and per-kind modules (`/v3/web`, `/v3/tui`, `/v3/server`, `/v3/worker` where
|
||||
|
|
@ -2295,13 +2295,13 @@ validation and copies the v2 kernel added around Delta.
|
|||
reloads) or the app's `canRetry` says no (a guest's share ended). References keep their last values and wait while
|
||||
down, so the UI shows a banner from the connection state. A network reconnect, not a crash restart.
|
||||
- **Slice 11, P7: React bindings (owner, 2026-11-01: "we definitely want something like that"; p2's `useObject`,
|
||||
`useReadable`, `useRegistry` in `runtime/web.tsx`):** an optional subpath (`chord/next/react`): `HostProvider`,
|
||||
`useReadable`, `useRegistry` in `runtime/web.tsx`):** an optional subpath (`chord/react`): `HostProvider`,
|
||||
`useHold(Type, params)`, `useWatch(watchable)`, `useRegistry(R)`, `useRefState(ref)`; releasing holds on unmount,
|
||||
and a component unmounted by a reload never counts as leaving (design §21). No components, no styling; non-React apps
|
||||
never load it.
|
||||
- **Slice 11, P8: the terminal client always runs sandboxed (**superseded in part** by "The terminal's sandbox is a
|
||||
child process", 2026-11-02: no longer a worker thread) (owner, 2026-11-01: "the terminal should always use the
|
||||
sandbox approach"):** `chord/next/tui` is a Node client host that connects with a token (decision "The app kit, point
|
||||
sandbox approach"):** `chord/tui` is a Node client host that connects with a token (decision "The app kit, point
|
||||
3", (c)), downloads the base bundle and the TUI plugin scripts from the server and runs the app's TUI entry, always
|
||||
inside p2's sandbox: a permission-dropped worker thread with a bridge terminal, the trusted launcher outside it
|
||||
holding the credentials, the terminal and local preferences. No unsandboxed mode, also for the owner's own server.
|
||||
|
|
@ -2319,7 +2319,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
or `github:bob/plugins#<commit>`, installed by plain npm), imported by path into it (`@bob/plugins/notes/contract.ts`)
|
||||
or through its `exports`. Removed: the dependency form of `chord.plugins` (`"name": "github:...#ref:subfolder"`) and
|
||||
its own clone-and-link installer (it could delete outside `node_modules`).
|
||||
- **Looks and themes (owner, 2026-11-02: "i suppose", then "do all the things"; proposal `docs/next/theming/`):**
|
||||
- **Looks and themes (owner, 2026-11-02: "i suppose", then "do all the things"; proposal `docs/theming/`):**
|
||||
shared components come from the app's per-kind API modules (one instance in the base bundle); looks are design
|
||||
tokens, CSS custom properties on `:root` on the web, which the app's components and plugins' CSS read; plugins scope
|
||||
their CSS with CSS modules (`*.module.css`); the terminal gets the app's components and a theme lookup by role. A
|
||||
|
|
@ -2373,7 +2373,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
strings anyway). Known gaps, accepted until Node closes them: the child can signal the user's processes
|
||||
(`process.kill`) and allocate memory outside its heap. Cost: one more Node process per terminal session (measured
|
||||
by the build against the worker). Back to a worker only if Node ships per-worker permissions.
|
||||
- **Messages and languages in the app kit (owner, 2026-11-03: "i18n proposal is a go"; proposal `docs/next/i18n/`):**
|
||||
- **Messages and languages in the app kit (owner, 2026-11-03: "i18n proposal is a go"; proposal `docs/i18n/`):**
|
||||
text people read is translated per viewer, never per server. (1) **Messages in code, per plugin:**
|
||||
`defineMessages(namespace, { en: {...}, de: {...} })` in the app's shared API returns a typed `t`; the first catalog
|
||||
defines the keys and argument types, every other catalog must match them (a missing key or a wrong argument is a
|
||||
|
|
@ -2399,8 +2399,9 @@ validation and copies the v2 kernel added around Delta.
|
|||
- **Chord v2 becomes the package's root (owner, 2026-11-03: "src/old", "subpaths", "yes", "yes", "no, chord is
|
||||
wip"; for merging pi-private into pi this week; trial `/home/badlogic/chord-root-move/report.md`):** `src/next` is
|
||||
the package's `src` (its main export), the Node parts `src/next-node` are `src/node` with short subpaths (`/http`,
|
||||
`/workers`, `/plugins`, `/plugins/runtime`, `/app`, and slice 11's `/web`, `/web/server`, `/react`, `/tui`,
|
||||
`/tui/launcher`), the shared foundations Durable and env use get their own subpaths (`/json`, `/watch`, next to
|
||||
`/workers`, `/plugins`, `/plugins/runtime`, `/app`, and slice 11's `/web`, `/surfaces`, `/react`, `/tui`,
|
||||
`/tui/launcher`; `/surfaces` was `/next/web/server`, renamed in the run because it exports the terminal surface's
|
||||
server side too), the shared foundations Durable and env use get their own subpaths (`/json`, `/watch`, next to
|
||||
`/context`, `/delta`, `/sqlite`), `test/next` and `docs/next` move up (`test/`, `docs/`). Chord v1, which only p2
|
||||
still runs on, moves to `src/old` (exported as `/old`, `/old/node`; its tests `test/old`, its docs `docs/old`), not
|
||||
into p2 (its vitest tests and about 140 imports would need porting); it is deleted in the change that moves p2 to
|
||||
|
|
@ -2414,7 +2415,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
dependencies), native addons included; they stay in Node's module cache until the process ends, so a new version
|
||||
takes effect after a restart. A browser or TUI facet importing an external package fails to build. Slice 7b
|
||||
(the loader).
|
||||
- **The dependency model is decided (owner, 2026-10-29: "just build this"):** `packages/chord/docs/next/dependencies/model.md`
|
||||
- **The dependency model is decided (owner, 2026-10-29: "just build this"):** `packages/chord/docs/dependencies/model.md`
|
||||
(explainer `book.md`, `index.html`) as decided, with its remaining four points taken as recommended: (1) the barrier
|
||||
is (a), `load` resolves at the first outcomes of this host's host-lifetime objects (local blockers still in their
|
||||
first attempt are waited for; waits on other hosts or failed dependencies end the first pass); (3) `waiting` is a new
|
||||
|
|
@ -2463,7 +2464,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
`remote()` (a type error without it, and a declaration error at runtime); a remote type without placement, a leftover
|
||||
of slice 3a (remote objects over one link, before host kinds in 4a), is gone, and with it the fallback "here if served
|
||||
here, otherwise the parent" and its re-placement at the first parent link. Every remote request is placed by its
|
||||
address alone (dependency model, `packages/chord/docs/next/dependencies/`). Tests and examples get `placement: () =>
|
||||
address alone (dependency model, `packages/chord/docs/dependencies/`). Tests and examples get `placement: () =>
|
||||
Server()` or their own host kind.
|
||||
- **No client providers for now (owner, 2026-10-28: "yeah, no client providers for now"; reviews
|
||||
`/tmp/barrier-{astra,fable,opus,sol}.md`):** clients stay unaddressable and serve only `local` types (design §3).
|
||||
|
|
@ -2502,7 +2503,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
round adds no new decisions or features; the same reviewers (restored, with their context) re-review their own
|
||||
findings; when only small items are left, the slice is committed and they are carried over.
|
||||
- **Size is not a goal by itself (owner, 2026-10-27):** no cut pass after slice 6a: "if the user-facing API is great
|
||||
and the underlying implementation fast and correct, we're ok". `src/next` is about 13.2k lines without blank lines
|
||||
and the underlying implementation fast and correct, we're ok". v2 (`src/next`) is about 13.2k lines without blank lines
|
||||
and comments after 6a's build (about 8.5k before), against the earlier target of 6-7k for all of Chord v2, which this
|
||||
supersedes; the bar stays a great API, HEAD-level speed and memory, and correctness.
|
||||
- **Second spec review: the majority (owner, 2026-10-27: "pick the majority vote for everything, get building"):** the
|
||||
|
|
@ -2714,7 +2715,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
by their own `readyTimeout`. (3) A host without an address uses `client` as its host kind in store addresses (owner
|
||||
strings, table names), so a browser tab with sqlite-wasm can have a store later.
|
||||
- **The commit-step benchmark (owner, 2026-10-26; S3's condition "SQLite must not get slower"):** in 6a
|
||||
`test/next/bench/store.ts` measures the store without outbox against Durable's `SqliteStorage` per commit on (a) a
|
||||
`test/bench/store.ts` measures the store without outbox against Durable's `SqliteStorage` per commit on (a) a
|
||||
commit of one document delta, (b) a commit with one table `put`, (c) 1,000 commits back to back, in bursts (median
|
||||
and p95 over several runs) with memory after GC; each must be no slower. The outbox's extra cost per commit is
|
||||
reported separately. A failed gate goes to the owner with numbers and options before the build continues. 6b repeats
|
||||
|
|
@ -2802,7 +2803,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
per type). Compression is built as option (b) (owner leaning, decided after the build): hosts with a synchronous
|
||||
compressor compress parts above 16 KiB at their origin; browsers send uncompressed and inflate asynchronously with
|
||||
`DecompressionStream`, holding later messages of the link in order (bounded by the window and N; acks after apply).
|
||||
- **Slice 4a approved (owner, 2026-10-24):** `packages/chord/docs/next/slice-4a.md` as revised after the reviews. Build
|
||||
- **Slice 4a approved (owner, 2026-10-24):** `packages/chord/docs/slice-4a.md` as revised after the reviews. Build
|
||||
order: the pending queue in Chord's shared code first (pi, upstream), then the slice by a fresh Opus builder (`s4`).
|
||||
- **Slice 4a review decisions (owner, 2026-10-23; Astra's and Fable's spec reviews, `/tmp/s4a-review-*.md`):**
|
||||
supersedes in part "Slice 4 split and first decisions" (resets as `r` ops). A `hold` carries the address the caller
|
||||
|
|
@ -2817,7 +2818,7 @@ validation and copies the v2 kernel added around Delta.
|
|||
requests one reset of every hold D has on U; an answer that finds no slot is requested again. Heartbeat: one
|
||||
`lastReceived` timestamp per link and one host timer; the 30 s silence covers the handshake. Transports: a connecting
|
||||
WebSocket buffers; IPC `send() === false` is backpressure. A hold from the parent is never routed back to it.
|
||||
- **Slice 4a spec decisions (owner, 2026-10-23; `packages/chord/docs/next/slice-4a.md`):** a heartbeat on every link
|
||||
- **Slice 4a spec decisions (owner, 2026-10-23; `packages/chord/docs/slice-4a.md`):** a heartbeat on every link
|
||||
kind, in the protocol layer (`ping`/`pong`; a side that received nothing for 15 s pings, a link silent for 30 s
|
||||
closes; host options with these defaults); supersedes design §3's "heartbeats are the adapter's policy". The text
|
||||
codec may carry relayed links (one parse per part per hop, accepted for a debugging codec). `resolve(address,
|
||||
|
|
@ -3011,7 +3012,7 @@ From `research/chord-on-durable-objects.md`, adopted for the Chord v2 kernel bec
|
|||
beyond a use (a pinned Durable phase holding an old table is fine). Natives reach other plugins only this way: no
|
||||
`opaque` members, no natives in method results. What a native's old value does after its owner restarts is the
|
||||
owner's `close` (a model runtime keeps working until collected; a closed SSH connection fails its in-flight `exec`).
|
||||
- **HTTP is an optional Chord component** (**superseded in part** by "Slice 10's points": `html` instead of `page`, request bodies through `json`/`body`, authentication one app hook, plugin sockets instead of a channel component until a second user) (`@earendil-works/chord/next/http`): a route gets a plain request summary
|
||||
- **HTTP is an optional Chord component** (**superseded in part** by "Slice 10's points": `html` instead of `page`, request bodies through `json`/`body`, authentication one app hook, plugin sockets instead of a channel component until a second user) (`@earendil-works/chord/http`): a route gets a plain request summary
|
||||
(method, path params, query, principal, selected headers; parsed and capped JSON bodies; uploads handed over in
|
||||
chunks) and returns a response description (`json`, `page`, `bytes` with `size`, `mime` and `read(offset, length)`,
|
||||
`redirect`, `error`). The component does ranges (206/416), HEAD, download headers, streaming, abort on disconnect and
|
||||
|
|
@ -3101,7 +3102,7 @@ created at runtime and mutable indirection), R17 (the harness starts after the b
|
|||
|
||||
## Open
|
||||
|
||||
Everything else from earlier lists is decided (see the entries above and `packages/chord/docs/next/design.md` §26).
|
||||
Everything else from earlier lists is decided (see the entries above and `packages/chord/docs/design.md` §26).
|
||||
|
||||
- **Future goal: deploy Chord apps to Cloudflare Durable Objects and similar platforms.** Hot reload there is not
|
||||
needed; composition through plugins is (plugin set fixed at deploy). Research in `research/chord-on-durable-objects.md`.
|
||||
|
|
@ -14,7 +14,7 @@ note: |
|
|||
resolution: a link going is like a plugin unloading") and five rounds of review by Astra, Fable, Opus and Sol, who
|
||||
played more than 300 scenarios from p2's plugins and their own against it. The working document is
|
||||
`/tmp/model/model.md`; the reviews are in `/tmp/dep-graph-*.md`, `/tmp/barrier-*.md` and `/tmp/model/review*-*.md`.
|
||||
Code references are to pi `main` at `358f7d46a` (`packages/chord/src/next`). Notes marked *review* record a point
|
||||
Code references are to pi `main` at `358f7d46a` (`packages/chord/src/next`, now `src`). Notes marked *review* record a point
|
||||
the reviewers found or still disagree on; *Owner* marks a decision that is his.
|
||||
colophon: Parked Until Resolved · first edition · 2026-10-29 · Chord v2 design proposal for slice 7
|
||||
---
|
||||
|
|
@ -118,7 +118,7 @@ table{max-width:100%}
|
|||
resolution: a link going is like a plugin unloading") and five rounds of review by Astra, Fable, Opus and Sol, who
|
||||
played more than 300 scenarios from p2's plugins and their own against it. The working document is
|
||||
<code>/tmp/model/model.md</code>; the reviews are in <code>/tmp/dep-graph-*.md</code>, <code>/tmp/barrier-*.md</code> and <code>/tmp/model/review*-*.md</code>.
|
||||
Code references are to pi <code>main</code> at <code>358f7d46a</code> (<code>packages/chord/src/next</code>). Notes marked <em>review</em> record a point
|
||||
Code references are to pi <code>main</code> at <code>358f7d46a</code> (<code>packages/chord/src/next</code>, now <code>src</code>). Notes marked <em>review</em> record a point
|
||||
the reviewers found or still disagree on; <em>Owner</em> marks a decision that is his.</p>
|
||||
</div>
|
||||
</section><div class="inner">
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
# Chord v2: target design
|
||||
|
||||
The design of Chord v2 (`packages/chord/src/next`, export `@earendil-works/chord/next`) and of how apps (the
|
||||
playground, p3) use it: **the one current statement of every rule** (owner, 2026-10-24). Every decision is recorded in
|
||||
the decision log `packages/chord/docs/next/decisions.md` (the history and the reasons, kept complete) and folded into this
|
||||
document in the same commit. Where a rule is built, the slice is named (`slices.md`); the code follows this document.
|
||||
It replaces `apps/p2/docs/target-design.md`.
|
||||
The design of Chord v2 (`packages/chord/src`, the package's root export `@earendil-works/chord`; Chord v1 is kept in
|
||||
`src/old`) and of how apps (the playground, p3) use it: **the one current statement of every rule** (owner,
|
||||
2026-10-24). Every decision is recorded in the decision log `packages/chord/docs/decisions.md` (the history and the
|
||||
reasons, kept complete) and folded into this document in the same commit. Where a rule is built, the slice is named
|
||||
(`slices.md`); the code follows this document. It replaces `apps/p2/docs/target-design.md`.
|
||||
|
||||
Marks: everything is decided unless marked **Decision needed** (problem with an example, then options; nothing in such
|
||||
a block is settled; section 26 lists them all). **(carried over)** marks behaviour of today's Chord that v2 keeps
|
||||
|
|
@ -1101,7 +1101,7 @@ host.serve(DraftStore, (_, self) => { // local, empty params: may
|
|||
## 10. Store and storage
|
||||
|
||||
> **Superseded (owner, 2026-10-21):** persisted data lives in one transactional store per session, designed in
|
||||
> `packages/chord/docs/next/store/book.md` and decided in the log ("The store", S1-S17, W1-W14, P2-P4, "Flat columns",
|
||||
> `packages/chord/docs/store/book.md` and decided in the log ("The store", S1-S17, W1-W14, P2-P4, "Flat columns",
|
||||
> "Query runner", "Index definitions are recorded", "Blobs and derived data"). In short: backends SQLite (Node, Durable
|
||||
> Objects, sqlite-wasm in the browser; tests on SQLite `:memory:`, no hand-written memory backend, owner, 2026-10-27); real tables owned by objects with empty params (P3), flat
|
||||
> columns (scalars, every `str()` as plain UTF-8 `TEXT` in code point order with lone surrogates replaced and NUL refused (owner, 2026-10-26, superseding W8), `json(...)` with its own version and on-access migration), a small
|
||||
|
|
@ -1322,7 +1322,7 @@ source (W1); a dirty derive nobody holds recomputes on its next read. A throwing
|
|||
visits per change) sets the documented limit.
|
||||
- **The footgun:** a derive that builds new objects for unchanged data makes every commit cost O(size of the value),
|
||||
per view, because the diff must compare contents. Unchanged parts must be the same objects as last time.
|
||||
- **`mapShared(f)`** (from `@earendil-works/chord/next` state; it moves into Delta when v2 goes upstream): an identity cache per source element: `f` runs only for new or
|
||||
- **`mapShared(f)`** (from `@earendil-works/chord` state; it moves into Delta when v2 goes upstream): an identity cache per source element: `f` runs only for new or
|
||||
changed source elements, unchanged ones return their previous result object. With it a list derive costs pointer
|
||||
comparisons, not content comparisons:
|
||||
|
||||
|
|
@ -1956,7 +1956,7 @@ type ReloadReport = {
|
|||
|
||||
- **No dev/prod split** (owner, 2026-10-30): a Chord app is self-modifying (the owner or an agent edits a plugin while
|
||||
it runs), so finding, building, loading, watching and rolling out plugins is core machinery: one component,
|
||||
`chord/next/plugins` (`src/next-node/plugins/`, Node-side; the kernel stays portable and takes facets however they
|
||||
`chord/plugins` (`src/node/plugins/`, Node-side; the kernel stays portable and takes facets however they
|
||||
were made: static imports in tests and Durable Objects work the same). Reloads stay explicit: the watcher marks,
|
||||
never applies.
|
||||
- **A plugin** is a directory with a `package.json` (its `name` is the plugin's name, npm style) and one file per facet
|
||||
|
|
@ -2145,11 +2145,11 @@ validation, no clone of snapshots or ops.
|
|||
|
||||
## 23. Optional components
|
||||
|
||||
The app kit's Node components (slice 10, `slice-10.md`, decision "Slice 10's points"): `chord/next/http` and
|
||||
`chord/next/workers` in `src/next-node/`; the web page host and the TUI are slice 11. Each is ordinary code on Chord's
|
||||
The app kit's Node components (slice 10, `slice-10.md`, decision "Slice 10's points"): `chord/http` and
|
||||
`chord/workers` in `src/node/`; the web page host and the TUI are slice 11. Each is ordinary code on Chord's
|
||||
public API; nothing in the kernel depends on them.
|
||||
|
||||
### HTTP (`@earendil-works/chord/next/http`)
|
||||
### HTTP (`@earendil-works/chord/http`)
|
||||
|
||||
The core is one Web-standard handler built from the registry `Http` (decision "The HTTP core is a fetch handler"):
|
||||
|
||||
|
|
@ -2242,7 +2242,7 @@ route's checks, never instead of them.
|
|||
- **Cost** (decision "a route's overhead accepted"): +20-21 µs per request over a plain Node handler (standard bench),
|
||||
+12-13 µs warmed. `ws` is an optional peer dependency (D7).
|
||||
|
||||
### Workers (`@earendil-works/chord/next/workers`)
|
||||
### Workers (`@earendil-works/chord/workers`)
|
||||
|
||||
`createWorkers(host, Kind, { kind, entry, execArgv?, env?, authorize, principal, start, lock, idleMs?, plugins,
|
||||
onOutput?, onError }, ctx)` routes `Kind` itself (`host.route`, keeping its `RouteHandle`): a forked worker per
|
||||
|
|
@ -2270,7 +2270,7 @@ fails closed when stale; a start waits for a draining old holder without polling
|
|||
complete sets (`createPluginRuntime`, no esbuild); `workers.step(name)` ships a rollout's set to every worker (D12:
|
||||
`{ host, report } | { host, stopped: true } | { host, error }` per worker).
|
||||
|
||||
### The app kit (`chord/next/app`; slice 13)
|
||||
### The app kit (`chord/app`; slice 13)
|
||||
|
||||
Decisions "The app kit, point 1" to "point 5". `startServer(options)` on the server, `startWorker(options)` in a worker
|
||||
entry. Opinionated and small: an app is its API, its built-in plugins and a start script.
|
||||
|
|
@ -2300,18 +2300,19 @@ entry. Opinionated and small: an app is its API, its built-in plugins and a star
|
|||
|
||||
Decision "Slice 11's points" and "Slice 11, P3" to "P9".
|
||||
|
||||
- `chord/next/web`: serving the surface, a page's client host with its connection loop (p2's `keepConnected`), loading
|
||||
and applying the plugins' web facets. A base bundle per client kind built on the server from the app's entry
|
||||
(content-hashed); at page load the kind's current plugin set as one combined file named by its hash, later changed
|
||||
plugins one script each, each a script that registers itself (no eval); new scripts apply in place (`host.reload` in
|
||||
the page); pages learn of them through an object the web component serves (the current set); a base code change
|
||||
reloads the page.
|
||||
- `chord/next/react`: `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState`.
|
||||
- `chord/next/tui`: a Node client connecting with a token, always running the downloaded code in a sandbox (decision
|
||||
"The terminal's sandbox is a child process": a child process on pipes with an empty environment, `--permission`
|
||||
granting nothing and no SQLite; the builtin allowlist and a frozen substitute `process` inside; a bridge terminal;
|
||||
the trusted launcher outside, confirming copy, open and attach on its own prompt); a component scope (`hold`,
|
||||
`watch`, `registry`, released on dispose, one `changed` callback for the app's redraw), no pi-tui dependency.
|
||||
- `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
|
||||
client kind built on the server from the app's entry (content-hashed); at page load the kind's current plugin set as
|
||||
one combined file named by its hash, later changed plugins one script each, each a script that registers itself (no
|
||||
eval); new scripts apply in place (`host.reload` in the page); pages learn of them through an object the web
|
||||
component serves (the current set); a base code change reloads the page.
|
||||
- `chord/react`: `HostProvider`, `useHold`, `useWatch`, `useRegistry`, `useRefState`.
|
||||
- `chord/tui/launcher`: a Node client connecting with a token, always running the downloaded code (the app's terminal
|
||||
entry on `chord/tui`) in a sandbox (decision "The terminal's sandbox is a child process": a child process on pipes
|
||||
with an empty environment, `--permission` granting nothing and no SQLite; the builtin allowlist and a frozen
|
||||
substitute `process` inside; a bridge terminal; the trusted launcher outside, confirming copy, open and attach on its
|
||||
own prompt); a component scope (`hold`, `watch`, `registry`, released on dispose, one `changed` callback for the
|
||||
app's redraw), no pi-tui dependency.
|
||||
|
||||
### Messages and languages (after slice 11)
|
||||
|
||||
|
|
@ -2482,7 +2483,7 @@ Apps (p3), from the functionality map, decided per domain when p3 is designed:
|
|||
| link protocol | protocol version | refused; hosts update together |
|
||||
| UI components, Chord and app APIs | none yet | exposed as they are; versioned with an SDK later |
|
||||
|
||||
**Contract evolution in code (Q10; implemented in `packages/chord/src/next/contracts/`, see its README):**
|
||||
**Contract evolution in code (Q10; implemented in `packages/chord/src/contracts/`, see its README):**
|
||||
|
||||
```ts
|
||||
export const NotesV1 = remote("notes", { version: 1, params, variants: {
|
||||
|
|
@ -2531,8 +2532,8 @@ metrics, the store interface, the renderer), `chord/context` (`Context.telemetry
|
|||
**Rules for every slice** (owner, 2026-10-22): the code follows the root `AGENTS.md` section "Chord v2 Code Style";
|
||||
one test file per source file, one `describe` per mechanism, the spec's test list case by case; cost guards through the
|
||||
in-memory telemetry adapter (section 30); GC tests for every cache and binding; one or more runnable examples
|
||||
(`packages/chord/test/next/examples/NN-topic.ts`); hot-path slices add a light benchmark script
|
||||
(`test/next/bench/`, by hand) with results in `benchmarks.md`; `npm run check` and the slice's tests before reporting.
|
||||
(`packages/chord/test/examples/NN-topic.ts`); hot-path slices add a light benchmark script
|
||||
(`test/bench/`, by hand) with results in `benchmarks.md`; `npm run check` and the slice's tests before reporting.
|
||||
Caches are `WeakMap`s keyed by their owner; no module-level strong caches; no hot-path work no decision asked for. The
|
||||
function doing counted work counts it through its context; call sites never count for others; plain totals without
|
||||
site labels.
|
||||
|
|
@ -428,5 +428,5 @@ Chord-like reentrancy, telemetry throws), a rebuild by a fresh builder, and the
|
|||
| reviews of the first build | `/tmp/observers-review-{astra,fable,opus,sol}.md` |
|
||||
| the proposal | `/tmp/observers-proposal.md` |
|
||||
| checks of the proposal | `/tmp/observers-proposal-{astra,fable,opus,sol}.md`; probes in `/tmp/obsprobe-*` |
|
||||
| decisions so far | `packages/chord/docs/next/decisions.md`, "Slice 6 first decisions" |
|
||||
| Chord rules | `packages/chord/docs/next/design.md` §11 (W1, the batch), §24 (Durable integration) |
|
||||
| decisions so far | `packages/chord/docs/decisions.md`, "Slice 6 first decisions" |
|
||||
| Chord rules | `packages/chord/docs/design.md` §11 (W1, the batch), §24 (Durable integration) |
|
||||
|
|
@ -456,11 +456,11 @@ Chord-like reentrancy, telemetry throws), a rebuild by a fresh builder, and the
|
|||
</tr>
|
||||
<tr>
|
||||
<td>decisions so far</td>
|
||||
<td><code>packages/chord/docs/next/decisions.md</code>, "Slice 6 first decisions"</td>
|
||||
<td><code>packages/chord/docs/decisions.md</code>, "Slice 6 first decisions"</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Chord rules</td>
|
||||
<td><code>packages/chord/docs/next/design.md</code> §11 (W1, the batch), §24 (Durable integration)</td>
|
||||
<td><code>packages/chord/docs/design.md</code> §11 (W1, the batch), §24 (Durable integration)</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
|
@ -10,9 +10,9 @@ stats:
|
|||
- 1 | rule: plugins talk only through object types and registries
|
||||
note: |
|
||||
A tutorial for people building apps on Chord v2, written by the redesign session from `design.md`, `slice-7c.md`,
|
||||
`slice-10.md`, `slice-12.md`, `slice-13.md`, the decision log and the code in `packages/chord/src/next` and
|
||||
`src/next-node`. API names are the current ones. The early chapters wire hosts by hand to show the parts; the last
|
||||
chapter shows the app kit (`chord/next/app`), which does that wiring for an app.
|
||||
`slice-10.md`, `slice-12.md`, `slice-13.md`, the decision log and the code in `packages/chord/src`. API names
|
||||
are the current ones. The early chapters wire hosts by hand to show the parts; the last chapter shows the app kit
|
||||
(`chord/app`), which does that wiring for an app.
|
||||
colophon: Hosts and Plugins · third edition · 2026-11-02 · Chord v2 slices 1-10, 12 and 13
|
||||
---
|
||||
|
||||
|
|
@ -84,7 +84,7 @@ extension point: one plugin defines it, others contribute. A **rollout** applies
|
|||
Start without plugins. One host, one object that keeps notes:
|
||||
|
||||
```ts
|
||||
import { createHost, defineFacet, defineState, local, method, obj, arr, str } from "@earendil-works/chord/next";
|
||||
import { createHost, defineFacet, defineState, local, method, obj, arr, str } from "@earendil-works/chord";
|
||||
|
||||
const NoteList = obj({ items: arr(str()) }); // one schema, used by the contract and the document
|
||||
|
||||
|
|
@ -267,7 +267,7 @@ a `remote` object is made per principal, so a guest can get a read-only variant
|
|||
|
||||
:::note | The app kit does this wiring
|
||||
Starting workers, routing a host kind to them, the WebSocket entry and authentication are slice 10's components
|
||||
(`createWorkers`, `chord.http`). An app built with the app kit (`chord/next/app`, the last chapter) does not wire them:
|
||||
(`createWorkers`, `chord.http`). An app built with the app kit (`chord/app`, the last chapter) does not wire them:
|
||||
`startServer(...)` on the server and `startWorker(...)` in a worker's entry do.
|
||||
:::
|
||||
|
||||
|
|
@ -588,7 +588,7 @@ Plugins import their host only through the API, a version in the path:
|
|||
|
||||
```ts
|
||||
// src/api/v1/index.ts: "@app/api/v1", for every kind
|
||||
export { defineRegistry, local, obj, remote, str } from "@earendil-works/chord/next"; // the Chord surface the app supports
|
||||
export { defineRegistry, local, obj, remote, str } from "@earendil-works/chord"; // the Chord surface the app supports
|
||||
export { defineFacet, Project, Server } from "../../core.ts"; // bound to the app's principal
|
||||
// src/api/v1/web.ts: "@app/api/v1/web", for pages only
|
||||
export const Panels = defineRegistry<Panel, readonly Panel[]>("app.panels", { version: 1 });
|
||||
|
|
@ -631,13 +631,13 @@ same name, the first root wins, and the other is reported.
|
|||
is deleting the directory.
|
||||
- **Testing** a plugin alone: `testPlugin("./plugins/stats", { kind: "worker", api, modules, stubs }, ctx)` builds it
|
||||
as production does, loads it on an in-memory host beside stub facets serving what it uses, collects what it
|
||||
contributes to registries nobody serves, and lists the contracts it waits for (`docs/next/plugin-authoring.md`).
|
||||
contributes to registries nobody serves, and lists the contracts it waits for (`docs/plugin-authoring.md`).
|
||||
|
||||
# The App Kit {#kit lede="startServer and startWorker: the hosts, stores, sign-in, files and plugin roots every app needs, so an app is its API, its plugins and a start script."}
|
||||
|
||||
The chapters so far wired an app by hand: a store, a host, `createHttp`, `createWorkers`, `createPlugins`, an
|
||||
authenticator, a worker entry connecting to its parent. Every app needs the same wiring, and most of it has one right
|
||||
answer. The **app kit** (`chord/next/app`) is that answer, opinionated on purpose: one data directory layout, one way
|
||||
answer. The **app kit** (`chord/app`) is that answer, opinionated on purpose: one data directory layout, one way
|
||||
to sign in, one place for files.
|
||||
|
||||
## A whole app
|
||||
|
|
@ -114,9 +114,8 @@ table{max-width:100%}
|
|||
<p class="subtitle">Build a small notes app step by step, from one host to plugins on a server and on workers, edited while it runs</p>
|
||||
<div class="cover-stats"><div class="stat"><b>3</b><span>kinds of host in an app: server, worker, client</span></div><div class="stat"><b>1</b><span>file per host kind in a plugin</span></div><div class="stat"><b>0</b><span>restarts to apply a plugin edit</span></div><div class="stat"><b>1</b><span>rule: plugins talk only through object types and registries</span></div></div>
|
||||
<div class="cover-note"><p>A tutorial for people building apps on Chord v2, written by the redesign session from <code>design.md</code>, <code>slice-7c.md</code>,
|
||||
<code>slice-10.md</code>, <code>slice-12.md</code>, <code>slice-13.md</code>, the decision log and the code in <code>packages/chord/src/next</code> and
|
||||
<code>src/next-node</code>. API names are the current ones. The early chapters wire hosts by hand to show the parts; the last
|
||||
chapter shows the app kit (<code>chord/next/app</code>), which does that wiring for an app.</p>
|
||||
<code>slice-10.md</code>, <code>slice-12.md</code>, <code>slice-13.md</code>, the decision log and the code in <code>packages/chord/src</code>. API names are the current ones. The early chapters wire hosts by hand to show the parts; the last
|
||||
chapter shows the app kit (<code>chord/app</code>), which does that wiring for an app.</p>
|
||||
</div>
|
||||
</section><div class="inner">
|
||||
<nav class="toc"><h2>Contents</h2><ol>
|
||||
|
|
@ -176,7 +175,7 @@ extension point: one plugin defines it, others contribute. A <strong>rollout</st
|
|||
|
||||
<section class="chapter" id="host"><header class="opener"><div class="numeral">I</div><h2>One Host</h2><p class="lede">The smallest app: one process, one object type, one call.</p></header>
|
||||
<p class="dropcap">Start without plugins. One host, one object that keeps notes:</p>
|
||||
<pre><code><span class="tk-k">import</span> { createHost, defineFacet, defineState, local, method, obj, arr, str } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||||
<pre><code><span class="tk-k">import</span> { createHost, defineFacet, defineState, local, method, obj, arr, str } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord"</span>;
|
||||
|
||||
<span class="tk-k">const</span> <span class="tk-t">NoteList</span> = <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-f">str</span>()) }); <span class="tk-c">// one schema, used by the contract and the document</span>
|
||||
|
||||
|
|
@ -308,7 +307,7 @@ tab.<span class="tk-f">connect</span>(<span class="tk-f">webSocketLink</span>(<s
|
|||
<p><code>P</code> is your app's type of <strong>principal</strong>, who is calling: an owner, a guest, the server, a worker. Links stamp it;
|
||||
a <code>remote</code> object is made per principal, so a guest can get a read-only variant of the same notes.</p>
|
||||
<div class="note"><b class="tag">The app kit does this wiring</b><p>Starting workers, routing a host kind to them, the WebSocket entry and authentication are slice 10's components
|
||||
(<code>createWorkers</code>, <code>chord.http</code>). An app built with the app kit (<code>chord/next/app</code>, the last chapter) does not wire them:
|
||||
(<code>createWorkers</code>, <code>chord.http</code>). An app built with the app kit (<code>chord/app</code>, the last chapter) does not wire them:
|
||||
<code>startServer(...)</code> on the server and <code>startWorker(...)</code> in a worker's entry do.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -557,7 +556,7 @@ registries. They reload on an edit, a broken one is left out alone, and an agent
|
|||
<h3 id="the-api-by-version-and-by-kind">The API, by version and by kind</h3>
|
||||
<p>Plugins import their host only through the API, a version in the path:</p>
|
||||
<pre><code><span class="tk-c">// src/api/v1/index.ts: "@app/api/v1", for every kind</span>
|
||||
<span class="tk-k">export</span> { defineRegistry, local, obj, remote, str } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>; <span class="tk-c">// the Chord surface the app supports</span>
|
||||
<span class="tk-k">export</span> { defineRegistry, local, obj, remote, str } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord"</span>; <span class="tk-c">// the Chord surface the app supports</span>
|
||||
<span class="tk-k">export</span> { defineFacet, <span class="tk-t">Project</span>, <span class="tk-t">Server</span> } <span class="tk-k">from</span> <span class="tk-s">"../../core.ts"</span>; <span class="tk-c">// bound to the app's principal</span>
|
||||
<span class="tk-c">// src/api/v1/web.ts: "@app/api/v1/web", for pages only</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Panels</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">Panel</span>, <span class="tk-k">readonly</span> <span class="tk-t">Panel</span>[]>(<span class="tk-s">"app.panels"</span>, { version: <span class="tk-n">1</span> });</code></pre>
|
||||
|
|
@ -590,14 +589,14 @@ or commit) into the root, <code>{ npm: name, version }</code> extracts the packa
|
|||
is deleting the directory.</li>
|
||||
<li><strong>Testing</strong> a plugin alone: <code>testPlugin("./plugins/stats", { kind: "worker", api, modules, stubs }, ctx)</code> builds it
|
||||
as production does, loads it on an in-memory host beside stub facets serving what it uses, collects what it
|
||||
contributes to registries nobody serves, and lists the contracts it waits for (<code>docs/next/plugin-authoring.md</code>).</li>
|
||||
contributes to registries nobody serves, and lists the contracts it waits for (<code>docs/plugin-authoring.md</code>).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="kit"><header class="opener"><div class="numeral">X</div><h2>The App Kit</h2><p class="lede">startServer and startWorker: the hosts, stores, sign-in, files and plugin roots every app needs, so an app is its API, its plugins and a start script.</p></header>
|
||||
<p class="dropcap">The chapters so far wired an app by hand: a store, a host, <code>createHttp</code>, <code>createWorkers</code>, <code>createPlugins</code>, an
|
||||
authenticator, a worker entry connecting to its parent. Every app needs the same wiring, and most of it has one right
|
||||
answer. The <strong>app kit</strong> (<code>chord/next/app</code>) is that answer, opinionated on purpose: one data directory layout, one way
|
||||
answer. The <strong>app kit</strong> (<code>chord/app</code>) is that answer, opinionated on purpose: one data directory layout, one way
|
||||
to sign in, one place for files.</p>
|
||||
<h3 id="a-whole-app">A whole app</h3>
|
||||
<p>The notes app of example 53 is four files of base code and two plugins:</p>
|
||||
|
|
@ -26,8 +26,8 @@ Three things have to work for him without thought. A plugin adds an HTTP route,
|
|||
website open in his browser can read his data or act as him through it. A session's worker exists exactly while
|
||||
someone uses it. And when a worker crashes, nothing restarts it in a loop: he sees why it failed and decides.
|
||||
|
||||
Slice 10 builds two components for this: `chord/next/http` (the server's HTTP and WebSocket entry) and
|
||||
`chord/next/workers` (a worker process per session). Both are Node code on top of Chord's kernel; the kernel itself
|
||||
Slice 10 builds two components for this: `chord/http` (the server's HTTP and WebSocket entry) and
|
||||
`chord/workers` (a worker process per session). Both are Node code on top of Chord's kernel; the kernel itself
|
||||
stays portable.
|
||||
|
||||
## Eight things to know
|
||||
|
|
@ -69,8 +69,8 @@ const server = createHost<P>({ address: Server(), principal: { kind: "server" },
|
|||
const plugins = createPlugins({
|
||||
roots: ["./plugins"],
|
||||
kinds: {
|
||||
server: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
|
||||
worker: { platform: "node", hostPackages: ["@earendil-works/chord/next", "@app/core"] },
|
||||
server: { platform: "node", hostPackages: ["@earendil-works/chord", "@app/core"] },
|
||||
worker: { platform: "node", hostPackages: ["@earendil-works/chord", "@app/core"] },
|
||||
},
|
||||
onError,
|
||||
}, ctx);
|
||||
|
|
@ -358,7 +358,7 @@ links), secrets reaching guests through worker errors, close and shutdown races,
|
|||
fixed with a regression test. A route costs about 20 µs more than a plain Node handler (12 µs warmed), accepted for
|
||||
what it buys: a scope per request, admission and the baseline headers.
|
||||
|
||||
Next comes the app kit (`chord/next/app`), which wires these components with Chord's conventions so an app writes
|
||||
Next comes the app kit (`chord/app`), which wires these components with Chord's conventions so an app writes
|
||||
`startApp` and `startWorker`, then slice 11: the browser page and the terminal UI as hosts.
|
||||
|
||||
# API in Brief {.appendix}
|
||||
|
|
@ -142,8 +142,8 @@ adds a terminal. Mario edits plugins while the app runs, and sometimes an agent
|
|||
<p>Three things have to work for him without thought. A plugin adds an HTTP route, and it is served safely: no other
|
||||
website open in his browser can read his data or act as him through it. A session's worker exists exactly while
|
||||
someone uses it. And when a worker crashes, nothing restarts it in a loop: he sees why it failed and decides.</p>
|
||||
<p>Slice 10 builds two components for this: <code>chord/next/http</code> (the server's HTTP and WebSocket entry) and
|
||||
<code>chord/next/workers</code> (a worker process per session). Both are Node code on top of Chord's kernel; the kernel itself
|
||||
<p>Slice 10 builds two components for this: <code>chord/http</code> (the server's HTTP and WebSocket entry) and
|
||||
<code>chord/workers</code> (a worker process per session). Both are Node code on top of Chord's kernel; the kernel itself
|
||||
stays portable.</p>
|
||||
<h3 id="eight-things-to-know">Eight things to know</h3>
|
||||
<ol class="findings">
|
||||
|
|
@ -179,8 +179,8 @@ workers component, then loads its plugins:</p>
|
|||
<span class="tk-k">const</span> plugins = <span class="tk-f">createPlugins</span>({
|
||||
roots: [<span class="tk-s">"./plugins"</span>],
|
||||
kinds: {
|
||||
server: { platform: <span class="tk-s">"node"</span>, hostPackages: [<span class="tk-s">"@earendil-works/chord/next"</span>, <span class="tk-s">"@app/core"</span>] },
|
||||
worker: { platform: <span class="tk-s">"node"</span>, hostPackages: [<span class="tk-s">"@earendil-works/chord/next"</span>, <span class="tk-s">"@app/core"</span>] },
|
||||
server: { platform: <span class="tk-s">"node"</span>, hostPackages: [<span class="tk-s">"@earendil-works/chord"</span>, <span class="tk-s">"@app/core"</span>] },
|
||||
worker: { platform: <span class="tk-s">"node"</span>, hostPackages: [<span class="tk-s">"@earendil-works/chord"</span>, <span class="tk-s">"@app/core"</span>] },
|
||||
},
|
||||
onError,
|
||||
}, ctx);
|
||||
|
|
@ -404,7 +404,7 @@ crashing the server, <code>Host</code>-only provenance, origins invented by a ta
|
|||
links), secrets reaching guests through worker errors, close and shutdown races, and leaks of request scopes; each is
|
||||
fixed with a regression test. A route costs about 20 µs more than a plain Node handler (12 µs warmed), accepted for
|
||||
what it buys: a scope per request, admission and the baseline headers.</p>
|
||||
<p>Next comes the app kit (<code>chord/next/app</code>), which wires these components with Chord's conventions so an app writes
|
||||
<p>Next comes the app kit (<code>chord/app</code>), which wires these components with Chord's conventions so an app writes
|
||||
<code>startApp</code> and <code>startWorker</code>, then slice 11: the browser page and the terminal UI as hosts.</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Status: **approved (owner, 2026-10-22)**, rewritten 2026-10-22 as the single source for a from-scratch implementation
|
||||
(the first implementation was discarded for code quality). Every rule here is decided; the decision log
|
||||
(`packages/chord/docs/next/decisions.md`) has the reasons. Code style: the section "Chord v2 Code Style" of the root
|
||||
(`packages/chord/docs/decisions.md`) has the reasons. Code style: the section "Chord v2 Code Style" of the root
|
||||
`AGENTS.md`.
|
||||
|
||||
## What L1 is
|
||||
|
|
@ -11,8 +11,8 @@ Everything about **data** that the upper layers need and that does not depend on
|
|||
object type is declared and evolves (contracts), what a value is, how params become an identity, how arguments are
|
||||
checked, what a principal key is, what an error is. Pure functions, types and weak caches; no I/O.
|
||||
|
||||
Source: `packages/chord/src/next/contracts/` (declarations) and `packages/chord/src/next/data/` (per-call checks,
|
||||
principals, errors), split into files by concept. Everything else under `src/next/` is the frozen old kernel: never
|
||||
Source: `packages/chord/src/contracts/` (declarations) and `packages/chord/src/data/` (per-call checks,
|
||||
principals, errors), split into files by concept. Everything else under `src/next/` (now `src/`) was the frozen old kernel: never
|
||||
imported, never edited. Reused: `src/context/` (`Context`, `BACKGROUND_CONTEXT`, `context.telemetry`) and
|
||||
`src/json.ts` (`JsonValue`), both unchanged. Schemas were TypeBox 1.3 when L1 was built; since the schema switch (2026-11-01) they are pi-schema's plain JSON Schema (design §3 "Values"). The TypeBox details below are historical.
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ vertical slices (`slices.md`). Still valid: the section "Decided while specifyin
|
|||
handle (`src/watch.ts`). Everything else here is background only; do not implement from it.
|
||||
|
||||
Original status: approved; open points decided (below). The single source for implementing L2; every rule comes from the decision log
|
||||
(`packages/chord/docs/next/decisions.md`: "Watchable and one watch API", W1, W2, W3, W4, W12, W14, Q14, R23, R24,
|
||||
(`packages/chord/docs/decisions.md`: "Watchable and one watch API", W1, W2, W3, W4, W12, W14, Q14, R23, R24,
|
||||
"Changing contributions, `self.writable`", "Cost counters") unless marked **Decision needed**. Code style: the section
|
||||
"Chord v2 Code Style" of the root `AGENTS.md`.
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ through it, L4 (objects) builds members from it, L6 (links) encodes what it flus
|
|||
Source: `packages/chord/src/next/watch/` (one directory; files by concept). Reused unchanged: Chord's shared
|
||||
`src/watch.ts` (the watch-handle types **and implementation**, moved there from Durable's `CommittedWatch` by a pi change
|
||||
first, owner 2026-10-22; Durable uses the same implementation), `src/delta/` (`Op`, `diffRevisions`), `src/context/`,
|
||||
`src/json.ts`, and L1's `next/data` errors (`invalid/reentrant`). Nothing from `next-frozen/`.
|
||||
`src/json.ts`, and L1's `data` errors (`invalid/reentrant`). Nothing from `next-frozen/`.
|
||||
|
||||
## 1. The two kinds
|
||||
|
||||
116
packages/chord/docs/old/README.md
Normal file
116
packages/chord/docs/old/README.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Chord v1 (`@earendil-works/chord/old`)
|
||||
|
||||
Chord v1 is the runtime p2 runs on. It moved unchanged to `src/old` (its tests to `test/old`, these docs to
|
||||
`docs/old`) when Chord v2 became the package's root (`@earendil-works/chord`); it stays until p2 runs on Chord v2,
|
||||
then it is removed. New code uses Chord v2 ([README](../../README.md)).
|
||||
|
||||
Chord v1 builds applications out of **objects** that live in several processes (a server, workers, browser tabs) and can be **hot reloaded** while they run.
|
||||
|
||||
- An **object** is state plus code at an address. It is created on demand and lives while someone references it.
|
||||
- Everyone gets a **reference**, never the object. References work the same locally and across processes, and survive reloads.
|
||||
- Every object knows its **principal**: an owner and a guest get different objects, with different methods.
|
||||
- **State** is observable JSON. Remote viewers get a snapshot followed by deltas.
|
||||
- **Hot reload** swaps code while it runs. Work in flight finishes on the code it started with, state is kept, and incompatible changes migrate at a safe point.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Import | Contents | Runs in |
|
||||
|--------|----------|---------|
|
||||
| `@earendil-works/chord/old` | Contracts (`local`, `remote`, `method`, `opaque`), declarations (through the factory's `self`: `state`, `derive`, `resource`, `on`, `task`), hosts (`createHost`, `defineFacet`, `defineRegistry`), links (`memoryLinkPair`, `ipcLink`, `webSocketLink`, `acceptWebSockets`), errors (`ChordError`, `isChordError`, `forbidden`, `invalid`), `replicatedState` with its types. It also re-exports the foundations it was built with (`Context`, `JsonValue`, `copyJson`, `Draft`, the watch types); new code imports those from `/context`, `/json`, `/delta` and `/watch`. | Node and browsers |
|
||||
| `@earendil-works/chord/old/node` | Child host processes: `createHostManager`, `forkDriver`, `childControl`, `processLock`; the facet module loader `nodeLoader`. | Node |
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { createHost, hostNamed, method, remote } from "@earendil-works/chord/old";
|
||||
|
||||
// contract.ts: shared by provider and consumers
|
||||
export const Counter = remote("demo.counter", {
|
||||
version: 1,
|
||||
input: obj({ name: str() }),
|
||||
host: hostNamed("server"), // where its objects live; callers never name a host
|
||||
variants: {
|
||||
default: {
|
||||
state: { count: num() },
|
||||
methods: { increment: method(obj({ by: num() })) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const host = createHost({ name: "server", self: { kind: "server" } });
|
||||
|
||||
host.serve(Counter, ({ name }, _principal, self) => {
|
||||
const count = self.state("count", num(), 0);
|
||||
return {
|
||||
variant: "default",
|
||||
count,
|
||||
increment: async ({ by }, ctx) => {
|
||||
count.change(ctx, (n) => n + by);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await host.root(async (ctx) => {
|
||||
const clicks = await ctx.get(Counter, { name: "clicks" }); // ready, with its state; released when this root ends
|
||||
clicks.count.subscribe((n) => console.log("count", n));
|
||||
await clicks.increment({ by: 2 }, ctx); // logs 0, then 2
|
||||
});
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Tutorial](tutorial.md): a shared todo list, built up section by section. Every example runs as `test/old/tutorial.test.ts`.
|
||||
- [Usage guide](guide.md): API reference for contracts, objects, state, roots, lifetime, plugins, hot reload, hosts, and links.
|
||||
- [Specification](spec.md): compatibility, lifetime, and reload semantics, plus known limitations and implementation decisions.
|
||||
|
||||
## Replicated state
|
||||
|
||||
Replicated state provides the delta tracker's model (`@earendil-works/chord/delta`) through a callback. `replicatedState` is a
|
||||
standalone observable value, independent of hosts:
|
||||
|
||||
```ts
|
||||
import { replicatedState } from "@earendil-works/chord/old";
|
||||
|
||||
const initial = { output: "", count: 0 };
|
||||
const status = replicatedState(initial); // transfers ownership of initial
|
||||
status.change(context, (draft) => {
|
||||
draft.output += "done\n";
|
||||
draft.count += 1;
|
||||
});
|
||||
```
|
||||
|
||||
A successful `change()` publishes exactly one atomic revision. If its callback
|
||||
throws, the original value and sequence remain unchanged. Draft references become
|
||||
unusable when the callback returns. The tracker emits string append and
|
||||
front-truncate operations, array splices and permutations, sets, and deletes; large
|
||||
edit sets may fold into a complete replacement.
|
||||
|
||||
`replicatedState(source, { onError })` instead attaches a publication-only state to
|
||||
an authoritative revision source (`ReplicatedStateSource`): it captures a snapshot,
|
||||
then publishes every committed frame in cursor order.
|
||||
|
||||
## Public state subscriptions
|
||||
|
||||
`state.subscribe(async (value, context, delivery) => { ... })` serializes callbacks
|
||||
independently for each subscription. Hydration starts immediately when a value is
|
||||
available, and its returned promise must settle before updates start. Synchronous
|
||||
callbacks still run synchronously. Read the captured `value` inside an asynchronous
|
||||
callback: `state.value` may already refer to a later revision.
|
||||
|
||||
Each subscription retains at most 100 pending complete-value deliveries, excluding
|
||||
the running callback. On overflow, only the newest pending value, context, and
|
||||
delivery metadata are retained; a not-yet-started initial hydration is preserved.
|
||||
Thus public delivery sequences may skip. This is a frame-count policy, not a byte
|
||||
limit.
|
||||
|
||||
The returned unsubscribe function immediately discards pending work and prevents
|
||||
new callbacks. It neither aborts nor waits for the running callback. Synchronous
|
||||
throws and promise rejections are observed, reported, and do not stop other
|
||||
subscriptions or subsequent callbacks. Attached sources use their `onError`
|
||||
handler; mutable states (and attached sources without a handler) report failures
|
||||
by throwing in a microtask. Unsubscribing does not hide a later rejection from the
|
||||
running callback.
|
||||
|
||||
Cells of Chord objects and their remote readables have their own subscription
|
||||
model; see [State](guide.md#state) in the usage guide.
|
||||
|
|
@ -37,7 +37,7 @@ Chord builds applications out of **objects** that live in several processes (a s
|
|||
|
||||
```ts
|
||||
import { num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { createHost, method, remote } from "@earendil-works/chord";
|
||||
import { createHost, method, remote } from "@earendil-works/chord/old";
|
||||
|
||||
// contract.ts: shared by provider and consumers
|
||||
export const Counter = remote("demo.counter", {
|
||||
|
|
@ -591,7 +591,7 @@ plugins/chat/
|
|||
|
||||
```ts
|
||||
// plugins/chat/worker.ts
|
||||
import { defineFacet } from "@earendil-works/chord";
|
||||
import { defineFacet } from "@earendil-works/chord/old";
|
||||
import { Chat, Conversation } from "./contract.ts";
|
||||
|
||||
export default defineFacet((host) => {
|
||||
|
|
@ -632,7 +632,7 @@ host, because installation is not a rollback transaction.
|
|||
|
||||
For real plugin files, default-export `defineFacet(...)`. Node hosts configure
|
||||
`createHost({ loader: nodeLoader })`, with `nodeLoader` imported from
|
||||
`@earendil-works/chord/node`, then pass module URLs to `load` and `reload`.
|
||||
`@earendil-works/chord/old/node`, then pass module URLs to `load` and `reload`.
|
||||
The in-memory facet objects here avoid filesystem setup. Built-in facets are
|
||||
trusted in-process code, not a sandbox; the current Node loader keeps old module
|
||||
copies in memory, so repeated loading has that limitation.
|
||||
|
|
@ -881,9 +881,9 @@ explicit load/save policy.
|
|||
|
||||
### Imports and running the examples
|
||||
|
||||
These are the shared imports from `test/tutorial.test.ts`. The relative
|
||||
These are the shared imports from `test/old/tutorial.test.ts`. The relative
|
||||
path is correct from that test directory; applications import the same names
|
||||
from `@earendil-works/chord`. `assert` expresses expected values; `it` and
|
||||
from `@earendil-works/chord/old`. `assert` expresses expected values; `it` and
|
||||
`onTestFinished` come from the test runner; `readFileSync` is used by the
|
||||
tutorial's source-matching check, not by the application.
|
||||
|
||||
|
|
@ -912,13 +912,13 @@ import {
|
|||
opaque,
|
||||
remote,
|
||||
type StateOptions,
|
||||
} from "../src/index.ts";
|
||||
} from "../../src/old/index.ts";
|
||||
```
|
||||
|
||||
From `packages/chord`, run:
|
||||
|
||||
```sh
|
||||
node ../../node_modules/vitest/dist/cli.js --run test/tutorial.test.ts
|
||||
node ../../node_modules/vitest/dist/cli.js --run test/old/tutorial.test.ts
|
||||
```
|
||||
|
||||
There is one test per numbered section. Every TypeScript block is matched
|
||||
|
|
@ -263,7 +263,7 @@ starts it to sync.
|
|||
|
||||
## The sync example
|
||||
|
||||
`packages/chord/test/next/examples/60-session-sync/` (uncommitted, in `/home/badlogic/pi-sync-ex`) plays the decided
|
||||
`packages/chord/test/examples/60-session-sync/` (uncommitted, in `/home/badlogic/pi-sync-ex`) plays the decided
|
||||
protocol with Chord's real store pieces (the outbox, `applyBatch`, export archives) and a small in-memory stand-in for
|
||||
Radius. Its output, step by step:
|
||||
|
||||
|
|
@ -475,4 +475,4 @@ Later: voice, terminals (channels and hands), artifacts, port forwards, chat mod
|
|||
| `/tmp/p3-prep/extensions.md` | all 78 extension examples, the capability matrix, the clusters with sketches, the hard cases |
|
||||
| `/tmp/p3-prep/envs.md` | p2's environments and Radius link, Chord's sync pieces, the sync and hands sketches, open questions |
|
||||
| `apps/p2/docs/p3-functions/` | the functionality map (partly outdated) |
|
||||
| `packages/chord/docs/next/` | design, decisions (2026-11-02 meeting summary), app kit and plugin model, theming |
|
||||
| `packages/chord/docs/` | design, decisions (2026-11-02 meeting summary), app kit and plugin model, theming |
|
||||
|
|
@ -74,13 +74,13 @@ as long as the host). Names are prefixed by the plugin (`bob.stats`).
|
|||
| a package in `dependencies`: `@bob/notes`, `@bob/notes/web` | inlined from `node_modules` at the installed version, through the package's `exports` |
|
||||
| a plugin inside a dependency on a whole repository of several: `@bob/plugins/notes/contract.ts` | inlined, as any package file |
|
||||
| a `chord.external` package, a Node builtin (Node facets) | loaded when the script runs |
|
||||
| `pluginFile` from `@earendil-works/chord/next/plugins` | the absolute path of a file of this plugin |
|
||||
| `pluginFile` from `@earendil-works/chord/plugins` | the absolute path of a file of this plugin |
|
||||
|
||||
Refused at build, each with the file, line and rule:
|
||||
|
||||
- `@acme/app/v1` when the app does not list v1 ("@acme/app/v1 is not available: this app provides v3, v2");
|
||||
another kind's module (`@acme/app/v3/web` from a worker facet); two API versions in one facet.
|
||||
- Chord itself (`@earendil-works/chord/next`): the app's API re-exports what plugins may use.
|
||||
- Chord itself (`@earendil-works/chord`): the app's API re-exports what plugins may use.
|
||||
- A relative path leaving the plugin's root (base code, another root); a file of a directory that is no plugin.
|
||||
- A file copied from another plugin importing anything but the API and its own plugin's files.
|
||||
- A per-kind file (`contract.web.tsx`, `contract.Web.ts` alike) or another kind's facet file (`web.tsx`) reached from
|
||||
|
|
@ -88,7 +88,7 @@ Refused at build, each with the file, line and rule:
|
|||
checked only when the package is plugin code (its `package.json`, or one above it in the package, has a `chord`
|
||||
field); ordinary libraries name files as they like (`pdf.worker.mjs`).
|
||||
- A package file whose real path is outside the plugin's own `node_modules` (a `file:` dependency linked elsewhere, a
|
||||
package importing `../../src`).
|
||||
package importing `../src`).
|
||||
- A copied file importing a Node builtin or a package.
|
||||
- A package not in `dependencies`; `import.meta`, `__dirname`, `__filename`, a literal `import("x")`, top-level
|
||||
`await` (read files through `pluginFile`).
|
||||
|
|
@ -138,7 +138,7 @@ or keeps its old code (at a reload), reported with the error.
|
|||
## Testing a plugin alone
|
||||
|
||||
```ts
|
||||
import { testPlugin } from "@earendil-works/chord/next/plugins";
|
||||
import { testPlugin } from "@earendil-works/chord/plugins";
|
||||
import * as apiV3 from "@acme/app/v3";
|
||||
import * as apiV3Worker from "@acme/app/v3/worker";
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ it still provides:
|
|||
```ts
|
||||
// plugins/stats/worker.ts
|
||||
import { defineFacet, Session, Panels } from "@acme/app/v3";
|
||||
import { remote, str } from "@earendil-works/chord/next/v2";
|
||||
import { remote, str } from "@earendil-works/chord/v2";
|
||||
```
|
||||
|
||||
- The app provides `/v3`, its current API. If version 2 plugins still work unchanged against it, `/v2` is listed too,
|
||||
|
|
@ -111,7 +111,7 @@ import { remote, str } from "@earendil-works/chord/next/v2";
|
|||
v3, v2". The plugin keeps its old code, and the message tells an agent exactly what to change.
|
||||
- Nothing to fill in elsewhere: no `package.json` field, no npm ranges. Skeletons and examples import the current
|
||||
version, so an agent copies it without thinking.
|
||||
- Chord's own surface is versioned the same way (`@earendil-works/chord/next/v2`).
|
||||
- Chord's own surface is versioned the same way (`@earendil-works/chord/v2`).
|
||||
|
||||
Decided: "the API version must be declared by the plugin in code" (owner, 2026-10-31); the import path is how.
|
||||
|
||||
|
|
@ -136,7 +136,7 @@ A contract file imports only host packages at runtime (Chord, the app's API); na
|
|||
| Import | Resolved to | Versioned by |
|
||||
| --- | --- | --- |
|
||||
| its own files (`./store.ts`) | copied into its script | the plugin itself |
|
||||
| host packages (`@acme/app/v3`, `@earendil-works/chord/next/v2`, `react`) | the running process's instance, shared | the path's version; the app lists what it provides |
|
||||
| host packages (`@acme/app/v3`, `@earendil-works/chord/v2`, `react`) | the running process's instance, shared | the path's version; the app lists what it provides |
|
||||
| npm packages in its `dependencies` (`slugify`, a third party's contracts package) | its own `node_modules`, bundled | the installed version |
|
||||
| the app's own plugins (`../notes/contract.ts`), in the same root | copied into its script | the same revision: one repository |
|
||||
|
||||
|
|
@ -202,7 +202,7 @@ API must not silently get the new one. So every API module has a version in its
|
|||
it still provides:</p>
|
||||
<pre><code><span class="tk-c">// plugins/stats/worker.ts</span>
|
||||
<span class="tk-k">import</span> { defineFacet, <span class="tk-t">Session</span>, <span class="tk-t">Panels</span> } <span class="tk-k">from</span> <span class="tk-s">"@acme/app/v3"</span>;
|
||||
<span class="tk-k">import</span> { remote, str } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/v2"</span>;</code></pre>
|
||||
<span class="tk-k">import</span> { remote, str } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/v2"</span>;</code></pre>
|
||||
<ul>
|
||||
<li>The app provides <code>/v3</code>, its current API. If version 2 plugins still work unchanged against it, <code>/v2</code> is listed too,
|
||||
pointing to the same module. An app that needs a compatibility layer makes <code>/v2</code> a thin module over <code>/v3</code>.</li>
|
||||
|
|
@ -210,7 +210,7 @@ pointing to the same module. An app that needs a compatibility layer makes <code
|
|||
v3, v2". The plugin keeps its old code, and the message tells an agent exactly what to change.</li>
|
||||
<li>Nothing to fill in elsewhere: no <code>package.json</code> field, no npm ranges. Skeletons and examples import the current
|
||||
version, so an agent copies it without thinking.</li>
|
||||
<li>Chord's own surface is versioned the same way (<code>@earendil-works/chord/next/v2</code>).</li>
|
||||
<li>Chord's own surface is versioned the same way (<code>@earendil-works/chord/v2</code>).</li>
|
||||
</ul>
|
||||
<p>Decided: "the API version must be declared by the plugin in code" (owner, 2026-10-31); the import path is how.</p>
|
||||
</section>
|
||||
|
|
@ -246,7 +246,7 @@ facet contributing to a web registry is a type error and a load error, not a sil
|
|||
<td>the plugin itself</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>host packages (<code>@acme/app/v3</code>, <code>@earendil-works/chord/next/v2</code>, <code>react</code>)</td>
|
||||
<td>host packages (<code>@acme/app/v3</code>, <code>@earendil-works/chord/v2</code>, <code>react</code>)</td>
|
||||
<td>the running process's instance, shared</td>
|
||||
<td>the path's version; the app lists what it provides</td>
|
||||
</tr>
|
||||
|
|
@ -9,9 +9,9 @@ stats:
|
|||
- 8 | points still to decide
|
||||
- 1 | rule decided for base code: the version is in the import path
|
||||
note: |
|
||||
Written by the redesign session for the owner, after the proposal `docs/next/plugin-model/` did not explain enough.
|
||||
Written by the redesign session for the owner, after the proposal `docs/plugin-model/` did not explain enough.
|
||||
Everything decided cites its decision in `decisions.md`; everything open is marked and ends with a recommendation.
|
||||
Built behaviour is slice 7c and slice 10 (`src/next-node/plugins/`).
|
||||
Built behaviour is slice 7c and slice 10 (`src/node/plugins/`).
|
||||
colophon: The Plugin Model, a Primer · first edition · 2026-10-31
|
||||
---
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ Look at the top of Bob's `worker.ts`:
|
|||
```ts
|
||||
import countWords from "words-count"; // 1. an npm package
|
||||
import { defineFacet, Project } from "@acme/app"; // 2. the app's base code
|
||||
import { obj, num, remote } from "@earendil-works/chord/next"; // 3. Chord
|
||||
import { obj, num, remote } from "@earendil-works/chord"; // 3. Chord
|
||||
import { Notes } from "@acme/notes/contract"; // 4. another plugin
|
||||
```
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ Each import is a different kind of dependency, and they behave differently over
|
|||
| --- | --- | --- |
|
||||
| `words-count` | its npm author | the version installed in Bob's plugin directory, copied into his script: frozen until Bob updates it |
|
||||
| `@acme/app` | Acme, when it releases the app | whatever the running app is: always the newest |
|
||||
| `@earendil-works/chord/next` | Chord, through Acme's upgrade | whatever the running app ships: always the newest |
|
||||
| `@earendil-works/chord` | Chord, through Acme's upgrade | whatever the running app ships: always the newest |
|
||||
| `@acme/notes/contract` | Acme, whenever it edits `notes` | the running `notes` plugin's file, copied at Bob's next build: always the newest |
|
||||
|
||||
The first row is safe: npm packages are copied into the script at the version Bob installed, so they never change
|
||||
|
|
@ -180,14 +180,14 @@ leaving the plugin's root stays a build error.
|
|||
|
||||
## 2. What the app's API is, and Chord in it
|
||||
|
||||
**Problem:** a plugin imports two host packages that both change, `@acme/app/v3` and `@earendil-works/chord/next/v2`.
|
||||
**Problem:** a plugin imports two host packages that both change, `@acme/app/v3` and `@earendil-works/chord/v2`.
|
||||
Acme must then say which combinations work: does a plugin importing app v3 and Chord v1 still run? Two version
|
||||
numbers per plugin, and a matrix nobody tests.
|
||||
**Proposal:** plugins import only the app's API. The app re-exports the part of Chord it supports:
|
||||
|
||||
```ts
|
||||
// src/api/v3/index.ts (Acme's base code)
|
||||
export { obj, arr, str, int, remote, local, defineRegistry } from "@earendil-works/chord/next";
|
||||
export { obj, arr, str, int, remote, local, defineRegistry } from "@earendil-works/chord";
|
||||
export { Workspace, Panels, defineFacet } from "../../core.ts";
|
||||
```
|
||||
|
||||
|
|
@ -195,7 +195,7 @@ One number, the app's, describes everything a plugin gets from its host. When Ac
|
|||
breaks plugins, Acme bumps its API version, and decides whether `/v2` survives as a thin layer.
|
||||
**Generations:** if the app provides `/v3` and `/v2`, a facet must import from one of them only, never both (the
|
||||
build checks it), because `/v2`'s `Panels` and `/v3`'s `Panels` may be different registries.
|
||||
**Recommendation:** yes; app kit apps get it for free (`chord/next/app` provides the API module).
|
||||
**Recommendation:** yes; app kit apps get it for free (`chord/app` provides the API module).
|
||||
|
||||
## 3. The API split by host kind
|
||||
|
||||
|
|
@ -113,9 +113,9 @@ table{max-width:100%}
|
|||
<h1>Plugins<br>from the Ground Up</h1>
|
||||
<p class="subtitle">One app, its own plugins and a stranger's plugin, followed through a year of changes, to show what the open decisions are about</p>
|
||||
<div class="cover-stats"><div class="stat"><b>4</b><span>things a plugin depends on</span></div><div class="stat"><b>3</b><span>of them change without the plugin knowing</span></div><div class="stat"><b>8</b><span>points still to decide</span></div><div class="stat"><b>1</b><span>rule decided for base code: the version is in the import path</span></div></div>
|
||||
<div class="cover-note"><p>Written by the redesign session for the owner, after the proposal <code>docs/next/plugin-model/</code> did not explain enough.
|
||||
<div class="cover-note"><p>Written by the redesign session for the owner, after the proposal <code>docs/plugin-model/</code> did not explain enough.
|
||||
Everything decided cites its decision in <code>decisions.md</code>; everything open is marked and ends with a recommendation.
|
||||
Built behaviour is slice 7c and slice 10 (<code>src/next-node/plugins/</code>).</p>
|
||||
Built behaviour is slice 7c and slice 10 (<code>src/node/plugins/</code>).</p>
|
||||
</div>
|
||||
</section><div class="inner">
|
||||
<nav class="toc"><h2>Contents</h2><ol>
|
||||
|
|
@ -154,7 +154,7 @@ is built (slices 7c and 10).</p>
|
|||
<p class="dropcap">Look at the top of Bob's <code>worker.ts</code>:</p>
|
||||
<pre><code><span class="tk-k">import</span> countWords <span class="tk-k">from</span> <span class="tk-s">"words-count"</span>; <span class="tk-c">// 1. an npm package</span>
|
||||
<span class="tk-k">import</span> { defineFacet, <span class="tk-t">Project</span> } <span class="tk-k">from</span> <span class="tk-s">"@acme/app"</span>; <span class="tk-c">// 2. the app's base code</span>
|
||||
<span class="tk-k">import</span> { obj, num, remote } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>; <span class="tk-c">// 3. Chord</span>
|
||||
<span class="tk-k">import</span> { obj, num, remote } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord"</span>; <span class="tk-c">// 3. Chord</span>
|
||||
<span class="tk-k">import</span> { <span class="tk-t">Notes</span> } <span class="tk-k">from</span> <span class="tk-s">"@acme/notes/contract"</span>; <span class="tk-c">// 4. another plugin</span></code></pre>
|
||||
<p>Each import is a different kind of dependency, and they behave differently over time:</p>
|
||||
<table>
|
||||
|
|
@ -176,7 +176,7 @@ is built (slices 7c and 10).</p>
|
|||
<td>whatever the running app is: always the newest</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@earendil-works/chord/next</code></td>
|
||||
<td><code>@earendil-works/chord</code></td>
|
||||
<td>Chord, through Acme's upgrade</td>
|
||||
<td>whatever the running app ships: always the newest</td>
|
||||
</tr>
|
||||
|
|
@ -270,18 +270,18 @@ leaving the plugin's root stays a build error.
|
|||
<strong>Recommendation:</strong> do it, in the plugin slice (slice 10 is committed with by-name imports; the change is small:
|
||||
<code>build.ts</code> and the examples' imports).</p>
|
||||
<h3 id="2-what-the-app-39-s-api-is-and-chord-in-it">2. What the app's API is, and Chord in it</h3>
|
||||
<p><strong>Problem:</strong> a plugin imports two host packages that both change, <code>@acme/app/v3</code> and <code>@earendil-works/chord/next/v2</code>.
|
||||
<p><strong>Problem:</strong> a plugin imports two host packages that both change, <code>@acme/app/v3</code> and <code>@earendil-works/chord/v2</code>.
|
||||
Acme must then say which combinations work: does a plugin importing app v3 and Chord v1 still run? Two version
|
||||
numbers per plugin, and a matrix nobody tests.
|
||||
<strong>Proposal:</strong> plugins import only the app's API. The app re-exports the part of Chord it supports:</p>
|
||||
<pre><code><span class="tk-c">// src/api/v3/index.ts (Acme's base code)</span>
|
||||
<span class="tk-k">export</span> { obj, arr, str, int, remote, local, defineRegistry } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||||
<span class="tk-k">export</span> { obj, arr, str, int, remote, local, defineRegistry } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord"</span>;
|
||||
<span class="tk-k">export</span> { <span class="tk-t">Workspace</span>, <span class="tk-t">Panels</span>, defineFacet } <span class="tk-k">from</span> <span class="tk-s">"../../core.ts"</span>;</code></pre>
|
||||
<p>One number, the app's, describes everything a plugin gets from its host. When Acme upgrades Chord in a way that
|
||||
breaks plugins, Acme bumps its API version, and decides whether <code>/v2</code> survives as a thin layer.
|
||||
<strong>Generations:</strong> if the app provides <code>/v3</code> and <code>/v2</code>, a facet must import from one of them only, never both (the
|
||||
build checks it), because <code>/v2</code>'s <code>Panels</code> and <code>/v3</code>'s <code>Panels</code> may be different registries.
|
||||
<strong>Recommendation:</strong> yes; app kit apps get it for free (<code>chord/next/app</code> provides the API module).</p>
|
||||
<strong>Recommendation:</strong> yes; app kit apps get it for free (<code>chord/app</code> provides the API module).</p>
|
||||
<h3 id="3-the-api-split-by-host-kind">3. The API split by host kind</h3>
|
||||
<p><strong>Problem:</strong> the API module holds everything plugins need, including web things: a <code>Sheet</code> component, a theme, the
|
||||
<code>Panels</code> registry whose entries are React components. A worker plugin imports <code>@acme/app/v3</code> for <code>Workspace</code>, and the
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
Status: **approved (owner, 2026-10-22)**, after Astra's and Fable's spec reviews. The single source for building slice 1,
|
||||
together with the feature map in `slices.md` (entries assigned to slice 1) and `design.md`, read in full (the most
|
||||
relevant sections: §3 host, §5-§7, §8, §9, §11, §12, §16, §18); rules come from the decision log
|
||||
(`packages/chord/docs/next/decisions.md`); code style: the section "Chord v2 Code Style" of the root `AGENTS.md`. Builds on
|
||||
L1 (`src/next/contracts`, `src/next/data`) and Chord's shared watch handle (`src/watch.ts`).
|
||||
(`packages/chord/docs/decisions.md`); code style: the section "Chord v2 Code Style" of the root `AGENTS.md`. Builds on
|
||||
L1 (`src/contracts`, `src/data`) and Chord's shared watch handle (`src/watch.ts`).
|
||||
|
||||
## What an app can do after this slice
|
||||
|
||||
|
|
@ -3,18 +3,18 @@
|
|||
Status: **built** (2026-10-31; build reviews by Fable, Opus, Astra and Sol, security audits by Fable, Opus and Astra, four fix rounds with rechecks; the shape after the fix rounds is in §2, §3 and design §23). Approved by the owner (2026-10-30: "just build it"; D1-D12, D10b, round 1's R1-R6 and round 2's Q1-Q6 in the
|
||||
decision "Slice 10's points"; spec reviews round 1 by Fable and Opus, `/tmp/s10-spec-review-{fable,opus}.md`, round 2
|
||||
by Fable, Opus, Astra and Sol, `/tmp/s10-spec-review2-*.md`, folded in).
|
||||
Slice 10 builds two Node components of the app kit (decision "Slice 7b's open points" (6)): `chord/next/http` (the
|
||||
Slice 10 builds two Node components of the app kit (decision "Slice 7b's open points" (6)): `chord/http` (the
|
||||
server's HTTP and WebSocket entry, routes contributed by plugins through a registry, responses as descriptions) and
|
||||
`chord/next/workers` (a worker process per host address, started on demand, stopped when idle, its plugins shipped from
|
||||
the server). Both live in `packages/chord/src/next-node/` (Node-only); the kernel stays portable. The web page host and
|
||||
`chord/workers` (a worker process per host address, started on demand, stopped when idle, its plugins shipped from
|
||||
the server). Both live in `packages/chord/src/node/` (Node-only); the kernel stays portable. The web page host and
|
||||
the TUI are slice 11, telemetry slice 9.
|
||||
|
||||
Read in full before writing code: this spec; `decisions.md`; `design.md` (§3 hosts, routes, `host.activity`, §17,
|
||||
§18, §21 "Loading plugins and the reload loop", §23 in full); `slices.md`; `slice-7c.md` and
|
||||
`src/next-node/plugins/README.md`; the 7b brief `slice-7b/book.md` (chapters "Assets and Files", "HTTP", "A Mini p2
|
||||
`src/node/plugins/README.md`; the 7b brief `slice-7b/book.md` (chapters "Assets and Files", "HTTP", "A Mini p2
|
||||
from Chord"); p2's `src/hosts/server.ts`, `src/runtime/server.ts`, `src/hosts/worker.ts`, `src/hosts/socket-limits.ts`,
|
||||
`src/hosts/principals.ts`, `plugins/files/server.ts`, `plugins/terminals/server.ts`, `plugins/radius/server.ts`; Chord
|
||||
v1's `src/manager.ts` (`processLock`, `forkDriver`, `createHostManager`, `childControl`).
|
||||
v1's `src/old/manager.ts` (`processLock`, `forkDriver`, `createHostManager`, `childControl`).
|
||||
|
||||
## 1. What p2 has, and what moves into Chord
|
||||
|
||||
|
|
@ -37,12 +37,12 @@ contribution to `Http` or a function the component calls per use, which the app
|
|||
nothing is copied out of a contribution or an option at startup. Bootstrap (changed by a restart): the listen address
|
||||
and port, the links path, the worker entry with its `execArgv` and `env`, the host kind workers serve.
|
||||
|
||||
## 2. `chord/next/http`
|
||||
## 2. `chord/http`
|
||||
|
||||
As built after the fix rounds (decisions "The HTTP core is a fetch handler", "The HTTP server is a native object",
|
||||
"Settings are arguments, extensions are registries", "The HTTP component's security baseline", "allowed hosts only
|
||||
through contributions", "A CSP nonce per response", "three small points", "a route's overhead accepted"); design §23
|
||||
states every rule, `src/next-node/http/README.md` the API.
|
||||
states every rule, `src/node/http/README.md` the API.
|
||||
|
||||
```ts
|
||||
const http = createHttp(server, {
|
||||
|
|
@ -83,7 +83,7 @@ await server.close(ctx);
|
|||
error }` (`authenticate`, `relay`, `links.options`).
|
||||
- `ws` is an optional peer dependency (D7), imported only here.
|
||||
|
||||
## 3. `chord/next/workers`
|
||||
## 3. `chord/workers`
|
||||
|
||||
```ts
|
||||
// server.ts
|
||||
|
|
@ -117,7 +117,7 @@ await workerMain(async ({ address, start, parent, plugins, onClose }, ctx) => {
|
|||
await plugins.load(worker, { kind: "worker", core: [{ name: "@app/core", facet: coreWorker(start) }] }, ctx);
|
||||
return { busy: harness.activeTasks }; // extra keep-alive counts
|
||||
}, {
|
||||
hostPackages: { "@earendil-works/chord/next": chordNext, "@app/core": appCore }, // the child's own instances
|
||||
hostPackages: { "@earendil-works/chord": chord, "@app/core": appCore }, // the child's own instances
|
||||
redact: (error) => ({ message: redactSecrets(String(error)), ...codeOf(error) }), // what crosses as `fatal`
|
||||
onError: (error) => log(error),
|
||||
});
|
||||
|
|
@ -197,7 +197,7 @@ the queue per host) moves into a file without esbuild or the watcher, `createPlu
|
|||
with `load(host, { kind, core, scripts, modules }, ctx)` (`modules`: the host packages' module objects of this process) and `reload(host, scripts, ctx)` (a complete set: removed = loaded −
|
||||
shipped; same digests unchanged); `createPlugins` uses it, and the child's `plugins` is a `WorkerPlugins` over one
|
||||
(`load(host, { kind, core })` loads the start message's scripts; `reload` messages wait until `fn` returned); `import`
|
||||
of `chord/next/plugins/runtime` loads no esbuild. Each kind is defined once in `createPlugins({ kinds: { <kind>: {
|
||||
of `chord/plugins/runtime` loads no esbuild. Each kind is defined once in `createPlugins({ kinds: { <kind>: {
|
||||
platform, hostPackages } } })` (decision "Slice 11's points", Kinds).
|
||||
|
||||
## 4. The rollout across server and workers
|
||||
|
|
@ -11,7 +11,7 @@ Read in full before writing code: this spec; `decisions.md` (slice 11's entries,
|
|||
`src/hosts/tui.ts`, `src/hosts/tui-client.ts`, `src/hosts/tui-sandbox.ts`, `src/hosts/tui-bootstrap.mjs`,
|
||||
`src/hosts/tui-worker-runtime.ts`, `src/runtime/tui.ts` (`TuiScope`).
|
||||
|
||||
## 1. `chord/next/web` (server side)
|
||||
## 1. `chord/surfaces` (the web surface's server side)
|
||||
|
||||
```ts
|
||||
// inside startApp (slice 13), or by hand:
|
||||
|
|
@ -37,7 +37,7 @@ const web = webSurface(server, {
|
|||
- Routes and the page are `Http` contributions of the surface's facet; the page route is `page: true` (navigation from
|
||||
another site allowed) and public or not as the app says.
|
||||
|
||||
## 2. `chord/next/web` (browser side)
|
||||
## 2. `chord/web` (browser side)
|
||||
|
||||
```ts
|
||||
// src/web/main.tsx (the app's entry, in the base bundle)
|
||||
|
|
@ -55,20 +55,20 @@ startPage<P>({
|
|||
visibility, `online` and network change, replace a silent socket, stop on an outdated protocol (reload the page) or
|
||||
when `canRetry` says no); `onConnection` reports `connecting | connected | waiting`.
|
||||
- Loads the combined plugins file, evaluates each registered facet with `modules` (the `require` they get answers only
|
||||
host packages and `@earendil-works/chord/next/plugins`'s browser half if any), loads them with `core` in one
|
||||
host packages and `@earendil-works/chord/plugins`'s browser half if any), loads them with `core` in one
|
||||
`host.load`, then holds `WebScripts`: on a new set it fetches the changed scripts (`<script src>`, one each), and
|
||||
calls `host.reload` with the complete set (P4: in place; a failed plugin keeps its old facet, reported to the app);
|
||||
a changed `base` hash reloads the page.
|
||||
- CSP: scripts are `'self'` plus the nonce for the page's inline bootstrap; no `eval`.
|
||||
|
||||
## 3. `chord/next/react`
|
||||
## 3. `chord/react`
|
||||
|
||||
`HostProvider`, `useHost()`, `useHold(Type, params)` (a reference held for the component's life, released on unmount,
|
||||
re-held when params change), `useWatch(watchable)` (the current value, re-rendering on change; `useSyncExternalStore`),
|
||||
`useRegistry(R)`, `useRefState(ref)`. A component unmounted by a reload has not left (design §21): releases are plain
|
||||
releases, presence is the app's. No components, no styling.
|
||||
|
||||
## 4. `chord/next/tui`
|
||||
## 4. `chord/tui` and `chord/tui/launcher`
|
||||
|
||||
- **Launcher** (trusted, a Node process): `startTerminal({ url, token | tokenFile, pairing?, ... })` connects (a token
|
||||
from the owner token file, slice 13, or device pairing), downloads the base bundle and the TUI kind's plugin scripts
|
||||
|
|
@ -82,7 +82,7 @@ releases, presence is the app's. No components, no styling.
|
|||
the bridged link, in-place reloads through a `TuiScripts` object as the web.
|
||||
- **`TuiScope`** (P9): `new TuiScope(host, changed)` with `hold`, `watch`, `registry`, `dispose`; no pi-tui dependency.
|
||||
|
||||
## 4b. Looks and themes (decision "Looks and themes", `docs/next/theming/`)
|
||||
## 4b. Looks and themes (decision "Looks and themes", `docs/theming/`)
|
||||
|
||||
- The kit ships `Themes` (`defineRegistry<Theme, readonly Theme[]>`, in the shared module the kit's apps re-export),
|
||||
`Theme = { id, name, mode: "dark" | "light", colors: { accent, bg, fg, muted, error, warning, success, ...app roles },
|
||||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
Status: **approved** (owner, 2026-11-01: the plugin model's decisions, then "go ahead, just do all the things"; this
|
||||
spec written by the redesign session from them, no separate spec review). Builds on 7c's plugins component
|
||||
(`src/next-node/plugins/`) and slice 10's kinds and `createPluginRuntime`.
|
||||
(`src/node/plugins/`) and slice 10's kinds and `createPluginRuntime`.
|
||||
|
||||
Read in full before writing code: this spec; `decisions.md` entries of 2026-11-01 "Contracts come from where the
|
||||
plugin's code came from", "Plugins import only the app's versioned API", "The plugin model, points 3, 4, 5 and 8,
|
||||
imports and builds", "Single-file plugins", "Testing a plugin alone"; `design.md` §21 (all of it, "The plugin model"
|
||||
included) and §16-§17; `slices.md` (carry-over "Slice 12"); `slice-7c.md`, `slice-10.md` §3 "Plugins in workers";
|
||||
`src/next-node/plugins/README.md` and its sources; the primer `docs/next/plugin-primer/book.md`.
|
||||
`src/node/plugins/README.md` and its sources; the primer `docs/plugin-primer/book.md`.
|
||||
|
||||
## 1. The app's API (`createPlugins` option `api`)
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ const plugins = createPlugins({
|
|||
a version not listed is a build error naming the listed ones ("@acme/app/v1 is not available: this app provides v3,
|
||||
v2"); a listed one that the kind's `hostPackages` lacks (`@acme/app/v3/web` from a worker facet) is a build error
|
||||
naming the kind. One facet's imports of the API all use one version (a mix is a build error naming both).
|
||||
- Chord itself (`@earendil-works/chord/next` and its subpaths other than `/plugins`, which stays as in 7c for
|
||||
- Chord itself (`@earendil-works/chord` and its subpaths other than `/plugins`, which stays as in 7c for
|
||||
`pluginFile`) is not importable by plugins: a build error pointing to the app's API. Apps that do not set `api` keep
|
||||
7c's behaviour (host packages as listed), so Chord's own tests and examples keep importing Chord directly where they
|
||||
model base code.
|
||||
|
|
@ -106,15 +106,15 @@ await t.close(ctx);
|
|||
itself serves (`t.contributions(R)`: the current entries).
|
||||
- `t.unserved()` lists the names of contracts whose requests wait unbound `not_served` on the host (the unbound
|
||||
index), so a test knows what to stub. `t.root(fn, ctx, { principal })` runs as another principal.
|
||||
- Exported from `chord/next/plugins` (`testing.ts`); no test-only modes elsewhere.
|
||||
- Exported from `chord/plugins` (`testing.ts`); no test-only modes elsewhere.
|
||||
|
||||
## 7. Docs for authors and agents
|
||||
|
||||
- `docs/next/plugin-authoring.md`: a plugin's layout (directory and single-file), its facet and contract files per
|
||||
- `docs/plugin-authoring.md`: a plugin's layout (directory and single-file), its facet and contract files per
|
||||
kind, what it may import and how each import resolves, versions, how the server builds it (esbuild, what the build
|
||||
handles, no build hooks), installing from git or npm, testing with `testPlugin`; written for agents (short, exact,
|
||||
with one complete example plugin).
|
||||
- Update `src/next-node/plugins/README.md`, the guide `docs/next/guide-plugins-hosts/book.md` (chapters "Plugins Using
|
||||
- Update `src/node/plugins/README.md`, the guide `docs/guide-plugins-hosts/book.md` (chapters "Plugins Using
|
||||
Plugins" and "Base Code and Plugins"; rebuild `index.html`), design §21's slice-10 note.
|
||||
|
||||
## 8. Tests, examples
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
# Slice 13: the app kit (spec)
|
||||
|
||||
Status: **approved** (owner, 2026-11-01: decisions "The app kit, point 1" to "point 5", then "go ahead, just do all
|
||||
the things"). Builds on slices 10 (`http`, `workers`) and 12 (the plugin model). `chord/next/app` in
|
||||
`src/next-node/app/`: an opinionated layer so an app is its API, its built-in plugins and a start script.
|
||||
the things"). Builds on slices 10 (`http`, `workers`) and 12 (the plugin model). `chord/app` in
|
||||
`src/node/app/`: an opinionated layer so an app is its API, its built-in plugins and a start script.
|
||||
|
||||
Read in full before writing code: this spec; `decisions.md` entries of 2026-10-31 and 2026-11-01 (the app kit, the
|
||||
plugin model, "The app kit is opinionated", "Settings are arguments, extensions are registries", "The HTTP
|
||||
component's security baseline"); `design.md` §3, §4, §10, §21, §23 (all of it, "The app kit" included); `slices.md`
|
||||
(carry-over "Slice 13"); `slice-10.md`, `slice-12.md`; the READMEs of `next-node/http`, `workers`, `plugins`; p2's
|
||||
(carry-over "Slice 13"); `slice-10.md`, `slice-12.md`; the READMEs of `node/http`, `workers`, `plugins`; p2's
|
||||
`src/runtime/server.ts`, `src/hosts/server.ts`, `src/hosts/worker.ts`, `src/runtime/loopback-cookie.ts`,
|
||||
`src/hosts/principals.ts`, `plugins/devices/`, `plugins/sharing/`, `plugins/files/` for what exists.
|
||||
|
||||
|
|
@ -117,4 +117,4 @@ project plugins (§26 #14).
|
|||
importing over an existing address refused), `applyChanges`, required root failure stopping `startApp`.
|
||||
- Examples: `53-app` (a small app: start script, API, two plugins, a worker kind, the owner link), `54-app-sessions`
|
||||
(pairing a command-line client), `55-app-export` (export and import a worker's address).
|
||||
- `src/next-node/app/README.md`; the guide "Hosts and Plugins" gets a closing chapter on the app kit.
|
||||
- `src/node/app/README.md`; the guide "Hosts and Plugins" gets a closing chapter on the app kit.
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
Status: **approved (owner, 2026-10-22)**, after Astra's and Fable's spec reviews. The single source for building slice 2, together with the feature map in `slices.md`
|
||||
(entries assigned to slice 2), `design.md` read in full (most relevant: §3 timeouts, §6 lifecycle and failures, §7
|
||||
references, §13 natives, §21 start barrier), the decision log, the carry-over (L4 section) and the root `AGENTS.md`
|
||||
style section. Builds on slice 1 (`src/next/state`, `documents`, `host`, committed `d8f5e67e6`) and L1.
|
||||
style section. Builds on slice 1 (`src/state`, `documents`, `host`, committed `d8f5e67e6`) and L1.
|
||||
|
||||
## What an app can do after this slice
|
||||
|
||||
|
|
@ -240,8 +240,8 @@ the own host's otherwise, R20), `ctx.get(Type, params)` (a reference released wh
|
|||
|
||||
## Benchmarks (owner: light benchmarks for memory, timing and wire size)
|
||||
|
||||
A script `packages/chord/test/next/bench/links.ts` (run by hand, not in CI) prints one table **per codec, side by
|
||||
side** (owner: both codecs, memory and compute); results are recorded in `packages/chord/docs/next/benchmarks.md` for
|
||||
A script `packages/chord/test/bench/links.ts` (run by hand, not in CI) prints one table **per codec, side by
|
||||
side** (owner: both codecs, memory and compute); results are recorded in `packages/chord/docs/benchmarks.md` for
|
||||
later slices to compare against. Per case and codec: wire bytes per message; compute (encode and decode time per
|
||||
message over many messages, and commit-to-frame time end to end); memory (heap allocated per message while encoding and
|
||||
decoding 10,000 messages, measured with `--expose-gc`, and memory retained per hold). Payloads modelled on p2:
|
||||
|
|
@ -159,7 +159,7 @@ reference on reconnect; a queued call holds its arguments until sent; `ref.state
|
|||
|
||||
## Benchmarks
|
||||
|
||||
Extend `test/next/bench/links.ts` (both codecs): reconnect with 1 and 100 references (time to all live, bytes), 1,000
|
||||
Extend `test/bench/links.ts` (both codecs): reconnect with 1 and 100 references (time to all live, bytes), 1,000
|
||||
queued calls flushed on reconnect (time, order), and the live paths of 3a unchanged (regression rows).
|
||||
|
||||
## Carry-over taken by this slice
|
||||
|
|
@ -273,7 +273,7 @@ so the upstream sees at most N per origin. Rules:
|
|||
`binaryType = "arraybuffer"`; messages sent while the socket is still connecting are buffered and sent on `open`;
|
||||
a socket already closed gives a closed link; close on `close` and `error`. The transport's `maxPayload` stays its
|
||||
hard cap (code 1009; size limits per part are 4b).
|
||||
- Both deliver whole messages in order and close both ends, like `memoryLinkPair`. They live in `src/next/link/`.
|
||||
- Both deliver whole messages in order and close both ends, like `memoryLinkPair`. They live in `src/link/`.
|
||||
- **Heartbeat on every link kind**, in the channel: each link keeps a `lastReceived` timestamp (updated on every
|
||||
received message; sends do not count) and the host runs one interval timer for all its links (no timer per
|
||||
message). A link silent for 15 s sends `ping` (once its handshake is done; `hello` comes first); the peer answers
|
||||
|
|
@ -360,7 +360,7 @@ O(length) append detection per commit until it writes through `appendText` (pi #
|
|||
|
||||
## Benchmarks
|
||||
|
||||
Extend `test/next/bench/` (by hand, recorded in `benchmarks.md`): commit-to-frame and bytes through a relay (tab ↔
|
||||
Extend `test/bench/` (by hand, recorded in `benchmarks.md`): commit-to-frame and bytes through a relay (tab ↔
|
||||
server ↔ worker over memory links, and over real IPC and a loopback WebSocket); hold-to-live through a relay including
|
||||
`resolve` of an in-process worker; a consumer throttled to 64 KiB/s with stalls of 1, 10 and 60 s receiving a streamed
|
||||
reply into a 500 KB view (bytes, catching up, sender heap; the producer's commit time recorded separately from the
|
||||
|
|
@ -19,7 +19,7 @@ const server = createHost<P, Revocation>({
|
|||
address: Server(), principal: { kind: "server" }, onError,
|
||||
revokedBy: (p) => p.kind === "guest" ? [{ grant: p.grant }, { session: p.session }]
|
||||
: p.kind === "owner" && p.access === "device" ? [{ device: p.device }] : [],
|
||||
compression: nodeCompression, // from `@earendil-works/chord/next/node`
|
||||
compression: nodeCompression, // Node-only: src/node/compression.ts
|
||||
});
|
||||
server.revoke({ grant: "g1" }, ctx); // ends g1's objects here and on every worker, closes g1's links
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ browsers have only the asynchronous `CompressionStream`/`DecompressionStream`.
|
|||
- **Who inflates:** the end that owns the part, when its message is handled. Relays never inflate (parts are opaque).
|
||||
- **The adapter**, portable: `compression?: { deflate(bytes): Uint8Array; inflate(bytes, limit): Uint8Array }`
|
||||
(synchronous). Chord ships `nodeCompression` (Node's `zlib.deflateRawSync`/`inflateRawSync` with
|
||||
`maxOutputLength`) in a Node-only subpath; `src/next` itself imports no Node module. Without the adapter, a host
|
||||
`maxOutputLength`) in a Node-only subpath; the portable core (`src` but `src/node`) imports no Node module. Without the adapter, a host
|
||||
inflates asynchronously with the web `DecompressionStream("deflate-raw")`, counting output bytes and aborting past
|
||||
the limit.
|
||||
- **Order with asynchronous inflate:** a received message whose parts need an asynchronous inflate is held, and every
|
||||
|
|
@ -7,7 +7,7 @@ the first spec review (`/tmp/s6a-review-{astra,fable,opus,sol}.md`) and the owne
|
|||
(owner): simple, fast, correct; Chord and Durable aligned as much as possible.
|
||||
|
||||
The single source for building 6a, together with: `design.md` read in full; the store book `store/book.md`; the
|
||||
decision log `packages/chord/docs/next/decisions.md` (later entries win; superseded parts are marked), in particular "The
|
||||
decision log `packages/chord/docs/decisions.md` (later entries win; superseded parts are marked), in particular "The
|
||||
store", S1-S17, W1-W14, P2, P3, "Flat columns", "Query runner", "Index definitions are recorded", R8, R12-R15, D9, D10,
|
||||
"Slice 6 first decisions", "One retirement mechanism", "Text is plain UTF-8, in code point order", and every entry of
|
||||
2026-10-26 and 2026-10-27 from "The host's store and how it is opened" to "Slice 6a small items"; the feature map and
|
||||
|
|
@ -209,8 +209,8 @@ their calls into it fail.
|
|||
|
||||
- **Opening:** `openStore(backend, ctx, { outbox? })` with `sqliteBackend(db)` over any `SqliteDatabase` (Node through
|
||||
`chord/sqlite/node`, Durable Objects through `chord/sqlite/cloudflare`, sqlite-wasm later). There is no hand-written
|
||||
memory backend: tests use SQLite `:memory:` through `chord/sqlite/node`. The backend interface is internal; `src/next`
|
||||
imports no Node module. No `openNodeStore` shortcut.
|
||||
memory backend: tests use SQLite `:memory:` through `chord/sqlite/node`. The backend interface is internal; the
|
||||
portable core imports no Node module. No `openNodeStore` shortcut.
|
||||
- `openStore` runs the store's own schema migrations (`applySqliteMigrations`), checks that the file uses the UTF-8
|
||||
encoding, and reads the metadata once: `lastSeq`, the outbox setting, the table definitions (version, state, columns
|
||||
with `json(...)` versions and fingerprints, index definitions) and the document id counter (S10); in memory from then
|
||||
|
|
@ -495,7 +495,7 @@ take the object's line
|
|||
|
||||
## 14. Benchmarks and tests
|
||||
|
||||
- **Benchmarks** (`test/next/bench/store.ts`, results in `benchmarks.md`), the gate (decision "The commit-step
|
||||
- **Benchmarks** (`test/bench/store.ts`, results in `benchmarks.md`), the gate (decision "The commit-step
|
||||
benchmark"): the store without outbox no slower than Durable's `SqliteStorage` per commit on (a) one document delta,
|
||||
(b) one table `put` (against `SqliteStorage`'s entry append), (c) 1,000 commits back to back, with and without a
|
||||
watched document; bursts (median and p95 over several runs), memory after GC, the same pragmas and file system. The
|
||||
|
|
@ -149,8 +149,8 @@ Every item below changes 6a's code; nothing is deployed, so the schema and the a
|
|||
|
||||
## 2. `StoreStorage`: Durable's `Storage` on the store (D1, D2)
|
||||
|
||||
In Durable's `src` (an ordinary pi module), importing `@earendil-works/chord/next` (Chord's `./next` export comes
|
||||
with it: the store and the adapter surface below, marked internal for adapters). It passes Durable's storage
|
||||
In Durable's `src` (an ordinary pi module), importing Chord v2 (`@earendil-works/chord`, then `/next`) for
|
||||
the store and the adapter surface below, marked internal for adapters. It passes Durable's storage
|
||||
conformance suite on SQLite `:memory:` and across reopen (a wrapper that closes and reopens the store file after
|
||||
every commit, Durable's reopen harness), and Durable's Session and harness test files run on it.
|
||||
|
||||
|
|
@ -214,7 +214,7 @@ A pure swap is not a replacement (decision "The seal fence"): the old attempt is
|
|||
|
||||
## 12. Out of scope
|
||||
|
||||
Slice 7c: the bundler and loader (`chord/next/loader`), the dev loop (`chord/next/dev`), plugin discovery and npm
|
||||
Slice 7c: the bundler and loader (`chord/loader`), the dev loop (`chord/dev`), plugin discovery and npm
|
||||
dependencies. The app: the rollout across hosts (decision 3: it calls `host.reload` per host in order). Slices 10 and
|
||||
11: the app kit. `rebuild` of tables (key or column-type changes): only if a case needs it (carry-over stays).
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ returns nothing.
|
|||
|
||||
```ts
|
||||
// plugins/notes/contract.ts
|
||||
import { defineRegistry, local, remote, str, obj, arr, method } from "@earendil-works/chord/next";
|
||||
import { defineRegistry, local, remote, str, obj, arr, method } from "@earendil-works/chord";
|
||||
import { Session } from "@app/core"; // the app's host kinds, a host package
|
||||
export const NoteStore = local("notes.store", { version: 1, params: {}, members: { /* … */ } });
|
||||
export const Notes = remote("notes", {
|
||||
|
|
@ -152,7 +152,7 @@ Each facet is built on its own, as one script. The bundler starts at the facet f
|
|||
| another plugin's contract (`../sessions/contract.ts`, or `@pi/sessions/contract` for an installed package) | inlined too: a copy, so a plugin carries the contract version it was built against (`accepts` needs that); only contracts and side-effect-free modules may be imported across plugins |
|
||||
| an npm package in `dependencies` (`marked`) | inlined, resolved from the plugin's own `node_modules` |
|
||||
| an npm package listed in `chord.external` (Node facets only) | external: `require`d from the plugin's own `node_modules` at evaluation |
|
||||
| a host package (`@earendil-works/chord/next`, `react`, `@app/core`, …) | **external**: a parameter of the script, the host's instance at load time |
|
||||
| a host package (`@earendil-works/chord`, `react`, `@app/core`, …) | **external**: a parameter of the script, the host's instance at load time |
|
||||
| a Node builtin (`node:fs`) | external, through the `require` the loader passes (server and worker facets only; a web facet that imports one fails to build) |
|
||||
| CSS (`import "./notes.css"`) | collected into one stylesheet per web facet |
|
||||
| an asset by URL (`import iconUrl from "./assets/icon.svg?url"`) | a content-hashed file served by the app; the import is its URL |
|
||||
|
|
@ -164,7 +164,7 @@ second copy of Chord, and an old version is garbage-collected once nothing holds
|
|||
// what the bundler emits for plugins/notes/worker.ts (simplified)
|
||||
(function (require, __files) {
|
||||
const { defineFacet } = require("@app/core"); // the host's instance
|
||||
const { local, remote, str } = require("@earendil-works/chord/next");
|
||||
const { local, remote, str } = require("@earendil-works/chord");
|
||||
const { marked } = /* marked's code, inlined */;
|
||||
/* contract.ts, store.ts, inlined */
|
||||
return defineFacet((host) => { /* … */ });
|
||||
|
|
@ -172,11 +172,11 @@ second copy of Chord, and an old version is garbage-collected once nothing holds
|
|||
```
|
||||
|
||||
```ts
|
||||
// the loader (proposed @earendil-works/chord/next/loader), Node side
|
||||
import { buildFacet, evaluateFacet } from "@earendil-works/chord/next/loader";
|
||||
// the loader (proposed @earendil-works/chord/loader), Node side
|
||||
import { buildFacet, evaluateFacet } from "@earendil-works/chord/loader";
|
||||
|
||||
const HOST_PACKAGES = { // the app's base bundle exports these
|
||||
"@earendil-works/chord/next": chordNext, "@earendil-works/pi-durable": durable,
|
||||
"@earendil-works/chord": chord, "@earendil-works/pi-durable": durable,
|
||||
"@app/core": core, react, "react/jsx-runtime": jsxRuntime,
|
||||
};
|
||||
const script = await buildFacet({ plugin: "/data/plugins/notes", facet: "worker.ts", hostPackages: Object.keys(HOST_PACKAGES), platform: "node" });
|
||||
|
|
@ -194,7 +194,7 @@ await host.reload([{ name: "@pi/notes", facet }], ctx); // or host.load the f
|
|||
synchronous declaration), `require` of anything but builtins.
|
||||
|
||||
:::review | Open 5: where the loader lives
|
||||
(a) Chord ships `chord/next/loader` (discovery, `buildFacet`, `evaluateFacet`, `pluginFile`), an app chooses its host
|
||||
(a) Chord ships `chord/loader` (discovery, `buildFacet`, `evaluateFacet`, `pluginFile`), an app chooses its host
|
||||
packages; (b) Chord knows only `host.reload(facets)` and the app bundles and evaluates. Recommended: (a); the kernel
|
||||
stays free of it, and Durable Objects (no `eval`) compose statically as before.
|
||||
:::
|
||||
|
|
@ -220,7 +220,7 @@ inside it, refusing `..` out of it:
|
|||
|
||||
```ts
|
||||
// plugins/notes/worker.ts
|
||||
import { pluginFile } from "@earendil-works/chord/next/loader"; // reads the directory the loader passed
|
||||
import { pluginFile } from "@earendil-works/chord/loader"; // reads the directory the loader passed
|
||||
const template = await readFile(pluginFile("assets/template.md"), "utf8");
|
||||
```
|
||||
|
||||
|
|
@ -248,7 +248,7 @@ own plugin's directory; nothing gives it another plugin's files.
|
|||
|
||||
# HTTP {#http lede="What Chord provides, what p2 has, and what p3 needs."}
|
||||
|
||||
**Chord v2 has no HTTP server today.** Design §23 designs one (`@earendil-works/chord/next/http`) and defers it until
|
||||
**Chord v2 has no HTTP server today.** Design §23 designs one (`@earendil-works/chord/http`) and defers it until
|
||||
"the kernel fixes and a first real proof" are done. p2's HTTP is its own, about 30 lines of helpers plus the server host:
|
||||
|
||||
```ts
|
||||
|
|
@ -314,13 +314,13 @@ subpath an app imports only if it wants it, none of them in Chord's kernel.
|
|||
|
||||
```ts
|
||||
// server.ts: a whole app with plugins, a web and a TUI surface
|
||||
import { createHost } from "@earendil-works/chord/next";
|
||||
import { discoverPlugins, pluginLoader } from "@earendil-works/chord/next/loader";
|
||||
import { httpServer } from "@earendil-works/chord/next/http";
|
||||
import { workerRoute } from "@earendil-works/chord/next/workers";
|
||||
import { webSurface } from "@earendil-works/chord/next/web";
|
||||
import { tuiSurface } from "@earendil-works/chord/next/tui";
|
||||
import { devReload } from "@earendil-works/chord/next/dev";
|
||||
import { createHost } from "@earendil-works/chord";
|
||||
import { discoverPlugins, pluginLoader } from "@earendil-works/chord/loader";
|
||||
import { httpServer } from "@earendil-works/chord/http";
|
||||
import { workerRoute } from "@earendil-works/chord/workers";
|
||||
import { webSurface } from "@earendil-works/chord/web";
|
||||
import { tuiSurface } from "@earendil-works/chord/tui";
|
||||
import { devReload } from "@earendil-works/chord/dev";
|
||||
import { Server, Session, core } from "./core.ts"; // the app's base code: host kinds, principals, core facet
|
||||
|
||||
const plugins = await discoverPlugins(["./plugins", join(dataDir, "plugins")]);
|
||||
|
|
@ -338,7 +338,7 @@ devReload({ server, loader, plugins, watch: true });
|
|||
|
||||
```ts
|
||||
// worker.ts
|
||||
import { workerMain } from "@earendil-works/chord/next/workers";
|
||||
import { workerMain } from "@earendil-works/chord/workers";
|
||||
await workerMain(async ({ address, init, parent }) => {
|
||||
const worker = createHost<P>({ address, principal: { kind: "worker", session: init.id }, onError });
|
||||
worker.connect(parent, { principal: { kind: "server" }, trusted: true }, ctx);
|
||||
|
|
@ -351,7 +351,7 @@ await workerMain(async ({ address, init, parent }) => {
|
|||
// core.ts, the web shell: the app decides the layout; plugins fill registries
|
||||
export const Panels = defineRegistry<Record<string, ComponentType>, Panel[]>("app.panels", { version: 1 });
|
||||
export const webShell = ({ host }: ShellProps) => {
|
||||
const panels = useRegistry(host, Panels); // from chord/next/web: a hook over a registry value
|
||||
const panels = useRegistry(host, Panels); // from chord/web: a hook over a registry value
|
||||
return <Tabs>{panels.map((p) => <p.component key={p.name} />)}</Tabs>;
|
||||
};
|
||||
```
|
||||
|
|
@ -363,12 +363,12 @@ app's registries (`Http`, `Panels`, a TUI command list).
|
|||
|
||||
| Subpath | What it does | Replaces in p2 |
|
||||
| --- | --- | --- |
|
||||
| `chord/next/loader` | `discoverPlugins` (directory listing, `package.json` names, no parsing), `buildFacet` (esbuild, host packages external), `evaluateFacet` (Node `vm`, browser `<script>`), `pluginFile`, npm/git install with `--ignore-scripts` | `src/hosts/plugins.ts`, `bundles.ts`, the source resolver, `nodeLoader` |
|
||||
| `chord/next/http` | the Node HTTP server, the `Http` registry (routes, wraps, request resolvers, responses as descriptions: ranges, downloads, `HEAD`), WebSocket entry calling `host.accept` | `src/hosts/server.ts` (HTTP part), `runtime/http.ts`, `Routes`, `Upgrades`, `RequestResolvers` |
|
||||
| `chord/next/workers` | `workerRoute` (fork a worker per address, IPC link, start message, idle stop from `host.activity`, crash restart with backoff), `workerMain` (the child's side) | `SessionHosts` in `runtime/server.ts`, `hosts/worker.ts` |
|
||||
| `chord/next/web` | the page host (connect over a WebSocket with reconnect, load web facets from their scripts, React provider and hooks: `useObject` over `host.hold`, `useRegistry`, `useWatch`), serving the bundle by hash, reload on a new manifest | `hosts/web/*`, `runtime/web.tsx`, `src/web/reconnect.ts` |
|
||||
| `chord/next/tui` | the TUI launcher and sandbox (pi-tui, the bundle download, a link over the launcher's socket), slots and commands as registries | `hosts/tui-*.ts`, `runtime/tui.ts` |
|
||||
| `chord/next/dev` | the source watcher marking plugins dirty, the reload control (`reload(plugins)` on every host in order: server, workers, then a manifest bump for pages and the TUI) | `performReload` in `hosts/server.ts`, the dev protocol |
|
||||
| `chord/loader` | `discoverPlugins` (directory listing, `package.json` names, no parsing), `buildFacet` (esbuild, host packages external), `evaluateFacet` (Node `vm`, browser `<script>`), `pluginFile`, npm/git install with `--ignore-scripts` | `src/hosts/plugins.ts`, `bundles.ts`, the source resolver, `nodeLoader` |
|
||||
| `chord/http` | the Node HTTP server, the `Http` registry (routes, wraps, request resolvers, responses as descriptions: ranges, downloads, `HEAD`), WebSocket entry calling `host.accept` | `src/hosts/server.ts` (HTTP part), `runtime/http.ts`, `Routes`, `Upgrades`, `RequestResolvers` |
|
||||
| `chord/workers` | `workerRoute` (fork a worker per address, IPC link, start message, idle stop from `host.activity`, crash restart with backoff), `workerMain` (the child's side) | `SessionHosts` in `runtime/server.ts`, `hosts/worker.ts` |
|
||||
| `chord/web` | the page host (connect over a WebSocket with reconnect, load web facets from their scripts, React provider and hooks: `useObject` over `host.hold`, `useRegistry`, `useWatch`), serving the bundle by hash, reload on a new manifest | `hosts/web/*`, `runtime/web.tsx`, `src/web/reconnect.ts` |
|
||||
| `chord/tui` | the TUI launcher and sandbox (pi-tui, the bundle download, a link over the launcher's socket), slots and commands as registries | `hosts/tui-*.ts`, `runtime/tui.ts` |
|
||||
| `chord/dev` | the source watcher marking plugins dirty, the reload control (`reload(plugins)` on every host in order: server, workers, then a manifest bump for pages and the TUI) | `performReload` in `hosts/server.ts`, the dev protocol |
|
||||
|
||||
Each is ordinary code on top of Chord's public API: nothing in the kernel depends on it, a Durable Object deployment
|
||||
uses none of it, and an app can replace any one (its own HTTP server, its own shell). React is a host package of the web
|
||||
|
|
@ -604,5 +604,5 @@ contracts bundled, no module map, stable script names, plugins' npm dependencies
|
|||
| 2 | a `writable` across a pure swap: back to its initial value, or kept by position | back to initial |
|
||||
| 3 | who runs the rollout: a Chord protocol, or the app calling `host.reload` per host | the app |
|
||||
| 4 | the install watchdog: a host option `installTimeout`, or the app's context | the app's context |
|
||||
| 5 | where loader D lives: Chord (`chord/next/loader`), or the app | Chord |
|
||||
| 5 | where loader D lives: Chord (`chord/loader`), or the app | Chord |
|
||||
| 6 | the app kit: Chord ships loader, HTTP, workers, web, TUI and dev reload as optional subpaths, or only the loader | Chord ships them, in that order |
|
||||
|
|
@ -219,7 +219,7 @@ automatic React runtime, a fixed setting of the loader. A plugin may keep a <cod
|
|||
<p>A facet only declares (chapter III of the model): it serves object types, serves or contributes to registries, and
|
||||
returns nothing.</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/contract.ts</span>
|
||||
<span class="tk-k">import</span> { defineRegistry, local, remote, str, obj, arr, method } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||||
<span class="tk-k">import</span> { defineRegistry, local, remote, str, obj, arr, method } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord"</span>;
|
||||
<span class="tk-k">import</span> { <span class="tk-t">Session</span> } <span class="tk-k">from</span> <span class="tk-s">"@app/core"</span>; <span class="tk-c">// the app's host kinds, a host package</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NoteStore</span> = <span class="tk-f">local</span>(<span class="tk-s">"notes.store"</span>, { version: <span class="tk-n">1</span>, params: {}, members: { <span class="tk-c">/* … */</span> } });
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Notes</span> = <span class="tk-f">remote</span>(<span class="tk-s">"notes"</span>, {
|
||||
|
|
@ -273,7 +273,7 @@ returns nothing.</p>
|
|||
<td>external: <code>require</code>d from the plugin's own <code>node_modules</code> at evaluation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>a host package (<code>@earendil-works/chord/next</code>, <code>react</code>, <code>@app/core</code>, …)</td>
|
||||
<td>a host package (<code>@earendil-works/chord</code>, <code>react</code>, <code>@app/core</code>, …)</td>
|
||||
<td><strong>external</strong>: a parameter of the script, the host's instance at load time</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
@ -294,16 +294,16 @@ second copy of Chord, and an old version is garbage-collected once nothing holds
|
|||
<pre><code><span class="tk-c">// what the bundler emits for plugins/notes/worker.ts (simplified)</span>
|
||||
(<span class="tk-k">function</span> (require, __files) {
|
||||
<span class="tk-k">const</span> { defineFacet } = <span class="tk-f">require</span>(<span class="tk-s">"@app/core"</span>); <span class="tk-c">// the host's instance</span>
|
||||
<span class="tk-k">const</span> { local, remote, str } = <span class="tk-f">require</span>(<span class="tk-s">"@earendil-works/chord/next"</span>);
|
||||
<span class="tk-k">const</span> { local, remote, str } = <span class="tk-f">require</span>(<span class="tk-s">"@earendil-works/chord"</span>);
|
||||
<span class="tk-k">const</span> { marked } = <span class="tk-c">/* marked's code, inlined */</span>;
|
||||
<span class="tk-c">/* contract.ts, store.ts, inlined */</span>
|
||||
<span class="tk-k">return</span> <span class="tk-f">defineFacet</span>((host) => { <span class="tk-c">/* … */</span> });
|
||||
})</code></pre>
|
||||
<pre><code><span class="tk-c">// the loader (proposed @earendil-works/chord/next/loader), Node side</span>
|
||||
<span class="tk-k">import</span> { buildFacet, evaluateFacet } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/loader"</span>;
|
||||
<pre><code><span class="tk-c">// the loader (proposed @earendil-works/chord/loader), Node side</span>
|
||||
<span class="tk-k">import</span> { buildFacet, evaluateFacet } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/loader"</span>;
|
||||
|
||||
<span class="tk-k">const</span> HOST_PACKAGES = { <span class="tk-c">// the app's base bundle exports these</span>
|
||||
<span class="tk-s">"@earendil-works/chord/next"</span>: chordNext, <span class="tk-s">"@earendil-works/pi-durable"</span>: durable,
|
||||
<span class="tk-s">"@earendil-works/chord"</span>: chord, <span class="tk-s">"@earendil-works/pi-durable"</span>: durable,
|
||||
<span class="tk-s">"@app/core"</span>: core, react, <span class="tk-s">"react/jsx-runtime"</span>: jsxRuntime,
|
||||
};
|
||||
<span class="tk-k">const</span> script = <span class="tk-k">await</span> <span class="tk-f">buildFacet</span>({ plugin: <span class="tk-s">"/data/plugins/notes"</span>, facet: <span class="tk-s">"worker.ts"</span>, hostPackages: <span class="tk-t">Object</span>.<span class="tk-f">keys</span>(HOST_PACKAGES), platform: <span class="tk-s">"node"</span> });
|
||||
|
|
@ -319,7 +319,7 @@ dev loop knows which files a facet used).</li>
|
|||
<li><strong>Imports a plugin cannot use:</strong> dynamic <code>import()</code> (resolved at build, or not at all), top-level <code>await</code> (a facet is a
|
||||
synchronous declaration), <code>require</code> of anything but builtins.</li>
|
||||
</ul>
|
||||
<div class="note review"><b class="tag">Open 5: where the loader lives</b><p>(a) Chord ships <code>chord/next/loader</code> (discovery, <code>buildFacet</code>, <code>evaluateFacet</code>, <code>pluginFile</code>), an app chooses its host
|
||||
<div class="note review"><b class="tag">Open 5: where the loader lives</b><p>(a) Chord ships <code>chord/loader</code> (discovery, <code>buildFacet</code>, <code>evaluateFacet</code>, <code>pluginFile</code>), an app chooses its host
|
||||
packages; (b) Chord knows only <code>host.reload(facets)</code> and the app bundles and evaluates. Recommended: (a); the kernel
|
||||
stays free of it, and Durable Objects (no <code>eval</code>) compose statically as before.</p>
|
||||
</div>
|
||||
|
|
@ -337,7 +337,7 @@ bundled script has no meaningful <code>import.meta.url</code> (today's <code
|
|||
<code>new URL("../../vendor/…", import.meta.url)</code>). The loader passes the plugin's directory, and a helper resolves a path
|
||||
inside it, refusing <code>..</code> out of it:</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/worker.ts</span>
|
||||
<span class="tk-k">import</span> { pluginFile } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/loader"</span>; <span class="tk-c">// reads the directory the loader passed</span>
|
||||
<span class="tk-k">import</span> { pluginFile } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/loader"</span>; <span class="tk-c">// reads the directory the loader passed</span>
|
||||
<span class="tk-k">const</span> template = <span class="tk-k">await</span> <span class="tk-f">readFile</span>(<span class="tk-f">pluginFile</span>(<span class="tk-s">"assets/template.md"</span>), <span class="tk-s">"utf8"</span>);</code></pre>
|
||||
<p><strong>3. Offered for download over HTTP.</strong> A plugin contributes a route to the app's HTTP registry; the handler opens the
|
||||
file through the same helper and streams it, with the plugin's own authorization:</p>
|
||||
|
|
@ -359,7 +359,7 @@ own plugin's directory; nothing gives it another plugin's files.</p>
|
|||
</section>
|
||||
|
||||
<section class="chapter" id="http"><header class="opener"><div class="numeral">IV</div><h2>HTTP</h2><p class="lede">What Chord provides, what p2 has, and what p3 needs.</p></header>
|
||||
<p class="dropcap"><strong>Chord v2 has no HTTP server today.</strong> Design §23 designs one (<code>@earendil-works/chord/next/http</code>) and defers it until
|
||||
<p class="dropcap"><strong>Chord v2 has no HTTP server today.</strong> Design §23 designs one (<code>@earendil-works/chord/http</code>) and defers it until
|
||||
"the kernel fixes and a first real proof" are done. p2's HTTP is its own, about 30 lines of helpers plus the server host:</p>
|
||||
<pre><code><span class="tk-c">// apps/p2/src/runtime/server.ts (p2 today, Chord v1)</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Routes</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">RouteHandler</span>>(<span class="tk-s">"p2.routes"</span>, { conflict: <span class="tk-s">"error"</span> }); <span class="tk-c">// "METHOD /path" → handler</span>
|
||||
|
|
@ -443,13 +443,13 @@ the reload control. Almost none of it is p2-specific. The proposal: Chord ships
|
|||
subpath an app imports only if it wants it, none of them in Chord's kernel.</p>
|
||||
<h3 id="what-it-would-look-like">What it would look like</h3>
|
||||
<pre><code><span class="tk-c">// server.ts: a whole app with plugins, a web and a TUI surface</span>
|
||||
<span class="tk-k">import</span> { createHost } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||||
<span class="tk-k">import</span> { discoverPlugins, pluginLoader } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/loader"</span>;
|
||||
<span class="tk-k">import</span> { httpServer } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/http"</span>;
|
||||
<span class="tk-k">import</span> { workerRoute } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/workers"</span>;
|
||||
<span class="tk-k">import</span> { webSurface } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/web"</span>;
|
||||
<span class="tk-k">import</span> { tuiSurface } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/tui"</span>;
|
||||
<span class="tk-k">import</span> { devReload } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/dev"</span>;
|
||||
<span class="tk-k">import</span> { createHost } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord"</span>;
|
||||
<span class="tk-k">import</span> { discoverPlugins, pluginLoader } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/loader"</span>;
|
||||
<span class="tk-k">import</span> { httpServer } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/http"</span>;
|
||||
<span class="tk-k">import</span> { workerRoute } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/workers"</span>;
|
||||
<span class="tk-k">import</span> { webSurface } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/web"</span>;
|
||||
<span class="tk-k">import</span> { tuiSurface } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/tui"</span>;
|
||||
<span class="tk-k">import</span> { devReload } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/dev"</span>;
|
||||
<span class="tk-k">import</span> { <span class="tk-t">Server</span>, <span class="tk-t">Session</span>, core } <span class="tk-k">from</span> <span class="tk-s">"./core.ts"</span>; <span class="tk-c">// the app's base code: host kinds, principals, core facet</span>
|
||||
|
||||
<span class="tk-k">const</span> plugins = <span class="tk-k">await</span> <span class="tk-f">discoverPlugins</span>([<span class="tk-s">"./plugins"</span>, <span class="tk-f">join</span>(dataDir, <span class="tk-s">"plugins"</span>)]);
|
||||
|
|
@ -464,7 +464,7 @@ http.<span class="tk-f">surface</span>(<span class="tk-f">webSurface</span>({ lo
|
|||
http.<span class="tk-f">surface</span>(<span class="tk-f">tuiSurface</span>({ loader, plugins })); <span class="tk-c">// the TUI launcher's bundle and socket</span>
|
||||
<span class="tk-f">devReload</span>({ server, loader, plugins, watch: <span class="tk-k">true</span> }); <span class="tk-c">// a changed plugin reloads everywhere</span></code></pre>
|
||||
<pre><code><span class="tk-c">// worker.ts</span>
|
||||
<span class="tk-k">import</span> { workerMain } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next/workers"</span>;
|
||||
<span class="tk-k">import</span> { workerMain } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/workers"</span>;
|
||||
<span class="tk-k">await</span> <span class="tk-f">workerMain</span>(<span class="tk-k">async</span> ({ address, init, parent }) => {
|
||||
<span class="tk-k">const</span> worker = <span class="tk-f">createHost</span><P>({ address, principal: { kind: <span class="tk-s">"worker"</span>, session: init.id }, onError });
|
||||
worker.<span class="tk-f">connect</span>(parent, { principal: { kind: <span class="tk-s">"server"</span> }, trusted: <span class="tk-k">true</span> }, ctx);
|
||||
|
|
@ -474,7 +474,7 @@ http.<span class="tk-f">surface</span>(<span class="tk-f">tuiSurface</span>({ lo
|
|||
<pre><code><span class="tk-c">// core.ts, the web shell: the app decides the layout; plugins fill registries</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Panels</span> = defineRegistry<<span class="tk-t">Record</span><string, <span class="tk-t">ComponentType</span>>, <span class="tk-t">Panel</span>[]>(<span class="tk-s">"app.panels"</span>, { version: <span class="tk-n">1</span> });
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> webShell = ({ host }: <span class="tk-t">ShellProps</span>) => {
|
||||
<span class="tk-k">const</span> panels = <span class="tk-f">useRegistry</span>(host, <span class="tk-t">Panels</span>); <span class="tk-c">// from chord/next/web: a hook over a registry value</span>
|
||||
<span class="tk-k">const</span> panels = <span class="tk-f">useRegistry</span>(host, <span class="tk-t">Panels</span>); <span class="tk-c">// from chord/web: a hook over a registry value</span>
|
||||
<span class="tk-k">return</span> <<span class="tk-t">Tabs</span>>{panels.<span class="tk-f">map</span>((p) => <p.component key={p.name} />)}</<span class="tk-t">Tabs</span>>;
|
||||
};</code></pre>
|
||||
<p>And a plugin is what chapter I shows: a directory, a <code>package.json</code>, facets that serve types and contribute to the
|
||||
|
|
@ -489,32 +489,32 @@ app's registries (<code>Http</code>, <code>Panels</code>, a TUI command list
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td><code>chord/next/loader</code></td>
|
||||
<td><code>chord/loader</code></td>
|
||||
<td><code>discoverPlugins</code> (directory listing, <code>package.json</code> names, no parsing), <code>buildFacet</code> (esbuild, host packages external), <code>evaluateFacet</code> (Node <code>vm</code>, browser <code><script></code>), <code>pluginFile</code>, npm/git install with <code>--ignore-scripts</code></td>
|
||||
<td><code>src/hosts/plugins.ts</code>, <code>bundles.ts</code>, the source resolver, <code>nodeLoader</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/http</code></td>
|
||||
<td><code>chord/http</code></td>
|
||||
<td>the Node HTTP server, the <code>Http</code> registry (routes, wraps, request resolvers, responses as descriptions: ranges, downloads, <code>HEAD</code>), WebSocket entry calling <code>host.accept</code></td>
|
||||
<td><code>src/hosts/server.ts</code> (HTTP part), <code>runtime/http.ts</code>, <code>Routes</code>, <code>Upgrades</code>, <code>RequestResolvers</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/workers</code></td>
|
||||
<td><code>chord/workers</code></td>
|
||||
<td><code>workerRoute</code> (fork a worker per address, IPC link, start message, idle stop from <code>host.activity</code>, crash restart with backoff), <code>workerMain</code> (the child's side)</td>
|
||||
<td><code>SessionHosts</code> in <code>runtime/server.ts</code>, <code>hosts/worker.ts</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/web</code></td>
|
||||
<td><code>chord/web</code></td>
|
||||
<td>the page host (connect over a WebSocket with reconnect, load web facets from their scripts, React provider and hooks: <code>useObject</code> over <code>host.hold</code>, <code>useRegistry</code>, <code>useWatch</code>), serving the bundle by hash, reload on a new manifest</td>
|
||||
<td><code>hosts/web/*</code>, <code>runtime/web.tsx</code>, <code>src/web/reconnect.ts</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/tui</code></td>
|
||||
<td><code>chord/tui</code></td>
|
||||
<td>the TUI launcher and sandbox (pi-tui, the bundle download, a link over the launcher's socket), slots and commands as registries</td>
|
||||
<td><code>hosts/tui-*.ts</code>, <code>runtime/tui.ts</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chord/next/dev</code></td>
|
||||
<td><code>chord/dev</code></td>
|
||||
<td>the source watcher marking plugins dirty, the reload control (<code>reload(plugins)</code> on every host in order: server, workers, then a manifest bump for pages and the TUI)</td>
|
||||
<td><code>performReload</code> in <code>hosts/server.ts</code>, the dev protocol</td>
|
||||
</tr>
|
||||
|
|
@ -779,7 +779,7 @@ contracts bundled, no module map, stable script names, plugins' npm dependen
|
|||
</tr>
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td>where loader D lives: Chord (<code>chord/next/loader</code>), or the app</td>
|
||||
<td>where loader D lives: Chord (<code>chord/loader</code>), or the app</td>
|
||||
<td>Chord</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# Slice 7c: plugins (spec)
|
||||
|
||||
Status: **ready to build** (owner, 2026-10-30: decisions "Slice 7c's points" and "Per plugin at startup and reload";
|
||||
spec reviews by Fable and Opus, `/tmp/s7c-spec-review-{fable,opus}.md`, folded in). 7c builds `chord/next/plugins`: the
|
||||
spec reviews by Fable and Opus, `/tmp/s7c-spec-review-{fable,opus}.md`, folded in). 7c builds `chord/plugins`: the
|
||||
code that turns plugin directories into facets, loads and reloads them per plugin, and watches them. A Chord app is
|
||||
self-modifying (the owner or an agent edits a plugin while it runs), so this is core machinery, not a dev tool (no
|
||||
dev/prod split). It also changes the kernel's `load` and `reload` to per-plugin failure (§4). HTTP and workers are slice
|
||||
|
|
@ -14,7 +14,7 @@ Read in full before writing code: this spec; `decisions.md` (2026-10-29/30: "Sli
|
|||
and Loading", "Assets and Files", "Performance", "A Mini p2 from Chord"); the two spec reviews; p2's
|
||||
`apps/p2/src/hosts/{bundles,plugins}.ts` and `apps/p2/docs/plugin-loading.md` §4.1, §7.1, §8, §9, §14; coding-agent's
|
||||
`packages/coding-agent/src/core/package-manager.ts` (dependency installs). Code lives in
|
||||
`packages/chord/src/next-node/plugins/` (Node-only: `fs`, esbuild, `vm`; the kernel in `src/next` stays portable),
|
||||
`packages/chord/src/node/plugins/` (Node-only: `fs`, esbuild, `vm`; the kernel stays portable),
|
||||
exported as `./next/plugins` when v2 gets its entry point; until then tests import the source.
|
||||
|
||||
## 1. A plugin on disk
|
||||
|
|
@ -55,8 +55,8 @@ value a worker (slice 10) or a page (slice 11) evaluates where it runs.
|
|||
| a file of another discovered plugin (`../sessions/contract.ts`) | inlined (a copy), listed in `copied`; it may import only host packages and files of its own plugin, never packages (a build error otherwise): contracts and side-effect-free modules only (decision 4) |
|
||||
| a package in the plugin's `dependencies` | inlined from its `node_modules` |
|
||||
| a package in `chord.external` (Node builds) | external, `require`d at evaluation from the plugin's `node_modules`; a browser build fails |
|
||||
| a host package (`options.hostPackages`, e.g. `@earendil-works/chord/next`, `react`, `react/jsx-runtime`, `@app/core`) | external, from the map at evaluation |
|
||||
| `@earendil-works/chord/next/plugins` | external always (its `pluginFile` is per facet, §3) |
|
||||
| a host package (`options.hostPackages`, e.g. `@earendil-works/chord`, `react`, `react/jsx-runtime`, `@app/core`) | external, from the map at evaluation |
|
||||
| `@earendil-works/chord/plugins` | external always (its `pluginFile` is per facet, §3) |
|
||||
| a Node builtin (`node:fs`, `fs`) | external (Node builds); a browser build fails |
|
||||
| CSS (browser builds) | one stylesheet per facet (`css`) |
|
||||
| an asset (`import url from "./icon.svg?url"`, `.wasm`, browser builds) | a content-hashed file in `assets`; the import is its URL under the app's `options.publicPath` |
|
||||
|
|
@ -79,7 +79,7 @@ const facet = evaluateFacet(built, { hostPackages: HOST, plugin }); // returns
|
|||
plus the digest), run in this context; the default export, checked to be a facet (a declaration error otherwise).
|
||||
- **The `require` it passes answers, in order:** the host-package map (each value is what Node's `require` would give:
|
||||
a CommonJS package's exports, an ESM package's namespace wrapped once with `__esModule: true`, so default and named
|
||||
imports both work); `@earendil-works/chord/next/plugins` → `{ pluginFile }` bound to this plugin's directory;
|
||||
imports both work); `@earendil-works/chord/plugins` → `{ pluginFile }` bound to this plugin's directory;
|
||||
`chord.external` packages and Node builtins through `createRequire(join(plugin.dir, "package.json"))`; anything else
|
||||
throws naming the facet.
|
||||
- **`pluginFile(path)`** gives the absolute path of a file inside the plugin's directory; `..` out of it is refused.
|
||||
|
|
@ -145,7 +145,7 @@ of cross-plugin imports beyond §2's rule.
|
|||
|
||||
## 7. Tests, examples, cost
|
||||
|
||||
- One test file per source file; fixture plugins under `test/next-node/fixtures/plugins/`. Discovery (names, duplicates,
|
||||
- One test file per source file; fixture plugins under `test/node/fixtures/plugins/`. Discovery (names, duplicates,
|
||||
missing or invalid `package.json`, two files of a kind, contracts-only plugins, symlinks); build (every row of §2's
|
||||
table, failures as results, relative source maps and stable digests across two checkout paths, inputs hashed at
|
||||
load); evaluation (host packages as the host's instances: `instanceof ChordError` across a plugin; default and named
|
||||
|
|
@ -15,9 +15,9 @@ entries it takes), the owner's approval, a build by a fresh Opus 5.5 subagent, r
|
|||
session and by the reviewers Astra and Fable (`v2-review-astra`, `v2-review-fable`), fixes by the builder, the owner's
|
||||
review of the uncommitted diff, commit. Deferred work goes into the section "Carry-over" below, under the slice
|
||||
that must do it, in the same change (owner, 2026-10-22; moved here from `carry-over.md` on 2026-10-24). Every slice that touches a hot path (links, the store, relays) also adds a light benchmark script
|
||||
(`packages/chord/test/next/bench/`, run by hand) for timing, memory and wire size of realistic payloads, with results
|
||||
(`packages/chord/test/bench/`, run by hand) for timing, memory and wire size of realistic payloads, with results
|
||||
in `benchmarks.md` (owner, 2026-10-22). Every slice adds one or more runnable
|
||||
examples (`packages/chord/test/next/examples/NN-topic.ts`, numbered across slices, in the format of Durable's
|
||||
examples (`packages/chord/test/examples/NN-topic.ts`, numbered across slices, in the format of Durable's
|
||||
`test/examples/`), each on one topic: for the owner to run and dig around in, and as references for Chord's users
|
||||
(owner, 2026-10-22). Implementers and reviewers read
|
||||
the slice spec, the feature map below and the whole of `design.md` in full, and check the work against all three; a
|
||||
|
|
@ -25,25 +25,25 @@ slice spec's section citations are for orientation, not a reading limit (owner,
|
|||
|
||||
## What exists
|
||||
|
||||
- **L1** (`src/next/contracts`, `src/next/data`): contracts, params and identity, argument checks, principal keys,
|
||||
- **L1** (`src/contracts`, `src/data`): contracts, params and identity, argument checks, principal keys,
|
||||
errors. `index.ts` for apps, `internal.ts` for Chord's own code.
|
||||
- **Slices 1, 2, 3a, 3b, 4a, 4b, 5, 6a, 6b, 8, 7a** (committed; 5 `4a5b45c45`, 6a and 6b 2026-10-27/28, 8 2026-10-28, 7a
|
||||
2026-10-29): `src/next/documents`, `state`, `host`, `jobs`, `link`, `remote`, `store`, `registries`; examples 01-32
|
||||
2026-10-29): `src/documents`, `state`, `host`, `jobs`, `link`, `remote`, `store`, `registries`; examples 01-32
|
||||
(Durable's 35-37); benchmarks in `benchmarks.md`.
|
||||
- **Slice 7b** (built 2026-10-29, `slice-7b.md`): `host.reload`, staging (`host/staging.ts`), fates, adapted members
|
||||
(`host/adapted.ts`), references, jobs and registries across a reload; examples 33-34; `test/next/bench/reload.ts`.
|
||||
- **Slice 7c** (committed 2026-10-30, `slice-7c.md`): `src/next-node/plugins/` (discovery, builds, evaluation,
|
||||
(`host/adapted.ts`), references, jobs and registries across a reload; examples 33-34; `test/bench/reload.ts`.
|
||||
- **Slice 7c** (committed 2026-10-30, `slice-7c.md`): `src/node/plugins/` (discovery, builds, evaluation,
|
||||
`pluginFile`, `mapStack`, `installDependencies`, the watcher, `rollout`), per-plugin `load` and `reload`; examples 35-39.
|
||||
- **Slice 10** (built 2026-10-31, `slice-10.md`): `src/next-node/http/` (a `fetch` core with the security baseline,
|
||||
Chord's Node listener, `nodeRequest`, plugin sockets, links), `src/next-node/workers/`, `createPluginRuntime`, kinds,
|
||||
- **Slice 10** (built 2026-10-31, `slice-10.md`): `src/node/http/` (a `fetch` core with the security baseline,
|
||||
Chord's Node listener, `nodeRequest`, plugin sockets, links), `src/node/workers/`, `createPluginRuntime`, kinds,
|
||||
the kernel's `RouteHandle` and anonymous `host.root`, origins only from relays; examples 40-48.
|
||||
- **Slice 12** (committed 2026-11-01, `slice-12.md`): the plugin model in `src/next-node/plugins/` (the app's versioned
|
||||
- **Slice 12** (committed 2026-11-01, `slice-12.md`): the plugin model in `src/node/plugins/` (the app's versioned
|
||||
API, per-kind files, single-file plugins, installs from git and npm, `testPlugin`); examples 49-52.
|
||||
- **Slice 13** (committed 2026-11-02, `slice-13.md`): the app kit, `src/next-node/app/` (`startServer`, `startWorker`,
|
||||
- **Slice 13** (committed 2026-11-02, `slice-13.md`): the app kit, `src/node/app/` (`startServer`, `startWorker`,
|
||||
sessions, owner link, pairing, `Files`, `exportData`/`importData`); examples 53-55.
|
||||
- **Slice 11** (committed 2026-11-03, `slice-11.md`): `src/next/surfaces/` (scripts objects, registered scripts, the
|
||||
connection loop, clients, themes), `src/next-web/`, `src/next-react/`, `src/next-tui/`, `src/next-node/surfaces/`,
|
||||
`src/next-node/tui/` (the launcher and its child-process sandbox); examples 56-59.
|
||||
- **Slice 11** (committed 2026-11-03, `slice-11.md`): `src/surfaces/` (scripts objects, registered scripts, the
|
||||
connection loop, clients, themes), `src/web/`, `src/react/`, `src/tui/`, `src/node/surfaces/`, `src/node/tui/` (the
|
||||
launcher and its child-process sandbox); examples 56-59.
|
||||
- **Chord's shared code** (upstream pi): `src/watch.ts` (`Watch`, `NativeWatch`, `WatchHandle`, `WatchEnd`,
|
||||
`NativeWatchable`), Delta (`track`, `diffRevisions`, `applyImmutable`, encoders, `PendingOps`, `appendText`),
|
||||
context, JSON, output, SQLite drivers.
|
||||
|
|
@ -68,11 +68,11 @@ telemetry last, since it instruments the app kit too; 2026-11-01: the plugin mod
|
|||
| 8 | **Registries and `host.root`** | registries, `self.writable`, `retryOn`, Durable's `registryReader`, `host.root` (`chord(fn, context)` and the harness object are p3's base code) |
|
||||
| 7a | **The dependency model** | requests that outlive providers, parked attempts without timers, wakes, the publication gate, getters, registries as dependencies, `parked_long` (`slice-7a.md`, `dependencies/model.md`) |
|
||||
| 7b | **Reload in the kernel** | `host.reload`: staging, commit, rollback; swaps, restarts, removal and re-serving; holds and `accepts` across a reload; jobs and registries across a reload; per-name wakes (`slice-7b/` brief) |
|
||||
| 7c | **Plugins** | `chord/next/plugins` (discovery, `buildFacet`, `evaluateFacet`, `pluginFile`, `installDependencies`, the watcher, `rollout`, `mapStack`; no dev/prod split; the kernel's `load`/`reload` per plugin; `slice-7c.md`) |
|
||||
| 10 | **HTTP and workers** | `chord/next/http` (server, `Http` registry, responses as descriptions, WebSocket entry) and `chord/next/workers` (a worker per address, IPC, idle stop) |
|
||||
| 7c | **Plugins** | `chord/plugins` (discovery, `buildFacet`, `evaluateFacet`, `pluginFile`, `installDependencies`, the watcher, `rollout`, `mapStack`; no dev/prod split; the kernel's `load`/`reload` per plugin; `slice-7c.md`) |
|
||||
| 10 | **HTTP and workers** | `chord/http` (server, `Http` registry, responses as descriptions, WebSocket entry) and `chord/workers` (a worker per address, IPC, idle stop) |
|
||||
| 12 | **The plugin model** | imports (relative within a repository, package names pinned across), versioned app API with Chord re-exported, per-kind API modules and contract files with the build's import check, single-file plugins, git installs, `testPlugin`, a required root (`slice-12.md`) |
|
||||
| 13 | **The app kit** | `chord/next/app`: `startServer`, `startWorker`, layout, worker kinds, owner link, deny by default, sessions, CLI tokens, storage, the files plugin, export per address, plugin roots and "apply changes", base code as API, bootstrap and a start-data facet (`slice-13.md`) |
|
||||
| 11 | **Web and TUI** | `chord/next/web` (page host, React hooks) and `chord/next/tui` (launcher, sandbox), from p2's code |
|
||||
| 13 | **The app kit** | `chord/app`: `startServer`, `startWorker`, layout, worker kinds, owner link, deny by default, sessions, CLI tokens, storage, the files plugin, export per address, plugin roots and "apply changes", base code as API, bootstrap and a start-data facet (`slice-13.md`) |
|
||||
| 11 | **Web and TUI** | `chord/web` (page host), `chord/react` (React hooks), `chord/tui` with `chord/tui/launcher` (sandbox, launcher) and `chord/surfaces` (their server side), from p2's code |
|
||||
| 9 | **Telemetry** | spans, events, metrics across hosts |
|
||||
|
||||
The playground (`apps/chord-playground`) moves to the new code once slice 4 exists. Round-3 cuts (C3-C16) and
|
||||
|
|
@ -196,7 +196,7 @@ deleted (the slice's spec and code are the record). Moved here from `carry-over.
|
|||
### After slice 11
|
||||
|
||||
- **Messages and languages in the app kit** (decision "Messages and languages in the app kit", proposal
|
||||
`docs/next/i18n/`): `defineMessages`, `t.message`/`t.render`, `MessagesOf`, the `Translations` registry with
|
||||
`docs/i18n/`): `defineMessages`, `t.message`/`t.render`, `MessagesOf`, the `Translations` registry with
|
||||
fallbacks and conflicts, the locale preference per principal (default from the device, live switch, `lang`/`dir` on
|
||||
the page), `useMessages` and the terminal scope's redraw, `formatTime`/`formatNumber`; tests and an example (the
|
||||
notes plugin in English and German, a French translation plugin, a server message rendered per viewer).
|
||||
|
|
@ -212,12 +212,11 @@ deleted (the slice's spec and code are the record). Moved here from `carry-over.
|
|||
|
||||
### When v2 goes upstream
|
||||
|
||||
- `mapShared` lives in `src/next/state/derive.ts` and moves into Delta. (L2 decisions.)
|
||||
- `withLinkedAbortSignal` lives in `src/next/host/ctx.ts` and moves next to `withAbortSignal` in `src/context` (pi
|
||||
- `mapShared` lives in `src/state/derive.ts` and moves into Delta. (L2 decisions.)
|
||||
- `withLinkedAbortSignal` lives in `src/host/ctx.ts` and moves next to `withAbortSignal` in `src/context` (pi
|
||||
first): a call's context linked to long-lived abort signals until the call settles, instead of `AbortSignal.any`,
|
||||
which keeps cleanup entries per call while its sources live before Node 24.16.0 (nodejs/node#62367). (Slice 4a.)
|
||||
- The `./next` package export returns when the new code has an entry point (removed with the frozen-kernel move).
|
||||
- A `./next/node` export for `nodeCompression` (`src/next-node/`), with `./next`. (Slice 4b.)
|
||||
- An export for `nodeCompression` (`src/node/compression.ts`; no subpath exports it yet). (Slice 4b.)
|
||||
|
||||
### The sync layer (not a slice; when Radius sync is built)
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ stats:
|
|||
- 0 | lines of plugin code a replica needs
|
||||
note: |
|
||||
Written for the owner from the discussion of 2026-10-21 and the store review by Fable and Astra. Every decision is in
|
||||
the decision log (`packages/chord/docs/next/decisions.md`, entries "The store", "S1" to "S17", "Context last"); the
|
||||
the decision log (`packages/chord/docs/decisions.md`, entries "The store", "S1" to "S17", "Context last"); the
|
||||
review is summarized in `apps/p2/docs/p3-reviews/store-review-summary.md`, bulk operations and migrations in
|
||||
`store-bulk-migrations.md`, the earlier discussion with measurements in `session-data-scenarios.md`. Numbers were
|
||||
measured on slayer with Node 24.4.1 and `node:sqlite` unless marked as estimates. Nothing here is built yet. Notes
|
||||
|
|
@ -612,7 +612,7 @@ outside it are small changes in pi:
|
|||
Session's handling of rejections after adoption, the narrowed `mintId` comment (S10), `traceIn(options, fn,
|
||||
context)` (context last), Durable's `WatchHandle`/`WatchEnd` moved into Chord with generic end reasons and explicit
|
||||
retirement (W2), and Durable's state getters and the `replicatedState` family removed (W14).
|
||||
- **The store** in `packages/chord/src/next`: commit step and outbox, tables and the query language, the SQLite
|
||||
- **The store** in `packages/chord/src`: commit step and outbox, tables and the query language, the SQLite
|
||||
backend with the text encoding, the documents engine, bulk operations and migrations, export and import;
|
||||
about 1,650 lines by Fable's estimate, plus its tests.
|
||||
- **`StoreStorage`**, passing Durable's existing storage conformance suite.
|
||||
|
|
@ -114,7 +114,7 @@ table{max-width:100%}
|
|||
<p class="subtitle">Why Durable's transcript, Chord's documents and every plugin's own tables live in one transactional store, and how that store moves a session to another machine without coming apart</p>
|
||||
<div class="cover-stats"><div class="stat"><b>17</b><span>review items decided (S1–S17)</span></div><div class="stat"><b>1</b><span>commit queue per store, one seq for every owner</span></div><div class="stat"><b>~150 B</b><span>outbox record for renaming 10,000 rows (instead of ~40 MB)</span></div><div class="stat"><b>0</b><span>lines of plugin code a replica needs</span></div></div>
|
||||
<div class="cover-note"><p>Written for the owner from the discussion of 2026-10-21 and the store review by Fable and Astra. Every decision is in
|
||||
the decision log (<code>packages/chord/docs/next/decisions.md</code>, entries "The store", "S1" to "S17", "Context last"); the
|
||||
the decision log (<code>packages/chord/docs/decisions.md</code>, entries "The store", "S1" to "S17", "Context last"); the
|
||||
review is summarized in <code>apps/p2/docs/p3-reviews/store-review-summary.md</code>, bulk operations and migrations in
|
||||
<code>store-bulk-migrations.md</code>, the earlier discussion with measurements in <code>session-data-scenarios.md</code>. Numbers were
|
||||
measured on slayer with Node 24.4.1 and <code>node:sqlite</code> unless marked as estimates. Nothing here is built yet. Notes
|
||||
|
|
@ -679,7 +679,7 @@ outside it are small changes in pi:</p>
|
|||
<li><strong>pi, one change:</strong> <code>Storage.commit(writes, adopt, context)</code> with every storage calling <code>adopt</code> (S1), the
|
||||
Session's handling of rejections after adoption, the narrowed <code>mintId</code> comment (S10), <code>traceIn(options, fn, context)</code> (context last), Durable's <code>WatchHandle</code>/<code>WatchEnd</code> moved into Chord with generic end reasons and explicit
|
||||
retirement (W2), and Durable's state getters and the <code>replicatedState</code> family removed (W14).</li>
|
||||
<li><strong>The store</strong> in <code>packages/chord/src/next</code>: commit step and outbox, tables and the query language, the SQLite and
|
||||
<li><strong>The store</strong> in <code>packages/chord/src</code>: commit step and outbox, tables and the query language, the SQLite and
|
||||
memory backends with the text encoding, the documents engine, bulk operations and migrations, export and import;
|
||||
about 1,650 lines by Fable's estimate, plus the conformance suite.</li>
|
||||
<li><strong><code>StoreStorage</code></strong>, passing Durable's existing storage conformance suite.</li>
|
||||
|
|
@ -5,8 +5,8 @@ kicker: A walkthrough · Chord v2 and p3 · 2026-10-21
|
|||
subtitle: How one transactional store keeps a p3 session's transcript, documents and plugin tables consistent, taught one concept at a time
|
||||
model: anthropic/claude-sonnet-5-5
|
||||
sources:
|
||||
- packages/chord/docs/next/store/book.md
|
||||
- packages/chord/docs/next/decisions.md
|
||||
- packages/chord/docs/store/book.md
|
||||
- packages/chord/docs/decisions.md
|
||||
- apps/p2/docs/p3-reviews/store-review-summary.md
|
||||
- apps/p2/docs/p3-reviews/store-bulk-migrations.md
|
||||
- apps/p2/docs/p3-reviews/session-data-scenarios.md
|
||||
|
|
@ -188,6 +188,6 @@ never overlap: chain or drop stale results); text compares like JS strings; pref
|
|||
|
||||
# What comes next {#next part="Where it stands"}
|
||||
Book "What's Next"; log entries. Must cover: everything decided on 2026-10-21; the pi change (adopt argument, Session
|
||||
rejection handling, mintId wording, traceIn context last); the store in packages/chord/src/next (about 1,650 lines
|
||||
rejection handling, mintId wording, traceIn context last); the store in packages/chord/src (about 1,650 lines
|
||||
estimated); StoreStorage passing Durable's conformance suite; the sync layer later; open points (new id of a split
|
||||
session, chunk size, whether IndexedDB is needed while sqlite-wasm covers the browser).
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@earendil-works/chord",
|
||||
"version": "1.5.0",
|
||||
"description": "Typed host runtime for plugins, contracts, replicated state, and RPC over JSON links",
|
||||
"description": "Object runtime for apps across processes: contracts, documents, a store, links, plugins with hot reload, HTTP, workers, web and terminal clients",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
|
@ -16,30 +16,25 @@
|
|||
"types": "./dist/context/index.d.ts",
|
||||
"import": "./dist/context/index.js"
|
||||
},
|
||||
"./output": {
|
||||
"source": "./src/output.ts",
|
||||
"types": "./dist/output.d.ts",
|
||||
"import": "./dist/output.js"
|
||||
},
|
||||
"./delta": {
|
||||
"source": "./src/delta/index.ts",
|
||||
"types": "./dist/delta/index.d.ts",
|
||||
"import": "./dist/delta/index.js"
|
||||
},
|
||||
"./node": {
|
||||
"source": "./src/node.ts",
|
||||
"types": "./dist/node.d.ts",
|
||||
"import": "./dist/node.js"
|
||||
"./json": {
|
||||
"source": "./src/json.ts",
|
||||
"types": "./dist/json.d.ts",
|
||||
"import": "./dist/json.js"
|
||||
},
|
||||
"./telemetry-sqlite": {
|
||||
"source": "./src/telemetry-sqlite/index.ts",
|
||||
"types": "./dist/telemetry-sqlite/index.d.ts",
|
||||
"import": "./dist/telemetry-sqlite/index.js"
|
||||
"./watch": {
|
||||
"source": "./src/watch.ts",
|
||||
"types": "./dist/watch.d.ts",
|
||||
"import": "./dist/watch.js"
|
||||
},
|
||||
"./next": {
|
||||
"source": "./src/next/index.ts",
|
||||
"types": "./dist/next/index.d.ts",
|
||||
"import": "./dist/next/index.js"
|
||||
"./output": {
|
||||
"source": "./src/output.ts",
|
||||
"types": "./dist/output.d.ts",
|
||||
"import": "./dist/output.js"
|
||||
},
|
||||
"./sqlite": {
|
||||
"source": "./src/sqlite/index.ts",
|
||||
|
|
@ -56,6 +51,71 @@
|
|||
"types": "./dist/sqlite/cloudflare.d.ts",
|
||||
"import": "./dist/sqlite/cloudflare.js"
|
||||
},
|
||||
"./telemetry-sqlite": {
|
||||
"source": "./src/telemetry-sqlite/index.ts",
|
||||
"types": "./dist/telemetry-sqlite/index.d.ts",
|
||||
"import": "./dist/telemetry-sqlite/index.js"
|
||||
},
|
||||
"./http": {
|
||||
"source": "./src/node/http/index.ts",
|
||||
"types": "./dist/node/http/index.d.ts",
|
||||
"import": "./dist/node/http/index.js"
|
||||
},
|
||||
"./workers": {
|
||||
"source": "./src/node/workers/index.ts",
|
||||
"types": "./dist/node/workers/index.d.ts",
|
||||
"import": "./dist/node/workers/index.js"
|
||||
},
|
||||
"./plugins": {
|
||||
"source": "./src/node/plugins/index.ts",
|
||||
"types": "./dist/node/plugins/index.d.ts",
|
||||
"import": "./dist/node/plugins/index.js"
|
||||
},
|
||||
"./plugins/runtime": {
|
||||
"source": "./src/node/plugins/runtime.ts",
|
||||
"types": "./dist/node/plugins/runtime.d.ts",
|
||||
"import": "./dist/node/plugins/runtime.js"
|
||||
},
|
||||
"./app": {
|
||||
"source": "./src/node/app/index.ts",
|
||||
"types": "./dist/node/app/index.d.ts",
|
||||
"import": "./dist/node/app/index.js"
|
||||
},
|
||||
"./web": {
|
||||
"source": "./src/web/index.ts",
|
||||
"types": "./dist/web/index.d.ts",
|
||||
"import": "./dist/web/index.js"
|
||||
},
|
||||
"./surfaces": {
|
||||
"source": "./src/node/surfaces/index.ts",
|
||||
"types": "./dist/node/surfaces/index.d.ts",
|
||||
"import": "./dist/node/surfaces/index.js"
|
||||
},
|
||||
"./react": {
|
||||
"source": "./src/react/index.ts",
|
||||
"types": "./dist/react/index.d.ts",
|
||||
"import": "./dist/react/index.js"
|
||||
},
|
||||
"./tui": {
|
||||
"source": "./src/tui/index.ts",
|
||||
"types": "./dist/tui/index.d.ts",
|
||||
"import": "./dist/tui/index.js"
|
||||
},
|
||||
"./tui/launcher": {
|
||||
"source": "./src/node/tui/index.ts",
|
||||
"types": "./dist/node/tui/index.d.ts",
|
||||
"import": "./dist/node/tui/index.js"
|
||||
},
|
||||
"./old": {
|
||||
"source": "./src/old/index.ts",
|
||||
"types": "./dist/old/index.d.ts",
|
||||
"import": "./dist/old/index.js"
|
||||
},
|
||||
"./old/node": {
|
||||
"source": "./src/old/node.ts",
|
||||
"types": "./dist/old/node.d.ts",
|
||||
"import": "./dist/old/node.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"sideEffects": false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Accepted versions: the older versions a provider still serves, with the direct mappings their edits require.
|
||||
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import {
|
||||
type Contract,
|
||||
type ContractData,
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
// Contract handles: the branded, immutable value an app declares, its type-level shape, and the Chord-owned data
|
||||
// behind it.
|
||||
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Watchable, WatchHandle } from "../../watch.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import type { Watchable, WatchHandle } from "../watch.ts";
|
||||
import type { Change, CurrentStep, History, VariantShape } from "./history.ts";
|
||||
import type { Address } from "./host-kind.ts";
|
||||
import type { Params, ParamsData } from "./params.ts";
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Fingerprints: the normal form of a contract version and its portable 64-bit hash. Not a security boundary.
|
||||
|
||||
import type { JsonValue } from "../../json.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
import { type Kind, LOCAL, type VariantData, type VersionData } from "./contract.ts";
|
||||
import type { Change } from "./history.ts";
|
||||
import type { ParamsData } from "./params.ts";
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Params declarations: the policy for params fields and the closed object schema checked on every get.
|
||||
|
||||
import type { JsonValue } from "../../json.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
import {
|
||||
type BooleanSchema,
|
||||
type IntegerSchema,
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// Chord-owned copy and its normal form (the fingerprint input).
|
||||
|
||||
import type { JsonSchema } from "@earendil-works/pi-schema";
|
||||
import { isJsonValue, type JsonValue } from "../../json.ts";
|
||||
import { isJsonValue, type JsonValue } from "../json.ts";
|
||||
|
||||
/**
|
||||
* A Chord-owned copy of an app schema, its normal form, and whether it has a `bytes()` leaf (values crossing a link
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Arguments: a provider's binding (its dispatch table per accepted version) and the per-call argument check.
|
||||
|
||||
import type { Validator } from "@earendil-works/pi-schema";
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import { type AcceptedVersion, type AcceptsTable, checkAccepts } from "../contracts/accepts.ts";
|
||||
import {
|
||||
type Contract,
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// ChordError: Chord's one error type, its codes and reasons (link protocol) and its wire form; and PluginError, what a
|
||||
// plugin throws to give its callers a code of its own.
|
||||
|
||||
import type { JsonValue } from "../../json.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
|
||||
/** Version of the error table below on links; adding or moving a code or reason bumps it. */
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// Params and identity: the checked params of a get, their key, and the in-process identity of an object.
|
||||
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import { type Contract, contractData, type ParamsOf, type ShapeOf } from "../contracts/contract.ts";
|
||||
import { type Address, addressKey } from "../contracts/host-kind.ts";
|
||||
import { joinKey, type ParamsData } from "../contracts/params.ts";
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Principals: the app's caller identities, and the keys Chord derives from them.
|
||||
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { JsonValue } from "../../json.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
|
||||
/** An app's principal: plain JSON with a `kind`. Chord never persists it. */
|
||||
export type Principal = { readonly kind: string; readonly [field: string]: JsonValue };
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Compiled checks: pi-schema validators of Chord-owned schemas, and the `invalid` error of a failed check.
|
||||
|
||||
import { compile, type JsonSchema, type Validator } from "@earendil-works/pi-schema";
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import { type ChordError, invalid } from "./errors.ts";
|
||||
|
||||
/** A Chord-owned record holding a schema: a snapshot, or a contract's params. */
|
||||
|
|
@ -87,5 +87,5 @@ replacements, retirements), `persistence.ts` (a slot's stored side: load, find,
|
|||
`residency.ts` (keyed residents and their eviction: Durable's idle structure, one timer per object), `handles.ts`,
|
||||
`fence.ts`, `transaction.ts`, `line.ts` (one holder at a time, in order) and `documents.ts`.
|
||||
|
||||
Tests: `test/next/documents/` (one file per source). Examples: `02-documents.ts`, `21-persisted-documents.ts`,
|
||||
Tests: `test/documents/` (one file per source). Examples: `02-documents.ts`, `21-persisted-documents.ts`,
|
||||
`23-keyed-watches.ts`.
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
// another base as a root replacement, a retirement as `[["r", initial()]]` with the `retired` flag, and a document
|
||||
// created after a retirement in the same batch as one root replacement with the flag.
|
||||
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Op, Prepared } from "../../delta/index.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import type { Op, Prepared } from "../delta/index.ts";
|
||||
import { Source } from "../state/internal.ts";
|
||||
import type { JsonObject } from "./definition.ts";
|
||||
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
// Document definitions: `defineState(name, ...)`, its fingerprint, the one check of a definition's `initial()` (R8),
|
||||
// the checked `migrate` of an older value and, for a persisted document, its checkpoint rule (slice 6a §10).
|
||||
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Op } from "../../delta/index.ts";
|
||||
import type { JsonValue } from "../../json.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import type { Schema, Static } from "../contracts/index.ts";
|
||||
import { checkName, fingerprintOf } from "../contracts/internal.ts";
|
||||
import { snapshotSchema } from "../contracts/snapshot.ts";
|
||||
import { validatorFor } from "../data/internal.ts";
|
||||
import type { Op } from "../delta/index.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
|
||||
/** A document root: a JSON object. */
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
// jobs run one at a time), the seal fence over the transaction holding the line, the factory's pins between attempts,
|
||||
// the activation's loads and the residency of keyed documents.
|
||||
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import { ChordError } from "../data/index.ts";
|
||||
import type { Batch } from "../state/internal.ts";
|
||||
import type { ChordStore } from "../store/store.ts";
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
// Document handles (slice 6a §1.2): what `self.state` returns, one per attempt and definition, a thin view of its
|
||||
// object's slot bound to the attempt's seal token and the definition's version.
|
||||
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Draft } from "../../delta/index.ts";
|
||||
import type { Watch, Watchable, WatchHandle } from "../../watch.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import type { Draft } from "../delta/index.ts";
|
||||
import type { Watch, Watchable, WatchHandle } from "../watch.ts";
|
||||
import type { JsonObject, StateDefinition } from "./definition.ts";
|
||||
import type { SealToken } from "./fence.ts";
|
||||
import type { KeyedSlot, KeySource } from "./keyed-slot.ts";
|
||||
|
|
@ -3,13 +3,13 @@
|
|||
// load in flight per key, shared) and leaves memory when unpinned and idle for the retention (`residency.ts`). A key
|
||||
// read through `at(key)` or a watch has its own source, which exists while the key is pinned.
|
||||
|
||||
import { awaitWithContext, type Context } from "../../context/index.ts";
|
||||
import { type Prepared, type Tracker, track } from "../../delta/index.ts";
|
||||
import type { Watch } from "../../watch.ts";
|
||||
import { awaitWithContext, type Context } from "../context/index.ts";
|
||||
import { ChordError } from "../data/index.ts";
|
||||
import { asChordError } from "../data/internal.ts";
|
||||
import { type Prepared, type Tracker, track } from "../delta/index.ts";
|
||||
import type { StagedWrite } from "../store/step.ts";
|
||||
import { compareText, storedText } from "../store/text.ts";
|
||||
import type { Watch } from "../watch.ts";
|
||||
import { AddressSource } from "./address-source.ts";
|
||||
import type { JsonObject } from "./definition.ts";
|
||||
import type { Documents } from "./documents.ts";
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
// loaded document (Durable's `LoadedDocument` fields), its loads with the version and fingerprint checks and the
|
||||
// checked `migrate`, and the writes one commit stages for a changed or retired document.
|
||||
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import type { Op, Prepared } from "../../delta/index.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import type { Op, Prepared } from "../delta/index.ts";
|
||||
import {
|
||||
changeDocument,
|
||||
createDocument,
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
// object, Durable's (`Session.#touchDocument`): an insertion-ordered map whose oldest entry the timer waits for, a
|
||||
// touch moving an entry to the back at most once per granule (O(1) per touch, no timer per key).
|
||||
|
||||
import type { Tracker } from "../../delta/index.ts";
|
||||
import type { Tracker } from "../delta/index.ts";
|
||||
import type { JsonObject } from "./definition.ts";
|
||||
import type { Stored } from "./persistence.ts";
|
||||
|
||||
|
|
@ -3,9 +3,9 @@
|
|||
// the watches; each attempt's handles are thin views of it. This file: what every slot is, and the singleton's slot
|
||||
// (the keyed one is `keyed-slot.ts`).
|
||||
|
||||
import type { Context } from "../../context/index.ts";
|
||||
import { type Prepared, type Tracker, track } from "../../delta/index.ts";
|
||||
import type { Context } from "../context/index.ts";
|
||||
import { ChordError } from "../data/index.ts";
|
||||
import { type Prepared, type Tracker, track } from "../delta/index.ts";
|
||||
import type { StagedWrite } from "../store/step.ts";
|
||||
import { AddressSource } from "./address-source.ts";
|
||||
import type { CommitPersistence, JsonObject, StateDefinition } from "./definition.ts";
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
// Transactions (§9, R12, R13): drafts of an object's documents, prepared and adopted in one batch, with Chord's own
|
||||
// staged changes (job runs, §15), fenced by the token they run under; they run on the object's line (`line.ts`).
|
||||
|
||||
import { type Context, withoutAbortSignal } from "../../context/index.ts";
|
||||
import { type Change, type Draft, type Prepared, type Tracker, track } from "../../delta/index.ts";
|
||||
import { type Context, withoutAbortSignal } from "../context/index.ts";
|
||||
import { ChordError } from "../data/index.ts";
|
||||
import { asChordError } from "../data/internal.ts";
|
||||
import { type Change, type Draft, type Prepared, type Tracker, track } from "../delta/index.ts";
|
||||
import type { JobHandle, Outcome, Run, StartOptions } from "../jobs/index.ts";
|
||||
import type { Job } from "../jobs/internal.ts";
|
||||
import type { Columns, PutRowOf, RowOf } from "../store/columns.ts";
|
||||
|
|
@ -112,7 +112,7 @@ type Revocation = { grant: string } | { session: string };
|
|||
const server = createHost<P, Revocation>({
|
||||
address: Server(), principal: { kind: "server" }, onError,
|
||||
revokedBy: (p) => (p.kind === "guest" ? [{ grant: p.grant }, { session: p.session }] : []), // the same on every host
|
||||
compression: nodeCompression, // Node only: `src/next-node/compression.ts`
|
||||
compression: nodeCompression, // Node only: `src/node/compression.ts`
|
||||
});
|
||||
server.revoke({ grant: "g1" }, ctx); // ends g1's objects here and on every worker, closes g1's links
|
||||
host.serve(Notes, ({ session }, principal, self) => { // in a facet: `host.references` is the facet's host's too
|
||||
|
|
@ -523,8 +523,8 @@ A native that follows a relocation (design §6) is not restarted at all: the nat
|
|||
routine on the session location (slice 5) switches the resource it manages (the watched directory) and bumps a
|
||||
generation, so late events from the old location are dropped.
|
||||
|
||||
Runnable examples, one topic each, in `test/next/examples/` (run from `packages/chord` with
|
||||
`node --conditions=source --experimental-strip-types test/next/examples/<file>`): `01-objects.ts` (contracts, facets,
|
||||
Runnable examples, one topic each, in `test/examples/` (run from `packages/chord` with
|
||||
`node --conditions=source --experimental-strip-types test/examples/<file>`): `01-objects.ts` (contracts, facets,
|
||||
references, calls, lifetimes), `02-documents.ts` (singleton and keyed documents, transactions, shorthands),
|
||||
`03-derives.ts` (derives, `mapShared`, derives over other objects), `04-watches.ts` (frames, folding for a busy
|
||||
listener, retire, cancellation, release), `05-errors.ts` (invalid arguments and params, unknown members, contract mismatch, released references,
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
// Keep-alive counts (§3, R26): `host.activity`, a JSON watchable of the work keeping a host busy, for the app's
|
||||
// supervisor. Counters are kept where the work happens; their changes in one microtask turn are one publication.
|
||||
|
||||
import { BACKGROUND_CONTEXT, type Context } from "../../context/index.ts";
|
||||
import type { JsonValue } from "../../json.ts";
|
||||
import { BACKGROUND_CONTEXT, type Context } from "../context/index.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
import { Source } from "../state/internal.ts";
|
||||
|
||||
/** What `host.activity` counts. */
|
||||
|
|
@ -7,10 +7,10 @@
|
|||
// instance; references move to a new instance's mappings as to any new instance. A version the provider does not
|
||||
// accept, or a refused member, gives no member (`contract_mismatch`): its consumers wait unbound.
|
||||
|
||||
import type { JsonValue } from "../../json.ts";
|
||||
import type { Contract } from "../contracts/index.ts";
|
||||
import { currentVariants, LOCAL, variantsAt } from "../contracts/internal.ts";
|
||||
import { ChordError } from "../data/index.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
import type { Batch } from "../state/batch.ts";
|
||||
import { Derive, type DeriveSource } from "../state/internal.ts";
|
||||
import type { Served } from "./object.ts";
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue