@synapse-sdk/client 0.3.3 → 0.3.6

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 CHANGED
@@ -1,33 +1,50 @@
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.
1
+ // The scene (PLAN v0.24 §1, §5, §10; attach PLAN v0.34), the JS spelling of the Rust client's `scene.rs`:
2
+ // the active project's scene as this node sees it.
3
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
4
+ // `scene()` asks the runtime where the live incarnation stands (`synapse/scene/open`: its `epoch`, its
5
+ // `version`) and reads both tracks of that incarnation (`runtime/scene/@<epoch>`): `authored` (a
6
+ // snapshot frame, then one diff frame per applied batch; a later group is a fresh snapshot that replaces
7
+ // the copy) and `live` (each group starts with the whole live set, then one frame per runtime tick). The
8
8
  // copy this node keeps (the mirror) is authored ⊕ live: a live row replaces `xform.t/q`, `joints` and
9
9
  // `state` of the authored entity it names; a live entity is a row of its own. Nothing is kept across
10
- // incarnations.
10
+ // incarnations. A diff out of sequence is not applied: the track is subscribed again (its open group
11
+ // starts with a snapshot) and `synced` is false until the copy is back at the version seen (the gap).
11
12
  //
12
13
  // One task per node, the store's shape: every `scene()` on the node shares it; the task owns the
13
14
  // session it is told about (`Node.monitor`), the incarnation it reads, the mirror and the writes in
14
15
  // flight, and is driven by one mailbox, in order:
15
16
  // - `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
+ // mirror empties, the handle is `detached`); the scene is opened again on the new one;
18
+ // - `opened`: the runtime's answer to the open: its version is wanted and its incarnation read;
19
+ // `project_settling` is asked again, paced; `project_inactive` answers the `scene()` calls waiting
20
+ // (detached) and opens again when the project feed shows a project active;
17
21
  // - 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`);
22
+ // tells the task of each authored frame applied (`applied`) and of the live track's first keyframe
23
+ // (`liveKeyed`); the incarnation's end (its final frame, or the tracks ending) empties the mirror and
24
+ // the scene is opened again (`followEnded`);
20
25
  // - `join`: a `scene()` call waiting for the scene to be in (attached, or no project active);
21
26
  // - `apply` / `applyDone`: an authoring batch (it names the bound incarnation's epoch) and the
22
27
  // runtime's answer; the caller is answered once the mirror shows the version;
23
28
  // - `notice`: the runtime dropped some of this node's live rows (`not_owner` with `data.ids`);
29
+ // - `upsert` / `remove` / `claim` / `claimDone` / `reclaim`: this node's live claims (PLAN v0.31: decisions are calls,
30
+ // data is streams): `live.upsert` and `live.remove` are the calls `synapse/scene/live/upsert` and
31
+ // `synapse/scene/live/remove`; a lane write for an id this node has not claimed in the bound incarnation asks the
32
+ // claim once (the override of an entity of the scene); the runtime's answers; a refused live entity due again;
24
33
  // - `close`: the last open handle left.
25
34
  //
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
35
+ // The claims are this node's own intent (id → the claim's args), never world state: on every attach to an incarnation
36
+ // (a new session, a project switch, a restarted scene owner) the live entities among them are claimed again with the
37
+ // same ids; an override is claimed again by its next lane write. A refused re-claim is asked again, paced, with a
38
+ // warning (the store's re-open rule and constants: `refusalPaceMs`). Lane rows for an id not claimed in the bound
39
+ // incarnation are parked (latest per lane) while the claim is asked, sent once granted, dropped and counted
40
+ // (`status.get().unclaimedDrops`) when refused. Nothing here reads the mirror to decide what to send: the mirror is a
41
+ // read cache. The Rust client's `scene.rs` is the reference; this file behaves as it does.
42
+ //
43
+ // States: `attached` (the copy is current as of the open: the authored plane at the version the open
44
+ // answered or past it, and the live track's first keyframe applied), `detached` (none: no
28
45
  // 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`.
46
+ // detached the entities are empty, every author verb fails `project_inactive` at once and a live
47
+ // write drops its batch and counts it. A closed handle's verbs fail `not_connected`.
31
48
  //
32
49
  // Known behaviour (as the Rust client's):
33
50
  // - live rows that disappear only through a keyframe (the ticks that removed them were missed) leave
@@ -42,9 +59,10 @@
42
59
  import * as Moq from "@moq/net";
43
60
  import { FLAGS, SynapseError, TYPES, decode as decodeBody, decodeHeader, encode as encodeBody, moqPriority, nowUs } from "@synapse-sdk/core";
44
61
  import { startOn } from "./calls.js";
45
- import { REFOLLOW_PACE_MS } from "./channel.js";
62
+ import { REFOLLOW_PACE_MS, isTransient, refusalPaceMs } from "./channel.js";
46
63
  import { CALL_GROUP_MAX_AGE_MS, StreamWriter, frameTooLarge, latestSubscription, waitActive } from "./session.js";
47
- import { AsyncQueue, deferred, replaceEqualDeep } from "./util.js";
64
+ import { ANNOUNCE_WITHIN_MS } from "./store.js";
65
+ import { AsyncQueue, StatusCell, deferred, replaceEqualDeep, rethrowLater, watchState } from "./util.js";
48
66
  /** Where the runtime announces the scene's incarnations: `runtime/scene/@<epoch>`. */
49
67
  const SCENE_BASE = "runtime/scene";
50
68
  const AUTHORED_TRACK = "authored";
@@ -60,12 +78,22 @@ export const PATCH_PROTECTED = ["id", "name", "parent", "order", "layer", "meta"
60
78
  /** moq's cap on one frame. */
61
79
  const MAX_FRAME_BYTES = 32 * 1024 * 1024;
62
80
  const EMPTY = Object.freeze({});
81
+ /** What a closed handle's `tree()` and `query()` read: one object each, so a reader that compares by reference stays quiet. */
82
+ const EMPTY_TREE = Object.freeze([]);
83
+ const EMPTY_LIST = Object.freeze([]);
63
84
  const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v) && !(v instanceof Uint8Array);
64
85
  /** Deep equality of decoded values (structural sharing keeps `a` when equal). */
65
86
  const deepEqual = (a, b) => replaceEqualDeep(a, b) === a;
66
87
  const parentOf = (e) => (typeof e.parent === "string" ? e.parent : null);
67
88
  const orderOf = (e) => (typeof e.order === "string" ? e.order : undefined);
68
- const rethrowLater = (e) => { queueMicrotask(() => { throw e; }); };
89
+ /** The id `x` names: a string, or a view this SDK made (`EntityView`); anything else (`{ id }` included) is `invalid_input` naming `arg`. */
90
+ const idOf = (x, arg = "entity") => {
91
+ if (typeof x === "string")
92
+ return x;
93
+ if (x instanceof EntityView)
94
+ return x.id;
95
+ throw new SynapseError("invalid_input", `scene: ${arg} is an id or an entity view (scene.entity, upsert), not ${x === null ? "null" : typeof x}`);
96
+ };
69
97
  // ------------------------------------------------------------------------------------- helpers
70
98
  const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
71
99
  const RAND_BITS = 74n;
