@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/calls.d.ts +5 -5
- package/dist/calls.js +19 -21
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/scene.d.ts +22 -11
- package/dist/scene.js +297 -218
- package/dist/session.js +1 -1
- package/dist/store.d.ts +12 -4
- package/dist/store.js +99 -69
- package/dist/synapse.d.ts +3 -6
- package/dist/synapse.js +2 -5
- package/dist/types.d.ts +35 -24
- package/package.json +2 -2
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`:
|
|
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()`
|
|
5
|
-
//
|
|
6
|
-
// frame, then one diff frame per applied batch; a later group is a fresh snapshot that replaces
|
|
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
|
|
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`)
|
|
19
|
-
// or the tracks ending) empties the mirror and
|
|
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` (
|
|
39
|
-
//
|
|
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;
|
|
477
|
-
gap
|
|
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.
|
|
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
|
|
574
|
+
this.#heal();
|
|
567
575
|
this.#rebuild();
|
|
568
576
|
this.#notify();
|
|
569
577
|
}
|
|
570
|
-
/** The
|
|
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
|
-
/**
|
|
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})
|
|
581
|
-
this.gap =
|
|
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 =
|
|
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
|
-
/**
|
|
999
|
-
|
|
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
|
-
/**
|
|
1035
|
-
|
|
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
|
-
|
|
1050
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
1087
|
-
return;
|
|
1088
|
-
answerJoins();
|
|
1202
|
+
attach();
|
|
1089
1203
|
};
|
|
1090
1204
|
/**
|
|
1091
|
-
*
|
|
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).
|
|
1208
|
+
* unbound attach). Attached, the `scene()` calls waiting are answered.
|
|
1094
1209
|
*/
|
|
1095
|
-
const attach = (
|
|
1096
|
-
const
|
|
1097
|
-
|
|
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 (
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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 "
|
|
1493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
try {
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
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
|
-
|
|
1685
|
-
|
|
1755
|
+
read = on(f.payload);
|
|
1756
|
+
if (read !== "read")
|
|
1757
|
+
break groups;
|
|
1686
1758
|
}
|
|
1687
|
-
|
|
1759
|
+
read = "end";
|
|
1688
1760
|
}
|
|
1689
1761
|
}
|
|
1690
|
-
catch {
|
|
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
|
-
*
|
|
1706
|
-
*
|
|
1707
|
-
*
|
|
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,
|
|
1791
|
+
async function followIncarnation(scene, session, announced, epoch, signal) {
|
|
1710
1792
|
const { mirror, warn } = scene;
|
|
1711
1793
|
const none = { epoch: undefined, reason: undefined };
|
|
1712
|
-
|
|
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
|
-
|
|
1722
|
-
for
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
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
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
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)) ===
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1846
|
+
let keyed = false;
|
|
1764
1847
|
const onAuthored = (p) => {
|
|
1765
|
-
if (
|
|
1766
|
-
return
|
|
1848
|
+
if (stopped())
|
|
1849
|
+
return "end";
|
|
1767
1850
|
const f = decodeFrame(p, warn, "authored");
|
|
1768
1851
|
if (!f)
|
|
1769
|
-
return
|
|
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
|
|
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
|
|
1863
|
+
return "read";
|
|
1781
1864
|
}
|
|
1782
|
-
mirror.applySnapshot(
|
|
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
|
|
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
|
|
1796
|
-
scene.post({ kind: "applied", epoch
|
|
1797
|
-
return
|
|
1879
|
+
return "read";
|
|
1880
|
+
scene.post({ kind: "applied", epoch, version });
|
|
1881
|
+
return "read";
|
|
1798
1882
|
};
|
|
1799
1883
|
const onLive = (p) => {
|
|
1800
|
-
if (
|
|
1801
|
-
return
|
|
1884
|
+
if (stopped())
|
|
1885
|
+
return "end";
|
|
1802
1886
|
const f = decodeFrame(p, warn, "live");
|
|
1803
1887
|
if (!f || !isObject(f.body))
|
|
1804
|
-
return
|
|
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
|
|
1898
|
+
return "read";
|
|
1810
1899
|
};
|
|
1811
|
-
//
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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 &&
|
|
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).
|
|
2167
|
-
* `spawn` (with `parent
|
|
2168
|
-
*
|
|
2169
|
-
*
|
|
2170
|
-
*
|
|
2171
|
-
*
|
|
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
|
-
|
|
2179
|
-
|
|
2246
|
+
let holder;
|
|
2247
|
+
try {
|
|
2180
2248
|
return this.entity(await this.#spawn(kind, opts));
|
|
2181
|
-
|
|
2182
|
-
|
|
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(
|
|
2187
|
-
return this.entity(
|
|
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
|
|
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
|
*/
|