@earendil-works/chord 1.2.0 → 1.3.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 +58 -0
- package/README.md +14 -13
- package/dist/cell.d.ts +30 -9
- package/dist/cell.d.ts.map +1 -1
- package/dist/cell.js +52 -12
- package/dist/cell.js.map +1 -1
- package/dist/cluster.d.ts +4 -3
- package/dist/cluster.d.ts.map +1 -1
- package/dist/cluster.js +58 -42
- package/dist/cluster.js.map +1 -1
- package/dist/connection.d.ts +171 -58
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +676 -217
- package/dist/connection.js.map +1 -1
- package/dist/contract.d.ts +120 -45
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +104 -15
- package/dist/contract.js.map +1 -1
- package/dist/declare.d.ts +11 -48
- package/dist/declare.d.ts.map +1 -1
- package/dist/declare.js +1 -21
- package/dist/declare.js.map +1 -1
- package/dist/delta/index.d.ts +2 -1
- package/dist/delta/index.d.ts.map +1 -1
- package/dist/delta/index.js +4 -0
- package/dist/delta/index.js.map +1 -1
- package/dist/errors.d.ts +4 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -1
- package/dist/host.d.ts +214 -120
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +446 -241
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/link.d.ts +47 -12
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +200 -14
- package/dist/link.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +7 -2
- package/dist/loader.js.map +1 -1
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +35 -9
- package/dist/manager.js.map +1 -1
- package/dist/object.d.ts +438 -0
- package/dist/object.d.ts.map +1 -0
- package/dist/{record.js → object.js} +614 -335
- package/dist/object.js.map +1 -0
- package/dist/output.d.ts +85 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +350 -0
- package/dist/output.js.map +1 -0
- package/dist/registry.d.ts +3 -3
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/registry.js.map +1 -1
- package/dist/reload.d.ts +35 -27
- package/dist/reload.d.ts.map +1 -1
- package/dist/reload.js +256 -192
- package/dist/reload.js.map +1 -1
- package/dist/remote.d.ts +130 -35
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +493 -189
- package/dist/remote.js.map +1 -1
- package/dist/types.d.ts +39 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/websocket.d.ts +4 -5
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +3 -2
- package/dist/websocket.js.map +1 -1
- package/dist/wire.d.ts +210 -129
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +108 -55
- package/dist/wire.js.map +1 -1
- package/docs/guide.md +267 -203
- package/docs/spec.md +141 -114
- package/docs/tutorial.md +257 -235
- package/package.json +6 -1
- package/dist/record.d.ts +0 -320
- package/dist/record.d.ts.map +0 -1
- package/dist/record.js.map +0 -1
package/docs/guide.md
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
-
- An **object** is
|
|
8
|
-
|
|
7
|
+
- An **object type** is declared by a contract: a name, an input (the object's address) and members. A plugin's
|
|
8
|
+
**facet** (its code for one host: server, worker, browser) serves object types.
|
|
9
|
+
- An **object** is one instance of an object type: state plus code at an address. It is created on demand and lives
|
|
10
|
+
while someone references it.
|
|
11
|
+
- Everyone gets a **reference**, never the object. References work the same locally and across processes, and survive reloads.
|
|
9
12
|
- Every object knows its **principal**: an owner and a guest get different objects, with different methods.
|
|
10
13
|
- **State** is observable JSON. Remote viewers get a snapshot followed by deltas.
|
|
11
14
|
- **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
|
|
|
18
21
|
- [Declarations](#declarations)
|
|
19
22
|
- [Activation](#activation)
|
|
20
23
|
- [Principals and variants](#principals-and-variants)
|
|
21
|
-
- [
|
|
24
|
+
- [References](#references)
|
|
22
25
|
- [State](#state)
|
|
23
26
|
- [Calls, ctx and roots](#calls-ctx-and-roots)
|
|
24
27
|
- [Lifetime](#lifetime)
|
|
@@ -34,7 +37,7 @@ Chord builds applications out of **objects** that live in several processes (a s
|
|
|
34
37
|
|
|
35
38
|
```ts
|
|
36
39
|
import { Type } from "typebox";
|
|
37
|
-
import {
|
|
40
|
+
import { createHost, method, remote } from "@earendil-works/chord";
|
|
38
41
|
|
|
39
42
|
// contract.ts: shared by provider and consumers
|
|
40
43
|
export const Counter = remote("demo.counter", {
|
|
@@ -43,27 +46,26 @@ export const Counter = remote("demo.counter", {
|
|
|
43
46
|
variants: {
|
|
44
47
|
default: {
|
|
45
48
|
state: { count: Type.Number() },
|
|
46
|
-
methods: { increment: method(Type.Object({ by: Type.Number() })
|
|
49
|
+
methods: { increment: method(Type.Object({ by: Type.Number() })) },
|
|
47
50
|
},
|
|
48
51
|
},
|
|
49
52
|
});
|
|
50
53
|
|
|
51
|
-
const host = createHost({ name: "server",
|
|
54
|
+
const host = createHost({ name: "server", self: { kind: "server" } });
|
|
52
55
|
|
|
53
56
|
host.serve(Counter, ({ name }, _principal, self) => {
|
|
54
|
-
const
|
|
57
|
+
const count = self.state("count", Type.Number(), 0);
|
|
55
58
|
return {
|
|
56
|
-
|
|
57
|
-
count
|
|
59
|
+
variant: "default",
|
|
60
|
+
count,
|
|
58
61
|
increment: async ({ by }, ctx) => {
|
|
59
|
-
|
|
60
|
-
return null;
|
|
62
|
+
count.change(ctx, (n) => n + by);
|
|
61
63
|
},
|
|
62
64
|
};
|
|
63
65
|
});
|
|
64
66
|
|
|
65
67
|
await host.root(async (ctx) => {
|
|
66
|
-
const clicks = ctx.get(Counter, { name: "clicks" }); //
|
|
68
|
+
const clicks = await ctx.get(Counter, { name: "clicks" }); // ready, with its state; released when this root ends
|
|
67
69
|
clicks.count.subscribe((n) => console.log("count", n));
|
|
68
70
|
await clicks.increment({ by: 2 }, ctx); // logs 0, then 2
|
|
69
71
|
});
|
|
@@ -81,6 +83,7 @@ Remote types can be reached from other hosts. Everything that crosses the wire h
|
|
|
81
83
|
export const Chat = remote("pi.chat", {
|
|
82
84
|
version: 1,
|
|
83
85
|
input: Type.Object({ session: Type.String(), conversation: Type.Number() }),
|
|
86
|
+
host: hostFamily("session:", "session"), // lives on `session:S1` for `{ session: "S1", … }`
|
|
84
87
|
variants: {
|
|
85
88
|
viewer: {
|
|
86
89
|
state: { view: ConversationViewSchema },
|
|
@@ -89,13 +92,26 @@ export const Chat = remote("pi.chat", {
|
|
|
89
92
|
state: { view: ConversationViewSchema },
|
|
90
93
|
methods: {
|
|
91
94
|
send: method(SendInputSchema, Type.Object({ submissionId: Type.Number() })),
|
|
92
|
-
stop: method(
|
|
95
|
+
stop: method(), // no arguments, no result: `chat.stop(ctx)`
|
|
93
96
|
},
|
|
94
97
|
},
|
|
95
98
|
},
|
|
96
99
|
});
|
|
97
100
|
```
|
|
98
101
|
|
|
102
|
+
**Where a remote type lives** is part of its contract, so no caller names a host:
|
|
103
|
+
|
|
104
|
+
- `host: hostNamed("server")`: every object lives on the host named `server`.
|
|
105
|
+
- `host: hostFamily("session:", "session")`: an object lives on `session:` + its input's `session` field, a required
|
|
106
|
+
string. TypeScript checks the field name against the input.
|
|
107
|
+
|
|
108
|
+
A `get` normalizes the input (defaults count), resolves the host, and acquires the object here if that is this host,
|
|
109
|
+
otherwise over the link to it. `serve` refuses a type whose placement does not include the serving host, so a misplaced
|
|
110
|
+
type fails at startup or at the reload that adds it. The host that gets a hold checks that its own contract places the
|
|
111
|
+
input on itself, and a relay checks the hold's target against its contract token: a client never chooses where an
|
|
112
|
+
object lives. The placement is part of the description and the fingerprint: moving a type is a breaking change, and
|
|
113
|
+
references to it get `upgrade_required`.
|
|
114
|
+
|
|
99
115
|
### Local types
|
|
100
116
|
|
|
101
117
|
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.
|
|
@@ -147,66 +163,60 @@ host.serve(Index, indexFactory, {
|
|
|
147
163
|
## Serving objects
|
|
148
164
|
|
|
149
165
|
```ts
|
|
150
|
-
host.serve(Type, (input, principal, self) =>
|
|
166
|
+
host.serve(Type, (input, principal, self) => implementation, options);
|
|
151
167
|
```
|
|
152
168
|
|
|
153
|
-
- The **factory** runs synchronously. It declares what the object needs and returns its
|
|
154
|
-
- There is one **
|
|
169
|
+
- 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.
|
|
170
|
+
- 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.
|
|
155
171
|
- **Options:**
|
|
156
|
-
- `state: { version, migrate }`: the state version and its migrations (see [Hot reload](#hot-reload));
|
|
157
172
|
- `accepts`: adapters for consumers built against older contract versions;
|
|
158
|
-
- `
|
|
159
|
-
|
|
173
|
+
- `lifetime`: `"referenced"` (default) creates the object at its first `get` and disposes it after the last reference
|
|
174
|
+
is released; `"host"` creates it when the plugin loads and keeps it until the host closes (see [Lifetime](#lifetime));
|
|
175
|
+
- `linger`: how long to keep a referenced object after the last reference is released.
|
|
160
176
|
|
|
161
177
|
## Declarations
|
|
162
178
|
|
|
163
|
-
A reload runs the factory again over the same
|
|
179
|
+
A reload runs the factory again over the same object. Declarations that keep something across a reload have a **name**: the new code's `status` cell is the old code's `status` cell, with its value. Derivations and tasks hold nothing across a reload and have no name.
|
|
164
180
|
|
|
165
181
|
```ts
|
|
166
182
|
host.serve(StatusStore, ({ session }, _host, self) => {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
errors: cell(Type.Array(Type.String()), []),
|
|
170
|
-
});
|
|
183
|
+
const status = self.state("status", StatusSchema, { running: false, tasks: 0 });
|
|
184
|
+
const errors = self.state("errors", Type.Array(Type.String()), []);
|
|
171
185
|
const catalog = self.get(Catalog, {});
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
},
|
|
186
|
+
const db = self.resource("db", {
|
|
187
|
+
version: 1,
|
|
188
|
+
open: async ({ signal }) => openSqlite(statusPath(session), { signal }),
|
|
189
|
+
close: async (db) => db.close(),
|
|
189
190
|
});
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
self.on("catalogChanges", catalog.rows, (rows, ctx) => errors.replace(ctx, checkRows(rows)));
|
|
192
|
+
self.task(async (ctx) => {
|
|
193
|
+
while (!ctx.signal.aborted) {
|
|
194
|
+
await sleep(10_000, ctx.signal);
|
|
195
|
+
await db.value.run("INSERT INTO history VALUES (?)", [JSON.stringify(status.value)]);
|
|
196
|
+
}
|
|
192
197
|
});
|
|
193
|
-
|
|
198
|
+
const summary = self.derive([status, errors], (s, e) => ({ ...s, failing: e.length > 0 }));
|
|
199
|
+
return { summary, report: reportMethod(status) }; // exposed state is what the implementation returns
|
|
194
200
|
});
|
|
195
201
|
```
|
|
196
202
|
|
|
197
203
|
| Declaration | What it is | On reload |
|
|
198
204
|
| --- | --- | --- |
|
|
199
|
-
| `self.state(
|
|
200
|
-
| `self.get(Type, input, { at? })` | a
|
|
201
|
-
| `self.
|
|
202
|
-
| `self.
|
|
203
|
-
| `self.
|
|
204
|
-
| `self.derive(
|
|
205
|
+
| `self.state(name, schema, initial \| { init }, { version?, migrate? })` | an observable JSON cell; the schema is required | same cell and value (`init` does not run); migrated when its version rises |
|
|
206
|
+
| `self.get(Type, input, { at? })` | a reference to another object; its identity is the address | kept if declared again, otherwise released |
|
|
207
|
+
| `self.resource(name, { version, deps?, exclusive?, open, close? })` | a connection, watch, socket, port | kept; reopened only if `version`, `deps` or `exclusive` change |
|
|
208
|
+
| `self.on(name, source, handler)` or `self.on(name, source, select, handler)` | reacts to state or to an event source | the same subscription, with the new handler, as long as the source and `select` are unchanged |
|
|
209
|
+
| `self.task(async (ctx) => ...)` | long-running background work | the old run is aborted through `ctx.signal`; the new code starts once it finished (at most `taskStopDeadline`, default 5 s) |
|
|
210
|
+
| `self.derive(sources, fn)` | live projection of state; `fn` is synchronous | a new derivation, computed when the reload commits |
|
|
205
211
|
| `self.register(Registry, { key: value })` | an entry in a registry | replaced at commit |
|
|
206
|
-
| `self.expose(
|
|
212
|
+
| `self.expose(resource)` | a member backed by a resource (local types) | follows the resource |
|
|
213
|
+
|
|
214
|
+
Names are strings: declaring one twice in a run fails (`invalid/duplicate`). Each kind has its own names, so a cell and a resource may share one.
|
|
207
215
|
|
|
208
216
|
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
217
|
|
|
218
|
+
**A listener on state reconciles.** A listener on state (a cell, a derivation, a reference's state member) gets the current value when it starts, then every new value. When a reload replaces its source (a derivation is new at every reload), it subscribes again and gets the current value again. Write its handler to bring things in line with the value it gets (set, compare, start or stop what the value says), not to count or append per call. An event source (a resource's events) delivers no current value.
|
|
219
|
+
|
|
210
220
|
A name you no longer declare is released, closed or stopped, after the old code has finished what it was doing.
|
|
211
221
|
|
|
212
222
|
**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.
|
|
@@ -215,21 +225,23 @@ A name you no longer declare is released, closed or stopped, after the old code
|
|
|
215
225
|
// wrong: reset on every reload
|
|
216
226
|
host.serve(Visits, (_, _p, self) => {
|
|
217
227
|
let visits = 0;
|
|
218
|
-
return { hit: async (
|
|
228
|
+
return { hit: async (ctx) => ++visits };
|
|
219
229
|
});
|
|
220
230
|
|
|
221
231
|
// right
|
|
222
232
|
host.serve(Visits, (_, _p, self) => {
|
|
223
|
-
const
|
|
233
|
+
const visits = self.state("visits", Type.Number(), 0);
|
|
224
234
|
return {
|
|
225
|
-
hit: async (
|
|
226
|
-
|
|
227
|
-
return
|
|
235
|
+
hit: async (ctx) => {
|
|
236
|
+
visits.change(ctx, (n) => n + 1);
|
|
237
|
+
return visits.value;
|
|
228
238
|
},
|
|
229
239
|
};
|
|
230
240
|
});
|
|
231
241
|
```
|
|
232
242
|
|
|
243
|
+
**Methods** are declared with `method(args?, result?)` in the contract. Without `args` a method takes none: callers write `ref.hit(ctx)` (or `ref.hit()`), and its implementation is `(ctx) => …`. Without `result` it returns nothing: the implementation returns nothing, and callers get `undefined`. `method(undefined, Type.Number())` takes no arguments and returns a number.
|
|
244
|
+
|
|
233
245
|
## Activation
|
|
234
246
|
|
|
235
247
|
The factory is synchronous, but activation isn't. `self.get` and `self.resources` only *declare*; the slow work happens afterwards:
|
|
@@ -238,24 +250,23 @@ The factory is synchronous, but activation isn't. `self.get` and `self.resources
|
|
|
238
250
|
get(Index, { session: "S1" })
|
|
239
251
|
1. run the factory → declarations
|
|
240
252
|
2. open declared resources → e.g. connect to SQLite
|
|
241
|
-
3. wait for declared
|
|
242
|
-
4.
|
|
253
|
+
3. wait for declared refs → dependencies activate first
|
|
254
|
+
4. run the cells' init → e.g. read the rows into a cell
|
|
255
|
+
5. live → refs become "live"; listeners and tasks start
|
|
243
256
|
```
|
|
244
257
|
|
|
245
258
|
```ts
|
|
246
259
|
host.serve(Index, ({ session }, _host, self) => {
|
|
247
|
-
const
|
|
248
|
-
db: resource({ rev: 1, open: () => openSqlite(indexPath(session)), close: (db) => db.close() }),
|
|
249
|
-
});
|
|
260
|
+
const db = self.resource("db", { version: 1, open: () => openSqlite(indexPath(session)), close: (db) => db.close() });
|
|
250
261
|
return {
|
|
251
|
-
search: async ({ query }, ctx) =>
|
|
262
|
+
search: async ({ query }, ctx) => db.value.all("SELECT entryId, text FROM fts WHERE fts MATCH ?", [query]),
|
|
252
263
|
};
|
|
253
264
|
});
|
|
254
265
|
|
|
255
266
|
host.serve(Chat, ({ session, conversation }, principal, self) => {
|
|
256
267
|
const index = self.get(Index, { session }); // a placeholder until Index is live
|
|
257
268
|
return {
|
|
258
|
-
|
|
269
|
+
variant: "participant",
|
|
259
270
|
find: async ({ query }, ctx) => index.search({ query }, ctx), // only ever runs once Chat (and Index) are live
|
|
260
271
|
// ...
|
|
261
272
|
};
|
|
@@ -263,79 +274,92 @@ host.serve(Chat, ({ session, conversation }, principal, self) => {
|
|
|
263
274
|
```
|
|
264
275
|
|
|
265
276
|
**Rules:**
|
|
266
|
-
- Inside the factory, never use what you declared: `
|
|
277
|
+
- Inside the factory, never use what you declared: `db.value` and `index.someState.value` throw `not_ready`, and calling a reference throws. Use them in methods, listeners, tasks and `init`.
|
|
278
|
+
- **The object goes live only with its references ready**: live, and (for a remote reference) every state member of its variant hydrated. The same holds for state it forwards or derives from a remote reference it holds some other way: it goes live once that state has its value. A reference that is not ready after the host's `readyTimeout` (default 60 s) fails the activation with `unavailable/ready_timeout`; a reference cycle across hosts ends that way instead of hanging.
|
|
267
279
|
- **Concurrent `get`s** of one address share one activation, so the database opens once.
|
|
268
280
|
- **Failure propagates:** if `openSqlite` throws, `Index` fails, `Chat` fails with that cause, and every waiting `get` rejects. The next `get` tries again.
|
|
269
281
|
- **Abandoned activations:** if everyone releases before activation finishes, the result is disposed.
|
|
270
282
|
|
|
271
283
|
### Startup readiness and parent admission
|
|
272
284
|
|
|
273
|
-
`await host.load(facets)` and `await host.install(facet)` wait for the
|
|
274
|
-
resources are open, declared
|
|
275
|
-
before activation is awaited, so
|
|
276
|
-
|
|
277
|
-
activation error;
|
|
285
|
+
`await host.load(facets)` and `await host.install(facet)` wait for the `lifetime: "host"` objects they install to
|
|
286
|
+
become live: resources are open, declared references are live, and registrations are published. All facets in a load
|
|
287
|
+
are installed before activation is awaited, so a host object can use a provider in a later facet. Referenced objects
|
|
288
|
+
are created only when something references them. Factory, dependency and resource errors reject startup with the
|
|
289
|
+
reference's activation error; host object failures also reach `onError`. Installation is not rolled back on failure: close the host
|
|
278
290
|
when abandoning startup. `install` registers synchronously, so setup/registration errors still throw synchronously.
|
|
279
291
|
|
|
280
|
-
`await host.ready()` waits for the
|
|
281
|
-
and previously deferred loads. It is an activation barrier, not a continuous health check, a wait for tasks to
|
|
282
|
-
finish
|
|
292
|
+
`await host.ready()` waits for the host objects present when called, including direct
|
|
293
|
+
`host.serve(..., { lifetime: "host" })` and previously deferred loads. It is an activation barrier, not a continuous health check, a wait for tasks to
|
|
294
|
+
finish. Later installations require another readiness wait.
|
|
283
295
|
|
|
284
296
|
A worker may need to open its local Harness and announce control readiness **before** the parent accepts its link.
|
|
285
|
-
|
|
286
|
-
whole load before announcing control readiness would deadlock. Defer the whole
|
|
297
|
+
A host object reporting to the server's SessionStatus cannot become live before that acceptance. Awaiting the
|
|
298
|
+
whole load before announcing control readiness would deadlock. Defer the whole barrier explicitly:
|
|
287
299
|
|
|
288
300
|
```ts
|
|
289
|
-
// Connect the upstream link first; its outgoing
|
|
290
|
-
await worker.load(workerFacets, {
|
|
291
|
-
const harness = worker.get(Harness, {});
|
|
292
|
-
await harness.ready; // local bootstrap only; no upstream dependency
|
|
301
|
+
// Connect the upstream link first; its outgoing references can queue until the parent accepts it.
|
|
302
|
+
await worker.load(workerFacets, { awaitHostObjects: false }); // also supported by install
|
|
303
|
+
const harness = await worker.get(Harness, {}); // local bootstrap only; no upstream dependency
|
|
293
304
|
announceControlReady(); // parent can now accept the worker link
|
|
294
|
-
await worker.ready(); //
|
|
305
|
+
await worker.ready(); // every host object live, including upstream references
|
|
295
306
|
harness.release();
|
|
296
307
|
```
|
|
297
308
|
|
|
298
|
-
`
|
|
299
|
-
automatic classification of upstream dependencies. Choose a local bootstrap
|
|
309
|
+
`awaitHostObjects: false` still starts every host object; it skips waiting, not activation. There is no
|
|
310
|
+
automatic classification of upstream dependencies. Choose a local bootstrap reference with no upstream dependency,
|
|
300
311
|
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
|
|
302
|
-
until the full barrier settles, and report a later failure through the worker's fatal/startup-error path.
|
|
312
|
+
readiness means admission is possible, not that every host object is live. Keep worker bootstrap counted as busy
|
|
313
|
+
until the full barrier settles, and report a later failure through the worker's fatal/startup-error path. Never await a
|
|
314
|
+
reference whose object needs the parent (a remote one, or a local one that holds one) before the parent admits the link.
|
|
303
315
|
|
|
304
|
-
To hand out a native object a resource opens (a local type's `opaque` member), expose the resource.
|
|
316
|
+
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:
|
|
305
317
|
|
|
306
318
|
```ts
|
|
307
319
|
host.serve(Harness, (_, _host, self) => {
|
|
308
|
-
const
|
|
309
|
-
return { harness: self.expose(
|
|
320
|
+
const durable = self.resource("durable", { version: 1, open: () => DurableHarness.open(storage), close: (h) => h.close() });
|
|
321
|
+
return { harness: self.expose(durable) }; // contract member: harness: opaque<DurableHarness>("pi.durable-harness@1")
|
|
310
322
|
});
|
|
311
323
|
|
|
312
|
-
const harness = ctx.get(Harness, {});
|
|
313
|
-
await harness.ready;
|
|
324
|
+
const harness = await ctx.get(Harness, {});
|
|
314
325
|
harness.harness.resume(); // the DurableHarness itself
|
|
315
326
|
```
|
|
316
327
|
|
|
317
328
|
## Principals and variants
|
|
318
329
|
|
|
319
|
-
The application defines its principals and how they're keyed:
|
|
330
|
+
The application defines its principals and how they're keyed. Chord invents none: the app gives every principal a
|
|
331
|
+
request can act as.
|
|
320
332
|
|
|
321
333
|
```ts
|
|
322
334
|
type Principal =
|
|
323
|
-
| { kind: "owner";
|
|
335
|
+
| { kind: "owner"; access: "local" | "relay" }
|
|
324
336
|
| { kind: "guest"; id: string; name: string; session: string; mode: "view" | "full"; revision: number }
|
|
325
|
-
| { kind: "worker"; session: string; generation: number }
|
|
337
|
+
| { kind: "worker"; session: string; generation: number }
|
|
338
|
+
| { kind: "server" } // the parent's own requests, as a worker sees them
|
|
339
|
+
| { kind: "host" }; // a host's own code
|
|
326
340
|
|
|
327
|
-
const host = createHost({
|
|
341
|
+
const host = createHost<Principal>({
|
|
328
342
|
name: "server",
|
|
329
|
-
|
|
330
|
-
|
|
343
|
+
self: { kind: "host" },
|
|
344
|
+
principalKey: (p) =>
|
|
345
|
+
p.kind === "owner" ? `owner:${p.access}` :
|
|
331
346
|
p.kind === "guest" ? `guest:${p.id}:r${p.revision}` :
|
|
332
|
-
`worker:${p.session}:${p.generation}
|
|
347
|
+
p.kind === "worker" ? `worker:${p.session}:${p.generation}` :
|
|
348
|
+
p.kind,
|
|
333
349
|
});
|
|
334
350
|
```
|
|
335
351
|
|
|
336
|
-
-
|
|
337
|
-
|
|
352
|
+
- `createHost<Principal>` types every factory's `principal`, `ctx.principal`, routes, `accept` and
|
|
353
|
+
`invalidatePrincipal` patterns, so code narrows on `kind` without casts. Facet modules say it too:
|
|
354
|
+
`defineFacet<Principal>((api) => …)` (an app usually exports its own `defineFacet` and `Ctx` aliases). Without a type
|
|
355
|
+
argument, principals are `unknown`.
|
|
356
|
+
- There is one object per principal key: two tabs of the owner share an object, and a guest gets its own.
|
|
357
|
+
- Code inside a host acts as `self`, so internal objects are shared. A principal with the same key as `self` is the
|
|
358
|
+
host's own, whichever object carries it.
|
|
338
359
|
- A link stamps the principal of whatever connects to it. Callers can never supply their own.
|
|
360
|
+
- A child host names what its parent's own requests act as: `connect(link, { principal })` (or
|
|
361
|
+
`upstream: { link, principal }`). Requests the parent relays act as the caller it forwards.
|
|
362
|
+
- A route names what the hosts it resolves act as when they call back: `route(prefix, { …, principal: (name) => … })`.
|
|
339
363
|
|
|
340
364
|
**A remote factory picks a variant:**
|
|
341
365
|
|
|
@@ -343,101 +367,126 @@ const host = createHost({
|
|
|
343
367
|
host.serve(Chat, ({ session, conversation }, principal, self) => {
|
|
344
368
|
if (!canView(principal, session)) throw forbidden();
|
|
345
369
|
const c = self.get(Conversation, { session, conversation }); // as host: one shared instance
|
|
346
|
-
if (!canSend(principal)) return {
|
|
370
|
+
if (!canSend(principal)) return { variant: "viewer", view: c.view };
|
|
347
371
|
const author = authorOf(principal);
|
|
348
372
|
return {
|
|
349
|
-
|
|
373
|
+
variant: "participant",
|
|
350
374
|
view: c.view,
|
|
351
375
|
send: async (input, ctx) => c.submit(input, author, ctx),
|
|
352
|
-
stop: async (
|
|
376
|
+
stop: async (ctx) => {
|
|
353
377
|
await c.stop(author, ctx);
|
|
354
|
-
return null;
|
|
355
378
|
},
|
|
356
379
|
};
|
|
357
380
|
});
|
|
358
381
|
```
|
|
359
382
|
|
|
360
383
|
```ts
|
|
361
|
-
const chat = ctx.get(Chat, { session: "S1", conversation: 1 }
|
|
362
|
-
|
|
363
|
-
if (chat.kind === "participant") showComposer(chat.send);
|
|
384
|
+
const chat = await ctx.get(Chat, { session: "S1", conversation: 1 }); // on session:S1
|
|
385
|
+
if (chat.variant === "participant") showComposer(chat.send); // narrows: `send` exists only there
|
|
364
386
|
else showReadOnly();
|
|
365
387
|
```
|
|
366
388
|
|
|
367
389
|
**What a variant doesn't do:**
|
|
368
390
|
- **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
391
|
- **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
|
|
392
|
+
- **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.
|
|
371
393
|
|
|
372
|
-
Local types have no variants. Their only callers are inside the host, acting as `
|
|
394
|
+
Local types have no variants. Their only callers are inside the host, acting as its `self`. If a local object needs to know who's acting, pass that explicitly, like `author` above.
|
|
373
395
|
|
|
374
|
-
##
|
|
396
|
+
## References
|
|
397
|
+
|
|
398
|
+
| Where | API | Released |
|
|
399
|
+
| --- | --- | --- |
|
|
400
|
+
| a factory | `self.get(Type, input)`: synchronous; the object goes live after it is ready | with the object (or its replaced declaration) |
|
|
401
|
+
| a call | `await ctx.get(Type, input, { state? })` | when the call settles |
|
|
402
|
+
| outside Chord | `await host.get(Type, input, { signal?, state? })` | `ref.release()` |
|
|
403
|
+
| a UI runtime | `host.hold(Type, input)`: synchronous, possibly `loading` | `ref.release()` |
|
|
375
404
|
|
|
376
405
|
```ts
|
|
377
|
-
const chat = ctx.get(Chat, input,
|
|
406
|
+
const chat = await ctx.get(Chat, input); // ready: live, every state member of its variant hydrated
|
|
378
407
|
chat.status; // "loading" | "live" | "released" | "gone"
|
|
379
|
-
|
|
380
|
-
chat.
|
|
381
|
-
chat.shape; // { kind, members, contract }
|
|
408
|
+
chat.variant; // the current variant; a multi-variant reference narrows on it
|
|
409
|
+
chat.shape; // { variant, members, contract }
|
|
382
410
|
const send = chat.send; // a stable function: always dispatches to the current code
|
|
383
|
-
chat.view.value; //
|
|
411
|
+
chat.view.value; // the current value, typed T
|
|
384
412
|
chat.view.subscribe((v) => render(v));
|
|
385
413
|
chat.onStatus((status, error) => { if (status === "gone") reconnect(error); });
|
|
386
414
|
chat.release();
|
|
387
415
|
```
|
|
388
416
|
|
|
389
|
-
- **
|
|
417
|
+
- **A reference you hold is usable:** an awaited `get` resolves once the object is live and every state member of its variant has its first snapshot, local or remote. It rejects with the activation error (`forbidden`, unreachable, …), with `aborted` (a `ctx.get` whose ctx is aborted or whose call settles first; `host.get`'s `signal`), or with `unavailable/ready_timeout` after the host's `readyTimeout`. Only that reference is released then.
|
|
418
|
+
- **A held reference stays subscribed** to the state of its variant, with or without listeners, until it is released. After a link loss the last values stay readable while it re-holds (status `loading`).
|
|
419
|
+
- **Reading state before the first hydration throws `not_ready`** (a `hold` that is still loading, a member read inside a factory). A member outside the current variant throws `not_in_variant`; an ended reference throws its end (`released`, or the `gone` error). A listener added before the hydration gets its snapshot then.
|
|
420
|
+
- **`state: false`** gives a method-only reference: it receives no state (unless another reference to the same object wants it) and is ready once its object is live.
|
|
421
|
+
- **Calls on a `loading` reference wait** until it's live, respecting `ctx` cancellation and a deadline.
|
|
390
422
|
- A member missing from the current variant rejects with `not_in_variant`.
|
|
391
423
|
- After `release()`, every member rejects with `released`.
|
|
392
|
-
- **`gone`** means the object was forced away while
|
|
393
|
-
- **
|
|
424
|
+
- **`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.
|
|
425
|
+
- **References to one remote object share one binding** per host and key (type, normalized input, principal key, contract fingerprint): one hold over the link, one stream per state member, calls over the same hold. Each reference keeps its own status, observers and `release`. With `createHost({ linger: { types: { [typeName]: ms }, cap } })`, a binding whose last reference is released stays held (and hydrated) for `ms`, at most `cap` (default 4) at a time, so taking the reference again costs nothing on the link.
|
|
394
426
|
- **Return values are values.** Anything long-lived you want to share is an object you reach with `get`.
|
|
395
427
|
|
|
396
428
|
## State
|
|
397
429
|
|
|
398
430
|
```ts
|
|
399
|
-
const
|
|
431
|
+
const todos = self.state("todos", Type.Array(TodoSchema), []);
|
|
400
432
|
|
|
401
|
-
|
|
433
|
+
todos.change(ctx, (draft) => {
|
|
402
434
|
draft.push({ id: "t1", text: "Write docs", done: false });
|
|
403
435
|
});
|
|
404
|
-
|
|
436
|
+
todos.change(ctx, (draft) => {
|
|
405
437
|
draft[0].done = true;
|
|
406
438
|
});
|
|
407
|
-
|
|
439
|
+
todos.replace(ctx, []);
|
|
408
440
|
|
|
409
|
-
|
|
410
|
-
|
|
441
|
+
todos.value; // immutable
|
|
442
|
+
todos.subscribe((value) => render(value));
|
|
411
443
|
|
|
412
444
|
// mirroring an external immutable store: subscribers get a diff, not the whole value
|
|
413
|
-
|
|
414
|
-
|
|
445
|
+
view.replace(ctx, conversation.view); // diffs when the new value reuses old containers
|
|
446
|
+
view.replace(ctx, rebuilt, { diff: true }); // always diff
|
|
415
447
|
```
|
|
416
448
|
|
|
417
449
|
- A `change` is atomic: one revision, or nothing if the callback throws.
|
|
418
450
|
- `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
451
|
- Values are immutable. Never mutate what you read or receive.
|
|
420
|
-
- Remote
|
|
452
|
+
- Remote references get a snapshot of each state member of their variant, then deltas. After a reconnect or a reload they get a `reset` snapshot, and nothing is replayed.
|
|
421
453
|
- `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
|
-
-
|
|
454
|
+
- An implementation may expose another object's state member as its own: `return { variant: "viewer", view: conversation.view }`. The object goes live only once that member has its value, local or remote.
|
|
455
|
+
|
|
456
|
+
**A cell whose first value comes from a source** declares `{ init }` instead of an initial value:
|
|
423
457
|
|
|
424
458
|
```ts
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
459
|
+
const db = self.resource("db", { version: 1, open: () => openSqlite(path), close: (db) => db.close() });
|
|
460
|
+
const notes = self.state("notes", Type.Array(NoteSchema), { init: () => db.value.all("SELECT * FROM notes") });
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
`init(ctx)` runs once the resources are open and the references live, before the object goes live, so no method,
|
|
464
|
+
listener, task or subscriber sees a value that did not come from the source. A task that loads the value instead
|
|
465
|
+
starts only after the object is live: a method called in between answers from the placeholder (a note that exists
|
|
466
|
+
reads as unknown), and an async load can overwrite a write made during its `await`. `init` may be async and gets a
|
|
467
|
+
`ctx` to call other objects. The `init`s of one object run in parallel, so one must not read another's cell. A throw or
|
|
468
|
+
an invalid value fails the activation. A reload keeps the cell and its value and does not run `init` again; a cell the
|
|
469
|
+
reload adds runs its `init` while the reload prepares, and a failure refuses the reload.
|
|
470
|
+
|
|
471
|
+
`init` reads a source the object has anyway. It saves nothing: what must outlive the process is written to that source
|
|
472
|
+
(a database, a file, a Durable document) by the code that changes it.
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
const visible = self.derive([catalog.rows], (rows) => rows.filter((r) => visibleTo(principal, r)));
|
|
476
|
+
const openCount = self.derive([todos], (t) => t.filter((x) => !x.done).length);
|
|
429
477
|
```
|
|
430
478
|
|
|
431
|
-
|
|
479
|
+
Derivation functions are synchronous. A derivation has no name: every reload makes a new one, computed when the
|
|
480
|
+
reload commits.
|
|
432
481
|
|
|
433
482
|
## Calls, ctx and roots
|
|
434
483
|
|
|
435
|
-
Every method gets `(args, ctx)
|
|
484
|
+
Every method gets `(args, ctx)`, or `(ctx)` when it takes no arguments. Pass `ctx` on to everything you call:
|
|
436
485
|
|
|
437
486
|
```ts
|
|
438
487
|
send: async (input, ctx) => {
|
|
439
488
|
ctx.signal.throwIfAborted();
|
|
440
|
-
const uploads = ctx.get(Uploads, {}); // a
|
|
489
|
+
const uploads = await ctx.get(Uploads, {}); // a reference owned by this call; released when it settles
|
|
441
490
|
const files = await uploads.resolve({ ids: input.uploads }, ctx);
|
|
442
491
|
return c.submit({ ...input, files }, author, ctx);
|
|
443
492
|
},
|
|
@@ -446,57 +495,62 @@ send: async (input, ctx) => {
|
|
|
446
495
|
A **root** is a short tree of calls, started at ingress:
|
|
447
496
|
- by a link, for a request that arrives without one;
|
|
448
497
|
- by `host.root(fn)`, for timers and scripts;
|
|
449
|
-
- by each event delivered to a `
|
|
498
|
+
- by each event delivered to a listener (`self.on`);
|
|
450
499
|
- by each Chord call made from a `task` or a Durable phase.
|
|
451
500
|
|
|
452
|
-
A root runs on the code that was current when it started (its **
|
|
501
|
+
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.
|
|
453
502
|
|
|
454
503
|
```ts
|
|
455
504
|
// wrong: holds a root (and its version of the code) open for minutes
|
|
456
|
-
approve: async (
|
|
505
|
+
approve: async (ctx) => waitForHuman(ctx),
|
|
457
506
|
|
|
458
507
|
// right: record the request as state; the answer is a separate call
|
|
459
508
|
request: async (req, ctx) => {
|
|
460
|
-
|
|
509
|
+
pending.change(ctx, (d) => {
|
|
461
510
|
d.push(req);
|
|
462
511
|
});
|
|
463
|
-
return null;
|
|
464
512
|
},
|
|
465
513
|
answer: async ({ id, value }, ctx) => {
|
|
466
|
-
|
|
514
|
+
pending.change(ctx, (d) => {
|
|
467
515
|
settle(d, id, value);
|
|
468
516
|
});
|
|
469
|
-
return null;
|
|
470
517
|
},
|
|
471
518
|
```
|
|
472
519
|
|
|
473
520
|
## Lifetime
|
|
474
521
|
|
|
475
|
-
An object lives while it's **
|
|
522
|
+
An object lives while it's **referenced**, and every reference has an owner:
|
|
476
523
|
|
|
477
524
|
| Owner | Acquired with | Released when |
|
|
478
525
|
| --- | --- | --- |
|
|
479
526
|
| another object | `self.get` | that object is disposed, or no longer declares it |
|
|
480
527
|
| a call | `ctx.get` | the call settles |
|
|
481
528
|
| a connection | a remote host's `get` | release, disconnect, revocation |
|
|
482
|
-
| the host | `
|
|
529
|
+
| the host | `lifetime: "host"` | host shutdown (or a reload removing its facet) |
|
|
483
530
|
| UI | `useObject` | unmount |
|
|
484
531
|
|
|
485
|
-
- **Disposal** runs after the last
|
|
486
|
-
- **A resource's `close` may still use the objects its
|
|
487
|
-
- **Forced disposal** (revocation, archive) makes
|
|
488
|
-
- **
|
|
532
|
+
- **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.
|
|
533
|
+
- **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.
|
|
534
|
+
- **Forced disposal** (revocation, archive) makes references `gone` at once and aborts in-flight calls.
|
|
535
|
+
- **References must not form cycles.** Inside one host this is checked at `get`.
|
|
489
536
|
|
|
490
537
|
```ts
|
|
491
538
|
host.serve(Conversation, conversationFactory, { linger: 30_000 }); // switching back quickly doesn't reopen its watch
|
|
539
|
+
host.serve(Index, indexFactory, { lifetime: "host" }); // one object from load to host shutdown
|
|
492
540
|
```
|
|
493
541
|
|
|
542
|
+
**`lifetime: "host"`** is for local types with an empty input (or only defaults): the type has exactly one object,
|
|
543
|
+
created for the host principal when its facet loads. Every `get` reaches that object, whoever asks; a `get` with
|
|
544
|
+
another input is invalid. References others hold do not change its lifetime, and it cannot `linger`. Only such
|
|
545
|
+
objects may register into registries. TypeScript refuses `lifetime: "host"` for a remote type, for an input with a
|
|
546
|
+
required field and together with `linger`; `serve` checks the same at runtime, for callers without types.
|
|
547
|
+
|
|
494
548
|
## Registries
|
|
495
549
|
|
|
496
550
|
```ts
|
|
497
551
|
export const Routes = defineRegistry<RouteHandler>("http.routes", { conflict: "error" });
|
|
498
552
|
|
|
499
|
-
// contribute (only from
|
|
553
|
+
// contribute (only from `lifetime: "host"` objects)
|
|
500
554
|
host.serve(Downloads, (_, _host, self) => {
|
|
501
555
|
const blobs = self.get(Blobs, {});
|
|
502
556
|
self.register(Routes, {
|
|
@@ -507,9 +561,9 @@ host.serve(Downloads, (_, _host, self) => {
|
|
|
507
561
|
},
|
|
508
562
|
});
|
|
509
563
|
return {};
|
|
510
|
-
}, {
|
|
564
|
+
}, { lifetime: "host" });
|
|
511
565
|
|
|
512
|
-
// consume: a request sees the table of its own
|
|
566
|
+
// consume: a request sees the table of its own code version
|
|
513
567
|
server.on("request", (req, res) =>
|
|
514
568
|
host.root(async (ctx) => {
|
|
515
569
|
const route = host.registry(Routes).get(`${req.method} ${pathOf(req)}`, ctx);
|
|
@@ -518,7 +572,7 @@ server.on("request", (req, res) =>
|
|
|
518
572
|
);
|
|
519
573
|
```
|
|
520
574
|
|
|
521
|
-
- Each
|
|
575
|
+
- Each code version has one complete table. A reload never leaves a moment where an entry is missing.
|
|
522
576
|
- A request that started before a reload keeps using the old table.
|
|
523
577
|
- Order is first-registration order and survives replacement.
|
|
524
578
|
- `conflict: "error"` makes a duplicate key fail the registration (or the reload).
|
|
@@ -556,13 +610,13 @@ await host.load([
|
|
|
556
610
|
|
|
557
611
|
Built-in plugins are trusted code from this repository and load directly from source.
|
|
558
612
|
|
|
559
|
-
The facet's `setup` argument is its host, scoped to what a plugin needs: `serve`, `name`, `root(fn)` and `
|
|
613
|
+
The facet's `setup` argument is its host, scoped to what a plugin needs: `serve`, `name`, `root(fn)`, `registry(R)` and `signal`, which aborts once this run of the facet is no longer installed (a reload replaced or removed it, or the reload that loaded it aborted). Code that is not a Chord method, listener or task (Durable tools and hooks, callbacks of native libraries) keeps it to start roots and read registries, instead of reaching for a process-global host:
|
|
560
614
|
|
|
561
615
|
```ts
|
|
562
616
|
export default defineFacet((host) => {
|
|
563
617
|
host.serve(Sessions, sessionsFactory);
|
|
564
618
|
tools.register("rename_session", (args) =>
|
|
565
|
-
host.root((ctx) => ctx.get(Sessions, {}
|
|
619
|
+
host.root(async (ctx) => (await ctx.get(Sessions, {})).rename(args, ctx)),
|
|
566
620
|
);
|
|
567
621
|
});
|
|
568
622
|
```
|
|
@@ -576,36 +630,31 @@ await host.reload([new URL("../plugins/chat/worker.ts", import.meta.url)]); //
|
|
|
576
630
|
### What happens
|
|
577
631
|
|
|
578
632
|
1. **Load** the new modules.
|
|
579
|
-
2. **Prepare:** run the new factories over the existing
|
|
580
|
-
3. **Gate:** only if a
|
|
633
|
+
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.
|
|
634
|
+
3. **Gate:** only if a cell's version was raised or an exclusive resource changes.
|
|
581
635
|
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.
|
|
636
|
+
5. **Retire:** old code keeps running the roots it started. Once they end, it's dropped, along with whatever it alone declared. If they are still running when the next reload starts, that reload waits up to `retireDeadline`, then aborts them (their `ctx.signal`) before it closes what only the old code used.
|
|
583
637
|
|
|
584
638
|
### Compatible changes: no waiting
|
|
585
639
|
|
|
586
640
|
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
641
|
|
|
588
|
-
### Changing what state looks like or means: bump the
|
|
642
|
+
### Changing what state looks like or means: bump the cell's version
|
|
589
643
|
|
|
590
644
|
```ts
|
|
591
645
|
// v1: { running, tasks } where tasks counted phases
|
|
592
646
|
// v2: tasks counts tasks, and usage is new
|
|
593
647
|
host.serve(StatusStore, ({ session }, _host, self) => {
|
|
594
|
-
const
|
|
648
|
+
const status = self.state("status", StatusV2, { running: false, tasks: 0, usage: emptyUsage() }, {
|
|
649
|
+
version: 2,
|
|
650
|
+
migrate: (old, from) => (from < 2 ? { running: old.running, tasks: 0, usage: emptyUsage() } : old),
|
|
651
|
+
});
|
|
595
652
|
return {
|
|
596
|
-
status
|
|
653
|
+
status,
|
|
597
654
|
report: async (s, ctx) => {
|
|
598
|
-
|
|
599
|
-
return null;
|
|
655
|
+
status.replace(ctx, s);
|
|
600
656
|
},
|
|
601
657
|
};
|
|
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
658
|
});
|
|
610
659
|
```
|
|
611
660
|
|
|
@@ -617,24 +666,26 @@ At the gate, for each live `StatusStore`:
|
|
|
617
666
|
|
|
618
667
|
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
668
|
|
|
620
|
-
The
|
|
669
|
+
The version belongs to the cell (default 1); other cells of the object are not migrated. The runtime compares each
|
|
670
|
+
cell's schema and version between old and new code:
|
|
621
671
|
- unchanged: compatible;
|
|
622
|
-
-
|
|
623
|
-
- changed **without** a
|
|
672
|
+
- version raised: migrate (without `migrate`, the value is kept and must fit the new schema);
|
|
673
|
+
- schema changed **without** a raised version, or a lowered version: the reload is refused.
|
|
624
674
|
|
|
625
675
|
A change of meaning with an identical schema needs a bump too. Nothing can detect that one for you.
|
|
626
676
|
|
|
627
677
|
### Resources
|
|
628
678
|
|
|
629
679
|
```ts
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
port: resource({ rev: 1, exclusive: true, open: () => listen(8787), close: (s) => s.close() }),
|
|
633
|
-
});
|
|
680
|
+
const db = self.resource("db", { version: 2, open: () => openSqlite(path, { wal: true }), close: (db) => db.close() }); // 1 → 2: reopen
|
|
681
|
+
const port = self.resource("port", { version: 1, exclusive: true, open: () => listen(8787), close: (s) => s.close() });
|
|
634
682
|
```
|
|
635
683
|
|
|
636
|
-
- A reload
|
|
637
|
-
|
|
684
|
+
- A reload keeps a resource's open value unless its `version`, `deps` or `exclusive` change: `open` doesn't run
|
|
685
|
+
again. The version is required: a native value has no schema to detect a change. The kept resource still takes the
|
|
686
|
+
new code's declaration at the commit, so a later `close` is the new one.
|
|
687
|
+
- A changed resource opens next to the old one during prepare. The old one is closed with its own `close` once the
|
|
688
|
+
old code retires.
|
|
638
689
|
- 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
690
|
|
|
640
691
|
### Contract changes
|
|
@@ -646,10 +697,10 @@ const res = self.resources({
|
|
|
646
697
|
| Breaking, without an adapter | refused, unless the consumers reload in the same batch |
|
|
647
698
|
| Breaking, version not bumped | refused: "consumer needs member …" |
|
|
648
699
|
|
|
649
|
-
The host's own **
|
|
650
|
-
its provider, that
|
|
651
|
-
The
|
|
652
|
-
|
|
700
|
+
The host's own **reference to a `lifetime: "host"` object** is a lifecycle pin, not an old-version consumer. When a reload replaces
|
|
701
|
+
its provider, that reference adopts the new contract at commit; it does not require an `accepts` adapter for itself.
|
|
702
|
+
The object, cells and unchanged resources stay alive without reactivation. A refused reload keeps the original
|
|
703
|
+
reference and contract. Other references (`self.get`, `ctx.get`, `host.get`, peers) still obey the compatibility rules above;
|
|
653
704
|
state-version bumps and migrations are still required when stored state changes.
|
|
654
705
|
|
|
655
706
|
### Several plugins at once
|
|
@@ -662,46 +713,59 @@ On the server, `host.reload(serverFacets, { remote: { "session:": workerFacets }
|
|
|
662
713
|
|
|
663
714
|
```ts
|
|
664
715
|
// server
|
|
665
|
-
const server = createHost({ name: "server", principalKey });
|
|
716
|
+
const server = createHost<Principal>({ name: "server", self: { kind: "host" }, principalKey });
|
|
666
717
|
server.accept(webSocketLink(wss, { principal: (req) => authenticate(req) })); // stamps owner or guest from cookies
|
|
667
718
|
server.route("session:", {
|
|
668
719
|
authorize: (principal, host) => canReach(principal, host), // before resolving or spawning
|
|
669
720
|
resolve: (host) => workers.ensure(host), // a link to the worker, spawned if needed
|
|
721
|
+
principal: (host) => workers.principal(host), // what the worker acts as when it calls the server
|
|
670
722
|
});
|
|
671
723
|
|
|
672
724
|
// worker
|
|
673
|
-
const worker = createHost({
|
|
725
|
+
const worker = createHost<Principal>({
|
|
726
|
+
name: `session:${id}`,
|
|
727
|
+
self: { kind: "host" },
|
|
728
|
+
principalKey,
|
|
729
|
+
upstream: { link: ipcLink(process), principal: { kind: "server" } }, // the server's own requests act as this
|
|
730
|
+
});
|
|
674
731
|
|
|
675
732
|
// anywhere
|
|
676
|
-
const chat = ctx.get(Chat,
|
|
677
|
-
const sessions = self.get(Sessions, {}
|
|
733
|
+
const chat = await ctx.get(Chat, { session: "S1", conversation: 1 }); // browser → server → worker session:S1
|
|
734
|
+
const sessions = self.get(Sessions, {}); // `hostNamed("server")`: worker → server, acting as worker{S1}
|
|
678
735
|
```
|
|
679
736
|
|
|
680
737
|
- **The server relays** between its children: worker to worker, and browser to worker.
|
|
681
|
-
- **Disconnects:** when a browser disconnects, exactly the
|
|
738
|
+
- **Disconnects:** when a browser disconnects, exactly the references it had in workers are released.
|
|
682
739
|
- **Principals across links:**
|
|
683
740
|
- a forwarded principal is accepted only from the parent link;
|
|
684
741
|
- calls an object makes itself act as its host;
|
|
685
742
|
- links ping, and a dead link releases everything held through it.
|
|
743
|
+
- **Client origins:** a hold that comes from a browser carries an opaque id of its connection (`origin`), forwarded by the server when it relays the hold, so a worker can tell its clients apart. A client cannot set it. A disconnect releases that client's holds at once.
|
|
744
|
+
- **Slow peers:** a peer acknowledges what it processed. While a window (`window`, default 256 KiB) of frames is unacknowledged, state waits and is coalesced per member's stream; calls, results and the first snapshots of a new reference are never held back, so a `get` does not wait behind other references' state. Waiting ops are capped per link (`pendingCap`, default four windows): beyond, the oldest streams are replaced by their current value. So under load a result can arrive before the state change it caused: wait for the state you need instead of assuming it is there when the call returns.
|
|
686
745
|
- **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
|
|
746
|
+
- **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`.
|
|
747
|
+
- **Protocol version:** both ends announce the link protocol version in their hello. A peer of another version is not
|
|
748
|
+
talked to: the link closes, and `onProtocolMismatch` (a `LinkOptions` field of `connect` and `accept`) tells the
|
|
749
|
+
side that has to load new code. A browser reloads its page instead of reconnecting:
|
|
750
|
+
|
|
751
|
+
```ts
|
|
752
|
+
host.connect(webSocketLink(socket), { onProtocolMismatch: () => location.reload() });
|
|
753
|
+
```
|
|
688
754
|
- **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
755
|
|
|
690
756
|
## Durable
|
|
691
757
|
|
|
692
|
-
A worker's host provides the Durable Harness as
|
|
758
|
+
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.
|
|
693
759
|
|
|
694
760
|
```ts
|
|
695
761
|
host.serve(Harness, (_, _host, self) => {
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
close: (h) => h.close(),
|
|
701
|
-
}),
|
|
762
|
+
const durable = self.resource("durable", {
|
|
763
|
+
version: 1,
|
|
764
|
+
open: () => DurableHarness.open(storage, { models, registry: durableRegistry(host, Extensions) }), // a reader over the code version tables
|
|
765
|
+
close: (h) => h.close(),
|
|
702
766
|
});
|
|
703
|
-
return { conversation: (id: number) =>
|
|
704
|
-
}, {
|
|
767
|
+
return { conversation: (id: number) => durable.value.conversation(id) };
|
|
768
|
+
}, { lifetime: "host" });
|
|
705
769
|
|
|
706
770
|
// a plugin contributes a Durable extension like any registration
|
|
707
771
|
host.serve(Guard, (_, _host, self) => {
|
|
@@ -712,7 +776,7 @@ host.serve(Guard, (_, _host, self) => {
|
|
|
712
776
|
}),
|
|
713
777
|
});
|
|
714
778
|
return {};
|
|
715
|
-
}, {
|
|
779
|
+
}, { lifetime: "host" });
|
|
716
780
|
```
|
|
717
781
|
|
|
718
782
|
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.
|
|
@@ -723,15 +787,15 @@ A reload changing the guard: Durable phases already running keep the old snapsho
|
|
|
723
787
|
| --- | --- |
|
|
724
788
|
| `forbidden` | The factory or a method refused this principal |
|
|
725
789
|
| `invalid` | Input, arguments or a result failed validation |
|
|
726
|
-
| `not_ready` | A declared resource or
|
|
727
|
-
| `not_in_variant` | The member doesn't exist in the variant you
|
|
728
|
-
| `released` | The
|
|
729
|
-
| `gone` | The object was forced away while
|
|
790
|
+
| `not_ready` | A declared resource or reference was used inside a factory, or state read before its reference's first hydration |
|
|
791
|
+
| `not_in_variant` | The member doesn't exist in the variant you have |
|
|
792
|
+
| `released` | The reference was released |
|
|
793
|
+
| `gone` | The object was forced away while referenced. Call `get` again. |
|
|
730
794
|
| `contract_mismatch` | The provider's contract doesn't cover the consumer's, and no adapter exists |
|
|
731
795
|
| `upgrade_conflict` | An old root reached an object migrated meanwhile. Retry as a new root. |
|
|
732
|
-
| `superseded` | The root's
|
|
796
|
+
| `superseded` | The root's code version is no longer available on this host. Retry. |
|
|
733
797
|
| `upgrade_required` | A browser's contract is outdated. Reload its web facets. |
|
|
734
|
-
| `unavailable` | The target host can't be reached or started |
|
|
798
|
+
| `unavailable` | The target host can't be reached or started; reason `ready_timeout`: a reference was not ready within `readyTimeout` |
|
|
735
799
|
| `aborted` | The call's `ctx` was cancelled |
|
|
736
800
|
|
|
737
801
|
## Rules of thumb
|
|
@@ -739,11 +803,11 @@ A reload changing the guard: Durable phases already running keep the old snapsho
|
|
|
739
803
|
| Do | Not |
|
|
740
804
|
| --- | --- |
|
|
741
805
|
| Keep reloadable state in cells | Closure variables (lost on reload) |
|
|
742
|
-
| Bump the
|
|
806
|
+
| Bump the cell's version, with `migrate`, when a cell's shape or meaning changes | Changing a schema without a bump (refused) or a meaning without a bump (silent) |
|
|
743
807
|
| Bump the contract version on breaking changes; add `accepts` for old consumers | Renaming or removing members at the same version (refused) |
|
|
744
808
|
| Do slow setup in a resource's `open` | Awaiting inside the factory (it's synchronous) |
|
|
745
809
|
| 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
|
|
810
|
+
| Use `ctx.get` inside methods | `self.get` inside methods (a new reference per call) |
|
|
747
811
|
| Keep roots short; long waits are state | Awaiting a human inside a method |
|
|
748
812
|
| Check arguments in methods | Treating the variant as all of authorization |
|
|
749
813
|
| Pass `ctx` everywhere | Starting background work with the caller's `ctx` (use a task) |
|