mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
refactor(chord): name things after the concepts; object variants; link protocol version; object lifetimes
- References are Ref<C> (was Handle<C>), objects ObjectImpl (record.ts is object.ts), ImplementationOf<C> (was
BehaviorOf<C>), ResourceDeclaration (was ResourceSpec); epochs are code versions (codeVersion in Ctx, RootOptions,
Shape, Host, frames and reload reports). Docs say object and reference.
- A remote object's variant is named variant (was kind): factories return { variant }, references expose ref.variant.
- Links announce a protocol version (PROTOCOL_VERSION = 2) in hello; peers of different versions close the link with
reason protocol-version <n> and report it through LinkOptions.onProtocolMismatch.
- ServeOptions.lifetime ("referenced" | "host") replaces eager: a host-lifetime object is local, has an empty input,
exists once per host and lives until the host closes; awaitEager is awaitHostObjects. A reload that makes a type
referenced releases the host's own reference.
This commit is contained in:
parent
3daa20280f
commit
2688aba7dc
38 changed files with 1841 additions and 1467 deletions
|
|
@ -2,6 +2,13 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Terminology follows the concepts: a reference (what `get` returns) is `Ref<C>` (was `Handle<C>`), its status `RefStatus` (was `HandleStatus`); what a factory returns is `ImplementationOf<C>` (was `BehaviorOf<C>`); a resource's options are a `ResourceDeclaration` (was `ResourceSpec`); epochs are code versions: `Ctx.codeVersion`, `RootOptions.codeVersion`, `Shape.codeVersion`, `Host.codeVersion`/`previousCodeVersion`, and the `codeVersion` fields of frames and reload reports (all were `epoch`). Docs and messages say object (was record) and reference (was handle and hold); the `hold` and `release` frame types keep their names
|
||||
- A remote object's variant is named `variant` (was `kind`): factories return `{ variant: "editor", … }`, references expose `ref.variant` and `shape.variant`, and the `shape` frame carries `variant`. `variant` is reserved as a member name; `kind` no longer is
|
||||
- Links have a protocol version (`PROTOCOL_VERSION`, now 2), announced in `hello`. Peers of different versions do not talk: the side that notices closes the link with reason `protocol-version <n>`, and both sides report it through `LinkOptions.onProtocolMismatch`. Peers without a version (older Chord) are refused
|
||||
- `serve(…, { lifetime })` replaces `eager`: `"referenced"` (the default) creates an object at the first `get` of its address and disposes it after the last reference is released (plus `linger`); `"host"` creates the type's one object when its facet loads and keeps it until the host closes or a reload removes its facet. `lifetime: "host"` is refused at `serve` for remote types, for inputs that do not accept `{}` and together with `linger`; every `get` of such a type reaches its one object, created for the host principal, and another input is `invalid/input`. `eager: { input }` and the extra objects host-principal `get`s with other inputs created are gone. Only `"host"` objects may register (reason `invalid/not_host_lifetime`, was `not_eager`). `LoadOptions.awaitHostObjects` replaces `awaitEager`
|
||||
|
||||
### Added
|
||||
|
||||
- Links are flow controlled end to end: peers acknowledge what they processed (`ack` frames, announced with `acks` in `hello`), and while a window of frames is unacknowledged (`window` in `LinkOptions`, default 256 KiB), state frames wait at the sender, coalesced per subscription, instead of queuing in front of call results. A stalled peer costs a bounded slot per subscription; a slot that outgrows it becomes a reset. Calls, results and control frames are never held back, so under load a result can arrive before state changes made before it
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
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** is state plus code at an address. It is created on demand and lives while someone holds it.
|
||||
- Everyone gets a **handle**, never the object. Handles work the same locally and across processes, and survive reloads.
|
||||
- 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.
|
||||
|
|
@ -42,7 +42,7 @@ const host = createHost({ name: "server", principalKey: (p) => p.kind });
|
|||
host.serve(Counter, ({ name }, _principal, self) => {
|
||||
const state = self.state({ count: cell(Type.Number(), 0) });
|
||||
return {
|
||||
kind: "default",
|
||||
variant: "default",
|
||||
count: state.count,
|
||||
increment: async ({ by }, ctx) => {
|
||||
state.count.change(ctx, (n) => n + by);
|
||||
|
|
@ -52,7 +52,7 @@ host.serve(Counter, ({ name }, _principal, self) => {
|
|||
});
|
||||
|
||||
await host.root(async (ctx) => {
|
||||
const clicks = ctx.get(Counter, { name: "clicks" }); // a handle; held until this root ends
|
||||
const clicks = ctx.get(Counter, { name: "clicks" }); // a reference; released when this root ends
|
||||
clicks.count.subscribe((n) => console.log("count", n));
|
||||
await clicks.increment({ by: 2 }, ctx); // logs 0, then 2
|
||||
});
|
||||
|
|
@ -112,7 +112,7 @@ status.change(context, (draft) => {
|
|||
```
|
||||
|
||||
A successful `change()` publishes exactly one atomic revision. If its callback
|
||||
throws, the original value and sequence remain unchanged. Draft handles become
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@
|
|||
|
||||
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** is state plus code at an address. It is created on demand and lives while someone holds it.
|
||||
- Everyone gets a **handle**, never the object. Handles work the same locally and across processes, and survive reloads.
|
||||
- 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.
|
||||
|
|
@ -18,7 +21,7 @@ Chord builds applications out of **objects** that live in several processes (a s
|
|||
- [Declarations](#declarations)
|
||||
- [Activation](#activation)
|
||||
- [Principals and variants](#principals-and-variants)
|
||||
- [Handles](#handles)
|
||||
- [References](#references)
|
||||
- [State](#state)
|
||||
- [Calls, ctx and roots](#calls-ctx-and-roots)
|
||||
- [Lifetime](#lifetime)
|
||||
|
|
@ -53,7 +56,7 @@ const host = createHost({ name: "server", principalKey: (p) => p.kind });
|
|||
host.serve(Counter, ({ name }, _principal, self) => {
|
||||
const state = self.state({ count: cell(Type.Number(), 0) });
|
||||
return {
|
||||
kind: "default",
|
||||
variant: "default",
|
||||
count: state.count,
|
||||
increment: async ({ by }, ctx) => {
|
||||
state.count.change(ctx, (n) => n + by);
|
||||
|
|
@ -63,7 +66,7 @@ host.serve(Counter, ({ name }, _principal, self) => {
|
|||
});
|
||||
|
||||
await host.root(async (ctx) => {
|
||||
const clicks = ctx.get(Counter, { name: "clicks" }); // a handle; held until this root ends
|
||||
const clicks = ctx.get(Counter, { name: "clicks" }); // a reference; released when this root ends
|
||||
clicks.count.subscribe((n) => console.log("count", n));
|
||||
await clicks.increment({ by: 2 }, ctx); // logs 0, then 2
|
||||
});
|
||||
|
|
@ -147,20 +150,21 @@ host.serve(Index, indexFactory, {
|
|||
## Serving objects
|
||||
|
||||
```ts
|
||||
host.serve(Type, (input, principal, self) => behavior, options);
|
||||
host.serve(Type, (input, principal, self) => implementation, options);
|
||||
```
|
||||
|
||||
- The **factory** runs synchronously. It declares what the object needs and returns its behavior: methods and state members, plus `kind` for remote types. Returning a promise throws.
|
||||
- There is one **record** per `(type, input, principal)`. It holds the object's state, resources and holds, and survives reloads. Only the behavior is replaced.
|
||||
- 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:**
|
||||
- `state: { version, migrate }`: the state version and its migrations (see [Hot reload](#hot-reload));
|
||||
- `accepts`: adapters for consumers built against older contract versions;
|
||||
- `linger`: how long to keep the object after the last hold is released;
|
||||
- `eager`: activate when the plugin loads, and keep it for the host's lifetime.
|
||||
- `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 record. Declarations are matched by **property name**, so the new code's `status` cell is the old code's `status` cell, with its value.
|
||||
A reload runs the factory again over the same object. Declarations are matched by **property name**, so the new code's `status` cell is the old code's `status` cell, with its value.
|
||||
|
||||
```ts
|
||||
host.serve(StatusStore, ({ session }, _host, self) => {
|
||||
|
|
@ -197,7 +201,7 @@ host.serve(StatusStore, ({ session }, _host, self) => {
|
|||
| Declaration | What it is | On reload |
|
||||
| --- | --- | --- |
|
||||
| `self.state({ name: cell(schema, initial) })` | an observable JSON cell; the schema is required | same cell and value; migrated when the state version is bumped |
|
||||
| `self.get(Type, input, { at? })` | a hold on another object; its identity is the address | kept if declared again, otherwise released |
|
||||
| `self.get(Type, input, { at? })` | a reference to another object; its identity is the address | kept if declared again, otherwise released |
|
||||
| `self.resources({ name: resource({ rev, deps?, exclusive?, open, close }) })` | a connection, watch, socket, port | kept; reopened only if `rev` or `deps` change |
|
||||
| `self.listen({ name: listener(source, fn) })` or `listener(source, select, fn)` | reacts to a state or resource event | the same subscription, with the new `fn`, as long as the source (resource) and `select` are unchanged |
|
||||
| `self.tasks({ name: async (ctx) => ... })` | long-running background work | the old run is aborted through `ctx.signal`, and the new code starts |
|
||||
|
|
@ -238,8 +242,8 @@ The factory is synchronous, but activation isn't. `self.get` and `self.resources
|
|||
get(Index, { session: "S1" })
|
||||
1. run the factory → declarations
|
||||
2. open declared resources → e.g. connect to SQLite
|
||||
3. wait for declared holds → dependencies activate first
|
||||
4. live → handles become "live"
|
||||
3. wait for declared refs → dependencies activate first
|
||||
4. live → refs become "live"
|
||||
```
|
||||
|
||||
```ts
|
||||
|
|
@ -255,7 +259,7 @@ host.serve(Index, ({ session }, _host, self) => {
|
|||
host.serve(Chat, ({ session, conversation }, principal, self) => {
|
||||
const index = self.get(Index, { session }); // a placeholder until Index is live
|
||||
return {
|
||||
kind: "participant",
|
||||
variant: "participant",
|
||||
find: async ({ query }, ctx) => index.search({ query }, ctx), // only ever runs once Chat (and Index) are live
|
||||
// ...
|
||||
};
|
||||
|
|
@ -263,45 +267,45 @@ host.serve(Chat, ({ session, conversation }, principal, self) => {
|
|||
```
|
||||
|
||||
**Rules:**
|
||||
- Inside the factory, never use what you declared: `res.db.value` throws `not_ready`, and calling a handle throws. Use them in methods, listeners and tasks.
|
||||
- Inside the factory, never use what you declared: `res.db.value` throws `not_ready`, and calling a reference throws. Use them in methods, listeners and tasks.
|
||||
- **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 eager objects they install to become live:
|
||||
resources are open, declared holds are live, and registrations are published. All facets in a load are installed
|
||||
before activation is awaited, so an eager consumer can use a provider in a later facet. Ordinary lazy objects stay
|
||||
lazy unless an eager object holds them. Factory, dependency and resource errors reject startup with the handle's
|
||||
activation error; eager failures also reach `onError`. Installation is not rolled back on failure: close the host
|
||||
`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 eager holds present when called, including direct `host.serve(..., { eager })`
|
||||
and previously deferred loads. It is an activation barrier, not a continuous health check, a wait for tasks to
|
||||
`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, or a remote-state hydration barrier. 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.
|
||||
An eager reporter waiting for the server's SessionStatus cannot become live before that acceptance. Awaiting the
|
||||
whole load before announcing control readiness would deadlock. Defer the whole eager barrier explicitly:
|
||||
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 holds can queue until the parent accepts it.
|
||||
await worker.load(workerFacets, { awaitEager: false }); // also supported by install
|
||||
// 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 = worker.get(Harness, {});
|
||||
await harness.ready; // local bootstrap only; no upstream dependency
|
||||
announceControlReady(); // parent can now accept the worker link
|
||||
await worker.ready(); // full eager readiness, including upstream holds
|
||||
await worker.ready(); // every host object live, including upstream references
|
||||
harness.release();
|
||||
```
|
||||
|
||||
`awaitEager: false` still starts and host-holds every eager object; it skips waiting, not activation. There is no
|
||||
automatic classification of upstream dependencies. Choose a local bootstrap handle with no upstream dependency,
|
||||
`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 eager object is live. Keep worker bootstrap counted as busy
|
||||
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.
|
||||
|
||||
To hand out a native object a resource opens (a local type's `opaque` member), expose the resource. Handles read its value once the object is live, and `undefined` before:
|
||||
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) => {
|
||||
|
|
@ -333,7 +337,7 @@ const host = createHost({
|
|||
});
|
||||
```
|
||||
|
||||
- There is one record per principal key: two tabs of the owner share an object, and a guest gets its own.
|
||||
- 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 the built-in `host` principal, so internal objects are shared.
|
||||
- A link stamps the principal of whatever connects to it. Callers can never supply their own.
|
||||
|
||||
|
|
@ -343,10 +347,10 @@ const host = createHost({
|
|||
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 { kind: "viewer", view: c.view };
|
||||
if (!canSend(principal)) return { variant: "viewer", view: c.view };
|
||||
const author = authorOf(principal);
|
||||
return {
|
||||
kind: "participant",
|
||||
variant: "participant",
|
||||
view: c.view,
|
||||
send: async (input, ctx) => c.submit(input, author, ctx),
|
||||
stop: async (_, ctx) => {
|
||||
|
|
@ -360,25 +364,25 @@ host.serve(Chat, ({ session, conversation }, principal, self) => {
|
|||
```ts
|
||||
const chat = ctx.get(Chat, { session: "S1", conversation: 1 }, { at: "session:S1" });
|
||||
await chat.ready;
|
||||
if (chat.kind === "participant") showComposer(chat.send);
|
||||
if (chat.variant === "participant") showComposer(chat.send);
|
||||
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 records, here and over links, and their handles become `gone`. A new `get` runs the factory with the new grants.
|
||||
- **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 `host`. If a local object needs to know who's acting, pass that explicitly, like `author` above.
|
||||
|
||||
## Handles
|
||||
## References
|
||||
|
||||
```ts
|
||||
const chat = ctx.get(Chat, input, { at: "session:S1" });
|
||||
chat.status; // "loading" | "live" | "released" | "gone"
|
||||
await chat.ready; // live, or rejects with the activation error
|
||||
chat.kind; // the current variant
|
||||
chat.shape; // { kind, members, contract }
|
||||
chat.variant; // the current variant
|
||||
chat.shape; // { variant, members, contract, codeVersion }
|
||||
const send = chat.send; // a stable function: always dispatches to the current code
|
||||
chat.view.value; // undefined until hydrated (remote: while someone subscribes)
|
||||
chat.view.subscribe((v) => render(v));
|
||||
|
|
@ -386,10 +390,10 @@ chat.onStatus((status, error) => { if (status === "gone") reconnect(error); });
|
|||
chat.release();
|
||||
```
|
||||
|
||||
- **Calls on a `loading` handle wait** until it's live, respecting `ctx` cancellation and a deadline.
|
||||
- **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 held: 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.
|
||||
- **`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.
|
||||
- **A remote state member is hydrated only while subscribed.** When its last subscriber leaves, its `value` goes back to `undefined`, so a later subscriber never sees a stale value: it gets `undefined`, then the current snapshot.
|
||||
- **Return values are values.** Anything long-lived you want to share is an object you reach with `get`.
|
||||
|
||||
|
|
@ -419,7 +423,7 @@ state.view.replace(ctx, rebuilt, { diff: true }); // always diff
|
|||
- Values are immutable. Never mutate what you read or receive.
|
||||
- Remote subscribers get a snapshot, 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.
|
||||
- A behavior may expose another object's state member as its own: `return { kind: "viewer", view: conversation.view }`. It reads `undefined` only while that object is loading, which never happens once this object is live.
|
||||
- An implementation may expose another object's state member as its own: `return { variant: "viewer", view: conversation.view }`. It reads `undefined` only while that object is loading, which never happens once this object is live.
|
||||
|
||||
```ts
|
||||
const derived = self.derive({
|
||||
|
|
@ -437,7 +441,7 @@ Every method gets `(args, ctx)`. Pass `ctx` on to everything you call:
|
|||
```ts
|
||||
send: async (input, ctx) => {
|
||||
ctx.signal.throwIfAborted();
|
||||
const uploads = ctx.get(Uploads, {}); // a hold owned by this call; released when it settles
|
||||
const uploads = 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);
|
||||
},
|
||||
|
|
@ -449,13 +453,13 @@ A **root** is a short tree of calls, started at ingress:
|
|||
- by each event delivered to a `listener`;
|
||||
- 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 **epoch**), across hosts, until it ends. Keep roots short. Long-running work is a `task`, and long waits are state.
|
||||
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
|
||||
// right: object the request as state; the answer is a separate call
|
||||
request: async (req, ctx) => {
|
||||
state.pending.change(ctx, (d) => {
|
||||
d.push(req);
|
||||
|
|
@ -472,31 +476,37 @@ answer: async ({ id, value }, ctx) => {
|
|||
|
||||
## Lifetime
|
||||
|
||||
An object lives while it's **held**, and every hold has an owner:
|
||||
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 | `eager: true` | host shutdown |
|
||||
| the host | `lifetime: "host"` | host shutdown (or a reload removing its facet) |
|
||||
| UI | `useObject` | unmount |
|
||||
|
||||
- **Disposal** runs after the last hold, plus `linger`. Tasks are aborted, listeners removed, resources closed in reverse order (every close runs, and errors are collected), then the object's own holds are released, which can dispose further objects.
|
||||
- **A resource's `close` may still use the objects its record holds.** That holds for host shutdown and revocation too: `host.close()` and `invalidatePrincipal` dispose holders before what they hold, 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 handles `gone` at once and aborts in-flight calls.
|
||||
- **Holds must not form cycles.** Inside one host this is checked at `get`.
|
||||
- **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.
|
||||
|
||||
## Registries
|
||||
|
||||
```ts
|
||||
export const Routes = defineRegistry<RouteHandler>("http.routes", { conflict: "error" });
|
||||
|
||||
// contribute (only from eager objects)
|
||||
// contribute (only from `lifetime: "host"` objects)
|
||||
host.serve(Downloads, (_, _host, self) => {
|
||||
const blobs = self.get(Blobs, {});
|
||||
self.register(Routes, {
|
||||
|
|
@ -507,9 +517,9 @@ host.serve(Downloads, (_, _host, self) => {
|
|||
},
|
||||
});
|
||||
return {};
|
||||
}, { eager: true });
|
||||
}, { lifetime: "host" });
|
||||
|
||||
// consume: a request sees the table of its own epoch
|
||||
// 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);
|
||||
|
|
@ -518,7 +528,7 @@ server.on("request", (req, res) =>
|
|||
);
|
||||
```
|
||||
|
||||
- Each epoch has one complete table. A reload never leaves a moment where an entry is missing.
|
||||
- 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).
|
||||
|
|
@ -576,7 +586,7 @@ await host.reload([new URL("../plugins/chat/worker.ts", import.meta.url)]); //
|
|||
### What happens
|
||||
|
||||
1. **Load** the new modules.
|
||||
2. **Prepare:** run the new factories over the existing records, 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.
|
||||
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 state version was bumped 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.
|
||||
|
|
@ -646,10 +656,10 @@ const res = self.resources({
|
|||
| 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 **eager bootstrap hold** is a lifecycle pin, not an old-version consumer. When a reload replaces
|
||||
its provider, that hold adopts the new contract at commit; it does not require an `accepts` adapter for itself.
|
||||
The record, cells and unchanged resources stay alive without reactivation. A refused reload keeps the original
|
||||
hold and contract. Other holds (`self.get`, `ctx.get`, `host.get`, peers) still obey the compatibility rules above;
|
||||
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
|
||||
|
|
@ -678,31 +688,38 @@ const sessions = self.get(Sessions, {}, { at: "server" }); // worker → server
|
|||
```
|
||||
|
||||
- **The server relays** between its children: worker to worker, and browser to worker.
|
||||
- **Disconnects:** when a browser disconnects, exactly the holds it had in workers are released.
|
||||
- **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.
|
||||
- **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 subscription; calls and results are never held back. 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 epoch before anything is routed to them. Calls their roots make to the server meanwhile run in the server's current epoch instead of failing `superseded`.
|
||||
- **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 an eager local object. 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.
|
||||
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 res = self.resources({
|
||||
durable: resource({
|
||||
rev: 1,
|
||||
open: () => DurableHarness.open(storage, { models, registry: durableRegistry(host, Extensions) }), // a reader over the epoch tables
|
||||
open: () => DurableHarness.open(storage, { models, registry: durableRegistry(host, Extensions) }), // a reader over the code version tables
|
||||
close: (h) => h.close(),
|
||||
}),
|
||||
});
|
||||
return { conversation: (id: number) => res.durable.value.conversation(id) };
|
||||
}, { eager: true });
|
||||
}, { lifetime: "host" });
|
||||
|
||||
// a plugin contributes a Durable extension like any registration
|
||||
host.serve(Guard, (_, _host, self) => {
|
||||
|
|
@ -713,7 +730,7 @@ host.serve(Guard, (_, _host, self) => {
|
|||
}),
|
||||
});
|
||||
return {};
|
||||
}, { eager: true });
|
||||
}, { 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.
|
||||
|
|
@ -724,13 +741,13 @@ A reload changing the guard: Durable phases already running keep the old snapsho
|
|||
| --- | --- |
|
||||
| `forbidden` | The factory or a method refused this principal |
|
||||
| `invalid` | Input, arguments or a result failed validation |
|
||||
| `not_ready` | A declared resource or handle was used inside a factory |
|
||||
| `not_in_variant` | The member doesn't exist in the variant you hold |
|
||||
| `released` | The handle was released |
|
||||
| `gone` | The object was forced away while held. Call `get` again. |
|
||||
| `not_ready` | A declared resource or reference was used inside a factory |
|
||||
| `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 epoch is no longer available on this host. Retry. |
|
||||
| `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 |
|
||||
| `aborted` | The call's `ctx` was cancelled |
|
||||
|
|
@ -744,7 +761,7 @@ A reload changing the guard: Durable phases already running keep the old snapsho
|
|||
| 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 hold per call) |
|
||||
| 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) |
|
||||
|
|
|
|||
|
|
@ -11,15 +11,15 @@ Hard constraints:
|
|||
|
||||
| Term | Meaning |
|
||||
| --- | --- |
|
||||
| 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) => behavior`. Synchronous. Registered with `host.serve(type, factory, options)` from a plugin facet. |
|
||||
| record | Stable identity per `(type, canonicalInput, principalKey)`. Owns cells, resources, holds, listeners, tasks, registrations. Survives reloads. |
|
||||
| behavior | What a factory run returns (methods + state members; `kind` for remote variants). Immutable, per epoch. |
|
||||
| handle | What `get` returns. Late-bound. Never the record or behavior. |
|
||||
| hold | One handle's claim. A record lives while held. |
|
||||
| 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, epoch }`. |
|
||||
| epoch | One committed set of behaviors/registrations per host (server-issued id). |
|
||||
| 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
|
||||
|
|
@ -52,7 +52,7 @@ export const Index = local("pi.index", {
|
|||
|
||||
- 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 handle 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 behavior exists. Members missing from the contract are found only once the handle is live.
|
||||
- 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.
|
||||
|
||||
### Compatibility rule
|
||||
|
|
@ -63,17 +63,17 @@ Covers: every consumer member exists with the same kind; methods: provider accep
|
|||
|
||||
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 holds (consumer token in the hold frame).
|
||||
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 eager bootstrap hold is a lifecycle pin, not a surviving old-contract consumer. When its
|
||||
provider is replaced, commit replaces that hold with one bound to the new provider contract, acquiring it before
|
||||
releasing the old hold so the same record, state and unchanged resources survive. It needs no `accepts` adapter
|
||||
for its own previous contract. Prepare/abort do not change that hold. All other consumers retain their own tokens
|
||||
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. Declarations are identified across factory reruns by **property names** in object literals (not call order, not separate string keys). Names are unique per record per declaration kind; a declaration method may be called more than once if names don't repeat. Holds are identified by their address.
|
||||
Factories are synchronous; returning a promise throws. Declarations are identified across factory reruns by **property names** in object literals (not call order, not separate string keys). Names are unique per object per declaration kind; a declaration method may be called more than once if names don't repeat. References are identified by their address.
|
||||
|
||||
```ts
|
||||
host.serve(StatusStore, ({ session }, _host, self) => {
|
||||
|
|
@ -81,7 +81,7 @@ host.serve(StatusStore, ({ session }, _host, self) => {
|
|||
status: cell(StatusSchema, { running: false, tasks: 0 }), // schema required, initial value
|
||||
errors: cell(Type.Array(Type.String()), []),
|
||||
});
|
||||
const catalog = self.get(Catalog, {}); // hold; identity = canonical address (+ at)
|
||||
const catalog = self.get(Catalog, {}); // reference; identity = canonical address (+ at)
|
||||
const res = self.resources({
|
||||
watch: resource({
|
||||
rev: 1, deps: [catalog], exclusive: false,
|
||||
|
|
@ -94,87 +94,104 @@ host.serve(StatusStore, ({ session }, _host, self) => {
|
|||
});
|
||||
self.tasks({ poll: async (ctx) => { /* loop until ctx.signal aborts */ } });
|
||||
const derived = self.derive({ visible: derivation([state.status], (s) => project(s)) }); // synchronous
|
||||
self.register(Routes, { "GET /status": handler }); // registry keys are the registry's own keys; eager records only
|
||||
self.register(Routes, { "GET /status": handler }); // registry keys are the registry's own keys; `lifetime: "host"` objects only
|
||||
return { status: derived.visible, report: (args, ctx) => { state.status.replace(ctx, args); return null; } };
|
||||
}, {
|
||||
state: { version: 2, migrate: { status: (old, from) => upgrade(old, from) } }, // migrate gets the old value and its version
|
||||
linger: 30_000,
|
||||
eager: false,
|
||||
linger: 30_000, // with lifetime "referenced" (the default) only
|
||||
});
|
||||
```
|
||||
|
||||
| Declaration | Survives reload | Notes |
|
||||
| --- | --- | --- |
|
||||
| `state({ name: cell(schema, initial) })` | same cell and value; migrated on state-version bump | Schema required (no TypeScript at runtime). `change(ctx, draft => void)`, `replace(ctx, value)`, `value`, `subscribe`. Writes through `change` touch only mutated paths. |
|
||||
| `get(type, input, { at? })` | kept if declared again (same address) | returns a handle |
|
||||
| `resources({ name: resource({ rev, deps?, exclusive?, open, close }) })` | kept unless `rev`/`deps` change | `open` runs at prepare (non-exclusive) or at the gate (exclusive); handle exposes `.value` |
|
||||
| `listen({ name: listener(source, select?, fn) })` | same subscription while source (resource) and `select` (identity or source text) are unchanged; `fn` swapped | each event starts a fresh root in the current epoch; `select` is not re-run for a kept subscription |
|
||||
| `get(type, input, { at? })` | kept if declared again (same address) | returns a reference |
|
||||
| `resources({ name: resource({ rev, deps?, exclusive?, open, close }) })` | kept unless `rev`/`deps` change | `open` runs at prepare (non-exclusive) or at the gate (exclusive); reference exposes `.value` |
|
||||
| `listen({ name: listener(source, select?, fn) })` | same subscription while source (resource) and `select` (identity or source text) are unchanged; `fn` swapped | each event starts a fresh root in the current code version; `select` is not re-run for a kept subscription |
|
||||
| `tasks({ name: fn })` | restarted with new code at commit (old one aborted via ctx.signal) | a code holder; calls inside start fresh roots |
|
||||
| `derive({ name: derivation(sources, fn) })` | kept; fn swapped | synchronous fn |
|
||||
| `register(registry, { key: value })` | value swapped at commit | eager records only (v1) |
|
||||
| `expose(resource)` | follows the resource | a local member (usually `opaque`) whose handle value is the resource's value once live |
|
||||
| `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 a behavior may be cells, derivations, or state members of handles the record holds (they are defined once the record 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 }` of its host.
|
||||
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 }` of its host.
|
||||
|
||||
`migrate` per cell is `(old, fromVersion) => new`. A record may be several versions behind (author bumped more than once between reloads).
|
||||
`migrate` per cell is `(old, fromVersion) => new`. An object may be several versions behind (author bumped more than once between reloads).
|
||||
|
||||
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 a record:
|
||||
`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 holds becoming live (dependencies activate first, coalesced);
|
||||
4. record is live: handles `live`, `ready` resolves.
|
||||
3. await declared references becoming live (dependencies activate first, coalesced);
|
||||
4. object is live: references `live`, `ready` resolves.
|
||||
|
||||
Inside the factory, placeholders must not be used (`resource.value` throws `not_ready`; handle calls throw). Methods/listeners/tasks run only on live records, so their dependencies are live. External consumers (UI, roots) holding a `loading` handle: calls wait until live (ctx cancellation, deadline); state members read `undefined` until hydrated. Activation failures propagate to dependents and all waiting gets; the next get retries.
|
||||
Inside the factory, placeholders must not be used (`resource.value` throws `not_ready`; reference calls throw). Methods/listeners/tasks run only on live objects, so their dependencies are live. External consumers (UI, roots) holding a `loading` reference: calls wait until live (ctx cancellation, deadline); state members read `undefined` until hydrated. Activation failures propagate to dependents and all waiting gets; the next get retries.
|
||||
|
||||
### Host eager readiness
|
||||
### Lifetime
|
||||
|
||||
- `host.install(facet, { awaitEager? })` installs declarations synchronously and returns a promise for this facet's
|
||||
eager activations. `host.load(facets, { awaitEager? })` installs the entire batch before awaiting its eager
|
||||
activations. `awaitEager` defaults to true. Unheld lazy objects are not activated.
|
||||
`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 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`). Eager failures also
|
||||
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.
|
||||
- `{ awaitEager: false }` starts the same eager objects but resolves after installation, without waiting for their
|
||||
activation. `host.ready()` awaits a snapshot of the host's eager holds at call time (including direct eager
|
||||
`serve` and deferred loads), rejects their activation failures, and does not acquire additional holds. It is not
|
||||
an ongoing health check and does not await task completion or remote state hydration.
|
||||
- `{ 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
|
||||
`{ awaitEager: false }`, awaits its local bootstrap handle (e.g. Harness, with no upstream dependency), then
|
||||
`{ 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 an eager reporter depending on server SessionStatus deadlocks with the parent waiting for control
|
||||
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 handles, keep bootstrap busy until full readiness, and propagate later startup errors.
|
||||
bootstrap references, keep bootstrap busy until full readiness, and propagate later startup errors.
|
||||
|
||||
## 4. Principals, variants, handles
|
||||
## 4. Principals, variants, references
|
||||
|
||||
- `createHost({ principalKey })` maps principal to a key that includes connection provenance and grant revision. Records are per principal key. `host` is a built-in principal for in-host calls.
|
||||
- Remote factories return `{ kind, ...members }`; `kind` must be a declared variant; members must match it. Local factories return any object.
|
||||
- Handle: `status` (`loading | live | released | gone`), `ready` promise, `kind`, `shape` (`{ kind, members, contract }`), `onStatus(listener)` (every status change, with the error for `gone`), late-bound members, `release()`. A remote handle's state member holds a value only while subscribed (`undefined` after its last subscriber leaves). Shape changes (reload, variant) are pushed; members missing from the current variant reject `not_in_variant`; released handles reject `released`; forced disposal makes handles `gone`.
|
||||
- `createHost({ principalKey })` maps principal to a key that includes connection provenance and grant revision. Objects are per principal key. `host` is a built-in principal for in-host calls.
|
||||
- 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, codeVersion }`), `onStatus(listener)` (every status change, with the error for `gone`), late-bound members, `release()`. A remote reference's state member holds a value only while subscribed (`undefined` after its last subscriber leaves). 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`.
|
||||
- Methods are `(args, ctx) => Promise<result>`. Factories still check arguments; variants are not complete authorization.
|
||||
- Revocation: `host.invalidatePrincipal(match)` force-disposes matching records on this host and propagates over links; links re-stamp or close.
|
||||
- Revocation: `host.invalidatePrincipal(match)` force-disposes matching objects on this host and propagates over links; links re-stamp or close.
|
||||
|
||||
## 5. Lifetime
|
||||
|
||||
- Hold owners: record (declared `get`), call (`ctx.get`, released at settle), connection (remote holds; relay maps per origin connection), host (eager), UI.
|
||||
- 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 last hold + `linger`: tasks aborted, listeners removed, resources closed (reverse order; all run; errors aggregated), holds released (cascade).
|
||||
- Bulk forced disposal (`host.close`, `invalidatePrincipal`) goes in waves, holders before what they hold, so a resource's `close` can still use its record's holds; `host.close` closes links after the records.
|
||||
- Forced disposal: handles `gone` immediately, new calls rejected, in-flight call contexts aborted, then cooperative cleanup.
|
||||
- Hold graph acyclic in-host (checked at get); across hosts by convention.
|
||||
- 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 epochs
|
||||
## 6. Roots and code versions
|
||||
|
||||
- `Ctx = { signal, rootId, epoch, get(...) , 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, epoch}`.
|
||||
- Dispatch: a call with epoch E runs the record's behavior for E. A host retains at most `current` and `previous` epochs (two-version rule). A call for an epoch the host doesn't have (older than previous, or host started later) is rejected `superseded` (retryable by starting a new root). Never substitute another epoch.
|
||||
- Browsers never carry epochs 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`.
|
||||
- `Ctx = { signal, rootId, codeVersion, get(...) , 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
|
||||
|
||||
|
|
@ -182,15 +199,15 @@ 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 record of each affected type, run the new factory in recording mode: existing cells readable, writes throw; listeners/tasks/registrations recorded, not started; holds recorded; non-exclusive resources whose key is new or `rev`/`deps` changed are opened as candidate-owned (closed on abort). Validate keys, variants, registry collisions. New records activated during prepare use the current epoch (old code). Any failure: abort, discard candidates. Vote.
|
||||
4. Gate (only records whose state version was bumped or which replace an exclusive resource, plus records depending on those resources):
|
||||
- Admission cut: from now, an old-epoch root that has not touched the record gets `upgrade_conflict` (retryable), not queued. Roots that already hold an activation lease on it are grandfathered; their re-entry is admitted.
|
||||
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 key is new or `rev`/`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 whose state version was bumped 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-epoch root before committing): each host in one synchronous step installs migrated cell values and state versions, installs behaviors as epoch E', swaps registry tables, restarts tasks with new code, rebinds listeners, pushes shape/reset to subscribers, opens gates. E' current, E previous.
|
||||
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 state 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 (or on the next reload, which first waits for retirement with a deadline), drop its behaviors and release declarations only it declared.
|
||||
6. Retire: when no roots of `previous` remain (or on the next reload, which first waits for retirement with a deadline), drop its implementations and release declarations only it declared.
|
||||
|
||||
Compatible changes (no state bump, no exclusive resource change) skip the gate: no waiting.
|
||||
|
||||
|
|
@ -200,21 +217,22 @@ State compatibility rule: reload prepare compares each cell's old and new schema
|
|||
|
||||
```ts
|
||||
export const Routes = defineRegistry<RouteHandler>("http.routes", { conflict: "error" });
|
||||
self.register(Routes, { "GET /download": handler }); // in an eager record; keys are the registry's keys
|
||||
host.registry(Routes).get("GET /download", ctx); // table of ctx.epoch (or current without ctx)
|
||||
host.registry(Routes).subscribe((table, epoch) => ...); // bridges for imperative consumers (Durable)
|
||||
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-epoch immutable tables; order = first registration order, kept on replace. An application can implement Durable's `RegistryReader` from the table (snapshot per epoch) so phases pin a complete snapshot; Chord does not ship that bridge.
|
||||
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, principalKey, upstream? })`; `host.accept(link)` for children; `host.route(prefix, { authorize(principal, hostName), resolve(hostName) => Promise<Link> })`.
|
||||
- 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; payload principal fields ignored.
|
||||
- A child that connects behind its parent's epoch is fenced (nothing routed to it) and reconciled by a reload of its modules to the parent's epoch. Calls its roots of the old epoch make upstream are admitted in the parent's current epoch (the parent never ran that epoch), not rejected `superseded`.
|
||||
- Relay: server forwards frames for `at` hosts to the resolved child link, mapping holds per origin connection; authorizes before resolving/spawning and re-checks after.
|
||||
- Wire frames (JSON): `hello`/`bye`, `hold`/`release` (a hold returns `shape` or `gone`; there is no separate `get`), `shape`, `call`/`result`/`cancel`, `sub`/`unsub` and `state` (`snap`/`ops`/`reset` with a per-subscription delta encoder), `gone`, `root_end`, `retire`, `invalidate`, `ping`/`pong`, `ack`, reload control (`prepare`/`vote`/`commit`/`abort`).
|
||||
- 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 frames for `at` hosts to the resolved child link, 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` and `state` (`snap`/`ops`/`reset` with a per-subscription delta encoder), `gone`, `root_end`, `retire`, `invalidate`, `ping`/`pong`, `ack`, reload control (`prepare`/`vote`/`commit`/`abort`).
|
||||
- Protocol version: `hello` carries `protocol` (`PROTOCOL_VERSION`, now 2; 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`.
|
||||
- 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 frames wait at the sender, one slot per subscription: ops are appended, a snapshot or reset replaces the slot, an unsubscription drops it, and a slot that outgrows four windows becomes a reset of the current value (a relay subscribes downstream anew instead). Every other frame is 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)
|
||||
|
|
@ -223,41 +241,41 @@ Per-epoch immutable tables; order = first registration order, kept on replace. A
|
|||
|
||||
## 11. Test coverage
|
||||
|
||||
The tests cover at least: lifetime/holds/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 epoch; 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`.
|
||||
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 an epoch only after its parent retired it (`retire` frame) and its own roots of that epoch ended; the server decides from its own roots. A root started on a worker that calls up to a server which has already retired the epoch 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 epoch until it reconnects and is reconciled.
|
||||
- **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 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 epoch 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 records are new ones. Keeping holds for a reconnect window would need session ids on links. Calls in flight when a link drops reject `unavailable`; whether they ran is unknown.
|
||||
- **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 behavior depends on captured values that change between runs is not detected.
|
||||
- **Shutdown and dead links.** `host.close()` disposes records in waves (holders before what they hold) 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.
|
||||
- **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_eager`, `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`.
|
||||
- **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 records during prepare.** A reload pre-allocates the next epoch ("pending"). Types the same reload adds are served from it; their records install behavior only for the pending epoch and are force-disposed if the reload aborts.
|
||||
- **Gate admission.** From the gate until commit or abort, creating a new record of a changed type fails with `upgrade_conflict` (retryable, reason `gate`); nothing is paused, so nothing can deadlock. Gated records get `minEpoch = E'`: calls and writes carrying an older epoch are rejected `upgrade_conflict` permanently, and a gated record drops its previous-epoch behavior at commit. Only records whose state version or exclusive resources change are gated.
|
||||
- **Tasks and listeners during the gate.** Tasks of gated records are aborted at the admission cut and restart with new code at commit, or with the old code on abort. Listener events for gated records 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 record 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.** Records of removed types are force-disposed at commit. When a retirement deadline is missed, retirement is forced, so those roots get `superseded` on their next call.
|
||||
- **Two-version rule.** A record of a changed type created after a commit has only new code; a previous-epoch root calling it gets `superseded`.
|
||||
- **Validation.** Remote calls are validated at handle 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 handle knows whether a member is a method, state or opaque. Canonical identity omits input fields equal to their default; with TypeBox, use `Type.Optional(Type.X({ default }))` 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 epochs. 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. `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`. Handle state members are stable readables, `undefined` while loading or outside the variant, and reset when their source moves. Remote state members hydrate only while subscribed and forget their value when the last listener leaves.
|
||||
- **Holds and calls.** A hold's identity is its address; a second `self.get` of the same address in one run returns the same handle. A handle call without ctx, or with a task ctx, starts a fresh root in the current epoch; a ctx whose call or root already settled is refused (`aborted/expired`), one from another host `invalid/foreign_ctx`. Calling a handle 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 record that depend on them. A new record 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.
|
||||
- **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 state version 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, so those roots get `superseded` on their next call.
|
||||
- **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; with TypeBox, use `Type.Optional(Type.X({ default }))` 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. `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, `undefined` while loading or outside the variant, and reset when their source moves. Remote state members hydrate only while subscribed and forget their value when the last listener leaves.
|
||||
- **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, so a stalled peer costs at most a bounded slot per subscription, and the next frame it gets is current.
|
||||
- **The state API stays.** Objects keep named cells and derivations; giving each object exactly one state value was rejected. Do not reopen this without a new, concrete problem.
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@ 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 **handle** is a caller's
|
||||
reference to that object, not the object itself. Several handles can reach the
|
||||
same object. Handles can also reach another host over a connection.
|
||||
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
|
||||
handle + subscribed rows -- link --> Todos view (owner or guest)
|
||||
ref + subscribed rows -- link --> Todos view (owner or guest)
|
||||
|
|
||||
handle
|
||||
ref
|
||||
v
|
||||
TodoStore: rows + methods
|
||||
```
|
||||
|
|
@ -61,7 +61,7 @@ const TodoLabel = local("tutorial.todos", {
|
|||
});
|
||||
```
|
||||
|
||||
A **factory** is a synchronous function that creates the object's behavior:
|
||||
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.
|
||||
|
|
@ -82,8 +82,8 @@ const result = await host.root(async (ctx) => {
|
|||
assert.equal(result, "Todos for family");
|
||||
```
|
||||
|
||||
The result is `"Todos for family"`. `ctx.get` returns a handle immediately;
|
||||
method calls wait for the object to become live. The handle acquired through
|
||||
The result is `"Todos for family"`. `ctx.get` returns a reference immediately;
|
||||
method calls wait for the object to become live. 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(null)`, starts its own root. Inside a root or method, pass the
|
||||
|
|
@ -92,7 +92,7 @@ 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 handle separates callers from implementations. Later, replacing
|
||||
> **Why:** The reference separates callers from implementations. Later, replacing
|
||||
> code will not require replacing every caller's reference.
|
||||
|
||||
## 3. Give the list live state
|
||||
|
|
@ -201,15 +201,15 @@ unsubscribe function. We await `ready` first so that value is not a loading
|
|||
placeholder.
|
||||
|
||||
> **Why:** Store reloadable state in cells, not closure variables. Treat
|
||||
> `cell.value`, `handle.rows.value`, and subscriber values as immutable.
|
||||
> `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 **hold**
|
||||
is a handle's claim that keeps an object alive. `self.get` declares a hold
|
||||
owned by this object; it lasts longer than a single method call.
|
||||
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
|
||||
|
|
@ -277,7 +277,7 @@ host.serve(
|
|||
);
|
||||
```
|
||||
|
||||
An outside consumer holds a `SavedTodos` handle. We also hold `Database`
|
||||
An outside consumer holds a `SavedTodos` reference. We also hold `Database`
|
||||
directly so we can inspect what was saved.
|
||||
|
||||
<!-- example:04-call -->
|
||||
|
|
@ -298,25 +298,25 @@ assert.ok(statuses.includes("released"));
|
|||
```
|
||||
|
||||
After the method, the fake database contains `["Buy milk"]`. It opened once,
|
||||
even though two objects held it. `SavedTodos` and `Database` become live
|
||||
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 must await readiness.
|
||||
|
||||
When both external handles are released, `SavedTodos` is disposed and
|
||||
releases its own database hold. With no holds left, the database closes; we
|
||||
wait for `closed.promise` to prove it. The released handle stays
|
||||
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 hold leaves. Zero disposes
|
||||
`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 held method or
|
||||
> **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 `rev` when its configuration should be reopened.
|
||||
> Disposal stops tasks and listeners, closes resources, then releases holds.
|
||||
> Disposal stops tasks and listeners, closes resources, then releases references.
|
||||
|
||||
## 5. Who is asking? One store, two views
|
||||
|
||||
|
|
@ -328,7 +328,7 @@ 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` defaults to the host principal, so owner and guest views share
|
||||
that store. Remote view records themselves are separate per principal key.
|
||||
that store. Remote view objects themselves are separate per principal key.
|
||||
|
||||
<!-- example:05-views -->
|
||||
```ts
|
||||
|
|
@ -349,10 +349,10 @@ function serveViews(api: Pick<Host, "serve">): void {
|
|||
if (list !== "family") throw forbidden("This list is not shared");
|
||||
const store = self.get(TodoStore, { list });
|
||||
if (principal !== "owner" && principal !== hostPrincipal) {
|
||||
return { kind: "viewer", rows: store.rows };
|
||||
return { variant: "viewer", rows: store.rows };
|
||||
}
|
||||
return {
|
||||
kind: "editor",
|
||||
variant: "editor",
|
||||
rows: store.rows,
|
||||
add: ({ text }, ctx) => {
|
||||
if (text.trim().length === 0) throw invalid("args", "A todo needs text");
|
||||
|
|
@ -365,7 +365,7 @@ function serveViews(api: Pick<Host, "serve">): void {
|
|||
|
||||
Here principals are the strings `"owner"` and `"guest"`, assigned by
|
||||
trusted application code. The built-in `hostPrincipal` can also edit.
|
||||
`principalKey` maps identity to record identity. Real applications should use
|
||||
`principalKey` maps identity to object identity. Real applications should use
|
||||
stable identity, connection provenance, and grant revision, not display names.
|
||||
|
||||
<!-- example:05-call -->
|
||||
|
|
@ -376,8 +376,8 @@ serveViews(host);
|
|||
const owner = host.get(Todos, { list: "family" }, { principal: "owner" });
|
||||
const guest = host.get(Todos, { list: "family" }, { principal: "guest" });
|
||||
await Promise.all([owner.ready, guest.ready]);
|
||||
assert.equal(owner.kind, "editor");
|
||||
assert.equal(guest.kind, "viewer");
|
||||
assert.equal(owner.variant, "editor");
|
||||
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 }]);
|
||||
await assert.rejects(guest.add({ text: "Not allowed" }), (error) => isChordError(error, "not_in_variant"));
|
||||
|
|
@ -386,7 +386,7 @@ await assert.rejects(owner.add({ text: " " }), (error) => isChordError(error, "i
|
|||
|
||||
The owner gets `editor`; the guest gets `viewer`. Both see the same milk
|
||||
todo. Calling the guest's `add` rejects with `not_in_variant`, even though
|
||||
TypeScript exposes methods from all variants on a handle. Whitespace-only text
|
||||
TypeScript exposes methods from all variants on a reference. 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.
|
||||
|
|
@ -394,8 +394,8 @@ 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 records; held handles
|
||||
> become `gone`. A UI watches `onStatus` and gets a new handle when appropriate.
|
||||
> `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
|
||||
|
|
@ -438,7 +438,7 @@ todos.release();
|
|||
```
|
||||
|
||||
The browser sees an empty snapshot, then the new milk todo. `ready` means
|
||||
the remote handle has bound to a live object; it does **not** guarantee that
|
||||
the remote reference has bound to a live object; it does **not** guarantee that
|
||||
subscribed state has arrived. That is why we separately await `hydrated` and
|
||||
`updated`. A remote readable starts at `undefined` and returns to
|
||||
`undefined` when its last subscriber leaves.
|
||||
|
|
@ -518,7 +518,7 @@ 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 handle aborts its task and resolves `stopped`.
|
||||
Releasing the activity reference aborts its task and resolves `stopped`.
|
||||
|
||||
Listeners can also subscribe to a resource's event stream with
|
||||
`listener(resource, select, fn)`: `select` gets the opened resource and
|
||||
|
|
@ -534,13 +534,14 @@ A selector that captures changing configuration should get a new listener name.
|
|||
## 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** declares what a
|
||||
plugin serves on one kind of host. A plugin can have server, worker, and browser
|
||||
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 eager objects: **eager** means the host creates and
|
||||
holds the object at startup, even if no screen asks for it. That keeps our menu
|
||||
contribution present. Lazy objects still activate only when held.
|
||||
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
|
||||
|
|
@ -567,7 +568,7 @@ const menuFacet: Facet = {
|
|||
});
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
click = () => api.root((ctx) => api.registry(Actions).get("addSample", ctx)!(ctx));
|
||||
},
|
||||
|
|
@ -575,7 +576,7 @@ const menuFacet: Facet = {
|
|||
),
|
||||
};
|
||||
const host = createHost();
|
||||
await host.load([menuFacet, storeFacet], { awaitEager: false });
|
||||
await host.load([menuFacet, storeFacet], { awaitHostObjects: false });
|
||||
await host.ready();
|
||||
assert.equal(await click!(), 1);
|
||||
assert.equal(host.registry(Actions).table().size, 1);
|
||||
|
|
@ -583,7 +584,7 @@ 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 eager activation. The facet declares `uses` so
|
||||
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`.
|
||||
|
|
@ -593,16 +594,16 @@ 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 eager objects. This example explicitly defers that barrier
|
||||
with `awaitEager: false`, then awaits `host.ready()`. That waits for a
|
||||
snapshot of the host's eager holds, not for task completion or later loads.
|
||||
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 handle, announce control readiness so its
|
||||
> an independent local bootstrap reference, announce control readiness so its
|
||||
> parent admits its link, and only then await `host.ready()`. Waiting for
|
||||
> upstream-dependent eager objects before admission would deadlock.
|
||||
> 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
|
||||
|
|
@ -613,9 +614,9 @@ copies in memory, so repeated loading has that limitation.
|
|||
|
||||
## 9. Change running code without losing the list
|
||||
|
||||
A handle is useful precisely because callers keep it while implementations
|
||||
change. An **epoch** identifies one committed set of code and registry tables.
|
||||
A root keeps the epoch it started with. New roots use the latest one.
|
||||
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
|
||||
|
||||
|
|
@ -693,10 +694,10 @@ stopEvents();
|
|||
```
|
||||
|
||||
The migration receives the old rows and their state version, `1`. It returns
|
||||
new rows; the handle now reads `[{ text: "BUY MILK", done: false }]`.
|
||||
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 record is gated. A **gate** waits for calls already inside that record to
|
||||
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.
|
||||
|
|
@ -704,13 +705,13 @@ they get `upgrade_conflict` and should retry in a new root.
|
|||
Changing a cell's schema also requires a state-version bump and a migration
|
||||
whose result fits the new schema. A change of meaning with the same schema,
|
||||
like this example, cannot be detected automatically. Real migrations must
|
||||
handle each stored version they support, not assume every installation is
|
||||
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 behavior
|
||||
contract version 2. The provider's `accepts[1]` adapter builds the implementation
|
||||
that version-1 consumers still expect.
|
||||
|
||||
<!-- example:09-contract -->
|
||||
|
|
@ -729,8 +730,8 @@ await host.reload([
|
|||
api.serve(
|
||||
RenamedStore,
|
||||
(input, principal, self) => {
|
||||
const { label, ...behavior } = todoFactory(input, principal, self);
|
||||
return { ...behavior, heading: label };
|
||||
const { label, ...implementation } = todoFactory(input, principal, self);
|
||||
return { ...implementation, heading: label };
|
||||
},
|
||||
{
|
||||
state: { version: 2 },
|
||||
|
|
@ -747,9 +748,9 @@ assert.equal(await newer.heading(null), "Todos for family");
|
|||
assert.equal(todos.rows.value?.[0]?.text, "BUY MILK");
|
||||
```
|
||||
|
||||
The old handle's `label` and the new handle's `heading` both return
|
||||
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
|
||||
behaviors, not handles; forwarding the existing method preserves its context.
|
||||
implementations, not references; forwarding the existing method preserves its context.
|
||||
We keep state version 2 here so this code-only change does not repeat migration.
|
||||
|
||||
> **Why:** Contract versions protect callers; state versions protect stored
|
||||
|
|
@ -762,9 +763,9 @@ We keep state version 2 here so this code-only change does not repeat migration.
|
|||
|
||||
## 10. Test the objects, not a whole deployment
|
||||
|
||||
Most behavior needs only a bare host. Use real factories for state tests, and a
|
||||
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.
|
||||
Here the fake objects what the editing view sends to the store.
|
||||
|
||||
<!-- example:10 -->
|
||||
```ts
|
||||
|
|
@ -793,23 +794,23 @@ await assert.rejects(owner.add({ text: "Too late" }), (error) => isChordError(er
|
|||
```
|
||||
|
||||
The fake received `["Test delegation"]`. Empty text failed before reaching
|
||||
the fake. The released handle rejects further calls with `released`.
|
||||
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 held through
|
||||
principal variants (5), a client over a memory link (6), and callers kept through
|
||||
reload (9). For remote state tests, await the value you need, not just
|
||||
`handle.ready`. Always unsubscribe and close hosts; leaked holds can hide
|
||||
`ref.ready`. 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
|
||||
> records calls rather than updating rows. Do not confuse that with persistence.
|
||||
> objects calls rather than updating rows. Do not confuse that with persistence.
|
||||
|
||||
## 11. Keep these habits
|
||||
|
||||
A temporary call handle should not accidentally become a permanent hold. A
|
||||
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.
|
||||
|
||||
|
|
@ -822,14 +823,14 @@ await screen.ready;
|
|||
await host.root(async (ctx) => {
|
||||
const temporary = ctx.get(TodoStore, { list: "family" });
|
||||
await temporary.add({ text: "Keep roots short" }, ctx);
|
||||
}); // The temporary handle is released here; screen still holds the store.
|
||||
}); // The temporary reference is released here; screen still holds the store.
|
||||
assert.equal(screen.openCount.value, 1);
|
||||
screen.release(); // No hold remains; without persistence, disposal loses these rows.
|
||||
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 hold. When the screen releases, no hold remains. This tutorial's cells
|
||||
are in memory: reload preserves a live record, but disposal or process restart
|
||||
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.
|
||||
|
||||
|
|
@ -841,17 +842,17 @@ explicit load/save policy.
|
|||
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 holds end with the call.
|
||||
- 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.
|
||||
- Subscribe before expecting remote state: readiness is not snapshot hydration.
|
||||
- Release handles and unsubscribe: otherwise unused objects remain held or observed.
|
||||
- Release references and unsubscribe: otherwise unused objects remain referenced or observed.
|
||||
- Bump state versions, contract versions, and resource revisions for their own changes:
|
||||
each protects a different boundary.
|
||||
- Await eager readiness before reporting startup success: declared is not yet live.
|
||||
- Keep hold dependencies acyclic: an object cannot wait for itself through another object.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export interface SourceOptions {
|
|||
readonly report: (error: unknown) => void;
|
||||
}
|
||||
|
||||
/** Checks that `ctx` may write a record's cells now; throws otherwise. */
|
||||
/** Checks that `ctx` may write an object's cells now; throws otherwise. */
|
||||
export type WriteGuard = (ctx: Ctx) => void;
|
||||
|
||||
let sequence = 0;
|
||||
|
|
@ -59,7 +59,7 @@ class Fanout<T> {
|
|||
}
|
||||
}
|
||||
|
||||
/** A record-owned state source (cell or derivation). Writes recompute dependents atomically. */
|
||||
/** 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;
|
||||
|
|
@ -269,8 +269,8 @@ const isReadable = (value: unknown): value is Readable<unknown> =>
|
|||
"value" in value;
|
||||
|
||||
/**
|
||||
* A synchronous derivation. Sources are record cells/derivations (recomputed atomically with the write) or other
|
||||
* readables such as handle state members (recomputed when they publish). Subscribers receive diffs.
|
||||
* 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>[];
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ 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 epoch, sends `commit` to every child, and only then starts what may make
|
||||
// new-epoch 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 epoch.
|
||||
// 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 epoch is fenced and reconciled with the same protocol.
|
||||
// A child that connects behind the server's code version is fenced and reconciled with the same protocol.
|
||||
|
||||
let reloadSeq = 0;
|
||||
|
||||
|
|
@ -41,13 +41,14 @@ export async function runClusterReload(
|
|||
plugins: readonly PluginRef[],
|
||||
options: ReloadOptions,
|
||||
): Promise<ReloadReport> {
|
||||
// Fenced children (behind, being reconciled) are not participants; they reconcile to the new epoch afterwards.
|
||||
// Fenced children (behind, being reconciled) are not participants; they reconcile to the new code version
|
||||
// afterwards.
|
||||
const participants = [...host.connections].filter(
|
||||
(connection) =>
|
||||
connection.role === "downstream" && connection.peer.kind === "host" && connection.open && connection.synced,
|
||||
);
|
||||
const epoch = host.peekNextEpoch();
|
||||
const reload = await beginReload(host, plugins, options, epoch);
|
||||
const codeVersion = host.peekNextCodeVersion();
|
||||
const reload = await beginReload(host, plugins, options, codeVersion);
|
||||
const id = ++reloadSeq;
|
||||
const deadline = options.voteDeadline ?? (options.gateDeadline ?? 5000) + (options.retireDeadline ?? 5000) + 5000;
|
||||
const votes = participants.map((connection) => {
|
||||
|
|
@ -56,7 +57,7 @@ export async function runClusterReload(
|
|||
connection.send({
|
||||
t: "prepare",
|
||||
reload: id,
|
||||
epoch,
|
||||
codeVersion,
|
||||
plugins: modulesFor(options.remote, name),
|
||||
options: {
|
||||
remove: modulesFor(options.remoteRemove, name),
|
||||
|
|
@ -92,7 +93,7 @@ export async function runClusterReload(
|
|||
const report = reload.commit(() => {
|
||||
for (const connection of participants) {
|
||||
connection.send({ t: "commit", reload: id });
|
||||
connection.peerEpoch = epoch;
|
||||
connection.peerCodeVersion = codeVersion;
|
||||
}
|
||||
});
|
||||
host.rememberRemoteModules(options.remote, options.remoteRemove);
|
||||
|
|
@ -109,16 +110,17 @@ export async function runClusterReload(
|
|||
}
|
||||
|
||||
/**
|
||||
* Brings a child that connected behind this host's epoch up to it: a reload of the child's modules (the ones
|
||||
* cluster reloads gave its name) to this host's current epoch, 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 epoch.
|
||||
* 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 (;;) {
|
||||
const epoch = host.current.id;
|
||||
const codeVersion = host.current.id;
|
||||
if (!connection.open) return;
|
||||
if (connection.peerEpoch === epoch) {
|
||||
connection.markSynced(epoch);
|
||||
if (connection.peerCodeVersion === codeVersion) {
|
||||
connection.markSynced(codeVersion);
|
||||
return;
|
||||
}
|
||||
const id = ++reloadSeq;
|
||||
|
|
@ -126,18 +128,18 @@ export async function reconcileChild(host: HostImpl, connection: Connection): Pr
|
|||
connection.send({
|
||||
t: "prepare",
|
||||
reload: id,
|
||||
epoch,
|
||||
codeVersion,
|
||||
plugins: modulesFor(host.remoteModules, connection.name),
|
||||
options: { remove: modulesFor(host.remoteRemoved, connection.name) },
|
||||
});
|
||||
await vote;
|
||||
if (host.current.id !== epoch) {
|
||||
// This host moved on meanwhile: abort and try again with the newer epoch.
|
||||
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.peerEpoch = epoch;
|
||||
connection.peerCodeVersion = codeVersion;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +218,7 @@ export class ChildReloads {
|
|||
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.epoch);
|
||||
operation.reload = await beginReload(this.#host, frame.plugins, frame.options, frame.codeVersion);
|
||||
if (operation.cancelled) throw cancelled();
|
||||
operation.state = "preparing";
|
||||
await operation.reload.prepare();
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { type Encoder, encoder } from "./delta/index.ts";
|
|||
import { ChordError, invalid } from "./errors.ts";
|
||||
import type { HostImpl } from "./host.ts";
|
||||
import { type Link, Packed, unpack } from "./link.ts";
|
||||
import { type CtxImpl, HandleImpl, kindOf } from "./record.ts";
|
||||
import type { RemoteChannel, RemoteHandle, StateSink } from "./remote.ts";
|
||||
import { type CtxImpl, RefImpl, variantOf } from "./object.ts";
|
||||
import type { RemoteChannel, RemoteRef, StateSink } from "./remote.ts";
|
||||
import { assertValid } from "./schema.ts";
|
||||
import type { Ctx, Principal, Readable, StateEvent } from "./types.ts";
|
||||
import { type Frame, fromWireError, isFrame, type RootRef, toWireError } from "./wire.ts";
|
||||
import { type Frame, fromWireError, isFrame, PROTOCOL_VERSION, type RootRef, toWireError } from "./wire.ts";
|
||||
|
||||
export interface LinkOptions {
|
||||
/** Milliseconds between pings (default 15000). */
|
||||
|
|
@ -19,6 +19,11 @@ export interface LinkOptions {
|
|||
* applies when both ends acknowledge frames and the link reports sizes; `Infinity` never holds state back.
|
||||
*/
|
||||
readonly window?: number;
|
||||
/**
|
||||
* The peer speaks another link protocol version (`peer`: undefined if it did not say): the link closes, and should
|
||||
* not be reconnected with the same code. A browser reloads its page, for instance.
|
||||
*/
|
||||
readonly onProtocolMismatch?: (versions: { readonly local: number; readonly peer: number | undefined }) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_WINDOW = 256 * 1024;
|
||||
|
|
@ -37,8 +42,8 @@ export interface AcceptOptions extends LinkOptions {
|
|||
/** The principal of whatever connects, from the app's authenticator. Every request on the link acts as it. */
|
||||
readonly principal: Principal;
|
||||
/**
|
||||
* What the peer is, decided here and never by its `hello`: `client` (default; a browser: no roots, no epochs, not
|
||||
* a reload participant, not addressable) or `host` (a child host; needs `name`).
|
||||
* What the peer is, decided here and never by its `hello`: `client` (default; a browser: no roots, no code versions,
|
||||
* not a reload participant, not addressable) or `host` (a child host; needs `name`).
|
||||
*/
|
||||
readonly kind?: "host" | "client";
|
||||
/** A child host's name: its hello must match, and it is reachable `at` this name. */
|
||||
|
|
@ -60,8 +65,8 @@ export interface Peer {
|
|||
|
||||
/** A hold the peer made on this host: served locally or relayed to another link. */
|
||||
type Served =
|
||||
| { readonly kind: "local"; readonly handle: HandleImpl; readonly principal: Principal; readonly feeds: Set<number> }
|
||||
| { readonly kind: "relay"; readonly relay: RelayHold };
|
||||
| { readonly kind: "local"; readonly ref: RefImpl; readonly principal: Principal; readonly feeds: Set<number> }
|
||||
| { readonly kind: "relay"; readonly relay: RelayRef };
|
||||
|
||||
type Pending = { readonly resolve: (value: unknown) => void; readonly reject: (error: unknown) => void };
|
||||
|
||||
|
|
@ -74,7 +79,7 @@ const unavailable = (message: string): ChordError => new ChordError("unavailable
|
|||
* calls queued while the route resolves, and the downstream twins once forwarded. `close` undoes all of it exactly
|
||||
* once (cancels calls downstream, drops subscriptions, releases the downstream hold, rejects waiters).
|
||||
*/
|
||||
export class RelayHold {
|
||||
export class RelayRef {
|
||||
readonly origin: Connection;
|
||||
readonly frame: Extract<Frame, { t: "hold" }>;
|
||||
readonly principal: Principal;
|
||||
|
|
@ -119,7 +124,7 @@ export class RelayHold {
|
|||
if (this.closed) return;
|
||||
this.down = down;
|
||||
this.downId = down.nextId();
|
||||
down.relayedHolds.set(this.downId, this);
|
||||
down.relayedRefs.set(this.downId, this);
|
||||
down.send({
|
||||
...this.frame,
|
||||
id: this.downId,
|
||||
|
|
@ -182,7 +187,7 @@ export class RelayHold {
|
|||
for (const originSubId of [...this.subs.keys()]) this.unsubscribe(originSubId, options.releaseDown);
|
||||
if (this.origin.served.get(this.originId)?.kind === "relay") this.origin.served.delete(this.originId);
|
||||
if (this.down !== undefined) {
|
||||
this.down.relayedHolds.delete(this.downId);
|
||||
this.down.relayedRefs.delete(this.downId);
|
||||
if (options.releaseDown) this.down.send({ t: "release", id: this.downId });
|
||||
}
|
||||
for (const waiter of this.#waiters) waiter.reject(error);
|
||||
|
|
@ -193,13 +198,13 @@ export class RelayHold {
|
|||
|
||||
/** One relayed call: settles once, by result, cancellation or the relay closing; removes both mappings. */
|
||||
export class RelayCall {
|
||||
readonly relay: RelayHold;
|
||||
readonly relay: RelayRef;
|
||||
readonly originId: number;
|
||||
readonly downId: number;
|
||||
readonly #pending: Pending;
|
||||
#settled = false;
|
||||
|
||||
constructor(relay: RelayHold, originId: number, downId: number, pending: Pending) {
|
||||
constructor(relay: RelayRef, originId: number, downId: number, pending: Pending) {
|
||||
this.relay = relay;
|
||||
this.originId = originId;
|
||||
this.downId = downId;
|
||||
|
|
@ -228,7 +233,7 @@ export class RelayCall {
|
|||
// ─── connection ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One end of a link on a host: consumer of remote handles, provider for the peer's holds, and relay.
|
||||
* One end of a link on a host: consumer of remote references, provider for the peer's holds, and relay.
|
||||
*
|
||||
* Flow control
|
||||
* ------------
|
||||
|
|
@ -264,13 +269,13 @@ export class Connection implements RemoteChannel {
|
|||
readonly restamp: ((principal: Principal) => Principal | undefined) | undefined;
|
||||
/** Bumped when the stamped principal is revoked or re-stamped: relays from before are refused. */
|
||||
generation = 0;
|
||||
/** The epoch a downstream host peer runs (from its hello, then from commits). */
|
||||
peerEpoch: number | undefined;
|
||||
/** A downstream host peer is usable once it runs this host's current epoch (see `HostImpl.peerHello`). */
|
||||
/** The code version a downstream host peer runs (from its hello, then from commits). */
|
||||
peerCodeVersion: number | undefined;
|
||||
/** A downstream host peer is usable once it runs this host's current code version (see `HostImpl.peerHello`). */
|
||||
synced: boolean;
|
||||
/** Epochs this child announced while behind this host (fenced): this host never ran them. */
|
||||
readonly fencedEpochs = new Set<number>();
|
||||
/** Roots of fenced epochs, by the epoch of this host they were admitted in. */
|
||||
/** Code versions this child announced while behind this host (fenced): this host never ran them. */
|
||||
readonly fencedCodeVersions = new Set<number>();
|
||||
/** Roots of fenced code versions, by the code version of this host they were admitted in. */
|
||||
readonly #admitted = new Map<string, number>();
|
||||
#parentName: string | undefined;
|
||||
readonly #syncWaiters = new Set<Pending>();
|
||||
|
|
@ -290,8 +295,11 @@ export class Connection implements RemoteChannel {
|
|||
readonly #pending = new Map<number, PendingState>();
|
||||
#received = 0;
|
||||
#ackTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
readonly #onProtocolMismatch: LinkOptions["onProtocolMismatch"];
|
||||
/** The peer speaks another protocol version: nothing it sends is processed any more. */
|
||||
#mismatched = false;
|
||||
// consumer side: what this host asked of the peer
|
||||
readonly remotes = new Map<number, RemoteHandle>();
|
||||
readonly remotes = new Map<number, RemoteRef>();
|
||||
readonly calls = new Map<number, Pending>();
|
||||
readonly subs = new Map<number, (frame: StateSink) => void>();
|
||||
// provider side: what the peer asked of this host
|
||||
|
|
@ -299,11 +307,11 @@ export class Connection implements RemoteChannel {
|
|||
readonly incoming = new Map<number, AbortController>();
|
||||
readonly feeds = new Map<number, () => void>();
|
||||
/** Origin side of relays: the peer's sub ids that are relayed. */
|
||||
readonly relaySubs = new Map<number, RelayHold>();
|
||||
readonly relaySubs = new Map<number, RelayRef>();
|
||||
// downstream side of relays: ids this host used on this link
|
||||
readonly relayedHolds = new Map<number, RelayHold>();
|
||||
readonly relayedRefs = new Map<number, RelayRef>();
|
||||
readonly relayedCalls = new Map<number, RelayCall>();
|
||||
readonly relayedSubs = new Map<number, { readonly relay: RelayHold; readonly originSubId: number }>();
|
||||
readonly relayedSubs = new Map<number, { readonly relay: RelayRef; readonly originSubId: number }>();
|
||||
/** Reload votes awaited from this (child) peer. */
|
||||
readonly votes = new Map<number, Pending>();
|
||||
|
||||
|
|
@ -327,9 +335,17 @@ export class Connection implements RemoteChannel {
|
|||
this.synced = !(this.role === "downstream" && this.peer.kind === "host");
|
||||
this.#window = config.window ?? DEFAULT_WINDOW;
|
||||
this.#maxCalls = config.maxCalls ?? Number.POSITIVE_INFINITY;
|
||||
this.#onProtocolMismatch = config.onProtocolMismatch;
|
||||
link.onClose((reason) => this.#closed(reason));
|
||||
link.receive((frame) => this.#receive(frame));
|
||||
this.send({ t: "hello", name: host.name, kind: host.kind, epoch: host.current.id, acks: true });
|
||||
this.send({
|
||||
t: "hello",
|
||||
protocol: PROTOCOL_VERSION,
|
||||
name: host.name,
|
||||
kind: host.kind,
|
||||
codeVersion: host.current.id,
|
||||
acks: true,
|
||||
});
|
||||
const interval = config.pingInterval ?? 15_000;
|
||||
const timeout = config.pingTimeout ?? 45_000;
|
||||
// A link that was already closed closed this connection above; it needs no keepalive.
|
||||
|
|
@ -421,15 +437,15 @@ export class Connection implements RemoteChannel {
|
|||
return this.#nextId++;
|
||||
}
|
||||
|
||||
/** Resolves once the peer is usable (a host peer runs this host's epoch); rejects if the link closes first. */
|
||||
/** Resolves once the peer is usable (a host peer runs this host's code version); rejects if the link closes first. */
|
||||
whenSynced(): Promise<void> {
|
||||
if (!this.open) return Promise.reject(unavailable(`link to ${this.name ?? "peer"} closed`));
|
||||
if (this.synced) return Promise.resolve();
|
||||
return new Promise<void>((resolve, reject) => this.#syncWaiters.add({ resolve: resolve as () => void, reject }));
|
||||
}
|
||||
|
||||
markSynced(epoch: number): void {
|
||||
this.peerEpoch = epoch;
|
||||
markSynced(codeVersion: number): void {
|
||||
this.peerCodeVersion = codeVersion;
|
||||
this.synced = true;
|
||||
for (const waiter of this.#syncWaiters) waiter.resolve(undefined);
|
||||
this.#syncWaiters.clear();
|
||||
|
|
@ -447,6 +463,21 @@ export class Connection implements RemoteChannel {
|
|||
}
|
||||
|
||||
/** A protocol violation: the peer cannot be trusted with anything on this link any more. */
|
||||
/**
|
||||
* Stops talking to a peer of another protocol version: reports it and closes the link. The close reason tells the
|
||||
* peer this side's version (`protocol-version <n>`); a peer that noticed first told us the same way (`#closed`).
|
||||
*/
|
||||
#protocolMismatch(peer: number | undefined, close = true): void {
|
||||
if (this.#mismatched) return;
|
||||
this.#mismatched = true;
|
||||
try {
|
||||
this.#onProtocolMismatch?.({ local: PROTOCOL_VERSION, peer });
|
||||
} catch (error) {
|
||||
this.host.report(error);
|
||||
}
|
||||
if (close) this.link.close(`protocol-version ${PROTOCOL_VERSION}`);
|
||||
}
|
||||
|
||||
#violation(reason: string): void {
|
||||
this.host.report(new ChordError("invalid", `link to ${this.name ?? "peer"}: ${reason}`, { reason: "protocol" }));
|
||||
this.link.close(`protocol error: ${reason}`);
|
||||
|
|
@ -454,19 +485,19 @@ export class Connection implements RemoteChannel {
|
|||
|
||||
// ─── consumer side (RemoteChannel) ───
|
||||
|
||||
hold(handle: RemoteHandle): number {
|
||||
hold(ref: RemoteRef): number {
|
||||
const id = this.nextId();
|
||||
this.remotes.set(id, handle);
|
||||
handle.bound(this, id);
|
||||
const principal = handle.principal;
|
||||
this.remotes.set(id, ref);
|
||||
ref.bound(this, id);
|
||||
const principal = ref.principal;
|
||||
this.send({
|
||||
t: "hold",
|
||||
id,
|
||||
at: handle.at,
|
||||
token: handle.contract.description,
|
||||
input: handle.input,
|
||||
at: ref.at,
|
||||
token: ref.contract.description,
|
||||
input: ref.input,
|
||||
...(principal === undefined ? {} : { principal }),
|
||||
...(handle.owner === "client" ? { client: true } : {}),
|
||||
...(ref.owner === "client" ? { client: true } : {}),
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
|
@ -500,7 +531,7 @@ export class Connection implements RemoteChannel {
|
|||
reject(error);
|
||||
},
|
||||
});
|
||||
// Clients never carry roots or epochs: the server starts roots for them.
|
||||
// Clients never carry roots or code versions: the server starts roots for them.
|
||||
const carried = root !== undefined && this.host.kind === "host";
|
||||
if (carried) this.host.rootSent(root.rootId, this);
|
||||
this.send({ t: "call", id, hold, member, args, ...(carried ? { root } : {}) });
|
||||
|
|
@ -521,6 +552,7 @@ export class Connection implements RemoteChannel {
|
|||
// ─── receiving ───
|
||||
|
||||
#receive(frame: unknown): void {
|
||||
if (this.#mismatched) return;
|
||||
this.#lastSeen = Date.now();
|
||||
if (!isFrame(frame)) {
|
||||
this.#violation("malformed frame");
|
||||
|
|
@ -596,7 +628,7 @@ export class Connection implements RemoteChannel {
|
|||
}
|
||||
if (frame.t === "invalidate") {
|
||||
this.host.invalidatePrincipal(frame.pattern).catch((error: unknown) => this.host.report(error));
|
||||
} else if (frame.t === "retire") this.host.allowRetire(frame.epoch);
|
||||
} else if (frame.t === "retire") this.host.allowRetire(frame.codeVersion);
|
||||
else this.host.onReloadFrame(this, frame);
|
||||
return;
|
||||
case "vote": {
|
||||
|
|
@ -614,10 +646,14 @@ export class Connection implements RemoteChannel {
|
|||
}
|
||||
|
||||
/**
|
||||
* The peer's hello must agree with what this side decided about it. A downstream host announces its epoch: the
|
||||
* host either finds it in step or fences and reconciles it (never renumbers).
|
||||
* The peer's hello must agree with what this side decided about it. A downstream host announces its code version:
|
||||
* the host either finds it in step or fences and reconciles it (never renumbers).
|
||||
*/
|
||||
#onHello(frame: Extract<Frame, { t: "hello" }>): void {
|
||||
if ((frame.protocol ?? 1) !== PROTOCOL_VERSION) {
|
||||
this.#protocolMismatch(frame.protocol ?? 1);
|
||||
return;
|
||||
}
|
||||
if (this.#peerAcks === undefined) {
|
||||
this.#peerAcks = frame.acks === true;
|
||||
if (!this.#peerAcks) {
|
||||
|
|
@ -639,7 +675,7 @@ export class Connection implements RemoteChannel {
|
|||
);
|
||||
return;
|
||||
}
|
||||
if (this.peer.kind === "host") this.host.peerHello(this, frame.epoch);
|
||||
if (this.peer.kind === "host") this.host.peerHello(this, frame.codeVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -667,43 +703,43 @@ export class Connection implements RemoteChannel {
|
|||
this.send({ t: "gone", id: frame.id, error: toWireError(unavailable(`${this.host.name} does not relay`)) });
|
||||
return;
|
||||
}
|
||||
const relay = new RelayHold(this, frame, principal, client);
|
||||
const relay = new RelayRef(this, frame, principal, client);
|
||||
this.served.set(frame.id, { kind: "relay", relay });
|
||||
this.host.relay(relay).catch((error: unknown) => relay.close(error, { releaseDown: true, notify: true }));
|
||||
return;
|
||||
}
|
||||
let handle: HandleImpl;
|
||||
let ref: RefImpl;
|
||||
try {
|
||||
const token = contractFromDescription(frame.token);
|
||||
if (token.scope !== "remote") throw invalid("token", `${token.name} is local; only remote types cross links`);
|
||||
const acquired = this.host.acquire(token, frame.input, { principal }, client ? "client" : "connection");
|
||||
if (!(acquired instanceof HandleImpl)) throw unavailable("not a local handle");
|
||||
handle = acquired;
|
||||
if (!(acquired instanceof RefImpl)) throw unavailable("not a local reference");
|
||||
ref = acquired;
|
||||
} catch (error) {
|
||||
this.send({ t: "gone", id: frame.id, error: toWireError(client ? clientError(error) : error) });
|
||||
return;
|
||||
}
|
||||
const served: Served = { kind: "local", handle, principal, feeds: new Set() };
|
||||
const served: Served = { kind: "local", ref, principal, feeds: new Set() };
|
||||
this.served.set(frame.id, served);
|
||||
const shape = (): void => {
|
||||
const current = handle.shape;
|
||||
const current = ref.shape;
|
||||
if (current === undefined || this.served.get(frame.id) !== served) return;
|
||||
this.send({
|
||||
t: "shape",
|
||||
id: frame.id,
|
||||
kind: current.kind,
|
||||
variant: current.variant,
|
||||
members: current.members,
|
||||
contract: current.contract,
|
||||
epoch: current.epoch,
|
||||
codeVersion: current.codeVersion,
|
||||
});
|
||||
};
|
||||
handle.onShape(shape);
|
||||
handle.goneListeners.add((error) => {
|
||||
ref.onShape(shape);
|
||||
ref.goneListeners.add((error) => {
|
||||
if (this.served.get(frame.id) !== served) return;
|
||||
this.#dropServed(frame.id, false);
|
||||
this.send({ t: "gone", id: frame.id, error: toWireError(error) });
|
||||
});
|
||||
if (handle.status === "live") shape();
|
||||
if (ref.status === "live") shape();
|
||||
}
|
||||
|
||||
/** Forgets a served hold: unsubscribes its feeds and releases it (locally or downstream). */
|
||||
|
|
@ -719,7 +755,7 @@ export class Connection implements RemoteChannel {
|
|||
return;
|
||||
}
|
||||
for (const feed of served.feeds) this.#onUnsub(feed);
|
||||
if (release) served.handle.release();
|
||||
if (release) served.ref.release();
|
||||
}
|
||||
|
||||
#onCall(frame: Extract<Frame, { t: "call" }>): void {
|
||||
|
|
@ -749,19 +785,19 @@ export class Connection implements RemoteChannel {
|
|||
const options = { principal, signal: controller.signal };
|
||||
const run =
|
||||
served.kind === "local"
|
||||
? (ctx: Ctx) => served.handle.record.call(served.handle, frame.member, unpack(frame.args), ctx as CtxImpl)
|
||||
? (ctx: Ctx) => served.ref.object.call(served.ref, frame.member, unpack(frame.args), ctx as CtxImpl)
|
||||
: (ctx: Ctx) =>
|
||||
relayCall(
|
||||
served.relay,
|
||||
frame,
|
||||
{ rootId: ctx.rootId, epoch: ctx.epoch },
|
||||
{ rootId: ctx.rootId, codeVersion: ctx.codeVersion },
|
||||
controller.signal,
|
||||
this.host,
|
||||
);
|
||||
const result =
|
||||
root === undefined
|
||||
? this.host.root(run, options)
|
||||
: this.host.joinRoot(root.rootId, root.epoch, run, { ...options, from: this });
|
||||
: this.host.joinRoot(root.rootId, root.codeVersion, run, { ...options, from: this });
|
||||
result
|
||||
.then(
|
||||
(value) => this.send({ t: "result", id: frame.id, value }),
|
||||
|
|
@ -771,19 +807,21 @@ export class Connection implements RemoteChannel {
|
|||
}
|
||||
|
||||
/**
|
||||
* A child that connected behind this host runs roots of an epoch this host never had (it was fenced and is being,
|
||||
* or was, reconciled). Such a root is admitted in this host's current epoch at its first call here, and keeps that
|
||||
* epoch for its later calls (`superseded` once this host retired it). Other unknown epochs stay `superseded`.
|
||||
* A child that connected behind this host runs roots of a code version this host never had (it was fenced and is
|
||||
* being, or was, reconciled). Such a root is admitted in this host's current code version at its first call here,
|
||||
* and keeps that code version for its later calls (`superseded` once this host retired it). Other unknown code
|
||||
* versions stay `superseded`.
|
||||
*/
|
||||
#admitRoot(root: RootRef | undefined): RootRef | undefined {
|
||||
if (root === undefined || this.role !== "downstream" || !this.fencedEpochs.has(root.epoch)) return root;
|
||||
if (this.host.hasEpoch(root.epoch)) return root;
|
||||
let epoch = this.#admitted.get(root.rootId);
|
||||
if (epoch === undefined) {
|
||||
epoch = this.host.current.id;
|
||||
this.#admitted.set(root.rootId, epoch);
|
||||
if (root === undefined || this.role !== "downstream" || !this.fencedCodeVersions.has(root.codeVersion))
|
||||
return root;
|
||||
if (this.host.hasCodeVersion(root.codeVersion)) return root;
|
||||
let codeVersion = this.#admitted.get(root.rootId);
|
||||
if (codeVersion === undefined) {
|
||||
codeVersion = this.host.current.id;
|
||||
this.#admitted.set(root.rootId, codeVersion);
|
||||
}
|
||||
return { rootId: root.rootId, epoch };
|
||||
return { rootId: root.rootId, codeVersion };
|
||||
}
|
||||
|
||||
#onSub(frame: Extract<Frame, { t: "sub" }>): void {
|
||||
|
|
@ -800,7 +838,7 @@ export class Connection implements RemoteChannel {
|
|||
served.relay.subscribe(frame.id, frame.member);
|
||||
return;
|
||||
}
|
||||
const readable = served.handle.member(frame.member) as Readable<unknown> | undefined;
|
||||
const readable = served.ref.member(frame.member) as Readable<unknown> | undefined;
|
||||
if (readable === undefined || typeof readable.events !== "function") {
|
||||
this.send({
|
||||
t: "state",
|
||||
|
|
@ -816,11 +854,11 @@ export class Connection implements RemoteChannel {
|
|||
// Too many ops wait for a slow peer: the current value replaces them.
|
||||
event = { type: "reset", value: event.value };
|
||||
}
|
||||
// A loading provider handle has nothing yet: the hydrated value follows as a reset.
|
||||
// A loading provider reference has nothing yet: the hydrated value follows as a reset.
|
||||
if (event.value === undefined) return;
|
||||
// Snapshots are validated against the effective schema once per (re)subscription; ops only in dev mode.
|
||||
try {
|
||||
validateSnapshot(served.handle, frame.member, event.value);
|
||||
validateSnapshot(served.ref, frame.member, event.value);
|
||||
} catch (error) {
|
||||
this.sendState({ t: "state", id: frame.id, error: toWireError(error) });
|
||||
return;
|
||||
|
|
@ -848,20 +886,25 @@ export class Connection implements RemoteChannel {
|
|||
}
|
||||
|
||||
#onHoldReply(frame: Extract<Frame, { t: "shape" | "gone" }>): void {
|
||||
const relay = this.relayedHolds.get(frame.id);
|
||||
const relay = this.relayedRefs.get(frame.id);
|
||||
if (relay !== undefined) {
|
||||
if (frame.t === "gone") relay.close(fromWireError(frame.error), { releaseDown: false, notify: true });
|
||||
else if (!relay.closed) relay.origin.send({ ...frame, id: relay.originId });
|
||||
return;
|
||||
}
|
||||
const handle = this.remotes.get(frame.id);
|
||||
if (handle === undefined) return;
|
||||
const ref = this.remotes.get(frame.id);
|
||||
if (ref === undefined) return;
|
||||
if (frame.t === "gone") {
|
||||
this.remotes.delete(frame.id);
|
||||
handle.gone(fromWireError(frame.error));
|
||||
ref.gone(fromWireError(frame.error));
|
||||
return;
|
||||
}
|
||||
handle.shaped({ kind: frame.kind, members: frame.members, contract: frame.contract, epoch: frame.epoch });
|
||||
ref.shaped({
|
||||
variant: frame.variant,
|
||||
members: frame.members,
|
||||
contract: frame.contract,
|
||||
codeVersion: frame.codeVersion,
|
||||
});
|
||||
}
|
||||
|
||||
#onResult(frame: Extract<Frame, { t: "result" }>): void {
|
||||
|
|
@ -915,6 +958,8 @@ export class Connection implements RemoteChannel {
|
|||
// ─── lifetime ───
|
||||
|
||||
#closed(reason: string): void {
|
||||
const told = /^protocol-version (\d+)$/.exec(reason);
|
||||
if (told !== null && Number(told[1]) !== PROTOCOL_VERSION) this.#protocolMismatch(Number(told[1]), false);
|
||||
if (this.#pinger !== undefined) clearInterval(this.#pinger);
|
||||
if (this.#ackTimer !== undefined) clearTimeout(this.#ackTimer);
|
||||
this.#pending.clear();
|
||||
|
|
@ -931,14 +976,14 @@ export class Connection implements RemoteChannel {
|
|||
for (const waiter of this.#syncWaiters) waiter.reject(error);
|
||||
this.#syncWaiters.clear();
|
||||
// As downstream of relays: their origins lose the hold.
|
||||
for (const relay of [...this.relayedHolds.values()]) relay.close(error, { releaseDown: false, notify: true });
|
||||
this.relayedHolds.clear();
|
||||
for (const relay of [...this.relayedRefs.values()]) relay.close(error, { releaseDown: false, notify: true });
|
||||
this.relayedRefs.clear();
|
||||
this.relayedCalls.clear();
|
||||
this.relayedSubs.clear();
|
||||
for (const handle of this.remotes.values()) {
|
||||
for (const ref of this.remotes.values()) {
|
||||
// An upstream link may be replaced (reconnect); a downstream host that went away is gone.
|
||||
if (this.role === "upstream") handle.detached();
|
||||
else handle.gone(error);
|
||||
if (this.role === "upstream") ref.detached();
|
||||
else ref.gone(error);
|
||||
}
|
||||
this.remotes.clear();
|
||||
this.host.connectionClosed(this);
|
||||
|
|
@ -950,7 +995,7 @@ export class Connection implements RemoteChannel {
|
|||
* cancel (or the origin going away) is forwarded downstream and removes both mappings.
|
||||
*/
|
||||
async function relayCall(
|
||||
relay: RelayHold,
|
||||
relay: RelayRef,
|
||||
frame: Extract<Frame, { t: "call" }>,
|
||||
root: RootRef,
|
||||
signal: AbortSignal,
|
||||
|
|
@ -982,17 +1027,19 @@ async function relayCall(
|
|||
* Validates a snapshot against the schema the consumer is promised: the provider's own schema for a direct binding,
|
||||
* the consumer's contract for one through an `accepts` adapter (the adapter's view is what the consumer gets).
|
||||
*/
|
||||
function validateSnapshot(handle: HandleImpl, member: string, value: unknown): void {
|
||||
function validateSnapshot(ref: RefImpl, member: string, value: unknown): void {
|
||||
if (value === undefined) return;
|
||||
const installed = handle.record.currentInstalled();
|
||||
const installed = ref.object.currentInstalled();
|
||||
if (installed === undefined) return;
|
||||
const view = handle.currentView();
|
||||
const kind = view === undefined ? kindOf(installed.behavior) : kindOf(view);
|
||||
const view = ref.currentView();
|
||||
const variant = view === undefined ? variantOf(installed.implementation) : variantOf(view);
|
||||
const description =
|
||||
view !== undefined && view !== installed.behavior
|
||||
? handle.contract.description.variants[kind ?? LOCAL_VARIANT]?.[member]
|
||||
: installed.service.contract.description.variants[kindOf(installed.behavior) ?? LOCAL_VARIANT]?.[member];
|
||||
if (description?.kind === "state") assertValid("state", `${handle.address}.${member}`, description.schema, value);
|
||||
view !== undefined && view !== installed.implementation
|
||||
? ref.contract.description.variants[variant ?? LOCAL_VARIANT]?.[member]
|
||||
: installed.service.contract.description.variants[variantOf(installed.implementation) ?? LOCAL_VARIANT]?.[
|
||||
member
|
||||
];
|
||||
if (description?.kind === "state") assertValid("state", `${ref.address}.${member}`, description.schema, value);
|
||||
}
|
||||
|
||||
/** Clients (browsers) holding an outdated contract are told to reload their code. */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { type Static, type TSchema, Type } from "typebox";
|
||||
import { ChordError } from "./errors.ts";
|
||||
import { canonicalJson, fits, hash64 } from "./schema.ts";
|
||||
import type { Ctx, Exposed, HandleBase, Readable, StateSource } from "./types.ts";
|
||||
import type { Ctx, Exposed, Readable, RefBase, StateSource } from "./types.ts";
|
||||
|
||||
const MEMBER = Symbol("chord.member");
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ export interface LocalDef {
|
|||
readonly members?: Readonly<Record<string, LocalMember>>;
|
||||
}
|
||||
|
||||
// A description travels as a hold's contract token (wire.ts), so it is declared once, as the schema that validates it.
|
||||
// A description travels as a reference's contract token (wire.ts), so it is declared once, as the schema that validates it.
|
||||
// `TSchema` is an empty interface: any object is one, which is all a peer's schema is checked for here.
|
||||
const SchemaObject = Type.Unsafe<TSchema>(Type.Object({}));
|
||||
|
||||
|
|
@ -93,9 +93,9 @@ export interface LocalContract<D extends LocalDef = LocalDef> extends ContractBa
|
|||
|
||||
export type AnyContract = RemoteContract | LocalContract;
|
||||
|
||||
/** Names a handle uses itself; members may not use them. */
|
||||
/** Names a reference uses itself; members may not use them. */
|
||||
export const RESERVED_MEMBERS: ReadonlySet<string> = new Set([
|
||||
"kind",
|
||||
"variant",
|
||||
"status",
|
||||
"ready",
|
||||
"shape",
|
||||
|
|
@ -208,7 +208,7 @@ export function coverage(provider: Description, consumer: Description): string |
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/** Maps the provider's current behavior to the view a consumer of an older contract version expects. */
|
||||
/** 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>>;
|
||||
|
||||
|
|
@ -243,30 +243,30 @@ type StateOf<V> = V extends { readonly state: infer S extends Readonly<Record<st
|
|||
type StaticOf<S> = S extends TSchema ? Static<S> : never;
|
||||
type MethodsOf<V> = V extends { readonly methods: infer M extends Readonly<Record<string, MethodSpec>> } ? M : Empty;
|
||||
|
||||
export type Implementation<M> = M extends MethodSpec<infer A, infer R>
|
||||
export type MethodImplementation<M> = M extends MethodSpec<infer A, infer R>
|
||||
? (args: Static<A>, ctx: Ctx) => Static<R> | Promise<Static<R>>
|
||||
: never;
|
||||
export type Call<M> = M extends MethodSpec<infer A, infer R>
|
||||
? (args: Static<A>, ctx?: Ctx) => Promise<Static<R>>
|
||||
: never;
|
||||
|
||||
type VariantBehavior<K, V> = { readonly kind: K } & {
|
||||
type VariantImplementation<K, V> = { readonly variant: K } & {
|
||||
readonly [S in keyof StateOf<V>]: StateSource<StaticOf<StateOf<V>[S]>>;
|
||||
} & {
|
||||
readonly [M in keyof MethodsOf<V>]: Implementation<MethodsOf<V>[M]>;
|
||||
readonly [M in keyof MethodsOf<V>]: MethodImplementation<MethodsOf<V>[M]>;
|
||||
};
|
||||
type VariantHandle<V> = { readonly [S in keyof StateOf<V>]: Readable<StaticOf<StateOf<V>[S]> | undefined> } & {
|
||||
type VariantRef<V> = { readonly [S in keyof StateOf<V>]: Readable<StaticOf<StateOf<V>[S]> | undefined> } & {
|
||||
readonly [M in keyof MethodsOf<V>]: Call<MethodsOf<V>[M]>;
|
||||
};
|
||||
|
||||
type LocalBehaviorMember<M> = M extends MethodSpec
|
||||
? Implementation<M>
|
||||
type LocalImplementationMember<M> = M extends MethodSpec
|
||||
? MethodImplementation<M>
|
||||
: M extends OpaqueSpec<infer T>
|
||||
? T | Exposed<T>
|
||||
: M extends TSchema
|
||||
? StateSource<Static<M>>
|
||||
: never;
|
||||
type LocalHandleMember<M> = M extends MethodSpec
|
||||
type LocalRefMember<M> = M extends MethodSpec
|
||||
? Call<M>
|
||||
: M extends OpaqueSpec<infer T>
|
||||
? T | undefined
|
||||
|
|
@ -280,17 +280,20 @@ type LocalMembers<D> = D extends { readonly members: infer M extends Readonly<Re
|
|||
export type InputOf<C> = C extends { readonly def: { readonly input: infer I extends TSchema } } ? Static<I> : never;
|
||||
|
||||
/** What a factory returns for contract C. */
|
||||
export type BehaviorOf<C> = C extends RemoteContract<infer D>
|
||||
? { [K in keyof D["variants"]]: VariantBehavior<K, D["variants"][K]> }[keyof D["variants"]]
|
||||
export type ImplementationOf<C> = C extends RemoteContract<infer D>
|
||||
? { [K in keyof D["variants"]]: VariantImplementation<K, D["variants"][K]> }[keyof D["variants"]]
|
||||
: C extends LocalContract<infer D>
|
||||
? { readonly [K in keyof LocalMembers<D>]: LocalBehaviorMember<LocalMembers<D>[K]> } & Record<string, unknown>
|
||||
? { readonly [K in keyof LocalMembers<D>]: LocalImplementationMember<LocalMembers<D>[K]> } & Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
: never;
|
||||
|
||||
/** Late-bound members a handle exposes for contract C (all variants merged). */
|
||||
/** Late-bound members a reference exposes for contract C (all variants merged). */
|
||||
export type MembersOf<C> = C extends RemoteContract<infer D>
|
||||
? UnionToIntersection<{ [K in keyof D["variants"]]: VariantHandle<D["variants"][K]> }[keyof D["variants"]]>
|
||||
? UnionToIntersection<{ [K in keyof D["variants"]]: VariantRef<D["variants"][K]> }[keyof D["variants"]]>
|
||||
: C extends LocalContract<infer D>
|
||||
? { readonly [K in keyof LocalMembers<D>]: LocalHandleMember<LocalMembers<D>[K]> }
|
||||
? { readonly [K in keyof LocalMembers<D>]: LocalRefMember<LocalMembers<D>[K]> }
|
||||
: never;
|
||||
|
||||
export type Handle<C extends AnyContract = AnyContract> = HandleBase<C> & MembersOf<C>;
|
||||
export type Ref<C extends AnyContract = AnyContract> = RefBase<C> & MembersOf<C>;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export interface CellDecl<T> {
|
|||
readonly initial: T;
|
||||
}
|
||||
|
||||
export interface ResourceSpec<T> {
|
||||
export interface ResourceDeclaration<T> {
|
||||
/** Bump to replace the resource at the next reload. */
|
||||
readonly rev: number;
|
||||
/** Holds or resources that must be live/open first. A change replaces the resource. */
|
||||
|
|
@ -30,7 +30,7 @@ export interface ResourceSpec<T> {
|
|||
|
||||
export interface ResourceDecl<T> {
|
||||
readonly [DECL]: "resource";
|
||||
readonly spec: ResourceSpec<T>;
|
||||
readonly spec: ResourceDeclaration<T>;
|
||||
}
|
||||
|
||||
export interface Resource<T> {
|
||||
|
|
@ -59,14 +59,14 @@ export const cell = <S extends TSchema>(schema: S, initial: Static<S>): CellDecl
|
|||
initial,
|
||||
});
|
||||
|
||||
export const resource = <T>(spec: ResourceSpec<T>): ResourceDecl<T> => ({ [DECL]: "resource", spec });
|
||||
export const resource = <T>(spec: ResourceDeclaration<T>): ResourceDecl<T> => ({ [DECL]: "resource", spec });
|
||||
|
||||
type ValueOf<S> = S extends Resource<infer T> ? T : S;
|
||||
type EventOf<S> = S extends Resource<infer T> ? EventOf<T> : S extends Subscribable<infer E> ? E : never;
|
||||
|
||||
/**
|
||||
* Reacts to a source once the record is live. `listener(source, fn)` subscribes to the source itself: a readable
|
||||
* (cell, derivation, handle state member: called with each value, starting with the current one), an event source,
|
||||
* Reacts to a source once the object is live. `listener(source, fn)` subscribes to the source itself: a readable
|
||||
* (cell, derivation, reference state member: called with each value, starting with the current one), an event source,
|
||||
* or a resource whose value is one. `listener(source, select, fn)` subscribes to `select(value)`, where value is the
|
||||
* opened resource for a resource source. Each event runs `fn` in a fresh root.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ export const CHORD_ERROR_CODES = [
|
|||
export type ChordErrorCode = (typeof CHORD_ERROR_CODES)[number];
|
||||
|
||||
/**
|
||||
* Finer cause inside a code, for logs and tests: e.g. `invalid` + `args`, `result`, `state`, `input`, `behavior`,
|
||||
* `duplicate`, `cycle`, `read_only`, `not_eager`, `registry_conflict`; `unavailable` + `unknown_type`.
|
||||
* 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +1,35 @@
|
|||
import { ChildReloads, reconcileChild, runClusterReload } from "./cluster.ts";
|
||||
import { type AcceptOptions, Connection, type LinkOptions, type RelayHold } from "./connection.ts";
|
||||
import { type AcceptOptions, Connection, type LinkOptions, type RelayRef } from "./connection.ts";
|
||||
import {
|
||||
type AnyContract,
|
||||
bind,
|
||||
type Handle,
|
||||
type InputOf,
|
||||
type LocalContract,
|
||||
type LocalDef,
|
||||
type Ref,
|
||||
type RemoteContract,
|
||||
type RemoteDef,
|
||||
} from "./contract.ts";
|
||||
import { ChordError, invalid, runAll } from "./errors.ts";
|
||||
import type { Link } from "./link.ts";
|
||||
import { enqueue } from "./publish.ts";
|
||||
import {
|
||||
CtxImpl,
|
||||
type Decls,
|
||||
type Factory,
|
||||
HandleImpl,
|
||||
type Hold,
|
||||
type HoldOwner,
|
||||
type HostCore,
|
||||
hostLifetime,
|
||||
makeService,
|
||||
RecordImpl,
|
||||
ObjectImpl,
|
||||
type RefCore,
|
||||
RefImpl,
|
||||
type RefOwner,
|
||||
type ServeOptions,
|
||||
type Service,
|
||||
} from "./record.ts";
|
||||
} from "./object.ts";
|
||||
import { enqueue } from "./publish.ts";
|
||||
import type { Registry, RegistryTable, RegistryView } from "./registry.ts";
|
||||
import { type ReloadOptions, type ReloadReport, runReload } from "./reload.ts";
|
||||
import { RemoteHandle } from "./remote.ts";
|
||||
import { RemoteRef } from "./remote.ts";
|
||||
import { canonicalIdentity, canonicalJson, deepEqual, normalize } from "./schema.ts";
|
||||
import type { Ctx, GetOptions, Principal } from "./types.ts";
|
||||
import type { Frame } from "./wire.ts";
|
||||
|
|
@ -38,19 +39,11 @@ export const hostPrincipal: Principal = Object.freeze({ host: true });
|
|||
|
||||
/**
|
||||
* Registers a factory for a type. Overloaded on the contract's definition (not the contract type) so TypeScript
|
||||
* keeps literal `kind`s in factory returns.
|
||||
* keeps literal `variant`s in factory returns.
|
||||
*/
|
||||
export interface Serve {
|
||||
<D extends RemoteDef>(
|
||||
type: RemoteContract<D>,
|
||||
factory: Factory<RemoteContract<D>>,
|
||||
options?: ServeOptions<RemoteContract<D>>,
|
||||
): void;
|
||||
<D extends LocalDef>(
|
||||
type: LocalContract<D>,
|
||||
factory: Factory<LocalContract<D>>,
|
||||
options?: ServeOptions<LocalContract<D>>,
|
||||
): void;
|
||||
<D extends RemoteDef>(type: RemoteContract<D>, factory: Factory<RemoteContract<D>>, options?: ServeOptions): void;
|
||||
<D extends LocalDef>(type: LocalContract<D>, factory: Factory<LocalContract<D>>, options?: ServeOptions): void;
|
||||
}
|
||||
|
||||
type AnyServe = (type: AnyContract, factory: Factory<AnyContract>, options?: ServeOptions) => void;
|
||||
|
|
@ -64,9 +57,9 @@ export interface FacetApi {
|
|||
readonly serve: Serve;
|
||||
/** The host's name (`server`, `session:S1`, ...). */
|
||||
readonly name: string;
|
||||
/** Starts a root on the facet's host (in its current epoch at call time). */
|
||||
/** Starts a root on the facet's host (in its current code version at call time). */
|
||||
root<T>(fn: (ctx: Ctx) => T | Promise<T>, options?: RootOptions): Promise<T>;
|
||||
/** The facet's host's registry view (`get(key, ctx)` reads the table of `ctx.epoch`). */
|
||||
/** The facet's host's registry view (`get(key, ctx)` reads the table of `ctx.codeVersion`). */
|
||||
registry<V>(registry: Registry<V>): RegistryView<V>;
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +90,7 @@ const isFacet = (ref: PluginRef): ref is Facet => typeof ref === "object" && !(r
|
|||
|
||||
export interface HostOptions {
|
||||
readonly name?: string;
|
||||
/** Maps a principal to its record key (should include connection provenance and grant revision). */
|
||||
/** Maps a principal to its object key (should include connection provenance and grant revision). */
|
||||
readonly principalKey?: (principal: Principal) => string;
|
||||
/** Validates every cell write and exposed state value against its schemas. */
|
||||
readonly dev?: boolean;
|
||||
|
|
@ -105,7 +98,7 @@ export interface HostOptions {
|
|||
readonly onError?: (error: unknown) => void;
|
||||
/** Needed for `load`/`reload` with modules; `nodeLoader` from loader.ts in Node. */
|
||||
readonly loader?: FacetLoader;
|
||||
/** `client` (browser): never a reload participant, never carries roots or epochs. Default `host`. */
|
||||
/** `client` (browser): never a reload participant, never carries roots or code versions. Default `host`. */
|
||||
readonly kind?: "host" | "client";
|
||||
/** The link to this host's parent (server). Can also be set later with `connect`. */
|
||||
readonly upstream?: Link;
|
||||
|
|
@ -128,37 +121,40 @@ export type PrincipalMatch = ((principal: Principal) => boolean) | Readonly<Reco
|
|||
export interface RootOptions {
|
||||
readonly principal?: Principal;
|
||||
readonly signal?: AbortSignal;
|
||||
/** Start the root in a retained epoch (links carry epochs). Defaults to the current epoch. */
|
||||
readonly epoch?: number;
|
||||
/** Start the root in a retained code version (links carry code versions). Defaults to the current code version. */
|
||||
readonly codeVersion?: number;
|
||||
}
|
||||
|
||||
export interface LoadOptions {
|
||||
/** Await eager activation (default true). False starts it but leaves readiness to `host.ready()`. */
|
||||
readonly awaitEager?: boolean;
|
||||
/**
|
||||
* Await the activation of the `lifetime: "host"` objects the facets serve (default true). False starts them but
|
||||
* leaves readiness to `host.ready()` (a child host whose objects need its parent, which admits it only later).
|
||||
*/
|
||||
readonly awaitHostObjects?: boolean;
|
||||
}
|
||||
|
||||
export interface Host {
|
||||
readonly name: string;
|
||||
readonly epoch: number;
|
||||
readonly previousEpoch: number | undefined;
|
||||
readonly codeVersion: number;
|
||||
readonly previousCodeVersion: number | undefined;
|
||||
readonly serve: Serve;
|
||||
/** Installs synchronously, then awaits this facet's eager objects unless `awaitEager` is false. */
|
||||
/** Installs synchronously, then awaits this facet's host objects unless `awaitHostObjects` is false. */
|
||||
install(facet: Facet, options?: LoadOptions): Promise<void>;
|
||||
/** Installs all facets before awaiting their eager objects; lazy objects stay lazy. */
|
||||
/** Installs all facets before awaiting their host objects; referenced objects are created when first referenced. */
|
||||
load(plugins: readonly PluginRef[], options?: LoadOptions): Promise<void>;
|
||||
/** Awaits the eager holds present at call time, including those started by `serve` or deferred loads. */
|
||||
/** Awaits the host objects present at call time, including those started by `serve` or deferred loads. */
|
||||
ready(): Promise<void>;
|
||||
get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions): Handle<C>;
|
||||
get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions): Ref<C>;
|
||||
root<T>(fn: (ctx: Ctx) => T | Promise<T>, options?: RootOptions): Promise<T>;
|
||||
registry<V>(registry: Registry<V>): RegistryView<V>;
|
||||
/**
|
||||
* Force-disposes every record whose principal matches (never the host principal), re-stamps or closes matching
|
||||
* Force-disposes every object whose principal matches (never the host principal), re-stamps or closes matching
|
||||
* links, and propagates pattern matches to child hosts.
|
||||
*/
|
||||
invalidatePrincipal(match: PrincipalMatch): Promise<void>;
|
||||
/** Accepts a child link (a worker or a browser); requests on it act as `options.principal`. */
|
||||
accept(link: Link, options: AcceptOptions): void;
|
||||
/** Sets (or replaces, after a reconnect) the link to this host's parent; remote handles re-hold over it. */
|
||||
/** Sets (or replaces, after a reconnect) the link to this host's parent; remote references re-hold over it. */
|
||||
connect(link: Link, options?: LinkOptions): void;
|
||||
route(prefix: string, route: Route): void;
|
||||
/** Reloads the given facets (modules are re-imported); facets not listed keep running unchanged. */
|
||||
|
|
@ -166,7 +162,7 @@ export interface Host {
|
|||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface EpochState {
|
||||
export interface CodeVersionState {
|
||||
readonly id: number;
|
||||
readonly services: Map<string, Service>;
|
||||
tables: ReadonlyMap<string, RegistryTable<unknown>>;
|
||||
|
|
@ -186,22 +182,23 @@ export class HostImpl implements Host, HostCore {
|
|||
readonly principal: Principal = hostPrincipal;
|
||||
inFactory = 0;
|
||||
failed: unknown;
|
||||
readonly records = new Map<string, RecordImpl>();
|
||||
readonly epochs = new Map<number, EpochState>();
|
||||
readonly objects = new Map<string, ObjectImpl>();
|
||||
readonly codeVersions = new Map<number, CodeVersionState>();
|
||||
readonly facets = new Map<string, Facet>();
|
||||
readonly eager = new Map<string, Hold>();
|
||||
/** The host's own references to its `lifetime: "host"` objects, by type name. */
|
||||
readonly hostObjects = new Map<string, RefCore>();
|
||||
readonly kind: "host" | "client";
|
||||
readonly connections = new Set<Connection>();
|
||||
upstream: Connection | undefined;
|
||||
/** Remote handles this host holds (re-held after an upstream reconnect). */
|
||||
readonly remotes = new Set<RemoteHandle>();
|
||||
/** Remote references this host references (re-held after an upstream reconnect). */
|
||||
readonly remotes = new Set<RemoteRef>();
|
||||
readonly #routes = new Map<string, Route>();
|
||||
readonly #childReloads = new ChildReloads(this);
|
||||
/** Child facet modules by host-name prefix, from every committed cluster reload (for reconciling children). */
|
||||
remoteModules: Record<string, string[]> = {};
|
||||
/** Removal tombstones also reconcile children that connected during a transaction. */
|
||||
remoteRemoved: Record<string, string[]> = {};
|
||||
/** Highest epoch the parent has retired. */
|
||||
/** Highest code version the parent has retired. */
|
||||
#retireAllowed = 0;
|
||||
/** Roots that came over a link, by the link they came from. */
|
||||
/** Foreign roots (started on another host), by the links they came over. */
|
||||
|
|
@ -211,23 +208,23 @@ export class HostImpl implements Host, HostCore {
|
|||
readonly #incarnation = Math.random().toString(36).slice(2, 8);
|
||||
/** Links each root's calls went out on. */
|
||||
readonly #rootLinks = new Map<string, Set<Connection>>();
|
||||
current: EpochState;
|
||||
previous: EpochState | undefined;
|
||||
/** The epoch a running reload will commit; new types activated during prepare install their behavior for it. */
|
||||
pending: EpochState | undefined;
|
||||
current: CodeVersionState;
|
||||
previous: CodeVersionState | undefined;
|
||||
/** The code version a running reload will commit; new types activated during prepare install their implementation for it. */
|
||||
pending: CodeVersionState | undefined;
|
||||
/** Activations of these types wait (reload gate). */
|
||||
/** Types behind a reload gate: new records of these types are rejected `upgrade_conflict` until commit/abort. */
|
||||
/** Types behind a reload gate: new objects of these types are rejected `upgrade_conflict` until commit/abort. */
|
||||
gating: ReadonlySet<string> | undefined;
|
||||
reloading = false;
|
||||
retired: () => void = () => {};
|
||||
readonly #principalKey: (principal: Principal) => string;
|
||||
readonly #onError: (error: unknown) => void;
|
||||
readonly #loader: FacetLoader | undefined;
|
||||
readonly #dying = new Map<string, RecordImpl>();
|
||||
readonly #touches = new Map<string, Set<RecordImpl>>();
|
||||
readonly #dying = new Map<string, ObjectImpl>();
|
||||
readonly #touches = new Map<string, Set<ObjectImpl>>();
|
||||
readonly #order = new Map<string, Map<string, number>>();
|
||||
readonly #subscribers = new Map<string, Set<(table: RegistryTable<unknown>, epoch: number) => void>>();
|
||||
#epochSeq = 1;
|
||||
readonly #subscribers = new Map<string, Set<(table: RegistryTable<unknown>, codeVersion: number) => void>>();
|
||||
#codeVersionSeq = 1;
|
||||
#rootSeq = 0;
|
||||
|
||||
constructor(options: HostOptions) {
|
||||
|
|
@ -236,26 +233,26 @@ export class HostImpl implements Host, HostCore {
|
|||
this.#principalKey = options.principalKey ?? canonicalJson;
|
||||
this.#onError = options.onError ?? ((error) => console.error("chord:", error));
|
||||
this.#loader = options.loader;
|
||||
this.current = { id: this.#epochSeq, services: new Map(), tables: new Map(), roots: new Map() };
|
||||
this.epochs.set(this.current.id, this.current);
|
||||
this.current = { id: this.#codeVersionSeq, services: new Map(), tables: new Map(), roots: new Map() };
|
||||
this.codeVersions.set(this.current.id, this.current);
|
||||
this.kind = options.kind ?? "host";
|
||||
if (options.upstream !== undefined) this.connect(options.upstream, options.upstreamOptions);
|
||||
}
|
||||
|
||||
get epoch(): number {
|
||||
get codeVersion(): number {
|
||||
return this.current.id;
|
||||
}
|
||||
|
||||
get previousEpoch(): number | undefined {
|
||||
get previousCodeVersion(): number | undefined {
|
||||
return this.previous?.id;
|
||||
}
|
||||
|
||||
/** Allocates the next epoch id, or adopts one issued by the coordinating server. */
|
||||
nextEpochId(given?: number): number {
|
||||
/** Allocates the next code version id, or adopts one issued by the coordinating server. */
|
||||
nextCodeVersionId(given?: number): number {
|
||||
if (given !== undefined && given <= this.current.id)
|
||||
throw new Error(`epoch ${given} is not newer than ${this.current.id}`);
|
||||
this.#epochSeq = given ?? this.#epochSeq + 1;
|
||||
return this.#epochSeq;
|
||||
throw new Error(`code version ${given} is not newer than ${this.current.id}`);
|
||||
this.#codeVersionSeq = given ?? this.#codeVersionSeq + 1;
|
||||
return this.#codeVersionSeq;
|
||||
}
|
||||
|
||||
report(error: unknown): void {
|
||||
|
|
@ -267,7 +264,7 @@ export class HostImpl implements Host, HostCore {
|
|||
readonly serve: Serve = ((type, factory, options = {}) => {
|
||||
if (this.reloading) throw new Error("host.serve during a reload; serve from a facet passed to reload()");
|
||||
this.serveInto(this.current.services, makeService(type, factory, options, ""));
|
||||
if (options.eager) this.startEager(this.current.services.get(type.name)!);
|
||||
if (options.lifetime === "host") this.startHostObject(this.current.services.get(type.name)!);
|
||||
}) satisfies AnyServe as Serve;
|
||||
|
||||
install(facet: Facet, options: LoadOptions = {}): Promise<void> {
|
||||
|
|
@ -282,22 +279,22 @@ export class HostImpl implements Host, HostCore {
|
|||
}) satisfies AnyServe as Serve),
|
||||
);
|
||||
this.facets.set(facet.name, facet);
|
||||
const holds = added.filter((service) => service.options.eager).map((service) => this.startEager(service));
|
||||
return options.awaitEager === false ? Promise.resolve() : this.#eagerReady(holds);
|
||||
const refs = added.filter(hostLifetime).map((service) => this.startHostObject(service));
|
||||
return options.awaitHostObjects === false ? Promise.resolve() : this.#hostObjectsReady(refs);
|
||||
}
|
||||
|
||||
async load(plugins: readonly PluginRef[], options: LoadOptions = {}): Promise<void> {
|
||||
const facets = await this.resolve(plugins);
|
||||
// No await between installs: an eager factory may consume a type served by a later facet.
|
||||
// No await between installs: a host object's factory may consume a type served by a later facet.
|
||||
await Promise.all(facets.map((facet) => this.install(facet, options)));
|
||||
}
|
||||
|
||||
ready(): Promise<void> {
|
||||
return this.#eagerReady(this.eager.values());
|
||||
return this.#hostObjectsReady(this.hostObjects.values());
|
||||
}
|
||||
|
||||
#eagerReady(holds: Iterable<Hold>): Promise<void> {
|
||||
const ready = Promise.all([...holds].map((hold) => hold.ready)).then(() => {});
|
||||
#hostObjectsReady(refs: Iterable<RefCore>): Promise<void> {
|
||||
const ready = Promise.all([...refs].map((ref) => ref.ready)).then(() => {});
|
||||
// Ignoring install's return still reports through onError without an unhandled rejection.
|
||||
ready.catch(() => {});
|
||||
return ready;
|
||||
|
|
@ -329,35 +326,33 @@ export class HostImpl implements Host, HostCore {
|
|||
services.set(service.contract.name, service);
|
||||
}
|
||||
|
||||
startEager(service: Service): Hold {
|
||||
const eager = service.options.eager;
|
||||
const input = typeof eager === "object" ? eager.input : {};
|
||||
const handle = this.acquire(service.contract, input, undefined, "host");
|
||||
this.eager.set(service.contract.name, handle);
|
||||
handle.ready.catch((error: unknown) => this.report(error));
|
||||
return handle;
|
||||
startHostObject(service: Service): RefCore {
|
||||
const ref = this.acquire(service.contract, {}, undefined, "host");
|
||||
this.hostObjects.set(service.contract.name, ref);
|
||||
ref.ready.catch((error: unknown) => this.report(error));
|
||||
return ref;
|
||||
}
|
||||
|
||||
// ─── HostCore ───
|
||||
|
||||
currentEpoch(): number {
|
||||
currentCodeVersion(): number {
|
||||
return this.current.id;
|
||||
}
|
||||
|
||||
hasEpoch(epoch: number): boolean {
|
||||
return this.epochs.has(epoch);
|
||||
hasCodeVersion(codeVersion: number): boolean {
|
||||
return this.codeVersions.has(codeVersion);
|
||||
}
|
||||
|
||||
*retainedEpochs(): Iterable<readonly [number, ReadonlyMap<string, Service>]> {
|
||||
for (const [id, state] of this.epochs) yield [id, state.services];
|
||||
*retainedCodeVersions(): Iterable<readonly [number, ReadonlyMap<string, Service>]> {
|
||||
for (const [id, state] of this.codeVersions) yield [id, state.services];
|
||||
if (this.pending !== undefined) yield [this.pending.id, this.pending.services];
|
||||
}
|
||||
|
||||
pendingEpoch(): number | undefined {
|
||||
pendingCodeVersion(): number | undefined {
|
||||
return this.pending?.id;
|
||||
}
|
||||
|
||||
/** The current code for a type; a type a running reload adds is served from the pending epoch. */
|
||||
/** The current code for a type; a type a running reload adds is served from the pending code version. */
|
||||
service(typeName: string): Service | undefined {
|
||||
return this.current.services.get(typeName) ?? this.pending?.services.get(typeName);
|
||||
}
|
||||
|
|
@ -367,70 +362,78 @@ export class HostImpl implements Host, HostCore {
|
|||
return remoteAddress(at, type, input, principal === this.principal ? undefined : principal);
|
||||
const service = this.#bindingService(type.name, candidate);
|
||||
if (service === undefined) throw unknownType(type.name, this.name);
|
||||
const normalized = normalize(service.contract.input, input, `${type.name} input`);
|
||||
return this.#address(service, normalized, principal);
|
||||
return this.#locate(service, input, principal).address;
|
||||
}
|
||||
|
||||
#address(service: Service, normalized: unknown, principal: Principal): string {
|
||||
/**
|
||||
* The address of `input` for `principal`, normalized. A `lifetime: "host"` type has one object: the empty input, for
|
||||
* the host principal, whoever asks; another input is invalid.
|
||||
*/
|
||||
#locate(service: Service, input: unknown, principal: Principal) {
|
||||
const { name, input: schema } = service.contract;
|
||||
const normalized = normalize(schema, input, `${name} input`);
|
||||
const identity = canonicalIdentity(schema, normalized);
|
||||
if (hostLifetime(service)) {
|
||||
if (identity !== canonicalIdentity(schema, normalize(schema, {}, `${name} input`)))
|
||||
throw invalid("input", `${name} has lifetime "host": its one object has the empty input`);
|
||||
principal = this.principal;
|
||||
}
|
||||
const key = principal === this.principal ? "host" : `p:${this.#principalKey(principal)}`;
|
||||
return `${service.contract.name}\u0000${canonicalIdentity(service.contract.input, normalized)}\u0000${key}`;
|
||||
return { address: `${name}\u0000${identity}\u0000${key}`, normalized, principal };
|
||||
}
|
||||
|
||||
acquire(
|
||||
type: AnyContract,
|
||||
input: unknown,
|
||||
options: GetOptions | undefined,
|
||||
owner: HoldOwner,
|
||||
from?: RecordImpl,
|
||||
owner: RefOwner,
|
||||
from?: ObjectImpl,
|
||||
candidate = false,
|
||||
): Hold {
|
||||
): RefCore {
|
||||
if (options?.at !== undefined && options.at !== this.name)
|
||||
return this.#acquireRemote(type, input, options, owner);
|
||||
const service = this.#bindingService(type.name, candidate);
|
||||
if (service === undefined) throw unknownType(type.name, this.name);
|
||||
bind(service.contract, service.options.accepts, type);
|
||||
const principal = options?.principal ?? this.principal;
|
||||
const normalized = normalize(service.contract.input, input, `${type.name} input`);
|
||||
const address = this.#address(service, normalized, principal);
|
||||
let record = this.records.get(address);
|
||||
if (from !== undefined && record !== undefined && (record === from || reaches(record, from))) {
|
||||
throw invalid("cycle", `${from.address} -> ${address} would create a hold cycle`);
|
||||
const { address, normalized, principal } = this.#locate(service, input, options?.principal ?? this.principal);
|
||||
let object = this.objects.get(address);
|
||||
if (from !== undefined && object !== undefined && (object === from || reaches(object, from))) {
|
||||
throw invalid("cycle", `${from.address} -> ${address} would create a ref cycle`);
|
||||
}
|
||||
if (record === undefined) {
|
||||
if (object === undefined) {
|
||||
if (this.gating?.has(type.name)) {
|
||||
throw new ChordError("upgrade_conflict", `${type.name} is being upgraded; retry`, { reason: "gate" });
|
||||
}
|
||||
const dying = this.#dying.get(address);
|
||||
if (dying?.status === "disposed") this.#dying.delete(address);
|
||||
record = new RecordImpl(this, {
|
||||
object = new ObjectImpl(this, {
|
||||
address,
|
||||
typeName: type.name,
|
||||
input: normalized,
|
||||
principal,
|
||||
eager:
|
||||
service.options.eager !== undefined && service.options.eager !== false && principal === this.principal,
|
||||
hostLifetime: hostLifetime(service),
|
||||
});
|
||||
this.records.set(address, record);
|
||||
// A new record for an address still being disposed waits for that cleanup (exclusive resources).
|
||||
record.start(dying?.status === "disposing" ? dying.dispose(false) : undefined);
|
||||
this.objects.set(address, object);
|
||||
// A new object for an address still being disposed waits for that cleanup (exclusive resources).
|
||||
object.start(dying?.status === "disposing" ? dying.dispose(false) : undefined);
|
||||
}
|
||||
const handle = new HandleImpl(record, type, owner);
|
||||
record.addHold(handle);
|
||||
return handle;
|
||||
const ref = new RefImpl(object, type, owner);
|
||||
object.addRef(ref);
|
||||
return ref;
|
||||
}
|
||||
|
||||
forget(record: RecordImpl): void {
|
||||
if (this.records.get(record.address) === record) this.records.delete(record.address);
|
||||
if (record.status === "disposing") this.#dying.set(record.address, record);
|
||||
forget(object: ObjectImpl): void {
|
||||
if (this.objects.get(object.address) === object) this.objects.delete(object.address);
|
||||
if (object.status === "disposing") this.#dying.set(object.address, object);
|
||||
}
|
||||
|
||||
touch(rootId: string, record: RecordImpl): void {
|
||||
touch(rootId: string, object: ObjectImpl): void {
|
||||
let touched = this.#touches.get(rootId);
|
||||
if (touched === undefined) {
|
||||
touched = new Set();
|
||||
this.#touches.set(rootId, touched);
|
||||
}
|
||||
touched.add(record);
|
||||
touched.add(object);
|
||||
}
|
||||
|
||||
// ─── roots ───
|
||||
|
|
@ -439,25 +442,25 @@ export class HostImpl implements Host, HostCore {
|
|||
// The incarnation keeps root ids unique across restarts of a host with the same name.
|
||||
const rootId = `${this.name}#${this.#incarnation}:${++this.#rootSeq}`;
|
||||
this.#local.add(rootId);
|
||||
return this.joinRoot(rootId, options.epoch ?? this.current.id, fn, options);
|
||||
return this.joinRoot(rootId, options.codeVersion ?? this.current.id, fn, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` inside root `rootId` of `epoch`. A root counts as running here (for retirement) while any of its calls
|
||||
* runs here. A root that started here ends when its last call here settles, even if it went out and came back
|
||||
* over a link meanwhile. A foreign root (started elsewhere, arriving `from` a link) keeps its leases on records
|
||||
* Runs `fn` inside root `rootId` of `codeVersion`. A root counts as running here (for retirement) while any of its
|
||||
* calls runs here. A root that started here ends when its last call here settles, even if it went out and came back
|
||||
* over a link meanwhile. A foreign root (started elsewhere, arriving `from` a link) keeps its leases on objects
|
||||
* until every link it came over announced its end (`root_end`) or closed, so its re-entry stays grandfathered.
|
||||
*/
|
||||
async joinRoot<T>(
|
||||
rootId: string,
|
||||
epochId: number,
|
||||
codeVersionId: number,
|
||||
fn: (ctx: Ctx) => T | Promise<T>,
|
||||
options: { readonly principal?: Principal; readonly signal?: AbortSignal; readonly from?: Connection } = {},
|
||||
): Promise<T> {
|
||||
const epoch = this.epochs.get(epochId);
|
||||
if (epoch === undefined) {
|
||||
const codeVersion = this.codeVersions.get(codeVersionId);
|
||||
if (codeVersion === undefined) {
|
||||
this.#local.delete(rootId);
|
||||
throw new ChordError("superseded", `epoch ${epochId} is not retained by ${this.name}`);
|
||||
throw new ChordError("superseded", `code version ${codeVersionId} is not retained by ${this.name}`);
|
||||
}
|
||||
if (options.from !== undefined && !this.#local.has(rootId)) {
|
||||
let links = this.#foreign.get(rootId);
|
||||
|
|
@ -467,21 +470,21 @@ export class HostImpl implements Host, HostCore {
|
|||
}
|
||||
links.add(options.from);
|
||||
}
|
||||
epoch.roots.set(rootId, (epoch.roots.get(rootId) ?? 0) + 1);
|
||||
codeVersion.roots.set(rootId, (codeVersion.roots.get(rootId) ?? 0) + 1);
|
||||
const ctx = new CtxImpl(this, {
|
||||
signal: options.signal ?? new AbortController().signal,
|
||||
rootId,
|
||||
epoch: epoch.id,
|
||||
codeVersion: codeVersion.id,
|
||||
principal: options.principal ?? this.principal,
|
||||
});
|
||||
try {
|
||||
return await fn(ctx);
|
||||
} finally {
|
||||
ctx.close();
|
||||
const count = (epoch.roots.get(rootId) ?? 1) - 1;
|
||||
if (count > 0) epoch.roots.set(rootId, count);
|
||||
const count = (codeVersion.roots.get(rootId) ?? 1) - 1;
|
||||
if (count > 0) codeVersion.roots.set(rootId, count);
|
||||
else {
|
||||
epoch.roots.delete(rootId);
|
||||
codeVersion.roots.delete(rootId);
|
||||
if (!this.#foreign.has(rootId)) this.endRoot(rootId);
|
||||
this.maybeRetire();
|
||||
}
|
||||
|
|
@ -502,7 +505,7 @@ export class HostImpl implements Host, HostCore {
|
|||
endRoot(rootId: string): void {
|
||||
this.#local.delete(rootId);
|
||||
this.#foreign.delete(rootId);
|
||||
for (const record of this.#touches.get(rootId) ?? []) record.rootEnded(rootId);
|
||||
for (const object of this.#touches.get(rootId) ?? []) object.rootEnded(rootId);
|
||||
this.#touches.delete(rootId);
|
||||
for (const connection of this.#rootLinks.get(rootId) ?? []) connection.send({ t: "root_end", rootId });
|
||||
this.#rootLinks.delete(rootId);
|
||||
|
|
@ -515,15 +518,15 @@ export class HostImpl implements Host, HostCore {
|
|||
foreignRootEnded(rootId: string, from: Connection): void {
|
||||
const links = this.#foreign.get(rootId);
|
||||
if (links === undefined || !links.delete(from) || links.size > 0) return;
|
||||
const running = [...this.epochs.values()].some((epoch) => epoch.roots.has(rootId));
|
||||
const running = [...this.codeVersions.values()].some((codeVersion) => codeVersion.roots.has(rootId));
|
||||
// Still running here (a call in flight): the lease ends when it settles.
|
||||
if (running) this.#foreign.delete(rootId);
|
||||
else this.endRoot(rootId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires the previous epoch once no local roots of it remain. A child also waits for its parent to retire it:
|
||||
* roots span hosts, and an old root on the server may still call in.
|
||||
* Retires the previous code version once no local roots of it remain. A child also waits for its parent to retire
|
||||
* it: roots span hosts, and an old root on the server may still call in.
|
||||
*/
|
||||
maybeRetire(): void {
|
||||
const previous = this.previous;
|
||||
|
|
@ -532,51 +535,51 @@ export class HostImpl implements Host, HostCore {
|
|||
this.retire();
|
||||
}
|
||||
|
||||
allowRetire(epoch: number): void {
|
||||
this.#retireAllowed = Math.max(this.#retireAllowed, epoch);
|
||||
allowRetire(codeVersion: number): void {
|
||||
this.#retireAllowed = Math.max(this.#retireAllowed, codeVersion);
|
||||
this.maybeRetire();
|
||||
}
|
||||
|
||||
/** Drops the previous epoch: its behaviors and the declarations only it used. */
|
||||
/** Drops the previous code version: its implementations and the declarations only it used. */
|
||||
retire(): void {
|
||||
const previous = this.previous;
|
||||
if (previous === undefined) return;
|
||||
this.previous = undefined;
|
||||
this.epochs.delete(previous.id);
|
||||
for (const record of [...this.records.values()]) this.retireBehavior(record, previous.id);
|
||||
this.codeVersions.delete(previous.id);
|
||||
for (const object of [...this.objects.values()]) this.retireImplementation(object, previous.id);
|
||||
for (const connection of this.connections) {
|
||||
if (connection.role === "downstream" && connection.peer?.kind === "host")
|
||||
connection.send({ t: "retire", epoch: previous.id });
|
||||
connection.send({ t: "retire", codeVersion: previous.id });
|
||||
}
|
||||
this.retired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a record's behavior for `epoch` and what only that behavior declared, in disposal order: resources close
|
||||
* (reverse open order) before its holds are released.
|
||||
* Drops an object's implementation for `codeVersion` and what only that implementation declared, in disposal order:
|
||||
* resources close (reverse open order) before its references are released.
|
||||
*/
|
||||
retireBehavior(record: RecordImpl, epoch: number): void {
|
||||
const installed = record.behaviors.get(epoch);
|
||||
record.behaviors.delete(epoch);
|
||||
if (installed === undefined || installed.decls === record.decls) return;
|
||||
const kept = record.allDecls();
|
||||
retireImplementation(object: ObjectImpl, codeVersion: number): void {
|
||||
const installed = object.implementations.get(codeVersion);
|
||||
object.implementations.delete(codeVersion);
|
||||
if (installed === undefined || installed.decls === object.decls) return;
|
||||
const kept = object.allDecls();
|
||||
const keep = <T>(pick: (decls: Decls) => Iterable<T>): Set<T> =>
|
||||
new Set([...kept].flatMap((decls) => [...pick(decls)]));
|
||||
const holds = keep((decls) => decls.holds.values());
|
||||
const refs = keep((decls) => decls.refs.values());
|
||||
const resources = keep((decls) => decls.resources.values());
|
||||
const derives = keep((decls) => decls.derives.values());
|
||||
for (const derived of installed.decls.derives.values()) if (!derives.has(derived)) derived.detach();
|
||||
const closing = [...installed.decls.resources.values()]
|
||||
.filter((resource) => !resources.has(resource))
|
||||
.sort((a, b) => record.openOrder.indexOf(b) - record.openOrder.indexOf(a));
|
||||
const releasing = [...installed.decls.holds.values()].filter((hold) => !holds.has(hold));
|
||||
record.refreshSchemas();
|
||||
.sort((a, b) => object.openOrder.indexOf(b) - object.openOrder.indexOf(a));
|
||||
const releasing = [...installed.decls.refs.values()].filter((ref) => !refs.has(ref));
|
||||
object.refreshSchemas();
|
||||
runAll(
|
||||
[
|
||||
...closing.map((resource) => () => resource.close(record.openOrder)),
|
||||
...releasing.map((hold) => () => hold.release()),
|
||||
...closing.map((resource) => () => resource.close(object.openOrder)),
|
||||
...releasing.map((ref) => () => ref.release()),
|
||||
],
|
||||
`${record.address}: retiring epoch ${epoch} failed`,
|
||||
`${object.address}: retiring code version ${codeVersion} failed`,
|
||||
).catch((error: unknown) => this.report(error));
|
||||
}
|
||||
|
||||
|
|
@ -584,9 +587,10 @@ export class HostImpl implements Host, HostCore {
|
|||
|
||||
registry<V>(registry: Registry<V>): RegistryView<V> {
|
||||
const table = (ctx?: Ctx): RegistryTable<V> => {
|
||||
const epoch = ctx === undefined ? this.current : this.epochs.get(ctx.epoch);
|
||||
if (epoch === undefined) throw new ChordError("superseded", `epoch ${ctx?.epoch} is retired`);
|
||||
return (epoch.tables.get(registry.name) ?? EMPTY_TABLE) as RegistryTable<V>;
|
||||
const codeVersion = ctx === undefined ? this.current : this.codeVersions.get(ctx.codeVersion);
|
||||
if (codeVersion === undefined)
|
||||
throw new ChordError("superseded", `code version ${ctx?.codeVersion} is retired`);
|
||||
return (codeVersion.tables.get(registry.name) ?? EMPTY_TABLE) as RegistryTable<V>;
|
||||
};
|
||||
return {
|
||||
table,
|
||||
|
|
@ -597,7 +601,7 @@ export class HostImpl implements Host, HostCore {
|
|||
set = new Set();
|
||||
this.#subscribers.set(registry.name, set);
|
||||
}
|
||||
const wrapped = listener as (table: RegistryTable<unknown>, epoch: number) => void;
|
||||
const wrapped = listener as (table: RegistryTable<unknown>, codeVersion: number) => void;
|
||||
set.add(wrapped);
|
||||
wrapped(table(), this.current.id);
|
||||
return () => set.delete(wrapped);
|
||||
|
|
@ -605,10 +609,10 @@ export class HostImpl implements Host, HostCore {
|
|||
};
|
||||
}
|
||||
|
||||
/** Builds registry tables from (record, declarations) pairs. Throws registry_conflict. */
|
||||
buildTables(entries: Iterable<readonly [RecordImpl, Decls]>): Map<string, RegistryTable<unknown>> {
|
||||
const byRegistry = new Map<string, Map<string, { value: unknown; record: RecordImpl }>>();
|
||||
for (const [record, decls] of entries) {
|
||||
/** Builds registry tables from (object, declarations) pairs. Throws registry_conflict. */
|
||||
buildTables(entries: Iterable<readonly [ObjectImpl, Decls]>): Map<string, RegistryTable<unknown>> {
|
||||
const byRegistry = new Map<string, Map<string, { value: unknown; object: ObjectImpl }>>();
|
||||
for (const [object, decls] of entries) {
|
||||
for (const { registry, key, value } of decls.registrations.values()) {
|
||||
let entries = byRegistry.get(registry.name);
|
||||
if (entries === undefined) {
|
||||
|
|
@ -616,13 +620,13 @@ export class HostImpl implements Host, HostCore {
|
|||
byRegistry.set(registry.name, entries);
|
||||
}
|
||||
const existing = entries.get(key);
|
||||
if (existing !== undefined && existing.record !== record && registry.conflict === "error") {
|
||||
if (existing !== undefined && existing.object !== object && registry.conflict === "error") {
|
||||
throw invalid(
|
||||
"registry_conflict",
|
||||
`${registry.name}: ${key} registered by ${existing.record.address} and ${record.address}`,
|
||||
`${registry.name}: ${key} registered by ${existing.object.address} and ${object.address}`,
|
||||
);
|
||||
}
|
||||
entries.set(key, { value, record });
|
||||
entries.set(key, { value, object });
|
||||
let order = this.#order.get(registry.name);
|
||||
if (order === undefined) {
|
||||
order = new Map();
|
||||
|
|
@ -641,27 +645,27 @@ export class HostImpl implements Host, HostCore {
|
|||
}
|
||||
|
||||
/**
|
||||
* The service a hold binds against: the current one, or for a prepare rerun (`candidate`) the running reload's
|
||||
* The service a reference binds against: the current one, or for a prepare rerun (`candidate`) the running reload's
|
||||
* pending one, so consumer and provider of a breaking change can reload in one batch.
|
||||
*/
|
||||
#bindingService(typeName: string, candidate: boolean): Service | undefined {
|
||||
return (candidate ? this.pending?.services.get(typeName) : undefined) ?? this.service(typeName);
|
||||
}
|
||||
|
||||
/** Live records with their current declarations; records waiting for a reload's commit only with `candidates`. */
|
||||
liveDecls(candidates = false): [RecordImpl, Decls][] {
|
||||
return [...this.records.values()]
|
||||
.filter((record) => record.status === "live" && (candidates || !record.deferredRuntime))
|
||||
.map((record) => [record, record.decls]);
|
||||
/** Live objects with their current declarations; objects waiting for a reload's commit only with `candidates`. */
|
||||
liveDecls(candidates = false): [ObjectImpl, Decls][] {
|
||||
return [...this.objects.values()]
|
||||
.filter((object) => object.status === "live" && (candidates || !object.deferredRuntime))
|
||||
.map((object) => [object, object.decls]);
|
||||
}
|
||||
|
||||
checkRegistrations(record: RecordImpl, decls: Decls): void {
|
||||
checkRegistrations(object: ObjectImpl, decls: Decls): void {
|
||||
if (decls.registrations.size === 0) return;
|
||||
this.buildTables([...this.liveDecls().filter(([other]) => other !== record), [record, decls]]);
|
||||
this.buildTables([...this.liveDecls().filter(([other]) => other !== object), [object, decls]]);
|
||||
}
|
||||
|
||||
registriesChanged(including?: RecordImpl): void {
|
||||
const entries = this.liveDecls().filter(([record]) => record !== including);
|
||||
registriesChanged(including?: ObjectImpl): void {
|
||||
const entries = this.liveDecls().filter(([object]) => object !== including);
|
||||
if (including !== undefined) entries.push([including, including.decls]);
|
||||
this.installTables(this.current, this.buildTables(entries));
|
||||
}
|
||||
|
|
@ -672,11 +676,11 @@ export class HostImpl implements Host, HostCore {
|
|||
this.report(error);
|
||||
}
|
||||
|
||||
/** Installs tables for `epoch`; subscribers hear about changed tables of the current epoch (all tables when `all`). */
|
||||
installTables(epoch: EpochState, tables: Map<string, RegistryTable<unknown>>, all = false): void {
|
||||
const before = epoch.tables;
|
||||
epoch.tables = tables;
|
||||
if (epoch !== this.current) return;
|
||||
/** Installs tables for `codeVersion`; subscribers hear about changed tables of the current code version (all tables when `all`). */
|
||||
installTables(codeVersion: CodeVersionState, tables: Map<string, RegistryTable<unknown>>, all = false): void {
|
||||
const before = codeVersion.tables;
|
||||
codeVersion.tables = tables;
|
||||
if (codeVersion !== this.current) return;
|
||||
for (const [name, listeners] of this.#subscribers) {
|
||||
const next = tables.get(name) ?? EMPTY_TABLE;
|
||||
if (!all && sameTable(before.get(name) ?? EMPTY_TABLE, next)) continue;
|
||||
|
|
@ -684,7 +688,7 @@ export class HostImpl implements Host, HostCore {
|
|||
enqueue(() => {
|
||||
for (const listener of current) {
|
||||
try {
|
||||
listener(next, epoch.id);
|
||||
listener(next, codeVersion.id);
|
||||
} catch (error) {
|
||||
this.report(error);
|
||||
}
|
||||
|
|
@ -695,8 +699,8 @@ export class HostImpl implements Host, HostCore {
|
|||
|
||||
// ─── public API ───
|
||||
|
||||
get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions): Handle<C> {
|
||||
return this.acquire(type, input, options, "external").proxy as Handle<C>;
|
||||
get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions): Ref<C> {
|
||||
return this.acquire(type, input, options, "external").proxy as Ref<C>;
|
||||
}
|
||||
|
||||
async invalidatePrincipal(match: PrincipalMatch): Promise<void> {
|
||||
|
|
@ -708,7 +712,7 @@ export class HostImpl implements Host, HostCore {
|
|||
// Relays under the old principal close at once, including those still resolving their route.
|
||||
connection.revoke(connection.restamp?.(connection.principal));
|
||||
}
|
||||
// Patterns propagate to child hosts, which dispose their own matching records (e.g. relayed guests).
|
||||
// Patterns propagate to child hosts, which dispose their own matching objects (e.g. relayed guests).
|
||||
if (typeof match !== "function") {
|
||||
for (const connection of this.connections) {
|
||||
if (connection.role === "downstream" && connection.peer.kind === "host") {
|
||||
|
|
@ -716,8 +720,8 @@ export class HostImpl implements Host, HostCore {
|
|||
}
|
||||
}
|
||||
}
|
||||
const matching = [...this.records.values()].filter(
|
||||
(record) => record.principal !== this.principal && matches(record.principal),
|
||||
const matching = [...this.objects.values()].filter(
|
||||
(object) => object.principal !== this.principal && matches(object.principal),
|
||||
);
|
||||
await disposeHoldersFirst(matching, reason, "invalidatePrincipal: disposal failed");
|
||||
}
|
||||
|
|
@ -744,8 +748,8 @@ export class HostImpl implements Host, HostCore {
|
|||
peer: { kind: "host", name: undefined },
|
||||
});
|
||||
this.upstream = connection;
|
||||
// Remote handles that lost their link re-hold over the new one; state subscribers get a reset.
|
||||
for (const handle of this.remotes) if (handle.binding === undefined) connection.hold(handle);
|
||||
// Remote references that lost their link re-hold over the new one; state subscribers get a reset.
|
||||
for (const ref of this.remotes) if (ref.binding === undefined) connection.hold(ref);
|
||||
}
|
||||
|
||||
#addConnection(link: Link, config: ConstructorParameters<typeof Connection>[2]): Connection {
|
||||
|
|
@ -760,21 +764,27 @@ export class HostImpl implements Host, HostCore {
|
|||
}
|
||||
|
||||
/**
|
||||
* A child host announced its epoch. In step: usable. Behind: fenced (nothing is routed to it) and reconciled by a
|
||||
* reload of its modules to this host's epoch, never by renumbering what it runs. Ahead: impossible, refused.
|
||||
* A child host announced its code version. In step: usable. Behind: fenced (nothing is routed to it) and reconciled
|
||||
* by a reload of its modules to this host's code version, never by renumbering what it runs. Ahead: impossible,
|
||||
* refused.
|
||||
*/
|
||||
peerHello(connection: Connection, epoch: number): void {
|
||||
if (epoch === this.current.id) {
|
||||
connection.markSynced(epoch);
|
||||
peerHello(connection: Connection, codeVersion: number): void {
|
||||
if (codeVersion === this.current.id) {
|
||||
connection.markSynced(codeVersion);
|
||||
return;
|
||||
}
|
||||
if (epoch > this.current.id) {
|
||||
this.report(new ChordError("invalid", `${connection.name} runs epoch ${epoch}, ahead of ${this.current.id}`));
|
||||
connection.link.close("epoch ahead of parent");
|
||||
if (codeVersion > this.current.id) {
|
||||
this.report(
|
||||
new ChordError(
|
||||
"invalid",
|
||||
`${connection.name} runs code version ${codeVersion}, ahead of ${this.current.id}`,
|
||||
),
|
||||
);
|
||||
connection.link.close("code version ahead of parent");
|
||||
return;
|
||||
}
|
||||
connection.peerEpoch = epoch;
|
||||
connection.fencedEpochs.add(epoch);
|
||||
connection.peerCodeVersion = codeVersion;
|
||||
connection.fencedCodeVersions.add(codeVersion);
|
||||
reconcileChild(this, connection).catch((error: unknown) => {
|
||||
this.report(error);
|
||||
connection.link.close("reconciliation failed");
|
||||
|
|
@ -793,8 +803,8 @@ export class HostImpl implements Host, HostCore {
|
|||
}
|
||||
}
|
||||
|
||||
forgetRemote(handle: RemoteHandle): void {
|
||||
this.remotes.delete(handle);
|
||||
forgetRemote(ref: RemoteRef): void {
|
||||
this.remotes.delete(ref);
|
||||
}
|
||||
|
||||
/** A downstream host connection by its configured name (never by what a peer announced). */
|
||||
|
|
@ -808,12 +818,12 @@ export class HostImpl implements Host, HostCore {
|
|||
);
|
||||
}
|
||||
|
||||
#acquireRemote(type: AnyContract, input: unknown, options: GetOptions, owner: HoldOwner): Hold {
|
||||
#acquireRemote(type: AnyContract, input: unknown, options: GetOptions, owner: RefOwner): RefCore {
|
||||
const at = options.at!;
|
||||
if (type.scope !== "remote") throw invalid("local", `${type.name} is local and cannot be reached at ${at}`);
|
||||
const principal =
|
||||
options.principal === undefined || options.principal === this.principal ? undefined : options.principal;
|
||||
const handle = new RemoteHandle(this, {
|
||||
const ref = new RemoteRef(this, {
|
||||
contract: type,
|
||||
at,
|
||||
input,
|
||||
|
|
@ -821,24 +831,24 @@ export class HostImpl implements Host, HostCore {
|
|||
owner,
|
||||
address: remoteAddress(at, type, input, principal),
|
||||
});
|
||||
this.remotes.add(handle);
|
||||
this.remotes.add(ref);
|
||||
const bindWhenSynced = (connection: Promise<Connection>): void => {
|
||||
connection.then(
|
||||
(target) => {
|
||||
if (handle.status === "loading" && handle.binding === undefined) target.hold(handle);
|
||||
if (ref.status === "loading" && ref.binding === undefined) target.hold(ref);
|
||||
},
|
||||
(error: unknown) => handle.gone(error),
|
||||
(error: unknown) => ref.gone(error),
|
||||
);
|
||||
};
|
||||
const direct = this.#childNamed(at);
|
||||
if (this.upstream !== undefined) this.upstream.hold(handle);
|
||||
if (this.upstream !== undefined) this.upstream.hold(ref);
|
||||
else if (direct !== undefined) bindWhenSynced(direct.whenSynced().then(() => direct));
|
||||
else {
|
||||
const route = this.#routeFor(at);
|
||||
if (route === undefined) handle.gone(new ChordError("unavailable", `no route to ${at}`));
|
||||
if (route === undefined) ref.gone(new ChordError("unavailable", `no route to ${at}`));
|
||||
else bindWhenSynced(this.#resolve(route, at, principal ?? this.principal));
|
||||
}
|
||||
return handle;
|
||||
return ref;
|
||||
}
|
||||
|
||||
#routeFor(hostName: string): Route | undefined {
|
||||
|
|
@ -876,11 +886,11 @@ export class HostImpl implements Host, HostCore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Relays a hold from `relay.origin` to the host it names. Relaying needs a route: it is where authorization
|
||||
* Relays a reference from `relay.origin` to the host it names. Relaying needs a route: it is where authorization
|
||||
* lives. The origin's authorization generation is re-checked after every await and right before forwarding, so a
|
||||
* revocation during resolution wins.
|
||||
*/
|
||||
async relay(relay: RelayHold): Promise<void> {
|
||||
async relay(relay: RelayRef): Promise<void> {
|
||||
const at = relay.frame.at;
|
||||
const route = this.#routeFor(at);
|
||||
if (route === undefined) {
|
||||
|
|
@ -894,7 +904,7 @@ export class HostImpl implements Host, HostCore {
|
|||
}
|
||||
|
||||
reload(plugins: readonly PluginRef[], options: ReloadOptions = {}): Promise<ReloadReport> {
|
||||
// Client-local facet epochs never cross the parent link; only child hosts require cluster coordination.
|
||||
// Client-local facet code versions never cross the parent link; only child hosts require cluster coordination.
|
||||
if (this.kind !== "client" && this.upstream !== undefined) {
|
||||
return Promise.reject(
|
||||
new ChordError("unavailable", "reloads are coordinated by the server", { reason: "not_coordinator" }),
|
||||
|
|
@ -928,20 +938,20 @@ export class HostImpl implements Host, HostCore {
|
|||
}
|
||||
}
|
||||
|
||||
/** The epoch id the next reload would commit. */
|
||||
peekNextEpoch(): number {
|
||||
return this.#epochSeq + 1;
|
||||
/** The code version id the next reload would commit. */
|
||||
peekNextCodeVersion(): number {
|
||||
return this.#codeVersionSeq + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes every record, holders before what they hold (a resource's `close` may still use the objects its record
|
||||
* holds, local or remote), then closes the links.
|
||||
* Disposes every object, holders before what they reference (a resource's `close` may still use the objects its object
|
||||
* references, local or remote), then closes the links.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.eager.clear();
|
||||
this.hostObjects.clear();
|
||||
const reason = new ChordError("gone", `host ${this.name} closed`);
|
||||
try {
|
||||
await disposeHoldersFirst([...this.records.values()], reason, "host close: disposal failed");
|
||||
await disposeHoldersFirst([...this.objects.values()], reason, "host close: disposal failed");
|
||||
} finally {
|
||||
for (const connection of [...this.connections]) connection.link.close("host closed");
|
||||
}
|
||||
|
|
@ -949,29 +959,29 @@ export class HostImpl implements Host, HostCore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Force-disposes `records` in waves: a record goes once no record of the set that holds it is left, so holders are
|
||||
* disposed (and their resources closed) while what they hold is still live. In-host holds are acyclic. Errors of
|
||||
* Force-disposes `objects` in waves: an object goes once no object of the set that references it is left, so holders are
|
||||
* disposed (and their resources closed) while what they reference is still live. In-host references are acyclic. Errors of
|
||||
* every disposal are collected.
|
||||
*/
|
||||
async function disposeHoldersFirst(records: readonly RecordImpl[], reason: unknown, message: string): Promise<void> {
|
||||
const remaining = new Set(records);
|
||||
const held = (record: RecordImpl): Set<RecordImpl> => {
|
||||
const result = new Set<RecordImpl>();
|
||||
for (const decls of record.allDecls()) {
|
||||
for (const hold of decls.holds.values()) if (hold.record !== undefined) result.add(hold.record);
|
||||
async function disposeHoldersFirst(objects: readonly ObjectImpl[], reason: unknown, message: string): Promise<void> {
|
||||
const remaining = new Set(objects);
|
||||
const held = (object: ObjectImpl): Set<ObjectImpl> => {
|
||||
const result = new Set<ObjectImpl>();
|
||||
for (const decls of object.allDecls()) {
|
||||
for (const ref of decls.refs.values()) if (ref.object !== undefined) result.add(ref.object);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const holds = new Map(records.map((record) => [record, held(record)] as const));
|
||||
const refs = new Map(objects.map((object) => [object, held(object)] as const));
|
||||
const errors: unknown[] = [];
|
||||
while (remaining.size > 0) {
|
||||
const heldByRemaining = new Set<RecordImpl>();
|
||||
for (const record of remaining) for (const target of holds.get(record) ?? []) heldByRemaining.add(target);
|
||||
let wave = [...remaining].filter((record) => !heldByRemaining.has(record));
|
||||
const heldByRemaining = new Set<ObjectImpl>();
|
||||
for (const object of remaining) for (const target of refs.get(object) ?? []) heldByRemaining.add(target);
|
||||
let wave = [...remaining].filter((object) => !heldByRemaining.has(object));
|
||||
// A cycle cannot happen in-host; dispose the rest together rather than loop.
|
||||
if (wave.length === 0) wave = [...remaining];
|
||||
for (const record of wave) remaining.delete(record);
|
||||
const results = await Promise.allSettled(wave.map((record) => record.dispose(true, reason)));
|
||||
for (const object of wave) remaining.delete(object);
|
||||
const results = await Promise.allSettled(wave.map((object) => object.dispose(true, reason)));
|
||||
for (const result of results) if (result.status === "rejected") errors.push(result.reason);
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
|
|
@ -989,25 +999,25 @@ const matchesPattern = (pattern: Readonly<Record<string, unknown>>, principal: P
|
|||
typeof principal === "object" &&
|
||||
Object.entries(pattern).every(([key, value]) => deepEqual((principal as Record<string, unknown>)[key], value));
|
||||
|
||||
/** Address of a remote hold, as the consumer sees it (the provider normalizes the input). */
|
||||
/** Address of a remote reference, as the consumer sees it (the provider normalizes the input). */
|
||||
const remoteAddress = (at: string, type: AnyContract, input: unknown, principal: Principal | undefined): string =>
|
||||
`@${at}\u0000${type.name}\u0000${canonicalJson(input)}\u0000${principal === undefined ? "host" : canonicalJson(principal)}`;
|
||||
|
||||
const unknownType = (type: string, host: string): ChordError =>
|
||||
new ChordError("unavailable", `${type} is not served by ${host}`, { reason: "unknown_type" });
|
||||
|
||||
/** True if `from` holds `to`, directly or transitively, through record-owned holds on this host. */
|
||||
function reaches(from: RecordImpl, to: RecordImpl): boolean {
|
||||
const seen = new Set<RecordImpl>();
|
||||
/** True if `from` references `to`, directly or transitively, through object-owned references on this host. */
|
||||
function reaches(from: ObjectImpl, to: ObjectImpl): boolean {
|
||||
const seen = new Set<ObjectImpl>();
|
||||
const stack = [from];
|
||||
while (stack.length > 0) {
|
||||
const record = stack.pop()!;
|
||||
if (record === to) return true;
|
||||
if (seen.has(record)) continue;
|
||||
seen.add(record);
|
||||
// Remote holds have no record here: cycles across hosts are avoided by convention.
|
||||
for (const decls of record.allDecls()) {
|
||||
for (const hold of decls.holds.values()) if (hold.record !== undefined) stack.push(hold.record);
|
||||
const object = stack.pop()!;
|
||||
if (object === to) return true;
|
||||
if (seen.has(object)) continue;
|
||||
seen.add(object);
|
||||
// Remote references have no object here: cycles across hosts are avoided by convention.
|
||||
for (const decls of object.allDecls()) {
|
||||
for (const ref of decls.refs.values()) if (ref.object !== undefined) stack.push(ref.object);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ export {
|
|||
type Accepts,
|
||||
type Adapter,
|
||||
type AnyContract,
|
||||
type BehaviorOf,
|
||||
bind,
|
||||
coverage,
|
||||
type Description,
|
||||
type Handle,
|
||||
type ImplementationOf,
|
||||
type InputOf,
|
||||
type LocalContract,
|
||||
local,
|
||||
type MembersOf,
|
||||
method,
|
||||
opaque,
|
||||
type Ref,
|
||||
type RemoteContract,
|
||||
remote,
|
||||
} from "./contract.ts";
|
||||
|
|
@ -28,7 +28,7 @@ export {
|
|||
listener,
|
||||
type Resource,
|
||||
type ResourceDecl,
|
||||
type ResourceSpec,
|
||||
type ResourceDeclaration,
|
||||
resource,
|
||||
type Subscribable,
|
||||
} from "./declare.ts";
|
||||
|
|
@ -61,7 +61,7 @@ export {
|
|||
type WebSocketLinkOptions,
|
||||
webSocketLink,
|
||||
} from "./link.ts";
|
||||
export type { Factory, Self, ServeOptions } from "./record.ts";
|
||||
export type { Factory, Lifetime, Self, ServeOptions } from "./object.ts";
|
||||
export { defineRegistry, type Registry, type RegistryTable, type RegistryView } from "./registry.ts";
|
||||
export type { ReloadOptions, ReloadReport } from "./reload.ts";
|
||||
export {
|
||||
|
|
@ -75,6 +75,6 @@ export {
|
|||
type ReplicatedStateSourceOptions,
|
||||
replicatedState,
|
||||
} from "./state.ts";
|
||||
export type { Ctx, GetOptions, HandleStatus, Principal, Readable, Shape, StateEvent } from "./types.ts";
|
||||
export type { Ctx, GetOptions, Principal, Readable, RefStatus, Shape, StateEvent } from "./types.ts";
|
||||
export { acceptWebSockets, type WebSocketAcceptOptions, type WebSocketServerLike } from "./websocket.ts";
|
||||
export type { Frame } from "./wire.ts";
|
||||
export { type Frame, PROTOCOL_VERSION } from "./wire.ts";
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export async function processLock(name: string): Promise<() => Promise<void>> {
|
|||
});
|
||||
}
|
||||
|
||||
/** sun_path holds 104 bytes on macOS and the BSDs, including the terminating NUL. */
|
||||
/** sun_path references 104 bytes on macOS and the BSDs, including the terminating NUL. */
|
||||
const MAX_SOCKET_PATH_BYTES = 103;
|
||||
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,7 +7,7 @@ export interface Registry<V> {
|
|||
readonly value?: V;
|
||||
}
|
||||
|
||||
/** `conflict: "error"` rejects two records registering one key; `"last"` keeps the later registration. */
|
||||
/** `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",
|
||||
|
|
@ -16,11 +16,11 @@ export const defineRegistry = <V>(name: string, options: { conflict?: "error" |
|
|||
export type RegistryTable<V> = ReadonlyMap<string, V>;
|
||||
|
||||
export interface RegistryView<V> {
|
||||
/** Table of `ctx.epoch`, or of the current epoch without ctx. */
|
||||
/** 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>, epoch: number) => void): () => void;
|
||||
subscribe(listener: (table: RegistryTable<V>, codeVersion: number) => void): () => void;
|
||||
}
|
||||
|
||||
export interface Registration {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import type { Cell } from "./cell.ts";
|
||||
import { type AnyContract, bind, coverage } from "./contract.ts";
|
||||
import { asChordError, ChordError, invalid } from "./errors.ts";
|
||||
import type { EpochState, Facet, HostImpl, PluginRef, Serve } from "./host.ts";
|
||||
import { holdPublications } from "./publish.ts";
|
||||
import type { CodeVersionState, Facet, HostImpl, PluginRef, Serve } from "./host.ts";
|
||||
import {
|
||||
type Factory,
|
||||
HandleImpl,
|
||||
hostLifetime,
|
||||
makeService,
|
||||
memberSchemas,
|
||||
type RecordImpl,
|
||||
type ObjectImpl,
|
||||
RefImpl,
|
||||
type ResourceImpl,
|
||||
type Run,
|
||||
type ServeOptions,
|
||||
type Service,
|
||||
type StagedListeners,
|
||||
} from "./record.ts";
|
||||
} from "./object.ts";
|
||||
import { holdPublications } from "./publish.ts";
|
||||
import type { RegistryTable } from "./registry.ts";
|
||||
import { assertValid, deepEqual } from "./schema.ts";
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ export interface ReloadOptions {
|
|||
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 epoch to retire first (default 5000). Missed: it is retired anyway. */
|
||||
/** 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[]>>;
|
||||
|
|
@ -34,13 +35,13 @@ export interface ReloadOptions {
|
|||
}
|
||||
|
||||
export interface ReloadReport {
|
||||
readonly epoch: number;
|
||||
readonly codeVersion: number;
|
||||
readonly previous: number;
|
||||
readonly changed: readonly string[];
|
||||
readonly removed: readonly string[];
|
||||
/** Addresses of records whose new factory was installed. */
|
||||
/** Addresses of objects whose new factory was installed. */
|
||||
readonly prepared: readonly string[];
|
||||
/** Addresses of records that went through the gate. */
|
||||
/** 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
|
||||
|
|
@ -50,10 +51,10 @@ export interface ReloadReport {
|
|||
}
|
||||
|
||||
interface Candidate {
|
||||
readonly record: RecordImpl;
|
||||
readonly object: ObjectImpl;
|
||||
readonly service: Service;
|
||||
readonly run: Run;
|
||||
/** Why the record needs the gate (empty: compatible). */
|
||||
/** 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[];
|
||||
|
|
@ -71,14 +72,14 @@ const noop = (): void => {};
|
|||
/**
|
||||
* 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
|
||||
* behavior's remote state-member schemas. A failure fails prepare; the commit then installs without re-checking.
|
||||
* 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, record } = candidate;
|
||||
const exposed = memberSchemas(service, run.behavior);
|
||||
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", `${record.address}: migrated state ${cell.key}`, schema, value);
|
||||
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);
|
||||
|
|
@ -88,7 +89,7 @@ function dryRun(candidate: Candidate): void {
|
|||
run.decls.deriveFns.get(name),
|
||||
);
|
||||
for (const schema of exposed.get(derived) ?? [])
|
||||
assertValid("state", `${record.address}: derived ${name}`, schema, value);
|
||||
assertValid("state", `${object.address}: derived ${name}`, schema, value);
|
||||
values.set(derived, value);
|
||||
}
|
||||
candidate.derived = values;
|
||||
|
|
@ -125,14 +126,14 @@ export async function beginReload(
|
|||
host: HostImpl,
|
||||
plugins: readonly PluginRef[],
|
||||
options: ReloadOptions,
|
||||
epoch?: number,
|
||||
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, epoch);
|
||||
return new Reload(host, await host.resolve(plugins), options, codeVersion);
|
||||
} catch (error) {
|
||||
host.reloading = false;
|
||||
throw reloadError(error);
|
||||
|
|
@ -149,25 +150,25 @@ export class Reload {
|
|||
readonly host: HostImpl;
|
||||
readonly facets: readonly Facet[];
|
||||
readonly options: ReloadOptions;
|
||||
/** Epoch id to commit (issued by the coordinating server); a local reload allocates one. */
|
||||
readonly epoch: number | undefined;
|
||||
readonly candidates = new Map<RecordImpl, Candidate>();
|
||||
/** 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: [RecordImpl, ResourceImpl][] = [];
|
||||
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;
|
||||
/** Records of types this reload adds, with their staged listeners: their runtime starts at commit. */
|
||||
readonly deferred = new Map<RecordImpl, StagedListeners>();
|
||||
/** Objects of types this reload adds, with their staged listeners: their runtime starts at commit. */
|
||||
readonly deferred = new Map<ObjectImpl, StagedListeners>();
|
||||
|
||||
constructor(host: HostImpl, facets: readonly Facet[], options: ReloadOptions, epoch?: number) {
|
||||
constructor(host: HostImpl, facets: readonly Facet[], options: ReloadOptions, codeVersion?: number) {
|
||||
this.host = host;
|
||||
this.facets = facets;
|
||||
this.options = options;
|
||||
this.epoch = epoch;
|
||||
this.codeVersion = codeVersion;
|
||||
}
|
||||
|
||||
/** Everything up to the commit. On failure the host is rolled back and the underlying error is thrown. */
|
||||
|
|
@ -177,11 +178,11 @@ export class Reload {
|
|||
this.load();
|
||||
this.checkProviders();
|
||||
await this.prepareAll();
|
||||
// From here new records of changed types are rejected (upgrade_conflict, retryable) until commit or abort,
|
||||
// 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.checkHolds();
|
||||
this.checkRefs();
|
||||
this.tables = this.host.buildTables(this.entries());
|
||||
await this.gate();
|
||||
this.#alive();
|
||||
|
|
@ -194,14 +195,14 @@ export class Reload {
|
|||
|
||||
/** What a successful prepare will commit (a child sends it with its vote). */
|
||||
summary(): ReloadReport {
|
||||
const live = [...this.candidates.values()].filter(({ record }) => record.status === "live");
|
||||
const live = [...this.candidates.values()].filter(({ object }) => object.status === "live");
|
||||
return {
|
||||
epoch: this.host.pending?.id ?? this.host.current.id,
|
||||
codeVersion: this.host.pending?.id ?? this.host.current.id,
|
||||
previous: this.host.current.id,
|
||||
changed: [...this.changed],
|
||||
removed: [...this.removed],
|
||||
prepared: live.map(({ record }) => record.address),
|
||||
gated: this.gated.map(({ record }) => record.address),
|
||||
prepared: live.map(({ object }) => object.address),
|
||||
gated: this.gated.map(({ object }) => object.address),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -212,16 +213,16 @@ export class Reload {
|
|||
|
||||
/**
|
||||
* Subscribes every new listener now, buffering its events, so a failing source fails prepare instead of leaving a
|
||||
* half-installed epoch. Records running only the new code (types this reload adds) are staged too.
|
||||
* 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.record.status === "live")
|
||||
candidate.staged = candidate.record.stageListeners(candidate.run.decls);
|
||||
if (candidate.object.status === "live")
|
||||
candidate.staged = candidate.object.stageListeners(candidate.run.decls);
|
||||
}
|
||||
for (const record of this.host.records.values()) {
|
||||
if (record.status === "live" && record.deferredRuntime)
|
||||
this.deferred.set(record, record.stageListeners(record.decls));
|
||||
for (const object of this.host.objects.values()) {
|
||||
if (object.status === "live" && object.deferredRuntime)
|
||||
this.deferred.set(object, object.stageListeners(object.decls));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,7 +238,7 @@ export class Reload {
|
|||
clearTimeout(timer);
|
||||
host.retired = noop;
|
||||
}
|
||||
// Roots of the dropped epoch still running get `superseded` on their next call.
|
||||
// Roots of the dropped code version still running get `superseded` on their next call.
|
||||
host.retire();
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +256,7 @@ export class Reload {
|
|||
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.nextEpochId(this.epoch),
|
||||
id: this.host.nextCodeVersionId(this.codeVersion),
|
||||
services: this.services,
|
||||
tables: new Map(),
|
||||
roots: new Map(),
|
||||
|
|
@ -305,24 +306,24 @@ export class Reload {
|
|||
}
|
||||
|
||||
/**
|
||||
* Prepares every live record of a changed type, including records that activate meanwhile, to a fixpoint. Records
|
||||
* 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.records.values()].filter(
|
||||
(record) =>
|
||||
this.changed.has(record.typeName) &&
|
||||
!this.candidates.has(record) &&
|
||||
record.service !== this.services.get(record.typeName) &&
|
||||
(record.status === "live" || record.status === "activating"),
|
||||
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((record) => record.ready));
|
||||
await Promise.allSettled(todo.map((object) => object.ready));
|
||||
this.#alive();
|
||||
for (const record of todo) {
|
||||
if (record.status === "live" && record.service !== this.services.get(record.typeName)) {
|
||||
await this.prepareRecord(record);
|
||||
for (const object of todo) {
|
||||
if (object.status === "live" && object.service !== this.services.get(object.typeName)) {
|
||||
await this.prepareObject(object);
|
||||
this.#alive();
|
||||
}
|
||||
}
|
||||
|
|
@ -330,11 +331,11 @@ export class Reload {
|
|||
}
|
||||
|
||||
/** Runs the new factory in recording mode and opens what can be opened without the gate. */
|
||||
async prepareRecord(record: RecordImpl): Promise<void> {
|
||||
const service = this.services.get(record.typeName)!;
|
||||
const run = record.run(service, record.decls);
|
||||
async prepareObject(object: ObjectImpl): Promise<void> {
|
||||
const service = this.services.get(object.typeName)!;
|
||||
const run = object.run(service, object.decls);
|
||||
const candidate: Candidate = {
|
||||
record,
|
||||
object,
|
||||
service,
|
||||
run,
|
||||
reasons: [],
|
||||
|
|
@ -344,18 +345,18 @@ export class Reload {
|
|||
derived: new Map(),
|
||||
staged: undefined,
|
||||
};
|
||||
this.candidates.set(record, candidate);
|
||||
const bumped = service.stateVersion !== record.stateVersion;
|
||||
if (bumped) candidate.reasons.push(`state version ${record.stateVersion} -> ${service.stateVersion}`);
|
||||
this.candidates.set(object, candidate);
|
||||
const bumped = service.stateVersion !== object.stateVersion;
|
||||
if (bumped) candidate.reasons.push(`state version ${object.stateVersion} -> ${service.stateVersion}`);
|
||||
for (const [name, cell] of run.decls.cells) {
|
||||
if (record.decls.cells.get(name) !== cell || deepEqual(cell.schema, run.decls.cellSchemas.get(name))) continue;
|
||||
if (object.decls.cells.get(name) !== cell || deepEqual(cell.schema, run.decls.cellSchemas.get(name))) continue;
|
||||
if (!bumped) {
|
||||
throw invalid("state", `${record.address}: schema of state ${name} changed without a state-version bump`);
|
||||
throw invalid("state", `${object.address}: schema of state ${name} changed without a state-version bump`);
|
||||
}
|
||||
}
|
||||
for (const name of Object.keys(service.options.state?.migrate ?? {})) {
|
||||
if (!run.decls.cells.has(name))
|
||||
throw invalid("state", `${record.address}: migrate for undeclared state ${name}`);
|
||||
throw invalid("state", `${object.address}: migrate for undeclared state ${name}`);
|
||||
}
|
||||
const deferred = new Set<ResourceImpl>();
|
||||
for (const resource of run.created.resources) {
|
||||
|
|
@ -364,60 +365,60 @@ export class Reload {
|
|||
}
|
||||
for (const resource of deferred) {
|
||||
candidate.atGate.push(resource);
|
||||
const old = record.decls.resources.get(resource.key);
|
||||
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");
|
||||
if (candidate.reasons.length === 0) dryRun(candidate);
|
||||
await record.open(
|
||||
await object.open(
|
||||
run.created.resources.filter((resource) => !deferred.has(resource)),
|
||||
run.created.holds,
|
||||
run.created.refs,
|
||||
this.abort.signal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every hold that survives into the new epoch must bind to the new provider. Browser holds don't block a reload:
|
||||
* after the commit they get `upgrade_required` instead.
|
||||
* 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.
|
||||
*/
|
||||
checkHolds(): void {
|
||||
checkRefs(): void {
|
||||
const dropped = new Set<unknown>();
|
||||
for (const { record, run } of this.candidates.values()) {
|
||||
for (const hold of record.decls.holds.values())
|
||||
if (![...run.decls.holds.values()].includes(hold)) dropped.add(hold);
|
||||
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 record of this.host.records.values()) {
|
||||
const service = this.services.get(record.typeName);
|
||||
if (service === undefined || !this.changed.has(record.typeName)) continue;
|
||||
for (const hold of record.holds) {
|
||||
// The host's bootstrap hold follows this provider at commit; it is not an old-code consumer.
|
||||
if (hold === this.host.eager.get(record.typeName)) continue;
|
||||
if (!dropped.has(hold) && hold.owner !== "client")
|
||||
bind(service.contract, service.options.accepts, hold.contract);
|
||||
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(): [RecordImpl, Run["decls"]][] {
|
||||
entries(): [ObjectImpl, Run["decls"]][] {
|
||||
return this.host
|
||||
.liveDecls(true)
|
||||
.filter(([record]) => !this.removed.has(record.typeName))
|
||||
.map(([record, decls]) => [record, this.candidates.get(record)?.run.decls ?? decls]);
|
||||
.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 records are stopped at the cut; listener events are queued until commit or abort.
|
||||
* 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.record.status === "live",
|
||||
(candidate) => candidate.reasons.length > 0 && candidate.object.status === "live",
|
||||
);
|
||||
const drained = this.gated.map(({ record }) => {
|
||||
const grandfathered = new Set(record.leases.keys());
|
||||
const drained = this.gated.map(({ object }) => {
|
||||
const grandfathered = new Set(object.leases.keys());
|
||||
return new Promise<void>((resolve) => {
|
||||
record.gate = { grandfathered, queue: [], drained: resolve };
|
||||
record.stopTasks();
|
||||
object.gate = { grandfathered, queue: [], drained: resolve };
|
||||
object.stopTasks();
|
||||
if (grandfathered.size === 0) resolve();
|
||||
});
|
||||
});
|
||||
|
|
@ -431,7 +432,7 @@ export class Reload {
|
|||
]);
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
const blocking = this.gated.flatMap(({ record }) => [...(record.gate?.grandfathered ?? [])]);
|
||||
const blocking = this.gated.flatMap(({ object }) => [...(object.gate?.grandfathered ?? [])]);
|
||||
throw new ChordError(
|
||||
"aborted",
|
||||
`gate deadline (${deadline} ms) missed; blocking roots: ${blocking.join(", ")}`,
|
||||
|
|
@ -443,31 +444,31 @@ export class Reload {
|
|||
}
|
||||
this.#alive();
|
||||
for (const candidate of this.gated) {
|
||||
const { record } = candidate;
|
||||
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([record, old]);
|
||||
await old.close(record.openOrder);
|
||||
this.closed.push([object, old]);
|
||||
await old.close(object.openOrder);
|
||||
}
|
||||
await record.open(candidate.atGate, [], this.abort.signal);
|
||||
await object.open(candidate.atGate, [], this.abort.signal);
|
||||
this.#alive();
|
||||
}
|
||||
for (const candidate of this.gated) this.migrate(candidate);
|
||||
}
|
||||
|
||||
migrate(candidate: Candidate): void {
|
||||
const { record, service, run } = candidate;
|
||||
const { object, service, run } = candidate;
|
||||
const migrate = service.options.state?.migrate ?? {};
|
||||
const bumped = service.stateVersion !== record.stateVersion;
|
||||
const bumped = service.stateVersion !== object.stateVersion;
|
||||
for (const [name, cell] of run.decls.cells) {
|
||||
if (record.decls.cells.get(name) !== cell) continue;
|
||||
if (object.decls.cells.get(name) !== cell) continue;
|
||||
const schema = run.decls.cellSchemas.get(name)!;
|
||||
const fn = migrate[name];
|
||||
let value: unknown;
|
||||
if (bumped && fn !== undefined) value = fn(structuredClone(cell.value), record.stateVersion);
|
||||
if (bumped && fn !== undefined) value = fn(structuredClone(cell.value), object.stateVersion);
|
||||
else if (!deepEqual(cell.schema, schema)) value = cell.value;
|
||||
else continue;
|
||||
assertValid("state", `${record.address}: migrated state ${name}`, schema, value);
|
||||
assertValid("state", `${object.address}: migrated state ${name}`, schema, value);
|
||||
candidate.migrated.set(cell, value);
|
||||
}
|
||||
dryRun(candidate);
|
||||
|
|
@ -480,48 +481,49 @@ export class Reload {
|
|||
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 { record, run } of this.candidates.values()) {
|
||||
record.pending.delete(run.decls);
|
||||
await record.unwind(run).catch(report);
|
||||
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 [record, resource] of this.closed) {
|
||||
for (const [object, resource] of this.closed) {
|
||||
if (resource.state !== "closed") continue;
|
||||
await resource.open(new AbortController().signal, record.openOrder).catch((error: unknown) => {
|
||||
await resource.open(new AbortController().signal, object.openOrder).catch((error: unknown) => {
|
||||
resource.markUnavailable();
|
||||
report(
|
||||
new ChordError("unavailable", `${record.address}: resource ${resource.key} could not be restored`, {
|
||||
new ChordError("unavailable", `${object.address}: resource ${resource.key} could not be restored`, {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
for (const { record } of this.gated) {
|
||||
if (record.gate === undefined) continue;
|
||||
record.openGate();
|
||||
for (const name of record.decls.tasks.keys()) record.startTask(name, this.host.current.id);
|
||||
for (const { object } of this.gated) {
|
||||
if (object.gate === undefined) continue;
|
||||
object.openGate();
|
||||
for (const name of object.decls.tasks.keys()) object.startTask(name, this.host.current.id);
|
||||
}
|
||||
// Records of types this reload would have added lose their code.
|
||||
// Objects of types this reload would have added lose their code.
|
||||
const reason = new ChordError("gone", "reload aborted; type not served");
|
||||
for (const record of [...this.host.records.values()]) {
|
||||
if (!this.host.current.services.has(record.typeName)) await record.dispose(true, reason).catch(report);
|
||||
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 epoch 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
|
||||
* epoch is half-published: the host fails terminally and admits no more work.
|
||||
* 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 epoch starts
|
||||
// (tasks, eager activations, then the held listener/registry/state notifications) can reach a child first.
|
||||
// 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;
|
||||
|
|
@ -532,92 +534,104 @@ export class Reload {
|
|||
}
|
||||
}
|
||||
|
||||
/** Work that may start new-epoch roots, run after installation (and after the coordinator's commit frames). */
|
||||
/** 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: EpochState = host.pending!;
|
||||
const next: CodeVersionState = host.pending!;
|
||||
host.pending = undefined;
|
||||
host.epochs.set(next.id, next);
|
||||
host.codeVersions.set(next.id, next);
|
||||
host.previous = previous;
|
||||
host.current = next;
|
||||
const prepared: string[] = [];
|
||||
for (const record of [...host.records.values()]) {
|
||||
if (this.removed.has(record.typeName)) continue;
|
||||
const candidate = this.candidates.get(record);
|
||||
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 = record.behaviors.get(previous.id);
|
||||
if (installed !== undefined) record.behaviors.set(next.id, { ...installed, epoch: next.id });
|
||||
const installed = object.implementations.get(previous.id);
|
||||
if (installed !== undefined) object.implementations.set(next.id, { ...installed, codeVersion: next.id });
|
||||
continue;
|
||||
}
|
||||
if (record.status !== "live") continue;
|
||||
if (object.status !== "live") continue;
|
||||
this.install(candidate, next.id);
|
||||
prepared.push(record.address);
|
||||
prepared.push(object.address);
|
||||
}
|
||||
for (const { record, run, staged } of this.candidates.values()) {
|
||||
if (record.status === "live") continue;
|
||||
for (const { object, run, staged } of this.candidates.values()) {
|
||||
if (object.status === "live") continue;
|
||||
staged?.discard();
|
||||
record.pending.delete(run.decls);
|
||||
record.unwind(run).catch((error: unknown) => host.report(error));
|
||||
object.pending.delete(run.decls);
|
||||
object.unwind(run).catch((error: unknown) => host.report(error));
|
||||
}
|
||||
// Records of types this reload adds start their runtime now.
|
||||
for (const [record, staged] of this.deferred) {
|
||||
if (record.status !== "live") {
|
||||
// Objects of types this reload adds start their runtime now.
|
||||
for (const [object, staged] of this.deferred) {
|
||||
if (object.status !== "live") {
|
||||
staged.discard();
|
||||
continue;
|
||||
}
|
||||
record.deferredRuntime = false;
|
||||
object.deferredRuntime = false;
|
||||
staged.activate();
|
||||
this.#starts.push(() => record.startTasks());
|
||||
this.#starts.push(() => object.startTasks());
|
||||
}
|
||||
host.installTables(next, this.tables, true);
|
||||
const reason = new ChordError("gone", "type no longer served");
|
||||
for (const record of [...host.records.values()]) {
|
||||
if (this.removed.has(record.typeName))
|
||||
record.dispose(true, reason).catch((error: unknown) => host.report(error));
|
||||
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.eager.delete(name);
|
||||
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);
|
||||
host.gating = undefined;
|
||||
for (const [name, service] of this.services) {
|
||||
if (service.options.eager && this.changed.has(name) && host.eager.get(name)?.record?.status !== "live") {
|
||||
this.#starts.push(() => host.startEager(service));
|
||||
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 {
|
||||
epoch: next.id,
|
||||
codeVersion: next.id,
|
||||
previous: previous.id,
|
||||
changed: [...this.changed],
|
||||
removed: [...this.removed],
|
||||
prepared,
|
||||
gated: this.gated.map(({ record }) => record.address),
|
||||
gated: this.gated.map(({ object }) => object.address),
|
||||
};
|
||||
}
|
||||
|
||||
install(candidate: Candidate, epoch: number): void {
|
||||
const { record, run, service } = candidate;
|
||||
install(candidate: Candidate, codeVersion: number): void {
|
||||
const { object, run, service } = candidate;
|
||||
const gated = candidate.reasons.length > 0;
|
||||
record.pending.delete(run.decls);
|
||||
record.stopTasks();
|
||||
object.pending.delete(run.decls);
|
||||
object.stopTasks();
|
||||
if (gated) {
|
||||
// Old derivations would compute over migrated values; the old behavior can no longer run anyway.
|
||||
// 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 record.decls.derives.values()) if (!kept.has(derived)) derived.detach();
|
||||
for (const derived of object.decls.derives.values()) if (!kept.has(derived)) derived.detach();
|
||||
}
|
||||
record.decls = run.decls;
|
||||
record.service = service;
|
||||
record.stateVersion = service.stateVersion;
|
||||
record.behaviors.set(epoch, { epoch, service, behavior: run.behavior, decls: run.decls });
|
||||
object.decls = run.decls;
|
||||
object.service = service;
|
||||
object.stateVersion = service.stateVersion;
|
||||
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 behavior now, not at retirement.
|
||||
record.minEpoch = epoch;
|
||||
for (const old of [...record.behaviors.keys()]) if (old !== epoch) this.host.retireBehavior(record, old);
|
||||
// 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.
|
||||
record.refreshSchemas();
|
||||
object.refreshSchemas();
|
||||
for (const [name, derived] of run.decls.derives) derived.setFn(run.decls.deriveFns.get(name)!);
|
||||
for (const [name, cell] of run.decls.cells) {
|
||||
const schema = run.decls.cellSchemas.get(name)!;
|
||||
|
|
@ -628,32 +642,32 @@ export class Reload {
|
|||
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(() => record.startTasks());
|
||||
record.openGate();
|
||||
const eager = this.host.eager.get(record.typeName);
|
||||
if (eager?.record === record && eager.contract.fingerprint !== service.contract.fingerprint) {
|
||||
const replacement = new HandleImpl(record, service.contract, "host");
|
||||
// Acquire first: releasing the only hold must not dispose/reopen the surviving eager record.
|
||||
record.addHold(replacement);
|
||||
this.host.eager.set(record.typeName, replacement);
|
||||
eager.release();
|
||||
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 handle of [...record.holds]) {
|
||||
// Browser holds whose contract no longer binds are told to reload their code (they never block a reload).
|
||||
if (handle.owner === "client") {
|
||||
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, handle.contract);
|
||||
bind(service.contract, service.options.accepts, ref.contract);
|
||||
} catch (error) {
|
||||
handle.gone(
|
||||
new ChordError("upgrade_required", `${record.typeName} changed; reload the web facets`, {
|
||||
ref.gone(
|
||||
new ChordError("upgrade_required", `${object.typeName} changed; reload the web facets`, {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
record.release(handle);
|
||||
object.release(ref);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
handle.shapeChanged();
|
||||
ref.shapeChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@ import { applyImmutable, type Decoder, decoder } from "./delta/index.ts";
|
|||
import { ChordError } from "./errors.ts";
|
||||
import {
|
||||
CtxImpl,
|
||||
HANDLE_BASE,
|
||||
type Hold,
|
||||
type HoldOwner,
|
||||
holdsByProxy,
|
||||
notifyStatus,
|
||||
REF_BASE,
|
||||
type RefCore,
|
||||
type RefOwner,
|
||||
raceSignal,
|
||||
refsByProxy,
|
||||
type StatusListener,
|
||||
} from "./record.ts";
|
||||
import type { Ctx, HandleStatus, Principal, Readable, Shape, StateEvent } from "./types.ts";
|
||||
} from "./object.ts";
|
||||
import type { Ctx, Principal, Readable, RefStatus, Shape, StateEvent } from "./types.ts";
|
||||
import { fromWireError, type RootRef, type WireError } from "./wire.ts";
|
||||
|
||||
const noop = (): void => {};
|
||||
|
|
@ -24,29 +24,29 @@ export interface StateSink {
|
|||
error?: WireError;
|
||||
}
|
||||
|
||||
/** What a remote handle needs from the link it is bound to (implemented by Connection). */
|
||||
/** What a remote reference needs from the link it is bound to (implemented by Connection). */
|
||||
export interface RemoteChannel {
|
||||
hold(handle: RemoteHandle): number;
|
||||
hold(ref: RemoteRef): number;
|
||||
release(id: number): void;
|
||||
call(hold: number, member: string, args: unknown, root: RootRef | undefined, signal: AbortSignal): Promise<unknown>;
|
||||
sub(hold: number, member: string, sink: (frame: StateSink) => void): number;
|
||||
unsub(id: number): void;
|
||||
}
|
||||
|
||||
/** What a remote handle needs from its host. */
|
||||
/** What a remote reference 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>;
|
||||
forgetRemote(handle: RemoteHandle): void;
|
||||
forgetRemote(ref: RemoteRef): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A state member of a remote handle: hydrated while subscribed (snapshot, then decoded ops); `undefined` before.
|
||||
* A state member of a remote reference: hydrated while subscribed (snapshot, then decoded ops); `undefined` before.
|
||||
* Re-subscribing after a reconnect delivers the new snapshot as a reset.
|
||||
*/
|
||||
class RemoteReadable implements Readable<unknown> {
|
||||
readonly #handle: RemoteHandle;
|
||||
readonly #ref: RemoteRef;
|
||||
readonly #name: string;
|
||||
readonly #values = new Set<(value: unknown) => void>();
|
||||
readonly #events = new Set<(event: StateEvent<unknown>) => void>();
|
||||
|
|
@ -54,8 +54,8 @@ class RemoteReadable implements Readable<unknown> {
|
|||
#decoder: Decoder = decoder();
|
||||
#value: unknown;
|
||||
|
||||
constructor(handle: RemoteHandle, name: string) {
|
||||
this.#handle = handle;
|
||||
constructor(ref: RemoteRef, name: string) {
|
||||
this.#ref = ref;
|
||||
this.#name = name;
|
||||
}
|
||||
|
||||
|
|
@ -77,12 +77,13 @@ class RemoteReadable implements Readable<unknown> {
|
|||
return () => this.#remove(this.#events, listener);
|
||||
}
|
||||
|
||||
/** Subscribes upstream while someone listens and the handle is bound; drops a subscription on an old link. */
|
||||
/** Subscribes upstream while someone listens and the reference is bound; drops a subscription on an old link. */
|
||||
bind(): void {
|
||||
const listening = this.#values.size + this.#events.size > 0;
|
||||
// Nobody listens: the value would go stale, so forget it. The next subscriber sees `undefined`, then the snapshot.
|
||||
// Nobody listens: the value would go stale, so forget it. The next subscriber sees `undefined`, then the
|
||||
// snapshot.
|
||||
if (!listening) this.#value = undefined;
|
||||
const binding = listening ? this.#handle.binding : undefined;
|
||||
const binding = listening ? this.#ref.binding : undefined;
|
||||
if (binding !== undefined && this.#sub?.channel === binding.channel) return;
|
||||
if (this.#sub !== undefined) {
|
||||
if (this.#sub.channel !== binding?.channel || binding === undefined) this.#sub.channel.unsub(this.#sub.id);
|
||||
|
|
@ -108,7 +109,7 @@ class RemoteReadable implements Readable<unknown> {
|
|||
|
||||
#receive(frame: StateSink): void {
|
||||
if (frame.error !== undefined) {
|
||||
this.#handle.host.report(fromWireError(frame.error));
|
||||
this.#ref.host.report(fromWireError(frame.error));
|
||||
return;
|
||||
}
|
||||
let event: StateEvent<unknown>;
|
||||
|
|
@ -136,25 +137,25 @@ class RemoteReadable implements Readable<unknown> {
|
|||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
this.#handle.host.report(error);
|
||||
this.#ref.host.report(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A hold on an object on another host, bound to a link. Late-bound like a local handle. */
|
||||
export class RemoteHandle implements Hold {
|
||||
readonly record = undefined;
|
||||
/** A reference on an object on another host, bound to a link. Late-bound like a local reference. */
|
||||
export class RemoteRef implements RefCore {
|
||||
readonly object = undefined;
|
||||
readonly host: RemoteHost;
|
||||
readonly contract: AnyContract;
|
||||
readonly at: string;
|
||||
readonly input: unknown;
|
||||
/** Forwarded principal (omitted for the host's own principal). */
|
||||
readonly principal: Principal | undefined;
|
||||
readonly owner: HoldOwner;
|
||||
readonly owner: RefOwner;
|
||||
readonly address: string;
|
||||
readonly proxy: object;
|
||||
readonly ready: Promise<void>;
|
||||
status: HandleStatus = "loading";
|
||||
status: RefStatus = "loading";
|
||||
error: unknown;
|
||||
shape: Shape | undefined;
|
||||
binding: { readonly channel: RemoteChannel; readonly id: number } | undefined;
|
||||
|
|
@ -171,7 +172,7 @@ export class RemoteHandle implements Hold {
|
|||
at: string;
|
||||
input: unknown;
|
||||
principal: Principal | undefined;
|
||||
owner: HoldOwner;
|
||||
owner: RefOwner;
|
||||
address: string;
|
||||
},
|
||||
) {
|
||||
|
|
@ -189,7 +190,7 @@ export class RemoteHandle implements Hold {
|
|||
this.#live = this.#newLive();
|
||||
this.proxy = new Proxy(this, {
|
||||
get: (target, property) => {
|
||||
if (HANDLE_BASE.has(property)) {
|
||||
if (REF_BASE.has(property)) {
|
||||
const value: unknown = Reflect.get(target, property, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
}
|
||||
|
|
@ -197,11 +198,11 @@ export class RemoteHandle implements Hold {
|
|||
return target.member(property);
|
||||
},
|
||||
});
|
||||
holdsByProxy.set(this.proxy, this);
|
||||
refsByProxy.set(this.proxy, this);
|
||||
}
|
||||
|
||||
get kind(): string | undefined {
|
||||
return this.shape?.kind;
|
||||
get variant(): string | undefined {
|
||||
return this.shape?.variant;
|
||||
}
|
||||
|
||||
onShape(listener: (shape: Shape) => void): () => void {
|
||||
|
|
@ -224,7 +225,7 @@ export class RemoteHandle implements Hold {
|
|||
if (this.status === "released" || this.status === "gone") return;
|
||||
this.status = "released";
|
||||
this.#statusChanged();
|
||||
const error = new ChordError("released", `handle ${this.address} released`);
|
||||
const error = new ChordError("released", `reference ${this.address} released`);
|
||||
this.#settleReady?.reject(error);
|
||||
this.#live.reject(error);
|
||||
for (const readable of this.#readables.values()) readable.stop();
|
||||
|
|
@ -328,19 +329,27 @@ export class RemoteHandle implements Hold {
|
|||
async #call(name: string, args: unknown, ctx: Ctx): Promise<unknown> {
|
||||
for (;;) {
|
||||
if (this.status === "released" || this.status === "gone") {
|
||||
throw this.status === "gone" ? this.error : new ChordError("released", `handle ${this.address} released`);
|
||||
throw this.status === "gone"
|
||||
? this.error
|
||||
: new ChordError("released", `reference ${this.address} released`);
|
||||
}
|
||||
if (this.status === "loading") await raceSignal(this.#live.promise, ctx.signal);
|
||||
const binding = this.binding;
|
||||
if (binding === undefined) continue;
|
||||
const kind = this.shape?.kind;
|
||||
if (kind === undefined || this.contract.description.variants[kind]?.[name]?.kind !== "method") {
|
||||
throw new ChordError("not_in_variant", `${this.address}: ${name} is not a method of variant ${kind}`);
|
||||
const variant = this.shape?.variant;
|
||||
if (variant === undefined || this.contract.description.variants[variant]?.[name]?.kind !== "method") {
|
||||
throw new ChordError("not_in_variant", `${this.address}: ${name} is not a method of variant ${variant}`);
|
||||
}
|
||||
// Send only what the consumer's own schema knows (the additive rule's premise; see RecordImpl.call).
|
||||
const description = this.contract.description.variants[kind]?.[name];
|
||||
const description = this.contract.description.variants[variant]?.[name];
|
||||
const sent = description?.kind === "method" ? Value.Clean(description.args, Value.Clone(args)) : args;
|
||||
return binding.channel.call(binding.id, name, sent, { rootId: ctx.rootId, epoch: ctx.epoch }, ctx.signal);
|
||||
return binding.channel.call(
|
||||
binding.id,
|
||||
name,
|
||||
sent,
|
||||
{ rootId: ctx.rootId, codeVersion: ctx.codeVersion },
|
||||
ctx.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
import type { AnyContract, Handle, InputOf } from "./contract.ts";
|
||||
import type { AnyContract, InputOf, Ref } from "./contract.ts";
|
||||
import type { Op } from "./delta/index.ts";
|
||||
|
||||
/** App-defined, JSON-serializable identity of a caller. Mapped to a record key by `createHost({ principalKey })`. */
|
||||
/** App-defined, JSON-serializable identity of a caller. Mapped to an object key by `createHost({ principalKey })`. */
|
||||
export type Principal = unknown;
|
||||
|
||||
export interface GetOptions {
|
||||
/** Principal the record is created for. Defaults to the host principal. */
|
||||
/** Principal the object is created for. Defaults to the host principal. */
|
||||
readonly principal?: Principal;
|
||||
/** Host name. Only the local host in phase 1a. */
|
||||
readonly at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The explicit call context of one root (a short Chord call tree). Calls made with it carry its root id and epoch;
|
||||
* `get` holds are released when the call that received this ctx settles.
|
||||
* 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 {
|
||||
readonly signal: AbortSignal;
|
||||
readonly rootId: string;
|
||||
readonly epoch: number;
|
||||
readonly codeVersion: number;
|
||||
readonly principal: Principal;
|
||||
get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions): Handle<C>;
|
||||
get<C extends AnyContract>(type: C, input: InputOf<C>, options?: GetOptions): Ref<C>;
|
||||
}
|
||||
|
||||
export type StateEvent<T> =
|
||||
|
|
@ -28,7 +28,7 @@ export type StateEvent<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 handle's state member. Values are immutable by contract. */
|
||||
/** 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. */
|
||||
|
|
@ -38,42 +38,42 @@ export interface Readable<T> {
|
|||
}
|
||||
|
||||
/**
|
||||
* What a behavior may expose as a state member: a cell, a derivation, or a held object's state member (whose value is
|
||||
* `undefined` only while that object is loading; a record is live only after its holds are).
|
||||
* What an implementation may expose as a state member: a cell, a derivation, or a held object's state member (whose
|
||||
* value is `undefined` only while that object is loading; an object is live only after its references are).
|
||||
*/
|
||||
export type StateSource<T> = Readable<T> | Readable<T | undefined>;
|
||||
|
||||
declare const EXPOSED: unique symbol;
|
||||
|
||||
/** A local member backed by a resource (`self.expose(res.x)`): handles read the resource's value once live. */
|
||||
/** 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 HandleStatus = "loading" | "live" | "released" | "gone";
|
||||
export type RefStatus = "loading" | "live" | "released" | "gone";
|
||||
|
||||
export interface Shape {
|
||||
readonly kind: string | undefined;
|
||||
readonly variant: string | undefined;
|
||||
readonly members: readonly string[];
|
||||
/** Fingerprint of the provider contract. */
|
||||
readonly contract: string;
|
||||
readonly epoch: number;
|
||||
readonly codeVersion: number;
|
||||
}
|
||||
|
||||
export interface HandleBase<C extends AnyContract> {
|
||||
/** The consumer contract token this handle was bound with. */
|
||||
export interface RefBase<C extends AnyContract> {
|
||||
/** The consumer contract token this reference was bound with. */
|
||||
readonly contract: C;
|
||||
readonly status: HandleStatus;
|
||||
readonly status: RefStatus;
|
||||
readonly ready: Promise<void>;
|
||||
readonly kind: string | undefined;
|
||||
readonly variant: string | undefined;
|
||||
readonly shape: Shape | undefined;
|
||||
/** Why the handle is gone, if it is. */
|
||||
/** 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: HandleStatus, error: unknown) => void): () => void;
|
||||
onStatus(listener: (status: RefStatus, error: unknown) => void): () => void;
|
||||
release(): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export interface WebSocketServerLike<Request> {
|
|||
on(event: "connection", listener: (socket: WebSocketLike, request: Request) => void): unknown;
|
||||
}
|
||||
|
||||
/** WebSocket peers are always accepted as clients: no roots, no epochs, no reload votes, not addressable. */
|
||||
/** WebSocket peers are always accepted as clients: no roots, no code versions, no reload votes, not addressable. */
|
||||
export interface WebSocketAcceptOptions<Request> extends Omit<AcceptOptions, "principal" | "kind" | "name"> {
|
||||
/** The authenticator: the principal of the connecting client, or undefined to refuse it. */
|
||||
principal(request: Request): Principal | undefined | Promise<Principal | undefined>;
|
||||
|
|
|
|||
|
|
@ -11,8 +11,15 @@ import { CHORD_ERROR_CODES, ChordError } from "./errors.ts";
|
|||
// 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.
|
||||
// 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 = 2;
|
||||
|
||||
const Id = Type.Integer({ minimum: 1 });
|
||||
const Name = Type.String({ minLength: 1 });
|
||||
|
|
@ -20,7 +27,7 @@ const Reload = Type.Integer({ minimum: 1 });
|
|||
/** Arrays a sender may pass as readonly. */
|
||||
const List = <T extends TSchema>(item: T) => Type.Immutable(Type.Array(item));
|
||||
|
||||
const RootRefSchema = Type.Object({ rootId: Name, epoch: Type.Integer({ minimum: 1 }) });
|
||||
const RootRefSchema = Type.Object({ rootId: Name, codeVersion: Type.Integer({ minimum: 1 }) });
|
||||
export type RootRef = Static<typeof RootRefSchema>;
|
||||
|
||||
const WireErrorSchema = Type.Object({
|
||||
|
|
@ -39,7 +46,7 @@ export type WireReloadOptions = Static<typeof WireReloadOptionsSchema>;
|
|||
|
||||
/** A child's prepared reload, sent with its vote (same fields as `ReloadReport`, without children). */
|
||||
const WireReloadReportSchema = Type.Object({
|
||||
epoch: Type.Integer(),
|
||||
codeVersion: Type.Integer(),
|
||||
previous: Type.Integer(),
|
||||
changed: List(Type.String()),
|
||||
removed: List(Type.String()),
|
||||
|
|
@ -51,9 +58,11 @@ export type WireReloadReport = Static<typeof WireReloadReportSchema>;
|
|||
const FrameSchema = Type.Union([
|
||||
Type.Object({
|
||||
t: Type.Literal("hello"),
|
||||
/** The link protocol version (`PROTOCOL_VERSION`; absent: 1). Peers of another version close the link. */
|
||||
protocol: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
name: Name,
|
||||
kind: Type.Union([Type.Literal("host"), Type.Literal("client")]),
|
||||
epoch: Type.Integer({ minimum: 0 }),
|
||||
codeVersion: Type.Integer({ minimum: 0 }),
|
||||
/** The sender acknowledges what it receives (`ack`). Flow control applies only when both hellos say so. */
|
||||
acks: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
|
|
@ -76,10 +85,10 @@ const FrameSchema = Type.Union([
|
|||
Type.Object({
|
||||
t: Type.Literal("shape"),
|
||||
id: Id,
|
||||
kind: Type.Optional(Type.String()),
|
||||
variant: Type.Optional(Type.String()),
|
||||
members: List(Type.String()),
|
||||
contract: Type.String(),
|
||||
epoch: Type.Integer(),
|
||||
codeVersion: Type.Integer(),
|
||||
}),
|
||||
Type.Object({ t: Type.Literal("gone"), id: Id, error: WireErrorSchema }),
|
||||
Type.Object({
|
||||
|
|
@ -111,14 +120,15 @@ const FrameSchema = Type.Union([
|
|||
}),
|
||||
Type.Object({ t: Type.Literal("ping"), n: Type.Integer() }),
|
||||
Type.Object({ t: Type.Literal("pong"), n: Type.Integer() }),
|
||||
/** Ends the link (consumed by the link itself). `reason: "protocol-version <n>"`: the sender speaks version n. */
|
||||
Type.Object({ t: Type.Literal("bye"), reason: Type.Optional(Type.String()) }),
|
||||
/** The root ended at its origin: its leases on records end (sent on every link the root's calls used). */
|
||||
/** The root ended at its origin: its leases on objects end (sent on every link the root's calls used). */
|
||||
Type.Object({ t: Type.Literal("root_end"), rootId: Name }),
|
||||
Type.Object({ t: Type.Literal("invalidate"), pattern: Type.Record(Type.String(), Type.Unknown()) }),
|
||||
Type.Object({
|
||||
t: Type.Literal("prepare"),
|
||||
reload: Reload,
|
||||
epoch: Type.Integer({ minimum: 1 }),
|
||||
codeVersion: Type.Integer({ minimum: 1 }),
|
||||
plugins: List(Type.String()),
|
||||
options: WireReloadOptionsSchema,
|
||||
}),
|
||||
|
|
@ -129,8 +139,8 @@ const FrameSchema = Type.Union([
|
|||
report: Type.Optional(WireReloadReportSchema),
|
||||
}),
|
||||
Type.Object({ t: Type.Literal("commit"), reload: Reload }),
|
||||
/** The parent dropped `epoch`: roots of it may still come from there no longer. */
|
||||
Type.Object({ t: Type.Literal("retire"), epoch: Type.Integer({ minimum: 1 }) }),
|
||||
/** The parent dropped `codeVersion`: roots of it may still come from there no longer. */
|
||||
Type.Object({ t: Type.Literal("retire"), codeVersion: Type.Integer({ minimum: 1 }) }),
|
||||
Type.Object({ t: Type.Literal("abort"), reload: Reload }),
|
||||
]);
|
||||
export type Frame = Static<typeof FrameSchema>;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ function greeterFacet(greeting: string, options: { fail?: boolean } = {}): Facet
|
|||
if (options.fail) throw new Error("worker factory fails");
|
||||
const state = self.state({ count: cell(Type.Object({ n: Type.Number() }), { n: 0 }) });
|
||||
return {
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
count: state.count,
|
||||
greet: (name: string, ctx: Ctx) => {
|
||||
state.count.change(ctx, (draft) => {
|
||||
|
|
@ -75,7 +75,7 @@ function setup() {
|
|||
}
|
||||
|
||||
describe("cross-host reload", () => {
|
||||
it("prepares every host, commits the server first, then the workers; epochs stay in step", async () => {
|
||||
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.get(Greeter, {}, { at: "session:S1" });
|
||||
|
|
@ -88,20 +88,20 @@ describe("cross-host reload", () => {
|
|||
});
|
||||
await tick();
|
||||
const shapes: number[] = [];
|
||||
greeter.onShape((shape) => shapes.push(shape.epoch));
|
||||
greeter.onShape((shape) => shapes.push(shape.codeVersion));
|
||||
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.epoch, report.epoch);
|
||||
assert.equal(worker.epoch, report.epoch);
|
||||
assert.equal(server.codeVersion, report.codeVersion);
|
||||
assert.equal(worker.codeVersion, report.codeVersion);
|
||||
go.open();
|
||||
assert.equal(await pinned, "v1 p");
|
||||
assert.deepEqual(shapes, [report.epoch]);
|
||||
// The server retires the old epoch once its last old root ended, then tells the worker.
|
||||
for (let i = 0; i < 20 && worker.previousEpoch !== undefined; i++) await tick();
|
||||
assert.equal(server.previousEpoch, undefined);
|
||||
assert.equal(worker.previousEpoch, undefined);
|
||||
assert.deepEqual(shapes, [report.codeVersion]);
|
||||
// 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 () => {
|
||||
|
|
@ -109,14 +109,14 @@ describe("cross-host reload", () => {
|
|||
await tick();
|
||||
const greeter = server.get(Greeter, {}, { at: "session:S1" });
|
||||
await greeter.greet("a");
|
||||
const before = [server.epoch, worker.epoch];
|
||||
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.epoch, worker.epoch], before);
|
||||
assert.deepEqual([server.codeVersion, worker.codeVersion], before);
|
||||
assert.equal(await greeter.greet("x"), "v1 x");
|
||||
});
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ describe("cross-host reload", () => {
|
|||
await go.promise;
|
||||
});
|
||||
await tick();
|
||||
const before = [server.epoch, worker.epoch];
|
||||
const before = [server.codeVersion, worker.codeVersion];
|
||||
const bumped: Facet = {
|
||||
name: "greeter",
|
||||
setup: (api) =>
|
||||
|
|
@ -145,7 +145,7 @@ describe("cross-host reload", () => {
|
|||
assert.match(error.message, /session:S1: .*blocking roots: server#\w+:\d+/);
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual([server.epoch, worker.epoch], before);
|
||||
assert.deepEqual([server.codeVersion, worker.codeVersion], before);
|
||||
go.open();
|
||||
await blocker;
|
||||
assert.equal(await greeter.greet("x"), "v1 x");
|
||||
|
|
@ -185,7 +185,7 @@ describe("cross-host reload", () => {
|
|||
cold.connect(up);
|
||||
for (let i = 0; i < 20 && old.status !== "gone"; i++) await tick();
|
||||
assert.equal(old.status, "gone");
|
||||
assert.equal(cold.epoch, server.epoch);
|
||||
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"));
|
||||
|
|
@ -220,16 +220,16 @@ describe("cross-host reload", () => {
|
|||
const viewer = browser.get(Greeter, {}, { at: "session:S1" });
|
||||
assert.equal(await viewer.greet("a"), "v1 a");
|
||||
const shapes: number[] = [];
|
||||
viewer.onShape((shape) => shapes.push(shape.epoch));
|
||||
viewer.onShape((shape) => shapes.push(shape.codeVersion));
|
||||
modules.set("greeter", greeterFacet("v2"));
|
||||
const report = await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
for (let i = 0; i < 20 && shapes.length === 0; i++) await tick();
|
||||
assert.deepEqual(shapes, [report.epoch]);
|
||||
assert.deepEqual(shapes, [report.codeVersion]);
|
||||
assert.equal(await viewer.greet("b"), "v2 b");
|
||||
|
||||
modules.set("greeter", {
|
||||
name: "greeter",
|
||||
setup: (api) => api.serve(GreeterV2, () => ({ kind: "main", hello: (name: string) => `hi ${name}` })),
|
||||
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();
|
||||
|
|
@ -259,18 +259,18 @@ describe("cross-host reload", () => {
|
|||
}),
|
||||
});
|
||||
const state = self.state({ count: cell(Type.Object({ n: Type.Number() }), { n: 0 }) });
|
||||
return { kind: "main", count: state.count, greet: (name: string) => `slow ${name}` };
|
||||
return { variant: "main", count: state.count, greet: (name: string) => `slow ${name}` };
|
||||
}),
|
||||
};
|
||||
modules.set("greeter", slow);
|
||||
const epoch = worker.epoch;
|
||||
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.epoch, epoch);
|
||||
assert.equal(worker.codeVersion, codeVersion);
|
||||
modules.set("greeter", greeterFacet("v3"));
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
assert.equal(await greeter.greet("b"), "v3 b");
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ describe("contracts", () => {
|
|||
() => bind(v2, undefined, chatV1),
|
||||
(error) => isChordError(error, "contract_mismatch"),
|
||||
);
|
||||
const adapter = (current: Record<string, unknown>) => ({ kind: "viewer", view: current.body });
|
||||
const adapter = (current: Record<string, unknown>) => ({ variant: "viewer", view: current.body });
|
||||
assert.equal(bind(v2, { 1: adapter }, chatV1), adapter);
|
||||
});
|
||||
|
||||
|
|
|
|||
6
packages/chord/test/fixtures/loader.ts
vendored
6
packages/chord/test/fixtures/loader.ts
vendored
|
|
@ -44,12 +44,12 @@ try {
|
|||
writeGreeting("v1");
|
||||
const host = createHost({ loader: nodeLoader, onError: () => {} });
|
||||
await host.load([`${dir}facet.ts`]);
|
||||
const handle = host.get(Hello, {});
|
||||
assert.equal(await handle.greet("x"), "v1 x");
|
||||
const ref = host.get(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 handle.greet("x"), "v2 x");
|
||||
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.
|
||||
|
|
|
|||
2
packages/chord/test/fixtures/worker.ts
vendored
2
packages/chord/test/fixtures/worker.ts
vendored
|
|
@ -23,7 +23,7 @@ if (mode === "fatal") {
|
|||
});
|
||||
const init = (await control.init) as { greeting: string };
|
||||
host.serve(Echo, () => ({
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
echo: (text: string) => `${init.greeting} ${text} from ${process.pid}`,
|
||||
pid: () => process.pid,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type Link,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
remote,
|
||||
} from "../src/index.ts";
|
||||
|
||||
|
|
@ -22,7 +23,7 @@ async function until(check: () => boolean, attempts = 200): Promise<void> {
|
|||
assert.ok(check(), "condition not reached");
|
||||
}
|
||||
|
||||
/** A client's end of a link that can hold back its acknowledgements, and records what it receives. */
|
||||
/** A client's end of a link that can hold back its acknowledgements, and objects what it receives. */
|
||||
class AckGate implements Link {
|
||||
readonly inner: Link;
|
||||
readonly received: Frame[] = [];
|
||||
|
|
@ -85,7 +86,7 @@ function serveCounter(host: Host): void {
|
|||
host.serve(Counter, (_input, _principal, self) => {
|
||||
const state = self.state({ count: cell(Type.Object({ n: Type.Number() }), { n: 0 }) });
|
||||
return {
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
count: state.count,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
state.count.change(ctx, (draft) => {
|
||||
|
|
@ -228,7 +229,7 @@ describe("link flow control", () => {
|
|||
server.accept(down, { principal: { user: "u" }, window: 1 });
|
||||
const frames: Frame[] = [];
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.send({ t: "hello", name: "raw", kind: "client", epoch: 0 });
|
||||
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: {} });
|
||||
raw.send({ t: "sub", id: 2, hold: 1, member: "count" });
|
||||
for (let i = 0; i < 5; i++) raw.send({ t: "call", id: 3 + i, hold: 1, member: "add", args: 1 });
|
||||
|
|
@ -245,7 +246,7 @@ describe("link flow control", () => {
|
|||
const [down, raw] = memoryLinkPair();
|
||||
server.accept(down, { principal: { user: "u" } });
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", name: "raw", kind: "client", epoch: 0, acks: true });
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { onTestFinished, test } from "vitest";
|
|||
import { HostImpl } from "../src/host.ts";
|
||||
import {
|
||||
cell,
|
||||
createHost,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
isChordError,
|
||||
|
|
@ -14,7 +15,7 @@ import {
|
|||
type Self,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const input = Type.Object({ key: Type.String() });
|
||||
const input = Type.Object({});
|
||||
const flags = Type.Record(Type.String(), Type.Boolean());
|
||||
const SwitchV1 = local("t.eager-switch", {
|
||||
version: 1,
|
||||
|
|
@ -73,7 +74,7 @@ function switchFacets(log: string[]) {
|
|||
},
|
||||
};
|
||||
},
|
||||
{ eager: { input: { key: "custom" } } },
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
};
|
||||
const v2 = (valid: boolean): Facet => ({
|
||||
|
|
@ -96,7 +97,7 @@ function switchFacets(log: string[]) {
|
|||
};
|
||||
},
|
||||
{
|
||||
eager: { input: { key: "custom" } },
|
||||
lifetime: "host",
|
||||
state: {
|
||||
version: 2,
|
||||
migrate: {
|
||||
|
|
@ -128,44 +129,44 @@ function switchFacets(log: string[]) {
|
|||
},
|
||||
};
|
||||
},
|
||||
{ eager: { input: { key: "custom" } }, state: { version: 2 } },
|
||||
{ lifetime: "host", state: { version: 2 } },
|
||||
),
|
||||
};
|
||||
return { v1, v2, v3 };
|
||||
}
|
||||
|
||||
test("an eager hold follows contract bumps at commit without reopening its record or resources; failures retain the old hold", async () => {
|
||||
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({ 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.eager.get(SwitchV1.name)!;
|
||||
const record = initial.record;
|
||||
const original = host.get(SwitchV1, { key: "custom" });
|
||||
const initial = host.hostObjects.get(SwitchV1.name)!;
|
||||
const object = initial.object;
|
||||
const original = host.get(SwitchV1, {});
|
||||
await original.set(true);
|
||||
original.release();
|
||||
const epoch = host.epoch;
|
||||
const codeVersion = host.codeVersion;
|
||||
await assert.rejects(host.reload([facets.v2(false)]), (error) => isChordError(error, "invalid"));
|
||||
assert.equal(host.epoch, epoch);
|
||||
assert.equal(host.eager.get(SwitchV1.name), initial);
|
||||
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.eager.get(SwitchV2.name)!;
|
||||
const updated = host.hostObjects.get(SwitchV2.name)!;
|
||||
assert.equal(updated.contract, SwitchV2);
|
||||
assert.equal(updated.record, record);
|
||||
assert.equal(updated.object, object);
|
||||
assert.equal(initial.status, "released");
|
||||
assert.equal(record?.holds.size, 1, "Attach the replacement before releasing the only old hold");
|
||||
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.get(SwitchV2, { key: "custom" });
|
||||
const modern = host.get(SwitchV2, {});
|
||||
await modern.set({ conversation: 2, enabled: true });
|
||||
modern.release();
|
||||
assert.deepEqual((await host.reload([facets.v3])).gated, []);
|
||||
assert.equal(host.eager.get(SwitchV3.name)?.contract, SwitchV3);
|
||||
assert.equal(host.eager.get(SwitchV3.name)?.record, record);
|
||||
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();
|
||||
|
|
@ -187,18 +188,18 @@ const ReadV2 = local("t.eager-read", {
|
|||
});
|
||||
const readerV1: Facet = {
|
||||
name: "read",
|
||||
setup: (api) => api.serve(ReadV1, () => ({ read: () => "one" }), { eager: true }),
|
||||
setup: (api) => api.serve(ReadV1, () => ({ read: () => "one" }), { lifetime: "host" }),
|
||||
};
|
||||
const readerV2 = (adapt = false): Facet => ({
|
||||
name: "read",
|
||||
setup: (api) =>
|
||||
api.serve(ReadV2, () => ({ fetch: () => "two" }), {
|
||||
eager: true,
|
||||
lifetime: "host",
|
||||
...(adapt ? { accepts: { 1: (current: Record<string, unknown>) => ({ read: current.fetch }) } } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
test("external old-version consumers still refuse an eager upgrade without accepts, and keep their adapter when it is provided", async () => {
|
||||
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({
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
|
|
@ -206,20 +207,20 @@ test("external old-version consumers still refuse an eager upgrade without accep
|
|||
});
|
||||
onTestFinished(() => host.close());
|
||||
await host.load([readerV1]);
|
||||
const own = host.eager.get(ReadV1.name)!;
|
||||
const own = host.hostObjects.get(ReadV1.name)!;
|
||||
const consumer = host.get(ReadV1, {});
|
||||
assert.equal(await consumer.read(null), "one");
|
||||
await assert.rejects(host.reload([readerV2()]), (error) => isChordError(error, "contract_mismatch"));
|
||||
assert.equal(host.eager.get(ReadV1.name), own);
|
||||
assert.equal(host.hostObjects.get(ReadV1.name), own);
|
||||
assert.equal(await consumer.read(null), "one");
|
||||
await host.reload([readerV2(true)]);
|
||||
assert.equal(host.eager.get(ReadV2.name)?.contract, ReadV2);
|
||||
assert.equal(host.hostObjects.get(ReadV2.name)?.contract, ReadV2);
|
||||
assert.equal(await consumer.read(null), "two");
|
||||
assert.equal(consumer.contract, ReadV1, "Only the bootstrap hold is retokened");
|
||||
assert.equal(await host.get(ReadV2, {}).fetch(null), "two");
|
||||
});
|
||||
|
||||
test("record-owned old-version holds still block an eager upgrade unless their consumer is replaced in the same batch", async () => {
|
||||
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({ onError: () => {} });
|
||||
onTestFinished(() => host.close());
|
||||
const Consumer = local("t.eager-consumer", {
|
||||
|
|
@ -250,10 +251,10 @@ test("record-owned old-version holds still block an eager upgrade unless their c
|
|||
assert.equal(await consumer.read(null), "one");
|
||||
await host.reload([readerV2(), newConsumer]);
|
||||
assert.equal(await consumer.read(null), "two");
|
||||
assert.equal(host.eager.get(ReadV2.name)?.contract, ReadV2);
|
||||
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 eager hold", async () => {
|
||||
test("a forgotten contract bump remains refused even when the only consumer is the host's own reference", async () => {
|
||||
const host = new HostImpl({ onError: () => {} });
|
||||
onTestFinished(() => host.close());
|
||||
await host.load([readerV1]);
|
||||
|
|
@ -264,14 +265,17 @@ test("a forgotten contract bump remains refused even when the only consumer is t
|
|||
});
|
||||
await assert.rejects(
|
||||
host.reload([
|
||||
{ name: "read", setup: (api) => api.serve(incompatible, () => ({ fetch: () => "bad" }), { eager: true }) },
|
||||
{
|
||||
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.eager.get(ReadV1.name)?.contract, ReadV1);
|
||||
assert.equal(host.hostObjects.get(ReadV1.name)?.contract, ReadV1);
|
||||
});
|
||||
|
||||
test("a cluster contract upgrade retokens server and worker eager holds only after unanimous preparation", async () => {
|
||||
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;
|
||||
|
|
@ -287,20 +291,58 @@ test("a cluster contract upgrade retokens server and worker eager holds only aft
|
|||
server.accept(down, { kind: "host", name: "worker", principal: { worker: true } });
|
||||
worker.connect(up);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const serverOwn = server.eager.get(SwitchV1.name);
|
||||
const workerOwn = worker.eager.get(SwitchV1.name);
|
||||
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.eager.get(SwitchV1.name), serverOwn);
|
||||
assert.equal(worker.eager.get(SwitchV1.name), workerOwn);
|
||||
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.eager.get(SwitchV2.name)?.contract, SwitchV2);
|
||||
assert.equal(worker.eager.get(SwitchV2.name)?.contract, SwitchV2);
|
||||
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.epoch, worker.epoch);
|
||||
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({ onError: () => {} });
|
||||
const Kept = local("t.lifetime-change", { version: 1, input: Type.Object({}) });
|
||||
const log: string[] = [];
|
||||
const facet = (lifetime: "host" | "referenced"): Facet => ({
|
||||
name: "kept",
|
||||
setup: (api) =>
|
||||
api.serve(
|
||||
Kept,
|
||||
(_input, _principal, self) => {
|
||||
self.resources({
|
||||
life: resource({
|
||||
rev: 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -8,12 +8,13 @@ import {
|
|||
defineRegistry,
|
||||
derivation,
|
||||
forbidden,
|
||||
type Handle,
|
||||
type Host,
|
||||
isChordError,
|
||||
listener,
|
||||
local,
|
||||
method,
|
||||
opaque,
|
||||
type Ref,
|
||||
remote,
|
||||
resource,
|
||||
} from "../src/index.ts";
|
||||
|
|
@ -68,22 +69,22 @@ describe("host: activation and lifetime", () => {
|
|||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
serveCounter(host, log);
|
||||
const handle = host.get(Counter, { name: "a" });
|
||||
assert.equal(handle.status, "loading");
|
||||
assert.equal(handle.count.value, undefined);
|
||||
const pending = handle.add(2);
|
||||
await handle.ready;
|
||||
assert.equal(handle.status, "live");
|
||||
const ref = host.get(Counter, { name: "a" });
|
||||
assert.equal(ref.status, "loading");
|
||||
assert.equal(ref.count.value, undefined);
|
||||
const pending = ref.add(2);
|
||||
await ref.ready;
|
||||
assert.equal(ref.status, "live");
|
||||
assert.equal(await pending, 2);
|
||||
assert.deepEqual(handle.count.value, { n: 2 });
|
||||
handle.release();
|
||||
assert.equal(handle.status, "released");
|
||||
await assert.rejects(handle.add(1), (error) => isChordError(error, "released"));
|
||||
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 record while held", async () => {
|
||||
it("coalesces concurrent gets and keeps the object while held", async () => {
|
||||
const host = quiet();
|
||||
const log: string[] = [];
|
||||
serveCounter(host, log);
|
||||
|
|
@ -135,9 +136,9 @@ describe("host: activation and lifetime", () => {
|
|||
});
|
||||
return {};
|
||||
});
|
||||
const handle = host.get(Slow, {});
|
||||
const ref = host.get(Slow, {});
|
||||
await tick();
|
||||
handle.release();
|
||||
ref.release();
|
||||
await tick();
|
||||
release();
|
||||
await tick();
|
||||
|
|
@ -268,9 +269,9 @@ describe("host: activation and lifetime", () => {
|
|||
});
|
||||
return {};
|
||||
});
|
||||
const handle = host.get(Multi, {});
|
||||
await handle.ready;
|
||||
handle.release();
|
||||
const ref = host.get(Multi, {});
|
||||
await ref.ready;
|
||||
ref.release();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(log.at(-1), "close a");
|
||||
|
|
@ -293,6 +294,66 @@ describe("host: activation and lifetime", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("host: lifetime", () => {
|
||||
const Store = local("t.store", {
|
||||
version: 1,
|
||||
input: Type.Object({ scope: Type.Optional(Type.String({ default: "all" })) }),
|
||||
members: { seen: opaque<unknown>("t.seen@1") },
|
||||
});
|
||||
|
||||
it('serve refuses a lifetime "host" the type cannot have', () => {
|
||||
const host = quiet();
|
||||
const Remote = remote("t.remote-store", { version: 1, input: Type.Object({}), variants: { main: {} } });
|
||||
assert.throws(
|
||||
() => host.serve(Remote, () => ({ variant: "main" }), { lifetime: "host" } as never),
|
||||
/needs a local type/,
|
||||
);
|
||||
const Keyed = local("t.keyed", { version: 1, input: Type.Object({ id: Type.String() }) });
|
||||
assert.throws(() => host.serve(Keyed, () => ({}), { lifetime: "host" }), /input that accepts \{\}/);
|
||||
assert.throws(
|
||||
() => host.serve(Store, () => ({ seen: null }), { lifetime: "host", linger: 10 }),
|
||||
/does not linger/,
|
||||
);
|
||||
assert.throws(
|
||||
() => host.serve(Store, () => ({ seen: null }), { lifetime: "forever" } as never),
|
||||
/unknown lifetime/,
|
||||
);
|
||||
});
|
||||
|
||||
it("every get reaches the one object, created at serve for the host principal; another input is invalid", async () => {
|
||||
const host = createHost({ 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.get(Store, {}, { principal: { guest: "g" } });
|
||||
const defaults = host.get(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.get(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.get(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,
|
||||
|
|
@ -310,9 +371,9 @@ describe("host: principals, variants and disposal", () => {
|
|||
host.serve(Doc, (_input, principal, self) => {
|
||||
principals.push(principal);
|
||||
const state = self.state({ view: cell(Type.Object({ text: Type.String() }), { text: "" }) });
|
||||
if ((principal as { role?: string }).role !== "editor") return { kind: "viewer", view: state.view };
|
||||
if ((principal as { role?: string }).role !== "editor") return { variant: "viewer", view: state.view };
|
||||
return {
|
||||
kind: "editor",
|
||||
variant: "editor",
|
||||
view: state.view,
|
||||
edit: ({ text }, ctx) => {
|
||||
state.view.replace(ctx, { text });
|
||||
|
|
@ -322,7 +383,7 @@ describe("host: principals, variants and disposal", () => {
|
|||
});
|
||||
}
|
||||
|
||||
it("keeps records per principal key and enforces variants", async () => {
|
||||
it("keeps objects per principal key and enforces variants", async () => {
|
||||
const host = createHost({ principalKey: (principal) => (principal as { user: string }).user, onError: () => {} });
|
||||
const principals: unknown[] = [];
|
||||
serveDoc(host, principals);
|
||||
|
|
@ -330,7 +391,7 @@ describe("host: principals, variants and disposal", () => {
|
|||
const editor = host.get(Doc, { id: "1" }, { principal: { user: "e", role: "editor" } });
|
||||
await Promise.all([viewer.ready, editor.ready]);
|
||||
assert.equal(principals.length, 2);
|
||||
assert.equal(viewer.kind, "viewer");
|
||||
assert.equal(viewer.variant, "viewer");
|
||||
assert.deepEqual(viewer.shape?.members, ["view"]);
|
||||
await assert.rejects(viewer.edit({ text: "x" }), (error) => isChordError(error, "not_in_variant"));
|
||||
assert.deepEqual(await editor.edit({ text: "x" }), { ok: true });
|
||||
|
|
@ -352,7 +413,7 @@ describe("host: principals, variants and disposal", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("forced disposal makes handles gone and aborts in-flight calls", async () => {
|
||||
it("forced disposal makes references gone and aborts in-flight calls", async () => {
|
||||
const host = quiet();
|
||||
let seenAbort = false;
|
||||
const Slow = local("t.inflight", {
|
||||
|
|
@ -369,15 +430,15 @@ describe("host: principals, variants and disposal", () => {
|
|||
}),
|
||||
),
|
||||
}));
|
||||
const handle = host.get(Slow, {}, { principal: { user: "u" } });
|
||||
await handle.ready;
|
||||
const call = handle.wait(null);
|
||||
const ref = host.get(Slow, {}, { principal: { user: "u" } });
|
||||
await ref.ready;
|
||||
const call = ref.wait(null);
|
||||
await tick();
|
||||
await host.invalidatePrincipal((principal) => (principal as { user: string }).user === "u");
|
||||
assert.equal(handle.status, "gone");
|
||||
assert.equal(ref.status, "gone");
|
||||
await call;
|
||||
assert.ok(seenAbort);
|
||||
await assert.rejects(handle.wait(null), (error) => isChordError(error, "gone"));
|
||||
await assert.rejects(ref.wait(null), (error) => isChordError(error, "gone"));
|
||||
const fresh = host.get(Slow, {}, { principal: { user: "u" } });
|
||||
await fresh.ready;
|
||||
assert.equal(fresh.status, "live");
|
||||
|
|
@ -386,9 +447,9 @@ describe("host: principals, variants and disposal", () => {
|
|||
it("state members are late-bound readables: values, then snapshot/reset/ops events", async () => {
|
||||
const host = quiet();
|
||||
serveDoc(host);
|
||||
const editor = host.get(Doc, { id: "3" }, { principal: { role: "editor" } }) as Handle<typeof Doc>;
|
||||
const editor = host.get(Doc, { id: "3" }, { principal: { role: "editor" } }) as Ref<typeof Doc>;
|
||||
const shapes: unknown[] = [];
|
||||
editor.onShape((shape) => shapes.push(shape.kind));
|
||||
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]));
|
||||
|
|
@ -408,7 +469,7 @@ describe("host: principals, variants and disposal", () => {
|
|||
describe("host: registries", () => {
|
||||
const Routes = defineRegistry<string>("t.routes");
|
||||
|
||||
it("only eager records register; tables follow registration order", async () => {
|
||||
it('only objects of lifetime "host" register; tables follow registration order', async () => {
|
||||
const host = quiet();
|
||||
const RoutesA = local("t.routes-a", { version: 1, input: Type.Object({}) });
|
||||
const RoutesB = local("t.routes-b", { version: 1, input: Type.Object({}) });
|
||||
|
|
@ -418,7 +479,7 @@ describe("host: registries", () => {
|
|||
self.register(Routes, { "GET /a": "a", "GET /c": "c" });
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
host.serve(
|
||||
RoutesB,
|
||||
|
|
@ -426,7 +487,7 @@ describe("host: registries", () => {
|
|||
self.register(Routes, { "GET /b": "b" });
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
const tables: string[][] = [];
|
||||
host.registry(Routes).subscribe((table) => tables.push([...table.keys()]));
|
||||
|
|
@ -441,7 +502,7 @@ describe("host: registries", () => {
|
|||
self.register(Routes, { x: "x" });
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.get(Lazy, {}).ready, /only eager/);
|
||||
await assert.rejects(host.get(Lazy, {}).ready, /only objects of lifetime "host" may register/);
|
||||
});
|
||||
|
||||
it("rejects conflicting registrations", async () => {
|
||||
|
|
@ -454,7 +515,7 @@ describe("host: registries", () => {
|
|||
self.register(Routes, { same: name });
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
}
|
||||
await tick();
|
||||
|
|
@ -475,7 +536,7 @@ describe("host: composition", () => {
|
|||
members: { copy: Type.Number(), doubled: Type.Number() },
|
||||
});
|
||||
|
||||
it("handle state members are sources for listeners and derivations", async () => {
|
||||
it("reference state members are sources for listeners and derivations", async () => {
|
||||
const host = quiet();
|
||||
host.serve(Source, (_input, _principal, self) => {
|
||||
const state = self.state({ n: cell(Type.Number(), 1) });
|
||||
|
|
@ -530,7 +591,7 @@ describe("host: composition", () => {
|
|||
assert.deepEqual(seen, [{ user: "guest" }, { host: true }]);
|
||||
});
|
||||
|
||||
it("calling a handle inside a factory throws not_ready", async () => {
|
||||
it("calling a reference inside a factory throws not_ready", async () => {
|
||||
const host = quiet();
|
||||
host.serve(Source, (_input, _principal, self) => ({
|
||||
n: self.state({ n: cell(Type.Number(), 0) }).n,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
isChordError,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
type Principal,
|
||||
remote,
|
||||
resource,
|
||||
|
|
@ -34,13 +35,13 @@ const Counter = remote("t.counter", {
|
|||
},
|
||||
});
|
||||
|
||||
/** Serves Counter and records the principal of every record it creates. */
|
||||
/** Serves Counter and objects the principal of every object it creates. */
|
||||
function serveCounter(host: Host, principals: unknown[] = []) {
|
||||
host.serve(Counter, (_input, principal, self) => {
|
||||
principals.push(principal);
|
||||
const state = self.state({ count: cell(Type.Object({ n: Type.Number() }), { n: 0 }) });
|
||||
return {
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
count: state.count,
|
||||
add: (delta, ctx) => {
|
||||
state.count.change(ctx, (draft) => {
|
||||
|
|
@ -71,7 +72,7 @@ describe("links: calls and state across hosts", () => {
|
|||
const result = await worker.root(async (ctx) => {
|
||||
const counter = ctx.get(Counter, { name: "c" }, { at: "server" });
|
||||
await counter.ready;
|
||||
assert.equal(counter.kind, "main");
|
||||
assert.equal(counter.variant, "main");
|
||||
return [await counter.add(2, ctx), await counter.who(null, ctx)];
|
||||
});
|
||||
assert.deepEqual(result, [2, { worker: "session:S1" }]);
|
||||
|
|
@ -151,14 +152,14 @@ function cluster(options: { pingInterval?: number; pingTimeout?: number } = {}):
|
|||
upstreamOptions: options,
|
||||
onError: () => {},
|
||||
});
|
||||
const handle = { host, up, reconnect: () => {} };
|
||||
handle.reconnect = () => {
|
||||
const ref = { host, up, reconnect: () => {} };
|
||||
ref.reconnect = () => {
|
||||
const [nextDown, nextUp] = memoryLinkPair();
|
||||
server.accept(nextDown, { principal });
|
||||
handle.up = nextUp;
|
||||
ref.up = nextUp;
|
||||
host.connect(nextUp);
|
||||
};
|
||||
return handle;
|
||||
return ref;
|
||||
},
|
||||
};
|
||||
server.route("session:", {
|
||||
|
|
@ -176,7 +177,7 @@ function cluster(options: { pingInterval?: number; pingTimeout?: number } = {}):
|
|||
});
|
||||
const counter = self.state({ count: cell(Type.Object({ n: Type.Number() }), { n: 0 }) });
|
||||
return {
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
count: counter.count,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
counter.count.change(ctx, (draft) => {
|
||||
|
|
@ -216,7 +217,7 @@ describe("links: relay, stamping, lifetime", () => {
|
|||
c.server.accept(down, { principal: guest });
|
||||
const frames: Frame[] = [];
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.send({ t: "hello", name: "raw", kind: "client", epoch: 0 });
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
raw.send({
|
||||
t: "hold",
|
||||
id: 1,
|
||||
|
|
@ -319,7 +320,7 @@ describe("links: relay, stamping, lifetime", () => {
|
|||
variants: { main: { methods: { inc: method(Type.Null(), Type.Number()) } } },
|
||||
});
|
||||
const { server, worker } = serverWithWorker();
|
||||
server.serve(CounterV2, () => ({ kind: "main", inc: () => 1 }));
|
||||
server.serve(CounterV2, () => ({ variant: "main", inc: () => 1 }));
|
||||
await assert.rejects(worker.get(Counter, { name: "x" }, { at: "server" }).ready, (error) =>
|
||||
isChordError(error, "contract_mismatch"),
|
||||
);
|
||||
|
|
@ -337,7 +338,7 @@ describe("links: relay, stamping, lifetime", () => {
|
|||
const counter = browser.host.get(Counter, { name: "a" }, { at: "session:S1" });
|
||||
await counter.add(1);
|
||||
await c.server.invalidatePrincipal({ guest: "g1" });
|
||||
// The relay under the old principal is closed at once; the worker disposes its record after.
|
||||
// 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();
|
||||
|
|
@ -360,7 +361,7 @@ describe("links: relay, stamping, lifetime", () => {
|
|||
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 record; the reset carries its value.
|
||||
// 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 });
|
||||
assert.ok(events.filter((type) => type === "reset").length >= 2);
|
||||
});
|
||||
|
|
@ -380,6 +381,71 @@ const Big = remote("t.big", {
|
|||
/** 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: 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({ 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({ name: "browser", kind: "client", onError: () => {} });
|
||||
browser.connect(up, { 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({ name: "browser", kind: "client", onError: () => {} });
|
||||
other.connect(up2, { 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({ name: "server", onError: () => {} });
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { guest: "g" }, onProtocolMismatch: both.onProtocolMismatch });
|
||||
const browser = createHost({ name: "browser", kind: "client", onError: () => {} });
|
||||
browser.connect(up, { 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({ name: "server", onError: () => {} });
|
||||
|
|
@ -390,7 +456,7 @@ describe("links: packed payloads", () => {
|
|||
const worker = createHost({ name, upstream: up, onError: () => {} });
|
||||
worker.serve(Big, (_input, _principal, self) => {
|
||||
const state = self.state({ notes: cell(Type.Array(Type.String()), large("a")) });
|
||||
return { kind: "main", notes: state.notes, echo: (notes: string[]) => notes };
|
||||
return { variant: "main", notes: state.notes, echo: (notes: string[]) => notes };
|
||||
});
|
||||
return down;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ import {
|
|||
type Host,
|
||||
isChordError,
|
||||
type Link,
|
||||
local,
|
||||
memoryLinkPair,
|
||||
method,
|
||||
PROTOCOL_VERSION,
|
||||
type Principal,
|
||||
remote,
|
||||
resource,
|
||||
|
|
@ -102,7 +104,7 @@ function cluster() {
|
|||
server.accept(down, { principal });
|
||||
const frames: Frame[] = [];
|
||||
raw.receive((frame) => frames.push(frame));
|
||||
raw.send({ t: "hello", name: "raw", kind: "client", epoch: 0 });
|
||||
raw.send({ t: "hello", protocol: PROTOCOL_VERSION, name: "raw", kind: "client", codeVersion: 0 });
|
||||
return { raw, frames };
|
||||
},
|
||||
};
|
||||
|
|
@ -126,7 +128,7 @@ function cluster() {
|
|||
});
|
||||
const counter = self.state({ count: cell(Type.Object({ n: Type.Number() }), { n: 0 }) });
|
||||
return {
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
count: counter.count,
|
||||
add: (delta: number, ctx: Ctx) => {
|
||||
counter.count.change(ctx, (draft) => {
|
||||
|
|
@ -154,7 +156,7 @@ const internals = (host: Host) =>
|
|||
connections: Set<{
|
||||
role: string;
|
||||
peer: { kind: string; name?: string };
|
||||
relayedHolds: Map<number, unknown>;
|
||||
relayedRefs: Map<number, unknown>;
|
||||
relayedSubs: Map<number, unknown>;
|
||||
relayedCalls: Map<number, unknown>;
|
||||
}>;
|
||||
|
|
@ -167,7 +169,7 @@ describe("links review: identity and stamping", () => {
|
|||
const [down, raw] = memoryLinkPair();
|
||||
c.server.accept(down, { principal: { guest: "g" } });
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", name: "session:S9", kind: "host", epoch: 1 });
|
||||
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/);
|
||||
|
|
@ -187,7 +189,7 @@ describe("links review: identity and stamping", () => {
|
|||
Echo,
|
||||
(_input, _principal, self) => {
|
||||
self.state({ n: cell(Type.Number(), 0) });
|
||||
return { kind: "main", root: (_args: null, ctx: Ctx) => ctx.rootId };
|
||||
return { variant: "main", root: (_args: null, ctx: Ctx) => ctx.rootId };
|
||||
},
|
||||
{ state: { version } },
|
||||
),
|
||||
|
|
@ -207,7 +209,7 @@ describe("links review: identity and stamping", () => {
|
|||
const [clientDown, raw] = memoryLinkPair();
|
||||
server.accept(clientDown, { principal: { guest: "g" } });
|
||||
raw.receive(() => {});
|
||||
raw.send({ t: "hello", name: "raw", kind: "client", epoch: 0 });
|
||||
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) => {
|
||||
|
|
@ -219,7 +221,7 @@ describe("links review: identity and stamping", () => {
|
|||
await pinned;
|
||||
});
|
||||
|
||||
it("#2 a revocation while the route is still resolving closes the relay; no worker record is made", async () => {
|
||||
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;
|
||||
|
|
@ -303,7 +305,7 @@ describe("links review: relay ownership", () => {
|
|||
const down = [...internals(c.server).connections].find((connection) => connection.peer.name === "session:S1")!;
|
||||
assert.equal(down.relayedSubs.size, 1);
|
||||
browser.up.close();
|
||||
await until(() => down.relayedHolds.size === 0 && down.relayedSubs.size === 0);
|
||||
await until(() => down.relayedRefs.size === 0 && down.relayedSubs.size === 0);
|
||||
await until(() => c.log.includes("close a"));
|
||||
});
|
||||
|
||||
|
|
@ -337,7 +339,7 @@ describe("links review: relay ownership", () => {
|
|||
server.serve(
|
||||
DocV2,
|
||||
(_input, _principal, self) => ({
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
view: self.state({ view: cell(Type.Object({ body: Type.String() }), { body: "hi" }) }).view,
|
||||
}),
|
||||
{
|
||||
|
|
@ -349,7 +351,7 @@ describe("links review: relay ownership", () => {
|
|||
};
|
||||
const map = (value: { body: string }) => ({ text: value.body });
|
||||
return {
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
view: {
|
||||
get value() {
|
||||
return map(view.value);
|
||||
|
|
@ -383,7 +385,7 @@ describe("links review: cross-host reload", () => {
|
|||
});
|
||||
const greeterFacet = (greeting: string): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) => api.serve(Greeter, () => ({ kind: "main", greet: (name: string) => `${greeting} ${name}` })),
|
||||
setup: (api) => api.serve(Greeter, () => ({ variant: "main", greet: (name: string) => `${greeting} ${name}` })),
|
||||
});
|
||||
|
||||
function setup(options: { slowLoad?: Promise<void> } = {}) {
|
||||
|
|
@ -430,14 +432,14 @@ describe("links review: cross-host reload", () => {
|
|||
await assert.rejects(reload);
|
||||
gate.open();
|
||||
await until(() => !internals(worker).reloading);
|
||||
assert.equal(worker.epoch, 1);
|
||||
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 = remote("t.caller", { version: 1, input: Type.Object({}), variants: { main: {} } });
|
||||
const Caller = local("t.caller", { version: 1, input: Type.Object({}) });
|
||||
const callerFacet = (active: boolean): Facet => ({
|
||||
name: "caller",
|
||||
setup: (api) =>
|
||||
|
|
@ -454,9 +456,9 @@ describe("links review: cross-host reload", () => {
|
|||
},
|
||||
});
|
||||
}
|
||||
return { kind: "main" };
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
server.install(callerFacet(false));
|
||||
|
|
@ -475,12 +477,12 @@ describe("links review: cross-host reload", () => {
|
|||
link.closeOn = (frame) => frame.t === "commit";
|
||||
await server.reload([], { remote: { "session:": ["greeter"] } });
|
||||
await until(() => !internals(worker).reloading);
|
||||
assert.notEqual(worker.epoch, server.epoch);
|
||||
assert.notEqual(worker.codeVersion, server.codeVersion);
|
||||
const next = connect();
|
||||
worker.connect(next.up);
|
||||
const greeter = server.get(Greeter, {}, { at: "session:S1" });
|
||||
assert.equal(await greeter.greet("x"), "v2 x");
|
||||
assert.equal(worker.epoch, server.epoch);
|
||||
assert.equal(worker.codeVersion, server.codeVersion);
|
||||
});
|
||||
|
||||
it("#6 a server root that bounces through a worker and back ends normally (no leaked lease)", async () => {
|
||||
|
|
@ -503,18 +505,18 @@ describe("links review: cross-host reload", () => {
|
|||
Echo,
|
||||
(_input, _principal, self) => {
|
||||
self.state({ n: cell(Type.Number(), 0) });
|
||||
return { kind: "main", ping: () => null };
|
||||
return { variant: "main", ping: () => null };
|
||||
},
|
||||
{ state: { version } },
|
||||
),
|
||||
});
|
||||
server.install(echoFacet(1));
|
||||
// Keeps the Echo record the bounced call reaches (it acts as the worker's principal) alive, so the reload gate
|
||||
// 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.get(Echo, {}, { principal: { worker: "w" } });
|
||||
await echo.ready;
|
||||
worker.serve(Bouncer, () => ({
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
bounce: async (_args: null, ctx: Ctx) => {
|
||||
await ctx.get(Echo, {}, { at: "server" }).ping(null, ctx);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ describe("host: eager readiness", () => {
|
|||
self.register(entries, { eager: "live" });
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
serve(Lazy, () => {
|
||||
lazyRuns++;
|
||||
|
|
@ -97,7 +97,7 @@ describe("host: eager readiness", () => {
|
|||
});
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -128,7 +128,7 @@ describe("host: eager readiness", () => {
|
|||
self.get(Lazy, {});
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
},
|
||||
{ name: "provider", setup: ({ serve }) => serve(Lazy, () => ({})) },
|
||||
|
|
@ -153,7 +153,7 @@ describe("host: eager readiness", () => {
|
|||
self.get(Lazy, {});
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
},
|
||||
]),
|
||||
|
|
@ -164,18 +164,18 @@ describe("host: eager readiness", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("ready includes direct eager serves and their configured inputs, but not later serves", async () => {
|
||||
it("ready includes direct host serves, but not later serves", async () => {
|
||||
const host = createHost({ onError: () => {} });
|
||||
const WithInput = local("ready.input", { version: 1, input: Type.Object({ id: Type.String() }) });
|
||||
let input: unknown;
|
||||
const First = local("ready.first", { version: 1, input: Type.Object({}) });
|
||||
let created = 0;
|
||||
try {
|
||||
host.serve(
|
||||
WithInput,
|
||||
(value) => {
|
||||
input = value;
|
||||
First,
|
||||
() => {
|
||||
created++;
|
||||
return {};
|
||||
},
|
||||
{ eager: { input: { id: "bootstrap" } } },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
const first = host.ready();
|
||||
host.serve(
|
||||
|
|
@ -183,10 +183,10 @@ describe("host: eager readiness", () => {
|
|||
() => {
|
||||
throw forbidden("later");
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
await first;
|
||||
assert.deepEqual(input, { id: "bootstrap" });
|
||||
assert.equal(created, 1);
|
||||
await assert.rejects(host.ready(), /later/);
|
||||
} finally {
|
||||
await host.close();
|
||||
|
|
@ -199,7 +199,7 @@ describe("host: eager readiness", () => {
|
|||
const [parent, upstream] = memoryLinkPair();
|
||||
const worker = createHost({ name: "worker", upstream, onError: unexpectedError });
|
||||
const Status = remote("ready.status", { version: 1, input: Type.Object({}), variants: { default: {} } });
|
||||
server.serve(Status, () => ({ kind: "default" }));
|
||||
server.serve(Status, () => ({ variant: "default" }));
|
||||
let bootstrap = false;
|
||||
let reported = false;
|
||||
const facet = {
|
||||
|
|
@ -211,7 +211,7 @@ describe("host: eager readiness", () => {
|
|||
bootstrap = true;
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
serve(
|
||||
Eager,
|
||||
|
|
@ -228,14 +228,14 @@ describe("host: eager readiness", () => {
|
|||
});
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
},
|
||||
};
|
||||
try {
|
||||
await (operation === "install"
|
||||
? worker.install(facet, { awaitEager: false })
|
||||
: worker.load([facet], { awaitEager: false }));
|
||||
? worker.install(facet, { awaitHostObjects: false })
|
||||
: worker.load([facet], { awaitHostObjects: false }));
|
||||
await worker.get(Lazy, {}).ready;
|
||||
assert.equal(bootstrap, true);
|
||||
let settled = false;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ interface GreeterSpec {
|
|||
readonly schema?: typeof CountV1 | ReturnType<typeof Type.Object>;
|
||||
readonly initial?: object;
|
||||
readonly bump?: (draft: Record<string, number>) => void;
|
||||
readonly options?: ServeOptions<typeof Greeter>;
|
||||
readonly options?: ServeOptions;
|
||||
readonly extra?: (self: Self, input: { id?: string }) => void;
|
||||
}
|
||||
|
||||
|
|
@ -87,27 +87,27 @@ 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 handle = host.get(Greeter, {});
|
||||
assert.equal(await handle.greet("a"), "hello a");
|
||||
const ref = host.get(Greeter, {});
|
||||
assert.equal(await ref.greet("a"), "hello a");
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
const first = await handle.greet("p", ctx);
|
||||
const first = await ref.greet("p", ctx);
|
||||
await go.promise;
|
||||
return [first, await handle.greet("p", ctx)];
|
||||
return [first, await ref.greet("p", ctx)];
|
||||
});
|
||||
await tick();
|
||||
const shapes: number[] = [];
|
||||
handle.onShape((shape) => shapes.push(shape.epoch));
|
||||
ref.onShape((shape) => shapes.push(shape.codeVersion));
|
||||
const report = await host.reload([greeterFacet({ greeting: "hi" })]);
|
||||
assert.deepEqual(report.gated, []);
|
||||
assert.equal(report.prepared.length, 1);
|
||||
assert.equal(await handle.greet("b"), "hi b");
|
||||
assert.deepEqual(shapes, [report.epoch]);
|
||||
assert.equal(host.previousEpoch, report.previous);
|
||||
assert.equal(await ref.greet("b"), "hi b");
|
||||
assert.deepEqual(shapes, [report.codeVersion]);
|
||||
assert.equal(host.previousCodeVersion, report.previous);
|
||||
go.open();
|
||||
assert.deepEqual(await pinned, ["hello p", "hello p"]);
|
||||
assert.equal(host.previousEpoch, undefined);
|
||||
assert.deepEqual(handle.count.value, { n: 4 });
|
||||
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 () => {
|
||||
|
|
@ -144,13 +144,13 @@ describe("reload", () => {
|
|||
if (input.id === "b") throw new Error("b fails");
|
||||
},
|
||||
});
|
||||
const epoch = host.epoch;
|
||||
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.epoch, epoch);
|
||||
assert.equal(host.codeVersion, codeVersion);
|
||||
assert.deepEqual(log, ["open a", "close a"]);
|
||||
assert.equal(await a.greet("x"), "hello x");
|
||||
});
|
||||
|
|
@ -176,16 +176,16 @@ describe("reload", () => {
|
|||
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 handle = host.get(Greeter, {});
|
||||
await handle.greet("a");
|
||||
await handle.greet("a");
|
||||
const ref = host.get(Greeter, {});
|
||||
await ref.greet("a");
|
||||
await ref.greet("a");
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
handle.count.events((event) => events.push(event));
|
||||
ref.count.events((event) => events.push(event));
|
||||
const go = latch();
|
||||
// Started before the reload, never touched the record: rejected after the commit.
|
||||
// Started before the reload, never touched the object: rejected after the commit.
|
||||
const late = host.root(async (ctx) => {
|
||||
await go.promise;
|
||||
return handle.greet("late", ctx);
|
||||
return ref.greet("late", ctx);
|
||||
});
|
||||
const froms: number[] = [];
|
||||
const v2 = greeterFacet({
|
||||
|
|
@ -211,8 +211,8 @@ describe("reload", () => {
|
|||
assert.equal(report.gated.length, 1);
|
||||
assert.deepEqual(froms, [1]);
|
||||
assert.deepEqual(events.at(-1), { type: "reset", value: { total: 2 } });
|
||||
assert.equal(await handle.greet("b"), "hi b");
|
||||
assert.deepEqual(handle.count.value, { total: 3 });
|
||||
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);
|
||||
});
|
||||
|
|
@ -220,19 +220,19 @@ describe("reload", () => {
|
|||
it("gate: late old roots get upgrade_conflict, grandfathered roots re-enter, then commit", async () => {
|
||||
const host = quiet();
|
||||
host.install(greeterFacet({ greeting: "hello" }));
|
||||
const handle = host.get(Greeter, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(Greeter, {});
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const grandfathered = host.root(async (ctx) => {
|
||||
await handle.greet("g", ctx);
|
||||
await ref.greet("g", ctx);
|
||||
await go.promise;
|
||||
return handle.greet("g", ctx);
|
||||
return ref.greet("g", ctx);
|
||||
});
|
||||
await tick();
|
||||
const reload = host.reload([greeterFacet({ greeting: "hi", options: { state: { version: 2 } } })]);
|
||||
await until(async () => {
|
||||
try {
|
||||
await handle.greet("probe");
|
||||
await ref.greet("probe");
|
||||
return false;
|
||||
} catch (error) {
|
||||
return isChordError(error, "upgrade_conflict");
|
||||
|
|
@ -242,7 +242,7 @@ describe("reload", () => {
|
|||
assert.equal(await grandfathered, "hello g");
|
||||
const report = await reload;
|
||||
assert.equal(report.gated.length, 1);
|
||||
assert.equal(await handle.greet("n"), "hi n");
|
||||
assert.equal(await ref.greet("n"), "hi n");
|
||||
});
|
||||
|
||||
it("gate deadline: reload aborts, reports blocking roots, nothing changes", async () => {
|
||||
|
|
@ -268,11 +268,11 @@ describe("reload", () => {
|
|||
),
|
||||
});
|
||||
host.install(facet("hello", 1));
|
||||
const handle = host.get(Greeter, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(Greeter, {});
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const blocker = host.root(async (ctx) => {
|
||||
await handle.greet("b", ctx);
|
||||
await ref.greet("b", ctx);
|
||||
await go.promise;
|
||||
});
|
||||
await tick();
|
||||
|
|
@ -281,7 +281,7 @@ describe("reload", () => {
|
|||
assert.match(error.message, /blocking roots: host#\w+:\d+/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(await handle.greet("x"), "hello x");
|
||||
assert.equal(await ref.greet("x"), "hello x");
|
||||
await tick();
|
||||
assert.deepEqual(log, ["start hello", "stop hello", "start hello"]);
|
||||
go.open();
|
||||
|
|
@ -316,39 +316,39 @@ describe("reload", () => {
|
|||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const handle = host.get(Greeter, {});
|
||||
assert.equal(await handle.greet("x"), "1 x");
|
||||
const ref = host.get(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 handle.greet("x"), "2 x");
|
||||
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 epoch; its roots get superseded", async () => {
|
||||
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 handle = host.get(Greeter, {});
|
||||
await handle.ready;
|
||||
const first = host.epoch;
|
||||
const ref = host.get(Greeter, {});
|
||||
await ref.ready;
|
||||
const first = host.codeVersion;
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await handle.greet("p", ctx);
|
||||
await ref.greet("p", ctx);
|
||||
await go.promise;
|
||||
return handle.greet("p", ctx);
|
||||
return ref.greet("p", ctx);
|
||||
});
|
||||
await tick();
|
||||
await host.reload([greeterFacet({ greeting: "v2" })]);
|
||||
assert.equal(host.previousEpoch, first);
|
||||
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, { epoch: first }),
|
||||
host.root(() => 1, { codeVersion: first }),
|
||||
(error) => isChordError(error, "superseded"),
|
||||
);
|
||||
assert.equal(await handle.greet("n"), "v3 n");
|
||||
assert.equal(await ref.greet("n"), "v3 n");
|
||||
});
|
||||
|
||||
it("checks contracts at reload: forgotten bumps, live consumers, accepts adapters", async () => {
|
||||
|
|
@ -370,28 +370,28 @@ describe("reload", () => {
|
|||
const host = quiet();
|
||||
host.install({
|
||||
name: "doc",
|
||||
setup: (api) => api.serve(DocV1, () => ({ kind: "main", read: () => ({ text: "v1" }) })),
|
||||
setup: (api) => api.serve(DocV1, () => ({ variant: "main", read: () => ({ text: "v1" }) })),
|
||||
});
|
||||
const consumer = host.get(DocV1, {});
|
||||
assert.deepEqual(await consumer.read(null), { text: "v1" });
|
||||
|
||||
const broken: Facet = {
|
||||
name: "doc",
|
||||
setup: (api) => api.serve(DocBroken, () => ({ kind: "main", read: () => ({ text: 1 }) })),
|
||||
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, () => ({ kind: "main", fetch })) };
|
||||
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, () => ({ kind: "main", fetch }), {
|
||||
api.serve(DocV2, () => ({ variant: "main", fetch }), {
|
||||
accepts: {
|
||||
1: (current) => ({
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
read: () => ({ text: `${(current.fetch as typeof fetch)().body} via adapter` }),
|
||||
}),
|
||||
},
|
||||
|
|
@ -447,19 +447,19 @@ describe("reload", () => {
|
|||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const handle = host.get(Sum, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(Sum, {});
|
||||
await ref.ready;
|
||||
emit(1);
|
||||
await tick();
|
||||
await host.reload([facet(10)]);
|
||||
assert.equal(listeners.size, 1);
|
||||
emit(1);
|
||||
await tick();
|
||||
assert.deepEqual(handle.sum.value, { v: 11 });
|
||||
assert.deepEqual(ref.sum.value, { v: 11 });
|
||||
assert.deepEqual(log, ["start 1", "start 10", "stop 1"]);
|
||||
});
|
||||
|
||||
it("keeps registry tables per epoch", async () => {
|
||||
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: Type.Object({}) });
|
||||
|
|
@ -472,14 +472,14 @@ describe("reload", () => {
|
|||
self.register(Routes, { "GET /": value });
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
),
|
||||
});
|
||||
host.install(facet("one"));
|
||||
await tick();
|
||||
await tick();
|
||||
const seen: [string | undefined, number][] = [];
|
||||
host.registry(Routes).subscribe((table, epoch) => seen.push([table.get("GET /"), epoch]));
|
||||
host.registry(Routes).subscribe((table, codeVersion) => seen.push([table.get("GET /"), codeVersion]));
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await go.promise;
|
||||
|
|
@ -491,15 +491,15 @@ describe("reload", () => {
|
|||
assert.equal(await pinned, "one");
|
||||
assert.deepEqual(seen, [
|
||||
["one", report.previous],
|
||||
["two", report.epoch],
|
||||
["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 handle = host.get(Greeter, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(Greeter, {});
|
||||
await ref.ready;
|
||||
const Clock = local("t.clock", {
|
||||
version: 1,
|
||||
input: Type.Object({}),
|
||||
|
|
@ -519,34 +519,34 @@ describe("reload", () => {
|
|||
};
|
||||
}),
|
||||
});
|
||||
const epoch = host.epoch;
|
||||
const codeVersion = host.codeVersion;
|
||||
await assert.rejects(host.reload([clockFacet, consumer(true)]), /consumer fails/);
|
||||
assert.equal(host.epoch, epoch);
|
||||
assert.equal(await handle.greet("x"), "hello x");
|
||||
assert.equal(host.codeVersion, codeVersion);
|
||||
assert.equal(await ref.greet("x"), "hello x");
|
||||
await host.reload([clockFacet, consumer(false)]);
|
||||
assert.equal(await handle.greet("x"), "42 x");
|
||||
assert.equal(await ref.greet("x"), "42 x");
|
||||
});
|
||||
|
||||
it("the gate rejects new records of a changed type; after commit they get the new code", async () => {
|
||||
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 handle = host.get(Greeter, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(Greeter, {});
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const grandfathered = host.root(async (ctx) => {
|
||||
await handle.greet("g", ctx);
|
||||
await ref.greet("g", ctx);
|
||||
await go.promise;
|
||||
});
|
||||
const later = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await later.promise;
|
||||
return handle.greet("old", ctx);
|
||||
return ref.greet("old", ctx);
|
||||
});
|
||||
await tick();
|
||||
const reload = host.reload([greeterFacet({ greeting: "hi", options: { state: { version: 2 } } })]);
|
||||
await until(async () => {
|
||||
try {
|
||||
await handle.greet("probe");
|
||||
await ref.greet("probe");
|
||||
return false;
|
||||
} catch (error) {
|
||||
return isChordError(error, "upgrade_conflict");
|
||||
|
|
@ -560,8 +560,8 @@ describe("reload", () => {
|
|||
await grandfathered;
|
||||
await reload;
|
||||
assert.equal(await host.get(Greeter, { id: "late" }).greet("l"), "hi l");
|
||||
// The previous epoch is still retained (pinned root), but the gated record dropped its old behavior.
|
||||
assert.notEqual(host.previousEpoch, undefined);
|
||||
// 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"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ describe("application requests", () => {
|
|||
server.serve(Conv, (_input, _principal, self) => {
|
||||
const state = self.state({ view: cell(View, { text: "" }) });
|
||||
return {
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
view: state.view,
|
||||
set: (text, ctx) => {
|
||||
state.view.replace(ctx, { text });
|
||||
|
|
@ -64,10 +64,10 @@ describe("application requests", () => {
|
|||
},
|
||||
};
|
||||
});
|
||||
// No derivation, no cast: the handle member is the behavior member.
|
||||
// No derivation, no cast: the reference member is the implementation member.
|
||||
server.serve(Chat, (_input, _principal, self) => {
|
||||
const conv = self.get(Conv, {});
|
||||
return { kind: "viewer", view: conv.view };
|
||||
return { variant: "viewer", view: conv.view };
|
||||
});
|
||||
const chat = server.get(Chat, {});
|
||||
const values: unknown[] = [];
|
||||
|
|
@ -108,7 +108,7 @@ describe("application requests", () => {
|
|||
self.register(Config, { greeting: value });
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
api.serve(Reader, () => ({
|
||||
read: (_args: null, ctx: Ctx) => api.registry(Config).get("greeting", ctx) ?? "",
|
||||
|
|
@ -166,15 +166,15 @@ describe("application requests", () => {
|
|||
});
|
||||
const host = createHost({ onError: () => {} });
|
||||
host.install(facet("v1"));
|
||||
const handle = host.get(Watcher, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(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(handle.seen.value, ["v1:a", "v3:b"]);
|
||||
assert.deepEqual(ref.seen.value, ["v1:a", "v3:b"]);
|
||||
});
|
||||
|
||||
it("#3 a changed select resubscribes", async () => {
|
||||
|
|
@ -223,66 +223,67 @@ describe("application requests", () => {
|
|||
},
|
||||
};
|
||||
});
|
||||
const handle = host.get(Mirror, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(Mirror, {});
|
||||
await ref.ready;
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
let replica: unknown;
|
||||
handle.doc.events((event) => {
|
||||
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 = handle.doc.value as { entries: string[]; status: string };
|
||||
const current = ref.doc.value as { entries: string[]; status: string };
|
||||
// A new revision reusing `entries`: a diff.
|
||||
await handle.set({ entries: current.entries, status: "running" });
|
||||
await ref.set({ entries: current.entries, status: "running" });
|
||||
assert.deepEqual(opsOf(events.at(-1)), [["s", ["status"], "running"]]);
|
||||
// Unrelated containers: one replacement op.
|
||||
await handle.set({ entries: ["a", "b", "c"], status: "running" });
|
||||
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 handle.set({ entries: ["a", "b", "c", "d"], status: "running" });
|
||||
await ref.set({ entries: ["a", "b", "c", "d"], status: "running" });
|
||||
assert.notEqual(opsOf(events.at(-1))[0]?.[0], "r");
|
||||
lastOptions = { diff: false };
|
||||
const shared = handle.doc.value as { entries: string[] };
|
||||
await handle.set({ entries: shared.entries, status: "done" });
|
||||
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, handle.doc.value);
|
||||
assert.deepEqual(replica, ref.doc.value);
|
||||
});
|
||||
|
||||
it("#5 roots of a child that connected behind the server are admitted in the server's epoch", async () => {
|
||||
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: Type.Object({}),
|
||||
variants: { main: { methods: { epoch: method(Type.Null(), Type.Number()) } } },
|
||||
variants: { main: { methods: { codeVersion: method(Type.Null(), Type.Number()) } } },
|
||||
});
|
||||
const server = createHost({ name: "server", onError: () => {} });
|
||||
const echoFacet: Facet = {
|
||||
name: "echo",
|
||||
setup: (api) => api.serve(Echo, () => ({ kind: "main", epoch: (_args: null, ctx: Ctx) => ctx.epoch })),
|
||||
setup: (api) =>
|
||||
api.serve(Echo, () => ({ variant: "main", codeVersion: (_args: null, ctx: Ctx) => ctx.codeVersion })),
|
||||
};
|
||||
server.install(echoFacet);
|
||||
await server.reload([echoFacet]);
|
||||
assert.equal(server.epoch, 2);
|
||||
assert.equal(server.previousEpoch, undefined);
|
||||
assert.equal(server.codeVersion, 2);
|
||||
assert.equal(server.previousCodeVersion, undefined);
|
||||
const loader: FacetLoader = async () => [];
|
||||
const worker = createHost({ name: "session:S1", loader, onError: () => {} });
|
||||
const go = latch();
|
||||
// A worker root of epoch 1 (an eager object's listener, say) calls the server after connecting.
|
||||
// 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 ctx.get(Echo, {}, { at: "server" }).epoch(null, ctx);
|
||||
return ctx.get(Echo, {}, { at: "server" }).codeVersion(null, ctx);
|
||||
});
|
||||
const [down, up] = memoryLinkPair();
|
||||
server.accept(down, { principal: { worker: "S1" }, kind: "host", name: "session:S1" });
|
||||
worker.connect(up);
|
||||
go.open();
|
||||
assert.equal(await call, 2);
|
||||
await until(() => worker.epoch === 2);
|
||||
// Unknown epochs from in-step peers stay superseded.
|
||||
await until(() => worker.codeVersion === 2);
|
||||
// Unknown code versions from in-step peers stay superseded.
|
||||
await assert.rejects(
|
||||
server.root(() => 0, { epoch: 1 }),
|
||||
server.root(() => 0, { codeVersion: 1 }),
|
||||
(error) => isChordError(error, "superseded"),
|
||||
);
|
||||
});
|
||||
|
|
@ -308,18 +309,18 @@ describe("application requests", () => {
|
|||
});
|
||||
return { db: self.expose(res.db) };
|
||||
});
|
||||
const handle = host.get(Store, {});
|
||||
assert.equal(handle.db, undefined);
|
||||
const ref = host.get(Store, {});
|
||||
assert.equal(ref.db, undefined);
|
||||
gate.open();
|
||||
await handle.ready;
|
||||
assert.equal(handle.db, native);
|
||||
await ref.ready;
|
||||
assert.equal(ref.db, native);
|
||||
|
||||
const Other = local("t.other", { version: 1, input: Type.Object({}) });
|
||||
host.serve(Other, (_input, _principal, self) => {
|
||||
self.expose({ key: "x", value: 1 });
|
||||
return {};
|
||||
});
|
||||
await assert.rejects(host.get(Other, {}).ready, /expose takes a resource declared by this record/);
|
||||
await assert.rejects(host.get(Other, {}).ready, /expose takes a resource declared by this object/);
|
||||
});
|
||||
|
||||
it("#7 a cluster reload reports what each child prepared", async () => {
|
||||
|
|
@ -330,7 +331,7 @@ describe("application requests", () => {
|
|||
});
|
||||
const greeter = (text: string): Facet => ({
|
||||
name: "greeter",
|
||||
setup: (api) => api.serve(Greeter, () => ({ kind: "main", greet: (name: string) => `${text} ${name}` })),
|
||||
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))!);
|
||||
|
|
@ -340,15 +341,15 @@ describe("application requests", () => {
|
|||
const worker = createHost({ name: "session:S1", upstream: up, loader, onError: () => {} });
|
||||
worker.install(greeter("v1"));
|
||||
await tick();
|
||||
const handle = server.get(Greeter, {}, { at: "session:S1" });
|
||||
assert.equal(await handle.greet("a"), "v1 a");
|
||||
const ref = server.get(Greeter, {}, { at: "session:S1" });
|
||||
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?.epoch, report.epoch);
|
||||
assert.equal(child?.codeVersion, report.codeVersion);
|
||||
});
|
||||
|
||||
it("#8 a remote state member forgets its value when nobody listens; the next subscriber sees the fresh value", async () => {
|
||||
|
|
@ -361,7 +362,7 @@ describe("application requests", () => {
|
|||
server.serve(Counter, (_input, _principal, self) => {
|
||||
const state = self.state({ n: cell(Type.Number(), 0) });
|
||||
return {
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
n: state.n,
|
||||
set: (value, ctx) => {
|
||||
state.n.replace(ctx, value);
|
||||
|
|
@ -386,10 +387,10 @@ describe("application requests", () => {
|
|||
again();
|
||||
});
|
||||
|
||||
it("#9 handles report status changes: live, loading while a link reconnects, gone, released", async () => {
|
||||
it("#9 references report status changes: live, loading while a link reconnects, gone, released", async () => {
|
||||
const Thing = remote("t.thing9", { version: 1, input: Type.Object({}), variants: { main: {} } });
|
||||
const server = createHost({ name: "server", onError: () => {} });
|
||||
server.serve(Thing, () => ({ kind: "main" }));
|
||||
server.serve(Thing, () => ({ variant: "main" }));
|
||||
const localThing = server.get(Thing, {}, { principal: { user: "u" } });
|
||||
const localStatus: unknown[] = [];
|
||||
localThing.onStatus((status, error) =>
|
||||
|
|
@ -428,7 +429,7 @@ describe("application requests", () => {
|
|||
});
|
||||
const server = createHost({ name: "server", onError: () => {} });
|
||||
server.serve(Remote, () => ({
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
note: (text) => {
|
||||
log.push(`server: ${text}`);
|
||||
return null;
|
||||
|
|
@ -452,7 +453,7 @@ describe("application requests", () => {
|
|||
});
|
||||
return { tracker: self.expose(res.tracker) };
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
await sleep(5);
|
||||
worker.serve(Chat, (_input, _principal, self) => {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import {
|
|||
createHost,
|
||||
defineRegistry,
|
||||
type Facet,
|
||||
type Handle,
|
||||
isChordError,
|
||||
listener,
|
||||
local,
|
||||
method,
|
||||
type Ref,
|
||||
remote,
|
||||
resource,
|
||||
} from "../src/index.ts";
|
||||
|
|
@ -56,7 +56,7 @@ const Item = local("t.item", {
|
|||
});
|
||||
|
||||
describe("review: reload commit", () => {
|
||||
it("#1 a failing listener subscription fails prepare; the epoch and all records stay unchanged", async () => {
|
||||
it("#1 a failing listener subscription fails prepare; the code version and all objects stay unchanged", async () => {
|
||||
const host = createHost({ onError: () => {} });
|
||||
const sources = { a: emitter<number>(), b: emitter<number>({ failSubscribe: true }), c: emitter<number>() };
|
||||
const facet = (version: string, listen: boolean): Facet => ({
|
||||
|
|
@ -68,12 +68,12 @@ describe("review: reload commit", () => {
|
|||
}),
|
||||
});
|
||||
host.install(facet("v1", false));
|
||||
const handles = ["a", "b", "c"].map((id) => host.get(Item, { id }));
|
||||
await Promise.all(handles.map((handle) => handle.ready));
|
||||
const epoch = host.epoch;
|
||||
const refs = ["a", "b", "c"].map((id) => host.get(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.epoch, epoch);
|
||||
assert.deepEqual(await Promise.all(handles.map((handle) => handle.version(null))), ["v1", "v1", "v1"]);
|
||||
assert.equal(host.codeVersion, codeVersion);
|
||||
assert.deepEqual(await Promise.all(refs.map((ref) => ref.version(null))), ["v1", "v1", "v1"]);
|
||||
assert.equal(sources.a.size, 0);
|
||||
assert.equal(sources.c.size, 0);
|
||||
});
|
||||
|
|
@ -96,7 +96,7 @@ describe("review: reload commit", () => {
|
|||
api.serve(
|
||||
V1,
|
||||
(_input, _principal, self) => ({
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
count: self.state({ count: cell(Type.Object({ n: Type.Number() }), { n: 3 }) }).count,
|
||||
}),
|
||||
{ linger: 60_000 },
|
||||
|
|
@ -112,7 +112,7 @@ describe("review: reload commit", () => {
|
|||
api.serve(
|
||||
V2,
|
||||
(_input, _principal, self) => ({
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
count: self.state({ count: cell(Type.Object({ total: Type.Number() }), { total: 0 }) }).count,
|
||||
}),
|
||||
{
|
||||
|
|
@ -130,15 +130,15 @@ describe("review: reload commit", () => {
|
|||
assert.deepEqual(second.count.value, { total: 3 });
|
||||
});
|
||||
|
||||
it("#1 a commit that still fails midway fails the host terminally (no half-published epoch serving work)", async () => {
|
||||
it("#1 a commit that still fails midway fails the host terminally (no half-published code version serving work)", async () => {
|
||||
const host = createHost({ onError: () => {} });
|
||||
host.install({ name: "item", setup: (api) => api.serve(Item, () => ({ version: () => "v1" })) });
|
||||
const handle = host.get(Item, { id: "a" });
|
||||
await handle.ready;
|
||||
const ref = host.get(Item, { id: "a" });
|
||||
await ref.ready;
|
||||
// Everything fallible runs in prepare now, so break an install step directly (white box).
|
||||
const records = (host as unknown as { records: Map<string, { startTasks(): void }> }).records;
|
||||
for (const record of records.values()) {
|
||||
record.startTasks = () => {
|
||||
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");
|
||||
};
|
||||
}
|
||||
|
|
@ -147,12 +147,12 @@ describe("review: reload commit", () => {
|
|||
/install step fails/,
|
||||
);
|
||||
await assert.rejects(
|
||||
handle.version(null),
|
||||
ref.version(null),
|
||||
(error) => isChordError(error, "unavailable") && error.reason === "host_failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("reentrant observers calling another record during a commit see the whole new epoch", async () => {
|
||||
it("reentrant observers calling another object during a commit see the whole new code version", async () => {
|
||||
const host = createHost({ onError: () => {} });
|
||||
const Other = local("t.other", {
|
||||
version: 1,
|
||||
|
|
@ -203,11 +203,11 @@ describe("review: leases, invocations, activation", () => {
|
|||
return { count: state.count };
|
||||
}),
|
||||
});
|
||||
const handle = host.get(Store, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(Store, {});
|
||||
await ref.ready;
|
||||
events.emit(1);
|
||||
await tick();
|
||||
const epoch = host.epoch;
|
||||
const codeVersion = host.codeVersion;
|
||||
const reload = host.reload(
|
||||
[
|
||||
{
|
||||
|
|
@ -225,11 +225,11 @@ describe("review: leases, invocations, activation", () => {
|
|||
{ gateDeadline: 2000 },
|
||||
);
|
||||
await ticks(20);
|
||||
assert.equal(host.epoch, epoch, "the gate must wait for the listener");
|
||||
assert.equal(host.codeVersion, codeVersion, "the gate must wait for the listener");
|
||||
go.open();
|
||||
await reload;
|
||||
assert.deepEqual(seen, [{ n: 1 }]);
|
||||
assert.deepEqual(handle.count.value, { total: 1 });
|
||||
assert.deepEqual(ref.count.value, { total: 1 });
|
||||
});
|
||||
|
||||
it("#5 disposal waits for running methods before closing resources (released and forced)", async () => {
|
||||
|
|
@ -305,7 +305,7 @@ describe("review: leases, invocations, activation", () => {
|
|||
self.register(Routes, { same: type.name });
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
}
|
||||
await ticks(20);
|
||||
|
|
@ -315,10 +315,10 @@ describe("review: leases, invocations, activation", () => {
|
|||
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 record", async () => {
|
||||
it("#6 releasing the last hold while listeners start up does not resurrect the object", async () => {
|
||||
const host = createHost({ onError: () => {} });
|
||||
const log: string[] = [];
|
||||
let handle: Handle<typeof Store> | undefined;
|
||||
let ref: Ref<typeof Store> | undefined;
|
||||
let runs = 0;
|
||||
host.serve(Store, (_input, _principal, self) => {
|
||||
runs++;
|
||||
|
|
@ -327,15 +327,15 @@ describe("review: leases, invocations, activation", () => {
|
|||
r: resource({ rev: 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.listen({ drop: listener(state.count, () => handle?.release()) });
|
||||
self.listen({ drop: listener(state.count, () => ref?.release()) });
|
||||
return { count: state.count };
|
||||
});
|
||||
handle = host.get(Store, {});
|
||||
await handle.ready.catch(() => {});
|
||||
ref = host.get(Store, {});
|
||||
await ref.ready.catch(() => {});
|
||||
await ticks(20);
|
||||
assert.deepEqual(log, ["open", "close"]);
|
||||
const again = host.get(Store, {});
|
||||
handle = undefined;
|
||||
ref = undefined;
|
||||
await again.ready;
|
||||
assert.equal(runs, 2);
|
||||
});
|
||||
|
|
@ -361,14 +361,14 @@ describe("review: prepare isolation and contracts", () => {
|
|||
}),
|
||||
});
|
||||
host.install(facet(false));
|
||||
const handle = host.get(Db, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(Db, {});
|
||||
await ref.ready;
|
||||
await assert.rejects(host.reload([facet(true)]), (error) => isChordError(error, "not_ready"));
|
||||
assert.equal(native.mutated, false);
|
||||
assert.deepEqual(await handle.read(null), { mutated: false });
|
||||
assert.deepEqual(await ref.read(null), { mutated: false });
|
||||
});
|
||||
|
||||
it("#7 records of types a reload adds start listeners and tasks only at commit", async () => {
|
||||
it("#7 objects of types a reload adds start listeners and tasks only at commit", async () => {
|
||||
const host = createHost({ onError: () => {} });
|
||||
const log: string[] = [];
|
||||
const Fresh = local("t.fresh", { version: 1, input: Type.Object({}) });
|
||||
|
|
@ -425,7 +425,7 @@ describe("review: prepare isolation and contracts", () => {
|
|||
host.install({
|
||||
name: "both",
|
||||
setup: (api) => {
|
||||
api.serve(P1, () => ({ kind: "main", get: () => "old" }));
|
||||
api.serve(P1, () => ({ variant: "main", get: () => "old" }));
|
||||
api.serve(Consumer, (_input, _principal, self) => {
|
||||
const provider = self.get(P1, {});
|
||||
return { ask: (_args: null, ctx: Ctx) => provider.get(null, ctx) };
|
||||
|
|
@ -438,7 +438,7 @@ describe("review: prepare isolation and contracts", () => {
|
|||
{
|
||||
name: "both",
|
||||
setup: (api) => {
|
||||
api.serve(P2, () => ({ kind: "main", fetch: () => "new" }));
|
||||
api.serve(P2, () => ({ variant: "main", fetch: () => "new" }));
|
||||
api.serve(Consumer, (_input, _principal, self) => {
|
||||
const provider = self.get(P2, {});
|
||||
return { ask: (_args: null, ctx: Ctx) => provider.fetch(null, ctx) };
|
||||
|
|
@ -478,15 +478,15 @@ describe("review: prepare isolation and contracts", () => {
|
|||
}),
|
||||
});
|
||||
host.install(facet(1));
|
||||
const handle = host.get(Db, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(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 handle.read(null), { rev: 1 });
|
||||
assert.deepEqual(await ref.read(null), { rev: 1 });
|
||||
} else {
|
||||
await assert.rejects(handle.read(null), (error) => isChordError(error, "unavailable"));
|
||||
await assert.rejects(ref.read(null), (error) => isChordError(error, "unavailable"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -522,11 +522,11 @@ describe("review: prepare isolation and contracts", () => {
|
|||
},
|
||||
});
|
||||
host.install(facet(true));
|
||||
const handle = host.get(Item, { id: "a" });
|
||||
await handle.ready;
|
||||
const ref = host.get(Item, { id: "a" });
|
||||
await ref.ready;
|
||||
const go = latch();
|
||||
const pinned = host.root(async (ctx) => {
|
||||
await handle.version(null, ctx);
|
||||
await ref.version(null, ctx);
|
||||
await go.promise;
|
||||
});
|
||||
await tick();
|
||||
|
|
@ -583,9 +583,9 @@ describe("review: prepare isolation and contracts", () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
host.serve(Strict, () => ({ kind: "main", echo: (args) => args }));
|
||||
const handle = host.get(Strict, {});
|
||||
assert.deepEqual(await handle.echo({ a: "x", junk: 1 } as never), { a: "x" });
|
||||
host.serve(Strict, () => ({ variant: "main", echo: (args) => args }));
|
||||
const ref = host.get(Strict, {});
|
||||
assert.deepEqual(await ref.echo({ a: "x", junk: 1 } as never), { a: "x" });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ describe("transactional overlay drafts", () => {
|
|||
expect(prepared.value).toEqual({ values: [5, 5, 9, 2] });
|
||||
});
|
||||
|
||||
it("copies inserted draft values without aliasing their original handle", () => {
|
||||
it("copies inserted draft values without aliasing their original reference", () => {
|
||||
const tracker = track({ values: [{ value: 1 }, { value: 2 }] });
|
||||
const change = tracker.beginChange();
|
||||
const held = change.state.values[0]!;
|
||||
|
|
@ -65,7 +65,7 @@ describe("transactional overlay drafts", () => {
|
|||
expect(prepared.value.rows.map((row) => row.comparisons)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it("drops writes through detached child handles", () => {
|
||||
it("drops writes through detached child references", () => {
|
||||
const tracker = track({ child: { value: "removed" }, items: [{ value: "removed" }, { value: "kept" }] });
|
||||
const change = tracker.beginChange();
|
||||
const child = change.state.child;
|
||||
|
|
|
|||
|
|
@ -75,25 +75,25 @@ describe("state cells", () => {
|
|||
it("publishes snapshots then exact ops; derivations follow", async () => {
|
||||
const host = createHost({ onError: () => {} });
|
||||
serveList(host);
|
||||
const handle = host.get(List, {});
|
||||
const ref = host.get(List, {});
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
const labels: StateEvent<unknown>[] = [];
|
||||
handle.list.events((event) => events.push(event));
|
||||
handle.labels.events((event) => labels.push(event));
|
||||
await handle.push({ id: 1, label: "a" });
|
||||
ref.list.events((event) => events.push(event));
|
||||
ref.labels.events((event) => labels.push(event));
|
||||
await ref.push({ id: 1, label: "a" });
|
||||
// Subscribed while loading: an empty snapshot, the hydrated value as a reset, then exact ops.
|
||||
assert.deepEqual(events[0], { type: "snap", value: undefined });
|
||||
assert.deepEqual(events[1], { type: "reset", value: { items: [], count: 0 } });
|
||||
const ops = events[2]?.type === "ops" ? events[2].ops.map((op) => JSON.stringify(op)).sort() : [];
|
||||
assert.deepEqual(ops, ['["p",["items"],0,0,[{"id":1,"label":"a"}]]', '["s",["count"],1]']);
|
||||
assert.deepEqual(events[2]?.value, { items: [{ id: 1, label: "a" }], count: 1 });
|
||||
assert.deepEqual(handle.labels.value, ["a"]);
|
||||
assert.deepEqual(ref.labels.value, ["a"]);
|
||||
assert.deepEqual(
|
||||
labels.map((event) => event.type),
|
||||
["snap", "reset", "ops"],
|
||||
);
|
||||
await handle.set({ items: [], count: 0 });
|
||||
assert.deepEqual(handle.labels.value, []);
|
||||
await ref.set({ items: [], count: 0 });
|
||||
assert.deepEqual(ref.labels.value, []);
|
||||
});
|
||||
|
||||
it("supports primitive cells and value-returning changes", async () => {
|
||||
|
|
@ -113,14 +113,14 @@ describe("state cells", () => {
|
|||
},
|
||||
};
|
||||
});
|
||||
const handle = host.get(Counter, {});
|
||||
const ref = host.get(Counter, {});
|
||||
const values: unknown[] = [];
|
||||
await handle.ready;
|
||||
await ref.ready;
|
||||
const events: StateEvent<unknown>[] = [];
|
||||
handle.n.events((event) => events.push(event));
|
||||
handle.n.subscribe((value) => values.push(value));
|
||||
assert.equal(await handle.inc(null), 1);
|
||||
assert.equal(await handle.inc(null), 2);
|
||||
ref.n.events((event) => events.push(event));
|
||||
ref.n.subscribe((value) => values.push(value));
|
||||
assert.equal(await ref.inc(null), 1);
|
||||
assert.equal(await ref.inc(null), 2);
|
||||
assert.deepEqual(values, [0, 1, 2]);
|
||||
assert.deepEqual(events, [
|
||||
{ type: "snap", value: 0 },
|
||||
|
|
@ -164,12 +164,12 @@ describe("state cells", () => {
|
|||
|
||||
const dev = createHost({ dev: true, onError: () => {} });
|
||||
serveList(dev);
|
||||
const handle = dev.get(List, {});
|
||||
const ref = dev.get(List, {});
|
||||
await assert.rejects(
|
||||
handle.set({ items: [], count: "x" } as never),
|
||||
ref.set({ items: [], count: "x" } as never),
|
||||
(error) => isChordError(error, "invalid") && error.reason === "state",
|
||||
);
|
||||
assert.deepEqual(handle.list.value, { items: [], count: 0 });
|
||||
assert.deepEqual(ref.list.value, { items: [], count: 0 });
|
||||
});
|
||||
|
||||
it("rejects writes from inside a factory and placeholder use before activation", async () => {
|
||||
|
|
@ -225,14 +225,14 @@ describe("state cells", () => {
|
|||
});
|
||||
return { seen: state.seen };
|
||||
});
|
||||
const handle = host.get(Watcher, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(Watcher, {});
|
||||
await ref.ready;
|
||||
events.emit(2);
|
||||
events.emit(3);
|
||||
await tick();
|
||||
assert.deepEqual(handle.seen.value, { sum: 5 });
|
||||
assert.deepEqual(ref.seen.value, { sum: 5 });
|
||||
assert.equal(roots.size, 2);
|
||||
handle.release();
|
||||
ref.release();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(events.size, 0);
|
||||
|
|
@ -264,20 +264,20 @@ describe("state cells", () => {
|
|||
},
|
||||
};
|
||||
});
|
||||
const handle = host.get(Echo, {});
|
||||
await handle.ready;
|
||||
const ref = host.get(Echo, {});
|
||||
await ref.ready;
|
||||
const values: unknown[] = [];
|
||||
let replica: unknown;
|
||||
handle.n.events((event) => {
|
||||
ref.n.events((event) => {
|
||||
replica = event.type === "ops" ? applyImmutable(replica, event.ops) : event.value;
|
||||
values.push(replica);
|
||||
});
|
||||
const doubled: unknown[] = [];
|
||||
handle.d.subscribe((value) => doubled.push(value));
|
||||
await handle.set(1);
|
||||
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(handle.d.value, 4);
|
||||
assert.equal(ref.d.value, 4);
|
||||
assert.deepEqual(doubled, [0, 2, 4]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -126,10 +126,10 @@ function serveViews(api: Pick<Host, "serve">): void {
|
|||
if (list !== "family") throw forbidden("This list is not shared");
|
||||
const store = self.get(TodoStore, { list });
|
||||
if (principal !== "owner" && principal !== hostPrincipal) {
|
||||
return { kind: "viewer", rows: store.rows };
|
||||
return { variant: "viewer", rows: store.rows };
|
||||
}
|
||||
return {
|
||||
kind: "editor",
|
||||
variant: "editor",
|
||||
rows: store.rows,
|
||||
add: ({ text }, ctx) => {
|
||||
if (text.trim().length === 0) throw invalid("args", "A todo needs text");
|
||||
|
|
@ -149,7 +149,7 @@ it("01: objects live inside hosts", { timeout: 5000 }, async () => {
|
|||
// end-example:01
|
||||
});
|
||||
|
||||
it("02: first contract, factory, handle and root", { timeout: 5000 }, async () => {
|
||||
it("02: first contract, factory, reference and root", { timeout: 5000 }, async () => {
|
||||
checkExamples("02");
|
||||
onTestFinished(() => host.close());
|
||||
// example:02-call
|
||||
|
|
@ -261,8 +261,8 @@ it("05: principals select views of one shared store", { timeout: 5000 }, async (
|
|||
const owner = host.get(Todos, { list: "family" }, { principal: "owner" });
|
||||
const guest = host.get(Todos, { list: "family" }, { principal: "guest" });
|
||||
await Promise.all([owner.ready, guest.ready]);
|
||||
assert.equal(owner.kind, "editor");
|
||||
assert.equal(guest.kind, "viewer");
|
||||
assert.equal(owner.variant, "editor");
|
||||
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 }]);
|
||||
await assert.rejects(guest.add({ text: "Not allowed" }), (error) => isChordError(error, "not_in_variant"));
|
||||
|
|
@ -374,7 +374,7 @@ it("08: plugins contribute registry entries and expose callbacks", { timeout: 50
|
|||
});
|
||||
return {};
|
||||
},
|
||||
{ eager: true },
|
||||
{ lifetime: "host" },
|
||||
);
|
||||
click = () => api.root((ctx) => api.registry(Actions).get("addSample", ctx)!(ctx));
|
||||
},
|
||||
|
|
@ -382,7 +382,7 @@ it("08: plugins contribute registry entries and expose callbacks", { timeout: 50
|
|||
),
|
||||
};
|
||||
const host = createHost();
|
||||
await host.load([menuFacet, storeFacet], { awaitEager: false });
|
||||
await host.load([menuFacet, storeFacet], { awaitHostObjects: false });
|
||||
await host.ready();
|
||||
assert.equal(await click!(), 1);
|
||||
assert.equal(host.registry(Actions).table().size, 1);
|
||||
|
|
@ -461,8 +461,8 @@ it("09: compatible reload, migration and an old-contract adapter", { timeout: 50
|
|||
api.serve(
|
||||
RenamedStore,
|
||||
(input, principal, self) => {
|
||||
const { label, ...behavior } = todoFactory(input, principal, self);
|
||||
return { ...behavior, heading: label };
|
||||
const { label, ...implementation } = todoFactory(input, principal, self);
|
||||
return { ...implementation, heading: label };
|
||||
},
|
||||
{
|
||||
state: { version: 2 },
|
||||
|
|
@ -520,8 +520,8 @@ it("11: short roots release temporary holds", { timeout: 5000 }, async () => {
|
|||
await host.root(async (ctx) => {
|
||||
const temporary = ctx.get(TodoStore, { list: "family" });
|
||||
await temporary.add({ text: "Keep roots short" }, ctx);
|
||||
}); // The temporary handle is released here; screen still holds the store.
|
||||
}); // The temporary reference is released here; screen still holds the store.
|
||||
assert.equal(screen.openCount.value, 1);
|
||||
screen.release(); // No hold remains; without persistence, disposal loses these rows.
|
||||
screen.release(); // No reference remains; without persistence, disposal loses these rows.
|
||||
// end-example:11
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ describe("WebSocket links", () => {
|
|||
server.serve(Board, (_input, principal, self) => {
|
||||
const state = self.state({ notes: cell(Type.Array(Type.String()), []) });
|
||||
return {
|
||||
kind: "main",
|
||||
variant: "main",
|
||||
notes: state.notes,
|
||||
add: (text, ctx) => {
|
||||
state.notes.change(ctx, (draft) => {
|
||||
|
|
@ -107,7 +107,7 @@ describe("WebSocket links: packed payloads", () => {
|
|||
const server = createHost({ name: "server", onError: () => {} });
|
||||
server.serve(Board, (_input, _principal, self) => {
|
||||
const state = self.state({ notes: cell(Type.Array(Type.String()), notes) });
|
||||
return { kind: "main", notes: state.notes, add: () => null, who: () => notes };
|
||||
return { variant: "main", notes: state.notes, add: () => null, who: () => notes };
|
||||
});
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue