@synapse-sdk/client 0.3.4 → 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,22 +1,27 @@
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;
@@ -35,8 +40,8 @@
35
40
  // (`status.get().unclaimedDrops`) when refused. Nothing here reads the mirror to decide what to send: the mirror is a
36
41
  // read cache. The Rust client's `scene.rs` is the reference; this file behaves as it does.
37
42
  //
38
- // States: `attached` (an incarnation's snapshot is in the mirror, and every frame of both tracks this
39
- // node already held when the first frame of each arrived has been applied), `detached` (none: no
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
40
45
  // active project, a session gap, between incarnations), `closed` (`close()` or the node ending). While
41
46
  // detached the entities are empty, every author verb fails `project_inactive` at once and a live
42
47
  // write drops its batch and counts it. A closed handle's verbs fail `not_connected`.
@@ -56,6 +61,7 @@ import { FLAGS, SynapseError, TYPES, decode as decodeBody, decodeHeader, encode
56
61
  import { startOn } from "./calls.js";
57
62
  import { REFOLLOW_PACE_MS, isTransient, refusalPaceMs } from "./channel.js";
58
63
  import { CALL_GROUP_MAX_AGE_MS, StreamWriter, frameTooLarge, latestSubscription, waitActive } from "./session.js";
64
+ import { ANNOUNCE_WITHIN_MS } from "./store.js";
59
65
  import { AsyncQueue, StatusCell, deferred, replaceEqualDeep, rethrowLater, watchState } from "./util.js";
60
66
  /** Where the runtime announces the scene's incarnations: `runtime/scene/@<epoch>`. */
61
67
  const SCENE_BASE = "runtime/scene";
@@ -473,8 +479,10 @@ export class Mirror {
473
479
  epoch;
474
480
  version = 0;
475
481
  hash = "";
476
- /** An authored diff skipped a version; cleared by the next snapshot. */
477
- 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;
478
486
  pending = 0;
479
487
  authored = new Map();
480
488
  live = new Map();
@@ -553,7 +561,7 @@ export class Mirror {
553
561
  this.merged = replaceEqualDeep(this.merged, merged);
554
562
  this.#treeStale = true;
555
563
  }
556
- /** A snapshot of incarnation `epoch` replaces the authored copy. 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. */
557
565
  applySnapshot(epoch, s) {
558
566
  if (this.state === "closed")
559
567
  return;
@@ -563,30 +571,41 @@ export class Mirror {
563
571
  this.version = s.version;
564
572
  this.hash = s.hash;
565
573
  this.epoch = epoch;
566
- this.gap = false;
574
+ this.#heal();
567
575
  this.#rebuild();
568
576
  this.#notify();
569
577
  }
570
- /** 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`. */
571
579
  attach() { if (this.epoch !== undefined && this.state !== "closed") {
572
580
  this.#setState("attached");
573
581
  this.#notify();
574
582
  } }
575
- /** One applied batch: its ops folded into the authored copy. */
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
+ */
576
591
  applyDiff(d, warn) {
577
592
  if (this.epoch === undefined || this.state === "closed")
578
- return;
593
+ return true;
579
594
  if (d.version !== this.version + 1) {
580
- warn(`scene: diff out of sequence (have ${this.version}, got ${d.version}); not synced until the next snapshot`);
581
- 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;
582
599
  }
583
600
  const touched = [];
584
601
  for (const op of d.ops)
585
602
  touched.push(...foldAuthored(this.authored, op, d.t, d.by ?? null));
586
603
  this.version = d.version;
587
604
  this.hash = d.hash;
605
+ this.#heal();
588
606
  this.#refresh(touched);
589
607
  this.#notify();
608
+ return true;
590
609
  }
591
610
  /** A keyframe replaces the live table. */
592
611
  applyKeyframe(k) {
@@ -597,6 +616,7 @@ export class Mirror {
597
616
  keepLiveEntities(table, id => old.get(id)?.entity, table.keys());
598
617
  const ids = [...this.live.keys(), ...table.keys()];
599
618
  this.live = table;
619
+ this.liveKeyed = true;
600
620
  this.#refresh(ids);
601
621
  this.#notify();
602
622
  }
@@ -628,7 +648,8 @@ export class Mirror {
628
648
  this.epoch = undefined;
629
649
  this.version = 0;
630
650
  this.hash = "";
631
- this.gap = false;
651
+ this.gap = undefined;
652
+ this.liveKeyed = false;
632
653
  this.#setState("detached");
633
654
  this.#notify();
634
655
  }
@@ -799,6 +820,16 @@ class EntityView extends MirrorView {
799
820
  watch(component) {
800
821
  return watchState(cb => this.onChange(cb), () => this.get(component), this.ended);
801
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
+ }
802
833
  }
803
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. */
804
835
  function release(node, session, id) {
@@ -817,6 +848,14 @@ function replyId(v) {
817
848
  return id;
818
849
  }
819
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
+ }
820
859
  function openLiveWriter(s) {
821
860
  const bc = s.createBroadcast(`${s.prefix}/scene/live`);
822
861
  // Kept as long as a call track keeps its groups (the runtime's follower reads every batch).
@@ -995,8 +1034,11 @@ async function run(scene, registry) {
995
1034
  let phase = { kind: "idle" };
996
1035
  const ph = () => phase;
997
1036
  let attempts = 0;
998
- /** The incarnation that last ended: not bound again (its announcement may still be seen). */
999
- 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;
1000
1042
  /** The session's project state as last read. */
1001
1043
  let project;
1002
1044
  let unwatchProject;
@@ -1029,10 +1071,15 @@ async function run(scene, registry) {
1029
1071
  phase.stop.abort();
1030
1072
  else if (phase.kind === "paced")
1031
1073
  clearTimeout(phase.timer);
1074
+ else if (phase.kind === "opening")
1075
+ phase.announced.close();
1032
1076
  phase = { kind: "idle" };
1033
1077
  };
1034
- /** Follows the announcements on the task's session, from `idle`. */
1035
- 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 = () => {
1036
1083
  if (phase.kind !== "idle" || mirror.state === "closed" || !session)
1037
1084
  return;
1038
1085
  const s = session;
@@ -1044,25 +1091,84 @@ async function run(scene, registry) {
1044
1091
  scene.warn(`scene: announcements not followed: ${e instanceof Error ? e.message : String(e)}`);
1045
1092
  return;
1046
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
+ }
1047
1102
  attempts += 1;
1048
1103
  const attempt = attempts;
1049
- const stop = new AbortController();
1050
- phase = { kind: "following", attempt, stop };
1051
- void followIncarnation(scene, s, announced, endedEpoch, stop.signal, attempt).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) } }));
1052
1106
  };
1053
- /** The incarnation is let go: the mirror empties, writes waiting to show fail, followed again after `paceMs`. */
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" };
1156
+ };
1157
+ /** The incarnation is let go: the mirror empties, writes waiting to show fail, opened again after `paceMs`. */
1054
1158
  const again = (paceMs) => {
1055
1159
  failWaiters(() => new SynapseError("conflict", "the scene's incarnation ended before the write showed"));
1056
1160
  stopPhase();
1057
1161
  mirror.detach();
1058
1162
  unbind();
1163
+ wanted = undefined;
1059
1164
  const a = ++attempts;
1060
1165
  phase = { kind: "paced", timer: setTimeout(() => scene.post({ kind: "paced", attempt: a }), paceMs) };
1061
1166
  };