@@ -227,17 +255,18 @@ export class Txn {
227
255
  #spawnedUnder(parent) {
228
256
  return this.#ops.flatMap(op => (op.op === "spawn" && op.entity.parent === parent ? [op.entity.id] : []));
229
257
  }
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. */
258
+ /** Spawns an entity of `kind` under `opts.parent` (default the world root) with the components given (`layer` `authored` when not given, else `calibration`; a live entity is not spawned here, it comes from `scene.live.upsert`); 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
259
  spawn(kind, opts) {
232
260
  const { parent, id: given, ...components } = opts;
233
261
  const id = given ?? ulid();
234
- const entity = { ...components, id, kind, parent: parent ?? null };
262
+ const entity = { ...components, id, kind, parent: parent == null ? null : idOf(parent, "parent") };
235
263
  entity.layer ??= "authored";
236
264
  this.#ops.push({ op: "spawn", entity });
237
265
  return id;
238
266
  }
239
- /** Sets components of `id` (one level deep: an object merges into an object). */
240
- patch(id, set) {
267
+ /** Sets components of the entity (one level deep: an object merges into an object). */
268
+ patch(entity, set) {
269
+ const id = idOf(entity);
241
270
  // A protected key is the runtime's to refuse: such a patch is sent as it is.
242
271
  const protectedKey = Object.keys(set).some(k => PATCH_PROTECTED.includes(k));
243
272
  const i = protectedKey ? -1 : this.#spawnIndex(id);
@@ -248,7 +277,8 @@ export class Txn {
248
277
  }
249
278
  this.#ops.push({ op: "patch", id, set });
250
279
  }
251
- rename(id, name) {
280
+ rename(entity, name) {
281
+ const id = idOf(entity);
252
282
  const i = this.#spawnIndex(id);
253
283
  if (i >= 0) {
254
284
  const op = this.#ops[i];
@@ -257,17 +287,18 @@ export class Txn {
257
287
  }
258
288
  this.#ops.push({ op: "rename", id, name });
259
289
  }
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 };
290
+ /** Moves the entity under `parent` (`null`: the world root), before or after a sibling (default: after the last). */
291
+ reparent(entity, parent, place) {
292
+ const op = { op: "reparent", id: idOf(entity), parent: parent === null ? null : idOf(parent, "parent") };
263
293
  if (place && "before" in place)
264
- op.before = place.before;
294
+ op.before = idOf(place.before, "before");
265
295
  else if (place && "after" in place)
266
- op.after = place.after;
296
+ op.after = idOf(place.after, "after");
267
297
  this.#ops.push(op);
268
298
  }
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 = {}) {
299
+ /** Despawns the entity (`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). */
300
+ despawn(entity, opts = {}) {
301
+ const id = idOf(entity);
271
302
  const cascade = opts.cascade ?? false;
272
303
  if (this.#spawnIndex(id) >= 0 && (cascade || this.#spawnedUnder(id).length === 0)) {
273
304
  this.#cancel(id);
@@ -275,8 +306,8 @@ export class Txn {
275
306
  }
276
307
  this.#ops.push({ op: "despawn", id, cascade });
277
308
  }
278
- /** Locks or unlocks `id` (a flags-only patch, which a lock lets through). */
279
- setLocked(id, locked) { this.patch(id, { flags: { locked } }); }
309
+ /** Locks or unlocks the entity (a flags-only patch, which a lock lets through). */
310
+ setLocked(entity, locked) { this.patch(entity, { flags: { locked } }); }
280
311
  /** Drops this batch's spawn of `id`, every op naming it, and the spawns under it. */
281
312
  #cancel(id) {
282
313
  const children = this.#spawnedUnder(id);
@@ -313,7 +344,8 @@ export function foldAuthored(authored, op, t, by) {
313
344
  const e = authored.get(op.id);
314
345
  if (!e)
315
346
  return [];
316
- authored.set(op.id, stamped(mergeSet(e, op.set), op.rev));
347
+ // An unchanged component keeps its object, so a view of the authored entity stays quiet on it.
348
+ authored.set(op.id, replaceEqualDeep(e, stamped(mergeSet(e, op.set), op.rev)));
317
349
  return [op.id];
318
350
  }
319
351
  case "reparent": {
@@ -336,34 +368,6 @@ export function foldAuthored(authored, op, t, by) {
336
368
  }
337
369
  return [];
338
370
  }
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
371
  const list = (v) => (Array.isArray(v) ? v : []);
368
372
  /** 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
373
  function putLanes(table, f) {
@@ -393,6 +397,20 @@ export function liveTableOf(k) {
393
397
  putLanes(t, k);
394
398
  return t;
395
399
  }
400
+ /** The authored or calibration entity named `name` (live overrides never count). */
401
+ export function authoredNamed(authored, name) {
402
+ for (const e of authored.values())
403
+ if (e.name === name)
404
+ return e;
405
+ return undefined;
406
+ }
407
+ /** The id of the live entity named `name` (a lane-only row carries no name). */
408
+ export function liveNamed(live, name) {
409
+ for (const [id, r] of live)
410
+ if (r.entity?.name === name)
411
+ return id;
412
+ return undefined;
413
+ }
396
414
  /** 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
415
  export function foldLive(table, d) {
398
416
  const ids = [];
@@ -461,8 +479,10 @@ export class Mirror {
461
479
  epoch;
462
480
  version = 0;
463
481
  hash = "";
464
- /** An authored diff skipped a version; cleared by the next snapshot. */
465
- gap = false;
482
+ /** An authored diff skipped a version (not applied; the track is read again from its snapshot): the highest version seen then. Cleared once the copy is back at it (PLAN v0.34 §2.2). */
483
+ gap;
484
+ /** The live track's first keyframe of the incarnation is in the copy. */
485
+ liveKeyed = false;
466
486
  pending = 0;
467
487
  authored = new Map();
468
488
  live = new Map();
@@ -471,7 +491,7 @@ export class Mirror {
471
491
  #tree = [];
472
492
  #treeStale = false;
473
493
  #listeners = new Set();
474
- #sinks = { changes: new Set(), removals: new Set(), denied: new Set(), states: new Set() };
494
+ #sinks = { removals: new Set(), denied: new Set(), states: new Set() };
475
495
  subscribe(l) { this.#listeners.add(l); return () => { this.#listeners.delete(l); }; }
476
496
  #notify() { for (const l of [...this.#listeners]) {
477
497
  try {
@@ -508,8 +528,6 @@ export class Mirror {
508
528
  this.state = s;
509
529
  this.#send("states", s);
510
530
  }
511
- #emitChanges(ops) { if (ops.length)
512
- this.#send("changes", ops); }
513
531
  /** The merged reads of `ids` recomputed. */
514
532
  #refresh(ids) {
515
533
  let merged;
@@ -543,43 +561,51 @@ export class Mirror {
543
561
  this.merged = replaceEqualDeep(this.merged, merged);
544
562
  this.#treeStale = true;
545
563
  }
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`). */
564
+ /** A snapshot of incarnation `epoch` replaces the authored copy. The first one is not yet `attached`: that is the task's, once the copy is at the version the open answered. */
547
565
  applySnapshot(epoch, s) {
548
566
  if (this.state === "closed")
549
567
  return;
550
568
  // An entity equal to the one held keeps its object, so a view of the authored entity stays quiet.
551
569
  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
570
  this.authored = next;
554
571
  this.version = s.version;
555
572
  this.hash = s.hash;
556
573
  this.epoch = epoch;
557
- this.gap = false;
574
+ this.#heal();
558
575
  this.#rebuild();
559
- this.#emitChanges(ops);
560
576
  this.#notify();
561
577
  }
562
- /** The reader has applied everything this node held when it bound the incarnation. */
578
+ /** The copy is current as of the attach (the task checked it): `attached`. */
563
579
  attach() { if (this.epoch !== undefined && this.state !== "closed") {
564
580
  this.#setState("attached");
565
581
  this.#notify();
566
582
  } }
567
- /** One applied batch: its ops folded into the authored copy; the feeds get them as they came. */
583
+ /** A gap is over once the copy is back at the highest version seen when it opened. */
584
+ #heal() { if (this.gap !== undefined && this.version >= this.gap)
585
+ this.gap = undefined; }
586
+ /**
587
+ * One applied batch: its ops folded into the authored copy, when it is the next version. Otherwise it is not applied
588
+ * (`false`): a gap, `synced` false until the copy is back at the version seen; the reader reads the track again from
589
+ * its snapshot (PLAN v0.34 §2.2).
590
+ */
568
591
  applyDiff(d, warn) {
569
592
  if (this.epoch === undefined || this.state === "closed")
570
- return;
593
+ return true;
571
594
  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;
595
+ warn(`scene: diff out of sequence (have ${this.version}, got ${d.version}): not applied; reading the track again from its snapshot`);
596
+ this.gap = Math.max(this.gap ?? d.version, d.version);
597
+ this.#notify();
598
+ return false;
574
599
  }
575
600
  const touched = [];
576
601
  for (const op of d.ops)
577
602
  touched.push(...foldAuthored(this.authored, op, d.t, d.by ?? null));
578
603
  this.version = d.version;
579
604
  this.hash = d.hash;
605
+ this.#heal();
580
606
  this.#refresh(touched);
581
- this.#emitChanges(d.ops);
582
607
  this.#notify();
608
+ return true;
583
609
  }
584
610
  /** A keyframe replaces the live table. */
585
611
  applyKeyframe(k) {
@@ -590,6 +616,7 @@ export class Mirror {
590
616
  keepLiveEntities(table, id => old.get(id)?.entity, table.keys());
591
617
  const ids = [...this.live.keys(), ...table.keys()];
592
618
  this.live = table;
619
+ this.liveKeyed = true;
593
620
  this.#refresh(ids);
594
621
  this.#notify();
595
622
  }
@@ -610,11 +637,10 @@ export class Mirror {
610
637
  denied(ids) { if (this.state !== "closed")
611
638
  this.#send("denied", ids); }
612
639
  setPending(n) { this.pending = n; this.#notify(); }
613
- /** No incarnation: the copy empties (the change feeds see the despawns), `detached`. */
640
+ /** No incarnation: the copy empties, `detached`. */
614
641
  detach() {
615
642
  if (this.state === "closed")
616
643
  return;
617
- const ops = netDifference(this.authored, new Map());
618
644
  this.authored = new Map();
619
645
  this.live = new Map();
620
646
  this.merged = EMPTY;
@@ -622,9 +648,9 @@ export class Mirror {
622
648
  this.epoch = undefined;
623
649
  this.version = 0;
624
650
  this.hash = "";
625
- this.gap = false;
651
+ this.gap = undefined;
652
+ this.liveKeyed = false;
626
653
  this.#setState("detached");
627
- this.#emitChanges(ops);
628
654
  this.#notify();
629
655
  }
630
656
  /** The end: emptied, `closed` (`reason` recorded), every feed ended. */
@@ -645,7 +671,7 @@ export class Mirror {
645
671
  // --------------------------------------------------------------------------------------- views
646
672
  /**
647
673
  * 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()`),
674
+ * changes). It follows the mirror only while someone listens (`onChange`, a pending `changed()`, a `watch()` loop),
649
675
  * and while it does it is in `active`, so the handle's close can end it.
650
676
  */
651
677
  class MirrorView {
@@ -654,9 +680,12 @@ class MirrorView {
654
680
  active;
655
681
  ended;
656
682
  #listeners = new Set();
683
+ /** Pending `changed()` calls, each with the value it was armed on: it resolves on the first value that differs from it. */
657
684
  #waiting = [];
658
685
  #unmirror;
659
686
  #last;
687
+ /** `end()` ran (it runs once). */
688
+ #over = false;
660
689
  constructor(mirror, read, active, ended) {
661
690
  this.mirror = mirror;
662
691
  this.read = read;
@@ -664,18 +693,21 @@ class MirrorView {
664
693
  this.ended = ended;
665
694
  }
666
695
  get() { return this.read(); }
667
- subscribe(listener) {
696
+ /** `cb` with the value on each change, and once more when the view ends; returns the remover. */
697
+ onChange(cb) {
668
698
  if (this.ended())
669
699
  return () => { };
670
- this.#listeners.add(listener);
700
+ this.#listeners.add(cb);
671
701
  this.#follow();
672
- return () => { this.#listeners.delete(listener); this.#unfollow(); };
702
+ return () => { this.#listeners.delete(cb); this.#unfollow(); };
673
703
  }
704
+ /** The value after each change; ends with the handle. */
705
+ watch() { return watchState(cb => this.onChange(cb), () => this.get(), this.ended); }
674
706
  changed() {
675
707
  if (this.ended())
676
708
  return Promise.resolve(this.read());
677
709
  const d = deferred();
678
- this.#waiting.push(d);
710
+ this.#waiting.push({ d, armedOn: this.read() });
679
711
  this.#follow();
680
712
  return d.promise;
681
713
  }
@@ -694,6 +726,11 @@ class MirrorView {
694
726
  this.active.delete(this);
695
727
  }
696
728
  #update() {
729
+ // The node ended (the mirror closed): the view ends with it.
730
+ if (this.ended()) {
731
+ this.end();
732
+ return;
733
+ }
697
734
  let v;
698
735
  try {
699
736
  v = this.read();
@@ -705,20 +742,28 @@ class MirrorView {
705
742
  if (v === this.#last)
706
743
  return;
707
744
  this.#last = v;
745
+ // The waits this change answers are taken before the listeners run: a `changed()` a listener arms waits for the next change.
746
+ // A wait armed on this value already (a scene-level callback ran before this view's update) is not answered by it: it keeps waiting.
747
+ const waiting = this.#waiting.splice(0);
748
+ this.#waiting.push(...waiting.filter(w => w.armedOn === v));
708
749
  for (const l of [...this.#listeners]) {
709
750
  try {
710
- l();
751
+ l(v);
711
752
  }
712
753
  catch (e) {
713
754
  rethrowLater(e);
714
755
  }
715
756
  }
716
- for (const d of this.#waiting.splice(0))
717
- d.resolve(v);
757
+ for (const w of waiting)
758
+ if (w.armedOn !== v)
759
+ w.d.resolve(v);
718
760
  this.#unfollow();
719
761
  }
720
- /** The handle closed: a pending `changed()` resolves with the value now; listeners are dropped. */
762
+ /** The handle closed: a pending `changed()` resolves with the value now; each callback is told once, then dropped (a `watch()` loop ends there). Once: a callback that closes the handle again is not told twice. */
721
763
  end() {
764
+ if (this.#over)
765
+ return;
766
+ this.#over = true;
722
767
  let v;
723
768
  try {
724
769
  v = this.read();
@@ -726,12 +771,91 @@ class MirrorView {
726
771
  catch {
727
772
  v = this.#last;
728
773
  }
729
- for (const d of this.#waiting.splice(0))
730
- d.resolve(v);
774
+ for (const w of this.#waiting.splice(0))
775
+ w.d.resolve(v);
776
+ for (const l of [...this.#listeners]) {
777
+ try {
778
+ l(v);
779
+ }
780
+ catch (e) {
781
+ rethrowLater(e);
782
+ }
783
+ }
731
784
  this.#listeners.clear();
732
785
  this.#unfollow();
733
786
  }
734
787
  }
788
+ /** `e`, or its `component` (`undefined` when `e` is). */
789
+ const pick = (e, component) => component === undefined ? e : e === undefined ? undefined : e[component];
790
+ /**
791
+ * One entity as a view (`scene.entity(id | { name })`): the `SceneView` of the merged entity with the store handle's
792
+ * members, and the entity's two planes (`authored`, `live`), frozen objects made once with the view. `read` picks the
793
+ * merged entity for the id the view names now; `ops.write` patches the authored entity the view names at the call,
794
+ * `ops.live` writes its live lanes, `ops.remove` drops its live row.
795
+ */
796
+ class EntityView extends MirrorView {
797
+ ops;
798
+ authored;
799
+ live;
800
+ constructor(mirror, read, active, ended, ops) {
801
+ super(mirror, read, active, ended);
802
+ this.ops = ops;
803
+ this.authored = Object.freeze({
804
+ get: ((component) => pick(ops.readAuthored(), component)),
805
+ set: ((a, b) => ops.write(typeof a === "string" ? { [a]: b } : a)),
806
+ });
807
+ this.live = Object.freeze({
808
+ get: ((component) => pick(ops.readLive(), component)),
809
+ set: (components, opts = {}) => ops.live(components, opts),
810
+ remove: () => ops.remove(),
811
+ });
812
+ }
813
+ get id() { return this.ops.id(); }
814
+ get(component) { return pick(this.read(), component); }
815
+ /**
816
+ * The entity (or `component` of it) after each change; changes in between fold into the latest. A component is
817
+ * compared by reference: the mirror keeps an unchanged component's object across a change elsewhere (structural
818
+ * sharing), so only a change of that component wakes it.
819
+ */
820
+ watch(component) {
821
+ return watchState(cb => this.onChange(cb), () => this.get(component), this.ended);
822
+ }
823
+ /** The entity (or `component` of it) after its next change, as `watch(component)` yields it; the value now once the handle ends. Armed at the call. */
824
+ changed(component) {
825
+ if (component === undefined)
826
+ return super.changed();
827
+ if (this.ended())
828
+ return Promise.resolve(this.get(component));
829
+ // the first step of `watch(component)`, started now (its baseline is read at the first `next()`), then left
830
+ const it = this.watch(component)[Symbol.asyncIterator]();
831
+ return it.next().then(r => { void it.return?.(undefined); return r.done ? this.get(component) : r.value; });
832
+ }
833
+ }
834
+ /** Releases a claim this node does not keep (granted after its handle closed, or after a `live.remove`): the call `synapse/scene/live/remove`, best effort. */
835
+ function release(node, session, id) {
836
+ if (!session)
837
+ return;
838
+ try {
839
+ void startOn(node, session, "synapse/scene/live/remove", { id }, {}).result.catch(() => { });
840
+ }
841
+ catch { /* the session's end releases it */ }
842
+ }
843
+ /** The id a claim's reply names. */
844
+ function replyId(v) {
845
+ const id = v?.id;
846
+ if (typeof id !== "string")
847
+ throw new SynapseError("internal", "scene live claim reply not understood");
848
+ return id;
849
+ }
850
+ const sessionGone = (e) => e.code === "connection_lost" || e.code === "not_connected";
851
+ /** The answer of `synapse/scene/open`: where the live incarnation stands. */
852
+ function parseOpen(reply) {
853
+ const r = reply;
854
+ const version = typeof r?.version === "bigint" ? Number(r.version) : r?.version;
855
+ if (typeof r?.epoch !== "string" || typeof version !== "number")
856
+ throw new SynapseError("internal", "scene open reply not understood");
857
+ return { epoch: r.epoch, version };
858
+ }
735
859
  function openLiveWriter(s) {
736
860
  const bc = s.createBroadcast(`${s.prefix}/scene/live`);
737
861
  // Kept as long as a call track keeps its groups (the runtime's follower reads every batch).
@@ -748,6 +872,82 @@ function closeLiveWriter(w) {
748
872
  }
749
873
  catch { /* closed */ }
750
874
  }
875
+ /** A live batch's wire bytes. */
876
+ function encodeLive(body) {
877
+ let bytes;
878
+ try {
879
+ bytes = encodeBody(body);
880
+ }
881
+ catch (e) {
882
+ throw new SynapseError("invalid_input", `live batch not encodable: ${e instanceof Error ? e.message : String(e)}`);
883
+ }
884
+ if (bytes.length > MAX_FRAME_BYTES)
885
+ throw frameTooLarge("scene live batch", bytes.length);
886
+ return bytes;
887
+ }
888
+ /** One frame on the live writer. */
889
+ function writeLive(w, bytes) {
890
+ const header = { flags: 0, type: TYPES.PUB, seq: w.seq, tUs: nowUs() };
891
+ w.seq = (w.seq + 1) >>> 0;
892
+ try {
893
+ w.stream.write(header, bytes);
894
+ }
895
+ catch (e) {
896
+ throw new SynapseError("connection_lost", { message: e instanceof Error ? e.message : String(e), source: "transport", cause: e });
897
+ }
898
+ }
899
+ /** Session `s`'s live writer: the current one, or a new one when that is another session's. */
900
+ function liveWriterOn(scene, s) {
901
+ let w = scene.writer;
902
+ if (w && w.session === s)
903
+ return w;
904
+ closeLiveWriter(w);
905
+ try {
906
+ w = scene.writer = openLiveWriter(s);
907
+ }
908
+ catch (e) {
909
+ scene.writer = undefined;
910
+ throw new SynapseError("internal", `scene live track not made: ${e instanceof Error ? e.message : String(e)}`);
911
+ }
912
+ return w;
913
+ }
914
+ const UNCLAIMED = { kind: "unclaimed" }, ASKING = { kind: "asking" }, CLAIMED = { kind: "claimed" };
915
+ const parkedRows = (p) => (p.pose ? 1 : 0) + (p.joints ? 1 : 0) + (p.state ? 1 : 0);
916
+ /** One of this node's claims: its own intent, never world state. */
917
+ class Claim {
918
+ args;
919
+ state;
920
+ /** Refusals of it in a row (the pace of the next ask). */
921
+ refusals = 0;
922
+ parked = {};
923
+ /** `args`: the `upsert` call's args besides `id` (a live entity's `kind`, `name`, `parent`, `lifetime_ms` and components); empty for the override of an entity of the scene. */
924
+ constructor(args, state) {
925
+ this.args = args;
926
+ this.state = state;
927
+ }
928
+ /** A live entity (claimed again on every attach), not an override (claimed by its lanes). */
929
+ get isEntity() { return Object.keys(this.args).length > 0; }
930
+ }
931
+ /** When the task's timer asks `c` again: a paced live entity (refused or transient), or a claim paced after a transient answer with rows parked; a refused override is asked by its next lane write instead. */
932
+ function timerDue(c) {
933
+ const st = c.state;
934
+ return st.kind === "paced" && (c.isEntity || (!st.refused && parkedRows(c.parked) > 0)) ? st.at : undefined;
935
+ }
936
+ /** A lane batch on the wire: data only (stamps are the runtime's). */
937
+ function laneBody(poses, joints, state, lifetimeMs) {
938
+ const body = {};
939
+ if (poses.length)
940
+ body.poses = poses.map(p => ({ id: p.id, t: p.t, q: p.q }));
941
+ if (joints.length)
942
+ body.joints = joints.map(r => ({ id: r.id, j: r.j }));
943
+ if (state.length)
944
+ body.state = state.map(r => ({ id: r.id, s: r.s }));
945
+ if (!Object.keys(body).length)
946
+ return undefined;
947
+ if (lifetimeMs !== undefined)
948
+ body.lifetime_ms = lifetimeMs;
949
+ return body;
950
+ }
751
951
  /** The node's scene: what every handle shares with the task. */
752
952
  export class Scene {
753
953
  node;
@@ -758,14 +958,52 @@ export class Scene {
758
958
  /** Handles neither closed nor dropped; the one that brings it to 0 tells the task `close`. */
759
959
  handles = 0;
760
960
  nextHandle = 0;
761
- /** The live writer of the current session, made on the first `live()`. */
961
+ /** The live writer of the current session, made on the first live write. */
762
962
  writer;
963
+ /** This node's claims by id (what `live.upsert` made and what lane writes asked for); `live.remove` drops one. Nothing else is kept. */
964
+ claims = new Map();
763
965
  detachedDrops = 0;
966
+ /** Lane rows dropped because their id's claim was refused (and not due again), or parked rows a refusal dropped. */
967
+ unclaimedDrops = 0;
968
+ /** Told when a counter of the status changes (the listeners of the status state values). */
969
+ counters = new Set();
764
970
  constructor(node, warn) {
765
971
  this.node = node;
766
972
  this.warn = warn;
767
973
  }
768
974
  post(e) { return this.mailbox.push(e); }
975
+ countersChanged() { for (const l of [...this.counters]) {
976
+ try {
977
+ l();
978
+ }
979
+ catch (e) {
980
+ rethrowLater(e);
981
+ }
982
+ } }
983
+ /**
984
+ * What happens to a lane row of `id` now. An id with no claim becomes the override's claim; an override that is
985
+ * unclaimed, or refused and due again, is marked asked and returned in `ask` (the task asks it: one ask per id at a
986
+ * time). A live entity's claim is asked by the task (on attach, paced after a refusal), never by a lane.
987
+ */
988
+ gate(id, now, ask) {
989
+ let c = this.claims.get(id);
990
+ if (!c)
991
+ this.claims.set(id, (c = new Claim({}, UNCLAIMED)));
992
+ const st = c.state;
993
+ if (st.kind === "claimed")
994
+ return "send";
995
+ if (st.kind === "asking")
996
+ return "park";
997
+ if (st.kind === "unclaimed" && c.isEntity)
998
+ return "park";
999
+ if (st.kind === "paced" && !st.refused)
1000
+ return "park";
1001
+ if (st.kind === "paced" && (c.isEntity || st.at > now))
1002
+ return "drop";
1003
+ c.state = ASKING;
1004
+ ask.push(id);
1005
+ return "park";
1006
+ }
769
1007
  request(make) {
770
1008
  const reply = deferred();
771
1009
  if (!this.post(make(reply)))
@@ -778,6 +1016,16 @@ const detachedError = () => new SynapseError("project_inactive", "the scene is d
778
1016
  function closedError(reason) {
779
1017
  return new SynapseError("not_connected", reason === ENDED_CONNECTION_LOST ? "the node ended" : "the scene handle is closed");
780
1018
  }
1019
+ /** A view by name whose name no entity holds: nothing to send to (`not_found`, as the runtime answers an unknown id). */
1020
+ function notNamed(ref) {
1021
+ const name = typeof ref === "string" ? undefined : ref.name;
1022
+ return new SynapseError("not_found", { message: `scene: no entity is named ${JSON.stringify(name)}`, data: { name } });
1023
+ }
1024
+ /** An `upsert` is keyed by its name: an `id` component (the type forbids it; JavaScript does not) is `invalid_input`, nothing sent. */
1025
+ function refuseId(opts, verb) {
1026
+ if ("id" in opts)
1027
+ throw new SynapseError("invalid_input", { message: `${verb} is keyed by its name: an id component is not taken`, data: { component: "id" } });
1028
+ }
781
1029
  const asError = (e) => (e instanceof SynapseError ? e : new SynapseError("internal", String(e)));
782
1030
  export function startScene(scene, registry) { void run(scene, registry); }
783
1031
  async function run(scene, registry) {
@@ -786,13 +1034,33 @@ async function run(scene, registry) {
786
1034
  let phase = { kind: "idle" };
787
1035
  const ph = () => phase;
788
1036
  let attempts = 0;
789
- /** The incarnation that last ended: not bound again (its announcement may still be seen). */
790
- let endedEpoch;
1037
+ /**
1038
+ * The incarnation the open answered and its version then: the copy is current (the handle `attached`) once it holds
1039
+ * that version and the live track's first keyframe (PLAN v0.34 §2.1).
1040
+ */
1041
+ let wanted;
791
1042
  /** The session's project state as last read. */
792
1043
  let project;
793
1044
  let unwatchProject;
794
1045
  const joining = [];
795
1046
  let waiters = [];
1047
+ /** The attach the claims are made for (session and incarnation), if any. */
1048
+ let bound;
1049
+ /** Counts attaches: an answer to a claim asked in an earlier one is not this one's. */
1050
+ let bindNo = 0;
1051
+ /** The ids whose claim is being asked again (one ask per id at a time). */
1052
+ const asking = new Set();
1053
+ /** `live.upsert` calls waiting for an attach (attached a moment before the task was told, or their session went before the answer). */
1054
+ let parkedUpserts = [];
1055
+ /**
1056
+ * The `live.upsert` calls on the wire, by token: the id each named (a claim of that name this node held), and whether its
1057
+ * answer may still be kept (`false` once a `live.remove` of that id or the handle's close came after it: a grant is then
1058
+ * released, never kept).
1059
+ */
1060
+ const firsts = new Map();
1061
+ let nextFirst = 0;
1062
+ let reclaimTimer;
1063
+ let reclaimAt = Infinity;
796
1064
  const inactive = () => project !== undefined && project.active === null;
797
1065
  const answerJoins = () => { for (const j of joining.splice(0))
798
1066
  j.resolve(); };
@@ -803,10 +1071,15 @@ async function run(scene, registry) {
803
1071
  phase.stop.abort();
804
1072
  else if (phase.kind === "paced")
805
1073
  clearTimeout(phase.timer);
1074
+ else if (phase.kind === "opening")
1075
+ phase.announced.close();
806
1076
  phase = { kind: "idle" };
807
1077
  };
808
- /** Follows the announcements on the task's session, from `idle`. */
809
- const follow = () => {
1078
+ /**
1079
+ * From `idle`: the announcement cursor under `runtime/scene`, then `synapse/scene/open` on the task's session (the
1080
+ * store's order); its answer comes back as `opened`.
1081
+ */
1082
+ const open = () => {
810
1083
  if (phase.kind !== "idle" || mirror.state === "closed" || !session)
811
1084
  return;
812
1085
  const s = session;
@@ -818,26 +1091,87 @@ async function run(scene, registry) {
818
1091
  scene.warn(`scene: announcements not followed: ${e instanceof Error ? e.message : String(e)}`);
819
1092
  return;
820
1093
  }
1094
+ let run;
1095
+ try {
1096
+ run = startOn(node, s, "synapse/scene/open", {}, {});
1097
+ }
1098
+ catch {
1099
+ announced.close();
1100
+ return; /* the session is going: its end is on its way */
1101
+ }
821
1102
  attempts += 1;
822
1103
  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 }));
1104
+ phase = { kind: "opening", attempt, session: s, announced };
1105
+ void run.result.then(v => scene.post({ kind: "opened", attempt, result: { ok: v } }), (e) => scene.post({ kind: "opened", attempt, result: { err: asError(e) } }));
1106
+ };
1107
+ /**
1108
+ * The open of attempt `attempt` answered (one no longer in flight is dropped): the incarnation it names is read, its
1109
+ * version wanted. `project_settling` (or another transient refusal) is asked again, paced; `project_inactive` answers
1110
+ * the waiting `scene()` calls (detached) and waits for the active project to change (at once, paced, when the session
1111
+ * already shows one); so does a scene the runtime cannot serve (warned); the session going is left to its end.
1112
+ */
1113
+ const opened = (attempt, result) => {
1114
+ const p = ph();
1115
+ if (p.kind !== "opening" || p.attempt !== attempt)
1116
+ return;
1117
+ const { session: s, announced } = p;
1118
+ phase = { kind: "idle" };
1119
+ let reply;
1120
+ if ("ok" in result) {
1121
+ try {
1122
+ reply = { ok: parseOpen(result.ok) };
1123
+ }
1124
+ catch (e) {
1125
+ reply = { err: asError(e) };
1126
+ }
1127
+ }
1128
+ else
1129
+ reply = result;
1130
+ if ("ok" in reply) {
1131
+ wanted = reply.ok;
1132
+ const stop = new AbortController();
1133
+ phase = { kind: "following", attempt, stop };
1134
+ void followIncarnation(scene, s, announced, reply.ok.epoch, stop.signal).then(r => scene.post({ kind: "followEnded", attempt, epoch: r.epoch, reason: r.reason }));
1135
+ return;
1136
+ }
1137
+ announced.close();
1138
+ const e = reply.err;
1139
+ if (sessionGone(e)) {
1140
+ session = undefined;
1141
+ return;
1142
+ }
1143
+ if (isTransient(e)) {
1144
+ again(REFOLLOW_PACE_MS);
1145
+ return;
1146
+ }
1147
+ if (e.code === "project_inactive")
1148
+ answerJoins();
1149
+ else
1150
+ scene.warn(`scene: open refused by the runtime (${e.code}${e.message ? `: ${e.message}` : ""}); opened again when the active project changes`);
1151
+ const shown = !!project?.active;
1152
+ if (e.code === "project_inactive" && shown)
1153
+ again(REFOLLOW_PACE_MS);
1154
+ else
1155
+ phase = { kind: "waitingProject" };
826
1156
  };
827
- /** The incarnation is let go: the mirror empties, writes waiting to show fail, followed again after `paceMs`. */
1157
+ /** The incarnation is let go: the mirror empties, writes waiting to show fail, opened again after `paceMs`. */
828
1158
  const again = (paceMs) => {
829
1159
  failWaiters(() => new SynapseError("conflict", "the scene's incarnation ended before the write showed"));
830
1160
  stopPhase();
831
1161
  mirror.detach();
1162
+ unbind();
1163
+ wanted = undefined;
832
1164
  const a = ++attempts;
833
1165
  phase = { kind: "paced", timer: setTimeout(() => scene.post({ kind: "paced", attempt: a }), paceMs) };
834
1166
  };
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`. */
1167
+ /** The node's session is now `s` (or none): what was bound on the previous one is over; the scene is opened on `s`. */
836
1168
  const sessionChanged = (s) => {
837
1169
  session = s;
838
1170
  stopPhase();
1171
+ wanted = undefined;
839
1172
  failWaiters(() => new SynapseError("connection_lost", "the session ended before the write showed in the scene"));
840
1173
  mirror.detach();
1174
+ unbind();
841
1175
  closeLiveWriter(scene.writer);
842
1176
  scene.writer = undefined;
843
1177
  unwatchProject?.();
@@ -847,15 +1181,333 @@ async function run(scene, registry) {
847
1181
  unwatchProject = s.project.subscribe(p => { scene.post({ kind: "project", snapshot: p }); });
848
1182
  if (inactive())
849
1183
  answerJoins();
850
- follow();
1184
+ open();
851
1185
  };
1186
+ /** The session's project feed changed: with none active the waiting `scene()` calls are answered; a task waiting for a project opens again. */
1187
+ const projectChanged = (snapshot) => {
1188
+ project = snapshot;
1189
+ if (inactive())
1190
+ answerJoins();
1191
+ if (phase.kind === "waitingProject") {
1192
+ phase = { kind: "idle" };
1193
+ open();
1194
+ }
1195
+ };
1196
+ /** The reader applied an authored frame of `epoch` reaching `version`: the writes it shows are answered; the handle attaches when the copy is current. */
852
1197
  const applied = (epoch, version) => {
853
1198
  const reached = waiters.filter(w => w.epoch === epoch && w.version <= version);
854
1199
  waiters = waiters.filter(w => !reached.includes(w));
855
1200
  for (const w of reached)
856
1201
  w.reply.resolve();
857
- if (mirror.state === "attached")
858
- answerJoins();
1202
+ attach();
1203
+ };
1204
+ /**
1205
+ * The copy is current as of the open (PLAN v0.34 §2.1: the authored plane at the version the open answered, or past
1206
+ * it, and the live track's first keyframe applied, both of the incarnation the open named): the claims are made for it
1207
+ * first (`bind`), then the handle is `attached` (one path: nothing a handle does after it sees `attached` meets an
1208
+ * unbound attach). Attached, the `scene()` calls waiting are answered.
1209
+ */
1210
+ const attach = () => {
1211
+ const current = mirror.state === "attached"
1212
+ || (wanted !== undefined && mirror.state === "detached" && mirror.epoch === wanted.epoch && mirror.version >= wanted.version && mirror.liveKeyed);
1213
+ if (!current)
1214
+ return;
1215
+ if (mirror.state !== "attached") {
1216
+ if (!session || !wanted)
1217
+ return;
1218
+ if (bound?.session !== session || bound.epoch !== wanted.epoch)
1219
+ bind(session, wanted.epoch);
1220
+ mirror.attach();
1221
+ }
1222
+ answerJoins();
1223
+ };
1224
+ // ---- claims (PLAN v0.31)
1225
+ /** The claims are made for an attach no more (a session gap, an incarnation let go): each is unclaimed; answers of asks made before are not this attach's. */
1226
+ const unbind = () => {
1227
+ bound = undefined;
1228
+ bindNo += 1;
1229
+ asking.clear();
1230
+ // Rows parked for the attach that ended are not this one's data.
1231
+ for (const c of scene.claims.values()) {
1232
+ c.state = UNCLAIMED;
1233
+ c.parked = {};
1234
+ }
1235
+ };
1236
+ /** Attached to incarnation `epoch` on session `s`: every live entity of this node's is claimed again, same id (an override, by its next lane write); the `live.upsert` calls waiting are asked. */
1237
+ const bind = (s, epoch) => {
1238
+ unbind();
1239
+ bound = { session: s, epoch };
1240
+ for (const [id, c] of [...scene.claims])
1241
+ if (c.isEntity)
1242
+ askAgain(id);
1243
+ const waiting = parkedUpserts;
1244
+ parkedUpserts = [];
1245
+ for (const u of waiting)
1246
+ askFirst(u.args, u.reply);
1247
+ };
1248
+ /** Starts the claim call with `args` in this attach; its answer comes back as `claimDone`. Returns the error when it could not be sent. */
1249
+ const callClaim = (ask, args) => {
1250
+ if (!session || !bound)
1251
+ return detachedError();
1252
+ let call;
1253
+ try {
1254
+ call = startOn(node, session, "synapse/scene/live/upsert", args, {});
1255
+ }
1256
+ catch (e) {
1257
+ return asError(e);
1258
+ }
1259
+ const b = bindNo;
1260
+ const s = session;
1261
+ const done = (result) => {
1262
+ if (scene.post({ kind: "claimDone", ask, bind: b, result }))
1263
+ return;
1264
+ // The task is gone (the handle closed): a grant is released, never kept; a `live.upsert` caller is answered from here.
1265
+ if ("ok" in result) {
1266
+ try {
1267
+ release(node, s, replyId(result.ok));
1268
+ }
1269
+ catch { /* not understood: nothing to release */ }
1270
+ }
1271
+ if (ask.kind === "first")
1272
+ ask.reply.reject(closedError(ENDED_CLIENT));
1273
+ };
1274
+ void call.result.then(v => done({ ok: v }), (e) => done({ err: asError(e) }));
1275
+ return undefined;
1276
+ };
1277
+ /** Asks the claim of `id` again with its args (one ask per id at a time). */
1278
+ const askAgain = (id) => {
1279
+ if (!bound || asking.has(id))
1280
+ return;
1281
+ const c = scene.claims.get(id);
1282
+ if (!c)
1283
+ return;
1284
+ c.state = ASKING;
1285
+ asking.add(id);
1286
+ if (callClaim({ kind: "again", id }, { ...c.args, id })) {
1287
+ // Not sent: asked again at the next attach.
1288
+ asking.delete(id);
1289
+ const now = scene.claims.get(id);
1290
+ if (now)
1291
+ now.state = UNCLAIMED;
1292
+ }
1293
+ };
1294
+ /** A `live.upsert`: asked with the id of this node's claim of that name, if it has one (its own intent), else without an id (the runtime mints it). */
1295
+ const askFirst = (args, reply) => {
1296
+ const callArgs = { ...args };
1297
+ let named;
1298
+ if (args.name !== undefined)
1299
+ for (const [id, c] of scene.claims)
1300
+ if (c.args.name === args.name) {
1301
+ callArgs.id = named = id;
1302
+ break;
1303
+ }
1304
+ const token = nextFirst++;
1305
+ firsts.set(token, { named, keep: true });
1306
+ const e = callClaim({ kind: "first", args, reply, token }, callArgs);
1307
+ if (e) {
1308
+ firsts.delete(token);
1309
+ reply.reject(e);
1310
+ }
1311
+ };
1312
+ const upsert = (args, reply) => {
1313
+ if (mirror.state === "closed")
1314
+ reply.reject(closedError(mirror.ended ?? ENDED_CLIENT));
1315
+ else if (mirror.state === "detached")
1316
+ reply.reject(detachedError());
1317
+ // Attached: the claims are bound (the task attaches the handle after the bind: one path).
1318
+ else
1319
+ askFirst(args, reply);
1320
+ };
1321
+ /** A lane write asked the claim of `id`: asked now when bound; otherwise unclaimed (the next lane write after the attach asks it). */
1322
+ const claimAsked = (id) => {
1323
+ if (bound) {
1324
+ askAgain(id);
1325
+ return;
1326
+ }
1327
+ const c = scene.claims.get(id);
1328
+ if (c?.state.kind === "asking")
1329
+ c.state = UNCLAIMED;
1330
+ };
1331
+ /** `live.remove`: the claim dropped; the runtime asked to release it when this attach holds it (or asks it), else answered at once. */
1332
+ const remove = (id, reply) => {
1333
+ const c = scene.claims.get(id);
1334
+ scene.claims.delete(id);
1335
+ asking.delete(id);
1336
+ // A `live.upsert` of this id on the wire: its answer is not kept (granted, it is released).
1337
+ for (const f of firsts.values())
1338
+ if (f.named === id)
1339
+ f.keep = false;
1340
+ const held = !c || c.state.kind === "claimed" || c.state.kind === "asking";
1341
+ if (!session || !bound || !held) {
1342
+ reply.resolve();
1343
+ return;
1344
+ }
1345
+ let call;
1346
+ try {
1347
+ call = startOn(node, session, "synapse/scene/live/remove", { id }, {});
1348
+ }
1349
+ catch (e) {
1350
+ reply.reject(asError(e));
1351
+ return;
1352
+ }
1353
+ void call.result.then(() => reply.resolve(), (e) => reply.reject(asError(e)));
1354
+ };
1355
+ /** The rows parked for `id` go out now, as one batch. */
1356
+ const sendParked = (id) => {
1357
+ const c = scene.claims.get(id);
1358
+ if (!c || !session)
1359
+ return;
1360
+ const p = c.parked;
1361
+ c.parked = {};
1362
+ const body = laneBody(p.pose ? [p.pose] : [], p.joints ? [p.joints] : [], p.state ? [p.state] : [], p.lifetimeMs);
1363
+ if (!body)
1364
+ return;
1365
+ // Not sent (the session is going): its end resets the claims, the rows are not re-sent (a batch is never re-sent).
1366
+ try {
1367
+ writeLive(liveWriterOn(scene, session), encodeLive(body));
1368
+ }
1369
+ catch { /* the session's end follows */ }
1370
+ };
1371
+ /** The runtime answered a claim asked in attach `b`. */
1372
+ const claimDone = (ask, b, result) => {
1373
+ if (ask.kind === "first") {
1374
+ let r;
1375
+ if ("ok" in result) {
1376
+ try {
1377
+ r = { ok: replyId(result.ok) };
1378
+ }
1379
+ catch (e) {
1380
+ r = { err: asError(e) };
1381
+ }
1382
+ }
1383
+ else
1384
+ r = result;
1385
+ const f = firsts.get(ask.token);
1386
+ firsts.delete(ask.token);
1387
+ const keep = (f?.keep ?? false) && mirror.state !== "closed";
1388
+ if ("ok" in r && !keep) {
1389
+ // Not kept: a `live.remove` of its id or the handle's close came after it. A grant is released; nothing is kept.
1390
+ release(node, session, r.ok);
1391
+ if (mirror.state === "closed")
1392
+ ask.reply.reject(closedError(mirror.ended ?? ENDED_CLIENT));
1393
+ else
1394
+ ask.reply.resolve(r.ok);
1395
+ }
1396
+ else if ("ok" in r) {
1397
+ // Granted in an earlier attach: claimed again in this one.
1398
+ const here = b === bindNo;
1399
+ scene.claims.set(r.ok, new Claim(ask.args, here ? CLAIMED : UNCLAIMED));
1400
+ if (!here)
1401
+ askAgain(r.ok);
1402
+ ask.reply.resolve(r.ok);
1403
+ }
1404
+ else if (sessionGone(r.err)) {
1405
+ // The session went before the runtime answered: asked on the next attach (unless no longer wanted).
1406
+ if (!keep)
1407
+ ask.reply.reject(r.err);
1408
+ else if (b !== bindNo && bound)
1409
+ askFirst(ask.args, ask.reply);
1410
+ else
1411
+ parkedUpserts.push({ args: ask.args, reply: ask.reply });
1412
+ }
1413
+ else
1414
+ ask.reply.reject(r.err);
1415
+ return;
1416
+ }
1417
+ if (b !== bindNo)
1418
+ return;
1419
+ const id = ask.id;
1420
+ asking.delete(id);
1421
+ const c = scene.claims.get(id);
1422
+ if (!c)
1423
+ return;
1424
+ if ("ok" in result) {
1425
+ c.state = CLAIMED;
1426
+ c.refusals = 0;
1427
+ // The rows parked while it was asked go out now.
1428
+ sendParked(id);
1429
+ return;
1430
+ }
1431
+ const e = result.err;
1432
+ if (sessionGone(e)) {
1433
+ c.state = UNCLAIMED;
1434
+ return;
1435
+ }
1436
+ // The runtime is mid-change: asked again shortly, silently (rows keep parking).
1437
+ if (isTransient(e)) {
1438
+ c.state = { kind: "paced", at: performance.now() + REFOLLOW_PACE_MS, refused: false };
1439
+ return;
1440
+ }
1441
+ c.refusals += 1;
1442
+ const pace = refusalPaceMs(c.refusals);
1443
+ scene.warn(`scene: live claim of ${id} refused by the runtime (${e.code}${e.message ? `: ${e.message}` : ""}; attempt ${c.refusals}; asking again in ${pace} ms)`);
1444
+ c.state = { kind: "paced", at: performance.now() + pace, refused: true };
1445
+ const dropped = parkedRows(c.parked);
1446
+ c.parked = {};
1447
+ if (dropped) {
1448
+ scene.unclaimedDrops += dropped;
1449
+ scene.countersChanged();
1450
+ }
1451
+ // An override's refusal is the writer's to see: a node notice (a `not_owner` reaches the denial feeds through it).
1452
+ if (!c.isEntity) {
1453
+ const body = { code: e.code };
1454
+ if (e.message)
1455
+ body.message = e.message;
1456
+ if (e.data !== undefined)
1457
+ body.data = e.data;
1458
+ for (const cb of [...node.notices]) {
1459
+ try {
1460
+ cb(body);
1461
+ }
1462
+ catch (x) {
1463
+ rethrowLater(x);
1464
+ }
1465
+ }
1466
+ }
1467
+ };
1468
+ /** The task ends: every claim held (or asked) in this attach is released by the call `synapse/scene/live/remove {id}`, one call per claim, best effort (not awaited); the claims are dropped. The session's end releases whatever remains. */
1469
+ const releaseClaims = () => {
1470
+ for (const f of firsts.values())
1471
+ f.keep = false;
1472
+ const claims = [...scene.claims];
1473
+ scene.claims.clear();
1474
+ if (!session || !bound)
1475
+ return;
1476
+ for (const [id, c] of claims) {
1477
+ if (c.state.kind !== "claimed" && c.state.kind !== "asking")
1478
+ continue;
1479
+ try {
1480
+ void startOn(node, session, "synapse/scene/live/remove", { id }, {}).result.catch(() => { });
1481
+ }
1482
+ catch { /* the session's end releases it */ }
1483
+ }
1484
+ };
1485
+ /** The refused live entities that are due are asked again. */
1486
+ const reclaimDue = () => {
1487
+ const now = performance.now();
1488
+ for (const [id, c] of [...scene.claims]) {
1489
+ const at = timerDue(c);
1490
+ if (at !== undefined && at <= now)
1491
+ askAgain(id);
1492
+ }
1493
+ };
1494
+ /** The timer for the next refused live entity due to be asked again (none while unbound or nothing paced). */
1495
+ const armReclaim = () => {
1496
+ let at = Infinity;
1497
+ if (bound)
1498
+ for (const c of scene.claims.values()) {
1499
+ const due = timerDue(c);
1500
+ if (due !== undefined && due < at)
1501
+ at = due;
1502
+ }
1503
+ if (at === reclaimAt)
1504
+ return;
1505
+ if (reclaimTimer !== undefined)
1506
+ clearTimeout(reclaimTimer);
1507
+ reclaimTimer = undefined;
1508
+ reclaimAt = at;
1509
+ if (at !== Infinity)
1510
+ reclaimTimer = setTimeout(() => { reclaimAt = Infinity; reclaimTimer = undefined; scene.post({ kind: "reclaim" }); }, Math.max(0, at - performance.now()));
859
1511
  };
860
1512
  const join = (reply) => { if (mirror.state === "attached" || inactive())
861
1513
  reply.resolve();
@@ -921,7 +1573,6 @@ async function run(scene, registry) {
921
1573
  const e = result.err;
922
1574
  const live = e.data?.live_epoch;
923
1575
  if (e.code === "conflict" && typeof live === "string" && live !== epoch && mirror.epoch === epoch) {
924
- endedEpoch = epoch;
925
1576
  again(REFOLLOW_PACE_MS);
926
1577
  }
927
1578
  reply.reject(e);
@@ -957,6 +1608,13 @@ async function run(scene, registry) {
957
1608
  case "applied":
958
1609
  applied(ev.epoch, ev.version);
959
1610
  break;
1611
+ case "opened":
1612
+ opened(ev.attempt, ev.result);
1613
+ break;
1614
+ case "liveKeyed":
1615
+ if (wanted?.epoch === ev.epoch)
1616
+ attach();
1617
+ break;
960
1618
  case "followEnded": {
961
1619
  {
962
1620
  const p = ph();
@@ -964,9 +1622,6 @@ async function run(scene, registry) {
964
1622
  break;
965
1623
  }
966
1624
  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
1625
  again(REFOLLOW_PACE_MS);
971
1626
  break;
972
1627
  }
@@ -980,9 +1635,7 @@ async function run(scene, registry) {
980
1635
  applyDone(ev.epoch, ev.result, ev.reply);
981
1636
  break;
982
1637
  case "project":
983
- project = ev.snapshot;
984
- if (inactive())
985
- answerJoins();
1638
+ projectChanged(ev.snapshot);
986
1639
  break;
987
1640
  case "notice":
988
1641
  notice(ev.body);
@@ -990,14 +1643,36 @@ async function run(scene, registry) {
990
1643
  case "paced":
991
1644
  if (ph().kind === "paced" && ev.attempt === attempts) {
992
1645
  phase = { kind: "idle" };
993
- follow();
1646
+ open();
994
1647
  }
995
1648
  break;
1649
+ case "upsert":
1650
+ upsert(ev.args, ev.reply);
1651
+ break;
1652
+ case "remove":
1653
+ remove(ev.id, ev.reply);
1654
+ break;
1655
+ case "claim":
1656
+ claimAsked(ev.id);
1657
+ break;
1658
+ case "claimDone":
1659
+ claimDone(ev.ask, ev.bind, ev.result);
1660
+ break;
1661
+ case "reclaim":
1662
+ reclaimDue();
1663
+ break;
996
1664
  case "close": break loop;
997
1665
  }
1666
+ armReclaim();
998
1667
  }
999
1668
  }
1000
1669
  finally {
1670
+ // The last handle closed (or the node ended): this node's claims are released (best effort).
1671
+ releaseClaims();
1672
+ if (reclaimTimer !== undefined)
1673
+ clearTimeout(reclaimTimer);
1674
+ for (const u of parkedUpserts.splice(0))
1675
+ u.reply.reject(closedError(reason));
1001
1676
  unmonitor();
1002
1677
  node.notices.delete(onNotice);
1003
1678
  unwatchProject?.();
@@ -1016,13 +1691,18 @@ async function run(scene, registry) {
1016
1691
  break;
1017
1692
  if (ev.kind === "applyDone")
1018
1693
  applyDone(ev.epoch, ev.result, ev.reply);
1019
- else if (ev.kind === "apply" || ev.kind === "join")
1694
+ else if (ev.kind === "apply" || ev.kind === "join" || ev.kind === "upsert" || ev.kind === "remove")
1020
1695
  ev.reply.reject(closedError(reason));
1696
+ else if (ev.kind === "claimDone")
1697
+ claimDone(ev.ask, ev.bind, ev.result); // a grant arriving now is released (`releaseClaims` marked every first not kept)
1021
1698
  }
1022
1699
  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")
1700
+ for (let ev = scene.mailbox.tryNext(); ev; ev = scene.mailbox.tryNext()) {
1701
+ if (ev.kind === "apply" || ev.kind === "join" || ev.kind === "applyDone" || ev.kind === "upsert" || ev.kind === "remove")
1025
1702
  ev.reply.reject(closedError(reason));
1703
+ else if (ev.kind === "claimDone")
1704
+ claimDone(ev.ask, ev.bind, ev.result); // a grant arriving now is released (`releaseClaims` marked every first not kept)
1705
+ }
1026
1706
  if (registry.current === scene)
1027
1707
  registry.current = undefined;
1028
1708
  scene.exited.resolve();
@@ -1035,80 +1715,33 @@ function announcedEpoch(prefix) {
1035
1715
  const e = prefix.slice(SCENE_BASE.length + 2);
1036
1716
  return e && !e.includes("/") ? e : undefined;
1037
1717
  }
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;
1718
+ /**
1719
+ * Reads track `name` of `bc` frame by frame as frames come until `on` says `end`, the track ends or `signal` aborts. A
1720
+ * `gap`: this subscription is let go and the track subscribed again, which delivers the open group from its snapshot
1721
+ * (PLAN v0.34 §2.2; no counter, no timer).
1722
+ */
1723
+ async function readTrack(bc, name, signal, on) {
1724
+ const track = bc.track(name);
1725
+ for (;;) {
1726
+ if (signal.aborted)
1727
+ return;
1728
+ const sub = track.subscribe(latestSubscription(moqPriority(1)));
1729
+ // Closing the subscriber ends `recvGroup`, not a read on the group it already handed us: that group is closed too.
1730
+ let g;
1731
+ const close = () => { try {
1732
+ g?.close();
1060
1733
  }
1061
- catch {
1062
- return undefined;
1734
+ catch { /* closed */ } try {
1735
+ sub.close();
1063
1736
  }
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) {
1737
+ catch { /* closed */ } };
1738
+ signal.addEventListener("abort", close, { once: true });
1739
+ let read = "end";
1104
1740
  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;
1741
+ groups: for (;;) {
1742
+ g = await sub.recvGroup();
1743
+ if (!g)
1744
+ break;
1112
1745
  for (;;) {
1113
1746
  let f;
1114
1747
  try {
@@ -1119,13 +1752,22 @@ class TrackReading {
1119
1752
  }
1120
1753
  if (!f)
1121
1754
  break;
1122
- if (!on(f.payload))
1123
- return;
1755
+ read = on(f.payload);
1756
+ if (read !== "read")
1757
+ break groups;
1124
1758
  }
1125
- this.group = undefined;
1759
+ read = "end";
1126
1760
  }
1127
1761
  }
1128
- catch { /* the track went */ }
1762
+ catch {
1763
+ read = "end"; /* the track went */
1764
+ }
1765
+ finally {
1766
+ signal.removeEventListener("abort", close);
1767
+ close();
1768
+ }
1769
+ if (read !== "gap")
1770
+ return;
1129
1771
  }
1130
1772
  }
1131
1773
  /** A frame's header flags and decoded body, or `undefined` (warned) when it does not decode. */
@@ -1140,14 +1782,18 @@ function decodeFrame(p, warn, what) {
1140
1782
  }
1141
1783
  }
1142
1784
  /**
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.
1785
+ * Reads incarnation `epoch` (the one `synapse/scene/open` answered) once it is announced: both its tracks into the
1786
+ * mirror, frame by frame as they come, telling the task of each authored frame applied and of the live track's first
1787
+ * keyframe (the task attaches the handle once the copy is current: a state of the copy, not a count of frames read);
1788
+ * returns once the `authored` track ends (its final frame, or the track going). Once `signal` aborts, or it returned,
1789
+ * it writes nothing more.
1146
1790
  */
1147
- async function followIncarnation(scene, session, announced, skip, signal) {
1791
+ async function followIncarnation(scene, session, announced, epoch, signal) {
1148
1792
  const { mirror, warn } = scene;
1149
1793
  const none = { epoch: undefined, reason: undefined };
1150
- const closers = [announced];
1794
+ // Its readers stop with the task's signal, and when this returns.
1795
+ const reading = new AbortController();
1796
+ const closers = [announced, { close: () => reading.abort() }];
1151
1797
  const closeAll = () => { for (const c of closers) {
1152
1798
  try {
1153
1799
  c.close();
@@ -1155,36 +1801,38 @@ async function followIncarnation(scene, session, announced, skip, signal) {
1155
1801
  catch { /* closed */ }
1156
1802
  } };
1157
1803
  signal.addEventListener("abort", closeAll, { once: true });
1804
+ const stopped = () => reading.signal.aborted;
1158
1805
  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;
1806
+ // The incarnation the open named, once announced: within the store's bound (an announce/retract pair the cursor never
1807
+ // saw is gone for good; the task opens again).
1808
+ const deadline = setTimeout(() => announced.close(), ANNOUNCE_WITHIN_MS);
1809
+ try {
1810
+ for (;;) {
1811
+ const u = await announced.next();
1812
+ if (!u || stopped())
1813
+ return none;
1814
+ if (u.kind !== "retracted" && announcedEpoch(String(u.prefix)) === epoch)
1815
+ break;
1170
1816
  }
1171
1817
  }
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}`);
1818
+ finally {
1819
+ clearTimeout(deadline);
1820
+ }
1821
+ // Resolving the broadcast races an end of this epoch already on the cursor (opened before the open call).
1822
+ const req = session.request(`${SCENE_BASE}/@${epoch}`);
1175
1823
  closers.push(req);
1176
1824
  const retracted = (async () => { for (;;) {
1177
1825
  const u = await announced.next();
1178
1826
  if (!u)
1179
1827
  return false;
1180
- if (u.kind === "retracted" && announcedEpoch(String(u.prefix)) === at)
1828
+ if (u.kind === "retracted" && announcedEpoch(String(u.prefix)) === epoch)
1181
1829
  return true;
1182
1830
  } })();
1183
1831
  let bc;
1184
1832
  try {
1185
- const r = await Promise.race([waitActive(req, 10_000, signal).then(b => ({ bc: b })), retracted.then(gone => ({ gone }))]);
1833
+ const r = await Promise.race([waitActive(req, 10_000, reading.signal).then(b => ({ bc: b })), retracted.then(gone => ({ gone }))]);
1186
1834
  if ("gone" in r)
1187
- return r.gone ? { epoch: at, reason: "ended" } : none;
1835
+ return r.gone ? { epoch, reason: "ended" } : none;
1188
1836
  bc = r.bc;
1189
1837
  }
1190
1838
  catch {
@@ -1192,88 +1840,67 @@ async function followIncarnation(scene, session, announced, skip, signal) {
1192
1840
  }
1193
1841
  announced.close();
1194
1842
  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)
1843
+ if (stopped())
1199
1844
  return none;
1200
1845
  let end;
1201
- const ended = () => ({ epoch: at, reason: end });
1846
+ let keyed = false;
1202
1847
  const onAuthored = (p) => {
1203
- if (signal.aborted)
1204
- return false;
1848
+ if (stopped())
1849
+ return "end";
1205
1850
  const f = decodeFrame(p, warn, "authored");
1206
1851
  if (!f)
1207
- return true;
1852
+ return "read";
1208
1853
  if (f.flags & FLAGS.END) {
1209
1854
  const r = f.body?.reason;
1210
1855
  end = typeof r === "string" ? r : "ended";
1211
- return false;
1856
+ return "end";
1212
1857
  }
1213
1858
  let version;
1214
1859
  if (f.flags & FLAGS.KEY) {
1215
1860
  const s = f.body;
1216
1861
  if (!s || typeof s.version !== "number" || !isObject(s.entities)) {
1217
1862
  warn("scene: snapshot not understood");
1218
- return true;
1863
+ return "read";
1219
1864
  }
1220
- mirror.applySnapshot(at, s);
1865
+ mirror.applySnapshot(epoch, s);
1221
1866
  version = s.version;
1222
1867
  }
1223
1868
  else if (f.flags & FLAGS.DELTA) {
1224
1869
  const d = f.body;
1225
1870
  if (!d || typeof d.version !== "number" || !Array.isArray(d.ops)) {
1226
1871
  warn("scene: diff not understood");
1227
- return true;
1872
+ return "read";
1228
1873
  }
1229
- mirror.applyDiff(d, warn);
1874
+ if (!mirror.applyDiff(d, warn))
1875
+ return "gap";
1230
1876
  version = d.version;
1231
1877
  }
1232
1878
  else
1233
- return true;
1234
- scene.post({ kind: "applied", epoch: at, version });
1235
- return true;
1879
+ return "read";
1880
+ scene.post({ kind: "applied", epoch, version });
1881
+ return "read";
1236
1882
  };
1237
1883
  const onLive = (p) => {
1238
- if (signal.aborted)
1239
- return false;
1884
+ if (stopped())
1885
+ return "end";
1240
1886
  const f = decodeFrame(p, warn, "live");
1241
1887
  if (!f || !isObject(f.body))
1242
- return true;
1243
- if (f.flags & FLAGS.KEY)
1888
+ return "read";
1889
+ if (f.flags & FLAGS.KEY) {
1244
1890
  mirror.applyKeyframe(f.body);
1891
+ if (!keyed) {
1892
+ keyed = true;
1893
+ scene.post({ kind: "liveKeyed", epoch });
1894
+ }
1895
+ }
1245
1896
  else if (f.flags & FLAGS.DELTA)
1246
1897
  mirror.applyLiveTick(f.body);
1247
- return true;
1898
+ return "read";
1248
1899
  };
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();
1900
+ // The live track's end is the incarnation's: the authored track says how it ended.
1901
+ void readTrack(bc, LIVE_TRACK, reading.signal, onLive);
1902
+ await readTrack(bc, AUTHORED_TRACK, reading.signal, onAuthored);
1903
+ return stopped() ? none : { epoch, reason: end };
1277
1904
  }
1278
1905
  catch {
1279
1906
  return none;
@@ -1316,34 +1943,180 @@ export class SceneHandleImpl {
1316
1943
  /** The views of this handle that follow the mirror now. */
1317
1944
  #views = new Set();
1318
1945
  #unsubscribes = new Set();
1946
+ /** `ready()` calls waiting for the handle to attach: rejected by `close()`. */
1947
+ #readies = new Set();
1948
+ /** Told when this handle closes (its status says `closed` from then on, whatever the node's other handles do). */
1949
+ #onClose = new Set();
1950
+ /** The authored plane's verbs. */
1951
+ authored;
1952
+ /** This node's live writes. */
1953
+ live;
1954
+ /** The handle's own state, a state value: `get()` (the same object while unchanged), `onChange`, `changed()`, `watch()`. */
1955
+ status;
1956
+ #status;
1319
1957
  constructor(scene) {
1320
1958
  this.scene = scene;
1321
1959
  scene.handles++;
1322
1960
  this.#id = scene.nextHandle++;
1961
+ this.status = this.#status = new StatusCell(() => this.#statusNow(), l => {
1962
+ const un = scene.mirror.subscribe(l);
1963
+ scene.counters.add(l);
1964
+ this.#onClose.add(l);
1965
+ return () => { un(); scene.counters.delete(l); this.#onClose.delete(l); };
1966
+ }, () => this.#closed || scene.mirror.state === "closed");
1967
+ // One object each for the handle's life.
1968
+ this.authored = Object.freeze({
1969
+ apply: (ops, base) => this.#apply(ops, base),
1970
+ txn: (build) => this.#txn(build),
1971
+ spawn: (kind, opts) => this.#spawn(kind, opts),
1972
+ patch: (entity, set) => this.#txn(t => t.patch(entity, set)),
1973
+ upsert: (kind, opts) => this.#upsert(kind, opts),
1974
+ rename: (entity, name) => this.#txn(t => t.rename(entity, name)),
1975
+ reparent: (entity, parent, place) => this.#txn(t => t.reparent(entity, parent, place)),
1976
+ despawn: (entity, opts) => this.#txn(t => t.despawn(entity, opts)),
1977
+ setLocked: (entity, locked) => this.#txn(t => t.setLocked(entity, locked)),
1978
+ });
1979
+ this.live = Object.freeze({
1980
+ batch: (batch) => this.#batch(batch),
1981
+ upsert: (kind, opts, live) => this.#liveUpsert(kind, opts ?? {}, live ?? {}),
1982
+ remove: (entity) => { try {
1983
+ return this.#releaseId(idOf(entity));
1984
+ }
1985
+ catch (e) {
1986
+ return Promise.reject(e);
1987
+ } },
1988
+ });
1323
1989
  }
1324
- get entities() { return this.scene.mirror.merged; }
1325
- entity(id, opts = {}) {
1990
+ /** The merged entities now; a closed handle reads nothing (the mirror may live on for the node's other handles). */
1991
+ get() { return this.#closed ? EMPTY : this.scene.mirror.merged; }
1992
+ /** One entity as a view: the merged entity, with its authored value (`authored`) and its live row (`live`). */
1993
+ entity(ref) {
1326
1994
  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(() => {
1995
+ const named = this.#idOf(ref);
1996
+ const id = () => {
1997
+ const id = named();
1998
+ if (id === undefined)
1999
+ throw notNamed(ref);
2000
+ return id;
2001
+ };
2002
+ const ended = () => this.#closed || mirror.state === "closed";
2003
+ // Every read goes through the same guard: a closed handle reads nothing (the mirror may live on for the node's other handles).
2004
+ const reader = (read) => () => {
2005
+ if (ended())
2006
+ return undefined;
2007
+ const id = named();
2008
+ return id === undefined ? undefined : read(id);
2009
+ };
2010
+ // The live row, rebuilt only when the row object changes and kept the same object while equal.
2011
+ let from;
2012
+ let last;
2013
+ const ops = {
2014
+ id,
2015
+ // The entity as declared (the authored one, or a live entity's upsert row): its object changes only when an
2016
+ // authored batch or a new upsert changes it, never on a lane tick.
2017
+ readAuthored: reader(id => mirror.authored.get(id) ?? mirror.live.get(id)?.entity),
2018
+ readLive: reader(id => {
1336
2019
  const row = mirror.live.get(id);
1337
2020
  if (row === from)
1338
2021
  return last;
1339
2022
  from = row;
1340
2023
  last = row ? replaceEqualDeep(last, liveValue(row)) : undefined;
1341
2024
  return last;
1342
- });
2025
+ }),
2026
+ write: components => this.#write(ref, named, components),
2027
+ live: (components, opts) => this.#liveLanes(ref, named, components, opts),
2028
+ remove: () => this.#liveRemove(ref, named),
2029
+ };
2030
+ return new EntityView(mirror, reader(id => mirror.merged[id]), this.#views, ended, ops);
2031
+ }
2032
+ /**
2033
+ * The id a view names now: its id, or the id of the entity holding its name (names are unique across every plane;
2034
+ * an authored or calibration entity, else a live entity). The last id found is checked first: a name is held by one
2035
+ * entity at a time.
2036
+ */
2037
+ #idOf(ref) {
2038
+ if (typeof ref === "string")
2039
+ return () => ref;
2040
+ if (!isObject(ref) || typeof ref.name !== "string")
2041
+ throw new SynapseError("invalid_input", "scene.entity takes an id or { name }");
2042
+ const name = ref.name, mirror = this.scene.mirror;
2043
+ let held;
2044
+ return () => {
2045
+ if (held !== undefined && (mirror.authored.get(held) ?? mirror.live.get(held)?.entity)?.name === name)
2046
+ return held;
2047
+ held = authoredNamed(mirror.authored, name)?.id ?? liveNamed(mirror.live, name);
2048
+ return held;
2049
+ };
2050
+ }
2051
+ /** An entity view's `authored.set`: one patch of the id it names now. A name no entity holds has nothing to send: `not_found`, as the runtime answers a patch of an unknown id. */
2052
+ async #write(ref, named, components) {
2053
+ if (this.#closed)
2054
+ throw closedError(ENDED_CLIENT);
2055
+ if (this.scene.mirror.state === "detached")
2056
+ throw detachedError();
2057
+ const id = named();
2058
+ if (id === undefined)
2059
+ throw notNamed(ref);
2060
+ await this.#txn(t => t.patch(id, components));
2061
+ }
2062
+ /** An entity view's `live.set`: the lanes of the id it names now, as one batch. */
2063
+ #liveLanes(ref, named, components, opts) {
2064
+ this.#liveOpen();
2065
+ const lanes = {};
2066
+ for (const [k, v] of Object.entries(components)) {
2067
+ if (v === undefined)
2068
+ continue;
2069
+ if (k === "xform") {
2070
+ const x = v;
2071
+ if (!isObject(v) || !("t" in x) || !("q" in x) || Object.keys(x).some(k => k !== "t" && k !== "q"))
2072
+ throw new SynapseError("invalid_input", "scene: live xform is the pose lane: { t, q }, both, nothing else");
2073
+ }
2074
+ else if (k !== "joints" && k !== "state")
2075
+ throw new SynapseError("invalid_input", { message: `scene: live.set(...) writes the lanes xform ({ t, q }), joints and state, not ${JSON.stringify(k)}`, data: { component: k } });
2076
+ lanes[k] = v;
1343
2077
  }
1344
- return this.#view(() => mirror.merged[id]);
2078
+ const id = named();
2079
+ if (id === undefined) {
2080
+ // Detached, nothing is named: the write is dropped and counted, as every live write then is.
2081
+ if (this.scene.mirror.state !== "attached") {
2082
+ this.scene.detachedDrops++;
2083
+ this.scene.countersChanged();
2084
+ return;
2085
+ }
2086
+ throw notNamed(ref);
2087
+ }
2088
+ const batch = {};
2089
+ const x = lanes.xform;
2090
+ if (x)
2091
+ batch.poses = [{ id, t: x.t, q: x.q }];
2092
+ if (lanes.joints !== undefined)
2093
+ batch.joints = [{ id, j: lanes.joints }];
2094
+ if (lanes.state !== undefined)
2095
+ batch.state = [{ id, s: lanes.state }];
2096
+ if (opts.lifetimeMs !== undefined)
2097
+ batch.lifetimeMs = opts.lifetimeMs;
2098
+ this.#batch(batch);
2099
+ }
2100
+ /** An entity view's `live.remove`: the release call of the id it names now (`scene.live.remove(id)`); a view by name whose name no entity holds has no id to release: `not_found`. */
2101
+ #liveRemove(ref, named) {
2102
+ try {
2103
+ this.#liveOpen();
2104
+ const id = named();
2105
+ if (id === undefined)
2106
+ throw notNamed(ref);
2107
+ return this.#releaseId(id);
2108
+ }
2109
+ catch (e) {
2110
+ return Promise.reject(e);
2111
+ }
2112
+ }
2113
+ /** `live.remove`: the call `synapse/scene/live/remove {id}` through the task (it drops the claim). */
2114
+ #releaseId(id) {
2115
+ if (this.#closed)
2116
+ return Promise.reject(closedError(ENDED_CLIENT));
2117
+ return this.scene.request(reply => ({ kind: "remove", id, reply }));
1345
2118
  }
1346
- tree() { return this.#view(() => this.scene.mirror.tree()); }
2119
+ tree() { return this.#view(() => this.scene.mirror.tree(), EMPTY_TREE); }
1347
2120
  query(where) {
1348
2121
  let from;
1349
2122
  let last = [];
@@ -1355,13 +2128,40 @@ export class SceneHandleImpl {
1355
2128
  const ids = Object.keys(m).filter(id => where(m[id])).sort();
1356
2129
  last = replaceEqualDeep(last, ids.map(id => m[id]));
1357
2130
  return last;
1358
- });
2131
+ }, EMPTY_LIST);
2132
+ }
2133
+ /** A view of `read`; a closed handle reads `empty` (the mirror may live on for the node's other handles), as its entity views and `get()` do. */
2134
+ #view(read, empty) {
2135
+ const ended = () => this.#closed || this.scene.mirror.state === "closed";
2136
+ return new MirrorView(this.scene.mirror, () => (ended() ? empty : read()), this.#views, ended);
1359
2137
  }
1360
- #view(read) { return new MirrorView(this.scene.mirror, read, this.#views, () => this.#closed || this.scene.mirror.state === "closed"); }
1361
- get status() {
2138
+ #statusNow() {
1362
2139
  const m = this.scene.mirror;
1363
2140
  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 };
2141
+ return { state, synced: state === "attached" && this.scene.node.isConnected && m.pending === 0 && m.gap === undefined, pending: m.pending, version: m.version, hash: m.hash, detachedDrops: this.scene.detachedDrops, unclaimedDrops: this.scene.unclaimedDrops };
2142
+ }
2143
+ /** The one boolean a node author checks: `status.get().synced`. */
2144
+ get synced() { return this.status.get().synced; }
2145
+ /** Resolves once the handle is attached (at once if it is); `not_connected` once it is closed (`close()`, or the node ending). */
2146
+ ready() {
2147
+ const m = this.scene.mirror;
2148
+ if (this.#closed || m.state === "closed")
2149
+ return Promise.reject(closedError(this.#closed ? ENDED_CLIENT : m.ended ?? ENDED_CLIENT));
2150
+ if (m.state === "attached")
2151
+ return Promise.resolve();
2152
+ const d = deferred();
2153
+ this.#readies.add(d);
2154
+ const un = this.onChange(() => {
2155
+ if (m.state === "closed")
2156
+ d.reject(closedError(m.ended ?? ENDED_CLIENT));
2157
+ else if (m.state === "attached")
2158
+ d.resolve();
2159
+ else
2160
+ return;
2161
+ un();
2162
+ this.#readies.delete(d);
2163
+ });
2164
+ return d.promise;
1365
2165
  }
1366
2166
  // ---- feeds
1367
2167
  #feed(kind, cb) {
@@ -1385,99 +2185,197 @@ export class SceneHandleImpl {
1385
2185
  void q.close();
1386
2186
  return iterate(q, () => m.removeSink(kind, sink));
1387
2187
  }
1388
- watchChanges(cb) { return this.#feed("changes", cb); }
2188
+ #ended() { return this.#closed || this.scene.mirror.state === "closed"; }
2189
+ /** The entities after their next change (state, not history); `not_connected` once this handle is closed. */
2190
+ changed() {
2191
+ const m = this.scene.mirror;
2192
+ const closed = () => closedError(this.#closed ? ENDED_CLIENT : m.ended ?? ENDED_CLIENT);
2193
+ if (this.#ended())
2194
+ return Promise.reject(closed());
2195
+ const from = this.get();
2196
+ const d = deferred();
2197
+ const un = this.onChange(v => {
2198
+ if (this.#ended())
2199
+ d.reject(closed());
2200
+ else if (v !== from)
2201
+ d.resolve(v);
2202
+ else
2203
+ return;
2204
+ un();
2205
+ });
2206
+ return d.promise;
2207
+ }
2208
+ /** The entities after each change; ends with the handle. */
2209
+ watch() { return watchState(cb => this.onChange(cb), () => this.get(), () => this.#ended()); }
1389
2210
  watchLiveRemovals(cb) { return this.#feed("removals", cb); }
1390
2211
  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) {
2212
+ // ---- authored (`scene.authored`): every verb is one `apply`
2213
+ async #apply(ops, base) {
1394
2214
  if (this.#closed)
1395
2215
  throw closedError(ENDED_CLIENT);
1396
2216
  if (this.scene.mirror.state === "detached")
1397
2217
  throw detachedError();
1398
2218
  await this.scene.request(reply => ({ kind: "apply", ops, base, reply }));
1399
2219
  }
1400
- async txn(build) {
2220
+ async #txn(build) {
1401
2221
  const t = new Txn();
1402
2222
  build(t);
1403
- await this.apply([...t.ops]);
2223
+ await this.#apply([...t.ops]);
1404
2224
  }
1405
- async spawn(kind, opts) {
2225
+ async #spawn(kind, opts) {
1406
2226
  let id = "";
1407
- await this.txn(t => { id = t.spawn(kind, opts); });
2227
+ await this.#txn(t => { id = t.spawn(kind, opts); });
1408
2228
  return id;
1409
2229
  }
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
2230
  /**
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.
2231
+ * The entity named `name`, made to carry `components`; its view (by id). The id is the runtime's answer, never the
2232
+ * copy's: a `spawn` (with `parent`, a fresh id minted here) is sent first; the runtime refusing it `name_taken` names
2233
+ * the holder and its kind (`data.holder`, `data.kind`, `data.layer`). A holder of this kind in the authored or
2234
+ * calibration layer is patched with the components that differ from what the copy shows of it (all of them when the
2235
+ * copy does not show it yet; equal, nothing more is sent): the copy only trims the patch, it never decides. A holder
2236
+ * of another kind, a live one, or an answer without them: the `name_taken` is raised as it came. `parent` is for the
2237
+ * spawn only (an existing entity is not reparented). Any other refusal is raised as it came (`not_found` on the patch:
2238
+ * the entity went meanwhile; upsert again). An `id` component: `invalid_input` (`data: { component: "id" }`), nothing
2239
+ * sent.
1420
2240
  */
1421
- live(batch) {
2241
+ async #upsert(kind, opts) {
1422
2242
  if (this.#closed)
1423
2243
  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;
2244
+ refuseId(opts, "scene.authored.upsert");
2245
+ const { name, parent: _parent, ...components } = opts;
2246
+ let holder;
1444
2247
  try {
1445
- bytes = encodeBody(body);
2248
+ return this.entity(await this.#spawn(kind, opts));
1446
2249
  }
1447
2250
  catch (e) {
1448
- throw new SynapseError("invalid_input", `live batch not encodable: ${e instanceof Error ? e.message : String(e)}`);
2251
+ if (!(e instanceof SynapseError) || e.code !== "name_taken")
2252
+ throw e;
2253
+ // The runtime's answer decides: the holder, its kind and its layer.
2254
+ const d = (e.data ?? {});
2255
+ if (typeof d.holder !== "string" || d.kind !== kind || (d.layer !== "authored" && d.layer !== "calibration"))
2256
+ throw e;
2257
+ holder = d.holder;
1449
2258
  }
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
- }
2259
+ const row = this.scene.mirror.authored.get(holder);
2260
+ const after = row ? mergeSet(row, components) : undefined;
2261
+ // Only the components that differ (a patch of `flags` alone is what a locked entity accepts).
2262
+ const differ = Object.fromEntries(Object.entries(components).filter(([k, v]) => v !== undefined && (!row || !deepEqual(row[k], after[k]))));
2263
+ if (Object.keys(differ).length)
2264
+ await this.#txn(t => t.patch(holder, differ));
2265
+ return this.entity(holder);
2266
+ }
2267
+ // ---- live (`scene.live`)
2268
+ /**
2269
+ * A live entity of this node's: the call `synapse/scene/live/upsert {kind, name?, parent, lifetime_ms?, ...components}`
2270
+ * through the task; resolves with the view of the id the runtime answered (a name this node already claimed sends that
2271
+ * claim's id: its own intent). The runtime decides: its refusal is the rejection, as it came, and nothing is kept.
2272
+ * Granted, it is this node's claim, claimed again (same id) on every attach until `remove`. A session that goes before
2273
+ * the answer: asked at the next attach. Detached: `project_inactive`; the node ending: `not_connected`.
2274
+ */
2275
+ #liveUpsert(kind, opts, live) {
2276
+ try {
2277
+ this.#liveOpen();
2278
+ refuseId(opts, "scene.live.upsert");
1462
2279
  }
1463
- const header = { flags: 0, type: TYPES.PUB, seq: w.seq, tUs: nowUs() };
1464
- w.seq = (w.seq + 1) >>> 0;
2280
+ catch (e) {
2281
+ return Promise.reject(e);
2282
+ }
2283
+ const { name, parent, ...components } = opts;
2284
+ let parentId;
1465
2285
  try {
1466
- w.stream.write(header, bytes);
2286
+ parentId = parent == null ? null : idOf(parent, "parent");
1467
2287
  }
1468
2288
  catch (e) {
1469
- throw new SynapseError("connection_lost", { message: e instanceof Error ? e.message : String(e), source: "transport", cause: e });
2289
+ return Promise.reject(e);
1470
2290
  }
2291
+ const args = {};
2292
+ for (const [k, v] of Object.entries(components))
2293
+ if (v !== undefined)
2294
+ args[k] = v;
2295
+ args.kind = kind;
2296
+ if (name !== undefined)
2297
+ args.name = name;
2298
+ args.parent = parentId;
2299
+ if (live.lifetimeMs !== undefined)
2300
+ args.lifetime_ms = live.lifetimeMs;
2301
+ return this.scene.request(reply => ({ kind: "upsert", args, reply })).then(id => this.entity(id));
1471
2302
  }
1472
- /** The framework bridge (React's hooks): called on every change of the entities or the status. */
1473
- subscribe(listener) {
2303
+ /**
2304
+ * Writes one lane batch on this node's live track (made on the first write of each session); returns at once, like
2305
+ * `publish`. A row goes out only for an id this node claimed in the bound incarnation; the first row of an id with no
2306
+ * claim asks `upsert {id}` (the override) once, and until the answer the latest row of each lane of that id is parked
2307
+ * (latest wins), sent once granted, dropped and counted when refused (`unclaimedDrops`; rows written while refused and
2308
+ * not due again are dropped and counted too). Not attached: dropped and counted (`detachedDrops`). `upsert`/`remove`
2309
+ * keys (the type does not allow them) are sent as given: the runtime refuses such a batch (`invalid_input`, `data {lane}`).
2310
+ */
2311
+ #batch(batch) {
2312
+ this.#liveOpen();
2313
+ const scene = this.scene;
2314
+ const s = scene.node.currentSession();
2315
+ if (scene.mirror.state !== "attached" || !s) {
2316
+ scene.detachedDrops++;
2317
+ scene.countersChanged();
2318
+ return;
2319
+ }
2320
+ const now = performance.now(), ask = [];
2321
+ const poses = [], joints = [], state = [];
2322
+ let dropped = 0;
2323
+ const lane = (rows, out, park) => {
2324
+ for (const r of rows ?? []) {
2325
+ const g = scene.gate(r.id, now, ask);
2326
+ if (g === "send")
2327
+ out.push(r);
2328
+ else if (g === "park") {
2329
+ const p = scene.claims.get(r.id).parked;
2330
+ park(p, r);
2331
+ p.lifetimeMs = batch.lifetimeMs;
2332
+ }
2333
+ else
2334
+ dropped += 1;
2335
+ }
2336
+ };
2337
+ lane(batch.poses, poses, (p, r) => { p.pose = r; });
2338
+ lane(batch.joints, joints, (p, r) => { p.joints = r; });
2339
+ lane(batch.state, state, (p, r) => { p.state = r; });
2340
+ if (dropped) {
2341
+ scene.unclaimedDrops += dropped;
2342
+ scene.countersChanged();
2343
+ }
2344
+ for (const id of ask)
2345
+ scene.post({ kind: "claim", id });
2346
+ const body = laneBody(poses, joints, state, batch.lifetimeMs) ?? {};
2347
+ const rest = batch;
2348
+ if (rest.upsert !== undefined)
2349
+ body.upsert = rest.upsert;
2350
+ if (rest.remove !== undefined)
2351
+ body.remove = rest.remove;
2352
+ if (!Object.keys(body).length)
2353
+ return;
2354
+ if (batch.lifetimeMs !== undefined)
2355
+ body.lifetime_ms = batch.lifetimeMs;
2356
+ writeLive(liveWriterOn(scene, s), encodeLive(body));
2357
+ }
2358
+ /** A live write on a closed handle, or after the node ended (the node says so at once; the mirror closes once the scene task took `nodeEnded`), is `not_connected`, never a detached drop. */
2359
+ #liveOpen() {
2360
+ if (this.#closed)
2361
+ throw closedError(ENDED_CLIENT);
2362
+ if (this.scene.node.isEnded)
2363
+ throw closedError(ENDED_CONNECTION_LOST);
2364
+ const m = this.scene.mirror;
2365
+ if (m.state === "closed")
2366
+ throw closedError(m.ended ?? ENDED_CLIENT);
2367
+ }
2368
+ /** `cb` with the entities on every change of the entities or the status (the framework bridge, React's hooks), and once more when this handle closes; returns the remover. */
2369
+ onChange(cb) {
1474
2370
  if (this.#closed)
1475
2371
  return () => { };
1476
- const un = this.scene.mirror.subscribe(listener);
2372
+ const l = () => cb(this.get());
2373
+ const un = this.scene.mirror.subscribe(l);
1477
2374
  this.#unsubscribes.add(un);
1478
- return () => { un(); this.#unsubscribes.delete(un); };
2375
+ this.#onClose.add(l);
2376
+ return () => { un(); this.#unsubscribes.delete(un); this.#onClose.delete(l); };
1479
2377
  }
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. */
2378
+ /** 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 releases this node's claims (one `synapse/scene/live/remove` call per claim, best effort, not awaited; the session's end releases what remains) and ends its live writer. Idempotent. */
1481
2379
  close() {
1482
2380
  if (this.#closed)
1483
2381
  return;
@@ -1488,6 +2386,19 @@ export class SceneHandleImpl {
1488
2386
  for (const un of this.#unsubscribes)
1489
2387
  un();
1490
2388
  this.#unsubscribes.clear();
2389
+ for (const d of this.#readies)
2390
+ d.reject(closedError(ENDED_CLIENT));
2391
+ this.#readies.clear();
2392
+ for (const l of [...this.#onClose]) {
2393
+ try {
2394
+ l();
2395
+ }
2396
+ catch (e) {
2397
+ rethrowLater(e);
2398
+ }
2399
+ }
2400
+ this.#onClose.clear();
2401
+ this.#status.dispose();
1491
2402
  if (--this.scene.handles === 0)
1492
2403
  this.scene.post({ kind: "close" });
1493
2404
  }
@@ -1495,12 +2406,12 @@ export class SceneHandleImpl {
1495
2406
  }
1496
2407
  /**
1497
2408
  * 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.
2409
+ * once the handle is attached (the copy at the version `synapse/scene/open` answered, or past it), or at
2410
+ * once `detached` when the runtime says no project is active; the
2411
+ * handle then follows the incarnations as they come and go. Without a session (before the first, between
2412
+ * two) the task waits for the next one, like after a drop.
1500
2413
  */
1501
2414
  export async function openScene(node, registry, warn) {
1502
- if (!node.currentSession())
1503
- throw new SynapseError("not_connected");
1504
2415
  let scene;
1505
2416
  let h;
1506
2417
  for (;;) {