@earendil-works/chord 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +9 -10
  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/dist/host.js ADDED
@@ -0,0 +1,795 @@
1
+ import { ChildReloads, reconcileChild, runClusterReload } from "./cluster.js";
2
+ import { Connection } from "./connection.js";
3
+ import { bind, } from "./contract.js";
4
+ import { ChordError, invalid, runAll } from "./errors.js";
5
+ import { enqueue } from "./publish.js";
6
+ import { CtxImpl, HandleImpl, makeService, RecordImpl, } from "./record.js";
7
+ import { runReload } from "./reload.js";
8
+ import { RemoteHandle } from "./remote.js";
9
+ import { canonicalIdentity, canonicalJson, deepEqual, normalize } from "./schema.js";
10
+ /** The principal of in-host code. */
11
+ export const hostPrincipal = Object.freeze({ host: true });
12
+ export const defineFacet = (setup, options = {}) => ({ setup, uses: options.uses });
13
+ const isFacet = (ref) => typeof ref === "object" && !(ref instanceof URL);
14
+ const EMPTY_TABLE = new Map();
15
+ export function createHost(options = {}) {
16
+ return new HostImpl(options);
17
+ }
18
+ export class HostImpl {
19
+ name;
20
+ dev;
21
+ principal = hostPrincipal;
22
+ inFactory = 0;
23
+ failed;
24
+ records = new Map();
25
+ epochs = new Map();
26
+ facets = new Map();
27
+ eager = new Map();
28
+ kind;
29
+ connections = new Set();
30
+ upstream;
31
+ /** Remote handles this host holds (re-held after an upstream reconnect). */
32
+ remotes = new Set();
33
+ #routes = new Map();
34
+ #childReloads = new ChildReloads(this);
35
+ /** Child facet modules by host-name prefix, from every committed cluster reload (for reconciling children). */
36
+ remoteModules = {};
37
+ /** Removal tombstones also reconcile children that connected during a transaction. */
38
+ remoteRemoved = {};
39
+ /** Highest epoch the parent has retired. */
40
+ #retireAllowed = 0;
41
+ /** Roots that came over a link, by the link they came from. */
42
+ /** Foreign roots (started on another host), by the links they came over. */
43
+ #foreign = new Map();
44
+ /** Roots started on this host that have not ended. */
45
+ #local = new Set();
46
+ #incarnation = Math.random().toString(36).slice(2, 8);
47
+ /** Links each root's calls went out on. */
48
+ #rootLinks = new Map();
49
+ current;
50
+ previous;
51
+ /** The epoch a running reload will commit; new types activated during prepare install their behavior for it. */
52
+ pending;
53
+ /** Activations of these types wait (reload gate). */
54
+ /** Types behind a reload gate: new records of these types are rejected `upgrade_conflict` until commit/abort. */
55
+ gating;
56
+ reloading = false;
57
+ retired = () => { };
58
+ #principalKey;
59
+ #onError;
60
+ #loader;
61
+ #dying = new Map();
62
+ #touches = new Map();
63
+ #order = new Map();
64
+ #subscribers = new Map();
65
+ #epochSeq = 1;
66
+ #rootSeq = 0;
67
+ constructor(options) {
68
+ this.name = options.name ?? "host";
69
+ this.dev = options.dev ?? false;
70
+ this.#principalKey = options.principalKey ?? canonicalJson;
71
+ this.#onError = options.onError ?? ((error) => console.error("chord:", error));
72
+ this.#loader = options.loader;
73
+ this.current = { id: this.#epochSeq, services: new Map(), tables: new Map(), roots: new Map() };
74
+ this.epochs.set(this.current.id, this.current);
75
+ this.kind = options.kind ?? "host";
76
+ if (options.upstream !== undefined)
77
+ this.connect(options.upstream, options.upstreamOptions);
78
+ }
79
+ get epoch() {
80
+ return this.current.id;
81
+ }
82
+ get previousEpoch() {
83
+ return this.previous?.id;
84
+ }
85
+ /** Allocates the next epoch id, or adopts one issued by the coordinating server. */
86
+ nextEpochId(given) {
87
+ if (given !== undefined && given <= this.current.id)
88
+ throw new Error(`epoch ${given} is not newer than ${this.current.id}`);
89
+ this.#epochSeq = given ?? this.#epochSeq + 1;
90
+ return this.#epochSeq;
91
+ }
92
+ report(error) {
93
+ this.#onError(error);
94
+ }
95
+ // ─── serving ───
96
+ serve = ((type, factory, options = {}) => {
97
+ if (this.reloading)
98
+ throw new Error("host.serve during a reload; serve from a facet passed to reload()");
99
+ this.serveInto(this.current.services, makeService(type, factory, options, ""));
100
+ if (options.eager)
101
+ this.startEager(this.current.services.get(type.name));
102
+ });
103
+ install(facet, options = {}) {
104
+ if (this.reloading)
105
+ throw new Error("host.install during a reload");
106
+ if (this.facets.has(facet.name))
107
+ throw new Error(`facet ${facet.name} is already installed; use reload()`);
108
+ const added = [];
109
+ facet.setup(this.facetApi(((type, factory, options = {}) => {
110
+ const service = makeService(type, factory, options, facet.name);
111
+ this.serveInto(this.current.services, service);
112
+ added.push(service);
113
+ })));
114
+ this.facets.set(facet.name, facet);
115
+ const holds = added.filter((service) => service.options.eager).map((service) => this.startEager(service));
116
+ return options.awaitEager === false ? Promise.resolve() : this.#eagerReady(holds);
117
+ }
118
+ async load(plugins, options = {}) {
119
+ const facets = await this.resolve(plugins);
120
+ // No await between installs: an eager factory may consume a type served by a later facet.
121
+ await Promise.all(facets.map((facet) => this.install(facet, options)));
122
+ }
123
+ ready() {
124
+ return this.#eagerReady(this.eager.values());
125
+ }
126
+ #eagerReady(holds) {
127
+ const ready = Promise.all([...holds].map((hold) => hold.ready)).then(() => { });
128
+ // Ignoring install's return still reports through onError without an unhandled rejection.
129
+ ready.catch(() => { });
130
+ return ready;
131
+ }
132
+ /** Turns plugin references into facets, importing modules through the loader. */
133
+ async resolve(plugins) {
134
+ const modules = plugins.filter((ref) => !isFacet(ref));
135
+ if (modules.length > 0 && this.#loader === undefined) {
136
+ throw new Error("loading modules needs createHost({ loader }), e.g. nodeLoader from @earendil-works/chord/node");
137
+ }
138
+ const loaded = modules.length > 0 && this.#loader !== undefined ? await this.#loader(modules) : [];
139
+ return plugins.map((ref) => (isFacet(ref) ? ref : loaded.shift()));
140
+ }
141
+ facetApi(serve) {
142
+ return {
143
+ serve,
144
+ name: this.name,
145
+ root: (fn, options) => this.root(fn, options),
146
+ registry: (registry) => this.registry(registry),
147
+ };
148
+ }
149
+ serveInto(services, service) {
150
+ if (services.has(service.contract.name))
151
+ throw new Error(`${service.contract.name} is already served`);
152
+ services.set(service.contract.name, service);
153
+ }
154
+ startEager(service) {
155
+ const eager = service.options.eager;
156
+ const input = typeof eager === "object" ? eager.input : {};
157
+ const handle = this.acquire(service.contract, input, undefined, "host");
158
+ this.eager.set(service.contract.name, handle);
159
+ handle.ready.catch((error) => this.report(error));
160
+ return handle;
161
+ }
162
+ // ─── HostCore ───
163
+ currentEpoch() {
164
+ return this.current.id;
165
+ }
166
+ hasEpoch(epoch) {
167
+ return this.epochs.has(epoch);
168
+ }
169
+ *retainedEpochs() {
170
+ for (const [id, state] of this.epochs)
171
+ yield [id, state.services];
172
+ if (this.pending !== undefined)
173
+ yield [this.pending.id, this.pending.services];
174
+ }
175
+ pendingEpoch() {
176
+ return this.pending?.id;
177
+ }
178
+ /** The current code for a type; a type a running reload adds is served from the pending epoch. */
179
+ service(typeName) {
180
+ return this.current.services.get(typeName) ?? this.pending?.services.get(typeName);
181
+ }
182
+ addressOf(type, input, principal, at, candidate = false) {
183
+ if (at !== undefined && at !== this.name)
184
+ return remoteAddress(at, type, input, principal === this.principal ? undefined : principal);
185
+ const service = this.#bindingService(type.name, candidate);
186
+ if (service === undefined)
187
+ throw unknownType(type.name, this.name);
188
+ const normalized = normalize(service.contract.input, input, `${type.name} input`);
189
+ return this.#address(service, normalized, principal);
190
+ }
191
+ #address(service, normalized, principal) {
192
+ const key = principal === this.principal ? "host" : `p:${this.#principalKey(principal)}`;
193
+ return `${service.contract.name}\u0000${canonicalIdentity(service.contract.input, normalized)}\u0000${key}`;
194
+ }
195
+ acquire(type, input, options, owner, from, candidate = false) {
196
+ if (options?.at !== undefined && options.at !== this.name)
197
+ return this.#acquireRemote(type, input, options, owner);
198
+ const service = this.#bindingService(type.name, candidate);
199
+ if (service === undefined)
200
+ throw unknownType(type.name, this.name);
201
+ bind(service.contract, service.options.accepts, type);
202
+ const principal = options?.principal ?? this.principal;
203
+ const normalized = normalize(service.contract.input, input, `${type.name} input`);
204
+ const address = this.#address(service, normalized, principal);
205
+ let record = this.records.get(address);
206
+ if (from !== undefined && record !== undefined && (record === from || reaches(record, from))) {
207
+ throw invalid("cycle", `${from.address} -> ${address} would create a hold cycle`);
208
+ }
209
+ if (record === undefined) {
210
+ if (this.gating?.has(type.name)) {
211
+ throw new ChordError("upgrade_conflict", `${type.name} is being upgraded; retry`, { reason: "gate" });
212
+ }
213
+ const dying = this.#dying.get(address);
214
+ if (dying?.status === "disposed")
215
+ this.#dying.delete(address);
216
+ record = new RecordImpl(this, {
217
+ address,
218
+ typeName: type.name,
219
+ input: normalized,
220
+ principal,
221
+ eager: service.options.eager !== undefined && service.options.eager !== false && principal === this.principal,
222
+ });
223
+ this.records.set(address, record);
224
+ // A new record for an address still being disposed waits for that cleanup (exclusive resources).
225
+ record.start(dying?.status === "disposing" ? dying.dispose(false) : undefined);
226
+ }
227
+ const handle = new HandleImpl(record, type, owner);
228
+ record.addHold(handle);
229
+ return handle;
230
+ }
231
+ forget(record) {
232
+ if (this.records.get(record.address) === record)
233
+ this.records.delete(record.address);
234
+ if (record.status === "disposing")
235
+ this.#dying.set(record.address, record);
236
+ }
237
+ touch(rootId, record) {
238
+ let touched = this.#touches.get(rootId);
239
+ if (touched === undefined) {
240
+ touched = new Set();
241
+ this.#touches.set(rootId, touched);
242
+ }
243
+ touched.add(record);
244
+ }
245
+ // ─── roots ───
246
+ async root(fn, options = {}) {
247
+ // The incarnation keeps root ids unique across restarts of a host with the same name.
248
+ const rootId = `${this.name}#${this.#incarnation}:${++this.#rootSeq}`;
249
+ this.#local.add(rootId);
250
+ return this.joinRoot(rootId, options.epoch ?? this.current.id, fn, options);
251
+ }
252
+ /**
253
+ * Runs `fn` inside root `rootId` of `epoch`. A root counts as running here (for retirement) while any of its calls
254
+ * runs here. A root that started here ends when its last call here settles, even if it went out and came back
255
+ * over a link meanwhile. A foreign root (started elsewhere, arriving `from` a link) keeps its leases on records
256
+ * until every link it came over announced its end (`root_end`) or closed, so its re-entry stays grandfathered.
257
+ */
258
+ async joinRoot(rootId, epochId, fn, options = {}) {
259
+ const epoch = this.epochs.get(epochId);
260
+ if (epoch === undefined) {
261
+ this.#local.delete(rootId);
262
+ throw new ChordError("superseded", `epoch ${epochId} is not retained by ${this.name}`);
263
+ }
264
+ if (options.from !== undefined && !this.#local.has(rootId)) {
265
+ let links = this.#foreign.get(rootId);
266
+ if (links === undefined) {
267
+ links = new Set();
268
+ this.#foreign.set(rootId, links);
269
+ }
270
+ links.add(options.from);
271
+ }
272
+ epoch.roots.set(rootId, (epoch.roots.get(rootId) ?? 0) + 1);
273
+ const ctx = new CtxImpl(this, {
274
+ signal: options.signal ?? new AbortController().signal,
275
+ rootId,
276
+ epoch: epoch.id,
277
+ principal: options.principal ?? this.principal,
278
+ });
279
+ try {
280
+ return await fn(ctx);
281
+ }
282
+ finally {
283
+ ctx.close();
284
+ const count = (epoch.roots.get(rootId) ?? 1) - 1;
285
+ if (count > 0)
286
+ epoch.roots.set(rootId, count);
287
+ else {
288
+ epoch.roots.delete(rootId);
289
+ if (!this.#foreign.has(rootId))
290
+ this.endRoot(rootId);
291
+ this.maybeRetire();
292
+ }
293
+ }
294
+ }
295
+ /** Remembers that calls of `rootId` went out on `connection`, which must hear when the root ends. */
296
+ rootSent(rootId, connection) {
297
+ let links = this.#rootLinks.get(rootId);
298
+ if (links === undefined) {
299
+ links = new Set();
300
+ this.#rootLinks.set(rootId, links);
301
+ }
302
+ links.add(connection);
303
+ }
304
+ /** A root ended (here, or announced by its origin): end its leases and tell the links it went out on. */
305
+ endRoot(rootId) {
306
+ this.#local.delete(rootId);
307
+ this.#foreign.delete(rootId);
308
+ for (const record of this.#touches.get(rootId) ?? [])
309
+ record.rootEnded(rootId);
310
+ this.#touches.delete(rootId);
311
+ for (const connection of this.#rootLinks.get(rootId) ?? [])
312
+ connection.send({ t: "root_end", rootId });
313
+ this.#rootLinks.delete(rootId);
314
+ }
315
+ /**
316
+ * `from` announced the end of a foreign root (or closed). Honored only if the root came over `from`: a peer cannot
317
+ * end leases of roots it never carried. The root ends when no link that brought it remains.
318
+ */
319
+ foreignRootEnded(rootId, from) {
320
+ const links = this.#foreign.get(rootId);
321
+ if (links === undefined || !links.delete(from) || links.size > 0)
322
+ return;
323
+ const running = [...this.epochs.values()].some((epoch) => epoch.roots.has(rootId));
324
+ // Still running here (a call in flight): the lease ends when it settles.
325
+ if (running)
326
+ this.#foreign.delete(rootId);
327
+ else
328
+ this.endRoot(rootId);
329
+ }
330
+ /**
331
+ * Retires the previous epoch once no local roots of it remain. A child also waits for its parent to retire it:
332
+ * roots span hosts, and an old root on the server may still call in.
333
+ */
334
+ maybeRetire() {
335
+ const previous = this.previous;
336
+ if (previous === undefined || previous.roots.size > 0)
337
+ return;
338
+ if (this.kind !== "client" && this.upstream !== undefined && previous.id > this.#retireAllowed)
339
+ return;
340
+ this.retire();
341
+ }
342
+ allowRetire(epoch) {
343
+ this.#retireAllowed = Math.max(this.#retireAllowed, epoch);
344
+ this.maybeRetire();
345
+ }
346
+ /** Drops the previous epoch: its behaviors and the declarations only it used. */
347
+ retire() {
348
+ const previous = this.previous;
349
+ if (previous === undefined)
350
+ return;
351
+ this.previous = undefined;
352
+ this.epochs.delete(previous.id);
353
+ for (const record of [...this.records.values()])
354
+ this.retireBehavior(record, previous.id);
355
+ for (const connection of this.connections) {
356
+ if (connection.role === "downstream" && connection.peer?.kind === "host")
357
+ connection.send({ t: "retire", epoch: previous.id });
358
+ }
359
+ this.retired();
360
+ }
361
+ /**
362
+ * Drops a record's behavior for `epoch` and what only that behavior declared, in disposal order: resources close
363
+ * (reverse open order) before its holds are released.
364
+ */
365
+ retireBehavior(record, epoch) {
366
+ const installed = record.behaviors.get(epoch);
367
+ record.behaviors.delete(epoch);
368
+ if (installed === undefined || installed.decls === record.decls)
369
+ return;
370
+ const kept = record.allDecls();
371
+ const keep = (pick) => new Set([...kept].flatMap((decls) => [...pick(decls)]));
372
+ const holds = keep((decls) => decls.holds.values());
373
+ const resources = keep((decls) => decls.resources.values());
374
+ const derives = keep((decls) => decls.derives.values());
375
+ for (const derived of installed.decls.derives.values())
376
+ if (!derives.has(derived))
377
+ derived.detach();
378
+ const closing = [...installed.decls.resources.values()]
379
+ .filter((resource) => !resources.has(resource))
380
+ .sort((a, b) => record.openOrder.indexOf(b) - record.openOrder.indexOf(a));
381
+ const releasing = [...installed.decls.holds.values()].filter((hold) => !holds.has(hold));
382
+ record.refreshSchemas();
383
+ runAll([
384
+ ...closing.map((resource) => () => resource.close(record.openOrder)),
385
+ ...releasing.map((hold) => () => hold.release()),
386
+ ], `${record.address}: retiring epoch ${epoch} failed`).catch((error) => this.report(error));
387
+ }
388
+ // ─── registries ───
389
+ registry(registry) {
390
+ const table = (ctx) => {
391
+ const epoch = ctx === undefined ? this.current : this.epochs.get(ctx.epoch);
392
+ if (epoch === undefined)
393
+ throw new ChordError("superseded", `epoch ${ctx?.epoch} is retired`);
394
+ return (epoch.tables.get(registry.name) ?? EMPTY_TABLE);
395
+ };
396
+ return {
397
+ table,
398
+ get: (key, ctx) => table(ctx).get(key),
399
+ subscribe: (listener) => {
400
+ let set = this.#subscribers.get(registry.name);
401
+ if (set === undefined) {
402
+ set = new Set();
403
+ this.#subscribers.set(registry.name, set);
404
+ }
405
+ const wrapped = listener;
406
+ set.add(wrapped);
407
+ wrapped(table(), this.current.id);
408
+ return () => set.delete(wrapped);
409
+ },
410
+ };
411
+ }
412
+ /** Builds registry tables from (record, declarations) pairs. Throws registry_conflict. */
413
+ buildTables(entries) {
414
+ const byRegistry = new Map();
415
+ for (const [record, decls] of entries) {
416
+ for (const { registry, key, value } of decls.registrations.values()) {
417
+ let entries = byRegistry.get(registry.name);
418
+ if (entries === undefined) {
419
+ entries = new Map();
420
+ byRegistry.set(registry.name, entries);
421
+ }
422
+ const existing = entries.get(key);
423
+ if (existing !== undefined && existing.record !== record && registry.conflict === "error") {
424
+ throw invalid("registry_conflict", `${registry.name}: ${key} registered by ${existing.record.address} and ${record.address}`);
425
+ }
426
+ entries.set(key, { value, record });
427
+ let order = this.#order.get(registry.name);
428
+ if (order === undefined) {
429
+ order = new Map();
430
+ this.#order.set(registry.name, order);
431
+ }
432
+ if (!order.has(key))
433
+ order.set(key, order.size);
434
+ }
435
+ }
436
+ const tables = new Map();
437
+ for (const [name, entries] of byRegistry) {
438
+ const order = this.#order.get(name);
439
+ const sorted = [...entries].sort(([a], [b]) => order.get(a) - order.get(b));
440
+ tables.set(name, new Map(sorted.map(([key, entry]) => [key, entry.value])));
441
+ }
442
+ return tables;
443
+ }
444
+ /**
445
+ * The service a hold binds against: the current one, or for a prepare rerun (`candidate`) the running reload's
446
+ * pending one, so consumer and provider of a breaking change can reload in one batch.
447
+ */
448
+ #bindingService(typeName, candidate) {
449
+ return (candidate ? this.pending?.services.get(typeName) : undefined) ?? this.service(typeName);
450
+ }
451
+ /** Live records with their current declarations; records waiting for a reload's commit only with `candidates`. */
452
+ liveDecls(candidates = false) {
453
+ return [...this.records.values()]
454
+ .filter((record) => record.status === "live" && (candidates || !record.deferredRuntime))
455
+ .map((record) => [record, record.decls]);
456
+ }
457
+ checkRegistrations(record, decls) {
458
+ if (decls.registrations.size === 0)
459
+ return;
460
+ this.buildTables([...this.liveDecls().filter(([other]) => other !== record), [record, decls]]);
461
+ }
462
+ registriesChanged(including) {
463
+ const entries = this.liveDecls().filter(([record]) => record !== including);
464
+ if (including !== undefined)
465
+ entries.push([including, including.decls]);
466
+ this.installTables(this.current, this.buildTables(entries));
467
+ }
468
+ /** A commit failed midway: from now on this host admits no work (calls fail `unavailable/host_failed`). */
469
+ fail(error) {
470
+ this.failed ??= error;
471
+ this.report(error);
472
+ }
473
+ /** Installs tables for `epoch`; subscribers hear about changed tables of the current epoch (all tables when `all`). */
474
+ installTables(epoch, tables, all = false) {
475
+ const before = epoch.tables;
476
+ epoch.tables = tables;
477
+ if (epoch !== this.current)
478
+ return;
479
+ for (const [name, listeners] of this.#subscribers) {
480
+ const next = tables.get(name) ?? EMPTY_TABLE;
481
+ if (!all && sameTable(before.get(name) ?? EMPTY_TABLE, next))
482
+ continue;
483
+ const current = [...listeners];
484
+ enqueue(() => {
485
+ for (const listener of current) {
486
+ try {
487
+ listener(next, epoch.id);
488
+ }
489
+ catch (error) {
490
+ this.report(error);
491
+ }
492
+ }
493
+ });
494
+ }
495
+ }
496
+ // ─── public API ───
497
+ get(type, input, options) {
498
+ return this.acquire(type, input, options, "external").proxy;
499
+ }
500
+ async invalidatePrincipal(match) {
501
+ const matches = typeof match === "function" ? match : (principal) => matchesPattern(match, principal);
502
+ const reason = new ChordError("gone", "principal invalidated");
503
+ // Links acting as a matching principal are re-stamped (if the acceptor can) or closed.
504
+ for (const connection of [...this.connections]) {
505
+ if (connection.role !== "downstream" || !matches(connection.principal))
506
+ continue;
507
+ // Relays under the old principal close at once, including those still resolving their route.
508
+ connection.revoke(connection.restamp?.(connection.principal));
509
+ }
510
+ // Patterns propagate to child hosts, which dispose their own matching records (e.g. relayed guests).
511
+ if (typeof match !== "function") {
512
+ for (const connection of this.connections) {
513
+ if (connection.role === "downstream" && connection.peer.kind === "host") {
514
+ connection.send({ t: "invalidate", pattern: match });
515
+ }
516
+ }
517
+ }
518
+ const matching = [...this.records.values()].filter((record) => record.principal !== this.principal && matches(record.principal));
519
+ await disposeHoldersFirst(matching, reason, "invalidatePrincipal: disposal failed");
520
+ }
521
+ // ─── links ───
522
+ accept(link, options) {
523
+ const kind = options.kind ?? "client";
524
+ if (kind === "host" && options.name === undefined)
525
+ throw new TypeError("accepting a host link needs its name");
526
+ this.#addConnection(link, {
527
+ ...options,
528
+ role: "downstream",
529
+ principal: options.principal,
530
+ peer: { kind, name: kind === "host" ? options.name : undefined },
531
+ });
532
+ }
533
+ connect(link, options = {}) {
534
+ if (this.upstream?.open)
535
+ this.upstream.link.close("replaced");
536
+ const connection = this.#addConnection(link, {
537
+ ...options,
538
+ role: "upstream",
539
+ principal: undefined,
540
+ peer: { kind: "host", name: undefined },
541
+ });
542
+ this.upstream = connection;
543
+ // Remote handles that lost their link re-hold over the new one; state subscribers get a reset.
544
+ for (const handle of this.remotes)
545
+ if (handle.binding === undefined)
546
+ connection.hold(handle);
547
+ }
548
+ #addConnection(link, config) {
549
+ const connection = new Connection(this, link, config);
550
+ this.connections.add(connection);
551
+ return connection;
552
+ }
553
+ route(prefix, route) {
554
+ this.#routes.set(prefix, route);
555
+ }
556
+ /**
557
+ * A child host announced its epoch. In step: usable. Behind: fenced (nothing is routed to it) and reconciled by a
558
+ * reload of its modules to this host's epoch, never by renumbering what it runs. Ahead: impossible, refused.
559
+ */
560
+ peerHello(connection, epoch) {
561
+ if (epoch === this.current.id) {
562
+ connection.markSynced(epoch);
563
+ return;
564
+ }
565
+ if (epoch > this.current.id) {
566
+ this.report(new ChordError("invalid", `${connection.name} runs epoch ${epoch}, ahead of ${this.current.id}`));
567
+ connection.link.close("epoch ahead of parent");
568
+ return;
569
+ }
570
+ connection.peerEpoch = epoch;
571
+ connection.fencedEpochs.add(epoch);
572
+ reconcileChild(this, connection).catch((error) => {
573
+ this.report(error);
574
+ connection.link.close("reconciliation failed");
575
+ });
576
+ }
577
+ connectionClosed(connection) {
578
+ this.connections.delete(connection);
579
+ // Foreign roots that came over this link will never announce their end over it.
580
+ for (const [rootId, links] of [...this.#foreign])
581
+ if (links.has(connection))
582
+ this.foreignRootEnded(rootId, connection);
583
+ for (const links of this.#rootLinks.values())
584
+ links.delete(connection);
585
+ if (this.upstream === connection) {
586
+ this.upstream = undefined;
587
+ this.#childReloads.parentLost();
588
+ }
589
+ }
590
+ forgetRemote(handle) {
591
+ this.remotes.delete(handle);
592
+ }
593
+ /** A downstream host connection by its configured name (never by what a peer announced). */
594
+ #childNamed(name) {
595
+ return [...this.connections].find((connection) => connection.role === "downstream" && connection.peer.kind === "host" && connection.peer.name === name);
596
+ }
597
+ #acquireRemote(type, input, options, owner) {
598
+ const at = options.at;
599
+ if (type.scope !== "remote")
600
+ throw invalid("local", `${type.name} is local and cannot be reached at ${at}`);
601
+ const principal = options.principal === undefined || options.principal === this.principal ? undefined : options.principal;
602
+ const handle = new RemoteHandle(this, {
603
+ contract: type,
604
+ at,
605
+ input,
606
+ principal,
607
+ owner,
608
+ address: remoteAddress(at, type, input, principal),
609
+ });
610
+ this.remotes.add(handle);
611
+ const bindWhenSynced = (connection) => {
612
+ connection.then((target) => {
613
+ if (handle.status === "loading" && handle.binding === undefined)
614
+ target.hold(handle);
615
+ }, (error) => handle.gone(error));
616
+ };
617
+ const direct = this.#childNamed(at);
618
+ if (this.upstream !== undefined)
619
+ this.upstream.hold(handle);
620
+ else if (direct !== undefined)
621
+ bindWhenSynced(direct.whenSynced().then(() => direct));
622
+ else {
623
+ const route = this.#routeFor(at);
624
+ if (route === undefined)
625
+ handle.gone(new ChordError("unavailable", `no route to ${at}`));
626
+ else
627
+ bindWhenSynced(this.#resolve(route, at, principal ?? this.principal));
628
+ }
629
+ return handle;
630
+ }
631
+ #routeFor(hostName) {
632
+ let best;
633
+ for (const entry of this.#routes) {
634
+ if (hostName.startsWith(entry[0]) && (best === undefined || entry[0].length > best[0].length))
635
+ best = entry;
636
+ }
637
+ return best?.[1];
638
+ }
639
+ /** Authorize, resolve (maybe spawn), authorize again; returns the synced connection to the host. */
640
+ async #resolve(route, hostName, principal, still = () => true) {
641
+ const refuse = () => new ChordError("forbidden", `${hostName} refused for this principal`);
642
+ const revoked = () => new ChordError("gone", "principal invalidated while resolving");
643
+ if (!(await route.authorize(principal, hostName)))
644
+ throw refuse();
645
+ if (!still())
646
+ throw revoked();
647
+ const link = await route.resolve(hostName);
648
+ if (!still())
649
+ throw revoked();
650
+ if (!(await route.authorize(principal, hostName)))
651
+ throw refuse();
652
+ if (!still())
653
+ throw revoked();
654
+ let connection = [...this.connections].find((candidate) => candidate.link === link);
655
+ connection ??= this.#addConnection(link, {
656
+ role: "downstream",
657
+ principal: route.principal?.(hostName) ?? { host: hostName },
658
+ peer: { kind: "host", name: hostName },
659
+ });
660
+ await connection.whenSynced();
661
+ if (!still())
662
+ throw revoked();
663
+ return connection;
664
+ }
665
+ /**
666
+ * Relays a hold from `relay.origin` to the host it names. Relaying needs a route: it is where authorization
667
+ * lives. The origin's authorization generation is re-checked after every await and right before forwarding, so a
668
+ * revocation during resolution wins.
669
+ */
670
+ async relay(relay) {
671
+ const at = relay.frame.at;
672
+ const route = this.#routeFor(at);
673
+ if (route === undefined) {
674
+ throw this.#childNamed(at) === undefined
675
+ ? new ChordError("unavailable", `no route to ${at}`)
676
+ : new ChordError("forbidden", `no route authorizes ${at}`);
677
+ }
678
+ const down = await this.#resolve(route, at, relay.principal, () => relay.current);
679
+ if (!relay.current)
680
+ return;
681
+ relay.open(down);
682
+ }
683
+ reload(plugins, options = {}) {
684
+ // Client-local facet epochs never cross the parent link; only child hosts require cluster coordination.
685
+ if (this.kind !== "client" && this.upstream !== undefined) {
686
+ return Promise.reject(new ChordError("unavailable", "reloads are coordinated by the server", { reason: "not_coordinator" }));
687
+ }
688
+ const children = [...this.connections].some((connection) => connection.role === "downstream" && connection.peer.kind === "host");
689
+ return children || options.remote !== undefined || options.remoteRemove !== undefined
690
+ ? runClusterReload(this, plugins, options)
691
+ : runReload(this, plugins, options);
692
+ }
693
+ onReloadFrame(connection, frame) {
694
+ this.#childReloads.handle(connection, frame);
695
+ }
696
+ rememberRemoteModules(remote, remove) {
697
+ for (const prefix of new Set([...Object.keys(remote ?? {}), ...Object.keys(remove ?? {})])) {
698
+ const modules = remote?.[prefix] ?? [];
699
+ const removed = remove?.[prefix] ?? [];
700
+ this.remoteModules[prefix] = [...new Set([...(this.remoteModules[prefix] ?? []), ...modules])].filter((module) => !removed.includes(module));
701
+ this.remoteRemoved[prefix] = [...new Set([...(this.remoteRemoved[prefix] ?? []), ...removed])].filter((module) => !modules.includes(module));
702
+ }
703
+ }
704
+ /** The epoch id the next reload would commit. */
705
+ peekNextEpoch() {
706
+ return this.#epochSeq + 1;
707
+ }
708
+ /**
709
+ * Disposes every record, holders before what they hold (a resource's `close` may still use the objects its record
710
+ * holds, local or remote), then closes the links.
711
+ */
712
+ async close() {
713
+ this.eager.clear();
714
+ const reason = new ChordError("gone", `host ${this.name} closed`);
715
+ try {
716
+ await disposeHoldersFirst([...this.records.values()], reason, "host close: disposal failed");
717
+ }
718
+ finally {
719
+ for (const connection of [...this.connections])
720
+ connection.link.close("host closed");
721
+ }
722
+ }
723
+ }
724
+ /**
725
+ * Force-disposes `records` in waves: a record goes once no record of the set that holds it is left, so holders are
726
+ * disposed (and their resources closed) while what they hold is still live. In-host holds are acyclic. Errors of
727
+ * every disposal are collected.
728
+ */
729
+ async function disposeHoldersFirst(records, reason, message) {
730
+ const remaining = new Set(records);
731
+ const held = (record) => {
732
+ const result = new Set();
733
+ for (const decls of record.allDecls()) {
734
+ for (const hold of decls.holds.values())
735
+ if (hold.record !== undefined)
736
+ result.add(hold.record);
737
+ }
738
+ return result;
739
+ };
740
+ const holds = new Map(records.map((record) => [record, held(record)]));
741
+ const errors = [];
742
+ while (remaining.size > 0) {
743
+ const heldByRemaining = new Set();
744
+ for (const record of remaining)
745
+ for (const target of holds.get(record) ?? [])
746
+ heldByRemaining.add(target);
747
+ let wave = [...remaining].filter((record) => !heldByRemaining.has(record));
748
+ // A cycle cannot happen in-host; dispose the rest together rather than loop.
749
+ if (wave.length === 0)
750
+ wave = [...remaining];
751
+ for (const record of wave)
752
+ remaining.delete(record);
753
+ const results = await Promise.allSettled(wave.map((record) => record.dispose(true, reason)));
754
+ for (const result of results)
755
+ if (result.status === "rejected")
756
+ errors.push(result.reason);
757
+ }
758
+ if (errors.length === 1)
759
+ throw errors[0];
760
+ if (errors.length > 1)
761
+ throw new AggregateError(errors, message);
762
+ }
763
+ function sameTable(a, b) {
764
+ if (a.size !== b.size)
765
+ return false;
766
+ const keys = [...a.keys()];
767
+ return [...b].every(([key, value], index) => keys[index] === key && a.get(key) === value);
768
+ }
769
+ const matchesPattern = (pattern, principal) => principal !== null &&
770
+ typeof principal === "object" &&
771
+ Object.entries(pattern).every(([key, value]) => deepEqual(principal[key], value));
772
+ /** Address of a remote hold, as the consumer sees it (the provider normalizes the input). */
773
+ const remoteAddress = (at, type, input, principal) => `@${at}\u0000${type.name}\u0000${canonicalJson(input)}\u0000${principal === undefined ? "host" : canonicalJson(principal)}`;
774
+ const unknownType = (type, host) => new ChordError("unavailable", `${type} is not served by ${host}`, { reason: "unknown_type" });
775
+ /** True if `from` holds `to`, directly or transitively, through record-owned holds on this host. */
776
+ function reaches(from, to) {
777
+ const seen = new Set();
778
+ const stack = [from];
779
+ while (stack.length > 0) {
780
+ const record = stack.pop();
781
+ if (record === to)
782
+ return true;
783
+ if (seen.has(record))
784
+ continue;
785
+ seen.add(record);
786
+ // Remote holds have no record here: cycles across hosts are avoided by convention.
787
+ for (const decls of record.allDecls()) {
788
+ for (const hold of decls.holds.values())
789
+ if (hold.record !== undefined)
790
+ stack.push(hold.record);
791
+ }
792
+ }
793
+ return false;
794
+ }
795
+ //# sourceMappingURL=host.js.map