mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(chord): remove Chord v1 (/old, /old/node), its tests and docs
p2 runs on Chord v2; apps still on v1 pin @earendil-works/chord@1.5.0.
This commit is contained in:
parent
90b42bca6e
commit
ed4f488401
75 changed files with 11 additions and 21192 deletions
|
|
@ -30,7 +30,7 @@
|
|||
|
||||
## Chord v2 Code Style
|
||||
|
||||
Binding for Chord v2 (`packages/chord/src/`, except Chord v1 in `src/old/`) and its tests, when writing and when reviewing. The model is pi-durable's source.
|
||||
Binding for Chord v2 (`packages/chord/src/`) and its tests, when writing and when reviewing. The model is pi-durable's source.
|
||||
|
||||
- Files: one concept per file, named after it; no one-helper files, no grab-bag files. First line: a comment saying what the file is for. Order: imports, exported types, then the public entry point first and its helpers below it in the order they are used; constants right above their first user. A blank line between top-level declarations. Split files by concept, never by size: one concept may take a long file (up to about 1,000 lines is fine); a second concept in a file is the reason to split.
|
||||
- Functions: every export has a one-line doc comment saying what it does or guarantees. Inline comments explain a non-obvious why (cost, race, decided rule), never what the next line does. Short functions with one job (about 40 lines needs a reason; one `switch` over a tagged union is fine). No boolean flag parameters that switch behaviour: two functions or a tagged value. Never write the same lines twice. Context last: required args, then `context`/`ctx` (always required, as in Durable; optional only on error-reporting paths), then optional args.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
### 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`).
|
||||
- Chord v1 (`@earendil-works/chord/old`, `@earendil-works/chord/old/node`) removed, with its tests and docs (`src/old`, `test/old`, `docs/old`). Apps still on v1 pin `@earendil-works/chord@1.5.0`.
|
||||
|
||||
### Added
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Chord builds applications out of **objects** that live in several processes (a s
|
|||
- 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.
|
||||
|
||||
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)).
|
||||
The design, the rule book for all of this, is [docs/design.md](docs/design.md).
|
||||
|
||||
## Entry points
|
||||
|
||||
|
|
@ -35,7 +35,6 @@ The design, the rule book for all of this, is [docs/design.md](docs/design.md).
|
|||
| `@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`.
|
||||
|
||||
|
|
@ -77,7 +76,6 @@ await host.close(context);
|
|||
- [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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
# Chord v2: target design
|
||||
|
||||
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,
|
||||
The design of Chord v2 (`packages/chord/src`, the package's root export `@earendil-works/chord`) 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`.
|
||||
|
|
@ -390,7 +389,7 @@ host.route(Session, {
|
|||
`closed` or the link closing. A call still queued when its hold rebinds (`loading`) is rejected `unavailable` (it may
|
||||
name a method of the old variant). A provider seeing more than N running calls of one origin closes the link
|
||||
(protocol error); a call naming a hold it does not know is ignored before it is counted (Q1). **There is no limit on
|
||||
calls in progress per link** (owner, 2026-11-03; v1's `maxCalls` has no counterpart): the call window N per origin
|
||||
calls in progress per link** (owner, 2026-11-03; Chord v1's `maxCalls` had no counterpart): the call window N per origin
|
||||
and the part limits (section 22) are the bounds; 200 concurrent calls over one socket all run, N at a time in
|
||||
flight. A per-link N, if ever needed, must bound a relay's forwarding too.
|
||||
- Holds are not limited by count (authorization and params checks protect them); size limits are a separate check
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,814 +0,0 @@
|
|||
# Chord usage guide
|
||||
|
||||
> Describes the runtime in `src`. The [specification](spec.md) defines its semantics and lists known limitations.
|
||||
|
||||
Chord 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 type** is declared by a contract: a name, an input (the object's address) and members. A plugin's
|
||||
**facet** (its code for one host: server, worker, browser) serves object types.
|
||||
- An **object** is one instance of an object type: 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.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Quick start](#quick-start)
|
||||
- [Contracts](#contracts)
|
||||
- [Serving objects](#serving-objects)
|
||||
- [Declarations](#declarations)
|
||||
- [Activation](#activation)
|
||||
- [Principals and variants](#principals-and-variants)
|
||||
- [References](#references)
|
||||
- [State](#state)
|
||||
- [Calls, ctx and roots](#calls-ctx-and-roots)
|
||||
- [Lifetime](#lifetime)
|
||||
- [Registries](#registries)
|
||||
- [Plugins](#plugins)
|
||||
- [Hot reload](#hot-reload)
|
||||
- [Hosts and links](#hosts-and-links)
|
||||
- [Durable](#durable)
|
||||
- [Errors](#errors)
|
||||
- [Rules of thumb](#rules-of-thumb)
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { createHost, 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() }),
|
||||
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
|
||||
});
|
||||
```
|
||||
|
||||
## Contracts
|
||||
|
||||
A contract is a runtime value: a name, a version number, and a description of the members. Shipped bundles have no TypeScript, so everything the runtime checks must be in the contract. TypeScript types are inferred from it.
|
||||
|
||||
### Remote types
|
||||
|
||||
Remote types can be reached from other hosts. Everything that crosses the wire has a schema, and the provider validates it.
|
||||
|
||||
```ts
|
||||
export const Chat = remote("pi.chat", {
|
||||
version: 1,
|
||||
input: obj({ session: str(), conversation: num() }),
|
||||
host: hostFamily("session:", "session"), // lives on `session:S1` for `{ session: "S1", … }`
|
||||
variants: {
|
||||
viewer: {
|
||||
state: { view: ConversationViewSchema },
|
||||
},
|
||||
participant: {
|
||||
state: { view: ConversationViewSchema },
|
||||
methods: {
|
||||
send: method(SendInputSchema, obj({ submissionId: num() })),
|
||||
stop: method(), // no arguments, no result: `chat.stop(ctx)`
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Where a remote type lives** is part of its contract, so no caller names a host:
|
||||
|
||||
- `host: hostNamed("server")`: every object lives on the host named `server`.
|
||||
- `host: hostFamily("session:", "session")`: an object lives on `session:` + its input's `session` field, a required
|
||||
string. TypeScript checks the field name against the input.
|
||||
|
||||
A `get` normalizes the input (defaults count), resolves the host, and acquires the object here if that is this host,
|
||||
otherwise over the link to it. `serve` refuses a type whose placement does not include the serving host, so a misplaced
|
||||
type fails at startup or at the reload that adds it. The host that gets a hold checks that its own contract places the
|
||||
input on itself, and a relay checks the hold's target against its contract token: a client never chooses where an
|
||||
object lives. The placement is part of the description and the fingerprint: moving a type is a breaking change, and
|
||||
references to it get `upgrade_required`.
|
||||
|
||||
### Local types
|
||||
|
||||
Local types are reachable only inside their host. Their members may be anything: functions, callbacks, native objects. Non-JSON members get opaque or function descriptors.
|
||||
|
||||
```ts
|
||||
export const Index = local("pi.index", {
|
||||
version: 1,
|
||||
input: obj({ session: str() }),
|
||||
members: {
|
||||
search: method(obj({ query: str() }), arr(HitSchema)),
|
||||
onChange: method({ type: "function", parameters: [ChangeSchema], returnType: { type: "void" } }, UnsubscribeSchema),
|
||||
harness: opaque("pi.durable-harness@1"),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Inputs identify objects
|
||||
|
||||
`{ session: "S1", conversation: 1 }` always means the same object. Inputs are normalized (defaults applied, unknown fields removed), validated, and serialized canonically. Fields equal to their default are left out, so adding a new optional field with a default doesn't change existing addresses. Changing a default, or how inputs are normalized, needs a new type name.
|
||||
|
||||
### Versions and compatibility
|
||||
|
||||
Every bundle carries its own copy of each contract it was built against. When a consumer binds to a provider, the two copies are compared:
|
||||
|
||||
> A consumer built against version M binds to a provider at version N if **N = M and the provider's description covers the consumer's**. Otherwise the provider must offer an adapter in `accepts[M]`, or binding fails with `contract_mismatch`.
|
||||
|
||||
"Covers" means the provider still offers everything the consumer was built against:
|
||||
- every member exists with the same kind;
|
||||
- methods accept everything the consumer can send (new arguments must be optional) and return at least what it expects (extra result fields are fine);
|
||||
- state values fit the consumer's schema;
|
||||
- opaque members have the same name and version.
|
||||
|
||||
| Change | Version | Result |
|
||||
| --- | --- | --- |
|
||||
| Add a method, an optional argument, a result field, a state field | keep | binds |
|
||||
| Remove or rename a member; make an argument required; change a type | **bump** | old consumers need `accepts`, or must be reloaded too |
|
||||
| Same shape, different meaning (`query` is now a regular expression) | **bump** | as above; undetectable, so it's on you |
|
||||
| Forgot to bump on a breaking shape change | keep | refused: "consumer needs member `search`" |
|
||||
|
||||
```ts
|
||||
// version 2 changed what `search` means; old consumers keep working through an adapter
|
||||
host.serve(Index, indexFactory, {
|
||||
accepts: {
|
||||
1: (current) => ({ search: ({ query }, ctx) => current.search({ query: escapeRegExp(query) }, ctx) }),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Serving objects
|
||||
|
||||
```ts
|
||||
host.serve(Type, (input, principal, self) => implementation, options);
|
||||
```
|
||||
|
||||
- The **factory** runs synchronously. It declares what the object needs and returns its implementation: methods and state members, plus `variant` for remote types. Returning a promise throws.
|
||||
- There is one **object** per `(type, input, principal)`. It holds the object's state, resources and references, and survives reloads. Only the implementation is replaced.
|
||||
- **Options:**
|
||||
- `accepts`: adapters for consumers built against older contract versions;
|
||||
- `lifetime`: `"referenced"` (default) creates the object at its first `get` and disposes it after the last reference
|
||||
is released; `"host"` creates it when the plugin loads and keeps it until the host closes (see [Lifetime](#lifetime));
|
||||
- `linger`: how long to keep a referenced object after the last reference is released.
|
||||
|
||||
## Declarations
|
||||
|
||||
A reload runs the factory again over the same object. Declarations that keep something across a reload have a **name**: the new code's `status` cell is the old code's `status` cell, with its value. Derivations and tasks hold nothing across a reload and have no name.
|
||||
|
||||
```ts
|
||||
host.serve(StatusStore, ({ session }, _host, self) => {
|
||||
const status = self.state("status", StatusSchema, { running: false, tasks: 0 });
|
||||
const errors = self.state("errors", arr(str()), []);
|
||||
const catalog = self.get(Catalog, {});
|
||||
const db = self.resource("db", {
|
||||
version: 1,
|
||||
open: async ({ signal }) => openSqlite(statusPath(session), { signal }),
|
||||
close: async (db) => db.close(),
|
||||
});
|
||||
self.on("catalogChanges", catalog.rows, (rows, ctx) => errors.replace(ctx, checkRows(rows)));
|
||||
self.task(async (ctx) => {
|
||||
while (!ctx.signal.aborted) {
|
||||
await sleep(10_000, ctx.signal);
|
||||
await db.value.run("INSERT INTO history VALUES (?)", [JSON.stringify(status.value)]);
|
||||
}
|
||||
});
|
||||
const summary = self.derive([status, errors], (s, e) => ({ ...s, failing: e.length > 0 }));
|
||||
return { summary, report: reportMethod(status) }; // exposed state is what the implementation returns
|
||||
});
|
||||
```
|
||||
|
||||
| Declaration | What it is | On reload |
|
||||
| --- | --- | --- |
|
||||
| `self.state(name, schema, initial \| { init }, { version?, migrate? })` | an observable JSON cell; the schema is required | same cell and value (`init` does not run); migrated when its version rises |
|
||||
| `self.get(Type, input, { at? })` | a reference to another object; its identity is the address | kept if declared again, otherwise released |
|
||||
| `self.resource(name, { version, deps?, exclusive?, open, close? })` | a connection, watch, socket, port | kept; reopened only if `version`, `deps` or `exclusive` change |
|
||||
| `self.on(name, source, handler)` or `self.on(name, source, select, handler)` | reacts to state or to an event source | the same subscription, with the new handler, as long as the source and `select` are unchanged |
|
||||
| `self.task(async (ctx) => ...)` | long-running background work | the old run is aborted through `ctx.signal`; the new code starts once it finished (at most `taskStopDeadline`, default 5 s) |
|
||||
| `self.derive(sources, fn)` | live projection of state; `fn` is synchronous | a new derivation, computed when the reload commits |
|
||||
| `self.register(Registry, { key: value })` | an entry in a registry | replaced at commit |
|
||||
| `self.expose(resource)` | a member backed by a resource (local types) | follows the resource |
|
||||
|
||||
Names are strings: declaring one twice in a run fails (`invalid/duplicate`). Each kind has its own names, so a cell and a resource may share one.
|
||||
|
||||
A listener keeps its subscription across reloads when it is declared on the same resource (or source) with the same `select`: the same function, or one with the same source text. `select` is not called again, so a source that accepts a single subscriber (a Durable watch) survives reloads, and `select` may wrap the value in a new adapter object. A `select` whose result depends on captured values that change between reloads is not noticed; give such a listener a new name.
|
||||
|
||||
**A listener on state reconciles.** A listener on state (a cell, a derivation, a reference's state member) gets the current value when it starts, then every new value. When a reload replaces its source (a derivation is new at every reload), it subscribes again and gets the current value again. Write its handler to bring things in line with the value it gets (set, compare, start or stop what the value says), not to count or append per call. An event source (a resource's events) delivers no current value.
|
||||
|
||||
A name you no longer declare is released, closed or stopped, after the old code has finished what it was doing.
|
||||
|
||||
**State that must survive a reload lives in cells,** never in closure variables. A closure variable lasts as long as one version of the code.
|
||||
|
||||
```ts
|
||||
// wrong: reset on every reload
|
||||
host.serve(Visits, (_, _p, self) => {
|
||||
let visits = 0;
|
||||
return { hit: async (ctx) => ++visits };
|
||||
});
|
||||
|
||||
// right
|
||||
host.serve(Visits, (_, _p, self) => {
|
||||
const visits = self.state("visits", num(), 0);
|
||||
return {
|
||||
hit: async (ctx) => {
|
||||
visits.change(ctx, (n) => n + 1);
|
||||
return visits.value;
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
**Methods** are declared with `method(args?, result?)` in the contract. Without `args` a method takes none: callers write `ref.hit(ctx)` (or `ref.hit()`), and its implementation is `(ctx) => …`. Without `result` it returns nothing: the implementation returns nothing, and callers get `undefined`. `method(undefined, num())` takes no arguments and returns a number.
|
||||
|
||||
## Activation
|
||||
|
||||
The factory is synchronous, but activation isn't. `self.get` and `self.resources` only *declare*; the slow work happens afterwards:
|
||||
|
||||
```text
|
||||
get(Index, { session: "S1" })
|
||||
1. run the factory → declarations
|
||||
2. open declared resources → e.g. connect to SQLite
|
||||
3. wait for declared refs → dependencies activate first
|
||||
4. run the cells' init → e.g. read the rows into a cell
|
||||
5. live → refs become "live"; listeners and tasks start
|
||||
```
|
||||
|
||||
```ts
|
||||
host.serve(Index, ({ session }, _host, self) => {
|
||||
const db = self.resource("db", { version: 1, open: () => openSqlite(indexPath(session)), close: (db) => db.close() });
|
||||
return {
|
||||
search: async ({ query }, ctx) => db.value.all("SELECT entryId, text FROM fts WHERE fts MATCH ?", [query]),
|
||||
};
|
||||
});
|
||||
|
||||
host.serve(Chat, ({ session, conversation }, principal, self) => {
|
||||
const index = self.get(Index, { session }); // a placeholder until Index is live
|
||||
return {
|
||||
variant: "participant",
|
||||
find: async ({ query }, ctx) => index.search({ query }, ctx), // only ever runs once Chat (and Index) are live
|
||||
// ...
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Inside the factory, never use what you declared: `db.value` and `index.someState.value` throw `not_ready`, and calling a reference throws. Use them in methods, listeners, tasks and `init`.
|
||||
- **The object goes live only with its references ready**: live, and (for a remote reference) every state member of its variant hydrated. The same holds for state it forwards or derives from a remote reference it holds some other way: it goes live once that state has its value. A reference that is not ready after the host's `readyTimeout` (default 60 s) fails the activation with `unavailable/ready_timeout`; a reference cycle across hosts ends that way instead of hanging.
|
||||
- **Concurrent `get`s** of one address share one activation, so the database opens once.
|
||||
- **Failure propagates:** if `openSqlite` throws, `Index` fails, `Chat` fails with that cause, and every waiting `get` rejects. The next `get` tries again.
|
||||
- **Abandoned activations:** if everyone releases before activation finishes, the result is disposed.
|
||||
|
||||
### Startup readiness and parent admission
|
||||
|
||||
`await host.load(facets)` and `await host.install(facet)` wait for the `lifetime: "host"` objects they install to
|
||||
become live: resources are open, declared references are live, and registrations are published. All facets in a load
|
||||
are installed before activation is awaited, so a host object can use a provider in a later facet. Referenced objects
|
||||
are created only when something references them. Factory, dependency and resource errors reject startup with the
|
||||
reference's activation error; host object failures also reach `onError`. Installation is not rolled back on failure: close the host
|
||||
when abandoning startup. `install` registers synchronously, so setup/registration errors still throw synchronously.
|
||||
|
||||
`await host.ready()` waits for the host objects present when called, including direct
|
||||
`host.serve(..., { lifetime: "host" })` and previously deferred loads. It is an activation barrier, not a continuous health check, a wait for tasks to
|
||||
finish. Later installations require another readiness wait.
|
||||
|
||||
A worker may need to open its local Harness and announce control readiness **before** the parent accepts its link.
|
||||
A host object reporting to the server's SessionStatus cannot become live before that acceptance. Awaiting the
|
||||
whole load before announcing control readiness would deadlock. Defer the whole barrier explicitly:
|
||||
|
||||
```ts
|
||||
// Connect the upstream link first; its outgoing references can queue until the parent accepts it.
|
||||
await worker.load(workerFacets, { awaitHostObjects: false }); // also supported by install
|
||||
const harness = await worker.get(Harness, {}); // local bootstrap only; no upstream dependency
|
||||
announceControlReady(); // parent can now accept the worker link
|
||||
await worker.ready(); // every host object live, including upstream references
|
||||
harness.release();
|
||||
```
|
||||
|
||||
`awaitHostObjects: false` still starts every host object; it skips waiting, not activation. There is no
|
||||
automatic classification of upstream dependencies. Choose a local bootstrap reference with no upstream dependency,
|
||||
connect before loading upstream consumers, and let the parent admit the link before the full barrier. Control
|
||||
readiness means admission is possible, not that every host object is live. Keep worker bootstrap counted as busy
|
||||
until the full barrier settles, and report a later failure through the worker's fatal/startup-error path. Never await a
|
||||
reference whose object needs the parent (a remote one, or a local one that holds one) before the parent admits the link.
|
||||
|
||||
To hand out a native object a resource opens (a local type's `opaque` member), expose the resource. References read its value once the object is live, and `undefined` before:
|
||||
|
||||
```ts
|
||||
host.serve(Harness, (_, _host, self) => {
|
||||
const durable = self.resource("durable", { version: 1, open: () => DurableHarness.open(storage), close: (h) => h.close() });
|
||||
return { harness: self.expose(durable) }; // contract member: harness: opaque<DurableHarness>("pi.durable-harness@1")
|
||||
});
|
||||
|
||||
const harness = await ctx.get(Harness, {});
|
||||
harness.harness.resume(); // the DurableHarness itself
|
||||
```
|
||||
|
||||
## Principals and variants
|
||||
|
||||
The application defines its principals and how they're keyed. Chord invents none: the app gives every principal a
|
||||
request can act as.
|
||||
|
||||
```ts
|
||||
type Principal =
|
||||
| { kind: "owner"; access: "local" | "relay" }
|
||||
| { kind: "guest"; id: string; name: string; session: string; mode: "view" | "full"; revision: number }
|
||||
| { kind: "worker"; session: string; generation: number }
|
||||
| { kind: "server" } // the parent's own requests, as a worker sees them
|
||||
| { kind: "host" }; // a host's own code
|
||||
|
||||
const host = createHost<Principal>({
|
||||
name: "server",
|
||||
self: { kind: "host" },
|
||||
principalKey: (p) =>
|
||||
p.kind === "owner" ? `owner:${p.access}` :
|
||||
p.kind === "guest" ? `guest:${p.id}:r${p.revision}` :
|
||||
p.kind === "worker" ? `worker:${p.session}:${p.generation}` :
|
||||
p.kind,
|
||||
});
|
||||
```
|
||||
|
||||
- `createHost<Principal>` types every factory's `principal`, `ctx.principal`, routes, `accept` and
|
||||
`invalidatePrincipal` patterns, so code narrows on `kind` without casts. Facet modules say it too:
|
||||
`defineFacet<Principal>((api) => …)` (an app usually exports its own `defineFacet` and `Ctx` aliases). Without a type
|
||||
argument, principals are `unknown`.
|
||||
- There is one object per principal key: two tabs of the owner share an object, and a guest gets its own.
|
||||
- Code inside a host acts as `self`, so internal objects are shared. A principal with the same key as `self` is the
|
||||
host's own, whichever object carries it.
|
||||
- A link stamps the principal of whatever connects to it. Callers can never supply their own.
|
||||
- A child host names what its parent's own requests act as: `connect(link, { principal })` (or
|
||||
`upstream: { link, principal }`). Requests the parent relays act as the caller it forwards.
|
||||
- A route names what the hosts it resolves act as when they call back: `route(prefix, { …, principal: (name) => … })`.
|
||||
|
||||
**A remote factory picks a variant:**
|
||||
|
||||
```ts
|
||||
host.serve(Chat, ({ session, conversation }, principal, self) => {
|
||||
if (!canView(principal, session)) throw forbidden();
|
||||
const c = self.get(Conversation, { session, conversation }); // as host: one shared instance
|
||||
if (!canSend(principal)) return { variant: "viewer", view: c.view };
|
||||
const author = authorOf(principal);
|
||||
return {
|
||||
variant: "participant",
|
||||
view: c.view,
|
||||
send: async (input, ctx) => c.submit(input, author, ctx),
|
||||
stop: async (ctx) => {
|
||||
await c.stop(author, ctx);
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
const chat = await ctx.get(Chat, { session: "S1", conversation: 1 }); // on session:S1
|
||||
if (chat.variant === "participant") showComposer(chat.send); // narrows: `send` exists only there
|
||||
else showReadOnly();
|
||||
```
|
||||
|
||||
**What a variant doesn't do:**
|
||||
- **It isn't complete authorization.** Methods still check their arguments: that a submission belongs to this conversation, or a session ID to this principal.
|
||||
- **It doesn't cover flags that change while you hold the object.** Those are state, for example `you: { canDraw: boolean }`.
|
||||
- **Revocation** is `host.invalidatePrincipal(match)`. It force-disposes matching objects, here and over links, and their references become `gone`. A new `get` runs the factory with the new grants.
|
||||
|
||||
Local types have no variants. Their only callers are inside the host, acting as its `self`. If a local object needs to know who's acting, pass that explicitly, like `author` above.
|
||||
|
||||
## References
|
||||
|
||||
| Where | API | Released |
|
||||
| --- | --- | --- |
|
||||
| a factory | `self.get(Type, input)`: synchronous; the object goes live after it is ready | with the object (or its replaced declaration) |
|
||||
| a call | `await ctx.get(Type, input, { state? })` | when the call settles |
|
||||
| outside Chord | `await host.get(Type, input, { signal?, state? })` | `ref.release()` |
|
||||
| a UI runtime | `host.hold(Type, input)`: synchronous, possibly `loading` | `ref.release()` |
|
||||
|
||||
```ts
|
||||
const chat = await ctx.get(Chat, input); // ready: live, every state member of its variant hydrated
|
||||
chat.status; // "loading" | "live" | "released" | "gone"
|
||||
chat.variant; // the current variant; a multi-variant reference narrows on it
|
||||
chat.shape; // { variant, members, contract }
|
||||
const send = chat.send; // a stable function: always dispatches to the current code
|
||||
chat.view.value; // the current value, typed T
|
||||
chat.view.subscribe((v) => render(v));
|
||||
chat.onStatus((status, error) => { if (status === "gone") reconnect(error); });
|
||||
chat.release();
|
||||
```
|
||||
|
||||
- **A reference you hold is usable:** an awaited `get` resolves once the object is live and every state member of its variant has its first snapshot, local or remote. It rejects with the activation error (`forbidden`, unreachable, …), with `aborted` (a `ctx.get` whose ctx is aborted or whose call settles first; `host.get`'s `signal`), or with `unavailable/ready_timeout` after the host's `readyTimeout`. Only that reference is released then.
|
||||
- **A held reference stays subscribed** to the state of its variant, with or without listeners, until it is released. After a link loss the last values stay readable while it re-holds (status `loading`).
|
||||
- **Reading state before the first hydration throws `not_ready`** (a `hold` that is still loading, a member read inside a factory). A member outside the current variant throws `not_in_variant`; an ended reference throws its end (`released`, or the `gone` error). A listener added before the hydration gets its snapshot then.
|
||||
- **`state: false`** gives a method-only reference: it receives no state (unless another reference to the same object wants it) and is ready once its object is live.
|
||||
- **Calls on a `loading` reference wait** until it's live, respecting `ctx` cancellation and a deadline.
|
||||
- A member missing from the current variant rejects with `not_in_variant`.
|
||||
- After `release()`, every member rejects with `released`.
|
||||
- **`gone`** means the object was forced away while referenced: revocation, archive, its host dying. Call `get` again, or show "unavailable". `onStatus` reports every status change (`live`, `loading` while a link reconnects, `released`, `gone` with the error), so a UI needn't poll.
|
||||
- **References to one remote object share one binding** per host and key (type, normalized input, principal key, contract fingerprint): one hold over the link, one stream per state member, calls over the same hold. Each reference keeps its own status, observers and `release`. With `createHost({ linger: { types: { [typeName]: ms }, cap } })`, a binding whose last reference is released stays held (and hydrated) for `ms`, at most `cap` (default 4) at a time, so taking the reference again costs nothing on the link.
|
||||
- **Return values are values.** Anything long-lived you want to share is an object you reach with `get`.
|
||||
|
||||
## State
|
||||
|
||||
```ts
|
||||
const todos = self.state("todos", arr(TodoSchema), []);
|
||||
|
||||
todos.change(ctx, (draft) => {
|
||||
draft.push({ id: "t1", text: "Write docs", done: false });
|
||||
});
|
||||
todos.change(ctx, (draft) => {
|
||||
draft[0].done = true;
|
||||
});
|
||||
todos.replace(ctx, []);
|
||||
|
||||
todos.value; // immutable
|
||||
todos.subscribe((value) => render(value));
|
||||
|
||||
// mirroring an external immutable store: subscribers get a diff, not the whole value
|
||||
view.replace(ctx, conversation.view); // diffs when the new value reuses old containers
|
||||
view.replace(ctx, rebuilt, { diff: true }); // always diff
|
||||
```
|
||||
|
||||
- A `change` is atomic: one revision, or nothing if the callback throws.
|
||||
- `change` only touches the paths you mutate, so fields your code doesn't know about survive. Use `replace` only where your code owns the whole value.
|
||||
- Values are immutable. Never mutate what you read or receive.
|
||||
- Remote references get a snapshot of each state member of their variant, then deltas. After a reconnect or a reload they get a `reset` snapshot, and nothing is replayed.
|
||||
- `replace` sends a structural diff when the new value shares containers with the old one (a new revision of the same state), and a single replacement otherwise. `{ diff: true | false }` forces either. The value is the same either way.
|
||||
- An implementation may expose another object's state member as its own: `return { variant: "viewer", view: conversation.view }`. The object goes live only once that member has its value, local or remote.
|
||||
|
||||
**A cell whose first value comes from a source** declares `{ init }` instead of an initial value:
|
||||
|
||||
```ts
|
||||
const db = self.resource("db", { version: 1, open: () => openSqlite(path), close: (db) => db.close() });
|
||||
const notes = self.state("notes", arr(NoteSchema), { init: () => db.value.all("SELECT * FROM notes") });
|
||||
```
|
||||
|
||||
`init(ctx)` runs once the resources are open and the references live, before the object goes live, so no method,
|
||||
listener, task or subscriber sees a value that did not come from the source. A task that loads the value instead
|
||||
starts only after the object is live: a method called in between answers from the placeholder (a note that exists
|
||||
reads as unknown), and an async load can overwrite a write made during its `await`. `init` may be async and gets a
|
||||
`ctx` to call other objects. The `init`s of one object run in parallel, so one must not read another's cell. A throw or
|
||||
an invalid value fails the activation. A reload keeps the cell and its value and does not run `init` again; a cell the
|
||||
reload adds runs its `init` while the reload prepares, and a failure refuses the reload.
|
||||
|
||||
`init` reads a source the object has anyway. It saves nothing: what must outlive the process is written to that source
|
||||
(a database, a file, a Durable document) by the code that changes it.
|
||||
|
||||
```ts
|
||||
const visible = self.derive([catalog.rows], (rows) => rows.filter((r) => visibleTo(principal, r)));
|
||||
const openCount = self.derive([todos], (t) => t.filter((x) => !x.done).length);
|
||||
```
|
||||
|
||||
Derivation functions are synchronous. A derivation has no name: every reload makes a new one, computed when the
|
||||
reload commits.
|
||||
|
||||
## Calls, ctx and roots
|
||||
|
||||
Every method gets `(args, ctx)`, or `(ctx)` when it takes no arguments. Pass `ctx` on to everything you call:
|
||||
|
||||
```ts
|
||||
send: async (input, ctx) => {
|
||||
ctx.signal.throwIfAborted();
|
||||
const uploads = await ctx.get(Uploads, {}); // a reference owned by this call; released when it settles
|
||||
const files = await uploads.resolve({ ids: input.uploads }, ctx);
|
||||
return c.submit({ ...input, files }, author, ctx);
|
||||
},
|
||||
```
|
||||
|
||||
A **root** is a short tree of calls, started at ingress:
|
||||
- by a link, for a request that arrives without one;
|
||||
- by `host.root(fn)`, for timers and scripts;
|
||||
- by each event delivered to a listener (`self.on`);
|
||||
- by each Chord call made from a `task` or a Durable phase.
|
||||
|
||||
A root runs on the code that was current when it started (its **code version**), across hosts, until it ends. Keep roots short. Long-running work is a `task`, and long waits are state.
|
||||
|
||||
```ts
|
||||
// wrong: holds a root (and its version of the code) open for minutes
|
||||
approve: async (ctx) => waitForHuman(ctx),
|
||||
|
||||
// right: record the request as state; the answer is a separate call
|
||||
request: async (req, ctx) => {
|
||||
pending.change(ctx, (d) => {
|
||||
d.push(req);
|
||||
});
|
||||
},
|
||||
answer: async ({ id, value }, ctx) => {
|
||||
pending.change(ctx, (d) => {
|
||||
settle(d, id, value);
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
## Lifetime
|
||||
|
||||
An object lives while it's **referenced**, and every reference has an owner:
|
||||
|
||||
| Owner | Acquired with | Released when |
|
||||
| --- | --- | --- |
|
||||
| another object | `self.get` | that object is disposed, or no longer declares it |
|
||||
| a call | `ctx.get` | the call settles |
|
||||
| a connection | a remote host's `get` | release, disconnect, revocation |
|
||||
| the host | `lifetime: "host"` | host shutdown (or a reload removing its facet) |
|
||||
| UI | `useObject` | unmount |
|
||||
|
||||
- **Disposal** runs after the last reference is released, plus `linger`. Tasks are aborted, listeners removed, resources closed in reverse order (every close runs, and errors are collected), then the object's own references are released, which can dispose further objects.
|
||||
- **A resource's `close` may still use the objects its object references.** That holds for host shutdown and revocation too: `host.close()` and `invalidatePrincipal` dispose referrers before what they reference, and close links last. Bound remote calls in `close` with a signal: a call over a link that is already down waits for a reconnect.
|
||||
- **Forced disposal** (revocation, archive) makes references `gone` at once and aborts in-flight calls.
|
||||
- **References must not form cycles.** Inside one host this is checked at `get`.
|
||||
|
||||
```ts
|
||||
host.serve(Conversation, conversationFactory, { linger: 30_000 }); // switching back quickly doesn't reopen its watch
|
||||
host.serve(Index, indexFactory, { lifetime: "host" }); // one object from load to host shutdown
|
||||
```
|
||||
|
||||
**`lifetime: "host"`** is for local types with an empty input (or only defaults): the type has exactly one object,
|
||||
created for the host principal when its facet loads. Every `get` reaches that object, whoever asks; a `get` with
|
||||
another input is invalid. References others hold do not change its lifetime, and it cannot `linger`. Only such
|
||||
objects may register into registries. TypeScript refuses `lifetime: "host"` for a remote type, for an input with a
|
||||
required field and together with `linger`; `serve` checks the same at runtime, for callers without types.
|
||||
|
||||
## Registries
|
||||
|
||||
```ts
|
||||
export const Routes = defineRegistry<RouteHandler>("http.routes", { conflict: "error" });
|
||||
|
||||
// contribute (only from `lifetime: "host"` objects)
|
||||
host.serve(Downloads, (_, _host, self) => {
|
||||
const blobs = self.get(Blobs, {});
|
||||
self.register(Routes, {
|
||||
"GET /download": async (request, who, ctx) => {
|
||||
const file = await blobs.find({ id: request.query.id }, ctx);
|
||||
if (!canView(who, file.session)) return forbidden();
|
||||
return stream(file, ctx);
|
||||
},
|
||||
});
|
||||
return {};
|
||||
}, { lifetime: "host" });
|
||||
|
||||
// consume: a request sees the table of its own code version
|
||||
server.on("request", (req, res) =>
|
||||
host.root(async (ctx) => {
|
||||
const route = host.registry(Routes).get(`${req.method} ${pathOf(req)}`, ctx);
|
||||
return route ? route(req, principalOf(req), ctx) : notFound(res);
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
- Each code version has one complete table. A reload never leaves a moment where an entry is missing.
|
||||
- A request that started before a reload keeps using the old table.
|
||||
- Order is first-registration order and survives replacement.
|
||||
- `conflict: "error"` makes a duplicate key fail the registration (or the reload).
|
||||
|
||||
## Plugins
|
||||
|
||||
A plugin is a feature with one **facet** per host kind:
|
||||
|
||||
```text
|
||||
plugins/chat/
|
||||
contract.ts Chat, Conversation, Drafts
|
||||
server.ts facet for the server
|
||||
worker.ts facet for session workers
|
||||
web.tsx facet for browsers
|
||||
```
|
||||
|
||||
```ts
|
||||
// plugins/chat/worker.ts
|
||||
import { defineFacet } from "@earendil-works/chord/old";
|
||||
import { Chat, Conversation } from "./contract.ts";
|
||||
|
||||
export default defineFacet((host) => {
|
||||
host.serve(Conversation, conversationFactory, { linger: 30_000 });
|
||||
host.serve(Chat, chatFactory);
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// a host loads the plugins' facets for its kind
|
||||
await host.load([
|
||||
new URL("../plugins/chat/worker.ts", import.meta.url),
|
||||
new URL("../plugins/status/worker.ts", import.meta.url),
|
||||
]);
|
||||
```
|
||||
|
||||
Built-in plugins are trusted code from this repository and load directly from source.
|
||||
|
||||
The facet's `setup` argument is its host, scoped to what a plugin needs: `serve`, `name`, `root(fn)`, `registry(R)` and `signal`, which aborts once this run of the facet is no longer installed (a reload replaced or removed it, or the reload that loaded it aborted). Code that is not a Chord method, listener or task (Durable tools and hooks, callbacks of native libraries) keeps it to start roots and read registries, instead of reaching for a process-global host:
|
||||
|
||||
```ts
|
||||
export default defineFacet((host) => {
|
||||
host.serve(Sessions, sessionsFactory);
|
||||
tools.register("rename_session", (args) =>
|
||||
host.root(async (ctx) => (await ctx.get(Sessions, {})).rename(args, ctx)),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Hot reload
|
||||
|
||||
```ts
|
||||
await host.reload([new URL("../plugins/chat/worker.ts", import.meta.url)]); // or reload every changed plugin
|
||||
```
|
||||
|
||||
### What happens
|
||||
|
||||
1. **Load** the new modules.
|
||||
2. **Prepare:** run the new factories over the existing objects, without side effects. Cell writes throw, listeners and tasks don't start, registrations are collected, and changed resources are opened next to the old ones. Contracts are checked against every live consumer. Any failure leaves everything as it was.
|
||||
3. **Gate:** only if a cell's version was raised or an exclusive resource changes.
|
||||
4. **Commit:** in one synchronous step, install the new code, swap registry tables, restart tasks, rebind listeners, and send `reset` and shape updates to subscribers.
|
||||
5. **Retire:** old code keeps running the roots it started. Once they end, it's dropped, along with whatever it alone declared. If they are still running when the next reload starts, that reload waits up to `retireDeadline`, then aborts them (their `ctx.signal`) before it closes what only the old code used.
|
||||
|
||||
### Compatible changes: no waiting
|
||||
|
||||
Bug fixes, new methods, new cells, new resources and registration changes all switch immediately. A root that started on the old code finishes on it, and new roots get the new code.
|
||||
|
||||
### Changing what state looks like or means: bump the cell's version
|
||||
|
||||
```ts
|
||||
// v1: { running, tasks } where tasks counted phases
|
||||
// v2: tasks counts tasks, and usage is new
|
||||
host.serve(StatusStore, ({ session }, _host, self) => {
|
||||
const status = self.state("status", StatusV2, { running: false, tasks: 0, usage: emptyUsage() }, {
|
||||
version: 2,
|
||||
migrate: (old, from) => (from < 2 ? { running: old.running, tasks: 0, usage: emptyUsage() } : old),
|
||||
});
|
||||
return {
|
||||
status,
|
||||
report: async (s, ctx) => {
|
||||
status.replace(ctx, s);
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
At the gate, for each live `StatusStore`:
|
||||
1. **Roots already inside finish.** Their re-entrant calls are let in.
|
||||
2. **Old roots that haven't touched it yet get `upgrade_conflict`** instead of old code over migrated state. They retry as a new root.
|
||||
3. **`migrate` runs** on a private copy, and the result is validated against the new schema.
|
||||
4. **Commit.** New roots use the new code and the migrated state.
|
||||
|
||||
If the roots inside don't finish within the deadline (5 s), the reload is **aborted** and nothing changes. The error names the blocking roots.
|
||||
|
||||
The version belongs to the cell (default 1); other cells of the object are not migrated. The runtime compares each
|
||||
cell's schema and version between old and new code:
|
||||
- unchanged: compatible;
|
||||
- version raised: migrate (without `migrate`, the value is kept and must fit the new schema);
|
||||
- schema changed **without** a raised version, or a lowered version: the reload is refused.
|
||||
|
||||
A change of meaning with an identical schema needs a bump too. Nothing can detect that one for you.
|
||||
|
||||
### Resources
|
||||
|
||||
```ts
|
||||
const db = self.resource("db", { version: 2, open: () => openSqlite(path, { wal: true }), close: (db) => db.close() }); // 1 → 2: reopen
|
||||
const port = self.resource("port", { version: 1, exclusive: true, open: () => listen(8787), close: (s) => s.close() });
|
||||
```
|
||||
|
||||
- A reload keeps a resource's open value unless its `version`, `deps` or `exclusive` change: `open` doesn't run
|
||||
again. The version is required: a native value has no schema to detect a change. The kept resource still takes the
|
||||
new code's declaration at the commit, so a later `close` is the new one.
|
||||
- A changed resource opens next to the old one during prepare. The old one is closed with its own `close` once the
|
||||
old code retires.
|
||||
- An `exclusive` resource can't exist twice, so it's swapped at the gate: close the old one, open the new one, and reopen the old one if that fails.
|
||||
|
||||
### Contract changes
|
||||
|
||||
| Change | What happens |
|
||||
| --- | --- |
|
||||
| Additive, same version | reloads; consumers keep working |
|
||||
| Breaking, version bumped, with `accepts[old]` | reloads; old consumers go through the adapter |
|
||||
| Breaking, without an adapter | refused, unless the consumers reload in the same batch |
|
||||
| Breaking, version not bumped | refused: "consumer needs member …" |
|
||||
|
||||
The host's own **reference to a `lifetime: "host"` object** is a lifecycle pin, not an old-version consumer. When a reload replaces
|
||||
its provider, that reference adopts the new contract at commit; it does not require an `accepts` adapter for itself.
|
||||
The object, cells and unchanged resources stay alive without reactivation. A refused reload keeps the original
|
||||
reference and contract. Other references (`self.get`, `ctx.get`, `host.get`, peers) still obey the compatibility rules above;
|
||||
state-version bumps and migrations are still required when stored state changes.
|
||||
|
||||
### Several plugins at once
|
||||
|
||||
Reload them together: `host.reload([a, b])`. Prepare sees all new code at once, so a changed contract and its updated consumer check against each other.
|
||||
|
||||
On the server, `host.reload(serverFacets, { remote: { "session:": workerFacets } })` reloads every connected worker too. Its report covers the server; `report.children["session:S1"]` says what each worker changed, prepared and gated.
|
||||
|
||||
## Hosts and links
|
||||
|
||||
```ts
|
||||
// server
|
||||
const server = createHost<Principal>({ name: "server", self: { kind: "host" }, principalKey });
|
||||
server.accept(webSocketLink(wss, { principal: (req) => authenticate(req) })); // stamps owner or guest from cookies
|
||||
server.route("session:", {
|
||||
authorize: (principal, host) => canReach(principal, host), // before resolving or spawning
|
||||
resolve: (host) => workers.ensure(host), // a link to the worker, spawned if needed
|
||||
principal: (host) => workers.principal(host), // what the worker acts as when it calls the server
|
||||
});
|
||||
|
||||
// worker
|
||||
const worker = createHost<Principal>({
|
||||
name: `session:${id}`,
|
||||
self: { kind: "host" },
|
||||
principalKey,
|
||||
upstream: { link: ipcLink(process), principal: { kind: "server" } }, // the server's own requests act as this
|
||||
});
|
||||
|
||||
// anywhere
|
||||
const chat = await ctx.get(Chat, { session: "S1", conversation: 1 }); // browser → server → worker session:S1
|
||||
const sessions = self.get(Sessions, {}); // `hostNamed("server")`: worker → server, acting as worker{S1}
|
||||
```
|
||||
|
||||
- **The server relays** between its children: worker to worker, and browser to worker.
|
||||
- **Disconnects:** when a browser disconnects, exactly the references it had in workers are released.
|
||||
- **Principals across links:**
|
||||
- a forwarded principal is accepted only from the parent link;
|
||||
- calls an object makes itself act as its host;
|
||||
- links ping, and a dead link releases everything held through it.
|
||||
- **Client origins:** a hold that comes from a browser carries an opaque id of its connection (`origin`), forwarded by the server when it relays the hold, so a worker can tell its clients apart. A client cannot set it. A disconnect releases that client's holds at once.
|
||||
- **Slow peers:** a peer acknowledges what it processed. While a window (`window`, default 256 KiB) of frames is unacknowledged, state waits and is coalesced per member's stream; calls, results and the first snapshots of a new reference are never held back, so a `get` does not wait behind other references' state. Waiting ops are capped per link (`pendingCap`, default four windows): beyond, the oldest streams are replaced by their current value. So under load a result can arrive before the state change it caused: wait for the state you need instead of assuming it is there when the call returns.
|
||||
- **Reloads across hosts:** the server coordinates prepare and commit for every host.
|
||||
- **Workers that start behind** (spawned after a reload) are caught up to the server's code version before anything is routed to them. Calls their roots make to the server meanwhile run in the server's current code version instead of failing `superseded`.
|
||||
- **Protocol version:** both ends announce the link protocol version in their hello. A peer of another version is not
|
||||
talked to: the link closes, and `onProtocolMismatch` (a `LinkOptions` field of `connect` and `accept`) tells the
|
||||
side that has to load new code. A browser reloads its page instead of reconnecting:
|
||||
|
||||
```ts
|
||||
host.connect(webSocketLink(socket), { onProtocolMismatch: () => location.reload() });
|
||||
```
|
||||
- **Browsers** don't take part in commits. They learn about new code through shape and `reset` messages, and an incompatible contract makes their calls fail with `upgrade_required`.
|
||||
|
||||
## Durable
|
||||
|
||||
A worker's host provides the Durable Harness as a local object of lifetime `"host"`. A Durable phase isn't a root: it's pinned to its registry snapshot by Durable itself. Each Chord call a hook or tool makes starts a fresh short root.
|
||||
|
||||
```ts
|
||||
host.serve(Harness, (_, _host, self) => {
|
||||
const durable = self.resource("durable", {
|
||||
version: 1,
|
||||
open: () => DurableHarness.open(storage, { models, registry: durableRegistry(host, Extensions) }), // a reader over the code version tables
|
||||
close: (h) => h.close(),
|
||||
});
|
||||
return { conversation: (id: number) => durable.value.conversation(id) };
|
||||
}, { lifetime: "host" });
|
||||
|
||||
// a plugin contributes a Durable extension like any registration
|
||||
host.serve(Guard, (_, _host, self) => {
|
||||
self.register(Extensions, {
|
||||
guard: defineExtension({
|
||||
name: "guard",
|
||||
hooks: [hook(ToolTask, { beforeTool: (call) => (isDangerous(call) ? { block: "Needs approval" } : undefined) })],
|
||||
}),
|
||||
});
|
||||
return {};
|
||||
}, { lifetime: "host" });
|
||||
```
|
||||
|
||||
A reload changing the guard: Durable phases already running keep the old snapshot, the next phase uses the new one, and there's never a phase without a guard.
|
||||
|
||||
## Errors
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `forbidden` | The factory or a method refused this principal |
|
||||
| `invalid` | Input, arguments or a result failed validation |
|
||||
| `not_ready` | A declared resource or reference was used inside a factory, or state read before its reference's first hydration |
|
||||
| `not_in_variant` | The member doesn't exist in the variant you have |
|
||||
| `released` | The reference was released |
|
||||
| `gone` | The object was forced away while referenced. Call `get` again. |
|
||||
| `contract_mismatch` | The provider's contract doesn't cover the consumer's, and no adapter exists |
|
||||
| `upgrade_conflict` | An old root reached an object migrated meanwhile. Retry as a new root. |
|
||||
| `superseded` | The root's code version is no longer available on this host. Retry. |
|
||||
| `upgrade_required` | A browser's contract is outdated. Reload its web facets. |
|
||||
| `unavailable` | The target host can't be reached or started; reason `ready_timeout`: a reference was not ready within `readyTimeout` |
|
||||
| `aborted` | The call's `ctx` was cancelled |
|
||||
|
||||
## Rules of thumb
|
||||
|
||||
| Do | Not |
|
||||
| --- | --- |
|
||||
| Keep reloadable state in cells | Closure variables (lost on reload) |
|
||||
| Bump the cell's version, with `migrate`, when a cell's shape or meaning changes | Changing a schema without a bump (refused) or a meaning without a bump (silent) |
|
||||
| Bump the contract version on breaking changes; add `accepts` for old consumers | Renaming or removing members at the same version (refused) |
|
||||
| Do slow setup in a resource's `open` | Awaiting inside the factory (it's synchronous) |
|
||||
| Do side effects in resources, listeners, tasks and registrations | Effects in the factory body (they run during prepare) |
|
||||
| Use `ctx.get` inside methods | `self.get` inside methods (a new reference per call) |
|
||||
| Keep roots short; long waits are state | Awaiting a human inside a method |
|
||||
| Check arguments in methods | Treating the variant as all of authorization |
|
||||
| Pass `ctx` everywhere | Starting background work with the caller's `ctx` (use a task) |
|
||||
| Treat state values as immutable | Mutating what you read |
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
# Chord specification
|
||||
|
||||
This document specifies the runtime in `src`. Where it and the code disagree, the disagreement is a bug. [Known limitations and decisions](#12-known-limitations-and-decisions) lists what the runtime does not do yet and the choices the sections below leave open.
|
||||
|
||||
Hard constraints:
|
||||
- Chord's runtime depends only on its own `delta` module (tracker, applyImmutable, encoder/decoder, diff) and Node/browser built-ins, plus `@earendil-works/pi-telemetry` for telemetry and `@earendil-works/pi-schema` for schemas. No other Pi packages.
|
||||
- Built-in plugins are trusted, loaded from source in-process. The plugin loader uses dynamic `import()` with a cache-busting query (owner-approved exception, `TODO(loader)`: replace with an isolating, evicting loader; old module copies leak; TUI plugins need isolation).
|
||||
- Hot reload is required. Host restart only for Chord/runtime code changes or host-level crash recovery.
|
||||
|
||||
## 1. Concepts
|
||||
|
||||
| Term | Meaning |
|
||||
| --- | --- |
|
||||
| facet | A plugin's code for one host (surface): it serves object types (`defineFacet`). |
|
||||
| object type | Name + contract. `remote` (addressable from other hosts; schemas for input, variant state members, method args/results) or `local` (host-only; members may be anything, including functions and native objects, but must be listed in the contract: `method`, `opaque` or a state schema). |
|
||||
| factory | `(input, principal, self) => implementation`. Synchronous. Registered with `host.serve(type, factory, options)` from a plugin facet. |
|
||||
| object | One instance of an object type. Stable identity per `(type, canonicalInput, principalKey)`. Owns cells, resources, references, listeners, tasks, registrations. Survives reloads. |
|
||||
| implementation | The object's code: what a factory run returns (methods + state members; `variant` for remote types). Immutable, per code version. |
|
||||
| reference | What `get` returns: a late-bound claim on an object, never the object or its implementation. An object lives while referenced. |
|
||||
| principal | App-defined. Stamped by links. In-host code acts as the `host` principal. |
|
||||
| root | One short Chord call tree, admitted at trusted ingress (link request without root, UI event, timer, `host.root`). Carries `{ rootId, codeVersion }`. |
|
||||
| code version | One committed set of implementations/registrations per host (server-issued id). |
|
||||
| code holder | Long-lived execution that is NOT a root: Durable phases/tool runs, browsers, `self.task` loops. Every Chord call they make starts a fresh short root. |
|
||||
|
||||
## 2. Contracts
|
||||
|
||||
Contracts are runtime values for every type (shipped bundles have no TypeScript; third-party plugins are built separately). TS types are inferred from them.
|
||||
|
||||
```ts
|
||||
export const Chat = remote("pi.chat", {
|
||||
version: 1, // contract version: a plain number, bumped only on breaking changes
|
||||
input: obj({ session: str(), conversation: num() }),
|
||||
host: hostFamily("session:", "session"), // where its objects live: `session:` + input.session
|
||||
variants: {
|
||||
viewer: { state: { view: ViewSchema } },
|
||||
participant: {
|
||||
state: { view: ViewSchema },
|
||||
methods: { send: method(SendSchema, obj({ submissionId: num() })) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Index = local("pi.index", {
|
||||
version: 1,
|
||||
input: obj({ session: str() }),
|
||||
members: {
|
||||
search: method(obj({ query: str() }), arr(Hit)),
|
||||
onChange: method({ type: "function", parameters: [Change], returnType: { type: "void" } }, Unsubscribe), // non-JSON allowed for locals
|
||||
harness: opaque("pi.durable-harness@1"), // native object, by name+version
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- Input identity: normalize (apply defaults, strip unknown), validate, then canonical JSON with fields equal to their schema default omitted. Changing defaults or normalization requires a new type name.
|
||||
- Remote calls validate args/results and state values at the provider boundary. Local calls are not validated per call.
|
||||
- Local contracts list every member (`members: { search: method(...), harness: opaque(...), status: Schema }`). A reference that is still `loading` must know whether `h.x` is a method (a caller that waits until live), a state member (a readable) or an opaque value, before any implementation exists. Members missing from the contract are found only once the reference is live.
|
||||
- Every bundle embeds the contract tokens it was built against (its own copy). Fingerprint = hash of the canonical description.
|
||||
- Placement (remote types, required): `host: hostNamed(name)` (one host) or `hostFamily(prefix, key)` (the host `prefix` + the input's required string field `key`). Data only, in the description (and so the fingerprint and the `hold` token); there is no `at` option. A `get` normalizes the input with the caller's contract, resolves the host and acquires locally if that is this host, else remotely. `serve` (and a reload that loads a facet) refuses a type whose placement excludes the host's name. A host refuses a hold whose input its own contract places elsewhere (`invalid/placement`), and a relay refuses a hold whose target disagrees with its token's placement and input. Coverage requires equal placements, and an `accepts` adapter never bridges two placements: a moved type is a breaking change. A remote reference's address is the type, the normalized input and the principal: no host name.
|
||||
|
||||
### Compatibility rule
|
||||
|
||||
A consumer built against version M binds to a provider at version N iff N = M and the provider's description covers the consumer's; otherwise only through `accepts[M]` adapters declared by the provider (`serve(type, factory, { accepts: { 1: (current) => v1View } })`); else `contract_mismatch`.
|
||||
|
||||
Covers: every consumer member exists with the same kind; methods: both take arguments or neither does, the provider accepts every argument the consumer can send (new fields optional, nothing newly required or retyped), and the provider's result fits the consumer's expected result (extra fields fine; a void consumer accepts any); state: provider value fits consumer schema (extra fields fine); opaque: same name@version.
|
||||
|
||||
Additive changes need no bump. Breaking changes (remove/rename/retype, newly required, or a change of meaning with the same shape) bump the number. Same version + non-covering description = forgotten bump, refused. Meaning changes without a bump are undetectable (author's responsibility, as with Durable document versions).
|
||||
|
||||
Checked at: `get` binding, reload prepare (new provider token vs every surviving consumer holding the type and every loaded plugin importing it), remote references (consumer token in the `hold` frame).
|
||||
|
||||
The exact host-owned reference to a `lifetime: "host"` object is a lifecycle pin, not a surviving old-contract consumer. When its
|
||||
provider is replaced, commit replaces that reference with one bound to the new provider contract, acquiring it before
|
||||
releasing the old one so the same object, state and unchanged resources survive. It needs no `accepts` adapter
|
||||
for its own previous contract. Prepare/abort do not change that reference. All other consumers retain their own tokens
|
||||
and the normal coverage/adapter checks; state-version and migration checks are unchanged.
|
||||
|
||||
## 3. Factory and declarations
|
||||
|
||||
Factories are synchronous; returning a promise throws. A declaration has a name only if something of it survives a reload and must be found again: state (its value) and resources (the open value), plus listeners (a kept subscription). Names are strings, unique per object per declaration kind (a repeated name is `invalid/duplicate`); kinds have separate names. Derivations and tasks have no names: a reload makes new ones. References are identified by their address.
|
||||
|
||||
```ts
|
||||
host.serve(StatusStore, ({ session }, _host, self) => {
|
||||
const status = self.state("status", StatusSchema, { running: false, tasks: 0 }, // schema required, initial value
|
||||
{ version: 2, migrate: (old, from) => upgrade(old, from) }); // per cell; default version 1
|
||||
const errors = self.state("errors", arr(str()), []);
|
||||
const catalog = self.get(Catalog, {}); // reference; identity = canonical address (+ at)
|
||||
const watch = self.resource("watch", {
|
||||
version: 1, deps: [catalog], exclusive: false,
|
||||
open: async ({ signal }) => openWatch(session, signal),
|
||||
close: async (w) => w.close(),
|
||||
});
|
||||
self.on("changes", watch, (w) => w.events, (event, ctx) => status.change(ctx, (d) => { d.tasks = event.tasks; }));
|
||||
self.task(async (ctx) => { /* loop until ctx.signal aborts */ });
|
||||
const visible = self.derive([status], (s) => project(s)); // synchronous
|
||||
self.register(Routes, { "GET /status": handler }); // registry keys are the registry's own keys; `lifetime: "host"` objects only
|
||||
return { status: visible, errors, report: (args, ctx) => { status.replace(ctx, args); } }; // exposed state = what is returned
|
||||
}, {
|
||||
linger: 30_000, // with lifetime "referenced" (the default) only
|
||||
});
|
||||
```
|
||||
|
||||
| Declaration | Survives reload | Notes |
|
||||
| --- | --- | --- |
|
||||
| `state(name, schema, initial \| { init }, { version?, migrate? })` | same cell and value (`initial` ignored, `init` not run); migrated when its version rises; a cell the reload adds runs `init` at prepare (gated objects: at the gate) | Schema required (no TypeScript at runtime). `change(ctx, draft => void)`, `replace(ctx, value)`, `value`, `subscribe`. Writes through `change` touch only mutated paths. Private state is a cell the implementation does not return. `init(ctx)` (may be async) gives the first value, see activation |
|
||||
| `get(type, input, { at? })` | kept if declared again (same address) | returns a reference |
|
||||
| `resource(name, { version, deps?, exclusive?, open, close? })` | kept unless `version`, `deps` or `exclusive` change; a kept resource adopts the new declaration (its `close`) at commit | `open` runs at prepare (non-exclusive) or at the gate (exclusive); returns a resource whose `.value` is the open value; a replaced resource closes with its own declaration's `close` |
|
||||
| `on(name, source, [select,] handler)` | same subscription while source and `select` (identity or source text) are unchanged; `handler` swapped | each event starts a fresh root in the current code version; `select` is not re-run for a kept subscription. State sources deliver their current value at start and after every reload that replaced them |
|
||||
| `task(fn)` | restarted with new code at commit: the old run is aborted via ctx.signal, and the new one starts once it finished (at most `taskStopDeadline`, default 5 s, then anyway, reported) | a code holder; calls inside start fresh roots |
|
||||
| `derive(sources, fn)` | a new derivation, computed at commit | synchronous fn |
|
||||
| `register(registry, { key: value })` | value swapped at commit | `lifetime: "host"` objects only |
|
||||
| `expose(resource)` | follows the resource | a local member (usually `opaque`) whose reference value is the resource's value once live |
|
||||
|
||||
State members of an implementation may be cells, derivations, or state members of references the object holds (they are defined once the object is live). `cell.replace(ctx, value, { diff? })` publishes a structural diff when the new value shares containers with the old one (or `diff: true`), else `r`. Facet `setup` receives `{ serve, name, root, registry, signal }` of its host; `signal` aborts once this run of the facet is no longer installed (a committed reload replaced or removed it, the reload that loaded it aborted, or the host closed).
|
||||
|
||||
`migrate` per cell is `(old, fromVersion) => new`. A cell may be several versions behind (author bumped more than once between reloads). Without `migrate`, a raised version keeps the value, which must fit the new schema.
|
||||
|
||||
**Listeners on state reconcile.** A listener whose source is state (a cell, a derivation, a reference's state member) is subscribed when it starts and gets the current value, then every new value. A reload that replaces its source (every derivation is new; a reference to another address is another source) subscribes it again, so it gets the current value again: its handler must reconcile (be idempotent), not count events. An event source (a resource's events, any other `subscribe`) delivers no current value; a kept subscription to it is not touched by a reload.
|
||||
|
||||
**Methods.** `method(args?, result?)`: without `args` the method takes none (callers `ref.name(ctx?)`, implementation `(ctx) => …`; `method(undefined, result)` for one that returns something); without `result` it is void (callers get `undefined`, whatever the implementation returned). The description records both (absent fields), and the `call` frame carries no `args` and the `result` frame no `value` for them. A consumer and provider must agree on whether a method takes arguments; a consumer without a result accepts a provider's.
|
||||
|
||||
Removed vs guide: `self.own`, `holdWhile`, async factories, inferred dependencies.
|
||||
|
||||
### Synchronous factory, asynchronous activation
|
||||
|
||||
`self.get` and `self.resource` only declare and return placeholders. Activation of an object:
|
||||
|
||||
1. run the factory synchronously (declarations);
|
||||
2. await declared resources' `open()` (e.g. connect a DB);
|
||||
3. await declared references becoming live (dependencies activate first, coalesced);
|
||||
4. run the `init` of every cell declared with one, in parallel, each in a root of its own acting as the host principal; validate and install the values;
|
||||
5. attach derivations, start listeners and tasks;
|
||||
6. object is live: references `live`, `ready` resolves.
|
||||
|
||||
Inside the factory, placeholders must not be used (`resource.value` and an `init` cell's `value` throw `not_ready`; reference calls throw). An `init` that throws, or returns a value its schema refuses, fails the activation like a failed `open`. An `init` must not wait for its own object or one that holds it (it is not live yet), and must not read another `init` cell of its object (they run in parallel). `init` gives a first value from a source the object reads anyway; it does not persist anything, and a cell is not saved anywhere by having one. Methods/listeners/tasks run only on live objects, so their dependencies are live. An object goes live only once its references are ready (live and, for remote ones, hydrated) and the state sources it exposes or derives from have their values, also where they forward a remote reference held some other way; waiting for references is bounded by the host's `readyTimeout` (default 60 s, `unavailable/ready_timeout`, also for a reference cycle across hosts). External consumers holding a `loading` reference (`host.hold`): calls wait until live (ctx cancellation, deadline); state members throw `not_ready` until the first hydration. Activation failures propagate to dependents and all waiting gets; the next get retries.
|
||||
|
||||
### Lifetime
|
||||
|
||||
`serve(type, factory, { lifetime })`:
|
||||
|
||||
| `lifetime` | Created | Ends | `linger` |
|
||||
| --- | --- | --- | --- |
|
||||
| `"referenced"` (default) | at the first `get` of its address | after the last reference is released, plus `linger` | allowed (default 0) |
|
||||
| `"host"` | when its facet loads (or at `serve`); loading waits until it is live | when the host closes, or a reload removes its facet | refused |
|
||||
|
||||
- `"host"` is refused at `serve` for remote types, for an input that does not accept `{}`, and together with `linger`;
|
||||
the types (`ServeOptionsFor`) refuse the same at compile time, where such an input is one with a required field.
|
||||
The type has exactly one object: the empty input (defaults applied), created for the host principal. Every `get`
|
||||
reaches it, whatever its principal (the factory sees the host principal); a `get` with another input is
|
||||
`invalid/input`. Other references do not change its lifetime: the host keeps its own until it closes.
|
||||
- Only `"host"` objects may register (`invalid/not_host_lifetime` otherwise).
|
||||
- A `"host"` object whose activation fails is started again by the next reload that changes its type. A reload that
|
||||
makes a type `"host"` creates its object; one that makes it `"referenced"` releases the host's reference, so the
|
||||
object lives on only while others reference it.
|
||||
|
||||
### Host objects readiness
|
||||
|
||||
- `host.install(facet, { awaitHostObjects? })` installs declarations synchronously and returns a promise for this
|
||||
facet's host object activations. `host.load(facets, { awaitHostObjects? })` installs the entire batch before awaiting
|
||||
its host object activations. `awaitHostObjects` defaults to true. Unreferenced objects are not activated.
|
||||
- Factory, resource-open, dependency and activation-finalization failures reject the returned promise with the
|
||||
activation error (ChordError codes preserved; ordinary errors wrapped as `unavailable`). Host object failures also
|
||||
reach `onError`. Installation is not a rollback transaction; setup/registration errors from `install` throw
|
||||
synchronously. Abandoned startup should close the host.
|
||||
- `{ awaitHostObjects: false }` starts the same host objects but resolves after installation, without waiting for
|
||||
their activation. `host.ready()` awaits a snapshot of the host's references to its host objects at call time
|
||||
(including direct `serve` and deferred loads), rejects their activation failures, and does not acquire additional
|
||||
references. It is not an ongoing health check and does not await task completion or remote state hydration.
|
||||
- Local bootstrap readiness and upstream admission are separate. A worker connects its upstream link, loads with
|
||||
`{ awaitHostObjects: false }`, awaits its local bootstrap reference (e.g. Harness, with no upstream dependency), then
|
||||
announces control readiness so its parent can accept the link. Only after admission does it await `host.ready()`.
|
||||
Otherwise a host object reporting to the server's SessionStatus deadlocks with the parent waiting for control
|
||||
readiness. Deferred loads skip the whole barrier, not selected dependencies; callers explicitly choose local
|
||||
bootstrap references, keep bootstrap busy until full readiness, and propagate later startup errors.
|
||||
|
||||
## 4. Principals, variants, references
|
||||
|
||||
- Principals are the app's type `P` (`createHost<P>`, `Host<P>`, `defineFacet<P>`, `Factory<C, P>`, `Ctx<P>`; default `unknown`). Chord invents none: `createHost({ self })` is the principal of the host's own code (roots, code inside objects, `lifetime: "host"` objects), `connect(link, { principal })` what the parent's own requests act as, `accept(link, { principal })` what a link acts as, `Route.principal(hostName)` what a routed host acts as when it calls back. All are required.
|
||||
- `createHost({ principalKey })` maps principal to a key that includes connection provenance and grant revision. Objects are per principal key. The host's own principal is recognized by key (`principalKey(p) === principalKey(self)`), not by object identity, so an equal principal never creates a second object.
|
||||
- Remote factories return `{ variant, ...members }`; `variant` must be a declared variant; members must match it. Local factories return any object. `variant` is reserved as a member name.
|
||||
- Reference: `status` (`loading | live | released | gone`), `ready` promise, `variant`, `shape` (`{ variant, members, contract }`), `onStatus(listener)` (every status change, with the error for `gone`), late-bound members, `release()`. `self.get` is synchronous (the object goes live after its references are ready); `ctx.get` and `host.get` resolve once the reference is ready: live, and every state member of its variant hydrated (`state: false`: live only). They reject with the activation error, `aborted` (ctx aborted or settled, `signal`), or `unavailable/ready_timeout`, releasing only that reference. `host.hold` is the synchronous form for UI runtimes. A held remote reference stays subscribed to its variant's state until released; after a link loss its last values stay readable while it re-holds. State members are typed `T`; reading before the first hydration throws `not_ready`, outside the variant `not_in_variant`, after the end the end's error. A remote reference type is a union discriminated by `variant`. Shape changes (reload, variant) are pushed; members missing from the current variant reject `not_in_variant`; released references reject `released`; forced disposal makes references `gone`.
|
||||
- Shared bindings: remote references on a host with the same key (type, normalized input identity, principal key, consumer contract fingerprint) are views onto one binding: one hold, one stream per state member, calls over that hold. Views have their own owner (object, call, external), status, observers and `release`; the binding ends (gone) or re-holds for all of them. Without views a binding closes, or lingers for `linger.types[typeName]` ms (`createHost({ linger: { types, cap } })`, at most `cap`, default 4, least recently released first) while staying held and hydrated.
|
||||
- Methods are `(args, ctx) => Promise<result>`. Factories still check arguments; variants are not complete authorization.
|
||||
- Revocation: `host.invalidatePrincipal(match)` force-disposes matching objects on this host and propagates over links; links re-stamp or close.
|
||||
|
||||
## 5. Lifetime
|
||||
|
||||
- Reference owners: object (declared `get`), call (`ctx.get`, released at settle), connection (remote references; relay maps per origin connection), host (`lifetime: "host"`), UI.
|
||||
- Activation coalesced per address; if all requesters leave before it completes, the result is disposed. Factory throw: unwind declarations, reject gets, next get retries.
|
||||
- Disposal after the last reference is released + `linger`: tasks aborted, listeners removed, resources closed (reverse order; all run; errors aggregated), the object's references released (cascade).
|
||||
- Bulk forced disposal (`host.close`, `invalidatePrincipal`) goes in waves, referrers before what they reference, so a resource's `close` can still use its object's references; `host.close` closes links after the objects.
|
||||
- Forced disposal: references `gone` immediately, new calls rejected, in-flight call contexts aborted, then cooperative cleanup.
|
||||
- Reference graph acyclic in-host (checked at get); across hosts by convention.
|
||||
- Links have ping/timeout; a dead link releases everything held through it.
|
||||
|
||||
## 6. Roots and code versions
|
||||
|
||||
- `Ctx = { signal, rootId, codeVersion, get(...): Promise<Ref>, principal? }`. Explicit; no AsyncLocalStorage dependency (may be used in dev to warn).
|
||||
- A link creates a root for a request that carries none; nested remote calls carry `{rootId, codeVersion}`.
|
||||
- Dispatch: a call with code version E runs the object's implementation for E. A host retains at most `current` and `previous` code versions (two-version rule). A call for a code version the host doesn't have (older than previous, or host started later) is rejected `superseded` (retryable by starting a new root). Never substitute another code version.
|
||||
- Browsers never carry code versions into the server: browser calls start roots at the server. Browsers learn of changes via shape/reset pushes and a server `Manifest` object; incompatible contract fingerprint -> `upgrade_required`.
|
||||
|
||||
## 7. Reload
|
||||
|
||||
Explicit trigger (`reload_plugins` tool, button, API). Serialized; no nesting.
|
||||
|
||||
1. Build/type-check gate (application tooling, outside Chord): dependency-closed set; refuse breaking remote contract changes without a contract version bump.
|
||||
2. Load: on every affected host, import new facet modules (cache-busted).
|
||||
3. Prepare: for each live object of each affected type, run the new factory in recording mode: existing cells readable, writes throw; listeners/tasks/registrations recorded, not started; references recorded; non-exclusive resources whose name is new or whose `version`/`deps` changed are opened as candidate-owned (closed on abort). Validate keys, variants, registry collisions. New objects activated during prepare use the current code version (old code). Any failure: abort, discard candidates. Vote.
|
||||
4. Gate (only objects with a cell whose version was raised or which replace an exclusive resource, plus objects depending on those resources):
|
||||
- Admission cut: from now, an old-code-version root that has not touched the object gets `upgrade_conflict` (retryable), not queued. Roots that already hold an activation lease on it are grandfathered; their re-entry is admitted.
|
||||
- Wait for grandfathered activations to finish, deadline (default 5 s). Missed deadline: abort the reload, reopen, nothing changed (report blocking roots).
|
||||
- Exclusive resources: close old, open new; on failure reopen old and abort.
|
||||
- Run `migrate` into private copies; validate.
|
||||
5. Commit (server commits itself, then sends commit to children; the decision is not persisted, see section 12; FIFO links guarantee no host receives a new-code-version root before committing): each host in one synchronous step installs migrated cell values and cell versions, installs implementations as code version E', swaps registry tables, restarts tasks with new code, rebinds listeners, pushes shape/reset to subscribers, opens gates. E' current, E previous.
|
||||
The coordinator's result carries `children`: each participating child's prepared report (its vote).
|
||||
6. Retire: when no roots of `previous` remain (and, on a child, the parent retired it), drop its implementations and release declarations only it declared. The next reload first waits for that, at most `retireDeadline`; then it aborts the roots still running on `previous` (their ctx signals), gives them up to 1 s to stop, and retires anyway. A coordinator retires before it sends `prepare`, so its `retire` frames reach the children first.
|
||||
|
||||
Compatible changes (no state bump, no exclusive resource change) skip the gate: no waiting.
|
||||
|
||||
State compatibility rule: reload prepare compares each cell's old and new schema and version. Unchanged: compatible (no gate). Version raised: gate + `migrate` (or the kept value), validated against the new schema. Schema changed without a raised version: reload refused. A lowered version: refused. A change of meaning with an identical schema needs a bump too (undetectable; author's responsibility). Initial values and migrate outputs are validated; per-write validation in dev mode.
|
||||
|
||||
## 8. Registries
|
||||
|
||||
```ts
|
||||
export const Routes = defineRegistry<RouteHandler>("http.routes", { conflict: "error" });
|
||||
self.register(Routes, { "GET /download": handler }); // in a `lifetime: "host"` object; keys are the registry's keys
|
||||
host.registry(Routes).get("GET /download", ctx); // table of ctx.codeVersion (or current without ctx)
|
||||
host.registry(Routes).subscribe((table, codeVersion) => ...); // bridges for imperative consumers (Durable)
|
||||
```
|
||||
|
||||
Per-code-version immutable tables; order = first registration order, kept on replace. An application can implement Durable's `RegistryReader` from the table (snapshot per code version) so phases pin a complete snapshot; Chord does not ship that bridge.
|
||||
|
||||
## 9. Hosts and links
|
||||
|
||||
- `createHost({ name, self, principalKey, upstream?: { link, principal } })`; `host.accept(link, { principal })` for children; `host.connect(link, { principal })` for the parent; `host.route(prefix, { authorize(principal, hostName), resolve(hostName) => Promise<Link>, principal(hostName) })`.
|
||||
- Links: `memoryLinkPair()` (tests), Node IPC (child_process), WebSocket (server `ws`, browser `WebSocket`).
|
||||
- Principal stamping: the accepting side stamps the principal of what connects (from its authenticator); forwarded principals accepted only from the parent link; the parent's own requests (no forwarded principal) act as the principal given to `connect`; payload principal fields ignored.
|
||||
- A child that connects behind its parent's code version is fenced (nothing routed to it) and reconciled by a reload of its modules to the parent's code version. Calls its roots of the old code version make upstream are admitted in the parent's current code version (the parent never ran that code version), not rejected `superseded`.
|
||||
- Relay: server forwards a hold to the child link of its target host (`at`, resolved by the consumer from the contract's placement and the normalized input, and checked against both), mapping references per origin connection; authorizes before resolving/spawning and re-checks after.
|
||||
- Wire frames (JSON): `hello`/`bye`, `hold`/`release` (a reference over a link: `hold` returns `shape` or `gone`; there is no separate `get`), `shape`, `call`/`result`/`cancel`, `sub`/`unsub`/`resync` and `state`, `gone`, `root_end`, `retire`, `invalidate`, `ping`/`pong`, `ack`, reload control (`prepare`/`vote`/`commit`/`abort`).
|
||||
- State: a `hold` implies the state of its variant (`state: false`: none until `sub`; `unsub` stops it). The provider answers with units: a `shape` whose `state` lists the members the consumer lacks, followed by one `state` frame `as: "snap"` per listed member; the consumer applies the shape once those snapshots are here. Later `state` frames of a member (addressed by hold id and member `m`) are `ops` (per-stream delta encoder), `reset`, or `error`; a `snap`/`reset` without `value` carries `undefined`. A failed snapshot before the first hydration ends the reference with its error. A reload or variant change sends a unit with the members that are new; members that left stop. `again` marks a re-hold after a link loss (the consumer has values). `origin` names the client connection a hold comes from (stamped by the host that accepted the client, forwarded by relays, honored only from the parent link).
|
||||
- Protocol version: `hello` carries `protocol` (`PROTOCOL_VERSION`, now 4; absent means 1). A side that sees another version processes nothing more, calls `LinkOptions.onProtocolMismatch({ local, peer })` and closes the link with reason `protocol-version <its version>`; the other side's connection reads that reason and reports the mismatch too. Peers do not negotiate: the outdated side must load the current code (a browser reloads its page). Frame fields follow the version: the `shape` frame names the variant `variant`, code versions travel as `codeVersion` (2); remote contract tokens carry their placement (`host`), and a `hold` carries the input normalized by the consumer (3); a hold implies its state, units, `origin`, and `shape` without a code version (4).
|
||||
- Flow control: both ends announce `acks` in their hello and acknowledge what they processed (`ack`, a cumulative count of every frame but `ack`). While the frames not yet acknowledged exceed the window (`window`, default 256 KiB of JSON text), state waits at the sender in slots flushed in creation order: one per member stream (ops are appended, a snapshot or reset replaces it, a release or `unsub` drops it) and one per hold for a unit (lazy: built from the current values when sent; a newer unit merges into it). Waiting ops are capped per link (`pendingCap`, default four windows): beyond, the oldest streams become a reset of their current value built at flush (a relay sends `resync` downstream and drops that member's ops until the reset arrives). The first unit of a hold that is not `again` and every frame but state are sent at once. Links that report no frame sizes (IPC) are never windowed; a peer without `acks` is neither windowed nor acknowledged. An acceptor can limit the calls a peer has in progress (`maxCalls`); calls beyond it are answered at once with `unavailable/too_many_calls`. Nothing outside the connection may add frames to a link: acknowledgements count them.
|
||||
|
||||
## 10. Host manager (library)
|
||||
|
||||
`createHostManager({ driver, keepAlive })`: fork driver (entry, init, lock, env), serialized start/stop/replace, exit-before-replace, process-tree kill after grace, control channel (ready/fatal/shutdown), liveness callback (booting, activeCalls, viewers with heartbeats, durable tasks, idleFor).
|
||||
|
||||
## 11. Test coverage
|
||||
|
||||
The tests cover at least: lifetime/references/linger/forced disposal/gone; activation coalescing and abandonment; principals and variants; remote calls over memory links incl. relay and principal forgery attempts; state snap/ops/reset over a link with reconnect; registries per code version; reload: compatible (no wait, pinned roots), prepare isolation (writes throw, failing factory leaves everything unchanged), state bump with migrate and gate, `upgrade_conflict` for late old roots, deadline abort, exclusive resource replacement, cross-host commit ordering, two-version retirement and `superseded`.
|
||||
|
||||
## 12. Known limitations and decisions
|
||||
|
||||
### Still open
|
||||
|
||||
- **Retirement across hosts.** A child retires a code version only after its parent retired it (`retire` frame) and its own roots of that code version ended; the server decides from its own roots. A child that comes in step (hello or reconciliation) is told which code versions the parent retired before. A root started on a worker that calls up to a server which has already retired the code version both had still gets `superseded` (retryable).
|
||||
- **The commit decision is not persisted** (section 7, step 5). A server crash between its own commit and a child's commit leaves that child on the old code version until it reconnects and is reconciled.
|
||||
- **One level of reload participants.** Only the coordinator's direct child hosts take part; a child does not forward `prepare` to its own children.
|
||||
- **Reconciliation needs the child's module list.** It is remembered from cluster reloads (`host.remoteModules`), so a server restart forgets it, and a child behind a restarted server then reloads no modules: it gets the code version id with its own code. The reconciliation vote deadline is fixed at 30 s; a failed reconciliation closes the link, so the host manager restarts the worker.
|
||||
- **Links do not resume.** The provider releases everything held through a closed link at once, so re-held objects are new ones. Keeping references for a reconnect window would need session ids on links. Calls in flight when a link drops reject `unavailable`; whether they ran is unknown.
|
||||
- **Refused clients cannot tell.** The WebSocket adapter closes unauthenticated sockets; the client sees a dropped upstream and its calls wait for a reconnect until their ctx is aborted.
|
||||
- **The publication queue is process-wide and synchronous.** A slow observer delays every later notification in the process, including those of other hosts in the same process.
|
||||
- **No automatic restart.** A terminal host failure is picked up by the host manager's `keepAlive` or `fatal` path, not inside Chord.
|
||||
- **The Node loader leaks.** Every reload imports fresh module copies that are never evicted, and it does not isolate plugins (`TODO(loader)`).
|
||||
- **Stale socket locks.** On Unix systems other than Linux, two processes that probe the same stale socket file at the same moment could both take the lock. Managers serialize starts per lock name.
|
||||
- **Listener `select` identity.** A listener's subscription is kept across factory runs when its origin and `select` are unchanged; `select` counts as unchanged when identical or with the same source text, so a `select` whose implementation depends on captured values that change between runs is not detected.
|
||||
- **Shutdown and dead links.** `host.close()` disposes objects in waves (referrers before what they reference) and closes links last. A `close` that awaits a remote call over a link that is already down waits for a reconnect; bound such calls.
|
||||
|
||||
### Decisions
|
||||
|
||||
- **Errors.** The guide's codes; a `reason` field carries the detail, for example `invalid/args`, `invalid/cycle`, `invalid/duplicate`, `invalid/read_only`, `invalid/not_host_lifetime`, `invalid/registry_conflict`, or `unavailable/unknown_type`. A `ChordError` thrown by a factory or a dependency keeps its code through every dependent; other errors become `unavailable`, with the original as `cause`.
|
||||
- **Reload errors.** There is no `reload_aborted` code. A failed reload throws the cause's code and reason with the message `reload aborted: …` (the cause is attached). Reasons of its own are `aborted/gate_deadline` (with the blocking roots in `details`), `aborted/deadline`, `aborted/vote_deadline` and `unavailable/reload_running`. Across hosts, the message names the host that failed. A child cannot reload on its own (`unavailable/not_coordinator`).
|
||||
- **New objects during prepare.** A reload pre-allocates the next code version ("pending"). Types the same reload adds are served from it; their objects install implementation only for the pending code version and are force-disposed if the reload aborts.
|
||||
- **Gate admission.** From the gate until commit or abort, creating a new object of a changed type fails with `upgrade_conflict` (retryable, reason `gate`); nothing is paused, so nothing can deadlock. Gated objects get `minCodeVersion = E'`: calls and writes carrying an older code version are rejected `upgrade_conflict` permanently, and a gated object drops its previous-code-version implementation at commit. Only objects whose cell versions or exclusive resources change are gated.
|
||||
- **Tasks and listeners during the gate.** Tasks of gated objects are aborted at the admission cut and restart with new code at commit, or with the old code on abort. Listener events for gated objects are queued and replayed through the new listener at commit, or the old one at abort.
|
||||
- **Grandfathered roots** are the roots that called into the object and are still running at the cut. Every host remembers on which links a root's calls went out and sends `root_end` on them when the root ends, so leases outlive calls across hosts.
|
||||
- **Removed types and missed retirement.** Objects of removed types are force-disposed at commit. When a retirement deadline is missed, retirement is forced: those roots are aborted first (their ctx signal, reason `superseded`; a relayed call's root cancels its downstream call), then what only that code version declared is closed, and their next call is `superseded`.
|
||||
- **Joining during a reload.** Participants are the child hosts in step when a reload starts. A child whose hello arrives while it runs is decided when it ends: in step, or reconciled to the committed code version; a child already behind is fenced at once, so the calls its roots make meanwhile are admitted in the parent's code version. A reconciliation that is running when a reload starts syncs its child only after that reload ended, against the code version it committed. A reconciliation announces the parent's retired code versions before its `prepare`.
|
||||
- **Two-version rule.** An object of a changed type created after a commit has only new code; a previous-code-version root calling it gets `superseded`.
|
||||
- **Validation.** Remote calls are validated at reference dispatch, in-host too: a direct binding against the provider's schema, a binding through an adapter against the consumer's. A provider validates each snapshot and reset of a remote state member once per (re)subscription, against the consumer's contract through an `accepts` adapter and the provider's otherwise; ops are validated only in dev mode. At reload, the new provider's description must cover the old provider's when the version is unchanged, in addition to the check against live consumers and facets' `uses`.
|
||||
- **Contracts.** Local contracts list their members, so a loading reference knows whether a member is a method, state or opaque. Canonical identity omits input fields equal to their default; use `opt(str({ default }))` (pi-schema) to let callers omit them. Provider variants must cover consumer variants; members the consumer's contract does not list are hidden. Schema change detection compares canonical JSON, so reordering `anyOf` branches counts as a change. Fingerprints use a non-cryptographic 64-bit hash: synchronous and browser-safe; contracts are trusted.
|
||||
- **Principals.** A root ctx at ingress acts as its principal (`host.root(fn, { principal })`); contexts inside objects act as `host`. The acceptor decides a link's kind and name (`accept(link, { principal, kind, name })`); only configured host links are addressable, carry roots and may vote, and control frames are accepted only from the parent. Clients never carry roots or code versions. Relaying needs a matching `route`; `authorize(principal, hostName)` runs before `resolve` and again after, and revoking or re-stamping a principal closes its relays at once. The host `resolve` returns is admitted before that second check, so a refused, revoked or released relay never leaves a spawned host's link unread. `invalidatePrincipal(match)` re-stamps or closes matching links and propagates pattern matches to child hosts.
|
||||
- **Cells and readables.** A value returned from `change` replaces the value and is always validated; `replace` transfers ownership without copying; by default (`{ diff }` overrides) it publishes a diff when the new value reuses a container of the old one at the same key, else `r`. `subscribe(fn)` delivers values, starting with the current one; `events(fn)` delivers `snap`, then `ops`, or `reset`. Reference state members are stable readables that throw `not_ready` while loading and `not_in_variant` outside the variant, and reset when their source moves; a listener added before they are readable gets its snapshot then. Remote state members stay hydrated while the reference is held.
|
||||
- **References and calls.** A reference's identity is its address; a second `self.get` of the same address in one run returns the same reference. A reference call without ctx, or with a task ctx, starts a fresh root in the current code version; a ctx whose call or root already settled is refused (`aborted/expired`), one from another host `invalid/foreign_ctx`. Calling a reference inside a factory throws `not_ready`.
|
||||
- **Resources and disposal.** New and replaced exclusive resources open at the gate, and so do non-exclusive resources of the same object that depend on them. A new object for an address that is still being disposed waits for that disposal. A resource open that completes after its signal aborted closes its value at once.
|
||||
- **Links and the host manager.** Every received frame is validated against its schema; malformed frames, duplicate ids and control frames from the wrong side close the link. Each connection pings every `pingInterval` and closes after `pingTimeout` without a frame. The child holds a kernel lock (an abstract socket on Linux and Android, a named pipe on Windows, a probed socket file elsewhere), acquired by `childControl().lock()` before storage opens. The manager kills the child's process group on every exit path and derives `booting` and `idleFor` from the child's liveness reports. WebSocket support is structural, so Chord does not depend on `ws`.
|
||||
- **Flow control holds back state only.** A relay (Cloudflare to a browser) can hide a slow reader, so transport backpressure cannot be relied on; acknowledgements are end to end. Results must not wait behind state, so links are FIFO except that waiting state frames can be overtaken: a result may arrive before state changes made before it. Frames of one subscription stay in order. Waiting state is coalesced rather than queued and capped per link, so a stalled peer costs bounded memory, and the next frame it gets is current. The first unit of a new reference is not held back, so a `get` never waits behind other streams.
|
||||
- **The state API stays.** Objects keep named cells and unnamed derivations; giving each object exactly one state value was rejected. Do not reopen this without a new, concrete problem.
|
||||
|
|
@ -1,932 +0,0 @@
|
|||
# Build a shared todo list with Chord
|
||||
|
||||
## 1. What problem does Chord solve?
|
||||
|
||||
A todo list has data, operations on that data, and several people looking at it.
|
||||
You could hand-write object creation, subscription cleanup, messages between
|
||||
processes, and code replacement. Chord connects these pieces for you.
|
||||
|
||||
A **host** is the runtime that owns objects. An **object** combines state with
|
||||
methods at an address, such as “the family todo list.” A **reference** is a caller's
|
||||
reference to that object, not the object itself. Several references can reach the
|
||||
same object. References can also reach another host over a connection.
|
||||
|
||||
```text
|
||||
browser host server host
|
||||
ref + subscribed rows -- link --> Todos view (owner or guest)
|
||||
|
|
||||
ref
|
||||
v
|
||||
TodoStore: rows + methods
|
||||
```
|
||||
|
||||
We will start inside one host, add observable state, then share it with a
|
||||
browser. Finally we will add background work, plugins, and hot reload.
|
||||
**Live state** means subscribers receive new values when data changes, without
|
||||
asking for it again.
|
||||
|
||||
<!-- example:01 -->
|
||||
```ts
|
||||
const host = createHost({ self: "server", name: "todo-server" });
|
||||
assert.equal(host.name, "todo-server");
|
||||
await host.close();
|
||||
```
|
||||
|
||||
The assertion passes: the host's name is `todo-server`. Creating a host does
|
||||
not create a todo object, start a server socket, or persist data. `close()`
|
||||
shuts down its objects and connections.
|
||||
|
||||
> **Tip:** These are executable TypeScript examples, using assertions to show
|
||||
> results. Shared imports are at the end. Later sections reuse earlier contracts
|
||||
> and functions, but each section's test starts with fresh hosts. Test-only
|
||||
> cleanup closes hosts even when an assertion fails; snippets omit that cleanup.
|
||||
|
||||
## 2. Your first object
|
||||
|
||||
Before callers can use an object, they need to agree on its name and members.
|
||||
That agreement is a **contract**: a runtime value that also gives TypeScript its
|
||||
types. A **schema** describes a value's shape: plain JSON Schema, which we write
|
||||
with the builders of `@earendil-works/pi-schema` (`obj`, `str`, `num`, ...;
|
||||
`{ open: true }` lets an object carry fields it does not name).
|
||||
`method(args, result)` declares a method by its argument and result schemas:
|
||||
`method()` takes no arguments and returns nothing, and
|
||||
`method(undefined, str())`, used below, takes no arguments and returns a string.
|
||||
|
||||
Start with a label for a named list. `local` means only code inside this host
|
||||
can reach it. The input `{ list: "family" }` identifies which list we want;
|
||||
it is not an argument to `label`.
|
||||
|
||||
<!-- example:02-contract -->
|
||||
```ts
|
||||
const TodoLabel = local("tutorial.todos", {
|
||||
version: 1,
|
||||
input: obj({ list: str() }, { open: true }),
|
||||
members: { label: method(undefined, str()) },
|
||||
});
|
||||
```
|
||||
|
||||
A **factory** is a synchronous function that creates the object's implementation:
|
||||
the methods and state callers will see. `serve` registers a contract and its
|
||||
factory. Registration alone does not activate this lazy object; the first
|
||||
`get` does.
|
||||
|
||||
A **root** is one short tree of calls, such as the work for a button click.
|
||||
`host.root` starts it. Its **context**, called `ctx`, carries cancellation,
|
||||
caller identity, and the code version for nested calls. Pass it to methods you
|
||||
call from that root.
|
||||
|
||||
<!-- example:02-call -->
|
||||
```ts
|
||||
const host = createHost({ self: "server" });
|
||||
host.serve(TodoLabel, ({ list }) => ({ label: () => `Todos for ${list}` }));
|
||||
const result = await host.root(async (ctx) => {
|
||||
const todos = await ctx.get(TodoLabel, { list: "family" });
|
||||
return todos.label(ctx);
|
||||
});
|
||||
assert.equal(result, "Todos for family");
|
||||
```
|
||||
|
||||
The result is `"Todos for family"`. `await ctx.get` resolves once the object is
|
||||
live (and, as we will see, has its state). The reference acquired through
|
||||
`ctx.get` is released when this root ends. We never receive the factory's
|
||||
returned object directly. A standalone call without `ctx`, such as
|
||||
`todos.label()`, starts its own root. A method without arguments takes only
|
||||
the context: `todos.label(ctx)`; its implementation is `(ctx) => …`. Inside a root or method, pass the
|
||||
existing `ctx` so nested calls stay in the same call tree.
|
||||
|
||||
The factory may also receive `principal` (who is asking) and `self` (the
|
||||
object's declaration API). We did not need them yet.
|
||||
|
||||
> **Why:** The reference separates callers from implementations. Later, replacing
|
||||
> code will not require replacing every caller's reference.
|
||||
|
||||
## 3. Give the list live state
|
||||
|
||||
A label is not a todo list. We need rows that can change, and a count that always
|
||||
agrees with them. A **cell** owns an observable JSON value. A **derivation**
|
||||
computes another observable value from cells or other readable sources.
|
||||
|
||||
Extend the contract with `rows`, `openCount`, and editing methods. State
|
||||
members describe values, not methods. This adds members at contract version 1;
|
||||
older callers that only need `label` still have everything they need.
|
||||
|
||||
`self.state(name, schema, initial)` declares a cell and returns it. The name
|
||||
`rows` is important: on reload, Chord matches that name to the existing cell and
|
||||
keeps its value. `self.derive(sources, fn)` declares a derivation; it has no
|
||||
name, because it holds nothing a reload must keep.
|
||||
|
||||
<!-- example:03-contract -->
|
||||
```ts
|
||||
const Todo = obj({ text: str(), done: bool() }, { open: true });
|
||||
const Rows = arr(Todo);
|
||||
const TodoStore = local("tutorial.todos", {
|
||||
version: 1,
|
||||
input: obj({ list: str() }, { open: true }),
|
||||
members: {
|
||||
label: method(undefined, str()),
|
||||
rows: Rows,
|
||||
openCount: num(),
|
||||
add: method(obj({ text: str() }, { open: true })),
|
||||
finish: method(obj({ index: int() }, { open: true })),
|
||||
reset: method(Rows),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The factory now declares a cell and a derivation. It is wrapped in `todoStore`,
|
||||
which takes the cell's options (section 9 raises its version); for now
|
||||
`todoFactory` uses the defaults. Each method receives its arguments and a
|
||||
context; changing the cell updates its readers and its derived count together.
|
||||
`add`, `finish` and `reset` declare no result, so they return nothing.
|
||||
|
||||
<!-- example:03-factory -->
|
||||
```ts
|
||||
/** The store's factory; `options` versions its rows (section 9). */
|
||||
const todoStore =
|
||||
(options?: StateOptions<Static<typeof Rows>>): Factory<typeof TodoStore> =>
|
||||
({ list }, _principal, self) => {
|
||||
const rows = self.state("rows", Rows, [], options);
|
||||
const openCount = self.derive([rows], (todos) => todos.filter((todo) => !todo.done).length);
|
||||
return {
|
||||
label: () => `Todos for ${list}`,
|
||||
rows,
|
||||
openCount,
|
||||
add: ({ text }, ctx) => {
|
||||
rows.change(ctx, (draft) => {
|
||||
draft.push({ text, done: false });
|
||||
});
|
||||
},
|
||||
finish: ({ index }, ctx) => {
|
||||
rows.change(ctx, (draft) => {
|
||||
const todo = draft[index];
|
||||
if (!todo) throw invalid("args", "No such todo");
|
||||
todo.done = true;
|
||||
});
|
||||
},
|
||||
reset: (next, ctx) => {
|
||||
rows.replace(ctx, next, { diff: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
const todoFactory = todoStore();
|
||||
```
|
||||
|
||||
`change(ctx, fn)` gives `fn` a temporary mutable **draft**. Here `add`
|
||||
pushes a row and `finish` changes one field. The draft is discarded if the
|
||||
callback throws. The callback must be synchronous.
|
||||
|
||||
`replace(ctx, value)` replaces the whole value. We use it for resetting a
|
||||
list we own completely. `{ diff: true }` asks Chord to publish just the
|
||||
structural changes. Without that option, replacement diffs automatically when
|
||||
the new value shares containers at the same keys with the old one; unrelated
|
||||
values normally publish a whole-value replacement. Either way, subscribers
|
||||
receive the same resulting value.
|
||||
|
||||
<!-- example:03-call -->
|
||||
```ts
|
||||
const host = createHost({ self: "server", dev: true });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
const todos = await host.get(TodoStore, { list: "family" });
|
||||
const counts: (number | undefined)[] = [];
|
||||
const stop = todos.openCount.subscribe((value) => counts.push(value));
|
||||
await host.root(async (ctx) => {
|
||||
await todos.add({ text: "Buy milk" }, ctx);
|
||||
await todos.finish({ index: 0 }, ctx);
|
||||
await todos.reset([], ctx);
|
||||
});
|
||||
assert.deepEqual(counts, [0, 1, 0]);
|
||||
assert.deepEqual(todos.rows.value, []);
|
||||
stop();
|
||||
todos.release();
|
||||
```
|
||||
|
||||
The count subscriber sees `0, 1, 0`: initially empty, one unfinished todo,
|
||||
then no unfinished todos. Resetting to empty does not add another count
|
||||
notification because the derived count is still zero. The final rows are `[]`.
|
||||
`subscribe` calls back immediately with the current value and returns an
|
||||
unsubscribe function. The awaited `get` makes sure there is a value: a reference
|
||||
you hold is live, with its state.
|
||||
|
||||
> **Why:** Store reloadable state in cells, not closure variables. Treat
|
||||
> `cell.value`, `ref.rows.value`, and subscriber values as immutable.
|
||||
> Only mutate drafts inside `change`; do not keep a draft for later use.
|
||||
> A derivation runs synchronously, so use it for projections, not database work.
|
||||
|
||||
## 4. Let objects use objects and resources
|
||||
|
||||
Saving a todo may require both the list and a database connection. A
|
||||
**reference** keeps the object it refers to alive. `self.get` declares a
|
||||
reference owned by this object; it lasts longer than a single method call.
|
||||
|
||||
A **resource** is something the runtime opens and later closes, such as a
|
||||
database connection. Its `open` may be asynchronous even though the factory
|
||||
must be synchronous. The example database is deliberately fake: it just saves
|
||||
strings in memory.
|
||||
|
||||
`opaque<T>("name@version")` describes a native, host-only member. It is not
|
||||
JSON state and cannot be sent over a link. `self.expose` connects that member
|
||||
to a declared resource, without reading the resource during the factory.
|
||||
|
||||
<!-- example:04-database -->
|
||||
```ts
|
||||
const Database = local("tutorial.database", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { db: opaque<{ saved: string[] }>("tutorial.database@1") },
|
||||
});
|
||||
const SavedTodos = local("tutorial.saved-todos", {
|
||||
version: 1,
|
||||
input: obj({ list: str() }, { open: true }),
|
||||
members: { add: method(obj({ text: str() }, { open: true })) },
|
||||
});
|
||||
const host = createHost({ self: "server" });
|
||||
let opens = 0;
|
||||
const closed = Promise.withResolvers<void>();
|
||||
host.serve(Database, (_input, _principal, self) => {
|
||||
const db = self.resource("db", {
|
||||
version: 1,
|
||||
open: async () => {
|
||||
opens++;
|
||||
return { saved: [] as string[] };
|
||||
},
|
||||
close: () => closed.resolve(),
|
||||
});
|
||||
return { db: self.expose(db) };
|
||||
});
|
||||
```
|
||||
|
||||
The factory returns an exposed marker, not an opened connection. Chord runs
|
||||
`open` after declarations finish. The promise named `closed` lets the test
|
||||
observe eventual cleanup.
|
||||
|
||||
Now declare `SavedTodos` as an object that holds both the store and the database.
|
||||
Its editing method uses these dependencies only after activation.
|
||||
|
||||
<!-- example:04-saving -->
|
||||
```ts
|
||||
host.serve(TodoStore, todoFactory);
|
||||
host.serve(
|
||||
SavedTodos,
|
||||
({ list }, _principal, self) => {
|
||||
const database = self.get(Database, {});
|
||||
const todos = self.get(TodoStore, { list });
|
||||
return {
|
||||
add: async ({ text }, ctx) => {
|
||||
database.db!.saved.push(text);
|
||||
await todos.add({ text }, ctx);
|
||||
},
|
||||
};
|
||||
},
|
||||
{ linger: 0 },
|
||||
);
|
||||
```
|
||||
|
||||
An outside consumer holds a `SavedTodos` reference. We also hold `Database`
|
||||
directly so we can inspect what was saved.
|
||||
|
||||
<!-- example:04-call -->
|
||||
```ts
|
||||
const saved = await host.get(SavedTodos, { list: "family" });
|
||||
const database = await host.get(Database, {});
|
||||
await host.root((ctx) => saved.add({ text: "Buy milk" }, ctx));
|
||||
assert.deepEqual(database.db!.saved, ["Buy milk"]);
|
||||
assert.equal(opens, 1);
|
||||
const statuses: string[] = [];
|
||||
saved.onStatus((status) => statuses.push(status));
|
||||
database.release();
|
||||
saved.release();
|
||||
await closed.promise;
|
||||
assert.equal(saved.status, "released");
|
||||
assert.ok(statuses.includes("released"));
|
||||
```
|
||||
|
||||
After the method, the fake database contains `["Buy milk"]`. It opened once,
|
||||
even though two objects referenced it. `SavedTodos` and `Database` become live
|
||||
only after their resources and declared dependencies are ready. That is why
|
||||
`database.db!` is safe inside this live object's method: activation has
|
||||
already established that dependency. An outside caller awaits its `get`.
|
||||
|
||||
When both external references are released, `SavedTodos` is disposed and
|
||||
releases its own database reference. With no references left, the database closes; we
|
||||
wait for `closed.promise` to prove it. The released reference stays
|
||||
`released`, and `onStatus` reports that transition. `onStatus` watches
|
||||
changes; unlike state subscription, it does not deliver an initial status.
|
||||
|
||||
`linger` is a delay in milliseconds after the last reference is released. Zero disposes
|
||||
without a grace period; a positive value can avoid reopening an expensive
|
||||
resource when a screen is briefly hidden. Linger is not persistence.
|
||||
|
||||
> **Tip:** A factory only declares dependencies. Never call a referenced object's method or
|
||||
> read `resource.value` there, including during reload. Activation happens
|
||||
> afterward. Bump a resource's `version` when its configuration should be reopened.
|
||||
> Disposal stops tasks and listeners, closes resources, then releases references.
|
||||
|
||||
## 5. Who is asking? One store, two views
|
||||
|
||||
Owners may add todos; guests may only read them. A **principal** is the
|
||||
application-defined identity of a caller. A **variant** is a named set of
|
||||
members a remote object offers to that principal. It lets the UI discover
|
||||
whether an editing method is available.
|
||||
|
||||
Do not create a separate data cell for each viewer: their lists would diverge.
|
||||
Instead, each view holds the same host-local `TodoStore` and exposes its rows.
|
||||
`self.get` acts as the host's own principal, so owner and guest views share
|
||||
that store. Remote view objects themselves are separate per principal key.
|
||||
A remote contract also says where its objects live: `host: hostNamed("server")`
|
||||
puts every `Todos` object on the host named `server`. Section 6 uses that.
|
||||
|
||||
<!-- example:05-views -->
|
||||
```ts
|
||||
const Todos = remote("tutorial.todo-view", {
|
||||
version: 1,
|
||||
input: obj({ list: str() }, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
viewer: { state: { rows: Rows } },
|
||||
editor: {
|
||||
state: { rows: Rows },
|
||||
methods: { add: method(obj({ text: str() }, { open: true })) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Who may ask: the owner, a guest, or the server's own code. */
|
||||
type Principal = "owner" | "guest" | "server";
|
||||
|
||||
function serveViews(api: Pick<Host<Principal>, "serve">): void {
|
||||
api.serve(Todos, ({ list }, principal, self) => {
|
||||
if (list !== "family") throw forbidden("This list is not shared");
|
||||
const store = self.get(TodoStore, { list });
|
||||
if (principal === "guest") {
|
||||
return { variant: "viewer", rows: store.rows };
|
||||
}
|
||||
return {
|
||||
variant: "editor",
|
||||
rows: store.rows,
|
||||
add: ({ text }, ctx) => {
|
||||
if (text.trim().length === 0) throw invalid("args", "A todo needs text");
|
||||
return store.add({ text }, ctx);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Here principals are the strings `"owner"` and `"guest"`, assigned by
|
||||
trusted application code, and `"server"`, the host's own code: every host is
|
||||
created with `self`, the principal its own roots and objects act as. Chord
|
||||
invents no principals. The `Principal` type parameter of `createHost` and
|
||||
`Host` makes `principal` in the factory that type, so TypeScript checks the
|
||||
comparisons. `principalKey` maps identity to object identity. Real
|
||||
applications should use stable identity, connection provenance, and grant
|
||||
revision, not display names.
|
||||
|
||||
<!-- example:05-call -->
|
||||
```ts
|
||||
const host = createHost<Principal>({ name: "server", self: "server", principalKey: (principal) => principal });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
serveViews(host);
|
||||
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
|
||||
const guest = await host.get(Todos, { list: "family" }, { principal: "guest" });
|
||||
assert.ok(owner.variant === "editor"); // narrows the reference to the editor variant
|
||||
assert.equal(guest.variant, "viewer");
|
||||
await host.root((ctx) => owner.add({ text: "Buy milk" }, ctx), { principal: "owner" });
|
||||
assert.deepEqual(guest.rows.value, [{ text: "Buy milk", done: false }]);
|
||||
// @ts-expect-error A viewer has no add: TypeScript refuses it, and so does the provider.
|
||||
await assert.rejects(guest.add({ text: "Not allowed" }), (error) => isChordError(error, "not_in_variant"));
|
||||
await assert.rejects(owner.add({ text: " " }), (error) => isChordError(error, "invalid"));
|
||||
```
|
||||
|
||||
The owner gets `editor`; the guest gets `viewer`. Both see the same milk
|
||||
todo. A reference is typed as the union of its variants: `add` is reachable only
|
||||
after narrowing on `variant` (`assert.ok(owner.variant === "editor")` does that).
|
||||
The guest's `add` is refused by TypeScript, and, called anyway, rejects with
|
||||
`not_in_variant`. Whitespace-only text
|
||||
rejects with `invalid`: it is a string, as the schema demands, but it breaks a
|
||||
rule only the method knows. Use `invalid` for bad input and `forbidden` only
|
||||
for callers who may not do something.
|
||||
|
||||
> **Why:** A variant is not all of authorization. The factory checks which
|
||||
> list may be opened; the method checks the operation's arguments. Production
|
||||
> code must also enforce grants that can change. Use
|
||||
> `host.invalidatePrincipal(match)` to revoke matching objects; held references
|
||||
> become `gone`. A UI watches `onStatus` and gets a new reference when appropriate.
|
||||
> Guest-provided names or message text must never establish identity.
|
||||
|
||||
## 6. Reach the list from another process
|
||||
|
||||
A browser cannot call a host-only contract. `remote` contracts describe only
|
||||
schema-checked state and methods that can travel as JSON. Our `Todos` view is
|
||||
remote; the underlying `TodoStore` and database stay local. A caller never
|
||||
names a host: `browser.get(Todos, …)` goes to `server` because the contract
|
||||
says so, and the server refuses to serve, or to be asked for, a type it is not
|
||||
the place of. A type that lives on one host per session says
|
||||
`host: hostFamily("session:", "session")`: its input's `session` field picks
|
||||
the host (`{ session: "S1" }` lives on `session:S1`).
|
||||
|
||||
A **link** carries ordered messages between hosts. A memory link runs both ends
|
||||
in one test process, but uses the remote protocol. The accepting host stamps
|
||||
the caller's principal from its authenticator, not from method arguments or a
|
||||
client's claimed identity. The connecting side names the principal its parent's
|
||||
own requests act as (`upstream: { link, principal }`, or `connect(link, {
|
||||
principal })`).
|
||||
|
||||
<!-- example:06 -->
|
||||
```ts
|
||||
const server = createHost<Principal>({ self: "server", name: "server" });
|
||||
server.serve(TodoStore, todoFactory);
|
||||
serveViews(server);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: "owner", kind: "client" });
|
||||
const browser = createHost({
|
||||
self: "browser",
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: "server" },
|
||||
});
|
||||
const todos = await browser.get(Todos, { list: "family" });
|
||||
assert.ok(todos.variant === "editor");
|
||||
assert.deepEqual(todos.rows.value, []);
|
||||
const updated = Promise.withResolvers<void>();
|
||||
const stop = todos.rows.subscribe((rows) => {
|
||||
if (rows.length === 1) updated.resolve();
|
||||
});
|
||||
await browser.root((ctx) => todos.add({ text: "Buy milk" }, ctx));
|
||||
await updated.promise;
|
||||
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
|
||||
// Deliberately bypass TypeScript to simulate a malformed caller.
|
||||
await assert.rejects(todos.add({ text: 123 } as never), (error) => isChordError(error, "invalid"));
|
||||
stop();
|
||||
// A held reference keeps its state without listeners.
|
||||
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
|
||||
todos.release();
|
||||
```
|
||||
|
||||
The browser sees an empty snapshot, then the new milk todo. `ready` means
|
||||
the remote reference has bound to a live object **and** has its state: every
|
||||
state member of its variant has a snapshot. A held reference stays subscribed,
|
||||
with or without listeners, until it is released.
|
||||
|
||||
What crosses the wire: the input and consumer's contract description, the
|
||||
provider's shape (variant and members), method arguments and results, and the
|
||||
state of the reference's variant. State starts with a **snapshot**, the whole current value;
|
||||
subsequent **deltas** describe changes. Rebinds and reloads can send a
|
||||
`reset`, a fresh whole value. Functions, resource connections, and cell
|
||||
instances do not cross the wire.
|
||||
|
||||
The deliberate bad argument rejects with `invalid`. Remote arguments,
|
||||
results, and outgoing state are validated at the provider boundary. Local
|
||||
methods do not get per-call schema validation; they still need app-level checks.
|
||||
`dev: true` additionally checks cell writes while developing.
|
||||
|
||||
> **Tip:** Replace `memoryLinkPair` with `webSocketLink` for a WebSocket or
|
||||
> `ipcLink` for a child-process IPC channel; object contracts stay the same.
|
||||
> Those adapters wrap an existing connection, not an authenticator. Configure
|
||||
> `accept` with an authenticated principal. A browser is `kind: "client"`;
|
||||
> a trusted child host is accepted with `kind: "host"` and its configured name.
|
||||
|
||||
## 7. React to changes and run background work
|
||||
|
||||
Not all work comes from method calls. The app may update an activity indicator
|
||||
when rows change, or run a background reminder. A **listener** reacts to an
|
||||
observable source. A **task** is background execution owned by an object,
|
||||
which is aborted when the object is disposed or its code is reloaded.
|
||||
|
||||
Our activity object mirrors the row count. Its task inserts a sample todo,
|
||||
then waits for shutdown. The promises make those events deterministic in a
|
||||
test; they are not stored application state.
|
||||
|
||||
<!-- example:07 -->
|
||||
```ts
|
||||
const Activity = local("tutorial.activity", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { seen: num() },
|
||||
});
|
||||
const host = createHost({ self: "server" });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
const observed = Promise.withResolvers<void>();
|
||||
const seeded = Promise.withResolvers<void>();
|
||||
const stopped = Promise.withResolvers<void>();
|
||||
host.serve(Activity, (_input, _principal, self) => {
|
||||
const todos = self.get(TodoStore, { list: "family" });
|
||||
const seen = self.state("seen", num(), 0);
|
||||
self.on("rows", todos.rows, (rows, ctx) => {
|
||||
seen.replace(ctx, rows?.length ?? 0);
|
||||
if (rows?.length === 1) observed.resolve();
|
||||
});
|
||||
self.task(async (ctx) => {
|
||||
await todos.add({ text: "Review the list" }, ctx);
|
||||
seeded.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (ctx.signal.aborted) resolve();
|
||||
else ctx.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
stopped.resolve();
|
||||
});
|
||||
return { seen };
|
||||
});
|
||||
const activity = await host.get(Activity, {});
|
||||
await Promise.all([seeded.promise, observed.promise]);
|
||||
assert.equal(activity.seen.value, 1);
|
||||
activity.release();
|
||||
await stopped.promise;
|
||||
```
|
||||
|
||||
The final `seen` is `1`. Each listener event receives a fresh root's
|
||||
`ctx`; its write belongs to that short call tree. A task is not one enormous
|
||||
root: Chord method calls made with its task context start fresh short roots.
|
||||
We pass that context into `todos.add` so cancellation follows the task.
|
||||
Releasing the activity reference aborts its task and resolves `stopped`.
|
||||
|
||||
`self.on(name, source, handler)` declares a listener; `self.task(fn)` a task.
|
||||
A listener on state, like `rows` here, gets the current value when it starts,
|
||||
then every change. It gets the current value again whenever a reload replaced
|
||||
its source (a derivation is new at every reload), so its handler must
|
||||
reconcile (`seen` is set from the rows, not counted up). Listeners can also
|
||||
subscribe to a resource's event stream with `self.on(name, resource, select,
|
||||
handler)`: `select` gets the opened resource and returns something with
|
||||
`subscribe`; an event stream delivers no current value. Kept subscriptions
|
||||
survive reload while the resource and selector remain unchanged; the handler
|
||||
switches to new code. A selector that captures changing configuration should
|
||||
get a new listener name. Tasks have no names: a reload restarts them all.
|
||||
|
||||
> **Why:** Do not wait for a person, sleep for minutes, or run an endless loop
|
||||
> inside a method. That pins a root and can block state migration. Store a
|
||||
> pending request in a cell and answer in another call, or use a task that obeys
|
||||
> `ctx.signal`. Never retain a completed method's `ctx` for background work.
|
||||
|
||||
## 8. Package features as plugins
|
||||
|
||||
Now a plugin can contribute a menu action without editing a central switch.
|
||||
A **registry** is a named table of contributions. A **facet** is a plugin's code
|
||||
for one kind of host: it serves object types. A plugin can have server, worker, and browser
|
||||
facets, each installing that part of the feature.
|
||||
|
||||
Registrations belong to objects of **lifetime `"host"`**: the host creates such
|
||||
an object when its facet loads and keeps it until the host closes, even if no
|
||||
screen asks for it. That keeps our menu contribution present. Objects of the
|
||||
default lifetime, `"referenced"`, are still created only when referenced.
|
||||
|
||||
<!-- example:08 -->
|
||||
```ts
|
||||
const Actions = defineRegistry<(ctx: Ctx) => Promise<number>>("tutorial.actions");
|
||||
const Menu = local("tutorial.menu", { version: 1, input: obj({}, { open: true }) });
|
||||
let click: (() => Promise<number>) | undefined;
|
||||
const storeFacet: Facet = {
|
||||
name: "todo-store",
|
||||
...defineFacet((api) => api.serve(TodoStore, todoFactory)),
|
||||
};
|
||||
const menuFacet: Facet = {
|
||||
name: "todo-menu",
|
||||
...defineFacet(
|
||||
(api) => {
|
||||
api.serve(
|
||||
Menu,
|
||||
(_input, _principal, self) => {
|
||||
const todos = self.get(TodoStore, { list: "family" });
|
||||
self.register(Actions, {
|
||||
addSample: async (ctx) => {
|
||||
await todos.add({ text: "Try plugins" }, ctx);
|
||||
return todos.openCount.value;
|
||||
},
|
||||
});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
click = () => api.root((ctx) => api.registry(Actions).get("addSample", ctx)!(ctx));
|
||||
},
|
||||
{ uses: [TodoStore] },
|
||||
),
|
||||
};
|
||||
const host = createHost({ self: "server" });
|
||||
await host.load([menuFacet, storeFacet], { awaitHostObjects: false });
|
||||
await host.ready();
|
||||
assert.equal(await click!(), 1);
|
||||
assert.equal(host.registry(Actions).table().size, 1);
|
||||
```
|
||||
|
||||
Clicking returns `1`, and the registry has one entry. The menu's dependency
|
||||
can come from a later facet in the load batch: `load` installs the whole
|
||||
batch before waiting for host objects to activate. The facet declares `uses` so
|
||||
reload can also check the contract it consumes.
|
||||
|
||||
The facet API supplies `serve`, `name`, `root`, and `registry`.
|
||||
Our ordinary UI callback uses its saved `api.root`, not a process-global
|
||||
host. Looking up a registry entry with `ctx` uses the table belonging to that
|
||||
root's code version. Without `ctx`, lookup uses the current table. Duplicate
|
||||
keys fail by default; `subscribe` can bridge tables to an imperative consumer.
|
||||
|
||||
Normally use `await host.load(facets)` (or `await host.install(facet)`),
|
||||
which waits for those host objects. This example explicitly defers that barrier
|
||||
with `awaitHostObjects: false`, then awaits `host.ready()`. That waits for a
|
||||
snapshot of the host's objects, not for task completion or later loads.
|
||||
Failures reject the readiness promise; abandoning startup requires closing the
|
||||
host, because installation is not a rollback transaction.
|
||||
|
||||
> **Tip:** Use deferred readiness only when needed. A worker may have to await
|
||||
> an independent local bootstrap reference, announce control readiness so its
|
||||
> parent admits its link, and only then await `host.ready()`. Waiting for
|
||||
> upstream-dependent host objects before admission would deadlock.
|
||||
|
||||
For real plugin files, default-export `defineFacet(...)`. Node hosts configure
|
||||
`createHost({ loader: nodeLoader })`, with `nodeLoader` imported from
|
||||
`@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.
|
||||
|
||||
## 9. Change running code without losing the list
|
||||
|
||||
A reference is useful precisely because callers keep it while implementations
|
||||
change. An **code version** identifies one committed set of code and registry tables.
|
||||
A root keeps the code version it started with. New roots use the latest one.
|
||||
|
||||
### Compatible code change: no waiting
|
||||
|
||||
Give the same named facet a new factory. It changes the label but keeps the
|
||||
cell declaration. This test deliberately pauses one caller to show what an
|
||||
old root sees; application methods should not pause like this.
|
||||
|
||||
<!-- example:09-compatible -->
|
||||
```ts
|
||||
const host = createHost({ self: "server" });
|
||||
const facet = (label: string): Facet => ({
|
||||
name: "todos",
|
||||
setup: (api) =>
|
||||
api.serve(TodoStore, (input, principal, self) => ({
|
||||
...todoFactory(input, principal, self),
|
||||
label: () => label,
|
||||
})),
|
||||
});
|
||||
await host.load([facet("Original")]);
|
||||
const todos = await host.get(TodoStore, { list: "family" });
|
||||
await todos.add({ text: "Buy milk" });
|
||||
const entered = Promise.withResolvers<void>();
|
||||
const resume = Promise.withResolvers<void>();
|
||||
const oldRoot = host.root(async (ctx) => {
|
||||
const first = await todos.label(ctx);
|
||||
entered.resolve();
|
||||
await resume.promise; // Test-only pause: pin an old caller during reload.
|
||||
return [first, await todos.label(ctx)];
|
||||
});
|
||||
await entered.promise;
|
||||
const compatible = await host.reload([facet("Updated")]);
|
||||
assert.deepEqual(compatible.gated, []);
|
||||
assert.equal(await todos.label(), "Updated");
|
||||
resume.resolve();
|
||||
assert.deepEqual(await oldRoot, ["Original", "Original"]);
|
||||
assert.equal(todos.rows.value?.[0]?.text, "Buy milk");
|
||||
```
|
||||
|
||||
Reload commits while the old root is paused: `gated` is empty. New calls see
|
||||
`"Updated"`; the resumed old root sees `"Original"` twice. The milk row
|
||||
survives. Compatible code changes do not wait for that old root to finish.
|
||||
|
||||
### State change: bump its version and migrate
|
||||
|
||||
Suppose a new policy converts existing todo text to uppercase. The schema is
|
||||
unchanged, but the data's meaning changes. We still bump the cell's **state
|
||||
version** (`self.state`'s `version` option, default 1). It belongs to one cell,
|
||||
independently of the contract version. A **migration** (`migrate`) converts an
|
||||
old value of that cell to the new representation.
|
||||
|
||||
<!-- example:09-migrate -->
|
||||
```ts
|
||||
const events: string[] = [];
|
||||
const stopEvents = todos.rows.events((event) => events.push(event.type));
|
||||
const migrated = await host.reload([
|
||||
{
|
||||
name: "todos",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
TodoStore,
|
||||
todoStore({
|
||||
version: 2,
|
||||
migrate: (old, from) => {
|
||||
assert.equal(from, 1);
|
||||
return (old as Static<typeof Rows>).map((todo) => ({ ...todo, text: todo.text.toUpperCase() }));
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
]);
|
||||
assert.equal(migrated.gated.length, 1);
|
||||
assert.deepEqual(todos.rows.value, [{ text: "BUY MILK", done: false }]);
|
||||
assert.deepEqual(events, ["snap", "reset"]);
|
||||
stopEvents();
|
||||
```
|
||||
|
||||
The migration receives the old rows and their state version, `1`. It returns
|
||||
new rows; the reference now reads `[{ text: "BUY MILK", done: false }]`.
|
||||
Subscribers get a `reset` event containing the migrated value. The event
|
||||
assertion sees an initial `snap`, then that `reset`.
|
||||
One object is gated. A **gate** waits for calls already inside that object to
|
||||
finish before migrating a private copy, validating it, and committing. Old
|
||||
roots that had not entered it cannot later use old code over migrated data:
|
||||
they get `upgrade_conflict` and should retry in a new root.
|
||||
|
||||
Changing a cell's schema also requires a version bump, and a migration unless
|
||||
the old value already fits the new schema. A change of meaning with the same schema,
|
||||
like this example, cannot be detected automatically. Real migrations must
|
||||
reference each stored version they support, not assume every installation is
|
||||
exactly one version behind.
|
||||
|
||||
### Contract change: keep an adapter for old callers
|
||||
|
||||
Rename `label` to `heading`. That is a breaking contract change, so use
|
||||
contract version 2. The provider's `accepts[1]` adapter builds the implementation
|
||||
that version-1 consumers still expect.
|
||||
|
||||
<!-- example:09-contract -->
|
||||
```ts
|
||||
// Remove label from the new provider's contract: it is now called heading.
|
||||
const { label: _label, ...members } = TodoStore.def.members;
|
||||
const RenamedStore = local("tutorial.todos", {
|
||||
version: 2,
|
||||
input: TodoStore.def.input,
|
||||
members: { ...members, heading: method(undefined, str()) },
|
||||
});
|
||||
await host.reload([
|
||||
{
|
||||
name: "todos",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
RenamedStore,
|
||||
(input, principal, self) => {
|
||||
const { label, ...implementation } = todoStore({ version: 2 })(input, principal, self);
|
||||
return { ...implementation, heading: label };
|
||||
},
|
||||
{
|
||||
accepts: {
|
||||
1: (current) => ({ ...current, label: current.heading }),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
]);
|
||||
assert.equal(await todos.label(), "Todos for family");
|
||||
const newer = await host.get(RenamedStore, { list: "family" });
|
||||
assert.equal(await newer.heading(), "Todos for family");
|
||||
assert.equal(todos.rows.value?.[0]?.text, "BUY MILK");
|
||||
```
|
||||
|
||||
The old reference's `label` and the new reference's `heading` both return
|
||||
`"Todos for family"`. Both still reach the migrated rows. The adapter maps
|
||||
implementations, not references; forwarding the existing method preserves its context.
|
||||
We keep the cell at version 2 here so this code-only change does not repeat
|
||||
migration.
|
||||
|
||||
> **Why:** Contract versions protect callers; state versions protect stored
|
||||
> data; resource versions control reopening. Do not use one number for all
|
||||
> three. Additive compatible contracts can keep their version; breaking shape
|
||||
> or meaning changes need a contract bump and adapters for old consumers.
|
||||
> Reload prepares candidates first; preparation failure leaves the old code
|
||||
> running. A migration gate that misses its deadline aborts rather than
|
||||
> switching data underneath active old code.
|
||||
|
||||
## 10. Test the objects, not a whole deployment
|
||||
|
||||
Most implementation needs only a bare host. Use real factories for state tests, and a
|
||||
fake facet when you want to test delegation without a real database or service.
|
||||
Here the fake records what the editing view sends to the store.
|
||||
|
||||
<!-- example:10 -->
|
||||
```ts
|
||||
const host = createHost<Principal>({ name: "server", self: "server", dev: true });
|
||||
const received: string[] = [];
|
||||
const fake: Facet = {
|
||||
name: "fake-store",
|
||||
...defineFacet((api: FacetApi) => {
|
||||
api.serve(TodoStore, (input, principal, self) => ({
|
||||
...todoFactory(input, principal, self),
|
||||
add: ({ text }) => {
|
||||
received.push(text);
|
||||
},
|
||||
}));
|
||||
}),
|
||||
};
|
||||
await host.load([fake]);
|
||||
serveViews(host);
|
||||
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
|
||||
assert.ok(owner.variant === "editor");
|
||||
await owner.add({ text: "Test delegation" });
|
||||
assert.deepEqual(received, ["Test delegation"]);
|
||||
await assert.rejects(owner.add({ text: "" }), (error) => isChordError(error, "invalid"));
|
||||
owner.release();
|
||||
await assert.rejects(owner.add({ text: "Too late" }), (error) => isChordError(error, "released"));
|
||||
```
|
||||
|
||||
The fake received `["Test delegation"]`. Empty text failed before reaching
|
||||
the fake. The released reference rejects further calls with `released`.
|
||||
`isChordError(error, code)` checks a stable error code; avoid testing only a
|
||||
message's wording.
|
||||
|
||||
Our other tests exercise the real cells (section 3), resource cleanup (4),
|
||||
principal variants (5), a client over a memory link (6), and callers kept through
|
||||
reload (9). After an awaited `get` the state is there; for later changes, await
|
||||
the value you need. Always unsubscribe and close hosts; leaked references can hide
|
||||
lifetime bugs or leave background tasks running.
|
||||
|
||||
> **Tip:** A fake must honor the contract it serves. This fake delegates all
|
||||
> ordinary members to `todoFactory` and replaces only `add`; it deliberately
|
||||
> objects calls rather than updating rows. Do not confuse that with persistence.
|
||||
|
||||
## 11. Keep these habits
|
||||
|
||||
A temporary call reference should not accidentally become a permanent one. A
|
||||
screen should not lose its data merely because a button click finishes. Use
|
||||
`ctx.get` for the first lifetime and `host.get` for the second.
|
||||
|
||||
<!-- example:11 -->
|
||||
```ts
|
||||
const host = createHost({ self: "server" });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
const screen = await host.get(TodoStore, { list: "family" });
|
||||
await host.root(async (ctx) => {
|
||||
const temporary = await ctx.get(TodoStore, { list: "family" });
|
||||
await temporary.add({ text: "Keep roots short" }, ctx);
|
||||
}); // The temporary reference is released here; screen still holds the store.
|
||||
assert.equal(screen.openCount.value, 1);
|
||||
screen.release(); // No reference remains; without persistence, disposal loses these rows.
|
||||
```
|
||||
|
||||
The screen still sees count `1` after the root finishes, because it owns a
|
||||
separate reference. When the screen releases it, no reference remains. This tutorial's cells
|
||||
are in memory: reload preserves a live object, but disposal or process restart
|
||||
loses its rows. Real persistence belongs in a database resource, with an
|
||||
explicit load/save policy.
|
||||
|
||||
### Checklist and common mistakes
|
||||
|
||||
- Keep reloadable state in cells, not closure variables: factories rerun.
|
||||
- Change drafts, not published values: mutation outside cells bypasses updates.
|
||||
- Use `change` for field edits and `replace` for owned snapshots: replacing
|
||||
can erase fields your code does not know about.
|
||||
- Declare dependencies in a synchronous factory, not during methods: activation
|
||||
and reload need a complete declaration list.
|
||||
- Use `ctx.get` for temporary method dependencies: their references end with the call.
|
||||
- Pass `ctx` into nested calls: cancellation and code versions must stay together.
|
||||
- Keep roots short and tasks cancellable: migration and shutdown need work to settle.
|
||||
- Check grants and arguments, not just variants: available methods are not a full policy.
|
||||
- Stamp principals at trusted ingress, not from client payloads: names are not identities.
|
||||
- Await `get` before reading state: a reference you hold is live and hydrated, a
|
||||
loading one (`host.hold`) throws `not_ready`.
|
||||
- Release references and unsubscribe: otherwise unused objects remain referenced or observed.
|
||||
- Bump state versions, contract versions, and resource versions for their own changes:
|
||||
each protects a different boundary.
|
||||
- Await host object readiness before reporting startup success: declared is not yet live.
|
||||
- Keep reference dependencies acyclic: an object cannot wait for itself through another object.
|
||||
|
||||
### Imports and running the examples
|
||||
|
||||
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/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.
|
||||
|
||||
<!-- example:imports -->
|
||||
```ts
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { arr, bool, int, num, obj, type Static, str } from "@earendil-works/pi-schema";
|
||||
import { it, onTestFinished } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineFacet,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
type FacetApi,
|
||||
type Factory,
|
||||
forbidden,
|
||||
type Host,
|
||||
hostNamed,
|
||||
invalid,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
opaque,
|
||||
remote,
|
||||
type StateOptions,
|
||||
} from "../../src/old/index.ts";
|
||||
```
|
||||
|
||||
From `packages/chord`, run:
|
||||
|
||||
```sh
|
||||
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
|
||||
against its runnable source, so changing either copy without the other fails a
|
||||
test. The test harness adds teardown and drift checks; it never uses a real
|
||||
database, network service, or provider API.
|
||||
|
||||
Next read [the usage guide](guide.md) for the API reference and
|
||||
[the specification](spec.md) for compatibility, lifetime, and reload
|
||||
rules, including its known limitations. If a reference disagrees with
|
||||
`src`, the current implementation wins.
|
||||
|
|
@ -14,7 +14,7 @@ Read in full before writing code: this spec; `decisions.md`; `design.md` (§3 ho
|
|||
`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/old/manager.ts` (`processLock`, `forkDriver`, `createHostManager`, `childControl`).
|
||||
v1's `src/manager.ts` (`processLock`, `forkDriver`, `createHostManager`, `childControl`; v1 has since been deleted).
|
||||
|
||||
## 1. What p2 has, and what moves into Chord
|
||||
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ own plugin's directory; nothing gives it another plugin's files.
|
|||
"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
|
||||
// apps/p2/src/runtime/server.ts (p2 today, Chord v1)
|
||||
// apps/p2/src/runtime/server.ts (p2 at the time, on Chord v1)
|
||||
export const Routes = defineRegistry<RouteHandler>("p2.routes", { conflict: "error" }); // "METHOD /path" → handler
|
||||
export const Upgrades = defineRegistry<UpgradeHandler>("p2.upgrades", { conflict: "error" }); // WebSocket upgrades
|
||||
export const RequestResolvers = defineRegistry<RequestResolver>("p2.request-resolvers", { conflict: "error" });
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@ own plugin's directory; nothing gives it another plugin's files.</p>
|
|||
<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/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>
|
||||
<pre><code><span class="tk-c">// apps/p2/src/runtime/server.ts (p2 at the time, on 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>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Upgrades</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">UpgradeHandler</span>>(<span class="tk-s">"p2.upgrades"</span>, { conflict: <span class="tk-s">"error"</span> }); <span class="tk-c">// WebSocket upgrades</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">RequestResolvers</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">RequestResolver</span>>(<span class="tk-s">"p2.request-resolvers"</span>, { conflict: <span class="tk-s">"error"</span> });
|
||||
|
|
|
|||
|
|
@ -106,16 +106,6 @@
|
|||
"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 @@
|
|||
// Chord (`@earendil-works/chord`, v2, experimental): every layer's API for apps, and the store's surface for adapters
|
||||
// (Durable's `StoreStorage`), marked internal. The Node components and the client surfaces are subpaths (`/http`,
|
||||
// `/workers`, `/plugins`, `/app`, `/surfaces`, `/web`, `/react`, `/tui`); Chord v1 is `/old`.
|
||||
// `/workers`, `/plugins`, `/app`, `/surfaces`, `/web`, `/react`, `/tui`).
|
||||
|
||||
export * from "./contracts/index.ts";
|
||||
export * from "./data/index.ts";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Process locks (slice 10 §3, Chord v1's `processLock`): one live worker per address. A lock is a listening socket the
|
||||
// Process locks (slice 10 §3): one live worker per address. A lock is a listening socket the
|
||||
// kernel owns for the holder's lifetime (an abstract socket on Linux and Android, a named pipe on Windows, a socket file
|
||||
// elsewhere), so there is no race between two would-be holders; a socket file a crashed holder left fails closed (the
|
||||
// error names it: a takeover would race another taker). A child finding the lock held waits
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// A worker process as the server sees it (slice 10 §3, Chord v1's `forkDriver`): forked with V8 serialization as a
|
||||
// A worker process as the server sees it (slice 10 §3): forked with V8 serialization as a
|
||||
// detached process group (the whole tree can be killed), stdout and stderr piped (the stderr tail kept, both mirrored to
|
||||
// the server's own when the app takes no output), its IPC link made at the fork so the child's `hello` and early holds
|
||||
// are queued, never lost; control messages, the link's close and the exit are told as they happen.
|
||||
|
|
|
|||
|
|
@ -1,402 +0,0 @@
|
|||
import type { Schema } from "@earendil-works/pi-schema";
|
||||
import { type Draft, diffRevisions, type JsonValue, type Op, type Tracker, track } from "../delta/index.ts";
|
||||
import { ChordError } from "./errors.ts";
|
||||
import { enqueue, holdPublications } from "./publish.ts";
|
||||
import { assertValid } from "./schema.ts";
|
||||
import type { Ctx, Readable, StateEvent } from "./types.ts";
|
||||
|
||||
export interface SourceOptions {
|
||||
readonly dev: boolean;
|
||||
readonly report: (error: unknown) => void;
|
||||
}
|
||||
|
||||
/** Checks that `ctx` may write an object's cells now; throws otherwise. */
|
||||
export type WriteGuard = (ctx: Ctx) => void;
|
||||
|
||||
let sequence = 0;
|
||||
|
||||
/** Fans out values and delta events; listener errors are reported, not thrown to the writer. */
|
||||
class Fanout<T> {
|
||||
readonly #values = new Set<(value: T) => void>();
|
||||
readonly #events = new Set<(event: StateEvent<T>) => void>();
|
||||
readonly #report: (error: unknown) => void;
|
||||
|
||||
constructor(report: (error: unknown) => void) {
|
||||
this.#report = report;
|
||||
}
|
||||
|
||||
subscribe(listener: (value: T) => void, current: T): () => void {
|
||||
this.#values.add(listener);
|
||||
this.#call(listener, current);
|
||||
return () => this.#values.delete(listener);
|
||||
}
|
||||
|
||||
events(listener: (event: StateEvent<T>) => void, current: T): () => void {
|
||||
this.#events.add(listener);
|
||||
this.#call(listener, { type: "snap", value: current });
|
||||
return () => this.#events.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues delivery to the listeners subscribed now. A listener that subscribes later already got this revision in
|
||||
* its snapshot; one that unsubscribes before delivery is skipped.
|
||||
*/
|
||||
emit(event: StateEvent<T>): void {
|
||||
const events = [...this.#events];
|
||||
const values = [...this.#values];
|
||||
enqueue(() => {
|
||||
for (const listener of events) if (this.#events.has(listener)) this.#call(listener, event);
|
||||
for (const listener of values) if (this.#values.has(listener)) this.#call(listener, event.value);
|
||||
});
|
||||
}
|
||||
|
||||
#call<V>(listener: (value: V) => void, value: V): void {
|
||||
try {
|
||||
listener(value);
|
||||
} catch (error) {
|
||||
this.#report(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a state source has its value (entry 8): a remote reference's member before its first snapshot has none, and
|
||||
* neither has anything derived from or forwarding it. Sources without this symbol always have their value.
|
||||
*/
|
||||
export const HYDRATION: unique symbol = Symbol("chord.hydration");
|
||||
|
||||
export interface Hydration {
|
||||
readonly hydrated: boolean;
|
||||
/** Resolves once hydrated; rejects if the source ends first. */
|
||||
whenHydrated(): Promise<void>;
|
||||
}
|
||||
|
||||
/** The hydration of a readable, or undefined for a source that always has its value. */
|
||||
export function hydrationOf(value: unknown): Hydration | undefined {
|
||||
if (value === null || typeof value !== "object") return undefined;
|
||||
return (value as { readonly [HYDRATION]?: Hydration })[HYDRATION];
|
||||
}
|
||||
|
||||
/** The hydrations of `sources` that are not hydrated yet. */
|
||||
export const unhydrated = (sources: Iterable<unknown>): Hydration[] =>
|
||||
[...sources].map(hydrationOf).filter((hydration): hydration is Hydration => hydration?.hydrated === false);
|
||||
|
||||
/** An object-owned state source (cell or derivation). Writes recompute dependents atomically. */
|
||||
export abstract class Source<T> implements Readable<T> {
|
||||
readonly key: string;
|
||||
readonly seq = ++sequence;
|
||||
readonly dependents = new Set<Derived<unknown>>();
|
||||
/** Schemas of remote state members currently exposing this source (checked per write in dev mode). */
|
||||
schemas: readonly Schema[] = [];
|
||||
protected readonly options: SourceOptions;
|
||||
protected readonly fanout: Fanout<T>;
|
||||
|
||||
constructor(key: string, options: SourceOptions) {
|
||||
this.key = key;
|
||||
this.options = options;
|
||||
this.fanout = new Fanout(options.report);
|
||||
}
|
||||
|
||||
abstract get value(): T;
|
||||
|
||||
subscribe(listener: (value: T) => void): () => void {
|
||||
return this.fanout.subscribe(listener, this.value);
|
||||
}
|
||||
|
||||
events(listener: (event: StateEvent<T>) => void): () => void {
|
||||
return this.fanout.events(listener, this.value);
|
||||
}
|
||||
|
||||
/** Per-write validation, dev mode only. */
|
||||
validate(value: unknown): void {
|
||||
if (!this.options.dev) return;
|
||||
for (const schema of this.ownSchemas()) assertValid("state", `state ${this.key}`, schema, value);
|
||||
for (const schema of this.schemas) assertValid("state", `state ${this.key}`, schema, value);
|
||||
}
|
||||
|
||||
protected ownSchemas(): readonly Schema[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** The tracker tracks containers, so a cell tracks `{ v: value }` and strips the box from published ops. */
|
||||
interface Box<T> {
|
||||
v: T;
|
||||
}
|
||||
|
||||
function unbox(ops: readonly Op[], value: unknown): Op[] {
|
||||
const result: Op[] = [];
|
||||
for (const op of ops) {
|
||||
if (op[0] === "r" || op[1].length === 0) return [["r", value as JsonValue]];
|
||||
const path = op[1].slice(1);
|
||||
switch (op[0]) {
|
||||
case "p":
|
||||
result.push(["p", path, op[2], op[3], op[4]]);
|
||||
break;
|
||||
case "m":
|
||||
result.push(["m", path, op[2]]);
|
||||
break;
|
||||
default:
|
||||
// s/d/a/t on the value itself replace it.
|
||||
if (path.length === 0) return [["r", value as JsonValue]];
|
||||
if (op[0] === "d") result.push(["d", path as [string | number, ...(string | number)[]]]);
|
||||
else result.push([op[0], path as [string | number, ...(string | number)[]], op[2]] as Op);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A state cell holding any JSON value, validated against its schema at creation and (dev mode) on writes. */
|
||||
export class Cell<T> extends Source<T> {
|
||||
/** Replaced at a reload commit that changes the schema (state-version bump). */
|
||||
schema: Schema;
|
||||
#tracker: Tracker<Box<T>>;
|
||||
readonly #guard: WriteGuard;
|
||||
/** False until `initialize` for a cell declared with `init`: reads and writes throw `not_ready`. */
|
||||
#initialized: boolean;
|
||||
|
||||
/**
|
||||
* `initial` is always validated against `schema`; ownership moves to the cell. Without `initial`, the cell has no
|
||||
* value until `initialize` (a cell declared with `init`, filled before its object goes live).
|
||||
*/
|
||||
constructor(
|
||||
key: string,
|
||||
schema: Schema,
|
||||
initial: { readonly value: T } | undefined,
|
||||
guard: WriteGuard,
|
||||
options: SourceOptions,
|
||||
) {
|
||||
super(key, options);
|
||||
if (initial !== undefined) assertValid("state", `initial value of state ${key}`, schema, initial.value);
|
||||
this.schema = schema;
|
||||
this.#tracker = track<Box<T>>({ v: (initial?.value ?? null) as T });
|
||||
this.#guard = guard;
|
||||
this.#initialized = initial !== undefined;
|
||||
}
|
||||
|
||||
/** Sets the first value of a cell declared with `init` (validated; ownership moves to the cell). Publishes nothing. */
|
||||
initialize(value: T): void {
|
||||
if (this.#initialized) throw new Error(`state ${this.key} is already initialized`);
|
||||
assertValid("state", `initial value of state ${this.key}`, this.schema, value);
|
||||
this.#tracker = track<Box<T>>({ v: value });
|
||||
this.#initialized = true;
|
||||
}
|
||||
|
||||
protected override ownSchemas(): readonly Schema[] {
|
||||
return [this.schema];
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
if (!this.#initialized) throw notReady(`state ${this.key} (its init has not run)`);
|
||||
return this.#tracker.value.v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutates a draft (only mutated paths are published), or returns a new value (`n => n + 1`). A returned value is
|
||||
* always validated against the schema. The draft is unusable after `fn` returns.
|
||||
*/
|
||||
change(ctx: Ctx, fn: (draft: Draft<T>) => T): void;
|
||||
change(ctx: Ctx, fn: (draft: Draft<T>) => void): void;
|
||||
change(ctx: Ctx, fn: (draft: Draft<T>) => unknown): void {
|
||||
this.#guard(ctx);
|
||||
if (!this.#initialized) throw notReady(`state ${this.key} (its init has not run)`);
|
||||
const change = this.#tracker.beginChange();
|
||||
let prepared: ReturnType<typeof change.prepare>;
|
||||
try {
|
||||
const draft = change.state as Draft<Box<T>>;
|
||||
const result: unknown = fn(draft.v as Draft<T>);
|
||||
if (result instanceof Promise) throw new TypeError("cell.change callbacks must be synchronous");
|
||||
if (result !== undefined) {
|
||||
if (result !== draft.v) assertValid("state", `state ${this.key}`, this.schema, result);
|
||||
draft.v = result as Draft<T>;
|
||||
}
|
||||
prepared = change.prepare();
|
||||
} catch (error) {
|
||||
change.abort();
|
||||
throw error;
|
||||
}
|
||||
this.#publish(prepared, "ops");
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the whole value. Ownership of `value` moves to the cell: never mutate it afterwards.
|
||||
*
|
||||
* Subscribers get a structural diff (`diffRevisions`) when `diff` is true, or by default when the new value shares
|
||||
* containers with the old one (a new immutable revision of the same state, e.g. mirrored from another store);
|
||||
* otherwise a single `r` op. The resulting value is the same either way; only the ops differ.
|
||||
*/
|
||||
replace(ctx: Ctx, value: T, options: { readonly diff?: boolean } = {}): void {
|
||||
this.#guard(ctx);
|
||||
if (!this.#initialized) throw notReady(`state ${this.key} (its init has not run)`);
|
||||
const before = this.value;
|
||||
const diff = options.diff ?? sharesStructure(before, value);
|
||||
const ops = diff ? diffRevisions(before as JsonValue, value as JsonValue) : undefined;
|
||||
this.#publish(this.#tracker.prepareReplace({ v: value }), "ops", true, ops);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a migrated value and its schema (reload commit). The caller validated the value and the derivations
|
||||
* over it against every schema that will apply, so nothing is checked here. Subscribers receive a reset.
|
||||
*/
|
||||
reset(value: T, schema: Schema): void {
|
||||
this.schema = schema;
|
||||
this.#publish(this.#tracker.prepareReplace({ v: value }), "reset", false);
|
||||
}
|
||||
|
||||
/** Adopts this revision and every derived one, then queues all notifications (cell first, then derivations). */
|
||||
#publish(
|
||||
prepared: ReturnType<Tracker<Box<T>>["prepareReplace"]>,
|
||||
type: "ops" | "reset",
|
||||
validate = true,
|
||||
ops?: readonly Op[],
|
||||
): void {
|
||||
if (prepared.ops.length === 0 && type === "ops") {
|
||||
this.#tracker.adopt(prepared);
|
||||
return;
|
||||
}
|
||||
const value = prepared.value.v;
|
||||
let candidates: Map<Derived<unknown>, unknown>;
|
||||
try {
|
||||
if (validate) this.validate(value);
|
||||
candidates = computeDependents(this as Source<unknown>, value, validate);
|
||||
} catch (error) {
|
||||
prepared.abort();
|
||||
throw error;
|
||||
}
|
||||
holdPublications(() => {
|
||||
this.#tracker.adopt(prepared);
|
||||
const published = ops !== undefined && ops.length > 0 ? ops : unbox(prepared.ops, value);
|
||||
this.fanout.emit(type === "reset" ? { type, value } : { type, ops: published, value });
|
||||
for (const [derived, next] of candidates) derived.install(next);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** True if `b` reuses at least one container of `a` at the same key: a new revision of the same state. */
|
||||
function sharesStructure(a: unknown, b: unknown): boolean {
|
||||
if (a === null || b === null || typeof a !== "object" || typeof b !== "object" || a === b) return false;
|
||||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||||
for (const [key, child] of Object.entries(b)) {
|
||||
if (child !== null && typeof child === "object" && (a as Record<string, unknown>)[key] === child) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Recomputes every transitive dependent of `changed` against candidate values and validates them. */
|
||||
function computeDependents(changed: Source<unknown>, value: unknown, validate = true): Map<Derived<unknown>, unknown> {
|
||||
const pending = new Set<Derived<unknown>>();
|
||||
const visit = (source: Source<unknown>): void => {
|
||||
for (const derived of source.dependents) {
|
||||
if (pending.has(derived)) continue;
|
||||
pending.add(derived);
|
||||
visit(derived);
|
||||
}
|
||||
};
|
||||
visit(changed);
|
||||
const values = new Map<Readable<unknown>, unknown>([[changed, value]]);
|
||||
const result = new Map<Derived<unknown>, unknown>();
|
||||
for (const derived of [...pending].sort((a, b) => a.seq - b.seq)) {
|
||||
const next = derived.compute((source) => (values.has(source) ? values.get(source) : source.value));
|
||||
if (validate) derived.validate(next);
|
||||
values.set(derived, next);
|
||||
result.set(derived, next);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export type Sources = readonly Readable<unknown>[];
|
||||
export type SourceValues<S extends Sources> = { [K in keyof S]: S[K] extends Readable<infer T> ? T : never };
|
||||
|
||||
const isReadable = (value: unknown): value is Readable<unknown> =>
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as Readable<unknown>).subscribe === "function" &&
|
||||
"value" in value;
|
||||
|
||||
/**
|
||||
* A synchronous derivation. Sources are object cells/derivations (recomputed atomically with the write) or other
|
||||
* readables such as reference state members (recomputed when they publish). Subscribers receive diffs.
|
||||
*/
|
||||
export class Derived<T> extends Source<T> {
|
||||
readonly sources: readonly Readable<unknown>[];
|
||||
readonly #fn: (...values: never[]) => unknown;
|
||||
#value: T;
|
||||
#external: (() => void)[] = [];
|
||||
|
||||
constructor(key: string, sources: Sources, fn: (...values: never[]) => unknown, options: SourceOptions) {
|
||||
super(key, options);
|
||||
this.sources = sources.map((source) => {
|
||||
if (!isReadable(source)) throw new TypeError(`derive ${key}: sources must be readables`);
|
||||
return source;
|
||||
});
|
||||
this.#fn = fn;
|
||||
this.#value = undefined as T;
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
/** Hydrated once every source is (a derivation over a remote member waits for its snapshot). */
|
||||
get [HYDRATION](): Hydration | undefined {
|
||||
const pending = unhydrated(this.sources);
|
||||
if (pending.length === 0) return undefined;
|
||||
return {
|
||||
hydrated: false,
|
||||
whenHydrated: () => Promise.all(pending.map((hydration) => hydration.whenHydrated())).then(() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the first value (or takes it from `precomputed`, a reload's validated dry run) and starts following
|
||||
* the sources (at install, never inside a factory).
|
||||
*/
|
||||
attach(precomputed?: ReadonlyMap<unknown, unknown>): void {
|
||||
this.#value = precomputed?.has(this) ? (precomputed.get(this) as T) : this.compute((source) => source.value);
|
||||
for (const source of this.sources) {
|
||||
if (source instanceof Source) source.dependents.add(this as Derived<unknown>);
|
||||
else this.#external.push(source.subscribe(() => this.refresh()));
|
||||
}
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
for (const source of this.sources)
|
||||
if (source instanceof Source) source.dependents.delete(this as Derived<unknown>);
|
||||
for (const unsubscribe of this.#external.splice(0)) unsubscribe();
|
||||
}
|
||||
|
||||
compute(read: (source: Readable<unknown>) => unknown): T {
|
||||
const result: unknown = this.#fn(...(this.sources.map(read) as never[]));
|
||||
if (result instanceof Promise) throw new TypeError(`derive ${this.key}: fn must be synchronous`);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
/** Recomputes and republishes, including dependents. Errors are reported (the source write already happened). */
|
||||
refresh(): void {
|
||||
try {
|
||||
const next = this.compute((source) => source.value);
|
||||
const dependents = computeDependents(this as Derived<unknown>, next);
|
||||
holdPublications(() => {
|
||||
this.install(next);
|
||||
for (const [derived, value] of dependents) derived.install(value);
|
||||
});
|
||||
} catch (error) {
|
||||
this.options.report(error);
|
||||
}
|
||||
}
|
||||
|
||||
install(next: T): void {
|
||||
const before = this.#value;
|
||||
this.#value = next;
|
||||
const ops: Op[] =
|
||||
before === undefined || next === undefined
|
||||
? [["r", (next ?? null) as JsonValue]]
|
||||
: diffRevisions(before as JsonValue, next as JsonValue);
|
||||
if (ops.length > 0) this.fanout.emit({ type: "ops", ops, value: next });
|
||||
}
|
||||
}
|
||||
|
||||
export const isSource = (value: unknown): value is Cell<unknown> | Derived<unknown> => value instanceof Source;
|
||||
|
||||
/** Thrown by placeholders used before activation finished. */
|
||||
export const notReady = (what: string): ChordError => new ChordError("not_ready", `${what} is not ready`);
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
import type { Connection } from "./connection.ts";
|
||||
import { ChordError } from "./errors.ts";
|
||||
import type { HostImpl, PluginRef } from "./host.ts";
|
||||
import { beginReload, endReload, type Reload, type ReloadOptions, type ReloadReport, reloadError } from "./reload.ts";
|
||||
import { type Frame, toWireError } from "./wire.ts";
|
||||
|
||||
// Cross-host reload, coordinated by the server (the host without a parent):
|
||||
// server: prepare itself and send `prepare` to every synced child host; collect votes
|
||||
// all ok: the server installs its new code version, sends `commit` to every child, and only then starts what may make
|
||||
// new-code-version calls (tasks, listener and registry callbacks). Links are FIFO, so a child commits before it
|
||||
// can receive any frame of a root started in the new code version.
|
||||
// any failure or missed vote deadline: `abort` everywhere; each host rolls back to where it was.
|
||||
// Clients (browsers) are not participants: they see shape/reset pushes, or `upgrade_required`.
|
||||
// A child that connects behind the server's code version is fenced and reconciled with the same protocol.
|
||||
|
||||
let reloadSeq = 0;
|
||||
|
||||
const modulesFor = (remote: ReloadOptions["remote"], hostName: string | undefined): string[] => {
|
||||
let best: [string, readonly string[]] | undefined;
|
||||
for (const entry of Object.entries(remote ?? {})) {
|
||||
if (hostName?.startsWith(entry[0]) && (best === undefined || entry[0].length > best[0].length)) best = entry;
|
||||
}
|
||||
return [...(best?.[1] ?? [])];
|
||||
};
|
||||
|
||||
/** A vote, or its rejection after `deadline` milliseconds. */
|
||||
function awaitVote(connection: Connection, id: number, deadline: number): Promise<unknown> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const vote = new Promise<unknown>((resolve, reject) => {
|
||||
connection.votes.set(id, { resolve, reject });
|
||||
timer = setTimeout(() => {
|
||||
connection.votes.delete(id);
|
||||
reject(new ChordError("aborted", `votes missing after ${deadline} ms`, { reason: "vote_deadline" }));
|
||||
}, deadline);
|
||||
});
|
||||
return vote.finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
export async function runClusterReload(
|
||||
host: HostImpl,
|
||||
plugins: readonly PluginRef[],
|
||||
options: ReloadOptions,
|
||||
): Promise<ReloadReport> {
|
||||
// Participants are the children in step now. Fenced children (behind, being reconciled) reconcile to the new code
|
||||
// version afterwards; a child whose hello arrives while the reload runs is synced or reconciled once it ends
|
||||
// (`HostImpl.peerHello`).
|
||||
const synced = [...host.connections].filter(
|
||||
(connection) =>
|
||||
connection.role === "downstream" && connection.peer.kind === "host" && connection.open && connection.synced,
|
||||
);
|
||||
const codeVersion = host.peekNextCodeVersion();
|
||||
const reload = await beginReload(host, plugins, options, codeVersion);
|
||||
try {
|
||||
// Retire first: its `retire` frames reach every child before `prepare` (FIFO), so a child never retires a code
|
||||
// version this host still runs roots of.
|
||||
await reload.waitForRetirement();
|
||||
const participants = synced.filter((connection) => connection.open);
|
||||
const id = ++reloadSeq;
|
||||
const deadline = options.voteDeadline ?? (options.gateDeadline ?? 5000) + (options.retireDeadline ?? 5000) + 5000;
|
||||
const votes = participants.map((connection) => {
|
||||
const name = connection.name ?? "child";
|
||||
const vote = awaitVote(connection, id, deadline);
|
||||
connection.send({
|
||||
t: "prepare",
|
||||
reload: id,
|
||||
codeVersion,
|
||||
plugins: modulesFor(options.remote, name),
|
||||
options: {
|
||||
remove: modulesFor(options.remoteRemove, name),
|
||||
gateDeadline: options.gateDeadline,
|
||||
retireDeadline: options.retireDeadline,
|
||||
},
|
||||
});
|
||||
return vote.then(
|
||||
(report) => [name, report as ReloadReport | undefined] as const,
|
||||
(error: unknown) => {
|
||||
const cause = reloadError(error);
|
||||
throw new ChordError(cause.code, `${name}: ${cause.message}`, { cause, reason: cause.reason });
|
||||
},
|
||||
);
|
||||
});
|
||||
let local = false;
|
||||
const results = await Promise.allSettled([
|
||||
reload.prepare().then(() => {
|
||||
local = true;
|
||||
}),
|
||||
...votes,
|
||||
]);
|
||||
const failed = results.find((result) => result.status === "rejected");
|
||||
if (failed !== undefined) {
|
||||
for (const connection of participants) {
|
||||
connection.votes.delete(id);
|
||||
connection.send({ t: "abort", reload: id });
|
||||
}
|
||||
if (local) await reload.rollback();
|
||||
throw failed.reason;
|
||||
}
|
||||
const report = reload.commit(() => {
|
||||
for (const connection of participants) {
|
||||
connection.send({ t: "commit", reload: id });
|
||||
connection.peerCodeVersion = codeVersion;
|
||||
}
|
||||
});
|
||||
host.rememberRemoteModules(options.remote, options.remoteRemove);
|
||||
const children: Record<string, ReloadReport> = {};
|
||||
for (const result of results.slice(1)) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
const [name, child] = result.value as readonly [string, ReloadReport | undefined];
|
||||
if (child !== undefined) children[name] = child;
|
||||
}
|
||||
return { ...report, children };
|
||||
} finally {
|
||||
endReload(host);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings a child that connected behind this host's code version up to it: a reload of the child's modules (the ones
|
||||
* cluster reloads gave its name) to this host's current code version, prepared, voted and committed like a cluster
|
||||
* reload. Until then the child is fenced: nothing is routed to it. The child's old code is never relabeled as a newer
|
||||
* code version.
|
||||
*/
|
||||
export async function reconcileChild(host: HostImpl, connection: Connection): Promise<void> {
|
||||
for (;;) {
|
||||
if (!connection.open) return;
|
||||
// Not during a reload of this host: the child is not one of its participants, so whatever it would be synced to
|
||||
// now may be superseded by that reload's commit. Recheck once it ended.
|
||||
if (host.reloading) {
|
||||
await host.whenReloadEnded();
|
||||
continue;
|
||||
}
|
||||
const codeVersion = host.current.id;
|
||||
if (connection.peerCodeVersion === codeVersion) {
|
||||
connection.markSynced(codeVersion);
|
||||
host.announceRetired(connection);
|
||||
return;
|
||||
}
|
||||
// Before `prepare`: the child's prepare first retires its previous code version, which waits for this permission.
|
||||
host.announceRetired(connection);
|
||||
const id = ++reloadSeq;
|
||||
const vote = awaitVote(connection, id, 30_000);
|
||||
connection.send({
|
||||
t: "prepare",
|
||||
reload: id,
|
||||
codeVersion,
|
||||
plugins: modulesFor(host.remoteModules, connection.name),
|
||||
options: { remove: modulesFor(host.remoteRemoved, connection.name) },
|
||||
});
|
||||
await vote;
|
||||
if (host.current.id !== codeVersion) {
|
||||
// This host moved on meanwhile: abort and try again with the newer code version.
|
||||
connection.send({ t: "abort", reload: id });
|
||||
continue;
|
||||
}
|
||||
connection.send({ t: "commit", reload: id });
|
||||
connection.peerCodeVersion = codeVersion;
|
||||
}
|
||||
}
|
||||
|
||||
/** One prepare the child received, registered before its first await so an abort or disconnect is never lost. */
|
||||
interface ChildOperation {
|
||||
readonly id: number;
|
||||
reload: Reload | undefined;
|
||||
state: "loading" | "preparing" | "prepared";
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The child side: prepare and vote, then wait for the server's commit or abort. An abort or a lost parent link can
|
||||
* arrive at any point: while loading modules, while preparing (pending opens close themselves when they complete),
|
||||
* or after the vote. The operation is unwound once its pending work settles, and the reload slot is freed.
|
||||
*/
|
||||
export class ChildReloads {
|
||||
readonly #host: HostImpl;
|
||||
#operation: ChildOperation | undefined;
|
||||
|
||||
constructor(host: HostImpl) {
|
||||
this.#host = host;
|
||||
}
|
||||
|
||||
handle(connection: Connection, frame: Extract<Frame, { t: "prepare" | "commit" | "abort" }>): void {
|
||||
if (frame.t === "prepare") {
|
||||
if (this.#operation !== undefined) {
|
||||
const error = new ChordError("unavailable", "a reload is already running", { reason: "reload_running" });
|
||||
connection.send({ t: "vote", reload: frame.reload, error: toWireError(error) });
|
||||
return;
|
||||
}
|
||||
const operation: ChildOperation = { id: frame.reload, reload: undefined, state: "loading", cancelled: false };
|
||||
this.#operation = operation;
|
||||
this.#run(operation, connection, frame).catch((error: unknown) => this.#host.report(error));
|
||||
return;
|
||||
}
|
||||
const operation = this.#operation;
|
||||
if (operation === undefined || operation.id !== frame.reload) return;
|
||||
if (frame.t === "abort") {
|
||||
this.#cancel(operation);
|
||||
return;
|
||||
}
|
||||
if (operation.state !== "prepared") return;
|
||||
this.#operation = undefined;
|
||||
try {
|
||||
operation.reload?.commit();
|
||||
} finally {
|
||||
endReload(this.#host);
|
||||
}
|
||||
}
|
||||
|
||||
/** The parent link died: nobody will commit, so roll back (now, or once pending work settles). */
|
||||
parentLost(): void {
|
||||
if (this.#operation !== undefined) this.#cancel(this.#operation);
|
||||
}
|
||||
|
||||
#cancel(operation: ChildOperation): void {
|
||||
operation.cancelled = true;
|
||||
if (operation.state !== "prepared") {
|
||||
// Loading or preparing: make prepare fail now; #run unwinds when it settles.
|
||||
operation.reload?.rollback().catch((error: unknown) => this.#host.report(error));
|
||||
return;
|
||||
}
|
||||
this.#operation = undefined;
|
||||
operation.reload
|
||||
?.rollback()
|
||||
.catch((error: unknown) => this.#host.report(error))
|
||||
.finally(() => endReload(this.#host));
|
||||
}
|
||||
|
||||
async #run(
|
||||
operation: ChildOperation,
|
||||
connection: Connection,
|
||||
frame: Extract<Frame, { t: "prepare" }>,
|
||||
): Promise<void> {
|
||||
const cancelled = (): ChordError =>
|
||||
new ChordError("aborted", "reload aborted by the server", { reason: "aborted" });
|
||||
try {
|
||||
operation.reload = await beginReload(this.#host, frame.plugins, frame.options, frame.codeVersion);
|
||||
if (operation.cancelled) throw cancelled();
|
||||
operation.state = "preparing";
|
||||
await operation.reload.prepare();
|
||||
if (operation.cancelled) throw cancelled();
|
||||
operation.state = "prepared";
|
||||
connection.send({ t: "vote", reload: frame.reload, report: operation.reload.summary() });
|
||||
} catch (error) {
|
||||
if (operation.reload !== undefined) {
|
||||
await operation.reload.rollback().catch((cleanup: unknown) => this.#host.report(cleanup));
|
||||
endReload(this.#host);
|
||||
}
|
||||
if (this.#operation === operation) this.#operation = undefined;
|
||||
if (!operation.cancelled) connection.send({ t: "vote", reload: frame.reload, error: toWireError(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,435 +0,0 @@
|
|||
import { int, lit, map, obj, opt, type Schema, type Static, str, union } from "@earendil-works/pi-schema";
|
||||
import { ChordError, invalid } from "./errors.ts";
|
||||
import { canonicalJson, fits, hash64, isValid } from "./schema.ts";
|
||||
import type { Ctx, Exposed, Readable, RefBase, StateSource } from "./types.ts";
|
||||
|
||||
const MEMBER = Symbol("chord.member");
|
||||
|
||||
/** A method's arguments and result; `undefined`: it takes no arguments, or returns nothing (void). */
|
||||
export interface MethodSpec<
|
||||
A extends Schema | undefined = Schema | undefined,
|
||||
R extends Schema | undefined = Schema | undefined,
|
||||
> {
|
||||
readonly [MEMBER]: "method";
|
||||
readonly args: A;
|
||||
readonly result: R;
|
||||
}
|
||||
|
||||
export interface OpaqueSpec<T = unknown> {
|
||||
readonly [MEMBER]: "opaque";
|
||||
readonly id: string;
|
||||
/** Type carrier only; never set. */
|
||||
readonly type?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* A method. `method()`: no arguments (called as `ref.name(ctx)`, implemented as `(ctx) => …`) and no result.
|
||||
* `method(args)`: no result, the handler returns nothing (void). `method(args, result)`, or `method(undefined, result)`
|
||||
* for a method without arguments that returns something.
|
||||
*/
|
||||
export function method(): MethodSpec<undefined, undefined>;
|
||||
export function method<A extends Schema>(args: A): MethodSpec<A, undefined>;
|
||||
export function method<A extends Schema | undefined, R extends Schema>(args: A, result: R): MethodSpec<A, R>;
|
||||
export function method(args?: Schema, result?: Schema): MethodSpec {
|
||||
return { [MEMBER]: "method", args, result };
|
||||
}
|
||||
|
||||
/** A native in-process object identified by `name@version` (local contracts only). */
|
||||
export const opaque = <T = unknown>(id: string): OpaqueSpec<T> => {
|
||||
if (!/^[^@\s]+@\d+$/.test(id)) throw new TypeError(`opaque id must be name@version: ${id}`);
|
||||
return { [MEMBER]: "opaque", id };
|
||||
};
|
||||
|
||||
export interface VariantDef {
|
||||
readonly state?: Readonly<Record<string, Schema>>;
|
||||
readonly methods?: Readonly<Record<string, MethodSpec>>;
|
||||
}
|
||||
|
||||
/** Where objects of a remote type live: one named host. */
|
||||
export interface HostNamed {
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/** Where objects of a remote type live: the host `prefix` + the input's string field `key` (`session:` + `session`). */
|
||||
export interface HostFamily<K extends string = string> {
|
||||
readonly prefix: string;
|
||||
readonly key: K;
|
||||
}
|
||||
|
||||
/** A remote type's placement: data only, so a description carries it and every host can check it. */
|
||||
export type Placement = HostNamed | HostFamily;
|
||||
|
||||
/** Objects of the type live on the host named `name` (`hostNamed("server")`). */
|
||||
export function hostNamed(name: string): HostNamed {
|
||||
if (typeof name !== "string" || name === "") throw new TypeError("hostNamed needs a host name");
|
||||
return { name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Objects of the type live on `prefix` + the value of the input's string field `key`: `hostFamily("session:",
|
||||
* "session")` puts `{ session: "S1", … }` on `session:S1`. The field is required and must be a non-empty string.
|
||||
*/
|
||||
export function hostFamily<const K extends string>(prefix: string, key: K): HostFamily<K> {
|
||||
if (typeof prefix !== "string" || prefix === "") throw new TypeError("hostFamily needs a host-name prefix");
|
||||
if (typeof key !== "string" || key === "") throw new TypeError("hostFamily needs an input field name");
|
||||
return { prefix, key };
|
||||
}
|
||||
|
||||
/** Required string fields of `I`: what a `hostFamily` may name. */
|
||||
type StringKeys<I> = { [K in keyof I]-?: I[K] extends string ? K : never }[keyof I] & string;
|
||||
/** The placements a remote type with input `I` may have. */
|
||||
export type PlacementFor<I> = HostNamed | HostFamily<StringKeys<I>>;
|
||||
|
||||
/** The host an object of `placement` with this (normalized) input lives on. Throws `invalid/placement`. */
|
||||
export function resolveHost(typeName: string, placement: Placement, input: unknown): string {
|
||||
if ("name" in placement) return placement.name;
|
||||
const value =
|
||||
input !== null && typeof input === "object" ? (input as Record<string, unknown>)[placement.key] : undefined;
|
||||
if (typeof value !== "string" || value === "")
|
||||
throw invalid("placement", `${typeName}: input field ${placement.key} names no host`);
|
||||
return placement.prefix + value;
|
||||
}
|
||||
|
||||
/** Whether the host named `hostName` may serve a type of `placement`. */
|
||||
export const placedOn = (placement: Placement, hostName: string): boolean =>
|
||||
"name" in placement
|
||||
? placement.name === hostName
|
||||
: hostName.startsWith(placement.prefix) && hostName.length > placement.prefix.length;
|
||||
|
||||
export const placementText = (placement: Placement): string =>
|
||||
"name" in placement ? placement.name : `${placement.prefix}<${placement.key}>`;
|
||||
|
||||
export interface RemoteDef {
|
||||
readonly version: number;
|
||||
readonly input: Schema;
|
||||
/** Where its objects live: `hostNamed(…)` or `hostFamily(…)`. Part of the contract (and its fingerprint). */
|
||||
readonly host: Placement;
|
||||
readonly variants: Readonly<Record<string, VariantDef>>;
|
||||
}
|
||||
|
||||
/** A local member: a method, an opaque native object, or a state member described by its schema. */
|
||||
export type LocalMember = MethodSpec | OpaqueSpec | Schema;
|
||||
|
||||
export interface LocalDef {
|
||||
readonly version: number;
|
||||
readonly input: Schema;
|
||||
readonly members?: Readonly<Record<string, LocalMember>>;
|
||||
}
|
||||
|
||||
// A description travels as a reference's contract token (wire.ts), so it is declared once, as the schema that validates it.
|
||||
// Any object is a schema, which is all a peer's schema is checked for here.
|
||||
const SchemaObject = obj({}, { open: true }) as Schema<Schema>;
|
||||
|
||||
const MemberDescriptionSchema = union([
|
||||
// Absent `args`: the method takes none. Absent `result`: it returns nothing.
|
||||
obj({ kind: lit("method"), args: opt(SchemaObject), result: opt(SchemaObject) }, { open: true }),
|
||||
obj({ kind: lit("state"), schema: SchemaObject }, { open: true }),
|
||||
obj({ kind: lit("opaque"), id: str() }, { open: true }),
|
||||
]);
|
||||
export type MemberDescription = Static<typeof MemberDescriptionSchema>;
|
||||
|
||||
/** Local contracts have one unnamed variant. */
|
||||
export const LOCAL_VARIANT = "";
|
||||
|
||||
const PlacementSchema = union([
|
||||
obj({ name: str({ minLength: 1 }) }),
|
||||
obj({ prefix: str({ minLength: 1 }), key: str({ minLength: 1 }) }),
|
||||
]);
|
||||
|
||||
export const DescriptionSchema = obj(
|
||||
{
|
||||
name: str({ minLength: 1 }),
|
||||
scope: union([lit("remote"), lit("local")]),
|
||||
version: int(),
|
||||
input: SchemaObject,
|
||||
/** Remote types only (required there): where their objects live. */
|
||||
host: opt(PlacementSchema),
|
||||
variants: map(map(MemberDescriptionSchema)),
|
||||
},
|
||||
{ open: true },
|
||||
);
|
||||
export type Description = Static<typeof DescriptionSchema>;
|
||||
|
||||
interface ContractBase {
|
||||
readonly name: string;
|
||||
readonly version: number;
|
||||
readonly input: Schema;
|
||||
readonly description: Description;
|
||||
readonly fingerprint: string;
|
||||
}
|
||||
|
||||
export interface RemoteContract<D extends RemoteDef = RemoteDef> extends ContractBase {
|
||||
readonly scope: "remote";
|
||||
readonly def: D;
|
||||
}
|
||||
|
||||
export interface LocalContract<D extends LocalDef = LocalDef> extends ContractBase {
|
||||
readonly scope: "local";
|
||||
readonly def: D;
|
||||
}
|
||||
|
||||
export type AnyContract = RemoteContract | LocalContract;
|
||||
|
||||
/** Names a reference uses itself; members may not use them. */
|
||||
export const RESERVED_MEMBERS: ReadonlySet<string> = new Set([
|
||||
"variant",
|
||||
"status",
|
||||
"ready",
|
||||
"shape",
|
||||
"error",
|
||||
"onShape",
|
||||
"onStatus",
|
||||
"release",
|
||||
"then",
|
||||
"contract",
|
||||
]);
|
||||
|
||||
function describeMember(name: string, member: unknown, scope: "remote" | "local"): MemberDescription {
|
||||
if (RESERVED_MEMBERS.has(name)) throw new TypeError(`member name is reserved: ${name}`);
|
||||
const brand = (member as { [MEMBER]?: string })[MEMBER];
|
||||
if (brand === "method") {
|
||||
const spec = member as MethodSpec;
|
||||
return {
|
||||
kind: "method",
|
||||
...(spec.args === undefined ? {} : { args: spec.args }),
|
||||
...(spec.result === undefined ? {} : { result: spec.result }),
|
||||
};
|
||||
}
|
||||
if (brand === "opaque") {
|
||||
if (scope === "remote") throw new TypeError(`remote contracts cannot expose opaque members: ${name}`);
|
||||
return { kind: "opaque", id: (member as OpaqueSpec).id };
|
||||
}
|
||||
return { kind: "state", schema: member as Schema };
|
||||
}
|
||||
|
||||
function finish<C extends AnyContract>(contract: Omit<C, "fingerprint">): C {
|
||||
return { ...contract, fingerprint: hash64(canonicalJson(contract.description)) } as C;
|
||||
}
|
||||
|
||||
function checkHeader(name: string, version: number): void {
|
||||
if (!name) throw new TypeError("contract name is empty");
|
||||
if (!Number.isInteger(version) || version < 1) throw new TypeError(`contract version must be a positive integer`);
|
||||
}
|
||||
|
||||
export function remote<const D extends RemoteDef>(
|
||||
name: string,
|
||||
def: D & { readonly host: PlacementFor<Static<D["input"]>> },
|
||||
): RemoteContract<D> {
|
||||
checkHeader(name, def.version);
|
||||
const placement = def.host as Placement | undefined;
|
||||
if (placement === undefined || !isValid(PlacementSchema, placement))
|
||||
throw new TypeError(`remote contract ${name} needs \`host\`: hostNamed(…) or hostFamily(…)`);
|
||||
// A JSON copy: the description travels, and a placement object may carry nothing else.
|
||||
const host: Placement =
|
||||
"name" in placement ? { name: placement.name } : { prefix: placement.prefix, key: placement.key };
|
||||
const variants: Record<string, Record<string, MemberDescription>> = {};
|
||||
if (Object.keys(def.variants).length === 0) throw new TypeError(`remote contract ${name} has no variants`);
|
||||
for (const [kind, variant] of Object.entries(def.variants)) {
|
||||
const members: Record<string, MemberDescription> = {};
|
||||
for (const [key, schema] of Object.entries(variant.state ?? {}))
|
||||
members[key] = describeMember(key, schema, "remote");
|
||||
for (const [key, spec] of Object.entries(variant.methods ?? {})) {
|
||||
if (key in members) throw new TypeError(`member ${key} is both state and method in ${name}.${kind}`);
|
||||
members[key] = describeMember(key, spec, "remote");
|
||||
if (members[key].kind !== "method") throw new TypeError(`methods.${key} is not a method()`);
|
||||
}
|
||||
variants[kind] = members;
|
||||
}
|
||||
const description: Description = { name, scope: "remote", version: def.version, input: def.input, host, variants };
|
||||
return finish<RemoteContract<D>>({
|
||||
scope: "remote",
|
||||
name,
|
||||
version: def.version,
|
||||
input: def.input,
|
||||
def,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
export function local<const D extends LocalDef>(name: string, def: D): LocalContract<D> {
|
||||
checkHeader(name, def.version);
|
||||
const members: Record<string, MemberDescription> = {};
|
||||
for (const [key, member] of Object.entries(def.members ?? {})) members[key] = describeMember(key, member, "local");
|
||||
const description: Description = {
|
||||
name,
|
||||
scope: "local",
|
||||
version: def.version,
|
||||
input: def.input,
|
||||
variants: { [LOCAL_VARIANT]: members },
|
||||
};
|
||||
return finish<LocalContract<D>>({ scope: "local", name, version: def.version, input: def.input, def, description });
|
||||
}
|
||||
|
||||
/**
|
||||
* A consumer token received over a link (only its description travels). Has no `def`, so it is only good for binding
|
||||
* and validation, never for TypeScript types.
|
||||
*/
|
||||
export function contractFromDescription(description: Description): AnyContract {
|
||||
const { name, scope, version, input, host } = description;
|
||||
if (
|
||||
typeof name !== "string" ||
|
||||
(scope !== "remote" && scope !== "local") ||
|
||||
!Number.isInteger(version) ||
|
||||
(scope === "remote") !== (host !== undefined)
|
||||
) {
|
||||
throw new ChordError("invalid", "malformed contract token", { reason: "token" });
|
||||
}
|
||||
const base = { name, version, input, description, fingerprint: hash64(canonicalJson(description)) };
|
||||
return scope === "remote"
|
||||
? { ...base, scope, def: { version, input, host: host!, variants: {} } }
|
||||
: { ...base, scope, def: { version, input } };
|
||||
}
|
||||
|
||||
/** Returns why `provider` does not cover `consumer`, or undefined when it does. */
|
||||
export function coverage(provider: Description, consumer: Description): string | undefined {
|
||||
if (provider.name !== consumer.name || provider.scope !== consumer.scope) return "different type";
|
||||
if (!samePlacement(provider, consumer)) return "host";
|
||||
if (!fits(consumer.input, provider.input)) return "input";
|
||||
for (const [kind, consumerMembers] of Object.entries(consumer.variants)) {
|
||||
const providerMembers = provider.variants[kind];
|
||||
if (providerMembers === undefined) return `variant ${kind} missing`;
|
||||
for (const [name, wanted] of Object.entries(consumerMembers)) {
|
||||
const offered = providerMembers[name];
|
||||
if (offered === undefined || offered.kind !== wanted.kind) return `${kind}.${name} missing or of another kind`;
|
||||
if (offered.kind === "method" && wanted.kind === "method") {
|
||||
// Both take arguments or neither does; a consumer that expects no result accepts any.
|
||||
if ((wanted.args === undefined) !== (offered.args === undefined)) return `${kind}.${name} arguments`;
|
||||
if (wanted.args !== undefined && offered.args !== undefined && !fits(wanted.args, offered.args))
|
||||
return `${kind}.${name} arguments`;
|
||||
if (wanted.result !== undefined && (offered.result === undefined || !fits(offered.result, wanted.result)))
|
||||
return `${kind}.${name} result`;
|
||||
} else if (offered.kind === "state" && wanted.kind === "state") {
|
||||
if (!fits(offered.schema, wanted.schema)) return `${kind}.${name} state`;
|
||||
} else if (offered.kind === "opaque" && wanted.kind === "opaque" && offered.id !== wanted.id) {
|
||||
return `${kind}.${name} opaque ${offered.id} != ${wanted.id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const samePlacement = (a: Description, b: Description): boolean =>
|
||||
canonicalJson(a.host ?? null) === canonicalJson(b.host ?? null);
|
||||
const placementOf = (description: Description): string =>
|
||||
description.host === undefined ? "(local)" : placementText(description.host);
|
||||
|
||||
/** Maps the provider's current implementation to the view a consumer of an older contract version expects. */
|
||||
export type Adapter = (current: Record<string, unknown>) => Record<string, unknown>;
|
||||
export type Accepts = Readonly<Record<number, Adapter>>;
|
||||
|
||||
/** How a consumer token binds to a provider: directly or through an `accepts` adapter. Throws contract_mismatch. */
|
||||
export function bind(provider: AnyContract, accepts: Accepts | undefined, consumer: AnyContract): Adapter | undefined {
|
||||
if (provider.fingerprint === consumer.fingerprint) return undefined;
|
||||
// A moved type is another type for its consumers: no adapter reaches across hosts.
|
||||
if (!samePlacement(provider.description, consumer.description)) {
|
||||
throw new ChordError(
|
||||
"contract_mismatch",
|
||||
`${provider.name} lives on ${placementOf(provider.description)}, the consumer expects ${placementOf(consumer.description)}`,
|
||||
);
|
||||
}
|
||||
if (provider.version === consumer.version) {
|
||||
const reason = coverage(provider.description, consumer.description);
|
||||
if (reason === undefined) return undefined;
|
||||
throw new ChordError(
|
||||
"contract_mismatch",
|
||||
`${provider.name}@${provider.version} does not cover the consumer's description (${reason}); forgotten version bump?`,
|
||||
);
|
||||
}
|
||||
const adapter = accepts?.[consumer.version];
|
||||
if (adapter === undefined) {
|
||||
throw new ChordError(
|
||||
"contract_mismatch",
|
||||
`${provider.name}: provider version ${provider.version}, consumer version ${consumer.version}, no accepts adapter`,
|
||||
);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
// ─── Static types ────────────────────────────────────────────────────────────
|
||||
|
||||
type Empty = Record<never, never>;
|
||||
type StateOf<V> = V extends { readonly state: infer S extends Readonly<Record<string, Schema>> } ? S : Empty;
|
||||
type StaticOf<S> = S extends Schema ? Static<S> : never;
|
||||
type MethodsOf<V> = V extends { readonly methods: infer M extends Readonly<Record<string, MethodSpec>> } ? M : Empty;
|
||||
|
||||
/** What a handler returns: the result's value, or nothing for a void method. */
|
||||
type Returned<R> = [R] extends [Schema] ? Static<R> | Promise<Static<R>> : void | Promise<void>;
|
||||
/** What a call resolves to. */
|
||||
type Resolved<R> = [R] extends [Schema] ? Static<R> : undefined;
|
||||
|
||||
/** A method's handler; `P` is the app's principal type (`ctx.principal`). */
|
||||
export type MethodImplementation<M, P = unknown> = M extends MethodSpec<infer A, infer R>
|
||||
? [A] extends [Schema]
|
||||
? (args: Static<A>, ctx: Ctx<P>) => Returned<R>
|
||||
: (ctx: Ctx<P>) => Returned<R>
|
||||
: never;
|
||||
export type Call<M> = M extends MethodSpec<infer A, infer R>
|
||||
? [A] extends [Schema]
|
||||
? (args: Static<A>, ctx?: Ctx) => Promise<Resolved<R>>
|
||||
: (ctx?: Ctx) => Promise<Resolved<R>>
|
||||
: never;
|
||||
|
||||
/** Whether `contract`'s method `name` takes no arguments (it is called as `ref.name(ctx)`). */
|
||||
export function takesNoArgs(contract: AnyContract, name: string): boolean {
|
||||
for (const members of Object.values(contract.description.variants)) {
|
||||
const member = members[name];
|
||||
if (member?.kind === "method") return member.args === undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type VariantImplementation<K, V, P> = { readonly variant: K } & {
|
||||
readonly [S in keyof StateOf<V>]: StateSource<StaticOf<StateOf<V>[S]>>;
|
||||
} & {
|
||||
readonly [M in keyof MethodsOf<V>]: MethodImplementation<MethodsOf<V>[M], P>;
|
||||
};
|
||||
/**
|
||||
* A reference in one variant: its state members are plain readables (a reference you hold has its state) and its
|
||||
* methods calls. `variant` discriminates the union `Ref` is for a multi-variant type.
|
||||
*/
|
||||
type VariantRef<C extends AnyContract, K, V> = RefBase<C> & { readonly variant: K } & {
|
||||
readonly [S in keyof StateOf<V>]: Readable<StaticOf<StateOf<V>[S]>>;
|
||||
} & {
|
||||
readonly [M in keyof MethodsOf<V>]: Call<MethodsOf<V>[M]>;
|
||||
};
|
||||
|
||||
type LocalImplementationMember<M, P> = M extends MethodSpec
|
||||
? MethodImplementation<M, P>
|
||||
: M extends OpaqueSpec<infer T>
|
||||
? T | Exposed<T>
|
||||
: M extends Schema
|
||||
? StateSource<Static<M>>
|
||||
: never;
|
||||
type LocalRefMember<M> = M extends MethodSpec
|
||||
? Call<M>
|
||||
: M extends OpaqueSpec<infer T>
|
||||
? T | undefined
|
||||
: M extends Schema
|
||||
? Readable<Static<M>>
|
||||
: never;
|
||||
type LocalMembers<D> = D extends { readonly members: infer M extends Readonly<Record<string, LocalMember>> }
|
||||
? M
|
||||
: Empty;
|
||||
|
||||
export type InputOf<C> = C extends { readonly def: { readonly input: infer I extends Schema } } ? Static<I> : never;
|
||||
|
||||
/** What a factory returns for contract C; its methods' `ctx.principal` is `P`. */
|
||||
export type ImplementationOf<C, P = unknown> = C extends RemoteContract<infer D>
|
||||
? { [K in keyof D["variants"]]: VariantImplementation<K, D["variants"][K], P> }[keyof D["variants"]]
|
||||
: C extends LocalContract<infer D>
|
||||
? { readonly [K in keyof LocalMembers<D>]: LocalImplementationMember<LocalMembers<D>[K], P> } & Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
: never;
|
||||
|
||||
/**
|
||||
* What `get` returns for contract C. A remote type's reference is a union discriminated by `variant`: another variant's
|
||||
* members are reachable only after narrowing (`if (ref.variant === "editor") ref.add(…)`). A local type's reference has
|
||||
* its members.
|
||||
*/
|
||||
export type Ref<C extends AnyContract = AnyContract> = C extends RemoteContract<infer D>
|
||||
? { [K in keyof D["variants"]]: VariantRef<C, K, D["variants"][K]> }[keyof D["variants"]]
|
||||
: C extends LocalContract<infer D>
|
||||
? RefBase<C> & { readonly [K in keyof LocalMembers<D>]: LocalRefMember<LocalMembers<D>[K]> }
|
||||
: never;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import type { Ctx } from "./types.ts";
|
||||
|
||||
// What a factory declares through `self` besides state cells: the shapes of resources and listeners.
|
||||
|
||||
export interface Subscribable<E> {
|
||||
subscribe(listener: (event: E) => void): () => void;
|
||||
}
|
||||
|
||||
/** `self.resource(name, declaration)`: something the object opens and later closes (a database, a watch, a socket). */
|
||||
export interface ResourceDeclaration<T> {
|
||||
/** Bump to replace the resource at the next reload (a native value has no schema to detect a change). */
|
||||
readonly version: number;
|
||||
/** References or resources that must be live/open first. A change replaces the resource. */
|
||||
readonly deps?: readonly unknown[];
|
||||
/** Exclusive resources are replaced at the gate: old closed before new opened. */
|
||||
readonly exclusive?: boolean;
|
||||
open(options: { readonly signal: AbortSignal }): T | Promise<T>;
|
||||
close?(value: T): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface Resource<T> {
|
||||
readonly key: string;
|
||||
/** Throws not_ready until opened. */
|
||||
readonly value: T;
|
||||
}
|
||||
|
||||
/** A listener as `self.on` declares it. */
|
||||
export interface ListenerDecl {
|
||||
readonly source: unknown;
|
||||
readonly select: ((value: never) => Subscribable<unknown>) | undefined;
|
||||
readonly fn: (event: unknown, ctx: Ctx) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// A cell has a `key` and a `value` too: sources are matched before resources.
|
||||
/** What `self.on(name, source, …)` passes to `select`: a resource's opened value, or the source itself. */
|
||||
export type ValueOf<S> = S extends Subscribable<unknown> ? S : S extends Resource<infer T> ? T : S;
|
||||
/** What `self.on(name, source, handler)` delivers: a readable's values, an event source's events. */
|
||||
export type EventOf<S> = S extends Subscribable<infer E> ? E : S extends Resource<infer T> ? EventOf<T> : never;
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
/** Error codes callers see (guide table). A refused or aborted reload keeps the code of its cause. */
|
||||
export const CHORD_ERROR_CODES = [
|
||||
"aborted",
|
||||
"contract_mismatch",
|
||||
"forbidden",
|
||||
"gone",
|
||||
"invalid",
|
||||
"not_in_variant",
|
||||
"not_ready",
|
||||
"released",
|
||||
"superseded",
|
||||
"unavailable",
|
||||
"upgrade_conflict",
|
||||
"upgrade_required",
|
||||
] as const;
|
||||
export type ChordErrorCode = (typeof CHORD_ERROR_CODES)[number];
|
||||
|
||||
/**
|
||||
* Finer cause inside a code, for logs and tests: e.g. `invalid` + `args`, `result`, `state`, `input`, `implementation`,
|
||||
* `duplicate`, `cycle`, `read_only`, `not_host_lifetime`, `registry_conflict`; `unavailable` + `unknown_type`.
|
||||
*/
|
||||
export type ChordErrorReason = string;
|
||||
|
||||
const RETRYABLE: ReadonlySet<ChordErrorCode> = new Set(["superseded", "upgrade_conflict"]);
|
||||
|
||||
export class ChordError extends Error {
|
||||
readonly code: ChordErrorCode;
|
||||
readonly reason: ChordErrorReason | undefined;
|
||||
readonly retryable: boolean;
|
||||
readonly details: unknown;
|
||||
constructor(
|
||||
code: ChordErrorCode,
|
||||
message: string,
|
||||
options?: { cause?: unknown; details?: unknown; reason?: ChordErrorReason },
|
||||
) {
|
||||
super(`${code}${options?.reason ? `/${options.reason}` : ""}: ${message}`, {
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.name = "ChordError";
|
||||
this.code = code;
|
||||
this.reason = options?.reason;
|
||||
this.retryable = RETRYABLE.has(code);
|
||||
this.details = options?.details;
|
||||
}
|
||||
}
|
||||
|
||||
export const isChordError = (error: unknown, code?: ChordErrorCode): error is ChordError =>
|
||||
error instanceof ChordError && (code === undefined || error.code === code);
|
||||
|
||||
/** For factories and methods refusing a principal: `throw forbidden()`. */
|
||||
export const forbidden = (message = "forbidden"): ChordError => new ChordError("forbidden", message);
|
||||
|
||||
export const invalid = (reason: ChordErrorReason, message: string, details?: unknown): ChordError =>
|
||||
new ChordError("invalid", message, { reason, details });
|
||||
|
||||
/** Keeps a ChordError's code; anything else becomes `unavailable` with the original as cause. */
|
||||
export const asChordError = (error: unknown, context: string): ChordError =>
|
||||
error instanceof ChordError
|
||||
? error
|
||||
: new ChordError("unavailable", `${context}: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
|
||||
/** Runs every step, then throws one error, or an AggregateError when several failed. */
|
||||
export async function runAll(steps: readonly (() => unknown)[], message: string): Promise<void> {
|
||||
const errors: unknown[] = [];
|
||||
for (const step of steps) {
|
||||
try {
|
||||
await step();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, message);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,96 +0,0 @@
|
|||
// Chord v1 (`@earendil-works/chord/old`), unchanged: the runtime p2 runs on, removed once p2 runs on Chord v2. It
|
||||
// re-exports the foundations it was built with; new code imports them from `/context`, `/json`, `/delta`, `/watch`.
|
||||
|
||||
export type { Context, ContextKey } from "../context/index.ts";
|
||||
export type { Draft } from "../delta/index.ts";
|
||||
export { type CopyJsonOptions, copyJson, isJsonValue, type JsonRepresentation, type JsonValue } from "../json.ts";
|
||||
export {
|
||||
NativeWatch,
|
||||
type NativeWatchable,
|
||||
type NativeWatchCall,
|
||||
type NativeWatchHandle,
|
||||
Watch,
|
||||
type Watchable,
|
||||
type WatchCall,
|
||||
type WatchEnd,
|
||||
type WatchHandle,
|
||||
} from "../watch.ts";
|
||||
export type { Cell } from "./cell.ts";
|
||||
export type { AcceptOptions, LinkOptions } from "./connection.ts";
|
||||
export {
|
||||
type Accepts,
|
||||
type Adapter,
|
||||
type AnyContract,
|
||||
bind,
|
||||
coverage,
|
||||
type Description,
|
||||
type HostFamily,
|
||||
type HostNamed,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
type ImplementationOf,
|
||||
type InputOf,
|
||||
type LocalContract,
|
||||
local,
|
||||
method,
|
||||
opaque,
|
||||
type Placement,
|
||||
type PlacementFor,
|
||||
type Ref,
|
||||
type RemoteContract,
|
||||
remote,
|
||||
} from "./contract.ts";
|
||||
export type { EventOf, Resource, ResourceDeclaration, Subscribable, ValueOf } from "./declare.ts";
|
||||
export { ChordError, type ChordErrorCode, forbidden, invalid, isChordError } from "./errors.ts";
|
||||
export type { ConnectOptions, PrincipalMatch, PrincipalPattern, Route } from "./host.ts";
|
||||
export {
|
||||
createHost,
|
||||
defineFacet,
|
||||
type Facet,
|
||||
type FacetApi,
|
||||
type FacetDefinition,
|
||||
type FacetLoader,
|
||||
type Host,
|
||||
type HostOptions,
|
||||
type LoadOptions,
|
||||
type PluginRef,
|
||||
type RootOptions,
|
||||
} from "./host.ts";
|
||||
export {
|
||||
type IpcChannel,
|
||||
inflateMessage,
|
||||
ipcLink,
|
||||
type Link,
|
||||
memoryLinkPair,
|
||||
PACK_THRESHOLD,
|
||||
type WebSocketLike,
|
||||
type WebSocketLinkOptions,
|
||||
webSocketLink,
|
||||
} from "./link.ts";
|
||||
export type {
|
||||
Factory,
|
||||
HostServeOptions,
|
||||
Lifetime,
|
||||
ReferencedServeOptions,
|
||||
Self,
|
||||
ServeOptions,
|
||||
ServeOptionsFor,
|
||||
StateInit,
|
||||
StateOptions,
|
||||
} from "./object.ts";
|
||||
export { defineRegistry, type Registry, type RegistryTable, type RegistryView } from "./registry.ts";
|
||||
export type { ReloadOptions, ReloadReport } from "./reload.ts";
|
||||
export {
|
||||
type AttachedReplicatedState,
|
||||
type MutableReplicatedState,
|
||||
type ReplicatedState,
|
||||
type ReplicatedStateDelivery,
|
||||
type ReplicatedStateSource,
|
||||
type ReplicatedStateSourceAttachment,
|
||||
type ReplicatedStateSourceFrame,
|
||||
type ReplicatedStateSourceOptions,
|
||||
replicatedState,
|
||||
} from "./state.ts";
|
||||
export type { Ctx, GetOptions, Readable, RefStatus, Shape, StateEvent } from "./types.ts";
|
||||
export { acceptWebSockets, type WebSocketAcceptOptions, type WebSocketServerLike } from "./websocket.ts";
|
||||
export { type Frame, PROTOCOL_VERSION } from "./wire.ts";
|
||||
|
|
@ -1,415 +0,0 @@
|
|||
import type * as Zlib from "node:zlib";
|
||||
import type { Frame } from "./wire.ts";
|
||||
|
||||
/** An ordered (FIFO), bidirectional frame channel between two hosts. */
|
||||
export interface Link {
|
||||
readonly closed: boolean;
|
||||
/**
|
||||
* Returns the size of the encoded frame as sent (JSON text length, or bytes of a packed message) when the transport
|
||||
* knows it. The connection's flow control counts these sizes; frames without one count as zero, so a link that never
|
||||
* reports sizes is not windowed.
|
||||
*/
|
||||
send(frame: Frame): number | undefined;
|
||||
/** Sets the single receiver. Frames arriving earlier are queued. */
|
||||
receive(handler: (frame: Frame) => void): void;
|
||||
onClose(handler: (reason: string) => void): void;
|
||||
/** Closes both ends: the peer gets a `bye`. */
|
||||
close(reason?: string): void;
|
||||
}
|
||||
|
||||
/** Shared plumbing: receive queue, close handlers, `bye`. Transports implement `transmit` and `shutdown`. */
|
||||
abstract class BaseLink implements Link {
|
||||
#handler: ((frame: Frame) => void) | undefined;
|
||||
#queue: Frame[] = [];
|
||||
readonly #closeHandlers = new Set<(reason: string) => void>();
|
||||
#closed = false;
|
||||
/** The tail of frames being decoded asynchronously; undefined when none is. */
|
||||
#decoding: Promise<void> | undefined;
|
||||
|
||||
get closed(): boolean {
|
||||
return this.#closed;
|
||||
}
|
||||
|
||||
protected abstract transmit(frame: Frame): number | undefined;
|
||||
protected abstract shutdown(): void;
|
||||
|
||||
send(frame: Frame): number | undefined {
|
||||
if (this.#closed) return undefined;
|
||||
return this.transmit(frame);
|
||||
}
|
||||
|
||||
receive(handler: (frame: Frame) => void): void {
|
||||
this.#handler = handler;
|
||||
for (const frame of this.#queue.splice(0)) handler(frame);
|
||||
}
|
||||
|
||||
onClose(handler: (reason: string) => void): void {
|
||||
if (this.#closed) handler("closed");
|
||||
else this.#closeHandlers.add(handler);
|
||||
}
|
||||
|
||||
close(reason = "closed"): void {
|
||||
if (this.#closed) return;
|
||||
try {
|
||||
this.transmit({ t: "bye", reason });
|
||||
} catch {
|
||||
// The transport may already be gone.
|
||||
}
|
||||
this.ended(reason);
|
||||
}
|
||||
|
||||
/** Delivers a parsed frame from the transport, after any frame still being decoded (`deliverLater`). */
|
||||
protected deliver(frame: Frame): void {
|
||||
if (this.#decoding !== undefined) this.deliverLater(Promise.resolve(frame));
|
||||
else this.#deliverNow(frame);
|
||||
}
|
||||
|
||||
/** Delivers a frame that is still being decoded (inflated in a browser); later frames wait for it, in order. */
|
||||
protected deliverLater(frame: Promise<Frame>): void {
|
||||
const next = (this.#decoding ?? Promise.resolve())
|
||||
.then(() => frame)
|
||||
.then(
|
||||
(decoded) => this.#deliverNow(decoded),
|
||||
() => this.close("protocol error"),
|
||||
)
|
||||
.finally(() => {
|
||||
if (this.#decoding === next) this.#decoding = undefined;
|
||||
});
|
||||
this.#decoding = next;
|
||||
}
|
||||
|
||||
#deliverNow(frame: Frame): void {
|
||||
if (this.#closed) return;
|
||||
if (frame.t === "bye") {
|
||||
this.ended(frame.reason ?? "peer closed");
|
||||
return;
|
||||
}
|
||||
if (this.#handler === undefined) this.#queue.push(frame);
|
||||
else this.#handler(frame);
|
||||
}
|
||||
|
||||
/** Marks the link closed (transport ended) and notifies once. */
|
||||
protected ended(reason: string): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#queue = [];
|
||||
try {
|
||||
this.shutdown();
|
||||
} finally {
|
||||
for (const handler of this.#closeHandlers) handler(reason);
|
||||
this.#closeHandlers.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── payload packing ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// A transport optimization, invisible above the links. A frame's payload (call args, a result value, a state snapshot)
|
||||
// whose JSON exceeds `PACK_THRESHOLD` characters is sent deflated. A host that only relays the frame forwards the
|
||||
// deflated bytes unread (`Packed`); the host that uses the payload inflates it (`unpack`). Only hosts with Node's zlib
|
||||
// deflate (synchronously, so frames keep their order); a browser inflates with `DecompressionStream` before delivery.
|
||||
|
||||
/** Payloads whose JSON is longer than this many characters are deflated. */
|
||||
export const PACK_THRESHOLD = 64 * 1024;
|
||||
|
||||
const zlib: typeof Zlib | undefined = (
|
||||
globalThis as { process?: { getBuiltinModule?(id: "node:zlib"): typeof Zlib } }
|
||||
).process?.getBuiltinModule?.("node:zlib");
|
||||
|
||||
/** A deflated JSON payload, kept as received until a host uses it. */
|
||||
export class Packed {
|
||||
readonly bytes: Uint8Array;
|
||||
constructor(bytes: Uint8Array) {
|
||||
this.bytes = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/** A frame payload as its user needs it: inflated and parsed if it arrived packed. */
|
||||
export function unpack(value: unknown): unknown {
|
||||
if (!(value instanceof Packed)) return value;
|
||||
if (zlib === undefined) throw new TypeError("packed payload without zlib");
|
||||
return JSON.parse(zlib.inflateRawSync(value.bytes).toString("utf8"));
|
||||
}
|
||||
|
||||
type PayloadField = "args" | "value";
|
||||
const PAYLOAD_FIELDS: ReadonlySet<string> = new Set<PayloadField>(["args", "value"]);
|
||||
|
||||
function payloadField(frame: Frame): PayloadField | undefined {
|
||||
if (frame.t === "call") return frame.args === undefined ? undefined : "args";
|
||||
if (frame.t === "result" || frame.t === "state") return frame.value === undefined ? undefined : "value";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The frame without its payload field (the envelope). */
|
||||
function envelopeOf(frame: Frame, field: PayloadField): Record<string, unknown> {
|
||||
const envelope: Record<string, unknown> = { ...frame };
|
||||
delete envelope[field];
|
||||
return envelope;
|
||||
}
|
||||
|
||||
/** The payload's deflated bytes: as received, or deflated now if large. Undefined: send it as JSON. */
|
||||
function packedBytes(value: unknown, compress: boolean, json?: string): Uint8Array | undefined {
|
||||
if (value instanceof Packed) return value.bytes;
|
||||
if (!compress || zlib === undefined || json === undefined || json.length <= PACK_THRESHOLD) return undefined;
|
||||
return zlib.deflateRawSync(json, { level: 1 });
|
||||
}
|
||||
|
||||
/** For object transports (Node IPC, memory): a packed payload travels as `z: [field, base64]`. */
|
||||
function packObject(frame: Frame, compress: boolean): unknown {
|
||||
const field = payloadField(frame);
|
||||
if (field === undefined) return frame;
|
||||
const value = (frame as Record<string, unknown>)[field];
|
||||
const bytes = packedBytes(value, compress, compress && zlib !== undefined ? JSON.stringify(value) : undefined);
|
||||
if (bytes === undefined) return frame;
|
||||
return {
|
||||
...envelopeOf(frame, field),
|
||||
z: [field, Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64")],
|
||||
};
|
||||
}
|
||||
|
||||
function unpackObject(message: unknown): unknown {
|
||||
if (message === null || typeof message !== "object" || !("z" in message)) return message;
|
||||
const { z, ...rest } = message as { z: unknown } & Record<string, unknown>;
|
||||
if (!Array.isArray(z) || !PAYLOAD_FIELDS.has(z[0]) || typeof z[1] !== "string")
|
||||
throw new TypeError("bad packed payload");
|
||||
return { ...rest, [z[0]]: new Packed(Buffer.from(z[1], "base64")) };
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
/**
|
||||
* For WebSockets: a frame with a packed payload is a binary message, `[envelope length: u32 BE][envelope JSON, with
|
||||
* z: field][deflated payload]`; any other frame is text. The payload's JSON is spliced in, not encoded twice.
|
||||
*/
|
||||
function packMessage(frame: Frame, compress: boolean): string | Uint8Array {
|
||||
const field = payloadField(frame);
|
||||
if (field === undefined) return JSON.stringify(frame);
|
||||
const value = (frame as Record<string, unknown>)[field];
|
||||
const json = value instanceof Packed ? undefined : JSON.stringify(value);
|
||||
const bytes = packedBytes(value, compress, json);
|
||||
const envelope = envelopeOf(frame, field);
|
||||
if (bytes === undefined) {
|
||||
const head = JSON.stringify(envelope);
|
||||
return `${head.slice(0, -1)},${JSON.stringify(field)}:${json}}`;
|
||||
}
|
||||
const head = encoder.encode(JSON.stringify({ ...envelope, z: field }));
|
||||
const message = new Uint8Array(4 + head.byteLength + bytes.byteLength);
|
||||
new DataView(message.buffer).setUint32(0, head.byteLength);
|
||||
message.set(head, 4);
|
||||
message.set(bytes, 4 + head.byteLength);
|
||||
return message;
|
||||
}
|
||||
|
||||
/** The envelope of a binary message and its payload bytes. */
|
||||
function splitMessage(data: Uint8Array): { envelope: Record<string, unknown>; field: PayloadField; bytes: Uint8Array } {
|
||||
const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0);
|
||||
const envelope = JSON.parse(decoder.decode(data.subarray(4, 4 + length))) as unknown;
|
||||
if (envelope === null || typeof envelope !== "object") throw new TypeError("bad envelope");
|
||||
const { z: field, ...rest } = envelope as { z?: unknown } & Record<string, unknown>;
|
||||
if (typeof field !== "string" || !PAYLOAD_FIELDS.has(field)) throw new TypeError("bad packed payload");
|
||||
return { envelope: rest, field: field as PayloadField, bytes: data.slice(4 + length) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The text frame of a binary (packed) WebSocket message, inflated: for a transport that relays text frames only (the
|
||||
* TUI launcher feeding its sandbox). Needs zlib.
|
||||
*/
|
||||
export function inflateMessage(data: Uint8Array): string {
|
||||
if (zlib === undefined) throw new TypeError("inflateMessage needs zlib");
|
||||
const { envelope, field, bytes } = splitMessage(data);
|
||||
const head = JSON.stringify(envelope);
|
||||
return `${head.slice(0, -1)},${JSON.stringify(field)}:${zlib.inflateRawSync(bytes).toString("utf8")}}`;
|
||||
}
|
||||
|
||||
/** Inflates without zlib (browsers). */
|
||||
async function inflate(bytes: Uint8Array): Promise<unknown> {
|
||||
const stream = new Blob([bytes as Uint8Array<ArrayBuffer>])
|
||||
.stream()
|
||||
.pipeThrough(new DecompressionStream("deflate-raw"));
|
||||
return JSON.parse(await new Response(stream).text());
|
||||
}
|
||||
|
||||
/** Parses a JSON frame; anything else is a protocol error. */
|
||||
function parseFrame(data: string): Frame {
|
||||
const frame = unpackObject(JSON.parse(data)) as Frame;
|
||||
if (frame === null || typeof frame !== "object" || typeof frame.t !== "string") throw new TypeError("not a frame");
|
||||
return frame;
|
||||
}
|
||||
|
||||
// ─── memory ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export class MemoryLink extends BaseLink {
|
||||
peer: MemoryLink | undefined;
|
||||
#severed = false;
|
||||
#pending = Promise.resolve();
|
||||
|
||||
/** Simulates a dead network: frames are dropped silently in both directions and no close is signalled. */
|
||||
sever(): void {
|
||||
this.#severed = true;
|
||||
if (this.peer) this.peer.#severed = true;
|
||||
}
|
||||
|
||||
protected transmit(frame: Frame): number | undefined {
|
||||
if (this.#severed) return undefined;
|
||||
// A JSON round trip detaches the frame (and rejects what JSON would mangle silently, e.g. bigint).
|
||||
const data = JSON.stringify(packObject(frame, true));
|
||||
const peer = this.peer;
|
||||
// One promise chain per direction keeps delivery FIFO and asynchronous, like a real transport.
|
||||
this.#pending = this.#pending.then(() => {
|
||||
if (!this.#severed && peer !== undefined) peer.deliver(parseFrame(data));
|
||||
});
|
||||
return data.length;
|
||||
}
|
||||
|
||||
protected shutdown(): void {
|
||||
const peer = this.peer;
|
||||
this.#pending = this.#pending.then(() => peer?.ended("peer closed"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Two connected in-memory links (tests). */
|
||||
export function memoryLinkPair(): [MemoryLink, MemoryLink] {
|
||||
const a = new MemoryLink();
|
||||
const b = new MemoryLink();
|
||||
a.peer = b;
|
||||
b.peer = a;
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
// ─── Node IPC ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** `process` in a forked child, or the parent's `ChildProcess`. Structural, so Chord needs no Node types here. */
|
||||
export interface IpcChannel {
|
||||
readonly connected: boolean;
|
||||
/**
|
||||
* With a callback, Node reports a failed write (the peer process exited and the pipe is broken: `EPIPE`) to it
|
||||
* instead of emitting `error`, which nobody would handle.
|
||||
*/
|
||||
send(message: unknown, callback?: (error: Error | null) => void): boolean;
|
||||
on(event: "message", listener: (message: unknown) => void): unknown;
|
||||
on(event: "disconnect", listener: () => void): unknown;
|
||||
off(event: "message", listener: (message: unknown) => void): unknown;
|
||||
off(event: "disconnect", listener: () => void): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chord frames on an IPC channel are wrapped as `{ chord2: frame }`; other messages (control) pass by. Both ends are
|
||||
* Node processes of one application, so large payloads are packed both ways. Node serializes the messages itself, so
|
||||
* the link reports no sizes and is not windowed: both ends are local processes.
|
||||
*/
|
||||
export class IpcLink extends BaseLink {
|
||||
readonly #channel: IpcChannel;
|
||||
readonly #onMessage = (message: unknown): void => {
|
||||
const frame = (message as { chord2?: unknown } | null)?.chord2;
|
||||
if (frame === undefined) return;
|
||||
try {
|
||||
this.deliver(unpackObject(frame) as Frame);
|
||||
} catch {
|
||||
this.close("protocol error");
|
||||
}
|
||||
};
|
||||
readonly #onDisconnect = (): void => this.ended("ipc disconnected");
|
||||
/**
|
||||
* A write can fail after the peer exited and before the channel reports `disconnect` (an `ack` or a `bye` sent then).
|
||||
* The peer is gone either way: the link ends, as on `disconnect`.
|
||||
*/
|
||||
readonly #onSent = (error: Error | null): void => {
|
||||
if (error !== null) this.ended(`ipc send failed: ${(error as { code?: string }).code ?? error.message}`);
|
||||
};
|
||||
|
||||
constructor(channel: IpcChannel) {
|
||||
super();
|
||||
this.#channel = channel;
|
||||
channel.on("message", this.#onMessage);
|
||||
channel.on("disconnect", this.#onDisconnect);
|
||||
}
|
||||
|
||||
protected transmit(frame: Frame): undefined {
|
||||
if (this.#channel.connected) this.#channel.send({ chord2: packObject(frame, true) }, this.#onSent);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected shutdown(): void {
|
||||
this.#channel.off("message", this.#onMessage);
|
||||
this.#channel.off("disconnect", this.#onDisconnect);
|
||||
}
|
||||
}
|
||||
|
||||
export const ipcLink = (channel: IpcChannel): IpcLink => new IpcLink(channel);
|
||||
|
||||
// ─── WebSocket ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** The WHATWG WebSocket surface: the browser `WebSocket`, Node's global `WebSocket`, and `ws` sockets. */
|
||||
export interface WebSocketLike {
|
||||
readonly readyState: number;
|
||||
send(data: string | Uint8Array): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
addEventListener(type: "message", listener: (event: { readonly data: unknown }) => void): void;
|
||||
addEventListener(type: "open" | "close" | "error", listener: () => void): void;
|
||||
}
|
||||
|
||||
const OPEN = 1;
|
||||
|
||||
export interface WebSocketLinkOptions {
|
||||
/** Deflate large payloads of outgoing frames (default false; the server's side of client sockets). */
|
||||
readonly compress?: boolean;
|
||||
/** Accept packed (binary) frames from the peer (default true; false for a server's untrusted clients). */
|
||||
readonly acceptPacked?: boolean;
|
||||
}
|
||||
|
||||
export class WebSocketLink extends BaseLink {
|
||||
readonly #socket: WebSocketLike;
|
||||
readonly #compress: boolean;
|
||||
#outbox: (string | Uint8Array)[] = [];
|
||||
|
||||
constructor(socket: WebSocketLike, options: WebSocketLinkOptions = {}) {
|
||||
super();
|
||||
this.#socket = socket;
|
||||
this.#compress = options.compress ?? false;
|
||||
const acceptPacked = options.acceptPacked ?? true;
|
||||
// Binary messages as ArrayBuffer (browsers and Node's WebSocket default to Blob).
|
||||
if ("binaryType" in socket) (socket as { binaryType: string }).binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
for (const data of this.#outbox.splice(0)) socket.send(data);
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
try {
|
||||
const data = event.data;
|
||||
if (typeof data === "string") {
|
||||
this.deliver(parseFrame(data));
|
||||
return;
|
||||
}
|
||||
if (!acceptPacked) throw new TypeError("packed frames are not accepted");
|
||||
const bytes =
|
||||
data instanceof ArrayBuffer
|
||||
? new Uint8Array(data)
|
||||
: ArrayBuffer.isView(data)
|
||||
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
: undefined;
|
||||
if (bytes === undefined) throw new TypeError("unknown message type");
|
||||
const { envelope, field, bytes: payload } = splitMessage(bytes);
|
||||
if (zlib !== undefined) this.deliver({ ...envelope, [field]: new Packed(payload) } as Frame);
|
||||
else this.deliverLater(inflate(payload).then((value) => ({ ...envelope, [field]: value }) as Frame));
|
||||
} catch {
|
||||
this.close("protocol error");
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => this.ended("socket closed"));
|
||||
}
|
||||
|
||||
protected transmit(frame: Frame): number {
|
||||
const data = packMessage(frame, this.#compress);
|
||||
if (this.#socket.readyState === OPEN) this.#socket.send(data);
|
||||
else this.#outbox.push(data);
|
||||
return typeof data === "string" ? data.length : data.byteLength;
|
||||
}
|
||||
|
||||
protected shutdown(): void {
|
||||
this.#outbox = [];
|
||||
if (this.#socket.readyState <= OPEN) this.#socket.close(1000);
|
||||
}
|
||||
}
|
||||
|
||||
export const webSocketLink = (socket: WebSocketLike, options?: WebSocketLinkOptions): WebSocketLink =>
|
||||
new WebSocketLink(socket, options);
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
// Node facet loader: `createHost({ loader: nodeLoader })`. Kept out of index.ts so the core stays browser-safe.
|
||||
//
|
||||
// TODO(loader): replace with an isolating, evicting loader. Every reload imports fresh module copies under a new
|
||||
// query and the old copies are never evicted (they leak until the process exits). TUI plugins need isolation, which
|
||||
// this loader does not provide.
|
||||
import { registerHooks } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { Facet, FacetDefinition, FacetLoader } from "./host.ts";
|
||||
|
||||
const PARAM = "chord2-reload";
|
||||
|
||||
/** Directory URLs (trailing slash) of loaded facet modules; their files are re-imported on reload. */
|
||||
const roots = new Set<string>();
|
||||
let hooked = false;
|
||||
let generation = 0;
|
||||
|
||||
/**
|
||||
* A query on the entry URL only re-imports the entry module: its relative imports would resolve to cached copies.
|
||||
* This hook carries the reload query from a re-imported parent to children inside a facet module's directory.
|
||||
* Modules outside (Chord itself, shared code) stay single instances.
|
||||
*/
|
||||
function hook(): void {
|
||||
if (hooked) return;
|
||||
hooked = true;
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
const result = nextResolve(specifier, context);
|
||||
const version = context.parentURL === undefined ? null : new URL(context.parentURL).searchParams.get(PARAM);
|
||||
if (version === null || !result.url.startsWith("file:")) return result;
|
||||
const url = new URL(result.url);
|
||||
if (url.searchParams.has(PARAM) || ![...roots].some((root) => url.href.startsWith(root))) return result;
|
||||
url.searchParams.set(PARAM, version);
|
||||
return { ...result, url: url.href };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const toUrl = (module: string | URL): URL =>
|
||||
module instanceof URL
|
||||
? new URL(module.href)
|
||||
: new URL(module.startsWith("file:") ? module : pathToFileURL(module).href);
|
||||
|
||||
/** Imports fresh copies of facet modules (default export: a FacetDefinition); each facet is named by its URL. */
|
||||
export const nodeLoader: FacetLoader = async (modules) => {
|
||||
hook();
|
||||
const version = String(++generation);
|
||||
const facets: Facet[] = [];
|
||||
const urls = modules.map((module) => {
|
||||
const url = toUrl(module);
|
||||
url.search = "";
|
||||
return url;
|
||||
});
|
||||
// Every root first: a facet importing another facet's directory gets this load's copy, whatever the order.
|
||||
for (const url of urls) roots.add(new URL(".", url).href);
|
||||
for (const url of urls) {
|
||||
const name = url.href;
|
||||
url.searchParams.set(PARAM, version);
|
||||
// Owner-approved exception to the no-dynamic-import rule; see TODO(loader) above.
|
||||
const loaded = (await import(url.href)) as { default?: FacetDefinition };
|
||||
const definition = loaded.default;
|
||||
if (definition === undefined || typeof definition.setup !== "function") {
|
||||
throw new TypeError(`${name}: default export must be a facet (defineFacet)`);
|
||||
}
|
||||
facets.push({ name, setup: definition.setup, uses: definition.uses });
|
||||
}
|
||||
return facets;
|
||||
};
|
||||
|
|
@ -1,443 +0,0 @@
|
|||
// Host manager (Node only): runs a child host process and keeps at most one instance of it alive.
|
||||
import { type ChildProcess, fork } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { lstat, mkdir, rm } from "node:fs/promises";
|
||||
import { connect, createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ChordError } from "./errors.ts";
|
||||
import { type IpcChannel, type IpcLink, ipcLink } from "./link.ts";
|
||||
|
||||
/** Control messages share the IPC channel with chord frames: `{ chord2control: message }`. */
|
||||
export type ControlMessage =
|
||||
| { readonly type: "init"; readonly value: unknown }
|
||||
| { readonly type: "ready" }
|
||||
| { readonly type: "fatal"; readonly message: string; readonly reason?: string }
|
||||
| { readonly type: "shutdown" }
|
||||
| {
|
||||
readonly type: "status";
|
||||
readonly activeCalls?: number;
|
||||
readonly viewers?: number;
|
||||
readonly durableTasks?: number;
|
||||
};
|
||||
|
||||
export interface Liveness {
|
||||
/** Started but not ready yet. */
|
||||
readonly booting: boolean;
|
||||
readonly activeCalls: number;
|
||||
/** Viewers the child reports (it counts heartbeating subscribers). */
|
||||
readonly viewers: number;
|
||||
readonly durableTasks: number;
|
||||
/** Milliseconds since the child last reported work (calls, viewers or tasks), or since it became ready. */
|
||||
readonly idleFor: number;
|
||||
}
|
||||
|
||||
export interface Exit {
|
||||
readonly code: number | null;
|
||||
readonly signal: string | null;
|
||||
}
|
||||
|
||||
/** A running child: its link (for `host.accept`) and its exit. */
|
||||
export interface Instance {
|
||||
readonly pid: number;
|
||||
readonly link: IpcLink;
|
||||
readonly exited: Promise<Exit>;
|
||||
send(message: ControlMessage): void;
|
||||
/** Kills the child and everything it spawned. */
|
||||
killTree(): void;
|
||||
onControl(listener: (message: ControlMessage) => void): void;
|
||||
}
|
||||
|
||||
export interface Driver {
|
||||
spawn(): Instance;
|
||||
}
|
||||
|
||||
export interface ForkDriverOptions {
|
||||
/** The child's entry module. */
|
||||
readonly entry: string;
|
||||
readonly args?: readonly string[];
|
||||
readonly env?: Readonly<Record<string, string>>;
|
||||
/** Node flags for the child, e.g. `["--import", sourceResolver]` (default: none). */
|
||||
readonly execArgv?: readonly string[];
|
||||
/** Sent to the child as its first control message (`childControl().init`). */
|
||||
readonly init?: unknown;
|
||||
/**
|
||||
* A lock name: one live process per name, across managers and processes. The child acquires it as a kernel lock
|
||||
* (`childControl().lock()`, before opening its storage); a second child fails to start with `unavailable/locked`.
|
||||
*/
|
||||
readonly lock?: string;
|
||||
/** Optional stdout/stderr tap. Streams are still mirrored to the parent's standard output. */
|
||||
readonly onOutput?: (stream: "stdout" | "stderr", chunk: Uint8Array) => void;
|
||||
/** Optional application metadata tap; control messages and chord frames keep their normal handling. */
|
||||
readonly onMessage?: (message: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A process lock owned by the kernel for the holder's lifetime: an abstract socket (Linux, Android) or a named pipe
|
||||
* (Windows) vanishes with its process, so there is no stale state to take over and no race between two would-be
|
||||
* owners. Other platforms need a socket file, removed on exit; a leftover one nobody accepts on is taken over.
|
||||
* The child acquires it (before opening storage), so the lock follows the process that owns the data.
|
||||
*/
|
||||
export async function processLock(name: string): Promise<() => Promise<void>> {
|
||||
const key = createHash("sha256").update(name).digest("hex").slice(0, 32);
|
||||
const file = process.platform !== "win32" && process.platform !== "linux" && process.platform !== "android";
|
||||
const address =
|
||||
process.platform === "win32"
|
||||
? `\\\\.\\pipe\\chord2-${key}`
|
||||
: file
|
||||
? await lockSocketPath(`chord2-${key}.sock`)
|
||||
: `\0chord2-${key}`;
|
||||
const server = createServer((socket) => socket.end());
|
||||
for (;;) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(address, () => {
|
||||
server.removeListener("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
|
||||
if (!file || (await accepting(address))) {
|
||||
throw new ChordError("unavailable", `lock ${name} is held by another process`, { reason: "locked" });
|
||||
}
|
||||
await rm(address, { force: true });
|
||||
}
|
||||
}
|
||||
const removeFile = (): void => {
|
||||
try {
|
||||
unlinkSync(address);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
};
|
||||
if (file) process.once("exit", removeFile);
|
||||
return () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (file) process.removeListener("exit", removeFile);
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
/** sun_path references 104 bytes on macOS and the BSDs, including the terminating NUL. */
|
||||
const MAX_SOCKET_PATH_BYTES = 103;
|
||||
|
||||
/**
|
||||
* The lock socket's path: in the temporary directory, or, when that path does not fit in sun_path (a deep TMPDIR,
|
||||
* such as a test runner's below macOS's per-user one), in a private per-user directory under /tmp. Other users must
|
||||
* not be able to create or remove sockets there, so the directory has to be ours and closed to everyone else.
|
||||
*/
|
||||
async function lockSocketPath(file: string): Promise<string> {
|
||||
const preferred = join(tmpdir(), file);
|
||||
if (Buffer.byteLength(preferred) <= MAX_SOCKET_PATH_BYTES) return preferred;
|
||||
const uid = process.getuid?.() ?? 0;
|
||||
const directory = `/tmp/chord2-${uid}`;
|
||||
await mkdir(directory, { mode: 0o700, recursive: true });
|
||||
const stats = await lstat(directory);
|
||||
if (!stats.isDirectory() || stats.uid !== uid || (stats.mode & 0o077) !== 0) {
|
||||
throw new ChordError("unavailable", `lock directory ${directory} must be a directory only its owner can access`);
|
||||
}
|
||||
return join(directory, file);
|
||||
}
|
||||
|
||||
function accepting(address: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = connect(address);
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", (error: NodeJS.ErrnoException) =>
|
||||
resolve(error.code !== "ECONNREFUSED" && error.code !== "ENOENT"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const LOCK_ENV = "CHORD2_LOCK";
|
||||
|
||||
/** A control message to a peer process that is gone: dropped (its exit or `disconnect` is reported separately). */
|
||||
const ignoreSendError = (): void => {};
|
||||
|
||||
export function forkDriver(options: ForkDriverOptions): Driver {
|
||||
return {
|
||||
spawn() {
|
||||
// A lock this process inherited (e.g. as a child host itself) is not the child's: it gets only its own.
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
delete env[LOCK_ENV];
|
||||
Object.assign(env, options.env);
|
||||
if (options.lock !== undefined) env[LOCK_ENV] = options.lock;
|
||||
// Detached: the child leads a process group, so the whole tree can be killed.
|
||||
const child: ChildProcess = fork(options.entry, [...(options.args ?? [])], {
|
||||
env,
|
||||
execArgv: [...(options.execArgv ?? [])],
|
||||
detached: true,
|
||||
stdio: ["ignore", options.onOutput ? "pipe" : "inherit", options.onOutput ? "pipe" : "inherit", "ipc"],
|
||||
});
|
||||
if (options.onOutput) {
|
||||
for (const stream of ["stdout", "stderr"] as const) {
|
||||
child[stream]?.on("data", (chunk: Buffer) => {
|
||||
options.onOutput!(stream, chunk);
|
||||
process[stream].write(chunk);
|
||||
});
|
||||
}
|
||||
}
|
||||
const pid = child.pid ?? -1;
|
||||
const killGroup = (): void => {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch {
|
||||
// No group left.
|
||||
}
|
||||
};
|
||||
const exited = new Promise<Exit>((resolve) => {
|
||||
child.once("exit", (code, signal) => {
|
||||
// However the leader ended (graceful, crash, kill), nothing it spawned outlives it.
|
||||
killGroup();
|
||||
resolve({ code, signal });
|
||||
});
|
||||
});
|
||||
const listeners = new Set<(message: ControlMessage) => void>();
|
||||
child.on("message", (message) => {
|
||||
options.onMessage?.(message);
|
||||
const control = (message as { chord2control?: ControlMessage } | null)?.chord2control;
|
||||
if (control !== undefined) for (const listener of listeners) listener(control);
|
||||
});
|
||||
const send = (message: ControlMessage): void => {
|
||||
// A child that exited before the channel reported it fails the write (EPIPE): its exit is what counts.
|
||||
if (child.connected) child.send({ chord2control: message }, ignoreSendError);
|
||||
};
|
||||
if (options.init !== undefined) send({ type: "init", value: options.init });
|
||||
return {
|
||||
pid,
|
||||
link: ipcLink(child as unknown as IpcChannel),
|
||||
exited,
|
||||
send,
|
||||
killTree: () => {
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
},
|
||||
onControl: (listener) => listeners.add(listener),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface HostManagerOptions {
|
||||
readonly driver: Driver;
|
||||
/** Evaluated every `checkInterval` while running; `false` stops the child. */
|
||||
readonly keepAlive?: (liveness: Liveness) => boolean;
|
||||
readonly checkInterval?: number;
|
||||
/** Milliseconds a child may take to shut down before its process tree is killed (default 5000). */
|
||||
readonly grace?: number;
|
||||
/** Milliseconds to wait for `ready` (default 30000). */
|
||||
readonly readyTimeout?: number;
|
||||
/** The child exited on its own (crash, fatal) or after a stop. */
|
||||
readonly onExit?: (exit: Exit) => void;
|
||||
}
|
||||
|
||||
export interface HostManager {
|
||||
readonly status: "stopped" | "starting" | "running" | "stopping";
|
||||
readonly instance: Instance | undefined;
|
||||
readonly liveness: Liveness;
|
||||
/** Starts the child (or returns the running one) once it reported ready. Serialized with stop/replace. */
|
||||
start(): Promise<Instance>;
|
||||
/** Asks the child to shut down, then kills its process tree after the grace period. */
|
||||
stop(): Promise<void>;
|
||||
/** Stop, wait for the exit, start. */
|
||||
replace(): Promise<Instance>;
|
||||
}
|
||||
|
||||
export function createHostManager(options: HostManagerOptions): HostManager {
|
||||
let status: HostManager["status"] = "stopped";
|
||||
let instance: Instance | undefined;
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
let report = { activeCalls: 0, viewers: 0, durableTasks: 0 };
|
||||
/** When the child last became idle; undefined while it reports work. */
|
||||
let idleSince: number | undefined;
|
||||
let booting = false;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const serialized = <T>(step: () => Promise<T>): Promise<T> => {
|
||||
const next = queue.then(step, step);
|
||||
queue = next.catch(() => {});
|
||||
return next;
|
||||
};
|
||||
|
||||
const liveness = (): Liveness => ({
|
||||
booting,
|
||||
...report,
|
||||
idleFor: idleSince === undefined ? 0 : Date.now() - idleSince,
|
||||
});
|
||||
|
||||
const doStop = async (): Promise<void> => {
|
||||
const current = instance;
|
||||
if (current === undefined) {
|
||||
status = "stopped";
|
||||
booting = false;
|
||||
return;
|
||||
}
|
||||
status = "stopping";
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
current.send({ type: "shutdown" });
|
||||
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const graceful = await Promise.race([
|
||||
current.exited.then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
graceTimer = setTimeout(() => resolve(false), options.grace ?? 5000);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(graceTimer);
|
||||
if (!graceful) current.killTree();
|
||||
await current.exited;
|
||||
if (instance === current) instance = undefined;
|
||||
status = "stopped";
|
||||
};
|
||||
|
||||
const doStart = async (): Promise<Instance> => {
|
||||
if (instance !== undefined && status === "running") return instance;
|
||||
status = "starting";
|
||||
booting = true;
|
||||
report = { activeCalls: 0, viewers: 0, durableTasks: 0 };
|
||||
let current: Instance;
|
||||
try {
|
||||
current = options.driver.spawn();
|
||||
} catch (error) {
|
||||
status = "stopped";
|
||||
booting = false;
|
||||
throw error;
|
||||
}
|
||||
instance = current;
|
||||
current.exited.then((exit) => {
|
||||
if (instance === current) {
|
||||
instance = undefined;
|
||||
status = "stopped";
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
}
|
||||
options.onExit?.(exit);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new ChordError("unavailable", "child not ready in time")),
|
||||
options.readyTimeout ?? 30_000,
|
||||
);
|
||||
const settle = (fn: () => void): void => {
|
||||
clearTimeout(timeout);
|
||||
fn();
|
||||
};
|
||||
current.exited.then((exit) =>
|
||||
settle(() =>
|
||||
reject(new ChordError("unavailable", `child exited before ready (${exit.code ?? exit.signal})`)),
|
||||
),
|
||||
);
|
||||
current.onControl((message) => {
|
||||
if (message.type === "ready") settle(resolve);
|
||||
else if (message.type === "fatal")
|
||||
settle(() =>
|
||||
reject(new ChordError("unavailable", `child failed: ${message.message}`, { reason: message.reason })),
|
||||
);
|
||||
else if (message.type === "status") {
|
||||
report = {
|
||||
activeCalls: message.activeCalls ?? 0,
|
||||
viewers: message.viewers ?? 0,
|
||||
durableTasks: message.durableTasks ?? 0,
|
||||
};
|
||||
// The idle clock starts at the busy → idle transition, not at the last busy report.
|
||||
if (report.activeCalls + report.viewers + report.durableTasks > 0) idleSince = undefined;
|
||||
else idleSince ??= Date.now();
|
||||
}
|
||||
});
|
||||
}).catch(async (error: unknown) => {
|
||||
current.killTree();
|
||||
await current.exited;
|
||||
throw error;
|
||||
});
|
||||
booting = false;
|
||||
if (report.activeCalls + report.viewers + report.durableTasks === 0) idleSince ??= Date.now();
|
||||
status = "running";
|
||||
if (options.keepAlive !== undefined) {
|
||||
const keepAlive = options.keepAlive;
|
||||
timer = setInterval(() => {
|
||||
if (status === "running" && !keepAlive(liveness())) void serialized(doStop);
|
||||
}, options.checkInterval ?? 1000);
|
||||
timer.unref?.();
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
return {
|
||||
get status() {
|
||||
return status;
|
||||
},
|
||||
get instance() {
|
||||
return instance;
|
||||
},
|
||||
get liveness() {
|
||||
return liveness();
|
||||
},
|
||||
start: () => serialized(doStart),
|
||||
stop: () => serialized(doStop),
|
||||
replace: () =>
|
||||
serialized(async () => {
|
||||
await doStop();
|
||||
return doStart();
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The child side of the control channel (call in the child's entry). */
|
||||
export function childControl(channel: NodeJS.Process = process): {
|
||||
readonly init: Promise<unknown>;
|
||||
/**
|
||||
* Acquires the manager's lock (if one was configured) as a kernel lock held for this process's lifetime. Call it
|
||||
* before opening storage. Fails with `unavailable/locked`, also reported to the manager as a fatal start error.
|
||||
*/
|
||||
lock(): Promise<void>;
|
||||
ready(): void;
|
||||
fatal(error: unknown): void;
|
||||
report(status: { activeCalls?: number; viewers?: number; durableTasks?: number }): void;
|
||||
onShutdown(handler: () => void | Promise<void>): void;
|
||||
} {
|
||||
const send = (message: ControlMessage): void => {
|
||||
// A parent that is gone fails the write (EPIPE); the `disconnect` that follows is how a child notices.
|
||||
if (channel.connected) channel.send?.({ chord2control: message }, undefined, undefined, ignoreSendError);
|
||||
};
|
||||
let resolveInit: (value: unknown) => void = () => {};
|
||||
const init = new Promise<unknown>((resolve) => {
|
||||
resolveInit = resolve;
|
||||
});
|
||||
const shutdown = new Set<() => void | Promise<void>>();
|
||||
channel.on("message", (message: unknown) => {
|
||||
const control = (message as { chord2control?: ControlMessage } | null)?.chord2control;
|
||||
if (control?.type === "init") resolveInit(control.value);
|
||||
if (control?.type === "shutdown") for (const handler of shutdown) void handler();
|
||||
});
|
||||
const fatal = (error: unknown): void =>
|
||||
send({
|
||||
type: "fatal",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
...(error instanceof ChordError && error.reason !== undefined ? { reason: error.reason } : {}),
|
||||
});
|
||||
return {
|
||||
init,
|
||||
lock: async () => {
|
||||
const name = process.env[LOCK_ENV];
|
||||
if (name === undefined) return;
|
||||
try {
|
||||
await processLock(name);
|
||||
} catch (error) {
|
||||
fatal(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
ready: () => send({ type: "ready" }),
|
||||
fatal,
|
||||
report: (status) => send({ type: "status", ...status }),
|
||||
onShutdown: (handler) => shutdown.add(handler),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
/** Node-only parts of Chord v1 (`@earendil-works/chord/old/node`): child host processes, their manager, and the facet module loader. */
|
||||
export { nodeLoader } from "./loader.ts";
|
||||
export {
|
||||
type ControlMessage,
|
||||
childControl,
|
||||
createHostManager,
|
||||
type Driver,
|
||||
type Exit,
|
||||
type ForkDriverOptions,
|
||||
forkDriver,
|
||||
type HostManager,
|
||||
type HostManagerOptions,
|
||||
type Instance,
|
||||
type Liveness,
|
||||
processLock,
|
||||
} from "./manager.ts";
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,39 +0,0 @@
|
|||
// One FIFO queue for every observer notification (state events, shape pushes, registry tables, listener
|
||||
// invocations).
|
||||
//
|
||||
// Problem: notifying observers synchronously from inside a write lets a re-entrant write overtake the write being
|
||||
// published. Example: subscriber A reacts to n = 1 by writing n = 2; subscriber B then sees 2 before 1.
|
||||
// Solution: a publication first adopts every revision it makes (cells and derivations), then enqueues its
|
||||
// notifications. Notifications run in enqueue order, so every observer sees revisions in adoption order.
|
||||
// `holdPublications` defers delivery until a multi-step change (a reload commit) is complete.
|
||||
|
||||
const queue: (() => void)[] = [];
|
||||
let held = 0;
|
||||
let draining = false;
|
||||
|
||||
/** Runs `task` after everything enqueued before it. Tasks must not throw (report instead). */
|
||||
export function enqueue(task: () => void): void {
|
||||
queue.push(task);
|
||||
drain();
|
||||
}
|
||||
|
||||
/** Delivers nothing until `fn` returns, then everything it enqueued, in order. */
|
||||
export function holdPublications<T>(fn: () => T): T {
|
||||
held++;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
held--;
|
||||
drain();
|
||||
}
|
||||
}
|
||||
|
||||
function drain(): void {
|
||||
if (draining || held > 0) return;
|
||||
draining = true;
|
||||
try {
|
||||
for (let task = queue.shift(); task !== undefined; task = queue.shift()) task();
|
||||
} finally {
|
||||
draining = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import type { Ctx } from "./types.ts";
|
||||
|
||||
export interface Registry<V> {
|
||||
readonly name: string;
|
||||
readonly conflict: "error" | "last";
|
||||
/** Type carrier only; never set. */
|
||||
readonly value?: V;
|
||||
}
|
||||
|
||||
/** `conflict: "error"` rejects two objects registering one key; `"last"` keeps the later registration. */
|
||||
export const defineRegistry = <V>(name: string, options: { conflict?: "error" | "last" } = {}): Registry<V> => ({
|
||||
name,
|
||||
conflict: options.conflict ?? "error",
|
||||
});
|
||||
|
||||
export type RegistryTable<V> = ReadonlyMap<string, V>;
|
||||
|
||||
export interface RegistryView<V> {
|
||||
/** Table of `ctx.codeVersion`, or of the current code version without ctx. */
|
||||
table(ctx?: Ctx): RegistryTable<V>;
|
||||
get(key: string, ctx?: Ctx): V | undefined;
|
||||
/** Called with the current table immediately and whenever a new table is installed. */
|
||||
subscribe(listener: (table: RegistryTable<V>, codeVersion: number) => void): () => void;
|
||||
}
|
||||
|
||||
export interface Registration {
|
||||
readonly registry: Registry<unknown>;
|
||||
readonly key: string;
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
|
@ -1,723 +0,0 @@
|
|||
import type { Cell } from "./cell.ts";
|
||||
import { type AnyContract, bind, coverage } from "./contract.ts";
|
||||
import { asChordError, ChordError, invalid, isChordError } from "./errors.ts";
|
||||
import type { CodeVersionState, Facet, HostImpl, PluginRef, Serve } from "./host.ts";
|
||||
import {
|
||||
type Factory,
|
||||
hostLifetime,
|
||||
makeService,
|
||||
memberSchemas,
|
||||
type ObjectImpl,
|
||||
RefImpl,
|
||||
type ResourceImpl,
|
||||
type Run,
|
||||
type ServeOptions,
|
||||
type Service,
|
||||
type StagedListeners,
|
||||
} from "./object.ts";
|
||||
import { holdPublications } from "./publish.ts";
|
||||
import type { RegistryTable } from "./registry.ts";
|
||||
import { assertValid, deepEqual } from "./schema.ts";
|
||||
import type { Readable } from "./types.ts";
|
||||
|
||||
export interface ReloadOptions {
|
||||
/** Facet names to remove. */
|
||||
readonly remove?: readonly string[];
|
||||
/** Milliseconds the gate waits for grandfathered roots (default 5000). Missed: the reload aborts. */
|
||||
readonly gateDeadline?: number;
|
||||
/** Milliseconds to wait for the previous code version to retire first (default 5000). Missed: it is retired anyway. */
|
||||
readonly retireDeadline?: number;
|
||||
/** Server: facet modules for child hosts by host-name prefix (longest wins). Every connected host participates. */
|
||||
readonly remote?: Readonly<Record<string, readonly string[]>>;
|
||||
/** Server: facet names to remove on child hosts, by the same host-name prefix rules. */
|
||||
readonly remoteRemove?: Readonly<Record<string, readonly string[]>>;
|
||||
/** Server: milliseconds to wait for every child's vote (default: gate + retire deadlines + 5000). */
|
||||
readonly voteDeadline?: number;
|
||||
}
|
||||
|
||||
export interface ReloadReport {
|
||||
readonly codeVersion: number;
|
||||
readonly previous: number;
|
||||
readonly changed: readonly string[];
|
||||
readonly removed: readonly string[];
|
||||
/** Addresses of objects whose new factory was installed. */
|
||||
readonly prepared: readonly string[];
|
||||
/** Addresses of objects that went through the gate. */
|
||||
readonly gated: readonly string[];
|
||||
/**
|
||||
* Cluster reloads: what each participating child host prepared, by host name (its vote; the child commits it after
|
||||
* the server). Absent for local reloads.
|
||||
*/
|
||||
readonly children?: Readonly<Record<string, ReloadReport>>;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
readonly object: ObjectImpl;
|
||||
readonly service: Service;
|
||||
readonly run: Run;
|
||||
/** Why the object needs the gate (empty: compatible). */
|
||||
readonly reasons: string[];
|
||||
/** New exclusive resources and their new dependents, opened at the gate. */
|
||||
readonly atGate: ResourceImpl[];
|
||||
/** Old exclusive resources closed at the gate. */
|
||||
readonly replaced: ResourceImpl[];
|
||||
readonly migrated: Map<Cell<unknown>, unknown>;
|
||||
/** Values of the run's derivations over the candidate (migrated) values, validated. */
|
||||
derived: Map<unknown, unknown>;
|
||||
/** Listener subscriptions made at the end of prepare, activated by the commit. */
|
||||
staged: StagedListeners | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the new derivation functions over the candidate values and validates every prospective value (migrated
|
||||
* cells and derivations) against every schema that will apply after the commit: the cell's new schema and the new
|
||||
* implementation's remote state-member schemas. A failure fails prepare; the commit then installs without re-checking.
|
||||
*/
|
||||
function dryRun(candidate: Candidate): void {
|
||||
const { run, migrated, service, object } = candidate;
|
||||
const exposed = memberSchemas(service, run.implementation);
|
||||
for (const [cell, value] of migrated) {
|
||||
for (const schema of exposed.get(cell) ?? [])
|
||||
assertValid("state", `${object.address}: migrated state ${cell.key}`, schema, value);
|
||||
}
|
||||
const values = new Map<unknown, unknown>(migrated);
|
||||
const derives = [...run.decls.derives].sort(([, a], [, b]) => a.seq - b.seq);
|
||||
for (const [name, derived] of derives) {
|
||||
const value = derived.compute((source) => (values.has(source) ? values.get(source) : candidateValue(source)));
|
||||
for (const schema of exposed.get(derived) ?? [])
|
||||
assertValid("state", `${object.address}: derivation ${name}`, schema, value);
|
||||
values.set(derived, value);
|
||||
}
|
||||
candidate.derived = values;
|
||||
}
|
||||
|
||||
/**
|
||||
* A source's value for a dry run. A reference to a provider this reload upgrades too reads nothing before the commit
|
||||
* (its new contract does not bind the old code): the dry run computes without it, the commit with the real value.
|
||||
*/
|
||||
function candidateValue(source: Readable<unknown>): unknown {
|
||||
try {
|
||||
return source.value;
|
||||
} catch (error) {
|
||||
if (isChordError(error, "not_ready") || isChordError(error, "not_in_variant")) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** A reload error keeps the underlying code (`contract_mismatch`, `invalid`, ...) and says it aborted the reload. */
|
||||
export function reloadError(error: unknown): ChordError {
|
||||
const cause = asChordError(error, "reload");
|
||||
if (/^reload aborted/.test(cause.message.replace(/^[^:]*: /, ""))) return cause;
|
||||
return new ChordError(cause.code, `reload aborted: ${cause.message}`, {
|
||||
cause,
|
||||
reason: cause.reason,
|
||||
details: cause.details,
|
||||
});
|
||||
}
|
||||
|
||||
/** Serialized: a second reload while one runs is rejected. */
|
||||
export async function runReload(
|
||||
host: HostImpl,
|
||||
plugins: readonly PluginRef[],
|
||||
options: ReloadOptions,
|
||||
): Promise<ReloadReport> {
|
||||
const reload = await beginReload(host, plugins, options);
|
||||
try {
|
||||
await reload.prepare();
|
||||
return reload.commit();
|
||||
} finally {
|
||||
endReload(host);
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes the host's reload slot and loads the plugin modules. Pair with `endReload`. */
|
||||
export async function beginReload(
|
||||
host: HostImpl,
|
||||
plugins: readonly PluginRef[],
|
||||
options: ReloadOptions,
|
||||
codeVersion?: number,
|
||||
): Promise<Reload> {
|
||||
if (host.reloading) {
|
||||
throw new ChordError("unavailable", "a reload is already running", { reason: "reload_running" });
|
||||
}
|
||||
host.reloading = true;
|
||||
try {
|
||||
return new Reload(host, await host.resolve(plugins), options, codeVersion);
|
||||
} catch (error) {
|
||||
host.reloadEnded();
|
||||
throw reloadError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function endReload(host: HostImpl): void {
|
||||
host.reloadEnded();
|
||||
}
|
||||
|
||||
/** Most milliseconds a forced retirement waits for aborted roots to settle before it closes what they used. */
|
||||
const ABORTED_ROOTS_GRACE = 1000;
|
||||
|
||||
/** Resolves true once `ready()` holds (checked whenever retirement-relevant state changes), false after `ms`. */
|
||||
function until(host: HostImpl, ready: () => boolean, ms: number): Promise<boolean> {
|
||||
if (ready()) return Promise.resolve(true);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
stop();
|
||||
resolve(false);
|
||||
}, ms);
|
||||
const stop = host.watchRetirement(() => {
|
||||
if (!ready()) return;
|
||||
clearTimeout(timer);
|
||||
stop();
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** One reload on one host: `prepare` (load, prepare, gate), then `commit` or `rollback`. */
|
||||
export class Reload {
|
||||
readonly host: HostImpl;
|
||||
readonly facets: readonly Facet[];
|
||||
readonly options: ReloadOptions;
|
||||
/** Code version id to commit (issued by the coordinating server); a local reload allocates one. */
|
||||
readonly codeVersion: number | undefined;
|
||||
readonly candidates = new Map<ObjectImpl, Candidate>();
|
||||
readonly abort = new AbortController();
|
||||
readonly closed: [ObjectImpl, ResourceImpl][] = [];
|
||||
services = new Map<string, Service>();
|
||||
changed = new Set<string>();
|
||||
removed = new Set<string>();
|
||||
tables = new Map<string, RegistryTable<unknown>>();
|
||||
gated: Candidate[] = [];
|
||||
rolledBack = false;
|
||||
/** Objects of types this reload adds, with their staged listeners: their runtime starts at commit. */
|
||||
readonly deferred = new Map<ObjectImpl, StagedListeners>();
|
||||
/** This reload's run of each facet it loads (`FacetApi.signal`): installed at commit, aborted at rollback. */
|
||||
readonly facetRuns = new Map<string, AbortController>();
|
||||
|
||||
constructor(host: HostImpl, facets: readonly Facet[], options: ReloadOptions, codeVersion?: number) {
|
||||
this.host = host;
|
||||
this.facets = facets;
|
||||
this.options = options;
|
||||
this.codeVersion = codeVersion;
|
||||
}
|
||||
|
||||
/** Everything up to the commit. On failure the host is rolled back and the underlying error is thrown. */
|
||||
async prepare(): Promise<void> {
|
||||
try {
|
||||
await this.waitForRetirement();
|
||||
this.load();
|
||||
this.checkProviders();
|
||||
await this.prepareAll();
|
||||
// From here new objects of changed types are rejected (upgrade_conflict, retryable) until commit or abort,
|
||||
// so the candidate set is final once the activations already in flight have been prepared.
|
||||
this.host.gating = this.changed;
|
||||
await this.withDeadline(this.prepareAll(), "activations in flight did not finish");
|
||||
this.checkRefs();
|
||||
this.tables = this.host.buildTables(this.entries());
|
||||
await this.gate();
|
||||
this.#alive();
|
||||
this.#stage();
|
||||
} catch (error) {
|
||||
await this.rollback();
|
||||
throw reloadError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** What a successful prepare will commit (a child sends it with its vote). */
|
||||
summary(): ReloadReport {
|
||||
const live = [...this.candidates.values()].filter(({ object }) => object.status === "live");
|
||||
return {
|
||||
codeVersion: this.host.pending?.id ?? this.host.current.id,
|
||||
previous: this.host.current.id,
|
||||
changed: [...this.changed],
|
||||
removed: [...this.removed],
|
||||
prepared: live.map(({ object }) => object.address),
|
||||
gated: this.gated.map(({ object }) => object.address),
|
||||
};
|
||||
}
|
||||
|
||||
/** After every await of prepare: a rollback meanwhile (abort from the coordinator) ends it. */
|
||||
#alive(): void {
|
||||
if (this.rolledBack) throw new ChordError("aborted", "reload aborted while preparing", { reason: "aborted" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes every new listener now, buffering its events, so a failing source fails prepare instead of leaving a
|
||||
* half-installed code version. Objects running only the new code (types this reload adds) are staged too.
|
||||
*/
|
||||
#stage(): void {
|
||||
for (const candidate of this.candidates.values()) {
|
||||
if (candidate.object.status === "live")
|
||||
candidate.staged = candidate.object.stageListeners(candidate.run.decls);
|
||||
}
|
||||
for (const object of this.host.objects.values()) {
|
||||
if (object.status === "live" && object.deferredRuntime)
|
||||
this.deferred.set(object, object.stageListeners(object.decls));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires the previous code version before loading the next one (a host keeps two). Waits until its roots ended and,
|
||||
* on a child host, its parent retired it too (`retire` frame), at most `retireDeadline`. Past the deadline it is
|
||||
* retired anyway: its remaining roots are aborted first and get a short grace to stop, so they see cancellation
|
||||
* before the resources only that code version used are closed; their next call is `superseded`.
|
||||
*/
|
||||
async waitForRetirement(): Promise<void> {
|
||||
const host = this.host;
|
||||
const previous = host.previous;
|
||||
if (previous === undefined) return;
|
||||
const deadline = this.options.retireDeadline ?? 5000;
|
||||
const gone = (): boolean => host.previous !== previous;
|
||||
if (!(await until(host, () => gone() || host.retirable(), deadline)) && !gone()) {
|
||||
previous.abort.abort(new ChordError("superseded", `code version ${previous.id} was retired`));
|
||||
await until(host, () => gone() || previous.roots.size === 0, Math.min(deadline, ABORTED_ROOTS_GRACE));
|
||||
}
|
||||
if (!gone()) host.retire();
|
||||
}
|
||||
|
||||
/** Builds the candidate service table: unchanged services plus everything the given facets serve. */
|
||||
load(): void {
|
||||
const replaced = new Set([...this.facets.map((facet) => facet.name), ...(this.options.remove ?? [])]);
|
||||
const current = this.host.current.services;
|
||||
this.services = new Map([...current].filter(([, service]) => !replaced.has(service.facet)));
|
||||
for (const facet of this.facets) {
|
||||
const run = new AbortController();
|
||||
this.facetRuns.set(facet.name, run);
|
||||
facet.setup(
|
||||
this.host.facetApi(
|
||||
((type: AnyContract, factory: Factory<AnyContract>, options: ServeOptions = {}) =>
|
||||
this.host.serveInto(this.services, makeService(type, factory, options, facet.name))) as Serve,
|
||||
run.signal,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const [name, service] of this.services) if (current.get(name) !== service) this.changed.add(name);
|
||||
for (const name of current.keys()) if (!this.services.has(name)) this.removed.add(name);
|
||||
this.host.pending = {
|
||||
id: this.host.nextCodeVersionId(this.codeVersion),
|
||||
services: this.services,
|
||||
tables: new Map(),
|
||||
roots: new Map(),
|
||||
abort: new AbortController(),
|
||||
};
|
||||
}
|
||||
|
||||
async withDeadline<T>(promise: Promise<T>, what: string): Promise<T> {
|
||||
const deadline = this.options.gateDeadline ?? 5000;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new ChordError("aborted", `${what} within ${deadline} ms`, { reason: "deadline" })),
|
||||
deadline,
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Same contract version must cover the old description (forgotten bump); facets' `uses` must bind. */
|
||||
checkProviders(): void {
|
||||
for (const name of this.changed) {
|
||||
const next = this.services.get(name)!.contract;
|
||||
const old = this.host.current.services.get(name)?.contract;
|
||||
if (old === undefined || old.version !== next.version) continue;
|
||||
const reason = coverage(next.description, old.description);
|
||||
if (reason !== undefined) {
|
||||
throw new ChordError(
|
||||
"contract_mismatch",
|
||||
`${name}@${next.version} changed incompatibly without a version bump (${reason})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const facets = new Map(this.host.facets);
|
||||
for (const facet of this.facets) facets.set(facet.name, facet);
|
||||
for (const name of this.options.remove ?? []) facets.delete(name);
|
||||
for (const facet of facets.values()) {
|
||||
for (const token of facet.uses ?? []) {
|
||||
const service = this.services.get(token.name);
|
||||
if (service !== undefined && this.changed.has(token.name))
|
||||
bind(service.contract, service.options.accepts, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares every live object of a changed type, including objects that activate meanwhile, to a fixpoint. Objects
|
||||
* already running the new code (types this reload adds) are skipped.
|
||||
*/
|
||||
async prepareAll(): Promise<void> {
|
||||
for (;;) {
|
||||
const todo = [...this.host.objects.values()].filter(
|
||||
(object) =>
|
||||
this.changed.has(object.typeName) &&
|
||||
!this.candidates.has(object) &&
|
||||
object.service !== this.services.get(object.typeName) &&
|
||||
(object.status === "live" || object.status === "activating"),
|
||||
);
|
||||
if (todo.length === 0) return;
|
||||
await Promise.allSettled(todo.map((object) => object.ready));
|
||||
this.#alive();
|
||||
for (const object of todo) {
|
||||
if (object.status === "live" && object.service !== this.services.get(object.typeName)) {
|
||||
await this.prepareObject(object);
|
||||
this.#alive();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the new factory in recording mode and opens what can be opened without the gate. */
|
||||
async prepareObject(object: ObjectImpl): Promise<void> {
|
||||
const service = this.services.get(object.typeName)!;
|
||||
const run = object.run(service, object.decls);
|
||||
const candidate: Candidate = {
|
||||
object,
|
||||
service,
|
||||
run,
|
||||
reasons: [],
|
||||
atGate: [],
|
||||
replaced: [],
|
||||
migrated: new Map(),
|
||||
derived: new Map(),
|
||||
staged: undefined,
|
||||
};
|
||||
this.candidates.set(object, candidate);
|
||||
// Each cell carries its own version: a raised one gates and migrates, a schema change without one is refused.
|
||||
for (const [name, cell] of run.decls.cells) {
|
||||
if (object.decls.cells.get(name) !== cell) continue;
|
||||
const from = object.decls.cellVersions.get(name) ?? 1;
|
||||
const to = run.decls.cellVersions.get(name) ?? 1;
|
||||
if (to < from)
|
||||
throw invalid("state", `${object.address}: version of state ${name} lowered from ${from} to ${to}`);
|
||||
if (to > from) candidate.reasons.push(`state ${name} version ${from} -> ${to}`);
|
||||
else if (!deepEqual(cell.schema, run.decls.cellSchemas.get(name)))
|
||||
throw invalid("state", `${object.address}: schema of state ${name} changed without a version bump`);
|
||||
}
|
||||
const deferred = new Set<ResourceImpl>();
|
||||
for (const resource of run.created.resources) {
|
||||
if (resource.exclusive || resource.deps.some((dep) => deferred.has(dep as ResourceImpl)))
|
||||
deferred.add(resource);
|
||||
}
|
||||
for (const resource of deferred) {
|
||||
candidate.atGate.push(resource);
|
||||
const old = object.decls.resources.get(resource.key);
|
||||
if (resource.exclusive && old?.exclusive && old.state === "open") candidate.replaced.push(old);
|
||||
}
|
||||
if (deferred.size > 0) candidate.reasons.push("exclusive resource replaced");
|
||||
await object.open(
|
||||
run.created.resources.filter((resource) => !deferred.has(resource)),
|
||||
run.created.refs,
|
||||
this.abort.signal,
|
||||
);
|
||||
await object.waitHydrated(run);
|
||||
// Cells this reload adds read their first value once the new resources are open (gated objects: at the gate);
|
||||
// the derivations over them are checked after.
|
||||
if (candidate.reasons.length === 0) {
|
||||
await object.initCells(run, this.abort.signal);
|
||||
dryRun(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every reference that survives into the new code version must bind to the new provider. Browser references don't block a
|
||||
* reload: after the commit they get `upgrade_required` instead.
|
||||
*/
|
||||
checkRefs(): void {
|
||||
const dropped = new Set<unknown>();
|
||||
for (const { object, run } of this.candidates.values()) {
|
||||
for (const ref of object.decls.refs.values())
|
||||
if (![...run.decls.refs.values()].includes(ref)) dropped.add(ref);
|
||||
}
|
||||
for (const object of this.host.objects.values()) {
|
||||
const service = this.services.get(object.typeName);
|
||||
if (service === undefined || !this.changed.has(object.typeName)) continue;
|
||||
for (const ref of object.refs) {
|
||||
// The host's bootstrap reference follows this provider at commit; it is not an old-code consumer.
|
||||
if (ref === this.host.hostObjects.get(object.typeName)) continue;
|
||||
if (!dropped.has(ref) && ref.owner !== "client")
|
||||
bind(service.contract, service.options.accepts, ref.contract);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries(): [ObjectImpl, Run["decls"]][] {
|
||||
return this.host
|
||||
.liveDecls(true)
|
||||
.filter(([object]) => !this.removed.has(object.typeName))
|
||||
.map(([object, decls]) => [object, this.candidates.get(object)?.run.decls ?? decls]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admission cut, wait for grandfathered roots (deadline), replace exclusive resources, migrate into private
|
||||
* copies. Old tasks of gated objects are stopped at the cut; listener events are queued until commit or abort.
|
||||
*/
|
||||
async gate(): Promise<void> {
|
||||
this.gated = [...this.candidates.values()].filter(
|
||||
(candidate) => candidate.reasons.length > 0 && candidate.object.status === "live",
|
||||
);
|
||||
const drained = this.gated.map(({ object }) => {
|
||||
const grandfathered = new Set(object.leases.keys());
|
||||
return new Promise<void>((resolve) => {
|
||||
object.gate = { grandfathered, queue: [], drained: resolve };
|
||||
object.stopTasks();
|
||||
if (grandfathered.size === 0) resolve();
|
||||
});
|
||||
});
|
||||
const deadline = this.options.gateDeadline ?? 5000;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = await Promise.race([
|
||||
Promise.all(drained).then(() => false),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), deadline);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
const blocking = this.gated.flatMap(({ object }) => [...(object.gate?.grandfathered ?? [])]);
|
||||
throw new ChordError(
|
||||
"aborted",
|
||||
`gate deadline (${deadline} ms) missed; blocking roots: ${blocking.join(", ")}`,
|
||||
{
|
||||
reason: "gate_deadline",
|
||||
details: { blocking },
|
||||
},
|
||||
);
|
||||
}
|
||||
this.#alive();
|
||||
for (const candidate of this.gated) {
|
||||
const { object } = candidate;
|
||||
for (const old of candidate.replaced) {
|
||||
// Owned by the rollback before closing: a failing close must still be restored.
|
||||
this.closed.push([object, old]);
|
||||
await old.close(object.openOrder);
|
||||
}
|
||||
await object.open(candidate.atGate, [], this.abort.signal);
|
||||
this.#alive();
|
||||
await object.initCells(candidate.run, this.abort.signal);
|
||||
this.#alive();
|
||||
}
|
||||
for (const candidate of this.gated) this.migrate(candidate);
|
||||
}
|
||||
|
||||
migrate(candidate: Candidate): void {
|
||||
const { object, run } = candidate;
|
||||
for (const [name, cell] of run.decls.cells) {
|
||||
if (object.decls.cells.get(name) !== cell) continue;
|
||||
const schema = run.decls.cellSchemas.get(name)!;
|
||||
const from = object.decls.cellVersions.get(name) ?? 1;
|
||||
const fn = run.decls.cellMigrations.get(name);
|
||||
let value: unknown;
|
||||
if ((run.decls.cellVersions.get(name) ?? 1) > from && fn !== undefined)
|
||||
value = fn(structuredClone(cell.value), from);
|
||||
else if (!deepEqual(cell.schema, schema)) value = cell.value;
|
||||
else continue;
|
||||
assertValid("state", `${object.address}: migrated state ${name}`, schema, value);
|
||||
candidate.migrated.set(cell, value);
|
||||
}
|
||||
dryRun(candidate);
|
||||
}
|
||||
|
||||
async rollback(): Promise<void> {
|
||||
if (this.rolledBack) return;
|
||||
this.rolledBack = true;
|
||||
this.abort.abort(new ChordError("aborted", "reload aborted"));
|
||||
for (const run of this.facetRuns.values()) run.abort(new ChordError("aborted", "reload aborted"));
|
||||
const report = (error: unknown): void => this.host.report(error);
|
||||
for (const candidate of this.candidates.values()) candidate.staged?.discard();
|
||||
for (const staged of this.deferred.values()) staged.discard();
|
||||
for (const { object, run } of this.candidates.values()) {
|
||||
object.pending.delete(run.decls);
|
||||
await object.unwind(run).catch(report);
|
||||
}
|
||||
// Replaced exclusive resources are restored; one that cannot be is marked unavailable, never silently closed.
|
||||
for (const [object, resource] of this.closed) {
|
||||
if (resource.state !== "closed") continue;
|
||||
await resource.open(new AbortController().signal, object.openOrder).catch((error: unknown) => {
|
||||
resource.markUnavailable();
|
||||
report(
|
||||
new ChordError("unavailable", `${object.address}: resource ${resource.key} could not be restored`, {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
for (const { object } of this.gated) {
|
||||
if (object.gate === undefined) continue;
|
||||
object.openGate();
|
||||
object.startTasks();
|
||||
}
|
||||
// Objects of types this reload would have added lose their code.
|
||||
const reason = new ChordError("gone", "reload aborted; type not served");
|
||||
for (const object of [...this.host.objects.values()]) {
|
||||
if (!this.host.current.services.has(object.typeName)) await object.dispose(true, reason).catch(report);
|
||||
}
|
||||
this.host.pending = undefined;
|
||||
this.host.gating = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the new code version in one synchronous step. Everything fallible ran in prepare (factories, opens,
|
||||
* migration and derivation checks against the prospective schemas, listener subscriptions), so this only installs.
|
||||
* Observers (state, shapes, registries, listener roots) are notified after the whole step. If an install still
|
||||
* throws, the code version is half-published: the host fails terminally and admits no more work.
|
||||
*/
|
||||
commit(beforeStart?: () => void): ReloadReport {
|
||||
try {
|
||||
return holdPublications(() => {
|
||||
const report = this.#publish();
|
||||
// Installed, not yet started: the coordinator queues its commit frames here, so nothing this code version
|
||||
// starts (tasks, host object activations, then the held listener/registry/state notifications) can reach a child
|
||||
// first.
|
||||
beforeStart?.();
|
||||
for (const start of this.#starts.splice(0)) start();
|
||||
return report;
|
||||
});
|
||||
} catch (error) {
|
||||
this.host.fail(error);
|
||||
throw reloadError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Work that may start new-code-version roots, run after installation (and after the coordinator's commit frames). */
|
||||
readonly #starts: (() => void)[] = [];
|
||||
|
||||
#publish(): ReloadReport {
|
||||
const host = this.host;
|
||||
const previous = host.current;
|
||||
const next: CodeVersionState = host.pending!;
|
||||
host.pending = undefined;
|
||||
host.codeVersions.set(next.id, next);
|
||||
host.previous = previous;
|
||||
host.current = next;
|
||||
const prepared: string[] = [];
|
||||
for (const object of [...host.objects.values()]) {
|
||||
if (this.removed.has(object.typeName)) continue;
|
||||
const candidate = this.candidates.get(object);
|
||||
if (candidate === undefined) {
|
||||
const installed = object.implementations.get(previous.id);
|
||||
if (installed !== undefined) object.implementations.set(next.id, { ...installed, codeVersion: next.id });
|
||||
continue;
|
||||
}
|
||||
if (object.status !== "live") continue;
|
||||
this.install(candidate, next.id);
|
||||
prepared.push(object.address);
|
||||
}
|
||||
for (const { object, run, staged } of this.candidates.values()) {
|
||||
if (object.status === "live") continue;
|
||||
staged?.discard();
|
||||
object.pending.delete(run.decls);
|
||||
object.unwind(run).catch((error: unknown) => host.report(error));
|
||||
}
|
||||
// Objects of types this reload adds start their runtime now.
|
||||
for (const [object, staged] of this.deferred) {
|
||||
if (object.status !== "live") {
|
||||
staged.discard();
|
||||
continue;
|
||||
}
|
||||
object.deferredRuntime = false;
|
||||
staged.activate();
|
||||
this.#starts.push(() => object.startTasks());
|
||||
}
|
||||
host.installTables(next, this.tables, true);
|
||||
const reason = new ChordError("gone", "type no longer served");
|
||||
for (const object of [...host.objects.values()]) {
|
||||
if (this.removed.has(object.typeName))
|
||||
object.dispose(true, reason).catch((error: unknown) => host.report(error));
|
||||
}
|
||||
for (const name of this.removed) host.hostObjects.delete(name);
|
||||
for (const facet of this.facets) host.facets.set(facet.name, facet);
|
||||
for (const name of this.options.remove ?? []) host.facets.delete(name);
|
||||
// The replaced and removed runs' signals abort after the install, like other observers.
|
||||
for (const [name, run] of this.facetRuns) this.#starts.push(() => host.facetRunChanged(name, run));
|
||||
for (const name of this.options.remove ?? []) this.#starts.push(() => host.facetRunChanged(name, undefined));
|
||||
host.gating = undefined;
|
||||
for (const [name, service] of this.services) {
|
||||
if (hostLifetime(service) && this.changed.has(name) && host.hostObjects.get(name)?.object?.status !== "live") {
|
||||
this.#starts.push(() => host.startHostObject(service));
|
||||
}
|
||||
// A type whose lifetime became "referenced" lives on only while someone else references its object.
|
||||
const pinned = host.hostObjects.get(name);
|
||||
if (!hostLifetime(service) && pinned !== undefined) {
|
||||
host.hostObjects.delete(name);
|
||||
this.#starts.push(() => pinned.release());
|
||||
}
|
||||
}
|
||||
return {
|
||||
codeVersion: next.id,
|
||||
previous: previous.id,
|
||||
changed: [...this.changed],
|
||||
removed: [...this.removed],
|
||||
prepared,
|
||||
gated: this.gated.map(({ object }) => object.address),
|
||||
};
|
||||
}
|
||||
|
||||
install(candidate: Candidate, codeVersion: number): void {
|
||||
const { object, run, service } = candidate;
|
||||
const gated = candidate.reasons.length > 0;
|
||||
object.pending.delete(run.decls);
|
||||
object.stopTasks();
|
||||
if (gated) {
|
||||
// Old derivations would compute over migrated values; the old implementation can no longer run anyway.
|
||||
const kept = new Set(run.decls.derives.values());
|
||||
for (const derived of object.decls.derives.values()) if (!kept.has(derived)) derived.detach();
|
||||
}
|
||||
object.decls = run.decls;
|
||||
object.service = service;
|
||||
// Kept resources close with the new code's `close`; replaced ones keep their own (closed at retirement).
|
||||
for (const [resource, declaration] of run.decls.adopted) resource.adopt(declaration);
|
||||
object.implementations.set(codeVersion, {
|
||||
codeVersion,
|
||||
service,
|
||||
implementation: run.implementation,
|
||||
decls: run.decls,
|
||||
});
|
||||
if (gated) {
|
||||
// Old code can never run over migrated state: drop the previous implementation now, not at retirement.
|
||||
object.minCodeVersion = codeVersion;
|
||||
for (const old of [...object.implementations.keys()])
|
||||
if (old !== codeVersion) this.host.retireImplementation(object, old);
|
||||
}
|
||||
// Schemas first, then the values validated against them in prepare.
|
||||
object.refreshSchemas();
|
||||
for (const [name, cell] of run.decls.cells) {
|
||||
const schema = run.decls.cellSchemas.get(name)!;
|
||||
if (candidate.migrated.has(cell)) cell.reset(candidate.migrated.get(cell), schema);
|
||||
else cell.schema = schema;
|
||||
}
|
||||
const created = new Set(run.created.derives);
|
||||
for (const derived of run.created.derives) derived.attach(candidate.derived);
|
||||
for (const derived of run.decls.derives.values()) if (!created.has(derived)) derived.refresh();
|
||||
candidate.staged?.activate();
|
||||
this.#starts.push(() => object.startTasks());
|
||||
object.openGate();
|
||||
const pinned = this.host.hostObjects.get(object.typeName);
|
||||
if (pinned?.object === object && pinned.contract.fingerprint !== service.contract.fingerprint) {
|
||||
const replacement = new RefImpl(object, service.contract, "host");
|
||||
// Acquire first: releasing the only reference must not dispose/reopen the surviving host object.
|
||||
object.addRef(replacement);
|
||||
this.host.hostObjects.set(object.typeName, replacement);
|
||||
pinned.release();
|
||||
}
|
||||
for (const ref of [...object.refs]) {
|
||||
// Browser references whose contract no longer binds are told to reload their code (they never block a reload).
|
||||
if (ref.owner === "client") {
|
||||
try {
|
||||
bind(service.contract, service.options.accepts, ref.contract);
|
||||
} catch (error) {
|
||||
ref.gone(
|
||||
new ChordError("upgrade_required", `${object.typeName} changed; reload the web facets`, {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
object.release(ref);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
ref.shapeChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,658 +0,0 @@
|
|||
import { applyImmutable, type Decoder, decoder } from "../delta/index.ts";
|
||||
import { HYDRATION, type Hydration } from "./cell.ts";
|
||||
import { type AnyContract, takesNoArgs } from "./contract.ts";
|
||||
import { ChordError, invalid } from "./errors.ts";
|
||||
import {
|
||||
CtxImpl,
|
||||
notifyStatus,
|
||||
REF_BASE,
|
||||
type RefCore,
|
||||
type RefOwner,
|
||||
raceSignal,
|
||||
refsByProxy,
|
||||
type StatusListener,
|
||||
} from "./object.ts";
|
||||
import { cleaned } from "./schema.ts";
|
||||
import type { Ctx, Readable, RefStatus, Shape, StateEvent } from "./types.ts";
|
||||
import { fromWireError, type RootRef, type WireError } from "./wire.ts";
|
||||
|
||||
const noop = (): void => {};
|
||||
|
||||
/** One state frame for a member, as the binding receives it (payload unpacked). */
|
||||
export interface StateIn {
|
||||
readonly as: "snap" | "reset" | "ops" | "error";
|
||||
readonly value?: unknown;
|
||||
readonly ops?: readonly unknown[];
|
||||
readonly error?: WireError;
|
||||
}
|
||||
|
||||
/** What a binding needs from the link it is bound to (implemented by Connection). */
|
||||
export interface RemoteChannel {
|
||||
/** Sends a hold for `binding`; `again`: the binding already has values (a re-hold), its unit is not urgent. */
|
||||
hold(binding: RemoteBinding, again: boolean): number;
|
||||
release(id: number): void;
|
||||
call(hold: number, member: string, args: unknown, root: RootRef | undefined, signal: AbortSignal): Promise<unknown>;
|
||||
/** Starts (`sub`) or stops (`unsub`) the state of a hold. */
|
||||
wantState(hold: number, wanted: boolean): void;
|
||||
}
|
||||
|
||||
/** What a binding needs from its host. */
|
||||
export interface RemoteHost {
|
||||
readonly inFactory: number;
|
||||
report(error: unknown): void;
|
||||
root<T>(fn: (ctx: Ctx) => T | Promise<T>, options?: { signal?: AbortSignal }): Promise<T>;
|
||||
/** The binding has no views left: the host lingers it or closes it. */
|
||||
bindingIdle(binding: RemoteBinding): void;
|
||||
/** The binding ended or was closed: the host forgets it. */
|
||||
forgetBinding(binding: RemoteBinding): void;
|
||||
}
|
||||
|
||||
type Settle = { resolve: () => void; reject: (error: unknown) => void };
|
||||
|
||||
function deferred(): { promise: Promise<void> } & Settle {
|
||||
let resolve = noop;
|
||||
let reject: (error: unknown) => void = noop;
|
||||
const promise = new Promise<void>((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
promise.catch(noop);
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
/** A member's value with the decoder of its current stream. */
|
||||
interface MemberState {
|
||||
value: unknown;
|
||||
decoder: Decoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* One hold per host and key (type, normalized input, principal key, contract fingerprint), shared by every reference to
|
||||
* it: one hold over the link, one stream per state member, calls over the same hold. References are views onto it.
|
||||
*
|
||||
* A hold implies the state of its variant (unless no view wants state): the provider answers with a unit, the shape
|
||||
* followed by a snapshot of each state member the binding lacks. A shape is applied only once all of its snapshots are
|
||||
* here, so a live binding always has every state member of its variant. After the first hydration the values stay
|
||||
* readable while the binding re-holds (link loss): status `loading`, last values kept.
|
||||
*/
|
||||
export class RemoteBinding {
|
||||
readonly host: RemoteHost;
|
||||
readonly key: string;
|
||||
readonly contract: AnyContract;
|
||||
readonly at: string;
|
||||
readonly input: unknown;
|
||||
/** Forwarded principal (omitted for the host's own principal). */
|
||||
readonly principal: unknown | undefined;
|
||||
/** `ended`: gone with `error`, or closed (no views, no linger). */
|
||||
status: "loading" | "live" | "ended" = "loading";
|
||||
error: unknown;
|
||||
/** The applied shape (kept while re-holding). */
|
||||
shape: Shape | undefined;
|
||||
/** Every state member of the applied shape has a value (stays true while re-holding). */
|
||||
hydrated = false;
|
||||
/** The current hold; undefined while resolving the route or after a link loss. */
|
||||
hold: { readonly channel: RemoteChannel; readonly id: number } | undefined;
|
||||
/** What the provider was told: state for this hold (`state: false` / `sub` / `unsub`). */
|
||||
stateOn = false;
|
||||
readonly views = new Set<RemoteRef>();
|
||||
readonly members = new Map<string, MemberState>();
|
||||
/** Lingering since (no views): set by the host. */
|
||||
lingerTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
readonly #listeners = new Map<string, Set<ViewReadable>>();
|
||||
/** A shape waiting for its snapshots. */
|
||||
#pending: { shape: Shape; readonly missing: Set<string>; readonly values: Map<string, MemberState> } | undefined;
|
||||
/** Resolved while a shape is applied for the current hold (calls wait for it). */
|
||||
#live = deferred();
|
||||
/** A shape was applied once (a re-hold says `again`). */
|
||||
#applied = false;
|
||||
/** Waiting for the first hydration (`whenHydrated`). */
|
||||
readonly #hydrationWaiters = new Set<Settle>();
|
||||
|
||||
constructor(
|
||||
host: RemoteHost,
|
||||
fields: { key: string; contract: AnyContract; at: string; input: unknown; principal: unknown | undefined },
|
||||
) {
|
||||
this.host = host;
|
||||
this.key = fields.key;
|
||||
this.contract = fields.contract;
|
||||
this.at = fields.at;
|
||||
this.input = fields.input;
|
||||
this.principal = fields.principal;
|
||||
}
|
||||
|
||||
get ended(): boolean {
|
||||
return this.status === "ended";
|
||||
}
|
||||
|
||||
/** Views that want state. */
|
||||
get stateViews(): number {
|
||||
let count = 0;
|
||||
for (const view of this.views) if (view.wantsState) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Whether the next hold should carry state: some view wants it, or nobody is left (lingering keeps it). */
|
||||
get wantsState(): boolean {
|
||||
return this.views.size === 0 ? this.stateOn : this.stateViews > 0;
|
||||
}
|
||||
|
||||
/** Whether a re-hold is `again` (the binding already has a shape). */
|
||||
get applied(): boolean {
|
||||
return this.#applied;
|
||||
}
|
||||
|
||||
// ─── views ───
|
||||
|
||||
addView(view: RemoteRef): void {
|
||||
this.views.add(view);
|
||||
if (this.lingerTimer !== undefined) {
|
||||
clearTimeout(this.lingerTimer);
|
||||
this.lingerTimer = undefined;
|
||||
}
|
||||
this.#syncState();
|
||||
view.update();
|
||||
}
|
||||
|
||||
removeView(view: RemoteRef): void {
|
||||
if (!this.views.delete(view)) return;
|
||||
for (const set of this.#listeners.values())
|
||||
for (const readable of set) if (readable.view === view) set.delete(readable);
|
||||
if (this.views.size === 0) {
|
||||
this.host.bindingIdle(this);
|
||||
return;
|
||||
}
|
||||
this.#syncState();
|
||||
}
|
||||
|
||||
/** Tells the provider to start or stop state when the views' wish changed. */
|
||||
#syncState(): void {
|
||||
if (this.ended || this.views.size === 0) return;
|
||||
const wanted = this.stateViews > 0;
|
||||
if (wanted === this.stateOn) return;
|
||||
this.stateOn = wanted;
|
||||
if (!wanted) {
|
||||
this.hydrated = false;
|
||||
this.members.clear();
|
||||
if (this.#pending !== undefined) this.#pending.missing.clear();
|
||||
}
|
||||
if (this.hold !== undefined) this.hold.channel.wantState(this.hold.id, wanted);
|
||||
for (const view of this.views) view.update();
|
||||
}
|
||||
|
||||
listen(name: string, readable: ViewReadable): void {
|
||||
let set = this.#listeners.get(name);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.#listeners.set(name, set);
|
||||
}
|
||||
set.add(readable);
|
||||
}
|
||||
|
||||
unlisten(name: string, readable: ViewReadable): void {
|
||||
this.#listeners.get(name)?.delete(readable);
|
||||
}
|
||||
|
||||
/** Whether `name` is a state member of the applied variant (as the consumer's contract describes it). */
|
||||
inVariant(name: string): boolean {
|
||||
const variant = this.shape?.variant;
|
||||
return variant !== undefined && this.contract.description.variants[variant]?.[name]?.kind === "state";
|
||||
}
|
||||
|
||||
/** The members' value as views read it: throws `not_ready` before the first hydration, `not_in_variant` outside it. */
|
||||
read(name: string): unknown {
|
||||
if (this.status === "ended") throw this.terminalError();
|
||||
if (!this.hydrated) throw new ChordError("not_ready", `${this.key}.${name} is not hydrated yet`);
|
||||
if (!this.inVariant(name))
|
||||
throw new ChordError(
|
||||
"not_in_variant",
|
||||
`${this.key}: ${name} is not a state member of variant ${this.shape?.variant}`,
|
||||
);
|
||||
return this.members.get(name)?.value;
|
||||
}
|
||||
|
||||
/** Resolves once the binding has its state (rejects if it ends first). */
|
||||
whenHydrated(): Promise<void> {
|
||||
if (this.hydrated) return Promise.resolve();
|
||||
if (this.ended) return Promise.reject(this.terminalError());
|
||||
return new Promise<void>((resolve, reject) => this.#hydrationWaiters.add({ resolve, reject }));
|
||||
}
|
||||
|
||||
#settleHydration(error?: unknown): void {
|
||||
const waiters = [...this.#hydrationWaiters];
|
||||
this.#hydrationWaiters.clear();
|
||||
for (const waiter of waiters) {
|
||||
if (error === undefined) waiter.resolve();
|
||||
else waiter.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
readable(name: string): boolean {
|
||||
return this.status !== "ended" && this.hydrated && this.inVariant(name);
|
||||
}
|
||||
|
||||
terminalError(): unknown {
|
||||
return this.error ?? new ChordError("released", `reference ${this.key} released`);
|
||||
}
|
||||
|
||||
// ─── driven by the host and the link ───
|
||||
|
||||
bound(channel: RemoteChannel, id: number): void {
|
||||
this.hold = { channel, id };
|
||||
this.stateOn = this.wantsState;
|
||||
}
|
||||
|
||||
/** A unit begins: `listed` are the members whose snapshots follow. */
|
||||
shaped(shape: Shape, listed: readonly string[]): void {
|
||||
if (this.ended) return;
|
||||
const previous = this.#pending;
|
||||
const missing = new Set<string>(listed);
|
||||
// A newer shape before the older one's snapshots (relays may reorder them): wait for both.
|
||||
if (previous !== undefined) for (const name of previous.missing) missing.add(name);
|
||||
const wanted = new Set(this.#stateMembers(shape));
|
||||
for (const name of [...missing]) if (!wanted.has(name) || !this.stateOn) missing.delete(name);
|
||||
this.#pending = { shape, missing, values: previous?.values ?? new Map() };
|
||||
if (missing.size === 0) this.#apply();
|
||||
}
|
||||
|
||||
/** A state frame for member `name` of the current hold. */
|
||||
state(name: string, frame: StateIn): void {
|
||||
if (this.ended) return;
|
||||
if (frame.as === "error") {
|
||||
const error = fromWireError(frame.error!);
|
||||
// Before the first hydration nobody could read anything: the binding fails instead of waiting forever.
|
||||
if (!this.hydrated && this.stateOn) {
|
||||
this.gone(error);
|
||||
this.close();
|
||||
} else this.host.report(error);
|
||||
return;
|
||||
}
|
||||
const pending = this.#pending;
|
||||
if (frame.as === "ops") {
|
||||
const target = pending?.values.get(name) ?? this.members.get(name);
|
||||
if (target === undefined) return;
|
||||
const ops = target.decoder.decode(frame.ops ?? []);
|
||||
target.value = applyImmutable(target.value, ops);
|
||||
if (target === this.members.get(name)) this.#deliver(name, { type: "ops", ops, value: target.value });
|
||||
return;
|
||||
}
|
||||
const fresh: MemberState = { value: frame.value, decoder: decoder() };
|
||||
if (pending !== undefined && (pending.missing.has(name) || pending.values.has(name))) {
|
||||
pending.values.set(name, fresh);
|
||||
pending.missing.delete(name);
|
||||
if (pending.missing.size === 0) this.#apply();
|
||||
return;
|
||||
}
|
||||
if (!this.inVariant(name)) return;
|
||||
this.members.set(name, fresh);
|
||||
this.#deliver(name, { type: "reset", value: fresh.value });
|
||||
}
|
||||
|
||||
/** The provider ended the object (or refused the hold): every view is gone. */
|
||||
gone(error: unknown): void {
|
||||
if (this.ended) return;
|
||||
this.status = "ended";
|
||||
this.error = error;
|
||||
this.hold = undefined;
|
||||
this.#pending = undefined;
|
||||
this.#live.reject(error);
|
||||
this.#settleHydration(error);
|
||||
for (const view of [...this.views]) view.end(error);
|
||||
this.#listeners.clear();
|
||||
this.host.forgetBinding(this);
|
||||
}
|
||||
|
||||
/** The link went down but may come back (upstream reconnect): keep the values, wait for the re-hold. */
|
||||
detached(): void {
|
||||
if (this.ended) return;
|
||||
this.hold = undefined;
|
||||
this.#pending = undefined;
|
||||
if (this.status === "live") {
|
||||
this.status = "loading";
|
||||
this.#live = deferred();
|
||||
}
|
||||
for (const view of this.views) view.update();
|
||||
}
|
||||
|
||||
/** No views and no linger: releases the hold and forgets the binding. */
|
||||
close(): void {
|
||||
if (this.lingerTimer !== undefined) clearTimeout(this.lingerTimer);
|
||||
this.lingerTimer = undefined;
|
||||
if (this.hold !== undefined) this.hold.channel.release(this.hold.id);
|
||||
this.hold = undefined;
|
||||
if (this.status !== "ended") {
|
||||
this.status = "ended";
|
||||
const error = new ChordError("released", `reference ${this.key} released`);
|
||||
this.#live.reject(error);
|
||||
this.#settleHydration(error);
|
||||
for (const view of [...this.views]) view.end(error);
|
||||
}
|
||||
this.#pending = undefined;
|
||||
this.#listeners.clear();
|
||||
this.host.forgetBinding(this);
|
||||
}
|
||||
|
||||
/** Calls `name` over the current hold, waiting for it while the binding (re)holds. */
|
||||
async call(name: string, args: unknown, ctx: Ctx): Promise<unknown> {
|
||||
for (;;) {
|
||||
if (this.ended) throw this.terminalError();
|
||||
if (this.status !== "live" || this.hold === undefined) await raceSignal(this.#live.promise, ctx.signal);
|
||||
const hold = this.hold;
|
||||
if (hold === undefined || this.status !== "live") continue;
|
||||
const variant = this.shape?.variant;
|
||||
const description = variant === undefined ? undefined : this.contract.description.variants[variant]?.[name];
|
||||
if (description?.kind !== "method") {
|
||||
throw new ChordError("not_in_variant", `${this.key}: ${name} is not a method of variant ${variant}`);
|
||||
}
|
||||
// Send only what the consumer's own schema knows (the additive rule's premise; see ObjectImpl.call).
|
||||
const sent = description.args === undefined ? undefined : cleaned(description.args, args);
|
||||
return hold.channel.call(
|
||||
hold.id,
|
||||
name,
|
||||
sent,
|
||||
{ rootId: ctx.rootId, codeVersion: ctx.codeVersion },
|
||||
ctx.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#stateMembers(shape: Shape): string[] {
|
||||
const members = shape.variant === undefined ? undefined : this.contract.description.variants[shape.variant];
|
||||
if (members === undefined) return [];
|
||||
return shape.members.filter((name) => members[name]?.kind === "state");
|
||||
}
|
||||
|
||||
/** Applies the pending shape with its snapshots: members outside the variant go, new values are announced. */
|
||||
#apply(): void {
|
||||
const pending = this.#pending;
|
||||
if (pending === undefined) return;
|
||||
this.#pending = undefined;
|
||||
this.shape = pending.shape;
|
||||
const wanted = new Set(this.#stateMembers(pending.shape));
|
||||
for (const name of [...this.members.keys()]) if (!wanted.has(name)) this.members.delete(name);
|
||||
for (const [name, state] of pending.values) if (wanted.has(name)) this.members.set(name, state);
|
||||
this.hydrated = this.stateOn && [...wanted].every((name) => this.members.has(name));
|
||||
if (this.hydrated) this.#settleHydration();
|
||||
this.status = "live";
|
||||
this.#applied = true;
|
||||
this.#live.resolve();
|
||||
for (const view of this.views) view.update();
|
||||
for (const [name, state] of pending.values) {
|
||||
if (wanted.has(name)) this.#deliver(name, { type: "reset", value: state.value });
|
||||
}
|
||||
for (const view of [...this.views]) view.shapeChanged();
|
||||
}
|
||||
|
||||
#deliver(name: string, event: StateEvent<unknown>): void {
|
||||
const readables = this.#listeners.get(name);
|
||||
if (readables === undefined) return;
|
||||
for (const readable of [...readables]) readable.deliver(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A state member of a remote reference: the binding's value. Reading throws `not_ready` before the first hydration and
|
||||
* `not_in_variant` outside the variant. A listener added before the hydration gets its snapshot then.
|
||||
*/
|
||||
class ViewReadable implements Readable<unknown> {
|
||||
readonly view: RemoteRef;
|
||||
readonly #name: string;
|
||||
readonly #values = new Set<(value: unknown) => void>();
|
||||
readonly #events = new Set<(event: StateEvent<unknown>) => void>();
|
||||
/** Event listeners that have not had their snapshot yet. */
|
||||
readonly #unseen = new Set<(event: StateEvent<unknown>) => void>();
|
||||
|
||||
constructor(view: RemoteRef, name: string) {
|
||||
this.view = view;
|
||||
this.#name = name;
|
||||
}
|
||||
|
||||
get value(): unknown {
|
||||
if (this.view.ended) throw this.view.terminalError();
|
||||
return this.view.binding.read(this.#name);
|
||||
}
|
||||
|
||||
/** Not hydrated before the binding's first snapshots (a local object forwarding this member waits for them). */
|
||||
get [HYDRATION](): Hydration | undefined {
|
||||
const binding = this.view.binding;
|
||||
if (binding.hydrated) return undefined;
|
||||
return { hydrated: false, whenHydrated: () => binding.whenHydrated() };
|
||||
}
|
||||
|
||||
subscribe(listener: (value: unknown) => void): () => void {
|
||||
this.#values.add(listener);
|
||||
this.#attach();
|
||||
if (this.#readable()) this.#safe(() => listener(this.view.binding.members.get(this.#name)?.value));
|
||||
return () => this.#remove(this.#values, listener);
|
||||
}
|
||||
|
||||
events(listener: (event: StateEvent<unknown>) => void): () => void {
|
||||
this.#events.add(listener);
|
||||
this.#attach();
|
||||
if (this.#readable()) {
|
||||
const value = this.view.binding.members.get(this.#name)?.value;
|
||||
this.#safe(() => listener({ type: "snap", value }));
|
||||
} else this.#unseen.add(listener);
|
||||
return () => {
|
||||
this.#unseen.delete(listener);
|
||||
this.#remove(this.#events, listener);
|
||||
};
|
||||
}
|
||||
|
||||
deliver(event: StateEvent<unknown>): void {
|
||||
for (const listener of [...this.#events]) {
|
||||
if (!this.#unseen.delete(listener)) this.#safe(() => listener(event));
|
||||
else if (event.type !== "ops") this.#safe(() => listener({ type: "snap", value: event.value }));
|
||||
}
|
||||
for (const listener of [...this.#values]) this.#safe(() => listener(event.value));
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.#values.clear();
|
||||
this.#events.clear();
|
||||
this.#unseen.clear();
|
||||
this.view.binding.unlisten(this.#name, this);
|
||||
}
|
||||
|
||||
#readable(): boolean {
|
||||
return !this.view.ended && this.view.binding.readable(this.#name);
|
||||
}
|
||||
|
||||
#attach(): void {
|
||||
if (!this.view.ended) this.view.binding.listen(this.#name, this);
|
||||
}
|
||||
|
||||
#remove<T>(set: Set<T>, listener: T): void {
|
||||
set.delete(listener);
|
||||
if (this.#values.size + this.#events.size === 0) this.view.binding.unlisten(this.#name, this);
|
||||
}
|
||||
|
||||
#safe(fn: () => void): void {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
this.view.binding.host.report(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to an object on another host: a view onto the host's shared binding for its key. Each view has its own
|
||||
* owner, status, observers and `release`; ending by the provider and re-holding belong to the binding.
|
||||
*/
|
||||
export class RemoteRef implements RefCore {
|
||||
readonly object = undefined;
|
||||
readonly binding: RemoteBinding;
|
||||
readonly owner: RefOwner;
|
||||
/** False: a method-only view (`state: false`), ready once live. */
|
||||
readonly wantsState: boolean;
|
||||
readonly proxy: object;
|
||||
readonly ready: Promise<void>;
|
||||
status: RefStatus = "loading";
|
||||
error: unknown;
|
||||
readonly shapeListeners = new Set<(shape: Shape) => void>();
|
||||
readonly #statusListeners = new Set<StatusListener>();
|
||||
readonly #readables = new Map<string, ViewReadable>();
|
||||
readonly #callers = new Map<string, (...given: unknown[]) => Promise<unknown>>();
|
||||
#settleReady: Settle | undefined;
|
||||
|
||||
constructor(binding: RemoteBinding, owner: RefOwner, wantsState: boolean) {
|
||||
this.binding = binding;
|
||||
this.owner = owner;
|
||||
this.wantsState = wantsState;
|
||||
const ready = deferred();
|
||||
this.ready = ready.promise;
|
||||
this.#settleReady = ready;
|
||||
this.proxy = new Proxy(this, {
|
||||
get: (target, property) => {
|
||||
if (REF_BASE.has(property)) {
|
||||
const value: unknown = Reflect.get(target, property, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
}
|
||||
if (typeof property !== "string" || property === "then") return undefined;
|
||||
return target.member(property);
|
||||
},
|
||||
});
|
||||
refsByProxy.set(this.proxy, this);
|
||||
}
|
||||
|
||||
get contract(): AnyContract {
|
||||
return this.binding.contract;
|
||||
}
|
||||
|
||||
get address(): string {
|
||||
return this.binding.key;
|
||||
}
|
||||
|
||||
get ended(): boolean {
|
||||
return this.status === "released" || this.status === "gone";
|
||||
}
|
||||
|
||||
get variant(): string | undefined {
|
||||
return this.binding.shape?.variant;
|
||||
}
|
||||
|
||||
get shape(): Shape | undefined {
|
||||
return this.binding.shape;
|
||||
}
|
||||
|
||||
onShape(listener: (shape: Shape) => void): () => void {
|
||||
this.shapeListeners.add(listener);
|
||||
return () => this.shapeListeners.delete(listener);
|
||||
}
|
||||
|
||||
onStatus(listener: StatusListener): () => void {
|
||||
this.#statusListeners.add(listener);
|
||||
return () => this.#statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
terminalError(): unknown {
|
||||
return this.status === "gone" ? this.error : new ChordError("released", `reference ${this.address} released`);
|
||||
}
|
||||
|
||||
release(): void {
|
||||
if (this.ended) return;
|
||||
this.status = "released";
|
||||
this.#notify();
|
||||
this.#settleReady?.reject(new ChordError("released", `reference ${this.address} released`));
|
||||
this.#settleReady = undefined;
|
||||
for (const readable of this.#readables.values()) readable.stop();
|
||||
this.binding.removeView(this);
|
||||
}
|
||||
|
||||
// ─── driven by the binding ───
|
||||
|
||||
/** Follows the binding: live once it has a shape (and, for a state view, its state) over a current hold. */
|
||||
update(): void {
|
||||
if (this.ended) return;
|
||||
const binding = this.binding;
|
||||
const live = binding.status === "live" && binding.hold !== undefined && (!this.wantsState || binding.hydrated);
|
||||
const next: RefStatus = live ? "live" : "loading";
|
||||
if (next === this.status) return;
|
||||
this.status = next;
|
||||
this.#notify();
|
||||
if (live) {
|
||||
this.#settleReady?.resolve();
|
||||
this.#settleReady = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
end(error: unknown): void {
|
||||
if (this.ended) return;
|
||||
const released = error instanceof ChordError && error.code === "released";
|
||||
this.status = released ? "released" : "gone";
|
||||
if (!released) this.error = error;
|
||||
this.#notify();
|
||||
this.#settleReady?.reject(error);
|
||||
this.#settleReady = undefined;
|
||||
for (const readable of this.#readables.values()) readable.stop();
|
||||
}
|
||||
|
||||
shapeChanged(): void {
|
||||
const shape = this.binding.shape;
|
||||
if (shape === undefined || this.ended) return;
|
||||
for (const listener of [...this.shapeListeners]) {
|
||||
try {
|
||||
listener(shape);
|
||||
} catch (error) {
|
||||
this.binding.host.report(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#notify(): void {
|
||||
notifyStatus(this.#statusListeners, this.status, this.error, (error) => this.binding.host.report(error));
|
||||
}
|
||||
|
||||
// ─── members ───
|
||||
|
||||
member(name: string): unknown {
|
||||
const description = Object.values(this.contract.description.variants)
|
||||
.map((members) => members[name])
|
||||
.find((member) => member !== undefined);
|
||||
if (description?.kind === "method") return this.#caller(name);
|
||||
if (description?.kind === "state") {
|
||||
let readable = this.#readables.get(name);
|
||||
if (readable === undefined) {
|
||||
readable = new ViewReadable(this, name);
|
||||
this.#readables.set(name, readable);
|
||||
}
|
||||
return readable;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
#caller(name: string): (...given: unknown[]) => Promise<unknown> {
|
||||
let caller = this.#callers.get(name);
|
||||
if (caller === undefined) {
|
||||
const noArgs = takesNoArgs(this.contract, name);
|
||||
caller = (...given) => {
|
||||
// A method without arguments is called as `ref.name(ctx)`.
|
||||
const [args, ctx] = (noArgs ? [undefined, given[0]] : given) as [unknown, Ctx | undefined];
|
||||
const host = this.binding.host;
|
||||
if (host.inFactory > 0)
|
||||
throw new ChordError("not_ready", `${this.address}.${name} called inside a factory`);
|
||||
// The same checks as a local reference's caller (RefImpl.caller and ObjectImpl.call): a ctx from another host
|
||||
// or one not made by Chord would carry a root this host does not own over its link.
|
||||
if (ctx !== undefined && !(ctx instanceof CtxImpl)) {
|
||||
return Promise.reject(invalid("foreign_ctx", "ctx must come from a Chord host"));
|
||||
}
|
||||
try {
|
||||
ctx?.check(host);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (ctx === undefined || ctx.holder) {
|
||||
return host.root((root) => this.#call(name, args, root), { signal: ctx?.signal });
|
||||
}
|
||||
return this.#call(name, args, ctx);
|
||||
};
|
||||
this.#callers.set(name, caller);
|
||||
}
|
||||
return caller;
|
||||
}
|
||||
|
||||
#call(name: string, args: unknown, ctx: Ctx): Promise<unknown> {
|
||||
if (this.ended) return Promise.reject(this.terminalError());
|
||||
return this.binding.call(name, args, ctx);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,382 +0,0 @@
|
|||
import { compile, type JsonSchema, type ValidationError, type Validator } from "@earendil-works/pi-schema";
|
||||
import { type ChordErrorReason, invalid } from "./errors.ts";
|
||||
|
||||
/** JSON Schema as data: pi-schema builders, or any JSON Schema object (TypeBox's included). */
|
||||
type Schema = { readonly [key: string]: unknown };
|
||||
|
||||
const asSchema = (schema: unknown): Schema =>
|
||||
schema !== null && typeof schema === "object" ? (schema as Schema) : schema === false ? { not: {} } : {};
|
||||
|
||||
/** Canonical JSON: sorted object keys, `undefined` members dropped. */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
||||
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
||||
const keys = Object.keys(value)
|
||||
.filter((key) => (value as Record<string, unknown>)[key] !== undefined)
|
||||
.sort();
|
||||
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
/** Non-cryptographic 64-bit hash (two FNV-1a lanes). Contracts are trusted, so collisions are not an attack surface. */
|
||||
export function hash64(text: string): string {
|
||||
let a = 0x811c9dc5;
|
||||
let b = 0x01000193 ^ 0x5bd1e995;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const code = text.charCodeAt(index);
|
||||
a = Math.imul(a ^ code, 0x01000193);
|
||||
b = Math.imul(b ^ code, 0x5bd1e995) ^ (b >>> 13);
|
||||
}
|
||||
return (a >>> 0).toString(16).padStart(8, "0") + (b >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
export function deepEqual(a: unknown, b: unknown): boolean {
|
||||
return a === b || canonicalJson(a) === canonicalJson(b);
|
||||
}
|
||||
|
||||
const validators = new WeakMap<object, Validator>();
|
||||
|
||||
/** The compiled validator of `schema`, compiled once per schema object; `format` is an annotation. */
|
||||
function validatorOf(schema: JsonSchema): Validator {
|
||||
if (typeof schema === "boolean") return compile(schema);
|
||||
let validator = validators.get(schema);
|
||||
if (validator === undefined) {
|
||||
validator = compile(schema);
|
||||
validators.set(schema, validator);
|
||||
}
|
||||
return validator;
|
||||
}
|
||||
|
||||
/** Whether `value` matches `schema`. */
|
||||
export function isValid(schema: JsonSchema, value: unknown): boolean {
|
||||
return validatorOf(schema).check(value);
|
||||
}
|
||||
|
||||
/** Throws `invalid` (with `reason`) and the first schema errors when `value` does not match. */
|
||||
export function assertValid(reason: ChordErrorReason, what: string, schema: JsonSchema, value: unknown): void {
|
||||
const validator = validatorOf(schema);
|
||||
if (validator.check(value)) return;
|
||||
const errors: ValidationError[] = validator.errors(value).slice(0, 3);
|
||||
const summary = errors.map((error) => `${error.path || "/"} ${error.message}`).join("; ");
|
||||
throw invalid(reason, `${what}: ${summary}`, errors);
|
||||
}
|
||||
|
||||
/** Applies defaults, strips unknown fields, validates. Returns a fresh value. */
|
||||
export function normalize(schema: JsonSchema, value: unknown, what: string): unknown {
|
||||
const normalized = cleanFrom(asSchema(schema), withDefaults(asSchema(schema), cloneData(value ?? {})));
|
||||
assertValid("input", what, schema, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** A copy of `value` with only what `schema` knows: unknown object fields dropped, recursively (TypeBox's `Clean`). */
|
||||
export function cleaned(schema: JsonSchema, value: unknown): unknown {
|
||||
return cleanFrom(asSchema(schema), cloneData(value));
|
||||
}
|
||||
|
||||
/** A deep copy of plain data: arrays, plain objects and bytes are copied, any other value is kept. */
|
||||
function cloneData(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(cloneData);
|
||||
if (value instanceof Uint8Array) return value.slice();
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) return value;
|
||||
const copy: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) copy[key] = cloneData(item);
|
||||
return copy;
|
||||
}
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Uint8Array);
|
||||
|
||||
const isSchemaObject = (value: unknown): value is Schema =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
/** The object an `allOf` of object schemas evaluates to (TypeBox's `Evaluate`), or undefined for any other `allOf`. */
|
||||
function mergedAllOf(schema: Schema): Schema | undefined {
|
||||
if (!Array.isArray(schema.allOf) || !schema.allOf.every((branch) => isSchemaObject(branch.properties)))
|
||||
return undefined;
|
||||
const properties: Record<string, unknown> = {};
|
||||
const required = new Set<string>();
|
||||
for (const branch of schema.allOf as Schema[]) {
|
||||
Object.assign(properties, branch.properties);
|
||||
for (const key of (branch.required as string[] | undefined) ?? []) required.add(key);
|
||||
}
|
||||
return {
|
||||
type: "object",
|
||||
properties,
|
||||
required: [...required],
|
||||
...(schema.unevaluatedProperties === undefined ? {} : { additionalProperties: schema.unevaluatedProperties }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The pattern and value schema of a record (`patternProperties` with one pattern). */
|
||||
function recordOf(schema: Schema): [RegExp, unknown] | undefined {
|
||||
if (!isSchemaObject(schema.patternProperties) || isSchemaObject(schema.properties)) return undefined;
|
||||
const [entry] = Object.entries(schema.patternProperties);
|
||||
return entry === undefined ? undefined : [new RegExp(entry[0], "u"), entry[1]];
|
||||
}
|
||||
|
||||
/** Whether `additional` (an object's `additionalProperties`) keeps an unknown field holding `value`. */
|
||||
const keepsUnknown = (additional: unknown, value: unknown): boolean =>
|
||||
additional === true || (isSchemaObject(additional) && isValid(additional, value));
|
||||
|
||||
// Mutates `value` (a fresh copy), as TypeBox's `Clean` does.
|
||||
function cleanFrom(schema: Schema, value: unknown): unknown {
|
||||
const merged = mergedAllOf(schema);
|
||||
if (merged !== undefined) return cleanFrom(merged, value);
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
for (const branch of schema.anyOf) {
|
||||
const candidate = cleanFrom(asSchema(branch), cloneData(value));
|
||||
if (isValid(asSchema(branch), candidate)) return candidate;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const prefix = Array.isArray(schema.items)
|
||||
? schema.items
|
||||
: Array.isArray(schema.prefixItems)
|
||||
? schema.prefixItems
|
||||
: undefined;
|
||||
if (prefix !== undefined) {
|
||||
const length = Math.min(value.length, prefix.length);
|
||||
for (let index = 0; index < length; index++) value[index] = cleanFrom(asSchema(prefix[index]), value[index]);
|
||||
return value.length > length ? value.slice(0, length) : value;
|
||||
}
|
||||
if (isSchemaObject(schema.items)) return value.map((item) => cleanFrom(schema.items as Schema, item));
|
||||
return value;
|
||||
}
|
||||
if (!isPlainObject(value)) return value;
|
||||
const record = recordOf(schema);
|
||||
const properties = isSchemaObject(schema.properties) ? schema.properties : undefined;
|
||||
// An object schema names its fields, a record its key pattern, a map (pi-schema's) its value schema.
|
||||
if (properties === undefined && record === undefined && !isSchemaObject(schema.additionalProperties)) return value;
|
||||
for (const key of Object.keys(value)) {
|
||||
const known = properties !== undefined && Object.hasOwn(properties, key) ? properties[key] : undefined;
|
||||
const matched = known ?? (record?.[0].test(key) ? record[1] : undefined);
|
||||
if (matched !== undefined) value[key] = cleanFrom(asSchema(matched), value[key]);
|
||||
else if (keepsUnknown(schema.additionalProperties, value[key]))
|
||||
value[key] = cleanFrom(asSchema(schema.additionalProperties), value[key]);
|
||||
else delete value[key];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Mutates `value` (a fresh copy), as TypeBox's `Default` does: a missing value takes its schema's default, a
|
||||
// missing optional field never does.
|
||||
function withDefaults(schema: Schema, value: unknown): unknown {
|
||||
const defaulted = value === undefined && "default" in schema ? cloneData(schema.default) : value;
|
||||
const merged = mergedAllOf(schema);
|
||||
if (merged !== undefined) return withDefaults(merged, defaulted);
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
for (const branch of schema.anyOf) {
|
||||
const candidate = withDefaults(asSchema(branch), cloneData(defaulted));
|
||||
if (isValid(asSchema(branch), candidate)) return candidate;
|
||||
}
|
||||
return defaulted;
|
||||
}
|
||||
if (Array.isArray(defaulted)) {
|
||||
const prefix = Array.isArray(schema.items)
|
||||
? schema.items
|
||||
: Array.isArray(schema.prefixItems)
|
||||
? schema.prefixItems
|
||||
: undefined;
|
||||
if (prefix !== undefined) {
|
||||
for (let index = 0; index < prefix.length; index++) {
|
||||
const item = withDefaults(asSchema(prefix[index]), defaulted[index]);
|
||||
if (index < defaulted.length || item !== undefined) defaulted[index] = item;
|
||||
}
|
||||
return defaulted;
|
||||
}
|
||||
if (isSchemaObject(schema.items)) return defaulted.map((item) => withDefaults(schema.items as Schema, item));
|
||||
return defaulted;
|
||||
}
|
||||
if (!isPlainObject(defaulted)) return defaulted;
|
||||
const properties = isSchemaObject(schema.properties) ? schema.properties : undefined;
|
||||
const record = recordOf(schema);
|
||||
if (properties !== undefined) {
|
||||
const required = (schema.required as string[] | undefined) ?? [];
|
||||
for (const [key, property] of Object.entries(properties)) {
|
||||
const item = withDefaults(asSchema(property), defaulted[key]);
|
||||
if (item === undefined && (!required.includes(key) || !("default" in asSchema(property)))) continue;
|
||||
defaulted[key] = item;
|
||||
}
|
||||
} else if (record !== undefined && isSchemaObject(record[1]) && "default" in record[1]) {
|
||||
for (const key of Object.keys(defaulted)) {
|
||||
if (record[0].test(key)) defaulted[key] = withDefaults(record[1], defaulted[key]);
|
||||
}
|
||||
}
|
||||
if (!isSchemaObject(schema.additionalProperties)) return defaulted;
|
||||
for (const key of Object.keys(defaulted)) {
|
||||
const known = properties !== undefined && Object.hasOwn(properties, key);
|
||||
if (!known && !record?.[0].test(key)) defaulted[key] = withDefaults(schema.additionalProperties, defaulted[key]);
|
||||
}
|
||||
return defaulted;
|
||||
}
|
||||
|
||||
/** Canonical identity of a normalized value: fields equal to their schema default are omitted. */
|
||||
export function canonicalIdentity(schema: JsonSchema, value: unknown): string {
|
||||
return canonicalJson(stripDefaults(asSchema(schema), value));
|
||||
}
|
||||
|
||||
function stripDefaults(schema: Schema, value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const items = schema.items;
|
||||
if (Array.isArray(items)) return value.map((item, index) => stripDefaults(asSchema(items[index]), item));
|
||||
return value.map((item) => stripDefaults(asSchema(items), item));
|
||||
}
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
const properties = asSchema(schema.properties);
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const property = asSchema(properties[key]);
|
||||
if ("default" in property && deepEqual(property.default, item)) continue;
|
||||
result[key] = stripDefaults(property, item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const CONSTRAINTS = [
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"format",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"multipleOf",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
] as const;
|
||||
|
||||
const typesOf = (schema: Schema): string[] | undefined =>
|
||||
typeof schema.type === "string" ? [schema.type] : Array.isArray(schema.type) ? (schema.type as string[]) : undefined;
|
||||
|
||||
/** Keywords `fits` reasons about. Any other validation keyword in the provider's schema forces exact equality. */
|
||||
const KNOWN: ReadonlySet<string> = new Set([
|
||||
...CONSTRAINTS,
|
||||
"type",
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"patternProperties",
|
||||
"items",
|
||||
"additionalItems",
|
||||
"const",
|
||||
"enum",
|
||||
"anyOf",
|
||||
"parameters",
|
||||
"returnType",
|
||||
]);
|
||||
|
||||
/** Annotations: no effect on validity. */
|
||||
const ANNOTATIONS: ReadonlySet<string> = new Set([
|
||||
"title",
|
||||
"description",
|
||||
"default",
|
||||
"examples",
|
||||
"$id",
|
||||
"$schema",
|
||||
"$comment",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"deprecated",
|
||||
]);
|
||||
|
||||
const withoutAnnotations = (schema: Schema): Schema =>
|
||||
Object.fromEntries(Object.entries(schema).filter(([key]) => !ANNOTATIONS.has(key)));
|
||||
|
||||
/**
|
||||
* Conservative structural subtyping: true only if every value valid under `sub` is valid under `sup`, where object
|
||||
* fields missing from `sub` but optional in `sup` count as fine (unknown fields are stripped at the provider, see
|
||||
* `RecordImpl.call`). A provider schema using a keyword this function does not model (`not`, `allOf`, `contains`,
|
||||
* `$ref`, ...) or an `additionalProperties` schema only fits an identical consumer schema.
|
||||
*/
|
||||
export function fits(subSchema: unknown, supSchema: unknown): boolean {
|
||||
const sub = withoutAnnotations(asSchema(subSchema));
|
||||
const sup = withoutAnnotations(asSchema(supSchema));
|
||||
if (Object.keys(sup).length === 0) return true;
|
||||
if (deepEqual(sub, sup)) return true;
|
||||
if (Object.keys(sup).some((key) => !KNOWN.has(key))) return false;
|
||||
if (sup.additionalProperties !== undefined && typeof sup.additionalProperties !== "boolean") return false;
|
||||
if (Array.isArray(sub.anyOf)) return sub.anyOf.every((branch) => fits(branch, sup));
|
||||
if (Array.isArray(sup.anyOf)) return sup.anyOf.some((branch) => fits(sub, branch));
|
||||
if ("const" in sup) return "const" in sub && deepEqual(sub.const, sup.const);
|
||||
if (Array.isArray(sup.enum)) {
|
||||
const values = "const" in sub ? [sub.const] : Array.isArray(sub.enum) ? sub.enum : undefined;
|
||||
return values?.every((item) => (sup.enum as unknown[]).some((allowed) => deepEqual(allowed, item))) ?? false;
|
||||
}
|
||||
for (const key of CONSTRAINTS) if (key in sup && !deepEqual(sub[key], sup[key])) return false;
|
||||
const supTypes = typesOf(sup);
|
||||
const subTypes = typesOf(sub);
|
||||
if (supTypes === undefined || subTypes === undefined) return false;
|
||||
if (!subTypes.every((type) => supTypes.includes(type) || (type === "integer" && supTypes.includes("number")))) {
|
||||
return false;
|
||||
}
|
||||
if ("const" in sub || Array.isArray(sub.enum)) return true;
|
||||
if (subTypes.includes("object") && !objectFits(sub, sup)) return false;
|
||||
if (subTypes.includes("array") && !arrayFits(sub, sup)) return false;
|
||||
if (subTypes.includes("function")) return functionFits(sub, sup);
|
||||
return true;
|
||||
}
|
||||
|
||||
function objectFits(sub: Schema, sup: Schema): boolean {
|
||||
const subProperties = asSchema(sub.properties);
|
||||
const supProperties = asSchema(sup.properties);
|
||||
const subRequired = new Set((sub.required as string[] | undefined) ?? []);
|
||||
for (const key of (sup.required as string[] | undefined) ?? []) if (!subRequired.has(key)) return false;
|
||||
for (const [key, property] of Object.entries(supProperties)) {
|
||||
if (key in subProperties) {
|
||||
if (!fits(subProperties[key], property)) return false;
|
||||
} else if (typeof sub.additionalProperties === "object" && !fits(sub.additionalProperties, property)) {
|
||||
// The consumer may send this name with any value its `additionalProperties` allows. Open consumer objects
|
||||
// are fine: unknown names are stripped with the consumer's own schema before the call (`RecordImpl.call`).
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (sup.additionalProperties === false) {
|
||||
if (sub.additionalProperties !== false) return false;
|
||||
if (Object.keys(subProperties).some((key) => !(key in supProperties))) return false;
|
||||
}
|
||||
const supPatterns = asSchema(sup.patternProperties);
|
||||
if (Object.keys(supPatterns).length > 0) {
|
||||
// Every value the consumer may put under a matching name must fit, and the consumer must not allow arbitrary
|
||||
// extra names (it would then allow any value under a matching one).
|
||||
const subPatterns = asSchema(sub.patternProperties);
|
||||
if (sub.additionalProperties !== false && Object.keys(subPatterns).length === 0) return false;
|
||||
for (const [pattern, property] of Object.entries(supPatterns)) {
|
||||
const matcher = new RegExp(pattern, "u");
|
||||
for (const [key, subProperty] of Object.entries(subProperties)) {
|
||||
if (!(key in supProperties) && matcher.test(key) && !fits(subProperty, property)) return false;
|
||||
}
|
||||
if (
|
||||
Object.keys(subPatterns).length > 0 &&
|
||||
(!(pattern in subPatterns) || !fits(subPatterns[pattern], property))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const pattern of Object.keys(subPatterns)) if (!(pattern in supPatterns)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function arrayFits(sub: Schema, sup: Schema): boolean {
|
||||
if (Array.isArray(sup.items)) {
|
||||
if (!Array.isArray(sub.items) || sub.items.length !== sup.items.length) return false;
|
||||
return sub.items.every((item, index) => fits(item, (sup.items as unknown[])[index]));
|
||||
}
|
||||
if (Array.isArray(sub.items)) return sub.items.every((item) => fits(item, sup.items));
|
||||
return fits(sub.items, sup.items);
|
||||
}
|
||||
|
||||
function functionFits(sub: Schema, sup: Schema): boolean {
|
||||
const subParameters = (sub.parameters as unknown[] | undefined) ?? [];
|
||||
const supParameters = (sup.parameters as unknown[] | undefined) ?? [];
|
||||
// A function accepting fewer parameters is fine; each shared parameter is contravariant.
|
||||
if (subParameters.length > supParameters.length) return false;
|
||||
if (!subParameters.every((parameter, index) => fits(supParameters[index], parameter))) return false;
|
||||
return fits(sub.returnType, sup.returnType);
|
||||
}
|
||||
|
|
@ -1,423 +0,0 @@
|
|||
/**
|
||||
* Replicated state: an immutable JSON value with ordered, serialized change notifications. Either authoritative and
|
||||
* mutable (`replicatedState(initial)`) or a publication-only view of an external revision source
|
||||
* (`replicatedState(source)`).
|
||||
*/
|
||||
import { BACKGROUND_CONTEXT, type Context } from "../context/index.ts";
|
||||
import { type Draft, type Op, type Prepared, type Tracker, track } from "../delta/index.ts";
|
||||
|
||||
export interface ReplicatedStateDelivery {
|
||||
readonly kind: "hydrate" | "update";
|
||||
readonly sequence: number;
|
||||
}
|
||||
|
||||
export interface ReplicatedState<T> {
|
||||
/**
|
||||
* Contract-immutable value, or undefined until hydration. It is not frozen and may share containers with an
|
||||
* in-process provider; consumers must not mutate it. Later updates do not mutate previously returned values.
|
||||
*/
|
||||
readonly value: T | undefined;
|
||||
/**
|
||||
* Each subscription serializes callbacks, awaiting hydration before updates. Values are immutable and may
|
||||
* share unchanged revision data. At most 100 deliveries wait behind the running callback; overflow keeps
|
||||
* only the newest pending value/context/delivery, so update sequences may skip. Failures are reported in
|
||||
* isolation and delivery continues. Unsubscribe discards pending work without aborting or joining a callback.
|
||||
*/
|
||||
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => Promise<void>): () => void;
|
||||
subscribe(listener: (value: T, context: Context, delivery: ReplicatedStateDelivery) => void): () => void;
|
||||
}
|
||||
|
||||
export interface MutableReplicatedState<T extends object> extends ReplicatedState<T> {
|
||||
readonly value: T;
|
||||
/**
|
||||
* Atomically publish one synchronous overlay mutation. Draft handles are unusable after the callback returns.
|
||||
* Values placed through the draft are cloned by value; assigning `undefined` to an object property deletes it.
|
||||
*/
|
||||
change(context: Context, mutate: (draft: Draft<T>) => void): void;
|
||||
/**
|
||||
* Atomically take immutable ownership of an alias-free strict-JSON replacement.
|
||||
* The caller must not mutate the transferred root after this call.
|
||||
*/
|
||||
replace(context: Context, value: T): void;
|
||||
}
|
||||
|
||||
/** One immutable authoritative revision committed after an attachment snapshot. */
|
||||
export interface ReplicatedStateSourceFrame<T> {
|
||||
/** Monotonic source cursor. The first frame after a snapshot must be `snapshot.cursor + 1`. */
|
||||
readonly cursor: number;
|
||||
/** The exact immutable value produced by this commit. */
|
||||
readonly value: T;
|
||||
/** The exact immutable operation batch that produced `value` from the preceding source revision. */
|
||||
readonly ops: readonly Op[];
|
||||
readonly context: Context;
|
||||
}
|
||||
|
||||
export interface ReplicatedStateSourceAttachment<T> {
|
||||
/** Fixed immutable snapshot captured at the atomic attachment boundary. */
|
||||
readonly snapshot: { readonly value: T; readonly cursor: number };
|
||||
/**
|
||||
* Install the sole listener and synchronously drain every buffered frame in source commit order.
|
||||
* This method is single-use. After it begins, every new committed frame must also be delivered in order
|
||||
* until disposal, including commits made reentrantly while a prior frame is being delivered.
|
||||
*/
|
||||
activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void;
|
||||
/** Stop delivery and release source resources. Disposal must be idempotent. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An authoritative immutable revision source.
|
||||
*
|
||||
* `attach()` must synchronously and atomically capture one snapshot and register the returned attachment to buffer
|
||||
* every later committed frame. The snapshot must include every commit before that boundary; buffered frames must
|
||||
* include every commit after it, with no overlap or gap. Snapshot values, frame values, and operation batches are
|
||||
* immutable and remain valid after delivery. Chord only publishes these references; it never applies or re-diffs them.
|
||||
*/
|
||||
export interface ReplicatedStateSource<T> {
|
||||
attach(): ReplicatedStateSourceAttachment<T>;
|
||||
}
|
||||
|
||||
export interface ReplicatedStateSourceOptions {
|
||||
/** Receives source-contract and publication-listener failures without throwing them into the source. */
|
||||
readonly onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/** A synchronously hydrated publication-only state backed by one source attachment. */
|
||||
export interface AttachedReplicatedState<T> extends ReplicatedState<T> {
|
||||
readonly value: T;
|
||||
/** Idempotently release the source attachment. The last published value remains readable. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** The void signature also accepts synchronous callbacks that return an ignored value. */
|
||||
type StateListener<T> = (value: T, context: Context, delivery: ReplicatedStateDelivery) => void;
|
||||
|
||||
type StateDelivery<T> = {
|
||||
readonly value: T;
|
||||
readonly context: Context;
|
||||
readonly delivery: ReplicatedStateDelivery;
|
||||
};
|
||||
|
||||
/** One public subscription, independent of producer and other subscriber progress. */
|
||||
class StateSubscriber<T> {
|
||||
readonly #listener: StateListener<T>;
|
||||
readonly #reportError: (error: Error) => void;
|
||||
readonly #pending: StateDelivery<T>[] = [];
|
||||
#running = false;
|
||||
#started = false;
|
||||
#closed = false;
|
||||
|
||||
constructor(listener: StateListener<T>, reportError: (error: Error) => void) {
|
||||
this.#listener = listener;
|
||||
this.#reportError = reportError;
|
||||
}
|
||||
|
||||
push(frame: StateDelivery<T>): void {
|
||||
if (this.#closed) return;
|
||||
if (this.#pending.length === 100) {
|
||||
// A cold replica can queue updates reentrantly before this subscriber's first hydration starts.
|
||||
const hydration = this.#started ? undefined : this.#pending[0];
|
||||
this.#pending.length = 0;
|
||||
if (hydration !== undefined) this.#pending.push(hydration);
|
||||
}
|
||||
this.#pending.push(frame);
|
||||
}
|
||||
|
||||
drain(): void {
|
||||
if (this.#running || this.#closed) return;
|
||||
this.#running = true;
|
||||
for (let frame = this.#pending.shift(); frame !== undefined; frame = this.#pending.shift()) {
|
||||
this.#started = true;
|
||||
try {
|
||||
const result: unknown = this.#listener(frame.value, frame.context, frame.delivery);
|
||||
if (isPromiseLike(result)) {
|
||||
void Promise.resolve(result).then(
|
||||
() => this.#resume(),
|
||||
(error: unknown) => {
|
||||
this.#report(error);
|
||||
this.#resume();
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
this.#report(error);
|
||||
}
|
||||
}
|
||||
this.#running = false;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#pending.length = 0;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#closed = true;
|
||||
this.clear();
|
||||
}
|
||||
|
||||
#resume(): void {
|
||||
this.#running = false;
|
||||
this.drain();
|
||||
}
|
||||
|
||||
#report(error: unknown): void {
|
||||
try {
|
||||
this.#reportError(toError(error));
|
||||
} catch (reportError) {
|
||||
reportErrorAsync(toError(reportError));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Publication<T> = {
|
||||
readonly value: T;
|
||||
readonly sequence: number;
|
||||
readonly context: Context;
|
||||
};
|
||||
|
||||
/** Maintains local publication order independently of how revisions are produced. */
|
||||
class ReplicatedStatePublisher<T> {
|
||||
readonly #listeners = new Map<StateSubscriber<T>, number>();
|
||||
readonly #reportError: (error: Error) => void;
|
||||
readonly #publications: Publication<T>[] = [];
|
||||
#value: T;
|
||||
#sequence = 0;
|
||||
#delivering = false;
|
||||
|
||||
constructor(initial: T, reportError: (error: Error) => void = reportErrorAsync) {
|
||||
this.#value = initial;
|
||||
this.#reportError = reportError;
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
snapshot(): { readonly value: T; readonly sequence: number } {
|
||||
return { value: this.#value, sequence: this.#sequence };
|
||||
}
|
||||
|
||||
subscribe(listener: StateListener<T>): () => void {
|
||||
const { value, sequence } = this.snapshot();
|
||||
const subscriber = new StateSubscriber(listener, this.#reportError);
|
||||
this.#listeners.set(subscriber, sequence);
|
||||
// Hydration has no caller; it is delivered with the background context.
|
||||
subscriber.push({ value, context: BACKGROUND_CONTEXT, delivery: { kind: "hydrate", sequence } });
|
||||
subscriber.drain();
|
||||
return () => {
|
||||
subscriber.close();
|
||||
this.#listeners.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
/** Publish an already-prepared immutable revision. Subscriber failures are reported, never thrown. */
|
||||
publish(value: T, context: Context): void {
|
||||
this.#value = value;
|
||||
this.#sequence += 1;
|
||||
this.#publications.push({ value, sequence: this.#sequence, context });
|
||||
if (this.#delivering) return;
|
||||
|
||||
this.#delivering = true;
|
||||
try {
|
||||
for (
|
||||
let publication = this.#publications.shift();
|
||||
publication !== undefined;
|
||||
publication = this.#publications.shift()
|
||||
) {
|
||||
const delivery = { kind: "update", sequence: publication.sequence } as const;
|
||||
for (const [subscriber, hydratedSequence] of [...this.#listeners]) {
|
||||
if (publication.sequence <= hydratedSequence) continue;
|
||||
subscriber.push({ value: publication.value, context: publication.context, delivery });
|
||||
subscriber.drain();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.#delivering = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MutableReplicatedStateImpl<T extends object> implements MutableReplicatedState<T> {
|
||||
readonly #tracker: Tracker<T>;
|
||||
readonly #publisher: ReplicatedStatePublisher<T>;
|
||||
#changing = false;
|
||||
|
||||
constructor(initial: T) {
|
||||
this.#tracker = track(initial);
|
||||
this.#publisher = new ReplicatedStatePublisher(this.#tracker.value);
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#tracker.value;
|
||||
}
|
||||
|
||||
change(context: Context, mutate: Parameters<MutableReplicatedState<T>["change"]>[1]): void {
|
||||
if (this.#changing) throw new Error("Replicated state cannot be changed reentrantly from a change callback");
|
||||
this.#changing = true;
|
||||
let prepared: Prepared<T>;
|
||||
try {
|
||||
const change = this.#tracker.beginChange();
|
||||
try {
|
||||
const outcome = (mutate as (draft: typeof change.state) => unknown)(change.state);
|
||||
if (isPromiseLike(outcome)) {
|
||||
void Promise.resolve(outcome).catch(() => undefined);
|
||||
throw new TypeError("Replicated state change callbacks must be synchronous");
|
||||
}
|
||||
prepared = change.prepare();
|
||||
} catch (error) {
|
||||
change.abort();
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
this.#changing = false;
|
||||
}
|
||||
|
||||
this.#tracker.adopt(prepared);
|
||||
if (prepared.ops.length > 0) this.#publisher.publish(prepared.value, context);
|
||||
}
|
||||
|
||||
replace(context: Context, value: T): void {
|
||||
if (this.#changing) throw new Error("Replicated state cannot be replaced from a change callback");
|
||||
const prepared = this.#tracker.prepareReplace(value);
|
||||
this.#tracker.adopt(prepared);
|
||||
if (prepared.ops.length > 0) this.#publisher.publish(prepared.value, context);
|
||||
}
|
||||
|
||||
subscribe(listener: StateListener<T>): () => void {
|
||||
return this.#publisher.subscribe(listener);
|
||||
}
|
||||
}
|
||||
|
||||
class AttachedReplicatedStateImpl<T> implements AttachedReplicatedState<T> {
|
||||
readonly #publisher: ReplicatedStatePublisher<T>;
|
||||
readonly #attachment: ReplicatedStateSourceAttachment<T>;
|
||||
readonly #reportError: (error: Error) => void;
|
||||
#cursor: number;
|
||||
#disposed = false;
|
||||
|
||||
constructor(attachment: ReplicatedStateSourceAttachment<T>, options: ReplicatedStateSourceOptions) {
|
||||
const { snapshot } = attachment;
|
||||
assertCursor(snapshot.cursor, "snapshot");
|
||||
this.#attachment = attachment;
|
||||
this.#cursor = snapshot.cursor;
|
||||
this.#reportError = options.onError ?? reportErrorAsync;
|
||||
this.#publisher = new ReplicatedStatePublisher(snapshot.value, (error) => this.#report(error));
|
||||
}
|
||||
|
||||
get value(): T {
|
||||
return this.#publisher.value;
|
||||
}
|
||||
|
||||
subscribe(listener: StateListener<T>): () => void {
|
||||
return this.#publisher.subscribe(listener);
|
||||
}
|
||||
|
||||
activate(): void {
|
||||
this.#attachment.activate((frame) => this.#receive(frame));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#attachment.dispose();
|
||||
}
|
||||
|
||||
#receive(frame: ReplicatedStateSourceFrame<T>): void {
|
||||
if (this.#disposed) return;
|
||||
try {
|
||||
assertCursor(frame.cursor, "frame");
|
||||
const expected = this.#cursor + 1;
|
||||
if (frame.cursor !== expected) {
|
||||
throw new Error(`Replicated state source cursor has a gap: expected ${expected}, received ${frame.cursor}`);
|
||||
}
|
||||
this.#cursor = frame.cursor;
|
||||
this.#publisher.publish(frame.value, frame.context);
|
||||
} catch (error) {
|
||||
this.#fail(toError(error));
|
||||
}
|
||||
}
|
||||
|
||||
#fail(error: Error): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
try {
|
||||
this.#attachment.dispose();
|
||||
} catch (disposeError) {
|
||||
this.#report(new AggregateError([error, disposeError], "Replicated state source contract failed"));
|
||||
return;
|
||||
}
|
||||
this.#report(error);
|
||||
}
|
||||
|
||||
#report(error: unknown): void {
|
||||
try {
|
||||
this.#reportError(toError(error));
|
||||
} catch (reportError) {
|
||||
reportErrorAsync(toError(reportError));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach a publication-only replicated state to one authoritative immutable source stream. */
|
||||
function attachReplicatedStateSource<T>(
|
||||
source: ReplicatedStateSource<T>,
|
||||
options: ReplicatedStateSourceOptions = {},
|
||||
): AttachedReplicatedState<T> {
|
||||
const attachment = source.attach();
|
||||
try {
|
||||
const state = new AttachedReplicatedStateImpl(attachment, options);
|
||||
state.activate();
|
||||
return state;
|
||||
} catch (error) {
|
||||
try {
|
||||
attachment.dispose();
|
||||
} catch (disposeError) {
|
||||
throw new AggregateError([error, disposeError], "Failed to attach replicated state source");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertCursor(cursor: number, kind: "snapshot" | "frame"): void {
|
||||
if (!Number.isSafeInteger(cursor))
|
||||
throw new TypeError(`Replicated state source ${kind} cursor must be a safe integer`);
|
||||
}
|
||||
|
||||
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
||||
return (
|
||||
((typeof value === "object" && value !== null) || typeof value === "function") &&
|
||||
typeof (value as { then?: unknown }).then === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function reportErrorAsync(error: Error): void {
|
||||
queueMicrotask(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
export function replicatedState<T>(
|
||||
source: ReplicatedStateSource<T>,
|
||||
options?: ReplicatedStateSourceOptions,
|
||||
): AttachedReplicatedState<T>;
|
||||
/**
|
||||
* Create authoritative state by taking immutable ownership of an alias-free strict-JSON root.
|
||||
* The caller must not mutate `initial` after this call.
|
||||
*/
|
||||
export function replicatedState<T extends object>(initial: T): MutableReplicatedState<T>;
|
||||
export function replicatedState(
|
||||
initialOrSource: object | ReplicatedStateSource<unknown>,
|
||||
options?: ReplicatedStateSourceOptions,
|
||||
): MutableReplicatedState<object> | AttachedReplicatedState<unknown> {
|
||||
if (isReplicatedStateSource(initialOrSource)) return attachReplicatedStateSource(initialOrSource, options);
|
||||
return new MutableReplicatedStateImpl(initialOrSource);
|
||||
}
|
||||
|
||||
function isReplicatedStateSource(value: object): value is ReplicatedStateSource<unknown> {
|
||||
return typeof (value as { readonly attach?: unknown }).attach === "function";
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import type { Op } from "../delta/index.ts";
|
||||
import type { AnyContract, InputOf, Ref } from "./contract.ts";
|
||||
|
||||
/**
|
||||
* Options of `get`. `P` is the app's principal type: a JSON-serializable identity of a caller, mapped to an object key
|
||||
* by `createHost({ principalKey })`.
|
||||
*/
|
||||
export interface GetOptions<P = unknown> {
|
||||
/** Principal the object is created for. Defaults to the host's own principal (`createHost({ self })`). */
|
||||
readonly principal?: P;
|
||||
/**
|
||||
* False: a method-only reference. It receives no state (unless another reference to the same object wants it), and
|
||||
* is ready once its object is live.
|
||||
*/
|
||||
readonly state?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The explicit call context of one root (a short Chord call tree). Calls made with it carry its root id and code
|
||||
* version; `get` references are released when the call that received this ctx settles.
|
||||
*/
|
||||
export interface Ctx<P = unknown> {
|
||||
readonly signal: AbortSignal;
|
||||
readonly rootId: string;
|
||||
readonly codeVersion: number;
|
||||
/** Who the root acts as: the caller at ingress, the host's own principal inside objects. */
|
||||
readonly principal: P;
|
||||
/**
|
||||
* A reference for this call, released when the call settles. Resolves once it is ready: live, with every state
|
||||
* member of its variant (unless `state: false`). Rejects with the activation error, `aborted` when the ctx is
|
||||
* aborted or the call settles first, or `unavailable/ready_timeout` after the host's `readyTimeout`; the reference
|
||||
* is released then.
|
||||
*/
|
||||
get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions<P>): Promise<Ref<C>>;
|
||||
}
|
||||
|
||||
export type StateEvent<T> =
|
||||
| { readonly type: "snap"; readonly value: T }
|
||||
| { readonly type: "ops"; readonly ops: readonly Op[]; readonly value: T }
|
||||
| { readonly type: "reset"; readonly value: T };
|
||||
|
||||
/** A readable state source: a cell, a derivation, or a reference's state member. Values are immutable by contract. */
|
||||
export interface Readable<T> {
|
||||
readonly value: T;
|
||||
/** Called with the current value immediately, then with every new value. */
|
||||
subscribe(listener: (value: T) => void): () => void;
|
||||
/** Delta stream: a snapshot immediately, then ops, or a reset after a migration or rebind. */
|
||||
events(listener: (event: StateEvent<T>) => void): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an implementation may expose as a state member: a cell, a derivation, or a held object's state member (an
|
||||
* object goes live only once the members it exposes have their values).
|
||||
*/
|
||||
export type StateSource<T> = Readable<T>;
|
||||
|
||||
declare const EXPOSED: unique symbol;
|
||||
|
||||
/** A local member backed by a resource (`self.expose(res.x)`): references read the resource's value once live. */
|
||||
export interface Exposed<T> {
|
||||
readonly [EXPOSED]: T;
|
||||
}
|
||||
|
||||
export type RefStatus = "loading" | "live" | "released" | "gone";
|
||||
|
||||
export interface Shape {
|
||||
readonly variant: string | undefined;
|
||||
readonly members: readonly string[];
|
||||
/** Fingerprint of the provider contract. */
|
||||
readonly contract: string;
|
||||
}
|
||||
|
||||
export interface RefBase<C extends AnyContract> {
|
||||
/** The consumer contract token this reference was bound with. */
|
||||
readonly contract: C;
|
||||
readonly status: RefStatus;
|
||||
readonly ready: Promise<void>;
|
||||
readonly variant: string | undefined;
|
||||
readonly shape: Shape | undefined;
|
||||
/** Why the reference is gone, if it is. */
|
||||
readonly error: unknown;
|
||||
onShape(listener: (shape: Shape) => void): () => void;
|
||||
/**
|
||||
* Called after every status change (`loading` → `live`, `live` → `loading` while a link reconnects, `released`,
|
||||
* `gone` with `error`). A UI watches for `gone` to `get` again.
|
||||
*/
|
||||
onStatus(listener: (status: RefStatus, error: unknown) => void): () => void;
|
||||
release(): void;
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import type { AcceptOptions } from "./connection.ts";
|
||||
import type { Host } from "./host.ts";
|
||||
import { type WebSocketLike, webSocketLink } from "./link.ts";
|
||||
|
||||
/** A WebSocket server (e.g. `ws`'s `WebSocketServer`), structurally: Chord does not depend on `ws`. */
|
||||
export interface WebSocketServerLike<Request> {
|
||||
on(event: "connection", listener: (socket: WebSocketLike, request: Request) => void): unknown;
|
||||
}
|
||||
|
||||
/** WebSocket peers are always accepted as clients: no roots, no code versions, no reload votes, not addressable. */
|
||||
export interface WebSocketAcceptOptions<Request, P = unknown>
|
||||
extends Omit<AcceptOptions<P>, "principal" | "kind" | "name"> {
|
||||
/** The authenticator: the principal of the connecting client, or undefined to refuse it. */
|
||||
principal(request: Request): P | undefined | Promise<P | undefined>;
|
||||
}
|
||||
|
||||
/** Accepts every connection of `server` on `host` as a client link stamped with the authenticated principal. */
|
||||
export function acceptWebSockets<Request, P = unknown>(
|
||||
server: WebSocketServerLike<Request>,
|
||||
host: Host<P>,
|
||||
options: WebSocketAcceptOptions<Request, P>,
|
||||
): void {
|
||||
server.on("connection", (socket, request) => {
|
||||
// Wrap at once so frames sent while authenticating are queued, not lost. Large payloads go out deflated; clients
|
||||
// may not send packed frames (a small message could inflate to anything).
|
||||
const link = webSocketLink(socket, { compress: true, acceptPacked: false });
|
||||
Promise.resolve(options.principal(request)).then(
|
||||
(principal) => {
|
||||
if (principal === undefined) link.close("unauthorized");
|
||||
else host.accept(link, { ...options, principal, kind: "client" });
|
||||
},
|
||||
() => link.close("authentication failed"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
import {
|
||||
arr,
|
||||
bool,
|
||||
compile,
|
||||
int,
|
||||
lit,
|
||||
map,
|
||||
num,
|
||||
obj,
|
||||
oneOf,
|
||||
opt,
|
||||
type Properties,
|
||||
type Schema,
|
||||
type Static,
|
||||
str,
|
||||
union,
|
||||
unknown,
|
||||
} from "@earendil-works/pi-schema";
|
||||
import { DescriptionSchema } from "./contract.ts";
|
||||
import { CHORD_ERROR_CODES, ChordError } from "./errors.ts";
|
||||
|
||||
// JSON frames between two hosts (or a host and a client). Ids are chosen by the side that creates the hold, call or
|
||||
// subscription; every frame type has one direction, so ids never collide:
|
||||
// consumer → provider: hold, release, call, cancel, sub, unsub, resync
|
||||
// provider → consumer: shape, gone, result, state
|
||||
// State is addressed by (hold id, member): a hold implies the state of its variant, sent as units (a shape followed by
|
||||
// a snapshot of each member the consumer lacks; see Connection).
|
||||
// both: hello, ping, pong, bye, root_end, ack; parent → child: invalidate, prepare, commit, abort, retire;
|
||||
// child → parent: vote
|
||||
//
|
||||
// Each frame is declared once, as the schema every received frame is checked against (`isFrame`); the types are derived
|
||||
// from it. Payloads (hold inputs, call args, results, snapshots) stay `unknown`: they are validated where they are
|
||||
// used, against the contract. State ops are checked op by op by the delta decoder.
|
||||
|
||||
/**
|
||||
* The link protocol version, announced in `hello`. Peers of different versions do not talk: the side that notices
|
||||
* closes the link with reason `protocol-version <its version>`, so the other side learns it too
|
||||
* (`LinkOptions.onProtocolMismatch`).
|
||||
*/
|
||||
export const PROTOCOL_VERSION = 4;
|
||||
|
||||
const Id = int({ minimum: 1 });
|
||||
const Name = str({ minLength: 1 });
|
||||
const Reload = int({ minimum: 1 });
|
||||
/** Arrays a sender may pass as readonly: the same schema, a readonly type. */
|
||||
const List = <T>(item: Schema<T>) => arr(item) as Schema<readonly T[]>;
|
||||
/** An object that admits fields it does not name (a newer peer's), as every frame does. */
|
||||
const Open = <const P extends Properties>(properties: P) => obj(properties, { open: true });
|
||||
|
||||
const RootRefSchema = Open({ rootId: Name, codeVersion: int({ minimum: 1 }) });
|
||||
export type RootRef = Static<typeof RootRefSchema>;
|
||||
|
||||
const WireErrorSchema = Open({
|
||||
code: oneOf(CHORD_ERROR_CODES),
|
||||
message: str(),
|
||||
reason: opt(str()),
|
||||
});
|
||||
export type WireError = Static<typeof WireErrorSchema>;
|
||||
|
||||
const WireReloadOptionsSchema = Open({
|
||||
remove: opt(List(str())),
|
||||
gateDeadline: opt(num()),
|
||||
retireDeadline: opt(num()),
|
||||
});
|
||||
export type WireReloadOptions = Static<typeof WireReloadOptionsSchema>;
|
||||
|
||||
/** A child's prepared reload, sent with its vote (same fields as `ReloadReport`, without children). */
|
||||
const WireReloadReportSchema = Open({
|
||||
codeVersion: int(),
|
||||
previous: int(),
|
||||
changed: List(str()),
|
||||
removed: List(str()),
|
||||
prepared: List(str()),
|
||||
gated: List(str()),
|
||||
});
|
||||
export type WireReloadReport = Static<typeof WireReloadReportSchema>;
|
||||
|
||||
const FrameSchema = union([
|
||||
Open({
|
||||
t: lit("hello"),
|
||||
/** The link protocol version (`PROTOCOL_VERSION`; absent: 1). Peers of another version close the link. */
|
||||
protocol: opt(int({ minimum: 1 })),
|
||||
name: Name,
|
||||
kind: union([lit("host"), lit("client")]),
|
||||
codeVersion: int({ minimum: 0 }),
|
||||
/** The sender acknowledges what it receives (`ack`). Flow control applies only when both hellos say so. */
|
||||
acks: opt(bool()),
|
||||
}),
|
||||
/** Every frame received so far except `ack` frames, counted from the link's start, once processed. */
|
||||
Open({ t: lit("ack"), n: int({ minimum: 1 }) }),
|
||||
Open({
|
||||
t: lit("hold"),
|
||||
id: Id,
|
||||
/**
|
||||
* Target host name, resolved by the consumer from the token's `host` and `input`. Relays route on it after
|
||||
* checking it against both; the target refuses an input its own contract places elsewhere.
|
||||
*/
|
||||
at: Name,
|
||||
/** The consumer's contract token; the provider checks that it covers it. Remote tokens carry `host`. */
|
||||
token: DescriptionSchema,
|
||||
/** Normalized with the consumer's contract. */
|
||||
input: unknown(),
|
||||
/** A forwarded principal. Honored only on frames from the parent link. */
|
||||
principal: opt(unknown()),
|
||||
/**
|
||||
* The client connection the hold comes from: an opaque id the server assigned to it, forwarded by relays. Honored
|
||||
* only on frames from the parent link; present means the hold is a client's (not a reload participant).
|
||||
*/
|
||||
origin: opt(Name),
|
||||
/** False: no state for this hold until `sub` (a method-only binding). */
|
||||
state: opt(lit(false)),
|
||||
/** The consumer already has values (a re-hold after a link loss): its unit is not urgent. */
|
||||
again: opt(lit(true)),
|
||||
}),
|
||||
Open({ t: lit("release"), id: Id }),
|
||||
Open({
|
||||
t: lit("shape"),
|
||||
id: Id,
|
||||
variant: opt(str()),
|
||||
members: List(str()),
|
||||
contract: str(),
|
||||
/** The state members whose snapshots follow this frame; the consumer applies the shape once they are here. */
|
||||
state: List(Name),
|
||||
}),
|
||||
Open({ t: lit("gone"), id: Id, error: WireErrorSchema }),
|
||||
Open({
|
||||
t: lit("call"),
|
||||
id: Id,
|
||||
hold: Id,
|
||||
member: Name,
|
||||
args: opt(unknown()),
|
||||
/** Absent: the receiving host starts a root (browsers never carry roots). */
|
||||
root: opt(RootRefSchema),
|
||||
}),
|
||||
Open({
|
||||
t: lit("result"),
|
||||
id: Id,
|
||||
value: opt(unknown()),
|
||||
error: opt(WireErrorSchema),
|
||||
}),
|
||||
Open({ t: lit("cancel"), id: Id }),
|
||||
/** Starts the state of a hold opened with `state: false` (answered with a unit). */
|
||||
Open({ t: lit("sub"), hold: Id }),
|
||||
/** Stops the state of a hold. */
|
||||
Open({ t: lit("unsub"), hold: Id }),
|
||||
/** Sends member `m` again as a reset (a relay that had to drop what waited for it). */
|
||||
Open({ t: lit("resync"), hold: Id, m: Name }),
|
||||
Open({
|
||||
t: lit("state"),
|
||||
hold: Id,
|
||||
m: Name,
|
||||
/** `snap`/`reset` without `value`: the value is `undefined`. */
|
||||
as: union([lit("snap"), lit("reset"), lit("ops"), lit("error")]),
|
||||
value: opt(unknown()),
|
||||
/** Encoded with the stream's delta encoder; the decoder validates each op. */
|
||||
ops: opt(List(List(unknown()))),
|
||||
error: opt(WireErrorSchema),
|
||||
}),
|
||||
Open({ t: lit("ping"), n: int() }),
|
||||
Open({ t: lit("pong"), n: int() }),
|
||||
/** Ends the link (consumed by the link itself). `reason: "protocol-version <n>"`: the sender speaks version n. */
|
||||
Open({ t: lit("bye"), reason: opt(str()) }),
|
||||
/** The root ended at its origin: its leases on objects end (sent on every link the root's calls used). */
|
||||
Open({ t: lit("root_end"), rootId: Name }),
|
||||
Open({ t: lit("invalidate"), pattern: map(unknown()) }),
|
||||
Open({
|
||||
t: lit("prepare"),
|
||||
reload: Reload,
|
||||
codeVersion: int({ minimum: 1 }),
|
||||
plugins: List(str()),
|
||||
options: WireReloadOptionsSchema,
|
||||
}),
|
||||
Open({
|
||||
t: lit("vote"),
|
||||
reload: Reload,
|
||||
error: opt(WireErrorSchema),
|
||||
report: opt(WireReloadReportSchema),
|
||||
}),
|
||||
Open({ t: lit("commit"), reload: Reload }),
|
||||
/** The parent dropped `codeVersion`: roots of it may still come from there no longer. */
|
||||
Open({ t: lit("retire"), codeVersion: int({ minimum: 1 }) }),
|
||||
Open({ t: lit("abort"), reload: Reload }),
|
||||
]);
|
||||
export type Frame = Static<typeof FrameSchema>;
|
||||
|
||||
/** A frame that passes validation; anything else is a protocol violation. */
|
||||
export const isFrame: (value: unknown) => value is Frame = compile(FrameSchema).check;
|
||||
|
||||
export function toWireError(error: unknown): WireError {
|
||||
if (error instanceof ChordError) {
|
||||
return error.reason === undefined
|
||||
? { code: error.code, message: error.message }
|
||||
: { code: error.code, message: error.message, reason: error.reason };
|
||||
}
|
||||
return { code: "unavailable", message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
export const fromWireError = (error: WireError): ChordError =>
|
||||
new ChordError(error.code, error.message.replace(/^[a-z_]+(\/[a-z_]+)?: /, ""), { reason: error.reason });
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
// Entry 8: awaited gets. `host.get` and `ctx.get` resolve once the reference is ready; abort, the ctx closing and the
|
||||
// host's `readyTimeout` reject them and release only that reference.
|
||||
import assert from "node:assert/strict";
|
||||
import { num, obj } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Host,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
memoryLinkPair,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
import { CountingLink } from "./fixtures/counting.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 200): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
const Slow = remote("t.e8-slow", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { n: num() } } },
|
||||
});
|
||||
|
||||
/** A server whose `Slow` object goes live when `open()` is called; a browser whose link counts frames. */
|
||||
function setup(readyTimeout?: number) {
|
||||
let open = () => {};
|
||||
const opened = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Slow, (_input, _principal, self) => {
|
||||
self.resource("gate", { version: 1, open: () => opened });
|
||||
return { variant: "main", n: self.state("n", num(), 1) };
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
const link = new CountingLink(up);
|
||||
const browser: Host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
...(readyTimeout === undefined ? {} : { readyTimeout }),
|
||||
});
|
||||
return { server, browser, link, open };
|
||||
}
|
||||
|
||||
describe("awaited gets", () => {
|
||||
it("an aborted get rejects and releases only its reference; the binding stays for the others", async () => {
|
||||
const { server, browser, link, open } = setup();
|
||||
const abort = new AbortController();
|
||||
const first = browser.get(Slow, {}, { signal: abort.signal });
|
||||
const second = browser.get(Slow, {});
|
||||
await sleep(5);
|
||||
abort.abort();
|
||||
await assert.rejects(first, (error) => isChordError(error, "aborted"));
|
||||
await sleep(5);
|
||||
assert.equal(link.count("sent", "release"), 0, "the other awaiting get keeps the hold");
|
||||
open();
|
||||
const ref = await second;
|
||||
assert.equal(ref.n.value, 1);
|
||||
assert.equal(link.count("sent", "hold"), 1);
|
||||
ref.release();
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a ctx.get still waiting when its call settles rejects aborted and releases its reference", async () => {
|
||||
const { server, browser, link, open } = setup();
|
||||
let pending: Promise<unknown> | undefined;
|
||||
await browser.root(async (ctx: Ctx) => {
|
||||
pending = ctx.get(Slow, {});
|
||||
pending.catch(() => {});
|
||||
});
|
||||
await assert.rejects(pending!, (error) => isChordError(error, "aborted"));
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
open();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a get times out after readyTimeout with ready_timeout and releases its hold", async () => {
|
||||
const { server, browser, link } = setup(30);
|
||||
await assert.rejects(
|
||||
browser.get(Slow, {}),
|
||||
(error) => isChordError(error, "unavailable") && error.reason === "ready_timeout",
|
||||
);
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("ctx.get resolves hydrated; the call's reference is released when the call ends", async () => {
|
||||
const { server, browser, link, open } = setup();
|
||||
open();
|
||||
const value = await browser.root(async (ctx: Ctx) => {
|
||||
const slow = await ctx.get(Slow, {});
|
||||
return slow.n.value;
|
||||
});
|
||||
assert.equal(value, 1);
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,459 +0,0 @@
|
|||
// Entry 8: one shared binding per host and key; a hold implies the state of its variant (units: shape, then
|
||||
// snapshots); method-only references; origins of client holds; protocol version 4.
|
||||
import assert from "node:assert/strict";
|
||||
import { arr, nul, num, obj, opt, str, unknown } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import { HostImpl } from "../../src/old/host.ts";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
type Link,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
import { CountingLink, stateFrames } from "./fixtures/counting.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 200): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
const NoteSchema = arr(str());
|
||||
const Notes = remote("t.e8-notes", {
|
||||
version: 1,
|
||||
input: obj({ list: opt(str({ default: "main" })) }, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { notes: NoteSchema, title: str() },
|
||||
methods: { add: method(str()), rename: method(str()) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function serveNotes(host: Host): void {
|
||||
host.serve(Notes, (_input, _principal, self) => {
|
||||
const notes = self.state("notes", NoteSchema, []);
|
||||
const title = self.state("title", str(), "untitled");
|
||||
return {
|
||||
variant: "main",
|
||||
notes,
|
||||
title,
|
||||
add: (text: string, ctx: Ctx) => {
|
||||
notes.change(ctx, (draft) => {
|
||||
draft.push(text);
|
||||
});
|
||||
},
|
||||
rename: (text: string, ctx: Ctx) => {
|
||||
title.replace(ctx, text);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** A server serving `Notes`, and a browser whose end of the link counts what crosses it. */
|
||||
function setup(
|
||||
options: { readonly window?: number; readonly linger?: { types?: Record<string, number>; cap?: number } } = {},
|
||||
) {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveNotes(server);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, {
|
||||
principal: { user: "u" },
|
||||
...(options.window === undefined ? {} : { window: options.window }),
|
||||
});
|
||||
const link = new CountingLink(up);
|
||||
const errors: unknown[] = [];
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: (error) => errors.push(error),
|
||||
...(options.linger === undefined ? {} : { linger: options.linger }),
|
||||
});
|
||||
return { server, browser, link, errors };
|
||||
}
|
||||
|
||||
describe("shared bindings: traffic", () => {
|
||||
it("N references to one object: one hold, one snapshot per member, one frame per change, one release", async () => {
|
||||
const { server, browser, link } = setup();
|
||||
const Panel = local("t.e8-panel", { version: 1, input: obj({}, { open: true }), members: { count: num() } });
|
||||
browser.serve(Panel, (_input, _principal, self) => {
|
||||
const notes = self.get(Notes, {});
|
||||
return { count: self.derive([notes.notes], (list) => list.length) };
|
||||
});
|
||||
const a = browser.hold(Notes, {});
|
||||
const b = browser.hold(Notes, { list: "main" });
|
||||
const panel = browser.hold(Panel, {});
|
||||
await Promise.all([a.ready, b.ready, panel.ready]);
|
||||
const viaCtx = await browser.root(async (ctx) => {
|
||||
const ref = await ctx.get(Notes, {});
|
||||
return ref.title.value;
|
||||
});
|
||||
assert.equal(viaCtx, "untitled");
|
||||
assert.equal(link.count("sent", "hold"), 1, "one hold for every reference");
|
||||
const snaps = stateFrames(link.received).filter((frame) => frame.as === "snap");
|
||||
assert.deepEqual(snaps.map((frame) => frame.m).sort(), ["notes", "title"]);
|
||||
|
||||
const before = stateFrames(link.received).length;
|
||||
await a.add("milk");
|
||||
await until(() => a.notes.value.length === 1 && b.notes.value.length === 1 && panel.count.value === 1);
|
||||
await tick();
|
||||
assert.equal(stateFrames(link.received).length - before, 1, "one delta frame for one change, whatever N");
|
||||
|
||||
const sent = link.traffic("sent").length;
|
||||
a.release();
|
||||
panel.release();
|
||||
await sleep(5);
|
||||
assert.equal(link.traffic("sent").length, sent, "releasing all but one reference sends nothing");
|
||||
b.release();
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a held reference stays subscribed without listeners; values are readable at once after ready", async () => {
|
||||
const { server, browser } = setup();
|
||||
const notes = browser.hold(Notes, {});
|
||||
await notes.ready;
|
||||
assert.deepEqual(notes.notes.value, []);
|
||||
await notes.add("bread");
|
||||
await until(() => notes.notes.value.length === 1);
|
||||
assert.equal(notes.title.value, "untitled");
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared bindings: linger", () => {
|
||||
it("a lingering binding keeps its hold and state; a reference taken meanwhile costs nothing on the link", async () => {
|
||||
const { server, browser, link } = setup({ linger: { types: { [Notes.name]: 40 } } });
|
||||
const first = await browser.get(Notes, {});
|
||||
const frames = link.traffic("received").length;
|
||||
first.release();
|
||||
await sleep(10);
|
||||
assert.equal(link.count("sent", "release"), 0, "not released before the linger");
|
||||
// A hook mounting and unmounting: no frames either way.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const again = await browser.get(Notes, {});
|
||||
assert.deepEqual(again.notes.value, []);
|
||||
again.release();
|
||||
}
|
||||
assert.equal(link.count("sent", "hold"), 1);
|
||||
assert.equal(link.traffic("received").length, frames, "nothing new crossed the link");
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("at most `cap` bindings linger: the least recently released goes first", async () => {
|
||||
const { server, browser, link } = setup({ linger: { types: { [Notes.name]: 10_000 }, cap: 1 } });
|
||||
const a = await browser.get(Notes, { list: "a" });
|
||||
const b = await browser.get(Notes, { list: "b" });
|
||||
a.release();
|
||||
await sleep(5);
|
||||
assert.equal(link.count("sent", "release"), 0);
|
||||
b.release();
|
||||
await until(() => link.count("sent", "release") === 1);
|
||||
const release = link.sent.find((frame) => frame.t === "release");
|
||||
const holdOfA = link.sent.find((frame) => frame.t === "hold" && (frame.input as { list: string }).list === "a");
|
||||
assert.equal(release?.t === "release" ? release.id : 0, holdOfA?.t === "hold" ? holdOfA.id : -1);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared bindings: identity", () => {
|
||||
it("normalized-equal inputs and equal principal keys share; other principals and fingerprints do not", async () => {
|
||||
const server = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "server",
|
||||
onError: () => {},
|
||||
});
|
||||
const NotesV2 = remote("t.e8-notes", {
|
||||
version: 2,
|
||||
input: obj({ list: opt(str({ default: "main" })) }, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { notes: NoteSchema, title: str() },
|
||||
methods: { add: method(str()), rename: method(str()), clear: method() },
|
||||
},
|
||||
},
|
||||
});
|
||||
server.serve(
|
||||
NotesV2,
|
||||
(_input, _principal, self) => {
|
||||
const notes = self.state("notes", NoteSchema, []);
|
||||
const title = self.state("title", str(), "untitled");
|
||||
return { variant: "main", notes, title, add: () => {}, rename: () => {}, clear: () => {} };
|
||||
},
|
||||
{ accepts: { 1: (current) => current } },
|
||||
);
|
||||
// A worker holds the server's objects; the principals it forwards key its bindings.
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:w" });
|
||||
const link = new CountingLink(up);
|
||||
const worker = createHost<unknown>({
|
||||
self: { kind: "host" },
|
||||
name: "session:w",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const refs = [
|
||||
worker.hold(Notes, {}),
|
||||
worker.hold(Notes, { list: "main" }),
|
||||
worker.hold(Notes, {}, { principal: { user: "a", grant: 1 } }),
|
||||
worker.hold(Notes, {}, { principal: { grant: 1, user: "a" } }),
|
||||
worker.hold(Notes, {}, { principal: { user: "a", grant: 2 } }),
|
||||
worker.hold(NotesV2, {}),
|
||||
];
|
||||
await Promise.all(refs.map((ref) => ref.ready));
|
||||
// host (shared by two inputs), user a grant 1 (shared by two key orders), user a grant 2, the v2 contract.
|
||||
assert.equal(link.count("sent", "hold"), 4);
|
||||
await worker.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydration over a link", () => {
|
||||
const Matrix = remote("t.e8-matrix", {
|
||||
version: 1,
|
||||
input: obj({ variant: str() }, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
values: { state: { none: nul(), empty: arr(str()), maybe: unknown() } },
|
||||
bare: { methods: { ping: method(undefined, str()) } },
|
||||
broken: { state: { n: num() } },
|
||||
},
|
||||
});
|
||||
|
||||
function serveMatrix(host: Host): void {
|
||||
host.serve(Matrix, ({ variant }, _principal, self) => {
|
||||
if (variant === "bare") return { variant: "bare", ping: () => "pong" };
|
||||
if (variant === "broken") {
|
||||
const source = self.state("source", str(), "not a number");
|
||||
// A derivation exposed under a number schema: its snapshot fails validation.
|
||||
return { variant: "broken", n: self.derive([source], (text) => text as unknown as number) };
|
||||
}
|
||||
const none = self.state("none", nul(), null);
|
||||
const empty = self.state("empty", arr(str()), []);
|
||||
return { variant: "values", none, empty, maybe: self.derive([none], () => undefined as unknown) };
|
||||
});
|
||||
}
|
||||
|
||||
it("null, empty and undefined values hydrate; a state-free variant is ready; a failed snapshot rejects", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveMatrix(server);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const values = await browser.get(Matrix, { variant: "values" });
|
||||
assert.ok(values.variant === "values");
|
||||
assert.equal(values.none.value, null);
|
||||
assert.deepEqual(values.empty.value, []);
|
||||
assert.equal(values.maybe.value, undefined);
|
||||
const bare = await browser.get(Matrix, { variant: "bare" });
|
||||
assert.ok(bare.variant === "bare");
|
||||
assert.equal(await bare.ping(), "pong");
|
||||
assert.throws(
|
||||
// @ts-expect-error `none` is not a member of the bare variant (a narrowed reference does not show it).
|
||||
() => bare.none.value,
|
||||
(error) => isChordError(error, "not_in_variant"),
|
||||
);
|
||||
const broken = browser.hold(Matrix, { variant: "broken" });
|
||||
await assert.rejects(broken.ready, (error) => isChordError(error, "invalid"));
|
||||
assert.equal(broken.status, "gone");
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("reading before the first hydration throws not_ready", async () => {
|
||||
const { server, browser } = setup();
|
||||
const notes = browser.hold(Notes, {});
|
||||
assert.throws(
|
||||
() => notes.notes.value,
|
||||
(error) => isChordError(error, "not_ready"),
|
||||
);
|
||||
await notes.ready;
|
||||
assert.deepEqual(notes.notes.value, []);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("method-only references (state: false)", () => {
|
||||
it("transfer no state alone; a full reference joining starts it once; its release stops it", async () => {
|
||||
const { server, browser, link } = setup();
|
||||
const caller = browser.hold(Notes, {}, { state: false });
|
||||
await caller.ready;
|
||||
await caller.add("x");
|
||||
const hold = link.sent.find((frame) => frame.t === "hold");
|
||||
assert.ok(hold?.t === "hold" && hold.state === false);
|
||||
assert.deepEqual(stateFrames(link.received), []);
|
||||
assert.throws(
|
||||
() => caller.notes.value,
|
||||
(error) => isChordError(error, "not_ready"),
|
||||
);
|
||||
|
||||
const viewer = browser.hold(Notes, {});
|
||||
await viewer.ready;
|
||||
assert.deepEqual(viewer.notes.value, ["x"]);
|
||||
assert.equal(link.count("sent", "sub"), 1);
|
||||
assert.equal(link.count("sent", "hold"), 1, "the same hold");
|
||||
viewer.release();
|
||||
await until(() => link.count("sent", "unsub") === 1);
|
||||
const after = stateFrames(link.received).length;
|
||||
await caller.add("y");
|
||||
await sleep(5);
|
||||
assert.equal(stateFrames(link.received).length, after, "no state once only method-only references remain");
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("origins of client holds", () => {
|
||||
const Counter = remote("t.e8-counter", {
|
||||
version: 1,
|
||||
input: obj({ session: str() }, { open: true }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: { main: { state: { n: num() }, methods: { add: method(num()) } } },
|
||||
});
|
||||
|
||||
function cluster() {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
let worker: HostImpl | undefined;
|
||||
let toWorker: CountingLink | undefined;
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
if (toWorker !== undefined) return toWorker;
|
||||
const [down, up] = memoryLinkPair();
|
||||
const created = new HostImpl({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
created.serve(Counter, (_input, _principal, self) => {
|
||||
const n = self.state("n", num(), 0);
|
||||
return {
|
||||
variant: "main",
|
||||
n,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
n.replace(ctx, n.value + delta);
|
||||
},
|
||||
};
|
||||
});
|
||||
worker = created;
|
||||
toWorker = new CountingLink(down);
|
||||
return toWorker;
|
||||
},
|
||||
});
|
||||
const browser = () => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
return { host, up };
|
||||
};
|
||||
const origins = () =>
|
||||
[...(worker?.upstream?.served.values() ?? [])].map((served) =>
|
||||
served.kind === "local" ? served.origin : undefined,
|
||||
);
|
||||
return { server, browser, origins, sentToWorker: () => toWorker?.sent ?? [] };
|
||||
}
|
||||
|
||||
it("a relayed hold carries its client connection's id; the server's own holds carry none", async () => {
|
||||
const c = cluster();
|
||||
const one = c.browser();
|
||||
const two = c.browser();
|
||||
await one.host.hold(Counter, { session: "S1" }).ready;
|
||||
await two.host.hold(Counter, { session: "S1" }).ready;
|
||||
const own = c.server.hold(Counter, { session: "S1" });
|
||||
await own.ready;
|
||||
const holds = c.sentToWorker().filter((frame): frame is Extract<Frame, { t: "hold" }> => frame.t === "hold");
|
||||
assert.equal(holds.length, 3);
|
||||
const relayed = holds.filter((frame) => frame.origin !== undefined);
|
||||
assert.equal(relayed.length, 2);
|
||||
assert.notEqual(relayed[0]!.origin, relayed[1]!.origin, "two tabs, two origins");
|
||||
assert.equal(holds.filter((frame) => frame.origin === undefined).length, 1);
|
||||
|
||||
// Disconnect is departure: the worker loses that origin at once; a reconnect holds under a new id.
|
||||
const before = new Set(c.origins());
|
||||
one.up.close();
|
||||
await until(() => c.origins().filter((origin) => origin !== undefined).length === 1);
|
||||
const three = c.browser();
|
||||
await three.host.hold(Counter, { session: "S1" }).ready;
|
||||
const after = c.origins().filter((origin) => origin !== undefined);
|
||||
assert.equal(after.length, 2);
|
||||
assert.ok(
|
||||
after.some((origin) => !before.has(origin)),
|
||||
"a new connection gets a new id",
|
||||
);
|
||||
await c.server.close();
|
||||
});
|
||||
|
||||
it("an origin a client sends is ignored: the server stamps its own", async () => {
|
||||
const c = cluster();
|
||||
const [down, raw] = memoryLinkPair();
|
||||
c.server.accept(down, { principal: { guest: "g" } });
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
raw.send({
|
||||
t: "hold",
|
||||
id: 1,
|
||||
at: "session:S1",
|
||||
token: Counter.description,
|
||||
input: { session: "S1" },
|
||||
origin: "spoofed",
|
||||
});
|
||||
await until(() => c.sentToWorker().some((frame) => frame.t === "hold"));
|
||||
const hold = c.sentToWorker().find((frame) => frame.t === "hold");
|
||||
assert.ok(hold?.t === "hold" && hold.origin !== undefined && hold.origin !== "spoofed");
|
||||
await c.server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("protocol version 4", () => {
|
||||
it("a version 3 peer is refused; shapes carry no code version", async () => {
|
||||
const { server, browser, link } = setup();
|
||||
await browser.hold(Notes, {}).ready;
|
||||
const shape = link.received.find((frame) => frame.t === "shape");
|
||||
assert.ok(shape !== undefined && !("codeVersion" in shape));
|
||||
assert.deepEqual(shape.t === "shape" ? [...shape.state].sort() : [], ["notes", "title"]);
|
||||
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "old" } });
|
||||
const closed = new Promise<string>((resolve) => (raw as Link).onClose(resolve));
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", protocol: 3, name: "old", kind: "client", codeVersion: 0 });
|
||||
assert.equal(await closed, `protocol-version ${PROTOCOL_VERSION}`);
|
||||
assert.equal(PROTOCOL_VERSION, 4);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,644 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { describe, it, onTestFinished } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Facet,
|
||||
type FacetLoader,
|
||||
type Frame,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
type Link,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
remote,
|
||||
type Self,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
function latch() {
|
||||
let open = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
const Greeter = remote("t.greeter", {
|
||||
version: 1,
|
||||
input: obj({ session: str() }, { open: true }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { count: obj({ n: num() }, { open: true }) },
|
||||
methods: { greet: method(str(), str()) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const GreeterV2 = remote("t.greeter", {
|
||||
version: 2,
|
||||
input: obj({ session: str() }, { open: true }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: { main: { methods: { hello: method(str(), str()) } } },
|
||||
});
|
||||
|
||||
/** The server's own greeter: the greeter facet serves this one on the server, `Greeter` on session hosts. */
|
||||
const ServerGreeter = remote("t.server-greeter", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { count: obj({ n: num() }, { open: true }) },
|
||||
methods: { greet: method(str(), str()) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function greeterFacet(greeting: string, options: { fail?: boolean; stateVersion?: number } = {}): Facet {
|
||||
return {
|
||||
name: "greeter",
|
||||
setup: (api) => {
|
||||
const greeter = (self: Self) => {
|
||||
if (options.fail) throw new Error("worker factory fails");
|
||||
const count = self.state(
|
||||
"count",
|
||||
obj({ n: num() }, { open: true }),
|
||||
{ n: 0 },
|
||||
{ version: options.stateVersion ?? 1 },
|
||||
);
|
||||
return {
|
||||
variant: "main" as const,
|
||||
count,
|
||||
greet: (name: string, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += 1;
|
||||
});
|
||||
return `${greeting} ${name}`;
|
||||
},
|
||||
};
|
||||
};
|
||||
if (api.name === "server") api.serve(ServerGreeter, (_input, _principal, self) => greeter(self));
|
||||
else api.serve(Greeter, (_input, _principal, self) => greeter(self));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Server + worker over a memory link; the worker loads facets by name from `modules`. */
|
||||
/** `link`, except that frames `drop` selects are never sent (a frame lost when its link dies). */
|
||||
function dropping(link: Link, drop: (frame: Frame) => boolean): Link {
|
||||
return {
|
||||
get closed() {
|
||||
return link.closed;
|
||||
},
|
||||
send: (frame) => (drop(frame) ? undefined : link.send(frame)),
|
||||
receive: (handler) => link.receive(handler),
|
||||
onClose: (handler) => link.onClose(handler),
|
||||
close: (reason) => link.close(reason),
|
||||
};
|
||||
}
|
||||
|
||||
const Ping = remote("t.cluster-ping", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { ping: method(undefined, str()) } } },
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const loader: FacetLoader = async (names) => names.map((name) => modules.get(String(name))!);
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader,
|
||||
onError: () => {},
|
||||
});
|
||||
worker.install(modules.get("greeter")!);
|
||||
return { server, worker, modules, down };
|
||||
}
|
||||
|
||||
describe("cross-host reload", () => {
|
||||
it("prepares every host, commits the server first, then the workers; code versions stay in step", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
await tick();
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await greeter.greet("a"), "v1 a");
|
||||
const go = latch();
|
||||
const pinned = server.root(async (ctx) => {
|
||||
await greeter.greet("p", ctx);
|
||||
await go.promise;
|
||||
return greeter.greet("p", ctx);
|
||||
});
|
||||
await tick();
|
||||
const shapes: string[] = [];
|
||||
greeter.onShape((shape) => shapes.push(shape.contract));
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
const report = await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
// No waiting: the commit frame precedes this call on the FIFO link, so the worker has committed when it arrives.
|
||||
assert.equal(await greeter.greet("b"), "v2 b");
|
||||
assert.equal(server.codeVersion, report.codeVersion);
|
||||
assert.equal(worker.codeVersion, report.codeVersion);
|
||||
go.open();
|
||||
assert.equal(await pinned, "v1 p");
|
||||
assert.equal(shapes.length, 1, "one shape push at the commit");
|
||||
// The server retires the old code version once its last old root ended, then tells the worker.
|
||||
for (let i = 0; i < 20 && worker.previousCodeVersion !== undefined; i++) await tick();
|
||||
assert.equal(server.previousCodeVersion, undefined);
|
||||
assert.equal(worker.previousCodeVersion, undefined);
|
||||
});
|
||||
|
||||
it("a failing vote aborts every host; the error keeps its code and names the host", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
await tick();
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
await greeter.greet("a");
|
||||
const before = [server.codeVersion, worker.codeVersion];
|
||||
modules.set("greeter", greeterFacet("v2", { fail: true }));
|
||||
await assert.rejects(server.reload([], { remote: { "session:": ["greeter"] } }), (error) => {
|
||||
assert.ok(isChordError(error, "unavailable"));
|
||||
assert.match(error.message, /session:S1: .*worker factory fails/);
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual([server.codeVersion, worker.codeVersion], before);
|
||||
assert.equal(await greeter.greet("x"), "v1 x");
|
||||
});
|
||||
|
||||
it("a worker gate blocked past its deadline aborts the whole reload", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
await tick();
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
const go = latch();
|
||||
const blocker = server.root(async (ctx) => {
|
||||
await greeter.greet("g", ctx);
|
||||
await go.promise;
|
||||
});
|
||||
await tick();
|
||||
const before = [server.codeVersion, worker.codeVersion];
|
||||
const bumped = greeterFacet("v2", { stateVersion: 2 });
|
||||
modules.set("greeter", bumped);
|
||||
await assert.rejects(server.reload([], { remote: { "session:": ["greeter"] }, gateDeadline: 30 }), (error) => {
|
||||
assert.ok(isChordError(error, "aborted") && error.reason === "gate_deadline");
|
||||
assert.match(error.message, /session:S1: .*blocking roots: server#\w+:\d+/);
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual([server.codeVersion, worker.codeVersion], before);
|
||||
go.open();
|
||||
await blocker;
|
||||
assert.equal(await greeter.greet("x"), "v1 x");
|
||||
});
|
||||
|
||||
it("removes facets from server and workers, reconciles removal tombstones, and clears them on re-add", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
onTestFinished(async () => {
|
||||
await worker.close();
|
||||
await server.close();
|
||||
});
|
||||
await server.install(greeterFacet("v1"));
|
||||
await tick();
|
||||
const local = server.hold(ServerGreeter, {});
|
||||
const child = server.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await local.greet("a"), "v1 a");
|
||||
assert.equal(await child.greet("a"), "v1 a");
|
||||
await server.reload([], { remove: ["greeter"], remoteRemove: { "session:": ["greeter"] } });
|
||||
for (let i = 0; i < 20 && child.status !== "gone"; i++) await tick();
|
||||
assert.equal(local.status, "gone");
|
||||
assert.equal(child.status, "gone");
|
||||
|
||||
// A child starting with the old set must remove it without trying to import its removed module.
|
||||
const cold = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:cold",
|
||||
loader: async () => {
|
||||
assert.fail("Removed modules must not be imported");
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => cold.close());
|
||||
await cold.install(greeterFacet("v1"));
|
||||
const old = cold.hold(Greeter, { session: "cold" });
|
||||
await old.ready;
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "cold" }, kind: "host", name: "session:cold" });
|
||||
cold.connect(up, { principal: { kind: "server" } });
|
||||
for (let i = 0; i < 20 && old.status !== "gone"; i++) await tick();
|
||||
assert.equal(old.status, "gone");
|
||||
assert.equal(cold.codeVersion, server.codeVersion);
|
||||
// Close this loader-less child before the addition; it tested only the removal reconciliation.
|
||||
await cold.close();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await server.reload([greeterFacet("v2")], { remote: { "session:": ["greeter"] } });
|
||||
assert.equal(await server.hold(Greeter, { session: "S1" }).greet("b"), "v2 b");
|
||||
const fresh = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:fresh",
|
||||
loader: async (names) => names.map((name) => modules.get(String(name))!),
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => fresh.close());
|
||||
await fresh.install(greeterFacet("v1"));
|
||||
const [freshDown, freshUp] = memoryLinkPair();
|
||||
server.accept(freshDown, { principal: { worker: "fresh" }, kind: "host", name: "session:fresh" });
|
||||
fresh.connect(freshUp, { principal: { kind: "server" } });
|
||||
assert.equal(await server.hold(Greeter, { session: "fresh" }).greet("c"), "v2 c");
|
||||
});
|
||||
|
||||
it("only the server coordinates reloads", async () => {
|
||||
const { worker } = setup();
|
||||
await tick();
|
||||
await assert.rejects(worker.reload([]), (error) => isChordError(error, "unavailable"));
|
||||
});
|
||||
|
||||
it("browsers are not participants: they see shape and reset pushes, or upgrade_required", async () => {
|
||||
const { server, modules, down: workerLink } = setup();
|
||||
// The route resolves to the already connected worker.
|
||||
server.route("session:", {
|
||||
authorize: () => true,
|
||||
resolve: async () => workerLink,
|
||||
principal: (name) => ({ host: name }),
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const viewer = browser.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await viewer.greet("a"), "v1 a");
|
||||
const shapes: string[] = [];
|
||||
viewer.onShape((shape) => shapes.push(shape.contract));
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
for (let i = 0; i < 20 && shapes.length === 0; i++) await tick();
|
||||
assert.equal(shapes.length, 1, "one shape push at the commit");
|
||||
assert.equal(await viewer.greet("b"), "v2 b");
|
||||
|
||||
modules.set("greeter", {
|
||||
name: "greeter",
|
||||
setup: (api) => api.serve(GreeterV2, () => ({ variant: "main", hello: (name: string) => `hi ${name}` })),
|
||||
});
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
for (let i = 0; i < 20 && viewer.status !== "gone"; i++) await tick();
|
||||
assert.equal(viewer.status, "gone");
|
||||
assert.ok(isChordError(viewer.error, "upgrade_required"));
|
||||
});
|
||||
|
||||
it("an abort while a worker is still preparing: a late open closes itself and the worker can reload again", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
await tick();
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
await greeter.greet("a");
|
||||
const log: string[] = [];
|
||||
const go = latch();
|
||||
const slow: Facet = {
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
self.resource("late", {
|
||||
version: 1,
|
||||
open: async () => {
|
||||
await go.promise;
|
||||
log.push("opened");
|
||||
},
|
||||
close: () => void log.push("closed"),
|
||||
});
|
||||
const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 });
|
||||
return { variant: "main", count, greet: (name: string) => `slow ${name}` };
|
||||
}),
|
||||
};
|
||||
modules.set("greeter", slow);
|
||||
const codeVersion = worker.codeVersion;
|
||||
await assert.rejects(server.reload([], { remote: { "session:": ["greeter"] }, voteDeadline: 50 }), (error) =>
|
||||
isChordError(error, "aborted"),
|
||||
);
|
||||
go.open();
|
||||
for (let i = 0; i < 50 && !log.includes("closed"); i++) await tick();
|
||||
assert.deepEqual(log, ["opened", "closed"]);
|
||||
assert.equal(worker.codeVersion, codeVersion);
|
||||
modules.set("greeter", greeterFacet("v3"));
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
assert.equal(await greeter.greet("b"), "v3 b");
|
||||
});
|
||||
|
||||
it("a child that says hello while a reload runs is reconciled after it, never left in step with the old code", async () => {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const loading = latch();
|
||||
const server = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "server",
|
||||
onError: () => {},
|
||||
loader: async (names) => {
|
||||
await loading.promise;
|
||||
return names.map((name) => ({ name: String(name), setup: () => {} }));
|
||||
},
|
||||
});
|
||||
onTestFinished(() => server.close());
|
||||
const reloading = server.reload(["server-plugin"], { remote: { "session:": ["greeter"] } });
|
||||
// The worker connects while the reload loads its modules: it announces the server's current code version.
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader: async (names) => names.map((name) => modules.get(String(name))!),
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
worker.install(modules.get("greeter")!);
|
||||
await tick();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
loading.open();
|
||||
const report = await reloading;
|
||||
assert.deepEqual(Object.keys(report.children ?? {}), [], "it was not a participant");
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await greeter.greet("a"), "v2 a");
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
});
|
||||
|
||||
it("a child keeps the previous code version until its parent retired it (spec 12)", async () => {
|
||||
const { server, worker, modules } = setup();
|
||||
await tick();
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await greeter.greet("a"), "v1 a");
|
||||
const first = server.codeVersion;
|
||||
const go = latch();
|
||||
// A server root of the first code version that calls the worker only after the next two reloads started.
|
||||
const pinned = server.root(async (ctx) => {
|
||||
await go.promise;
|
||||
return greeter.greet("p", ctx);
|
||||
});
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
assert.equal(worker.previousCodeVersion, first);
|
||||
const second = server.reload([], { remote: { "session:": [] }, retireDeadline: 2000 });
|
||||
await tick();
|
||||
await tick();
|
||||
// The server still waits for its root, so the worker must not have retired that code version.
|
||||
assert.equal(worker.previousCodeVersion, first);
|
||||
go.open();
|
||||
assert.equal(await pinned, "v1 p");
|
||||
await second;
|
||||
assert.equal(server.previousCodeVersion, undefined);
|
||||
});
|
||||
|
||||
it("a child reconciled after its parent retired old code versions may retire its own previous one at once", async () => {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
onTestFinished(() => server.close());
|
||||
const plugin = (): Facet => ({ name: "server-plugin", setup: () => {} });
|
||||
server.install(plugin());
|
||||
await server.reload([plugin()]);
|
||||
await server.reload([plugin()]);
|
||||
assert.equal(server.previousCodeVersion, undefined);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader: async (names) => names.map((name) => modules.get(String(name))!),
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
worker.install(modules.get("greeter")!);
|
||||
for (let i = 0; i < 50 && worker.codeVersion !== server.codeVersion; i++) await tick();
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
// Without the parent's retire watermark it would keep code version 1 until the parent's next retirement.
|
||||
for (let i = 0; i < 20 && worker.previousCodeVersion !== undefined; i++) await tick();
|
||||
assert.equal(worker.previousCodeVersion, undefined);
|
||||
// And the next cluster reload does not wait for a retirement the parent will never announce.
|
||||
const started = Date.now();
|
||||
await server.reload([], { remote: { "session:": ["greeter"] }, retireDeadline: 10_000 });
|
||||
assert.ok(Date.now() - started < 2000);
|
||||
});
|
||||
|
||||
it("a reconciliation running when a reload starts syncs the child only after it, to the committed code version", async () => {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const serverLoading = latch();
|
||||
let blockServer = false;
|
||||
const server = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "server",
|
||||
onError: () => {},
|
||||
loader: async (names) => {
|
||||
if (blockServer) await serverLoading.promise;
|
||||
return names.map((name) => ({ name: String(name), setup: () => {} }));
|
||||
},
|
||||
});
|
||||
onTestFinished(() => server.close());
|
||||
server.install({ name: "server-plugin", setup: () => {} });
|
||||
await server.reload(["server-plugin"], { remote: { "session:": ["greeter"] } });
|
||||
// The worker connects behind: its reconciliation to code version 2 waits in the worker's module loading.
|
||||
const workerLoading = latch();
|
||||
let blockWorker = true;
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader: async (names) => {
|
||||
if (blockWorker) await workerLoading.promise;
|
||||
return names.map((name) => modules.get(String(name))!);
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
worker.install(modules.get("greeter")!);
|
||||
await tick();
|
||||
// A reload starts before the reconciliation committed; the worker is not one of its participants.
|
||||
blockServer = true;
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
const reloading = server.reload(["server-plugin"], { remote: { "session:": ["greeter"] } });
|
||||
blockWorker = false;
|
||||
workerLoading.open();
|
||||
for (let i = 0; i < 50 && worker.codeVersion !== 2; i++) await tick();
|
||||
assert.equal(worker.codeVersion, 2);
|
||||
serverLoading.open();
|
||||
const report = await reloading;
|
||||
assert.deepEqual(Object.keys(report.children ?? {}), []);
|
||||
assert.equal(report.codeVersion, 3);
|
||||
const greeter = server.hold(Greeter, { session: "S1" });
|
||||
assert.equal(await greeter.greet("a"), "v2 a");
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
});
|
||||
|
||||
it("a child behind whose hello waits for a running reload is fenced at once: its calls up are admitted (spec 9)", async () => {
|
||||
const pingFacet: Facet = {
|
||||
name: "ping",
|
||||
setup: (api) => api.serve(Ping, () => ({ variant: "main", ping: () => "pong" })),
|
||||
};
|
||||
const loading = latch();
|
||||
const server = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "server",
|
||||
onError: () => {},
|
||||
loader: async (names) => {
|
||||
await loading.promise;
|
||||
return names.map((name) => ({ name: String(name), setup: () => {} }));
|
||||
},
|
||||
});
|
||||
onTestFinished(() => server.close());
|
||||
server.install(pingFacet);
|
||||
await server.reload([pingFacet]);
|
||||
assert.equal(server.previousCodeVersion, undefined, "code version 1 is retired");
|
||||
const reloading = server.reload(["server-plugin"], { remote: { "session:": [] } });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
// While the server's reload still loads its modules, a worker root (code version 1) calls the server.
|
||||
const answer = await worker.root(async (ctx) => (await ctx.get(Ping, {})).ping(ctx));
|
||||
assert.equal(answer, "pong");
|
||||
loading.open();
|
||||
await reloading;
|
||||
for (let i = 0; i < 50 && worker.codeVersion !== server.codeVersion; i++) await tick();
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
});
|
||||
|
||||
it("a reconnecting child behind its parent, still retaining its previous code version, reconciles without waiting out the retire deadline", async () => {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const loader: FacetLoader = async (names) => names.map((name) => modules.get(String(name))!);
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
onTestFinished(() => server.close());
|
||||
const [down, up] = memoryLinkPair();
|
||||
// The `retire` frame is lost: the worker keeps code version 1, not permitted to retire it.
|
||||
server.accept(
|
||||
dropping(down, (frame) => frame.t === "retire"),
|
||||
{ principal: { worker: "w" }, kind: "host", name: "session:S1" },
|
||||
);
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader,
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
worker.install(modules.get("greeter")!);
|
||||
await tick();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
await tick();
|
||||
assert.equal(worker.previousCodeVersion, 1);
|
||||
up.close("gone");
|
||||
await tick();
|
||||
// The server moves on while the worker is away, then the worker reconnects behind it.
|
||||
modules.set("greeter", greeterFacet("v3"));
|
||||
await server.reload([{ name: "server-plugin", setup: () => {} }]);
|
||||
const [down2, up2] = memoryLinkPair();
|
||||
server.accept(down2, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
const started = Date.now();
|
||||
worker.connect(up2, { principal: { kind: "server" } });
|
||||
for (let i = 0; i < 400 && worker.codeVersion !== server.codeVersion; i++)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
assert.ok(Date.now() - started < 2000, `reconciled after ${Date.now() - started} ms`);
|
||||
});
|
||||
|
||||
it("forced retirement cancels a browser call relayed to a worker before the server closes the old code's resources", async () => {
|
||||
const log: string[] = [];
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
onTestFinished(() => server.close());
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => worker.close());
|
||||
const never = latch();
|
||||
const Slow = remote("t.cluster-slow", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: { main: { methods: { wait: method() } } },
|
||||
});
|
||||
worker.serve(Slow, () => ({
|
||||
variant: "main",
|
||||
wait: async (ctx: Ctx) => {
|
||||
ctx.signal.addEventListener("abort", () => log.push("worker aborted"));
|
||||
await never.promise;
|
||||
},
|
||||
}));
|
||||
// Records the cancel the server sends down: the guarantee is that it leaves before the old resources close.
|
||||
const watched = dropping(down, (frame) => {
|
||||
if (frame.t === "cancel") log.push("cancel sent");
|
||||
return false;
|
||||
});
|
||||
server.route("session:", {
|
||||
authorize: () => true,
|
||||
resolve: async () => watched,
|
||||
principal: (name) => ({ host: name }),
|
||||
});
|
||||
const Store = local("t.cluster-store", { version: 1, input: obj({}, { open: true }) });
|
||||
const storeFacet = (version: number): Facet => ({
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Store,
|
||||
(_input, _principal, self) => {
|
||||
self.resource("conn", {
|
||||
version,
|
||||
open: () => ({}),
|
||||
close: () => void log.push(`server closes ${version}`),
|
||||
});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
await server.install(storeFacet(1));
|
||||
const [browserDown, browserUp] = memoryLinkPair();
|
||||
server.accept(browserDown, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: browserUp, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
// The relayed call's root runs on the server's code version 1.
|
||||
const call = browser.hold(Slow, {}).wait();
|
||||
call.catch(() => {});
|
||||
for (let i = 0; i < 50 && log.length === 0 && !worker.codeVersion; i++) await tick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await server.reload([storeFacet(2)]);
|
||||
await server.reload([storeFacet(3)], { retireDeadline: 20 });
|
||||
const settled = await Promise.race([
|
||||
call.then(
|
||||
() => "resolved",
|
||||
() => "rejected",
|
||||
),
|
||||
new Promise((resolve) => setTimeout(() => resolve("pending"), 2000)),
|
||||
]);
|
||||
assert.equal(settled, "rejected", "the browser's call is cancelled with its root");
|
||||
for (let i = 0; i < 50 && !log.includes("worker aborted"); i++) await tick();
|
||||
assert.deepEqual(
|
||||
log.filter((entry) => entry === "cancel sent" || entry === "server closes 1"),
|
||||
["cancel sent", "server closes 1"],
|
||||
);
|
||||
assert.ok(log.includes("worker aborted"));
|
||||
never.open();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { bool, num, obj, opt, str, unknown } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
bind,
|
||||
coverage,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
method,
|
||||
opaque,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
import { canonicalIdentity, fits, normalize } from "../../src/old/schema.ts";
|
||||
|
||||
const View = obj({ text: str() }, { open: true });
|
||||
|
||||
const chatV1 = remote("t.chat", {
|
||||
version: 1,
|
||||
input: obj({ session: str(), conversation: num({ default: 0 }) }, { open: true }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: {
|
||||
viewer: { state: { view: View } },
|
||||
participant: {
|
||||
state: { view: View },
|
||||
methods: { send: method(obj({ text: str() }, { open: true }), obj({ id: num() }, { open: true })) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("contracts", () => {
|
||||
it("treats `format` as an annotation: an input with format email accepts any string", () => {
|
||||
const Mail = local("t.mail", { version: 1, input: obj({ to: str({ format: "email" }) }) });
|
||||
assert.deepEqual(normalize(Mail.input, { to: "nope" }, "input"), { to: "nope" });
|
||||
});
|
||||
|
||||
it("normalizes input and omits defaults from the identity", () => {
|
||||
const a = normalize(chatV1.input, { session: "s", extra: 1 }, "input");
|
||||
assert.deepEqual(a, { session: "s", conversation: 0 });
|
||||
const b = normalize(chatV1.input, { conversation: 0, session: "s" }, "input");
|
||||
assert.equal(canonicalIdentity(chatV1.input, a), canonicalIdentity(chatV1.input, b));
|
||||
assert.equal(canonicalIdentity(chatV1.input, a), '{"session":"s"}');
|
||||
assert.throws(
|
||||
() => normalize(chatV1.input, { session: 1 }, "input"),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "input",
|
||||
);
|
||||
});
|
||||
|
||||
it("fingerprints are stable and description-based", () => {
|
||||
const copy = remote("t.chat", chatV1.def);
|
||||
assert.equal(copy.fingerprint, chatV1.fingerprint);
|
||||
assert.notEqual(remote("t.chat", { ...chatV1.def, version: 2 }).fingerprint, chatV1.fingerprint);
|
||||
});
|
||||
|
||||
it("rejects reserved member names and opaque remote members", () => {
|
||||
assert.throws(() => local("t.x", { version: 1, input: obj({}, { open: true }), members: { status: method() } }));
|
||||
assert.throws(() =>
|
||||
remote("t.x", {
|
||||
host: hostNamed("server"),
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
variants: { a: { state: { h: opaque("x@1") as never } } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("covers additive changes and refuses breaking ones", () => {
|
||||
const additive = remote("t.chat", {
|
||||
...chatV1.def,
|
||||
variants: {
|
||||
...chatV1.def.variants,
|
||||
participant: {
|
||||
state: { view: obj({ text: str(), extra: num() }, { open: true }) },
|
||||
methods: {
|
||||
send: method(
|
||||
obj({ text: str(), urgent: opt(bool()) }, { open: true }),
|
||||
obj({ id: num(), at: num() }, { open: true }),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(coverage(additive.description, chatV1.description), undefined);
|
||||
assert.equal(bind(additive, undefined, chatV1), undefined);
|
||||
|
||||
const required = remote("t.chat", {
|
||||
...chatV1.def,
|
||||
variants: {
|
||||
...chatV1.def.variants,
|
||||
participant: {
|
||||
state: { view: View },
|
||||
methods: {
|
||||
send: method(
|
||||
obj({ text: str(), urgent: bool() }, { open: true }),
|
||||
obj({ id: num() }, { open: true }),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.match(coverage(required.description, chatV1.description) ?? "", /arguments/);
|
||||
assert.throws(
|
||||
() => bind(required, undefined, chatV1),
|
||||
(error) => isChordError(error, "contract_mismatch"),
|
||||
);
|
||||
|
||||
const retyped = remote("t.chat", {
|
||||
...chatV1.def,
|
||||
variants: { ...chatV1.def.variants, viewer: { state: { view: obj({ text: num() }, { open: true }) } } },
|
||||
});
|
||||
assert.match(coverage(retyped.description, chatV1.description) ?? "", /state/);
|
||||
});
|
||||
|
||||
it("methods without arguments or result: described without them; their presence must match, a void consumer accepts a result", () => {
|
||||
const of = (members: Parameters<typeof local>[1]["members"]) =>
|
||||
local("t.methods", { version: 1, input: obj({}, { open: true }), members }).description;
|
||||
const none = of({ m: method() });
|
||||
assert.deepEqual(none.variants[""]?.m, { kind: "method" });
|
||||
const withArgs = of({ m: method(str()) });
|
||||
const withResult = of({ m: method(undefined, num()) });
|
||||
assert.equal(coverage(none, none), undefined);
|
||||
assert.match(coverage(withArgs, none) ?? "", /arguments/, "the consumer sends none, the provider expects some");
|
||||
assert.match(coverage(none, withArgs) ?? "", /arguments/, "the consumer sends some, the provider takes none");
|
||||
assert.equal(coverage(withResult, none), undefined, "a void consumer ignores a result");
|
||||
assert.match(coverage(none, withResult) ?? "", /result/, "a consumer expecting a result needs one");
|
||||
});
|
||||
|
||||
it("binds other versions only through accepts adapters", () => {
|
||||
const v2 = remote("t.chat", { ...chatV1.def, version: 2, variants: { viewer: { state: { body: View } } } });
|
||||
assert.throws(
|
||||
() => bind(v2, undefined, chatV1),
|
||||
(error) => isChordError(error, "contract_mismatch"),
|
||||
);
|
||||
const adapter = (current: Record<string, unknown>) => ({ variant: "viewer", view: current.body });
|
||||
assert.equal(bind(v2, { 1: adapter }, chatV1), adapter);
|
||||
});
|
||||
|
||||
it("compares opaque members by name@version and function members structurally", () => {
|
||||
const a = local("t.idx", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: {
|
||||
harness: opaque("h@1"),
|
||||
onChange: method({ type: "function", parameters: [str()], returnType: { type: "void" } }),
|
||||
},
|
||||
});
|
||||
const b = local("t.idx", { ...a.def, members: { ...a.def.members, harness: opaque("h@2") } });
|
||||
assert.match(coverage(b.description, a.description) ?? "", /opaque/);
|
||||
assert.ok(
|
||||
fits(
|
||||
{ type: "function", parameters: [unknown()], returnType: { type: "void" } },
|
||||
{ type: "function", parameters: [str()], returnType: { type: "void" } },
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!fits(
|
||||
{ type: "function", parameters: [str()], returnType: { type: "void" } },
|
||||
{ type: "function", parameters: [unknown()], returnType: { type: "void" } },
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
// A link wrapper that counts what crosses it, per direction: frames by type and bytes (JSON text length). It can also
|
||||
// hold back the acknowledgements its own side sends (`paused`), to fill the peer's flow-control window.
|
||||
import type { Frame, Link } from "../../../src/old/index.ts";
|
||||
|
||||
export class CountingLink implements Link {
|
||||
readonly inner: Link;
|
||||
/** Frames this side sent, in order. */
|
||||
readonly sent: Frame[] = [];
|
||||
/** Frames this side received, in order. */
|
||||
readonly received: Frame[] = [];
|
||||
sentBytes = 0;
|
||||
receivedBytes = 0;
|
||||
/** Acknowledgements are held back (the peer's window fills up). */
|
||||
paused = false;
|
||||
#held: Frame | undefined;
|
||||
|
||||
constructor(inner: Link) {
|
||||
this.inner = inner;
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.inner.closed;
|
||||
}
|
||||
|
||||
send(frame: Frame): number | undefined {
|
||||
if (frame.t === "ack" && this.paused) {
|
||||
this.#held = frame;
|
||||
return 0;
|
||||
}
|
||||
this.sent.push(frame);
|
||||
this.sentBytes += JSON.stringify(frame).length;
|
||||
return this.inner.send(frame);
|
||||
}
|
||||
|
||||
/** Lets the held acknowledgement through and stops holding them. */
|
||||
release(): void {
|
||||
this.paused = false;
|
||||
const held = this.#held;
|
||||
this.#held = undefined;
|
||||
if (held !== undefined) this.inner.send(held);
|
||||
}
|
||||
|
||||
receive(handler: (frame: Frame) => void): void {
|
||||
this.inner.receive((frame) => {
|
||||
this.received.push(frame);
|
||||
this.receivedBytes += JSON.stringify(frame).length;
|
||||
handler(frame);
|
||||
});
|
||||
}
|
||||
|
||||
onClose(handler: (reason: string) => void): void {
|
||||
this.inner.onClose(handler);
|
||||
}
|
||||
|
||||
close(reason?: string): void {
|
||||
this.inner.close(reason);
|
||||
}
|
||||
|
||||
/** Frames of type `t` this side sent (`sent`) or received (`received`). */
|
||||
count(direction: "sent" | "received", t: Frame["t"]): number {
|
||||
return this[direction].filter((frame) => frame.t === t).length;
|
||||
}
|
||||
|
||||
/** Frames this side sent or received, except pings, pongs and acknowledgements. */
|
||||
traffic(direction: "sent" | "received"): Frame[] {
|
||||
return this[direction].filter((frame) => frame.t !== "ping" && frame.t !== "pong" && frame.t !== "ack");
|
||||
}
|
||||
}
|
||||
|
||||
export type StateFrame = Extract<Frame, { t: "state" }>;
|
||||
|
||||
export const stateFrames = (frames: readonly Frame[]): StateFrame[] =>
|
||||
frames.filter((frame): frame is StateFrame => frame.t === "state");
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { hostNamed, method, remote } from "../../../src/old/index.ts";
|
||||
|
||||
export const Echo = remote("t.echo", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("w1"),
|
||||
variants: {
|
||||
main: { methods: { echo: method(str(), str()), pid: method(undefined, num()) } },
|
||||
},
|
||||
});
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
// Runs the node loader outside vitest: its module runner bypasses the Node resolve hooks the loader installs.
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { bool, obj, str } from "@earendil-works/pi-schema";
|
||||
import { createHost, local, method } from "../../../src/old/index.ts";
|
||||
import { nodeLoader } from "../../../src/old/loader.ts";
|
||||
|
||||
// Plugin modules live under the package so bare imports (pi-schema) resolve; removed afterwards.
|
||||
const dir = fileURLToPath(new URL(`../.tmp-plugins-${process.pid}/`, import.meta.url));
|
||||
// Separate from `dir`, which becomes a reload root once its facet loads.
|
||||
const plugins = fileURLToPath(new URL(`../.tmp-plugins-${process.pid}-order/`, import.meta.url));
|
||||
const chord = new URL("../../../src/old/index.ts", import.meta.url).href;
|
||||
|
||||
// The test's own copy of the plugin's contract: binding is by description, not object identity.
|
||||
const Hello = local("t.hello", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { greet: method(str(), str()) },
|
||||
});
|
||||
|
||||
const facetSource = `
|
||||
import { obj, str } from "@earendil-works/pi-schema";
|
||||
import { defineFacet, local, method } from ${JSON.stringify(chord)};
|
||||
import { greeting } from "./greeting.ts";
|
||||
|
||||
const Hello = local("t.hello", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { greet: method(str(), str()) },
|
||||
});
|
||||
|
||||
export default defineFacet((host) => {
|
||||
host.serve(Hello, () => ({ greet: (name) => greeting + " " + name }));
|
||||
});
|
||||
`;
|
||||
|
||||
const writeGreeting = (text: string) =>
|
||||
writeFileSync(`${dir}greeting.ts`, `export const greeting = ${JSON.stringify(text)};\n`);
|
||||
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(`${dir}facet.ts`, facetSource);
|
||||
writeGreeting("v1");
|
||||
const host = createHost({ self: { kind: "host" }, loader: nodeLoader, onError: () => {} });
|
||||
await host.load([`${dir}facet.ts`]);
|
||||
const ref = host.hold(Hello, {});
|
||||
assert.equal(await ref.greet("x"), "v1 x");
|
||||
writeGreeting("v2");
|
||||
const report = await host.reload([`${dir}facet.ts`]);
|
||||
assert.deepEqual(report.changed, ["t.hello"]);
|
||||
assert.equal(await ref.greet("x"), "v2 x");
|
||||
await host.close();
|
||||
|
||||
// A facet listed first that imports a later facet's directory shares that facet's copy of the module.
|
||||
const Shared = local("t.shared", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { same: method(undefined, bool()) },
|
||||
});
|
||||
mkdirSync(`${plugins}a`, { recursive: true });
|
||||
mkdirSync(`${plugins}b`, { recursive: true });
|
||||
writeFileSync(`${plugins}b/state.ts`, "export const state = {};\n");
|
||||
writeFileSync(
|
||||
`${plugins}b/facet.ts`,
|
||||
`import { defineFacet } from ${JSON.stringify(chord)};\nimport { state } from "./state.ts";\nexport const own = state;\nexport default defineFacet(() => {});\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
`${plugins}a/facet.ts`,
|
||||
`import { bool, obj } from "@earendil-works/pi-schema";\nimport { defineFacet, local, method } from ${JSON.stringify(chord)};\nimport { state } from "../b/state.ts";\n` +
|
||||
`const Shared = local("t.shared", { version: 1, input: obj({}, { open: true }), members: { same: method(undefined, bool()) } });\n` +
|
||||
`export default defineFacet((host) => host.serve(Shared, () => ({ same: async () => (await import(new URL("../b/facet.ts", import.meta.url).href)).own === state })));\n`,
|
||||
);
|
||||
const ordered = createHost({ self: { kind: "host" }, loader: nodeLoader, onError: () => {} });
|
||||
await ordered.load([`${plugins}a/facet.ts`, `${plugins}b/facet.ts`]);
|
||||
assert.equal(await ordered.hold(Shared, {}).same(), true);
|
||||
await ordered.close();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(plugins, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
// Schemas with the exact JSON TypeBox's `Type.Literal` and `Type.Record` built, so contracts declared with them keep
|
||||
// the fingerprints they had under TypeBox (see the migration notes in CHANGELOG.md).
|
||||
|
||||
import type { Schema } from "@earendil-works/pi-schema";
|
||||
|
||||
/** `Type.Literal(value)`: the literal with its JSON type (pi-schema's `lit` has no `type`). */
|
||||
export function typedLit<const V extends string | number | boolean>(value: V): Schema<V> {
|
||||
return { type: typeof value, const: value };
|
||||
}
|
||||
|
||||
/** `Type.Record(Type.String(), value)`: one pattern property for every key (pi-schema's `map` uses `additionalProperties`). */
|
||||
export function record<T>(value: Schema<T>): Schema<Record<string, T>> {
|
||||
return { type: "object", patternProperties: { "^.*$": value } };
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
// Child host for manager tests. FIXTURE_MODE: normal | ignore-shutdown | fatal.
|
||||
import { spawn } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { createHost, type IpcChannel, ipcLink } from "../../../src/old/index.ts";
|
||||
import { childControl } from "../../../src/old/manager.ts";
|
||||
import { Echo } from "./echo.ts";
|
||||
|
||||
const mode = process.env.FIXTURE_MODE ?? "normal";
|
||||
const control = childControl();
|
||||
if (mode === "fatal") {
|
||||
control.fatal(new Error("fixture refuses to start"));
|
||||
setTimeout(() => process.exit(1), 200);
|
||||
} else {
|
||||
// Before anything that owns data: the kernel lock (if the manager configured one).
|
||||
await control.lock().catch(() => {
|
||||
setTimeout(() => process.exit(1), 200);
|
||||
return new Promise<never>(() => {});
|
||||
});
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: process.env.HOST_NAME ?? "worker",
|
||||
upstream: { link: ipcLink(process as unknown as IpcChannel), principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const init = (await control.init) as { greeting: string };
|
||||
host.serve(Echo, () => ({
|
||||
variant: "main",
|
||||
echo: (text: string) => `${init.greeting} ${text} from ${process.pid}`,
|
||||
pid: () => process.pid,
|
||||
}));
|
||||
if (process.env.GRANDCHILD_FILE) {
|
||||
const grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
|
||||
writeFileSync(process.env.GRANDCHILD_FILE, String(grandchild.pid));
|
||||
}
|
||||
control.onShutdown(async () => {
|
||||
if (mode === "ignore-shutdown") return;
|
||||
await host.close();
|
||||
process.exit(0);
|
||||
});
|
||||
control.ready();
|
||||
}
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
// Entry 8: flow control with units. First units pass the window, so a get never waits behind other streams; waiting
|
||||
// ops are capped per link; relays ask downstream for a reset when they drop ops.
|
||||
import assert from "node:assert/strict";
|
||||
import { arr, num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import { HostImpl } from "../../src/old/host.ts";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
type Link,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
import { CountingLink, stateFrames } from "./fixtures/counting.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 400): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
const Blob = remote("t.e8-blob", {
|
||||
version: 1,
|
||||
input: obj({ name: str() }, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { lines: arr(str()) },
|
||||
methods: { push: method(str()), ping: method(undefined, str()) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** About `kib` KiB of lines. */
|
||||
const lines = (kib: number, tag: string) => Array.from({ length: kib }, (_, i) => `${tag}${i}:${"x".repeat(1000)}`);
|
||||
|
||||
function serveBlob(host: Host, size: (name: string) => number): void {
|
||||
host.serve(Blob, ({ name }, _principal, self) => {
|
||||
const state = self.state("lines", arr(str()), lines(size(name), name));
|
||||
return {
|
||||
variant: "main",
|
||||
lines: state,
|
||||
push: (line: string, ctx: Ctx) => {
|
||||
state.change(ctx, (draft) => {
|
||||
draft.push(line);
|
||||
});
|
||||
},
|
||||
ping: () => "pong",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function client(server: Host, window: number, pendingCap?: number) {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" }, window, ...(pendingCap === undefined ? {} : { pendingCap }) });
|
||||
const link = new CountingLink(up);
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const connection = () => [...(server as HostImpl).connections].find((candidate) => candidate.role === "downstream")!;
|
||||
return { host, link, connection };
|
||||
}
|
||||
|
||||
describe("flow control with units", () => {
|
||||
it("with large streams waiting behind a full window, a new get resolves at once and calls are answered", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveBlob(server, (name) => (name === "small" ? 1 : 500));
|
||||
const { host, link } = client(server, 64 * 1024);
|
||||
const bigs = ["a", "b", "c"].map((name) => host.hold(Blob, { name }));
|
||||
await Promise.all(bigs.map((big) => big.ready));
|
||||
link.paused = true;
|
||||
// Changes to every large stream now wait for acknowledgements that do not come.
|
||||
for (const big of bigs) for (let i = 0; i < 50; i++) await big.push(`${i}:${"y".repeat(2000)}`);
|
||||
const small = host.hold(Blob, { name: "small" });
|
||||
await small.ready;
|
||||
assert.equal(small.lines.value.length, 1);
|
||||
assert.equal(await bigs[0]!.ping(), "pong");
|
||||
assert.ok(bigs[0]!.lines.value.length < 550, "the large streams still wait");
|
||||
link.release();
|
||||
await until(() => bigs.every((big) => big.lines.value.length === 550));
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a snapshot larger than the window arrives whole; later changes wait for acknowledgements", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveBlob(server, () => 300);
|
||||
const { host, link } = client(server, 16 * 1024);
|
||||
link.paused = true;
|
||||
const big = host.hold(Blob, { name: "chat" });
|
||||
await big.ready;
|
||||
assert.equal(big.lines.value.length, 300);
|
||||
for (let i = 0; i < 40; i++) await big.push(`${i}:${"z".repeat(1000)}`);
|
||||
assert.ok(big.lines.value.length < 340);
|
||||
link.release();
|
||||
await until(() => big.lines.value.length === 340);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a link that reports no sizes (IPC) never holds state back", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveBlob(server, () => 1);
|
||||
const [down, up] = memoryLinkPair();
|
||||
// A link without sizes: what Node's IPC channel is.
|
||||
const unsized: Link = {
|
||||
get closed() {
|
||||
return down.closed;
|
||||
},
|
||||
send: (frame: Frame) => {
|
||||
down.send(frame);
|
||||
return undefined;
|
||||
},
|
||||
receive: (handler) => down.receive(handler),
|
||||
onClose: (handler) => down.onClose(handler),
|
||||
close: (reason) => down.close(reason),
|
||||
};
|
||||
server.accept(unsized, { principal: { user: "u" }, window: 1 });
|
||||
const link = new CountingLink(up);
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "worker",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
link.paused = true;
|
||||
const blob = host.hold(Blob, { name: "x" });
|
||||
await blob.ready;
|
||||
for (let i = 0; i < 20; i++) await blob.push(`${i}`);
|
||||
await until(() => blob.lines.value.length === 21);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("waiting ops stay under the per-link cap however many streams stall; each then gets one reset", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveBlob(server, () => 1);
|
||||
const cap = 8 * 1024;
|
||||
const { host, link, connection } = client(server, 1024, cap);
|
||||
const K = 6;
|
||||
const blobs = Array.from({ length: K }, (_, i) => host.hold(Blob, { name: `s${i}` }));
|
||||
await Promise.all(blobs.map((blob) => blob.ready));
|
||||
link.paused = true;
|
||||
let peak = 0;
|
||||
for (let round = 0; round < 30; round++) {
|
||||
for (const blob of blobs) await blob.push(`${round}:${"w".repeat(200)}`);
|
||||
peak = Math.max(peak, connection().waiting.bytes);
|
||||
}
|
||||
assert.ok(peak <= cap, `waiting ops ${peak} exceed the cap ${cap}`);
|
||||
const before = link.received.length;
|
||||
link.release();
|
||||
await until(() => blobs.every((blob) => blob.lines.value.length === 31));
|
||||
const resets = stateFrames(link.received.slice(before)).filter((frame) => frame.as === "reset");
|
||||
const per = new Map<number, number>();
|
||||
for (const frame of resets) per.set(frame.hold, (per.get(frame.hold) ?? 0) + 1);
|
||||
assert.ok(resets.length >= 1, "dropped streams came back as resets");
|
||||
for (const count of per.values()) assert.ok(count <= 2, "about one reset per stream");
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay topology", () => {
|
||||
const Counter = remote("t.e8-relayed", {
|
||||
version: 1,
|
||||
input: obj({ session: str() }, { open: true }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: { main: { state: { n: num() }, methods: { add: method(num()) } } },
|
||||
});
|
||||
|
||||
function cluster() {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
let worker: HostImpl | undefined;
|
||||
let toWorker: CountingLink | undefined;
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
if (toWorker !== undefined) return toWorker;
|
||||
const [down, up] = memoryLinkPair();
|
||||
const created = new HostImpl({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
created.serve(Counter, (_input, _principal, self) => {
|
||||
const n = self.state("n", num(), 0);
|
||||
return {
|
||||
variant: "main",
|
||||
n,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
n.replace(ctx, n.value + delta);
|
||||
},
|
||||
};
|
||||
});
|
||||
worker = created;
|
||||
toWorker = new CountingLink(down);
|
||||
return toWorker;
|
||||
},
|
||||
});
|
||||
const browser = (principal: unknown, window?: number) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal, ...(window === undefined ? {} : { window, pendingCap: window * 2 }) });
|
||||
const link = new CountingLink(up);
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
return { host, link, up };
|
||||
};
|
||||
const served = () => [...(worker?.upstream?.served.values() ?? [])];
|
||||
const relayed = () =>
|
||||
[...(server as HostImpl).connections]
|
||||
.filter((connection) => connection.peer.kind === "host")
|
||||
.reduce((sum, connection) => sum + connection.relayedRefs.size, 0);
|
||||
return { server, browser, served, relayed, toWorker: () => toWorker! };
|
||||
}
|
||||
|
||||
it("each origin hold is one downstream hold; nothing is left after release, close or revoke", async () => {
|
||||
const c = cluster();
|
||||
// Two tabs of one guest (two connections, one principal) and another guest.
|
||||
const one = c.browser({ guest: "g" });
|
||||
const two = c.browser({ guest: "g" });
|
||||
const three = c.browser({ guest: "h" });
|
||||
const a = one.host.hold(Counter, { session: "S1" });
|
||||
const a2 = one.host.hold(Counter, { session: "S1" });
|
||||
const b = two.host.hold(Counter, { session: "S1" });
|
||||
const d = three.host.hold(Counter, { session: "S1" });
|
||||
await Promise.all([a.ready, a2.ready, b.ready, d.ready]);
|
||||
assert.equal(c.toWorker().count("sent", "hold"), 3, "two tabs and another principal: three holds");
|
||||
assert.equal(c.served().length, 3);
|
||||
a.release();
|
||||
a2.release();
|
||||
await until(() => c.served().length === 2 && c.relayed() === 2);
|
||||
two.up.close();
|
||||
await until(() => c.served().length === 1 && c.relayed() === 1);
|
||||
await c.server.invalidatePrincipal({ guest: "h" });
|
||||
await until(() => c.served().length === 0 && c.relayed() === 0);
|
||||
await c.server.close();
|
||||
});
|
||||
|
||||
it("a stalled tab overflows and gets a reset while another tab keeps getting every change", async () => {
|
||||
const c = cluster();
|
||||
const slow = c.browser({ guest: "g" }, 256);
|
||||
const fast = c.browser({ guest: "g" });
|
||||
const stalled = slow.host.hold(Counter, { session: "S1" });
|
||||
const live = fast.host.hold(Counter, { session: "S1" });
|
||||
await Promise.all([stalled.ready, live.ready]);
|
||||
slow.link.paused = true;
|
||||
const seen: number[] = [];
|
||||
live.n.subscribe((value) => seen.push(value));
|
||||
for (let i = 0; i < 200; i++) await live.add(1);
|
||||
await until(() => live.n.value === 200);
|
||||
assert.ok(seen.length > 100, "the fast tab saw the changes as they came");
|
||||
assert.ok(stalled.n.value < 200, "the stalled tab waits");
|
||||
assert.ok(c.toWorker().count("sent", "resync") >= 1, "the relay asked the worker for a reset");
|
||||
slow.link.release();
|
||||
await until(() => stalled.n.value === 200);
|
||||
await c.server.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
// Link flow control: state waits while the peer's acknowledgements lag a window behind, coalesced per subscription;
|
||||
// everything else is sent at once.
|
||||
import assert from "node:assert/strict";
|
||||
import { num, obj } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
type Link,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 200): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
/** A client's end of a link that can hold back its acknowledgements, and records what it receives. */
|
||||
class AckGate implements Link {
|
||||
readonly inner: Link;
|
||||
readonly received: Frame[] = [];
|
||||
paused = false;
|
||||
#held: Frame | undefined;
|
||||
constructor(inner: Link) {
|
||||
this.inner = inner;
|
||||
}
|
||||
get closed(): boolean {
|
||||
return this.inner.closed;
|
||||
}
|
||||
send(frame: Frame): number | undefined {
|
||||
if (frame.t === "ack" && this.paused) {
|
||||
this.#held = frame;
|
||||
return 0;
|
||||
}
|
||||
return this.inner.send(frame);
|
||||
}
|
||||
release(): void {
|
||||
this.paused = false;
|
||||
if (this.#held !== undefined) this.inner.send(this.#held);
|
||||
this.#held = undefined;
|
||||
}
|
||||
receive(handler: (frame: Frame) => void): void {
|
||||
this.inner.receive((frame) => {
|
||||
this.received.push(frame);
|
||||
handler(frame);
|
||||
});
|
||||
}
|
||||
onClose(handler: (reason: string) => void): void {
|
||||
this.inner.onClose(handler);
|
||||
}
|
||||
close(reason?: string): void {
|
||||
this.inner.close(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/** A counter on the server, or (`t.flow-worker-counter`) on the worker host `w`. */
|
||||
const counterOn = (name: string, host: string) =>
|
||||
remote(name, {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed(host),
|
||||
variants: {
|
||||
main: {
|
||||
state: { count: obj({ n: num() }, { open: true }) },
|
||||
methods: { add: method(num(), num()), wait: method() },
|
||||
},
|
||||
},
|
||||
});
|
||||
const Counter = counterOn("t.flow-counter", "server");
|
||||
const WorkerCounter = counterOn("t.flow-worker-counter", "w");
|
||||
|
||||
let release = () => {};
|
||||
const waiting = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
const previous = release;
|
||||
release = () => {
|
||||
previous();
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
function serveCounter(host: Host, type = Counter): void {
|
||||
host.serve(type, (_input, _principal, self) => {
|
||||
const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 });
|
||||
return {
|
||||
variant: "main",
|
||||
count,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += delta;
|
||||
});
|
||||
return count.value.n;
|
||||
},
|
||||
wait: waiting,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** A client of `server` (accepted with `window`) whose acknowledgements can be held back. */
|
||||
function client(server: Host, window: number) {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" }, window });
|
||||
const gate = new AckGate(up);
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: gate, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
return { host, gate };
|
||||
}
|
||||
|
||||
const stateFrames = (frames: readonly Frame[]) =>
|
||||
frames.filter((frame): frame is Extract<Frame, { t: "state" }> => frame.t === "state");
|
||||
|
||||
describe("link flow control", () => {
|
||||
it("holds state back while the window is full, answers calls meanwhile, and sends one coalesced frame", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const { host, gate } = client(server, 200);
|
||||
const counter = host.hold(Counter, {});
|
||||
const values: unknown[] = [];
|
||||
counter.count.subscribe((value) => values.push(value));
|
||||
await until(() => values.at(-1) !== undefined);
|
||||
|
||||
gate.paused = true;
|
||||
const results: number[] = [];
|
||||
for (let i = 0; i < 40; i++) results.push((await counter.add(1)) as number);
|
||||
// Every call was answered, though most state is still waiting for an acknowledgement.
|
||||
assert.equal(results.at(-1), 40);
|
||||
assert.ok((values.at(-1) as { n: number }).n < 40, "state waits behind the window");
|
||||
const before = stateFrames(gate.received).length;
|
||||
|
||||
gate.release();
|
||||
await until(() => (values.at(-1) as { n: number }).n === 40);
|
||||
assert.equal(stateFrames(gate.received).length - before, 1, "the waiting ops arrive as one frame");
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("replaces ops that outgrow their slot with the current value", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const { host, gate } = client(server, 64);
|
||||
const counter = host.hold(Counter, {});
|
||||
const values: unknown[] = [];
|
||||
counter.count.subscribe((value) => values.push(value));
|
||||
await until(() => values.at(-1) !== undefined);
|
||||
|
||||
gate.paused = true;
|
||||
const before = gate.received.length;
|
||||
for (let i = 0; i < 100; i++) await counter.add(1);
|
||||
gate.release();
|
||||
await until(() => (values.at(-1) as { n: number }).n === 100);
|
||||
const sent = stateFrames(gate.received.slice(before));
|
||||
assert.ok(
|
||||
sent.some((frame) => frame.as === "reset"),
|
||||
"a reset replaced the ops",
|
||||
);
|
||||
assert.ok(sent.length < 10, `few state frames, not one per change (${sent.length})`);
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("drops what waits for a reference that is released", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const { host, gate } = client(server, 64);
|
||||
const counter = host.hold(Counter, {});
|
||||
await counter.ready;
|
||||
const other = host.hold(Counter, {});
|
||||
await other.ready;
|
||||
|
||||
gate.paused = true;
|
||||
for (let i = 0; i < 10; i++) await counter.add(1);
|
||||
counter.release();
|
||||
other.release();
|
||||
const before = gate.received.length;
|
||||
gate.release();
|
||||
await sleep(10);
|
||||
assert.deepEqual(stateFrames(gate.received.slice(before)), []);
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("coalesces relayed state for the origin and asks downstream for a reset when it overflows", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const toWorker: Frame[] = [];
|
||||
server.route("w", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
serveCounter(worker, WorkerCounter);
|
||||
return {
|
||||
get closed() {
|
||||
return down.closed;
|
||||
},
|
||||
send: (frame: Frame) => {
|
||||
toWorker.push(frame);
|
||||
return down.send(frame);
|
||||
},
|
||||
receive: (handler) => down.receive(handler),
|
||||
onClose: (handler) => down.onClose(handler),
|
||||
close: (reason) => down.close(reason),
|
||||
} satisfies Link;
|
||||
},
|
||||
});
|
||||
const { host, gate } = client(server, 64);
|
||||
const counter = host.hold(WorkerCounter, {});
|
||||
const values: unknown[] = [];
|
||||
counter.count.subscribe((value) => values.push(value));
|
||||
await until(() => values.at(-1) !== undefined);
|
||||
const resyncs = () => toWorker.filter((frame) => frame.t === "resync").length;
|
||||
assert.equal(resyncs(), 0);
|
||||
|
||||
gate.paused = true;
|
||||
for (let i = 0; i < 100; i++) await counter.add(1);
|
||||
// An overflow drops the waiting ops and asks the worker for a reset (which then waits in the slot).
|
||||
assert.ok(resyncs() >= 1, "the overflowing stream was resynced downstream");
|
||||
assert.ok(!toWorker.some((frame) => frame.t === "sub" || frame.t === "unsub"));
|
||||
gate.release();
|
||||
await until(() => (values.at(-1) as { n: number }).n === 100);
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("neither windows nor acknowledges a peer whose hello does not announce acks", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" }, window: 1 });
|
||||
const frames: Frame[] = [];
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
raw.send({ t: "hold", id: 1, at: "server", token: Counter.description, input: {} });
|
||||
for (let i = 0; i < 5; i++) raw.send({ t: "call", id: 3 + i, hold: 1, member: "add", args: 1 });
|
||||
await until(() => frames.filter((frame) => frame.t === "result").length === 5);
|
||||
await sleep(5);
|
||||
assert.equal(stateFrames(frames).length, 6, "the snapshot and one frame per change");
|
||||
assert.ok(!frames.some((frame) => frame.t === "ack"));
|
||||
assert.equal((frames[0] as Extract<Frame, { t: "hello" }>).acks, true);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("closes the link on an acknowledgement of frames never sent", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", name: "raw", kind: "client", codeVersion: 0, acks: true });
|
||||
raw.send({ t: "ack", n: 99 });
|
||||
await until(() => raw.closed);
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("link call limit", () => {
|
||||
it("answers calls beyond maxCalls at once with too_many_calls and keeps the link", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" }, maxCalls: 2 });
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const counter = host.hold(Counter, {});
|
||||
await counter.ready;
|
||||
release = () => {};
|
||||
const first = counter.wait();
|
||||
const second = counter.wait();
|
||||
await assert.rejects(
|
||||
counter.add(1),
|
||||
(error) => isChordError(error, "unavailable") && error.reason === "too_many_calls",
|
||||
);
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(await counter.add(1), 1);
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
// Old code generations and disposed objects must be collectable. A "generation" is the code of one facet module
|
||||
// evaluation: here a facet built by a function call, whose factory closes over a per-generation sentinel registered in a
|
||||
// FinalizationRegistry. A generation is alive while anything Chord keeps references its code.
|
||||
import assert from "node:assert/strict";
|
||||
import { setFlagsFromString } from "node:v8";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { num, obj } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import { createHost, defineRegistry, type Facet, local, method } from "../../src/old/index.ts";
|
||||
|
||||
setFlagsFromString("--expose-gc");
|
||||
const gc = runInNewContext("gc") as () => void;
|
||||
|
||||
/** Tracks which sentinels are still reachable. */
|
||||
function tracker() {
|
||||
const alive = new Set<number>();
|
||||
const registry = new FinalizationRegistry<number>((id) => alive.delete(id));
|
||||
return {
|
||||
alive,
|
||||
track<T extends object>(id: number, value: T): T {
|
||||
alive.add(id);
|
||||
registry.register(value, id);
|
||||
return value;
|
||||
},
|
||||
/**
|
||||
* Collects garbage until only `expected` is alive, at most `timeout` ms (finalizers run some time after a
|
||||
* collection), and returns what is alive then.
|
||||
*/
|
||||
async collect(expected: readonly number[], timeout = 5000): Promise<number[]> {
|
||||
const sorted = () => [...alive].sort((a, b) => a - b);
|
||||
const deadline = Date.now() + timeout;
|
||||
do {
|
||||
gc();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
if (sorted().join() === expected.join()) break;
|
||||
} while (Date.now() < deadline);
|
||||
return sorted();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Store = local("t.gen-store", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { get: method(undefined, num()), count: num() },
|
||||
});
|
||||
const Routes = defineRegistry<() => number>("t.gen-routes");
|
||||
const RELOADS = 6;
|
||||
|
||||
describe("generation GC", () => {
|
||||
it("a reference held across reloads keeps only the current generation alive", async () => {
|
||||
const generations = tracker();
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const facet = (generation: number): Facet => {
|
||||
const sentinel = generations.track(generation, { generation });
|
||||
return {
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(Store, (_input, _principal, self) => {
|
||||
const count = self.state("count", num(), 0);
|
||||
return { count, get: () => sentinel.generation };
|
||||
}),
|
||||
};
|
||||
};
|
||||
host.install(facet(0));
|
||||
const ref = host.hold(Store, {});
|
||||
await ref.ready;
|
||||
for (let generation = 1; generation <= RELOADS; generation++) {
|
||||
await host.reload([facet(generation)]);
|
||||
assert.equal(await ref.get(), generation);
|
||||
}
|
||||
assert.deepEqual(await generations.collect([RELOADS]), [RELOADS]);
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it('a lifetime "host" object with state, a kept resource, a listener, a task and registrations keeps only the current generation alive', async () => {
|
||||
const generations = tracker();
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const facet = (generation: number): Facet => {
|
||||
const sentinel = generations.track(generation, { generation });
|
||||
return {
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Store,
|
||||
(_input, _principal, self) => {
|
||||
const count = self.state("count", num(), 0);
|
||||
// Kept across reloads (same version): adopts each new declaration's close.
|
||||
self.resource("conn", {
|
||||
version: 1,
|
||||
open: () => ({ open: true }),
|
||||
close: () => void sentinel.generation,
|
||||
});
|
||||
self.on("count", count, () => void sentinel.generation);
|
||||
self.task(async (ctx) => {
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
void sentinel.generation;
|
||||
});
|
||||
self.register(Routes, { generation: () => sentinel.generation });
|
||||
return { count, get: () => sentinel.generation };
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
};
|
||||
};
|
||||
await host.install(facet(0));
|
||||
for (let generation = 1; generation <= RELOADS; generation++) {
|
||||
await host.reload([facet(generation)]);
|
||||
assert.equal(host.registry(Routes).get("generation")?.(), generation);
|
||||
}
|
||||
assert.deepEqual(await generations.collect([RELOADS]), [RELOADS]);
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it("abort listeners that calls and tasks add to their ctx.signal do not outlive them", async () => {
|
||||
const generations = tracker();
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const facet = (generation: number): Facet => {
|
||||
const sentinel = generations.track(generation, { generation });
|
||||
return {
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Store,
|
||||
(_input, _principal, self) => {
|
||||
const count = self.state("count", num(), 0);
|
||||
self.task(async (ctx) => {
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
void sentinel.generation;
|
||||
});
|
||||
return {
|
||||
count,
|
||||
get: (ctx) => {
|
||||
ctx.signal.addEventListener("abort", () => void sentinel.generation);
|
||||
return sentinel.generation;
|
||||
},
|
||||
};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
};
|
||||
};
|
||||
await host.install(facet(0));
|
||||
for (let generation = 1; generation <= RELOADS; generation++) {
|
||||
await host.reload([facet(generation)]);
|
||||
assert.equal(await host.root(async (ctx) => (await ctx.get(Store, {})).get(ctx)), generation);
|
||||
}
|
||||
assert.deepEqual(await generations.collect([RELOADS]), [RELOADS]);
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it("disposed objects are not kept once their disposal finished", async () => {
|
||||
const objects = tracker();
|
||||
const Item = local("t.gen-item", {
|
||||
version: 1,
|
||||
input: obj({ id: num() }, { open: true }),
|
||||
members: { id: method(undefined, num()) },
|
||||
});
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Item, (input) => {
|
||||
// The normalized input is held by the object: it stands for the object.
|
||||
const { id } = objects.track(input.id, input);
|
||||
return { id: () => id };
|
||||
});
|
||||
// In a function of its own, so no reference outlives the iteration in this frame.
|
||||
const use = async (id: number): Promise<void> => {
|
||||
const ref = host.hold(Item, { id });
|
||||
assert.equal(await ref.id(), id);
|
||||
ref.release();
|
||||
};
|
||||
for (let id = 0; id < 50; id++) await use(id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(await objects.collect([]), []);
|
||||
await host.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,346 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { bool, num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { onTestFinished, test } from "vitest";
|
||||
import { HostImpl } from "../../src/old/host.ts";
|
||||
import {
|
||||
createHost,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
type Self,
|
||||
} from "../../src/old/index.ts";
|
||||
import { record } from "./fixtures/schemas.ts";
|
||||
|
||||
const input = obj({}, { open: true });
|
||||
const flags = record(bool());
|
||||
const SwitchV1 = local("t.eager-switch", {
|
||||
version: 1,
|
||||
input,
|
||||
members: {
|
||||
enabled: bool(),
|
||||
set: method(bool()),
|
||||
},
|
||||
});
|
||||
const SwitchV2 = local("t.eager-switch", {
|
||||
version: 2,
|
||||
input,
|
||||
members: {
|
||||
enabled: flags,
|
||||
set: method(obj({ conversation: num(), enabled: bool() }, { open: true })),
|
||||
},
|
||||
});
|
||||
const SwitchV3 = local("t.eager-switch", {
|
||||
version: 3,
|
||||
input,
|
||||
members: {
|
||||
enabled: flags,
|
||||
update: method(obj({ conversation: num(), enabled: bool() }, { open: true })),
|
||||
},
|
||||
});
|
||||
const Read = defineRegistry<() => unknown>("t.eager-switch-read");
|
||||
|
||||
function switchFacets(log: string[]) {
|
||||
const declareResource = (self: Self) =>
|
||||
self.resource("watch", {
|
||||
version: 1,
|
||||
open: () => {
|
||||
log.push("open");
|
||||
return null;
|
||||
},
|
||||
close: () => {
|
||||
log.push("close");
|
||||
},
|
||||
});
|
||||
const v1: Facet = {
|
||||
name: "switch",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
SwitchV1,
|
||||
(_input, _principal, self) => {
|
||||
declareResource(self);
|
||||
const enabledState = self.state("enabled", bool(), false);
|
||||
self.register(Read, { current: () => enabledState.value });
|
||||
return {
|
||||
enabled: enabledState,
|
||||
set: (enabled, ctx) => {
|
||||
enabledState.replace(ctx, enabled);
|
||||
},
|
||||
};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
};
|
||||
const v2 = (valid: boolean): Facet => ({
|
||||
name: "switch",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
SwitchV2,
|
||||
(_input, _principal, self) => {
|
||||
declareResource(self);
|
||||
const enabledState = self.state(
|
||||
"enabled",
|
||||
flags,
|
||||
{},
|
||||
{
|
||||
version: 2,
|
||||
migrate: (old, from) => {
|
||||
assert.equal(from, 1);
|
||||
return valid ? { 1: old as boolean } : ("invalid migration" as never);
|
||||
},
|
||||
},
|
||||
);
|
||||
self.register(Read, { current: () => enabledState.value });
|
||||
return {
|
||||
enabled: enabledState,
|
||||
set: ({ conversation, enabled }, ctx) => {
|
||||
enabledState.change(ctx, (draft) => {
|
||||
draft[conversation] = enabled;
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
const v3: Facet = {
|
||||
name: "switch",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
SwitchV3,
|
||||
(_input, _principal, self) => {
|
||||
declareResource(self);
|
||||
const enabledState = self.state("enabled", flags, {}, { version: 2 });
|
||||
self.register(Read, { current: () => enabledState.value });
|
||||
return {
|
||||
enabled: enabledState,
|
||||
update: ({ conversation, enabled }, ctx) => {
|
||||
enabledState.change(ctx, (draft) => {
|
||||
draft[conversation] = enabled;
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
};
|
||||
return { v1, v2, v3 };
|
||||
}
|
||||
|
||||
test("the host's reference to a host object follows contract bumps at commit without reopening its object or resources; failures retain the old reference", async () => {
|
||||
const errors: unknown[] = [];
|
||||
const host = new HostImpl({ self: { kind: "host" }, dev: true, onError: (error) => errors.push(error) });
|
||||
onTestFinished(() => host.close());
|
||||
const log: string[] = [];
|
||||
const facets = switchFacets(log);
|
||||
await host.load([facets.v1]);
|
||||
const initial = host.hostObjects.get(SwitchV1.name)!;
|
||||
const object = initial.object;
|
||||
const original = host.hold(SwitchV1, {});
|
||||
await original.set(true);
|
||||
original.release();
|
||||
const codeVersion = host.codeVersion;
|
||||
await assert.rejects(host.reload([facets.v2(false)]), (error) => isChordError(error, "invalid"));
|
||||
assert.equal(host.codeVersion, codeVersion);
|
||||
assert.equal(host.hostObjects.get(SwitchV1.name), initial);
|
||||
assert.equal(initial.status, "live");
|
||||
assert.equal(host.registry(Read).get("current")!(), true);
|
||||
const report = await host.reload([facets.v2(true)]);
|
||||
assert.equal(report.gated.length, 1);
|
||||
const updated = host.hostObjects.get(SwitchV2.name)!;
|
||||
assert.equal(updated.contract, SwitchV2);
|
||||
assert.equal(updated.object, object);
|
||||
assert.equal(initial.status, "released");
|
||||
assert.equal(object?.refs.size, 1, "Attach the replacement before releasing the only old hold");
|
||||
assert.deepEqual(host.registry(Read).get("current")!(), { 1: true });
|
||||
const modern = host.hold(SwitchV2, {});
|
||||
await modern.set({ conversation: 2, enabled: true });
|
||||
modern.release();
|
||||
assert.deepEqual((await host.reload([facets.v3])).gated, []);
|
||||
assert.equal(host.hostObjects.get(SwitchV3.name)?.contract, SwitchV3);
|
||||
assert.equal(host.hostObjects.get(SwitchV3.name)?.object, object);
|
||||
assert.equal(updated.status, "released");
|
||||
assert.deepEqual(host.registry(Read).get("current")!(), { 1: true, 2: true });
|
||||
await host.ready();
|
||||
assert.deepEqual(log, ["open"]);
|
||||
assert.deepEqual(errors, []);
|
||||
await host.close();
|
||||
assert.deepEqual(log, ["open", "close"]);
|
||||
});
|
||||
|
||||
const ReadV1 = local("t.eager-read", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { read: method(undefined, str()) },
|
||||
});
|
||||
const ReadV2 = local("t.eager-read", {
|
||||
version: 2,
|
||||
input: obj({}, { open: true }),
|
||||
members: { fetch: method(undefined, str()) },
|
||||
});
|
||||
const readerV1: Facet = {
|
||||
name: "read",
|
||||
setup: (api) => api.serve(ReadV1, () => ({ read: () => "one" }), { lifetime: "host" }),
|
||||
};
|
||||
const readerV2 = (adapt = false): Facet => ({
|
||||
name: "read",
|
||||
setup: (api) =>
|
||||
api.serve(ReadV2, () => ({ fetch: () => "two" }), {
|
||||
lifetime: "host",
|
||||
...(adapt ? { accepts: { 1: (current: Record<string, unknown>) => ({ read: current.fetch }) } } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
test("external old-version consumers still refuse a host object's upgrade without accepts, and keep their adapter when it is provided", async () => {
|
||||
const host = new HostImpl({
|
||||
self: { kind: "host" },
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
onTestFinished(() => host.close());
|
||||
await host.load([readerV1]);
|
||||
const own = host.hostObjects.get(ReadV1.name)!;
|
||||
const consumer = host.hold(ReadV1, {});
|
||||
assert.equal(await consumer.read(), "one");
|
||||
await assert.rejects(host.reload([readerV2()]), (error) => isChordError(error, "contract_mismatch"));
|
||||
assert.equal(host.hostObjects.get(ReadV1.name), own);
|
||||
assert.equal(await consumer.read(), "one");
|
||||
await host.reload([readerV2(true)]);
|
||||
assert.equal(host.hostObjects.get(ReadV2.name)?.contract, ReadV2);
|
||||
assert.equal(await consumer.read(), "two");
|
||||
assert.equal(consumer.contract, ReadV1, "Only the bootstrap hold is retokened");
|
||||
assert.equal(await host.hold(ReadV2, {}).fetch(), "two");
|
||||
});
|
||||
|
||||
test("object-owned old-version references still block a host object's upgrade unless their consumer is replaced in the same batch", async () => {
|
||||
const host = new HostImpl({ self: { kind: "host" }, onError: () => {} });
|
||||
onTestFinished(() => host.close());
|
||||
const Consumer = local("t.eager-consumer", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { read: method(undefined, str()) },
|
||||
});
|
||||
const oldConsumer: Facet = {
|
||||
name: "consumer",
|
||||
setup: (api) =>
|
||||
api.serve(Consumer, (_input, _principal, self) => {
|
||||
const reader = self.get(ReadV1, {});
|
||||
return { read: (ctx) => reader.read(ctx) };
|
||||
}),
|
||||
};
|
||||
const newConsumer: Facet = {
|
||||
name: "consumer",
|
||||
setup: (api) =>
|
||||
api.serve(Consumer, (_input, _principal, self) => {
|
||||
const reader = self.get(ReadV2, {});
|
||||
return { read: (ctx) => reader.fetch(ctx) };
|
||||
}),
|
||||
};
|
||||
await host.load([readerV1, oldConsumer]);
|
||||
const consumer = host.hold(Consumer, {});
|
||||
assert.equal(await consumer.read(), "one");
|
||||
await assert.rejects(host.reload([readerV2()]), (error) => isChordError(error, "contract_mismatch"));
|
||||
assert.equal(await consumer.read(), "one");
|
||||
await host.reload([readerV2(), newConsumer]);
|
||||
assert.equal(await consumer.read(), "two");
|
||||
assert.equal(host.hostObjects.get(ReadV2.name)?.contract, ReadV2);
|
||||
});
|
||||
|
||||
test("a forgotten contract bump remains refused even when the only consumer is the host's own reference", async () => {
|
||||
const host = new HostImpl({ self: { kind: "host" }, onError: () => {} });
|
||||
onTestFinished(() => host.close());
|
||||
await host.load([readerV1]);
|
||||
const incompatible = local(ReadV1.name, {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { fetch: method(undefined, str()) },
|
||||
});
|
||||
await assert.rejects(
|
||||
host.reload([
|
||||
{
|
||||
name: "read",
|
||||
setup: (api) => api.serve(incompatible, () => ({ fetch: () => "bad" }), { lifetime: "host" }),
|
||||
},
|
||||
]),
|
||||
(error) => isChordError(error, "contract_mismatch") && /without a version bump/.test(error.message),
|
||||
);
|
||||
assert.equal(host.hostObjects.get(ReadV1.name)?.contract, ReadV1);
|
||||
});
|
||||
|
||||
test("a cluster contract upgrade retokens the server's and worker's references to host objects only after unanimous preparation", async () => {
|
||||
const log: string[] = [];
|
||||
const facets = switchFacets(log);
|
||||
let valid = false;
|
||||
const server = new HostImpl({ self: { kind: "host" }, onError: () => {} });
|
||||
const worker = new HostImpl({
|
||||
self: { kind: "host" },
|
||||
name: "worker",
|
||||
onError: () => {},
|
||||
loader: async () => [facets.v2(valid)],
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await worker.close();
|
||||
await server.close();
|
||||
});
|
||||
await server.load([facets.v1]);
|
||||
await worker.load([facets.v1]);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { kind: "host", name: "worker", principal: { worker: true } });
|
||||
worker.connect(up, { principal: { kind: "server" } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const serverOwn = server.hostObjects.get(SwitchV1.name);
|
||||
const workerOwn = worker.hostObjects.get(SwitchV1.name);
|
||||
await assert.rejects(server.reload([facets.v2(true)], { remote: { worker: ["switch"] } }), (error) =>
|
||||
isChordError(error, "invalid"),
|
||||
);
|
||||
assert.equal(server.hostObjects.get(SwitchV1.name), serverOwn);
|
||||
assert.equal(worker.hostObjects.get(SwitchV1.name), workerOwn);
|
||||
valid = true;
|
||||
await server.reload([facets.v2(true)], { remote: { worker: ["switch"] } });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(server.hostObjects.get(SwitchV2.name)?.contract, SwitchV2);
|
||||
assert.equal(worker.hostObjects.get(SwitchV2.name)?.contract, SwitchV2);
|
||||
assert.deepEqual(server.registry(Read).get("current")!(), { 1: false });
|
||||
assert.deepEqual(worker.registry(Read).get("current")!(), { 1: false });
|
||||
assert.equal(server.codeVersion, worker.codeVersion);
|
||||
assert.deepEqual(log, ["open", "open"]);
|
||||
});
|
||||
|
||||
test("a reload that changes a type's lifetime creates its host object, or lets it go", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Kept = local("t.lifetime-change", { version: 1, input: obj({}, { open: true }) });
|
||||
const log: string[] = [];
|
||||
const facet = (lifetime: "host" | "referenced"): Facet => ({
|
||||
name: "kept",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Kept,
|
||||
(_input, _principal, self) => {
|
||||
self.resource("life", {
|
||||
version: 1,
|
||||
open: () => log.push(`open ${lifetime}`),
|
||||
close: () => {
|
||||
log.push("close");
|
||||
},
|
||||
});
|
||||
return {};
|
||||
},
|
||||
{ lifetime },
|
||||
),
|
||||
});
|
||||
try {
|
||||
await host.install(facet("referenced"));
|
||||
assert.deepEqual(log, [], "a referenced object waits for its first reference");
|
||||
await host.reload([facet("host")]);
|
||||
await host.ready();
|
||||
assert.deepEqual(log, ["open host"], "created by the reload that made it a host object");
|
||||
await host.reload([facet("referenced")]);
|
||||
for (let i = 0; i < 50 && log.length < 2; i++) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.deepEqual(log, ["open host", "close"], "nobody else references it: disposed");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -1,739 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { bool, num, obj, opt, str } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
forbidden,
|
||||
type Host,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
method,
|
||||
opaque,
|
||||
type Ref,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const quiet = (errors: unknown[] = []) =>
|
||||
createHost({ self: { kind: "host" }, onError: (error) => errors.push(error) });
|
||||
async function until(check: () => boolean): Promise<void> {
|
||||
for (let attempt = 0; attempt < 200 && !check(); attempt++) await tick();
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
const Counter = local("t.counter", {
|
||||
version: 1,
|
||||
input: obj({ name: str() }, { open: true }),
|
||||
members: {
|
||||
count: obj({ n: num() }, { open: true }),
|
||||
add: method(num(), num()),
|
||||
},
|
||||
});
|
||||
|
||||
function serveCounter(host: Host, log: string[] = [], options: { linger?: number } = {}) {
|
||||
host.serve(
|
||||
Counter,
|
||||
({ name }, _principal, self) => {
|
||||
log.push(`factory ${name}`);
|
||||
const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 });
|
||||
const conn = self.resource("conn", {
|
||||
version: 1,
|
||||
open: async () => {
|
||||
log.push(`open ${name}`);
|
||||
return { name };
|
||||
},
|
||||
close: () => {
|
||||
log.push(`close ${name}`);
|
||||
},
|
||||
});
|
||||
return {
|
||||
count,
|
||||
conn,
|
||||
add: (delta: number, ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += delta;
|
||||
});
|
||||
return count.value.n;
|
||||
},
|
||||
};
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
describe("host: activation and lifetime", () => {
|
||||
it("activates, goes live, binds members late, disposes after release", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
serveCounter(host, log);
|
||||
const ref = host.hold(Counter, { name: "a" });
|
||||
assert.equal(ref.status, "loading");
|
||||
assert.throws(
|
||||
() => ref.count.value,
|
||||
(error) => isChordError(error, "not_ready"),
|
||||
);
|
||||
const pending = ref.add(2);
|
||||
await ref.ready;
|
||||
assert.equal(ref.status, "live");
|
||||
assert.equal(await pending, 2);
|
||||
assert.deepEqual(ref.count.value, { n: 2 });
|
||||
ref.release();
|
||||
assert.equal(ref.status, "released");
|
||||
await assert.rejects(ref.add(1), (error) => isChordError(error, "released"));
|
||||
await tick();
|
||||
assert.deepEqual(log, ["factory a", "open a", "close a"]);
|
||||
});
|
||||
|
||||
it("coalesces concurrent gets and keeps the object while held", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
serveCounter(host, log);
|
||||
const a = host.hold(Counter, { name: "x" });
|
||||
const b = host.hold(Counter, { name: "x" });
|
||||
await Promise.all([a.ready, b.ready]);
|
||||
assert.deepEqual(log, ["factory x", "open x"]);
|
||||
a.release();
|
||||
await tick();
|
||||
assert.equal(await b.add(1), 1);
|
||||
b.release();
|
||||
await tick();
|
||||
assert.deepEqual(log, ["factory x", "open x", "close x"]);
|
||||
});
|
||||
|
||||
it("lingers after the last release and revives on a new get", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
serveCounter(host, log, { linger: 30 });
|
||||
const a = host.hold(Counter, { name: "l" });
|
||||
await a.add(5);
|
||||
a.release();
|
||||
const b = host.hold(Counter, { name: "l" });
|
||||
await b.ready;
|
||||
assert.deepEqual(b.count.value, { n: 5 });
|
||||
b.release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
assert.deepEqual(log, ["factory l", "open l", "close l"]);
|
||||
});
|
||||
|
||||
it("disposes an abandoned activation once it settles", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
let release = () => {};
|
||||
const Slow = local("t.slow", { version: 1, input: obj({}, { open: true }) });
|
||||
host.serve(Slow, (_input, _principal, self) => {
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: ({ signal }) =>
|
||||
new Promise<string>((resolve) => {
|
||||
release = () => resolve("r");
|
||||
signal.addEventListener("abort", () => log.push("abort seen"));
|
||||
}),
|
||||
close: () => {
|
||||
log.push("closed");
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
const ref = host.hold(Slow, {});
|
||||
await tick();
|
||||
ref.release();
|
||||
await tick();
|
||||
release();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.deepEqual(log, ["abort seen", "closed"]);
|
||||
const again = host.hold(Slow, {});
|
||||
await tick();
|
||||
release();
|
||||
await again.ready;
|
||||
assert.equal(again.status, "live");
|
||||
});
|
||||
|
||||
it("propagates activation failures to dependents and retries on the next get", async () => {
|
||||
const host = quiet();
|
||||
let fail = true;
|
||||
const Leaf = local("t.leaf", { version: 1, input: obj({}, { open: true }) });
|
||||
const Parent = local("t.parent", { version: 1, input: obj({}, { open: true }) });
|
||||
host.serve(Leaf, (_input, _principal, self) => {
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () => {
|
||||
if (fail) throw new Error("boom");
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
host.serve(Parent, (_input, _principal, self) => {
|
||||
self.get(Leaf, {});
|
||||
return {};
|
||||
});
|
||||
const parent = host.hold(Parent, {});
|
||||
// A plain error from a dependency surfaces as `unavailable`, with the original as cause.
|
||||
await assert.rejects(parent.ready, (error) => isChordError(error, "unavailable") && /boom/.test(error.message));
|
||||
assert.equal(parent.status, "gone");
|
||||
fail = false;
|
||||
const retry = host.hold(Parent, {});
|
||||
await retry.ready;
|
||||
assert.equal(retry.status, "live");
|
||||
});
|
||||
|
||||
it("unwinds declarations when a factory throws and rejects async factories", async () => {
|
||||
const host = quiet();
|
||||
const Leaf = local("t.leaf2", { version: 1, input: obj({}, { open: true }) });
|
||||
const Bad = local("t.bad", { version: 1, input: obj({ async: bool() }, { open: true }) });
|
||||
let leafLive = 0;
|
||||
host.serve(Leaf, (_input, _principal, self) => {
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () => {
|
||||
leafLive++;
|
||||
},
|
||||
close: () => {
|
||||
leafLive--;
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
host.serve(Bad, ({ async }, _principal, self) => {
|
||||
self.get(Leaf, {});
|
||||
if (async) return Promise.resolve({}) as never;
|
||||
throw new Error("factory failed");
|
||||
});
|
||||
await assert.rejects(host.hold(Bad, { async: false }).ready, /factory failed/);
|
||||
await assert.rejects(host.hold(Bad, { async: true }).ready, /synchronous/);
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(leafLive, 0);
|
||||
});
|
||||
|
||||
it("rejects duplicate declaration names", async () => {
|
||||
const host = quiet();
|
||||
const Dup = local("t.dup", { version: 1, input: obj({}, { open: true }) });
|
||||
host.serve(Dup, (_input, _principal, self) => {
|
||||
self.state("a", obj({}, { open: true }), {});
|
||||
self.state("a", obj({}, { open: true }), {});
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Dup, {}).ready, /duplicate state name a/);
|
||||
});
|
||||
|
||||
it("detects hold cycles", async () => {
|
||||
const host = quiet();
|
||||
const A = local("t.cycle-a", { version: 1, input: obj({}, { open: true }) });
|
||||
const B = local("t.cycle-b", { version: 1, input: obj({}, { open: true }) });
|
||||
host.serve(A, (_input, _principal, self) => {
|
||||
self.get(B, {});
|
||||
return {};
|
||||
});
|
||||
host.serve(B, (_input, _principal, self) => {
|
||||
self.get(A, {});
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(A, {}).ready, /cycle/);
|
||||
});
|
||||
|
||||
it("closes resources in reverse order, runs all steps and aggregates errors", async () => {
|
||||
const errors: unknown[] = [];
|
||||
const host = quiet(errors);
|
||||
const log: string[] = [];
|
||||
const Multi = local("t.multi", { version: 1, input: obj({}, { open: true }) });
|
||||
host.serve(Multi, (_input, _principal, self) => {
|
||||
const a = self.resource("a", { version: 1, open: () => "a", close: () => void log.push("close a") });
|
||||
self.resource("b", {
|
||||
version: 1,
|
||||
deps: [a],
|
||||
open: () => "b",
|
||||
close: () => {
|
||||
log.push("close b");
|
||||
throw new Error("b failed");
|
||||
},
|
||||
});
|
||||
self.resource("c", {
|
||||
version: 1,
|
||||
open: () => "c",
|
||||
close: () => {
|
||||
log.push("close c");
|
||||
throw new Error("c failed");
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
const ref = host.hold(Multi, {});
|
||||
await ref.ready;
|
||||
ref.release();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(log.at(-1), "close a");
|
||||
assert.equal(log.length, 3);
|
||||
assert.ok(errors[0] instanceof AggregateError);
|
||||
assert.equal((errors[0] as AggregateError).errors.length, 2);
|
||||
});
|
||||
|
||||
it("releases ctx.get holds when the call settles", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
serveCounter(host, log);
|
||||
const value = await host.root(async (ctx) => {
|
||||
const counter = await ctx.get(Counter, { name: "c" });
|
||||
return counter.add(3, ctx);
|
||||
});
|
||||
assert.equal(value, 3);
|
||||
await tick();
|
||||
assert.deepEqual(log, ["factory c", "open c", "close c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host: declarations and methods", () => {
|
||||
it("refuses a second declaration of one name per kind; kinds have their own names", async () => {
|
||||
const host = quiet();
|
||||
const Twice = local("t.twice", { version: 1, input: obj({ kind: str() }, { open: true }) });
|
||||
host.serve(Twice, ({ kind }, _principal, self) => {
|
||||
const open = { version: 1, open: () => null };
|
||||
self.state("x", num(), 0);
|
||||
self.resource("x", open);
|
||||
self.on("x", self.state("y", num(), 0), () => {});
|
||||
if (kind === "state") self.state("x", num(), 1);
|
||||
if (kind === "resource") self.resource("x", open);
|
||||
if (kind === "listener") self.on("x", self.state("z", num(), 0), () => {});
|
||||
return {};
|
||||
});
|
||||
await host.hold(Twice, { kind: "none" }).ready;
|
||||
for (const kind of ["state", "resource", "listener"]) {
|
||||
await assert.rejects(
|
||||
host.hold(Twice, { kind }).ready,
|
||||
(error) =>
|
||||
isChordError(error, "invalid") &&
|
||||
error.reason === "duplicate" &&
|
||||
error.message.includes(`${kind} name x`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("methods without arguments are called with ctx alone; void methods resolve to undefined", async () => {
|
||||
const host = quiet();
|
||||
const Tool = local("t.tool", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: {
|
||||
ping: method(),
|
||||
count: method(undefined, num()),
|
||||
note: method(str()),
|
||||
},
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
host.serve(Tool, () => ({
|
||||
ping: (ctx) => {
|
||||
seen.push(typeof ctx.rootId);
|
||||
return "ignored" as never;
|
||||
},
|
||||
count: () => 3,
|
||||
note: (text, ctx) => {
|
||||
seen.push(text, typeof ctx.rootId);
|
||||
},
|
||||
}));
|
||||
const tool = host.hold(Tool, {});
|
||||
await tool.ready;
|
||||
assert.equal(await tool.ping(), undefined);
|
||||
assert.equal(await host.root((ctx) => tool.count(ctx)), 3);
|
||||
assert.equal(await tool.note("hi"), undefined);
|
||||
assert.deepEqual(seen, ["string", "hi", "string"]);
|
||||
});
|
||||
|
||||
it("a listener on state gets the current value at start and again after a reload replaced its derivation", async () => {
|
||||
const host = quiet();
|
||||
const Doubled = local("t.doubled", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { set: method(num()) },
|
||||
});
|
||||
const seen: number[] = [];
|
||||
const facet = (label: string): Facet => ({
|
||||
name: "doubled",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Doubled,
|
||||
(_input, _principal, self) => {
|
||||
const n = self.state("n", num(), 1);
|
||||
const doubled = self.derive([n], (value) => value * 2);
|
||||
self.on("doubled", doubled, (value) => {
|
||||
seen.push(value);
|
||||
void label;
|
||||
});
|
||||
return {
|
||||
set: (value, ctx) => {
|
||||
n.replace(ctx, value);
|
||||
},
|
||||
};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
await host.install(facet("v1"));
|
||||
await until(() => seen.length === 1);
|
||||
await host.root((ctx) => host.hold(Doubled, {}).set(5, ctx));
|
||||
await until(() => seen.length === 2);
|
||||
assert.deepEqual(seen, [2, 10]);
|
||||
// The reload makes a new derivation: the listener subscribes again and gets its current value once.
|
||||
await host.reload([facet("v2")]);
|
||||
await until(() => seen.length === 3);
|
||||
await tick();
|
||||
assert.deepEqual(seen, [2, 10, 10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host: lifetime", () => {
|
||||
const Store = local("t.store", {
|
||||
version: 1,
|
||||
input: obj({ scope: opt(str({ default: "all" })) }, { open: true }),
|
||||
members: { seen: opaque<unknown>("t.seen@1") },
|
||||
});
|
||||
|
||||
it('serve refuses a lifetime "host" the type cannot have, at compile time and at runtime', () => {
|
||||
const host = quiet();
|
||||
const Remote = remote("t.remote-store", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: {} },
|
||||
});
|
||||
const Keyed = local("t.keyed", { version: 1, input: obj({ id: str() }, { open: true }) });
|
||||
// Each refused call is also a type error; the runtime check is the backstop for untyped callers.
|
||||
const refused: [() => void, RegExp][] = [
|
||||
// @ts-expect-error: a remote type is never of lifetime "host"
|
||||
[() => host.serve(Remote, () => ({ variant: "main" }), { lifetime: "host" }), /needs a local type/],
|
||||
// @ts-expect-error: the one object has the empty input, and `id` is required
|
||||
[() => host.serve(Keyed, () => ({}), { lifetime: "host" }), /input that accepts \{\}/],
|
||||
// @ts-expect-error: an object of lifetime "host" does not linger
|
||||
[() => host.serve(Store, () => ({ seen: null }), { lifetime: "host", linger: 10 }), /does not linger/],
|
||||
// @ts-expect-error: no such lifetime
|
||||
[() => host.serve(Store, () => ({ seen: null }), { lifetime: "forever" }), /unknown lifetime/],
|
||||
];
|
||||
for (const [serve, error] of refused) assert.throws(serve, error);
|
||||
// Allowed: a local type whose input has only optional fields, and any type with the default lifetime.
|
||||
const Optional = local("t.optional", { version: 1, input: obj({ id: opt(str()) }, { open: true }) });
|
||||
host.serve(Optional, () => ({}), { lifetime: "host" });
|
||||
host.serve(Keyed, () => ({}), { lifetime: "referenced", linger: 10 });
|
||||
host.serve(Remote, () => ({ variant: "main" }), { linger: 10 });
|
||||
});
|
||||
|
||||
it("every get reaches the one object, created at serve for the host principal; another input is invalid", async () => {
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
principalKey: (principal) => JSON.stringify(principal),
|
||||
onError: () => {},
|
||||
});
|
||||
const created: unknown[] = [];
|
||||
host.serve(
|
||||
Store,
|
||||
(input, principal) => {
|
||||
created.push({ input, principal });
|
||||
return { seen: principal };
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
await host.ready();
|
||||
assert.equal(created.length, 1, "created when served, before anyone asked");
|
||||
const guest = host.hold(Store, {}, { principal: { guest: "g" } });
|
||||
const defaults = host.hold(Store, { scope: "all" });
|
||||
await Promise.all([guest.ready, defaults.ready]);
|
||||
assert.equal(created.length, 1, "the same object for every principal and the default input");
|
||||
assert.deepEqual(guest.seen, created[0] && (created[0] as { principal: unknown }).principal);
|
||||
assert.throws(
|
||||
() => host.hold(Store, { scope: "other" }),
|
||||
(error) => isChordError(error, "invalid"),
|
||||
);
|
||||
// References do not change its lifetime: released by everyone else, it stays until the host closes.
|
||||
guest.release();
|
||||
defaults.release();
|
||||
await tick();
|
||||
const again = host.hold(Store, {});
|
||||
await again.ready;
|
||||
assert.equal(created.length, 1);
|
||||
await host.close();
|
||||
assert.equal(again.status, "gone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host: principals, variants and disposal", () => {
|
||||
const Doc = remote("t.doc", {
|
||||
version: 1,
|
||||
input: obj({ id: str() }, { open: true }),
|
||||
host: hostNamed("host"),
|
||||
variants: {
|
||||
viewer: { state: { view: obj({ text: str() }, { open: true }) } },
|
||||
editor: {
|
||||
state: { view: obj({ text: str() }, { open: true }) },
|
||||
methods: { edit: method(obj({ text: str() }, { open: true }), obj({ ok: bool() }, { open: true })) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function serveDoc(host: Host, principals: unknown[] = []) {
|
||||
host.serve(Doc, (_input, principal, self) => {
|
||||
principals.push(principal);
|
||||
const view = self.state("view", obj({ text: str() }, { open: true }), { text: "" });
|
||||
if ((principal as { role?: string }).role !== "editor") return { variant: "viewer", view };
|
||||
return {
|
||||
variant: "editor",
|
||||
view,
|
||||
edit: ({ text }, ctx) => {
|
||||
view.replace(ctx, { text });
|
||||
return text === "bad" ? ({ ok: "no" } as never) : { ok: true };
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps objects per principal key and enforces variants", async () => {
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
principalKey: (principal) => (principal as { user: string }).user,
|
||||
onError: () => {},
|
||||
});
|
||||
const principals: unknown[] = [];
|
||||
serveDoc(host, principals);
|
||||
const viewer = host.hold(Doc, { id: "1" }, { principal: { user: "v", role: "viewer" } });
|
||||
const editor = host.hold(Doc, { id: "1" }, { principal: { user: "e", role: "editor" } });
|
||||
await Promise.all([viewer.ready, editor.ready]);
|
||||
assert.equal(principals.length, 2);
|
||||
assert.equal(viewer.variant, "viewer");
|
||||
assert.deepEqual(viewer.shape?.members, ["view"]);
|
||||
// @ts-expect-error A viewer has no edit: TypeScript refuses it, and so does the provider at runtime.
|
||||
await assert.rejects(viewer.edit({ text: "x" }), (error) => isChordError(error, "not_in_variant"));
|
||||
assert.ok(editor.variant === "editor");
|
||||
assert.deepEqual(await editor.edit({ text: "x" }), { ok: true });
|
||||
assert.deepEqual(editor.view.value, { text: "x" });
|
||||
assert.deepEqual(viewer.view.value, { text: "" });
|
||||
});
|
||||
|
||||
it("validates remote args and results at the provider boundary", async () => {
|
||||
const host = quiet();
|
||||
serveDoc(host);
|
||||
const editor = await host.get(Doc, { id: "2" }, { principal: { role: "editor" } });
|
||||
assert.ok(editor.variant === "editor");
|
||||
await assert.rejects(
|
||||
editor.edit({ text: 1 } as never),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "args",
|
||||
);
|
||||
await assert.rejects(
|
||||
editor.edit({ text: "bad" }),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "result",
|
||||
);
|
||||
});
|
||||
|
||||
it("forced disposal makes references gone and aborts in-flight calls", async () => {
|
||||
const host = quiet();
|
||||
let seenAbort = false;
|
||||
const Slow = local("t.inflight", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { wait: method() },
|
||||
});
|
||||
host.serve(Slow, () => ({
|
||||
wait: (ctx) =>
|
||||
new Promise<void>((resolve) =>
|
||||
ctx.signal.addEventListener("abort", () => {
|
||||
seenAbort = true;
|
||||
resolve();
|
||||
}),
|
||||
),
|
||||
}));
|
||||
const ref = host.hold(Slow, {}, { principal: { user: "u" } });
|
||||
await ref.ready;
|
||||
const call = ref.wait();
|
||||
await tick();
|
||||
await host.invalidatePrincipal((principal) => (principal as { user: string }).user === "u");
|
||||
assert.equal(ref.status, "gone");
|
||||
await call;
|
||||
assert.ok(seenAbort);
|
||||
await assert.rejects(ref.wait(), (error) => isChordError(error, "gone"));
|
||||
const fresh = host.hold(Slow, {}, { principal: { user: "u" } });
|
||||
await fresh.ready;
|
||||
assert.equal(fresh.status, "live");
|
||||
});
|
||||
|
||||
it("state members are late-bound readables: values, then snapshot/reset/ops events", async () => {
|
||||
const host = quiet();
|
||||
serveDoc(host);
|
||||
const editor = host.hold(Doc, { id: "3" }, { principal: { role: "editor" } }) as Ref<typeof Doc>;
|
||||
const shapes: unknown[] = [];
|
||||
editor.onShape((shape) => shapes.push(shape.variant));
|
||||
const events: unknown[] = [];
|
||||
const values: unknown[] = [];
|
||||
editor.view.events((event) => events.push(event.type === "ops" ? ["ops", event.ops] : [event.type, event.value]));
|
||||
editor.view.subscribe((value) => values.push(value));
|
||||
await editor.ready;
|
||||
assert.ok(editor.variant === "editor");
|
||||
await editor.edit({ text: "hi" });
|
||||
assert.deepEqual(shapes, ["editor"]);
|
||||
// Listeners added while loading get their snapshot once the reference is live.
|
||||
assert.deepEqual(events, [
|
||||
["snap", { text: "" }],
|
||||
["ops", [["r", { text: "hi" }]]],
|
||||
]);
|
||||
assert.deepEqual(values, [{ text: "" }, { text: "hi" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host: registries", () => {
|
||||
const Routes = defineRegistry<string>("t.routes");
|
||||
|
||||
it('only objects of lifetime "host" register; tables follow registration order', async () => {
|
||||
const host = quiet();
|
||||
const RoutesA = local("t.routes-a", { version: 1, input: obj({}, { open: true }) });
|
||||
const RoutesB = local("t.routes-b", { version: 1, input: obj({}, { open: true }) });
|
||||
host.serve(
|
||||
RoutesA,
|
||||
(_input, _principal, self) => {
|
||||
self.register(Routes, { "GET /a": "a", "GET /c": "c" });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
host.serve(
|
||||
RoutesB,
|
||||
(_input, _principal, self) => {
|
||||
self.register(Routes, { "GET /b": "b" });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
const tables: string[][] = [];
|
||||
host.registry(Routes).subscribe((table) => tables.push([...table.keys()]));
|
||||
await tick();
|
||||
await tick();
|
||||
assert.deepEqual([...host.registry(Routes).table().keys()], ["GET /a", "GET /c", "GET /b"]);
|
||||
assert.equal(host.registry(Routes).get("GET /b"), "b");
|
||||
assert.deepEqual(tables.at(-1), ["GET /a", "GET /c", "GET /b"]);
|
||||
|
||||
const Lazy = local("t.lazy-register", { version: 1, input: obj({}, { open: true }) });
|
||||
host.serve(Lazy, (_input, _principal, self) => {
|
||||
self.register(Routes, { x: "x" });
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Lazy, {}).ready, /only objects of lifetime "host" may register/);
|
||||
});
|
||||
|
||||
it("rejects conflicting registrations", async () => {
|
||||
const errors: unknown[] = [];
|
||||
const host = quiet(errors);
|
||||
for (const name of ["t.conflict-a", "t.conflict-b"]) {
|
||||
host.serve(
|
||||
local(name, { version: 1, input: obj({}, { open: true }) }),
|
||||
(_input, _principal, self) => {
|
||||
self.register(Routes, { same: name });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
}
|
||||
await tick();
|
||||
await tick();
|
||||
assert.ok(errors.some((error) => /registry_conflict/.test(String(error))));
|
||||
});
|
||||
});
|
||||
|
||||
describe("host: composition", () => {
|
||||
const Source = local("t.source", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { n: num(), set: method(num()) },
|
||||
});
|
||||
const Mirror = local("t.mirror", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { copy: num(), doubled: num() },
|
||||
});
|
||||
|
||||
it("reference state members are sources for listeners and derivations", async () => {
|
||||
const host = quiet();
|
||||
host.serve(Source, (_input, _principal, self) => {
|
||||
const n = self.state("n", num(), 1);
|
||||
return {
|
||||
n,
|
||||
set: (value: number, ctx: Ctx) => {
|
||||
n.replace(ctx, value);
|
||||
},
|
||||
};
|
||||
});
|
||||
host.serve(Mirror, (_input, _principal, self) => {
|
||||
const source = self.get(Source, {});
|
||||
const copy = self.state("copy", num(), 0);
|
||||
self.on("follow", source.n, (value, ctx) => copy.replace(ctx, value ?? 0));
|
||||
const doubled = self.derive([source.n], (n) => (n ?? 0) * 2);
|
||||
return { copy, doubled };
|
||||
});
|
||||
const mirror = host.hold(Mirror, {});
|
||||
await mirror.ready;
|
||||
await tick();
|
||||
assert.equal(mirror.copy.value, 1);
|
||||
assert.equal(mirror.doubled.value, 2);
|
||||
await host.hold(Source, {}).set(5);
|
||||
await tick();
|
||||
assert.equal(mirror.copy.value, 5);
|
||||
assert.equal(mirror.doubled.value, 10);
|
||||
});
|
||||
|
||||
it("ctx.get acts as the root principal at ingress and as the host inside objects", async () => {
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
principalKey: (principal) => JSON.stringify(principal),
|
||||
onError: () => {},
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
const Who = local("t.who", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { ping: method() },
|
||||
});
|
||||
const Front = local("t.front", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { relay: method() },
|
||||
});
|
||||
host.serve(Who, (_input, principal) => {
|
||||
seen.push(principal);
|
||||
return { ping: () => {} };
|
||||
});
|
||||
host.serve(Front, (_input, principal) => {
|
||||
seen.push(principal);
|
||||
return { relay: async (ctx: Ctx) => (await ctx.get(Who, {})).ping(ctx) };
|
||||
});
|
||||
await host.root(async (ctx) => (await ctx.get(Front, {})).relay(ctx), { principal: { user: "guest" } });
|
||||
assert.deepEqual(seen, [{ user: "guest" }, { kind: "host" }]);
|
||||
});
|
||||
|
||||
it("calling a reference inside a factory throws not_ready", async () => {
|
||||
const host = quiet();
|
||||
host.serve(Source, (_input, _principal, self) => ({
|
||||
n: self.state("n", num(), 0),
|
||||
set: () => {},
|
||||
}));
|
||||
const Eager = local("t.eager-call", { version: 1, input: obj({}, { open: true }) });
|
||||
host.serve(Eager, (_input, _principal, self) => {
|
||||
const source = self.get(Source, {});
|
||||
void source.set(1);
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Eager, {}).ready, (error) => isChordError(error, "not_ready"));
|
||||
});
|
||||
|
||||
it("a ChordError from a factory keeps its code through dependents", async () => {
|
||||
const host = quiet();
|
||||
const Secret = local("t.secret", { version: 1, input: obj({}, { open: true }) });
|
||||
const Page = local("t.page", { version: 1, input: obj({}, { open: true }) });
|
||||
host.serve(Secret, () => {
|
||||
throw forbidden("not for you");
|
||||
});
|
||||
host.serve(Page, (_input, _principal, self) => {
|
||||
self.get(Secret, {});
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Page, {}).ready, (error) => isChordError(error, "forbidden"));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
// Entry 8: a local object goes live only with its state, also where it forwards or derives from a remote reference's
|
||||
// member; activation waits at most `readyTimeout` (a cycle across hosts fails with a hint instead of hanging).
|
||||
import assert from "node:assert/strict";
|
||||
import { num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Host,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Lives on the worker `w`; its value is set by `set`. */
|
||||
const Source = remote("t.e8-source", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("w"),
|
||||
variants: { main: { state: { v: str() }, methods: { set: method(str()) } } },
|
||||
});
|
||||
|
||||
/** Lives on the server; forwards (and derives from) the worker's `Source`. */
|
||||
const Forward = remote("t.e8-forward", {
|
||||
version: 1,
|
||||
input: obj({ how: str() }, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { v: str(), size: num() } } },
|
||||
});
|
||||
|
||||
function serveSource(host: Host): void {
|
||||
host.serve(Source, (_input, _principal, self) => {
|
||||
const v = self.state("v", str(), "from the worker");
|
||||
return {
|
||||
variant: "main",
|
||||
v,
|
||||
set: (text: string, ctx: Ctx) => {
|
||||
v.replace(ctx, text);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** A server with a worker child `w` (accepted after `delay` ms, as p2's server admits a worker after it is ready). */
|
||||
function cluster(options: { readonly delay?: number; readonly readyTimeout?: number } = {}) {
|
||||
const server = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "server",
|
||||
onError: () => {},
|
||||
...(options.readyTimeout === undefined ? {} : { readyTimeout: options.readyTimeout }),
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "w",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
...(options.readyTimeout === undefined ? {} : { readyTimeout: options.readyTimeout }),
|
||||
});
|
||||
const admit = () => server.accept(down, { principal: { kind: "worker" }, kind: "host", name: "w" });
|
||||
if (options.delay === undefined) admit();
|
||||
else setTimeout(admit, options.delay);
|
||||
const browser = () => {
|
||||
const [down2, up2] = memoryLinkPair();
|
||||
server.accept(down2, { principal: { user: "u" } });
|
||||
return createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up2, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
};
|
||||
return { server, worker, browser };
|
||||
}
|
||||
|
||||
describe("local hydration", () => {
|
||||
it("an object that forwards and derives from a remote member goes live with its value", async () => {
|
||||
const c = cluster();
|
||||
serveSource(c.worker);
|
||||
// Held outside the factory (no `self.get`): activation still waits for its snapshot.
|
||||
const held = c.server.hold(Source, {});
|
||||
c.server.serve(Forward, ({ how }, _principal, self) => {
|
||||
const source = how === "declared" ? self.get(Source, {}) : held;
|
||||
return { variant: "main", v: source.v, size: self.derive([source.v], (text) => text.length) };
|
||||
});
|
||||
const browser = c.browser();
|
||||
for (const how of ["held", "declared"]) {
|
||||
const forward = browser.hold(Forward, { how });
|
||||
await forward.ready;
|
||||
assert.equal(forward.v.value, "from the worker");
|
||||
assert.equal(forward.size.value, "from the worker".length);
|
||||
const local = c.server.hold(Forward, { how });
|
||||
await local.ready;
|
||||
assert.equal(local.v.value, "from the worker");
|
||||
}
|
||||
await c.server.close();
|
||||
await c.worker.close();
|
||||
});
|
||||
|
||||
it("a member read inside a factory throws not_ready; it is a plain value once live", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Inner = local("t.e8-inner", { version: 1, input: obj({}, { open: true }), members: { n: num() } });
|
||||
const Outer = local("t.e8-outer", { version: 1, input: obj({}, { open: true }), members: { m: num() } });
|
||||
host.serve(Inner, (_input, _principal, self) => ({ n: self.state("n", num(), 7) }));
|
||||
const seen: unknown[] = [];
|
||||
host.serve(Outer, (_input, _principal, self) => {
|
||||
const inner = self.get(Inner, {});
|
||||
try {
|
||||
seen.push(inner.n.value);
|
||||
} catch (error) {
|
||||
seen.push(isChordError(error, "not_ready") ? "not_ready" : error);
|
||||
}
|
||||
return { m: self.derive([inner.n], (n) => n * 2) };
|
||||
});
|
||||
const outer = host.hold(Outer, {});
|
||||
await outer.ready;
|
||||
assert.deepEqual(seen, ["not_ready"]);
|
||||
assert.equal(outer.m.value, 14);
|
||||
});
|
||||
|
||||
it("a host object of a child holding the parent's object goes live once the parent admits the child", async () => {
|
||||
const Settings = remote("t.e8-settings", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { theme: str() } } },
|
||||
});
|
||||
const Local = local("t.e8-boot", { version: 1, input: obj({}, { open: true }), members: { theme: str() } });
|
||||
const c = cluster({ delay: 20 });
|
||||
c.server.serve(Settings, (_input, _principal, self) => ({
|
||||
variant: "main",
|
||||
theme: self.state("theme", str(), "dark"),
|
||||
}));
|
||||
await c.worker.install(
|
||||
{
|
||||
name: "boot",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Local,
|
||||
(_input, _principal, self) => {
|
||||
const settings = self.get(Settings, {});
|
||||
return { theme: settings.theme };
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
},
|
||||
{ awaitHostObjects: false },
|
||||
);
|
||||
await c.worker.ready();
|
||||
const boot = c.worker.hold(Local, {});
|
||||
await boot.ready;
|
||||
assert.equal(boot.theme.value, "dark");
|
||||
await c.server.close();
|
||||
await c.worker.close();
|
||||
});
|
||||
|
||||
it("a reference cycle across hosts fails at readyTimeout with a cycle hint", async () => {
|
||||
const A = remote("t.e8-cycle-a", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: {} },
|
||||
});
|
||||
const B = remote("t.e8-cycle-b", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("w"),
|
||||
variants: { main: {} },
|
||||
});
|
||||
const c = cluster({ readyTimeout: 50 });
|
||||
c.server.serve(A, (_input, _principal, self) => {
|
||||
self.get(B, {});
|
||||
return { variant: "main" };
|
||||
});
|
||||
c.worker.serve(B, (_input, _principal, self) => {
|
||||
self.get(A, {});
|
||||
return { variant: "main" };
|
||||
});
|
||||
const a = c.server.hold(A, {});
|
||||
await assert.rejects(
|
||||
a.ready,
|
||||
(error) =>
|
||||
isChordError(error, "unavailable") && error.reason === "ready_timeout" && /cycle/.test(error.message),
|
||||
);
|
||||
await sleep(10);
|
||||
await c.server.close();
|
||||
await c.worker.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,631 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { arr, num, obj, str, unknown } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
type IpcChannel,
|
||||
ipcLink,
|
||||
isChordError,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
remote,
|
||||
type Self,
|
||||
type StateEvent,
|
||||
} from "../../src/old/index.ts";
|
||||
import { Packed, unpack } from "../../src/old/link.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const counterVariants = {
|
||||
main: {
|
||||
state: { count: obj({ n: num() }, { open: true }) },
|
||||
methods: {
|
||||
add: method(num(), num()),
|
||||
who: method(undefined, unknown()),
|
||||
},
|
||||
},
|
||||
};
|
||||
/** A counter on the server. */
|
||||
const Counter = remote("t.counter", {
|
||||
version: 1,
|
||||
input: obj({ name: str() }, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: counterVariants,
|
||||
});
|
||||
/** A counter on a session host (`session:` + its `session` field). */
|
||||
const SessionCounter = remote("t.session-counter", {
|
||||
version: 1,
|
||||
input: obj({ session: str(), name: str() }, { open: true }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: counterVariants,
|
||||
});
|
||||
|
||||
/** Serves Counter and records the principal of every object it creates. */
|
||||
/** Serves the counter of the host's kind (`Counter` on the server, `SessionCounter` elsewhere). */
|
||||
function serveCounter(host: Host, principals: unknown[] = []) {
|
||||
const counter = (principal: unknown, self: Self) => {
|
||||
principals.push(principal);
|
||||
const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 });
|
||||
return {
|
||||
variant: "main" as const,
|
||||
count,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += delta;
|
||||
});
|
||||
return count.value.n;
|
||||
},
|
||||
who: () => principal,
|
||||
};
|
||||
};
|
||||
if (host.name === "server") host.serve(Counter, (_input, principal, self) => counter(principal, self));
|
||||
else host.serve(SessionCounter, (_input, principal, self) => counter(principal, self));
|
||||
}
|
||||
|
||||
/** A server and a worker connected by a memory link; the server accepts the worker as `{ worker: name }`. */
|
||||
function serverWithWorker(workerName = "session:S1") {
|
||||
const errors: unknown[] = [];
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: (error) => errors.push(error) });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: workerName }, kind: "host", name: workerName });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: workerName,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
return { server, worker, down, up, errors };
|
||||
}
|
||||
|
||||
describe("links: calls and state across hosts", () => {
|
||||
it("a child calls the server; the server stamps the child's principal", async () => {
|
||||
const { server, worker } = serverWithWorker();
|
||||
const principals: unknown[] = [];
|
||||
serveCounter(server, principals);
|
||||
const result = await worker.root(async (ctx) => {
|
||||
const counter = await ctx.get(Counter, { name: "c" });
|
||||
assert.equal(counter.variant, "main");
|
||||
return [await counter.add(2, ctx), await counter.who(ctx)];
|
||||
});
|
||||
assert.deepEqual(result, [2, { worker: "session:S1" }]);
|
||||
assert.deepEqual(principals, [{ worker: "session:S1" }]);
|
||||
});
|
||||
|
||||
it("state members: snapshot, then delta-encoded ops; values decode to the provider's", async () => {
|
||||
const { server, worker } = serverWithWorker();
|
||||
serveCounter(server);
|
||||
const counter = worker.hold(Counter, { name: "s" });
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
const values: unknown[] = [];
|
||||
counter.count.events((event) => events.push(event));
|
||||
counter.count.subscribe((value) => values.push(value));
|
||||
await counter.ready;
|
||||
await counter.add(1);
|
||||
await counter.add(1);
|
||||
await tick();
|
||||
assert.deepEqual(counter.count.value, { n: 2 });
|
||||
// Listeners added before the hydration get the snapshot then (not `undefined` first).
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["snap", "ops", "ops"],
|
||||
);
|
||||
assert.deepEqual(values, [{ n: 0 }, { n: 1 }, { n: 2 }]);
|
||||
});
|
||||
|
||||
it("the server reaches a connected child by name", async () => {
|
||||
const { server, worker } = serverWithWorker("session:w1");
|
||||
const principals: unknown[] = [];
|
||||
serveCounter(worker, principals);
|
||||
await tick();
|
||||
const counter = server.hold(SessionCounter, { session: "w1", name: "x" });
|
||||
assert.equal(await counter.add(5), 5);
|
||||
// The parent's own requests act as the principal the child gave `connect`.
|
||||
assert.deepEqual(principals, [{ kind: "server" }]);
|
||||
});
|
||||
|
||||
it("rejects local types and unknown hosts", async () => {
|
||||
const { worker } = serverWithWorker();
|
||||
const missing = worker.hold(Counter, { name: "m" });
|
||||
await assert.rejects(missing.ready, (error) => isChordError(error, "unavailable"));
|
||||
});
|
||||
});
|
||||
|
||||
interface Cluster {
|
||||
readonly server: Host;
|
||||
readonly workers: Map<string, Host>;
|
||||
readonly log: string[];
|
||||
browser(
|
||||
principal: unknown,
|
||||
options?: { restamp?: (principal: unknown) => unknown | undefined },
|
||||
): {
|
||||
host: Host;
|
||||
up: ReturnType<typeof memoryLinkPair>[1];
|
||||
reconnect(): void;
|
||||
};
|
||||
allow: (principal: unknown, host: string) => boolean;
|
||||
}
|
||||
|
||||
/** Server routing `session:*` to workers it spawns on demand (memory links); browsers connect as clients. */
|
||||
function cluster(options: { pingInterval?: number; pingTimeout?: number } = {}): Cluster {
|
||||
const log: string[] = [];
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const workers = new Map<string, Host>();
|
||||
const state: Cluster = {
|
||||
server,
|
||||
workers,
|
||||
log,
|
||||
allow: () => true,
|
||||
browser(principal, browserOptions = {}) {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal, restamp: browserOptions.restamp, ...options });
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" }, ...options },
|
||||
onError: () => {},
|
||||
});
|
||||
const ref = { host, up, reconnect: () => {} };
|
||||
ref.reconnect = () => {
|
||||
const [nextDown, nextUp] = memoryLinkPair();
|
||||
server.accept(nextDown, { principal });
|
||||
ref.up = nextUp;
|
||||
host.connect(nextUp, { principal: { kind: "server" } });
|
||||
};
|
||||
return ref;
|
||||
},
|
||||
};
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: (principal, host) => state.allow(principal, host),
|
||||
resolve: async (name) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" }, ...options },
|
||||
onError: () => {},
|
||||
});
|
||||
worker.serve(SessionCounter, (input, principal, self) => {
|
||||
self.resource("probe", {
|
||||
version: 1,
|
||||
open: () => void log.push(`open ${input.name}`),
|
||||
close: () => void log.push(`close ${input.name}`),
|
||||
});
|
||||
const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 });
|
||||
return {
|
||||
variant: "main",
|
||||
count,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += delta;
|
||||
});
|
||||
return count.value.n;
|
||||
},
|
||||
who: () => principal,
|
||||
};
|
||||
});
|
||||
workers.set(name, worker);
|
||||
return down;
|
||||
},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("links: relay, stamping, lifetime", () => {
|
||||
it("relays browser → server → worker; the worker sees the browser's stamped principal", async () => {
|
||||
const c = cluster();
|
||||
const guest = { guest: "g1" };
|
||||
const browser = c.browser(guest);
|
||||
const counter = browser.host.hold(SessionCounter, { session: "S1", name: "a" });
|
||||
assert.equal(await counter.add(3), 3);
|
||||
assert.deepEqual(await counter.who(), guest);
|
||||
const values: unknown[] = [];
|
||||
counter.count.subscribe((value) => values.push(value));
|
||||
await counter.add(1);
|
||||
await tick();
|
||||
assert.deepEqual(values.at(-1), { n: 4 });
|
||||
});
|
||||
|
||||
it("ignores payload principals from clients and from child hosts", async () => {
|
||||
const c = cluster();
|
||||
const guest = { guest: "g1" };
|
||||
const [down, raw] = memoryLinkPair();
|
||||
c.server.accept(down, { principal: guest });
|
||||
const frames: Frame[] = [];
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
raw.send({
|
||||
t: "hold",
|
||||
id: 1,
|
||||
at: "session:S1",
|
||||
token: SessionCounter.description,
|
||||
input: { session: "S1", name: "f" },
|
||||
principal: { owner: true },
|
||||
});
|
||||
raw.send({ t: "call", id: 2, hold: 1, member: "who", args: null });
|
||||
for (let i = 0; i < 50 && !frames.some((frame) => frame.t === "result"); i++) await sleep(5);
|
||||
const result = frames.find((frame) => frame.t === "result");
|
||||
assert.deepEqual(result?.t === "result" ? result.value : undefined, guest);
|
||||
|
||||
// A child host cannot forward principals upward either: the server stamps it.
|
||||
const { server, down: _down, up } = serverWithWorker("w9");
|
||||
serveCounter(server);
|
||||
const childFrames: Frame[] = [];
|
||||
up.receive((frame) => childFrames.push(frame));
|
||||
up.send({
|
||||
t: "hold",
|
||||
id: 7,
|
||||
at: "server",
|
||||
token: Counter.description,
|
||||
input: { name: "f" },
|
||||
principal: { owner: true },
|
||||
});
|
||||
up.send({ t: "call", id: 8, hold: 7, member: "who", args: null });
|
||||
for (let i = 0; i < 50 && !childFrames.some((frame) => frame.t === "result"); i++) await sleep(5);
|
||||
const childResult = childFrames.find((frame) => frame.t === "result");
|
||||
assert.deepEqual(childResult?.t === "result" ? childResult.value : undefined, { worker: "w9" });
|
||||
});
|
||||
|
||||
it("a route that returns an already closed link does not poison the host name", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
let attempt = 0;
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
attempt++;
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
serveCounter(worker);
|
||||
// The first worker dies before the server wraps its link.
|
||||
if (attempt === 1) down.close("worker exited");
|
||||
return down;
|
||||
},
|
||||
});
|
||||
const first = server.hold(SessionCounter, { session: "S1", name: "a" });
|
||||
await assert.rejects(first.add(1), (error: Error) => /closed/.test(error.message));
|
||||
// The next call resolves the route again and reaches the new worker.
|
||||
const second = server.hold(SessionCounter, { session: "S1", name: "a" });
|
||||
assert.equal(await second.add(2), 2);
|
||||
assert.equal(attempt, 2);
|
||||
});
|
||||
|
||||
it("authorizes before resolving and again after", async () => {
|
||||
const c = cluster();
|
||||
c.allow = (principal) => (principal as { guest: string }).guest !== "blocked";
|
||||
const blocked = c.browser({ guest: "blocked" });
|
||||
await assert.rejects(blocked.host.hold(SessionCounter, { session: "S1", name: "a" }).ready, (error) =>
|
||||
isChordError(error, "forbidden"),
|
||||
);
|
||||
assert.equal(c.workers.size, 0);
|
||||
let calls = 0;
|
||||
c.allow = () => ++calls === 1;
|
||||
const flaky = c.browser({ guest: "flaky" });
|
||||
await assert.rejects(flaky.host.hold(SessionCounter, { session: "S2", name: "a" }).ready, (error) =>
|
||||
isChordError(error, "forbidden"),
|
||||
);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
it("a browser disconnect releases exactly its holds; the worker link stays up", async () => {
|
||||
const c = cluster();
|
||||
const one = c.browser({ guest: "g1" });
|
||||
const two = c.browser({ guest: "g2" });
|
||||
await one.host.hold(SessionCounter, { session: "S1", name: "a" }).add(1);
|
||||
const other = two.host.hold(SessionCounter, { session: "S1", name: "b" });
|
||||
await other.add(1);
|
||||
one.up.close();
|
||||
for (let i = 0; i < 20 && !c.log.includes("close a"); i++) await tick();
|
||||
assert.ok(c.log.includes("close a"));
|
||||
assert.ok(!c.log.includes("close b"));
|
||||
assert.equal(await other.add(1), 2);
|
||||
});
|
||||
|
||||
it("a dead link (no pongs) is closed and everything held through it is released", async () => {
|
||||
const c = cluster({ pingInterval: 10, pingTimeout: 40 });
|
||||
const browser = c.browser({ guest: "g1" });
|
||||
await browser.host.hold(SessionCounter, { session: "S1", name: "a" }).add(1);
|
||||
browser.up.sever();
|
||||
for (let i = 0; i < 40 && !c.log.includes("close a"); i++) await sleep(10);
|
||||
assert.ok(c.log.includes("close a"));
|
||||
});
|
||||
|
||||
it("checks contract tokens across links: contract_mismatch for hosts, upgrade_required for browsers", async () => {
|
||||
const CounterV2 = remote("t.counter", {
|
||||
version: 2,
|
||||
input: obj({ name: str() }, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { inc: method(undefined, num()) } } },
|
||||
});
|
||||
const { server, worker } = serverWithWorker();
|
||||
server.serve(CounterV2, () => ({ variant: "main", inc: () => 1 }));
|
||||
await assert.rejects(worker.hold(Counter, { name: "x" }).ready, (error) =>
|
||||
isChordError(error, "contract_mismatch"),
|
||||
);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
await assert.rejects(browser.hold(Counter, { name: "x" }).ready, (error) =>
|
||||
isChordError(error, "upgrade_required"),
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidatePrincipal re-stamps the link and propagates to workers", async () => {
|
||||
const c = cluster();
|
||||
const browser = c.browser({ guest: "g1", revision: 1 }, { restamp: (p) => ({ ...(p as object), revision: 2 }) });
|
||||
const counter = browser.host.hold(SessionCounter, { session: "S1", name: "a" });
|
||||
await counter.add(1);
|
||||
await c.server.invalidatePrincipal({ guest: "g1" });
|
||||
// The relay under the old principal is closed at once; the worker disposes its object after.
|
||||
for (let i = 0; i < 20 && counter.status !== "gone"; i++) await tick();
|
||||
assert.equal(counter.status, "gone");
|
||||
for (let i = 0; i < 20 && !c.log.includes("close a"); i++) await tick();
|
||||
assert.ok(c.log.includes("close a"));
|
||||
const again = browser.host.hold(SessionCounter, { session: "S1", name: "a" });
|
||||
assert.deepEqual(await again.who(), { guest: "g1", revision: 2 });
|
||||
});
|
||||
|
||||
it("after a reconnect, state subscribers get a reset and calls work again", async () => {
|
||||
const c = cluster();
|
||||
const browser = c.browser({ guest: "g1" });
|
||||
const counter = browser.host.hold(SessionCounter, { session: "S1", name: "r" });
|
||||
const events: string[] = [];
|
||||
counter.count.events((event) => events.push(event.type));
|
||||
await counter.add(2);
|
||||
browser.up.close();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(counter.status, "loading");
|
||||
browser.reconnect();
|
||||
assert.equal(await counter.add(1), 1);
|
||||
await tick();
|
||||
// The old hold was released with the link, so the worker made a fresh object; the reset carries its value.
|
||||
assert.deepEqual(counter.count.value, { n: 1 });
|
||||
// The first hydration is the listener's snapshot; the re-hold's snapshot is a reset.
|
||||
assert.equal(events[0], "snap");
|
||||
assert.ok(events.includes("reset"));
|
||||
});
|
||||
});
|
||||
|
||||
const Big = remote("t.big", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { notes: arr(str()) },
|
||||
methods: { echo: method(arr(str()), arr(str())) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** About 160,000 characters of JSON: above PACK_THRESHOLD. */
|
||||
const large = (tag: string) => Array.from({ length: 40 }, (_, i) => `${tag}${i}:${"x".repeat(4000)}`);
|
||||
|
||||
describe("links: methods without arguments or result", () => {
|
||||
it("travel without args and without a value, and resolve to the result or undefined", async () => {
|
||||
const Tool = remote("t.tool", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: { methods: { ping: method(), count: method(undefined, num()), note: method(str()) } },
|
||||
},
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const notes: string[] = [];
|
||||
server.serve(Tool, () => ({
|
||||
variant: "main",
|
||||
ping: () => {},
|
||||
count: () => 2,
|
||||
note: (text) => {
|
||||
notes.push(text);
|
||||
return "dropped" as never;
|
||||
},
|
||||
}));
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const sent: Frame[] = [];
|
||||
const send = up.send.bind(up);
|
||||
up.send = (frame) => {
|
||||
sent.push(frame);
|
||||
return send(frame);
|
||||
};
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const tool = browser.hold(Tool, {});
|
||||
await tool.ready;
|
||||
assert.equal(await tool.ping(), undefined);
|
||||
assert.equal(await tool.count(), 2);
|
||||
assert.equal(await tool.note("hi"), undefined);
|
||||
assert.deepEqual(notes, ["hi"]);
|
||||
const calls = sent.filter((frame) => frame.t === "call");
|
||||
assert.deepEqual(
|
||||
calls.map((frame) => (frame.t === "call" ? "args" in frame && frame.args !== undefined : undefined)),
|
||||
[false, false, true],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links: protocol version", () => {
|
||||
const mismatches = () => {
|
||||
const seen: { local: number; peer: number | undefined }[] = [];
|
||||
return {
|
||||
seen,
|
||||
onProtocolMismatch: (versions: { local: number; peer: number | undefined }) => seen.push(versions),
|
||||
};
|
||||
};
|
||||
|
||||
it("a peer without a protocol version (an old client) is told and dropped; nothing it sends is served", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
serveCounter(server);
|
||||
const accepted = mismatches();
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" }, onProtocolMismatch: accepted.onProtocolMismatch });
|
||||
const frames: Frame[] = [];
|
||||
let closed: string | undefined;
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.onClose((reason) => {
|
||||
closed = reason;
|
||||
});
|
||||
raw.send({ t: "hello", name: "old", kind: "client", codeVersion: 0 });
|
||||
raw.send({ t: "hold", id: 1, at: "server", token: Counter.description, input: { name: "c" } });
|
||||
await tick();
|
||||
assert.deepEqual(accepted.seen, [{ local: PROTOCOL_VERSION, peer: 1 }]);
|
||||
assert.equal(closed, `protocol-version ${PROTOCOL_VERSION}`);
|
||||
assert.equal(down.closed, true);
|
||||
assert(!frames.some((frame) => frame.t === "shape"), "the old client's hold was not served");
|
||||
});
|
||||
|
||||
it("a client learns that its server speaks another version, and stops", async () => {
|
||||
const client = mismatches();
|
||||
const [down, up] = memoryLinkPair();
|
||||
const browser = createHost({ self: { kind: "host" }, name: "browser", kind: "client", onError: () => {} });
|
||||
browser.connect(up, { principal: { kind: "server" }, onProtocolMismatch: client.onProtocolMismatch });
|
||||
// A newer server: its hello announces the next version, and it closes the link the same way Chord does.
|
||||
down.send({ t: "hello", protocol: PROTOCOL_VERSION + 1, name: "server", kind: "host", codeVersion: 1 });
|
||||
await tick();
|
||||
assert.deepEqual(client.seen, [{ local: PROTOCOL_VERSION, peer: PROTOCOL_VERSION + 1 }]);
|
||||
assert.equal(up.closed, true);
|
||||
|
||||
// Told by the peer (it noticed first): the close reason carries its version.
|
||||
const told = mismatches();
|
||||
const [down2, up2] = memoryLinkPair();
|
||||
const other = createHost({ self: { kind: "host" }, name: "browser", kind: "client", onError: () => {} });
|
||||
other.connect(up2, { principal: { kind: "server" }, onProtocolMismatch: told.onProtocolMismatch });
|
||||
down2.close(`protocol-version ${PROTOCOL_VERSION + 1}`);
|
||||
await tick();
|
||||
assert.deepEqual(told.seen, [{ local: PROTOCOL_VERSION, peer: PROTOCOL_VERSION + 1 }]);
|
||||
});
|
||||
|
||||
it("peers of the same version talk; an ordinary close is no mismatch", async () => {
|
||||
const both = mismatches();
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" }, onProtocolMismatch: both.onProtocolMismatch });
|
||||
const browser = createHost({ self: { kind: "host" }, name: "browser", kind: "client", onError: () => {} });
|
||||
browser.connect(up, { principal: { kind: "server" }, onProtocolMismatch: both.onProtocolMismatch });
|
||||
await tick();
|
||||
up.close("bye");
|
||||
await tick();
|
||||
assert.deepEqual(both.seen, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links: packed payloads", () => {
|
||||
it("large args, results and snapshots cross a relay deflated and arrive intact", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
worker.serve(Big, (_input, _principal, self) => {
|
||||
const notesState = self.state("notes", arr(str()), large("a"));
|
||||
return { variant: "main", notes: notesState, echo: (notes: string[]) => notes };
|
||||
});
|
||||
return down;
|
||||
},
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { owner: true } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const big = browser.hold(Big, {});
|
||||
assert.deepEqual(await big.echo(large("c")), large("c"));
|
||||
big.notes.subscribe(() => {});
|
||||
for (let i = 0; i < 100 && big.notes.value === undefined; i++) await sleep(5);
|
||||
assert.deepEqual(big.notes.value, large("a"));
|
||||
});
|
||||
|
||||
it("only payloads above the threshold are packed; the receiver unpacks them", async () => {
|
||||
const [a, b] = memoryLinkPair();
|
||||
const frames: Frame[] = [];
|
||||
b.receive((frame) => frames.push(frame));
|
||||
a.send({ t: "result", id: 1, value: large("v") });
|
||||
a.send({ t: "result", id: 2, value: ["small"] });
|
||||
a.send({ t: "state", hold: 3, m: "notes", as: "snap", value: large("s") });
|
||||
for (let i = 0; i < 50 && frames.length < 3; i++) await tick();
|
||||
const [packed, small, snap] = frames;
|
||||
assert(packed?.t === "result" && packed.value instanceof Packed);
|
||||
assert.deepEqual(unpack(packed.value), large("v"));
|
||||
assert(small?.t === "result");
|
||||
assert.deepEqual(small.value, ["small"]);
|
||||
assert(snap?.t === "state" && snap.value instanceof Packed);
|
||||
assert.deepEqual(unpack(snap.value), large("s"));
|
||||
|
||||
// A relay forwards the packed bytes as they are.
|
||||
const [c, d] = memoryLinkPair();
|
||||
const forwarded: Frame[] = [];
|
||||
d.receive((frame) => forwarded.push(frame));
|
||||
c.send({ ...packed, id: 9 });
|
||||
for (let i = 0; i < 50 && forwarded.length < 1; i++) await tick();
|
||||
const relayed = forwarded[0];
|
||||
assert(relayed?.t === "result" && relayed.value instanceof Packed);
|
||||
assert.deepEqual(relayed.value.bytes, packed.value.bytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links: IPC", () => {
|
||||
it("a write that fails after the peer process exited (EPIPE) ends the link instead of raising an unhandled error", async () => {
|
||||
// Node reports such a failure asynchronously: to the send callback, or else as an `error` event nobody handles.
|
||||
const sent: unknown[] = [];
|
||||
const channel: IpcChannel = {
|
||||
connected: true,
|
||||
send: (message, callback) => {
|
||||
sent.push(message);
|
||||
const error = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||
if (callback === undefined) throw new Error("an IPC send without a callback cannot report EPIPE");
|
||||
process.nextTick(() => callback(error));
|
||||
return false;
|
||||
},
|
||||
on: () => undefined,
|
||||
off: () => undefined,
|
||||
};
|
||||
const link = ipcLink(channel);
|
||||
const reasons: string[] = [];
|
||||
link.onClose((reason) => reasons.push(reason));
|
||||
link.send({ t: "ping", n: 1 });
|
||||
await tick();
|
||||
assert.equal(link.closed, true);
|
||||
assert.deepEqual(reasons, ["ipc send failed: EPIPE"]);
|
||||
assert.equal(sent.length, 1);
|
||||
link.send({ t: "ping", n: 2 });
|
||||
assert.equal(sent.length, 1, "a closed link sends nothing more");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,576 +0,0 @@
|
|||
// Regression tests for Astra's phase 1b review (findings #1-#11) and the shared-worker test gap.
|
||||
import assert from "node:assert/strict";
|
||||
import { num, obj, str, unknown } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Facet,
|
||||
type FacetLoader,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
type Link,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 100): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
function latch() {
|
||||
let open = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
/** A link that logs what it sends and can drop a frame by closing instead. */
|
||||
class WatchedLink implements Link {
|
||||
readonly inner: Link;
|
||||
readonly sent: Frame[] = [];
|
||||
closeOn: ((frame: Frame) => boolean) | undefined;
|
||||
constructor(inner: Link) {
|
||||
this.inner = inner;
|
||||
}
|
||||
get closed(): boolean {
|
||||
return this.inner.closed;
|
||||
}
|
||||
send(frame: Frame): number | undefined {
|
||||
this.sent.push(frame);
|
||||
if (this.closeOn?.(frame)) {
|
||||
this.inner.close("dropped");
|
||||
return undefined;
|
||||
}
|
||||
return this.inner.send(frame);
|
||||
}
|
||||
receive(handler: (frame: Frame) => void): void {
|
||||
this.inner.receive(handler);
|
||||
}
|
||||
onClose(handler: (reason: string) => void): void {
|
||||
this.inner.onClose(handler);
|
||||
}
|
||||
close(reason?: string): void {
|
||||
this.inner.close(reason);
|
||||
}
|
||||
}
|
||||
|
||||
const Counter = remote("t.counter", {
|
||||
version: 1,
|
||||
input: obj({ name: str() }, { open: true }),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { count: obj({ n: num() }, { open: true }) },
|
||||
methods: {
|
||||
add: method(num(), num()),
|
||||
who: method(undefined, unknown()),
|
||||
slow: method(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** A server routing `session:*` to workers it spawns once per name; the server→worker link is watched. */
|
||||
function cluster() {
|
||||
const log: string[] = [];
|
||||
const principals: unknown[] = [];
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const workers = new Map<string, { host: Host; link: WatchedLink }>();
|
||||
const state = {
|
||||
server,
|
||||
log,
|
||||
principals,
|
||||
workers,
|
||||
resolveGate: undefined as Promise<void> | undefined,
|
||||
slowGate: latch(),
|
||||
browser(principal: unknown, restamp?: (p: unknown) => unknown | undefined) {
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal, restamp });
|
||||
return {
|
||||
host: createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
}),
|
||||
up,
|
||||
};
|
||||
},
|
||||
raw(principal: unknown) {
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal });
|
||||
const frames: Frame[] = [];
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
return { raw, frames };
|
||||
},
|
||||
};
|
||||
server.route("session:", {
|
||||
principal: (name) => ({ host: name }),
|
||||
authorize: () => true,
|
||||
resolve: async (name) => {
|
||||
await state.resolveGate;
|
||||
const existing = workers.get(name);
|
||||
if (existing !== undefined) return existing.link;
|
||||
const [down, up] = memoryLinkPair();
|
||||
const link = new WatchedLink(down);
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
worker.serve(Counter, (input, principal, self) => {
|
||||
principals.push(principal);
|
||||
self.resource("probe", {
|
||||
version: 1,
|
||||
open: () => void log.push(`open ${input.name}`),
|
||||
close: () => void log.push(`close ${input.name}`),
|
||||
});
|
||||
const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 });
|
||||
return {
|
||||
variant: "main",
|
||||
count,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
draft.n += delta;
|
||||
});
|
||||
return count.value.n;
|
||||
},
|
||||
who: () => principal,
|
||||
slow: async (ctx: Ctx) => {
|
||||
ctx.signal.addEventListener("abort", () => log.push("slow aborted"));
|
||||
await state.slowGate.promise;
|
||||
},
|
||||
};
|
||||
});
|
||||
workers.set(name, { host: worker, link });
|
||||
return link;
|
||||
},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
const internals = (host: Host) =>
|
||||
host as unknown as {
|
||||
connections: Set<{
|
||||
role: string;
|
||||
peer: { kind: string; name?: string };
|
||||
relayedRefs: Map<number, unknown>;
|
||||
relayedSubs: Map<number, unknown>;
|
||||
relayedCalls: Map<number, unknown>;
|
||||
}>;
|
||||
reloading: boolean;
|
||||
};
|
||||
|
||||
describe("links review: identity and stamping", () => {
|
||||
it("#1 the acceptor decides what a peer is: a client announcing itself as a host is cut off", async () => {
|
||||
const c = cluster();
|
||||
const [down, raw] = memoryLinkPair();
|
||||
c.server.accept(down, { principal: { guest: "g" } });
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", name: "session:S9", kind: "host", codeVersion: 1 });
|
||||
await until(() => raw.closed);
|
||||
// A host link must have a name and its hello must match it.
|
||||
assert.throws(() => c.server.accept(memoryLinkPair()[0], { principal: {}, kind: "host" }), /needs its name/);
|
||||
});
|
||||
|
||||
it("#1 root_end is honored only from the link the root came over", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const Echo = remote("t.echo", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { root: method(undefined, str()) } } },
|
||||
});
|
||||
const facet = (version: number): Facet => ({
|
||||
name: "echo",
|
||||
setup: (api) =>
|
||||
api.serve(Echo, (_input, _principal, self) => {
|
||||
self.state("n", num(), 0, { version });
|
||||
return { variant: "main", root: (ctx: Ctx) => ctx.rootId };
|
||||
}),
|
||||
});
|
||||
server.install(facet(1));
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "w" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "w",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const go = latch();
|
||||
let rootId = "";
|
||||
const pinned = worker.root(async (ctx) => {
|
||||
rootId = (await (await ctx.get(Echo, {})).root(ctx)) as string;
|
||||
await go.promise;
|
||||
});
|
||||
await until(() => rootId !== "");
|
||||
// A client forging the worker root's end must not release its lease.
|
||||
const [clientDown, raw] = memoryLinkPair();
|
||||
server.accept(clientDown, { principal: { guest: "g" } });
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
raw.send({ t: "root_end", rootId });
|
||||
await sleep(10);
|
||||
await assert.rejects(server.reload([facet(2)], { gateDeadline: 40 }), (error) => {
|
||||
assert.ok(isChordError(error, "aborted") && error.reason === "gate_deadline");
|
||||
assert.ok(error.message.includes(rootId));
|
||||
return true;
|
||||
});
|
||||
go.open();
|
||||
await pinned;
|
||||
});
|
||||
|
||||
it("#2 a revocation while the route is still resolving closes the relay; no worker object is made", async () => {
|
||||
const c = cluster();
|
||||
const gate = latch();
|
||||
c.resolveGate = gate.promise;
|
||||
const browser = c.browser({ guest: "g", revision: 1 }, (p) => ({ ...(p as object), revision: 2 }));
|
||||
const counter = browser.host.hold(Counter, { name: "a" });
|
||||
await sleep(5);
|
||||
await c.server.invalidatePrincipal({ guest: "g" });
|
||||
await until(() => counter.status === "gone");
|
||||
gate.open();
|
||||
await sleep(10);
|
||||
assert.deepEqual(c.principals, []);
|
||||
const again = browser.host.hold(Counter, { name: "a" });
|
||||
assert.deepEqual(await again.who(), { guest: "g", revision: 2 });
|
||||
});
|
||||
|
||||
it("#10 malformed frames and duplicate ids close the link", async () => {
|
||||
const c = cluster();
|
||||
const first = c.raw({ guest: "g" });
|
||||
first.raw.send({ t: "call", hold: 1 } as never);
|
||||
await until(() => first.raw.closed);
|
||||
const second = c.raw({ guest: "g" });
|
||||
const token = Counter.description;
|
||||
second.raw.send({ t: "hold", id: 1, at: "session:S1", token, input: { name: "a" } });
|
||||
second.raw.send({ t: "hold", id: 1, at: "session:S1", token, input: { name: "b" } });
|
||||
await until(() => second.raw.closed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("links review: relay ownership", () => {
|
||||
it("#7 a browser disconnect cancels its call downstream", async () => {
|
||||
const c = cluster();
|
||||
const browser = c.browser({ guest: "g" });
|
||||
const counter = browser.host.hold(Counter, { name: "a" });
|
||||
await counter.ready;
|
||||
void counter.slow().catch(() => {});
|
||||
await sleep(10);
|
||||
browser.up.close();
|
||||
await until(() => c.log.includes("slow aborted"));
|
||||
const down = [...internals(c.server).connections].find((connection) => connection.peer.name === "session:S1")!;
|
||||
assert.equal(down.relayedCalls.size, 0);
|
||||
});
|
||||
|
||||
it("#9 state stopped while the route resolves is forwarded as a hold without state", async () => {
|
||||
const c = cluster();
|
||||
const gate = latch();
|
||||
c.resolveGate = gate.promise;
|
||||
const { raw } = c.raw({ guest: "g" });
|
||||
raw.send({ t: "hold", id: 1, at: "session:S1", token: Counter.description, input: { name: "a" } });
|
||||
raw.send({ t: "unsub", hold: 1 });
|
||||
await sleep(5);
|
||||
gate.open();
|
||||
await until(() => c.workers.get("session:S1")?.link.sent.some((frame) => frame.t === "hold") === true);
|
||||
await sleep(10);
|
||||
const sent = c.workers.get("session:S1")!.link.sent;
|
||||
const hold = sent.find((frame) => frame.t === "hold");
|
||||
assert.ok(hold?.t === "hold" && hold.state === false);
|
||||
assert.ok(!sent.some((frame) => frame.t === "sub" || frame.t === "unsub"));
|
||||
});
|
||||
|
||||
it("#9 a release while the route resolves settles waiting calls and forwards nothing", async () => {
|
||||
const c = cluster();
|
||||
const gate = latch();
|
||||
c.resolveGate = gate.promise;
|
||||
const { raw, frames } = c.raw({ guest: "g" });
|
||||
raw.send({ t: "hold", id: 1, at: "session:S1", token: Counter.description, input: { name: "a" } });
|
||||
raw.send({ t: "call", id: 2, hold: 1, member: "add", args: 1 });
|
||||
await sleep(5);
|
||||
raw.send({ t: "release", id: 1 });
|
||||
await until(() => frames.some((frame) => frame.t === "result" && frame.id === 2));
|
||||
const result = frames.find((frame) => frame.t === "result" && frame.id === 2);
|
||||
assert.ok(result?.t === "result" && result.error !== undefined);
|
||||
gate.open();
|
||||
await sleep(10);
|
||||
assert.ok(!(c.workers.get("session:S1")?.link.sent ?? []).some((frame) => frame.t === "hold"));
|
||||
});
|
||||
|
||||
it("a release while the route resolves still admits the resolved worker: its references upstream go live", async () => {
|
||||
const Feed = remote("t.feed", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { ping: method(undefined, str()) } } },
|
||||
});
|
||||
const c = cluster();
|
||||
c.server.serve(Feed, () => ({ variant: "main", ping: () => "pong" }));
|
||||
const gate = latch();
|
||||
c.resolveGate = gate.promise;
|
||||
const { raw } = c.raw({ guest: "g" });
|
||||
raw.send({ t: "hold", id: 1, at: "session:S1", token: Counter.description, input: { name: "a" } });
|
||||
await sleep(5);
|
||||
// The client goes away while the worker starts: the route still hands over the worker it spawned.
|
||||
raw.send({ t: "release", id: 1 });
|
||||
gate.open();
|
||||
await until(() => c.workers.has("session:S1"));
|
||||
const worker = c.workers.get("session:S1")!.host;
|
||||
const feed = await worker.get(Feed, {}, { signal: AbortSignal.timeout(500) });
|
||||
assert.equal(await feed.ping(), "pong");
|
||||
});
|
||||
|
||||
it("#9 an origin disconnect removes its relayed holds and subscriptions downstream", async () => {
|
||||
const c = cluster();
|
||||
const browser = c.browser({ guest: "g" });
|
||||
const counter = browser.host.hold(Counter, { name: "a" });
|
||||
counter.count.subscribe(() => {});
|
||||
await counter.add(1);
|
||||
const down = [...internals(c.server).connections].find((connection) => connection.peer.name === "session:S1")!;
|
||||
assert.equal(down.relayedRefs.size, 1);
|
||||
browser.up.close();
|
||||
await until(() => down.relayedRefs.size === 0);
|
||||
await until(() => c.log.includes("close a"));
|
||||
});
|
||||
|
||||
it("two browsers share one worker connection; one leaving releases only its own holds", async () => {
|
||||
const c = cluster();
|
||||
const one = c.browser({ guest: "g1" });
|
||||
const two = c.browser({ guest: "g2" });
|
||||
const a = one.host.hold(Counter, { name: "same" });
|
||||
const b = two.host.hold(Counter, { name: "same" });
|
||||
assert.equal(await a.add(1), 1);
|
||||
assert.equal(await b.add(5), 5);
|
||||
assert.equal(c.workers.size, 1);
|
||||
assert.deepEqual(c.principals, [{ guest: "g1" }, { guest: "g2" }]);
|
||||
one.up.close();
|
||||
await until(() => c.log.filter((entry) => entry === "close same").length === 1);
|
||||
assert.equal(await b.add(1), 6);
|
||||
});
|
||||
|
||||
it("#11 snapshots through an accepts adapter are validated against the consumer's schema", async () => {
|
||||
const DocV1 = remote("t.doc", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { view: obj({ text: str() }, { open: true }) } } },
|
||||
});
|
||||
const DocV2 = remote("t.doc", {
|
||||
version: 2,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { view: obj({ body: str() }, { open: true }) } } },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(
|
||||
DocV2,
|
||||
(_input, _principal, self) => ({
|
||||
variant: "main",
|
||||
view: self.state("view", obj({ body: str() }, { open: true }), { body: "hi" }),
|
||||
}),
|
||||
{
|
||||
accepts: {
|
||||
1: (current) => {
|
||||
const view = current.view as {
|
||||
value: { body: string };
|
||||
events: (fn: (event: never) => void) => () => void;
|
||||
};
|
||||
const map = (value: { body: string }) => ({ text: value.body });
|
||||
return {
|
||||
variant: "main",
|
||||
view: {
|
||||
get value() {
|
||||
return map(view.value);
|
||||
},
|
||||
subscribe: (fn: (value: unknown) => void) => view.events(() => fn(map(view.value))),
|
||||
events: (fn: (event: unknown) => void) =>
|
||||
view.events((event: { type: string }) =>
|
||||
fn({ type: event.type === "ops" ? "reset" : event.type, value: map(view.value) }),
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const doc = browser.hold(DocV1, {});
|
||||
await doc.ready;
|
||||
assert.deepEqual(doc.view.value, { text: "hi" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("links review: cross-host reload", () => {
|
||||
const Greeter = remote("t.greeter", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: { main: { methods: { greet: method(str(), str()) } } },
|
||||
});
|
||||
const greeterFacet = (greeting: string): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) => api.serve(Greeter, () => ({ variant: "main", greet: (name: string) => `${greeting} ${name}` })),
|
||||
});
|
||||
|
||||
function setup(options: { slowLoad?: Promise<void> } = {}) {
|
||||
const modules = new Map<string, Facet>([["greeter", greeterFacet("v1")]]);
|
||||
const loader: FacetLoader = async (names) => {
|
||||
await options.slowLoad;
|
||||
return names.map((name) => modules.get(String(name))!);
|
||||
};
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const connect = () => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
const watched = new WatchedLink(down);
|
||||
server.accept(watched, { principal: { worker: "w" }, kind: "host", name: "session:S1" });
|
||||
return { watched, up };
|
||||
};
|
||||
const first = connect();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: first.up, principal: { kind: "server" } },
|
||||
loader,
|
||||
onError: () => {},
|
||||
});
|
||||
worker.install(modules.get("greeter")!);
|
||||
return { server, worker, modules, link: first.watched, connect };
|
||||
}
|
||||
|
||||
it("#3 an abort while the child is still loading modules frees its reload slot; a later reload works", async () => {
|
||||
const gate = latch();
|
||||
const { server, worker, modules } = setup({ slowLoad: gate.promise });
|
||||
await tick();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await assert.rejects(server.reload([], { remote: { "session:": ["greeter"] }, voteDeadline: 30 }), (error) =>
|
||||
isChordError(error, "aborted"),
|
||||
);
|
||||
gate.open();
|
||||
await until(() => !internals(worker).reloading);
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
assert.equal(await server.hold(Greeter, {}).greet("x"), "v2 x");
|
||||
});
|
||||
|
||||
it("#3 the parent link dying while the child prepares unwinds the child", async () => {
|
||||
const gate = latch();
|
||||
const { server, worker, modules, link } = setup({ slowLoad: gate.promise });
|
||||
await tick();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
const reload = server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
await sleep(5);
|
||||
link.close("gone");
|
||||
await assert.rejects(reload);
|
||||
gate.open();
|
||||
await until(() => !internals(worker).reloading);
|
||||
assert.equal(worker.codeVersion, 1);
|
||||
});
|
||||
|
||||
it("#4 tasks the commit starts cannot reach a child before the child's commit", async () => {
|
||||
const { server, modules } = setup();
|
||||
await tick();
|
||||
const results: unknown[] = [];
|
||||
const Caller = local("t.caller", { version: 1, input: obj({}, { open: true }) });
|
||||
const callerFacet = (active: boolean): Facet => ({
|
||||
name: "caller",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Caller,
|
||||
(_input, _principal, self) => {
|
||||
const greeter = self.get(Greeter, {});
|
||||
if (active) {
|
||||
self.task(async (ctx) => {
|
||||
results.push(
|
||||
await greeter.greet("t", ctx).catch((error: unknown) => (error as { code: string }).code),
|
||||
);
|
||||
});
|
||||
}
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
server.install(callerFacet(false));
|
||||
await sleep(10);
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
await server.reload([callerFacet(true)], { remote: { "session:": ["greeter"] } });
|
||||
await until(() => results.length === 1);
|
||||
assert.deepEqual(results, ["v2 t"]);
|
||||
});
|
||||
|
||||
it("#5 a child that missed a commit is reconciled on reconnect, never relabeled", async () => {
|
||||
const { server, worker, modules, link, connect } = setup();
|
||||
await tick();
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
// The commit frame is lost: the link dies right after the child voted.
|
||||
link.closeOn = (frame) => frame.t === "commit";
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
await until(() => !internals(worker).reloading);
|
||||
assert.notEqual(worker.codeVersion, server.codeVersion);
|
||||
const next = connect();
|
||||
worker.connect(next.up, { principal: { kind: "server" } });
|
||||
const greeter = server.hold(Greeter, {});
|
||||
assert.equal(await greeter.greet("x"), "v2 x");
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
});
|
||||
|
||||
it("#6 a server root that bounces through a worker and back ends normally (no leaked lease)", async () => {
|
||||
const { server, worker } = setup();
|
||||
await tick();
|
||||
const Echo = remote("t.echo2", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { ping: method() } } },
|
||||
});
|
||||
const Bouncer = remote("t.bouncer", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: { main: { methods: { bounce: method() } } },
|
||||
});
|
||||
const echoFacet = (version: number): Facet => ({
|
||||
name: "echo",
|
||||
setup: (api) =>
|
||||
api.serve(Echo, (_input, _principal, self) => {
|
||||
self.state("n", num(), 0, { version });
|
||||
return { variant: "main", ping: () => {} };
|
||||
}),
|
||||
});
|
||||
server.install(echoFacet(1));
|
||||
// Keeps the Echo object the bounced call reaches (it acts as the worker's principal) alive, so the reload gate
|
||||
// must decide about the bounced root's lease.
|
||||
const echo = server.hold(Echo, {}, { principal: { worker: "w" } });
|
||||
await echo.ready;
|
||||
worker.serve(Bouncer, () => ({
|
||||
variant: "main",
|
||||
bounce: async (ctx: Ctx) => {
|
||||
await (await ctx.get(Echo, {})).ping(ctx);
|
||||
},
|
||||
}));
|
||||
await server.root((ctx) => server.hold(Bouncer, {}).bounce(ctx));
|
||||
await sleep(10);
|
||||
await server.reload([echoFacet(2)], { gateDeadline: 200 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, it } from "vitest";
|
||||
import { createHost } from "../../src/old/index.ts";
|
||||
|
||||
describe("node loader", () => {
|
||||
it("loads facet modules and reloads them with fresh copies of their relative imports", () => {
|
||||
const child = spawnSync(process.execPath, [fileURLToPath(new URL("./fixtures/loader.ts", import.meta.url))], {
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
assert.equal(child.error, undefined, child.stderr);
|
||||
assert.equal(child.status, 0, `${child.stdout}\n${child.stderr}`);
|
||||
});
|
||||
|
||||
it("needs a configured loader for modules", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
await assert.rejects(host.load(["./facet.ts"]), /createHost\(\{ loader \}\)/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, it } from "vitest";
|
||||
import { createHost, ipcLink, isChordError } from "../../src/old/index.ts";
|
||||
import { type ControlMessage, createHostManager, type Driver, forkDriver, processLock } from "../../src/old/manager.ts";
|
||||
import { Echo } from "./fixtures/echo.ts";
|
||||
|
||||
const entry = fileURLToPath(new URL("./fixtures/worker.ts", import.meta.url));
|
||||
const scratch = (name: string) => join(tmpdir(), `chord2-${process.pid}-${name}`);
|
||||
|
||||
const alive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const driver = (env: Record<string, string> = {}, lock?: string): Driver =>
|
||||
forkDriver({ entry, env: { HOST_NAME: "w1", ...env }, init: { greeting: "hi" }, lock });
|
||||
|
||||
describe("processLock", () => {
|
||||
// A socket path over 104 bytes failed with EINVAL on macOS when TMPDIR was deep (./test.sh below macOS's TMPDIR).
|
||||
it("admits one holder when the temporary directory is too deep for a socket path", async () => {
|
||||
const deep = join(scratch("deep"), "a".repeat(60), "b".repeat(60));
|
||||
mkdirSync(deep, { recursive: true });
|
||||
const previous = process.env.TMPDIR;
|
||||
process.env.TMPDIR = deep;
|
||||
try {
|
||||
const name = `deep-${process.pid}`;
|
||||
const release = await processLock(name);
|
||||
await assert.rejects(processLock(name), (error) => isChordError(error) && error.reason === "locked");
|
||||
await release();
|
||||
await (await processLock(name))();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.TMPDIR;
|
||||
else process.env.TMPDIR = previous;
|
||||
rmSync(scratch("deep"), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("host manager (real child processes)", () => {
|
||||
// A child host that forks its own children used to pass its CHORD2_LOCK on, so they failed with "locked".
|
||||
it("a child without a lock does not inherit the parent's lock", async () => {
|
||||
const name = `chord2-test-${process.pid}-inherited`;
|
||||
const release = await processLock(name);
|
||||
const previous = process.env.CHORD2_LOCK;
|
||||
process.env.CHORD2_LOCK = name;
|
||||
const manager = createHostManager({ driver: driver() });
|
||||
try {
|
||||
await manager.start();
|
||||
assert.equal(manager.status, "running");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CHORD2_LOCK;
|
||||
else process.env.CHORD2_LOCK = previous;
|
||||
await manager.stop();
|
||||
await release();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts a child host, links it, stops it gracefully", async () => {
|
||||
const manager = createHostManager({ driver: driver() });
|
||||
const instance = await manager.start();
|
||||
assert.equal(manager.status, "running");
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.accept(instance.link, { principal: { worker: "w1" }, kind: "host", name: "w1" });
|
||||
const echo = server.hold(Echo, {});
|
||||
assert.equal(await echo.echo("x"), `hi x from ${instance.pid}`);
|
||||
assert.equal(await manager.start(), instance);
|
||||
await manager.stop();
|
||||
assert.equal(manager.status, "stopped");
|
||||
assert.deepEqual(await instance.exited, { code: 0, signal: null });
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a child-owned kernel lock admits one live process; replace waits for the exit before the next start", async () => {
|
||||
const lock = `chord2-test-${process.pid}-lock`;
|
||||
const pids: number[] = [];
|
||||
const base = driver({}, lock);
|
||||
const watched: Driver = {
|
||||
spawn() {
|
||||
// Exit before replace: the previous child is gone when the next one spawns.
|
||||
for (const pid of pids) assert.ok(!alive(pid), `pid ${pid} still alive`);
|
||||
const instance = base.spawn();
|
||||
pids.push(instance.pid);
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
const first = createHostManager({ driver: watched });
|
||||
const second = createHostManager({ driver: driver({}, lock) });
|
||||
const a = await first.start();
|
||||
// Both managers race for the same lock; only the kernel decides, so the second child cannot start.
|
||||
await assert.rejects(second.start(), (error) => isChordError(error, "unavailable") && error.reason === "locked");
|
||||
const b = await first.replace();
|
||||
assert.notEqual(a.pid, b.pid);
|
||||
await first.stop();
|
||||
await second.start();
|
||||
await second.stop();
|
||||
});
|
||||
|
||||
it("a graceful exit still takes the child's descendants with it", async () => {
|
||||
const file = scratch("grandchild-graceful");
|
||||
rmSync(file, { force: true });
|
||||
const manager = createHostManager({ driver: driver({ GRANDCHILD_FILE: file }) });
|
||||
await manager.start();
|
||||
const grandchild = Number(readFileSync(file, "utf8"));
|
||||
assert.ok(alive(grandchild));
|
||||
await manager.stop();
|
||||
for (let i = 0; i < 50 && alive(grandchild); i++) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.ok(!alive(grandchild));
|
||||
rmSync(file, { force: true });
|
||||
});
|
||||
|
||||
it("idleFor counts from the busy → idle transition", async () => {
|
||||
const listeners = new Set<(message: ControlMessage) => void>();
|
||||
const fake: Driver = {
|
||||
spawn: () => ({
|
||||
pid: 0,
|
||||
link: ipcLink({ connected: false, send: () => false, on: () => undefined, off: () => undefined }),
|
||||
exited: new Promise(() => {}),
|
||||
send: () => {},
|
||||
killTree: () => {},
|
||||
onControl: (listener) => listeners.add(listener),
|
||||
}),
|
||||
};
|
||||
const emit = (message: ControlMessage) => {
|
||||
for (const listener of listeners) listener(message);
|
||||
};
|
||||
const manager = createHostManager({ driver: fake });
|
||||
const started = manager.start();
|
||||
for (let i = 0; i < 20 && listeners.size === 0; i++) await new Promise((resolve) => setImmediate(resolve));
|
||||
emit({ type: "ready" });
|
||||
await started;
|
||||
emit({ type: "status", activeCalls: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
assert.equal(manager.liveness.idleFor, 0);
|
||||
emit({ type: "status", activeCalls: 0 });
|
||||
assert.ok(manager.liveness.idleFor < 60, `idleFor ${manager.liveness.idleFor}`);
|
||||
});
|
||||
|
||||
it("kills the process tree when a child ignores shutdown past the grace period", async () => {
|
||||
const file = scratch("grandchild");
|
||||
rmSync(file, { force: true });
|
||||
const manager = createHostManager({
|
||||
driver: driver({ FIXTURE_MODE: "ignore-shutdown", GRANDCHILD_FILE: file }),
|
||||
grace: 300,
|
||||
});
|
||||
const instance = await manager.start();
|
||||
const grandchild = Number(readFileSync(file, "utf8"));
|
||||
assert.ok(alive(grandchild));
|
||||
await manager.stop();
|
||||
assert.equal((await instance.exited).signal, "SIGKILL");
|
||||
for (let i = 0; i < 50 && alive(grandchild); i++) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.ok(!alive(grandchild));
|
||||
rmSync(file, { force: true });
|
||||
});
|
||||
|
||||
it("reports a fatal child as a failed start", async () => {
|
||||
const manager = createHostManager({ driver: driver({ FIXTURE_MODE: "fatal" }) });
|
||||
await assert.rejects(manager.start(), /fixture refuses to start/);
|
||||
assert.equal(manager.status, "stopped");
|
||||
});
|
||||
|
||||
it("stops an idle child when keepAlive says so", async () => {
|
||||
const exits: unknown[] = [];
|
||||
const manager = createHostManager({
|
||||
driver: driver(),
|
||||
keepAlive: (liveness) => !liveness.booting && liveness.idleFor < 150,
|
||||
checkInterval: 50,
|
||||
onExit: (exit) => exits.push(exit),
|
||||
});
|
||||
await manager.start();
|
||||
for (let i = 0; i < 100 && manager.status !== "stopped"; i++)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(manager.status, "stopped");
|
||||
assert.equal(exits.length, 1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { num, obj, opt, str } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
bind,
|
||||
createHost,
|
||||
type Facet,
|
||||
type Frame,
|
||||
type Host,
|
||||
hostFamily,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
type Link,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
/** Lives on `session:` + its input's `session`. */
|
||||
const Note = remote("t.note", {
|
||||
version: 1,
|
||||
input: obj({ session: str(), id: str() }, { open: true }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: { main: { methods: { where: method(undefined, str()) } } },
|
||||
});
|
||||
|
||||
/** Lives on `server`. */
|
||||
const Catalog = remote("t.catalog", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { where: method(undefined, str()) } } },
|
||||
});
|
||||
|
||||
/** A server with two session hosts as children; every host serves what it may and reports its name. */
|
||||
function cluster() {
|
||||
const server = createHost({ name: "server", self: "host", onError: () => {} });
|
||||
server.serve(Catalog, () => ({ variant: "main", where: () => "server" }));
|
||||
const children = new Map<string, Host>();
|
||||
const links = new Map<string, Link>();
|
||||
const child = (name: string) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
links.set(name, down);
|
||||
server.accept(down, { kind: "host", name, principal: "server-side" });
|
||||
const host = createHost({ name, self: "host", upstream: { link: up, principal: "server" }, onError: () => {} });
|
||||
host.serve(Note, ({ id }) => ({ variant: "main", where: () => `${host.name} ${id}` }));
|
||||
children.set(name, host);
|
||||
return { host, down };
|
||||
};
|
||||
const s1 = child("session:S1");
|
||||
const s2 = child("session:S2");
|
||||
const close = async () => {
|
||||
for (const host of children.values()) await host.close();
|
||||
await server.close();
|
||||
};
|
||||
// Relays need a route (where authorization lives); it finds the connected child.
|
||||
server.route("session:", {
|
||||
authorize: () => true,
|
||||
resolve: async (name) => links.get(name)!,
|
||||
principal: (name) => name,
|
||||
});
|
||||
return { server, s1: s1.host, s2: s2.host, close };
|
||||
}
|
||||
|
||||
describe("placement", () => {
|
||||
it("a get goes where the contract places the input: here, a child, or the parent, never named by the caller", async () => {
|
||||
const { server, s1, close } = cluster();
|
||||
await tick();
|
||||
assert.equal(await server.hold(Note, { session: "S1", id: "a" }).where(), "session:S1 a");
|
||||
assert.equal(await server.hold(Note, { session: "S2", id: "b" }).where(), "session:S2 b");
|
||||
// From a session host: its own notes locally, the other session's through the server, the catalog upstream.
|
||||
assert.equal(await s1.hold(Note, { session: "S1", id: "c" }).where(), "session:S1 c");
|
||||
assert.equal(await s1.hold(Catalog, {}).where(), "server");
|
||||
await close();
|
||||
});
|
||||
|
||||
it("serve refuses a type the host is not the place of, also at a reload", async () => {
|
||||
const host = createHost({ name: "session:S1", self: "host", onError: () => {} });
|
||||
assert.throws(
|
||||
() => host.serve(Catalog, () => ({ variant: "main", where: () => "" })),
|
||||
/t\.catalog is declared for server, but session:S1 serves it/,
|
||||
);
|
||||
const server = createHost({ name: "server", self: "host", onError: () => {} });
|
||||
assert.throws(
|
||||
() => server.serve(Note, () => ({ variant: "main", where: () => "" })),
|
||||
/t\.note is declared for session:<session>, but server serves it/,
|
||||
);
|
||||
const misplaced: Facet = {
|
||||
name: "misplaced",
|
||||
setup: (api) => api.serve(Note, () => ({ variant: "main", where: () => "" })),
|
||||
};
|
||||
await assert.rejects(server.reload([misplaced]), /declared for session:<session>, but server serves it/);
|
||||
await host.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("a host refuses a hold its own contract places elsewhere, and a relay one whose target disagrees", async () => {
|
||||
const { server, close } = cluster();
|
||||
await tick();
|
||||
const holds = async (target: string, input: unknown): Promise<Frame | undefined> => {
|
||||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal: "guest" });
|
||||
const frames: Frame[] = [];
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
raw.send({ t: "hold", id: 1, at: target, token: Note.description, input });
|
||||
for (let i = 0; i < 50 && !frames.some((frame) => frame.t === "shape" || frame.t === "gone"); i++)
|
||||
await tick();
|
||||
return frames.find((frame) => frame.t === "shape" || frame.t === "gone");
|
||||
};
|
||||
// A client picks session:S2 for a note of S1: the relay refuses, nothing reaches S2.
|
||||
const relayed = await holds("session:S2", { session: "S1", id: "x" });
|
||||
assert.equal(relayed?.t, "gone");
|
||||
assert.match(relayed?.t === "gone" ? relayed.error.message : "", /placement|does not live on session:S2/);
|
||||
// The right target is served.
|
||||
assert.equal((await holds("session:S1", { session: "S1", id: "x" }))?.t, "shape");
|
||||
// Addressed to the server itself, a note is refused by the server's own contract check.
|
||||
const direct = await holds("server", { session: "S1", id: "x" });
|
||||
assert.equal(direct?.t, "gone");
|
||||
await close();
|
||||
});
|
||||
|
||||
it("the host comes from the normalized input: unknown fields and defaults do not change it", async () => {
|
||||
const Doc = remote("t.doc-default", {
|
||||
version: 1,
|
||||
input: obj({ session: str({ default: "S1" }) }, { open: true }),
|
||||
host: hostFamily("session:", "session"),
|
||||
variants: { main: { methods: { where: method(undefined, str()) } } },
|
||||
});
|
||||
const { server, s1, close } = cluster();
|
||||
s1.serve(Doc, () => ({ variant: "main", where: () => "session:S1" }));
|
||||
await tick();
|
||||
// An untyped caller leaves the field out: the default places it.
|
||||
assert.equal(await server.hold(Doc, {} as never).where(), "session:S1");
|
||||
assert.throws(() => server.hold(Note, { session: "", id: "x" }), /input field session names no host/);
|
||||
await close();
|
||||
});
|
||||
|
||||
it("placement is part of the contract: a moved type is a breaking change, never bridged by an adapter", async () => {
|
||||
const at = (host: string) =>
|
||||
remote("t.moved", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed(host),
|
||||
variants: { main: { methods: { ping: method() } } },
|
||||
});
|
||||
assert.notEqual(at("server").fingerprint, at("session:S1").fingerprint);
|
||||
const host = createHost({ name: "server", self: "host", onError: () => {} });
|
||||
const facet = (version: number, place: string): Facet => ({
|
||||
name: "moved",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
remote("t.moved", {
|
||||
version,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed(place),
|
||||
variants: { main: { methods: { ping: method() } } },
|
||||
}),
|
||||
() => ({ variant: "main", ping: () => {} }),
|
||||
{ accepts: { 1: (current) => current } },
|
||||
),
|
||||
});
|
||||
await host.install(facet(1, "server"));
|
||||
await host.get(at("server"), {});
|
||||
// Same version, other placement: a forgotten bump.
|
||||
await assert.rejects(host.reload([facet(1, "session:S1")]), /declared for session:S1|without a version bump/);
|
||||
// A new version that `accepts` version 1 but lives elsewhere: no adapter makes version 1 consumers bind.
|
||||
const moved = remote("t.moved", {
|
||||
version: 2,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: { main: { methods: { ping: method() } } },
|
||||
});
|
||||
assert.throws(
|
||||
() => bind(moved, { 1: (current) => current }, at("server")),
|
||||
(error) => isChordError(error, "contract_mismatch") && /lives on session:S1/.test(error.message),
|
||||
);
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it("types: the family's field is a required string of the input, and remote contracts need a host", () => {
|
||||
const input = obj({ session: str(), count: num(), note: opt(str()) }, { open: true });
|
||||
const variants = { main: {} };
|
||||
remote("t.ok", { version: 1, input, host: hostFamily("session:", "session"), variants });
|
||||
// @ts-expect-error: no such field
|
||||
remote("t.typo", { version: 1, input, host: hostFamily("session:", "sesion"), variants });
|
||||
// @ts-expect-error: not a string
|
||||
remote("t.number", { version: 1, input, host: hostFamily("session:", "count"), variants });
|
||||
// @ts-expect-error: optional
|
||||
remote("t.optional", { version: 1, input, host: hostFamily("session:", "note"), variants });
|
||||
// @ts-expect-error: a remote contract says where it lives
|
||||
assert.throws(() => remote("t.nowhere", { version: 1, input, variants }), /needs `host`/);
|
||||
assert.throws(() => hostFamily("", "session"), /prefix/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { obj, str } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineFacet,
|
||||
type FacetApi,
|
||||
type Host,
|
||||
hostNamed,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
/** An app's principals: what TypeScript narrows on. */
|
||||
type Principal =
|
||||
| { readonly kind: "owner" }
|
||||
| { readonly kind: "guest"; readonly id: string }
|
||||
| { readonly kind: "server" }
|
||||
| { readonly kind: "host" };
|
||||
|
||||
const principalKey = (principal: Principal): string =>
|
||||
principal.kind === "guest" ? `guest:${principal.id}` : principal.kind;
|
||||
|
||||
const Who = local("t.who", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { who: method(undefined, str()) },
|
||||
});
|
||||
|
||||
describe("principals", () => {
|
||||
it("factories, methods and contexts see the app's principal type", async () => {
|
||||
const host = createHost<Principal>({ self: { kind: "host" }, principalKey });
|
||||
const facet = defineFacet<Principal>((api) => {
|
||||
const plain: FacetApi = api;
|
||||
void plain;
|
||||
api.serve(Who, (_input, principal, self) => {
|
||||
// Narrowing on `kind` needs no cast.
|
||||
const created = principal.kind === "guest" ? `guest ${principal.id}` : principal.kind;
|
||||
self.task(async (ctx) => {
|
||||
const kind: Principal["kind"] = ctx.principal.kind;
|
||||
void kind;
|
||||
});
|
||||
return { who: (ctx) => `${created}, called by ${ctx.principal.kind}` };
|
||||
});
|
||||
});
|
||||
await host.install({ name: "who", ...facet });
|
||||
const guest = host.hold(Who, {}, { principal: { kind: "guest", id: "g1" } });
|
||||
assert.equal(
|
||||
await host.root((ctx) => guest.who(ctx), { principal: { kind: "guest", id: "g1" } }),
|
||||
"guest g1, called by host",
|
||||
);
|
||||
// A typed host, facet API and context are usable where Chord's untyped ones are expected.
|
||||
const untyped: Host = host;
|
||||
void untyped;
|
||||
await host.root(async (ctx) => {
|
||||
const plain: Ctx = ctx;
|
||||
void plain;
|
||||
});
|
||||
// @ts-expect-error: not a principal of this app
|
||||
host.hold(Who, {}, { principal: { kind: "stranger" } });
|
||||
// @ts-expect-error: a pattern names fields of the app's principals
|
||||
void host.invalidatePrincipal({ kind: "guest", name: "g1" }).catch(() => {});
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it("the host's own principal is recognized by key: an equal object reaches the same objects", async () => {
|
||||
const host = createHost<Principal>({ self: { kind: "host" }, principalKey });
|
||||
let created = 0;
|
||||
host.serve(Who, () => {
|
||||
created++;
|
||||
return { who: () => "one" };
|
||||
});
|
||||
const own = host.hold(Who, {});
|
||||
const equal = host.hold(Who, {}, { principal: { kind: "host" } });
|
||||
await Promise.all([own.ready, equal.ready]);
|
||||
assert.equal(created, 1);
|
||||
// Never disposed as a matching principal: it is the host's own.
|
||||
await host.invalidatePrincipal({ kind: "host" });
|
||||
assert.equal(own.status, "live");
|
||||
await host.close();
|
||||
});
|
||||
|
||||
it("a child's parent acts as the principal the child gave `connect`; relayed callers keep theirs", async () => {
|
||||
const Echo = remote("t.echo", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("worker"),
|
||||
variants: { main: { methods: { who: method(undefined, str()) } } },
|
||||
});
|
||||
const server = createHost<Principal>({ name: "server", self: { kind: "host" }, principalKey });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { kind: "host", name: "worker", principal: { kind: "host" } });
|
||||
const worker = createHost<Principal>({
|
||||
name: "worker",
|
||||
self: { kind: "host" },
|
||||
principalKey,
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
});
|
||||
worker.serve(Echo, (_input, principal) => ({ variant: "main", who: () => principalKey(principal) }));
|
||||
const [browserDown, browserUp] = memoryLinkPair();
|
||||
server.accept(browserDown, { principal: { kind: "guest", id: "g1" } });
|
||||
const browser = createHost<Principal>({
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
self: { kind: "host" },
|
||||
upstream: { link: browserUp, principal: { kind: "server" } },
|
||||
});
|
||||
assert.equal(await server.hold(Echo, {}).who(), "server");
|
||||
server.route("worker", {
|
||||
authorize: () => true,
|
||||
resolve: async () => down,
|
||||
principal: () => ({ kind: "host" }),
|
||||
});
|
||||
assert.equal(await browser.hold(Echo, {}).who(), "guest:g1");
|
||||
await browser.close();
|
||||
await worker.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("refuses a host, a parent link or a route without its principal", () => {
|
||||
// @ts-expect-error: `self` is required
|
||||
assert.throws(() => createHost({ name: "x" }), /needs `self`/);
|
||||
const host = createHost({ self: "host" });
|
||||
const [, up] = memoryLinkPair();
|
||||
// @ts-expect-error: `principal` is required
|
||||
assert.throws(() => host.connect(up, {}), /connect needs `principal`/);
|
||||
assert.throws(
|
||||
// @ts-expect-error: `principal` is required
|
||||
() => host.route("w:", { authorize: () => true, resolve: async () => up }),
|
||||
/needs `principal`/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
// Entry 8: what a change costs the provider. The diff is computed once per change; each feed (one per connection and
|
||||
// member) encodes it with its own stateful encoder and its link serializes it.
|
||||
import assert from "node:assert/strict";
|
||||
import { arr, obj, str } from "@earendil-works/pi-schema";
|
||||
import { describe, it, vi } from "vitest";
|
||||
import type * as delta from "../../src/delta/index.ts";
|
||||
import { type Ctx, createHost, hostNamed, memoryLinkPair, method, remote } from "../../src/old/index.ts";
|
||||
import { CountingLink, stateFrames } from "./fixtures/counting.ts";
|
||||
|
||||
const counts = vi.hoisted(() => ({ encoders: 0, encodes: 0 }));
|
||||
|
||||
vi.mock("../../src/delta/index.ts", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof delta>();
|
||||
return {
|
||||
...original,
|
||||
encoder: () => {
|
||||
counts.encoders++;
|
||||
const inner = original.encoder();
|
||||
return {
|
||||
encode: (ops: Parameters<typeof inner.encode>[0]) => {
|
||||
counts.encodes++;
|
||||
return inner.encode(ops);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const List = remote("t.e8-cost", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { items: arr(str()) }, methods: { add: method(str()) } } },
|
||||
});
|
||||
|
||||
describe("provider cost", () => {
|
||||
it("one change to K clients: K encodes (one per feed), one frame per link, the same ops on every link", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(List, (_input, _principal, self) => {
|
||||
const items = self.state("items", arr(str()), []);
|
||||
return {
|
||||
variant: "main",
|
||||
items,
|
||||
add: (text: string, ctx: Ctx) => {
|
||||
items.change(ctx, (draft) => {
|
||||
draft.push(text);
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
const K = 4;
|
||||
const clients = Array.from({ length: K }, (_, i) => {
|
||||
const [down, up] = memoryLinkPair();
|
||||
// One principal: every client reaches the same object.
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
const link = new CountingLink(up);
|
||||
const host = createHost({
|
||||
self: { kind: "host" },
|
||||
name: `browser${i}`,
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
return { host, link, ref: host.hold(List, {}) };
|
||||
});
|
||||
await Promise.all(clients.map((client) => client.ref.ready));
|
||||
const encoders = counts.encoders;
|
||||
const encodes = counts.encodes;
|
||||
const before = clients.map((client) => stateFrames(client.link.received).length);
|
||||
await clients[0]!.ref.add("x");
|
||||
await sleep(10);
|
||||
assert.equal(counts.encodes - encodes, K, "one encode per feed");
|
||||
assert.equal(counts.encoders, encoders, "no new streams for a change");
|
||||
const frames = clients.map((client, i) => stateFrames(client.link.received).slice(before[i]));
|
||||
for (const list of frames) assert.equal(list.length, 1);
|
||||
const ops = frames.map((list) => JSON.stringify(list[0]?.ops));
|
||||
assert.equal(new Set(ops).size, 1, "every link carries the same ops");
|
||||
for (const client of clients) assert.deepEqual(client.ref.items.value, ["x"]);
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { obj } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
createHost,
|
||||
defineRegistry,
|
||||
type FacetApi,
|
||||
forbidden,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const unexpectedError = (error: unknown) => assert.fail(String(error));
|
||||
const Eager = local("ready.eager", { version: 1, input: obj({}, { open: true }) });
|
||||
const Lazy = local("ready.lazy", { version: 1, input: obj({}, { open: true }) });
|
||||
|
||||
// Startup must not report ready while an eager object has failed.
|
||||
describe("host: eager readiness", () => {
|
||||
for (const operation of ["install", "load"] as const) {
|
||||
it(`${operation} awaits resource activation and registration, leaving lazy objects lazy`, async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: unexpectedError });
|
||||
const entries = defineRegistry<string>("ready.entries");
|
||||
let opened = () => {};
|
||||
let lazyRuns = 0;
|
||||
let settled = false;
|
||||
const facet = {
|
||||
name: "ready",
|
||||
setup({ serve }: FacetApi) {
|
||||
serve(
|
||||
Eager,
|
||||
(_input, _principal, self) => {
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
opened = resolve;
|
||||
}),
|
||||
});
|
||||
self.register(entries, { eager: "live" });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
serve(Lazy, () => {
|
||||
lazyRuns++;
|
||||
return {};
|
||||
});
|
||||
},
|
||||
};
|
||||
try {
|
||||
const loading = operation === "install" ? host.install(facet) : host.load([facet]);
|
||||
void loading.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await tick();
|
||||
assert.equal(settled, false);
|
||||
assert.equal(host.registry(entries).get("eager"), undefined);
|
||||
opened();
|
||||
await loading;
|
||||
assert.equal(host.registry(entries).get("eager"), "live");
|
||||
assert.equal(lazyRuns, 0);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const failure of ["factory", "dependency", "resource"] as const) {
|
||||
it(`${operation} rejects eager ${failure} failure with the activation error`, async () => {
|
||||
const errors: unknown[] = [];
|
||||
const host = createHost({ self: { kind: "host" }, onError: (error) => errors.push(error) });
|
||||
const cause = forbidden(`${failure} failed`);
|
||||
const facet = {
|
||||
name: "bad",
|
||||
setup({ serve }: FacetApi) {
|
||||
serve(Lazy, () => {
|
||||
throw cause;
|
||||
});
|
||||
serve(
|
||||
Eager,
|
||||
(_input, _principal, self) => {
|
||||
if (failure === "factory") throw cause;
|
||||
if (failure === "dependency") self.get(Lazy, {});
|
||||
if (failure === "resource")
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () => {
|
||||
throw cause;
|
||||
},
|
||||
});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
},
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
operation === "install" ? host.install(facet) : host.load([facet]),
|
||||
(error) => error === cause,
|
||||
);
|
||||
await assert.rejects(host.ready(), (error) => error === cause);
|
||||
assert.deepEqual(errors, [cause]);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("load installs every facet before awaiting eager consumers", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: unexpectedError });
|
||||
try {
|
||||
await host.load([
|
||||
{
|
||||
name: "consumer",
|
||||
setup: ({ serve }) =>
|
||||
serve(
|
||||
Eager,
|
||||
(_input, _principal, self) => {
|
||||
self.get(Lazy, {});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
},
|
||||
{ name: "provider", setup: ({ serve }) => serve(Lazy, () => ({})) },
|
||||
]);
|
||||
assert.equal(host.hold(Eager, {}).status, "live");
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("missing eager dependencies reject load instead of only reaching onError", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
try {
|
||||
await assert.rejects(
|
||||
host.load([
|
||||
{
|
||||
name: "missing",
|
||||
setup: ({ serve }) =>
|
||||
serve(
|
||||
Eager,
|
||||
(_i, _p, self) => {
|
||||
self.get(Lazy, {});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
},
|
||||
]),
|
||||
(error) => isChordError(error, "unavailable") && error.reason === "unknown_type",
|
||||
);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("ready includes direct host serves, but not later serves", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const First = local("ready.first", { version: 1, input: obj({}, { open: true }) });
|
||||
let created = 0;
|
||||
try {
|
||||
host.serve(
|
||||
First,
|
||||
() => {
|
||||
created++;
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
const first = host.ready();
|
||||
host.serve(
|
||||
Eager,
|
||||
() => {
|
||||
throw forbidden("later");
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
await first;
|
||||
assert.equal(created, 1);
|
||||
await assert.rejects(host.ready(), /later/);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const operation of ["install", "load"] as const) {
|
||||
it(`${operation} can defer eager readiness until the parent admits the worker`, { timeout: 2000 }, async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: unexpectedError });
|
||||
const [parent, upstream] = memoryLinkPair();
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "worker",
|
||||
upstream: { link: upstream, principal: { kind: "server" } },
|
||||
onError: unexpectedError,
|
||||
});
|
||||
const Status = remote("ready.status", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { default: {} },
|
||||
});
|
||||
server.serve(Status, () => ({ variant: "default" }));
|
||||
let bootstrap = false;
|
||||
let reported = false;
|
||||
const facet = {
|
||||
name: "worker",
|
||||
setup({ serve }: FacetApi) {
|
||||
serve(
|
||||
Lazy,
|
||||
() => {
|
||||
bootstrap = true;
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
serve(
|
||||
Eager,
|
||||
(_input, _principal, self) => {
|
||||
const status = self.get(Status, {});
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
deps: [status],
|
||||
open: () => {
|
||||
reported = true;
|
||||
},
|
||||
});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
},
|
||||
};
|
||||
try {
|
||||
await (operation === "install"
|
||||
? worker.install(facet, { awaitHostObjects: false })
|
||||
: worker.load([facet], { awaitHostObjects: false }));
|
||||
await worker.hold(Lazy, {}).ready;
|
||||
assert.equal(bootstrap, true);
|
||||
let settled = false;
|
||||
const ready = worker.ready().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await tick();
|
||||
assert.equal(settled, false);
|
||||
assert.equal(reported, false);
|
||||
// Control-ready announcement allows the parent to accept the queued upstream holds.
|
||||
server.accept(parent, { kind: "host", name: "worker", principal: { host: "worker" } });
|
||||
await ready;
|
||||
assert.equal(reported, true);
|
||||
} finally {
|
||||
await worker.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -1,738 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { num, obj, opt, type Schema, str, unknown } from "@earendil-works/pi-schema";
|
||||
import { describe, it, onTestFinished, vi } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
method,
|
||||
remote,
|
||||
type Self,
|
||||
type ServeOptions,
|
||||
type StateEvent,
|
||||
type StateOptions,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const quiet = () => createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
|
||||
function latch() {
|
||||
let open = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
const Greeter = local("t.greeter", {
|
||||
version: 1,
|
||||
input: obj({ id: opt(str({ default: "a" })) }, { open: true }),
|
||||
members: { greet: method(str(), str()), count: unknown() },
|
||||
});
|
||||
|
||||
const CountV1 = obj({ n: num() }, { open: true });
|
||||
|
||||
interface GreeterSpec {
|
||||
readonly greeting: string;
|
||||
readonly schema?: typeof CountV1 | Schema;
|
||||
readonly initial?: object;
|
||||
readonly bump?: (draft: Record<string, number>) => void;
|
||||
readonly options?: ServeOptions;
|
||||
readonly state?: StateOptions<never>;
|
||||
readonly extra?: (self: Self, input: { id?: string }) => void;
|
||||
}
|
||||
|
||||
function greeterFacet(spec: GreeterSpec): Facet {
|
||||
return {
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Greeter,
|
||||
(input, _principal, self) => {
|
||||
spec.extra?.(self, input);
|
||||
const count = self.state(
|
||||
"count",
|
||||
spec.schema ?? CountV1,
|
||||
(spec.initial ?? { n: 0 }) as never,
|
||||
spec.state,
|
||||
);
|
||||
return {
|
||||
count,
|
||||
greet: (name: string, ctx: Ctx) => {
|
||||
count.change(ctx, (draft) => {
|
||||
const bump =
|
||||
spec.bump ??
|
||||
((d: Record<string, number>) => {
|
||||
d.n = (d.n ?? 0) + 1;
|
||||
});
|
||||
bump(draft as Record<string, number>);
|
||||
});
|
||||
return `${spec.greeting} ${name}`;
|
||||
},
|
||||
};
|
||||
},
|
||||
spec.options,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function until(check: () => Promise<boolean>, attempts = 200): Promise<void> {
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
if (await check()) return;
|
||||
await tick();
|
||||
}
|
||||
throw new Error("condition not reached");
|
||||
}
|
||||
|
||||
describe("reload", () => {
|
||||
it("compatible reload: no gate, pinned roots keep old code, state preserved, previous retires", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
assert.equal(await ref.greet("a"), "hello a");
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
const first = await ref.greet("p", ctx);
|
||||
await go.promise;
|
||||
return [first, await ref.greet("p", ctx)];
|
||||
});
|
||||
await tick();
|
||||
const shapes: string[] = [];
|
||||
ref.onShape((shape) => shapes.push(shape.contract));
|
||||
const report = await host.reload([greeterFacet({ greeting: "hi" })]);
|
||||
assert.deepEqual(report.gated, []);
|
||||
assert.equal(report.prepared.length, 1);
|
||||
assert.equal(await ref.greet("b"), "hi b");
|
||||
assert.equal(shapes.length, 1, "one shape push at the commit");
|
||||
assert.equal(host.previousCodeVersion, report.previous);
|
||||
go.open();
|
||||
assert.deepEqual(await pinned, ["hello p", "hello p"]);
|
||||
assert.equal(host.previousCodeVersion, undefined);
|
||||
assert.deepEqual(ref.count.value, { n: 4 });
|
||||
});
|
||||
|
||||
it("prepare runs in recording mode: writes throw and a failing factory leaves everything unchanged", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const a = host.hold(Greeter, { id: "a" });
|
||||
const b = host.hold(Greeter, { id: "b" });
|
||||
await Promise.all([a.ready, b.ready]);
|
||||
let captured: Ctx | undefined;
|
||||
await host.root((ctx) => {
|
||||
captured = ctx;
|
||||
});
|
||||
|
||||
const writing = greeterFacet({
|
||||
greeting: "hi",
|
||||
extra: (self) => {
|
||||
const probe = self.state("probe", obj({}, { open: true }), {});
|
||||
probe.replace(captured!, {});
|
||||
},
|
||||
});
|
||||
await assert.rejects(host.reload([writing]), /read_only/);
|
||||
|
||||
const log: string[] = [];
|
||||
const failing = greeterFacet({
|
||||
greeting: "hi",
|
||||
extra: (self, input) => {
|
||||
self.resource("extra", {
|
||||
version: 1,
|
||||
open: () => void log.push(`open ${input.id}`),
|
||||
close: () => void log.push(`close ${input.id}`),
|
||||
});
|
||||
if (input.id === "b") throw new Error("b fails");
|
||||
},
|
||||
});
|
||||
const codeVersion = host.codeVersion;
|
||||
await assert.rejects(
|
||||
host.reload([failing]),
|
||||
// A plain error from the new factory surfaces as `unavailable`; the message says the reload aborted.
|
||||
(error) => isChordError(error, "unavailable") && /reload aborted.*b fails/.test(error.message),
|
||||
);
|
||||
assert.equal(host.codeVersion, codeVersion);
|
||||
assert.deepEqual(log, ["open a", "close a"]);
|
||||
assert.equal(await a.greet("x"), "hello x");
|
||||
});
|
||||
|
||||
it("refuses a changed cell schema without a version bump", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
await host.hold(Greeter, {}).ready;
|
||||
const changed = greeterFacet({
|
||||
greeting: "hi",
|
||||
schema: obj({ total: num() }, { open: true }),
|
||||
initial: { total: 0 },
|
||||
});
|
||||
await assert.rejects(
|
||||
host.reload([changed]),
|
||||
(error) =>
|
||||
isChordError(error, "invalid") && error.reason === "state" && /without a version bump/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
it("state bump: gate, migrate(old, fromVersion) into the new schema, reset to watchers, old roots upgrade_conflict", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.greet("a");
|
||||
await ref.greet("a");
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
ref.count.events((event) => events.push(event));
|
||||
const go = latch();
|
||||
// Started before the reload, never touched the object: rejected after the commit.
|
||||
const late = host.root(async (ctx) => {
|
||||
await go.promise;
|
||||
return ref.greet("late", ctx);
|
||||
});
|
||||
const froms: number[] = [];
|
||||
const v2 = greeterFacet({
|
||||
greeting: "hi",
|
||||
schema: obj({ total: num() }, { open: true }),
|
||||
initial: { total: 0 },
|
||||
bump: (draft) => {
|
||||
draft.total = (draft.total ?? 0) + 1;
|
||||
},
|
||||
state: {
|
||||
version: 2,
|
||||
migrate: (old, from) => {
|
||||
froms.push(from);
|
||||
return { total: (old as { n: number }).n } as never;
|
||||
},
|
||||
},
|
||||
});
|
||||
const report = await host.reload([v2]);
|
||||
assert.equal(report.gated.length, 1);
|
||||
assert.deepEqual(froms, [1]);
|
||||
assert.deepEqual(events.at(-1), { type: "reset", value: { total: 2 } });
|
||||
assert.equal(await ref.greet("b"), "hi b");
|
||||
assert.deepEqual(ref.count.value, { total: 3 });
|
||||
go.open();
|
||||
await assert.rejects(late, (error) => isChordError(error, "upgrade_conflict") && error.retryable);
|
||||
});
|
||||
|
||||
it("versions are per cell: a raised one migrates only that cell, a lowered one is refused", async () => {
|
||||
const host = quiet();
|
||||
const Pair = local("t.pair", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { a: unknown(), b: unknown(), bump: method() },
|
||||
});
|
||||
const migrated: string[] = [];
|
||||
const facet = (version: number): Facet => ({
|
||||
name: "pair",
|
||||
setup: (api) =>
|
||||
api.serve(Pair, (_input, _principal, self) => {
|
||||
const a = self.state("a", num(), 1);
|
||||
const b = self.state("b", num(), 1, {
|
||||
version,
|
||||
migrate: (old, from) => {
|
||||
migrated.push(`b ${from}->${version}`);
|
||||
return (old as number) * 10;
|
||||
},
|
||||
});
|
||||
return {
|
||||
a,
|
||||
b,
|
||||
bump: (ctx) => {
|
||||
a.replace(ctx, a.value + 1);
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const pair = host.hold(Pair, {});
|
||||
await pair.ready;
|
||||
await pair.bump();
|
||||
const report = await host.reload([facet(2)]);
|
||||
assert.equal(report.gated.length, 1);
|
||||
assert.deepEqual(migrated, ["b 1->2"]);
|
||||
assert.deepEqual([pair.a.value, pair.b.value], [2, 10], "a kept as it was, b migrated");
|
||||
await assert.rejects(host.reload([facet(1)]), /version of state b lowered from 2 to 1/);
|
||||
});
|
||||
|
||||
it("gate: late old roots get upgrade_conflict, grandfathered roots re-enter, then commit", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const grandfathered = host.root(async (ctx) => {
|
||||
await ref.greet("g", ctx);
|
||||
await go.promise;
|
||||
return ref.greet("g", ctx);
|
||||
});
|
||||
await tick();
|
||||
const reload = host.reload([greeterFacet({ greeting: "hi", state: { version: 2 } })]);
|
||||
await until(async () => {
|
||||
try {
|
||||
await ref.greet("probe");
|
||||
return false;
|
||||
} catch (error) {
|
||||
return isChordError(error, "upgrade_conflict");
|
||||
}
|
||||
});
|
||||
go.open();
|
||||
assert.equal(await grandfathered, "hello g");
|
||||
const report = await reload;
|
||||
assert.equal(report.gated.length, 1);
|
||||
assert.equal(await ref.greet("n"), "hi n");
|
||||
});
|
||||
|
||||
it("gate deadline: reload aborts, reports blocking roots, nothing changes", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
const facet = (greeting: string, version: number): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
self.task(async (ctx) => {
|
||||
log.push(`start ${greeting}`);
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
log.push(`stop ${greeting}`);
|
||||
});
|
||||
const count = self.state("count", num(), 0, { version });
|
||||
return { count, greet: (name: string) => `${greeting} ${name}` };
|
||||
}),
|
||||
});
|
||||
host.install(facet("hello", 1));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const blocker = host.root(async (ctx) => {
|
||||
await ref.greet("b", ctx);
|
||||
await go.promise;
|
||||
});
|
||||
await tick();
|
||||
await assert.rejects(host.reload([facet("hi", 2)], { gateDeadline: 30 }), (error) => {
|
||||
assert.ok(isChordError(error, "aborted") && error.reason === "gate_deadline");
|
||||
assert.match(error.message, /blocking roots: host#\w+:\d+/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(await ref.greet("x"), "hello x");
|
||||
await tick();
|
||||
assert.deepEqual(log, ["start hello", "stop hello", "start hello"]);
|
||||
go.open();
|
||||
await blocker;
|
||||
});
|
||||
|
||||
it("exclusive resources are replaced at the gate; non-exclusive ones at prepare and closed at retirement", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
const facet = (rev: number): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
const lock = self.resource("lock", {
|
||||
version: rev,
|
||||
exclusive: true,
|
||||
open: () => {
|
||||
log.push(`open lock ${rev}`);
|
||||
return rev;
|
||||
},
|
||||
close: () => void log.push(`close lock ${rev}`),
|
||||
});
|
||||
self.resource("cache", {
|
||||
version: rev,
|
||||
open: () => void log.push(`open cache ${rev}`),
|
||||
close: () => void log.push(`close cache ${rev}`),
|
||||
});
|
||||
const count = self.state("count", num(), 0);
|
||||
return { count, greet: (name: string) => `${lock.value} ${name}` };
|
||||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const ref = host.hold(Greeter, {});
|
||||
assert.equal(await ref.greet("x"), "1 x");
|
||||
log.length = 0;
|
||||
const report = await host.reload([facet(2)]);
|
||||
assert.equal(report.gated.length, 1);
|
||||
assert.equal(await ref.greet("x"), "2 x");
|
||||
await tick();
|
||||
assert.deepEqual(log, ["open cache 2", "close lock 1", "open lock 2", "close cache 1"]);
|
||||
});
|
||||
|
||||
it("two-version rule: the next reload retires the previous code version; its roots get superseded", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "v1" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const first = host.codeVersion;
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await ref.greet("p", ctx);
|
||||
await go.promise;
|
||||
return ref.greet("p", ctx);
|
||||
});
|
||||
await tick();
|
||||
await host.reload([greeterFacet({ greeting: "v2" })]);
|
||||
assert.equal(host.previousCodeVersion, first);
|
||||
await host.reload([greeterFacet({ greeting: "v3" })], { retireDeadline: 20 });
|
||||
go.open();
|
||||
await assert.rejects(pinned, (error) => isChordError(error, "superseded") && error.retryable);
|
||||
await assert.rejects(
|
||||
host.root(() => 1, { codeVersion: first }),
|
||||
(error) => isChordError(error, "superseded"),
|
||||
);
|
||||
assert.equal(await ref.greet("n"), "v3 n");
|
||||
});
|
||||
|
||||
it("forced retirement aborts the leftover roots before it closes the resources only their code used", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
const hold = latch();
|
||||
const facet = (version: number): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
const conn = self.resource("conn", {
|
||||
version,
|
||||
open: () => ({ version }),
|
||||
close: () => void log.push(`close ${version}`),
|
||||
});
|
||||
const count = self.state("count", CountV1, { n: 0 });
|
||||
return {
|
||||
count,
|
||||
greet: async (name: string, ctx: Ctx) => {
|
||||
if (name === "hold") {
|
||||
await hold.promise;
|
||||
return `held on ${conn.value.version}, aborted ${ctx.signal.aborted}`;
|
||||
}
|
||||
if (name !== "wait") return name;
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
// Still open: the abort comes first, then the grace, then the close.
|
||||
log.push(`aborted, conn ${conn.value.version}`);
|
||||
return name;
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const old = host.root((ctx) => ref.greet("wait", ctx));
|
||||
await tick();
|
||||
await host.reload([facet(2)]);
|
||||
// A root of the code version that becomes the previous one: forced retirement of the older one leaves it alone.
|
||||
const current = host.root((ctx) => ref.greet("hold", ctx));
|
||||
await tick();
|
||||
await host.reload([facet(3)], { retireDeadline: 20 });
|
||||
assert.equal(await old, "wait");
|
||||
for (let i = 0; i < 50 && !log.includes("close 1"); i++) await tick();
|
||||
assert.deepEqual(log, ["aborted, conn 1", "close 1"]);
|
||||
hold.open();
|
||||
assert.equal(await current, "held on 2, aborted false");
|
||||
assert.equal(await ref.greet("x"), "x");
|
||||
});
|
||||
|
||||
it("a kept resource closes with the newest declaration's close", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
const facet = (label: string): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
self.resource("conn", { version: 1, open: () => ({}), close: () => void log.push(`close by ${label}`) });
|
||||
const count = self.state("count", CountV1, { n: 0 });
|
||||
return { count, greet: (name: string) => `${label} ${name}` };
|
||||
}),
|
||||
});
|
||||
host.install(facet("a"));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
await host.reload([facet("b")]);
|
||||
await host.reload([facet("c")]);
|
||||
assert.equal(await ref.greet("x"), "c x");
|
||||
assert.deepEqual(log, [], "kept, not reopened");
|
||||
ref.release();
|
||||
for (let i = 0; i < 10 && log.length === 0; i++) await tick();
|
||||
assert.deepEqual(log, ["close by c"]);
|
||||
});
|
||||
|
||||
it("a task that ignores its abort delays its replacement at most taskStopDeadline (reported)", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
onTestFinished(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
const errors: unknown[] = [];
|
||||
const host = createHost({ self: { kind: "host" }, onError: (error) => errors.push(error), taskStopDeadline: 30 });
|
||||
const log: string[] = [];
|
||||
const stuck = latch();
|
||||
const facet = (label: string): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
self.task(async () => {
|
||||
log.push(`start ${label}`);
|
||||
if (label === "a") await stuck.promise; // ignores ctx.signal
|
||||
});
|
||||
const count = self.state("count", CountV1, { n: 0 });
|
||||
return { count, greet: (name: string) => name };
|
||||
}),
|
||||
});
|
||||
host.install(facet("a"));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
await host.reload([facet("b")]);
|
||||
await vi.advanceTimersByTimeAsync(29);
|
||||
await tick();
|
||||
assert.deepEqual(log, ["start a"], "waits for the old run");
|
||||
assert.equal(errors.length, 0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await tick();
|
||||
assert.deepEqual(log, ["start a", "start b"]);
|
||||
assert.ok(
|
||||
errors.some((error) => isChordError(error, "aborted") && /still running after 30 ms/.test(error.message)),
|
||||
);
|
||||
stuck.open();
|
||||
});
|
||||
|
||||
it("a facet's signal aborts when its run is replaced or removed, when its reload aborts, and when the host closes", async () => {
|
||||
const host = quiet();
|
||||
const signals = new Map<string, AbortSignal>();
|
||||
const facet = (label: string, fail = false): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) => {
|
||||
signals.set(label, api.signal);
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
if (fail) throw new Error("broken");
|
||||
const count = self.state("count", CountV1, { n: 0 });
|
||||
return { count, greet: (name: string) => name };
|
||||
});
|
||||
},
|
||||
});
|
||||
const other: Facet = { name: "other", setup: (api) => void signals.set("other", api.signal) };
|
||||
host.install(facet("a"));
|
||||
host.install(other);
|
||||
await host.hold(Greeter, {}).ready;
|
||||
await assert.rejects(host.reload([facet("b", true)]));
|
||||
assert.equal(signals.get("b")?.aborted, true, "the aborted reload's run");
|
||||
assert.equal(signals.get("a")?.aborted, false, "still installed");
|
||||
await host.reload([facet("c")]);
|
||||
assert.equal(signals.get("a")?.aborted, true, "replaced");
|
||||
assert.equal(signals.get("c")?.aborted, false);
|
||||
await host.reload([], { remove: ["other"] });
|
||||
assert.equal(signals.get("other")?.aborted, true, "removed");
|
||||
await host.close();
|
||||
assert.equal(signals.get("c")?.aborted, true, "host closed");
|
||||
});
|
||||
|
||||
it("checks contracts at reload: forgotten bumps, live consumers, accepts adapters", async () => {
|
||||
const DocV1 = remote("t.rdoc", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { methods: { read: method(undefined, obj({ text: str() }, { open: true })) } } },
|
||||
});
|
||||
const DocBroken = remote("t.rdoc", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { methods: { read: method(undefined, obj({ text: num() }, { open: true })) } } },
|
||||
});
|
||||
const DocV2 = remote("t.rdoc", {
|
||||
version: 2,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { methods: { fetch: method(undefined, obj({ body: str() }, { open: true })) } } },
|
||||
});
|
||||
const host = quiet();
|
||||
host.install({
|
||||
name: "doc",
|
||||
setup: (api) => api.serve(DocV1, () => ({ variant: "main", read: () => ({ text: "v1" }) })),
|
||||
});
|
||||
const consumer = host.hold(DocV1, {});
|
||||
assert.deepEqual(await consumer.read(), { text: "v1" });
|
||||
|
||||
const broken: Facet = {
|
||||
name: "doc",
|
||||
setup: (api) => api.serve(DocBroken, () => ({ variant: "main", read: () => ({ text: 1 }) })),
|
||||
};
|
||||
await assert.rejects(host.reload([broken]), /without a version bump/);
|
||||
|
||||
const fetch = () => ({ body: "v2" });
|
||||
const v2: Facet = { name: "doc", setup: (api) => api.serve(DocV2, () => ({ variant: "main", fetch })) };
|
||||
await assert.rejects(host.reload([v2]), (error) => isChordError(error, "contract_mismatch"));
|
||||
|
||||
const adapted: Facet = {
|
||||
name: "doc",
|
||||
setup: (api) =>
|
||||
api.serve(DocV2, () => ({ variant: "main", fetch }), {
|
||||
accepts: {
|
||||
1: (current) => ({
|
||||
variant: "main",
|
||||
read: () => ({ text: `${(current.fetch as typeof fetch)().body} via adapter` }),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
await host.reload([adapted]);
|
||||
assert.deepEqual(await consumer.read(), { text: "v2 via adapter" });
|
||||
const modern = host.hold(DocV2, {});
|
||||
assert.deepEqual(await modern.fetch(), { body: "v2" });
|
||||
});
|
||||
|
||||
it("rebinds listeners to the new code and restarts tasks", async () => {
|
||||
const host = quiet();
|
||||
const listeners = new Set<(event: number) => void>();
|
||||
const events = {
|
||||
subscribe: (fn: (event: number) => void) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
const emit = (value: number) => {
|
||||
for (const fn of listeners) fn(value);
|
||||
};
|
||||
const log: string[] = [];
|
||||
const Sum = local("t.sum", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { sum: obj({ v: num() }, { open: true }) },
|
||||
});
|
||||
const facet = (factor: number): Facet => ({
|
||||
name: "sum",
|
||||
setup: (api) =>
|
||||
api.serve(Sum, (_input, _principal, self) => {
|
||||
const sum = self.state("sum", obj({ v: num() }, { open: true }), { v: 0 });
|
||||
self.on(
|
||||
"add",
|
||||
events,
|
||||
(source) => source,
|
||||
(value, ctx) =>
|
||||
sum.change(ctx, (draft) => {
|
||||
draft.v += value * factor;
|
||||
}),
|
||||
);
|
||||
self.task(async (ctx) => {
|
||||
log.push(`start ${factor}`);
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
log.push(`stop ${factor}`);
|
||||
});
|
||||
return { sum };
|
||||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const ref = host.hold(Sum, {});
|
||||
await ref.ready;
|
||||
emit(1);
|
||||
await tick();
|
||||
await host.reload([facet(10)]);
|
||||
assert.equal(listeners.size, 1);
|
||||
emit(1);
|
||||
await tick();
|
||||
assert.deepEqual(ref.sum.value, { v: 11 });
|
||||
// The replacement starts once the aborted run finished.
|
||||
assert.deepEqual(log, ["start 1", "stop 1", "start 10"]);
|
||||
});
|
||||
|
||||
it("keeps registry tables per code version", async () => {
|
||||
const host = quiet();
|
||||
const Routes = defineRegistry<string>("t.reload-routes");
|
||||
const Router = local("t.router", { version: 1, input: obj({}, { open: true }) });
|
||||
const facet = (value: string): Facet => ({
|
||||
name: "router",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Router,
|
||||
(_input, _principal, self) => {
|
||||
self.register(Routes, { "GET /": value });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
host.install(facet("one"));
|
||||
await tick();
|
||||
await tick();
|
||||
const seen: [string | undefined, number][] = [];
|
||||
host.registry(Routes).subscribe((table, codeVersion) => seen.push([table.get("GET /"), codeVersion]));
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await go.promise;
|
||||
return host.registry(Routes).get("GET /", ctx);
|
||||
});
|
||||
const report = await host.reload([facet("two")]);
|
||||
assert.equal(host.registry(Routes).get("GET /"), "two");
|
||||
go.open();
|
||||
assert.equal(await pinned, "one");
|
||||
assert.deepEqual(seen, [
|
||||
["one", report.previous],
|
||||
["two", report.codeVersion],
|
||||
]);
|
||||
});
|
||||
|
||||
it("a reload can add a type together with a consumer that holds it", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const Clock = local("t.clock", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { now: method(undefined, num()) },
|
||||
});
|
||||
const clockFacet: Facet = { name: "clock", setup: (api) => api.serve(Clock, () => ({ now: () => 42 })) };
|
||||
const consumer = (fail: boolean): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
api.serve(Greeter, (_input, _principal, self) => {
|
||||
const clock = self.get(Clock, {});
|
||||
const count = self.state("count", CountV1, { n: 0 });
|
||||
if (fail) throw new Error("consumer fails");
|
||||
return {
|
||||
count,
|
||||
greet: async (name: string, ctx: Ctx) => `${await clock.now(ctx)} ${name}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
const codeVersion = host.codeVersion;
|
||||
await assert.rejects(host.reload([clockFacet, consumer(true)]), /consumer fails/);
|
||||
assert.equal(host.codeVersion, codeVersion);
|
||||
assert.equal(await ref.greet("x"), "hello x");
|
||||
await host.reload([clockFacet, consumer(false)]);
|
||||
assert.equal(await ref.greet("x"), "42 x");
|
||||
});
|
||||
|
||||
it("the gate rejects new objects of a changed type; after commit they get the new code", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const ref = host.hold(Greeter, {});
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const grandfathered = host.root(async (ctx) => {
|
||||
await ref.greet("g", ctx);
|
||||
await go.promise;
|
||||
});
|
||||
const later = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await later.promise;
|
||||
return ref.greet("old", ctx);
|
||||
});
|
||||
await tick();
|
||||
const reload = host.reload([greeterFacet({ greeting: "hi", state: { version: 2 } })]);
|
||||
await until(async () => {
|
||||
try {
|
||||
await ref.greet("probe");
|
||||
return false;
|
||||
} catch (error) {
|
||||
return isChordError(error, "upgrade_conflict");
|
||||
}
|
||||
});
|
||||
assert.throws(
|
||||
() => host.hold(Greeter, { id: "late" }),
|
||||
(error) => isChordError(error, "upgrade_conflict") && error.retryable,
|
||||
);
|
||||
go.open();
|
||||
await grandfathered;
|
||||
await reload;
|
||||
assert.equal(await host.hold(Greeter, { id: "late" }).greet("l"), "hi l");
|
||||
// The previous code version is still retained (pinned root), but the gated object dropped its old implementation.
|
||||
assert.notEqual(host.previousCodeVersion, undefined);
|
||||
later.open();
|
||||
await assert.rejects(pinned, (error) => isChordError(error, "upgrade_conflict"));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT, withCancel } from "../../src/context/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
type ReplicatedState,
|
||||
type ReplicatedStateDelivery,
|
||||
type ReplicatedStateSourceFrame,
|
||||
replicatedState,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
type Value = { value: number };
|
||||
type Kind = "mutable" | "attached";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<void>((accept, fail) => {
|
||||
resolve = accept;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function fixture(kind: Kind, onError: (error: Error) => void = () => {}) {
|
||||
let state: ReplicatedState<Value>;
|
||||
let publish: (value: Value, context: Context) => void;
|
||||
if (kind === "mutable") {
|
||||
const mutable = replicatedState({ value: 0 });
|
||||
state = mutable;
|
||||
publish = (value, context) => mutable.replace(context, value);
|
||||
} else {
|
||||
let receive!: (frame: ReplicatedStateSourceFrame<Value>) => void;
|
||||
state = replicatedState<Value>(
|
||||
{
|
||||
attach: () => ({
|
||||
snapshot: { value: { value: 0 }, cursor: 0 },
|
||||
activate: (listener) => {
|
||||
receive = listener;
|
||||
},
|
||||
dispose() {},
|
||||
}),
|
||||
},
|
||||
{ onError },
|
||||
);
|
||||
publish = (value, context) =>
|
||||
receive({ value, cursor: value.value, ops: [["s", ["value"], value.value]], context });
|
||||
}
|
||||
return { state, publish };
|
||||
}
|
||||
|
||||
describe.each<Kind>(["mutable", "attached"])("%s public delivery", (kind) => {
|
||||
test("awaits hydration and each update independently of other subscribers", async () => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const hydration = deferred();
|
||||
const update = deferred();
|
||||
const events: string[] = [];
|
||||
const fast: number[] = [];
|
||||
state.subscribe(async (value) => {
|
||||
events.push(`start:${value.value}`);
|
||||
if (value.value === 0) await hydration.promise;
|
||||
if (value.value === 1) await update.promise;
|
||||
events.push(`end:${value.value}`);
|
||||
});
|
||||
state.subscribe((value) => fast.push(value.value));
|
||||
publish({ value: 1 }, BACKGROUND_CONTEXT);
|
||||
publish({ value: 2 }, BACKGROUND_CONTEXT);
|
||||
expect(events).toEqual(["start:0"]);
|
||||
expect(fast).toEqual([0, 1, 2]);
|
||||
expect(state.value).toEqual({ value: 2 });
|
||||
hydration.resolve();
|
||||
await vi.waitFor(() => expect(events).toEqual(["start:0", "end:0", "start:1"]));
|
||||
update.resolve();
|
||||
await vi.waitFor(() => expect(events).toEqual(["start:0", "end:0", "start:1", "end:1", "start:2", "end:2"]));
|
||||
});
|
||||
|
||||
test.each([100, 101, 102, 201, 202])("bounds %i pending deliveries", async (count) => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const hydration = deferred();
|
||||
const received: number[] = [];
|
||||
state.subscribe((value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 0) return hydration.promise;
|
||||
});
|
||||
for (let value = 1; value <= count; value += 1) publish({ value }, BACKGROUND_CONTEXT);
|
||||
expect(received).toEqual([0]);
|
||||
hydration.resolve();
|
||||
await hydration.promise;
|
||||
const first = Math.floor((count - 1) / 100) * 100 + 1;
|
||||
expect(received).toEqual([0, ...Array.from({ length: count - first + 1 }, (_, index) => first + index)]);
|
||||
});
|
||||
|
||||
test("excludes a running update from overflow and retains exact value/context/delivery", async () => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const gate = deferred();
|
||||
const context = withCancel(BACKGROUND_CONTEXT).context;
|
||||
const received: { value: Value; context: Context; delivery: ReplicatedStateDelivery }[] = [];
|
||||
state.subscribe((value, context, delivery) => {
|
||||
received.push({ value, context, delivery });
|
||||
if (value.value === 1) return gate.promise;
|
||||
});
|
||||
for (let value = 1; value < 102; value += 1) publish({ value }, BACKGROUND_CONTEXT);
|
||||
const newest = { value: 102 };
|
||||
publish(newest, context);
|
||||
const adopted = state.value;
|
||||
publish({ value: 103 }, BACKGROUND_CONTEXT);
|
||||
expect(received.map(({ value }) => value.value)).toEqual([0, 1]);
|
||||
gate.resolve();
|
||||
await gate.promise;
|
||||
expect(received.map(({ value }) => value.value)).toEqual([0, 1, 102, 103]);
|
||||
expect(received[2]?.value).toBe(adopted);
|
||||
expect(received[2]?.context).toBe(context);
|
||||
expect(received[2]?.delivery).toEqual({ kind: "update", sequence: 102 });
|
||||
});
|
||||
|
||||
test("serializes reentrant hydration and update callbacks", () => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const events: string[] = [];
|
||||
state.subscribe((value) => {
|
||||
events.push(`start:${value.value}`);
|
||||
if (value.value < 2) publish({ value: value.value + 1 }, BACKGROUND_CONTEXT);
|
||||
events.push(`end:${value.value}`);
|
||||
});
|
||||
expect(events).toEqual(["start:0", "end:0", "start:1", "end:1", "start:2", "end:2"]);
|
||||
});
|
||||
|
||||
test("treats two subscriptions of the same callback independently", async () => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const gate = deferred();
|
||||
const received: number[] = [];
|
||||
const listener = (value: Value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 0) return gate.promise;
|
||||
};
|
||||
const stopFirst = state.subscribe(listener);
|
||||
const stopSecond = state.subscribe(listener);
|
||||
publish({ value: 1 }, BACKGROUND_CONTEXT);
|
||||
stopFirst();
|
||||
stopFirst();
|
||||
gate.resolve();
|
||||
await gate.promise;
|
||||
expect(received).toEqual([0, 0, 1]);
|
||||
stopSecond();
|
||||
});
|
||||
|
||||
test("unsubscribe drops queued callbacks without joining or aborting the running callback", async () => {
|
||||
const { state, publish } = fixture(kind);
|
||||
const gate = deferred();
|
||||
const { context } = withCancel(BACKGROUND_CONTEXT);
|
||||
const received: number[] = [];
|
||||
let completed = false;
|
||||
const stop = state.subscribe(async (value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 1) {
|
||||
await gate.promise;
|
||||
completed = true;
|
||||
}
|
||||
});
|
||||
await Promise.resolve();
|
||||
publish({ value: 1 }, context);
|
||||
publish({ value: 2 }, context);
|
||||
expect(stop()).toBeUndefined();
|
||||
publish({ value: 3 }, context);
|
||||
expect(context.abortSignal?.aborted).toBe(false);
|
||||
expect(completed).toBe(false);
|
||||
gate.resolve();
|
||||
await vi.waitFor(() => expect(completed).toBe(true));
|
||||
expect(received).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attached listener errors", () => {
|
||||
const kind: Kind = "attached";
|
||||
test("isolates a synchronous hydration failure without removing the subscription", () => {
|
||||
const errors: Error[] = [];
|
||||
const { state, publish } = fixture(kind, (error) => errors.push(error));
|
||||
const received: number[] = [];
|
||||
const failure = new Error("sync hydration");
|
||||
expect(() =>
|
||||
state.subscribe((value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 0) throw failure;
|
||||
}),
|
||||
).not.toThrow();
|
||||
publish({ value: 1 }, BACKGROUND_CONTEXT);
|
||||
expect(errors).toEqual([failure]);
|
||||
expect(received).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
test("observes hydration rejection, synchronous throw, and update rejection while continuing delivery", async () => {
|
||||
const errors: Error[] = [];
|
||||
const { state, publish } = fixture(kind, (error) => errors.push(error));
|
||||
const gate = deferred();
|
||||
const received: number[] = [];
|
||||
const fast: number[] = [];
|
||||
state.subscribe((value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 0) return gate.promise;
|
||||
if (value.value === 1) throw new Error("sync update");
|
||||
if (value.value === 2) return Promise.reject(new Error("async update"));
|
||||
});
|
||||
state.subscribe((value) => fast.push(value.value));
|
||||
for (let value = 1; value <= 3; value += 1) publish({ value }, BACKGROUND_CONTEXT);
|
||||
gate.reject(new Error("async hydration"));
|
||||
await vi.waitFor(() => expect(received).toEqual([0, 1, 2, 3]));
|
||||
expect(errors.map((error) => error.message)).toEqual(["async hydration", "sync update", "async update"]);
|
||||
expect(fast).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
test("still observes a callback rejection after unsubscribe", async () => {
|
||||
const errors: Error[] = [];
|
||||
const { state, publish } = fixture(kind, (error) => errors.push(error));
|
||||
const gate = deferred();
|
||||
const received: number[] = [];
|
||||
const stop = state.subscribe((value) => {
|
||||
received.push(value.value);
|
||||
return gate.promise;
|
||||
});
|
||||
publish({ value: 1 }, BACKGROUND_CONTEXT);
|
||||
stop();
|
||||
const failure = new Error("stopped callback");
|
||||
gate.reject(failure);
|
||||
await vi.waitFor(() => expect(errors).toEqual([failure]));
|
||||
expect(received).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
test("mutable state reports rejected callbacks through its default error reporter", async () => {
|
||||
const reports: (() => void)[] = [];
|
||||
const spy = vi.spyOn(globalThis, "queueMicrotask").mockImplementation((report) => {
|
||||
reports.push(report);
|
||||
});
|
||||
try {
|
||||
const state = replicatedState({ value: 0 });
|
||||
const failure = new Error("async mutable hydration");
|
||||
const received: number[] = [];
|
||||
state.subscribe((value) => {
|
||||
received.push(value.value);
|
||||
if (value.value === 0) return Promise.reject(failure);
|
||||
});
|
||||
expect(() => state.replace(BACKGROUND_CONTEXT, { value: 1 })).not.toThrow();
|
||||
await Promise.resolve();
|
||||
expect(received).toEqual([0, 1]);
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0]).toThrow(failure);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
|
@ -1,351 +0,0 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../../src/context/index.ts";
|
||||
import type { Draft, Op } from "../../src/delta/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
type ReplicatedStateSource,
|
||||
type ReplicatedStateSourceAttachment,
|
||||
type ReplicatedStateSourceFrame,
|
||||
replicatedState,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
describe("transactional replicated state", () => {
|
||||
it("publishes one immutable structurally shared revision", () => {
|
||||
const initial = { changed: { value: 1 }, retained: { value: 2 } };
|
||||
const state = replicatedState(initial);
|
||||
const previous = state.value;
|
||||
const deliveries: number[] = [];
|
||||
state.subscribe((_value, _context, delivery) => deliveries.push(delivery.sequence));
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.changed.value = 3;
|
||||
draft.changed.value = 4;
|
||||
});
|
||||
expect(state.value).toEqual({ changed: { value: 4 }, retained: { value: 2 } });
|
||||
expect(state.value).not.toBe(previous);
|
||||
expect(state.value.changed).not.toBe(previous.changed);
|
||||
expect(state.value.retained).toBe(previous.retained);
|
||||
expect(Object.isFrozen(state.value)).toBe(false);
|
||||
expect(deliveries).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("rolls back callback failures and revokes escaped drafts", () => {
|
||||
const state = replicatedState({ nested: { value: 1 } });
|
||||
const previous = state.value;
|
||||
let escaped: Draft<{ value: number }> | undefined;
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
escaped = draft.nested;
|
||||
draft.nested.value = 2;
|
||||
throw new Error("stop");
|
||||
}),
|
||||
).toThrow("stop");
|
||||
expect(state.value).toBe(previous);
|
||||
expect(() => escaped?.value).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("rejects PromiseLike callbacks and aborts their draft", () => {
|
||||
const state = replicatedState({ value: 0 });
|
||||
const previous = state.value;
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, ((draft: Draft<{ value: number }>) => {
|
||||
draft.value = 1;
|
||||
return Promise.resolve();
|
||||
}) as (draft: Draft<{ value: number }>) => void),
|
||||
).toThrow(/synchronous/);
|
||||
expect(state.value).toBe(previous);
|
||||
});
|
||||
|
||||
it("rejects nested changes and replacements without losing the outer rollback", () => {
|
||||
const state = replicatedState({ left: 0, right: 0 });
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.left = 1;
|
||||
state.change(BACKGROUND_CONTEXT, (nested) => {
|
||||
nested.right = 2;
|
||||
});
|
||||
}),
|
||||
).toThrow(/reentrantly/);
|
||||
expect(state.value).toEqual({ left: 0, right: 0 });
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, () => {
|
||||
state.replace(BACKGROUND_CONTEXT, { left: 1, right: 2 });
|
||||
}),
|
||||
).toThrow(/change callback/);
|
||||
expect(state.value).toEqual({ left: 0, right: 0 });
|
||||
});
|
||||
|
||||
it("queues listener-triggered changes in sequence order", () => {
|
||||
const state = replicatedState({ value: 0 });
|
||||
const deliveries: Array<{ value: number; sequence: number }> = [];
|
||||
const lateDeliveries: Array<{ kind: string; sequence: number }> = [];
|
||||
let nested = false;
|
||||
state.subscribe((_value, _context, delivery) => {
|
||||
if (delivery.kind !== "update" || nested) return;
|
||||
nested = true;
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 2;
|
||||
});
|
||||
state.subscribe((_value, _context, delivery) => lateDeliveries.push(delivery));
|
||||
});
|
||||
state.subscribe((value, _context, delivery) => {
|
||||
if (delivery.kind === "update") deliveries.push({ value: value.value, sequence: delivery.sequence });
|
||||
});
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 1;
|
||||
});
|
||||
expect(deliveries).toEqual([
|
||||
{ value: 1, sequence: 1 },
|
||||
{ value: 2, sequence: 2 },
|
||||
]);
|
||||
expect(lateDeliveries).toEqual([{ kind: "hydrate", sequence: 2 }]);
|
||||
});
|
||||
|
||||
it("isolates listener failures after committing the revision", () => {
|
||||
const reports: (() => void)[] = [];
|
||||
const spy = vi.spyOn(globalThis, "queueMicrotask").mockImplementation((report) => {
|
||||
reports.push(report);
|
||||
});
|
||||
try {
|
||||
const state = replicatedState({ value: 0 });
|
||||
const received: number[] = [];
|
||||
state.subscribe((_value, _context, delivery) => {
|
||||
if (delivery.kind === "update") throw new Error("listener failed");
|
||||
});
|
||||
state.subscribe((_value, _context, delivery) => received.push(delivery.sequence));
|
||||
expect(() =>
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.value = 1;
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(state.value).toEqual({ value: 1 });
|
||||
expect(received).toEqual([0, 1]);
|
||||
expect(reports).toHaveLength(1);
|
||||
expect(reports[0]).toThrow("listener failed");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("copies assigned values by value", () => {
|
||||
const external = { value: 1 };
|
||||
const state = replicatedState<{ left: { value: number } | null; right: { value: number } | null }>({
|
||||
left: null,
|
||||
right: null,
|
||||
});
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.left = external;
|
||||
draft.right = external;
|
||||
draft.left.value = 2;
|
||||
});
|
||||
expect(external.value).toBe(1);
|
||||
expect(state.value).toEqual({ left: { value: 2 }, right: { value: 1 } });
|
||||
expect(state.value.left).not.toBe(state.value.right);
|
||||
});
|
||||
|
||||
it("takes immutable ownership of alias-free replacements", () => {
|
||||
const state = replicatedState({ left: { value: 1 }, right: { value: 2 } });
|
||||
const replacement = { left: { ...state.value.left }, right: { ...state.value.left } };
|
||||
state.replace(BACKGROUND_CONTEXT, replacement);
|
||||
expect(state.value).toBe(replacement);
|
||||
expect(state.value.left).not.toBe(state.value.right);
|
||||
state.change(BACKGROUND_CONTEXT, (draft) => {
|
||||
draft.left.value = 9;
|
||||
});
|
||||
expect(state.value).toEqual({ left: { value: 9 }, right: { value: 1 } });
|
||||
});
|
||||
|
||||
it("replaces atomically and ignores deeply equal replacements", () => {
|
||||
const state = replicatedState({ value: { nested: 1 }, retained: { nested: 2 } });
|
||||
const previous = state.value;
|
||||
state.replace(BACKGROUND_CONTEXT, { value: { nested: 1 }, retained: { nested: 2 } });
|
||||
expect(state.value).toBe(previous);
|
||||
state.replace(BACKGROUND_CONTEXT, { ...state.value, value: { nested: 2 } });
|
||||
expect(state.value).toEqual({ value: { nested: 2 }, retained: { nested: 2 } });
|
||||
expect(state.value.retained).toBe(previous.retained);
|
||||
});
|
||||
});
|
||||
|
||||
type SourceValue = Readonly<{ value: number }>;
|
||||
|
||||
class TestSource<T> implements ReplicatedStateSource<T> {
|
||||
readonly attachments = new Set<TestSourceAttachment<T>>();
|
||||
onAttach: (() => void) | undefined;
|
||||
#value: T;
|
||||
#cursor: number;
|
||||
|
||||
constructor(value: T, cursor = 0) {
|
||||
this.#value = value;
|
||||
this.#cursor = cursor;
|
||||
}
|
||||
|
||||
attach(): ReplicatedStateSourceAttachment<T> {
|
||||
const attachment = new TestSourceAttachment(Object.freeze({ value: this.#value, cursor: this.#cursor }), () =>
|
||||
this.attachments.delete(attachment),
|
||||
);
|
||||
this.attachments.add(attachment);
|
||||
this.onAttach?.();
|
||||
return attachment;
|
||||
}
|
||||
|
||||
commit(value: T, ops: readonly Op[], context: Context = BACKGROUND_CONTEXT, cursor = this.#cursor + 1): void {
|
||||
this.#value = value;
|
||||
this.#cursor = cursor;
|
||||
const frame = Object.freeze({ cursor, value, ops, context });
|
||||
for (const attachment of [...this.attachments]) attachment.publish(frame);
|
||||
}
|
||||
}
|
||||
|
||||
class TestSourceAttachment<T> implements ReplicatedStateSourceAttachment<T> {
|
||||
readonly snapshot: { readonly value: T; readonly cursor: number };
|
||||
readonly #onDispose: () => void;
|
||||
readonly #buffer: ReplicatedStateSourceFrame<T>[] = [];
|
||||
#listener: ((frame: ReplicatedStateSourceFrame<T>) => void) | undefined;
|
||||
#activated = false;
|
||||
#disposed = false;
|
||||
|
||||
constructor(snapshot: { readonly value: T; readonly cursor: number }, onDispose: () => void) {
|
||||
this.snapshot = snapshot;
|
||||
this.#onDispose = onDispose;
|
||||
}
|
||||
|
||||
activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void {
|
||||
if (this.#activated) throw new Error("attachment is already active");
|
||||
this.#activated = true;
|
||||
this.#listener = listener;
|
||||
for (const frame of this.#buffer.splice(0)) listener(frame);
|
||||
}
|
||||
|
||||
publish(frame: ReplicatedStateSourceFrame<T>): void {
|
||||
if (this.#disposed) return;
|
||||
if (this.#listener === undefined) this.#buffer.push(frame);
|
||||
else this.#listener(frame);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#buffer.length = 0;
|
||||
this.#onDispose();
|
||||
}
|
||||
}
|
||||
|
||||
describe("authoritative replicated state sources", () => {
|
||||
it("captures before activation and drains queued commits in order", () => {
|
||||
const initial = Object.freeze({ value: 0 });
|
||||
const first = Object.freeze({ value: 1 });
|
||||
const second = Object.freeze({ value: 2 });
|
||||
const firstOps = Object.freeze([["s", ["value"], 1] as const]);
|
||||
const secondOps = Object.freeze([["s", ["value"], 2] as const]);
|
||||
const source = new TestSource<SourceValue>(initial, 10);
|
||||
source.onAttach = () => {
|
||||
source.commit(first, firstOps);
|
||||
source.commit(second, secondOps);
|
||||
};
|
||||
|
||||
const state = replicatedState(source);
|
||||
const deliveries: Array<{ value: SourceValue; sequence: number }> = [];
|
||||
state.subscribe((value, _context, delivery) => deliveries.push({ value, sequence: delivery.sequence }));
|
||||
expect(state.value).toBe(second);
|
||||
expect(deliveries).toEqual([{ value: second, sequence: 2 }]);
|
||||
});
|
||||
|
||||
it("hydrates at sequence zero when attaching after existing commits", () => {
|
||||
const initial = Object.freeze({ value: 0 });
|
||||
const current = Object.freeze({ value: 1 });
|
||||
const source = new TestSource<SourceValue>(initial, 40);
|
||||
source.commit(current, Object.freeze([["s", ["value"], 1] as const]));
|
||||
const state = replicatedState(source);
|
||||
const deliveries: number[] = [];
|
||||
state.subscribe((_value, _context, delivery) => deliveries.push(delivery.sequence));
|
||||
expect(state.value).toBe(current);
|
||||
expect(deliveries).toEqual([0]);
|
||||
});
|
||||
|
||||
it("publishes exact source values without applying or re-diffing", () => {
|
||||
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
||||
const state = replicatedState(source);
|
||||
const next = Object.freeze({ value: 1 });
|
||||
// Deliberately not the operation that produces `next`: Chord must publish the source value as is.
|
||||
const ops = Object.freeze([["s", ["value"], 7] as const]);
|
||||
let publishedValue: SourceValue | undefined;
|
||||
state.subscribe((value, _context, delivery) => {
|
||||
if (delivery.kind === "update") publishedValue = value;
|
||||
});
|
||||
|
||||
source.commit(next, ops);
|
||||
expect(state.value).toBe(next);
|
||||
expect(publishedValue).toBe(next);
|
||||
});
|
||||
|
||||
it("buffers reentrant frames and skips updates covered by a late hydration", () => {
|
||||
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
||||
const state = replicatedState(source);
|
||||
const received: number[] = [];
|
||||
const late: Array<{ kind: string; sequence: number; value: number }> = [];
|
||||
let nested = false;
|
||||
state.subscribe((_value, _context, delivery) => {
|
||||
if (delivery.kind !== "update" || delivery.sequence !== 1 || nested) return;
|
||||
nested = true;
|
||||
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]));
|
||||
state.subscribe((value, _context, delivery) => late.push({ ...delivery, value: value.value }));
|
||||
});
|
||||
state.subscribe((value, _context, delivery) => {
|
||||
if (delivery.kind === "update") received.push(value.value);
|
||||
});
|
||||
|
||||
source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const]));
|
||||
expect(received).toEqual([1, 2]);
|
||||
expect(late).toEqual([{ kind: "hydrate", sequence: 2, value: 2 }]);
|
||||
});
|
||||
|
||||
it("reports listener failures without throwing them into the source", () => {
|
||||
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
||||
const errors: Error[] = [];
|
||||
const state = replicatedState(source, { onError: (error) => errors.push(error) });
|
||||
const received: number[] = [];
|
||||
state.subscribe((_value, _context, delivery) => {
|
||||
if (delivery.kind === "update") throw new Error("listener failed");
|
||||
});
|
||||
state.subscribe((value, _context, delivery) => {
|
||||
if (delivery.kind === "update") received.push(value.value);
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const])),
|
||||
).not.toThrow();
|
||||
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]));
|
||||
expect(received).toEqual([1, 2]);
|
||||
expect(errors.map((error) => error.message)).toEqual(["listener failed", "listener failed"]);
|
||||
});
|
||||
|
||||
it("reports cursor gaps, disposes the broken attachment, and ignores later frames", () => {
|
||||
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }), 5);
|
||||
const errors: Error[] = [];
|
||||
const state = replicatedState(source, { onError: (error) => errors.push(error) });
|
||||
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]), BACKGROUND_CONTEXT, 7);
|
||||
expect(errors[0]?.message).toContain("expected 6, received 7");
|
||||
expect(source.attachments.size).toBe(0);
|
||||
expect(state.value).toEqual({ value: 0 });
|
||||
source.commit(Object.freeze({ value: 3 }), Object.freeze([["s", ["value"], 3] as const]), BACKGROUND_CONTEXT, 8);
|
||||
expect(state.value).toEqual({ value: 0 });
|
||||
});
|
||||
|
||||
it("keeps attachments independent and disposes each idempotently", () => {
|
||||
const source = new TestSource<SourceValue>(Object.freeze({ value: 0 }));
|
||||
const first = replicatedState(source);
|
||||
const second = replicatedState(source);
|
||||
expect(source.attachments.size).toBe(2);
|
||||
source.commit(Object.freeze({ value: 1 }), Object.freeze([["s", ["value"], 1] as const]));
|
||||
expect(first.value.value).toBe(1);
|
||||
expect(second.value.value).toBe(1);
|
||||
|
||||
first.dispose();
|
||||
first.dispose();
|
||||
expect(source.attachments.size).toBe(1);
|
||||
source.commit(Object.freeze({ value: 2 }), Object.freeze([["s", ["value"], 2] as const]));
|
||||
expect(first.value.value).toBe(1);
|
||||
expect(second.value.value).toBe(2);
|
||||
second.dispose();
|
||||
expect(source.attachments.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,509 +0,0 @@
|
|||
// Regression tests for resolved application requests (#1-#10), numbered as in their test names.
|
||||
import assert from "node:assert/strict";
|
||||
import { arr, num, obj, str, unknown } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import { applyImmutable } from "../../src/delta/index.ts";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineFacet,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
type FacetApi,
|
||||
type FacetLoader,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
opaque,
|
||||
remote,
|
||||
type StateEvent,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 100): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
function latch() {
|
||||
let open = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
const View = obj({ text: str() }, { open: true });
|
||||
|
||||
describe("application requests", () => {
|
||||
it("#1 a held object's state member can be returned as this object's state member", async () => {
|
||||
const Conv = remote("t.conv", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { view: View }, methods: { set: method(str()) } } },
|
||||
});
|
||||
const Chat = remote("t.chat", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { viewer: { state: { view: View } } },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Conv, (_input, _principal, self) => {
|
||||
const view = self.state("view", View, { text: "" });
|
||||
return {
|
||||
variant: "main",
|
||||
view,
|
||||
set: (text, ctx) => {
|
||||
view.replace(ctx, { text });
|
||||
},
|
||||
};
|
||||
});
|
||||
// No derivation, no cast: the reference member is the implementation member.
|
||||
server.serve(Chat, (_input, _principal, self) => {
|
||||
const conv = self.get(Conv, {});
|
||||
return { variant: "viewer", view: conv.view };
|
||||
});
|
||||
const chat = server.hold(Chat, {});
|
||||
const values: unknown[] = [];
|
||||
chat.view.subscribe((value) => values.push(value));
|
||||
await chat.ready;
|
||||
await server.hold(Conv, {}).set("hi");
|
||||
await tick();
|
||||
assert.deepEqual(chat.view.value, { text: "hi" });
|
||||
assert.deepEqual(values.at(-1), { text: "hi" });
|
||||
|
||||
// Across a link: the snapshot and its ops reach a browser.
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const remoteChat = browser.hold(Chat, {});
|
||||
await remoteChat.ready;
|
||||
await server.hold(Conv, {}).set("there");
|
||||
await until(() => (remoteChat.view.value as { text: string } | undefined)?.text === "there");
|
||||
});
|
||||
|
||||
it("#2 facets reach their own host through FacetApi.root and FacetApi.registry", async () => {
|
||||
const Config = defineRegistry<string>("t.config");
|
||||
const Settings = local("t.settings", { version: 1, input: obj({}, { open: true }) });
|
||||
const Reader = local("t.reader", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { read: method(undefined, str()) },
|
||||
});
|
||||
const captured: FacetApi[] = [];
|
||||
const facet = (value: string): Facet => ({
|
||||
name: "config",
|
||||
...defineFacet((api) => {
|
||||
captured.push(api);
|
||||
api.serve(
|
||||
Settings,
|
||||
(_input, _principal, self) => {
|
||||
self.register(Config, { greeting: value });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
api.serve(Reader, () => ({
|
||||
read: (ctx: Ctx) => api.registry(Config).get("greeting", ctx) ?? "",
|
||||
}));
|
||||
}),
|
||||
});
|
||||
const host = createHost({ self: { kind: "host" }, name: "worker", onError: () => {} });
|
||||
host.install(facet("v1"));
|
||||
await sleep(5);
|
||||
const api = captured[0]!;
|
||||
assert.equal(api.name, "worker");
|
||||
// A callback outside Chord (a Durable tool, say) starts its own root.
|
||||
assert.equal(await api.root(async (ctx) => (await ctx.get(Reader, {})).read(ctx)), "v1");
|
||||
await host.reload([facet("v2")]);
|
||||
await sleep(5);
|
||||
assert.equal(captured[1]!.registry(Config).get("greeting"), "v2");
|
||||
assert.equal(await api.root(async (ctx) => (await ctx.get(Reader, {})).read(ctx)), "v2");
|
||||
});
|
||||
|
||||
it("#3 a listener keeps its subscription across reloads when resource and select are unchanged", async () => {
|
||||
const Watcher = local("t.watcher", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { seen: arr(str()) },
|
||||
});
|
||||
// A source that accepts one subscriber at a time (like a Durable watch).
|
||||
let subscribers = 0;
|
||||
let emit = (_event: string) => {};
|
||||
const single = {
|
||||
start: (fn: (event: string) => void) => {
|
||||
if (subscribers > 0) throw new Error("Watch is already started");
|
||||
subscribers++;
|
||||
emit = fn;
|
||||
return () => {
|
||||
subscribers--;
|
||||
};
|
||||
},
|
||||
};
|
||||
const facet = (tag: string): Facet => ({
|
||||
name: "watcher",
|
||||
setup: (api) =>
|
||||
api.serve(Watcher, (_input, _principal, self) => {
|
||||
const seen = self.state("seen", arr(str()), []);
|
||||
const watchResource = self.resource("watch", { version: 1, open: () => single });
|
||||
// A new adapter object per call, and a new arrow per factory run.
|
||||
self.on(
|
||||
"events",
|
||||
watchResource,
|
||||
(watch) => ({ subscribe: (fn: (event: string) => void) => watch.start(fn) }),
|
||||
(event, ctx) => seen.change(ctx, (draft) => void draft.push(`${tag}:${event}`)),
|
||||
);
|
||||
return { seen };
|
||||
}),
|
||||
});
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.install(facet("v1"));
|
||||
const ref = host.hold(Watcher, {});
|
||||
await ref.ready;
|
||||
emit("a");
|
||||
await host.reload([facet("v2")]);
|
||||
await host.reload([facet("v3")]);
|
||||
emit("b");
|
||||
await sleep(5);
|
||||
assert.equal(subscribers, 1);
|
||||
assert.deepEqual(ref.seen.value, ["v1:a", "v3:b"]);
|
||||
});
|
||||
|
||||
it("#3 a changed select resubscribes", async () => {
|
||||
const Watcher = local("t.watcher2", { version: 1, input: obj({}, { open: true }) });
|
||||
let subscribes = 0;
|
||||
const source = {
|
||||
subscribe: () => {
|
||||
subscribes++;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
const facet = (selectText: boolean): Facet => ({
|
||||
name: "watcher2",
|
||||
setup: (api) =>
|
||||
api.serve(Watcher, (_input, _principal, self) => {
|
||||
const sResource = self.resource("s", { version: 1, open: () => source });
|
||||
self.on("s", sResource, selectText ? (s) => s : (value) => value, () => {});
|
||||
return {};
|
||||
}),
|
||||
});
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.install(facet(true));
|
||||
await host.hold(Watcher, {}).ready;
|
||||
await host.reload([facet(true)]);
|
||||
assert.equal(subscribes, 1);
|
||||
await host.reload([facet(false)]);
|
||||
assert.equal(subscribes, 2);
|
||||
});
|
||||
|
||||
it("#4 replace diffs a new revision that shares structure; unrelated values replace", async () => {
|
||||
const Mirror = local("t.mirror", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { doc: unknown(), set: method(unknown()) },
|
||||
});
|
||||
const Doc = obj({ entries: arr(str()), status: str() }, { open: true });
|
||||
let lastOptions: { diff?: boolean } | undefined;
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Mirror, (_input, _principal, self) => {
|
||||
const doc = self.state("doc", Doc, { entries: ["a", "b"], status: "idle" });
|
||||
return {
|
||||
doc,
|
||||
set: (value: unknown, ctx: Ctx) => {
|
||||
doc.replace(ctx, value as never, lastOptions);
|
||||
},
|
||||
};
|
||||
});
|
||||
const ref = host.hold(Mirror, {});
|
||||
await ref.ready;
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
let replica: unknown;
|
||||
ref.doc.events((event) => {
|
||||
replica = event.type === "ops" ? applyImmutable(replica, event.ops) : event.value;
|
||||
events.push(event);
|
||||
});
|
||||
const opsOf = (event: StateEvent<unknown> | undefined) => (event?.type === "ops" ? event.ops : []);
|
||||
const current = ref.doc.value as { entries: string[]; status: string };
|
||||
// A new revision reusing `entries`: a diff.
|
||||
await ref.set({ entries: current.entries, status: "running" });
|
||||
assert.deepEqual(opsOf(events.at(-1)), [["s", ["status"], "running"]]);
|
||||
// Unrelated containers: one replacement op.
|
||||
await ref.set({ entries: ["a", "b", "c"], status: "running" });
|
||||
assert.equal(opsOf(events.at(-1))[0]?.[0], "r");
|
||||
// Forced either way.
|
||||
lastOptions = { diff: true };
|
||||
await ref.set({ entries: ["a", "b", "c", "d"], status: "running" });
|
||||
assert.notEqual(opsOf(events.at(-1))[0]?.[0], "r");
|
||||
lastOptions = { diff: false };
|
||||
const shared = ref.doc.value as { entries: string[] };
|
||||
await ref.set({ entries: shared.entries, status: "done" });
|
||||
assert.equal(opsOf(events.at(-1))[0]?.[0], "r");
|
||||
await tick();
|
||||
assert.deepEqual(replica, ref.doc.value);
|
||||
});
|
||||
|
||||
it("#5 roots of a child that connected behind the server are admitted in the server's code version", async () => {
|
||||
const Echo = remote("t.echo", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { codeVersion: method(undefined, num()) } } },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const echoFacet: Facet = {
|
||||
name: "echo",
|
||||
setup: (api) => api.serve(Echo, () => ({ variant: "main", codeVersion: (ctx: Ctx) => ctx.codeVersion })),
|
||||
};
|
||||
server.install(echoFacet);
|
||||
await server.reload([echoFacet]);
|
||||
assert.equal(server.codeVersion, 2);
|
||||
assert.equal(server.previousCodeVersion, undefined);
|
||||
const loader: FacetLoader = async () => [];
|
||||
const worker = createHost({ self: { kind: "host" }, name: "session:S1", loader, onError: () => {} });
|
||||
const go = latch();
|
||||
// A worker root of code version 1 (an eager object's listener, say) calls the server after connecting.
|
||||
const call = worker.root(async (ctx) => {
|
||||
await go.promise;
|
||||
return (await ctx.get(Echo, {})).codeVersion(ctx);
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "S1" }, kind: "host", name: "session:S1" });
|
||||
worker.connect(up, { principal: { kind: "server" } });
|
||||
go.open();
|
||||
assert.equal(await call, 2);
|
||||
await until(() => worker.codeVersion === 2);
|
||||
// Unknown code versions from in-step peers stay superseded.
|
||||
await assert.rejects(
|
||||
server.root(() => 0, { codeVersion: 1 }),
|
||||
(error) => isChordError(error, "superseded"),
|
||||
);
|
||||
});
|
||||
|
||||
it("#6 self.expose: an opaque member backed by a resource, readable once live", async () => {
|
||||
const Store = local("t.store", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { db: opaque<{ readonly queries: string[] }>("t.db@1") },
|
||||
});
|
||||
const gate = latch();
|
||||
const native = { queries: [] as string[] };
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Store, (_input, _principal, self) => {
|
||||
const db = self.resource("db", {
|
||||
version: 1,
|
||||
open: async () => {
|
||||
await gate.promise;
|
||||
return native;
|
||||
},
|
||||
});
|
||||
return { db: self.expose(db) };
|
||||
});
|
||||
const ref = host.hold(Store, {});
|
||||
assert.equal(ref.db, undefined);
|
||||
gate.open();
|
||||
await ref.ready;
|
||||
assert.equal(ref.db, native);
|
||||
|
||||
const Other = local("t.other", { version: 1, input: obj({}, { open: true }) });
|
||||
host.serve(Other, (_input, _principal, self) => {
|
||||
self.expose({ key: "x", value: 1 });
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Other, {}).ready, /expose takes a resource declared by this object/);
|
||||
});
|
||||
|
||||
it("#7 a cluster reload reports what each child prepared", async () => {
|
||||
const Greeter = remote("t.greeter", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("session:S1"),
|
||||
variants: { main: { methods: { greet: method(str(), str()) } } },
|
||||
});
|
||||
const greeter = (text: string): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) => api.serve(Greeter, () => ({ variant: "main", greet: (name: string) => `${text} ${name}` })),
|
||||
});
|
||||
const modules = new Map([["greeter", greeter("v1")]]);
|
||||
const loader: FacetLoader = async (names) => names.map((name) => modules.get(String(name))!);
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "S1" }, kind: "host", name: "session:S1" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
loader,
|
||||
onError: () => {},
|
||||
});
|
||||
worker.install(greeter("v1"));
|
||||
await tick();
|
||||
const ref = server.hold(Greeter, {});
|
||||
assert.equal(await ref.greet("a"), "v1 a");
|
||||
modules.set("greeter", greeter("v2"));
|
||||
const report = await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
assert.deepEqual(report.changed, []);
|
||||
const child = report.children?.["session:S1"];
|
||||
assert.deepEqual(child?.changed, ["t.greeter"]);
|
||||
assert.equal(child?.prepared.length, 1);
|
||||
assert.equal(child?.codeVersion, report.codeVersion);
|
||||
});
|
||||
|
||||
it("#8 a held remote reference keeps its state without listeners; a later subscriber sees the current value", async () => {
|
||||
const Counter = remote("t.count8", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { n: num() }, methods: { set: method(num()) } } },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Counter, (_input, _principal, self) => {
|
||||
const n = self.state("n", num(), 0);
|
||||
return {
|
||||
variant: "main",
|
||||
n,
|
||||
set: (value, ctx) => {
|
||||
n.replace(ctx, value);
|
||||
},
|
||||
};
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const counter = browser.hold(Counter, {});
|
||||
await counter.ready;
|
||||
assert.equal(counter.n.value, 0);
|
||||
const stop = counter.n.subscribe(() => {});
|
||||
await counter.set(1);
|
||||
await until(() => counter.n.value === 1);
|
||||
stop();
|
||||
await counter.set(2);
|
||||
await until(() => counter.n.value === 2);
|
||||
const seen: unknown[] = [];
|
||||
const again = counter.n.subscribe((value) => seen.push(value));
|
||||
assert.deepEqual(seen, [2]);
|
||||
again();
|
||||
});
|
||||
|
||||
it("#9 references report status changes: live, loading while a link reconnects, gone, released", async () => {
|
||||
const Thing = remote("t.thing9", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: {} },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Thing, () => ({ variant: "main" }));
|
||||
const localThing = server.hold(Thing, {}, { principal: { user: "u" } });
|
||||
const localStatus: unknown[] = [];
|
||||
localThing.onStatus((status, error) =>
|
||||
localStatus.push(status === "gone" ? [status, (error as { code: string }).code] : status),
|
||||
);
|
||||
await localThing.ready;
|
||||
await tick();
|
||||
await server.invalidatePrincipal({ user: "u" });
|
||||
await tick();
|
||||
assert.deepEqual(localStatus, ["live", ["gone", "gone"]]);
|
||||
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const remoteThing = browser.hold(Thing, {});
|
||||
const remoteStatus: string[] = [];
|
||||
remoteThing.onStatus((status) => remoteStatus.push(status));
|
||||
await remoteThing.ready;
|
||||
up.close();
|
||||
await until(() => remoteStatus.length === 2);
|
||||
const [down2, up2] = memoryLinkPair();
|
||||
server.accept(down2, { principal: { guest: "g" } });
|
||||
browser.connect(up2, { principal: { kind: "server" } });
|
||||
await until(() => remoteStatus.length === 3);
|
||||
remoteThing.release();
|
||||
await tick();
|
||||
assert.deepEqual(remoteStatus, ["live", "loading", "live", "released"]);
|
||||
});
|
||||
|
||||
it("#10 host close disposes holders before what they hold, and before closing links", async () => {
|
||||
const log: string[] = [];
|
||||
const Remote = remote("t.remote10", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { methods: { note: method(str()) } } },
|
||||
});
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Remote, () => ({
|
||||
variant: "main",
|
||||
note: (text) => {
|
||||
log.push(`server: ${text}`);
|
||||
},
|
||||
}));
|
||||
const Proc = local("t.proc", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { tracker: opaque<Set<string>>("t.tracker@1") },
|
||||
});
|
||||
const Chat = local("t.chat10", { version: 1, input: obj({}, { open: true }) });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "w" }, kind: "host", name: "w" });
|
||||
const worker = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "w",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: (error) => log.push(`error: ${String(error)}`),
|
||||
});
|
||||
// Eager: activated (and registered with the host) before the object that holds it.
|
||||
worker.serve(
|
||||
Proc,
|
||||
(_input, _principal, self) => {
|
||||
const tracker = self.resource("tracker", {
|
||||
version: 1,
|
||||
open: () => new Set<string>(),
|
||||
close: () => void log.push("proc closed"),
|
||||
});
|
||||
return { tracker: self.expose(tracker) };
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
await sleep(5);
|
||||
worker.serve(Chat, (_input, _principal, self) => {
|
||||
const proc = self.get(Proc, {});
|
||||
const upstream = self.get(Remote, {});
|
||||
self.resource("session", {
|
||||
version: 1,
|
||||
open: () => proc.tracker?.add("chat"),
|
||||
close: async () => {
|
||||
proc.tracker?.delete("chat");
|
||||
log.push(`chat closed, proc ${proc.status}, tracker ${proc.tracker === undefined ? "gone" : "ok"}`);
|
||||
await upstream.note("bye");
|
||||
},
|
||||
});
|
||||
return {};
|
||||
});
|
||||
await worker.hold(Chat, {}).ready;
|
||||
await worker.close();
|
||||
assert.deepEqual(log, ["chat closed, proc live, tracker ok", "server: bye", "proc closed"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,689 +0,0 @@
|
|||
// Regression tests for Sol's phase 1a review (findings #1-#11) and the requested race tests.
|
||||
import assert from "node:assert/strict";
|
||||
import { arr, bool, compile, num, obj, opt, str, unknown } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
type Ref,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
import { fits } from "../../src/old/schema.ts";
|
||||
import { CountingLink } from "./fixtures/counting.ts";
|
||||
import { typedLit } from "./fixtures/schemas.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const ticks = async (n = 10) => {
|
||||
for (let i = 0; i < n; i++) await tick();
|
||||
};
|
||||
|
||||
function latch() {
|
||||
let open = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
function emitter<E>(options: { failSubscribe?: boolean } = {}) {
|
||||
const listeners = new Set<(event: E) => void>();
|
||||
return {
|
||||
emit: (event: E) => {
|
||||
for (const fn of [...listeners]) fn(event);
|
||||
},
|
||||
subscribe: (fn: (event: E) => void) => {
|
||||
if (options.failSubscribe) throw new Error("subscribe fails");
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Item = local("t.item", {
|
||||
version: 1,
|
||||
input: obj({ id: str() }, { open: true }),
|
||||
members: { version: method(undefined, str()) },
|
||||
});
|
||||
|
||||
describe("review: reload commit", () => {
|
||||
it("#1 a failing listener subscription fails prepare; the code version and all objects stay unchanged", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const sources = { a: emitter<number>(), b: emitter<number>({ failSubscribe: true }), c: emitter<number>() };
|
||||
const facet = (version: string, listen: boolean): Facet => ({
|
||||
name: "item",
|
||||
setup: (api) =>
|
||||
api.serve(Item, ({ id }, _principal, self) => {
|
||||
if (listen) self.on("events", sources[id as "a"], () => {});
|
||||
return { version: () => version };
|
||||
}),
|
||||
});
|
||||
host.install(facet("v1", false));
|
||||
const refs = ["a", "b", "c"].map((id) => host.hold(Item, { id }));
|
||||
await Promise.all(refs.map((ref) => ref.ready));
|
||||
const codeVersion = host.codeVersion;
|
||||
await assert.rejects(host.reload([facet("v2", true)]), /subscribe fails/);
|
||||
assert.equal(host.codeVersion, codeVersion);
|
||||
assert.deepEqual(await Promise.all(refs.map((ref) => ref.version())), ["v1", "v1", "v1"]);
|
||||
assert.equal(sources.a.size, 0);
|
||||
assert.equal(sources.c.size, 0);
|
||||
});
|
||||
|
||||
it("#2 migration is validated against the new remote schemas, not the obsolete ones (dev mode)", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, dev: true, onError: () => {} });
|
||||
const V1 = remote("t.counted", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { state: { count: obj({ n: num() }, { open: true }) } } },
|
||||
});
|
||||
const V2 = remote("t.counted", {
|
||||
version: 2,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { state: { count: obj({ total: num() }, { open: true }) } } },
|
||||
});
|
||||
host.install({
|
||||
name: "counted",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
V1,
|
||||
(_input, _principal, self) => ({
|
||||
variant: "main",
|
||||
count: self.state("count", obj({ n: num() }, { open: true }), { n: 3 }),
|
||||
}),
|
||||
{ linger: 60_000 },
|
||||
),
|
||||
});
|
||||
const first = host.hold(V1, {});
|
||||
await first.ready;
|
||||
first.release();
|
||||
await host.reload([
|
||||
{
|
||||
name: "counted",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
V2,
|
||||
(_input, _principal, self) => ({
|
||||
variant: "main",
|
||||
count: self.state(
|
||||
"count",
|
||||
obj({ total: num() }, { open: true }),
|
||||
{ total: 0 },
|
||||
{ version: 2, migrate: (old) => ({ total: (old as { n: number }).n }) },
|
||||
),
|
||||
}),
|
||||
{ linger: 60_000 },
|
||||
),
|
||||
},
|
||||
]);
|
||||
const second = host.hold(V2, {});
|
||||
const values: unknown[] = [];
|
||||
second.count.subscribe((value) => values.push(value));
|
||||
await second.ready;
|
||||
await ticks();
|
||||
assert.deepEqual(second.count.value, { total: 3 });
|
||||
});
|
||||
|
||||
it("#1 a commit that still fails midway fails the host terminally (no half-published code version serving work)", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.install({ name: "item", setup: (api) => api.serve(Item, () => ({ version: () => "v1" })) });
|
||||
const ref = host.hold(Item, { id: "a" });
|
||||
await ref.ready;
|
||||
// Everything fallible runs in prepare now, so break an install step directly (white box).
|
||||
const objects = (host as unknown as { objects: Map<string, { startTasks(): void }> }).objects;
|
||||
for (const object of objects.values()) {
|
||||
object.startTasks = () => {
|
||||
throw new Error("install step fails");
|
||||
};
|
||||
}
|
||||
await assert.rejects(
|
||||
host.reload([{ name: "item", setup: (api) => api.serve(Item, () => ({ version: () => "v2" })) }]),
|
||||
/install step fails/,
|
||||
);
|
||||
await assert.rejects(
|
||||
ref.version(),
|
||||
(error) => isChordError(error, "unavailable") && error.reason === "host_failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("reentrant observers calling another object during a commit see the whole new code version", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Other = local("t.other", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { version: method(undefined, str()) },
|
||||
});
|
||||
const facet = (version: string): Facet => ({
|
||||
name: "pair",
|
||||
setup: (api) => {
|
||||
api.serve(Item, () => ({ version: () => version }));
|
||||
api.serve(Other, () => ({ version: () => version }));
|
||||
},
|
||||
});
|
||||
host.install(facet("v1"));
|
||||
const item = host.hold(Item, { id: "a" });
|
||||
const other = host.hold(Other, {});
|
||||
await Promise.all([item.ready, other.ready]);
|
||||
const seen: Promise<string>[] = [];
|
||||
item.onShape(() => seen.push(other.version()));
|
||||
await host.reload([facet("v2")]);
|
||||
assert.deepEqual(await Promise.all(seen), ["v2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("review: leases, invocations, activation", () => {
|
||||
const Store = local("t.store", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { count: unknown() },
|
||||
});
|
||||
|
||||
it("#4 a running listener holds a lease: the gate waits for it before migrating", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const events = emitter<number>();
|
||||
const go = latch();
|
||||
const seen: unknown[] = [];
|
||||
host.install({
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(Store, (_input, _principal, self) => {
|
||||
const count = self.state("count", obj({ n: num() }, { open: true }), { n: 1 });
|
||||
self.on("slow", events, async () => {
|
||||
await go.promise;
|
||||
seen.push(count.value);
|
||||
});
|
||||
return { count };
|
||||
}),
|
||||
});
|
||||
const ref = host.hold(Store, {});
|
||||
await ref.ready;
|
||||
events.emit(1);
|
||||
await tick();
|
||||
const codeVersion = host.codeVersion;
|
||||
const reload = host.reload(
|
||||
[
|
||||
{
|
||||
name: "store",
|
||||
setup: (api) =>
|
||||
api.serve(Store, (_input, _principal, self) => ({
|
||||
count: self.state(
|
||||
"count",
|
||||
obj({ total: num() }, { open: true }),
|
||||
{ total: 0 },
|
||||
{ version: 2, migrate: (old) => ({ total: (old as { n: number }).n }) },
|
||||
),
|
||||
})),
|
||||
},
|
||||
],
|
||||
{ gateDeadline: 2000 },
|
||||
);
|
||||
await ticks(20);
|
||||
assert.equal(host.codeVersion, codeVersion, "the gate must wait for the listener");
|
||||
go.open();
|
||||
await reload;
|
||||
assert.deepEqual(seen, [{ n: 1 }]);
|
||||
assert.deepEqual(ref.count.value, { total: 1 });
|
||||
});
|
||||
|
||||
it("#5 disposal waits for running methods before closing resources (released and forced)", async () => {
|
||||
const Worker = local("t.busy", {
|
||||
version: 1,
|
||||
input: obj({ id: str() }, { open: true }),
|
||||
members: { work: method(undefined, bool()) },
|
||||
});
|
||||
const log: string[] = [];
|
||||
let go = latch();
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Worker, ({ id }, _principal, self) => {
|
||||
const connResource = self.resource("conn", {
|
||||
version: 1,
|
||||
open: () => ({ closed: false }),
|
||||
close: (conn) => {
|
||||
conn.closed = true;
|
||||
log.push(`close ${id}`);
|
||||
},
|
||||
});
|
||||
return {
|
||||
work: async () => {
|
||||
await go.promise;
|
||||
log.push(`done ${id}`);
|
||||
return connResource.value.closed;
|
||||
},
|
||||
};
|
||||
});
|
||||
const released = host.hold(Worker, { id: "r" });
|
||||
const call = released.work();
|
||||
await ticks();
|
||||
released.release();
|
||||
await ticks();
|
||||
go.open();
|
||||
assert.equal(await call, false);
|
||||
await ticks();
|
||||
assert.deepEqual(log, ["done r", "close r"]);
|
||||
|
||||
go = latch();
|
||||
log.length = 0;
|
||||
const forced = host.hold(Worker, { id: "f" }, { principal: { user: "u" } });
|
||||
const forcedCall = forced.work();
|
||||
await ticks();
|
||||
const invalidated = host.invalidatePrincipal((principal) => (principal as { user?: string }).user === "u");
|
||||
await ticks();
|
||||
assert.equal(forced.status, "gone");
|
||||
assert.deepEqual(log, []);
|
||||
go.open();
|
||||
await invalidated;
|
||||
assert.equal(await forcedCall, false);
|
||||
assert.deepEqual(log, ["done f", "close f"]);
|
||||
});
|
||||
|
||||
it("#6 a registry conflict found while finalizing activation unwinds the loser completely", async () => {
|
||||
const errors: unknown[] = [];
|
||||
const host = createHost({ self: { kind: "host" }, onError: (error) => errors.push(error) });
|
||||
const Routes = defineRegistry<string>("t.review-routes");
|
||||
const log: string[] = [];
|
||||
const types = ["t.eager-a", "t.eager-b"].map((name) =>
|
||||
local(name, { version: 1, input: obj({}, { open: true }) }),
|
||||
);
|
||||
for (const type of types) {
|
||||
host.serve(
|
||||
type,
|
||||
(_input, _principal, self) => {
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () => void log.push(`open ${type.name}`),
|
||||
close: () => void log.push(`close ${type.name}`),
|
||||
});
|
||||
self.register(Routes, { same: type.name });
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
}
|
||||
await ticks(20);
|
||||
assert.equal(log.filter((entry) => entry.startsWith("open")).length, 2);
|
||||
assert.equal(log.filter((entry) => entry.startsWith("close")).length, 1);
|
||||
assert.equal(host.registry(Routes).table().size, 1);
|
||||
assert.ok(errors.some((error) => isChordError(error, "invalid") && error.reason === "registry_conflict"));
|
||||
});
|
||||
|
||||
it("#6 releasing the last hold while listeners start up does not resurrect the object", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const log: string[] = [];
|
||||
let ref: Ref<typeof Store> | undefined;
|
||||
let runs = 0;
|
||||
host.serve(Store, (_input, _principal, self) => {
|
||||
runs++;
|
||||
const count = self.state("count", num(), 0);
|
||||
self.resource("r", {
|
||||
version: 1,
|
||||
open: () => void log.push("open"),
|
||||
close: () => void log.push("close"),
|
||||
});
|
||||
// A readable delivers its current value as the listener starts: release the only hold right then.
|
||||
self.on("drop", count, () => ref?.release());
|
||||
return { count };
|
||||
});
|
||||
ref = host.hold(Store, {});
|
||||
await ref.ready.catch(() => {});
|
||||
await ticks(20);
|
||||
assert.deepEqual(log, ["open", "close"]);
|
||||
const again = host.hold(Store, {});
|
||||
ref = undefined;
|
||||
await again.ready;
|
||||
assert.equal(runs, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("review: prepare isolation and contracts", () => {
|
||||
const Db = local("t.db", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { read: method(undefined, unknown()) },
|
||||
});
|
||||
|
||||
it("#7 a prepare rerun cannot touch retained resources, even open ones", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const native = { mutated: false };
|
||||
const facet = (touch: boolean): Facet => ({
|
||||
name: "db",
|
||||
setup: (api) =>
|
||||
api.serve(Db, (_input, _principal, self) => {
|
||||
const db = self.resource("db", { version: 1, open: () => native });
|
||||
if (touch) db.value.mutated = true;
|
||||
return { read: () => db.value };
|
||||
}),
|
||||
});
|
||||
host.install(facet(false));
|
||||
const ref = host.hold(Db, {});
|
||||
await ref.ready;
|
||||
await assert.rejects(host.reload([facet(true)]), (error) => isChordError(error, "not_ready"));
|
||||
assert.equal(native.mutated, false);
|
||||
assert.deepEqual(await ref.read(), { mutated: false });
|
||||
});
|
||||
|
||||
it("#7 objects of types a reload adds start listeners and tasks only at commit", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const log: string[] = [];
|
||||
const Fresh = local("t.fresh", { version: 1, input: obj({}, { open: true }) });
|
||||
host.install({ name: "item", setup: (api) => api.serve(Item, () => ({ version: () => "v1" })) });
|
||||
await Promise.all(["a", "b"].map((id) => host.hold(Item, { id }).ready));
|
||||
const plugins = (fail: boolean): Facet[] => [
|
||||
{
|
||||
name: "fresh",
|
||||
setup: (api) =>
|
||||
api.serve(Fresh, (_input, _principal, self) => {
|
||||
self.task(async () => {
|
||||
log.push("task started");
|
||||
});
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "item",
|
||||
setup: (api) =>
|
||||
api.serve(Item, ({ id }, _principal, self) => {
|
||||
self.get(Fresh, {});
|
||||
if (fail && id === "b") throw new Error("b fails");
|
||||
return { version: () => "v2" };
|
||||
}),
|
||||
},
|
||||
];
|
||||
await assert.rejects(host.reload(plugins(true)), /b fails/);
|
||||
await ticks();
|
||||
assert.deepEqual(log, []);
|
||||
await host.reload(plugins(false));
|
||||
await ticks();
|
||||
assert.deepEqual(log, ["task started"]);
|
||||
});
|
||||
|
||||
it("#8 a breaking provider change and its updated consumer reload in one batch", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const P1 = remote("t.provider", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { methods: { get: method(undefined, str()) } } },
|
||||
});
|
||||
const P2 = remote("t.provider", {
|
||||
version: 2,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("host"),
|
||||
variants: { main: { methods: { fetch: method(undefined, str()) } } },
|
||||
});
|
||||
const Consumer = local("t.consumer", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { ask: method(undefined, str()) },
|
||||
});
|
||||
host.install({
|
||||
name: "both",
|
||||
setup: (api) => {
|
||||
api.serve(P1, () => ({ variant: "main", get: () => "old" }));
|
||||
api.serve(Consumer, (_input, _principal, self) => {
|
||||
const provider = self.get(P1, {});
|
||||
return { ask: (ctx: Ctx) => provider.get(ctx) };
|
||||
});
|
||||
},
|
||||
});
|
||||
const consumer = host.hold(Consumer, {});
|
||||
assert.equal(await consumer.ask(), "old");
|
||||
await host.reload([
|
||||
{
|
||||
name: "both",
|
||||
setup: (api) => {
|
||||
api.serve(P2, () => ({ variant: "main", fetch: () => "new" }));
|
||||
api.serve(Consumer, (_input, _principal, self) => {
|
||||
const provider = self.get(P2, {});
|
||||
return { ask: (ctx: Ctx) => provider.fetch(ctx) };
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(await consumer.ask(), "new");
|
||||
});
|
||||
|
||||
it("#9 an exclusive resource whose close failed is restored on rollback, or marked unavailable", async () => {
|
||||
for (const reopen of ["works", "fails"] as const) {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const log: string[] = [];
|
||||
let opens = 0;
|
||||
const facet = (rev: number): Facet => ({
|
||||
name: "db",
|
||||
setup: (api) =>
|
||||
api.serve(Db, (_input, _principal, self) => {
|
||||
const port = self.resource("port", {
|
||||
version: rev,
|
||||
exclusive: true,
|
||||
open: () => {
|
||||
if (rev === 2) throw new Error("new port fails");
|
||||
if (++opens > 1 && reopen === "fails") throw new Error("reopen fails");
|
||||
log.push(`open ${rev}`);
|
||||
return { rev };
|
||||
},
|
||||
close: () => {
|
||||
log.push(`close ${rev}`);
|
||||
throw new Error("close fails");
|
||||
},
|
||||
});
|
||||
return { read: () => port.value };
|
||||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const ref = host.hold(Db, {});
|
||||
await ref.ready;
|
||||
// The failing close aborts the reload at the gate; the old resource must not stay closed.
|
||||
await assert.rejects(host.reload([facet(2)]), /close fails/);
|
||||
if (reopen === "works") {
|
||||
assert.deepEqual(log, ["open 1", "close 1", "open 1"]);
|
||||
assert.deepEqual(await ref.read(), { rev: 1 });
|
||||
} else {
|
||||
await assert.rejects(ref.read(), (error) => isChordError(error, "unavailable"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("#10 retirement closes dropped resources before releasing dropped holds", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const log: string[] = [];
|
||||
const Dep = local("t.dep", { version: 1, input: obj({}, { open: true }) });
|
||||
const facet = (withDecls: boolean): Facet => ({
|
||||
name: "item",
|
||||
setup: (api) => {
|
||||
api.serve(Dep, (_input, _principal, self) => {
|
||||
self.resource("r", { version: 1, open: () => {}, close: () => void log.push("dep closed") });
|
||||
return {};
|
||||
});
|
||||
api.serve(Item, (_input, _principal, self) => {
|
||||
if (withDecls) {
|
||||
self.get(Dep, {});
|
||||
// Slow close: releasing the hold first would let the dependency close before it finishes.
|
||||
self.resource("own", {
|
||||
version: 1,
|
||||
open: () => {},
|
||||
close: async () => {
|
||||
await ticks(3);
|
||||
log.push("own closed");
|
||||
},
|
||||
});
|
||||
}
|
||||
return { version: () => (withDecls ? "v1" : "v2") };
|
||||
});
|
||||
},
|
||||
});
|
||||
host.install(facet(true));
|
||||
const ref = host.hold(Item, { id: "a" });
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await ref.version(ctx);
|
||||
await go.promise;
|
||||
});
|
||||
await tick();
|
||||
await host.reload([facet(false)]);
|
||||
assert.deepEqual(log, []);
|
||||
go.open();
|
||||
await pinned;
|
||||
await ticks(20);
|
||||
assert.deepEqual(log, ["own closed", "dep closed"]);
|
||||
});
|
||||
|
||||
it("#11 fits has no false positives for unmodelled keywords; counterexamples checked with compile", () => {
|
||||
const cases = [
|
||||
[str(), { ...str(), not: { const: "x" } }, "x"],
|
||||
[arr(num()), { ...arr(num()), contains: typedLit(1) }, [2]],
|
||||
[
|
||||
obj({ a: str() }, { open: true }),
|
||||
{ ...obj({ a: str() }, { open: true }), additionalProperties: num() },
|
||||
{ a: "s", b: "t" },
|
||||
],
|
||||
[
|
||||
obj({ x: str() }, { open: true }),
|
||||
{ ...obj({}, { open: true }), patternProperties: { "^x$": num() } },
|
||||
{ x: "s" },
|
||||
],
|
||||
[num(), { allOf: [num(), num({ minimum: 1 })] }, 0],
|
||||
[
|
||||
{ ...obj({ a: str() }, { open: true }), additionalProperties: num() },
|
||||
obj({ a: str(), b: opt(str()) }, { open: true }),
|
||||
{ a: "", b: 1 },
|
||||
],
|
||||
] as const;
|
||||
for (const [sub, sup, value] of cases) {
|
||||
assert.ok(
|
||||
compile(sub).check(value) && !compile(sup).check(value),
|
||||
`bad counterexample ${JSON.stringify(value)}`,
|
||||
);
|
||||
assert.equal(fits(sub, sup), false, `fits(${JSON.stringify(sub)}, ${JSON.stringify(sup)})`);
|
||||
}
|
||||
// Additive changes still fit.
|
||||
assert.ok(fits(obj({ a: str() }, { open: true }), obj({ a: str(), b: opt(num()) }, { open: true })));
|
||||
assert.ok(fits(obj({ a: str(), extra: num() }, { open: true }), obj({ a: str() }, { open: true })));
|
||||
});
|
||||
|
||||
it("#11 arguments are stripped to what the consumer knows, then to what the provider knows", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Strict = remote("t.strict", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("host"),
|
||||
variants: {
|
||||
main: {
|
||||
methods: {
|
||||
echo: method(obj({ a: str() }), unknown()),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
host.serve(Strict, () => ({ variant: "main", echo: (args) => args }));
|
||||
const ref = host.hold(Strict, {});
|
||||
assert.deepEqual(await ref.echo({ a: "x", junk: 1 } as never), { a: "x" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("review: ctx races", () => {
|
||||
const Target = local("t.target", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { hit: method() },
|
||||
});
|
||||
|
||||
it("an aborted task ctx cannot start fresh calls", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Runner = local("t.runner", { version: 1, input: obj({}, { open: true }) });
|
||||
let hits = 0;
|
||||
const go = latch();
|
||||
let outcome: unknown;
|
||||
host.serve(Target, () => ({
|
||||
hit: () => {
|
||||
hits++;
|
||||
},
|
||||
}));
|
||||
host.serve(Runner, (_input, _principal, self) => {
|
||||
const target = self.get(Target, {});
|
||||
self.task(async (ctx) => {
|
||||
await go.promise;
|
||||
outcome = await target.hit(ctx).catch((error: unknown) => error);
|
||||
});
|
||||
return {};
|
||||
});
|
||||
const runner = host.hold(Runner, {});
|
||||
await runner.ready;
|
||||
const target = host.hold(Target, {});
|
||||
await target.ready;
|
||||
runner.release();
|
||||
await ticks();
|
||||
go.open();
|
||||
await ticks(20);
|
||||
assert.equal(hits, 0);
|
||||
assert.ok(isChordError(outcome, "aborted"));
|
||||
});
|
||||
|
||||
it("an expired ctx and a foreign ctx are refused", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const other = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Target, () => ({ hit: () => {} }));
|
||||
const target = host.hold(Target, {});
|
||||
await target.ready;
|
||||
const expired = await host.root((ctx) => ctx);
|
||||
await assert.rejects(
|
||||
target.hit(expired),
|
||||
(error) => isChordError(error, "aborted") && error.reason === "expired",
|
||||
);
|
||||
const foreign = await other.root(async (ctx) => {
|
||||
await assert.rejects(
|
||||
target.hit(ctx),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "foreign_ctx",
|
||||
);
|
||||
return true;
|
||||
});
|
||||
assert.ok(foreign);
|
||||
});
|
||||
|
||||
// #10959
|
||||
it("a remote reference refuses a foreign ctx and a ctx not made by Chord, without calling", async () => {
|
||||
const RemoteTarget = remote("t.remote-target", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: {}, methods: { hit: method() } } },
|
||||
});
|
||||
let hits = 0;
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(RemoteTarget, () => ({
|
||||
variant: "main",
|
||||
hit: () => {
|
||||
hits++;
|
||||
},
|
||||
}));
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { kind: "host", name: "worker", principal: { worker: true } });
|
||||
const link = new CountingLink(up);
|
||||
const worker = createHost({ self: { kind: "host" }, name: "worker", onError: () => {} });
|
||||
worker.connect(link, { principal: { kind: "server" } });
|
||||
const other = createHost({ self: { kind: "host" }, name: "other", onError: () => {} });
|
||||
const target = worker.hold(RemoteTarget, {});
|
||||
await target.ready;
|
||||
const foreign = await other.root((ctx) => target.hit(ctx).catch((error: unknown) => error));
|
||||
assert.ok(isChordError(foreign, "invalid") && foreign.reason === "foreign_ctx", String(foreign));
|
||||
const forged = { rootId: "forged", codeVersion: 1, signal: new AbortController().signal } as unknown as Ctx;
|
||||
await assert.rejects(
|
||||
target.hit(forged),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "foreign_ctx",
|
||||
);
|
||||
await ticks();
|
||||
assert.equal(hits, 0);
|
||||
assert.equal(link.count("sent", "call"), 0);
|
||||
await worker.root((ctx) => target.hit(ctx));
|
||||
assert.equal(hits, 1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { arr, bool, num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import { type Ctx, createHost, type Facet, isChordError, local, method } from "../../src/old/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
async function until(check: () => boolean): Promise<void> {
|
||||
for (let attempt = 0; attempt < 200 && !check(); attempt++) await tick();
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
function latch<T>() {
|
||||
let open: (value: T) => void = () => {};
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
const Notes = local("t.notes", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: {
|
||||
notes: arr(str()),
|
||||
count: num(),
|
||||
has: method(str(), bool()),
|
||||
add: method(str()),
|
||||
},
|
||||
});
|
||||
|
||||
const NotesSchema = arr(str());
|
||||
|
||||
describe("state init", () => {
|
||||
it("runs after the resources are open and before the object goes live: nothing sees another value", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const loaded = latch<string[]>();
|
||||
const log: string[] = [];
|
||||
host.serve(Notes, (_input, _principal, self) => {
|
||||
const db = self.resource("db", {
|
||||
version: 1,
|
||||
open: () => {
|
||||
log.push("open");
|
||||
return { load: () => loaded.promise };
|
||||
},
|
||||
});
|
||||
const notes = self.state("notes", NotesSchema, {
|
||||
init: async () => {
|
||||
log.push("init");
|
||||
return await db.value.load();
|
||||
},
|
||||
});
|
||||
const count = self.derive([notes], (list) => list.length);
|
||||
self.on("notes", notes, (list) => {
|
||||
log.push(`listener ${list.join(",")}`);
|
||||
});
|
||||
self.task(async () => {
|
||||
log.push(`task ${notes.value.join(",")}`);
|
||||
});
|
||||
return {
|
||||
notes,
|
||||
count,
|
||||
has: (note) => notes.value.includes(note),
|
||||
add: (note, ctx) => {
|
||||
notes.change(ctx, (draft) => {
|
||||
draft.push(note);
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
const ref = host.hold(Notes, {});
|
||||
const seen: unknown[] = [];
|
||||
ref.notes.subscribe((value) => seen.push(value));
|
||||
// A call made while init runs waits until the object is live, then reads the loaded value.
|
||||
const has = host.root((ctx) => ref.has("a", ctx));
|
||||
await until(() => log.includes("init"));
|
||||
assert.equal(ref.status, "loading");
|
||||
loaded.open(["a", "b"]);
|
||||
assert.equal(await has, true);
|
||||
assert.equal(ref.count.value, 2);
|
||||
await until(() => log.length === 4);
|
||||
assert.deepEqual(log, ["open", "init", "listener a,b", "task a,b"]);
|
||||
// Before live, a reference's member has no value (undefined); its first value is the loaded one.
|
||||
assert.deepEqual(
|
||||
seen.filter((value) => value !== undefined),
|
||||
[["a", "b"]],
|
||||
);
|
||||
});
|
||||
|
||||
it("an init cell cannot be read inside the factory", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Notes, (_input, _principal, self) => {
|
||||
const notes = self.state("notes", NotesSchema, { init: () => [] });
|
||||
const count = self.derive([notes], (list) => list.length);
|
||||
notes.value;
|
||||
return { notes, count, has: () => false, add: () => {} };
|
||||
});
|
||||
await assert.rejects(host.hold(Notes, {}).ready, (error) => isChordError(error) && error.code === "not_ready");
|
||||
});
|
||||
|
||||
it("a throwing or invalid init fails the activation and closes its resources; the next get tries again", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
let result: () => unknown = () => {
|
||||
throw new Error("db down");
|
||||
};
|
||||
const closed: string[] = [];
|
||||
host.serve(Notes, (_input, _principal, self) => {
|
||||
self.resource("db", { version: 1, open: () => "db", close: (db) => void closed.push(db) });
|
||||
const notes = self.state("notes", NotesSchema, { init: () => result() as string[] });
|
||||
const count = self.derive([notes], (list) => list.length);
|
||||
return { notes, count, has: () => false, add: () => {} };
|
||||
});
|
||||
await assert.rejects(host.hold(Notes, {}).ready, /db down/);
|
||||
assert.deepEqual(closed, ["db"]);
|
||||
result = () => [1];
|
||||
await assert.rejects(host.hold(Notes, {}).ready, (error) => isChordError(error) && error.code === "invalid");
|
||||
assert.deepEqual(closed, ["db", "db"]);
|
||||
result = () => ["ok"];
|
||||
const ref = host.hold(Notes, {});
|
||||
await ref.ready;
|
||||
assert.deepEqual(ref.notes.value, ["ok"]);
|
||||
});
|
||||
|
||||
it("init runs in a root: it can call other objects", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Source = local("t.source", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { all: method(undefined, NotesSchema) },
|
||||
});
|
||||
host.serve(Source, () => ({ all: () => ["from source"] }));
|
||||
host.serve(Notes, (_input, _principal, self) => {
|
||||
const notes = self.state("notes", NotesSchema, {
|
||||
init: async (ctx: Ctx) => (await ctx.get(Source, {})).all(ctx),
|
||||
});
|
||||
const count = self.derive([notes], (list) => list.length);
|
||||
return { notes, count, has: () => false, add: () => {} };
|
||||
});
|
||||
const ref = host.hold(Notes, {});
|
||||
await ref.ready;
|
||||
assert.deepEqual(ref.notes.value, ["from source"]);
|
||||
});
|
||||
|
||||
describe("reload", () => {
|
||||
const facet = (spec: {
|
||||
readonly inits: string[];
|
||||
readonly extra?: () => string[];
|
||||
readonly countVersion?: number;
|
||||
}): Facet => ({
|
||||
name: "notes",
|
||||
setup: (api) =>
|
||||
api.serve(Notes, (_input, _principal, self) => {
|
||||
const notes = self.state("notes", NotesSchema, {
|
||||
init: () => {
|
||||
spec.inits.push("notes");
|
||||
return ["loaded"];
|
||||
},
|
||||
});
|
||||
const counter = self.state("counter", num(), 0, { version: spec.countVersion ?? 1 });
|
||||
const extra = spec.extra;
|
||||
const more =
|
||||
extra === undefined
|
||||
? undefined
|
||||
: self.state("more", NotesSchema, {
|
||||
init: () => {
|
||||
spec.inits.push("more");
|
||||
return extra();
|
||||
},
|
||||
});
|
||||
const count = self.derive(more === undefined ? [notes, counter] : [notes, counter, more], (...lists) =>
|
||||
lists.reduce<number>((sum, list) => sum + (typeof list === "number" ? list : list.length), 0),
|
||||
);
|
||||
return {
|
||||
notes,
|
||||
count,
|
||||
has: (note) => notes.value.includes(note),
|
||||
add: (note, ctx) => {
|
||||
notes.change(ctx, (draft) => {
|
||||
draft.push(note);
|
||||
});
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
it("keeps an existing cell without running init; a cell the reload adds runs init while it prepares", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const inits: string[] = [];
|
||||
await host.install(facet({ inits }));
|
||||
const ref = host.hold(Notes, {});
|
||||
await ref.ready;
|
||||
await host.root((ctx) => ref.add("written", ctx));
|
||||
await host.reload([facet({ inits, extra: () => ["x", "y"] })]);
|
||||
assert.deepEqual(inits, ["notes", "more"]);
|
||||
assert.deepEqual(ref.notes.value, ["loaded", "written"]);
|
||||
// The new derivation counts the kept cell and the added one: 2 + 0 + 2.
|
||||
assert.equal(ref.count.value, 4);
|
||||
});
|
||||
|
||||
it("a failing init of an added cell refuses the reload; the old code keeps running", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const inits: string[] = [];
|
||||
await host.install(facet({ inits }));
|
||||
const ref = host.hold(Notes, {});
|
||||
await ref.ready;
|
||||
await assert.rejects(
|
||||
host.reload([
|
||||
facet({
|
||||
inits,
|
||||
extra: () => {
|
||||
throw new Error("cannot load more");
|
||||
},
|
||||
}),
|
||||
]),
|
||||
/cannot load more/,
|
||||
);
|
||||
assert.equal(ref.count.value, 1);
|
||||
assert.equal(await host.root((ctx) => ref.has("loaded", ctx)), true);
|
||||
});
|
||||
|
||||
it("in a gated reload, an added cell runs init at the gate", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const inits: string[] = [];
|
||||
await host.install(facet({ inits }));
|
||||
const ref = host.hold(Notes, {});
|
||||
await ref.ready;
|
||||
const report = await host.reload([facet({ inits, countVersion: 2, extra: () => ["x"] })]);
|
||||
assert.equal(report.gated.length, 1);
|
||||
assert.deepEqual(inits, ["notes", "more"]);
|
||||
assert.equal(ref.count.value, 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { arr, num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import { applyImmutable } from "../../src/delta/index.ts";
|
||||
import { type Ctx, createHost, isChordError, local, method, type StateEvent } from "../../src/old/index.ts";
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
function emitter<E>() {
|
||||
const listeners = new Set<(event: E) => void>();
|
||||
return {
|
||||
emit: (event: E) => {
|
||||
for (const fn of listeners) fn(event);
|
||||
},
|
||||
subscribe: (fn: (event: E) => void) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const Item = obj({ id: num(), label: str() }, { open: true });
|
||||
const Items = obj({ items: arr(Item), count: num() }, { open: true });
|
||||
|
||||
const List = local("t.list", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: {
|
||||
list: Items,
|
||||
labels: arr(str()),
|
||||
push: method(Item),
|
||||
set: method(Items),
|
||||
},
|
||||
});
|
||||
|
||||
function serveList(host: ReturnType<typeof createHost>) {
|
||||
host.serve(List, (_input, _principal, self) => {
|
||||
const listState = self.state("list", Items, { items: [], count: 0 });
|
||||
const labels = self.derive([listState], (list) => list.items.map((item) => item.label));
|
||||
return {
|
||||
list: listState,
|
||||
labels,
|
||||
push: (item: { id: number; label: string }, ctx: Ctx) => {
|
||||
listState.change(ctx, (draft) => {
|
||||
draft.items.push(item);
|
||||
draft.count += 1;
|
||||
});
|
||||
},
|
||||
set: (value: { items: { id: number; label: string }[]; count: number }, ctx: Ctx) => {
|
||||
listState.replace(ctx, value);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("state cells", () => {
|
||||
it("publishes snapshots then exact ops; derivations follow", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
serveList(host);
|
||||
const ref = host.hold(List, {});
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
const labels: StateEvent<unknown>[] = [];
|
||||
ref.list.events((event) => events.push(event));
|
||||
ref.labels.events((event) => labels.push(event));
|
||||
await ref.push({ id: 1, label: "a" });
|
||||
// Subscribed while loading: the snapshot once live, then exact ops.
|
||||
assert.deepEqual(events[0], { type: "snap", value: { items: [], count: 0 } });
|
||||
const ops = events[1]?.type === "ops" ? events[1].ops.map((op) => JSON.stringify(op)).sort() : [];
|
||||
assert.deepEqual(ops, ['["p",["items"],0,0,[{"id":1,"label":"a"}]]', '["s",["count"],1]']);
|
||||
assert.deepEqual(events[1]?.value, { items: [{ id: 1, label: "a" }], count: 1 });
|
||||
assert.deepEqual(ref.labels.value, ["a"]);
|
||||
assert.deepEqual(
|
||||
labels.map((event) => event.type),
|
||||
["snap", "ops"],
|
||||
);
|
||||
await ref.set({ items: [], count: 0 });
|
||||
assert.deepEqual(ref.labels.value, []);
|
||||
});
|
||||
|
||||
it("supports primitive cells and value-returning changes", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Counter = local("t.prim", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { n: num(), inc: method(undefined, num()) },
|
||||
});
|
||||
host.serve(Counter, (_input, _principal, self) => {
|
||||
const nState = self.state("n", num(), 0);
|
||||
return {
|
||||
n: nState,
|
||||
inc: (ctx: Ctx) => {
|
||||
nState.change(ctx, (n) => n + 1);
|
||||
return nState.value;
|
||||
},
|
||||
};
|
||||
});
|
||||
const ref = host.hold(Counter, {});
|
||||
const values: unknown[] = [];
|
||||
await ref.ready;
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
ref.n.events((event) => events.push(event));
|
||||
ref.n.subscribe((value) => values.push(value));
|
||||
assert.equal(await ref.inc(), 1);
|
||||
assert.equal(await ref.inc(), 2);
|
||||
assert.deepEqual(values, [0, 1, 2]);
|
||||
assert.deepEqual(events, [
|
||||
{ type: "snap", value: 0 },
|
||||
{ type: "ops", ops: [["r", 1]], value: 1 },
|
||||
{ type: "ops", ops: [["r", 2]], value: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a returned value that does not fit the schema, even outside dev mode", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Box = local("t.box", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { put: method() },
|
||||
});
|
||||
host.serve(Box, (_input, _principal, self) => {
|
||||
const list = self.state("list", arr(num()), []);
|
||||
return {
|
||||
put: (ctx: Ctx) => {
|
||||
// The classic slip: an expression-bodied arrow returns push()'s length.
|
||||
list.change(ctx, (draft) => draft.push(1) as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
await assert.rejects(host.hold(Box, {}).put(), (error) => isChordError(error, "invalid"));
|
||||
});
|
||||
|
||||
it("validates initial values always and writes only in dev mode", async () => {
|
||||
const Bad = local("t.bad-initial", { version: 1, input: obj({}, { open: true }) });
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
host.serve(Bad, (_input, _principal, self) => {
|
||||
self.state("x", obj({ n: num() }, { open: true }), { n: "no" } as never);
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Bad, {}).ready, /initial value of state x/);
|
||||
|
||||
const lax = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
serveList(lax);
|
||||
await lax.hold(List, {}).set({ items: [], count: "x" } as never);
|
||||
|
||||
const dev = createHost({ self: { kind: "host" }, dev: true, onError: () => {} });
|
||||
serveList(dev);
|
||||
const ref = dev.hold(List, {});
|
||||
await assert.rejects(
|
||||
ref.set({ items: [], count: "x" } as never),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "state",
|
||||
);
|
||||
assert.deepEqual(ref.list.value, { items: [], count: 0 });
|
||||
});
|
||||
|
||||
it("rejects writes from inside a factory and placeholder use before activation", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Writer = local("t.writer", { version: 1, input: obj({}, { open: true }) });
|
||||
let captured: Ctx | undefined;
|
||||
await host.root((ctx) => {
|
||||
captured = ctx;
|
||||
});
|
||||
host.serve(Writer, (_input, _principal, self) => {
|
||||
const x = self.state("x", obj({}, { open: true }), {});
|
||||
const r = self.resource("r", { version: 1, open: () => 1 });
|
||||
assert.throws(
|
||||
() => r.value,
|
||||
(error) => isChordError(error, "not_ready"),
|
||||
);
|
||||
x.replace(captured!, {});
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.hold(Writer, {}).ready, /read_only/);
|
||||
});
|
||||
|
||||
it("listeners run each event in a fresh root; tasks get an abortable ctx", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const events = emitter<number>();
|
||||
const Watcher = local("t.watcher", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { seen: obj({ sum: num() }, { open: true }) },
|
||||
});
|
||||
const roots = new Set<string>();
|
||||
let taskAborted = false;
|
||||
host.serve(Watcher, (_input, _principal, self) => {
|
||||
const seen = self.state("seen", obj({ sum: num() }, { open: true }), { sum: 0 });
|
||||
const sourceResource = self.resource("source", { version: 1, open: () => events });
|
||||
self.on(
|
||||
"onEvent",
|
||||
sourceResource,
|
||||
(source) => source,
|
||||
(value, ctx) => {
|
||||
roots.add(ctx.rootId);
|
||||
seen.change(ctx, (draft) => {
|
||||
draft.sum += value;
|
||||
});
|
||||
},
|
||||
);
|
||||
self.task(async (ctx) => {
|
||||
await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve()));
|
||||
taskAborted = true;
|
||||
});
|
||||
return { seen };
|
||||
});
|
||||
const ref = host.hold(Watcher, {});
|
||||
await ref.ready;
|
||||
events.emit(2);
|
||||
events.emit(3);
|
||||
await tick();
|
||||
assert.deepEqual(ref.seen.value, { sum: 5 });
|
||||
assert.equal(roots.size, 2);
|
||||
ref.release();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(events.size, 0);
|
||||
assert.ok(taskAborted);
|
||||
});
|
||||
|
||||
it("re-entrant writes publish in adoption order; derivations never go stale (Sol #3)", async () => {
|
||||
const host = createHost({ self: { kind: "host" }, onError: () => {} });
|
||||
const Echo = local("t.reentrant", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { n: num(), d: num(), set: method(num()) },
|
||||
});
|
||||
host.serve(Echo, (_input, _principal, self) => {
|
||||
const nState = self.state("n", num(), 0);
|
||||
const d = self.derive([nState], (n) => n * 2);
|
||||
// Subscribed first: re-enters the write while revision 1 is being published.
|
||||
self.on("bump", nState, (n, ctx) => {
|
||||
if (n === 1) nState.replace(ctx, 2);
|
||||
});
|
||||
return {
|
||||
n: nState,
|
||||
d,
|
||||
set: (value: number, ctx: Ctx) => {
|
||||
nState.replace(ctx, value);
|
||||
},
|
||||
};
|
||||
});
|
||||
const ref = host.hold(Echo, {});
|
||||
await ref.ready;
|
||||
const values: unknown[] = [];
|
||||
let replica: unknown;
|
||||
ref.n.events((event) => {
|
||||
replica = event.type === "ops" ? applyImmutable(replica, event.ops) : event.value;
|
||||
values.push(replica);
|
||||
});
|
||||
const doubled: unknown[] = [];
|
||||
ref.d.subscribe((value) => doubled.push(value));
|
||||
await ref.set(1);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(values, [0, 1, 2]);
|
||||
assert.equal(ref.d.value, 4);
|
||||
assert.deepEqual(doubled, [0, 2, 4]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,517 +0,0 @@
|
|||
// example:imports
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { arr, bool, int, num, obj, type Static, str } from "@earendil-works/pi-schema";
|
||||
import { it, onTestFinished } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
defineFacet,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
type FacetApi,
|
||||
type Factory,
|
||||
forbidden,
|
||||
type Host,
|
||||
hostNamed,
|
||||
invalid,
|
||||
isChordError,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
opaque,
|
||||
remote,
|
||||
type StateOptions,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
// end-example:imports
|
||||
|
||||
// Compare every printed example with its runnable source, not a second handwritten copy.
|
||||
const tutorial = readFileSync(new URL("../../docs/old/tutorial.md", import.meta.url), "utf8");
|
||||
const source = readFileSync(new URL(import.meta.url), "utf8");
|
||||
const examples = [...source.matchAll(/^[\t ]*\/\/ example:([\w-]+)\n([\s\S]*?)^[\t ]*\/\/ end-example:\1$/gm)];
|
||||
const printed = [...tutorial.matchAll(/<!-- example:([\w-]+) -->\n```ts\n([\s\S]*?)\n```/g)];
|
||||
function normalize(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
function checkExamples(section: string): void {
|
||||
assert.equal(printed.length, examples.length, "every tutorial example must have runnable source");
|
||||
assert.equal((tutorial.match(/```ts\n/g) ?? []).length, printed.length, "no untracked TypeScript examples");
|
||||
for (const match of examples.filter((entry) => entry[1] === "imports" || entry[1]?.startsWith(section))) {
|
||||
const copies = printed.filter((entry) => entry[1] === match[1]);
|
||||
assert.equal(copies.length, 1, `one printed copy of ${match[1]}`);
|
||||
assert.equal(normalize(copies[0]![2]!), normalize(match[2]!), `tutorial drift: ${match[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// example:02-contract
|
||||
const TodoLabel = local("tutorial.todos", {
|
||||
version: 1,
|
||||
input: obj({ list: str() }, { open: true }),
|
||||
members: { label: method(undefined, str()) },
|
||||
});
|
||||
// end-example:02-contract
|
||||
|
||||
// example:03-contract
|
||||
const Todo = obj({ text: str(), done: bool() }, { open: true });
|
||||
const Rows = arr(Todo);
|
||||
const TodoStore = local("tutorial.todos", {
|
||||
version: 1,
|
||||
input: obj({ list: str() }, { open: true }),
|
||||
members: {
|
||||
label: method(undefined, str()),
|
||||
rows: Rows,
|
||||
openCount: num(),
|
||||
add: method(obj({ text: str() }, { open: true })),
|
||||
finish: method(obj({ index: int() }, { open: true })),
|
||||
reset: method(Rows),
|
||||
},
|
||||
});
|
||||
// end-example:03-contract
|
||||
|
||||
// example:03-factory
|
||||
/** The store's factory; `options` versions its rows (section 9). */
|
||||
const todoStore =
|
||||
(options?: StateOptions<Static<typeof Rows>>): Factory<typeof TodoStore> =>
|
||||
({ list }, _principal, self) => {
|
||||
const rows = self.state("rows", Rows, [], options);
|
||||
const openCount = self.derive([rows], (todos) => todos.filter((todo) => !todo.done).length);
|
||||
return {
|
||||
label: () => `Todos for ${list}`,
|
||||
rows,
|
||||
openCount,
|
||||
add: ({ text }, ctx) => {
|
||||
rows.change(ctx, (draft) => {
|
||||
draft.push({ text, done: false });
|
||||
});
|
||||
},
|
||||
finish: ({ index }, ctx) => {
|
||||
rows.change(ctx, (draft) => {
|
||||
const todo = draft[index];
|
||||
if (!todo) throw invalid("args", "No such todo");
|
||||
todo.done = true;
|
||||
});
|
||||
},
|
||||
reset: (next, ctx) => {
|
||||
rows.replace(ctx, next, { diff: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
const todoFactory = todoStore();
|
||||
// end-example:03-factory
|
||||
|
||||
// example:05-views
|
||||
const Todos = remote("tutorial.todo-view", {
|
||||
version: 1,
|
||||
input: obj({ list: str() }, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
viewer: { state: { rows: Rows } },
|
||||
editor: {
|
||||
state: { rows: Rows },
|
||||
methods: { add: method(obj({ text: str() }, { open: true })) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Who may ask: the owner, a guest, or the server's own code. */
|
||||
type Principal = "owner" | "guest" | "server";
|
||||
|
||||
function serveViews(api: Pick<Host<Principal>, "serve">): void {
|
||||
api.serve(Todos, ({ list }, principal, self) => {
|
||||
if (list !== "family") throw forbidden("This list is not shared");
|
||||
const store = self.get(TodoStore, { list });
|
||||
if (principal === "guest") {
|
||||
return { variant: "viewer", rows: store.rows };
|
||||
}
|
||||
return {
|
||||
variant: "editor",
|
||||
rows: store.rows,
|
||||
add: ({ text }, ctx) => {
|
||||
if (text.trim().length === 0) throw invalid("args", "A todo needs text");
|
||||
return store.add({ text }, ctx);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
// end-example:05-views
|
||||
|
||||
it("01: objects live inside hosts", { timeout: 5000 }, async () => {
|
||||
checkExamples("01");
|
||||
// example:01
|
||||
const host = createHost({ self: "server", name: "todo-server" });
|
||||
assert.equal(host.name, "todo-server");
|
||||
await host.close();
|
||||
// end-example:01
|
||||
});
|
||||
|
||||
it("02: first contract, factory, reference and root", { timeout: 5000 }, async () => {
|
||||
checkExamples("02");
|
||||
onTestFinished(() => host.close());
|
||||
// example:02-call
|
||||
const host = createHost({ self: "server" });
|
||||
host.serve(TodoLabel, ({ list }) => ({ label: () => `Todos for ${list}` }));
|
||||
const result = await host.root(async (ctx) => {
|
||||
const todos = await ctx.get(TodoLabel, { list: "family" });
|
||||
return todos.label(ctx);
|
||||
});
|
||||
assert.equal(result, "Todos for family");
|
||||
// end-example:02-call
|
||||
});
|
||||
|
||||
it("03: cells publish values and derivations", { timeout: 5000 }, async () => {
|
||||
checkExamples("03");
|
||||
onTestFinished(() => host.close());
|
||||
// example:03-call
|
||||
const host = createHost({ self: "server", dev: true });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
const todos = await host.get(TodoStore, { list: "family" });
|
||||
const counts: (number | undefined)[] = [];
|
||||
const stop = todos.openCount.subscribe((value) => counts.push(value));
|
||||
await host.root(async (ctx) => {
|
||||
await todos.add({ text: "Buy milk" }, ctx);
|
||||
await todos.finish({ index: 0 }, ctx);
|
||||
await todos.reset([], ctx);
|
||||
});
|
||||
assert.deepEqual(counts, [0, 1, 0]);
|
||||
assert.deepEqual(todos.rows.value, []);
|
||||
stop();
|
||||
todos.release();
|
||||
// end-example:03-call
|
||||
});
|
||||
|
||||
it("04: holds, asynchronous resources and release", { timeout: 5000 }, async () => {
|
||||
checkExamples("04");
|
||||
onTestFinished(() => host.close());
|
||||
// example:04-database
|
||||
const Database = local("tutorial.database", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { db: opaque<{ saved: string[] }>("tutorial.database@1") },
|
||||
});
|
||||
const SavedTodos = local("tutorial.saved-todos", {
|
||||
version: 1,
|
||||
input: obj({ list: str() }, { open: true }),
|
||||
members: { add: method(obj({ text: str() }, { open: true })) },
|
||||
});
|
||||
const host = createHost({ self: "server" });
|
||||
let opens = 0;
|
||||
const closed = Promise.withResolvers<void>();
|
||||
host.serve(Database, (_input, _principal, self) => {
|
||||
const db = self.resource("db", {
|
||||
version: 1,
|
||||
open: async () => {
|
||||
opens++;
|
||||
return { saved: [] as string[] };
|
||||
},
|
||||
close: () => closed.resolve(),
|
||||
});
|
||||
return { db: self.expose(db) };
|
||||
});
|
||||
// end-example:04-database
|
||||
// example:04-saving
|
||||
host.serve(TodoStore, todoFactory);
|
||||
host.serve(
|
||||
SavedTodos,
|
||||
({ list }, _principal, self) => {
|
||||
const database = self.get(Database, {});
|
||||
const todos = self.get(TodoStore, { list });
|
||||
return {
|
||||
add: async ({ text }, ctx) => {
|
||||
database.db!.saved.push(text);
|
||||
await todos.add({ text }, ctx);
|
||||
},
|
||||
};
|
||||
},
|
||||
{ linger: 0 },
|
||||
);
|
||||
// end-example:04-saving
|
||||
// example:04-call
|
||||
const saved = await host.get(SavedTodos, { list: "family" });
|
||||
const database = await host.get(Database, {});
|
||||
await host.root((ctx) => saved.add({ text: "Buy milk" }, ctx));
|
||||
assert.deepEqual(database.db!.saved, ["Buy milk"]);
|
||||
assert.equal(opens, 1);
|
||||
const statuses: string[] = [];
|
||||
saved.onStatus((status) => statuses.push(status));
|
||||
database.release();
|
||||
saved.release();
|
||||
await closed.promise;
|
||||
assert.equal(saved.status, "released");
|
||||
assert.ok(statuses.includes("released"));
|
||||
// end-example:04-call
|
||||
});
|
||||
|
||||
it("05: principals select views of one shared store", { timeout: 5000 }, async () => {
|
||||
checkExamples("05");
|
||||
onTestFinished(() => host.close());
|
||||
// example:05-call
|
||||
const host = createHost<Principal>({ name: "server", self: "server", principalKey: (principal) => principal });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
serveViews(host);
|
||||
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
|
||||
const guest = await host.get(Todos, { list: "family" }, { principal: "guest" });
|
||||
assert.ok(owner.variant === "editor"); // narrows the reference to the editor variant
|
||||
assert.equal(guest.variant, "viewer");
|
||||
await host.root((ctx) => owner.add({ text: "Buy milk" }, ctx), { principal: "owner" });
|
||||
assert.deepEqual(guest.rows.value, [{ text: "Buy milk", done: false }]);
|
||||
// @ts-expect-error A viewer has no add: TypeScript refuses it, and so does the provider.
|
||||
await assert.rejects(guest.add({ text: "Not allowed" }), (error) => isChordError(error, "not_in_variant"));
|
||||
await assert.rejects(owner.add({ text: " " }), (error) => isChordError(error, "invalid"));
|
||||
// end-example:05-call
|
||||
});
|
||||
|
||||
it("06: remote methods and live state over a memory link", { timeout: 5000 }, async () => {
|
||||
checkExamples("06");
|
||||
onTestFinished(() => server.close());
|
||||
onTestFinished(() => browser.close());
|
||||
// example:06
|
||||
const server = createHost<Principal>({ self: "server", name: "server" });
|
||||
server.serve(TodoStore, todoFactory);
|
||||
serveViews(server);
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: "owner", kind: "client" });
|
||||
const browser = createHost({
|
||||
self: "browser",
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: "server" },
|
||||
});
|
||||
const todos = await browser.get(Todos, { list: "family" });
|
||||
assert.ok(todos.variant === "editor");
|
||||
assert.deepEqual(todos.rows.value, []);
|
||||
const updated = Promise.withResolvers<void>();
|
||||
const stop = todos.rows.subscribe((rows) => {
|
||||
if (rows.length === 1) updated.resolve();
|
||||
});
|
||||
await browser.root((ctx) => todos.add({ text: "Buy milk" }, ctx));
|
||||
await updated.promise;
|
||||
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
|
||||
// Deliberately bypass TypeScript to simulate a malformed caller.
|
||||
await assert.rejects(todos.add({ text: 123 } as never), (error) => isChordError(error, "invalid"));
|
||||
stop();
|
||||
// A held reference keeps its state without listeners.
|
||||
assert.deepEqual(todos.rows.value, [{ text: "Buy milk", done: false }]);
|
||||
todos.release();
|
||||
// end-example:06
|
||||
});
|
||||
|
||||
it("07: listeners react and tasks start fresh calls", { timeout: 5000 }, async () => {
|
||||
checkExamples("07");
|
||||
onTestFinished(() => host.close());
|
||||
// example:07
|
||||
const Activity = local("tutorial.activity", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
members: { seen: num() },
|
||||
});
|
||||
const host = createHost({ self: "server" });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
const observed = Promise.withResolvers<void>();
|
||||
const seeded = Promise.withResolvers<void>();
|
||||
const stopped = Promise.withResolvers<void>();
|
||||
host.serve(Activity, (_input, _principal, self) => {
|
||||
const todos = self.get(TodoStore, { list: "family" });
|
||||
const seen = self.state("seen", num(), 0);
|
||||
self.on("rows", todos.rows, (rows, ctx) => {
|
||||
seen.replace(ctx, rows?.length ?? 0);
|
||||
if (rows?.length === 1) observed.resolve();
|
||||
});
|
||||
self.task(async (ctx) => {
|
||||
await todos.add({ text: "Review the list" }, ctx);
|
||||
seeded.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (ctx.signal.aborted) resolve();
|
||||
else ctx.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
stopped.resolve();
|
||||
});
|
||||
return { seen };
|
||||
});
|
||||
const activity = await host.get(Activity, {});
|
||||
await Promise.all([seeded.promise, observed.promise]);
|
||||
assert.equal(activity.seen.value, 1);
|
||||
activity.release();
|
||||
await stopped.promise;
|
||||
// end-example:07
|
||||
});
|
||||
|
||||
it("08: plugins contribute registry entries and expose callbacks", { timeout: 5000 }, async () => {
|
||||
checkExamples("08");
|
||||
onTestFinished(() => host.close());
|
||||
// example:08
|
||||
const Actions = defineRegistry<(ctx: Ctx) => Promise<number>>("tutorial.actions");
|
||||
const Menu = local("tutorial.menu", { version: 1, input: obj({}, { open: true }) });
|
||||
let click: (() => Promise<number>) | undefined;
|
||||
const storeFacet: Facet = {
|
||||
name: "todo-store",
|
||||
...defineFacet((api) => api.serve(TodoStore, todoFactory)),
|
||||
};
|
||||
const menuFacet: Facet = {
|
||||
name: "todo-menu",
|
||||
...defineFacet(
|
||||
(api) => {
|
||||
api.serve(
|
||||
Menu,
|
||||
(_input, _principal, self) => {
|
||||
const todos = self.get(TodoStore, { list: "family" });
|
||||
self.register(Actions, {
|
||||
addSample: async (ctx) => {
|
||||
await todos.add({ text: "Try plugins" }, ctx);
|
||||
return todos.openCount.value;
|
||||
},
|
||||
});
|
||||
return {};
|
||||
},
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
click = () => api.root((ctx) => api.registry(Actions).get("addSample", ctx)!(ctx));
|
||||
},
|
||||
{ uses: [TodoStore] },
|
||||
),
|
||||
};
|
||||
const host = createHost({ self: "server" });
|
||||
await host.load([menuFacet, storeFacet], { awaitHostObjects: false });
|
||||
await host.ready();
|
||||
assert.equal(await click!(), 1);
|
||||
assert.equal(host.registry(Actions).table().size, 1);
|
||||
// end-example:08
|
||||
});
|
||||
|
||||
it("09: compatible reload, migration and an old-contract adapter", { timeout: 5000 }, async () => {
|
||||
checkExamples("09");
|
||||
onTestFinished(() => host.close());
|
||||
// example:09-compatible
|
||||
const host = createHost({ self: "server" });
|
||||
const facet = (label: string): Facet => ({
|
||||
name: "todos",
|
||||
setup: (api) =>
|
||||
api.serve(TodoStore, (input, principal, self) => ({
|
||||
...todoFactory(input, principal, self),
|
||||
label: () => label,
|
||||
})),
|
||||
});
|
||||
await host.load([facet("Original")]);
|
||||
const todos = await host.get(TodoStore, { list: "family" });
|
||||
await todos.add({ text: "Buy milk" });
|
||||
const entered = Promise.withResolvers<void>();
|
||||
const resume = Promise.withResolvers<void>();
|
||||
const oldRoot = host.root(async (ctx) => {
|
||||
const first = await todos.label(ctx);
|
||||
entered.resolve();
|
||||
await resume.promise; // Test-only pause: pin an old caller during reload.
|
||||
return [first, await todos.label(ctx)];
|
||||
});
|
||||
await entered.promise;
|
||||
const compatible = await host.reload([facet("Updated")]);
|
||||
assert.deepEqual(compatible.gated, []);
|
||||
assert.equal(await todos.label(), "Updated");
|
||||
resume.resolve();
|
||||
assert.deepEqual(await oldRoot, ["Original", "Original"]);
|
||||
assert.equal(todos.rows.value?.[0]?.text, "Buy milk");
|
||||
// end-example:09-compatible
|
||||
// example:09-migrate
|
||||
const events: string[] = [];
|
||||
const stopEvents = todos.rows.events((event) => events.push(event.type));
|
||||
const migrated = await host.reload([
|
||||
{
|
||||
name: "todos",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
TodoStore,
|
||||
todoStore({
|
||||
version: 2,
|
||||
migrate: (old, from) => {
|
||||
assert.equal(from, 1);
|
||||
return (old as Static<typeof Rows>).map((todo) => ({ ...todo, text: todo.text.toUpperCase() }));
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
]);
|
||||
assert.equal(migrated.gated.length, 1);
|
||||
assert.deepEqual(todos.rows.value, [{ text: "BUY MILK", done: false }]);
|
||||
assert.deepEqual(events, ["snap", "reset"]);
|
||||
stopEvents();
|
||||
// end-example:09-migrate
|
||||
// example:09-contract
|
||||
// Remove label from the new provider's contract: it is now called heading.
|
||||
const { label: _label, ...members } = TodoStore.def.members;
|
||||
const RenamedStore = local("tutorial.todos", {
|
||||
version: 2,
|
||||
input: TodoStore.def.input,
|
||||
members: { ...members, heading: method(undefined, str()) },
|
||||
});
|
||||
await host.reload([
|
||||
{
|
||||
name: "todos",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
RenamedStore,
|
||||
(input, principal, self) => {
|
||||
const { label, ...implementation } = todoStore({ version: 2 })(input, principal, self);
|
||||
return { ...implementation, heading: label };
|
||||
},
|
||||
{
|
||||
accepts: {
|
||||
1: (current) => ({ ...current, label: current.heading }),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
]);
|
||||
assert.equal(await todos.label(), "Todos for family");
|
||||
const newer = await host.get(RenamedStore, { list: "family" });
|
||||
assert.equal(await newer.heading(), "Todos for family");
|
||||
assert.equal(todos.rows.value?.[0]?.text, "BUY MILK");
|
||||
// end-example:09-contract
|
||||
});
|
||||
|
||||
it("10: stub a provider with a fake facet and check error codes", { timeout: 5000 }, async () => {
|
||||
checkExamples("10");
|
||||
onTestFinished(() => host.close());
|
||||
// example:10
|
||||
const host = createHost<Principal>({ name: "server", self: "server", dev: true });
|
||||
const received: string[] = [];
|
||||
const fake: Facet = {
|
||||
name: "fake-store",
|
||||
...defineFacet((api: FacetApi) => {
|
||||
api.serve(TodoStore, (input, principal, self) => ({
|
||||
...todoFactory(input, principal, self),
|
||||
add: ({ text }) => {
|
||||
received.push(text);
|
||||
},
|
||||
}));
|
||||
}),
|
||||
};
|
||||
await host.load([fake]);
|
||||
serveViews(host);
|
||||
const owner = await host.get(Todos, { list: "family" }, { principal: "owner" });
|
||||
assert.ok(owner.variant === "editor");
|
||||
await owner.add({ text: "Test delegation" });
|
||||
assert.deepEqual(received, ["Test delegation"]);
|
||||
await assert.rejects(owner.add({ text: "" }), (error) => isChordError(error, "invalid"));
|
||||
owner.release();
|
||||
await assert.rejects(owner.add({ text: "Too late" }), (error) => isChordError(error, "released"));
|
||||
// end-example:10
|
||||
});
|
||||
|
||||
it("11: short roots release temporary holds", { timeout: 5000 }, async () => {
|
||||
checkExamples("11");
|
||||
onTestFinished(() => host.close());
|
||||
// example:11
|
||||
const host = createHost({ self: "server" });
|
||||
host.serve(TodoStore, todoFactory);
|
||||
const screen = await host.get(TodoStore, { list: "family" });
|
||||
await host.root(async (ctx) => {
|
||||
const temporary = await ctx.get(TodoStore, { list: "family" });
|
||||
await temporary.add({ text: "Keep roots short" }, ctx);
|
||||
}); // The temporary reference is released here; screen still holds the store.
|
||||
assert.equal(screen.openCount.value, 1);
|
||||
screen.release(); // No reference remains; without persistence, disposal loses these rows.
|
||||
// end-example:11
|
||||
});
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
// Entry 8: reference types. State members are plain values (a reference you hold has its state); a remote reference is a
|
||||
// union discriminated by `variant`, so another variant's members are reachable only after narrowing. Checked by tsc.
|
||||
import { num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
import { type Ctx, type Host, hostNamed, local, method, type Readable, type Ref, remote } from "../../src/old/index.ts";
|
||||
|
||||
const Doc = remote("t.e8-types-doc", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
reader: { state: { text: str() } },
|
||||
editor: { state: { text: str() }, methods: { edit: method(str()) } },
|
||||
},
|
||||
});
|
||||
const Single = remote("t.e8-types-single", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: { main: { state: { n: num() }, methods: { bump: method() } } },
|
||||
});
|
||||
const Local = local("t.e8-types-local", { version: 1, input: obj({}, { open: true }), members: { n: num() } });
|
||||
|
||||
describe("reference types", () => {
|
||||
it("state members are T; other variants' members need narrowing; gets are awaited", () => {
|
||||
const check = async (host: Host, ctx: Ctx) => {
|
||||
const doc = await host.get(Doc, {});
|
||||
expectTypeOf(doc.text).toEqualTypeOf<Readable<string>>();
|
||||
// @ts-expect-error `edit` exists only in the editor variant.
|
||||
void doc.edit;
|
||||
if (doc.variant === "editor") expectTypeOf(doc.edit).toBeFunction();
|
||||
const single = await ctx.get(Single, {});
|
||||
expectTypeOf(single.n.value).toEqualTypeOf<number>();
|
||||
expectTypeOf(single.bump).toBeFunction();
|
||||
const held = host.hold(Local, {});
|
||||
expectTypeOf(held.n).toEqualTypeOf<Readable<number>>();
|
||||
expectTypeOf(host.get(Single, {})).toEqualTypeOf<Promise<Ref<typeof Single>>>();
|
||||
};
|
||||
expectTypeOf(check).toBeFunction();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
// Entry 8: a binding applies a shape only once its new state members have snapshots; members outside the variant
|
||||
// throw `not_in_variant`; a re-hold after a link loss keeps the last values readable and sends one fresh unit.
|
||||
import assert from "node:assert/strict";
|
||||
import { num, obj, str } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
type Ctx,
|
||||
createHost,
|
||||
type Facet,
|
||||
hostNamed,
|
||||
isChordError,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
type Readable,
|
||||
remote,
|
||||
} from "../../src/old/index.ts";
|
||||
import { CountingLink, stateFrames } from "./fixtures/counting.ts";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function until(check: () => boolean, attempts = 200): Promise<void> {
|
||||
for (let i = 0; i < attempts && !check(); i++) await sleep(2);
|
||||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
const Doc = remote("t.e8-doc", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
a: { state: { x: num() }, methods: { bump: method() } },
|
||||
b: { state: { x: num(), y: str() }, methods: { bump: method() } },
|
||||
c: { state: { y: str() }, methods: { bump: method() } },
|
||||
},
|
||||
});
|
||||
|
||||
/** The server's facet: `Doc` in variant `variant` (the cells survive reloads by name). */
|
||||
function docFacet(variant: "a" | "b" | "c"): Facet {
|
||||
return {
|
||||
name: "doc",
|
||||
setup: (api) =>
|
||||
api.serve(Doc, (_input, _principal, self) => {
|
||||
const x = self.state("x", num(), 1);
|
||||
const y = self.state("y", str(), "why");
|
||||
const bump = (ctx: Ctx) => {
|
||||
x.replace(ctx, x.value + 1);
|
||||
};
|
||||
if (variant === "a") return { variant, x, bump };
|
||||
if (variant === "b") return { variant, x, y, bump };
|
||||
return { variant, y, bump };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A reference whose variant changes under the test: its members, typed loosely (TypeScript narrows only once). */
|
||||
type View = { readonly variant: string | undefined; readonly x: Readable<number>; readonly y: Readable<string> };
|
||||
const view = (ref: object): View => ref as unknown as View;
|
||||
|
||||
function setup() {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
const link = new CountingLink(up);
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
return { server, browser, link };
|
||||
}
|
||||
|
||||
describe("variants", () => {
|
||||
it("a new variant's members arrive with its shape; a member outside the variant throws not_in_variant", async () => {
|
||||
const { server, browser, link } = setup();
|
||||
await server.install(docFacet("a"));
|
||||
const doc = browser.hold(Doc, {});
|
||||
await doc.ready;
|
||||
assert.equal(view(doc).variant, "a");
|
||||
assert.equal(view(doc).x.value, 1);
|
||||
assert.throws(
|
||||
() => view(doc).y.value,
|
||||
(error) => isChordError(error, "not_in_variant"),
|
||||
);
|
||||
// Every shape the reference shows is readable in full, at the moment it is shown.
|
||||
const failures: unknown[] = [];
|
||||
doc.onShape((shape) => {
|
||||
for (const name of ["x", "y"]) {
|
||||
const member = Doc.description.variants[shape.variant ?? ""]?.[name];
|
||||
if (member?.kind !== "state") continue;
|
||||
try {
|
||||
void (view(doc) as unknown as Record<string, Readable<unknown>>)[name]!.value;
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
const before = stateFrames(link.received).length;
|
||||
await server.reload([docFacet("b")]);
|
||||
await until(() => view(doc).variant === "b");
|
||||
assert.equal(view(doc).y.value, "why");
|
||||
assert.equal(view(doc).x.value, 1);
|
||||
const unit = stateFrames(link.received).slice(before);
|
||||
assert.deepEqual(
|
||||
unit.filter((frame) => frame.as === "snap").map((frame) => frame.m),
|
||||
["y"],
|
||||
"only the new member's snapshot",
|
||||
);
|
||||
await server.reload([docFacet("c")]);
|
||||
await until(() => view(doc).variant === "c");
|
||||
assert.throws(
|
||||
() => view(doc).x.value,
|
||||
(error) => isChordError(error, "not_in_variant"),
|
||||
);
|
||||
assert.equal(view(doc).y.value, "why");
|
||||
assert.deepEqual(failures, []);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("re-hold after a link loss", () => {
|
||||
it("keeps the last values readable, sends one fresh unit, and a reference joining meanwhile waits for it", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
await server.install(docFacet("b"));
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: up, principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const doc = browser.hold(Doc, {});
|
||||
await doc.ready;
|
||||
await doc.bump();
|
||||
await until(() => view(doc).x.value === 2);
|
||||
up.close();
|
||||
await until(() => doc.status === "loading");
|
||||
// The last values stay readable while the binding re-holds.
|
||||
assert.equal(view(doc).x.value, 2);
|
||||
const joining = browser.hold(Doc, {});
|
||||
assert.equal(joining.status, "loading");
|
||||
let joined = false;
|
||||
void joining.ready.then(() => {
|
||||
joined = true;
|
||||
});
|
||||
await sleep(5);
|
||||
assert.equal(joined, false, "a reference joining during the re-hold waits for the fresh unit");
|
||||
|
||||
const [down2, up2] = memoryLinkPair();
|
||||
server.accept(down2, { principal: { user: "u" } });
|
||||
const link = new CountingLink(up2);
|
||||
browser.connect(link, { principal: { kind: "server" } });
|
||||
await joining.ready;
|
||||
assert.equal(doc.status, "live");
|
||||
const hold = link.sent.find((frame) => frame.t === "hold");
|
||||
assert.ok(hold?.t === "hold" && hold.again === true, "a re-hold says again");
|
||||
assert.equal(link.count("sent", "hold"), 1, "one hold for both references");
|
||||
const snaps = stateFrames(link.received).filter((frame) => frame.as === "snap");
|
||||
assert.deepEqual(snaps.map((frame) => frame.m).sort(), ["x", "y"], "one snapshot per member");
|
||||
// The old hold was released with the link: the server made a fresh object (its cells start over).
|
||||
assert.equal(view(doc).x.value, 1);
|
||||
await browser.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { arr, obj, str, unknown } from "@earendil-works/pi-schema";
|
||||
import { describe, it } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
import {
|
||||
acceptWebSockets,
|
||||
createHost,
|
||||
hostNamed,
|
||||
inflateMessage,
|
||||
isChordError,
|
||||
method,
|
||||
remote,
|
||||
type WebSocketLike,
|
||||
webSocketLink,
|
||||
} from "../../src/old/index.ts";
|
||||
|
||||
const Board = remote("t.board", {
|
||||
version: 1,
|
||||
input: obj({}, { open: true }),
|
||||
host: hostNamed("server"),
|
||||
variants: {
|
||||
main: {
|
||||
state: { notes: arr(str()) },
|
||||
methods: { add: method(str()), who: method(undefined, unknown()) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("WebSocket links", () => {
|
||||
it("serves a browser-style client over a real socket, stamped by the authenticator", async () => {
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Board, (_input, principal, self) => {
|
||||
const notes = self.state("notes", arr(str()), []);
|
||||
return {
|
||||
variant: "main",
|
||||
notes,
|
||||
add: (text, ctx) => {
|
||||
notes.change(ctx, (draft) => {
|
||||
draft.push(text);
|
||||
});
|
||||
},
|
||||
who: () => principal,
|
||||
};
|
||||
});
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
acceptWebSockets(wss as never, server, {
|
||||
principal: (request: IncomingMessage) => {
|
||||
const token = new URL(request.url ?? "/", "http://x").searchParams.get("token");
|
||||
return token === "ok" ? { guest: "web" } : undefined;
|
||||
},
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
const open = (token: string) =>
|
||||
createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: {
|
||||
link: webSocketLink(new WebSocket(`ws://127.0.0.1:${port}/?token=${token}`) as unknown as WebSocketLike),
|
||||
principal: { kind: "server" },
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
try {
|
||||
const browser = open("ok");
|
||||
const board = browser.hold(Board, {});
|
||||
const seen: unknown[] = [];
|
||||
board.notes.subscribe((value) => seen.push(value));
|
||||
await board.add("hello");
|
||||
assert.deepEqual(await board.who(), { guest: "web" });
|
||||
for (let i = 0; i < 50 && board.notes.value?.length !== 1; i++) await new Promise((r) => setTimeout(r, 5));
|
||||
assert.deepEqual(board.notes.value, ["hello"]);
|
||||
assert.deepEqual(seen.at(-1), ["hello"]);
|
||||
await browser.close();
|
||||
|
||||
// WebSocket peers are always clients: one announcing itself as a host is cut off.
|
||||
const impostor = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "session:S1",
|
||||
upstream: {
|
||||
link: webSocketLink(new WebSocket(`ws://127.0.0.1:${port}/?token=ok`) as unknown as WebSocketLike),
|
||||
principal: { kind: "server" },
|
||||
},
|
||||
onError: () => {},
|
||||
});
|
||||
await assert.rejects(
|
||||
impostor.root((ctx) => impostor.hold(Board, {}).add("x", ctx), {
|
||||
signal: AbortSignal.timeout(150),
|
||||
}),
|
||||
(error) => isChordError(error, "aborted"),
|
||||
);
|
||||
await impostor.close();
|
||||
|
||||
const refused = open("bad");
|
||||
const denied = refused.hold(Board, {});
|
||||
// The server closes unauthenticated sockets; calls wait for a reconnect until their ctx gives up.
|
||||
await assert.rejects(
|
||||
refused.root((ctx) => denied.add("x", ctx), { signal: AbortSignal.timeout(100) }),
|
||||
(error) => isChordError(error, "aborted"),
|
||||
);
|
||||
await refused.close();
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket links: packed payloads", () => {
|
||||
it("large snapshots and results reach the client as binary messages; clients may not send them", async () => {
|
||||
const notes = Array.from({ length: 40 }, (_, i) => `${i}:${"x".repeat(4000)}`);
|
||||
const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} });
|
||||
server.serve(Board, (_input, _principal, self) => {
|
||||
const state = self.state("notes", arr(str()), notes);
|
||||
return { variant: "main", notes: state, add: () => {}, who: () => notes };
|
||||
});
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
acceptWebSockets(wss as never, server, { principal: () => ({ guest: "web" }) });
|
||||
const url = `ws://127.0.0.1:${(wss.address() as { port: number }).port}/`;
|
||||
try {
|
||||
const binary: Uint8Array[] = [];
|
||||
const socket = new WebSocket(url);
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (typeof event.data !== "string") binary.push(new Uint8Array(event.data as ArrayBuffer));
|
||||
});
|
||||
const browser = createHost({
|
||||
self: { kind: "host" },
|
||||
name: "browser",
|
||||
kind: "client",
|
||||
upstream: { link: webSocketLink(socket as unknown as WebSocketLike), principal: { kind: "server" } },
|
||||
onError: () => {},
|
||||
});
|
||||
const board = browser.hold(Board, {});
|
||||
assert.deepEqual(await board.who(), notes);
|
||||
board.notes.subscribe(() => {});
|
||||
for (let i = 0; i < 100 && board.notes.value === undefined; i++) await new Promise((r) => setTimeout(r, 5));
|
||||
assert.deepEqual(board.notes.value, notes);
|
||||
assert(binary.length >= 2, "the result and the snapshot came as binary messages");
|
||||
// A text-only transport (the TUI launcher) turns a packed message back into its text frame.
|
||||
const frames = binary.map((message) => JSON.parse(inflateMessage(message)) as Record<string, unknown>);
|
||||
assert(frames.some((frame) => frame.t === "result" && JSON.stringify(frame.value) === JSON.stringify(notes)));
|
||||
assert(frames.some((frame) => frame.t === "state" && JSON.stringify(frame.value) === JSON.stringify(notes)));
|
||||
await browser.close();
|
||||
|
||||
// A client's binary message is a protocol error: the server closes the socket.
|
||||
const raw = new WebSocket(url);
|
||||
raw.binaryType = "arraybuffer";
|
||||
await new Promise<void>((resolve) => raw.addEventListener("open", () => resolve()));
|
||||
const closed = new Promise<void>((resolve) => raw.addEventListener("close", () => resolve()));
|
||||
raw.send(new Uint8Array([0, 0, 0, 2, 123, 125]));
|
||||
await closed;
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -67,12 +67,6 @@
|
|||
"@earendil-works/chord/tui/launcher": [
|
||||
"../../../packages/chord/src/node/tui/index.ts"
|
||||
],
|
||||
"@earendil-works/chord/old": [
|
||||
"../../../packages/chord/src/old/index.ts"
|
||||
],
|
||||
"@earendil-works/chord/old/node": [
|
||||
"../../../packages/chord/src/old/node.ts"
|
||||
],
|
||||
"@test/contracts/*": [
|
||||
"../../chord/test/node/fixtures/plugins/contracts/*.ts"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { createHost, memoryLinkPair } from "@earendil-works/chord";
|
||||
import { createHost as createOldHost, replicatedState } from "@earendil-works/chord/old";
|
||||
import { createAssistantMessageEventStream, validateToolArguments } from "@earendil-works/pi-ai";
|
||||
import { complete, getModel, getProviders, streamSimple } from "@earendil-works/pi-ai/compat";
|
||||
import { Agent, streamProxy } from "@earendil-works/pi-agent-core";
|
||||
|
|
@ -28,7 +27,5 @@ console.log(
|
|||
agent.hasQueuedMessages(),
|
||||
typeof streamProxy,
|
||||
typeof createHost,
|
||||
typeof createOldHost,
|
||||
typeof memoryLinkPair,
|
||||
replicatedState({ browser: true }).value.browser,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@
|
|||
"@earendil-works/chord/react": ["./packages/chord/src/react/index.ts"],
|
||||
"@earendil-works/chord/tui": ["./packages/chord/src/tui/index.ts"],
|
||||
"@earendil-works/chord/tui/launcher": ["./packages/chord/src/node/tui/index.ts"],
|
||||
"@earendil-works/chord/old": ["./packages/chord/src/old/index.ts"],
|
||||
"@earendil-works/chord/old/node": ["./packages/chord/src/old/node.ts"],
|
||||
"@acme/app/*": ["./packages/chord/test/examples/49-plugin-api/app/*.ts"],
|
||||
"@notes/app/*": ["./packages/chord/test/examples/53-app/app/*.ts"],
|
||||
"@board/app/*": ["./packages/chord/test/examples/56-web/app/*.ts"],
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ export const workspaceSourcePaths = {
|
|||
chordReact: fileURLToPath(new URL("./packages/chord/src/react/index.ts", import.meta.url)),
|
||||
chordTui: fileURLToPath(new URL("./packages/chord/src/tui/index.ts", import.meta.url)),
|
||||
chordTuiLauncher: fileURLToPath(new URL("./packages/chord/src/node/tui/index.ts", import.meta.url)),
|
||||
chordOld: fileURLToPath(new URL("./packages/chord/src/old/index.ts", import.meta.url)),
|
||||
chordOldNode: fileURLToPath(new URL("./packages/chord/src/old/node.ts", import.meta.url)),
|
||||
telemetryIndex: fileURLToPath(new URL("./packages/telemetry/src/index.ts", import.meta.url)),
|
||||
telemetryTesting: fileURLToPath(new URL("./packages/telemetry/src/testing/index.ts", import.meta.url)),
|
||||
mcpIndex: fileURLToPath(new URL("./packages/mcp/src/index.ts", import.meta.url)),
|
||||
|
|
@ -67,8 +65,6 @@ export default defineConfig({
|
|||
{ find: /^@earendil-works\/chord\/react$/, replacement: workspaceSourcePaths.chordReact },
|
||||
{ find: /^@earendil-works\/chord\/tui$/, replacement: workspaceSourcePaths.chordTui },
|
||||
{ find: /^@earendil-works\/chord\/tui\/launcher$/, replacement: workspaceSourcePaths.chordTuiLauncher },
|
||||
{ find: /^@earendil-works\/chord\/old$/, replacement: workspaceSourcePaths.chordOld },
|
||||
{ find: /^@earendil-works\/chord\/old\/node$/, replacement: workspaceSourcePaths.chordOldNode },
|
||||
{ find: /^@earendil-works\/pi-telemetry$/, replacement: workspaceSourcePaths.telemetryIndex },
|
||||
{ find: /^@earendil-works\/pi-telemetry\/testing$/, replacement: workspaceSourcePaths.telemetryTesting },
|
||||
{ find: /^@earendil-works\/pi-mcp$/, replacement: workspaceSourcePaths.mcpIndex },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue