@synapse-sdk/client 0.3.3 → 0.3.4

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
@@ -21,13 +21,25 @@
21
21
  // - `apply` / `applyDone`: an authoring batch (it names the bound incarnation's epoch) and the
22
22
  // runtime's answer; the caller is answered once the mirror shows the version;
23
23
  // - `notice`: the runtime dropped some of this node's live rows (`not_owner` with `data.ids`);
24
+ // - `upsert` / `remove` / `claim` / `claimDone` / `reclaim`: this node's live claims (PLAN v0.31: decisions are calls,
25
+ // data is streams): `live.upsert` and `live.remove` are the calls `synapse/scene/live/upsert` and
26
+ // `synapse/scene/live/remove`; a lane write for an id this node has not claimed in the bound incarnation asks the
27
+ // claim once (the override of an entity of the scene); the runtime's answers; a refused live entity due again;
24
28
  // - `close`: the last open handle left.
25
29
  //
30
+ // The claims are this node's own intent (id → the claim's args), never world state: on every attach to an incarnation
31
+ // (a new session, a project switch, a restarted scene owner) the live entities among them are claimed again with the
32
+ // same ids; an override is claimed again by its next lane write. A refused re-claim is asked again, paced, with a
33
+ // warning (the store's re-open rule and constants: `refusalPaceMs`). Lane rows for an id not claimed in the bound
34
+ // incarnation are parked (latest per lane) while the claim is asked, sent once granted, dropped and counted
35
+ // (`status.get().unclaimedDrops`) when refused. Nothing here reads the mirror to decide what to send: the mirror is a
36
+ // read cache. The Rust client's `scene.rs` is the reference; this file behaves as it does.
37
+ //
26
38
  // States: `attached` (an incarnation's snapshot is in the mirror, and every frame of both tracks this
27
39
  // node already held when the first frame of each arrived has been applied), `detached` (none: no
28
40
  // 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`.
41
+ // detached the entities are empty, every author verb fails `project_inactive` at once and a live
42
+ // write drops its batch and counts it. A closed handle's verbs fail `not_connected`.
31
43
  //
32
44
  // Known behaviour (as the Rust client's):
33
45
  // - live rows that disappear only through a keyframe (the ticks that removed them were missed) leave
@@ -42,9 +54,9 @@
42
54
  import * as Moq from "@moq/net";
43
55
  import { FLAGS, SynapseError, TYPES, decode as decodeBody, decodeHeader, encode as encodeBody, moqPriority, nowUs } from "@synapse-sdk/core";
44
56
  import { startOn } from "./calls.js";
45
- import { REFOLLOW_PACE_MS } from "./channel.js";
57
+ import { REFOLLOW_PACE_MS, isTransient, refusalPaceMs } from "./channel.js";
46
58
  import { CALL_GROUP_MAX_AGE_MS, StreamWriter, frameTooLarge, latestSubscription, waitActive } from "./session.js";
47
- import { AsyncQueue, deferred, replaceEqualDeep } from "./util.js";
59
+ import { AsyncQueue, StatusCell, deferred, replaceEqualDeep, rethrowLater, watchState } from "./util.js";
48
60
  /** Where the runtime announces the scene's incarnations: `runtime/scene/@<epoch>`. */
49
61
  const SCENE_BASE = "runtime/scene";
50
62
  const AUTHORED_TRACK = "authored";
@@ -60,12 +72,22 @@ export const PATCH_PROTECTED = ["id", "name", "parent", "order", "layer", "meta"
60
72
  /** moq's cap on one frame. */
61
73
  const MAX_FRAME_BYTES = 32 * 1024 * 1024;
62
74
  const EMPTY = Object.freeze({});
75
+ /** What a closed handle's `tree()` and `query()` read: one object each, so a reader that compares by reference stays quiet. */
76
+ const EMPTY_TREE = Object.freeze([]);
77
+ const EMPTY_LIST = Object.freeze([]);
63
78
  const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v) && !(v instanceof Uint8Array);
64
79
  /** Deep equality of decoded values (structural sharing keeps `a` when equal). */
65
80
  const deepEqual = (a, b) => replaceEqualDeep(a, b) === a;
66
81
  const parentOf = (e) => (typeof e.parent === "string" ? e.parent : null);
67
82
  const orderOf = (e) => (typeof e.order === "string" ? e.order : undefined);
68
- const rethrowLater = (e) => { queueMicrotask(() => { throw e; }); };
83
+ /** The id `x` names: a string, or a view this SDK made (`EntityView`); anything else (`{ id }` included) is `invalid_input` naming `arg`. */
84
+ const idOf = (x, arg = "entity") => {
85
+ if (typeof x === "string")
86
+ return x;
87
+ if (x instanceof EntityView)
88
+ return x.id;
89
+ throw new SynapseError("invalid_input", `scene: ${arg} is an id or an entity view (scene.entity, upsert), not ${x === null ? "null" : typeof x}`);
90
+ };
69
91
  // ------------------------------------------------------------------------------------- helpers
70
92
  const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
71
93
  const RAND_BITS = 74n;
