@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/remote.js CHANGED
@@ -1,145 +1,468 @@
1
1
  import { Value } from "typebox/value";
2
+ import { HYDRATION } from "./cell.js";
3
+ import { takesNoArgs } from "./contract.js";
2
4
  import { applyImmutable, decoder } from "./delta/index.js";
3
- import { ChordError } from "./errors.js";
4
- import { CtxImpl, HANDLE_BASE, holdsByProxy, notifyStatus, raceSignal, } from "./record.js";
5
+ import { ChordError, invalid } from "./errors.js";
6
+ import { CtxImpl, notifyStatus, REF_BASE, raceSignal, refsByProxy, } from "./object.js";
5
7
  import { fromWireError } from "./wire.js";
6
8
  const noop = () => { };
9
+ function deferred() {
10
+ let resolve = noop;
11
+ let reject = noop;
12
+ const promise = new Promise((done, fail) => {
13
+ resolve = done;
14
+ reject = fail;
15
+ });
16
+ promise.catch(noop);
17
+ return { promise, resolve, reject };
18
+ }
19
+ /**
20
+ * One hold per host and key (type, normalized input, principal key, contract fingerprint), shared by every reference to
21
+ * it: one hold over the link, one stream per state member, calls over the same hold. References are views onto it.
22
+ *
23
+ * A hold implies the state of its variant (unless no view wants state): the provider answers with a unit, the shape
24
+ * followed by a snapshot of each state member the binding lacks. A shape is applied only once all of its snapshots are
25
+ * here, so a live binding always has every state member of its variant. After the first hydration the values stay
26
+ * readable while the binding re-holds (link loss): status `loading`, last values kept.
27
+ */
28
+ export class RemoteBinding {
29
+ host;
30
+ key;
31
+ contract;
32
+ at;
33
+ input;
34
+ /** Forwarded principal (omitted for the host's own principal). */
35
+ principal;
36
+ /** `ended`: gone with `error`, or closed (no views, no linger). */
37
+ status = "loading";
38
+ error;
39
+ /** The applied shape (kept while re-holding). */
40
+ shape;
41
+ /** Every state member of the applied shape has a value (stays true while re-holding). */
42
+ hydrated = false;
43
+ /** The current hold; undefined while resolving the route or after a link loss. */
44
+ hold;
45
+ /** What the provider was told: state for this hold (`state: false` / `sub` / `unsub`). */
46
+ stateOn = false;
47
+ views = new Set();
48
+ members = new Map();
49
+ /** Lingering since (no views): set by the host. */
50
+ lingerTimer;
51
+ #listeners = new Map();
52
+ /** A shape waiting for its snapshots. */
53
+ #pending;
54
+ /** Resolved while a shape is applied for the current hold (calls wait for it). */
55
+ #live = deferred();
56
+ /** A shape was applied once (a re-hold says `again`). */
57
+ #applied = false;
58
+ /** Waiting for the first hydration (`whenHydrated`). */
59
+ #hydrationWaiters = new Set();
60
+ constructor(host, fields) {
61
+ this.host = host;
62
+ this.key = fields.key;
63
+ this.contract = fields.contract;
64
+ this.at = fields.at;
65
+ this.input = fields.input;
66
+ this.principal = fields.principal;
67
+ }
68
+ get ended() {
69
+ return this.status === "ended";
70
+ }
71
+ /** Views that want state. */
72
+ get stateViews() {
73
+ let count = 0;
74
+ for (const view of this.views)
75
+ if (view.wantsState)
76
+ count++;
77
+ return count;
78
+ }
79
+ /** Whether the next hold should carry state: some view wants it, or nobody is left (lingering keeps it). */
80
+ get wantsState() {
81
+ return this.views.size === 0 ? this.stateOn : this.stateViews > 0;
82
+ }
83
+ /** Whether a re-hold is `again` (the binding already has a shape). */
84
+ get applied() {
85
+ return this.#applied;
86
+ }
87
+ // ─── views ───
88
+ addView(view) {
89
+ this.views.add(view);
90
+ if (this.lingerTimer !== undefined) {
91
+ clearTimeout(this.lingerTimer);
92
+ this.lingerTimer = undefined;
93
+ }
94
+ this.#syncState();
95
+ view.update();
96
+ }
97
+ removeView(view) {
98
+ if (!this.views.delete(view))
99
+ return;
100
+ for (const set of this.#listeners.values())
101
+ for (const readable of set)
102
+ if (readable.view === view)
103
+ set.delete(readable);
104
+ if (this.views.size === 0) {
105
+ this.host.bindingIdle(this);
106
+ return;
107
+ }
108
+ this.#syncState();
109
+ }
110
+ /** Tells the provider to start or stop state when the views' wish changed. */
111
+ #syncState() {
112
+ if (this.ended || this.views.size === 0)
113
+ return;
114
+ const wanted = this.stateViews > 0;
115
+ if (wanted === this.stateOn)
116
+ return;
117
+ this.stateOn = wanted;
118
+ if (!wanted) {
119
+ this.hydrated = false;
120
+ this.members.clear();
121
+ if (this.#pending !== undefined)
122
+ this.#pending.missing.clear();
123
+ }
124
+ if (this.hold !== undefined)
125
+ this.hold.channel.wantState(this.hold.id, wanted);
126
+ for (const view of this.views)
127
+ view.update();
128
+ }
129
+ listen(name, readable) {
130
+ let set = this.#listeners.get(name);
131
+ if (set === undefined) {
132
+ set = new Set();
133
+ this.#listeners.set(name, set);
134
+ }
135
+ set.add(readable);
136
+ }
137
+ unlisten(name, readable) {
138
+ this.#listeners.get(name)?.delete(readable);
139
+ }
140
+ /** Whether `name` is a state member of the applied variant (as the consumer's contract describes it). */
141
+ inVariant(name) {
142
+ const variant = this.shape?.variant;
143
+ return variant !== undefined && this.contract.description.variants[variant]?.[name]?.kind === "state";
144
+ }
145
+ /** The members' value as views read it: throws `not_ready` before the first hydration, `not_in_variant` outside it. */
146
+ read(name) {
147
+ if (this.status === "ended")
148
+ throw this.terminalError();
149
+ if (!this.hydrated)
150
+ throw new ChordError("not_ready", `${this.key}.${name} is not hydrated yet`);
151
+ if (!this.inVariant(name))
152
+ throw new ChordError("not_in_variant", `${this.key}: ${name} is not a state member of variant ${this.shape?.variant}`);
153
+ return this.members.get(name)?.value;
154
+ }
155
+ /** Resolves once the binding has its state (rejects if it ends first). */
156
+ whenHydrated() {
157
+ if (this.hydrated)
158
+ return Promise.resolve();
159
+ if (this.ended)
160
+ return Promise.reject(this.terminalError());
161
+ return new Promise((resolve, reject) => this.#hydrationWaiters.add({ resolve, reject }));
162
+ }
163
+ #settleHydration(error) {
164
+ const waiters = [...this.#hydrationWaiters];
165
+ this.#hydrationWaiters.clear();
166
+ for (const waiter of waiters) {
167
+ if (error === undefined)
168
+ waiter.resolve();
169
+ else
170
+ waiter.reject(error);
171
+ }
172
+ }
173
+ readable(name) {
174
+ return this.status !== "ended" && this.hydrated && this.inVariant(name);
175
+ }
176
+ terminalError() {
177
+ return this.error ?? new ChordError("released", `reference ${this.key} released`);
178
+ }
179
+ // ─── driven by the host and the link ───
180
+ bound(channel, id) {
181
+ this.hold = { channel, id };
182
+ this.stateOn = this.wantsState;
183
+ }
184
+ /** A unit begins: `listed` are the members whose snapshots follow. */
185
+ shaped(shape, listed) {
186
+ if (this.ended)
187
+ return;
188
+ const previous = this.#pending;
189
+ const missing = new Set(listed);
190
+ // A newer shape before the older one's snapshots (relays may reorder them): wait for both.
191
+ if (previous !== undefined)
192
+ for (const name of previous.missing)
193
+ missing.add(name);
194
+ const wanted = new Set(this.#stateMembers(shape));
195
+ for (const name of [...missing])
196
+ if (!wanted.has(name) || !this.stateOn)
197
+ missing.delete(name);
198
+ this.#pending = { shape, missing, values: previous?.values ?? new Map() };
199
+ if (missing.size === 0)
200
+ this.#apply();
201
+ }
202
+ /** A state frame for member `name` of the current hold. */
203
+ state(name, frame) {
204
+ if (this.ended)
205
+ return;
206
+ if (frame.as === "error") {
207
+ const error = fromWireError(frame.error);
208
+ // Before the first hydration nobody could read anything: the binding fails instead of waiting forever.
209
+ if (!this.hydrated && this.stateOn) {
210
+ this.gone(error);
211
+ this.close();
212
+ }
213
+ else
214
+ this.host.report(error);
215
+ return;
216
+ }
217
+ const pending = this.#pending;
218
+ if (frame.as === "ops") {
219
+ const target = pending?.values.get(name) ?? this.members.get(name);
220
+ if (target === undefined)
221
+ return;
222
+ const ops = target.decoder.decode(frame.ops ?? []);
223
+ target.value = applyImmutable(target.value, ops);
224
+ if (target === this.members.get(name))
225
+ this.#deliver(name, { type: "ops", ops, value: target.value });
226
+ return;
227
+ }
228
+ const fresh = { value: frame.value, decoder: decoder() };
229
+ if (pending !== undefined && (pending.missing.has(name) || pending.values.has(name))) {
230
+ pending.values.set(name, fresh);
231
+ pending.missing.delete(name);
232
+ if (pending.missing.size === 0)
233
+ this.#apply();
234
+ return;
235
+ }
236
+ if (!this.inVariant(name))
237
+ return;
238
+ this.members.set(name, fresh);
239
+ this.#deliver(name, { type: "reset", value: fresh.value });
240
+ }
241
+ /** The provider ended the object (or refused the hold): every view is gone. */
242
+ gone(error) {
243
+ if (this.ended)
244
+ return;
245
+ this.status = "ended";
246
+ this.error = error;
247
+ this.hold = undefined;
248
+ this.#pending = undefined;
249
+ this.#live.reject(error);
250
+ this.#settleHydration(error);
251
+ for (const view of [...this.views])
252
+ view.end(error);
253
+ this.#listeners.clear();
254
+ this.host.forgetBinding(this);
255
+ }
256
+ /** The link went down but may come back (upstream reconnect): keep the values, wait for the re-hold. */
257
+ detached() {
258
+ if (this.ended)
259
+ return;
260
+ this.hold = undefined;
261
+ this.#pending = undefined;
262
+ if (this.status === "live") {
263
+ this.status = "loading";
264
+ this.#live = deferred();
265
+ }
266
+ for (const view of this.views)
267
+ view.update();
268
+ }
269
+ /** No views and no linger: releases the hold and forgets the binding. */
270
+ close() {
271
+ if (this.lingerTimer !== undefined)
272
+ clearTimeout(this.lingerTimer);
273
+ this.lingerTimer = undefined;
274
+ if (this.hold !== undefined)
275
+ this.hold.channel.release(this.hold.id);
276
+ this.hold = undefined;
277
+ if (this.status !== "ended") {
278
+ this.status = "ended";
279
+ const error = new ChordError("released", `reference ${this.key} released`);
280
+ this.#live.reject(error);
281
+ this.#settleHydration(error);
282
+ for (const view of [...this.views])
283
+ view.end(error);
284
+ }
285
+ this.#pending = undefined;
286
+ this.#listeners.clear();
287
+ this.host.forgetBinding(this);
288
+ }
289
+ /** Calls `name` over the current hold, waiting for it while the binding (re)holds. */
290
+ async call(name, args, ctx) {
291
+ for (;;) {
292
+ if (this.ended)
293
+ throw this.terminalError();
294
+ if (this.status !== "live" || this.hold === undefined)
295
+ await raceSignal(this.#live.promise, ctx.signal);
296
+ const hold = this.hold;
297
+ if (hold === undefined || this.status !== "live")
298
+ continue;
299
+ const variant = this.shape?.variant;
300
+ const description = variant === undefined ? undefined : this.contract.description.variants[variant]?.[name];
301
+ if (description?.kind !== "method") {
302
+ throw new ChordError("not_in_variant", `${this.key}: ${name} is not a method of variant ${variant}`);
303
+ }
304
+ // Send only what the consumer's own schema knows (the additive rule's premise; see ObjectImpl.call).
305
+ const sent = description.args === undefined ? undefined : Value.Clean(description.args, Value.Clone(args));
306
+ return hold.channel.call(hold.id, name, sent, { rootId: ctx.rootId, codeVersion: ctx.codeVersion }, ctx.signal);
307
+ }
308
+ }
309
+ #stateMembers(shape) {
310
+ const members = shape.variant === undefined ? undefined : this.contract.description.variants[shape.variant];
311
+ if (members === undefined)
312
+ return [];
313
+ return shape.members.filter((name) => members[name]?.kind === "state");
314
+ }
315
+ /** Applies the pending shape with its snapshots: members outside the variant go, new values are announced. */
316
+ #apply() {
317
+ const pending = this.#pending;
318
+ if (pending === undefined)
319
+ return;
320
+ this.#pending = undefined;
321
+ this.shape = pending.shape;
322
+ const wanted = new Set(this.#stateMembers(pending.shape));
323
+ for (const name of [...this.members.keys()])
324
+ if (!wanted.has(name))
325
+ this.members.delete(name);
326
+ for (const [name, state] of pending.values)
327
+ if (wanted.has(name))
328
+ this.members.set(name, state);
329
+ this.hydrated = this.stateOn && [...wanted].every((name) => this.members.has(name));
330
+ if (this.hydrated)
331
+ this.#settleHydration();
332
+ this.status = "live";
333
+ this.#applied = true;
334
+ this.#live.resolve();
335
+ for (const view of this.views)
336
+ view.update();
337
+ for (const [name, state] of pending.values) {
338
+ if (wanted.has(name))
339
+ this.#deliver(name, { type: "reset", value: state.value });
340
+ }
341
+ for (const view of [...this.views])
342
+ view.shapeChanged();
343
+ }
344
+ #deliver(name, event) {
345
+ const readables = this.#listeners.get(name);
346
+ if (readables === undefined)
347
+ return;
348
+ for (const readable of [...readables])
349
+ readable.deliver(event);
350
+ }
351
+ }
7
352
  /**
8
- * A state member of a remote handle: hydrated while subscribed (snapshot, then decoded ops); `undefined` before.
9
- * Re-subscribing after a reconnect delivers the new snapshot as a reset.
353
+ * A state member of a remote reference: the binding's value. Reading throws `not_ready` before the first hydration and
354
+ * `not_in_variant` outside the variant. A listener added before the hydration gets its snapshot then.
10
355
  */
11
- class RemoteReadable {
12
- #handle;
356
+ class ViewReadable {
357
+ view;
13
358
  #name;
14
359
  #values = new Set();
15
360
  #events = new Set();
16
- #sub;
17
- #decoder = decoder();
18
- #value;
19
- constructor(handle, name) {
20
- this.#handle = handle;
361
+ /** Event listeners that have not had their snapshot yet. */
362
+ #unseen = new Set();
363
+ constructor(view, name) {
364
+ this.view = view;
21
365
  this.#name = name;
22
366
  }
23
367
  get value() {
24
- return this.#value;
368
+ if (this.view.ended)
369
+ throw this.view.terminalError();
370
+ return this.view.binding.read(this.#name);
371
+ }
372
+ /** Not hydrated before the binding's first snapshots (a local object forwarding this member waits for them). */
373
+ get [HYDRATION]() {
374
+ const binding = this.view.binding;
375
+ if (binding.hydrated)
376
+ return undefined;
377
+ return { hydrated: false, whenHydrated: () => binding.whenHydrated() };
25
378
  }
26
379
  subscribe(listener) {
27
380
  this.#values.add(listener);
28
- this.#safe(() => listener(this.#value));
29
- this.bind();
381
+ this.#attach();
382
+ if (this.#readable())
383
+ this.#safe(() => listener(this.view.binding.members.get(this.#name)?.value));
30
384
  return () => this.#remove(this.#values, listener);
31
385
  }
32
386
  events(listener) {
33
387
  this.#events.add(listener);
34
- this.#safe(() => listener({ type: "snap", value: this.#value }));
35
- this.bind();
36
- return () => this.#remove(this.#events, listener);
37
- }
38
- /** Subscribes upstream while someone listens and the handle is bound; drops a subscription on an old link. */
39
- bind() {
40
- const listening = this.#values.size + this.#events.size > 0;
41
- // Nobody listens: the value would go stale, so forget it. The next subscriber sees `undefined`, then the snapshot.
42
- if (!listening)
43
- this.#value = undefined;
44
- const binding = listening ? this.#handle.binding : undefined;
45
- if (binding !== undefined && this.#sub?.channel === binding.channel)
46
- return;
47
- if (this.#sub !== undefined) {
48
- if (this.#sub.channel !== binding?.channel || binding === undefined)
49
- this.#sub.channel.unsub(this.#sub.id);
50
- this.#sub = undefined;
388
+ this.#attach();
389
+ if (this.#readable()) {
390
+ const value = this.view.binding.members.get(this.#name)?.value;
391
+ this.#safe(() => listener({ type: "snap", value }));
51
392
  }
52
- if (binding === undefined)
53
- return;
54
- const channel = binding.channel;
55
- this.#decoder = decoder();
56
- const id = channel.sub(binding.id, this.#name, (frame) => this.#receive(frame));
57
- this.#sub = { channel, id };
393
+ else
394
+ this.#unseen.add(listener);
395
+ return () => {
396
+ this.#unseen.delete(listener);
397
+ this.#remove(this.#events, listener);
398
+ };
58
399
  }
59
- /** The link is gone: keep the last value, resubscribe on the next binding. */
60
- detach() {
61
- this.#sub = undefined;
400
+ deliver(event) {
401
+ for (const listener of [...this.#events]) {
402
+ if (!this.#unseen.delete(listener))
403
+ this.#safe(() => listener(event));
404
+ else if (event.type !== "ops")
405
+ this.#safe(() => listener({ type: "snap", value: event.value }));
406
+ }
407
+ for (const listener of [...this.#values])
408
+ this.#safe(() => listener(event.value));
62
409
  }
63
410
  stop() {
64
411
  this.#values.clear();
65
412
  this.#events.clear();
66
- this.bind();
413
+ this.#unseen.clear();
414
+ this.view.binding.unlisten(this.#name, this);
67
415
  }
68
- #receive(frame) {
69
- if (frame.error !== undefined) {
70
- this.#handle.host.report(fromWireError(frame.error));
71
- return;
72
- }
73
- let event;
74
- if (frame.ops === undefined && !("snap" in frame) && !("reset" in frame))
75
- return;
76
- if (frame.ops !== undefined) {
77
- const ops = this.#decoder.decode(frame.ops);
78
- this.#value = applyImmutable(this.#value, ops);
79
- event = { type: "ops", ops, value: this.#value };
80
- }
81
- else {
82
- // Listeners already saw a snapshot (`undefined` or an older value), so every upstream snapshot is a reset.
83
- this.#decoder = decoder();
84
- this.#value = "snap" in frame ? frame.snap : frame.reset;
85
- event = { type: "reset", value: this.#value };
86
- }
87
- for (const listener of [...this.#events])
88
- this.#safe(() => listener(event));
89
- for (const listener of [...this.#values])
90
- this.#safe(() => listener(this.#value));
416
+ #readable() {
417
+ return !this.view.ended && this.view.binding.readable(this.#name);
418
+ }
419
+ #attach() {
420
+ if (!this.view.ended)
421
+ this.view.binding.listen(this.#name, this);
91
422
  }
92
423
  #remove(set, listener) {
93
424
  set.delete(listener);
94
- this.bind();
425
+ if (this.#values.size + this.#events.size === 0)
426
+ this.view.binding.unlisten(this.#name, this);
95
427
  }
96
428
  #safe(fn) {
97
429
  try {
98
430
  fn();
99
431
  }
100
432
  catch (error) {
101
- this.#handle.host.report(error);
433
+ this.view.binding.host.report(error);
102
434
  }
103
435
  }
104
436
  }
105
- /** A hold on an object on another host, bound to a link. Late-bound like a local handle. */
106
- export class RemoteHandle {
107
- record = undefined;
108
- host;
109
- contract;
110
- at;
111
- input;
112
- /** Forwarded principal (omitted for the host's own principal). */
113
- principal;
437
+ /**
438
+ * A reference to an object on another host: a view onto the host's shared binding for its key. Each view has its own
439
+ * owner, status, observers and `release`; ending by the provider and re-holding belong to the binding.
440
+ */
441
+ export class RemoteRef {
442
+ object = undefined;
443
+ binding;
114
444
  owner;
115
- address;
445
+ /** False: a method-only view (`state: false`), ready once live. */
446
+ wantsState;
116
447
  proxy;
117
448
  ready;
118
449
  status = "loading";
119
450
  error;
120
- shape;
121
- binding;
122
451
  shapeListeners = new Set();
452
+ #statusListeners = new Set();
123
453
  #readables = new Map();
124
454
  #callers = new Map();
125
455
  #settleReady;
126
- #live;
127
- constructor(host, fields) {
128
- this.host = host;
129
- this.contract = fields.contract;
130
- this.at = fields.at;
131
- this.input = fields.input;
132
- this.principal = fields.principal;
133
- this.owner = fields.owner;
134
- this.address = fields.address;
135
- this.ready = new Promise((resolve, reject) => {
136
- this.#settleReady = { resolve, reject };
137
- });
138
- this.ready.catch(noop);
139
- this.#live = this.#newLive();
456
+ constructor(binding, owner, wantsState) {
457
+ this.binding = binding;
458
+ this.owner = owner;
459
+ this.wantsState = wantsState;
460
+ const ready = deferred();
461
+ this.ready = ready.promise;
462
+ this.#settleReady = ready;
140
463
  this.proxy = new Proxy(this, {
141
464
  get: (target, property) => {
142
- if (HANDLE_BASE.has(property)) {
465
+ if (REF_BASE.has(property)) {
143
466
  const value = Reflect.get(target, property, target);
144
467
  return typeof value === "function" ? value.bind(target) : value;
145
468
  }
@@ -148,10 +471,22 @@ export class RemoteHandle {
148
471
  return target.member(property);
149
472
  },
150
473
  });
151
- holdsByProxy.set(this.proxy, this);
474
+ refsByProxy.set(this.proxy, this);
475
+ }
476
+ get contract() {
477
+ return this.binding.contract;
478
+ }
479
+ get address() {
480
+ return this.binding.key;
481
+ }
482
+ get ended() {
483
+ return this.status === "released" || this.status === "gone";
152
484
  }
153
- get kind() {
154
- return this.shape?.kind;
485
+ get variant() {
486
+ return this.binding.shape?.variant;
487
+ }
488
+ get shape() {
489
+ return this.binding.shape;
155
490
  }
156
491
  onShape(listener) {
157
492
  this.shapeListeners.add(listener);
@@ -161,78 +496,65 @@ export class RemoteHandle {
161
496
  this.#statusListeners.add(listener);
162
497
  return () => this.#statusListeners.delete(listener);
163
498
  }
164
- #statusListeners = new Set();
165
- #statusChanged() {
166
- notifyStatus(this.#statusListeners, this.status, this.error, (error) => this.host.report(error));
499
+ terminalError() {
500
+ return this.status === "gone" ? this.error : new ChordError("released", `reference ${this.address} released`);
167
501
  }
168
502
  release() {
169
- if (this.status === "released" || this.status === "gone")
503
+ if (this.ended)
170
504
  return;
171
505
  this.status = "released";
172
- this.#statusChanged();
173
- const error = new ChordError("released", `handle ${this.address} released`);
174
- this.#settleReady?.reject(error);
175
- this.#live.reject(error);
506
+ this.#notify();
507
+ this.#settleReady?.reject(new ChordError("released", `reference ${this.address} released`));
508
+ this.#settleReady = undefined;
176
509
  for (const readable of this.#readables.values())
177
510
  readable.stop();
178
- if (this.binding !== undefined)
179
- this.binding.channel.release(this.binding.id);
180
- this.binding = undefined;
181
- this.host.forgetRemote(this);
182
- }
183
- // ─── driven by the link ───
184
- bound(channel, id) {
185
- this.binding = { channel, id };
511
+ this.binding.removeView(this);
186
512
  }
187
- shaped(shape) {
188
- if (this.status === "released" || this.status === "gone")
513
+ // ─── driven by the binding ───
514
+ /** Follows the binding: live once it has a shape (and, for a state view, its state) over a current hold. */
515
+ update() {
516
+ if (this.ended)
189
517
  return;
190
- this.shape = shape;
191
- const wasLoading = this.status === "loading";
192
- this.status = "live";
193
- if (wasLoading)
194
- this.#statusChanged();
195
- if (wasLoading) {
518
+ const binding = this.binding;
519
+ const live = binding.status === "live" && binding.hold !== undefined && (!this.wantsState || binding.hydrated);
520
+ const next = live ? "live" : "loading";
521
+ if (next === this.status)
522
+ return;
523
+ this.status = next;
524
+ this.#notify();
525
+ if (live) {
196
526
  this.#settleReady?.resolve();
197
527
  this.#settleReady = undefined;
198
- this.#live.resolve();
199
528
  }
529
+ }
530
+ end(error) {
531
+ if (this.ended)
532
+ return;
533
+ const released = error instanceof ChordError && error.code === "released";
534
+ this.status = released ? "released" : "gone";
535
+ if (!released)
536
+ this.error = error;
537
+ this.#notify();
538
+ this.#settleReady?.reject(error);
539
+ this.#settleReady = undefined;
200
540
  for (const readable of this.#readables.values())
201
- readable.bind();
541
+ readable.stop();
542
+ }
543
+ shapeChanged() {
544
+ const shape = this.binding.shape;
545
+ if (shape === undefined || this.ended)
546
+ return;
202
547
  for (const listener of [...this.shapeListeners]) {
203
548
  try {
204
549
  listener(shape);
205
550
  }
206
551
  catch (error) {
207
- this.host.report(error);
552
+ this.binding.host.report(error);
208
553
  }
209
554
  }
210
555
  }
211
- gone(error) {
212
- if (this.status === "released" || this.status === "gone")
213
- return;
214
- this.status = "gone";
215
- this.error = error;
216
- this.#statusChanged();
217
- this.binding = undefined;
218
- this.#settleReady?.reject(error);
219
- this.#live.reject(error);
220
- for (const readable of this.#readables.values())
221
- readable.stop();
222
- this.host.forgetRemote(this);
223
- }
224
- /** The link went down but may come back (upstream reconnect): wait, then re-hold. */
225
- detached() {
226
- if (this.status === "released" || this.status === "gone")
227
- return;
228
- this.binding = undefined;
229
- for (const readable of this.#readables.values())
230
- readable.detach();
231
- if (this.status === "live") {
232
- this.status = "loading";
233
- this.#live = this.#newLive();
234
- this.#statusChanged();
235
- }
556
+ #notify() {
557
+ notifyStatus(this.#statusListeners, this.status, this.error, (error) => this.binding.host.report(error));
236
558
  }
237
559
  // ─── members ───
238
560
  member(name) {
@@ -244,7 +566,7 @@ export class RemoteHandle {
244
566
  if (description?.kind === "state") {
245
567
  let readable = this.#readables.get(name);
246
568
  if (readable === undefined) {
247
- readable = new RemoteReadable(this, name);
569
+ readable = new ViewReadable(this, name);
248
570
  this.#readables.set(name, readable);
249
571
  }
250
572
  return readable;
@@ -254,19 +576,26 @@ export class RemoteHandle {
254
576
  #caller(name) {
255
577
  let caller = this.#callers.get(name);
256
578
  if (caller === undefined) {
257
- caller = (args, ctx) => {
258
- if (this.host.inFactory > 0)
579
+ const noArgs = takesNoArgs(this.contract, name);
580
+ caller = (...given) => {
581
+ // A method without arguments is called as `ref.name(ctx)`.
582
+ const [args, ctx] = (noArgs ? [undefined, given[0]] : given);
583
+ const host = this.binding.host;
584
+ if (host.inFactory > 0)
259
585
  throw new ChordError("not_ready", `${this.address}.${name} called inside a factory`);
260
- if (ctx instanceof CtxImpl) {
261
- try {
262
- ctx.check(ctx.host);
263
- }
264
- catch (error) {
265
- return Promise.reject(error);
266
- }
586
+ // The same checks as a local reference's caller (RefImpl.caller and ObjectImpl.call): a ctx from another host
587
+ // or one not made by Chord would carry a root this host does not own over its link.
588
+ if (ctx !== undefined && !(ctx instanceof CtxImpl)) {
589
+ return Promise.reject(invalid("foreign_ctx", "ctx must come from a Chord host"));
590
+ }
591
+ try {
592
+ ctx?.check(host);
267
593
  }
268
- if (ctx === undefined || (ctx instanceof CtxImpl && ctx.holder)) {
269
- return this.host.root((root) => this.#call(name, args, root), { signal: ctx?.signal });
594
+ catch (error) {
595
+ return Promise.reject(error);
596
+ }
597
+ if (ctx === undefined || ctx.holder) {
598
+ return host.root((root) => this.#call(name, args, root), { signal: ctx?.signal });
270
599
  }
271
600
  return this.#call(name, args, ctx);
272
601
  };
@@ -274,35 +603,10 @@ export class RemoteHandle {
274
603
  }
275
604
  return caller;
276
605
  }
277
- async #call(name, args, ctx) {
278
- for (;;) {
279
- if (this.status === "released" || this.status === "gone") {
280
- throw this.status === "gone" ? this.error : new ChordError("released", `handle ${this.address} released`);
281
- }
282
- if (this.status === "loading")
283
- await raceSignal(this.#live.promise, ctx.signal);
284
- const binding = this.binding;
285
- if (binding === undefined)
286
- continue;
287
- const kind = this.shape?.kind;
288
- if (kind === undefined || this.contract.description.variants[kind]?.[name]?.kind !== "method") {
289
- throw new ChordError("not_in_variant", `${this.address}: ${name} is not a method of variant ${kind}`);
290
- }
291
- // Send only what the consumer's own schema knows (the additive rule's premise; see RecordImpl.call).
292
- const description = this.contract.description.variants[kind]?.[name];
293
- const sent = description?.kind === "method" ? Value.Clean(description.args, Value.Clone(args)) : args;
294
- return binding.channel.call(binding.id, name, sent, { rootId: ctx.rootId, epoch: ctx.epoch }, ctx.signal);
295
- }
296
- }
297
- #newLive() {
298
- let resolve = noop;
299
- let reject = noop;
300
- const promise = new Promise((done, fail) => {
301
- resolve = done;
302
- reject = fail;
303
- });
304
- promise.catch(noop);
305
- return { promise, resolve, reject };
606
+ #call(name, args, ctx) {
607
+ if (this.ended)
608
+ return Promise.reject(this.terminalError());
609
+ return this.binding.call(name, args, ctx);
306
610
  }
307
611
  }
308
612
  //# sourceMappingURL=remote.js.map