@earendil-works/chord 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +77 -194
  3. package/dist/cell.d.ts +101 -0
  4. package/dist/cell.d.ts.map +1 -0
  5. package/dist/cell.js +316 -0
  6. package/dist/cell.js.map +1 -0
  7. package/dist/cluster.d.ts +26 -0
  8. package/dist/cluster.d.ts.map +1 -0
  9. package/dist/cluster.js +214 -0
  10. package/dist/cluster.js.map +1 -0
  11. package/dist/connection.d.ts +157 -0
  12. package/dist/connection.d.ts.map +1 -0
  13. package/dist/connection.js +757 -0
  14. package/dist/connection.js.map +1 -0
  15. package/dist/context/index.d.ts +12 -1
  16. package/dist/context/index.d.ts.map +1 -1
  17. package/dist/context/index.js.map +1 -1
  18. package/dist/contract.d.ts +133 -0
  19. package/dist/contract.d.ts.map +1 -0
  20. package/dist/contract.js +157 -0
  21. package/dist/contract.js.map +1 -0
  22. package/dist/declare.d.ts +70 -0
  23. package/dist/declare.d.ts.map +1 -0
  24. package/dist/declare.js +22 -0
  25. package/dist/declare.js.map +1 -0
  26. package/dist/delta/apply-immutable-trusted.js.map +1 -1
  27. package/dist/delta/diff.d.ts +1 -1
  28. package/dist/delta/diff.d.ts.map +1 -1
  29. package/dist/delta/diff.js.map +1 -1
  30. package/dist/delta/index.d.ts +2 -2
  31. package/dist/delta/index.d.ts.map +1 -1
  32. package/dist/delta/index.js +41 -0
  33. package/dist/delta/index.js.map +1 -1
  34. package/dist/delta/revision-validator.d.ts +1 -1
  35. package/dist/delta/revision-validator.d.ts.map +1 -1
  36. package/dist/delta/revision-validator.js.map +1 -1
  37. package/dist/delta/tracker.d.ts +1 -1
  38. package/dist/delta/tracker.d.ts.map +1 -1
  39. package/dist/delta/tracker.js +40 -4
  40. package/dist/delta/tracker.js.map +1 -1
  41. package/dist/errors.d.ts +27 -0
  42. package/dist/errors.d.ts.map +1 -0
  43. package/dist/errors.js +44 -0
  44. package/dist/errors.js.map +1 -0
  45. package/dist/host.d.ts +254 -0
  46. package/dist/host.d.ts.map +1 -0
  47. package/dist/host.js +795 -0
  48. package/dist/host.js.map +1 -0
  49. package/dist/index.d.ts +17 -10
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +8 -8
  52. package/dist/index.js.map +1 -1
  53. package/dist/json.d.ts +9 -1
  54. package/dist/json.d.ts.map +1 -1
  55. package/dist/json.js.map +1 -1
  56. package/dist/link.d.ts +72 -0
  57. package/dist/link.d.ts.map +1 -0
  58. package/dist/link.js +169 -0
  59. package/dist/link.js.map +1 -0
  60. package/dist/loader.d.ts +4 -0
  61. package/dist/loader.d.ts.map +1 -0
  62. package/dist/loader.js +68 -0
  63. package/dist/loader.js.map +1 -0
  64. package/dist/manager.d.ts +115 -0
  65. package/dist/manager.d.ts.map +1 -0
  66. package/dist/manager.js +319 -0
  67. package/dist/manager.js.map +1 -0
  68. package/dist/node.d.ts +3 -4
  69. package/dist/node.d.ts.map +1 -1
  70. package/dist/node.js +3 -2
  71. package/dist/node.js.map +1 -1
  72. package/dist/publish.d.ts +5 -0
  73. package/dist/publish.d.ts.map +1 -0
  74. package/dist/publish.js +40 -0
  75. package/dist/publish.js.map +1 -0
  76. package/dist/record.d.ts +320 -0
  77. package/dist/record.d.ts.map +1 -0
  78. package/dist/record.js +1292 -0
  79. package/dist/record.js.map +1 -0
  80. package/dist/registry.d.ts +25 -0
  81. package/dist/registry.d.ts.map +1 -0
  82. package/dist/registry.js +6 -0
  83. package/dist/registry.js.map +1 -0
  84. package/dist/reload.d.ts +118 -0
  85. package/dist/reload.d.ts.map +1 -0
  86. package/dist/reload.js +589 -0
  87. package/dist/reload.js.map +1 -0
  88. package/dist/remote.d.ts +70 -0
  89. package/dist/remote.d.ts.map +1 -0
  90. package/dist/remote.js +308 -0
  91. package/dist/remote.js.map +1 -0
  92. package/dist/schema.d.ts +21 -0
  93. package/dist/schema.d.ts.map +1 -0
  94. package/dist/schema.js +228 -0
  95. package/dist/schema.js.map +1 -0
  96. package/dist/state.d.ts +92 -0
  97. package/dist/state.d.ts.map +1 -0
  98. package/dist/{services/state.js → state.js} +28 -138
  99. package/dist/state.js.map +1 -0
  100. package/dist/types.d.ts +62 -262
  101. package/dist/types.d.ts.map +1 -1
  102. package/dist/types.js.map +1 -1
  103. package/dist/websocket.d.ts +16 -0
  104. package/dist/websocket.d.ts.map +1 -0
  105. package/dist/websocket.js +15 -0
  106. package/dist/websocket.js.map +1 -0
  107. package/dist/wire.d.ts +135 -0
  108. package/dist/wire.d.ts.map +1 -0
  109. package/dist/wire.js +112 -0
  110. package/dist/wire.js.map +1 -0
  111. package/docs/guide.md +750 -0
  112. package/docs/spec.md +261 -0
  113. package/docs/tutorial.md +909 -0
  114. package/package.json +9 -10
  115. package/src/delta/README.md +5 -3
  116. package/dist/api.d.ts +0 -20
  117. package/dist/api.d.ts.map +0 -1
  118. package/dist/api.js +0 -77
  119. package/dist/api.js.map +0 -1
  120. package/dist/bundler.d.ts +0 -6
  121. package/dist/bundler.d.ts.map +0 -1
  122. package/dist/bundler.js +0 -3
  123. package/dist/bundler.js.map +0 -1
  124. package/dist/facets/host.d.ts +0 -12
  125. package/dist/facets/host.d.ts.map +0 -1
  126. package/dist/facets/host.js +0 -763
  127. package/dist/facets/host.js.map +0 -1
  128. package/dist/facets/loader.d.ts +0 -3
  129. package/dist/facets/loader.d.ts.map +0 -1
  130. package/dist/facets/loader.js +0 -5
  131. package/dist/facets/loader.js.map +0 -1
  132. package/dist/node/bundle-loader.d.ts +0 -30
  133. package/dist/node/bundle-loader.d.ts.map +0 -1
  134. package/dist/node/bundle-loader.js +0 -377
  135. package/dist/node/bundle-loader.js.map +0 -1
  136. package/dist/node/bundle.d.ts +0 -26
  137. package/dist/node/bundle.d.ts.map +0 -1
  138. package/dist/node/bundle.js +0 -183
  139. package/dist/node/bundle.js.map +0 -1
  140. package/dist/node/manifest.d.ts +0 -36
  141. package/dist/node/manifest.d.ts.map +0 -1
  142. package/dist/node/manifest.js +0 -6
  143. package/dist/node/manifest.js.map +0 -1
  144. package/dist/node/package.d.ts +0 -15
  145. package/dist/node/package.d.ts.map +0 -1
  146. package/dist/node/package.js +0 -199
  147. package/dist/node/package.js.map +0 -1
  148. package/dist/services/consumer.d.ts +0 -11
  149. package/dist/services/consumer.d.ts.map +0 -1
  150. package/dist/services/consumer.js +0 -585
  151. package/dist/services/consumer.js.map +0 -1
  152. package/dist/services/errors.d.ts +0 -8
  153. package/dist/services/errors.d.ts.map +0 -1
  154. package/dist/services/errors.js +0 -22
  155. package/dist/services/errors.js.map +0 -1
  156. package/dist/services/handle.d.ts +0 -15
  157. package/dist/services/handle.d.ts.map +0 -1
  158. package/dist/services/handle.js +0 -101
  159. package/dist/services/handle.js.map +0 -1
  160. package/dist/services/instances.d.ts +0 -26
  161. package/dist/services/instances.d.ts.map +0 -1
  162. package/dist/services/instances.js +0 -140
  163. package/dist/services/instances.js.map +0 -1
  164. package/dist/services/loopback.d.ts +0 -5
  165. package/dist/services/loopback.d.ts.map +0 -1
  166. package/dist/services/loopback.js +0 -15
  167. package/dist/services/loopback.js.map +0 -1
  168. package/dist/services/provider.d.ts +0 -37
  169. package/dist/services/provider.d.ts.map +0 -1
  170. package/dist/services/provider.js +0 -521
  171. package/dist/services/provider.js.map +0 -1
  172. package/dist/services/state-codec.d.ts +0 -15
  173. package/dist/services/state-codec.d.ts.map +0 -1
  174. package/dist/services/state-codec.js +0 -133
  175. package/dist/services/state-codec.js.map +0 -1
  176. package/dist/services/state-internals.d.ts +0 -13
  177. package/dist/services/state-internals.d.ts.map +0 -1
  178. package/dist/services/state-internals.js +0 -10
  179. package/dist/services/state-internals.js.map +0 -1
  180. package/dist/services/state.d.ts +0 -28
  181. package/dist/services/state.d.ts.map +0 -1
  182. package/dist/services/state.js.map +0 -1
  183. package/dist/services/wire.d.ts +0 -63
  184. package/dist/services/wire.d.ts.map +0 -1
  185. package/dist/services/wire.js +0 -186
  186. package/dist/services/wire.js.map +0 -1