1062
- /** 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`. */
1063
1168
  const sessionChanged = (s) => {
1064
1169
  session = s;
1065
1170
  stopPhase();
1171
+ wanted = undefined;
1066
1172
  failWaiters(() => new SynapseError("connection_lost", "the session ended before the write showed in the scene"));
1067
1173
  mirror.detach();
1068
1174
  unbind();
@@ -1075,31 +1181,45 @@ async function run(scene, registry) {
1075
1181
  unwatchProject = s.project.subscribe(p => { scene.post({ kind: "project", snapshot: p }); });
1076
1182
  if (inactive())
1077
1183
  answerJoins();
1078
- follow();
1184
+ open();
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
+ }
1079
1195
  };
1080
- /** The reader applied an authored frame of `epoch` reaching `version`: the writes it shows are answered; attached, the `scene()` calls waiting too. */
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. */
1081
1197
  const applied = (epoch, version) => {
1082
1198
  const reached = waiters.filter(w => w.epoch === epoch && w.version <= version);
1083
1199
  waiters = waiters.filter(w => !reached.includes(w));
1084
1200
  for (const w of reached)
1085
1201
  w.reply.resolve();
1086
- if (mirror.state !== "attached")
1087
- return;
1088
- answerJoins();
1202
+ attach();
1089
1203
  };
