@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
@@ -1,18 +1,39 @@
1
1
  import { Value } from "typebox/value";
2
- import { Cell, Derived, isSource, notReady } from "./cell.js";
3
- import { bind, LOCAL_VARIANT, } from "./contract.js";
4
- import { declKind, } from "./declare.js";
2
+ import { Cell, Derived, HYDRATION, hydrationOf, isSource, notReady, unhydrated, } from "./cell.js";
3
+ import { bind, LOCAL_VARIANT, takesNoArgs, } from "./contract.js";
5
4
  import { asChordError, ChordError, invalid, runAll } from "./errors.js";
6
5
  import { enqueue } from "./publish.js";
7
- import { assertValid } from "./schema.js";
6
+ import { assertValid, normalize } from "./schema.js";
8
7
  const noop = () => { };
9
- export const makeService = (contract, factory, options, facet) => ({
10
- contract,
11
- factory: factory,
12
- options: options,
13
- facet,
14
- stateVersion: options.state?.version ?? 1,
15
- });
8
+ export const makeService = (contract, factory, options, facet) => {
9
+ checkLifetime(contract, options);
10
+ return {
11
+ contract,
12
+ factory: factory,
13
+ options: options,
14
+ facet,
15
+ };
16
+ };
17
+ /** Whether objects of `service` have `lifetime: "host"`. */
18
+ export const hostLifetime = (service) => service.options.lifetime === "host";
19
+ /** Refuses lifetimes the type cannot have, when it is served. */
20
+ function checkLifetime(contract, options) {
21
+ const lifetime = options.lifetime ?? "referenced";
22
+ if (lifetime !== "referenced" && lifetime !== "host")
23
+ throw new TypeError(`${contract.name}: unknown lifetime ${String(lifetime)}`);
24
+ if (lifetime !== "host")
25
+ return;
26
+ if (contract.scope !== "local")
27
+ throw new TypeError(`${contract.name}: lifetime "host" needs a local type (remote types are referenced)`);
28
+ if (options.linger !== undefined)
29
+ throw new TypeError(`${contract.name}: an object of lifetime "host" does not linger (it lives as long as the host)`);
30
+ try {
31
+ normalize(contract.input, {}, `${contract.name} input`);
32
+ }
33
+ catch {
34
+ throw new TypeError(`${contract.name}: lifetime "host" needs an input that accepts {} (the object's one address)`);
35
+ }
36
+ }
16
37
  /** Runtime form of `Exposed<T>`. */
17
38
  class ExposedImpl {
18
39
  resource;
@@ -22,6 +43,7 @@ class ExposedImpl {
22
43
  }
23
44
  export class ResourceImpl {
24
45
  key;
46
+ /** The declaration of the factory run whose code is current: a kept resource adopts the new one at commit. */
25
47
  spec;
26
48
  deps;
27
49
  /** `unavailable`: a close/reopen failed during a reload rollback; the value is gone for good. */
@@ -82,6 +104,13 @@ export class ResourceImpl {
82
104
  this.state = "unavailable";
83
105
  this.#value = undefined;
84
106
  }
107
+ /**
108
+ * A reload kept this resource (same version, deps and exclusivity): its later `close` is the new declaration's, and
109
+ * the old declaration (with the old code it closes over) is no longer referenced.
110
+ */
111
+ adopt(spec) {
112
+ this.spec = spec;
113
+ }
85
114
  }
86
115
  /**
87
116
  * What `self.resources` returns: the resource, except that `.value` throws `not_ready` while any factory runs, even
@@ -110,68 +139,142 @@ const resourceOf = (value) => value instanceof ResourceImpl ? value : value !==
110
139
  export class Decls {
111
140
  cells = new Map();
112
141
  cellSchemas = new Map();
113
- holds = new Map();
142
+ cellVersions = new Map();
143
+ cellMigrations = new Map();
144
+ refs = new Map();
114
145
  resources = new Map();
146
+ /** Resources this run kept from the previous one, with this run's declarations (adopted at commit). */
147
+ adopted = new Map();
115
148
  listeners = new Map();
149
+ /** By position (`#0`, `#1`, …): tasks have no names. */
116
150
  tasks = new Map();
151
+ /** By position: derivations have no names and are new at every run. */
117
152
  derives = new Map();
118
- deriveFns = new Map();
119
153
  registrations = new Map();
120
154
  }
155
+ const isStateInit = (value) => value !== null && typeof value === "object" && typeof value.init === "function";
121
156
  // ─── Contexts ────────────────────────────────────────────────────────────────