package/dist/reload.js ADDED
@@ -0,0 +1,589 @@
1
+ import { bind, coverage } from "./contract.js";
2
+ import { asChordError, ChordError, invalid } from "./errors.js";
3
+ import { holdPublications } from "./publish.js";
4
+ import { HandleImpl, makeService, memberSchemas, } from "./record.js";
5
+ import { assertValid, deepEqual } from "./schema.js";
6
+ const noop = () => { };
7
+ /**
8
+ * Computes the new derivation functions over the candidate values and validates every prospective value (migrated
9
+ * 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.
11
+ */
12
+ function dryRun(candidate) {
13
+ const { run, migrated, service, record } = candidate;
14
+ const exposed = memberSchemas(service, run.behavior);
15
+ for (const [cell, value] of migrated) {
16
+ for (const schema of exposed.get(cell) ?? [])
17
+ assertValid("state", `${record.address}: migrated state ${cell.key}`, schema, value);
18
+ }
19
+ const values = new Map(migrated);
20
+ const derives = [...run.decls.derives].sort(([, a], [, b]) => a.seq - b.seq);
21
+ 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));
23
+ for (const schema of exposed.get(derived) ?? [])
24
+ assertValid("state", `${record.address}: derived ${name}`, schema, value);
25
+ values.set(derived, value);
26
+ }
27
+ candidate.derived = values;
28
+ }
29
+ /** A reload error keeps the underlying code (`contract_mismatch`, `invalid`, ...) and says it aborted the reload. */
30
+ export function reloadError(error) {
31
+ const cause = asChordError(error, "reload");
32
+ if (/^reload aborted/.test(cause.message.replace(/^[^:]*: /, "")))
33
+ return cause;
34
+ return new ChordError(cause.code, `reload aborted: ${cause.message}`, {
35
+ cause,
36
+ reason: cause.reason,
37
+ details: cause.details,
38
+ });
39
+ }
40
+ /** Serialized: a second reload while one runs is rejected. */
41
+ export async function runReload(host, plugins, options) {
42
+ const reload = await beginReload(host, plugins, options);
43
+ try {
44
+ await reload.prepare();
45
+ return reload.commit();
46
+ }
47
+ finally {
48
+ endReload(host);
49
+ }
50
+ }
51
+ /** Takes the host's reload slot and loads the plugin modules. Pair with `endReload`. */
52
+ export async function beginReload(host, plugins, options, epoch) {
53
+ if (host.reloading) {
54
+ throw new ChordError("unavailable", "a reload is already running", { reason: "reload_running" });
55
+ }
56
+ host.reloading = true;
57
+ try {
58
+ return new Reload(host, await host.resolve(plugins), options, epoch);
59
+ }
60
+ catch (error) {
61
+ host.reloading = false;
62
+ throw reloadError(error);
63
+ }
64
+ }
65
+ export function endReload(host) {
66
+ host.reloading = false;
67
+ host.maybeRetire();
68
+ }
69
+ /** One reload on one host: `prepare` (load, prepare, gate), then `commit` or `rollback`. */
70
+ export class Reload {
71
+ host;
72
+ facets;
73
+ options;
74
+ /** Epoch id to commit (issued by the coordinating server); a local reload allocates one. */
75
+ epoch;
76
+ candidates = new Map();
77
+ abort = new AbortController();
78
+ closed = [];
79
+ services = new Map();
80
+ changed = new Set();
81
+ removed = new Set();
82
+ tables = new Map();
83
+ gated = [];
84
+ rolledBack = false;
85
+ /** Records of types this reload adds, with their staged listeners: their runtime starts at commit. */
86
+ deferred = new Map();
87
+ constructor(host, facets, options, epoch) {
88
+ this.host = host;
89
+ this.facets = facets;
90
+ this.options = options;
91
+ this.epoch = epoch;
92
+ }
93
+ /** Everything up to the commit. On failure the host is rolled back and the underlying error is thrown. */
94
+ async prepare() {
95
+ try {
96
+ await this.waitForRetirement();
97
+ this.load();
98
+ this.checkProviders();
99
+ await this.prepareAll();
100
+ // From here new records of changed types are rejected (upgrade_conflict, retryable) until commit or abort,
101
+ // so the candidate set is final once the activations already in flight have been prepared.
102
+ this.host.gating = this.changed;
103
+ await this.withDeadline(this.prepareAll(), "activations in flight did not finish");
104
+ this.checkHolds();
105
+ this.tables = this.host.buildTables(this.entries());
106
+ await this.gate();
107
+ this.#alive();
108
+ this.#stage();
109
+ }
110
+ catch (error) {
111
+ await this.rollback();
112
+ throw reloadError(error);
113
+ }
114
+ }
115
+ /** What a successful prepare will commit (a child sends it with its vote). */
116
+ summary() {
117
+ const live = [...this.candidates.values()].filter(({ record }) => record.status === "live");
118
+ return {
119
+ epoch: this.host.pending?.id ?? this.host.current.id,
120
+ previous: this.host.current.id,
121
+ changed: [...this.changed],
122
+ removed: [...this.removed],
123
+ prepared: live.map(({ record }) => record.address),
124
+ gated: this.gated.map(({ record }) => record.address),
125
+ };
126
+ }
127
+ /** After every await of prepare: a rollback meanwhile (abort from the coordinator) ends it. */
128
+ #alive() {
129
+ if (this.rolledBack)
130
+ throw new ChordError("aborted", "reload aborted while preparing", { reason: "aborted" });
131
+ }
132
+ /**
133
+ * 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.
135
+ */
136
+ #stage() {
137
+ for (const candidate of this.candidates.values()) {
138
+ if (candidate.record.status === "live")
139
+ candidate.staged = candidate.record.stageListeners(candidate.run.decls);
140
+ }
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));
144
+ }
145
+ }
146
+ async waitForRetirement() {
147
+ const host = this.host;
148
+ if (host.previous === undefined)
149
+ 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();
161
+ }
162
+ /** Builds the candidate service table: unchanged services plus everything the given facets serve. */
163
+ load() {
164
+ const replaced = new Set([...this.facets.map((facet) => facet.name), ...(this.options.remove ?? [])]);
165
+ const current = this.host.current.services;
166
+ this.services = new Map([...current].filter(([, service]) => !replaced.has(service.facet)));
167
+ 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)))));
169
+ }
170
+ for (const [name, service] of this.services)
171
+ if (current.get(name) !== service)
172
+ this.changed.add(name);
173
+ for (const name of current.keys())
174
+ if (!this.services.has(name))
175
+ this.removed.add(name);
176
+ this.host.pending = {
177
+ id: this.host.nextEpochId(this.epoch),
178
+ services: this.services,
179
+ tables: new Map(),
180
+ roots: new Map(),
181
+ };
182
+ }
183
+ async withDeadline(promise, what) {
184
+ const deadline = this.options.gateDeadline ?? 5000;
185
+ let timer;
186
+ const timeout = new Promise((_, reject) => {
187
+ timer = setTimeout(() => reject(new ChordError("aborted", `${what} within ${deadline} ms`, { reason: "deadline" })), deadline);
188
+ });
189
+ try {
190
+ return await Promise.race([promise, timeout]);
191
+ }
192
+ finally {
193
+ clearTimeout(timer);
194
+ }
195
+ }
196
+ /** Same contract version must cover the old description (forgotten bump); facets' `uses` must bind. */
197
+ checkProviders() {
198
+ for (const name of this.changed) {
199
+ const next = this.services.get(name).contract;
200
+ const old = this.host.current.services.get(name)?.contract;
201
+ if (old === undefined || old.version !== next.version)
202
+ continue;
203
+ const reason = coverage(next.description, old.description);
204
+ if (reason !== undefined) {
205
+ throw new ChordError("contract_mismatch", `${name}@${next.version} changed incompatibly without a version bump (${reason})`);
206
+ }
207
+ }
208
+ const facets = new Map(this.host.facets);
209
+ for (const facet of this.facets)
210
+ facets.set(facet.name, facet);
211
+ for (const name of this.options.remove ?? [])
212
+ facets.delete(name);
213
+ for (const facet of facets.values()) {
214
+ for (const token of facet.uses ?? []) {
215
+ const service = this.services.get(token.name);
216
+ if (service !== undefined && this.changed.has(token.name))
217
+ bind(service.contract, service.options.accepts, token);
218
+ }
219
+ }
220
+ }
221
+ /**
222
+ * Prepares every live record of a changed type, including records that activate meanwhile, to a fixpoint. Records
223
+ * already running the new code (types this reload adds) are skipped.
224
+ */
225
+ async prepareAll() {
226
+ 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"));
231
+ if (todo.length === 0)
232
+ return;
233
+ await Promise.allSettled(todo.map((record) => record.ready));
234
+ 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);
238
+ this.#alive();
239
+ }
240
+ }
241
+ }
242
+ }
243
+ /** 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);
247
+ const candidate = {
248
+ record,
249
+ service,
250
+ run,
251
+ reasons: [],
252
+ atGate: [],
253
+ replaced: [],
254
+ migrated: new Map(),
255
+ derived: new Map(),
256
+ staged: undefined,
257
+ };
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}`);
262
+ for (const [name, cell] of run.decls.cells) {
263
+ if (record.decls.cells.get(name) !== cell || deepEqual(cell.schema, run.decls.cellSchemas.get(name)))
264
+ 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}`);
272
+ }
273
+ const deferred = new Set();
274
+ for (const resource of run.created.resources) {
275
+ if (resource.exclusive || resource.deps.some((dep) => deferred.has(dep)))
276
+ deferred.add(resource);
277
+ }
278
+ for (const resource of deferred) {
279
+ candidate.atGate.push(resource);
280
+ const old = record.decls.resources.get(resource.key);
281
+ if (resource.exclusive && old?.exclusive && old.state === "open")
282
+ candidate.replaced.push(old);
283
+ }
284
+ if (deferred.size > 0)
285
+ candidate.reasons.push("exclusive resource replaced");
286
+ if (candidate.reasons.length === 0)
287
+ dryRun(candidate);
288
+ await record.open(run.created.resources.filter((resource) => !deferred.has(resource)), run.created.holds, this.abort.signal);
289
+ }
290
+ /**
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.
293
+ */
294
+ checkHolds() {
295
+ 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))
304
+ 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))
308
+ continue;
309
+ if (!dropped.has(hold) && hold.owner !== "client")
310
+ bind(service.contract, service.options.accepts, hold.contract);
311
+ }
312
+ }
313
+ }
314
+ entries() {
315
+ return this.host
316
+ .liveDecls(true)
317
+ .filter(([record]) => !this.removed.has(record.typeName))
318
+ .map(([record, decls]) => [record, this.candidates.get(record)?.run.decls ?? decls]);
319
+ }
320
+ /**
321
+ * 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.
323
+ */
324
+ 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());
328
+ return new Promise((resolve) => {
329
+ record.gate = { grandfathered, queue: [], drained: resolve };
330
+ record.stopTasks();
331
+ if (grandfathered.size === 0)
332
+ resolve();
333
+ });
334
+ });
335
+ const deadline = this.options.gateDeadline ?? 5000;
336
+ let timer;
337
+ const timedOut = await Promise.race([
338
+ Promise.all(drained).then(() => false),
339
+ new Promise((resolve) => {
340
+ timer = setTimeout(() => resolve(true), deadline);
341
+ }),
342
+ ]);
343
+ clearTimeout(timer);
344
+ if (timedOut) {
345
+ const blocking = this.gated.flatMap(({ record }) => [...(record.gate?.grandfathered ?? [])]);
346
+ throw new ChordError("aborted", `gate deadline (${deadline} ms) missed; blocking roots: ${blocking.join(", ")}`, {
347
+ reason: "gate_deadline",
348
+ details: { blocking },
349
+ });
350
+ }
351
+ this.#alive();
352
+ for (const candidate of this.gated) {
353
+ const { record } = candidate;
354
+ for (const old of candidate.replaced) {
355
+ // 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);
358
+ }
359
+ await record.open(candidate.atGate, [], this.abort.signal);
360
+ this.#alive();
361
+ }
362
+ for (const candidate of this.gated)
363
+ this.migrate(candidate);
364
+ }
365
+ migrate(candidate) {
366
+ const { record, service, run } = candidate;
367
+ const migrate = service.options.state?.migrate ?? {};
368
+ const bumped = service.stateVersion !== record.stateVersion;
369
+ for (const [name, cell] of run.decls.cells) {
370
+ if (record.decls.cells.get(name) !== cell)
371
+ continue;
372
+ const schema = run.decls.cellSchemas.get(name);
373
+ const fn = migrate[name];
374
+ let value;
375
+ if (bumped && fn !== undefined)
376
+ value = fn(structuredClone(cell.value), record.stateVersion);
377
+ else if (!deepEqual(cell.schema, schema))
378
+ value = cell.value;
379
+ else
380
+ continue;
381
+ assertValid("state", `${record.address}: migrated state ${name}`, schema, value);
382
+ candidate.migrated.set(cell, value);
383
+ }
384
+ dryRun(candidate);
385
+ }
386
+ async rollback() {
387
+ if (this.rolledBack)
388
+ return;
389
+ this.rolledBack = true;
390
+ this.abort.abort(new ChordError("aborted", "reload aborted"));
391
+ const report = (error) => this.host.report(error);
392
+ for (const candidate of this.candidates.values())
393
+ candidate.staged?.discard();
394
+ for (const staged of this.deferred.values())
395
+ staged.discard();
396
+ for (const { record, run } of this.candidates.values()) {
397
+ record.pending.delete(run.decls);
398
+ await record.unwind(run).catch(report);
399
+ }
400
+ // Replaced exclusive resources are restored; one that cannot be is marked unavailable, never silently closed.
401
+ for (const [record, resource] of this.closed) {
402
+ if (resource.state !== "closed")
403
+ continue;
404
+ await resource.open(new AbortController().signal, record.openOrder).catch((error) => {
405
+ resource.markUnavailable();
406
+ report(new ChordError("unavailable", `${record.address}: resource ${resource.key} could not be restored`, {
407
+ cause: error,
408
+ }));
409
+ });
410
+ }
411
+ for (const { record } of this.gated) {
412
+ if (record.gate === undefined)
413
+ continue;
414
+ record.openGate();
415
+ for (const name of record.decls.tasks.keys())
416
+ record.startTask(name, this.host.current.id);
417
+ }
418
+ // Records of types this reload would have added lose their code.
419
+ 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);
423
+ }
424
+ this.host.pending = undefined;
425
+ this.host.gating = undefined;
426
+ }
427
+ /**
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.
432
+ */
433
+ commit(beforeStart) {
434
+ try {
435
+ return holdPublications(() => {
436
+ 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.
439
+ beforeStart?.();
440
+ for (const start of this.#starts.splice(0))
441
+ start();
442
+ return report;
443
+ });
444
+ }
445
+ catch (error) {
446
+ this.host.fail(error);
447
+ throw reloadError(error);
448
+ }
449
+ }
450
+ /** Work that may start new-epoch roots, run after installation (and after the coordinator's commit frames). */
451
+ #starts = [];
452
+ #publish() {
453
+ const host = this.host;
454
+ const previous = host.current;
455
+ const next = host.pending;
456
+ host.pending = undefined;
457
+ host.epochs.set(next.id, next);
458
+ host.previous = previous;
459
+ host.current = next;
460
+ const prepared = [];
461
+ for (const record of [...host.records.values()]) {
462
+ if (this.removed.has(record.typeName))
463
+ continue;
464
+ const candidate = this.candidates.get(record);
465
+ if (candidate === undefined) {
466
+ const installed = record.behaviors.get(previous.id);
467
+ if (installed !== undefined)
468
+ record.behaviors.set(next.id, { ...installed, epoch: next.id });
469
+ continue;
470
+ }
471
+ if (record.status !== "live")
472
+ continue;
473
+ this.install(candidate, next.id);
474
+ prepared.push(record.address);
475
+ }
476
+ for (const { record, run, staged } of this.candidates.values()) {
477
+ if (record.status === "live")
478
+ continue;
479
+ staged?.discard();
480
+ record.pending.delete(run.decls);
481
+ record.unwind(run).catch((error) => host.report(error));
482
+ }
483
+ // Records of types this reload adds start their runtime now.
484
+ for (const [record, staged] of this.deferred) {
485
+ if (record.status !== "live") {
486
+ staged.discard();
487
+ continue;
488
+ }
489
+ record.deferredRuntime = false;
490
+ staged.activate();
491
+ this.#starts.push(() => record.startTasks());
492
+ }
493
+ host.installTables(next, this.tables, true);
494
+ 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));
498
+ }
499
+ for (const name of this.removed)
500
+ host.eager.delete(name);
501
+ for (const facet of this.facets)
502
+ host.facets.set(facet.name, facet);
503
+ for (const name of this.options.remove ?? [])
504
+ host.facets.delete(name);
505
+ host.gating = undefined;
506
+ 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));
509
+ }
510
+ }
511
+ return {
512
+ epoch: next.id,
513
+ previous: previous.id,
514
+ changed: [...this.changed],
515
+ removed: [...this.removed],
516
+ prepared,
517
+ gated: this.gated.map(({ record }) => record.address),
518
+ };
519
+ }
520
+ install(candidate, epoch) {
521
+ const { record, run, service } = candidate;
522
+ const gated = candidate.reasons.length > 0;
523
+ record.pending.delete(run.decls);
524
+ record.stopTasks();
525
+ if (gated) {
526
+ // Old derivations would compute over migrated values; the old behavior can no longer run anyway.
527
+ const kept = new Set(run.decls.derives.values());
528
+ for (const derived of record.decls.derives.values())
529
+ if (!kept.has(derived))
530
+ derived.detach();
531
+ }
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 });
536
+ 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);
542
+ }
543
+ // 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));
547
+ for (const [name, cell] of run.decls.cells) {
548
+ const schema = run.decls.cellSchemas.get(name);
549
+ if (candidate.migrated.has(cell))
550
+ cell.reset(candidate.migrated.get(cell), schema);
551
+ else
552
+ cell.schema = schema;
553
+ }
554
+ const created = new Set(run.created.derives);
555
+ for (const derived of run.created.derives)
556
+ derived.attach(candidate.derived);
557
+ for (const derived of run.decls.derives.values())
558
+ if (!created.has(derived))
559
+ derived.refresh();
560
+ 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") {
574
+ try {
575
+ bind(service.contract, service.options.accepts, handle.contract);
576
+ }
577
+ catch (error) {
578
+ handle.gone(new ChordError("upgrade_required", `${record.typeName} changed; reload the web facets`, {
579
+ cause: error,
580
+ }));
581
+ record.release(handle);
582
+ continue;
583
+ }
584
+ }
585
+ handle.shapeChanged();
586
+ }
587
+ }
588
+ }
589
+ //# sourceMappingURL=reload.js.map