@earendil-works/chord 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +77 -194
- package/dist/cell.d.ts +101 -0
- package/dist/cell.d.ts.map +1 -0
- package/dist/cell.js +316 -0
- package/dist/cell.js.map +1 -0
- package/dist/cluster.d.ts +26 -0
- package/dist/cluster.d.ts.map +1 -0
- package/dist/cluster.js +214 -0
- package/dist/cluster.js.map +1 -0
- package/dist/connection.d.ts +157 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +757 -0
- package/dist/connection.js.map +1 -0
- package/dist/context/index.d.ts +12 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js.map +1 -1
- package/dist/contract.d.ts +133 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +157 -0
- package/dist/contract.js.map +1 -0
- package/dist/declare.d.ts +70 -0
- package/dist/declare.d.ts.map +1 -0
- package/dist/declare.js +22 -0
- package/dist/declare.js.map +1 -0
- package/dist/delta/apply-immutable-trusted.js.map +1 -1
- package/dist/delta/diff.d.ts +1 -1
- package/dist/delta/diff.d.ts.map +1 -1
- package/dist/delta/diff.js.map +1 -1
- package/dist/delta/index.d.ts +2 -2
- package/dist/delta/index.d.ts.map +1 -1
- package/dist/delta/index.js +41 -0
- package/dist/delta/index.js.map +1 -1
- package/dist/delta/revision-validator.d.ts +1 -1
- package/dist/delta/revision-validator.d.ts.map +1 -1
- package/dist/delta/revision-validator.js.map +1 -1
- package/dist/delta/tracker.d.ts +1 -1
- package/dist/delta/tracker.d.ts.map +1 -1
- package/dist/delta/tracker.js +40 -4
- package/dist/delta/tracker.js.map +1 -1
- package/dist/errors.d.ts +27 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +44 -0
- package/dist/errors.js.map +1 -0
- package/dist/host.d.ts +254 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +795 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +17 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -8
- package/dist/index.js.map +1 -1
- package/dist/json.d.ts +9 -1
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js.map +1 -1
- package/dist/link.d.ts +72 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +169 -0
- package/dist/link.js.map +1 -0
- package/dist/loader.d.ts +4 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +68 -0
- package/dist/loader.js.map +1 -0
- package/dist/manager.d.ts +115 -0
- package/dist/manager.d.ts.map +1 -0
- package/dist/manager.js +319 -0
- package/dist/manager.js.map +1 -0
- package/dist/node.d.ts +3 -4
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +3 -2
- package/dist/node.js.map +1 -1
- package/dist/publish.d.ts +5 -0
- package/dist/publish.d.ts.map +1 -0
- package/dist/publish.js +40 -0
- package/dist/publish.js.map +1 -0
- package/dist/record.d.ts +320 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +1292 -0
- package/dist/record.js.map +1 -0
- package/dist/registry.d.ts +25 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +6 -0
- package/dist/registry.js.map +1 -0
- package/dist/reload.d.ts +118 -0
- package/dist/reload.d.ts.map +1 -0
- package/dist/reload.js +589 -0
- package/dist/reload.js.map +1 -0
- package/dist/remote.d.ts +70 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +308 -0
- package/dist/remote.js.map +1 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +228 -0
- package/dist/schema.js.map +1 -0
- package/dist/state.d.ts +92 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/{services/state.js → state.js} +28 -138
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +62 -262
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/websocket.d.ts +16 -0
- package/dist/websocket.d.ts.map +1 -0
- package/dist/websocket.js +15 -0
- package/dist/websocket.js.map +1 -0
- package/dist/wire.d.ts +135 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +112 -0
- package/dist/wire.js.map +1 -0
- package/docs/guide.md +750 -0
- package/docs/spec.md +261 -0
- package/docs/tutorial.md +909 -0
- package/package.json +9 -10
- package/src/delta/README.md +5 -3
- package/dist/api.d.ts +0 -20
- package/dist/api.d.ts.map +0 -1
- package/dist/api.js +0 -77
- package/dist/api.js.map +0 -1
- package/dist/bundler.d.ts +0 -6
- package/dist/bundler.d.ts.map +0 -1
- package/dist/bundler.js +0 -3
- package/dist/bundler.js.map +0 -1
- package/dist/facets/host.d.ts +0 -12
- package/dist/facets/host.d.ts.map +0 -1
- package/dist/facets/host.js +0 -763
- package/dist/facets/host.js.map +0 -1
- package/dist/facets/loader.d.ts +0 -3
- package/dist/facets/loader.d.ts.map +0 -1
- package/dist/facets/loader.js +0 -5
- package/dist/facets/loader.js.map +0 -1
- package/dist/node/bundle-loader.d.ts +0 -30
- package/dist/node/bundle-loader.d.ts.map +0 -1
- package/dist/node/bundle-loader.js +0 -377
- package/dist/node/bundle-loader.js.map +0 -1
- package/dist/node/bundle.d.ts +0 -26
- package/dist/node/bundle.d.ts.map +0 -1
- package/dist/node/bundle.js +0 -183
- package/dist/node/bundle.js.map +0 -1
- package/dist/node/manifest.d.ts +0 -36
- package/dist/node/manifest.d.ts.map +0 -1
- package/dist/node/manifest.js +0 -6
- package/dist/node/manifest.js.map +0 -1
- package/dist/node/package.d.ts +0 -15
- package/dist/node/package.d.ts.map +0 -1
- package/dist/node/package.js +0 -199
- package/dist/node/package.js.map +0 -1
- package/dist/services/consumer.d.ts +0 -11
- package/dist/services/consumer.d.ts.map +0 -1
- package/dist/services/consumer.js +0 -585
- package/dist/services/consumer.js.map +0 -1
- package/dist/services/errors.d.ts +0 -8
- package/dist/services/errors.d.ts.map +0 -1
- package/dist/services/errors.js +0 -22
- package/dist/services/errors.js.map +0 -1
- package/dist/services/handle.d.ts +0 -15
- package/dist/services/handle.d.ts.map +0 -1
- package/dist/services/handle.js +0 -101
- package/dist/services/handle.js.map +0 -1
- package/dist/services/instances.d.ts +0 -26
- package/dist/services/instances.d.ts.map +0 -1
- package/dist/services/instances.js +0 -140
- package/dist/services/instances.js.map +0 -1
- package/dist/services/loopback.d.ts +0 -5
- package/dist/services/loopback.d.ts.map +0 -1
- package/dist/services/loopback.js +0 -15
- package/dist/services/loopback.js.map +0 -1
- package/dist/services/provider.d.ts +0 -37
- package/dist/services/provider.d.ts.map +0 -1
- package/dist/services/provider.js +0 -521
- package/dist/services/provider.js.map +0 -1
- package/dist/services/state-codec.d.ts +0 -15
- package/dist/services/state-codec.d.ts.map +0 -1
- package/dist/services/state-codec.js +0 -133
- package/dist/services/state-codec.js.map +0 -1
- package/dist/services/state-internals.d.ts +0 -13
- package/dist/services/state-internals.d.ts.map +0 -1
- package/dist/services/state-internals.js +0 -10
- package/dist/services/state-internals.js.map +0 -1
- package/dist/services/state.d.ts +0 -28
- package/dist/services/state.d.ts.map +0 -1
- package/dist/services/state.js.map +0 -1
- package/dist/services/wire.d.ts +0 -63
- package/dist/services/wire.d.ts.map +0 -1
- package/dist/services/wire.js +0 -186
- package/dist/services/wire.js.map +0 -1
package/docs/guide.md
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
# Chord usage guide
|
|
2
|
+
|
|
3
|
+
> Describes the runtime in `src`. The [specification](spec.md) defines its semantics and lists known limitations.
|
|
4
|
+
|
|
5
|
+
Chord builds applications out of **objects** that live in several processes (a server, workers, browser tabs) and can be **hot reloaded** while they run.
|
|
6
|
+
|
|
7
|
+
- An **object** is state plus code at an address. It is created on demand and lives while someone holds it.
|
|
8
|
+
- Everyone gets a **handle**, never the object. Handles work the same locally and across processes, and survive reloads.
|
|
9
|
+
- Every object knows its **principal**: an owner and a guest get different objects, with different methods.
|
|
10
|
+
- **State** is observable JSON. Remote viewers get a snapshot followed by deltas.
|
|
11
|
+
- **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.
|
|
12
|
+
|
|
13
|
+
## Contents
|
|
14
|
+
|
|
15
|
+
- [Quick start](#quick-start)
|
|
16
|
+
- [Contracts](#contracts)
|
|
17
|
+
- [Serving objects](#serving-objects)
|
|
18
|
+
- [Declarations](#declarations)
|
|
19
|
+
- [Activation](#activation)
|
|
20
|
+
- [Principals and variants](#principals-and-variants)
|
|
21
|
+
- [Handles](#handles)
|
|
22
|
+
- [State](#state)
|
|
23
|
+
- [Calls, ctx and roots](#calls-ctx-and-roots)
|
|
24
|
+
- [Lifetime](#lifetime)
|
|
25
|
+
- [Registries](#registries)
|
|
26
|
+
- [Plugins](#plugins)
|
|
27
|
+
- [Hot reload](#hot-reload)
|
|
28
|
+
- [Hosts and links](#hosts-and-links)
|
|
29
|
+
- [Durable](#durable)
|
|
30
|
+
- [Errors](#errors)
|
|
31
|
+
- [Rules of thumb](#rules-of-thumb)
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { Type } from "typebox";
|
|
37
|
+
import { cell, createHost, method, remote } from "@earendil-works/chord";
|
|
38
|
+
|
|
39
|
+
// contract.ts: shared by provider and consumers
|
|
40
|
+
export const Counter = remote("demo.counter", {
|
|
41
|
+
version: 1,
|
|
42
|
+
input: Type.Object({ name: Type.String() }),
|
|
43
|
+
variants: {
|
|
44
|
+
default: {
|
|
45
|
+
state: { count: Type.Number() },
|
|
46
|
+
methods: { increment: method(Type.Object({ by: Type.Number() }), Type.Null()) },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const host = createHost({ name: "server", principalKey: (p) => p.kind });
|
|
52
|
+
|
|
53
|
+
host.serve(Counter, ({ name }, _principal, self) => {
|
|
54
|
+
const state = self.state({ count: cell(Type.Number(), 0) });
|
|
55
|
+
return {
|
|
56
|
+
kind: "default",
|
|
57
|
+
count: state.count,
|
|
58
|
+
increment: async ({ by }, ctx) => {
|
|
59
|
+
state.count.change(ctx, (n) => n + by);
|
|
60
|
+
return null;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await host.root(async (ctx) => {
|
|
66
|
+
const clicks = ctx.get(Counter, { name: "clicks" }); // a handle; held until this root ends
|
|
67
|
+
clicks.count.subscribe((n) => console.log("count", n));
|
|
68
|
+
await clicks.increment({ by: 2 }, ctx); // logs 0, then 2
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Contracts
|
|
73
|
+
|
|
74
|
+
A contract is a runtime value: a name, a version number, and a description of the members. Shipped bundles have no TypeScript, so everything the runtime checks must be in the contract. TypeScript types are inferred from it.
|
|
75
|
+
|
|
76
|
+
### Remote types
|
|
77
|
+
|
|
78
|
+
Remote types can be reached from other hosts. Everything that crosses the wire has a schema, and the provider validates it.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
export const Chat = remote("pi.chat", {
|
|
82
|
+
version: 1,
|
|
83
|
+
input: Type.Object({ session: Type.String(), conversation: Type.Number() }),
|
|
84
|
+
variants: {
|
|
85
|
+
viewer: {
|
|
86
|
+
state: { view: ConversationViewSchema },
|
|
87
|
+
},
|
|
88
|
+
participant: {
|
|
89
|
+
state: { view: ConversationViewSchema },
|
|
90
|
+
methods: {
|
|
91
|
+
send: method(SendInputSchema, Type.Object({ submissionId: Type.Number() })),
|
|
92
|
+
stop: method(Type.Null(), Type.Null()),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Local types
|
|
100
|
+
|
|
101
|
+
Local types are reachable only inside their host. Their members may be anything: functions, callbacks, native objects. Non-JSON members get opaque or function descriptors.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
export const Index = local("pi.index", {
|
|
105
|
+
version: 1,
|
|
106
|
+
input: Type.Object({ session: Type.String() }),
|
|
107
|
+
members: {
|
|
108
|
+
search: method(Type.Object({ query: Type.String() }), Type.Array(HitSchema)),
|
|
109
|
+
onChange: method(Type.Function([ChangeSchema], Type.Void()), UnsubscribeSchema),
|
|
110
|
+
harness: opaque("pi.durable-harness@1"),
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Inputs identify objects
|
|
116
|
+
|
|
117
|
+
`{ session: "S1", conversation: 1 }` always means the same object. Inputs are normalized (defaults applied, unknown fields removed), validated, and serialized canonically. Fields equal to their default are left out, so adding a new optional field with a default doesn't change existing addresses. Changing a default, or how inputs are normalized, needs a new type name.
|
|
118
|
+
|
|
119
|
+
### Versions and compatibility
|
|
120
|
+
|
|
121
|
+
Every bundle carries its own copy of each contract it was built against. When a consumer binds to a provider, the two copies are compared:
|
|
122
|
+
|
|
123
|
+
> A consumer built against version M binds to a provider at version N if **N = M and the provider's description covers the consumer's**. Otherwise the provider must offer an adapter in `accepts[M]`, or binding fails with `contract_mismatch`.
|
|
124
|
+
|
|
125
|
+
"Covers" means the provider still offers everything the consumer was built against:
|
|
126
|
+
- every member exists with the same kind;
|
|
127
|
+
- methods accept everything the consumer can send (new arguments must be optional) and return at least what it expects (extra result fields are fine);
|
|
128
|
+
- state values fit the consumer's schema;
|
|
129
|
+
- opaque members have the same name and version.
|
|
130
|
+
|
|
131
|
+
| Change | Version | Result |
|
|
132
|
+
| --- | --- | --- |
|
|
133
|
+
| Add a method, an optional argument, a result field, a state field | keep | binds |
|
|
134
|
+
| Remove or rename a member; make an argument required; change a type | **bump** | old consumers need `accepts`, or must be reloaded too |
|
|
135
|
+
| Same shape, different meaning (`query` is now a regular expression) | **bump** | as above; undetectable, so it's on you |
|
|
136
|
+
| Forgot to bump on a breaking shape change | keep | refused: "consumer needs member `search`" |
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// version 2 changed what `search` means; old consumers keep working through an adapter
|
|
140
|
+
host.serve(Index, indexFactory, {
|
|
141
|
+
accepts: {
|
|
142
|
+
1: (current) => ({ search: ({ query }, ctx) => current.search({ query: escapeRegExp(query) }, ctx) }),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Serving objects
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
host.serve(Type, (input, principal, self) => behavior, options);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- 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.
|
|
154
|
+
- 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.
|
|
155
|
+
- **Options:**
|
|
156
|
+
- `state: { version, migrate }`: the state version and its migrations (see [Hot reload](#hot-reload));
|
|
157
|
+
- `accepts`: adapters for consumers built against older contract versions;
|
|
158
|
+
- `linger`: how long to keep the object after the last hold is released;
|
|
159
|
+
- `eager`: activate when the plugin loads, and keep it for the host's lifetime.
|
|
160
|
+
|
|
161
|
+
## Declarations
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
host.serve(StatusStore, ({ session }, _host, self) => {
|
|
167
|
+
const state = self.state({
|
|
168
|
+
status: cell(StatusSchema, { running: false, tasks: 0 }),
|
|
169
|
+
errors: cell(Type.Array(Type.String()), []),
|
|
170
|
+
});
|
|
171
|
+
const catalog = self.get(Catalog, {});
|
|
172
|
+
const res = self.resources({
|
|
173
|
+
db: resource({
|
|
174
|
+
rev: 1,
|
|
175
|
+
open: async ({ signal }) => openSqlite(statusPath(session), { signal }),
|
|
176
|
+
close: async (db) => db.close(),
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
self.listen({
|
|
180
|
+
catalogChanges: listener(catalog.rows, (rows, ctx) => state.errors.replace(ctx, checkRows(rows))),
|
|
181
|
+
});
|
|
182
|
+
self.tasks({
|
|
183
|
+
flush: async (ctx) => {
|
|
184
|
+
while (!ctx.signal.aborted) {
|
|
185
|
+
await sleep(10_000, ctx.signal);
|
|
186
|
+
await res.db.value.run("INSERT INTO history VALUES (?)", [JSON.stringify(state.status.value)]);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
const derived = self.derive({
|
|
191
|
+
summary: derivation([state.status, state.errors], (s, e) => ({ ...s, failing: e.length > 0 })),
|
|
192
|
+
});
|
|
193
|
+
return { summary: derived.summary, report: reportMethod(state) };
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
| Declaration | What it is | On reload |
|
|
198
|
+
| --- | --- | --- |
|
|
199
|
+
| `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 |
|
|
200
|
+
| `self.get(Type, input, { at? })` | a hold on another object; its identity is the address | kept if declared again, otherwise released |
|
|
201
|
+
| `self.resources({ name: resource({ rev, deps?, exclusive?, open, close }) })` | a connection, watch, socket, port | kept; reopened only if `rev` or `deps` change |
|
|
202
|
+
| `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 |
|
|
203
|
+
| `self.tasks({ name: async (ctx) => ... })` | long-running background work | the old run is aborted through `ctx.signal`, and the new code starts |
|
|
204
|
+
| `self.derive({ name: derivation(sources, fn) })` | live projection of state; `fn` is synchronous | kept; `fn` replaced |
|
|
205
|
+
| `self.register(Registry, { key: value })` | an entry in a registry | replaced at commit |
|
|
206
|
+
| `self.expose(res.name)` | a member backed by a resource (local types) | follows the resource |
|
|
207
|
+
|
|
208
|
+
A listener keeps its subscription across reloads when it is declared on the same resource (or source) with the same `select`: the same function, or one with the same source text. `select` is not called again, so a source that accepts a single subscriber (a Durable watch) survives reloads, and `select` may wrap the value in a new adapter object. A `select` whose result depends on captured values that change between reloads is not noticed; give such a listener a new name.
|
|
209
|
+
|
|
210
|
+
A name you no longer declare is released, closed or stopped, after the old code has finished what it was doing.
|
|
211
|
+
|
|
212
|
+
**State that must survive a reload lives in cells,** never in closure variables. A closure variable lasts as long as one version of the code.
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
// wrong: reset on every reload
|
|
216
|
+
host.serve(Visits, (_, _p, self) => {
|
|
217
|
+
let visits = 0;
|
|
218
|
+
return { hit: async (_, ctx) => ++visits };
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// right
|
|
222
|
+
host.serve(Visits, (_, _p, self) => {
|
|
223
|
+
const state = self.state({ visits: cell(Type.Number(), 0) });
|
|
224
|
+
return {
|
|
225
|
+
hit: async (_, ctx) => {
|
|
226
|
+
state.visits.change(ctx, (n) => n + 1);
|
|
227
|
+
return state.visits.value;
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Activation
|
|
234
|
+
|
|
235
|
+
The factory is synchronous, but activation isn't. `self.get` and `self.resources` only *declare*; the slow work happens afterwards:
|
|
236
|
+
|
|
237
|
+
```text
|
|
238
|
+
get(Index, { session: "S1" })
|
|
239
|
+
1. run the factory → declarations
|
|
240
|
+
2. open declared resources → e.g. connect to SQLite
|
|
241
|
+
3. wait for declared holds → dependencies activate first
|
|
242
|
+
4. live → handles become "live"
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
host.serve(Index, ({ session }, _host, self) => {
|
|
247
|
+
const res = self.resources({
|
|
248
|
+
db: resource({ rev: 1, open: () => openSqlite(indexPath(session)), close: (db) => db.close() }),
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
search: async ({ query }, ctx) => res.db.value.all("SELECT entryId, text FROM fts WHERE fts MATCH ?", [query]),
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
host.serve(Chat, ({ session, conversation }, principal, self) => {
|
|
256
|
+
const index = self.get(Index, { session }); // a placeholder until Index is live
|
|
257
|
+
return {
|
|
258
|
+
kind: "participant",
|
|
259
|
+
find: async ({ query }, ctx) => index.search({ query }, ctx), // only ever runs once Chat (and Index) are live
|
|
260
|
+
// ...
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**Rules:**
|
|
266
|
+
- 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.
|
|
267
|
+
- **Concurrent `get`s** of one address share one activation, so the database opens once.
|
|
268
|
+
- **Failure propagates:** if `openSqlite` throws, `Index` fails, `Chat` fails with that cause, and every waiting `get` rejects. The next `get` tries again.
|
|
269
|
+
- **Abandoned activations:** if everyone releases before activation finishes, the result is disposed.
|
|
270
|
+
|
|
271
|
+
### Startup readiness and parent admission
|
|
272
|
+
|
|
273
|
+
`await host.load(facets)` and `await host.install(facet)` wait for the eager objects they install to become live:
|
|
274
|
+
resources are open, declared holds are live, and registrations are published. All facets in a load are installed
|
|
275
|
+
before activation is awaited, so an eager consumer can use a provider in a later facet. Ordinary lazy objects stay
|
|
276
|
+
lazy unless an eager object holds them. Factory, dependency and resource errors reject startup with the handle's
|
|
277
|
+
activation error; eager failures also reach `onError`. Installation is not rolled back on failure: close the host
|
|
278
|
+
when abandoning startup. `install` registers synchronously, so setup/registration errors still throw synchronously.
|
|
279
|
+
|
|
280
|
+
`await host.ready()` waits for the eager holds present when called, including direct `host.serve(..., { eager })`
|
|
281
|
+
and previously deferred loads. It is an activation barrier, not a continuous health check, a wait for tasks to
|
|
282
|
+
finish, or a remote-state hydration barrier. Later installations require another readiness wait.
|
|
283
|
+
|
|
284
|
+
A worker may need to open its local Harness and announce control readiness **before** the parent accepts its link.
|
|
285
|
+
An eager reporter waiting for the server's SessionStatus cannot become live before that acceptance. Awaiting the
|
|
286
|
+
whole load before announcing control readiness would deadlock. Defer the whole eager barrier explicitly:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
// Connect the upstream link first; its outgoing holds can queue until the parent accepts it.
|
|
290
|
+
await worker.load(workerFacets, { awaitEager: false }); // also supported by install
|
|
291
|
+
const harness = worker.get(Harness, {});
|
|
292
|
+
await harness.ready; // local bootstrap only; no upstream dependency
|
|
293
|
+
announceControlReady(); // parent can now accept the worker link
|
|
294
|
+
await worker.ready(); // full eager readiness, including upstream holds
|
|
295
|
+
harness.release();
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`awaitEager: false` still starts and host-holds every eager object; it skips waiting, not activation. There is no
|
|
299
|
+
automatic classification of upstream dependencies. Choose a local bootstrap handle with no upstream dependency,
|
|
300
|
+
connect before loading upstream consumers, and let the parent admit the link before the full barrier. Control
|
|
301
|
+
readiness means admission is possible, not that every eager object is live. Keep worker bootstrap counted as busy
|
|
302
|
+
until the full barrier settles, and report a later failure through the worker's fatal/startup-error path.
|
|
303
|
+
|
|
304
|
+
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:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
host.serve(Harness, (_, _host, self) => {
|
|
308
|
+
const res = self.resources({ durable: resource({ rev: 1, open: () => DurableHarness.open(storage), close: (h) => h.close() }) });
|
|
309
|
+
return { harness: self.expose(res.durable) }; // contract member: harness: opaque<DurableHarness>("pi.durable-harness@1")
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const harness = ctx.get(Harness, {});
|
|
313
|
+
await harness.ready;
|
|
314
|
+
harness.harness.resume(); // the DurableHarness itself
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Principals and variants
|
|
318
|
+
|
|
319
|
+
The application defines its principals and how they're keyed:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
type Principal =
|
|
323
|
+
| { kind: "owner"; via: "device" | "tunnel" | "relay" }
|
|
324
|
+
| { kind: "guest"; id: string; name: string; session: string; mode: "view" | "full"; revision: number }
|
|
325
|
+
| { kind: "worker"; session: string; generation: number };
|
|
326
|
+
|
|
327
|
+
const host = createHost({
|
|
328
|
+
name: "server",
|
|
329
|
+
principalKey: (p: Principal) =>
|
|
330
|
+
p.kind === "owner" ? `owner@${p.via}` :
|
|
331
|
+
p.kind === "guest" ? `guest:${p.id}:r${p.revision}` :
|
|
332
|
+
`worker:${p.session}:${p.generation}`,
|
|
333
|
+
});
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
- There is one record per principal key: two tabs of the owner share an object, and a guest gets its own.
|
|
337
|
+
- Code inside a host acts as the built-in `host` principal, so internal objects are shared.
|
|
338
|
+
- A link stamps the principal of whatever connects to it. Callers can never supply their own.
|
|
339
|
+
|
|
340
|
+
**A remote factory picks a variant:**
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
host.serve(Chat, ({ session, conversation }, principal, self) => {
|
|
344
|
+
if (!canView(principal, session)) throw forbidden();
|
|
345
|
+
const c = self.get(Conversation, { session, conversation }); // as host: one shared instance
|
|
346
|
+
if (!canSend(principal)) return { kind: "viewer", view: c.view };
|
|
347
|
+
const author = authorOf(principal);
|
|
348
|
+
return {
|
|
349
|
+
kind: "participant",
|
|
350
|
+
view: c.view,
|
|
351
|
+
send: async (input, ctx) => c.submit(input, author, ctx),
|
|
352
|
+
stop: async (_, ctx) => {
|
|
353
|
+
await c.stop(author, ctx);
|
|
354
|
+
return null;
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
const chat = ctx.get(Chat, { session: "S1", conversation: 1 }, { at: "session:S1" });
|
|
362
|
+
await chat.ready;
|
|
363
|
+
if (chat.kind === "participant") showComposer(chat.send);
|
|
364
|
+
else showReadOnly();
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**What a variant doesn't do:**
|
|
368
|
+
- **It isn't complete authorization.** Methods still check their arguments: that a submission belongs to this conversation, or a session ID to this principal.
|
|
369
|
+
- **It doesn't cover flags that change while you hold the object.** Those are state, for example `you: { canDraw: boolean }`.
|
|
370
|
+
- **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.
|
|
371
|
+
|
|
372
|
+
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.
|
|
373
|
+
|
|
374
|
+
## Handles
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
const chat = ctx.get(Chat, input, { at: "session:S1" });
|
|
378
|
+
chat.status; // "loading" | "live" | "released" | "gone"
|
|
379
|
+
await chat.ready; // live, or rejects with the activation error
|
|
380
|
+
chat.kind; // the current variant
|
|
381
|
+
chat.shape; // { kind, members, contract }
|
|
382
|
+
const send = chat.send; // a stable function: always dispatches to the current code
|
|
383
|
+
chat.view.value; // undefined until hydrated (remote: while someone subscribes)
|
|
384
|
+
chat.view.subscribe((v) => render(v));
|
|
385
|
+
chat.onStatus((status, error) => { if (status === "gone") reconnect(error); });
|
|
386
|
+
chat.release();
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- **Calls on a `loading` handle wait** until it's live, respecting `ctx` cancellation and a deadline.
|
|
390
|
+
- A member missing from the current variant rejects with `not_in_variant`.
|
|
391
|
+
- After `release()`, every member rejects with `released`.
|
|
392
|
+
- **`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.
|
|
393
|
+
- **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.
|
|
394
|
+
- **Return values are values.** Anything long-lived you want to share is an object you reach with `get`.
|
|
395
|
+
|
|
396
|
+
## State
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
const state = self.state({ todos: cell(Type.Array(TodoSchema), []) });
|
|
400
|
+
|
|
401
|
+
state.todos.change(ctx, (draft) => {
|
|
402
|
+
draft.push({ id: "t1", text: "Write docs", done: false });
|
|
403
|
+
});
|
|
404
|
+
state.todos.change(ctx, (draft) => {
|
|
405
|
+
draft[0].done = true;
|
|
406
|
+
});
|
|
407
|
+
state.todos.replace(ctx, []);
|
|
408
|
+
|
|
409
|
+
state.todos.value; // immutable
|
|
410
|
+
state.todos.subscribe((value) => render(value));
|
|
411
|
+
|
|
412
|
+
// mirroring an external immutable store: subscribers get a diff, not the whole value
|
|
413
|
+
state.view.replace(ctx, conversation.view); // diffs when the new value reuses old containers
|
|
414
|
+
state.view.replace(ctx, rebuilt, { diff: true }); // always diff
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
- A `change` is atomic: one revision, or nothing if the callback throws.
|
|
418
|
+
- `change` only touches the paths you mutate, so fields your code doesn't know about survive. Use `replace` only where your code owns the whole value.
|
|
419
|
+
- Values are immutable. Never mutate what you read or receive.
|
|
420
|
+
- Remote subscribers get a snapshot, then deltas. After a reconnect or a reload they get a `reset` snapshot, and nothing is replayed.
|
|
421
|
+
- `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.
|
|
422
|
+
- 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.
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
const derived = self.derive({
|
|
426
|
+
visible: derivation([catalog.rows], (rows) => rows.filter((r) => visibleTo(principal, r))),
|
|
427
|
+
openCount: derivation([state.todos], (t) => t.filter((x) => !x.done).length),
|
|
428
|
+
});
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
`derivation` functions are synchronous.
|
|
432
|
+
|
|
433
|
+
## Calls, ctx and roots
|
|
434
|
+
|
|
435
|
+
Every method gets `(args, ctx)`. Pass `ctx` on to everything you call:
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
send: async (input, ctx) => {
|
|
439
|
+
ctx.signal.throwIfAborted();
|
|
440
|
+
const uploads = ctx.get(Uploads, {}); // a hold owned by this call; released when it settles
|
|
441
|
+
const files = await uploads.resolve({ ids: input.uploads }, ctx);
|
|
442
|
+
return c.submit({ ...input, files }, author, ctx);
|
|
443
|
+
},
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
A **root** is a short tree of calls, started at ingress:
|
|
447
|
+
- by a link, for a request that arrives without one;
|
|
448
|
+
- by `host.root(fn)`, for timers and scripts;
|
|
449
|
+
- by each event delivered to a `listener`;
|
|
450
|
+
- by each Chord call made from a `task` or a Durable phase.
|
|
451
|
+
|
|
452
|
+
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.
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
// wrong: holds a root (and its version of the code) open for minutes
|
|
456
|
+
approve: async (_, ctx) => waitForHuman(ctx),
|
|
457
|
+
|
|
458
|
+
// right: record the request as state; the answer is a separate call
|
|
459
|
+
request: async (req, ctx) => {
|
|
460
|
+
state.pending.change(ctx, (d) => {
|
|
461
|
+
d.push(req);
|
|
462
|
+
});
|
|
463
|
+
return null;
|
|
464
|
+
},
|
|
465
|
+
answer: async ({ id, value }, ctx) => {
|
|
466
|
+
state.pending.change(ctx, (d) => {
|
|
467
|
+
settle(d, id, value);
|
|
468
|
+
});
|
|
469
|
+
return null;
|
|
470
|
+
},
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
## Lifetime
|
|
474
|
+
|
|
475
|
+
An object lives while it's **held**, and every hold has an owner:
|
|
476
|
+
|
|
477
|
+
| Owner | Acquired with | Released when |
|
|
478
|
+
| --- | --- | --- |
|
|
479
|
+
| another object | `self.get` | that object is disposed, or no longer declares it |
|
|
480
|
+
| a call | `ctx.get` | the call settles |
|
|
481
|
+
| a connection | a remote host's `get` | release, disconnect, revocation |
|
|
482
|
+
| the host | `eager: true` | host shutdown |
|
|
483
|
+
| UI | `useObject` | unmount |
|
|
484
|
+
|
|
485
|
+
- **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.
|
|
486
|
+
- **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.
|
|
487
|
+
- **Forced disposal** (revocation, archive) makes handles `gone` at once and aborts in-flight calls.
|
|
488
|
+
- **Holds must not form cycles.** Inside one host this is checked at `get`.
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
host.serve(Conversation, conversationFactory, { linger: 30_000 }); // switching back quickly doesn't reopen its watch
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
## Registries
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
export const Routes = defineRegistry<RouteHandler>("http.routes", { conflict: "error" });
|
|
498
|
+
|
|
499
|
+
// contribute (only from eager objects)
|
|
500
|
+
host.serve(Downloads, (_, _host, self) => {
|
|
501
|
+
const blobs = self.get(Blobs, {});
|
|
502
|
+
self.register(Routes, {
|
|
503
|
+
"GET /download": async (request, who, ctx) => {
|
|
504
|
+
const file = await blobs.find({ id: request.query.id }, ctx);
|
|
505
|
+
if (!canView(who, file.session)) return forbidden();
|
|
506
|
+
return stream(file, ctx);
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
return {};
|
|
510
|
+
}, { eager: true });
|
|
511
|
+
|
|
512
|
+
// consume: a request sees the table of its own epoch
|
|
513
|
+
server.on("request", (req, res) =>
|
|
514
|
+
host.root(async (ctx) => {
|
|
515
|
+
const route = host.registry(Routes).get(`${req.method} ${pathOf(req)}`, ctx);
|
|
516
|
+
return route ? route(req, principalOf(req), ctx) : notFound(res);
|
|
517
|
+
}),
|
|
518
|
+
);
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
- Each epoch has one complete table. A reload never leaves a moment where an entry is missing.
|
|
522
|
+
- A request that started before a reload keeps using the old table.
|
|
523
|
+
- Order is first-registration order and survives replacement.
|
|
524
|
+
- `conflict: "error"` makes a duplicate key fail the registration (or the reload).
|
|
525
|
+
|
|
526
|
+
## Plugins
|
|
527
|
+
|
|
528
|
+
A plugin is a feature with one **facet** per host kind:
|
|
529
|
+
|
|
530
|
+
```text
|
|
531
|
+
plugins/chat/
|
|
532
|
+
contract.ts Chat, Conversation, Drafts
|
|
533
|
+
server.ts facet for the server
|
|
534
|
+
worker.ts facet for session workers
|
|
535
|
+
web.tsx facet for browsers
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
// plugins/chat/worker.ts
|
|
540
|
+
import { defineFacet } from "@earendil-works/chord";
|
|
541
|
+
import { Chat, Conversation } from "./contract.ts";
|
|
542
|
+
|
|
543
|
+
export default defineFacet((host) => {
|
|
544
|
+
host.serve(Conversation, conversationFactory, { linger: 30_000 });
|
|
545
|
+
host.serve(Chat, chatFactory);
|
|
546
|
+
});
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
// a host loads the plugins' facets for its kind
|
|
551
|
+
await host.load([
|
|
552
|
+
new URL("../plugins/chat/worker.ts", import.meta.url),
|
|
553
|
+
new URL("../plugins/status/worker.ts", import.meta.url),
|
|
554
|
+
]);
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Built-in plugins are trusted code from this repository and load directly from source.
|
|
558
|
+
|
|
559
|
+
The facet's `setup` argument is its host, scoped to what a plugin needs: `serve`, `name`, `root(fn)` and `registry(R)`. Code that is not a Chord method, listener or task (Durable tools and hooks, callbacks of native libraries) keeps it to start roots and read registries, instead of reaching for a process-global host:
|
|
560
|
+
|
|
561
|
+
```ts
|
|
562
|
+
export default defineFacet((host) => {
|
|
563
|
+
host.serve(Sessions, sessionsFactory);
|
|
564
|
+
tools.register("rename_session", (args) =>
|
|
565
|
+
host.root((ctx) => ctx.get(Sessions, {}, { at: "server" }).rename(args, ctx)),
|
|
566
|
+
);
|
|
567
|
+
});
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
## Hot reload
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
await host.reload([new URL("../plugins/chat/worker.ts", import.meta.url)]); // or reload every changed plugin
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### What happens
|
|
577
|
+
|
|
578
|
+
1. **Load** the new modules.
|
|
579
|
+
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.
|
|
580
|
+
3. **Gate:** only if a state version was bumped or an exclusive resource changes.
|
|
581
|
+
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.
|
|
582
|
+
5. **Retire:** old code keeps running the roots it started. Once they end, it's dropped, along with whatever it alone declared.
|
|
583
|
+
|
|
584
|
+
### Compatible changes: no waiting
|
|
585
|
+
|
|
586
|
+
Bug fixes, new methods, new cells, new resources and registration changes all switch immediately. A root that started on the old code finishes on it, and new roots get the new code.
|
|
587
|
+
|
|
588
|
+
### Changing what state looks like or means: bump the state version
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
// v1: { running, tasks } where tasks counted phases
|
|
592
|
+
// v2: tasks counts tasks, and usage is new
|
|
593
|
+
host.serve(StatusStore, ({ session }, _host, self) => {
|
|
594
|
+
const state = self.state({ status: cell(StatusV2, { running: false, tasks: 0, usage: emptyUsage() }) });
|
|
595
|
+
return {
|
|
596
|
+
status: state.status,
|
|
597
|
+
report: async (s, ctx) => {
|
|
598
|
+
state.status.replace(ctx, s);
|
|
599
|
+
return null;
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
}, {
|
|
603
|
+
state: {
|
|
604
|
+
version: 2,
|
|
605
|
+
migrate: {
|
|
606
|
+
status: (old, from) => (from < 2 ? { running: old.running, tasks: 0, usage: emptyUsage() } : old),
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
At the gate, for each live `StatusStore`:
|
|
613
|
+
1. **Roots already inside finish.** Their re-entrant calls are let in.
|
|
614
|
+
2. **Old roots that haven't touched it yet get `upgrade_conflict`** instead of old code over migrated state. They retry as a new root.
|
|
615
|
+
3. **`migrate` runs** on a private copy, and the result is validated against the new schema.
|
|
616
|
+
4. **Commit.** New roots use the new code and the migrated state.
|
|
617
|
+
|
|
618
|
+
If the roots inside don't finish within the deadline (5 s), the reload is **aborted** and nothing changes. The error names the blocking roots.
|
|
619
|
+
|
|
620
|
+
The runtime compares each cell's schema between old and new code:
|
|
621
|
+
- unchanged: compatible;
|
|
622
|
+
- changed with a bump: migrate;
|
|
623
|
+
- changed **without** a bump: the reload is refused.
|
|
624
|
+
|
|
625
|
+
A change of meaning with an identical schema needs a bump too. Nothing can detect that one for you.
|
|
626
|
+
|
|
627
|
+
### Resources
|
|
628
|
+
|
|
629
|
+
```ts
|
|
630
|
+
const res = self.resources({
|
|
631
|
+
db: resource({ rev: 2, open: () => openSqlite(path, { wal: true }), close: (db) => db.close() }), // rev 1 → 2: reopen
|
|
632
|
+
port: resource({ rev: 1, exclusive: true, open: () => listen(8787), close: (s) => s.close() }),
|
|
633
|
+
});
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
- A reload doesn't touch a resource unless its `rev` or `deps` change.
|
|
637
|
+
- A changed resource opens next to the old one during prepare.
|
|
638
|
+
- An `exclusive` resource can't exist twice, so it's swapped at the gate: close the old one, open the new one, and reopen the old one if that fails.
|
|
639
|
+
|
|
640
|
+
### Contract changes
|
|
641
|
+
|
|
642
|
+
| Change | What happens |
|
|
643
|
+
| --- | --- |
|
|
644
|
+
| Additive, same version | reloads; consumers keep working |
|
|
645
|
+
| Breaking, version bumped, with `accepts[old]` | reloads; old consumers go through the adapter |
|
|
646
|
+
| Breaking, without an adapter | refused, unless the consumers reload in the same batch |
|
|
647
|
+
| Breaking, version not bumped | refused: "consumer needs member …" |
|
|
648
|
+
|
|
649
|
+
The host's own **eager bootstrap hold** is a lifecycle pin, not an old-version consumer. When a reload replaces
|
|
650
|
+
its provider, that hold adopts the new contract at commit; it does not require an `accepts` adapter for itself.
|
|
651
|
+
The record, cells and unchanged resources stay alive without reactivation. A refused reload keeps the original
|
|
652
|
+
hold and contract. Other holds (`self.get`, `ctx.get`, `host.get`, peers) still obey the compatibility rules above;
|
|
653
|
+
state-version bumps and migrations are still required when stored state changes.
|
|
654
|
+
|
|
655
|
+
### Several plugins at once
|
|
656
|
+
|
|
657
|
+
Reload them together: `host.reload([a, b])`. Prepare sees all new code at once, so a changed contract and its updated consumer check against each other.
|
|
658
|
+
|
|
659
|
+
On the server, `host.reload(serverFacets, { remote: { "session:": workerFacets } })` reloads every connected worker too. Its report covers the server; `report.children["session:S1"]` says what each worker changed, prepared and gated.
|
|
660
|
+
|
|
661
|
+
## Hosts and links
|
|
662
|
+
|
|
663
|
+
```ts
|
|
664
|
+
// server
|
|
665
|
+
const server = createHost({ name: "server", principalKey });
|
|
666
|
+
server.accept(webSocketLink(wss, { principal: (req) => authenticate(req) })); // stamps owner or guest from cookies
|
|
667
|
+
server.route("session:", {
|
|
668
|
+
authorize: (principal, host) => canReach(principal, host), // before resolving or spawning
|
|
669
|
+
resolve: (host) => workers.ensure(host), // a link to the worker, spawned if needed
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// worker
|
|
673
|
+
const worker = createHost({ name: `session:${id}`, principalKey, upstream: ipcLink(process) });
|
|
674
|
+
|
|
675
|
+
// anywhere
|
|
676
|
+
const chat = ctx.get(Chat, input, { at: "session:S1" }); // browser → server → worker
|
|
677
|
+
const sessions = self.get(Sessions, {}, { at: "server" }); // worker → server, acting as worker{S1}
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
- **The server relays** between its children: worker to worker, and browser to worker.
|
|
681
|
+
- **Disconnects:** when a browser disconnects, exactly the holds it had in workers are released.
|
|
682
|
+
- **Principals across links:**
|
|
683
|
+
- a forwarded principal is accepted only from the parent link;
|
|
684
|
+
- calls an object makes itself act as its host;
|
|
685
|
+
- links ping, and a dead link releases everything held through it.
|
|
686
|
+
- **Reloads across hosts:** the server coordinates prepare and commit for every host.
|
|
687
|
+
- **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`.
|
|
688
|
+
- **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`.
|
|
689
|
+
|
|
690
|
+
## Durable
|
|
691
|
+
|
|
692
|
+
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.
|
|
693
|
+
|
|
694
|
+
```ts
|
|
695
|
+
host.serve(Harness, (_, _host, self) => {
|
|
696
|
+
const res = self.resources({
|
|
697
|
+
durable: resource({
|
|
698
|
+
rev: 1,
|
|
699
|
+
open: () => DurableHarness.open(storage, { models, registry: durableRegistry(host, Extensions) }), // a reader over the epoch tables
|
|
700
|
+
close: (h) => h.close(),
|
|
701
|
+
}),
|
|
702
|
+
});
|
|
703
|
+
return { conversation: (id: number) => res.durable.value.conversation(id) };
|
|
704
|
+
}, { eager: true });
|
|
705
|
+
|
|
706
|
+
// a plugin contributes a Durable extension like any registration
|
|
707
|
+
host.serve(Guard, (_, _host, self) => {
|
|
708
|
+
self.register(Extensions, {
|
|
709
|
+
guard: defineExtension({
|
|
710
|
+
name: "guard",
|
|
711
|
+
hooks: [hook(ToolTask, { beforeTool: (call) => (isDangerous(call) ? { block: "Needs approval" } : undefined) })],
|
|
712
|
+
}),
|
|
713
|
+
});
|
|
714
|
+
return {};
|
|
715
|
+
}, { eager: true });
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
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.
|
|
719
|
+
|
|
720
|
+
## Errors
|
|
721
|
+
|
|
722
|
+
| Code | Meaning |
|
|
723
|
+
| --- | --- |
|
|
724
|
+
| `forbidden` | The factory or a method refused this principal |
|
|
725
|
+
| `invalid` | Input, arguments or a result failed validation |
|
|
726
|
+
| `not_ready` | A declared resource or handle was used inside a factory |
|
|
727
|
+
| `not_in_variant` | The member doesn't exist in the variant you hold |
|
|
728
|
+
| `released` | The handle was released |
|
|
729
|
+
| `gone` | The object was forced away while held. Call `get` again. |
|
|
730
|
+
| `contract_mismatch` | The provider's contract doesn't cover the consumer's, and no adapter exists |
|
|
731
|
+
| `upgrade_conflict` | An old root reached an object migrated meanwhile. Retry as a new root. |
|
|
732
|
+
| `superseded` | The root's epoch is no longer available on this host. Retry. |
|
|
733
|
+
| `upgrade_required` | A browser's contract is outdated. Reload its web facets. |
|
|
734
|
+
| `unavailable` | The target host can't be reached or started |
|
|
735
|
+
| `aborted` | The call's `ctx` was cancelled |
|
|
736
|
+
|
|
737
|
+
## Rules of thumb
|
|
738
|
+
|
|
739
|
+
| Do | Not |
|
|
740
|
+
| --- | --- |
|
|
741
|
+
| Keep reloadable state in cells | Closure variables (lost on reload) |
|
|
742
|
+
| Bump the state version, with `migrate`, when a cell's shape or meaning changes | Changing a schema without a bump (refused) or a meaning without a bump (silent) |
|
|
743
|
+
| Bump the contract version on breaking changes; add `accepts` for old consumers | Renaming or removing members at the same version (refused) |
|
|
744
|
+
| Do slow setup in a resource's `open` | Awaiting inside the factory (it's synchronous) |
|
|
745
|
+
| Do side effects in resources, listeners, tasks and registrations | Effects in the factory body (they run during prepare) |
|
|
746
|
+
| Use `ctx.get` inside methods | `self.get` inside methods (a new hold per call) |
|
|
747
|
+
| Keep roots short; long waits are state | Awaiting a human inside a method |
|
|
748
|
+
| Check arguments in methods | Treating the variant as all of authorization |
|
|
749
|
+
| Pass `ctx` everywhere | Starting background work with the caller's `ctx` (use a task) |
|
|
750
|
+
| Treat state values as immutable | Mutating what you read |
|