@synapse-sdk/client 0.1.0 → 0.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.
package/dist/scene.js ADDED
@@ -0,0 +1,1531 @@
1
+ // The scene (PLAN v0.24 §1, §5, §10), the JS spelling of the Rust client's `scene.rs`: the active
2
+ // project's scene as this node sees it.
3
+ //
4
+ // `scene()` follows the incarnations the runtime announces under `runtime/scene/` (`@<epoch>`, one at
5
+ // a time: the active project's) and reads both tracks of the one it binds to: `authored` (a snapshot
6
+ // frame, then one diff frame per applied batch; a later group is a fresh snapshot that replaces the
7
+ // copy) and `live` (each group starts with the whole live set, then one frame per runtime tick). The
8
+ // copy this node keeps (the mirror) is authored ⊕ live: a live row replaces `xform.t/q`, `joints` and
9
+ // `state` of the authored entity it names; a live entity is a row of its own. Nothing is kept across
10
+ // incarnations.
11
+ //
12
+ // One task per node, the store's shape: every `scene()` on the node shares it; the task owns the
13
+ // session it is told about (`Node.monitor`), the incarnation it reads, the mirror and the writes in
14
+ // flight, and is driven by one mailbox, in order:
15
+ // - `session` / `sessionEnded` / `nodeEnded`: what was bound on the previous session is over (the
16
+ // mirror empties, the handle is `detached`); the announcements are followed again on the new one;
17
+ // - the incarnation's tracks are read by the task's reader (the only writer of the mirror), which
18
+ // tells the task of each authored frame applied (`applied`); the incarnation's end (its final frame,
19
+ // or the tracks ending) empties the mirror and the announcements are followed again (`followEnded`);
20
+ // - `join`: a `scene()` call waiting for the scene to be in (attached, or no project active);
21
+ // - `apply` / `applyDone`: an authoring batch (it names the bound incarnation's epoch) and the
22
+ // runtime's answer; the caller is answered once the mirror shows the version;
23
+ // - `notice`: the runtime dropped some of this node's live rows (`not_owner` with `data.ids`);
24
+ // - `close`: the last open handle left.
25
+ //
26
+ // States: `attached` (an incarnation's snapshot is in the mirror, and every frame of both tracks this
27
+ // node already held when the first frame of each arrived has been applied), `detached` (none: no
28
+ // active project, a session gap, between incarnations), `closed` (`close()` or the node ending). While
29
+ // detached the entities are empty, every author verb fails `project_inactive` at once and `live()`
30
+ // drops its batch and counts it. A closed handle's verbs fail `not_connected`.
31
+ //
32
+ // Known behaviour (as the Rust client's):
33
+ // - live rows that disappear only through a keyframe (the ticks that removed them were missed) leave
34
+ // the entities but are not reported on `watchLiveRemovals`: a keyframe carries no reason;
35
+ // - a node that closes its last scene handle and opens a new one on the same session announces a new
36
+ // live writer track; the runtime follows one writer per session, so while its follower of the old
37
+ // track is still ending, the new track may be ignored.
38
+ //
39
+ // The runtime decides every write: nothing is checked here, a refused batch is the caller's error and
40
+ // never retried; a `conflict` naming a stale epoch makes the task let go of the incarnation it reads
41
+ // and follow the announcements again.
42
+ import * as Moq from "@moq/net";
43
+ import { FLAGS, SynapseError, TYPES, decode as decodeBody, decodeHeader, encode as encodeBody, moqPriority, nowUs } from "@synapse-sdk/core";
44
+ import { startOn } from "./calls.js";
45
+ import { REFOLLOW_PACE_MS } from "./channel.js";
46
+ import { CALL_GROUP_MAX_AGE_MS, StreamWriter, frameTooLarge, latestSubscription, waitActive } from "./session.js";
47
+ import { AsyncQueue, deferred, replaceEqualDeep } from "./util.js";
48
+ /** Where the runtime announces the scene's incarnations: `runtime/scene/@<epoch>`. */
49
+ const SCENE_BASE = "runtime/scene";
50
+ const AUTHORED_TRACK = "authored";
51
+ const LIVE_TRACK = "live";
52
+ /** What a closed handle's errors name when the node ended. */
53
+ const ENDED_CONNECTION_LOST = "connection_lost";
54
+ /** What they name after `close()`. */
55
+ const ENDED_CLIENT = "client";
56
+ /** How many batches a feed may fall behind before it is ended (the store's `STORE_FEED_CAP`). */
57
+ export const SCENE_FEED_CAP = 256;
58
+ /** Keys a `patch` may not set (the runtime refuses them); a txn never folds such a patch into a spawn. */
59
+ export const PATCH_PROTECTED = ["id", "name", "parent", "order", "layer", "meta"];
60
+ /** moq's cap on one frame. */
61
+ const MAX_FRAME_BYTES = 32 * 1024 * 1024;
62
+ const EMPTY = Object.freeze({});
63
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v) && !(v instanceof Uint8Array);
64
+ /** Deep equality of decoded values (structural sharing keeps `a` when equal). */
65
+ const deepEqual = (a, b) => replaceEqualDeep(a, b) === a;
66
+ const parentOf = (e) => (typeof e.parent === "string" ? e.parent : null);
67
+ const orderOf = (e) => (typeof e.order === "string" ? e.order : undefined);
68
+ const rethrowLater = (e) => { queueMicrotask(() => { throw e; }); };
69
+ // ------------------------------------------------------------------------------------- helpers
70
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
71
+ const RAND_BITS = 74n;
72
+ let lastMs = 0;
73
+ let lastRand = 0n;
74
+ /**
75
+ * A fresh entity id: a ULID (26 Crockford base-32 characters) made from a UUIDv7 (48-bit ms, version,
76
+ * 74 random bits, variant), so ids sort by the time they were minted; within one millisecond the
77
+ * random part counts up, so ids minted here keep their order.
78
+ */
79
+ export function ulid() {
80
+ let ms = Date.now();
81
+ let rand;
82
+ if (ms <= lastMs) {
83
+ ms = lastMs;
84
+ rand = (lastRand + 1n) & ((1n << RAND_BITS) - 1n);
85
+ }
86
+ else {
87
+ const b = new Uint8Array(10);
88
+ crypto.getRandomValues(b);
89
+ rand = b.reduce((n, x) => (n << 8n) | BigInt(x), 0n) & ((1n << RAND_BITS) - 1n);
90
+ }
91
+ lastMs = ms;
92
+ lastRand = rand;
93
+ const n = (BigInt(ms) << 80n) | (7n << 76n) | ((rand >> 62n) << 64n) | (2n << 62n) | (rand & ((1n << 62n) - 1n));
94
+ let out = "";
95
+ for (let i = 0; i < 26; i++)
96
+ out += CROCKFORD[Number((n >> BigInt(125 - 5 * i)) & 31n)];
97
+ return out;
98
+ }
99
+ // The fractional `order` (the runtime's `order.rs`, loro's family): an index is bytes ending with the
100
+ // terminator 0x80, compared bytewise; on the wire lowercase hex (which keeps the byte order).
101
+ const TERMINATOR = 0x80;
102
+ const ORDER_MAX_BYTES = 512;
103
+ function orderBytes(hex) {
104
+ const ok = hex.length > 0 && hex.length % 2 === 0 && /^[0-9a-f]+$/.test(hex);
105
+ const bytes = ok ? Array.from({ length: hex.length / 2 }, (_, i) => parseInt(hex.slice(2 * i, 2 * i + 2), 16)) : [];
106
+ if (!ok || bytes.length > ORDER_MAX_BYTES || bytes[bytes.length - 1] !== TERMINATOR)
107
+ throw new SynapseError("invalid_input", `order ${JSON.stringify(hex)} is not a canonical index (lowercase hex ending in 80)`);
108
+ return bytes;
109
+ }
110
+ const orderHex = (bytes) => bytes.map(b => b.toString(16).padStart(2, "0")).join("");
111
+ function beforeBytes(bytes, i = 0) {
112
+ for (;; i++) {
113
+ const b = bytes[i];
114
+ if (b > TERMINATOR)
115
+ return bytes.slice(0, i);
116
+ if (b > 0)
117
+ return [...bytes.slice(0, i), b - 1];
118
+ }
119
+ }
120
+ function afterBytes(bytes, i = 0) {
121
+ for (;; i++) {
122
+ const b = bytes[i];
123
+ if (b < TERMINATOR)
124
+ return bytes.slice(0, i);
125
+ if (b < 0xff)
126
+ return [...bytes.slice(0, i), b + 1];
127
+ }
128
+ }
129
+ function betweenBytes(left, right) {
130
+ const shorter = Math.min(left.length, right.length) - 1;
131
+ for (let i = 0; i < shorter; i++) {
132
+ const lb = left[i], rb = right[i];
133
+ if (rb - lb > 1)
134
+ return [...left.slice(0, i), lb + Math.floor((rb - lb) / 2)];
135
+ if (rb - lb === 1)
136
+ return [...left.slice(0, i + 1), ...afterBytes(left.slice(i + 1))];
137
+ if (rb - lb < 0)
138
+ return undefined;
139
+ }
140
+ if (left.length < right.length) {
141
+ const prefix = right.slice(0, shorter + 1);
142
+ if (prefix[prefix.length - 1] < TERMINATOR)
143
+ return undefined;
144
+ return [...prefix, ...beforeBytes(right.slice(shorter + 1))];
145
+ }
146
+ if (left.length === right.length)
147
+ return undefined;
148
+ const prefix = left.slice(0, shorter + 1);
149
+ if (prefix[prefix.length - 1] >= TERMINATOR)
150
+ return undefined;
151
+ return [...prefix, ...afterBytes(left.slice(shorter + 1))];
152
+ }
153
+ /** An `order` strictly before `order`. */
154
+ export function orderBefore(order) { return orderHex([...beforeBytes(orderBytes(order)), TERMINATOR]); }
155
+ /** An `order` strictly after `order`. */
156
+ export function orderAfter(order) { return orderHex([...afterBytes(orderBytes(order)), TERMINATOR]); }
157
+ /** An `order` strictly between `left` and `right` (either may be open: `null`); `invalid_input` when `left >= right`. */
158
+ export function orderBetween(left, right) {
159
+ if (left == null && right == null)
160
+ return orderHex([TERMINATOR]);
161
+ if (right == null)
162
+ return orderAfter(left);
163
+ if (left == null)
164
+ return orderBefore(right);
165
+ const b = betweenBytes(orderBytes(left), orderBytes(right));
166
+ if (!b)
167
+ throw new SynapseError("invalid_input", `no order between ${left} and ${right}: the bounds are equal or inverted`);
168
+ return orderHex([...b, TERMINATOR]);
169
+ }
170
+ /**
171
+ * The hierarchy of `entities`, as its roots: each node's children by `(order, id)` (an entity without
172
+ * an `order` after the ordered ones); an entity whose parent is not in the scene is a root (the
173
+ * prototype's rule). A cycle cannot come from the runtime; were one there, it is cut where it closes.
174
+ */
175
+ export function treeOf(entities) {
176
+ const groups = new Map();
177
+ for (const [id, e] of Object.entries(entities)) {
178
+ let p = parentOf(e);
179
+ if (p !== null && !(p in entities))
180
+ p = null;
181
+ const o = orderOf(e);
182
+ let g = groups.get(p);
183
+ if (!g)
184
+ groups.set(p, (g = []));
185
+ g.push([o === undefined, o ?? "", id]);
186
+ }
187
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
188
+ const seen = new Set();
189
+ const build = (parent) => {
190
+ const kids = groups.get(parent);
191
+ if (!kids)
192
+ return [];
193
+ kids.sort((a, b) => (a[0] !== b[0] ? (a[0] ? 1 : -1) : cmp(a[1], b[1]) || cmp(a[2], b[2])));
194
+ const out = [];
195
+ for (const [, , id] of kids) {
196
+ if (seen.has(id))
197
+ continue;
198
+ seen.add(id);
199
+ out.push({ id, children: build(id) });
200
+ }
201
+ return out;
202
+ };
203
+ return build(null);
204
+ }
205
+ // -------------------------------------------------------------------------------------------- txn
206
+ /** One level of the runtime's patch merge: an object merges into an object key by key; anything else replaces. */
207
+ function mergeSet(into, set) {
208
+ const out = { ...into };
209
+ for (const [k, v] of Object.entries(set)) {
210
+ if (v === undefined)
211
+ continue;
212
+ const old = out[k];
213
+ out[k] = isObject(old) && isObject(v) ? { ...old, ...v } : v;
214
+ }
215
+ return out;
216
+ }
217
+ /**
218
+ * One authoring batch being built (`txn`): the ops it sends as one atomic `apply`. Ops on an entity
219
+ * spawned in the same batch fold into its spawn: a patch (of keys a patch may set) or a rename merges
220
+ * into it, a despawn cancels it (with every entity spawned under it in the batch). Nothing else is
221
+ * checked here: the runtime decides.
222
+ */
223
+ export class Txn {
224
+ #ops = [];
225
+ get ops() { return this.#ops; }
226
+ #spawnIndex(id) { return this.#ops.findIndex(op => op.op === "spawn" && op.entity.id === id); }
227
+ #spawnedUnder(parent) {
228
+ return this.#ops.flatMap(op => (op.op === "spawn" && op.entity.parent === parent ? [op.entity.id] : []));
229
+ }
230
+ /** Spawns an entity of `kind` under `opts.parent` (default the world root) with the components given (`layer` `authored` when not given); its id is minted here (or `opts.id`, a known one) and returned. No `order` is sent: the runtime appends it after its siblings. */
231
+ spawn(kind, opts) {
232
+ const { parent, id: given, ...components } = opts;
233
+ const id = given ?? ulid();
234
+ const entity = { ...components, id, kind, parent: parent ?? null };
235
+ entity.layer ??= "authored";
236
+ this.#ops.push({ op: "spawn", entity });
237
+ return id;
238
+ }
239
+ /** Sets components of `id` (one level deep: an object merges into an object). */
240
+ patch(id, set) {
241
+ // A protected key is the runtime's to refuse: such a patch is sent as it is.
242
+ const protectedKey = Object.keys(set).some(k => PATCH_PROTECTED.includes(k));
243
+ const i = protectedKey ? -1 : this.#spawnIndex(id);
244
+ if (i >= 0) {
245
+ const op = this.#ops[i];
246
+ this.#ops[i] = { op: "spawn", entity: mergeSet(op.entity, set) };
247
+ return;
248
+ }
249
+ this.#ops.push({ op: "patch", id, set });
250
+ }
251
+ rename(id, name) {
252
+ const i = this.#spawnIndex(id);
253
+ if (i >= 0) {
254
+ const op = this.#ops[i];
255
+ this.#ops[i] = { op: "spawn", entity: { ...op.entity, name } };
256
+ return;
257
+ }
258
+ this.#ops.push({ op: "rename", id, name });
259
+ }
260
+ /** Moves `id` under `parent` (`null`: the world root), before or after a sibling (default: after the last). */
261
+ reparent(id, parent, place) {
262
+ const op = { op: "reparent", id, parent };
263
+ if (place && "before" in place)
264
+ op.before = place.before;
265
+ else if (place && "after" in place)
266
+ op.after = place.after;
267
+ this.#ops.push(op);
268
+ }
269
+ /** Despawns `id` (`cascade`: with its subtree). One spawned in this batch is cancelled instead, unless it has children spawned in the batch and `cascade` is off (then the runtime decides). */
270
+ despawn(id, opts = {}) {
271
+ const cascade = opts.cascade ?? false;
272
+ if (this.#spawnIndex(id) >= 0 && (cascade || this.#spawnedUnder(id).length === 0)) {
273
+ this.#cancel(id);
274
+ return;
275
+ }
276
+ this.#ops.push({ op: "despawn", id, cascade });
277
+ }
278
+ /** Locks or unlocks `id` (a flags-only patch, which a lock lets through). */
279
+ setLocked(id, locked) { this.patch(id, { flags: { locked } }); }
280
+ /** Drops this batch's spawn of `id`, every op naming it, and the spawns under it. */
281
+ #cancel(id) {
282
+ const children = this.#spawnedUnder(id);
283
+ for (let i = this.#ops.length - 1; i >= 0; i--) {
284
+ const op = this.#ops[i];
285
+ if ((op.op === "spawn" && op.entity.id === id) || (op.op !== "spawn" && op.id === id))
286
+ this.#ops.splice(i, 1);
287
+ }
288
+ for (const c of children)
289
+ this.#cancel(c);
290
+ }
291
+ }
292
+ // ----------------------------------------------------------------------------------- the folds
293
+ /**
294
+ * Applies one diff op of a batch the runtime stamped `t` by `by` to the authored entities (the
295
+ * runtime's result, as the op describes it); returns the ids whose entity changed. A changed entity's
296
+ * `meta` is `{rev, t, by}`, as the runtime stamped it.
297
+ */
298
+ export function foldAuthored(authored, op, t, by) {
299
+ const stamped = (e, rev) => ({ ...e, meta: { rev, t, by } });
300
+ switch (op.op) {
301
+ case "spawn": {
302
+ const id = op.entity?.id;
303
+ if (typeof id !== "string")
304
+ return [];
305
+ authored.set(id, op.entity);
306
+ return [id];
307
+ }
308
+ case "despawn":
309
+ for (const id of op.removed)
310
+ authored.delete(id);
311
+ return [...op.removed];
312
+ case "patch": {
313
+ const e = authored.get(op.id);
314
+ if (!e)
315
+ return [];
316
+ authored.set(op.id, stamped(mergeSet(e, op.set), op.rev));
317
+ return [op.id];
318
+ }
319
+ case "reparent": {
320
+ const e = authored.get(op.id);
321
+ if (!e)
322
+ return [];
323
+ const next = { ...e, parent: op.parent ?? null, order: op.order };
324
+ if (op.xform)
325
+ next.xform = op.xform;
326
+ authored.set(op.id, stamped(next, op.rev));
327
+ return [op.id];
328
+ }
329
+ case "rename": {
330
+ const e = authored.get(op.id);
331
+ if (!e)
332
+ return [];
333
+ authored.set(op.id, stamped({ ...e, name: op.name }, op.rev));
334
+ return [op.id];
335
+ }
336
+ }
337
+ return [];
338
+ }
339
+ /** What changed from `old` to `next`, in the diff ops' shapes (a snapshot replacing the mirror, the mirror emptying): a spawn per new entity, a despawn per gone one, and per changed one a reparent (parent or order), a rename (name) and a patch of the other keys that differ. */
340
+ export function netDifference(old, next) {
341
+ const ops = [];
342
+ for (const [id, e] of next) {
343
+ const o = old.get(id);
344
+ if (!o) {
345
+ ops.push({ op: "spawn", entity: e });
346
+ continue;
347
+ }
348
+ if (o === e || deepEqual(o, e))
349
+ continue;
350
+ const rev = typeof e.meta?.rev === "number" ? e.meta.rev : 0;
351
+ if (parentOf(o) !== parentOf(e) || orderOf(o) !== orderOf(e))
352
+ ops.push({ op: "reparent", id, parent: parentOf(e), order: orderOf(e) ?? "", old_parent: parentOf(o), old_order: orderOf(o) ?? null, rev });
353
+ if (o.name !== e.name && typeof e.name === "string")
354
+ ops.push({ op: "rename", id, name: e.name, old_name: typeof o.name === "string" ? o.name : null, rev });
355
+ const set = {};
356
+ for (const [k, v] of Object.entries(e))
357
+ if (k !== "parent" && k !== "order" && k !== "name" && k !== "meta" && !deepEqual(o[k], v))
358
+ set[k] = v;
359
+ if (Object.keys(set).length)
360
+ ops.push({ op: "patch", id, set: set, rev });
361
+ }
362
+ for (const [id, o] of old)
363
+ if (!next.has(id))
364
+ ops.push({ op: "despawn", id, removed: [id], old_parent: parentOf(o), old_order: orderOf(o) ?? null });
365
+ return ops;
366
+ }
367
+ const list = (v) => (Array.isArray(v) ? v : []);
368
+ /** Writes the lanes of a tick or keyframe into `table`: a lane replaces that lane of the row (made when missing), the others stay. */
369
+ function putLanes(table, f) {
370
+ const ids = [];
371
+ // The row's stamp is the newest of its lanes' stamps (the runtime stamps every lane it sends).
372
+ const lane = (id, stamp, set) => {
373
+ const r = set(table.get(id) ?? {});
374
+ if (typeof stamp === "number" && (r.stamp === undefined || stamp > r.stamp))
375
+ r.stamp = stamp;
376
+ table.set(id, r);
377
+ ids.push(id);
378
+ };
379
+ for (const p of list(f.poses))
380
+ lane(p.id, p.stamp, r => ({ ...r, pose: { t: p.t, q: p.q } }));
381
+ for (const j of list(f.joints))
382
+ lane(j.id, j.stamp, r => ({ ...r, joints: j.j }));
383
+ for (const s of list(f.state))
384
+ lane(s.id, s.stamp, r => ({ ...r, state: s.s }));
385
+ return ids;
386
+ }
387
+ /** A keyframe as a fresh live table. */
388
+ export function liveTableOf(k) {
389
+ const t = new Map();
390
+ for (const e of list(k.upsert))
391
+ if (typeof e?.id === "string")
392
+ t.set(e.id, { entity: e });
393
+ putLanes(t, k);
394
+ return t;
395
+ }
396
+ /** One tick folded into `table` with the runtime's rules (an upsert replaces the whole row, a lane replaces its lane, a remove drops the row); returns the ids it touched. */
397
+ export function foldLive(table, d) {
398
+ const ids = [];
399
+ for (const e of list(d.upsert))
400
+ if (typeof e?.id === "string") {
401
+ table.set(e.id, { entity: e });
402
+ ids.push(e.id);
403
+ }
404
+ ids.push(...putLanes(table, d));
405
+ for (const r of list(d.remove)) {
406
+ table.delete(r.id);
407
+ ids.push(r.id);
408
+ }
409
+ return ids;
410
+ }
411
+ /** The merged read of one entity: the pose lane replaces `xform.t`/`xform.q` (the rest of the xform stays), the joints lane replaces `joints`, the state lane replaces `state`; nothing merges deeper. */
412
+ export function overlay(entity, row) {
413
+ if (!row.pose && !row.joints && !row.state)
414
+ return entity;
415
+ const out = { ...entity };
416
+ if (row.pose)
417
+ out.xform = { ...(isObject(entity.xform) ? entity.xform : {}), t: row.pose.t, q: row.pose.q };
418
+ if (row.joints)
419
+ out.joints = row.joints;
420
+ if (row.state)
421
+ out.state = row.state;
422
+ return out;
423
+ }
424
+ /** A live row as the live plane shows it (`SceneEntityLive`): each key present only when the row has it. */
425
+ export function liveValue(row) {
426
+ const out = {};
427
+ if (row.entity)
428
+ out.entity = row.entity;
429
+ if (row.pose)
430
+ out.pose = { t: row.pose.t, q: row.pose.q };
431
+ if (row.joints)
432
+ out.joints = row.joints;
433
+ if (row.state)
434
+ out.state = row.state;
435
+ if (row.stamp !== undefined)
436
+ out.stamp = row.stamp;
437
+ return out;
438
+ }
439
+ /** A live entity equal to the one held keeps its object (an upsert repeated, the periodic keyframe), so a view of it stays quiet. */
440
+ function keepLiveEntities(table, held, ids) {
441
+ for (const id of ids) {
442
+ const r = table.get(id), o = held(id);
443
+ if (r?.entity && o && r.entity !== o)
444
+ table.set(id, { ...r, entity: replaceEqualDeep(o, r.entity) });
445
+ }
446
+ }
447
+ /** `id` as the entities show it: its live row over its authored entity, or a live entity's own row. */
448
+ export function mergedOf(authored, live, id) {
449
+ const row = live.get(id), a = authored.get(id);
450
+ if (row?.entity)
451
+ return overlay(row.entity, row);
452
+ if (a && row)
453
+ return overlay(a, row);
454
+ return a;
455
+ }
456
+ /** What the task's reader writes and every handle reads: the scene as this node has it, and the handles' feeds. */
457
+ export class Mirror {
458
+ state = "detached";
459
+ /** Why the handle closed (`client`, `connection_lost`). */
460
+ ended;
461
+ epoch;
462
+ version = 0;
463
+ hash = "";
464
+ /** An authored diff skipped a version; cleared by the next snapshot. */
465
+ gap = false;
466
+ pending = 0;
467
+ authored = new Map();
468
+ live = new Map();
469
+ /** authored ⊕ live: the same object while nothing changed; an unchanged entity keeps its object. */
470
+ merged = EMPTY;
471
+ #tree = [];
472
+ #treeStale = false;
473
+ #listeners = new Set();
474
+ #sinks = { changes: new Set(), removals: new Set(), denied: new Set(), states: new Set() };
475
+ subscribe(l) { this.#listeners.add(l); return () => { this.#listeners.delete(l); }; }
476
+ #notify() { for (const l of [...this.#listeners]) {
477
+ try {
478
+ l();
479
+ }
480
+ catch (e) {
481
+ rethrowLater(e);
482
+ }
483
+ } }
484
+ addSink(kind, s) { this.#sinks[kind].add(s); }
485
+ removeSink(kind, s) { this.#sinks[kind].delete(s); }
486
+ /** Every feed of handle `id` ends. */
487
+ dropHandle(id) {
488
+ for (const set of Object.values(this.#sinks))
489
+ for (const s of [...set])
490
+ if (s.handle === id) {
491
+ set.delete(s);
492
+ s.end();
493
+ }
494
+ }
495
+ #send(kind, item) { for (const s of [...this.#sinks[kind]])
496
+ s.deliver(item); }
497
+ /** The hierarchy now (recomputed only after a change of the ids, a parent or an order; the same object while equal). */
498
+ tree() {
499
+ if (this.#treeStale) {
500
+ this.#tree = replaceEqualDeep(this.#tree, treeOf(this.merged));
501
+ this.#treeStale = false;
502
+ }
503
+ return this.#tree;
504
+ }
505
+ #setState(s) {
506
+ if (this.state === s || this.state === "closed")
507
+ return;
508
+ this.state = s;
509
+ this.#send("states", s);
510
+ }
511
+ #emitChanges(ops) { if (ops.length)
512
+ this.#send("changes", ops); }
513
+ /** The merged reads of `ids` recomputed. */
514
+ #refresh(ids) {
515
+ let merged;
516
+ for (const id of new Set(ids)) {
517
+ const prev = this.merged[id];
518
+ let next = mergedOf(this.authored, this.live, id);
519
+ if (next && prev)
520
+ next = replaceEqualDeep(prev, next);
521
+ if (next === prev)
522
+ continue;
523
+ merged ??= { ...this.merged };
524
+ if (next)
525
+ merged[id] = next;
526
+ else
527
+ delete merged[id];
528
+ if (!prev || !next || parentOf(prev) !== parentOf(next) || orderOf(prev) !== orderOf(next))
529
+ this.#treeStale = true;
530
+ }
531
+ if (merged)
532
+ this.merged = merged;
533
+ }
534
+ /** Every merged read recomputed. */
535
+ #rebuild() {
536
+ const merged = {};
537
+ for (const id of new Set([...this.authored.keys(), ...this.live.keys()])) {
538
+ const prev = this.merged[id];
539
+ const next = mergedOf(this.authored, this.live, id);
540
+ if (next)
541
+ merged[id] = prev ? replaceEqualDeep(prev, next) : next;
542
+ }
543
+ this.merged = replaceEqualDeep(this.merged, merged);
544
+ this.#treeStale = true;
545
+ }
546
+ /** A snapshot of incarnation `epoch` replaces the authored copy; the change feeds get the net difference. Not yet `attached`: the reader catches up first (`attach`). */
547
+ applySnapshot(epoch, s) {
548
+ if (this.state === "closed")
549
+ return;
550
+ // An entity equal to the one held keeps its object, so a view of the authored entity stays quiet.
551
+ const next = new Map(Object.entries(s.entities).map(([id, e]) => [id, replaceEqualDeep(this.authored.get(id), e)]));
552
+ const ops = netDifference(this.authored, next);
553
+ this.authored = next;
554
+ this.version = s.version;
555
+ this.hash = s.hash;
556
+ this.epoch = epoch;
557
+ this.gap = false;
558
+ this.#rebuild();
559
+ this.#emitChanges(ops);
560
+ this.#notify();
561
+ }
562
+ /** The reader has applied everything this node held when it bound the incarnation. */
563
+ attach() { if (this.epoch !== undefined && this.state !== "closed") {
564
+ this.#setState("attached");
565
+ this.#notify();
566
+ } }
567
+ /** One applied batch: its ops folded into the authored copy; the feeds get them as they came. */
568
+ applyDiff(d, warn) {
569
+ if (this.epoch === undefined || this.state === "closed")
570
+ return;
571
+ if (d.version !== this.version + 1) {
572
+ warn(`scene: diff out of sequence (have ${this.version}, got ${d.version}); not synced until the next snapshot`);
573
+ this.gap = true;
574
+ }
575
+ const touched = [];
576
+ for (const op of d.ops)
577
+ touched.push(...foldAuthored(this.authored, op, d.t, d.by ?? null));
578
+ this.version = d.version;
579
+ this.hash = d.hash;
580
+ this.#refresh(touched);
581
+ this.#emitChanges(d.ops);
582
+ this.#notify();
583
+ }
584
+ /** A keyframe replaces the live table. */
585
+ applyKeyframe(k) {
586
+ if (this.state === "closed")
587
+ return;
588
+ const table = liveTableOf(k);
589
+ const old = this.live;
590
+ keepLiveEntities(table, id => old.get(id)?.entity, table.keys());
591
+ const ids = [...this.live.keys(), ...table.keys()];
592
+ this.live = table;
593
+ this.#refresh(ids);
594
+ this.#notify();
595
+ }
596
+ /** One tick folded into the live table; its removals go to the feeds. */
597
+ applyLiveTick(d) {
598
+ if (this.state === "closed")
599
+ return;
600
+ const held = new Map(list(d.upsert).map(e => [e?.id, this.live.get(e?.id)?.entity]));
601
+ const ids = foldLive(this.live, d);
602
+ keepLiveEntities(this.live, id => held.get(id), held.keys());
603
+ this.#refresh(ids);
604
+ const removed = list(d.remove);
605
+ if (removed.length)
606
+ this.#send("removals", removed);
607
+ this.#notify();
608
+ }
609
+ /** Ids of this node's live rows the runtime dropped. */
610
+ denied(ids) { if (this.state !== "closed")
611
+ this.#send("denied", ids); }
612
+ setPending(n) { this.pending = n; this.#notify(); }
613
+ /** No incarnation: the copy empties (the change feeds see the despawns), `detached`. */
614
+ detach() {
615
+ if (this.state === "closed")
616
+ return;
617
+ const ops = netDifference(this.authored, new Map());
618
+ this.authored = new Map();
619
+ this.live = new Map();
620
+ this.merged = EMPTY;
621
+ this.#treeStale = true;
622
+ this.epoch = undefined;
623
+ this.version = 0;
624
+ this.hash = "";
625
+ this.gap = false;
626
+ this.#setState("detached");
627
+ this.#emitChanges(ops);
628
+ this.#notify();
629
+ }
630
+ /** The end: emptied, `closed` (`reason` recorded), every feed ended. */
631
+ close(reason) {
632
+ if (this.state === "closed")
633
+ return;
634
+ this.detach();
635
+ this.#setState("closed");
636
+ this.ended = reason;
637
+ for (const set of Object.values(this.#sinks)) {
638
+ for (const s of set)
639
+ s.end();
640
+ set.clear();
641
+ }
642
+ this.#notify();
643
+ }
644
+ }
645
+ // --------------------------------------------------------------------------------------- views
646
+ /**
647
+ * A value of the scene (`SceneView`): `read` picks it from the mirror (the same object until it
648
+ * changes). It follows the mirror only while someone listens (`subscribe`, a pending `changed()`),
649
+ * and while it does it is in `active`, so the handle's close can end it.
650
+ */
651
+ class MirrorView {
652
+ mirror;
653
+ read;
654
+ active;
655
+ ended;
656
+ #listeners = new Set();
657
+ #waiting = [];
658
+ #unmirror;
659
+ #last;
660
+ constructor(mirror, read, active, ended) {
661
+ this.mirror = mirror;
662
+ this.read = read;
663
+ this.active = active;
664
+ this.ended = ended;
665
+ }
666
+ get() { return this.read(); }
667
+ subscribe(listener) {
668
+ if (this.ended())
669
+ return () => { };
670
+ this.#listeners.add(listener);
671
+ this.#follow();
672
+ return () => { this.#listeners.delete(listener); this.#unfollow(); };
673
+ }
674
+ changed() {
675
+ if (this.ended())
676
+ return Promise.resolve(this.read());
677
+ const d = deferred();
678
+ this.#waiting.push(d);
679
+ this.#follow();
680
+ return d.promise;
681
+ }
682
+ #follow() {
683
+ if (this.#unmirror)
684
+ return;
685
+ this.#last = this.read();
686
+ this.#unmirror = this.mirror.subscribe(() => this.#update());
687
+ this.active.add(this);
688
+ }
689
+ #unfollow() {
690
+ if (!this.#unmirror || this.#listeners.size || this.#waiting.length)
691
+ return;
692
+ this.#unmirror();
693
+ this.#unmirror = undefined;
694
+ this.active.delete(this);
695
+ }
696
+ #update() {
697
+ let v;
698
+ try {
699
+ v = this.read();
700
+ }
701
+ catch (e) {
702
+ rethrowLater(e);
703
+ return;
704
+ }
705
+ if (v === this.#last)
706
+ return;
707
+ this.#last = v;
708
+ for (const l of [...this.#listeners]) {
709
+ try {
710
+ l();
711
+ }
712
+ catch (e) {
713
+ rethrowLater(e);
714
+ }
715
+ }
716
+ for (const d of this.#waiting.splice(0))
717
+ d.resolve(v);
718
+ this.#unfollow();
719
+ }
720
+ /** The handle closed: a pending `changed()` resolves with the value now; listeners are dropped. */
721
+ end() {
722
+ let v;
723
+ try {
724
+ v = this.read();
725
+ }
726
+ catch {
727
+ v = this.#last;
728
+ }
729
+ for (const d of this.#waiting.splice(0))
730
+ d.resolve(v);
731
+ this.#listeners.clear();
732
+ this.#unfollow();
733
+ }
734
+ }
735
+ function openLiveWriter(s) {
736
+ const bc = s.createBroadcast(`${s.prefix}/scene/live`);
737
+ // Kept as long as a call track keeps its groups (the runtime's follower reads every batch).
738
+ const track = bc.createTrack(LIVE_TRACK, { priority: moqPriority(3), maxAge: Moq.Time.Milli(CALL_GROUP_MAX_AGE_MS), timescale: Moq.Time.Timescale.MICRO });
739
+ bc.announce();
740
+ return { session: s, bc, stream: new StreamWriter(track), seq: 0 };
741
+ }
742
+ function closeLiveWriter(w) {
743
+ if (!w)
744
+ return;
745
+ w.stream.close();
746
+ try {
747
+ w.bc.close();
748
+ }
749
+ catch { /* closed */ }
750
+ }
751
+ /** The node's scene: what every handle shares with the task. */
752
+ export class Scene {
753
+ node;
754
+ warn;
755
+ mirror = new Mirror();
756
+ mailbox = new AsyncQueue(Infinity);
757
+ exited = deferred();
758
+ /** Handles neither closed nor dropped; the one that brings it to 0 tells the task `close`. */
759
+ handles = 0;
760
+ nextHandle = 0;
761
+ /** The live writer of the current session, made on the first `live()`. */
762
+ writer;
763
+ detachedDrops = 0;
764
+ constructor(node, warn) {
765
+ this.node = node;
766
+ this.warn = warn;
767
+ }
768
+ post(e) { return this.mailbox.push(e); }
769
+ request(make) {
770
+ const reply = deferred();
771
+ if (!this.post(make(reply)))
772
+ return Promise.reject(closedError(this.mirror.ended ?? ENDED_CLIENT));
773
+ return reply.promise;
774
+ }
775
+ }
776
+ const detachedError = () => new SynapseError("project_inactive", "the scene is detached: no incarnation of the active project's scene is bound");
777
+ /** What a closed handle's verbs fail with: `not_connected`, the message saying why. */
778
+ function closedError(reason) {
779
+ return new SynapseError("not_connected", reason === ENDED_CONNECTION_LOST ? "the node ended" : "the scene handle is closed");
780
+ }
781
+ const asError = (e) => (e instanceof SynapseError ? e : new SynapseError("internal", String(e)));
782
+ export function startScene(scene, registry) { void run(scene, registry); }
783
+ async function run(scene, registry) {
784
+ const { mirror, node } = scene;
785
+ let session;
786
+ let phase = { kind: "idle" };
787
+ const ph = () => phase;
788
+ let attempts = 0;
789
+ /** The incarnation that last ended: not bound again (its announcement may still be seen). */
790
+ let endedEpoch;
791
+ /** The session's project state as last read. */
792
+ let project;
793
+ let unwatchProject;
794
+ const joining = [];
795
+ let waiters = [];
796
+ const inactive = () => project !== undefined && project.active === null;
797
+ const answerJoins = () => { for (const j of joining.splice(0))
798
+ j.resolve(); };
799
+ const failWaiters = (e) => { for (const w of waiters.splice(0))
800
+ w.reply.reject(e()); };
801
+ const stopPhase = () => {
802
+ if (phase.kind === "following")
803
+ phase.stop.abort();
804
+ else if (phase.kind === "paced")
805
+ clearTimeout(phase.timer);
806
+ phase = { kind: "idle" };
807
+ };
808
+ /** Follows the announcements on the task's session, from `idle`. */
809
+ const follow = () => {
810
+ if (phase.kind !== "idle" || mirror.state === "closed" || !session)
811
+ return;
812
+ const s = session;
813
+ let announced;
814
+ try {
815
+ announced = s.sub.announced(Moq.Path.Pattern.parse(`${SCENE_BASE}/*`));
816
+ }
817
+ catch (e) {
818
+ scene.warn(`scene: announcements not followed: ${e instanceof Error ? e.message : String(e)}`);
819
+ return;
820
+ }
821
+ attempts += 1;
822
+ const attempt = attempts;
823
+ const stop = new AbortController();
824
+ phase = { kind: "following", attempt, stop };
825
+ void followIncarnation(scene, s, announced, endedEpoch, stop.signal).then(r => scene.post({ kind: "followEnded", attempt, epoch: r.epoch, reason: r.reason }));
826
+ };
827
+ /** The incarnation is let go: the mirror empties, writes waiting to show fail, followed again after `paceMs`. */
828
+ const again = (paceMs) => {
829
+ failWaiters(() => new SynapseError("conflict", "the scene's incarnation ended before the write showed"));
830
+ stopPhase();
831
+ mirror.detach();
832
+ const a = ++attempts;
833
+ phase = { kind: "paced", timer: setTimeout(() => scene.post({ kind: "paced", attempt: a }), paceMs) };
834
+ };
835
+ /** The node's session is now `s` (or none): what was bound on the previous one is over; the announcements are followed on `s`. */
836
+ const sessionChanged = (s) => {
837
+ session = s;
838
+ stopPhase();
839
+ failWaiters(() => new SynapseError("connection_lost", "the session ended before the write showed in the scene"));
840
+ mirror.detach();
841
+ closeLiveWriter(scene.writer);
842
+ scene.writer = undefined;
843
+ unwatchProject?.();
844
+ unwatchProject = undefined;
845
+ project = s?.project.get();
846
+ if (s)
847
+ unwatchProject = s.project.subscribe(p => { scene.post({ kind: "project", snapshot: p }); });
848
+ if (inactive())
849
+ answerJoins();
850
+ follow();
851
+ };
852
+ const applied = (epoch, version) => {
853
+ const reached = waiters.filter(w => w.epoch === epoch && w.version <= version);
854
+ waiters = waiters.filter(w => !reached.includes(w));
855
+ for (const w of reached)
856
+ w.reply.resolve();
857
+ if (mirror.state === "attached")
858
+ answerJoins();
859
+ };
860
+ const join = (reply) => { if (mirror.state === "attached" || inactive())
861
+ reply.resolve();
862
+ else
863
+ joining.push(reply); };
864
+ /** A batch: sent naming the bound incarnation's epoch. */
865
+ const apply = (ops, base, reply) => {
866
+ if (mirror.state === "closed") {
867
+ reply.reject(closedError(mirror.ended ?? ENDED_CLIENT));
868
+ return;
869
+ }
870
+ const epoch = mirror.epoch;
871
+ if (mirror.state !== "attached" || epoch === undefined || !session) {
872
+ reply.reject(detachedError());
873
+ return;
874
+ }
875
+ const args = { epoch, ops };
876
+ if (base !== undefined)
877
+ args.base = base;
878
+ let call;
879
+ try {
880
+ call = startOn(node, session, "synapse/scene/apply", args, {});
881
+ }
882
+ catch (e) {
883
+ reply.reject(asError(e));
884
+ return;
885
+ }
886
+ mirror.setPending(mirror.pending + 1);
887
+ const done = (result) => {
888
+ // The task is gone: the caller is answered from here (the runtime's answer, unseen).
889
+ if (!scene.post({ kind: "applyDone", epoch, result, reply })) {
890
+ if ("ok" in result)
891
+ reply.resolve();
892
+ else
893
+ reply.reject(result.err);
894
+ }
895
+ };
896
+ void call.result.then(v => done({ ok: v }), (e) => done({ err: asError(e) }));
897
+ };
898
+ /** The runtime answered a batch of `epoch`: applied, the caller is answered once the mirror shows it; refused, at once — and a stale epoch lets go of the incarnation read. */
899
+ const applyDone = (epoch, result, reply) => {
900
+ mirror.setPending(mirror.pending - 1);
901
+ if ("ok" in result) {
902
+ const v = result.ok?.version;
903
+ const version = typeof v === "number" ? v : typeof v === "bigint" ? Number(v) : undefined;
904
+ if (version === undefined) {
905
+ reply.reject(new SynapseError("internal", "scene apply reply not understood"));
906
+ return;
907
+ }
908
+ if (mirror.state === "closed") {
909
+ reply.reject(closedError(mirror.ended ?? ENDED_CLIENT));
910
+ return;
911
+ }
912
+ const here = mirror.state === "attached" && mirror.epoch === epoch;
913
+ if (here && mirror.version >= version)
914
+ reply.resolve();
915
+ else if (here)
916
+ waiters.push({ epoch, version, reply });
917
+ else
918
+ reply.reject(new SynapseError("conflict", "the scene's incarnation changed before the write showed"));
919
+ return;
920
+ }
921
+ const e = result.err;
922
+ const live = e.data?.live_epoch;
923
+ if (e.code === "conflict" && typeof live === "string" && live !== epoch && mirror.epoch === epoch) {
924
+ endedEpoch = epoch;
925
+ again(REFOLLOW_PACE_MS);
926
+ }
927
+ reply.reject(e);
928
+ };
929
+ /** A node notice: a live denial (`not_owner` with `data.ids`) goes to the denial feeds. */
930
+ const notice = (b) => {
931
+ if (b.code !== "not_owner")
932
+ return;
933
+ const ids = b.data?.ids;
934
+ if (Array.isArray(ids))
935
+ mirror.denied(ids.filter((x) => typeof x === "string"));
936
+ };
937
+ const { session: first, unmonitor } = node.monitor((e) => scene.post(e.kind === "started" ? { kind: "session", session: e.session } : e.kind === "ended" ? { kind: "sessionEnded" } : { kind: "nodeEnded" }));
938
+ const onNotice = (b) => { scene.post({ kind: "notice", body: b }); };
939
+ node.notices.add(onNotice);
940
+ let reason = ENDED_CLIENT;
941
+ try {
942
+ sessionChanged(first);
943
+ loop: for (;;) {
944
+ const ev = await scene.mailbox.next();
945
+ if (!ev)
946
+ break;
947
+ switch (ev.kind) {
948
+ case "session":
949
+ sessionChanged(ev.session);
950
+ break;
951
+ case "sessionEnded":
952
+ sessionChanged(undefined);
953
+ break;
954
+ case "nodeEnded":
955
+ reason = ENDED_CONNECTION_LOST;
956
+ break loop;
957
+ case "applied":
958
+ applied(ev.epoch, ev.version);
959
+ break;
960
+ case "followEnded": {
961
+ {
962
+ const p = ph();
963
+ if (p.kind !== "following" || p.attempt !== ev.attempt)
964
+ break;
965
+ }
966
+ phase = { kind: "idle" };
967
+ // An incarnation that ended with its final frame is not bound again.
968
+ if (ev.epoch !== undefined && ev.reason !== undefined)
969
+ endedEpoch = ev.epoch;
970
+ again(REFOLLOW_PACE_MS);
971
+ break;
972
+ }
973
+ case "join":
974
+ join(ev.reply);
975
+ break;
976
+ case "apply":
977
+ apply(ev.ops, ev.base, ev.reply);
978
+ break;
979
+ case "applyDone":
980
+ applyDone(ev.epoch, ev.result, ev.reply);
981
+ break;
982
+ case "project":
983
+ project = ev.snapshot;
984
+ if (inactive())
985
+ answerJoins();
986
+ break;
987
+ case "notice":
988
+ notice(ev.body);
989
+ break;
990
+ case "paced":
991
+ if (ph().kind === "paced" && ev.attempt === attempts) {
992
+ phase = { kind: "idle" };
993
+ follow();
994
+ }
995
+ break;
996
+ case "close": break loop;
997
+ }
998
+ }
999
+ }
1000
+ finally {
1001
+ unmonitor();
1002
+ node.notices.delete(onNotice);
1003
+ unwatchProject?.();
1004
+ stopPhase();
1005
+ mirror.close(reason);
1006
+ closeLiveWriter(scene.writer);
1007
+ scene.writer = undefined;
1008
+ for (const j of joining.splice(0))
1009
+ j.reject(closedError(reason));
1010
+ failWaiters(() => closedError(reason));
1011
+ // No new work is taken (a batch finds the handle closed), but every batch already on the wire is
1012
+ // answered to its caller with what became of it before the task ends.
1013
+ while (mirror.pending > 0) {
1014
+ const ev = await scene.mailbox.next();
1015
+ if (!ev)
1016
+ break;
1017
+ if (ev.kind === "applyDone")
1018
+ applyDone(ev.epoch, ev.result, ev.reply);
1019
+ else if (ev.kind === "apply" || ev.kind === "join")
1020
+ ev.reply.reject(closedError(reason));
1021
+ }
1022
+ void scene.mailbox.close();
1023
+ for (let ev = scene.mailbox.tryNext(); ev; ev = scene.mailbox.tryNext())
1024
+ if (ev.kind === "apply" || ev.kind === "join" || ev.kind === "applyDone")
1025
+ ev.reply.reject(closedError(reason));
1026
+ if (registry.current === scene)
1027
+ registry.current = undefined;
1028
+ scene.exited.resolve();
1029
+ }
1030
+ }
1031
+ /** The epoch of an announced prefix (`runtime/scene/@<epoch>`). */
1032
+ function announcedEpoch(prefix) {
1033
+ if (!prefix.startsWith(`${SCENE_BASE}/@`))
1034
+ return undefined;
1035
+ const e = prefix.slice(SCENE_BASE.length + 2);
1036
+ return e && !e.includes("/") ? e : undefined;
1037
+ }
1038
+ /** One track being read: its subscriber and the group it is in (both closed when the reading stops). */
1039
+ class TrackReading {
1040
+ sub;
1041
+ group;
1042
+ constructor(sub) {
1043
+ this.sub = sub;
1044
+ }
1045
+ close() { try {
1046
+ this.group?.close();
1047
+ }
1048
+ catch { /* closed */ } try {
1049
+ this.sub.close();
1050
+ }
1051
+ catch { /* closed */ } }
1052
+ /** The first group and its first frame's payload. */
1053
+ async first() {
1054
+ try {
1055
+ const g = await this.sub.recvGroup();
1056
+ if (!g)
1057
+ return undefined;
1058
+ this.group = g;
1059
+ return (await g.readFrame())?.payload;
1060
+ }
1061
+ catch {
1062
+ return undefined;
1063
+ }
1064
+ }
1065
+ /** Reads, without waiting, every frame already held, from the current group on (a newer group that is ready replaces the current one); `on` returns `false` to stop. */
1066
+ drain(on) {
1067
+ for (;;) {
1068
+ const g = this.group;
1069
+ if (g) {
1070
+ let f;
1071
+ try {
1072
+ f = g.tryReadFrame();
1073
+ }
1074
+ catch {
1075
+ f = undefined;
1076
+ this.group = undefined;
1077
+ }
1078
+ if (f) {
1079
+ if (on(f.payload))
1080
+ continue;
1081
+ return;
1082
+ }
1083
+ if (this.group && g.done)
1084
+ this.group = undefined;
1085
+ }
1086
+ let next;
1087
+ try {
1088
+ next = this.sub.tryRecvGroup();
1089
+ }
1090
+ catch {
1091
+ next = undefined;
1092
+ }
1093
+ if (!next)
1094
+ return;
1095
+ try {
1096
+ this.group?.close();
1097
+ }
1098
+ catch { /* closed */ }
1099
+ this.group = next;
1100
+ }
1101
+ }
1102
+ /** Reads frame by frame as frames come, from the current group on, until `on` returns `false` or the track ends. */
1103
+ async readOn(on) {
1104
+ try {
1105
+ for (;;) {
1106
+ if (!this.group) {
1107
+ this.group = await this.sub.recvGroup();
1108
+ if (!this.group)
1109
+ return;
1110
+ }
1111
+ const g = this.group;
1112
+ for (;;) {
1113
+ let f;
1114
+ try {
1115
+ f = await g.readFrame();
1116
+ }
1117
+ catch {
1118
+ break;
1119
+ }
1120
+ if (!f)
1121
+ break;
1122
+ if (!on(f.payload))
1123
+ return;
1124
+ }
1125
+ this.group = undefined;
1126
+ }
1127
+ }
1128
+ catch { /* the track went */ }
1129
+ }
1130
+ }
1131
+ /** A frame's header flags and decoded body, or `undefined` (warned) when it does not decode. */
1132
+ function decodeFrame(p, warn, what) {
1133
+ try {
1134
+ const { header, hlen } = decodeHeader(p);
1135
+ return { flags: header.flags, body: decodeBody(p.subarray(hlen)) };
1136
+ }
1137
+ catch {
1138
+ warn(`scene: ${what} frame not decodable`);
1139
+ return undefined;
1140
+ }
1141
+ }
1142
+ /**
1143
+ * Binds the first incarnation announced (other than `skip`, the one that last ended) and reads both
1144
+ * its tracks into the mirror, telling the task of each authored frame applied; returns once the
1145
+ * `authored` track ends (its final frame, or the track going). Once `signal` aborts it writes nothing more.
1146
+ */
1147
+ async function followIncarnation(scene, session, announced, skip, signal) {
1148
+ const { mirror, warn } = scene;
1149
+ const none = { epoch: undefined, reason: undefined };
1150
+ const closers = [announced];
1151
+ const closeAll = () => { for (const c of closers) {
1152
+ try {
1153
+ c.close();
1154
+ }
1155
+ catch { /* closed */ }
1156
+ } };
1157
+ signal.addEventListener("abort", closeAll, { once: true });
1158
+ try {
1159
+ let epoch;
1160
+ for (;;) {
1161
+ const u = await announced.next();
1162
+ if (!u || signal.aborted)
1163
+ return none;
1164
+ if (u.kind === "retracted")
1165
+ continue;
1166
+ const e = announcedEpoch(String(u.prefix));
1167
+ if (e !== undefined && e !== skip) {
1168
+ epoch = e;
1169
+ break;
1170
+ }
1171
+ }
1172
+ const at = epoch;
1173
+ // Resolving the broadcast races an end of this epoch already on the cursor.
1174
+ const req = session.request(`${SCENE_BASE}/@${at}`);
1175
+ closers.push(req);
1176
+ const retracted = (async () => { for (;;) {
1177
+ const u = await announced.next();
1178
+ if (!u)
1179
+ return false;
1180
+ if (u.kind === "retracted" && announcedEpoch(String(u.prefix)) === at)
1181
+ return true;
1182
+ } })();
1183
+ let bc;
1184
+ try {
1185
+ const r = await Promise.race([waitActive(req, 10_000, signal).then(b => ({ bc: b })), retracted.then(gone => ({ gone }))]);
1186
+ if ("gone" in r)
1187
+ return r.gone ? { epoch: at, reason: "ended" } : none;
1188
+ bc = r.bc;
1189
+ }
1190
+ catch {
1191
+ return none;
1192
+ }
1193
+ announced.close();
1194
+ closers.push(bc);
1195
+ const authored = new TrackReading(bc.track(AUTHORED_TRACK).subscribe(latestSubscription(moqPriority(1))));
1196
+ const live = new TrackReading(bc.track(LIVE_TRACK).subscribe(latestSubscription(moqPriority(1))));
1197
+ closers.push(authored, live);
1198
+ if (signal.aborted)
1199
+ return none;
1200
+ let end;
1201
+ const ended = () => ({ epoch: at, reason: end });
1202
+ const onAuthored = (p) => {
1203
+ if (signal.aborted)
1204
+ return false;
1205
+ const f = decodeFrame(p, warn, "authored");
1206
+ if (!f)
1207
+ return true;
1208
+ if (f.flags & FLAGS.END) {
1209
+ const r = f.body?.reason;
1210
+ end = typeof r === "string" ? r : "ended";
1211
+ return false;
1212
+ }
1213
+ let version;
1214
+ if (f.flags & FLAGS.KEY) {
1215
+ const s = f.body;
1216
+ if (!s || typeof s.version !== "number" || !isObject(s.entities)) {
1217
+ warn("scene: snapshot not understood");
1218
+ return true;
1219
+ }
1220
+ mirror.applySnapshot(at, s);
1221
+ version = s.version;
1222
+ }
1223
+ else if (f.flags & FLAGS.DELTA) {
1224
+ const d = f.body;
1225
+ if (!d || typeof d.version !== "number" || !Array.isArray(d.ops)) {
1226
+ warn("scene: diff not understood");
1227
+ return true;
1228
+ }
1229
+ mirror.applyDiff(d, warn);
1230
+ version = d.version;
1231
+ }
1232
+ else
1233
+ return true;
1234
+ scene.post({ kind: "applied", epoch: at, version });
1235
+ return true;
1236
+ };
1237
+ const onLive = (p) => {
1238
+ if (signal.aborted)
1239
+ return false;
1240
+ const f = decodeFrame(p, warn, "live");
1241
+ if (!f || !isObject(f.body))
1242
+ return true;
1243
+ if (f.flags & FLAGS.KEY)
1244
+ mirror.applyKeyframe(f.body);
1245
+ else if (f.flags & FLAGS.DELTA)
1246
+ mirror.applyLiveTick(f.body);
1247
+ return true;
1248
+ };
1249
+ // Catching up: the first frame of each track (the open group's snapshot, the whole live set),
1250
+ // then every frame this node already holds on either, read without waiting; only then is the
1251
+ // handle `attached` (its version is the scene's as far as this node has it).
1252
+ const a0 = await authored.first();
1253
+ if (signal.aborted)
1254
+ return none;
1255
+ if (!a0)
1256
+ return ended();
1257
+ if (!onAuthored(a0))
1258
+ return ended();
1259
+ const l0 = await live.first();
1260
+ if (signal.aborted)
1261
+ return none;
1262
+ if (!l0)
1263
+ return ended();
1264
+ onLive(l0);
1265
+ authored.drain(onAuthored);
1266
+ if (end !== undefined)
1267
+ return ended();
1268
+ live.drain(onLive);
1269
+ if (signal.aborted)
1270
+ return none;
1271
+ mirror.attach();
1272
+ scene.post({ kind: "applied", epoch: at, version: mirror.version });
1273
+ // Then frame by frame as they come. The live track's end is the incarnation's: the authored track says how it ended.
1274
+ void live.readOn(onLive);
1275
+ await authored.readOn(onAuthored);
1276
+ return ended();
1277
+ }
1278
+ catch {
1279
+ return none;
1280
+ }
1281
+ finally {
1282
+ signal.removeEventListener("abort", closeAll);
1283
+ closeAll();
1284
+ }
1285
+ }
1286
+ // ------------------------------------------------------------------------------------- handle
1287
+ /** Collects the items of one feed for an async iterator: a feed `SCENE_FEED_CAP` items behind is ended (what it holds is still read). */
1288
+ function queueSink(handle, onEnd) {
1289
+ const q = new AsyncQueue(Infinity);
1290
+ const sink = {
1291
+ handle,
1292
+ deliver: item => { if (q.size >= SCENE_FEED_CAP) {
1293
+ onEnd();
1294
+ void q.close();
1295
+ return;
1296
+ } q.push(item); },
1297
+ end: () => { void q.close(); },
1298
+ };
1299
+ return { sink, q };
1300
+ }
1301
+ async function* iterate(q, leave) {
1302
+ try {
1303
+ for await (const item of q)
1304
+ yield item;
1305
+ }
1306
+ finally {
1307
+ leave();
1308
+ void q.close();
1309
+ }
1310
+ }
1311
+ /** The application's handle on the node's scene: the last one closed ends the task. */
1312
+ export class SceneHandleImpl {
1313
+ scene;
1314
+ #closed = false;
1315
+ #id;
1316
+ /** The views of this handle that follow the mirror now. */
1317
+ #views = new Set();
1318
+ #unsubscribes = new Set();
1319
+ constructor(scene) {
1320
+ this.scene = scene;
1321
+ scene.handles++;
1322
+ this.#id = scene.nextHandle++;
1323
+ }
1324
+ get entities() { return this.scene.mirror.merged; }
1325
+ entity(id, opts = {}) {
1326
+ const mirror = this.scene.mirror;
1327
+ // The entity as declared (the authored one, or a live entity's upsert row): its object changes
1328
+ // only when an authored batch or a new upsert changes it, never on a lane tick.
1329
+ if (opts.plane === "authored")
1330
+ return this.#view(() => mirror.authored.get(id) ?? mirror.live.get(id)?.entity);
1331
+ if (opts.plane === "live") {
1332
+ // The live row, rebuilt only when the row object changes and kept the same object while equal.
1333
+ let from;
1334
+ let last;
1335
+ return this.#view(() => {
1336
+ const row = mirror.live.get(id);
1337
+ if (row === from)
1338
+ return last;
1339
+ from = row;
1340
+ last = row ? replaceEqualDeep(last, liveValue(row)) : undefined;
1341
+ return last;
1342
+ });
1343
+ }
1344
+ return this.#view(() => mirror.merged[id]);
1345
+ }
1346
+ tree() { return this.#view(() => this.scene.mirror.tree()); }
1347
+ query(where) {
1348
+ let from;
1349
+ let last = [];
1350
+ return this.#view(() => {
1351
+ const m = this.scene.mirror.merged;
1352
+ if (m === from)
1353
+ return last;
1354
+ from = m;
1355
+ const ids = Object.keys(m).filter(id => where(m[id])).sort();
1356
+ last = replaceEqualDeep(last, ids.map(id => m[id]));
1357
+ return last;
1358
+ });
1359
+ }
1360
+ #view(read) { return new MirrorView(this.scene.mirror, read, this.#views, () => this.#closed || this.scene.mirror.state === "closed"); }
1361
+ get status() {
1362
+ const m = this.scene.mirror;
1363
+ const state = this.#closed ? "closed" : m.state;
1364
+ return { state, synced: state === "attached" && this.scene.node.isConnected && m.pending === 0 && !m.gap, pending: m.pending, version: m.version, hash: m.hash, detachedDrops: this.scene.detachedDrops };
1365
+ }
1366
+ // ---- feeds
1367
+ #feed(kind, cb) {
1368
+ const m = this.scene.mirror;
1369
+ const open = !this.#closed && m.state !== "closed";
1370
+ if (cb) {
1371
+ const sink = { handle: this.#id, deliver: item => { try {
1372
+ cb(item);
1373
+ }
1374
+ catch (e) {
1375
+ rethrowLater(e);
1376
+ } }, end: () => { } };
1377
+ if (open)
1378
+ m.addSink(kind, sink);
1379
+ return () => m.removeSink(kind, sink);
1380
+ }
1381
+ const { sink, q } = queueSink(this.#id, () => m.removeSink(kind, sink));
1382
+ if (open)
1383
+ m.addSink(kind, sink);
1384
+ else
1385
+ void q.close();
1386
+ return iterate(q, () => m.removeSink(kind, sink));
1387
+ }
1388
+ watchChanges(cb) { return this.#feed("changes", cb); }
1389
+ watchLiveRemovals(cb) { return this.#feed("removals", cb); }
1390
+ watchLiveDenied(cb) { return this.#feed("denied", cb); }
1391
+ watchState(cb) { return this.#feed("states", cb); }
1392
+ // ---- author: every verb is one `apply`
1393
+ async apply(ops, base) {
1394
+ if (this.#closed)
1395
+ throw closedError(ENDED_CLIENT);
1396
+ if (this.scene.mirror.state === "detached")
1397
+ throw detachedError();
1398
+ await this.scene.request(reply => ({ kind: "apply", ops, base, reply }));
1399
+ }
1400
+ async txn(build) {
1401
+ const t = new Txn();
1402
+ build(t);
1403
+ await this.apply([...t.ops]);
1404
+ }
1405
+ async spawn(kind, opts) {
1406
+ let id = "";
1407
+ await this.txn(t => { id = t.spawn(kind, opts); });
1408
+ return id;
1409
+ }
1410
+ patch(id, set) { return this.txn(t => t.patch(id, set)); }
1411
+ rename(id, name) { return this.txn(t => t.rename(id, name)); }
1412
+ reparent(id, parent, place) { return this.txn(t => t.reparent(id, parent, place)); }
1413
+ despawn(id, opts) { return this.txn(t => t.despawn(id, opts)); }
1414
+ setLocked(id, locked) { return this.txn(t => t.setLocked(id, locked)); }
1415
+ // ---- live
1416
+ /**
1417
+ * Writes one live batch on this node's live track (made on the first call of each session); returns
1418
+ * at once, like `publish`. The runtime stamps the rows and owns the outcome: an id another writer owns
1419
+ * comes back on `watchLiveDenied`, a malformed batch as a node notice. Not attached: dropped and counted.
1420
+ */
1421
+ live(batch) {
1422
+ if (this.#closed)
1423
+ throw closedError(ENDED_CLIENT);
1424
+ const scene = this.scene;
1425
+ const s = scene.node.currentSession();
1426
+ if (scene.mirror.state !== "attached" || !s) {
1427
+ scene.detachedDrops++;
1428
+ return;
1429
+ }
1430
+ const body = {};
1431
+ if (batch.upsert?.length)
1432
+ body.upsert = batch.upsert;
1433
+ if (batch.poses?.length)
1434
+ body.poses = batch.poses.map(p => ({ id: p.id, t: p.t, q: p.q }));
1435
+ if (batch.joints?.length)
1436
+ body.joints = batch.joints.map(r => ({ id: r.id, j: r.j }));
1437
+ if (batch.state?.length)
1438
+ body.state = batch.state.map(r => ({ id: r.id, s: r.s }));
1439
+ if (batch.remove?.length)
1440
+ body.remove = batch.remove;
1441
+ if (batch.lifetimeMs !== undefined)
1442
+ body.lifetime_ms = batch.lifetimeMs;
1443
+ let bytes;
1444
+ try {
1445
+ bytes = encodeBody(body);
1446
+ }
1447
+ catch (e) {
1448
+ throw new SynapseError("invalid_input", `live batch not encodable: ${e instanceof Error ? e.message : String(e)}`);
1449
+ }
1450
+ if (bytes.length > MAX_FRAME_BYTES)
1451
+ throw frameTooLarge("scene live batch", bytes.length);
1452
+ let w = scene.writer;
1453
+ if (!w || w.session !== s) {
1454
+ closeLiveWriter(w);
1455
+ try {
1456
+ w = scene.writer = openLiveWriter(s);
1457
+ }
1458
+ catch (e) {
1459
+ scene.writer = undefined;
1460
+ throw new SynapseError("internal", `scene live track not made: ${e instanceof Error ? e.message : String(e)}`);
1461
+ }
1462
+ }
1463
+ const header = { flags: 0, type: TYPES.PUB, seq: w.seq, tUs: nowUs() };
1464
+ w.seq = (w.seq + 1) >>> 0;
1465
+ try {
1466
+ w.stream.write(header, bytes);
1467
+ }
1468
+ catch (e) {
1469
+ throw new SynapseError("connection_lost", { message: e instanceof Error ? e.message : String(e), source: "transport", cause: e });
1470
+ }
1471
+ }
1472
+ /** The framework bridge (React's hooks): called on every change of the entities or the status. */
1473
+ subscribe(listener) {
1474
+ if (this.#closed)
1475
+ return () => { };
1476
+ const un = this.scene.mirror.subscribe(listener);
1477
+ this.#unsubscribes.add(un);
1478
+ return () => { un(); this.#unsubscribes.delete(un); };
1479
+ }
1480
+ /** Closes the handle: its verbs fail, its views and feeds end, its status says `closed`; the node's other scene handles go on. The last one ends the task, which ends this node's live writer (the runtime retracts its lifetimed rows and live entities: `writer_gone`). Idempotent. */
1481
+ close() {
1482
+ if (this.#closed)
1483
+ return;
1484
+ this.#closed = true;
1485
+ this.scene.mirror.dropHandle(this.#id);
1486
+ for (const v of [...this.#views])
1487
+ v.end();
1488
+ for (const un of this.#unsubscribes)
1489
+ un();
1490
+ this.#unsubscribes.clear();
1491
+ if (--this.scene.handles === 0)
1492
+ this.scene.post({ kind: "close" });
1493
+ }
1494
+ async [Symbol.asyncDispose]() { this.close(); }
1495
+ }
1496
+ /**
1497
+ * The active project's scene (one per project; one task per node, shared by every handle). Resolves
1498
+ * once the handle is attached, or at once `detached` when the runtime says no project is active; the
1499
+ * handle then follows the incarnations as they come and go. `not_connected` without a session.
1500
+ */
1501
+ export async function openScene(node, registry, warn) {
1502
+ if (!node.currentSession())
1503
+ throw new SynapseError("not_connected");
1504
+ let scene;
1505
+ let h;
1506
+ for (;;) {
1507
+ const live = registry.current;
1508
+ if (live) {
1509
+ if (live.handles === 0 || live.mirror.state === "closed") {
1510
+ await live.exited.promise;
1511
+ continue;
1512
+ }
1513
+ scene = live;
1514
+ h = new SceneHandleImpl(live);
1515
+ break;
1516
+ }
1517
+ scene = new Scene(node, warn);
1518
+ registry.current = scene;
1519
+ h = new SceneHandleImpl(scene);
1520
+ startScene(scene, registry);
1521
+ break;
1522
+ }
1523
+ try {
1524
+ await scene.request(reply => ({ kind: "join", reply }));
1525
+ }
1526
+ catch (e) {
1527
+ h.close();
1528
+ throw e;
1529
+ }
1530
+ return h;
1531
+ }