@earendil-works/chord 1.0.4 → 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.
Files changed (186) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +77 -194
  3. package/dist/cell.d.ts +101 -0
  4. package/dist/cell.d.ts.map +1 -0
  5. package/dist/cell.js +316 -0
  6. package/dist/cell.js.map +1 -0
  7. package/dist/cluster.d.ts +26 -0
  8. package/dist/cluster.d.ts.map +1 -0
  9. package/dist/cluster.js +214 -0
  10. package/dist/cluster.js.map +1 -0
  11. package/dist/connection.d.ts +157 -0
  12. package/dist/connection.d.ts.map +1 -0
  13. package/dist/connection.js +757 -0
  14. package/dist/connection.js.map +1 -0
  15. package/dist/context/index.d.ts +12 -1
  16. package/dist/context/index.d.ts.map +1 -1
  17. package/dist/context/index.js.map +1 -1
  18. package/dist/contract.d.ts +133 -0
  19. package/dist/contract.d.ts.map +1 -0
  20. package/dist/contract.js +157 -0
  21. package/dist/contract.js.map +1 -0
  22. package/dist/declare.d.ts +70 -0
  23. package/dist/declare.d.ts.map +1 -0
  24. package/dist/declare.js +22 -0
  25. package/dist/declare.js.map +1 -0
  26. package/dist/delta/apply-immutable-trusted.js.map +1 -1
  27. package/dist/delta/diff.d.ts +1 -1
  28. package/dist/delta/diff.d.ts.map +1 -1
  29. package/dist/delta/diff.js.map +1 -1
  30. package/dist/delta/index.d.ts +2 -2
  31. package/dist/delta/index.d.ts.map +1 -1
  32. package/dist/delta/index.js +41 -0
  33. package/dist/delta/index.js.map +1 -1
  34. package/dist/delta/revision-validator.d.ts +1 -1
  35. package/dist/delta/revision-validator.d.ts.map +1 -1
  36. package/dist/delta/revision-validator.js.map +1 -1
  37. package/dist/delta/tracker.d.ts +1 -1
  38. package/dist/delta/tracker.d.ts.map +1 -1
  39. package/dist/delta/tracker.js +40 -4
  40. package/dist/delta/tracker.js.map +1 -1
  41. package/dist/errors.d.ts +27 -0
  42. package/dist/errors.d.ts.map +1 -0
  43. package/dist/errors.js +44 -0
  44. package/dist/errors.js.map +1 -0
  45. package/dist/host.d.ts +254 -0
  46. package/dist/host.d.ts.map +1 -0
  47. package/dist/host.js +795 -0
  48. package/dist/host.js.map +1 -0
  49. package/dist/index.d.ts +17 -10
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +8 -8
  52. package/dist/index.js.map +1 -1
  53. package/dist/json.d.ts +9 -1
  54. package/dist/json.d.ts.map +1 -1
  55. package/dist/json.js.map +1 -1
  56. package/dist/link.d.ts +72 -0
  57. package/dist/link.d.ts.map +1 -0
  58. package/dist/link.js +169 -0
  59. package/dist/link.js.map +1 -0
  60. package/dist/loader.d.ts +4 -0
  61. package/dist/loader.d.ts.map +1 -0
  62. package/dist/loader.js +68 -0
  63. package/dist/loader.js.map +1 -0
  64. package/dist/manager.d.ts +115 -0
  65. package/dist/manager.d.ts.map +1 -0
  66. package/dist/manager.js +319 -0
  67. package/dist/manager.js.map +1 -0
  68. package/dist/node.d.ts +3 -4
  69. package/dist/node.d.ts.map +1 -1
  70. package/dist/node.js +3 -2
  71. package/dist/node.js.map +1 -1
  72. package/dist/publish.d.ts +5 -0
  73. package/dist/publish.d.ts.map +1 -0
  74. package/dist/publish.js +40 -0
  75. package/dist/publish.js.map +1 -0
  76. package/dist/record.d.ts +320 -0
  77. package/dist/record.d.ts.map +1 -0
  78. package/dist/record.js +1292 -0
  79. package/dist/record.js.map +1 -0
  80. package/dist/registry.d.ts +25 -0
  81. package/dist/registry.d.ts.map +1 -0
  82. package/dist/registry.js +6 -0
  83. package/dist/registry.js.map +1 -0
  84. package/dist/reload.d.ts +118 -0
  85. package/dist/reload.d.ts.map +1 -0
  86. package/dist/reload.js +589 -0
  87. package/dist/reload.js.map +1 -0
  88. package/dist/remote.d.ts +70 -0
  89. package/dist/remote.d.ts.map +1 -0
  90. package/dist/remote.js +308 -0
  91. package/dist/remote.js.map +1 -0
  92. package/dist/schema.d.ts +21 -0
  93. package/dist/schema.d.ts.map +1 -0
  94. package/dist/schema.js +228 -0
  95. package/dist/schema.js.map +1 -0
  96. package/dist/state.d.ts +92 -0
  97. package/dist/state.d.ts.map +1 -0
  98. package/dist/{services/state.js → state.js} +28 -138
  99. package/dist/state.js.map +1 -0
  100. package/dist/types.d.ts +62 -262
  101. package/dist/types.d.ts.map +1 -1
  102. package/dist/types.js.map +1 -1
  103. package/dist/websocket.d.ts +16 -0
  104. package/dist/websocket.d.ts.map +1 -0
  105. package/dist/websocket.js +15 -0
  106. package/dist/websocket.js.map +1 -0
  107. package/dist/wire.d.ts +135 -0
  108. package/dist/wire.d.ts.map +1 -0
  109. package/dist/wire.js +112 -0
  110. package/dist/wire.js.map +1 -0
  111. package/docs/guide.md +750 -0
  112. package/docs/spec.md +261 -0
  113. package/docs/tutorial.md +909 -0
  114. package/package.json +10 -11
  115. package/src/delta/README.md +5 -3
  116. package/dist/api.d.ts +0 -20
  117. package/dist/api.d.ts.map +0 -1
  118. package/dist/api.js +0 -77
  119. package/dist/api.js.map +0 -1
  120. package/dist/bundler.d.ts +0 -6
  121. package/dist/bundler.d.ts.map +0 -1
  122. package/dist/bundler.js +0 -3
  123. package/dist/bundler.js.map +0 -1
  124. package/dist/facets/host.d.ts +0 -12
  125. package/dist/facets/host.d.ts.map +0 -1
  126. package/dist/facets/host.js +0 -763
  127. package/dist/facets/host.js.map +0 -1
  128. package/dist/facets/loader.d.ts +0 -3
  129. package/dist/facets/loader.d.ts.map +0 -1
  130. package/dist/facets/loader.js +0 -5
  131. package/dist/facets/loader.js.map +0 -1
  132. package/dist/node/bundle-loader.d.ts +0 -30
  133. package/dist/node/bundle-loader.d.ts.map +0 -1
  134. package/dist/node/bundle-loader.js +0 -377
  135. package/dist/node/bundle-loader.js.map +0 -1
  136. package/dist/node/bundle.d.ts +0 -26
  137. package/dist/node/bundle.d.ts.map +0 -1
  138. package/dist/node/bundle.js +0 -183
  139. package/dist/node/bundle.js.map +0 -1
  140. package/dist/node/manifest.d.ts +0 -36
  141. package/dist/node/manifest.d.ts.map +0 -1
  142. package/dist/node/manifest.js +0 -6
  143. package/dist/node/manifest.js.map +0 -1
  144. package/dist/node/package.d.ts +0 -15
  145. package/dist/node/package.d.ts.map +0 -1
  146. package/dist/node/package.js +0 -199
  147. package/dist/node/package.js.map +0 -1
  148. package/dist/services/consumer.d.ts +0 -11
  149. package/dist/services/consumer.d.ts.map +0 -1
  150. package/dist/services/consumer.js +0 -585
  151. package/dist/services/consumer.js.map +0 -1
  152. package/dist/services/errors.d.ts +0 -8
  153. package/dist/services/errors.d.ts.map +0 -1
  154. package/dist/services/errors.js +0 -22
  155. package/dist/services/errors.js.map +0 -1
  156. package/dist/services/handle.d.ts +0 -15
  157. package/dist/services/handle.d.ts.map +0 -1
  158. package/dist/services/handle.js +0 -101
  159. package/dist/services/handle.js.map +0 -1
  160. package/dist/services/instances.d.ts +0 -26
  161. package/dist/services/instances.d.ts.map +0 -1
  162. package/dist/services/instances.js +0 -140
  163. package/dist/services/instances.js.map +0 -1
  164. package/dist/services/loopback.d.ts +0 -5
  165. package/dist/services/loopback.d.ts.map +0 -1
  166. package/dist/services/loopback.js +0 -15
  167. package/dist/services/loopback.js.map +0 -1
  168. package/dist/services/provider.d.ts +0 -37
  169. package/dist/services/provider.d.ts.map +0 -1
  170. package/dist/services/provider.js +0 -521
  171. package/dist/services/provider.js.map +0 -1
  172. package/dist/services/state-codec.d.ts +0 -15
  173. package/dist/services/state-codec.d.ts.map +0 -1
  174. package/dist/services/state-codec.js +0 -133
  175. package/dist/services/state-codec.js.map +0 -1
  176. package/dist/services/state-internals.d.ts +0 -13
  177. package/dist/services/state-internals.d.ts.map +0 -1
  178. package/dist/services/state-internals.js +0 -10
  179. package/dist/services/state-internals.js.map +0 -1
  180. package/dist/services/state.d.ts +0 -28
  181. package/dist/services/state.d.ts.map +0 -1
  182. package/dist/services/state.js.map +0 -1
  183. package/dist/services/wire.d.ts +0 -63
  184. package/dist/services/wire.d.ts.map +0 -1
  185. package/dist/services/wire.js +0 -186
  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 |