@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/reload.js CHANGED
@@ -1,31 +1,44 @@
1
1
  import { bind, coverage } from "./contract.js";
2
- import { asChordError, ChordError, invalid } from "./errors.js";
2
+ import { asChordError, ChordError, invalid, isChordError } from "./errors.js";
3
+ import { hostLifetime, makeService, memberSchemas, RefImpl, } from "./object.js";
3
4
  import { holdPublications } from "./publish.js";
4
- import { HandleImpl, makeService, memberSchemas, } from "./record.js";
5
5
  import { assertValid, deepEqual } from "./schema.js";
6
- const noop = () => { };
7
6
  /**
8
7
  * Computes the new derivation functions over the candidate values and validates every prospective value (migrated
9
8
  * cells and derivations) against every schema that will apply after the commit: the cell's new schema and the new
10
- * behavior's remote state-member schemas. A failure fails prepare; the commit then installs without re-checking.
9
+ * implementation's remote state-member schemas. A failure fails prepare; the commit then installs without re-checking.
11
10
  */
12
11
  function dryRun(candidate) {
13
- const { run, migrated, service, record } = candidate;
14
- const exposed = memberSchemas(service, run.behavior);
12
+ const { run, migrated, service, object } = candidate;
13
+ const exposed = memberSchemas(service, run.implementation);
15
14
  for (const [cell, value] of migrated) {
16
15
  for (const schema of exposed.get(cell) ?? [])
17
- assertValid("state", `${record.address}: migrated state ${cell.key}`, schema, value);
16
+ assertValid("state", `${object.address}: migrated state ${cell.key}`, schema, value);
18
17
  }
19
18
  const values = new Map(migrated);
20
19
  const derives = [...run.decls.derives].sort(([, a], [, b]) => a.seq - b.seq);
21
20
  for (const [name, derived] of derives) {
22
- const value = derived.compute((source) => (values.has(source) ? values.get(source) : source.value), run.decls.deriveFns.get(name));
21
+ const value = derived.compute((source) => (values.has(source) ? values.get(source) : candidateValue(source)));
23
22
  for (const schema of exposed.get(derived) ?? [])
24
- assertValid("state", `${record.address}: derived ${name}`, schema, value);
23
+ assertValid("state", `${object.address}: derivation ${name}`, schema, value);
25
24
  values.set(derived, value);
26
25
  }
27
26
  candidate.derived = values;
28
27
  }
28
+ /**
29
+ * A source's value for a dry run. A reference to a provider this reload upgrades too reads nothing before the commit
30
+ * (its new contract does not bind the old code): the dry run computes without it, the commit with the real value.
31
+ */
32
+ function candidateValue(source) {
33
+ try {
34
+ return source.value;
35
+ }
36
+ catch (error) {
37
+ if (isChordError(error, "not_ready") || isChordError(error, "not_in_variant"))
38
+ return undefined;
39
+ throw error;
40
+ }
41
+ }
29
42
  /** A reload error keeps the underlying code (`contract_mismatch`, `invalid`, ...) and says it aborted the reload. */
30
43
  export function reloadError(error) {
31
44
  const cause = asChordError(error, "reload");
@@ -49,30 +62,49 @@ export async function runReload(host, plugins, options) {
49
62
  }
50
63
  }
51
64
  /** Takes the host's reload slot and loads the plugin modules. Pair with `endReload`. */