@@ -227,17 +249,18 @@ export class Txn {
227
249
  #spawnedUnder(parent) {
228
250
  return this.#ops.flatMap(op => (op.op === "spawn" && op.entity.parent === parent ? [op.entity.id] : []));
229
251
  }
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. */
252
+ /** 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
253
  spawn(kind, opts) {
232
254
  const { parent, id: given, ...components } = opts;
233
255
  const id = given ?? ulid();
234
- const entity = { ...components, id, kind, parent: parent ?? null };
256
+ const entity = { ...components, id, kind, parent: parent == null ? null : idOf(parent, "parent") };
235
257
  entity.layer ??= "authored";
236
258
  this.#ops.push({ op: "spawn", entity });
237
259
  return id;
238
260
  }
239
- /** Sets components of `id` (one level deep: an object merges into an object). */
240
- patch(id, set) {
261
+ /** Sets components of the entity (one level deep: an object merges into an object). */
262
+ patch(entity, set) {
263
+ const id = idOf(entity);
241
264
  // A protected key is the runtime's to refuse: such a patch is sent as it is.
242
265
  const protectedKey = Object.keys(set).some(k => PATCH_PROTECTED.includes(k));
243
266
  const i = protectedKey ? -1 : this.#spawnIndex(id);
@@ -248,7 +271,8 @@ export class Txn {
248
271
  }
249
272
  this.#ops.push({ op: "patch", id, set });
250
273
  }
251
- rename(id, name) {
274
+ rename(entity, name) {
275
+ const id = idOf(entity);
252
276
  const i = this.#spawnIndex(id);
253
277
  if (i >= 0) {
254
278
  const op = this.#ops[i];
@@ -257,17 +281,18 @@ export class Txn {
257
281
  }
258
282
  this.#ops.push({ op: "rename", id, name });
259
283
  }
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 };
284
+ /** Moves the entity under `parent` (`null`: the world root), before or after a sibling (default: after the last). */
285
+ reparent(entity, parent, place) {
286
+ const op = { op: "reparent", id: idOf(entity), parent: parent === null ? null : idOf(parent, "parent") };
263
287
  if (place && "before" in place)
264
- op.before = place.before;
288
+ op.before = idOf(place.before, "before");
265
289
  else if (place && "after" in place)
266
- op.after = place.after;
290
+ op.after = idOf(place.after, "after");
267
291
  this.#ops.push(op);
268
292
  }
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 = {}) {
293
+ /** 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). */
294
+ despawn(entity, opts = {}) {
295
+ const id = idOf(entity);
271
296
  const cascade = opts.cascade ?? false;
272
297
  if (this.#spawnIndex(id) >= 0 && (cascade || this.#spawnedUnder(id).length === 0)) {
273
298
  this.#cancel(id);
@@ -275,8 +300,8 @@ export class Txn {
275
300
  }
276
301
  this.#ops.push({ op: "despawn", id, cascade });
277
302
  }
278
- /** Locks or unlocks `id` (a flags-only patch, which a lock lets through). */
279
- setLocked(id, locked) { this.patch(id, { flags: { locked } }); }
303
+ /** Locks or unlocks the entity (a flags-only patch, which a lock lets through). */
304
+ setLocked(entity, locked) { this.patch(entity, { flags: { locked } }); }
280
305
  /** Drops this batch's spawn of `id`, every op naming it, and the spawns under it. */
281
306
  #cancel(id) {
282
307
  const children = this.#spawnedUnder(id);
@@ -313,7 +338,8 @@ export function foldAuthored(authored, op, t, by) {
313
338
  const e = authored.get(op.id);
314
339
  if (!e)
315
340
  return [];
316
- authored.set(op.id, stamped(mergeSet(e, op.set), op.rev));
341
+ // An unchanged component keeps its object, so a view of the authored entity stays quiet on it.
342
+ authored.set(op.id, replaceEqualDeep(e, stamped(mergeSet(e, op.set), op.rev)));
317
343
  return [op.id];
318
344
  }
319
345
  case "reparent": {
@@ -336,34 +362,6 @@ export function foldAuthored(authored, op, t, by) {
336
362
  }
337
363
  return [];
338
364
  }
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
365
  const list = (v) => (Array.isArray(v) ? v : []);
368
366
  /** 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
367
  function putLanes(table, f) {
@@ -393,6 +391,20 @@ export function liveTableOf(k) {
393
391
  putLanes(t, k);
394
392
  return t;
395
393
  }
394
+ /** The authored or calibration entity named `name` (live overrides never count). */
395
+ export function authoredNamed(authored, name) {
396
+ for (const e of authored.values())
397
+ if (e.name === name)
398
+ return e;
399
+ return undefined;
400
+ }
401
+ /** The id of the live entity named `name` (a lane-only row carries no name). */
402
+ export function liveNamed(live, name) {
403
+ for (const [id, r] of live)
404
+ if (r.entity?.name === name)
405
+ return id;
406
+ return undefined;
407
+ }
396
408
  /** 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
409
  export function foldLive(table, d) {
398
410
  const ids = [];
@@ -471,7 +483,7 @@ export class Mirror {
471
483
  #tree = [];
472
484
  #treeStale = false;
473
485
  #listeners = new Set();
474
- #sinks = { changes: new Set(), removals: new Set(), denied: new Set(), states: new Set() };
486
+ #sinks = { removals: new Set(), denied: new Set(), states: new Set() };
475
487
  subscribe(l) { this.#listeners.add(l); return () => { this.#listeners.delete(l); }; }
476
488
  #notify() { for (const l of [...this.#listeners]) {
477
489
  try {
@@ -508,8 +520,6 @@ export class Mirror {
508
520
  this.state = s;
509
521
  this.#send("states", s);
510
522
  }
511
- #emitChanges(ops) { if (ops.length)
512
- this.#send("changes", ops); }
513
523
  /** The merged reads of `ids` recomputed. */
514
524
  #refresh(ids) {
515
525
  let merged;
@@ -543,20 +553,18 @@ export class Mirror {
543
553
  this.merged = replaceEqualDeep(this.merged, merged);
544
554
  this.#treeStale = true;
545
555
  }
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`). */
556
+ /** A snapshot of incarnation `epoch` replaces the authored copy. Not yet `attached`: the reader catches up first (`attach`). */
547
557
  applySnapshot(epoch, s) {
548
558
  if (this.state === "closed")
549
559
  return;
550
560
  // An entity equal to the one held keeps its object, so a view of the authored entity stays quiet.
551
561
  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
562
  this.authored = next;
554
563
  this.version = s.version;
555
564
  this.hash = s.hash;
556
565
  this.epoch = epoch;
557
566
  this.gap = false;
558
567
  this.#rebuild();
559
- this.#emitChanges(ops);
560
568
  this.#notify();
561
569
  }
562
570
  /** The reader has applied everything this node held when it bound the incarnation. */
@@ -564,7 +572,7 @@ export class Mirror {
564
572
  this.#setState("attached");
565
573
  this.#notify();
566
574
  } }
567
- /** One applied batch: its ops folded into the authored copy; the feeds get them as they came. */
575
+ /** One applied batch: its ops folded into the authored copy. */
568
576
  applyDiff(d, warn) {
569
577
  if (this.epoch === undefined || this.state === "closed")
570
578
  return;
@@ -578,7 +586,6 @@ export class Mirror {
578
586
  this.version = d.version;
579
587
  this.hash = d.hash;
580
588
  this.#refresh(touched);
581
- this.#emitChanges(d.ops);
582
589
  this.#notify();
583
590
  }
584
591
  /** A keyframe replaces the live table. */
@@ -610,11 +617,10 @@ export class Mirror {
610
617
  denied(ids) { if (this.state !== "closed")
611
618
  this.#send("denied", ids); }
612
619
  setPending(n) { this.pending = n; this.#notify(); }
613
- /** No incarnation: the copy empties (the change feeds see the despawns), `detached`. */
620
+ /** No incarnation: the copy empties, `detached`. */
614
621
  detach() {
615
622
  if (this.state === "closed")
616
623
  return;
617
- const ops = netDifference(this.authored, new Map());
618
624
  this.authored = new Map();
619
625
  this.live = new Map();
620
626
  this.merged = EMPTY;
@@ -624,7 +630,6 @@ export class Mirror {
624
630
  this.hash = "";
625
631
  this.gap = false;
626
632
  this.#setState("detached");
627
- this.#emitChanges(ops);
628
633
  this.#notify();
629
634
  }
630
635
  /** The end: emptied, `closed` (`reason` recorded), every feed ended. */
@@ -645,7 +650,7 @@ export class Mirror {
645
650
  // --------------------------------------------------------------------------------------- views
646
651
  /**
647
652
  * 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()`),
653
+ * changes). It follows the mirror only while someone listens (`onChange`, a pending `changed()`, a `watch()` loop),
649
654
  * and while it does it is in `active`, so the handle's close can end it.
650
655
  */
651
656
  class MirrorView {
@@ -654,9 +659,12 @@ class MirrorView {
654
659
  active;
655
660
  ended;
656
661
  #listeners = new Set();
662
+ /** Pending `changed()` calls, each with the value it was armed on: it resolves on the first value that differs from it. */
657
663
  #waiting = [];
658
664
  #unmirror;
659
665
  #last;
666
+ /** `end()` ran (it runs once). */
667
+ #over = false;
660
668
  constructor(mirror, read, active, ended) {
661
669
  this.mirror = mirror;
662
670
  this.read = read;
@@ -664,18 +672,21 @@ class MirrorView {
664
672
  this.ended = ended;
665
673
  }
666
674
  get() { return this.read(); }
667
- subscribe(listener) {
675
+ /** `cb` with the value on each change, and once more when the view ends; returns the remover. */
676
+ onChange(cb) {
668
677
  if (this.ended())
669
678
  return () => { };
670
- this.#listeners.add(listener);
679
+ this.#listeners.add(cb);
671
680
  this.#follow();
672
- return () => { this.#listeners.delete(listener); this.#unfollow(); };
681
+ return () => { this.#listeners.delete(cb); this.#unfollow(); };
673
682
  }
683
+ /** The value after each change; ends with the handle. */
684
+ watch() { return watchState(cb => this.onChange(cb), () => this.get(), this.ended); }
674
685
  changed() {
675
686
  if (this.ended())
676
687
  return Promise.resolve(this.read());
677
688
  const d = deferred();
678
- this.#waiting.push(d);
689
+ this.#waiting.push({ d, armedOn: this.read() });
679
690
  this.#follow();
680
691
  return d.promise;
681
692
  }
@@ -694,6 +705,11 @@ class MirrorView {
694
705
  this.active.delete(this);
695
706
  }
696
707
  #update() {
708
+ // The node ended (the mirror closed): the view ends with it.
709
+ if (this.ended()) {
710
+ this.end();
711
+ return;
712
+ }
697
713
  let v;
698
714
  try {
699
715
  v = this.read();
@@ -705,20 +721,28 @@ class MirrorView {
705
721
  if (v === this.#last)
706
722
  return;
707
723
  this.#last = v;
724
+ // The waits this change answers are taken before the listeners run: a `changed()` a listener arms waits for the next change.
725
+ // 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.
726
+ const waiting = this.#waiting.splice(0);
727
+ this.#waiting.push(...waiting.filter(w => w.armedOn === v));
708
728
  for (const l of [...this.#listeners]) {
709
729
  try {
710
- l();
730
+ l(v);
711
731
  }
712
732
  catch (e) {
713
733
  rethrowLater(e);
714
734
  }
715
735
  }
716
- for (const d of this.#waiting.splice(0))
717
- d.resolve(v);
736
+ for (const w of waiting)
737
+ if (w.armedOn !== v)
738
+ w.d.resolve(v);
718
739
  this.#unfollow();
719
740
  }
720
- /** The handle closed: a pending `changed()` resolves with the value now; listeners are dropped. */
741
+ /** 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
742
  end() {
743
+ if (this.#over)
744
+ return;
745
+ this.#over = true;
722
746
  let v;
723
747
  try {
724
748
  v = this.read();
@@ -726,12 +750,73 @@ class MirrorView {
726
750
  catch {
727
751
  v = this.#last;
728
752
  }
729
- for (const d of this.#waiting.splice(0))
730
- d.resolve(v);
753
+ for (const w of this.#waiting.splice(0))
754
+ w.d.resolve(v);
755
+ for (const l of [...this.#listeners]) {
756
+ try {
757
+ l(v);
758
+ }
759
+ catch (e) {
760
+ rethrowLater(e);
761
+ }
762
+ }
731
763
  this.#listeners.clear();
732
764
  this.#unfollow();
733
765
  }
734
766
  }
767
+ /** `e`, or its `component` (`undefined` when `e` is). */
768
+ const pick = (e, component) => component === undefined ? e : e === undefined ? undefined : e[component];
769
+ /**
770
+ * One entity as a view (`scene.entity(id | { name })`): the `SceneView` of the merged entity with the store handle's
771
+ * members, and the entity's two planes (`authored`, `live`), frozen objects made once with the view. `read` picks the
772
+ * merged entity for the id the view names now; `ops.write` patches the authored entity the view names at the call,
773
+ * `ops.live` writes its live lanes, `ops.remove` drops its live row.
774
+ */
775
+ class EntityView extends MirrorView {
776
+ ops;
777
+ authored;
778
+ live;
779
+ constructor(mirror, read, active, ended, ops) {
780
+ super(mirror, read, active, ended);
781
+ this.ops = ops;
782
+ this.authored = Object.freeze({
783
+ get: ((component) => pick(ops.readAuthored(), component)),
784
+ set: ((a, b) => ops.write(typeof a === "string" ? { [a]: b } : a)),
785
+ });
786
+ this.live = Object.freeze({
787
+ get: ((component) => pick(ops.readLive(), component)),
788
+ set: (components, opts = {}) => ops.live(components, opts),
789
+ remove: () => ops.remove(),
790
+ });
791
+ }
792
+ get id() { return this.ops.id(); }
793
+ get(component) { return pick(this.read(), component); }
794
+ /**
795
+ * The entity (or `component` of it) after each change; changes in between fold into the latest. A component is
796
+ * compared by reference: the mirror keeps an unchanged component's object across a change elsewhere (structural
797
+ * sharing), so only a change of that component wakes it.
798
+ */
799
+ watch(component) {
800
+ return watchState(cb => this.onChange(cb), () => this.get(component), this.ended);
801
+ }
802
+ }
803
+ /** 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. */
804
+ function release(node, session, id) {
805
+ if (!session)
806
+ return;
807
+ try {
808
+ void startOn(node, session, "synapse/scene/live/remove", { id }, {}).result.catch(() => { });
809
+ }
810
+ catch { /* the session's end releases it */ }
811
+ }
812
+ /** The id a claim's reply names. */
813
+ function replyId(v) {
814
+ const id = v?.id;
815
+ if (typeof id !== "string")
816
+ throw new SynapseError("internal", "scene live claim reply not understood");
817
+ return id;
818
+ }
819
+ const sessionGone = (e) => e.code === "connection_lost" || e.code === "not_connected";
735
820
  function openLiveWriter(s) {
736
821
  const bc = s.createBroadcast(`${s.prefix}/scene/live`);
737
822
  // Kept as long as a call track keeps its groups (the runtime's follower reads every batch).
@@ -748,6 +833,82 @@ function closeLiveWriter(w) {
748
833
  }
749
834
  catch { /* closed */ }
750
835
  }
836
+ /** A live batch's wire bytes. */
837
+ function encodeLive(body) {
838
+ let bytes;
839
+ try {
840
+ bytes = encodeBody(body);
841
+ }
842
+ catch (e) {
843
+ throw new SynapseError("invalid_input", `live batch not encodable: ${e instanceof Error ? e.message : String(e)}`);
844
+ }
845
+ if (bytes.length > MAX_FRAME_BYTES)
846
+ throw frameTooLarge("scene live batch", bytes.length);
847
+ return bytes;
848
+ }
849
+ /** One frame on the live writer. */
850
+ function writeLive(w, bytes) {
851
+ const header = { flags: 0, type: TYPES.PUB, seq: w.seq, tUs: nowUs() };
852
+ w.seq = (w.seq + 1) >>> 0;
853
+ try {
854
+ w.stream.write(header, bytes);
855
+ }
856
+ catch (e) {
857
+ throw new SynapseError("connection_lost", { message: e instanceof Error ? e.message : String(e), source: "transport", cause: e });
858
+ }
859
+ }
860
+ /** Session `s`'s live writer: the current one, or a new one when that is another session's. */
861
+ function liveWriterOn(scene, s) {
862
+ let w = scene.writer;
863
+ if (w && w.session === s)
864
+ return w;
865
+ closeLiveWriter(w);
866
+ try {
867
+ w = scene.writer = openLiveWriter(s);
868
+ }
869
+ catch (e) {
870
+ scene.writer = undefined;
871
+ throw new SynapseError("internal", `scene live track not made: ${e instanceof Error ? e.message : String(e)}`);
872
+ }
873
+ return w;
874
+ }
875
+ const UNCLAIMED = { kind: "unclaimed" }, ASKING = { kind: "asking" }, CLAIMED = { kind: "claimed" };
876
+ const parkedRows = (p) => (p.pose ? 1 : 0) + (p.joints ? 1 : 0) + (p.state ? 1 : 0);
877
+ /** One of this node's claims: its own intent, never world state. */
878
+ class Claim {
879
+ args;
880
+ state;
881
+ /** Refusals of it in a row (the pace of the next ask). */
882
+ refusals = 0;
883
+ parked = {};
884
+ /** `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. */
885
+ constructor(args, state) {
886
+ this.args = args;
887
+ this.state = state;
888
+ }
889
+ /** A live entity (claimed again on every attach), not an override (claimed by its lanes). */
890
+ get isEntity() { return Object.keys(this.args).length > 0; }
891
+ }
892
+ /** 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. */
893
+ function timerDue(c) {
894
+ const st = c.state;
895
+ return st.kind === "paced" && (c.isEntity || (!st.refused && parkedRows(c.parked) > 0)) ? st.at : undefined;
896
+ }
897
+ /** A lane batch on the wire: data only (stamps are the runtime's). */
898
+ function laneBody(poses, joints, state, lifetimeMs) {
899
+ const body = {};
900
+ if (poses.length)
901
+ body.poses = poses.map(p => ({ id: p.id, t: p.t, q: p.q }));
902
+ if (joints.length)
903
+ body.joints = joints.map(r => ({ id: r.id, j: r.j }));
904
+ if (state.length)
905
+ body.state = state.map(r => ({ id: r.id, s: r.s }));
906
+ if (!Object.keys(body).length)
907
+ return undefined;
908
+ if (lifetimeMs !== undefined)
909
+ body.lifetime_ms = lifetimeMs;
910
+ return body;
911
+ }
751
912
  /** The node's scene: what every handle shares with the task. */
752
913
  export class Scene {
753
914
  node;
@@ -758,14 +919,52 @@ export class Scene {
758
919
  /** Handles neither closed nor dropped; the one that brings it to 0 tells the task `close`. */
759
920
  handles = 0;
760
921
  nextHandle = 0;
761
- /** The live writer of the current session, made on the first `live()`. */
922
+ /** The live writer of the current session, made on the first live write. */
762
923
  writer;
924
+ /** This node's claims by id (what `live.upsert` made and what lane writes asked for); `live.remove` drops one. Nothing else is kept. */
925
+ claims = new Map();
763
926
  detachedDrops = 0;
927
+ /** Lane rows dropped because their id's claim was refused (and not due again), or parked rows a refusal dropped. */
928
+ unclaimedDrops = 0;
929
+ /** Told when a counter of the status changes (the listeners of the status state values). */
930
+ counters = new Set();
764
931
  constructor(node, warn) {
765
932
  this.node = node;
766
933
  this.warn = warn;
767
934
  }
768
935
  post(e) { return this.mailbox.push(e); }
936
+ countersChanged() { for (const l of [...this.counters]) {
937
+ try {
938
+ l();
939
+ }
940
+ catch (e) {
941
+ rethrowLater(e);
942
+ }
943
+ } }
944
+ /**
945
+ * What happens to a lane row of `id` now. An id with no claim becomes the override's claim; an override that is
946
+ * unclaimed, or refused and due again, is marked asked and returned in `ask` (the task asks it: one ask per id at a
947
+ * time). A live entity's claim is asked by the task (on attach, paced after a refusal), never by a lane.
948
+ */
949
+ gate(id, now, ask) {
950
+ let c = this.claims.get(id);
951
+ if (!c)
952
+ this.claims.set(id, (c = new Claim({}, UNCLAIMED)));
953
+ const st = c.state;
954
+ if (st.kind === "claimed")
955
+ return "send";
956
+ if (st.kind === "asking")
957
+ return "park";
958
+ if (st.kind === "unclaimed" && c.isEntity)
959
+ return "park";
960
+ if (st.kind === "paced" && !st.refused)
961
+ return "park";
962
+ if (st.kind === "paced" && (c.isEntity || st.at > now))
963
+ return "drop";
964
+ c.state = ASKING;
965
+ ask.push(id);
966
+ return "park";
967
+ }
769
968
  request(make) {
770
969
  const reply = deferred();
771
970
  if (!this.post(make(reply)))
@@ -778,6 +977,16 @@ const detachedError = () => new SynapseError("project_inactive", "the scene is d
778
977
  function closedError(reason) {
779
978
  return new SynapseError("not_connected", reason === ENDED_CONNECTION_LOST ? "the node ended" : "the scene handle is closed");
780
979
  }
980
+ /** A view by name whose name no entity holds: nothing to send to (`not_found`, as the runtime answers an unknown id). */
981
+ function notNamed(ref) {
982
+ const name = typeof ref === "string" ? undefined : ref.name;
983
+ return new SynapseError("not_found", { message: `scene: no entity is named ${JSON.stringify(name)}`, data: { name } });
984
+ }
985
+ /** An `upsert` is keyed by its name: an `id` component (the type forbids it; JavaScript does not) is `invalid_input`, nothing sent. */
986
+ function refuseId(opts, verb) {
987
+ if ("id" in opts)
988
+ throw new SynapseError("invalid_input", { message: `${verb} is keyed by its name: an id component is not taken`, data: { component: "id" } });
989
+ }
781
990
  const asError = (e) => (e instanceof SynapseError ? e : new SynapseError("internal", String(e)));
782
991
  export function startScene(scene, registry) { void run(scene, registry); }
783
992
  async function run(scene, registry) {
@@ -793,6 +1002,23 @@ async function run(scene, registry) {
793
1002
  let unwatchProject;
794
1003
  const joining = [];
795
1004
  let waiters = [];
1005
+ /** The attach the claims are made for (session and incarnation), if any. */
1006
+ let bound;
1007
+ /** Counts attaches: an answer to a claim asked in an earlier one is not this one's. */
1008
+ let bindNo = 0;
1009
+ /** The ids whose claim is being asked again (one ask per id at a time). */
1010
+ const asking = new Set();
1011
+ /** `live.upsert` calls waiting for an attach (attached a moment before the task was told, or their session went before the answer). */
1012
+ let parkedUpserts = [];
1013
+ /**
1014
+ * The `live.upsert` calls on the wire, by token: the id each named (a claim of that name this node held), and whether its
1015
+ * 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
1016
+ * released, never kept).
1017
+ */
1018
+ const firsts = new Map();
1019
+ let nextFirst = 0;
1020
+ let reclaimTimer;
1021
+ let reclaimAt = Infinity;
796
1022
  const inactive = () => project !== undefined && project.active === null;
797
1023
  const answerJoins = () => { for (const j of joining.splice(0))
798
1024
  j.resolve(); };
@@ -822,13 +1048,14 @@ async function run(scene, registry) {
822
1048
  const attempt = attempts;
823
1049
  const stop = new AbortController();
824
1050
  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 }));
1051
+ void followIncarnation(scene, s, announced, endedEpoch, stop.signal, attempt).then(r => scene.post({ kind: "followEnded", attempt, epoch: r.epoch, reason: r.reason }));
826
1052
  };
827
1053
  /** The incarnation is let go: the mirror empties, writes waiting to show fail, followed again after `paceMs`. */
828
1054
  const again = (paceMs) => {
829
1055
  failWaiters(() => new SynapseError("conflict", "the scene's incarnation ended before the write showed"));
830
1056
  stopPhase();
831
1057
  mirror.detach();
1058
+ unbind();
832
1059
  const a = ++attempts;
833
1060
  phase = { kind: "paced", timer: setTimeout(() => scene.post({ kind: "paced", attempt: a }), paceMs) };
834
1061
  };
@@ -838,6 +1065,7 @@ async function run(scene, registry) {
838
1065
  stopPhase();
839
1066
  failWaiters(() => new SynapseError("connection_lost", "the session ended before the write showed in the scene"));
840
1067
  mirror.detach();
1068
+ unbind();
841
1069
  closeLiveWriter(scene.writer);
842
1070
  scene.writer = undefined;
843
1071
  unwatchProject?.();
@@ -849,13 +1077,317 @@ async function run(scene, registry) {
849
1077
  answerJoins();
850
1078
  follow();
851
1079
  };
1080
+ /** The reader applied an authored frame of `epoch` reaching `version`: the writes it shows are answered; attached, the `scene()` calls waiting too. */
852
1081
  const applied = (epoch, version) => {
853
1082
  const reached = waiters.filter(w => w.epoch === epoch && w.version <= version);
854
1083
  waiters = waiters.filter(w => !reached.includes(w));
855
1084
  for (const w of reached)
856
1085
  w.reply.resolve();
857
- if (mirror.state === "attached")
858
- answerJoins();
1086
+ if (mirror.state !== "attached")
1087
+ return;
1088
+ answerJoins();
1089
+ };
1090
+ /**
1091
+ * Reading `attempt` (the one in progress) caught up with incarnation `epoch` at `version`: the claims are made for it
1092
+ * first (`bind`), then the handle is `attached` (one path: nothing a handle does after it sees `attached` meets an
1093
+ * unbound attach). A stale reader's attach is ignored.
1094
+ */
1095
+ const attach = (attempt, epoch, version) => {
1096
+ const p = ph();
1097
+ if (p.kind !== "following" || p.attempt !== attempt || mirror.epoch !== epoch || !session)
1098
+ return;
1099
+ if (bound?.session !== session || bound.epoch !== epoch)
1100
+ bind(session, epoch);
1101
+ mirror.attach();
1102
+ applied(epoch, version);
1103
+ };
1104
+ // ---- claims (PLAN v0.31)
1105
+ /** 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. */
1106
+ const unbind = () => {
1107
+ bound = undefined;
1108
+ bindNo += 1;
1109
+ asking.clear();
1110
+ // Rows parked for the attach that ended are not this one's data.
1111
+ for (const c of scene.claims.values()) {
1112
+ c.state = UNCLAIMED;
1113
+ c.parked = {};
1114
+ }
1115
+ };
1116
+ /** 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. */
1117
+ const bind = (s, epoch) => {
1118
+ unbind();
1119
+ bound = { session: s, epoch };
1120
+ for (const [id, c] of [...scene.claims])
1121
+ if (c.isEntity)
1122
+ askAgain(id);
1123
+ const waiting = parkedUpserts;
1124
+ parkedUpserts = [];
1125
+ for (const u of waiting)
1126
+ askFirst(u.args, u.reply);
1127
+ };
1128
+ /** Starts the claim call with `args` in this attach; its answer comes back as `claimDone`. Returns the error when it could not be sent. */
1129
+ const callClaim = (ask, args) => {
1130
+ if (!session || !bound)
1131
+ return detachedError();
1132
+ let call;
1133
+ try {
1134
+ call = startOn(node, session, "synapse/scene/live/upsert", args, {});
1135
+ }
1136
+ catch (e) {
1137
+ return asError(e);
1138
+ }
1139
+ const b = bindNo;
1140
+ const s = session;
1141
+ const done = (result) => {
1142
+ if (scene.post({ kind: "claimDone", ask, bind: b, result }))
1143
+ return;
1144
+ // The task is gone (the handle closed): a grant is released, never kept; a `live.upsert` caller is answered from here.
1145
+ if ("ok" in result) {
1146
+ try {
1147
+ release(node, s, replyId(result.ok));
1148
+ }
1149
+ catch { /* not understood: nothing to release */ }
1150
+ }
1151
+ if (ask.kind === "first")
1152
+ ask.reply.reject(closedError(ENDED_CLIENT));
1153
+ };
1154
+ void call.result.then(v => done({ ok: v }), (e) => done({ err: asError(e) }));
1155
+ return undefined;
1156
+ };
1157
+ /** Asks the claim of `id` again with its args (one ask per id at a time). */
1158
+ const askAgain = (id) => {
1159
+ if (!bound || asking.has(id))
1160
+ return;
1161
+ const c = scene.claims.get(id);
1162
+ if (!c)
1163
+ return;
1164
+ c.state = ASKING;
1165
+ asking.add(id);
1166
+ if (callClaim({ kind: "again", id }, { ...c.args, id })) {
1167
+ // Not sent: asked again at the next attach.
1168
+ asking.delete(id);
1169
+ const now = scene.claims.get(id);
1170
+ if (now)
1171
+ now.state = UNCLAIMED;
1172
+ }
1173
+ };
1174
+ /** 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). */
1175
+ const askFirst = (args, reply) => {
1176
+ const callArgs = { ...args };
1177
+ let named;
1178
+ if (args.name !== undefined)
1179
+ for (const [id, c] of scene.claims)
1180
+ if (c.args.name === args.name) {
1181
+ callArgs.id = named = id;
1182
+ break;
1183
+ }
1184
+ const token = nextFirst++;
1185
+ firsts.set(token, { named, keep: true });
1186
+ const e = callClaim({ kind: "first", args, reply, token }, callArgs);
1187
+ if (e) {
1188
+ firsts.delete(token);
1189
+ reply.reject(e);
1190
+ }
1191
+ };
1192
+ const upsert = (args, reply) => {
1193
+ if (mirror.state === "closed")
1194
+ reply.reject(closedError(mirror.ended ?? ENDED_CLIENT));
1195
+ else if (mirror.state === "detached")
1196
+ reply.reject(detachedError());
1197
+ // Attached: the claims are bound (the task attaches the handle after the bind: one path).
1198
+ else
1199
+ askFirst(args, reply);
1200
+ };
1201
+ /** A lane write asked the claim of `id`: asked now when bound; otherwise unclaimed (the next lane write after the attach asks it). */
1202
+ const claimAsked = (id) => {
1203
+ if (bound) {
1204
+ askAgain(id);
1205
+ return;
1206
+ }
1207
+ const c = scene.claims.get(id);
1208
+ if (c?.state.kind === "asking")
1209
+ c.state = UNCLAIMED;
1210
+ };
1211
+ /** `live.remove`: the claim dropped; the runtime asked to release it when this attach holds it (or asks it), else answered at once. */
1212
+ const remove = (id, reply) => {
1213
+ const c = scene.claims.get(id);
1214
+ scene.claims.delete(id);
1215
+ asking.delete(id);
1216
+ // A `live.upsert` of this id on the wire: its answer is not kept (granted, it is released).
1217
+ for (const f of firsts.values())
1218
+ if (f.named === id)
1219
+ f.keep = false;
1220
+ const held = !c || c.state.kind === "claimed" || c.state.kind === "asking";
1221
+ if (!session || !bound || !held) {
1222
+ reply.resolve();
1223
+ return;
1224
+ }
1225
+ let call;
1226
+ try {
1227
+ call = startOn(node, session, "synapse/scene/live/remove", { id }, {});
1228
+ }
1229
+ catch (e) {
1230
+ reply.reject(asError(e));
1231
+ return;
1232
+ }
1233
+ void call.result.then(() => reply.resolve(), (e) => reply.reject(asError(e)));
1234
+ };
1235
+ /** The rows parked for `id` go out now, as one batch. */
1236
+ const sendParked = (id) => {
1237
+ const c = scene.claims.get(id);
1238
+ if (!c || !session)
1239
+ return;
1240
+ const p = c.parked;
1241
+ c.parked = {};
1242
+ const body = laneBody(p.pose ? [p.pose] : [], p.joints ? [p.joints] : [], p.state ? [p.state] : [], p.lifetimeMs);
1243
+ if (!body)
1244
+ return;
1245
+ // Not sent (the session is going): its end resets the claims, the rows are not re-sent (a batch is never re-sent).
1246
+ try {
1247
+ writeLive(liveWriterOn(scene, session), encodeLive(body));
1248
+ }
1249
+ catch { /* the session's end follows */ }
1250
+ };
1251
+ /** The runtime answered a claim asked in attach `b`. */
1252
+ const claimDone = (ask, b, result) => {
1253
+ if (ask.kind === "first") {
1254
+ let r;
1255
+ if ("ok" in result) {
1256
+ try {
1257
+ r = { ok: replyId(result.ok) };
1258
+ }
1259
+ catch (e) {
1260
+ r = { err: asError(e) };
1261
+ }
1262
+ }
1263
+ else
1264
+ r = result;
1265
+ const f = firsts.get(ask.token);
1266
+ firsts.delete(ask.token);
1267
+ const keep = (f?.keep ?? false) && mirror.state !== "closed";
1268
+ if ("ok" in r && !keep) {
1269
+ // Not kept: a `live.remove` of its id or the handle's close came after it. A grant is released; nothing is kept.
1270
+ release(node, session, r.ok);
1271
+ if (mirror.state === "closed")
1272
+ ask.reply.reject(closedError(mirror.ended ?? ENDED_CLIENT));
1273
+ else
1274
+ ask.reply.resolve(r.ok);
1275
+ }
1276
+ else if ("ok" in r) {
1277
+ // Granted in an earlier attach: claimed again in this one.
1278
+ const here = b === bindNo;
1279
+ scene.claims.set(r.ok, new Claim(ask.args, here ? CLAIMED : UNCLAIMED));
1280
+ if (!here)
1281
+ askAgain(r.ok);
1282
+ ask.reply.resolve(r.ok);
1283
+ }
1284
+ else if (sessionGone(r.err)) {
1285
+ // The session went before the runtime answered: asked on the next attach (unless no longer wanted).
1286
+ if (!keep)
1287
+ ask.reply.reject(r.err);
1288
+ else if (b !== bindNo && bound)
1289
+ askFirst(ask.args, ask.reply);
1290
+ else
1291
+ parkedUpserts.push({ args: ask.args, reply: ask.reply });
1292
+ }
1293
+ else
1294
+ ask.reply.reject(r.err);
1295
+ return;
1296
+ }
1297
+ if (b !== bindNo)
1298
+ return;
1299
+ const id = ask.id;
1300
+ asking.delete(id);
1301
+ const c = scene.claims.get(id);
1302
+ if (!c)
1303
+ return;
1304
+ if ("ok" in result) {
1305
+ c.state = CLAIMED;
1306
+ c.refusals = 0;
1307
+ // The rows parked while it was asked go out now.
1308
+ sendParked(id);
1309
+ return;
1310
+ }
1311
+ const e = result.err;
1312
+ if (sessionGone(e)) {
1313
+ c.state = UNCLAIMED;
1314
+ return;
1315
+ }
1316
+ // The runtime is mid-change: asked again shortly, silently (rows keep parking).
1317
+ if (isTransient(e)) {
1318
+ c.state = { kind: "paced", at: performance.now() + REFOLLOW_PACE_MS, refused: false };
1319
+ return;
1320
+ }
1321
+ c.refusals += 1;
1322
+ const pace = refusalPaceMs(c.refusals);
1323
+ 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)`);
1324
+ c.state = { kind: "paced", at: performance.now() + pace, refused: true };
1325
+ const dropped = parkedRows(c.parked);
1326
+ c.parked = {};
1327
+ if (dropped) {
1328
+ scene.unclaimedDrops += dropped;
1329
+ scene.countersChanged();
1330
+ }
1331
+ // An override's refusal is the writer's to see: a node notice (a `not_owner` reaches the denial feeds through it).
1332
+ if (!c.isEntity) {
1333
+ const body = { code: e.code };
1334
+ if (e.message)
1335
+ body.message = e.message;
1336
+ if (e.data !== undefined)
1337
+ body.data = e.data;
1338
+ for (const cb of [...node.notices]) {
1339
+ try {
1340
+ cb(body);
1341
+ }
1342
+ catch (x) {
1343
+ rethrowLater(x);
1344
+ }
1345
+ }
1346
+ }
1347
+ };
1348
+ /** 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. */
1349
+ const releaseClaims = () => {
1350
+ for (const f of firsts.values())
1351
+ f.keep = false;
1352
+ const claims = [...scene.claims];
1353
+ scene.claims.clear();
1354
+ if (!session || !bound)
1355
+ return;
1356
+ for (const [id, c] of claims) {
1357
+ if (c.state.kind !== "claimed" && c.state.kind !== "asking")
1358
+ continue;
1359
+ try {
1360
+ void startOn(node, session, "synapse/scene/live/remove", { id }, {}).result.catch(() => { });
1361
+ }
1362
+ catch { /* the session's end releases it */ }
1363
+ }
1364
+ };
1365
+ /** The refused live entities that are due are asked again. */
1366
+ const reclaimDue = () => {
1367
+ const now = performance.now();
1368
+ for (const [id, c] of [...scene.claims]) {
1369
+ const at = timerDue(c);
1370
+ if (at !== undefined && at <= now)
1371
+ askAgain(id);
1372
+ }
1373
+ };
1374
+ /** The timer for the next refused live entity due to be asked again (none while unbound or nothing paced). */
1375
+ const armReclaim = () => {
1376
+ let at = Infinity;
1377
+ if (bound)
1378
+ for (const c of scene.claims.values()) {
1379
+ const due = timerDue(c);
1380
+ if (due !== undefined && due < at)
1381
+ at = due;
1382
+ }
1383
+ if (at === reclaimAt)
1384
+ return;
1385
+ if (reclaimTimer !== undefined)
1386
+ clearTimeout(reclaimTimer);
1387
+ reclaimTimer = undefined;
1388
+ reclaimAt = at;
1389
+ if (at !== Infinity)
1390
+ reclaimTimer = setTimeout(() => { reclaimAt = Infinity; reclaimTimer = undefined; scene.post({ kind: "reclaim" }); }, Math.max(0, at - performance.now()));
859
1391
  };
860
1392
  const join = (reply) => { if (mirror.state === "attached" || inactive())
861
1393
  reply.resolve();
@@ -957,6 +1489,9 @@ async function run(scene, registry) {
957
1489
  case "applied":
958
1490
  applied(ev.epoch, ev.version);
959
1491
  break;
1492
+ case "attach":
1493
+ attach(ev.attempt, ev.epoch, ev.version);
1494
+ break;
960
1495
  case "followEnded": {
961
1496
  {
962
1497
  const p = ph();
@@ -993,11 +1528,33 @@ async function run(scene, registry) {
993
1528
  follow();
994
1529
  }
995
1530
  break;
1531
+ case "upsert":
1532
+ upsert(ev.args, ev.reply);
1533
+ break;
1534
+ case "remove":
1535
+ remove(ev.id, ev.reply);
1536
+ break;
1537
+ case "claim":
1538
+ claimAsked(ev.id);
1539
+ break;
1540
+ case "claimDone":
1541
+ claimDone(ev.ask, ev.bind, ev.result);
1542
+ break;
1543
+ case "reclaim":
1544
+ reclaimDue();
1545
+ break;
996
1546
  case "close": break loop;
997
1547
  }
1548
+ armReclaim();
998
1549
  }
999
1550
  }
1000
1551
  finally {
1552
+ // The last handle closed (or the node ended): this node's claims are released (best effort).
1553
+ releaseClaims();
1554
+ if (reclaimTimer !== undefined)
1555
+ clearTimeout(reclaimTimer);
1556
+ for (const u of parkedUpserts.splice(0))
1557
+ u.reply.reject(closedError(reason));
1001
1558
  unmonitor();
1002
1559
  node.notices.delete(onNotice);
1003
1560
  unwatchProject?.();
@@ -1016,13 +1573,18 @@ async function run(scene, registry) {
1016
1573
  break;
1017
1574
  if (ev.kind === "applyDone")
1018
1575
  applyDone(ev.epoch, ev.result, ev.reply);
1019
- else if (ev.kind === "apply" || ev.kind === "join")
1576
+ else if (ev.kind === "apply" || ev.kind === "join" || ev.kind === "upsert" || ev.kind === "remove")
1020
1577
  ev.reply.reject(closedError(reason));
1578
+ else if (ev.kind === "claimDone")
1579
+ claimDone(ev.ask, ev.bind, ev.result); // a grant arriving now is released (`releaseClaims` marked every first not kept)
1021
1580
  }
1022
1581
  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")
1582
+ for (let ev = scene.mailbox.tryNext(); ev; ev = scene.mailbox.tryNext()) {
1583
+ if (ev.kind === "apply" || ev.kind === "join" || ev.kind === "applyDone" || ev.kind === "upsert" || ev.kind === "remove")
1025
1584
  ev.reply.reject(closedError(reason));
1585
+ else if (ev.kind === "claimDone")
1586
+ claimDone(ev.ask, ev.bind, ev.result); // a grant arriving now is released (`releaseClaims` marked every first not kept)
1587
+ }
1026
1588
  if (registry.current === scene)
1027
1589
  registry.current = undefined;
1028
1590
  scene.exited.resolve();
@@ -1144,7 +1706,7 @@ function decodeFrame(p, warn, what) {
1144
1706
  * its tracks into the mirror, telling the task of each authored frame applied; returns once the
1145
1707
  * `authored` track ends (its final frame, or the track going). Once `signal` aborts it writes nothing more.
1146
1708
  */
1147
- async function followIncarnation(scene, session, announced, skip, signal) {
1709
+ async function followIncarnation(scene, session, announced, skip, signal, attempt) {
1148
1710
  const { mirror, warn } = scene;
1149
1711
  const none = { epoch: undefined, reason: undefined };
1150
1712
  const closers = [announced];
@@ -1268,8 +1830,8 @@ async function followIncarnation(scene, session, announced, skip, signal) {
1268
1830
  live.drain(onLive);
1269
1831
  if (signal.aborted)
1270
1832
  return none;
1271
- mirror.attach();
1272
- scene.post({ kind: "applied", epoch: at, version: mirror.version });
1833
+ // The task attaches the handle, once the claims are made for this incarnation.
1834
+ scene.post({ kind: "attach", attempt, epoch: at, version: mirror.version });
1273
1835
  // Then frame by frame as they come. The live track's end is the incarnation's: the authored track says how it ended.
1274
1836
  void live.readOn(onLive);
1275
1837
  await authored.readOn(onAuthored);
@@ -1316,34 +1878,180 @@ export class SceneHandleImpl {
1316
1878
  /** The views of this handle that follow the mirror now. */
1317
1879
  #views = new Set();
1318
1880
  #unsubscribes = new Set();
1881
+ /** `ready()` calls waiting for the handle to attach: rejected by `close()`. */
1882
+ #readies = new Set();
1883
+ /** Told when this handle closes (its status says `closed` from then on, whatever the node's other handles do). */
1884
+ #onClose = new Set();
1885
+ /** The authored plane's verbs. */
1886
+ authored;
1887
+ /** This node's live writes. */
1888
+ live;
1889
+ /** The handle's own state, a state value: `get()` (the same object while unchanged), `onChange`, `changed()`, `watch()`. */
1890
+ status;
1891
+ #status;
1319
1892
  constructor(scene) {
1320
1893
  this.scene = scene;
1321
1894
  scene.handles++;
1322
1895
  this.#id = scene.nextHandle++;
1896
+ this.status = this.#status = new StatusCell(() => this.#statusNow(), l => {
1897
+ const un = scene.mirror.subscribe(l);
1898
+ scene.counters.add(l);
1899
+ this.#onClose.add(l);
1900
+ return () => { un(); scene.counters.delete(l); this.#onClose.delete(l); };
1901
+ }, () => this.#closed || scene.mirror.state === "closed");
1902
+ // One object each for the handle's life.
1903
+ this.authored = Object.freeze({
1904
+ apply: (ops, base) => this.#apply(ops, base),
1905
+ txn: (build) => this.#txn(build),
1906
+ spawn: (kind, opts) => this.#spawn(kind, opts),
1907
+ patch: (entity, set) => this.#txn(t => t.patch(entity, set)),
1908
+ upsert: (kind, opts) => this.#upsert(kind, opts),
1909
+ rename: (entity, name) => this.#txn(t => t.rename(entity, name)),
1910
+ reparent: (entity, parent, place) => this.#txn(t => t.reparent(entity, parent, place)),
1911
+ despawn: (entity, opts) => this.#txn(t => t.despawn(entity, opts)),
1912
+ setLocked: (entity, locked) => this.#txn(t => t.setLocked(entity, locked)),
1913
+ });
1914
+ this.live = Object.freeze({
1915
+ batch: (batch) => this.#batch(batch),
1916
+ upsert: (kind, opts, live) => this.#liveUpsert(kind, opts ?? {}, live ?? {}),
1917
+ remove: (entity) => { try {
1918
+ return this.#releaseId(idOf(entity));
1919
+ }
1920
+ catch (e) {
1921
+ return Promise.reject(e);
1922
+ } },
1923
+ });
1323
1924
  }
1324
- get entities() { return this.scene.mirror.merged; }
1325
- entity(id, opts = {}) {
1925
+ /** The merged entities now; a closed handle reads nothing (the mirror may live on for the node's other handles). */
1926
+ get() { return this.#closed ? EMPTY : this.scene.mirror.merged; }
1927
+ /** One entity as a view: the merged entity, with its authored value (`authored`) and its live row (`live`). */
1928
+ entity(ref) {
1326
1929
  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(() => {
1930
+ const named = this.#idOf(ref);
1931
+ const id = () => {
1932
+ const id = named();
1933
+ if (id === undefined)
1934
+ throw notNamed(ref);
1935
+ return id;
1936
+ };
1937
+ const ended = () => this.#closed || mirror.state === "closed";
1938
+ // Every read goes through the same guard: a closed handle reads nothing (the mirror may live on for the node's other handles).
1939
+ const reader = (read) => () => {
1940
+ if (ended())
1941
+ return undefined;
1942
+ const id = named();
1943
+ return id === undefined ? undefined : read(id);
1944
+ };
1945
+ // The live row, rebuilt only when the row object changes and kept the same object while equal.
1946
+ let from;
1947
+ let last;
1948
+ const ops = {
1949
+ id,
1950
+ // The entity as declared (the authored one, or a live entity's upsert row): its object changes only when an
1951
+ // authored batch or a new upsert changes it, never on a lane tick.
1952
+ readAuthored: reader(id => mirror.authored.get(id) ?? mirror.live.get(id)?.entity),
1953
+ readLive: reader(id => {
1336
1954
  const row = mirror.live.get(id);
1337
1955
  if (row === from)
1338
1956
  return last;
1339
1957
  from = row;
1340
1958
  last = row ? replaceEqualDeep(last, liveValue(row)) : undefined;
1341
1959
  return last;
1342
- });
1960
+ }),
1961
+ write: components => this.#write(ref, named, components),
1962
+ live: (components, opts) => this.#liveLanes(ref, named, components, opts),
1963
+ remove: () => this.#liveRemove(ref, named),
1964
+ };
1965
+ return new EntityView(mirror, reader(id => mirror.merged[id]), this.#views, ended, ops);
1966
+ }
1967
+ /**
1968
+ * The id a view names now: its id, or the id of the entity holding its name (names are unique across every plane;
1969
+ * an authored or calibration entity, else a live entity). The last id found is checked first: a name is held by one
1970
+ * entity at a time.
1971
+ */
1972
+ #idOf(ref) {
1973
+ if (typeof ref === "string")
1974
+ return () => ref;
1975
+ if (!isObject(ref) || typeof ref.name !== "string")
1976
+ throw new SynapseError("invalid_input", "scene.entity takes an id or { name }");
1977
+ const name = ref.name, mirror = this.scene.mirror;
1978
+ let held;
1979
+ return () => {
1980
+ if (held !== undefined && (mirror.authored.get(held) ?? mirror.live.get(held)?.entity)?.name === name)
1981
+ return held;
1982
+ held = authoredNamed(mirror.authored, name)?.id ?? liveNamed(mirror.live, name);
1983
+ return held;
1984
+ };
1985
+ }
1986
+ /** 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. */
1987
+ async #write(ref, named, components) {
1988
+ if (this.#closed)
1989
+ throw closedError(ENDED_CLIENT);
1990
+ if (this.scene.mirror.state === "detached")
1991
+ throw detachedError();
1992
+ const id = named();
1993
+ if (id === undefined)
1994
+ throw notNamed(ref);
1995
+ await this.#txn(t => t.patch(id, components));
1996
+ }
1997
+ /** An entity view's `live.set`: the lanes of the id it names now, as one batch. */
1998
+ #liveLanes(ref, named, components, opts) {
1999
+ this.#liveOpen();
2000
+ const lanes = {};
2001
+ for (const [k, v] of Object.entries(components)) {
2002
+ if (v === undefined)
2003
+ continue;
2004
+ if (k === "xform") {
2005
+ const x = v;
2006
+ if (!isObject(v) || !("t" in x) || !("q" in x) || Object.keys(x).some(k => k !== "t" && k !== "q"))
2007
+ throw new SynapseError("invalid_input", "scene: live xform is the pose lane: { t, q }, both, nothing else");
2008
+ }
2009
+ else if (k !== "joints" && k !== "state")
2010
+ 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 } });
2011
+ lanes[k] = v;
2012
+ }
2013
+ const id = named();
2014
+ if (id === undefined) {
2015
+ // Detached, nothing is named: the write is dropped and counted, as every live write then is.
2016
+ if (this.scene.mirror.state !== "attached") {
2017
+ this.scene.detachedDrops++;
2018
+ this.scene.countersChanged();
2019
+ return;
2020
+ }
2021
+ throw notNamed(ref);
2022
+ }
2023
+ const batch = {};
2024
+ const x = lanes.xform;
2025
+ if (x)
2026
+ batch.poses = [{ id, t: x.t, q: x.q }];
2027
+ if (lanes.joints !== undefined)
2028
+ batch.joints = [{ id, j: lanes.joints }];
2029
+ if (lanes.state !== undefined)
2030
+ batch.state = [{ id, s: lanes.state }];
2031
+ if (opts.lifetimeMs !== undefined)
2032
+ batch.lifetimeMs = opts.lifetimeMs;
2033
+ this.#batch(batch);
2034
+ }
2035
+ /** 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`. */
2036
+ #liveRemove(ref, named) {
2037
+ try {
2038
+ this.#liveOpen();
2039
+ const id = named();
2040
+ if (id === undefined)
2041
+ throw notNamed(ref);
2042
+ return this.#releaseId(id);
1343
2043
  }
1344
- return this.#view(() => mirror.merged[id]);
2044
+ catch (e) {
2045
+ return Promise.reject(e);
2046
+ }
2047
+ }
2048
+ /** `live.remove`: the call `synapse/scene/live/remove {id}` through the task (it drops the claim). */
2049
+ #releaseId(id) {
2050
+ if (this.#closed)
2051
+ return Promise.reject(closedError(ENDED_CLIENT));
2052
+ return this.scene.request(reply => ({ kind: "remove", id, reply }));
1345
2053
  }
1346
- tree() { return this.#view(() => this.scene.mirror.tree()); }
2054
+ tree() { return this.#view(() => this.scene.mirror.tree(), EMPTY_TREE); }
1347
2055
  query(where) {
1348
2056
  let from;
1349
2057
  let last = [];
@@ -1355,13 +2063,40 @@ export class SceneHandleImpl {
1355
2063
  const ids = Object.keys(m).filter(id => where(m[id])).sort();
1356
2064
  last = replaceEqualDeep(last, ids.map(id => m[id]));
1357
2065
  return last;
1358
- });
2066
+ }, EMPTY_LIST);
2067
+ }
2068
+ /** 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. */
2069
+ #view(read, empty) {
2070
+ const ended = () => this.#closed || this.scene.mirror.state === "closed";
2071
+ return new MirrorView(this.scene.mirror, () => (ended() ? empty : read()), this.#views, ended);
1359
2072
  }
1360
- #view(read) { return new MirrorView(this.scene.mirror, read, this.#views, () => this.#closed || this.scene.mirror.state === "closed"); }
1361
- get status() {
2073
+ #statusNow() {
1362
2074
  const m = this.scene.mirror;
1363
2075
  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 };
2076
+ 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, unclaimedDrops: this.scene.unclaimedDrops };
2077
+ }
2078
+ /** The one boolean a node author checks: `status.get().synced`. */
2079
+ get synced() { return this.status.get().synced; }
2080
+ /** Resolves once the handle is attached (at once if it is); `not_connected` once it is closed (`close()`, or the node ending). */
2081
+ ready() {
2082
+ const m = this.scene.mirror;
2083
+ if (this.#closed || m.state === "closed")
2084
+ return Promise.reject(closedError(this.#closed ? ENDED_CLIENT : m.ended ?? ENDED_CLIENT));
2085
+ if (m.state === "attached")
2086
+ return Promise.resolve();
2087
+ const d = deferred();
2088
+ this.#readies.add(d);
2089
+ const un = this.onChange(() => {
2090
+ if (m.state === "closed")
2091
+ d.reject(closedError(m.ended ?? ENDED_CLIENT));
2092
+ else if (m.state === "attached")
2093
+ d.resolve();
2094
+ else
2095
+ return;
2096
+ un();
2097
+ this.#readies.delete(d);
2098
+ });
2099
+ return d.promise;
1365
2100
  }
1366
2101
  // ---- feeds
1367
2102
  #feed(kind, cb) {
@@ -1385,99 +2120,184 @@ export class SceneHandleImpl {
1385
2120
  void q.close();
1386
2121
  return iterate(q, () => m.removeSink(kind, sink));
1387
2122
  }
1388
- watchChanges(cb) { return this.#feed("changes", cb); }
2123
+ #ended() { return this.#closed || this.scene.mirror.state === "closed"; }
2124
+ /** The entities after their next change (state, not history); `not_connected` once this handle is closed. */
2125
+ changed() {
2126
+ const m = this.scene.mirror;
2127
+ const closed = () => closedError(this.#closed ? ENDED_CLIENT : m.ended ?? ENDED_CLIENT);
2128
+ if (this.#ended())
2129
+ return Promise.reject(closed());
2130
+ const from = this.get();
2131
+ const d = deferred();
2132
+ const un = this.onChange(v => {
2133
+ if (this.#ended())
2134
+ d.reject(closed());
2135
+ else if (v !== from)
2136
+ d.resolve(v);
2137
+ else
2138
+ return;
2139
+ un();
2140
+ });
2141
+ return d.promise;
2142
+ }
2143
+ /** The entities after each change; ends with the handle. */
2144
+ watch() { return watchState(cb => this.onChange(cb), () => this.get(), () => this.#ended()); }
1389
2145
  watchLiveRemovals(cb) { return this.#feed("removals", cb); }
1390
2146
  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) {
2147
+ // ---- authored (`scene.authored`): every verb is one `apply`
2148
+ async #apply(ops, base) {
1394
2149
  if (this.#closed)
1395
2150
  throw closedError(ENDED_CLIENT);
1396
2151
  if (this.scene.mirror.state === "detached")
1397
2152
  throw detachedError();
1398
2153
  await this.scene.request(reply => ({ kind: "apply", ops, base, reply }));
1399
2154
  }
1400
- async txn(build) {
2155
+ async #txn(build) {
1401
2156
  const t = new Txn();
1402
2157
  build(t);
1403
- await this.apply([...t.ops]);
2158
+ await this.#apply([...t.ops]);
1404
2159
  }
1405
- async spawn(kind, opts) {
2160
+ async #spawn(kind, opts) {
1406
2161
  let id = "";
1407
- await this.txn(t => { id = t.spawn(kind, opts); });
2162
+ await this.#txn(t => { id = t.spawn(kind, opts); });
1408
2163
  return id;
1409
2164
  }
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
2165
  /**
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.
2166
+ * The entity named `name`, made to carry `components`; its view (by id). None of that name in the authored scene:
2167
+ * `spawn` (with `parent`). One: its authored row (live overrides never count) is compared with what a `patch` of the
2168
+ * components would make of it; equal, nothing is sent; else a `patch` of the components that differ. `parent` is for
2169
+ * the spawn only (an existing entity is not reparented). One of another kind in the mirror: `spawn` too, and the
2170
+ * runtime answers (the mirror may be stale: `name_taken` with `holder` while the name is held, the spawn when it was
2171
+ * freed). An `id` component: `invalid_input` (`data: { component: "id" }`), nothing sent.
1420
2172
  */
1421
- live(batch) {
2173
+ async #upsert(kind, opts) {
1422
2174
  if (this.#closed)
1423
2175
  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;
2176
+ refuseId(opts, "scene.authored.upsert");
2177
+ const { name, parent: _parent, ...components } = opts;
2178
+ const found = authoredNamed(this.scene.mirror.authored, name);
2179
+ if (!found || found.kind !== kind)
2180
+ return this.entity(await this.#spawn(kind, opts));
2181
+ const row = found;
2182
+ const after = mergeSet(row, components);
2183
+ // Only the components that differ (a patch of `flags` alone is what a locked entity accepts).
2184
+ const differ = Object.fromEntries(Object.entries(components).filter(([k]) => !deepEqual(row[k], after[k])));
2185
+ if (Object.keys(differ).length)
2186
+ await this.#txn(t => t.patch(found.id, differ));
2187
+ return this.entity(found.id);
2188
+ }
2189
+ // ---- live (`scene.live`)
2190
+ /**
2191
+ * A live entity of this node's: the call `synapse/scene/live/upsert {kind, name?, parent, lifetime_ms?, ...components}`
2192
+ * through the task; resolves with the view of the id the runtime answered (a name this node already claimed sends that
2193
+ * claim's id: its own intent). The runtime decides: its refusal is the rejection, as it came, and nothing is kept.
2194
+ * Granted, it is this node's claim, claimed again (same id) on every attach until `remove`. A session that goes before
2195
+ * the answer: asked at the next attach. Detached: `project_inactive`; the node ending: `not_connected`.
2196
+ */
2197
+ #liveUpsert(kind, opts, live) {
1444
2198
  try {
1445
- bytes = encodeBody(body);
2199
+ this.#liveOpen();
2200
+ refuseId(opts, "scene.live.upsert");
1446
2201
  }
1447
2202
  catch (e) {
1448
- throw new SynapseError("invalid_input", `live batch not encodable: ${e instanceof Error ? e.message : String(e)}`);
2203
+ return Promise.reject(e);
1449
2204
  }
1450
- if (bytes.length > MAX_FRAME_BYTES)
1451
- throw frameTooLarge("scene live batch", bytes.length);
1452
- let w = scene.writer;
1453
- if (!w || w.session !== s) {
1454
- closeLiveWriter(w);
1455
- try {
1456
- w = scene.writer = openLiveWriter(s);
1457
- }
1458
- catch (e) {
1459
- scene.writer = undefined;
1460
- throw new SynapseError("internal", `scene live track not made: ${e instanceof Error ? e.message : String(e)}`);
1461
- }
1462
- }
1463
- const header = { flags: 0, type: TYPES.PUB, seq: w.seq, tUs: nowUs() };
1464
- w.seq = (w.seq + 1) >>> 0;
2205
+ const { name, parent, ...components } = opts;
2206
+ let parentId;
1465
2207
  try {
1466
- w.stream.write(header, bytes);
2208
+ parentId = parent == null ? null : idOf(parent, "parent");
1467
2209
  }
1468
2210
  catch (e) {
1469
- throw new SynapseError("connection_lost", { message: e instanceof Error ? e.message : String(e), source: "transport", cause: e });
2211
+ return Promise.reject(e);
2212
+ }
2213
+ const args = {};
2214
+ for (const [k, v] of Object.entries(components))
2215
+ if (v !== undefined)
2216
+ args[k] = v;
2217
+ args.kind = kind;
2218
+ if (name !== undefined)
2219
+ args.name = name;
2220
+ args.parent = parentId;
2221
+ if (live.lifetimeMs !== undefined)
2222
+ args.lifetime_ms = live.lifetimeMs;
2223
+ return this.scene.request(reply => ({ kind: "upsert", args, reply })).then(id => this.entity(id));
2224
+ }
2225
+ /**
2226
+ * Writes one lane batch on this node's live track (made on the first write of each session); returns at once, like
2227
+ * `publish`. A row goes out only for an id this node claimed in the bound incarnation; the first row of an id with no
2228
+ * claim asks `upsert {id}` (the override) once, and until the answer the latest row of each lane of that id is parked
2229
+ * (latest wins), sent once granted, dropped and counted when refused (`unclaimedDrops`; rows written while refused and
2230
+ * not due again are dropped and counted too). Not attached: dropped and counted (`detachedDrops`). `upsert`/`remove`
2231
+ * keys (the type does not allow them) are sent as given: the runtime refuses such a batch (`invalid_input`, `data {lane}`).
2232
+ */
2233
+ #batch(batch) {
2234
+ this.#liveOpen();
2235
+ const scene = this.scene;
2236
+ const s = scene.node.currentSession();
2237
+ if (scene.mirror.state !== "attached" || !s) {
2238
+ scene.detachedDrops++;
2239
+ scene.countersChanged();
2240
+ return;
2241
+ }
2242
+ const now = performance.now(), ask = [];
2243
+ const poses = [], joints = [], state = [];
2244
+ let dropped = 0;
2245
+ const lane = (rows, out, park) => {
2246
+ for (const r of rows ?? []) {
2247
+ const g = scene.gate(r.id, now, ask);
2248
+ if (g === "send")
2249
+ out.push(r);
2250
+ else if (g === "park") {
2251
+ const p = scene.claims.get(r.id).parked;
2252
+ park(p, r);
2253
+ p.lifetimeMs = batch.lifetimeMs;
2254
+ }
2255
+ else
2256
+ dropped += 1;
2257
+ }
2258
+ };
2259
+ lane(batch.poses, poses, (p, r) => { p.pose = r; });
2260
+ lane(batch.joints, joints, (p, r) => { p.joints = r; });
2261
+ lane(batch.state, state, (p, r) => { p.state = r; });
2262
+ if (dropped) {
2263
+ scene.unclaimedDrops += dropped;
2264
+ scene.countersChanged();
1470
2265
  }
2266
+ for (const id of ask)
2267
+ scene.post({ kind: "claim", id });
2268
+ const body = laneBody(poses, joints, state, batch.lifetimeMs) ?? {};
2269
+ const rest = batch;
2270
+ if (rest.upsert !== undefined)
2271
+ body.upsert = rest.upsert;
2272
+ if (rest.remove !== undefined)
2273
+ body.remove = rest.remove;
2274
+ if (!Object.keys(body).length)
2275
+ return;
2276
+ if (batch.lifetimeMs !== undefined)
2277
+ body.lifetime_ms = batch.lifetimeMs;
2278
+ writeLive(liveWriterOn(scene, s), encodeLive(body));
2279
+ }
2280
+ /** 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. */
2281
+ #liveOpen() {
2282
+ if (this.#closed)
2283
+ throw closedError(ENDED_CLIENT);
2284
+ if (this.scene.node.isEnded)
2285
+ throw closedError(ENDED_CONNECTION_LOST);
2286
+ const m = this.scene.mirror;
2287
+ if (m.state === "closed")
2288
+ throw closedError(m.ended ?? ENDED_CLIENT);
1471
2289
  }
1472
- /** The framework bridge (React's hooks): called on every change of the entities or the status. */
1473
- subscribe(listener) {
2290
+ /** `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. */
2291
+ onChange(cb) {
1474
2292
  if (this.#closed)
1475
2293
  return () => { };
1476
- const un = this.scene.mirror.subscribe(listener);
2294
+ const l = () => cb(this.get());
2295
+ const un = this.scene.mirror.subscribe(l);
1477
2296
  this.#unsubscribes.add(un);
1478
- return () => { un(); this.#unsubscribes.delete(un); };
2297
+ this.#onClose.add(l);
2298
+ return () => { un(); this.#unsubscribes.delete(un); this.#onClose.delete(l); };
1479
2299
  }
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. */
2300
+ /** 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
2301
  close() {
1482
2302
  if (this.#closed)
1483
2303
  return;
@@ -1488,6 +2308,19 @@ export class SceneHandleImpl {
1488
2308
  for (const un of this.#unsubscribes)
1489
2309
  un();
1490
2310
  this.#unsubscribes.clear();
2311
+ for (const d of this.#readies)
2312
+ d.reject(closedError(ENDED_CLIENT));
2313
+ this.#readies.clear();
2314
+ for (const l of [...this.#onClose]) {
2315
+ try {
2316
+ l();
2317
+ }
2318
+ catch (e) {
2319
+ rethrowLater(e);
2320
+ }
2321
+ }
2322
+ this.#onClose.clear();
2323
+ this.#status.dispose();
1491
2324
  if (--this.scene.handles === 0)
1492
2325
  this.scene.post({ kind: "close" });
1493
2326
  }
@@ -1496,11 +2329,10 @@ export class SceneHandleImpl {
1496
2329
  /**
1497
2330
  * The active project's scene (one per project; one task per node, shared by every handle). Resolves
1498
2331
  * 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.
2332
+ * handle then follows the incarnations as they come and go. Without a session (before the first, between
2333
+ * two) the task waits for the next one, like after a drop.
1500
2334
  */
1501
2335
  export async function openScene(node, registry, warn) {
1502
- if (!node.currentSession())
1503
- throw new SynapseError("not_connected");
1504
2336
  let scene;
1505
2337
  let h;
1506
2338
  for (;;) {