@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +14 -13
  3. package/dist/cell.d.ts +30 -9
  4. package/dist/cell.d.ts.map +1 -1
  5. package/dist/cell.js +52 -12
  6. package/dist/cell.js.map +1 -1
  7. package/dist/cluster.d.ts +4 -3
  8. package/dist/cluster.d.ts.map +1 -1
  9. package/dist/cluster.js +58 -42
  10. package/dist/cluster.js.map +1 -1
  11. package/dist/connection.d.ts +171 -58
  12. package/dist/connection.d.ts.map +1 -1
  13. package/dist/connection.js +676 -217
  14. package/dist/connection.js.map +1 -1
  15. package/dist/contract.d.ts +120 -45
  16. package/dist/contract.d.ts.map +1 -1
  17. package/dist/contract.js +104 -15
  18. package/dist/contract.js.map +1 -1
  19. package/dist/declare.d.ts +11 -48
  20. package/dist/declare.d.ts.map +1 -1
  21. package/dist/declare.js +1 -21
  22. package/dist/declare.js.map +1 -1
  23. package/dist/delta/index.d.ts +2 -1
  24. package/dist/delta/index.d.ts.map +1 -1
  25. package/dist/delta/index.js +4 -0
  26. package/dist/delta/index.js.map +1 -1
  27. package/dist/errors.d.ts +4 -3
  28. package/dist/errors.d.ts.map +1 -1
  29. package/dist/errors.js +15 -0
  30. package/dist/errors.js.map +1 -1
  31. package/dist/host.d.ts +214 -120
  32. package/dist/host.d.ts.map +1 -1
  33. package/dist/host.js +446 -241
  34. package/dist/host.js.map +1 -1
  35. package/dist/index.d.ts +8 -8
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +4 -4
  38. package/dist/index.js.map +1 -1
  39. package/dist/link.d.ts +47 -12
  40. package/dist/link.d.ts.map +1 -1
  41. package/dist/link.js +200 -14
  42. package/dist/link.js.map +1 -1
  43. package/dist/loader.d.ts.map +1 -1
  44. package/dist/loader.js +7 -2
  45. package/dist/loader.js.map +1 -1
  46. package/dist/manager.d.ts.map +1 -1
  47. package/dist/manager.js +35 -9
  48. package/dist/manager.js.map +1 -1
  49. package/dist/object.d.ts +438 -0
  50. package/dist/object.d.ts.map +1 -0
  51. package/dist/{record.js → object.js} +614 -335
  52. package/dist/object.js.map +1 -0
  53. package/dist/output.d.ts +85 -0
  54. package/dist/output.d.ts.map +1 -0
  55. package/dist/output.js +350 -0
  56. package/dist/output.js.map +1 -0
  57. package/dist/registry.d.ts +3 -3
  58. package/dist/registry.d.ts.map +1 -1
  59. package/dist/registry.js +1 -1
  60. package/dist/registry.js.map +1 -1
  61. package/dist/reload.d.ts +35 -27
  62. package/dist/reload.d.ts.map +1 -1
  63. package/dist/reload.js +256 -192
  64. package/dist/reload.js.map +1 -1
  65. package/dist/remote.d.ts +130 -35
  66. package/dist/remote.d.ts.map +1 -1
  67. package/dist/remote.js +493 -189
  68. package/dist/remote.js.map +1 -1
  69. package/dist/types.d.ts +39 -28
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/types.js.map +1 -1
  72. package/dist/websocket.d.ts +4 -5
  73. package/dist/websocket.d.ts.map +1 -1
  74. package/dist/websocket.js +3 -2
  75. package/dist/websocket.js.map +1 -1
  76. package/dist/wire.d.ts +210 -129
  77. package/dist/wire.d.ts.map +1 -1
  78. package/dist/wire.js +108 -55
  79. package/dist/wire.js.map +1 -1
  80. package/docs/guide.md +267 -203
  81. package/docs/spec.md +141 -114
  82. package/docs/tutorial.md +257 -235
  83. package/package.json +6 -1
  84. package/dist/record.d.ts +0 -320
  85. package/dist/record.d.ts.map +0 -1
  86. package/dist/record.js.map +0 -1
package/dist/host.js CHANGED
@@ -1,42 +1,48 @@
1
1
  import { ChildReloads, reconcileChild, runClusterReload } from "./cluster.js";
2
2
  import { Connection } from "./connection.js";
3
- import { bind, } from "./contract.js";
3
+ import { bind, contractFromDescription, placedOn, placementText, resolveHost, } from "./contract.js";
4
4
  import { ChordError, invalid, runAll } from "./errors.js";
5
+ import { awaitReady, CtxImpl, hostLifetime, makeService, ObjectImpl, RefImpl, } from "./object.js";
5
6
  import { enqueue } from "./publish.js";
6
- import { CtxImpl, HandleImpl, makeService, RecordImpl, } from "./record.js";
7
7
  import { runReload } from "./reload.js";
8
- import { RemoteHandle } from "./remote.js";
8
+ import { RemoteBinding, RemoteRef } from "./remote.js";
9
9
  import { canonicalIdentity, canonicalJson, deepEqual, normalize } from "./schema.js";
10
- /** The principal of in-host code. */
11
- export const hostPrincipal = Object.freeze({ host: true });
10
+ /** `defineFacet<P>(…)`: an app passes its principal type, so factories and contexts see `P`. */
12
11
  export const defineFacet = (setup, options = {}) => ({ setup, uses: options.uses });
13
12
  const isFacet = (ref) => typeof ref === "object" && !(ref instanceof URL);
14
13
  const EMPTY_TABLE = new Map();