52
- export async function beginReload(host, plugins, options, epoch) {
65
+ export async function beginReload(host, plugins, options, codeVersion) {
53
66
  if (host.reloading) {
54
67
  throw new ChordError("unavailable", "a reload is already running", { reason: "reload_running" });
55
68
  }
56
69
  host.reloading = true;
57
70
  try {
58
- return new Reload(host, await host.resolve(plugins), options, epoch);
71
+ return new Reload(host, await host.resolve(plugins), options, codeVersion);
59
72
  }
60
73
  catch (error) {
61
- host.reloading = false;
74
+ host.reloadEnded();
62
75
  throw reloadError(error);
63
76
  }
64
77
  }
65
78
  export function endReload(host) {
66
- host.reloading = false;
67
- host.maybeRetire();
79
+ host.reloadEnded();
80
+ }
81
+ /** Most milliseconds a forced retirement waits for aborted roots to settle before it closes what they used. */
82
+ const ABORTED_ROOTS_GRACE = 1000;
83
+ /** Resolves true once `ready()` holds (checked whenever retirement-relevant state changes), false after `ms`. */
84
+ function until(host, ready, ms) {
85
+ if (ready())
86
+ return Promise.resolve(true);
87
+ return new Promise((resolve) => {
88
+ const timer = setTimeout(() => {
89
+ stop();
90
+ resolve(false);
91
+ }, ms);
92
+ const stop = host.watchRetirement(() => {
93
+ if (!ready())
94
+ return;
95
+ clearTimeout(timer);
96
+ stop();
97
+ resolve(true);
98
+ });
99
+ });
68
100
  }
69
101
  /** One reload on one host: `prepare` (load, prepare, gate), then `commit` or `rollback`. */
70
102
  export class Reload {
71
103
  host;
72
104
  facets;
73
105
  options;
74
- /** Epoch id to commit (issued by the coordinating server); a local reload allocates one. */
75
- epoch;
106
+ /** Code version id to commit (issued by the coordinating server); a local reload allocates one. */
107
+ codeVersion;
76
108
  candidates = new Map();
77
109
  abort = new AbortController();
78
110
  closed = [];
@@ -82,13 +114,15 @@ export class Reload {
82
114
  tables = new Map();
83
115
  gated = [];
84
116
  rolledBack = false;
85
- /** Records of types this reload adds, with their staged listeners: their runtime starts at commit. */
117
+ /** Objects of types this reload adds, with their staged listeners: their runtime starts at commit. */
86
118
  deferred = new Map();
87
- constructor(host, facets, options, epoch) {
119
+ /** This reload's run of each facet it loads (`FacetApi.signal`): installed at commit, aborted at rollback. */
120
+ facetRuns = new Map();
121
+ constructor(host, facets, options, codeVersion) {
88
122
  this.host = host;
89
123
  this.facets = facets;
90
124
  this.options = options;
91
- this.epoch = epoch;
125
+ this.codeVersion = codeVersion;
92
126
  }
93
127
  /** Everything up to the commit. On failure the host is rolled back and the underlying error is thrown. */
94
128
  async prepare() {
@@ -97,11 +131,11 @@ export class Reload {
97
131
  this.load();
98
132
  this.checkProviders();
99
133
  await this.prepareAll();
100
- // From here new records of changed types are rejected (upgrade_conflict, retryable) until commit or abort,
134
+ // From here new objects of changed types are rejected (upgrade_conflict, retryable) until commit or abort,
101
135
  // so the candidate set is final once the activations already in flight have been prepared.
102
136
  this.host.gating = this.changed;
103
137
  await this.withDeadline(this.prepareAll(), "activations in flight did not finish");
104
- this.checkHolds();
138
+ this.checkRefs();
105
139
  this.tables = this.host.buildTables(this.entries());
106
140
  await this.gate();
107
141
  this.#alive();
@@ -114,14 +148,14 @@ export class Reload {
114
148
  }
115
149
  /** What a successful prepare will commit (a child sends it with its vote). */
116
150
  summary() {
117
- const live = [...this.candidates.values()].filter(({ record }) => record.status === "live");
151
+ const live = [...this.candidates.values()].filter(({ object }) => object.status === "live");
118
152
  return {
119
- epoch: this.host.pending?.id ?? this.host.current.id,
153
+ codeVersion: this.host.pending?.id ?? this.host.current.id,
120
154
  previous: this.host.current.id,
121
155
  changed: [...this.changed],
122
156
  removed: [...this.removed],
123
- prepared: live.map(({ record }) => record.address),
124
- gated: this.gated.map(({ record }) => record.address),
157
+ prepared: live.map(({ object }) => object.address),
158
+ gated: this.gated.map(({ object }) => object.address),
125
159
  };
126
160
  }
127
161
  /** After every await of prepare: a rollback meanwhile (abort from the coordinator) ends it. */
@@ -131,33 +165,37 @@ export class Reload {
131
165
  }
132
166
  /**
133
167
  * Subscribes every new listener now, buffering its events, so a failing source fails prepare instead of leaving a
134
- * half-installed epoch. Records running only the new code (types this reload adds) are staged too.
168
+ * half-installed code version. Objects running only the new code (types this reload adds) are staged too.
135
169
  */
136
170
  #stage() {
137
171
  for (const candidate of this.candidates.values()) {
138
- if (candidate.record.status === "live")
139
- candidate.staged = candidate.record.stageListeners(candidate.run.decls);
172
+ if (candidate.object.status === "live")
173
+ candidate.staged = candidate.object.stageListeners(candidate.run.decls);
140
174
  }
141
- for (const record of this.host.records.values()) {
142
- if (record.status === "live" && record.deferredRuntime)
143
- this.deferred.set(record, record.stageListeners(record.decls));
175
+ for (const object of this.host.objects.values()) {
176
+ if (object.status === "live" && object.deferredRuntime)
177
+ this.deferred.set(object, object.stageListeners(object.decls));
144
178
  }
145
179
  }
180
+ /**
181
+ * Retires the previous code version before loading the next one (a host keeps two). Waits until its roots ended and,
182
+ * on a child host, its parent retired it too (`retire` frame), at most `retireDeadline`. Past the deadline it is
183
+ * retired anyway: its remaining roots are aborted first and get a short grace to stop, so they see cancellation
184
+ * before the resources only that code version used are closed; their next call is `superseded`.
185
+ */
146
186
  async waitForRetirement() {
147
187
  const host = this.host;
148
- if (host.previous === undefined)
188
+ const previous = host.previous;
189
+ if (previous === undefined)
149
190
  return;
150
- if (host.previous.roots.size > 0) {
151
- let timer;
152
- await new Promise((resolve) => {
153
- host.retired = resolve;
154
- timer = setTimeout(resolve, this.options.retireDeadline ?? 5000);
155
- });
156
- clearTimeout(timer);
157
- host.retired = noop;
158
- }
159
- // Roots of the dropped epoch still running get `superseded` on their next call.
160
- host.retire();
191
+ const deadline = this.options.retireDeadline ?? 5000;
192
+ const gone = () => host.previous !== previous;
193
+ if (!(await until(host, () => gone() || host.retirable(), deadline)) && !gone()) {
194
+ previous.abort.abort(new ChordError("superseded", `code version ${previous.id} was retired`));
195
+ await until(host, () => gone() || previous.roots.size === 0, Math.min(deadline, ABORTED_ROOTS_GRACE));
196
+ }
197
+ if (!gone())
198
+ host.retire();
161
199
  }
162
200
  /** Builds the candidate service table: unchanged services plus everything the given facets serve. */
163
201
  load() {
@@ -165,7 +203,9 @@ export class Reload {
165
203
  const current = this.host.current.services;
166
204
  this.services = new Map([...current].filter(([, service]) => !replaced.has(service.facet)));
167
205
  for (const facet of this.facets) {
168
- facet.setup(this.host.facetApi(((type, factory, options = {}) => this.host.serveInto(this.services, makeService(type, factory, options, facet.name)))));
206
+ const run = new AbortController();
207
+ this.facetRuns.set(facet.name, run);
208
+ facet.setup(this.host.facetApi(((type, factory, options = {}) => this.host.serveInto(this.services, makeService(type, factory, options, facet.name))), run.signal));
169
209
  }
170
210
  for (const [name, service] of this.services)
171
211
  if (current.get(name) !== service)
@@ -174,10 +214,11 @@ export class Reload {
174
214
  if (!this.services.has(name))
175
215
  this.removed.add(name);
176
216
  this.host.pending = {
177
- id: this.host.nextEpochId(this.epoch),
217
+ id: this.host.nextCodeVersionId(this.codeVersion),
178
218
  services: this.services,
179
219
  tables: new Map(),
180
220
  roots: new Map(),
221
+ abort: new AbortController(),
181
222
  };
182
223
  }
183
224
  async withDeadline(promise, what) {
@@ -219,33 +260,33 @@ export class Reload {
219
260
  }
220
261
  }
221
262
  /**
222
- * Prepares every live record of a changed type, including records that activate meanwhile, to a fixpoint. Records
263
+ * Prepares every live object of a changed type, including objects that activate meanwhile, to a fixpoint. Objects
223
264
  * already running the new code (types this reload adds) are skipped.
224
265
  */
225
266
  async prepareAll() {
226
267
  for (;;) {
227
- const todo = [...this.host.records.values()].filter((record) => this.changed.has(record.typeName) &&
228
- !this.candidates.has(record) &&
229
- record.service !== this.services.get(record.typeName) &&
230
- (record.status === "live" || record.status === "activating"));
268
+ const todo = [...this.host.objects.values()].filter((object) => this.changed.has(object.typeName) &&
269
+ !this.candidates.has(object) &&
270
+ object.service !== this.services.get(object.typeName) &&
271
+ (object.status === "live" || object.status === "activating"));
231
272
  if (todo.length === 0)
232
273
  return;
233
- await Promise.allSettled(todo.map((record) => record.ready));
274
+ await Promise.allSettled(todo.map((object) => object.ready));
234
275
  this.#alive();
235
- for (const record of todo) {
236
- if (record.status === "live" && record.service !== this.services.get(record.typeName)) {
237
- await this.prepareRecord(record);
276
+ for (const object of todo) {
277
+ if (object.status === "live" && object.service !== this.services.get(object.typeName)) {
278
+ await this.prepareObject(object);
238
279
  this.#alive();
239
280
  }
240
281
  }
241
282
  }
242
283
  }
243
284
  /** Runs the new factory in recording mode and opens what can be opened without the gate. */
244
- async prepareRecord(record) {
245
- const service = this.services.get(record.typeName);
246
- const run = record.run(service, record.decls);
285
+ async prepareObject(object) {
286
+ const service = this.services.get(object.typeName);
287
+ const run = object.run(service, object.decls);
247
288
  const candidate = {
248
- record,
289
+ object,
249
290
  service,
250
291
  run,
251
292
  reasons: [],
@@ -255,20 +296,19 @@ export class Reload {
255
296
  derived: new Map(),
256
297
  staged: undefined,
257
298
  };
258
- this.candidates.set(record, candidate);
259
- const bumped = service.stateVersion !== record.stateVersion;
260
- if (bumped)
261
- candidate.reasons.push(`state version ${record.stateVersion} -> ${service.stateVersion}`);
299
+ this.candidates.set(object, candidate);
300
+ // Each cell carries its own version: a raised one gates and migrates, a schema change without one is refused.
262
301
  for (const [name, cell] of run.decls.cells) {
263
- if (record.decls.cells.get(name) !== cell || deepEqual(cell.schema, run.decls.cellSchemas.get(name)))
302
+ if (object.decls.cells.get(name) !== cell)
264
303
  continue;
265
- if (!bumped) {
266
- throw invalid("state", `${record.address}: schema of state ${name} changed without a state-version bump`);
267
- }
268
- }
269
- for (const name of Object.keys(service.options.state?.migrate ?? {})) {
270
- if (!run.decls.cells.has(name))
271
- throw invalid("state", `${record.address}: migrate for undeclared state ${name}`);
304
+ const from = object.decls.cellVersions.get(name) ?? 1;
305
+ const to = run.decls.cellVersions.get(name) ?? 1;
306
+ if (to < from)
307
+ throw invalid("state", `${object.address}: version of state ${name} lowered from ${from} to ${to}`);
308
+ if (to > from)
309
+ candidate.reasons.push(`state ${name} version ${from} -> ${to}`);
310
+ else if (!deepEqual(cell.schema, run.decls.cellSchemas.get(name)))
311
+ throw invalid("state", `${object.address}: schema of state ${name} changed without a version bump`);
272
312
  }
273
313
  const deferred = new Set();
274
314
  for (const resource of run.created.resources) {
@@ -277,57 +317,62 @@ export class Reload {
277
317
  }
278
318
  for (const resource of deferred) {
279
319
  candidate.atGate.push(resource);
280
- const old = record.decls.resources.get(resource.key);
320
+ const old = object.decls.resources.get(resource.key);
281
321
  if (resource.exclusive && old?.exclusive && old.state === "open")
282
322
  candidate.replaced.push(old);
283
323
  }
284
324
  if (deferred.size > 0)
285
325
  candidate.reasons.push("exclusive resource replaced");
286
- if (candidate.reasons.length === 0)
326
+ await object.open(run.created.resources.filter((resource) => !deferred.has(resource)), run.created.refs, this.abort.signal);
327
+ await object.waitHydrated(run);
328
+ // Cells this reload adds read their first value once the new resources are open (gated objects: at the gate);
329
+ // the derivations over them are checked after.
330
+ if (candidate.reasons.length === 0) {
331
+ await object.initCells(run, this.abort.signal);
287
332
  dryRun(candidate);
288
- await record.open(run.created.resources.filter((resource) => !deferred.has(resource)), run.created.holds, this.abort.signal);
333
+ }
289
334
  }
290
335
  /**
291
- * Every hold that survives into the new epoch must bind to the new provider. Browser holds don't block a reload:
292
- * after the commit they get `upgrade_required` instead.
336
+ * Every reference that survives into the new code version must bind to the new provider. Browser references don't block a
337
+ * reload: after the commit they get `upgrade_required` instead.
293
338
  */
294
- checkHolds() {
339
+ checkRefs() {
295
340
  const dropped = new Set();
296
- for (const { record, run } of this.candidates.values()) {
297
- for (const hold of record.decls.holds.values())
298
- if (![...run.decls.holds.values()].includes(hold))
299
- dropped.add(hold);
300
- }
301
- for (const record of this.host.records.values()) {
302
- const service = this.services.get(record.typeName);
303
- if (service === undefined || !this.changed.has(record.typeName))
341
+ for (const { object, run } of this.candidates.values()) {
342
+ for (const ref of object.decls.refs.values())
343
+ if (![...run.decls.refs.values()].includes(ref))
344
+ dropped.add(ref);
345
+ }
346
+ for (const object of this.host.objects.values()) {
347
+ const service = this.services.get(object.typeName);
348
+ if (service === undefined || !this.changed.has(object.typeName))
304
349
  continue;
305
- for (const hold of record.holds) {
306
- // The host's bootstrap hold follows this provider at commit; it is not an old-code consumer.
307
- if (hold === this.host.eager.get(record.typeName))
350
+ for (const ref of object.refs) {
351
+ // The host's bootstrap reference follows this provider at commit; it is not an old-code consumer.
352
+ if (ref === this.host.hostObjects.get(object.typeName))
308
353
  continue;
309
- if (!dropped.has(hold) && hold.owner !== "client")
310
- bind(service.contract, service.options.accepts, hold.contract);
354
+ if (!dropped.has(ref) && ref.owner !== "client")
355
+ bind(service.contract, service.options.accepts, ref.contract);
311
356
  }
312
357
  }
313
358
  }
314
359
  entries() {
315
360
  return this.host
316
361
  .liveDecls(true)
317
- .filter(([record]) => !this.removed.has(record.typeName))
318
- .map(([record, decls]) => [record, this.candidates.get(record)?.run.decls ?? decls]);
362
+ .filter(([object]) => !this.removed.has(object.typeName))
363
+ .map(([object, decls]) => [object, this.candidates.get(object)?.run.decls ?? decls]);
319
364
  }
320
365
  /**
321
366
  * Admission cut, wait for grandfathered roots (deadline), replace exclusive resources, migrate into private
322
- * copies. Old tasks of gated records are stopped at the cut; listener events are queued until commit or abort.
367
+ * copies. Old tasks of gated objects are stopped at the cut; listener events are queued until commit or abort.
323
368
  */
324
369
  async gate() {
325
- this.gated = [...this.candidates.values()].filter((candidate) => candidate.reasons.length > 0 && candidate.record.status === "live");
326
- const drained = this.gated.map(({ record }) => {
327
- const grandfathered = new Set(record.leases.keys());
370
+ this.gated = [...this.candidates.values()].filter((candidate) => candidate.reasons.length > 0 && candidate.object.status === "live");
371
+ const drained = this.gated.map(({ object }) => {
372
+ const grandfathered = new Set(object.leases.keys());
328
373
  return new Promise((resolve) => {
329
- record.gate = { grandfathered, queue: [], drained: resolve };
330
- record.stopTasks();
374
+ object.gate = { grandfathered, queue: [], drained: resolve };
375
+ object.stopTasks();
331
376
  if (grandfathered.size === 0)
332
377
  resolve();
333
378
  });
@@ -342,7 +387,7 @@ export class Reload {
342
387
  ]);
343
388
  clearTimeout(timer);
344
389
  if (timedOut) {
345
- const blocking = this.gated.flatMap(({ record }) => [...(record.gate?.grandfathered ?? [])]);
390
+ const blocking = this.gated.flatMap(({ object }) => [...(object.gate?.grandfathered ?? [])]);
346
391
  throw new ChordError("aborted", `gate deadline (${deadline} ms) missed; blocking roots: ${blocking.join(", ")}`, {
347
392
  reason: "gate_deadline",
348
393
  details: { blocking },
@@ -350,35 +395,36 @@ export class Reload {
350
395
  }
351
396
  this.#alive();
352
397
  for (const candidate of this.gated) {
353
- const { record } = candidate;
398
+ const { object } = candidate;
354
399
  for (const old of candidate.replaced) {
355
400
  // Owned by the rollback before closing: a failing close must still be restored.
356
- this.closed.push([record, old]);
357
- await old.close(record.openOrder);
401
+ this.closed.push([object, old]);
402
+ await old.close(object.openOrder);
358
403
  }
359
- await record.open(candidate.atGate, [], this.abort.signal);
404
+ await object.open(candidate.atGate, [], this.abort.signal);
405
+ this.#alive();
406
+ await object.initCells(candidate.run, this.abort.signal);
360
407
  this.#alive();
361
408
  }
362
409
  for (const candidate of this.gated)
363
410
  this.migrate(candidate);
364
411
  }
365
412
  migrate(candidate) {
366
- const { record, service, run } = candidate;
367
- const migrate = service.options.state?.migrate ?? {};
368
- const bumped = service.stateVersion !== record.stateVersion;
413
+ const { object, run } = candidate;
369
414
  for (const [name, cell] of run.decls.cells) {
370
- if (record.decls.cells.get(name) !== cell)
415
+ if (object.decls.cells.get(name) !== cell)
371
416
  continue;
372
417
  const schema = run.decls.cellSchemas.get(name);
373
- const fn = migrate[name];
418
+ const from = object.decls.cellVersions.get(name) ?? 1;
419
+ const fn = run.decls.cellMigrations.get(name);
374
420
  let value;
375
- if (bumped && fn !== undefined)
376
- value = fn(structuredClone(cell.value), record.stateVersion);
421
+ if ((run.decls.cellVersions.get(name) ?? 1) > from && fn !== undefined)
422
+ value = fn(structuredClone(cell.value), from);
377
423
  else if (!deepEqual(cell.schema, schema))
378
424
  value = cell.value;
379
425
  else
380
426
  continue;
381
- assertValid("state", `${record.address}: migrated state ${name}`, schema, value);
427
+ assertValid("state", `${object.address}: migrated state ${name}`, schema, value);
382
428
  candidate.migrated.set(cell, value);
383
429
  }
384
430
  dryRun(candidate);
@@ -388,54 +434,56 @@ export class Reload {
388
434
  return;
389
435
  this.rolledBack = true;
390
436
  this.abort.abort(new ChordError("aborted", "reload aborted"));
437
+ for (const run of this.facetRuns.values())
438
+ run.abort(new ChordError("aborted", "reload aborted"));
391
439
  const report = (error) => this.host.report(error);
392
440
  for (const candidate of this.candidates.values())
393
441
  candidate.staged?.discard();
394
442
  for (const staged of this.deferred.values())
395
443
  staged.discard();
396
- for (const { record, run } of this.candidates.values()) {
397
- record.pending.delete(run.decls);
398
- await record.unwind(run).catch(report);
444
+ for (const { object, run } of this.candidates.values()) {
445
+ object.pending.delete(run.decls);
446
+ await object.unwind(run).catch(report);
399
447
  }
400
448
  // Replaced exclusive resources are restored; one that cannot be is marked unavailable, never silently closed.
401
- for (const [record, resource] of this.closed) {
449
+ for (const [object, resource] of this.closed) {
402
450
  if (resource.state !== "closed")
403
451
  continue;
404
- await resource.open(new AbortController().signal, record.openOrder).catch((error) => {
452
+ await resource.open(new AbortController().signal, object.openOrder).catch((error) => {
405
453
  resource.markUnavailable();
406
- report(new ChordError("unavailable", `${record.address}: resource ${resource.key} could not be restored`, {
454
+ report(new ChordError("unavailable", `${object.address}: resource ${resource.key} could not be restored`, {
407
455
  cause: error,
408
456
  }));
409
457
  });
410
458
  }
411
- for (const { record } of this.gated) {
412
- if (record.gate === undefined)
459
+ for (const { object } of this.gated) {
460
+ if (object.gate === undefined)
413
461
  continue;
414
- record.openGate();
415
- for (const name of record.decls.tasks.keys())
416
- record.startTask(name, this.host.current.id);
462
+ object.openGate();
463
+ object.startTasks();
417
464
  }
418
- // Records of types this reload would have added lose their code.
465
+ // Objects of types this reload would have added lose their code.
419
466
  const reason = new ChordError("gone", "reload aborted; type not served");
420
- for (const record of [...this.host.records.values()]) {
421
- if (!this.host.current.services.has(record.typeName))
422
- await record.dispose(true, reason).catch(report);
467
+ for (const object of [...this.host.objects.values()]) {
468
+ if (!this.host.current.services.has(object.typeName))
469
+ await object.dispose(true, reason).catch(report);
423
470
  }
424
471
  this.host.pending = undefined;
425
472
  this.host.gating = undefined;
426
473
  }
427
474
  /**
428
- * Publishes the new epoch in one synchronous step. Everything fallible ran in prepare (factories, opens, migration
429
- * and derivation checks against the prospective schemas, listener subscriptions), so this only installs. Observers
430
- * (state, shapes, registries, listener roots) are notified after the whole step. If an install still throws, the
431
- * epoch is half-published: the host fails terminally and admits no more work.
475
+ * Publishes the new code version in one synchronous step. Everything fallible ran in prepare (factories, opens,
476
+ * migration and derivation checks against the prospective schemas, listener subscriptions), so this only installs.
477
+ * Observers (state, shapes, registries, listener roots) are notified after the whole step. If an install still
478
+ * throws, the code version is half-published: the host fails terminally and admits no more work.
432
479
  */
433
480
  commit(beforeStart) {
434
481
  try {
435
482
  return holdPublications(() => {
436
483
  const report = this.#publish();
437
- // Installed, not yet started: the coordinator queues its commit frames here, so nothing this epoch starts
438
- // (tasks, eager activations, then the held listener/registry/state notifications) can reach a child first.
484
+ // Installed, not yet started: the coordinator queues its commit frames here, so nothing this code version
485
+ // starts (tasks, host object activations, then the held listener/registry/state notifications) can reach a child
486
+ // first.
439
487
  beforeStart?.();
440
488
  for (const start of this.#starts.splice(0))
441
489
  start();
@@ -447,103 +495,119 @@ export class Reload {
447
495
  throw reloadError(error);
448
496
  }
449
497
  }
450
- /** Work that may start new-epoch roots, run after installation (and after the coordinator's commit frames). */
498
+ /** Work that may start new-code-version roots, run after installation (and after the coordinator's commit frames). */
451
499
  #starts = [];
452
500
  #publish() {
453
501
  const host = this.host;
454
502
  const previous = host.current;
455
503
  const next = host.pending;
456
504
  host.pending = undefined;
457
- host.epochs.set(next.id, next);
505
+ host.codeVersions.set(next.id, next);
458
506
  host.previous = previous;
459
507
  host.current = next;
460
508
  const prepared = [];
461
- for (const record of [...host.records.values()]) {
462
- if (this.removed.has(record.typeName))
509
+ for (const object of [...host.objects.values()]) {
510
+ if (this.removed.has(object.typeName))
463
511
  continue;
464
- const candidate = this.candidates.get(record);
512
+ const candidate = this.candidates.get(object);
465
513
  if (candidate === undefined) {
466
- const installed = record.behaviors.get(previous.id);
514
+ const installed = object.implementations.get(previous.id);
467
515
  if (installed !== undefined)
468
- record.behaviors.set(next.id, { ...installed, epoch: next.id });
516
+ object.implementations.set(next.id, { ...installed, codeVersion: next.id });
469
517
  continue;
470
518
  }
471
- if (record.status !== "live")
519
+ if (object.status !== "live")
472
520
  continue;
473
521
  this.install(candidate, next.id);
474
- prepared.push(record.address);
522
+ prepared.push(object.address);
475
523
  }
476
- for (const { record, run, staged } of this.candidates.values()) {
477
- if (record.status === "live")
524
+ for (const { object, run, staged } of this.candidates.values()) {
525
+ if (object.status === "live")
478
526
  continue;
479
527
  staged?.discard();
480
- record.pending.delete(run.decls);
481
- record.unwind(run).catch((error) => host.report(error));
528
+ object.pending.delete(run.decls);
529
+ object.unwind(run).catch((error) => host.report(error));
482
530
  }
483
- // Records of types this reload adds start their runtime now.
484
- for (const [record, staged] of this.deferred) {
485
- if (record.status !== "live") {
531
+ // Objects of types this reload adds start their runtime now.
532
+ for (const [object, staged] of this.deferred) {
533
+ if (object.status !== "live") {
486
534
  staged.discard();
487
535
  continue;
488
536
  }
489
- record.deferredRuntime = false;
537
+ object.deferredRuntime = false;
490
538
  staged.activate();
491
- this.#starts.push(() => record.startTasks());
539
+ this.#starts.push(() => object.startTasks());
492
540
  }
493
541
  host.installTables(next, this.tables, true);
494
542
  const reason = new ChordError("gone", "type no longer served");
495
- for (const record of [...host.records.values()]) {
496
- if (this.removed.has(record.typeName))
497
- record.dispose(true, reason).catch((error) => host.report(error));
543
+ for (const object of [...host.objects.values()]) {
544
+ if (this.removed.has(object.typeName))
545
+ object.dispose(true, reason).catch((error) => host.report(error));
498
546
  }
499
547
  for (const name of this.removed)
500
- host.eager.delete(name);
548
+ host.hostObjects.delete(name);
501
549
  for (const facet of this.facets)
502
550
  host.facets.set(facet.name, facet);
503
551
  for (const name of this.options.remove ?? [])
504
552
  host.facets.delete(name);
553
+ // The replaced and removed runs' signals abort after the install, like other observers.
554
+ for (const [name, run] of this.facetRuns)
555
+ this.#starts.push(() => host.facetRunChanged(name, run));
556
+ for (const name of this.options.remove ?? [])
557
+ this.#starts.push(() => host.facetRunChanged(name, undefined));
505
558
  host.gating = undefined;
506
559
  for (const [name, service] of this.services) {
507
- if (service.options.eager && this.changed.has(name) && host.eager.get(name)?.record?.status !== "live") {
508
- this.#starts.push(() => host.startEager(service));
560
+ if (hostLifetime(service) && this.changed.has(name) && host.hostObjects.get(name)?.object?.status !== "live") {
561
+ this.#starts.push(() => host.startHostObject(service));
562
+ }
563
+ // A type whose lifetime became "referenced" lives on only while someone else references its object.
564
+ const pinned = host.hostObjects.get(name);
565
+ if (!hostLifetime(service) && pinned !== undefined) {
566
+ host.hostObjects.delete(name);
567
+ this.#starts.push(() => pinned.release());
509
568
  }
510
569
  }
511
570
  return {
512
- epoch: next.id,
571
+ codeVersion: next.id,
513
572
  previous: previous.id,
514
573
  changed: [...this.changed],
515
574
  removed: [...this.removed],
516
575
  prepared,
517
- gated: this.gated.map(({ record }) => record.address),
576
+ gated: this.gated.map(({ object }) => object.address),
518
577
  };
519
578
  }
520
- install(candidate, epoch) {
521
- const { record, run, service } = candidate;
579
+ install(candidate, codeVersion) {
580
+ const { object, run, service } = candidate;
522
581
  const gated = candidate.reasons.length > 0;
523
- record.pending.delete(run.decls);
524
- record.stopTasks();
582
+ object.pending.delete(run.decls);
583
+ object.stopTasks();
525
584
  if (gated) {
526
- // Old derivations would compute over migrated values; the old behavior can no longer run anyway.
585
+ // Old derivations would compute over migrated values; the old implementation can no longer run anyway.
527
586
  const kept = new Set(run.decls.derives.values());
528
- for (const derived of record.decls.derives.values())
587
+ for (const derived of object.decls.derives.values())
529
588
  if (!kept.has(derived))
530
589
  derived.detach();
531
590
  }
532
- record.decls = run.decls;
533
- record.service = service;
534
- record.stateVersion = service.stateVersion;
535
- record.behaviors.set(epoch, { epoch, service, behavior: run.behavior, decls: run.decls });
591
+ object.decls = run.decls;
592
+ object.service = service;
593
+ // Kept resources close with the new code's `close`; replaced ones keep their own (closed at retirement).
594
+ for (const [resource, declaration] of run.decls.adopted)
595
+ resource.adopt(declaration);
596
+ object.implementations.set(codeVersion, {
597
+ codeVersion,
598
+ service,
599
+ implementation: run.implementation,
600
+ decls: run.decls,
601
+ });
536
602
  if (gated) {
537
- // Old code can never run over migrated state: drop the previous behavior now, not at retirement.
538
- record.minEpoch = epoch;
539
- for (const old of [...record.behaviors.keys()])
540
- if (old !== epoch)
541
- this.host.retireBehavior(record, old);
603
+ // Old code can never run over migrated state: drop the previous implementation now, not at retirement.
604
+ object.minCodeVersion = codeVersion;
605
+ for (const old of [...object.implementations.keys()])
606
+ if (old !== codeVersion)
607
+ this.host.retireImplementation(object, old);
542
608
  }
543
609
  // Schemas first, then the values validated against them in prepare.
544
- record.refreshSchemas();
545
- for (const [name, derived] of run.decls.derives)
546
- derived.setFn(run.decls.deriveFns.get(name));
610
+ object.refreshSchemas();
547
611
  for (const [name, cell] of run.decls.cells) {
548
612
  const schema = run.decls.cellSchemas.get(name);
549
613
  if (candidate.migrated.has(cell))
@@ -558,31 +622,31 @@ export class Reload {
558
622
  if (!created.has(derived))
559
623
  derived.refresh();
560
624
  candidate.staged?.activate();
561
- this.#starts.push(() => record.startTasks());
562
- record.openGate();
563
- const eager = this.host.eager.get(record.typeName);
564
- if (eager?.record === record && eager.contract.fingerprint !== service.contract.fingerprint) {
565
- const replacement = new HandleImpl(record, service.contract, "host");
566
- // Acquire first: releasing the only hold must not dispose/reopen the surviving eager record.
567
- record.addHold(replacement);
568
- this.host.eager.set(record.typeName, replacement);
569
- eager.release();
570
- }
571
- for (const handle of [...record.holds]) {
572
- // Browser holds whose contract no longer binds are told to reload their code (they never block a reload).
573
- if (handle.owner === "client") {
625
+ this.#starts.push(() => object.startTasks());
626
+ object.openGate();
627
+ const pinned = this.host.hostObjects.get(object.typeName);
628
+ if (pinned?.object === object && pinned.contract.fingerprint !== service.contract.fingerprint) {
629
+ const replacement = new RefImpl(object, service.contract, "host");
630
+ // Acquire first: releasing the only reference must not dispose/reopen the surviving host object.
631
+ object.addRef(replacement);
632
+ this.host.hostObjects.set(object.typeName, replacement);
633
+ pinned.release();
634
+ }
635
+ for (const ref of [...object.refs]) {
636
+ // Browser references whose contract no longer binds are told to reload their code (they never block a reload).
637
+ if (ref.owner === "client") {
574
638
  try {
575
- bind(service.contract, service.options.accepts, handle.contract);
639
+ bind(service.contract, service.options.accepts, ref.contract);
576
640
  }
577
641
  catch (error) {
578
- handle.gone(new ChordError("upgrade_required", `${record.typeName} changed; reload the web facets`, {
642
+ ref.gone(new ChordError("upgrade_required", `${object.typeName} changed; reload the web facets`, {
579
643
  cause: error,
580
644
  }));
581
- record.release(handle);
645
+ object.release(ref);
582
646
  continue;
583
647
  }
584
648
  }
585
- handle.shapeChanged();
649
+ ref.shapeChanged();
586
650
  }
587
651
  }
588
652
  }