122
157
  export class CtxImpl {
123
158
  host;
124
159
  signal;
125
160
  rootId;
126
- epoch;
161
+ codeVersion;
127
162
  principal;
128
163
  /** Code holders (tasks): every call they make starts a fresh root. */
129
164
  holder;
130
- #holds = [];
165
+ #refs = [];
166
+ #release;
167
+ /** Aborted when the ctx closes: awaited `get`s give up. */
168
+ #closing = new AbortController();
131
169
  #closed = false;
132
170
  /** True after the call or root that received this ctx settled: it may no longer start work. */
133
171
  get closed() {
134
172
  return this.#closed;
135
173
  }
136
- /** Throws unless this ctx may start work on `host` now (not expired, not aborted, not from another host). */
174
+ /** Throws unless this ctx may start work on `host` (the called reference's host) now: not expired, aborted or foreign. */
137
175
  check(host) {
138
176
  if (this.host !== host)
139
177
  throw invalid("foreign_ctx", "ctx belongs to another host");
140
178
  if (this.#closed)
141
179
  throw new ChordError("aborted", "ctx expired: its call already settled", { reason: "expired" });
142
- if (this.signal.aborted)
143
- throw new ChordError("aborted", "ctx aborted", { cause: this.signal.reason });
180
+ if (this.signal.aborted) {
181
+ const reason = this.signal.reason;
182
+ // A forced retirement aborted the root: its code version is gone, so the next call is `superseded` (retryable).
183
+ if (reason instanceof ChordError && reason.code === "superseded")
184
+ throw new ChordError("superseded", reason.message, { cause: reason });
185
+ throw new ChordError("aborted", "ctx aborted", { cause: reason });
186
+ }
144
187
  }
188
+ /** `signals`: the ctx is aborted with the first of them; it stops following them when it closes. */
145
189
  constructor(host, fields) {
146
190
  this.host = host;
147
- this.signal = fields.signal;
191
+ const linked = linkedSignal(fields.signals);
192
+ this.signal = linked.signal;
193
+ this.#release = linked.release;
148
194
  this.rootId = fields.rootId;
149
- this.epoch = fields.epoch;
195
+ this.codeVersion = fields.codeVersion;
150
196
  this.principal = fields.principal;
151
197
  this.holder = fields.holder ?? false;
152
198
  }
153
- get(type, input, options) {
199
+ async get(type, input, options) {
154
200
  this.check(this.host);
155
- const handle = this.host.acquire(type, input, { principal: this.principal, ...options }, "call");
156
- this.#holds.push(handle);
157
- return handle.proxy;
201
+ const ref = this.host.acquire(type, input, { principal: this.principal, ...options }, "call");
202
+ this.#refs.push(ref);
203
+ await awaitReady(ref, [this.signal, this.#closing.signal], this.host.readyTimeout);
204
+ return ref.proxy;
158
205
  }
159
206
  /**
160
- * A context for a call into `record`: same root and epoch, its own call-owned holds, aborted on forced disposal.
161
- * Code inside an object acts as the host principal.
207
+ * A context for a call into `object`: same root and code version, its own call-owned references, aborted on forced
208
+ * disposal. Code inside an object acts as the host principal.
162
209
  */
163
- child(record) {
210
+ child(object) {
164
211
  return new CtxImpl(this.host, {
165
- signal: AbortSignal.any([this.signal, record.callAbort.signal]),
212
+ signals: [this.signal, object.callAbort.signal],
166
213
  rootId: this.rootId,
167
- epoch: this.epoch,
214
+ codeVersion: this.codeVersion,
168
215
  principal: this.host.principal,
169
216
  });
170
217
  }
171
218
  close() {
172
219
  this.#closed = true;
173
- for (const handle of this.#holds.splice(0))
174
- handle.release();
220
+ this.#closing.abort(new ChordError("aborted", "ctx expired: its call already settled", { reason: "expired" }));
221
+ this.#release();
222
+ for (const ref of this.#refs.splice(0))
223
+ ref.release();
224
+ }
225
+ }
226
+ /**
227
+ * A signal aborted with the first of `sources` to abort, until `release()`. Not `AbortSignal.any`: Node keeps a
228
+ * dependant of a long-lived source (an object's or a code version's) reachable while it has abort listeners, so every
229
+ * call or task context would keep its listeners, and the code they close over, alive as long as the source.
230
+ */
231
+ export function linkedSignal(sources) {
232
+ const controller = new AbortController();
233
+ const detach = [];
234
+ const release = () => {
235
+ for (const remove of detach.splice(0))
236
+ remove();
237
+ };
238
+ for (const source of sources) {
239
+ if (source.aborted) {
240
+ release();
241
+ controller.abort(source.reason);
242
+ break;
243
+ }
244
+ const forward = () => {
245
+ release();
246
+ controller.abort(source.reason);
247
+ };
248
+ source.addEventListener("abort", forward, { once: true });
249
+ detach.push(() => source.removeEventListener("abort", forward));
250
+ }
251
+ return { signal: controller.signal, release };
252
+ }
253
+ /**
254
+ * Waits until `ref` is ready, at most `ms` and until a signal aborts; on failure the reference is released (only it: a
255
+ * binding other references share stays) and the error is rethrown: the activation error, `aborted`, or
256
+ * `unavailable/ready_timeout`.
257
+ */
258
+ export async function awaitReady(ref, signals, ms) {
259
+ const timeout = new AbortController();
260
+ const timer = Number.isFinite(ms)
261
+ ? setTimeout(() => timeout.abort(new ChordError("unavailable", `${ref.address} was not ready after ${ms} ms`, {
262
+ reason: "ready_timeout",
263
+ })), ms)
264
+ : undefined;
265
+ timer?.unref?.();
266
+ const linked = linkedSignal([...signals, timeout.signal]);
267
+ try {
268
+ await raceSignal(ref.ready, linked.signal);
269
+ }
270
+ catch (error) {
271
+ ref.release();
272
+ const reason = linked.signal.reason;
273
+ throw reason instanceof ChordError && reason.reason === "ready_timeout" ? reason : error;
274
+ }
275
+ finally {
276
+ clearTimeout(timer);
277
+ linked.release();
175
278
  }
176
279
  }
177
280
  export async function raceSignal(promise, signal) {
@@ -189,11 +292,11 @@ export async function raceSignal(promise, signal) {
189
292
  signal.removeEventListener("abort", onAbort);
190
293
  }
191
294
  }
192
- export const HANDLE_BASE = new Set([
295
+ export const REF_BASE = new Set([
193
296
  "contract",
194
297
  "status",
195
298
  "ready",
196
- "kind",
299
+ "variant",
197
300
  "shape",
198
301
  "error",
199
302
  "onShape",
@@ -216,43 +319,83 @@ export function notifyStatus(listeners, status, error, report) {
216
319
  }
217
320
  });
218
321
  }
219
- /** Proxy → hold, for `deps: [handle]` and links. */
220
- export const holdsByProxy = new WeakMap();
221
- export const handleOf = (value) => value !== null && typeof value === "object" ? holdsByProxy.get(value) : undefined;
322
+ /** Proxy → reference, for `deps: [ref]` and links. */
323
+ export const refsByProxy = new WeakMap();
324
+ export const refOf = (value) => value !== null && typeof value === "object" ? refsByProxy.get(value) : undefined;
222
325
  /**
223
- * A handle's state member: a stable readable that follows whatever source the current behavior exposes under that
224
- * name. `undefined` until the handle is live (or while the variant lacks the member). Rebinding (reload, variant)
225
- * delivers a reset. Upstream is subscribed only while someone listens.
326
+ * A reference's state member: a stable readable that follows whatever source the current implementation exposes under
327
+ * that name. Reading throws `not_ready` until the reference is live, `not_in_variant` while the variant lacks the
328
+ * member. A listener added before the member is readable gets its snapshot then. Rebinding (reload, variant) delivers a
329
+ * reset. Upstream is subscribed only while someone listens.
226
330
  */
227
331
  class MemberReadable {
228
- #handle;
332
+ #ref;
229
333
  #name;
230
334
  #values = new Set();
231
335
  #events = new Set();
336
+ /** Event listeners that have not had their snapshot yet (added while the member was not readable). */
337
+ #unseen = new Set();
232
338
  #source;
233
339
  #upstream = noop;
234
340
  /** The value listeners last saw; a first upstream snapshot equal to it is not re-announced. */
235
341
  #seen;
236
- constructor(handle, name) {
237
- this.#handle = handle;
342
+ constructor(ref, name) {
343
+ this.#ref = ref;
238
344
  this.#name = name;
239
345
  }
240
346
  get value() {
241
- return this.#current()?.value;
347
+ const ref = this.#ref;
348
+ if (ref.status === "released" || ref.status === "gone")
349
+ throw ref.terminalError();
350
+ if (ref.status !== "live")
351
+ throw notReady(`${ref.address}.${this.#name}`);
352
+ const view = ref.currentView();
353
+ if (view === undefined)
354
+ throw notReady(`${ref.address}.${this.#name}`);
355
+ const source = this.#current();
356
+ if (source === undefined) {
357
+ throw new ChordError("not_in_variant", `${ref.address}: ${this.#name} is not a state member of variant ${variantOf(view) ?? "(local)"}`);
358
+ }
359
+ return source.value;
360
+ }
361
+ /** Not hydrated while the reference is not live, or while its source (a forwarded remote member) is not. */
362
+ get [HYDRATION]() {
363
+ const ref = this.#ref;
364
+ if (ref.status === "live")
365
+ return hydrationOf(this.#current());
366
+ return {
367
+ hydrated: false,
368
+ whenHydrated: async () => {
369
+ await ref.ready;
370
+ await hydrationOf(this.#current())?.whenHydrated();
371
+ },
372
+ };
242
373
  }
243
374
  subscribe(listener) {
244
375
  this.#values.add(listener);
245
- this.#seen = this.value;
246
- this.#safe(() => listener(this.#seen));
376
+ const source = this.#readable();
377
+ if (source !== undefined) {
378
+ this.#seen = { value: source.value };
379
+ this.#safe(() => listener(source.value));
380
+ }
247
381
  this.bind();
248
382
  return () => this.#remove(this.#values, listener);
249
383
  }
250
384
  events(listener) {
251
385
  this.#events.add(listener);
252
- this.#seen = this.value;
253
- this.#safe(() => listener({ type: "snap", value: this.#seen }));
386
+ const source = this.#readable();
387
+ if (source !== undefined) {
388
+ const value = source.value;
389
+ this.#seen = { value };
390
+ this.#safe(() => listener({ type: "snap", value }));
391
+ }
392
+ else
393
+ this.#unseen.add(listener);
254
394
  this.bind();
255
- return () => this.#remove(this.#events, listener);
395
+ return () => {
396
+ this.#unseen.delete(listener);
397
+ this.#remove(this.#events, listener);
398
+ };
256
399
  }
257
400
  /** (Re)subscribes upstream to the current source; a changed source is announced as a reset. */
258
401
  bind() {
@@ -268,17 +411,24 @@ class MemberReadable {
268
411
  this.#upstream = source.events((event) => {
269
412
  const isFirst = first;
270
413
  first = false;
271
- if (isFirst && event.value === this.#seen)
414
+ if (isFirst && this.#unseen.size === 0 && this.#seen !== undefined && event.value === this.#seen.value)
272
415
  return;
273
- this.#seen = event.value;
416
+ this.#seen = { value: event.value };
274
417
  const translated = isFirst ? { type: "reset", value: event.value } : event;
275
418
  const events = [...this.#events];
276
419
  const values = [...this.#values];
420
+ const unseen = new Set(this.#unseen);
421
+ this.#unseen.clear();
277
422
  // Queued: a rebind during a commit must not run observers before the commit completes.
278
423
  enqueue(() => {
279
- for (const listener of events)
280
- if (this.#events.has(listener))
281
- this.#safe(() => listener(translated));
424
+ for (const listener of events) {
425
+ if (!this.#events.has(listener))
426
+ continue;
427
+ const delivered = unseen.has(listener)
428
+ ? { type: "snap", value: event.value }
429
+ : translated;
430
+ this.#safe(() => listener(delivered));
431
+ }
282
432
  for (const listener of values)
283
433
  if (this.#values.has(listener))
284
434
  this.#safe(() => listener(event.value));
@@ -288,16 +438,22 @@ class MemberReadable {
288
438
  stop() {
289
439
  this.#values.clear();
290
440
  this.#events.clear();
441
+ this.#unseen.clear();
291
442
  this.bind();
292
443
  }
293
444
  #current() {
294
- const view = this.#handle.currentView();
295
- if (view === undefined || !this.#handle.inVariant(view, this.#name))
445
+ const view = this.#ref.currentView();
446
+ if (view === undefined || !this.#ref.inVariant(view, this.#name))
296
447
  return undefined;
297
448
  const value = view[this.#name];
298
449
  // Cells and derivations, or any readable an `accepts` adapter maps them to.
299
450
  return isSource(value) || isReadableLike(value) ? value : undefined;
300
451
  }
452
+ /** The current source if its value can be read now (live, in the variant, hydrated). */
453
+ #readable() {
454
+ const source = this.#current();
455
+ return source === undefined || hydrationOf(source)?.hydrated === false ? undefined : source;
456
+ }
301
457
  #remove(set, listener) {
302
458
  set.delete(listener);
303
459
  this.bind();
@@ -307,13 +463,13 @@ class MemberReadable {
307
463
  fn();
308
464
  }
309
465
  catch (error) {
310
- this.#handle.record.host.report(error);
466
+ this.#ref.object.host.report(error);
311
467
  }
312
468
  }
313
469
  }
314
- /** One hold on a record. Exposed through a Proxy so members bind late to the current behavior. */
315
- export class HandleImpl {
316
- record;
470
+ /** One reference on an object. Exposed through a Proxy so members bind late to the current implementation. */
471
+ export class RefImpl {
472
+ object;
317
473
  contract;
318
474
  owner;
319
475
  address;
@@ -322,28 +478,29 @@ export class HandleImpl {
322
478
  status;
323
479
  error;
324
480
  shapeListeners = new Set();
325
- /** Internal: notified when the handle becomes gone (links forward it). */
481
+ /** Internal: notified when the reference becomes gone (links forward it). */
326
482
  goneListeners = new Set();
327
483
  readables = new Map();
328
- bindings = new Map();
484
+ /** Weak: a retired code version's service (and the factory code it holds) must not be kept alive by its consumers. */
485
+ bindings = new WeakMap();
329
486
  views = new WeakMap();
330
487
  callers = new Map();
331
- constructor(record, contract, owner) {
332
- this.record = record;
488
+ constructor(object, contract, owner) {
489
+ this.object = object;
333
490
  this.contract = contract;
334
491
  this.owner = owner;
335
- this.address = record.address;
336
- this.status = record.status === "live" ? "live" : "loading";
337
- this.ready = record.ready.then(() => {
492
+ this.address = object.address;
493
+ this.status = object.status === "live" ? "live" : "loading";
494
+ this.ready = object.ready.then(() => {
338
495
  if (this.status === "gone")
339
496
  throw this.error;
340
497
  if (this.status === "released")
341
- throw new ChordError("released", `handle ${this.address} released`);
498
+ throw new ChordError("released", `reference ${this.address} released`);
342
499
  });
343
500
  this.ready.catch(noop);
344
501
  this.proxy = new Proxy(this, {
345
502
  get: (target, property) => {
346
- if (HANDLE_BASE.has(property)) {
503
+ if (REF_BASE.has(property)) {
347
504
  const value = Reflect.get(target, property, target);
348
505
  return typeof value === "function" ? value.bind(target) : value;
349
506
  }
@@ -351,24 +508,23 @@ export class HandleImpl {
351
508
  return undefined;
352
509
  return target.member(property);
353
510
  },
354
- has: (target, property) => HANDLE_BASE.has(property) || target.member(String(property)) !== undefined,
511
+ has: (target, property) => REF_BASE.has(property) || target.member(String(property)) !== undefined,
355
512
  });
356
- holdsByProxy.set(this.proxy, this);
513
+ refsByProxy.set(this.proxy, this);
357
514
  }
358
- get kind() {
515
+ get variant() {
359
516
  const view = this.currentView();
360
- return view === undefined ? undefined : kindOf(view);
517
+ return view === undefined ? undefined : variantOf(view);
361
518
  }
362
519
  get shape() {
363
- const installed = this.status === "live" ? this.record.currentInstalled() : undefined;
520
+ const installed = this.status === "live" ? this.object.currentInstalled() : undefined;
364
521
  const view = installed === undefined ? undefined : this.#safeView(installed);
365
522
  if (installed === undefined || view === undefined)
366
523
  return undefined;
367
524
  return {
368
- kind: kindOf(view),
369
- members: Object.keys(view).filter((key) => key !== "kind"),
525
+ variant: variantOf(view),
526
+ members: Object.keys(view).filter((key) => key !== "variant"),
370
527
  contract: installed.service.contract.fingerprint,
371
- epoch: installed.epoch,
372
528
  };
373
529
  }
374
530
  onShape(listener) {
@@ -381,7 +537,7 @@ export class HandleImpl {
381
537
  }
382
538
  #statusListeners = new Set();
383
539
  #statusChanged() {
384
- notifyStatus(this.#statusListeners, this.status, this.error, (error) => this.record.host.report(error));
540
+ notifyStatus(this.#statusListeners, this.status, this.error, (error) => this.object.host.report(error));
385
541
  }
386
542
  release() {
387
543
  if (this.status === "released" || this.status === "gone")
@@ -389,7 +545,7 @@ export class HandleImpl {
389
545
  this.status = "released";
390
546
  this.#stopReadables();
391
547
  this.#statusChanged();
392
- this.record.release(this);
548
+ this.object.release(this);
393
549
  }
394
550
  // ─── internal ───
395
551
  live() {
@@ -422,15 +578,15 @@ export class HandleImpl {
422
578
  listener(shape);
423
579
  }
424
580
  catch (error) {
425
- this.record.host.report(error);
581
+ this.object.host.report(error);
426
582
  }
427
583
  }
428
584
  });
429
585
  }
430
586
  terminalError() {
431
- return this.status === "gone" ? this.error : new ChordError("released", `handle ${this.address} released`);
587
+ return this.status === "gone" ? this.error : new ChordError("released", `reference ${this.address} released`);
432
588
  }
433
- /** The behavior as this consumer sees it (through an accepts adapter when bound to another version). */
589
+ /** The implementation as this consumer sees it (through an accepts adapter when bound to another version). */
434
590
  viewOf(installed) {
435
591
  let adapter = this.bindings.get(installed.service);
436
592
  if (!this.bindings.has(installed.service)) {
@@ -438,18 +594,18 @@ export class HandleImpl {
438
594
  this.bindings.set(installed.service, adapter);
439
595
  }
440
596
  if (adapter === undefined)
441
- return installed.behavior;
442
- let view = this.views.get(installed.behavior);
597
+ return installed.implementation;
598
+ let view = this.views.get(installed.implementation);
443
599
  if (view === undefined) {
444
- view = adapter(installed.behavior);
445
- this.views.set(installed.behavior, view);
600
+ view = adapter(installed.implementation);
601
+ this.views.set(installed.implementation, view);
446
602
  }
447
603
  return view;
448
604
  }
449
605
  currentView() {
450
606
  if (this.status !== "live")
451
607
  return undefined;
452
- const installed = this.record.currentInstalled();
608
+ const installed = this.object.currentInstalled();
453
609
  return installed === undefined ? undefined : this.#safeView(installed);
454
610
  }
455
611
  /** The view, or undefined when this consumer no longer binds (reported; calls reject with the bind error). */
@@ -458,14 +614,14 @@ export class HandleImpl {
458
614
  return this.viewOf(installed);
459
615
  }
460
616
  catch (error) {
461
- this.record.host.report(error);
617
+ this.object.host.report(error);
462
618
  return undefined;
463
619
  }
464
620
  }
465
- describe(kind, member) {
621
+ describe(variant, member) {
466
622
  const variants = this.contract.description.variants;
467
- if (kind !== undefined)
468
- return variants[kind]?.[member];
623
+ if (variant !== undefined)
624
+ return variants[variant]?.[member];
469
625
  for (const members of Object.values(variants))
470
626
  if (members[member] !== undefined)
471
627
  return members[member];
@@ -473,7 +629,7 @@ export class HandleImpl {
473
629
  }
474
630
  /** Remote consumers see only the members their contract lists for the current variant. */
475
631
  inVariant(view, name) {
476
- return this.contract.scope !== "remote" || this.describe(kindOf(view), name) !== undefined;
632
+ return this.contract.scope !== "remote" || this.describe(variantOf(view), name) !== undefined;
477
633
  }
478
634
  member(name) {
479
635
  const declared = this.describe(undefined, name);
@@ -497,8 +653,11 @@ export class HandleImpl {
497
653
  caller(name) {
498
654
  let caller = this.callers.get(name);
499
655
  if (caller === undefined) {
500
- caller = (args, ctx) => {
501
- if (this.record.host.inFactory > 0) {
656
+ const noArgs = takesNoArgs(this.contract, name);
657
+ caller = (...given) => {
658
+ // A method without arguments is called as `ref.name(ctx)`.
659
+ const [args, ctx] = (noArgs ? [undefined, given[0]] : given);
660
+ if (this.object.host.inFactory > 0) {
502
661
  throw new ChordError("not_ready", `${this.address}.${name} called inside a factory`);
503
662
  }
504
663
  if (ctx !== undefined && !(ctx instanceof CtxImpl)) {
@@ -507,16 +666,16 @@ export class HandleImpl {
507
666
  if (ctx === undefined || ctx.holder) {
508
667
  // A code holder (task) starts a fresh root per call, cancelled with the holder.
509
668
  try {
510
- ctx?.check(this.record.host);
669
+ ctx?.check(this.object.host);
511
670
  }
512
671
  catch (error) {
513
672
  return Promise.reject(error);
514
673
  }
515
- return this.record.host.root((root) => this.record.call(this, name, args, root), {
674
+ return this.object.host.root((root) => this.object.call(this, name, args, root), {
516
675
  signal: ctx?.signal,
517
676
  });
518
677
  }
519
- return this.record.call(this, name, args, ctx);
678
+ return this.object.call(this, name, args, ctx);
520
679
  };
521
680
  this.callers.set(name, caller);
522
681
  }
@@ -527,42 +686,45 @@ export class HandleImpl {
527
686
  readable.stop();
528
687
  }
529
688
  }
530
- export const kindOf = (behavior) => typeof behavior.kind === "string" ? behavior.kind : undefined;
689
+ export const variantOf = (implementation) => typeof implementation.variant === "string" ? implementation.variant : undefined;
531
690
  /**
532
691
  * Two `select` functions are the same if they are identical or have the same source text: an inline arrow is a new
533
- * object on every factory run, but its code is what matters. (A select whose behavior depends on captured values
692
+ * object on every factory run, but its code is what matters. (A select whose implementation depends on captured values
534
693
  * that change between runs is not detected; such a listener keeps its old subscription until its source changes.)
535
694
  */
536
695
  const sameSelect = (a, b) => a === b || (a !== undefined && b !== undefined && String(a) === String(b));
537
- export class RecordImpl {
696
+ export class ObjectImpl {
538
697
  host;
539
698
  address;
540
699
  typeName;
541
700
  input;
542
701
  principal;
543
- eager;
702
+ /** The one object of a `lifetime: "host"` type: only such objects may register. */
703
+ hostLifetime;
544
704
  status = "activating";
545
705
  ready = Promise.resolve();
546
706
  service;
547
- stateVersion = 0;
548
- /** Calls carrying an older epoch are rejected upgrade_conflict (state bump or exclusive resource replaced). */
549
- minEpoch = 0;
707
+ /** Calls carrying an older code version are rejected upgrade_conflict (state bump or exclusive resource replaced). */
708
+ minCodeVersion = 0;
550
709
  decls = new Decls();
551
710
  /** Declarations of a factory run that has not been installed yet (activation or reload candidate). */
552
711
  pending = new Set();
553
- behaviors = new Map();
554
- holds = new Set();
712
+ implementations = new Map();
713
+ refs = new Set();
555
714
  leases = new Map();
556
715
  openOrder = [];
557
716
  callAbort = new AbortController();
558
717
  activationAbort = new AbortController();
559
718
  gate;
560
- /** Served only by a running reload's pending epoch: listeners, tasks and registrations start at its commit. */
719
+ /** Served only by a running reload's pending code version: listeners, tasks and registrations start at its commit. */
561
720
  deferredRuntime = false;
562
721
  #subscriptions = new Map();
563
722
  #invocations = new Set();
564
723
  #tasks = new Map();
565
- #stopped = [];
724
+ /** Aborted task runs that have not finished yet. */
725
+ #stopped = new Set();
726
+ /** Bumped by every start and stop of the tasks: a delayed start that is no longer the latest is dropped. */
727
+ #taskRuns = 0;
566
728
  #linger;
567
729
  #disposal;
568
730
  constructor(host, identity) {
@@ -571,12 +733,12 @@ export class RecordImpl {
571
733
  this.typeName = identity.typeName;
572
734
  this.input = identity.input;
573
735
  this.principal = identity.principal;
574
- this.eager = identity.eager;
736
+ this.hostLifetime = identity.hostLifetime;
575
737
  }
576
738
  start(after) {
577
739
  this.ready = this.#activate(after).then(() => {
578
- for (const handle of this.holds)
579
- handle.live();
740
+ for (const ref of this.refs)
741
+ ref.live();
580
742
  this.#maybeIdle();
581
743
  }, (cause) => {
582
744
  if (this.status === "activating")
@@ -584,15 +746,15 @@ export class RecordImpl {
584
746
  this.host.forget(this);
585
747
  // A ChordError (forbidden, a dependency's error, ...) keeps its code; anything else is `unavailable`.
586
748
  const error = asChordError(cause, `activating ${this.address}`);
587
- for (const handle of this.holds)
588
- handle.gone(error);
749
+ for (const ref of this.refs)
750
+ ref.gone(error);
589
751
  throw error;
590
752
  });
591
753
  this.ready.catch(noop);
592
754
  }
593
755
  /**
594
756
  * Activation as one transaction: factory, opens, then a synchronous finalization (registry check and publication,
595
- * behaviors, runtime). Every failure, including one during finalization or a release while listeners start,
757
+ * implementations, runtime). Every failure, including one during finalization or a release while listeners start,
596
758
  * unwinds everything this activation did.
597
759
  */
598
760
  async #activate(after) {
@@ -605,35 +767,46 @@ export class RecordImpl {
605
767
  if (service === undefined)
606
768
  throw new ChordError("unavailable", `${this.typeName} is not served`, { reason: "unknown_type" });
607
769
  this.service = service;
608
- this.stateVersion = service.stateVersion;
609
770
  const run = this.run(service, undefined);
610
771
  try {
611
- await this.open(run.created.resources, run.created.holds, signal);
772
+ await this.open(run.created.resources, run.created.refs, signal);
773
+ if (signal.aborted)
774
+ throw abandoned();
775
+ await this.waitHydrated(run);
776
+ if (signal.aborted)
777
+ throw abandoned();
778
+ await this.initCells(run, signal);
612
779
  if (signal.aborted)
613
780
  throw abandoned();
614
781
  this.host.checkRegistrations(this, run.decls);
615
782
  this.decls = run.decls;
616
- for (const [retained, services] of this.host.retainedEpochs()) {
783
+ for (const [retained, services] of this.host.retainedCodeVersions()) {
617
784
  if (services.get(this.typeName) === service) {
618
- this.behaviors.set(retained, { epoch: retained, service, behavior: run.behavior, decls: run.decls });
785
+ this.implementations.set(retained, {
786
+ codeVersion: retained,
787
+ service,
788
+ implementation: run.implementation,
789
+ decls: run.decls,
790
+ });
619
791
  }
620
792
  }
621
- const pending = this.host.pendingEpoch();
622
- if (!this.behaviors.has(this.host.currentEpoch()) && (pending === undefined || !this.behaviors.has(pending))) {
793
+ const pending = this.host.pendingCodeVersion();
794
+ if (!this.implementations.has(this.host.currentCodeVersion()) &&
795
+ (pending === undefined || !this.implementations.has(pending))) {
623
796
  throw new ChordError("superseded", `${this.address}: code changed during activation`);
624
797
  }
625
798
  for (const derived of run.created.derives)
626
799
  derived.attach();
627
800
  this.refreshSchemas();
628
- // Served only by a running reload's pending epoch: new code must not run (listeners, tasks, registrations)
629
- // before that reload commits.
630
- this.deferredRuntime = !this.behaviors.has(this.host.currentEpoch());
801
+ // Served only by a running reload's pending code version: new code must not run (listeners, tasks,
802
+ // registrations) before that reload commits.
803
+ this.deferredRuntime = !this.implementations.has(this.host.currentCodeVersion());
631
804
  if (!this.deferredRuntime) {
632
805
  if (run.decls.registrations.size > 0)
633
806
  this.host.registriesChanged(this);
634
807
  this.startRuntime();
635
808
  }
636
- // A listener starting up may have released the last hold synchronously: no resurrection.
809
+ // A listener starting up may have released the last reference synchronously: no resurrection.
637
810
  if (signal.aborted)
638
811
  throw abandoned();
639
812
  this.status = "live";
@@ -644,11 +817,11 @@ export class RecordImpl {
644
817
  this.stopListeners();
645
818
  this.stopTasks();
646
819
  const registered = this.decls.registrations.size > 0 && !this.deferredRuntime;
647
- this.behaviors.clear();
820
+ this.implementations.clear();
648
821
  this.decls = new Decls();
649
822
  if (registered)
650
823
  this.host.registriesChanged();
651
- await Promise.allSettled([...this.#invocations, ...this.#stopped.splice(0)]);
824
+ await Promise.allSettled([...this.#invocations, ...this.#stopped]);
652
825
  await this.unwind(run).catch((cleanup) => this.host.report(cleanup));
653
826
  throw error;
654
827
  }
@@ -668,9 +841,9 @@ export class RecordImpl {
668
841
  };
669
842
  this.pending.add(self.run(undefined).decls);
670
843
  this.host.inFactory++;
671
- let behavior;
844
+ let implementation;
672
845
  try {
673
- behavior = service.factory(this.input, this.principal, self);
846
+ implementation = service.factory(this.input, this.principal, self);
674
847
  }
675
848
  catch (error) {
676
849
  this.host.inFactory--;
@@ -678,11 +851,11 @@ export class RecordImpl {
678
851
  throw error;
679
852
  }
680
853
  this.host.inFactory--;
681
- const run = self.run(behavior);
854
+ const run = self.run(implementation);
682
855
  try {
683
- if (behavior instanceof Promise)
684
- throw invalid("behavior", `${this.typeName}: factories must be synchronous`);
685
- validateBehavior(service, run.behavior);
856
+ if (implementation instanceof Promise)
857
+ throw invalid("implementation", `${this.typeName}: factories must be synchronous`);
858
+ validateImplementation(service, run.implementation);
686
859
  }
687
860
  catch (error) {
688
861
  discard(run);
@@ -690,8 +863,8 @@ export class RecordImpl {
690
863
  }
691
864
  return run;
692
865
  }
693
- /** Opens resources (dependencies first) and waits for holds; settles all before failing. */
694
- async open(resources, holds, signal) {
866
+ /** Opens resources (dependencies first) and waits for references; settles all before failing. */
867
+ async open(resources, refs, signal) {
695
868
  const opening = new Map();
696
869
  const openOne = (resource) => {
697
870
  let promise = opening.get(resource);
@@ -704,29 +877,85 @@ export class RecordImpl {
704
877
  }
705
878
  return promise;
706
879
  };
707
- const results = await Promise.allSettled([...resources.map(openOne), ...holds.map((hold) => hold.ready)]);
880
+ const ready = this.withinReadyTimeout(Promise.all(refs.map((ref) => ref.ready)), () => refs.filter((ref) => ref.status === "loading").map((ref) => ref.address));
881
+ const results = await Promise.allSettled([...resources.map(openOne), ready]);
708
882
  const failure = results.find((result) => result.status === "rejected");
709
883
  if (failure !== undefined)
710
884
  throw failure.reason;
711
885
  }
712
- /** Undoes the objects a run created: closes its resources (reverse order) and releases its holds. */
886
+ /**
887
+ * Waits until the state sources a run exposes or derives from have their values: a local object goes live only with
888
+ * its state, also where it forwards or derives from a remote reference's member.
889
+ */
890
+ async waitHydrated(run) {
891
+ const exposed = Object.entries(run.implementation ?? {});
892
+ const pending = unhydrated([...run.created.derives, ...exposed.map(([, value]) => value)]);
893
+ if (pending.length === 0)
894
+ return;
895
+ await this.withinReadyTimeout(Promise.all(pending.map((hydration) => hydration.whenHydrated())), () => [
896
+ ...exposed
897
+ .filter(([, value]) => hydrationOf(value)?.hydrated === false)
898
+ .map(([name]) => `${this.address}.${name}`),
899
+ ...run.created.derives
900
+ .filter((derived) => hydrationOf(derived) !== undefined)
901
+ .map((derived) => `derive ${derived.key}`),
902
+ ]);
903
+ }
904
+ /** `work` within the host's `readyTimeout`; a timeout names what is still awaited. */
905
+ async withinReadyTimeout(work, waiting) {
906
+ const ms = this.host.readyTimeout;
907
+ if (!Number.isFinite(ms))
908
+ return work;
909
+ let timer;
910
+ const timeout = new Promise((_, reject) => {
911
+ timer = setTimeout(() => {
912
+ const list = waiting();
913
+ reject(new ChordError("unavailable", `${this.address} was not ready after ${ms} ms${list.length === 0 ? "" : `, waiting on ${list.join(", ")}`} (a reference cycle across hosts?)`, { reason: "ready_timeout" }));
914
+ }, ms);
915
+ timer.unref?.();
916
+ });
917
+ timeout.catch(noop);
918
+ try {
919
+ return await Promise.race([work, timeout]);
920
+ }
921
+ finally {
922
+ clearTimeout(timer);
923
+ }
924
+ }
925
+ /**
926
+ * Fills the cells `run` created with `init`, each in a root of its own, in parallel; settles all before failing. Runs
927
+ * after the run's resources are open and its references live, before anything reads the cells.
928
+ */
929
+ async initCells(run, signal) {
930
+ if (run.created.inits.length === 0)
931
+ return;
932
+ const results = await Promise.allSettled(run.created.inits.map(async ([cell, init]) => {
933
+ const value = await this.host.root((ctx) => init(ctx), { signal });
934
+ if (!signal.aborted)
935
+ cell.initialize(value);
936
+ }));
937
+ const failure = results.find((result) => result.status === "rejected");
938
+ if (failure !== undefined)
939
+ throw failure.reason;
940
+ }
941
+ /** Undoes the objects a run created: closes its resources (reverse order) and releases its references. */
713
942
  async unwind(run) {
714
943
  for (const derived of run.created.derives)
715
944
  derived.detach();
716
945
  const resources = [...run.created.resources].sort((a, b) => this.openOrder.indexOf(b) - this.openOrder.indexOf(a));
717
946
  await runAll([
718
947
  ...resources.map((resource) => () => resource.close(this.openOrder)),
719
- ...run.created.holds.map((hold) => () => hold.release()),
948
+ ...run.created.refs.map((ref) => () => ref.release()),
720
949
  ], `${this.address}: cleanup failed`);
721
950
  }
722
951
  currentInstalled() {
723
- return this.behaviors.get(this.host.currentEpoch()) ?? [...this.behaviors.values()].at(-1);
952
+ return this.implementations.get(this.host.currentCodeVersion()) ?? [...this.implementations.values()].at(-1);
724
953
  }
725
954
  /** Every state source exposed as a remote state member gets that member's schema (checked per write in dev mode). */
726
955
  refreshSchemas() {
727
956
  const schemas = new Map();
728
- for (const installed of this.behaviors.values()) {
729
- for (const [source, list] of memberSchemas(installed.service, installed.behavior)) {
957
+ for (const installed of this.implementations.values()) {
958
+ for (const [source, list] of memberSchemas(installed.service, installed.implementation)) {
730
959
  schemas.set(source, [...(schemas.get(source) ?? []), ...list]);
731
960
  }
732
961
  }
@@ -737,7 +966,7 @@ export class RecordImpl {
737
966
  }
738
967
  allDecls() {
739
968
  const result = new Set([this.decls, ...this.pending]);
740
- for (const installed of this.behaviors.values())
969
+ for (const installed of this.implementations.values())
741
970
  result.add(installed.decls);
742
971
  return result;
743
972
  }
@@ -752,7 +981,7 @@ export class RecordImpl {
752
981
  const value = resource === undefined ? decl.source : resource.value;
753
982
  const source = (decl.select === undefined ? value : decl.select(value));
754
983
  if (typeof source?.subscribe !== "function")
755
- throw invalid("behavior", `listener ${key}: source has no subscribe()`);
984
+ throw invalid("implementation", `listener ${key}: source has no subscribe()`);
756
985
  return source;
757
986
  }
758
987
  /**
@@ -780,6 +1009,15 @@ export class RecordImpl {
780
1009
  buffer: [],
781
1010
  };
782
1011
  fresh.set(key, entry);
1012
+ // State (a readable) is subscribed when the listener activates: it delivers the value current then, not one
1013
+ // of the candidate before the commit (a new derivation computes its value at the commit).
1014
+ if (isSource(source) || isReadableLike(source)) {
1015
+ entry.buffer = undefined;
1016
+ entry.subscribe = () => {
1017
+ entry.unsubscribe = source.subscribe((event) => this.#deliver(key, event));
1018
+ };
1019
+ continue;
1020
+ }
783
1021
  entry.unsubscribe = source.subscribe((event) => {
784
1022
  if (entry.buffer !== undefined)
785
1023
  entry.buffer.push(event);
@@ -803,6 +1041,7 @@ export class RecordImpl {
803
1041
  }
804
1042
  for (const [key, entry] of fresh) {
805
1043
  this.#subscriptions.set(key, entry);
1044
+ entry.subscribe?.();
806
1045
  const buffered = entry.buffer ?? [];
807
1046
  entry.buffer = undefined;
808
1047
  for (const event of buffered)
@@ -819,24 +1058,51 @@ export class RecordImpl {
819
1058
  this.stageListeners(this.decls).activate();
820
1059
  this.startTasks();
821
1060
  }
1061
+ /**
1062
+ * Starts the tasks of the current declarations. Runs aborted by `stopTasks` (a reload, a gate) may still be finishing:
1063
+ * their replacements start once they all finished, or after `taskStopDeadline` (reported), so the old and the new run
1064
+ * of a task never work side by side. A later start or stop, or the object leaving `live`, cancels a delayed start.
1065
+ */
822
1066
  startTasks() {
823
- for (const key of this.decls.tasks.keys())
824
- this.startTask(key, this.host.currentEpoch());
1067
+ const run = ++this.#taskRuns;
1068
+ const start = () => {
1069
+ for (const key of this.decls.tasks.keys())
1070
+ this.#startTask(key, this.host.currentCodeVersion());
1071
+ };
1072
+ if (this.#stopped.size === 0) {
1073
+ start();
1074
+ return;
1075
+ }
1076
+ const deadline = this.host.taskStopDeadline;
1077
+ let timer;
1078
+ const late = new Promise((resolve) => {
1079
+ timer = setTimeout(() => resolve(true), deadline);
1080
+ });
1081
+ const stopped = Promise.allSettled([...this.#stopped]).then(() => false);
1082
+ void Promise.race([stopped, late]).then((missed) => {
1083
+ clearTimeout(timer);
1084
+ if (run !== this.#taskRuns || this.status !== "live")
1085
+ return;
1086
+ if (missed) {
1087
+ this.host.report(new ChordError("aborted", `${this.address}: aborted tasks still running after ${deadline} ms; starting their replacements anyway`));
1088
+ }
1089
+ start();
1090
+ });
825
1091
  }
826
1092
  stopListeners() {
827
1093
  for (const subscription of this.#subscriptions.values())
828
1094
  subscription.unsubscribe();
829
1095
  this.#subscriptions.clear();
830
1096
  }
831
- startTask(key, epoch) {
1097
+ #startTask(key, codeVersion) {
832
1098
  const fn = this.decls.tasks.get(key);
833
1099
  if (fn === undefined)
834
1100
  return;
835
1101
  const controller = new AbortController();
836
1102
  const ctx = new CtxImpl(this.host, {
837
- signal: AbortSignal.any([controller.signal, this.callAbort.signal]),
1103
+ signals: [controller.signal, this.callAbort.signal],
838
1104
  rootId: `task:${this.address}:${key}`,
839
- epoch,
1105
+ codeVersion,
840
1106
  principal: this.host.principal,
841
1107
  holder: true,
842
1108
  });
@@ -856,17 +1122,23 @@ export class RecordImpl {
856
1122
  })();
857
1123
  this.#tasks.set(key, { controller, done });
858
1124
  }
859
- /** Aborts running tasks; their completion is awaited at disposal. */
1125
+ /**
1126
+ * Aborts running tasks (and cancels a delayed start). Their completion is awaited by the next `startTasks` and at
1127
+ * disposal; each leaves `#stopped` when it finishes.
1128
+ */
860
1129
  stopTasks() {
1130
+ this.#taskRuns++;
861
1131
  for (const task of this.#tasks.values()) {
862
1132
  task.controller.abort(new ChordError("aborted", "task stopped"));
863
- this.#stopped.push(task.done);
1133
+ const done = task.done;
1134
+ this.#stopped.add(done);
1135
+ void done.finally(() => this.#stopped.delete(done));
864
1136
  }
865
1137
  this.#tasks.clear();
866
1138
  }
867
1139
  /**
868
- * Delivers one listener event, in publication order. The invocation is a root that holds a lease on this record
869
- * (a reload gate waits for it like for any other root that touched the record), counts as an invocation (disposal
1140
+ * Delivers one listener event, in publication order. The invocation is a root that references a lease on this object
1141
+ * (a reload gate waits for it like for any other root that touched the object), counts as an invocation (disposal
870
1142
  * waits for it) and is cancelled by forced disposal.
871
1143
  */
872
1144
  #deliver(key, event) {
@@ -893,7 +1165,7 @@ export class RecordImpl {
893
1165
  invocation.catch((error) => this.host.report(error));
894
1166
  });
895
1167
  }
896
- /** Opens the record's gate and replays queued listener events through the current listeners. */
1168
+ /** Opens the object's gate and replays queued listener events through the current listeners. */
897
1169
  openGate() {
898
1170
  const gate = this.gate;
899
1171
  this.gate = undefined;
@@ -906,20 +1178,20 @@ export class RecordImpl {
906
1178
  if (this.host.failed !== undefined) {
907
1179
  throw new ChordError("unavailable", `${this.host.name} failed and admits no work`, { reason: "host_failed" });
908
1180
  }
909
- if (!this.host.hasEpoch(ctx.epoch))
910
- throw new ChordError("superseded", `epoch ${ctx.epoch} is retired`);
911
- if (ctx.epoch < this.minEpoch) {
912
- throw new ChordError("upgrade_conflict", `${this.address} was upgraded in epoch ${this.minEpoch}`);
1181
+ if (!this.host.hasCodeVersion(ctx.codeVersion))
1182
+ throw new ChordError("superseded", `code version ${ctx.codeVersion} is retired`);
1183
+ if (ctx.codeVersion < this.minCodeVersion) {
1184
+ throw new ChordError("upgrade_conflict", `${this.address} was upgraded in code version ${this.minCodeVersion}`);
913
1185
  }
914
1186
  if (this.gate !== undefined && !this.gate.grandfathered.has(ctx.rootId)) {
915
1187
  throw new ChordError("upgrade_conflict", `${this.address} is being upgraded`);
916
1188
  }
917
1189
  }
918
- /** Records that the root of `ctx` touched this record (a reload gate grandfathers it). */
1190
+ /** Records that the root of `ctx` touched this object (a reload gate grandfathers it). */
919
1191
  lease(ctx) {
920
1192
  if (this.leases.has(ctx.rootId))
921
1193
  return;
922
- this.leases.set(ctx.rootId, ctx.epoch);
1194
+ this.leases.set(ctx.rootId, ctx.codeVersion);
923
1195
  this.host.touch(ctx.rootId, this);
924
1196
  }
925
1197
  guard = (ctx) => {
@@ -935,52 +1207,65 @@ export class RecordImpl {
935
1207
  return;
936
1208
  this.admit(ctx);
937
1209
  };
938
- async call(handle, name, args, ctx) {
1210
+ async call(ref, name, args, ctx) {
939
1211
  ctx.check(this.host);
940
- if (handle.status === "released" || handle.status === "gone")
941
- throw handle.terminalError();
942
- if (handle.status === "loading")
943
- await raceSignal(handle.ready, ctx.signal);
944
- if (handle.status !== "live" || this.status !== "live") {
945
- throw handle.status === "live"
1212
+ if (ref.status === "released" || ref.status === "gone")
1213
+ throw ref.terminalError();
1214
+ if (ref.status === "loading")
1215
+ await raceSignal(ref.ready, ctx.signal);
1216
+ if (ref.status !== "live" || this.status !== "live") {
1217
+ throw ref.status === "live"
946
1218
  ? new ChordError("gone", `${this.address} is ${this.status}`)
947
- : handle.terminalError();
1219
+ : ref.terminalError();
948
1220
  }
949
1221
  this.admit(ctx);
950
- const installed = this.behaviors.get(ctx.epoch);
1222
+ const installed = this.implementations.get(ctx.codeVersion);
951
1223
  if (installed === undefined)
952
- throw new ChordError("superseded", `${this.address} has no behavior for epoch ${ctx.epoch}`);
953
- const view = handle.viewOf(installed);
954
- const kind = kindOf(view);
955
- const wanted = handle.describe(handle.contract.scope === "remote" ? kind : LOCAL_VARIANT, name);
1224
+ throw new ChordError("superseded", `${this.address} has no implementation for code version ${ctx.codeVersion}`);
1225
+ const view = ref.viewOf(installed);
1226
+ const variant = variantOf(view);
1227
+ const wanted = ref.describe(ref.contract.scope === "remote" ? variant : LOCAL_VARIANT, name);
956
1228
  const fn = view[name];
957
- if (typeof fn !== "function" || (handle.contract.scope === "remote" && wanted?.kind !== "method")) {
958
- throw new ChordError("not_in_variant", `${this.address}: ${name} is not a method of variant ${kind ?? "(local)"}`);
1229
+ if (typeof fn !== "function" || (ref.contract.scope === "remote" && wanted?.kind !== "method")) {
1230
+ throw new ChordError("not_in_variant", `${this.address}: ${name} is not a method of variant ${variant ?? "(local)"}`);
1231
+ }
1232
+ const direct = view === installed.implementation;
1233
+ const schema = direct
1234
+ ? installed.service.contract.description.variants[variant ?? LOCAL_VARIANT]?.[name]
1235
+ : wanted;
1236
+ const method = schema?.kind === "method" ? schema : undefined;
1237
+ const validated = ref.contract.scope === "remote" ? method : undefined;
1238
+ // No arguments: the handler takes only ctx. No result (void): the caller gets undefined, whatever it returned.
1239
+ const takesArgs = method === undefined || method.args !== undefined;
1240
+ const returns = method === undefined || method.result !== undefined;
1241
+ let input;
1242
+ if (takesArgs) {
1243
+ // The additive rule assumes a consumer sends only what its own schema knows, and a provider sees only what its
1244
+ // schema knows: strip with both before validating.
1245
+ const own = ref.contract.scope === "remote" && wanted?.kind === "method" ? wanted.args : undefined;
1246
+ const sent = own === undefined ? args : Value.Clean(own, Value.Clone(args));
1247
+ input = validated?.args ? Value.Clean(validated.args, Value.Clone(sent)) : sent;
1248
+ if (validated?.args)
1249
+ assertValid("args", `${this.address}.${name} args`, validated.args, input);
959
1250
  }
960
- const direct = view === installed.behavior;
961
- const schema = direct ? installed.service.contract.description.variants[kind ?? LOCAL_VARIANT]?.[name] : wanted;
962
- const validated = handle.contract.scope === "remote" && schema?.kind === "method" ? schema : undefined;
963
- // The additive rule assumes a consumer sends only what its own schema knows, and a provider sees only what its
964
- // schema knows: strip with both before validating.
965
- const own = handle.contract.scope === "remote" && wanted?.kind === "method" ? wanted.args : undefined;
966
- const sent = own === undefined ? args : Value.Clean(own, Value.Clone(args));
967
- const input = validated ? Value.Clean(validated.args, Value.Clone(sent)) : sent;
968
- if (validated)
969
- assertValid("args", `${this.address}.${name} args`, validated.args, input);
970
1251
  this.lease(ctx);
971
1252
  const child = ctx.child(this);
972
1253
  let result;
973
1254
  try {
974
- result = await this.#invoke(() => fn(input, child));
1255
+ result = await this.#invoke(() => takesArgs
1256
+ ? fn(input, child)
1257
+ : fn(child));
975
1258
  }
976
1259
  finally {
977
1260
  child.close();
978
1261
  }
979
- if (validated)
1262
+ if (!returns)
1263
+ return undefined;
1264
+ if (validated?.result)
980
1265
  assertValid("result", `${this.address}.${name} result`, validated.result, result);
981
1266
  return result;
982
1267
  }
983
- /** Runs an invocation (method or listener) that keeps the record alive until it settles. */
1268
+ /** Runs an invocation (method or listener) that keeps the object alive until it settles. */
984
1269
  #invoke(fn) {
985
1270
  const invocation = (async () => fn())();
986
1271
  this.#invocations.add(invocation);
@@ -999,19 +1284,19 @@ export class RecordImpl {
999
1284
  gate.drained();
1000
1285
  }
1001
1286
  // ─── lifetime ───
1002
- addHold(handle) {
1003
- this.holds.add(handle);
1287
+ addRef(ref) {
1288
+ this.refs.add(ref);
1004
1289
  if (this.#linger !== undefined)
1005
1290
  clearTimeout(this.#linger);
1006
1291
  this.#linger = undefined;
1007
1292
  }
1008
- release(handle) {
1009
- if (this.holds.delete(handle))
1293
+ release(ref) {
1294
+ if (this.refs.delete(ref))
1010
1295
  this.#maybeIdle();
1011
1296
  }
1012
- /** No holds and no invocations: dispose (after `linger` for a live record). */
1297
+ /** No references and no invocations: dispose (after `linger` for a live object). */
1013
1298
  #maybeIdle() {
1014
- if (this.holds.size > 0 || this.#invocations.size > 0 || this.#linger !== undefined)
1299
+ if (this.refs.size > 0 || this.#invocations.size > 0 || this.#linger !== undefined)
1015
1300
  return;
1016
1301
  if (this.status === "activating") {
1017
1302
  this.#background(this.dispose(false));
@@ -1026,7 +1311,7 @@ export class RecordImpl {
1026
1311
  }
1027
1312
  this.#linger = setTimeout(() => {
1028
1313
  this.#linger = undefined;
1029
- if (this.holds.size === 0 && this.#invocations.size === 0 && this.status === "live") {
1314
+ if (this.refs.size === 0 && this.#invocations.size === 0 && this.status === "live") {
1030
1315
  this.#background(this.dispose(false));
1031
1316
  }
1032
1317
  }, linger);
@@ -1036,7 +1321,7 @@ export class RecordImpl {
1036
1321
  disposal.catch((error) => this.host.report(error));
1037
1322
  }
1038
1323
  /**
1039
- * Disposes the record. Forced: handles become gone at once (new calls are rejected) and running invocations are
1324
+ * Disposes the object. Forced: references become gone at once (new calls are rejected) and running invocations are
1040
1325
  * cancelled. Either way, resources close only after every invocation settled.
1041
1326
  */
1042
1327
  dispose(forced, reason) {
@@ -1052,40 +1337,41 @@ export class RecordImpl {
1052
1337
  this.#linger = undefined;
1053
1338
  const error = reason ?? new ChordError("gone", `${this.address} was disposed`);
1054
1339
  if (forced) {
1055
- for (const handle of this.holds)
1056
- handle.gone(error);
1340
+ for (const ref of this.refs)
1341
+ ref.gone(error);
1057
1342
  this.callAbort.abort(error);
1058
1343
  }
1059
1344
  this.activationAbort.abort(error);
1060
1345
  if (wasActivating)
1061
1346
  await this.ready.catch(noop);
1062
- for (const handle of this.holds)
1063
- handle.gone(error);
1347
+ for (const ref of this.refs)
1348
+ ref.gone(error);
1064
1349
  const decls = this.allDecls();
1065
1350
  this.stopTasks();
1066
1351
  this.stopListeners();
1067
1352
  await Promise.allSettled([...this.#invocations]);
1068
1353
  const registered = [...decls].some((set) => set.registrations.size > 0) && !this.deferredRuntime;
1069
- this.behaviors.clear();
1354
+ this.implementations.clear();
1070
1355
  this.decls = new Decls();
1071
1356
  if (registered)
1072
1357
  this.host.registriesChanged();
1073
- const holds = new Set();
1358
+ const refs = new Set();
1074
1359
  for (const set of decls) {
1075
1360
  for (const derived of set.derives.values())
1076
1361
  derived.detach();
1077
- for (const hold of set.holds.values())
1078
- holds.add(hold);
1362
+ for (const ref of set.refs.values())
1363
+ refs.add(ref);
1079
1364
  }
1080
1365
  try {
1081
1366
  await runAll([
1082
- () => Promise.allSettled(this.#stopped.splice(0)),
1367
+ () => Promise.allSettled([...this.#stopped]),
1083
1368
  ...[...this.openOrder].reverse().map((resource) => () => resource.close(this.openOrder)),
1084
- ...[...holds].map((hold) => () => hold.release()),
1369
+ ...[...refs].map((ref) => () => ref.release()),
1085
1370
  ], `${this.address}: disposal failed`);
1086
1371
  }
1087
1372
  finally {
1088
1373
  this.status = "disposed";
1374
+ this.host.disposed(this);
1089
1375
  }
1090
1376
  }
1091
1377
  }
@@ -1093,43 +1379,43 @@ const isReadableLike = (value) => value !== null &&
1093
1379
  typeof value === "object" &&
1094
1380
  typeof value.events === "function" &&
1095
1381
  typeof value.subscribe === "function";
1096
- /** The remote state-member schemas a behavior exposes, by source. */
1097
- export function memberSchemas(service, behavior) {
1382
+ /** The remote state-member schemas an implementation exposes, by source. */
1383
+ export function memberSchemas(service, implementation) {
1098
1384
  const schemas = new Map();
1099
1385
  const contract = service.contract;
1100
1386
  if (contract.scope !== "remote")
1101
1387
  return schemas;
1102
- const members = contract.description.variants[kindOf(behavior) ?? ""] ?? {};
1388
+ const members = contract.description.variants[variantOf(implementation) ?? ""] ?? {};
1103
1389
  for (const [name, description] of Object.entries(members)) {
1104
- const source = behavior[name];
1390
+ const source = implementation[name];
1105
1391
  if (description.kind !== "state" || !isSource(source))
1106
1392
  continue;
1107
1393
  schemas.set(source, [...(schemas.get(source) ?? []), description.schema]);
1108
1394
  }
1109
1395
  return schemas;
1110
1396
  }
1111
- function validateBehavior(service, behavior) {
1397
+ function validateImplementation(service, implementation) {
1112
1398
  const contract = service.contract;
1113
1399
  const fail = (message) => {
1114
- throw invalid("behavior", `${contract.name}: ${message}`);
1400
+ throw invalid("implementation", `${contract.name}: ${message}`);
1115
1401
  };
1116
- if (behavior === null || typeof behavior !== "object")
1402
+ if (implementation === null || typeof implementation !== "object")
1117
1403
  fail("factory must return an object");
1118
1404
  let members;
1119
1405
  if (contract.scope === "remote") {
1120
- const kind = behavior.kind;
1121
- if (typeof kind !== "string" || contract.description.variants[kind] === undefined)
1122
- fail(`unknown variant ${String(kind)}`);
1123
- members = contract.description.variants[kind];
1124
- for (const key of Object.keys(behavior))
1125
- if (key !== "kind" && members[key] === undefined)
1126
- fail(`${key} is not in variant ${String(kind)}`);
1406
+ const variant = implementation.variant;
1407
+ if (typeof variant !== "string" || contract.description.variants[variant] === undefined)
1408
+ fail(`unknown variant ${String(variant)}`);
1409
+ members = contract.description.variants[variant];
1410
+ for (const key of Object.keys(implementation))
1411
+ if (key !== "variant" && members[key] === undefined)
1412
+ fail(`${key} is not in variant ${String(variant)}`);
1127
1413
  }
1128
1414
  else {
1129
1415
  members = contract.description.variants[LOCAL_VARIANT] ?? {};
1130
1416
  }
1131
1417
  for (const [name, description] of Object.entries(members)) {
1132
- const value = behavior[name];
1418
+ const value = implementation[name];
1133
1419
  if (description.kind === "method" && typeof value !== "function")
1134
1420
  fail(`${name} must be a method`);
1135
1421
  if (description.kind === "state" && !isSource(value) && !isReadableLike(value)) {
@@ -1141,152 +1427,145 @@ function validateBehavior(service, behavior) {
1141
1427
  }
1142
1428
  // ─── The factory's `self` ────────────────────────────────────────────────────
1143
1429
  class SelfImpl {
1144
- #record;
1430
+ #object;
1145
1431
  #previous;
1146
1432
  #decls = new Decls();
1147
- #created = {
1148
- holds: [],
1149
- resources: [],
1150
- derives: [],
1151
- };
1152
- constructor(record, previous) {
1153
- this.#record = record;
1433
+ #created = { refs: [], resources: [], derives: [], inits: [] };
1434
+ constructor(object, previous) {
1435
+ this.#object = object;
1154
1436
  this.#previous = previous;
1155
1437
  }
1156
- run(behavior) {
1157
- return { behavior: behavior, decls: this.#decls, created: this.#created };
1158
- }
1159
- /** Iterates named declarations of one kind, rejecting repeated names and wrong descriptors. */
1160
- *#entries(map, kind, entries, descriptor) {
1161
- if (entries === null || typeof entries !== "object")
1162
- throw new TypeError(`${kind} takes an object of named declarations`);
1163
- for (const [name, value] of Object.entries(entries)) {
1164
- if (map.has(name))
1165
- throw invalid("duplicate", `${this.#record.typeName}: duplicate ${kind} name ${name}`);
1166
- if (descriptor !== undefined && declKind(value) !== descriptor) {
1167
- throw new TypeError(`${kind}.${name} must be declared with ${descriptor}()`);
1168
- }
1169
- yield [name, value];
1170
- }
1171
- }
1172
- state(cells) {
1173
- const result = {};
1174
- const host = this.#record.host;
1175
- for (const [name, decl] of this.#entries(this.#decls.cells, "state", cells, "cell")) {
1176
- const cell = this.#previous?.cells.get(name) ??
1177
- new Cell(name, decl.schema, decl.initial, this.#record.guard, {
1178
- dev: host.dev,
1179
- report: (error) => host.report(error),
1180
- });
1181
- this.#decls.cells.set(name, cell);
1182
- this.#decls.cellSchemas.set(name, decl.schema);
1183
- result[name] = cell;
1438
+ run(implementation) {
1439
+ return { implementation: implementation, decls: this.#decls, created: this.#created };
1440
+ }
1441
+ /** Refuses a second declaration of `name` of one kind in this run (TypeScript cannot see string names). */
1442
+ #unique(map, kind, name) {
1443
+ if (typeof name !== "string" || name === "")
1444
+ throw new TypeError(`${kind} needs a name`);
1445
+ if (map.has(name))
1446
+ throw invalid("duplicate", `${this.#object.typeName}: duplicate ${kind} name ${name}`);
1447
+ }
1448
+ state(name, schema, initial, options = {}) {
1449
+ this.#unique(this.#decls.cells, "state", name);
1450
+ const version = options.version ?? 1;
1451
+ if (!Number.isInteger(version) || version < 1)
1452
+ throw new TypeError(`state ${name}: version must be a positive integer`);
1453
+ const host = this.#object.host;
1454
+ // A JSON value never holds a function: an object with a function `init` is the initializer, not a value.
1455
+ const init = isStateInit(initial) ? initial.init : undefined;
1456
+ let cell = this.#previous?.cells.get(name);
1457
+ if (cell === undefined) {
1458
+ cell = new Cell(name, schema, init === undefined ? { value: initial } : undefined, this.#object.guard, {
1459
+ dev: host.dev,
1460
+ report: (error) => host.report(error),
1461
+ });
1462
+ if (init !== undefined)
1463
+ this.#created.inits.push([cell, init]);
1184
1464
  }
1185
- return result;
1465
+ this.#decls.cells.set(name, cell);
1466
+ this.#decls.cellSchemas.set(name, schema);
1467
+ this.#decls.cellVersions.set(name, version);
1468
+ if (options.migrate !== undefined)
1469
+ this.#decls.cellMigrations.set(name, options.migrate);
1470
+ return cell;
1186
1471
  }
1187
1472
  get(type, input, options) {
1188
- const host = this.#record.host;
1189
- // A prepare rerun (recording mode) binds new holds against the reload's pending contracts: a breaking provider
1473
+ const host = this.#object.host;
1474
+ // A prepare rerun (recording mode) binds new references against the reload's pending contracts: a breaking provider
1190
1475
  // change and its updated consumer reload together.
1191
1476
  const candidate = this.#previous !== undefined;
1192
- const address = host.addressOf(type, input, options?.principal ?? host.principal, options?.at, candidate);
1193
- const declared = this.#decls.holds.get(address);
1477
+ const address = host.addressOf(type, input, options?.principal ?? host.principal, candidate);
1478
+ const declared = this.#decls.refs.get(address);
1194
1479
  if (declared !== undefined) {
1195
1480
  if (declared.contract.fingerprint !== type.fingerprint) {
1196
- throw invalid("duplicate", `${this.#record.typeName}: ${type.name} held twice with different contracts`);
1481
+ throw invalid("duplicate", `${this.#object.typeName}: ${type.name} held twice with different contracts`);
1197
1482
  }
1198
1483
  return declared.proxy;
1199
1484
  }
1200
- const existing = this.#previous?.holds.get(address);
1485
+ const existing = this.#previous?.refs.get(address);
1201
1486
  if (existing !== undefined &&
1202
1487
  existing.contract.fingerprint === type.fingerprint &&
1203
1488
  existing.status !== "gone" &&
1204
1489
  existing.status !== "released") {
1205
- this.#decls.holds.set(address, existing);
1490
+ this.#decls.refs.set(address, existing);
1206
1491
  return existing.proxy;
1207
1492
  }
1208
- const handle = host.acquire(type, input, options, "record", this.#record, candidate);
1209
- this.#decls.holds.set(address, handle);
1210
- this.#created.holds.push(handle);
1211
- return handle.proxy;
1212
- }
1213
- resources(resources) {
1214
- const result = {};
1215
- for (const [name, { spec }] of this.#entries(this.#decls.resources, "resources", resources, "resource")) {
1216
- const deps = (spec.deps ?? []).map((dep) => {
1217
- const resolved = handleOf(dep) ?? resourceOf(dep);
1218
- if (resolved === undefined) {
1219
- throw new TypeError(`resource ${name}: deps must be handles or resources of this record`);
1220
- }
1221
- return resolved;
1222
- });
1223
- const existing = this.#previous?.resources.get(name);
1224
- const kept = existing !== undefined &&
1225
- existing.spec.rev === spec.rev &&
1226
- existing.exclusive === (spec.exclusive === true) &&
1227
- existing.deps.length === deps.length &&
1228
- existing.deps.every((dep, index) => dep === deps[index]);
1229
- const resource = kept ? existing : new ResourceImpl(name, spec, deps);
1230
- if (!kept)
1231
- this.#created.resources.push(resource);
1232
- this.#decls.resources.set(name, resource);
1233
- result[name] = new ResourceFacade(resource, this.#record.host);
1234
- }
1235
- return result;
1236
- }
1237
- listen(listeners) {
1238
- for (const [name, decl] of this.#entries(this.#decls.listeners, "listen", listeners, "listener")) {
1239
- this.#decls.listeners.set(name, decl);
1240
- }
1241
- }
1242
- tasks(tasks) {
1243
- for (const [name, fn] of this.#entries(this.#decls.tasks, "tasks", tasks)) {
1244
- if (typeof fn !== "function")
1245
- throw new TypeError(`tasks.${name} must be a function`);
1246
- this.#decls.tasks.set(name, fn);
1247
- }
1248
- }
1249
- derive(derivations) {
1250
- const result = {};
1251
- const host = this.#record.host;
1252
- for (const [name, { sources, fn }] of this.#entries(this.#decls.derives, "derive", derivations, "derivation")) {
1253
- const existing = this.#previous?.derives.get(name);
1254
- let derived;
1255
- if (existing !== undefined &&
1256
- existing.sources.length === sources.length &&
1257
- existing.sources.every((source, index) => source === sources[index])) {
1258
- derived = existing;
1259
- }
1260
- else {
1261
- derived = new Derived(name, sources, fn, { dev: host.dev, report: (error) => host.report(error) });
1262
- this.#created.derives.push(derived);
1493
+ const ref = host.acquire(type, input, options, "object", this.#object, candidate);
1494
+ this.#decls.refs.set(address, ref);
1495
+ this.#created.refs.push(ref);
1496
+ return ref.proxy;
1497
+ }
1498
+ resource(name, declaration) {
1499
+ this.#unique(this.#decls.resources, "resource", name);
1500
+ if (!Number.isInteger(declaration.version))
1501
+ throw new TypeError(`resource ${name}: version must be an integer`);
1502
+ const deps = (declaration.deps ?? []).map((dep) => {
1503
+ const resolved = refOf(dep) ?? resourceOf(dep);
1504
+ if (resolved === undefined) {
1505
+ throw new TypeError(`resource ${name}: deps must be references or resources of this object`);
1263
1506
  }
1264
- this.#decls.derives.set(name, derived);
1265
- this.#decls.deriveFns.set(name, fn);
1266
- result[name] = derived;
1267
- }
1268
- return result;
1507
+ return resolved;
1508
+ });
1509
+ const existing = this.#previous?.resources.get(name);
1510
+ const kept = existing !== undefined &&
1511
+ existing.spec.version === declaration.version &&
1512
+ existing.exclusive === (declaration.exclusive === true) &&
1513
+ existing.deps.length === deps.length &&
1514
+ existing.deps.every((dep, index) => dep === deps[index]);
1515
+ const resource = kept ? existing : new ResourceImpl(name, declaration, deps);
1516
+ if (kept)
1517
+ this.#decls.adopted.set(existing, declaration);
1518
+ else
1519
+ this.#created.resources.push(resource);
1520
+ this.#decls.resources.set(name, resource);
1521
+ return new ResourceFacade(resource, this.#object.host);
1522
+ }
1523
+ on(name, source, ...rest) {
1524
+ this.#unique(this.#decls.listeners, "listener", name);
1525
+ const [select, fn] = rest.length === 1 ? [undefined, rest[0]] : [rest[0], rest[1]];
1526
+ if (typeof fn !== "function")
1527
+ throw new TypeError(`listener ${name}: the handler must be a function`);
1528
+ if (select !== undefined && typeof select !== "function")
1529
+ throw new TypeError(`listener ${name}: select must be a function`);
1530
+ this.#decls.listeners.set(name, {
1531
+ source,
1532
+ select: select,
1533
+ fn: fn,
1534
+ });
1535
+ }
1536
+ task(fn) {
1537
+ if (typeof fn !== "function")
1538
+ throw new TypeError("a task must be a function");
1539
+ this.#decls.tasks.set(`#${this.#decls.tasks.size}`, fn);
1540
+ }
1541
+ derive(sources, fn) {
1542
+ if (typeof fn !== "function")
1543
+ throw new TypeError("a derivation needs a function");
1544
+ const host = this.#object.host;
1545
+ const key = `#${this.#decls.derives.size}`;
1546
+ const derived = new Derived(key, sources, fn, {
1547
+ dev: host.dev,
1548
+ report: (error) => host.report(error),
1549
+ });
1550
+ this.#created.derives.push(derived);
1551
+ this.#decls.derives.set(key, derived);
1552
+ return derived;
1269
1553
  }
1270
1554
  expose(resource) {
1271
1555
  const resolved = resourceOf(resource);
1272
1556
  if (resolved === undefined || ![...this.#decls.resources.values()].includes(resolved)) {
1273
- throw new TypeError("expose takes a resource declared by this record");
1557
+ throw new TypeError("expose takes a resource declared by this object");
1274
1558
  }
1275
1559
  return new ExposedImpl(resolved);
1276
1560
  }
1277
1561
  register(registry, entries) {
1278
- if (!this.#record.eager)
1279
- throw invalid("not_eager", `${this.#record.typeName}: only eager records may register`);
1280
- const prefixed = {};
1281
- for (const [key, value] of Object.entries(entries))
1282
- prefixed[`${registry.name}\u0000${key}`] = value;
1283
- for (const [id, value] of this.#entries(this.#decls.registrations, "register", prefixed)) {
1284
- this.#decls.registrations.set(id, {
1285
- registry: registry,
1286
- key: id.slice(registry.name.length + 1),
1287
- value,
1288
- });
1562
+ if (!this.#object.hostLifetime)
1563
+ throw invalid("not_host_lifetime", `${this.#object.typeName}: only objects of lifetime "host" may register`);
1564
+ for (const [key, value] of Object.entries(entries)) {
1565
+ const id = `${registry.name}\u0000${key}`;
1566
+ this.#unique(this.#decls.registrations, "register", id);
1567
+ this.#decls.registrations.set(id, { registry: registry, key, value });
1289
1568
  }
1290
1569
  }
1291
1570
  }
1292
- //# sourceMappingURL=record.js.map
1571
+ //# sourceMappingURL=object.js.map