15
- export function createHost(options = {}) {
14
+ /** A host whose principals are `P` (the app's type; `unknown` if not given). */
15
+ export function createHost(options) {
16
16
  return new HostImpl(options);
17
17
  }
18
18
  export class HostImpl {
19
19
  name;
20
20
  dev;
21
- principal = hostPrincipal;
21
+ principal;
22
22
  inFactory = 0;
23
23
  failed;
24
- records = new Map();
25
- epochs = new Map();
24
+ objects = new Map();
25
+ codeVersions = new Map();
26
26
  facets = new Map();
27
- eager = new Map();
27
+ /** The host's own references to its `lifetime: "host"` objects, by type name. */
28
+ hostObjects = new Map();
28
29
  kind;
29
30
  connections = new Set();
30
31
  upstream;
31
- /** Remote handles this host holds (re-held after an upstream reconnect). */
32
- remotes = new Set();
32
+ /** Shared bindings of this host's remote references, by key (re-held after an upstream reconnect). */
33
+ bindings = new Map();
34
+ /** Bindings without references that linger, least recently released first. */
35
+ #lingering = new Map();
36
+ #lingerTypes;
37
+ #lingerCap;
38
+ #originSeq = 0;
33
39
  #routes = new Map();
34
40
  #childReloads = new ChildReloads(this);
35
41
  /** Child facet modules by host-name prefix, from every committed cluster reload (for reconciling children). */
36
42
  remoteModules = {};
37
43
  /** Removal tombstones also reconcile children that connected during a transaction. */
38
44
  remoteRemoved = {};
39
- /** Highest epoch the parent has retired. */
45
+ /** Highest code version the parent has retired. */
40
46
  #retireAllowed = 0;
41
47
  /** Roots that came over a link, by the link they came from. */
42
48
  /** Foreign roots (started on another host), by the links they came over. */
@@ -48,46 +54,75 @@ export class HostImpl {
48
54
  #rootLinks = new Map();
49
55
  current;
50
56
  previous;
51
- /** The epoch a running reload will commit; new types activated during prepare install their behavior for it. */
57
+ /** The code version a running reload will commit; new types activated during prepare install their implementation for it. */
52
58
  pending;
53
59
  /** 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. */
60
+ /** Types behind a reload gate: new objects of these types are rejected `upgrade_conflict` until commit/abort. */
55
61
  gating;
56
62
  reloading = false;
57
- retired = () => { };
63
+ taskStopDeadline;
64
+ readyTimeout;
58
65
  #principalKey;
66
+ /** `principalKey(self)`: an equal principal is the host's own, whatever object carries it. */
67
+ #selfKey;
68
+ /** Called when a root ends, the parent allows a retirement, or the previous code version retires. */
69
+ #retirementWatchers = new Set();
70
+ /** Child hosts whose hello arrived while a reload ran: synced or reconciled once it ends. */
71
+ #deferredHellos = new Map();
72
+ /** Resolved when the running reload ends. */
73
+ #reloadWaiters = [];
74
+ /** Aborted when the installed run of a facet (by name) is replaced, removed, or the host closes. */
75
+ #facetRuns = new Map();
59
76
  #onError;
60
77
  #loader;
61
78
  #dying = new Map();
62
79
  #touches = new Map();
63
80
  #order = new Map();
64
81
  #subscribers = new Map();
65
- #epochSeq = 1;
82
+ #codeVersionSeq = 1;
66
83
  #rootSeq = 0;
67
84
  constructor(options) {
68
85
  this.name = options.name ?? "host";
69
86
  this.dev = options.dev ?? false;
87
+ if (!("self" in options))
88
+ throw new TypeError("createHost needs `self`: the principal of the host's own code");
89
+ this.principal = options.self;
70
90
  this.#principalKey = options.principalKey ?? canonicalJson;
91
+ this.#selfKey = this.#principalKey(options.self);
71
92
  this.#onError = options.onError ?? ((error) => console.error("chord:", error));
72
93
  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);
94
+ this.taskStopDeadline = options.taskStopDeadline ?? 5000;
95
+ this.readyTimeout = options.readyTimeout ?? 60_000;
96
+ this.#lingerTypes = options.linger?.types ?? {};
97
+ this.#lingerCap = options.linger?.cap ?? 4;
98
+ this.current = {
99
+ id: this.#codeVersionSeq,
100
+ services: new Map(),
101
+ tables: new Map(),
102
+ roots: new Map(),
103
+ abort: new AbortController(),
104
+ };
105
+ this.codeVersions.set(this.current.id, this.current);
75
106
  this.kind = options.kind ?? "host";
76
107
  if (options.upstream !== undefined)
77
- this.connect(options.upstream, options.upstreamOptions);
108
+ this.connect(options.upstream.link, options.upstream);
109
+ }
110
+ /** Whether `principal` is the host's own: the same key as `self`. */
111
+ isSelf(principal) {
112
+ return principal === this.principal || this.#principalKey(principal) === this.#selfKey;
78
113
  }
79
- get epoch() {
114
+ get codeVersion() {
80
115
  return this.current.id;
81
116
  }
82
- get previousEpoch() {
117
+ get previousCodeVersion() {
83
118
  return this.previous?.id;
84
119
  }
85
- /** Allocates the next epoch id, or adopts one issued by the coordinating server. */
86
- nextEpochId(given) {
120
+ /** Allocates the next code version id, or adopts one issued by the coordinating server. */
121
+ nextCodeVersionId(given) {
87
122
  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;
123
+ throw new Error(`code version ${given} is not newer than ${this.current.id}`);
124
+ this.#codeVersionSeq = given ?? this.#codeVersionSeq + 1;
125
+ return this.#codeVersionSeq;
91
126
  }
92
127
  report(error) {
93
128
  this.#onError(error);
@@ -97,8 +132,8 @@ export class HostImpl {
97
132
  if (this.reloading)
98
133
  throw new Error("host.serve during a reload; serve from a facet passed to reload()");
99
134
  this.serveInto(this.current.services, makeService(type, factory, options, ""));
100
- if (options.eager)
101
- this.startEager(this.current.services.get(type.name));
135
+ if (options.lifetime === "host")
136
+ this.startHostObject(this.current.services.get(type.name));
102
137
  });
103
138
  install(facet, options = {}) {
104
139
  if (this.reloading)
@@ -106,25 +141,33 @@ export class HostImpl {
106
141
  if (this.facets.has(facet.name))
107
142
  throw new Error(`facet ${facet.name} is already installed; use reload()`);
108
143
  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
- })));
144
+ const run = new AbortController();
145
+ try {
146
+ facet.setup(this.facetApi(((type, factory, options = {}) => {
147
+ const service = makeService(type, factory, options, facet.name);
148
+ this.serveInto(this.current.services, service);
149
+ added.push(service);
150
+ }), run.signal));
151
+ }
152
+ catch (error) {
153
+ run.abort(error);
154
+ throw error;
155
+ }
114
156
  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);
157
+ this.facetRunChanged(facet.name, run);
158
+ const refs = added.filter(hostLifetime).map((service) => this.startHostObject(service));
159
+ return options.awaitHostObjects === false ? Promise.resolve() : this.#hostObjectsReady(refs);
117
160
  }
118
161
  async load(plugins, options = {}) {
119
162
  const facets = await this.resolve(plugins);
120
- // No await between installs: an eager factory may consume a type served by a later facet.
163
+ // No await between installs: a host object's factory may consume a type served by a later facet.
121
164
  await Promise.all(facets.map((facet) => this.install(facet, options)));
122
165
  }
123
166
  ready() {
124
- return this.#eagerReady(this.eager.values());
167
+ return this.#hostObjectsReady(this.hostObjects.values());
125
168
  }
126
- #eagerReady(holds) {
127
- const ready = Promise.all([...holds].map((hold) => hold.ready)).then(() => { });
169
+ #hostObjectsReady(refs) {
170
+ const ready = Promise.all([...refs].map((ref) => ref.ready)).then(() => { });
128
171
  // Ignoring install's return still reports through onError without an unhandled rejection.
129
172
  ready.catch(() => { });
130
173
  return ready;
@@ -138,128 +181,183 @@ export class HostImpl {
138
181
  const loaded = modules.length > 0 && this.#loader !== undefined ? await this.#loader(modules) : [];
139
182
  return plugins.map((ref) => (isFacet(ref) ? ref : loaded.shift()));
140
183
  }
141
- facetApi(serve) {
184
+ facetApi(serve, signal) {
142
185
  return {
143
186
  serve,
144
187
  name: this.name,
145
188
  root: (fn, options) => this.root(fn, options),
146
189
  registry: (registry) => this.registry(registry),
190
+ signal,
147
191
  };
148
192
  }
193
+ /** The installed run of facet `name` is now `run` (or none): the run it replaces is aborted. */
194
+ facetRunChanged(name, run) {
195
+ const replaced = this.#facetRuns.get(name);
196
+ if (run === undefined)
197
+ this.#facetRuns.delete(name);
198
+ else
199
+ this.#facetRuns.set(name, run);
200
+ if (replaced !== run)
201
+ replaced?.abort(new ChordError("gone", `facet ${name} was replaced or removed`));
202
+ }
149
203
  serveInto(services, service) {
150
204
  if (services.has(service.contract.name))
151
205
  throw new Error(`${service.contract.name} is already served`);
206
+ const placement = service.contract.description.host;
207
+ if (placement !== undefined && !placedOn(placement, this.name)) {
208
+ throw new TypeError(`${service.contract.name} is declared for ${placementText(placement)}, but ${this.name} serves it`);
209
+ }
152
210
  services.set(service.contract.name, service);
153
211
  }
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;
212
+ startHostObject(service) {
213
+ const ref = this.acquire(service.contract, {}, undefined, "host");
214
+ this.hostObjects.set(service.contract.name, ref);
215
+ ref.ready.catch((error) => this.report(error));
216
+ return ref;
161
217
  }
162
218
  // ─── HostCore ───
163
- currentEpoch() {
219
+ currentCodeVersion() {
164
220
  return this.current.id;
165
221
  }
166
- hasEpoch(epoch) {
167
- return this.epochs.has(epoch);
222
+ hasCodeVersion(codeVersion) {
223
+ return this.codeVersions.has(codeVersion);
168
224
  }
169
- *retainedEpochs() {
170
- for (const [id, state] of this.epochs)
225
+ *retainedCodeVersions() {
226
+ for (const [id, state] of this.codeVersions)
171
227
  yield [id, state.services];
172
228
  if (this.pending !== undefined)
173
229
  yield [this.pending.id, this.pending.services];
174
230
  }
175
- pendingEpoch() {
231
+ pendingCodeVersion() {
176
232
  return this.pending?.id;
177
233
  }
178
- /** The current code for a type; a type a running reload adds is served from the pending epoch. */
234
+ /** The current code for a type; a type a running reload adds is served from the pending code version. */
179
235
  service(typeName) {
180
236
  return this.current.services.get(typeName) ?? this.pending?.services.get(typeName);
181
237
  }
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);
238
+ addressOf(type, input, principal, candidate = false) {
239
+ const elsewhere = this.#elsewhere(type, input);
240
+ if (elsewhere !== undefined)
241
+ return this.#remoteKey(type, elsewhere.input, principal);
185
242
  const service = this.#bindingService(type.name, candidate);
186
243
  if (service === undefined)
187
244
  throw unknownType(type.name, this.name);
188
- const normalized = normalize(service.contract.input, input, `${type.name} input`);
189
- return this.#address(service, normalized, principal);
245
+ return this.#locate(service, input, principal).address;
190
246
  }
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}`;
247
+ /**
248
+ * Where an object of a remote type lives when that is another host: its name and the input normalized with the
249
+ * caller's contract (the placement is read from it). `undefined`: here (and every local type).
250
+ */
251
+ #elsewhere(type, input) {
252
+ const placement = type.description.host;
253
+ if (type.scope !== "remote" || placement === undefined)
254
+ return undefined;
255
+ const normalized = normalize(type.input, input, `${type.name} input`);
256
+ const at = resolveHost(type.name, placement, normalized);
257
+ return at === this.name ? undefined : { at, input: normalized };
258
+ }
259
+ /**
260
+ * The address of `input` for `principal`, normalized. A `lifetime: "host"` type has one object: the empty input, for
261
+ * the host principal, whoever asks; another input is invalid.
262
+ */
263
+ #locate(service, input, principal) {
264
+ const { name, input: schema } = service.contract;
265
+ const normalized = normalize(schema, input, `${name} input`);
266
+ const identity = canonicalIdentity(schema, normalized);
267
+ if (hostLifetime(service)) {
268
+ if (identity !== canonicalIdentity(schema, normalize(schema, {}, `${name} input`)))
269
+ throw invalid("input", `${name} has lifetime "host": its one object has the empty input`);
270
+ principal = this.principal;
271
+ }
272
+ return { address: `${name}\u0000${identity}\u0000${this.#principalPart(principal)}`, normalized, principal };
273
+ }
274
+ #principalPart(principal) {
275
+ return this.isSelf(principal) ? "host" : `p:${this.#principalKey(principal)}`;
276
+ }
277
+ /**
278
+ * The key of a remote binding: type, input identity (fields equal to their defaults omitted, as for local objects),
279
+ * principal key and the consumer's contract fingerprint. No host name: the type and the input decide where it lives.
280
+ */
281
+ #remoteKey(type, normalized, principal) {
282
+ const identity = canonicalIdentity(type.input, normalized);
283
+ return `@${type.name}\u0000${identity}\u0000${this.#principalPart(principal)}\u0000${type.fingerprint}`;
194
284
  }
195
285
  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);
286
+ const elsewhere = this.#elsewhere(type, input);
287
+ if (elsewhere !== undefined)
288
+ return this.#acquireRemote(type, elsewhere.at, elsewhere.input, options, owner);
198
289
  const service = this.#bindingService(type.name, candidate);
199
290
  if (service === undefined)
200
291
  throw unknownType(type.name, this.name);
201
292
  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) {
293
+ const { address, normalized, principal } = this.#locate(service, input, options?.principal ?? this.principal);
294
+ // The provider's own placement decides, never where a request says it is going (a client picks that).
295
+ const placement = service.contract.description.host;
296
+ if (placement !== undefined && resolveHost(type.name, placement, normalized) !== this.name) {
297
+ throw invalid("placement", `${type.name} with this input lives on ${resolveHost(type.name, placement, normalized)}, not ${this.name}`);
298
+ }
299
+ let object = this.objects.get(address);
300
+ if (from !== undefined && object !== undefined && (object === from || reaches(object, from))) {
301
+ throw invalid("cycle", `${from.address} -> ${address} would create a ref cycle`);
302
+ }
303
+ if (object === undefined) {
210
304
  if (this.gating?.has(type.name)) {
211
305
  throw new ChordError("upgrade_conflict", `${type.name} is being upgraded; retry`, { reason: "gate" });
212
306
  }
213
307
  const dying = this.#dying.get(address);
214
308
  if (dying?.status === "disposed")
215
309
  this.#dying.delete(address);
216
- record = new RecordImpl(this, {
310
+ object = new ObjectImpl(this, {
217
311
  address,
218
312
  typeName: type.name,
219
313
  input: normalized,
220
314
  principal,
221
- eager: service.options.eager !== undefined && service.options.eager !== false && principal === this.principal,
315
+ hostLifetime: hostLifetime(service),
222
316
  });
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);
317
+ this.objects.set(address, object);
318
+ // A new object for an address still being disposed waits for that cleanup (exclusive resources).
319
+ object.start(dying?.status === "disposing" ? dying.dispose(false) : undefined);
226
320
  }
227
- const handle = new HandleImpl(record, type, owner);
228
- record.addHold(handle);
229
- return handle;
321
+ const ref = new RefImpl(object, type, owner);
322
+ object.addRef(ref);
323
+ return ref;
324
+ }
325
+ forget(object) {
326
+ if (this.objects.get(object.address) === object)
327
+ this.objects.delete(object.address);
328
+ if (object.status === "disposing")
329
+ this.#dying.set(object.address, object);
230
330
  }
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);
331
+ disposed(object) {
332
+ if (this.#dying.get(object.address) === object)
333
+ this.#dying.delete(object.address);
236
334
  }
237
- touch(rootId, record) {
335
+ touch(rootId, object) {
238
336
  let touched = this.#touches.get(rootId);
239
337
  if (touched === undefined) {
240
338
  touched = new Set();
241
339
  this.#touches.set(rootId, touched);
242
340
  }
243
- touched.add(record);
341
+ touched.add(object);
244
342
  }
245
343
  // ─── roots ───
246
344
  async root(fn, options = {}) {
247
345
  // The incarnation keeps root ids unique across restarts of a host with the same name.
248
346
  const rootId = `${this.name}#${this.#incarnation}:${++this.#rootSeq}`;
249
347
  this.#local.add(rootId);
250
- return this.joinRoot(rootId, options.epoch ?? this.current.id, fn, options);
348
+ return this.joinRoot(rootId, options.codeVersion ?? this.current.id, fn, options);
251
349
  }
252
350
  /**
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
351
+ * Runs `fn` inside root `rootId` of `codeVersion`. A root counts as running here (for retirement) while any of its
352
+ * calls runs here. A root that started here ends when its last call here settles, even if it went out and came back
353
+ * over a link meanwhile. A foreign root (started elsewhere, arriving `from` a link) keeps its leases on objects
256
354
  * until every link it came over announced its end (`root_end`) or closed, so its re-entry stays grandfathered.
257
355
  */
258
- async joinRoot(rootId, epochId, fn, options = {}) {
259
- const epoch = this.epochs.get(epochId);
260
- if (epoch === undefined) {
356
+ async joinRoot(rootId, codeVersionId, fn, options = {}) {
357
+ const codeVersion = this.codeVersions.get(codeVersionId);
358
+ if (codeVersion === undefined) {
261
359
  this.#local.delete(rootId);
262
- throw new ChordError("superseded", `epoch ${epochId} is not retained by ${this.name}`);
360
+ throw new ChordError("superseded", `code version ${codeVersionId} is not retained by ${this.name}`);
263
361
  }
264
362
  if (options.from !== undefined && !this.#local.has(rootId)) {
265
363
  let links = this.#foreign.get(rootId);
@@ -269,11 +367,12 @@ export class HostImpl {
269
367
  }
270
368
  links.add(options.from);
271
369
  }
272
- epoch.roots.set(rootId, (epoch.roots.get(rootId) ?? 0) + 1);
370
+ codeVersion.roots.set(rootId, (codeVersion.roots.get(rootId) ?? 0) + 1);
273
371
  const ctx = new CtxImpl(this, {
274
- signal: options.signal ?? new AbortController().signal,
372
+ // Forced retirement aborts what still runs on a code version before closing what only it used.
373
+ signals: options.signal === undefined ? [codeVersion.abort.signal] : [options.signal, codeVersion.abort.signal],
275
374
  rootId,
276
- epoch: epoch.id,
375
+ codeVersion: codeVersion.id,
277
376
  principal: options.principal ?? this.principal,
278
377
  });
279
378
  try {
@@ -281,14 +380,15 @@ export class HostImpl {
281
380
  }
282
381
  finally {
283
382
  ctx.close();
284
- const count = (epoch.roots.get(rootId) ?? 1) - 1;
383
+ const count = (codeVersion.roots.get(rootId) ?? 1) - 1;
285
384
  if (count > 0)
286
- epoch.roots.set(rootId, count);
385
+ codeVersion.roots.set(rootId, count);
287
386
  else {
288
- epoch.roots.delete(rootId);
387
+ codeVersion.roots.delete(rootId);
289
388
  if (!this.#foreign.has(rootId))
290
389
  this.endRoot(rootId);
291
390
  this.maybeRetire();
391
+ this.#retirementChanged();
292
392
  }
293
393
  }
294
394
  }
@@ -305,8 +405,8 @@ export class HostImpl {
305
405
  endRoot(rootId) {
306
406
  this.#local.delete(rootId);
307
407
  this.#foreign.delete(rootId);
308
- for (const record of this.#touches.get(rootId) ?? [])
309
- record.rootEnded(rootId);
408
+ for (const object of this.#touches.get(rootId) ?? [])
409
+ object.rootEnded(rootId);
310
410
  this.#touches.delete(rootId);
311
411
  for (const connection of this.#rootLinks.get(rootId) ?? [])
312
412
  connection.send({ t: "root_end", rootId });
@@ -320,7 +420,7 @@ export class HostImpl {
320
420
  const links = this.#foreign.get(rootId);
321
421
  if (links === undefined || !links.delete(from) || links.size > 0)
322
422
  return;
323
- const running = [...this.epochs.values()].some((epoch) => epoch.roots.has(rootId));
423
+ const running = [...this.codeVersions.values()].some((codeVersion) => codeVersion.roots.has(rootId));
324
424
  // Still running here (a call in flight): the lease ends when it settles.
325
425
  if (running)
326
426
  this.#foreign.delete(rootId);
@@ -328,48 +428,71 @@ export class HostImpl {
328
428
  this.endRoot(rootId);
329
429
  }
330
430
  /**
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.
431
+ * Retires the previous code version once no local roots of it remain. A child also waits for its parent to retire
432
+ * it: roots span hosts, and an old root on the server may still call in.
333
433
  */
334
434
  maybeRetire() {
435
+ if (this.retirable())
436
+ this.retire();
437
+ }
438
+ /** The previous code version has no roots left here and, on a child host, the parent has retired it. */
439
+ retirable() {
335
440
  const previous = this.previous;
336
441
  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();
442
+ return false;
443
+ return this.kind === "client" || this.upstream === undefined || previous.id <= this.#retireAllowed;
341
444
  }
342
- allowRetire(epoch) {
343
- this.#retireAllowed = Math.max(this.#retireAllowed, epoch);
445
+ allowRetire(codeVersion) {
446
+ this.#retireAllowed = Math.max(this.#retireAllowed, codeVersion);
344
447
  this.maybeRetire();
448
+ this.#retirementChanged();
449
+ }
450
+ /** Calls `watcher` whenever a root ends, the parent allows a retirement, or a code version retires. */
451
+ watchRetirement(watcher) {
452
+ this.#retirementWatchers.add(watcher);
453
+ return () => this.#retirementWatchers.delete(watcher);
454
+ }
455
+ #retirementChanged() {
456
+ for (const watcher of [...this.#retirementWatchers])
457
+ watcher();
458
+ }
459
+ /**
460
+ * Tells a child that is now in step which code versions this host retired before: every id below the oldest one it
461
+ * retains (ids only grow). Without it, a child reconciled after those retirements would never hear a `retire` frame for
462
+ * its own previous code version and would wait for the retire deadline at every reload.
463
+ */
464
+ announceRetired(connection) {
465
+ const retired = (this.previous ?? this.current).id - 1;
466
+ if (retired > 0)
467
+ connection.send({ t: "retire", codeVersion: retired });
345
468
  }
346
- /** Drops the previous epoch: its behaviors and the declarations only it used. */
469
+ /** Drops the previous code version: its implementations and the declarations only it used. */
347
470
  retire() {
348
471
  const previous = this.previous;
349
472
  if (previous === undefined)
350
473
  return;
351
474
  this.previous = undefined;
352
- this.epochs.delete(previous.id);
353
- for (const record of [...this.records.values()])
354
- this.retireBehavior(record, previous.id);
475
+ this.codeVersions.delete(previous.id);
476
+ for (const object of [...this.objects.values()])
477
+ this.retireImplementation(object, previous.id);
355
478
  for (const connection of this.connections) {
356
479
  if (connection.role === "downstream" && connection.peer?.kind === "host")
357
- connection.send({ t: "retire", epoch: previous.id });
480
+ connection.send({ t: "retire", codeVersion: previous.id });
358
481
  }
359
- this.retired();
482
+ this.#retirementChanged();
360
483
  }
361
484
  /**
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.
485
+ * Drops an object's implementation for `codeVersion` and what only that implementation declared, in disposal order:
486
+ * resources close (reverse open order) before its references are released.
364
487
  */
365
- retireBehavior(record, epoch) {
366
- const installed = record.behaviors.get(epoch);
367
- record.behaviors.delete(epoch);
368
- if (installed === undefined || installed.decls === record.decls)
488
+ retireImplementation(object, codeVersion) {
489
+ const installed = object.implementations.get(codeVersion);
490
+ object.implementations.delete(codeVersion);
491
+ if (installed === undefined || installed.decls === object.decls)
369
492
  return;
370
- const kept = record.allDecls();
493
+ const kept = object.allDecls();
371
494
  const keep = (pick) => new Set([...kept].flatMap((decls) => [...pick(decls)]));
372
- const holds = keep((decls) => decls.holds.values());
495
+ const refs = keep((decls) => decls.refs.values());
373
496
  const resources = keep((decls) => decls.resources.values());
374
497
  const derives = keep((decls) => decls.derives.values());
375
498
  for (const derived of installed.decls.derives.values())
@@ -377,21 +500,21 @@ export class HostImpl {
377
500
  derived.detach();
378
501
  const closing = [...installed.decls.resources.values()]
379
502
  .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();
503
+ .sort((a, b) => object.openOrder.indexOf(b) - object.openOrder.indexOf(a));
504
+ const releasing = [...installed.decls.refs.values()].filter((ref) => !refs.has(ref));
505
+ object.refreshSchemas();
383
506
  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));
507
+ ...closing.map((resource) => () => resource.close(object.openOrder)),
508
+ ...releasing.map((ref) => () => ref.release()),
509
+ ], `${object.address}: retiring code version ${codeVersion} failed`).catch((error) => this.report(error));
387
510
  }
388
511
  // ─── registries ───
389
512
  registry(registry) {
390
513
  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);
514
+ const codeVersion = ctx === undefined ? this.current : this.codeVersions.get(ctx.codeVersion);
515
+ if (codeVersion === undefined)
516
+ throw new ChordError("superseded", `code version ${ctx?.codeVersion} is retired`);
517
+ return (codeVersion.tables.get(registry.name) ?? EMPTY_TABLE);
395
518
  };
396
519
  return {
397
520
  table,
@@ -409,10 +532,10 @@ export class HostImpl {
409
532
  },
410
533
  };
411
534
  }
412
- /** Builds registry tables from (record, declarations) pairs. Throws registry_conflict. */
535
+ /** Builds registry tables from (object, declarations) pairs. Throws registry_conflict. */
413
536
  buildTables(entries) {
414
537
  const byRegistry = new Map();
415
- for (const [record, decls] of entries) {
538
+ for (const [object, decls] of entries) {
416
539
  for (const { registry, key, value } of decls.registrations.values()) {
417
540
  let entries = byRegistry.get(registry.name);
418
541
  if (entries === undefined) {
@@ -420,10 +543,10 @@ export class HostImpl {
420
543
  byRegistry.set(registry.name, entries);
421
544
  }
422
545
  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}`);
546
+ if (existing !== undefined && existing.object !== object && registry.conflict === "error") {
547
+ throw invalid("registry_conflict", `${registry.name}: ${key} registered by ${existing.object.address} and ${object.address}`);
425
548
  }
426
- entries.set(key, { value, record });
549
+ entries.set(key, { value, object });
427
550
  let order = this.#order.get(registry.name);
428
551
  if (order === undefined) {
429
552
  order = new Map();
@@ -442,25 +565,25 @@ export class HostImpl {
442
565
  return tables;
443
566
  }
444
567
  /**
445
- * The service a hold binds against: the current one, or for a prepare rerun (`candidate`) the running reload's
568
+ * The service a reference binds against: the current one, or for a prepare rerun (`candidate`) the running reload's
446
569
  * pending one, so consumer and provider of a breaking change can reload in one batch.
447
570
  */
448
571
  #bindingService(typeName, candidate) {
449
572
  return (candidate ? this.pending?.services.get(typeName) : undefined) ?? this.service(typeName);
450
573
  }
451
- /** Live records with their current declarations; records waiting for a reload's commit only with `candidates`. */
574
+ /** Live objects with their current declarations; objects waiting for a reload's commit only with `candidates`. */
452
575
  liveDecls(candidates = false) {
453
- return [...this.records.values()]
454
- .filter((record) => record.status === "live" && (candidates || !record.deferredRuntime))
455
- .map((record) => [record, record.decls]);
576
+ return [...this.objects.values()]
577
+ .filter((object) => object.status === "live" && (candidates || !object.deferredRuntime))
578
+ .map((object) => [object, object.decls]);
456
579
  }
457
- checkRegistrations(record, decls) {
580
+ checkRegistrations(object, decls) {
458
581
  if (decls.registrations.size === 0)
459
582
  return;
460
- this.buildTables([...this.liveDecls().filter(([other]) => other !== record), [record, decls]]);
583
+ this.buildTables([...this.liveDecls().filter(([other]) => other !== object), [object, decls]]);
461
584
  }
462
585
  registriesChanged(including) {
463
- const entries = this.liveDecls().filter(([record]) => record !== including);
586
+ const entries = this.liveDecls().filter(([object]) => object !== including);
464
587
  if (including !== undefined)
465
588
  entries.push([including, including.decls]);
466
589
  this.installTables(this.current, this.buildTables(entries));
@@ -470,11 +593,11 @@ export class HostImpl {
470
593
  this.failed ??= error;
471
594
  this.report(error);
472
595
  }
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)
596
+ /** Installs tables for `codeVersion`; subscribers hear about changed tables of the current code version (all tables when `all`). */
597
+ installTables(codeVersion, tables, all = false) {
598
+ const before = codeVersion.tables;
599
+ codeVersion.tables = tables;
600
+ if (codeVersion !== this.current)
478
601
  return;
479
602
  for (const [name, listeners] of this.#subscribers) {
480
603
  const next = tables.get(name) ?? EMPTY_TABLE;
@@ -484,7 +607,7 @@ export class HostImpl {
484
607
  enqueue(() => {
485
608
  for (const listener of current) {
486
609
  try {
487
- listener(next, epoch.id);
610
+ listener(next, codeVersion.id);
488
611
  }
489
612
  catch (error) {
490
613
  this.report(error);
@@ -494,7 +617,12 @@ export class HostImpl {
494
617
  }
495
618
  }
496
619
  // ─── public API ───
497
- get(type, input, options) {
620
+ async get(type, input, options) {
621
+ const ref = this.acquire(type, input, options, "external");
622
+ await awaitReady(ref, options?.signal === undefined ? [] : [options.signal], this.readyTimeout);
623
+ return ref.proxy;
624
+ }
625
+ hold(type, input, options) {
498
626
  return this.acquire(type, input, options, "external").proxy;
499
627
  }
500
628
  async invalidatePrincipal(match) {
@@ -507,7 +635,7 @@ export class HostImpl {
507
635
  // Relays under the old principal close at once, including those still resolving their route.
508
636
  connection.revoke(connection.restamp?.(connection.principal));
509
637
  }
510
- // Patterns propagate to child hosts, which dispose their own matching records (e.g. relayed guests).
638
+ // Patterns propagate to child hosts, which dispose their own matching objects (e.g. relayed guests).
511
639
  if (typeof match !== "function") {
512
640
  for (const connection of this.connections) {
513
641
  if (connection.role === "downstream" && connection.peer.kind === "host") {
@@ -515,7 +643,7 @@ export class HostImpl {
515
643
  }
516
644
  }
517
645
  }
518
- const matching = [...this.records.values()].filter((record) => record.principal !== this.principal && matches(record.principal));
646
+ const matching = [...this.objects.values()].filter((object) => !this.isSelf(object.principal) && matches(object.principal));
519
647
  await disposeHoldersFirst(matching, reason, "invalidatePrincipal: disposal failed");
520
648
  }
521
649
  // ─── links ───
@@ -528,54 +656,93 @@ export class HostImpl {
528
656
  role: "downstream",
529
657
  principal: options.principal,
530
658
  peer: { kind, name: kind === "host" ? options.name : undefined },
659
+ // Holds over a client link carry its id as their origin (also when relayed), so hosts can tell clients apart.
660
+ ...(kind === "client" ? { originId: `${this.#incarnation}:${++this.#originSeq}` } : {}),
531
661
  });
532
662
  }
533
- connect(link, options = {}) {
663
+ connect(link, options) {
664
+ if (!("principal" in options))
665
+ throw new TypeError("connect needs `principal`: what the parent's own requests act as");
534
666
  if (this.upstream?.open)
535
667
  this.upstream.link.close("replaced");
536
668
  const connection = this.#addConnection(link, {
537
669
  ...options,
538
670
  role: "upstream",
539
- principal: undefined,
671
+ principal: options.principal,
540
672
  peer: { kind: "host", name: undefined },
541
673
  });
542
674
  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);
675
+ // Bindings that lost their link re-hold over the new one (`again`: they keep their values meanwhile).
676
+ for (const binding of this.bindings.values())
677
+ if (binding.hold === undefined && !binding.ended)
678
+ connection.hold(binding, binding.applied);
547
679
  }
548
680
  #addConnection(link, config) {
549
681
  const connection = new Connection(this, link, config);
550
- this.connections.add(connection);
682
+ // A link that was already closed closed the connection in its constructor: keeping it would route to it forever.
683
+ if (connection.open)
684
+ this.connections.add(connection);
551
685
  return connection;
552
686
  }
553
687
  route(prefix, route) {
688
+ if (typeof route.principal !== "function")
689
+ throw new TypeError(`route ${prefix} needs \`principal\`: what the routed hosts act as when they call back`);
554
690
  this.#routes.set(prefix, route);
555
691
  }
556
692
  /**
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.
693
+ * A child host announced its code version. In step: usable. Behind: fenced (nothing is routed to it) and reconciled
694
+ * by a reload of its modules to this host's code version, never by renumbering what it runs. Ahead: impossible,
695
+ * refused.
559
696
  */
560
- peerHello(connection, epoch) {
561
- if (epoch === this.current.id) {
562
- connection.markSynced(epoch);
697
+ peerHello(connection, codeVersion) {
698
+ if (codeVersion > this.current.id) {
699
+ this.report(new ChordError("invalid", `${connection.name} runs code version ${codeVersion}, ahead of ${this.current.id}`));
700
+ connection.link.close("code version ahead of parent");
563
701
  return;
564
702
  }
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");
703
+ if (this.reloading) {
704
+ // Not a participant of the running reload (participants are fixed when it starts): in step now, it would miss
705
+ // the commit. Decided when the reload ends. A child already behind is fenced now, so the calls its roots make
706
+ // meanwhile are admitted in this host's code version (never relabeled) instead of being `superseded`.
707
+ connection.peerCodeVersion = codeVersion;
708
+ if (codeVersion < this.current.id)
709
+ connection.fencedCodeVersions.add(codeVersion);
710
+ this.#deferredHellos.set(connection, codeVersion);
568
711
  return;
569
712
  }
570
- connection.peerEpoch = epoch;
571
- connection.fencedEpochs.add(epoch);
713
+ if (codeVersion === this.current.id) {
714
+ connection.markSynced(codeVersion);
715
+ this.announceRetired(connection);
716
+ return;
717
+ }
718
+ connection.peerCodeVersion = codeVersion;
719
+ connection.fencedCodeVersions.add(codeVersion);
572
720
  reconcileChild(this, connection).catch((error) => {
573
721
  this.report(error);
574
722
  connection.link.close("reconciliation failed");
575
723
  });
576
724
  }
725
+ /** The host's reload slot was freed: children that said hello meanwhile are synced or reconciled now. */
726
+ reloadEnded() {
727
+ this.reloading = false;
728
+ const deferred = [...this.#deferredHellos];
729
+ this.#deferredHellos.clear();
730
+ for (const [connection, codeVersion] of deferred)
731
+ if (connection.open)
732
+ this.peerHello(connection, codeVersion);
733
+ for (const resolve of this.#reloadWaiters.splice(0))
734
+ resolve();
735
+ this.maybeRetire();
736
+ }
737
+ /** Resolves once no reload runs on this host. */
738
+ whenReloadEnded() {
739
+ if (!this.reloading)
740
+ return Promise.resolve();
741
+ return new Promise((resolve) => this.#reloadWaiters.push(resolve));
742
+ }
577
743
  connectionClosed(connection) {
578
744
  this.connections.delete(connection);
745
+ this.#deferredHellos.delete(connection);
579
746
  // Foreign roots that came over this link will never announce their end over it.
580
747
  for (const [rootId, links] of [...this.#foreign])
581
748
  if (links.has(connection))
@@ -587,46 +754,78 @@ export class HostImpl {
587
754
  this.#childReloads.parentLost();
588
755
  }
589
756
  }
590
- forgetRemote(handle) {
591
- this.remotes.delete(handle);
757
+ /** The last reference to `binding` was released: it lingers if its type does (`HostOptions.linger`), else closes. */
758
+ bindingIdle(binding) {
759
+ const ms = Object.hasOwn(this.#lingerTypes, binding.contract.name)
760
+ ? this.#lingerTypes[binding.contract.name]
761
+ : 0;
762
+ // A binding that never had a shape has nothing worth keeping.
763
+ if (ms <= 0 || binding.ended || !binding.applied || this.#lingerCap <= 0) {
764
+ binding.close();
765
+ return;
766
+ }
767
+ binding.lingerTimer = setTimeout(() => binding.close(), ms);
768
+ binding.lingerTimer.unref?.();
769
+ this.#lingering.set(binding.key, binding);
770
+ for (const oldest of this.#lingering.values()) {
771
+ if (this.#lingering.size <= this.#lingerCap)
772
+ break;
773
+ oldest.close();
774
+ }
775
+ }
776
+ forgetBinding(binding) {
777
+ if (this.bindings.get(binding.key) === binding)
778
+ this.bindings.delete(binding.key);
779
+ if (this.#lingering.get(binding.key) === binding)
780
+ this.#lingering.delete(binding.key);
592
781
  }
593
782
  /** A downstream host connection by its configured name (never by what a peer announced). */
594
783
  #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);
784
+ return [...this.connections].find((connection) => connection.open &&
785
+ connection.role === "downstream" &&
786
+ connection.peer.kind === "host" &&
787
+ connection.peer.name === name);
788
+ }
789
+ /**
790
+ * A reference to an object on host `at`: a view onto the binding for its key, created (and held) if there is none.
791
+ * `input` is normalized with `type` (the hold carries it so).
792
+ */
793
+ #acquireRemote(type, at, input, options, owner) {
794
+ const principal = options?.principal === undefined || this.isSelf(options.principal) ? undefined : options.principal;
795
+ const key = this.#remoteKey(type, input, principal ?? this.principal);
796
+ let binding = this.bindings.get(key);
797
+ const fresh = binding === undefined || binding.ended;
798
+ if (binding === undefined || binding.ended) {
799
+ binding = new RemoteBinding(this, { key, contract: type, at, input, principal });
800
+ this.bindings.set(key, binding);
801
+ }
802
+ const ref = new RemoteRef(binding, owner, options?.state !== false);
803
+ this.#lingering.delete(key);
804
+ binding.addView(ref);
805
+ if (fresh)
806
+ this.#hold(binding);
807
+ return ref;
808
+ }
809
+ /** Holds `binding` over the link to its host: the parent's, a child's, or one a route resolves. */
810
+ #hold(binding) {
611
811
  const bindWhenSynced = (connection) => {
612
812
  connection.then((target) => {
613
- if (handle.status === "loading" && handle.binding === undefined)
614
- target.hold(handle);
615
- }, (error) => handle.gone(error));
813
+ if (!binding.ended && binding.hold === undefined)
814
+ target.hold(binding, false);
815
+ }, (error) => binding.gone(error));
616
816
  };
617
- const direct = this.#childNamed(at);
817
+ const direct = this.#childNamed(binding.at);
618
818
  if (this.upstream !== undefined)
619
- this.upstream.hold(handle);
819
+ this.upstream.hold(binding, false);
620
820
  else if (direct !== undefined)
621
821
  bindWhenSynced(direct.whenSynced().then(() => direct));
622
822
  else {
623
- const route = this.#routeFor(at);
823
+ const route = this.#routeFor(binding.at);
624
824
  if (route === undefined)
625
- handle.gone(new ChordError("unavailable", `no route to ${at}`));
825
+ binding.gone(new ChordError("unavailable", `no route to ${binding.at}`));
626
826
  else
627
- bindWhenSynced(this.#resolve(route, at, principal ?? this.principal));
827
+ bindWhenSynced(this.#resolve(route, binding.at, binding.principal ?? this.principal));
628
828
  }
629
- return handle;
630
829
  }
631
830
  #routeFor(hostName) {
632
831
  let best;
@@ -654,7 +853,7 @@ export class HostImpl {
654
853
  let connection = [...this.connections].find((candidate) => candidate.link === link);
655
854
  connection ??= this.#addConnection(link, {
656
855
  role: "downstream",
657
- principal: route.principal?.(hostName) ?? { host: hostName },
856
+ principal: route.principal(hostName),
658
857
  peer: { kind: "host", name: hostName },
659
858
  });
660
859
  await connection.whenSynced();
@@ -663,12 +862,16 @@ export class HostImpl {
663
862
  return connection;
664
863
  }
665
864
  /**
666
- * Relays a hold from `relay.origin` to the host it names. Relaying needs a route: it is where authorization
865
+ * Relays a reference from `relay.origin` to the host it names. Relaying needs a route: it is where authorization
667
866
  * lives. The origin's authorization generation is re-checked after every await and right before forwarding, so a
668
867
  * revocation during resolution wins.
669
868
  */
670
869
  async relay(relay) {
671
870
  const at = relay.frame.at;
871
+ // The hold names its target; its contract token says where the type lives. They must agree.
872
+ const placement = contractFromDescription(relay.frame.token).description.host;
873
+ if (placement === undefined || resolveHost(relay.frame.token.name, placement, relay.frame.input) !== at)
874
+ throw invalid("placement", `${relay.frame.token.name} with this input does not live on ${at}`);
672
875
  const route = this.#routeFor(at);
673
876
  if (route === undefined) {
674
877
  throw this.#childNamed(at) === undefined
@@ -681,7 +884,7 @@ export class HostImpl {
681
884
  relay.open(down);
682
885
  }
683
886
  reload(plugins, options = {}) {
684
- // Client-local facet epochs never cross the parent link; only child hosts require cluster coordination.
887
+ // Client-local facet code versions never cross the parent link; only child hosts require cluster coordination.
685
888
  if (this.kind !== "client" && this.upstream !== undefined) {
686
889
  return Promise.reject(new ChordError("unavailable", "reloads are coordinated by the server", { reason: "not_coordinator" }));
687
890
  }
@@ -701,19 +904,23 @@ export class HostImpl {
701
904
  this.remoteRemoved[prefix] = [...new Set([...(this.remoteRemoved[prefix] ?? []), ...removed])].filter((module) => !modules.includes(module));
702
905
  }
703
906
  }
704
- /** The epoch id the next reload would commit. */
705
- peekNextEpoch() {
706
- return this.#epochSeq + 1;
907
+ /** The code version id the next reload would commit. */
908
+ peekNextCodeVersion() {
909
+ return this.#codeVersionSeq + 1;
707
910
  }
708
911
  /**
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.
912
+ * Disposes every object, holders before what they reference (a resource's `close` may still use the objects its object
913
+ * references, local or remote), then closes the links.
711
914
  */
712
915
  async close() {
713
- this.eager.clear();
916
+ for (const binding of [...this.#lingering.values()])
917
+ binding.close();
918
+ this.hostObjects.clear();
919
+ for (const name of [...this.#facetRuns.keys()])
920
+ this.facetRunChanged(name, undefined);
714
921
  const reason = new ChordError("gone", `host ${this.name} closed`);
715
922
  try {
716
- await disposeHoldersFirst([...this.records.values()], reason, "host close: disposal failed");
923
+ await disposeHoldersFirst([...this.objects.values()], reason, "host close: disposal failed");
717
924
  }
718
925
  finally {
719
926
  for (const connection of [...this.connections])
@@ -722,35 +929,35 @@ export class HostImpl {
722
929
  }
723
930
  }
724
931
  /**
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
932
+ * Force-disposes `objects` in waves: an object goes once no object of the set that references it is left, so holders are
933
+ * disposed (and their resources closed) while what they reference is still live. In-host references are acyclic. Errors of
727
934
  * every disposal are collected.
728
935
  */
729
- async function disposeHoldersFirst(records, reason, message) {
730
- const remaining = new Set(records);
731
- const held = (record) => {
936
+ async function disposeHoldersFirst(objects, reason, message) {
937
+ const remaining = new Set(objects);
938
+ const held = (object) => {
732
939
  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);
940
+ for (const decls of object.allDecls()) {
941
+ for (const ref of decls.refs.values())
942
+ if (ref.object !== undefined)
943
+ result.add(ref.object);
737
944
  }
738
945
  return result;
739
946
  };
740
- const holds = new Map(records.map((record) => [record, held(record)]));
947
+ const refs = new Map(objects.map((object) => [object, held(object)]));
741
948
  const errors = [];
742
949
  while (remaining.size > 0) {
743
950
  const heldByRemaining = new Set();
744
- for (const record of remaining)
745
- for (const target of holds.get(record) ?? [])
951
+ for (const object of remaining)
952
+ for (const target of refs.get(object) ?? [])
746
953
  heldByRemaining.add(target);
747
- let wave = [...remaining].filter((record) => !heldByRemaining.has(record));
954
+ let wave = [...remaining].filter((object) => !heldByRemaining.has(object));
748
955
  // A cycle cannot happen in-host; dispose the rest together rather than loop.
749
956
  if (wave.length === 0)
750
957
  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)));
958
+ for (const object of wave)
959
+ remaining.delete(object);
960
+ const results = await Promise.allSettled(wave.map((object) => object.dispose(true, reason)));
754
961
  for (const result of results)
755
962
  if (result.status === "rejected")
756
963
  errors.push(result.reason);
@@ -769,25 +976,23 @@ function sameTable(a, b) {
769
976
  const matchesPattern = (pattern, principal) => principal !== null &&
770
977
  typeof principal === "object" &&
771
978
  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
979
  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. */
980
+ /** True if `from` references `to`, directly or transitively, through object-owned references on this host. */
776
981
  function reaches(from, to) {
777
982
  const seen = new Set();
778
983
  const stack = [from];
779
984
  while (stack.length > 0) {
780
- const record = stack.pop();
781
- if (record === to)
985
+ const object = stack.pop();
986
+ if (object === to)
782
987
  return true;
783
- if (seen.has(record))
988
+ if (seen.has(object))
784
989
  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);
990
+ seen.add(object);
991
+ // Remote references have no object here: cycles across hosts are avoided by convention.
992
+ for (const decls of object.allDecls()) {
993
+ for (const ref of decls.refs.values())
994
+ if (ref.object !== undefined)
995
+ stack.push(ref.object);
791
996
  }
792
997
  }
793
998
  return false;