1090
1204
  /**
1091
- * Reading `attempt` (the one in progress) caught up with incarnation `epoch` at `version`: the claims are made for it
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
1092
1207
  * 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.
1208
+ * unbound attach). Attached, the `scene()` calls waiting are answered.
1094
1209
  */
1095
- const attach = (attempt, epoch, version) => {
1096
- const p = ph();
1097
- if (p.kind !== "following" || p.attempt !== attempt || mirror.epoch !== epoch || !session)
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)
1098
1214
  return;
1099
- if (bound?.session !== session || bound.epoch !== epoch)
1100
- bind(session, epoch);
1101
- mirror.attach();
1102
- applied(epoch, version);
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();
1103
1223
  };
1104
1224
  // ---- claims (PLAN v0.31)
1105
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. */
@@ -1453,7 +1573,6 @@ async function run(scene, registry) {
1453
1573
  const e = result.err;
1454
1574
  const live = e.data?.live_epoch;
1455
1575
  if (e.code === "conflict" && typeof live === "string" && live !== epoch && mirror.epoch === epoch) {
1456
- endedEpoch = epoch;
1457
1576
  again(REFOLLOW_PACE_MS);
1458
1577
  }
1459
1578
  reply.reject(e);
@@ -1489,8 +1608,12 @@ async function run(scene, registry) {
1489
1608
  case "applied":
1490
1609
  applied(ev.epoch, ev.version);
1491
1610
  break;
1492
- case "attach":
1493
- attach(ev.attempt, ev.epoch, ev.version);
1611
+ case "opened":
1612
+ opened(ev.attempt, ev.result);
1613
+ break;
1614
+ case "liveKeyed":
1615
+ if (wanted?.epoch === ev.epoch)
1616
+ attach();
1494
1617
  break;
1495
1618
  case "followEnded": {
1496
1619
  {
@@ -1499,9 +1622,6 @@ async function run(scene, registry) {
1499
1622
  break;
1500
1623
  }
1501
1624
  phase = { kind: "idle" };
1502
- // An incarnation that ended with its final frame is not bound again.
1503
- if (ev.epoch !== undefined && ev.reason !== undefined)
1504
- endedEpoch = ev.epoch;
1505
1625
  again(REFOLLOW_PACE_MS);
1506
1626
  break;
1507
1627
  }
@@ -1515,9 +1635,7 @@ async function run(scene, registry) {
1515
1635
  applyDone(ev.epoch, ev.result, ev.reply);
1516
1636
  break;
1517
1637
  case "project":
1518
- project = ev.snapshot;
1519
- if (inactive())
1520
- answerJoins();
1638
+ projectChanged(ev.snapshot);
1521
1639
  break;
1522
1640
  case "notice":
1523
1641
  notice(ev.body);
@@ -1525,7 +1643,7 @@ async function run(scene, registry) {
1525
1643
  case "paced":
1526
1644
  if (ph().kind === "paced" && ev.attempt === attempts) {
1527
1645
  phase = { kind: "idle" };
1528
- follow();
1646
+ open();
1529
1647
  }
1530
1648
  break;
1531
1649
  case "upsert":
@@ -1597,80 +1715,33 @@ function announcedEpoch(prefix) {
1597
1715
  const e = prefix.slice(SCENE_BASE.length + 2);
1598
1716
  return e && !e.includes("/") ? e : undefined;
1599
1717
  }
1600
- /** One track being read: its subscriber and the group it is in (both closed when the reading stops). */
1601
- class TrackReading {
1602
- sub;
1603
- group;
1604
- constructor(sub) {
1605
- this.sub = sub;
1606
- }
1607
- close() { try {
1608
- this.group?.close();
1609
- }
1610
- catch { /* closed */ } try {
1611
- this.sub.close();
1612
- }
1613
- catch { /* closed */ } }
1614
- /** The first group and its first frame's payload. */
1615
- async first() {
1616
- try {
1617
- const g = await this.sub.recvGroup();
1618
- if (!g)
1619
- return undefined;
1620
- this.group = g;
1621
- return (await g.readFrame())?.payload;
1622
- }
1623
- catch {
1624
- return undefined;
1625
- }
1626
- }
1627
- /** 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. */
1628
- drain(on) {
1629
- for (;;) {
1630
- const g = this.group;
1631
- if (g) {
1632
- let f;
1633
- try {
1634
- f = g.tryReadFrame();
1635
- }
1636
- catch {
1637
- f = undefined;
1638
- this.group = undefined;
1639
- }
1640
- if (f) {
1641
- if (on(f.payload))
1642
- continue;
1643
- return;
1644
- }
1645
- if (this.group && g.done)
1646
- this.group = undefined;
1647
- }
1648
- let next;
1649
- try {
1650
- next = this.sub.tryRecvGroup();
1651
- }
1652
- catch {
1653
- next = undefined;
1654
- }
1655
- if (!next)
1656
- return;
1657
- try {
1658
- this.group?.close();
1659
- }
1660
- catch { /* closed */ }
1661
- this.group = next;
1662
- }
1663
- }
1664
- /** Reads frame by frame as frames come, from the current group on, until `on` returns `false` or the track ends. */
1665
- async readOn(on) {
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();
1733
+ }
1734
+ catch { /* closed */ } try {
1735
+ sub.close();
1736
+ }
1737
+ catch { /* closed */ } };
1738
+ signal.addEventListener("abort", close, { once: true });
1739
+ let read = "end";
1666
1740
  try {
1667
- for (;;) {
1668
- if (!this.group) {
1669
- this.group = await this.sub.recvGroup();
1670
- if (!this.group)
1671
- return;
1672
- }
1673
- const g = this.group;
1741
+ groups: for (;;) {
1742
+ g = await sub.recvGroup();
1743
+ if (!g)
1744
+ break;
1674
1745
  for (;;) {
1675
1746
  let f;
1676
1747
  try {
@@ -1681,13 +1752,22 @@ class TrackReading {
1681
1752
  }
1682
1753
  if (!f)
1683
1754
  break;
1684
- if (!on(f.payload))
1685
- return;
1755
+ read = on(f.payload);
1756
+ if (read !== "read")
1757
+ break groups;
1686
1758
  }
1687
- this.group = undefined;
1759
+ read = "end";
1688
1760
  }
1689
1761
  }
1690
- 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;
1691
1771
  }
1692
1772
  }
1693
1773
  /** A frame's header flags and decoded body, or `undefined` (warned) when it does not decode. */
@@ -1702,14 +1782,18 @@ function decodeFrame(p, warn, what) {
1702
1782
  }
1703
1783
  }
1704
1784
  /**
1705
- * Binds the first incarnation announced (other than `skip`, the one that last ended) and reads both
1706
- * its tracks into the mirror, telling the task of each authored frame applied; returns once the
1707
- * `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.
1708
1790
  */
1709
- async function followIncarnation(scene, session, announced, skip, signal, attempt) {
1791
+ async function followIncarnation(scene, session, announced, epoch, signal) {
1710
1792
  const { mirror, warn } = scene;
1711
1793
  const none = { epoch: undefined, reason: undefined };
1712
- 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() }];
1713
1797
  const closeAll = () => { for (const c of closers) {
1714
1798
  try {
1715
1799
  c.close();
@@ -1717,36 +1801,38 @@ async function followIncarnation(scene, session, announced, skip, signal, attemp
1717
1801
  catch { /* closed */ }
1718
1802
  } };
1719
1803
  signal.addEventListener("abort", closeAll, { once: true });
1804
+ const stopped = () => reading.signal.aborted;
1720
1805
  try {
1721
- let epoch;
1722
- for (;;) {
1723
- const u = await announced.next();
1724
- if (!u || signal.aborted)
1725
- return none;
1726
- if (u.kind === "retracted")
1727
- continue;
1728
- const e = announcedEpoch(String(u.prefix));
1729
- if (e !== undefined && e !== skip) {
1730
- epoch = e;
1731
- 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;
1732
1816
  }
1733
1817
  }
1734
- const at = epoch;
1735
- // Resolving the broadcast races an end of this epoch already on the cursor.
1736
- 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}`);
1737
1823
  closers.push(req);
1738
1824
  const retracted = (async () => { for (;;) {
1739
1825
  const u = await announced.next();
1740
1826
  if (!u)
1741
1827
  return false;
1742
- if (u.kind === "retracted" && announcedEpoch(String(u.prefix)) === at)
1828
+ if (u.kind === "retracted" && announcedEpoch(String(u.prefix)) === epoch)
1743
1829
  return true;
1744
1830
  } })();
1745
1831
  let bc;
1746
1832
  try {
1747
- 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 }))]);
1748
1834
  if ("gone" in r)
1749
- return r.gone ? { epoch: at, reason: "ended" } : none;
1835
+ return r.gone ? { epoch, reason: "ended" } : none;
1750
1836
  bc = r.bc;
1751
1837
  }
1752
1838
  catch {
@@ -1754,88 +1840,67 @@ async function followIncarnation(scene, session, announced, skip, signal, attemp
1754
1840
  }
1755
1841
  announced.close();
1756
1842
  closers.push(bc);
1757
- const authored = new TrackReading(bc.track(AUTHORED_TRACK).subscribe(latestSubscription(moqPriority(1))));
1758
- const live = new TrackReading(bc.track(LIVE_TRACK).subscribe(latestSubscription(moqPriority(1))));
1759
- closers.push(authored, live);
1760
- if (signal.aborted)
1843
+ if (stopped())
1761
1844
  return none;
1762
1845
  let end;
1763
- const ended = () => ({ epoch: at, reason: end });
1846
+ let keyed = false;
1764
1847
  const onAuthored = (p) => {
1765
- if (signal.aborted)
1766
- return false;
1848
+ if (stopped())
1849
+ return "end";
1767
1850
  const f = decodeFrame(p, warn, "authored");
1768
1851
  if (!f)
1769
- return true;
1852
+ return "read";
1770
1853
  if (f.flags & FLAGS.END) {
1771
1854
  const r = f.body?.reason;
1772
1855
  end = typeof r === "string" ? r : "ended";
1773
- return false;
1856
+ return "end";
1774
1857
  }
1775
1858
  let version;
1776
1859
  if (f.flags & FLAGS.KEY) {
1777
1860
  const s = f.body;
1778
1861
  if (!s || typeof s.version !== "number" || !isObject(s.entities)) {
1779
1862
  warn("scene: snapshot not understood");
1780
- return true;
1863
+ return "read";
1781
1864
  }
1782
- mirror.applySnapshot(at, s);
1865
+ mirror.applySnapshot(epoch, s);
1783
1866
  version = s.version;
1784
1867
  }
1785
1868
  else if (f.flags & FLAGS.DELTA) {
1786
1869
  const d = f.body;
1787
1870
  if (!d || typeof d.version !== "number" || !Array.isArray(d.ops)) {
1788
1871
  warn("scene: diff not understood");
1789
- return true;
1872
+ return "read";
1790
1873
  }
1791
- mirror.applyDiff(d, warn);
1874
+ if (!mirror.applyDiff(d, warn))
1875
+ return "gap";
1792
1876
  version = d.version;
1793
1877
  }
1794
1878
  else
1795
- return true;
1796
- scene.post({ kind: "applied", epoch: at, version });
1797
- return true;
1879
+ return "read";
1880
+ scene.post({ kind: "applied", epoch, version });
1881
+ return "read";
1798
1882
  };
1799
1883
  const onLive = (p) => {
1800
- if (signal.aborted)
1801
- return false;
1884
+ if (stopped())
1885
+ return "end";
1802
1886
  const f = decodeFrame(p, warn, "live");
1803
1887
  if (!f || !isObject(f.body))
1804
- return true;
1805
- if (f.flags & FLAGS.KEY)
1888
+ return "read";
1889
+ if (f.flags & FLAGS.KEY) {
1806
1890
  mirror.applyKeyframe(f.body);
1891
+ if (!keyed) {
1892
+ keyed = true;
1893
+ scene.post({ kind: "liveKeyed", epoch });
1894
+ }
1895
+ }
1807
1896
  else if (f.flags & FLAGS.DELTA)
1808
1897
  mirror.applyLiveTick(f.body);
1809
- return true;
1898
+ return "read";
1810
1899
  };
1811
- // Catching up: the first frame of each track (the open group's snapshot, the whole live set),
1812
- // then every frame this node already holds on either, read without waiting; only then is the
1813
- // handle `attached` (its version is the scene's as far as this node has it).
1814
- const a0 = await authored.first();
1815
- if (signal.aborted)
1816
- return none;
1817
- if (!a0)
1818
- return ended();
1819
- if (!onAuthored(a0))
1820
- return ended();
1821
- const l0 = await live.first();
1822
- if (signal.aborted)
1823
- return none;
1824
- if (!l0)
1825
- return ended();
1826
- onLive(l0);
1827
- authored.drain(onAuthored);
1828
- if (end !== undefined)
1829
- return ended();
1830
- live.drain(onLive);
1831
- if (signal.aborted)
1832
- return none;
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 });
1835
- // Then frame by frame as they come. The live track's end is the incarnation's: the authored track says how it ended.
1836
- void live.readOn(onLive);
1837
- await authored.readOn(onAuthored);
1838
- 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 };
1839
1904
  }
1840
1905
  catch {
1841
1906
  return none;
@@ -2073,7 +2138,7 @@ export class SceneHandleImpl {
2073
2138
  #statusNow() {
2074
2139
  const m = this.scene.mirror;
2075
2140
  const state = this.#closed ? "closed" : m.state;
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 };
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 };
2077
2142
  }
2078
2143
  /** The one boolean a node author checks: `status.get().synced`. */
2079
2144
  get synced() { return this.status.get().synced; }
@@ -2163,28 +2228,41 @@ export class SceneHandleImpl {
2163
2228
  return id;
2164
2229
  }
2165
2230
  /**
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.
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.
2172
2240
  */
2173
2241
  async #upsert(kind, opts) {
2174
2242
  if (this.#closed)
2175
2243
  throw closedError(ENDED_CLIENT);
2176
2244
  refuseId(opts, "scene.authored.upsert");
2177
2245
  const { name, parent: _parent, ...components } = opts;
2178
- const found = authoredNamed(this.scene.mirror.authored, name);
2179
- if (!found || found.kind !== kind)
2246
+ let holder;
2247
+ try {
2180
2248
  return this.entity(await this.#spawn(kind, opts));
2181
- const row = found;
2182
- const after = mergeSet(row, components);
2249
+ }
2250
+ catch (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;
2258
+ }
2259
+ const row = this.scene.mirror.authored.get(holder);
2260
+ const after = row ? mergeSet(row, components) : undefined;
2183
2261
  // 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])));
2262
+ const differ = Object.fromEntries(Object.entries(components).filter(([k, v]) => v !== undefined && (!row || !deepEqual(row[k], after[k]))));
2185
2263
  if (Object.keys(differ).length)
2186
- await this.#txn(t => t.patch(found.id, differ));
2187
- return this.entity(found.id);
2264
+ await this.#txn(t => t.patch(holder, differ));
2265
+ return this.entity(holder);
2188
2266
  }
2189
2267
  // ---- live (`scene.live`)
2190
2268
  /**
@@ -2328,7 +2406,8 @@ export class SceneHandleImpl {
2328
2406
  }
2329
2407
  /**
2330
2408
  * The active project's scene (one per project; one task per node, shared by every handle). Resolves
2331
- * once the handle is attached, or at once `detached` when the runtime says no project is active; the
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
2332
2411
  * handle then follows the incarnations as they come and go. Without a session (before the first, between
2333
2412
  * two) the task waits for the next one, like after a drop.
2334
2413
  */