@panphora/clayjs 1.9.2 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/clay.standalone.js +9701 -6938
- package/entries/clay-router.js +38 -0
- package/entries/sap.js +6 -6
- package/package.json +2 -2
- package/packed-contract.json +1 -0
- package/src/attrs/add-from.js +72 -0
- package/src/attrs/save-freeze.js +12 -2
- package/src/core/conflict-notice.js +2 -5
- package/src/core/conflict-presentation.js +42 -0
- package/src/core/save-core.js +22 -0
- package/src/core/save.js +11 -2
- package/src/core/snapshot.js +4 -1
- package/src/core/source-map.js +1 -1
- package/src/lib/mutation.js +28 -4
- package/src/lib/person-face.js +16 -0
- package/src/lib/region-capabilities.js +1 -0
- package/src/lib/region-policy.js +10 -3
- package/src/lib/root-attrs.js +28 -0
- package/src/live/checkpoint.js +106 -0
- package/src/live/fast-lane.js +590 -0
- package/src/live/local-lane.js +582 -0
- package/src/live/ops.js +291 -0
- package/src/live/server-lane.js +84 -0
- package/src/live/store.js +291 -0
- package/src/live/stream-bridge.js +60 -0
- package/src/loader-logic.js +7 -2
- package/src/loader.js +2 -0
- package/src/plugins/live.js +446 -0
- package/src/plugins/people.js +3 -14
- package/src/plugins/sortable.js +3 -1
- package/src/route/route.js +188 -0
- package/src/standalone.js +1 -0
- package/src/sync/conflict-revert.js +2 -3
- package/src/sync/editing-marker.js +172 -0
- package/src/sync/editing-path.js +46 -0
- package/src/sync/editing-sender.js +100 -0
- package/src/sync/live-sync.js +85 -19
- package/src/sync/presence.js +16 -44
- package/src/sync/stream.js +16 -5
- package/src/vendor/hyper-morph.vendor.js +6 -4
- package/src/sync/section-notice.js +0 -254
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// clay.live's fast lane: every op goes to a room that orders it (seq) and hands
|
|
2
|
+
// it to every tab, so stores move at network speed instead of save speed. The
|
|
3
|
+
// room is on the host (server-lane.js) or in one tab of this browser; both speak
|
|
4
|
+
// the same frames through a transport:
|
|
5
|
+
// start({ open, close, frame, state, writer }) -> stop function
|
|
6
|
+
// send(clientId, { name, n, ops, epoch }) -> Promise<{ status, body, retryAfter }>
|
|
7
|
+
// fetchState(clientId) -> Promise<{ epoch, seq, writer, stores } | { status } | null>
|
|
8
|
+
// A new room (a new epoch) holds some of this tab's frames still in flight; see
|
|
9
|
+
// sortAwaiting for how each is sorted.
|
|
10
|
+
//
|
|
11
|
+
// Per open store the room says how far its file reaches: entry.last (the seq of
|
|
12
|
+
// the store's latest change) and entry.landed (the seq from which the file plus
|
|
13
|
+
// the room's log rebuilds the value; -1: rewrite now). Both are numbers in the
|
|
14
|
+
// current epoch, taken from the room and never carried across epochs.
|
|
15
|
+
|
|
16
|
+
import { applyOps, cloneJson } from "./ops.js";
|
|
17
|
+
|
|
18
|
+
export const MIN_GAP_MS = 40;
|
|
19
|
+
export const MAX_OPS = 128;
|
|
20
|
+
export const RETRY_MS = 500;
|
|
21
|
+
export const FRAME_BYTES = 15 * 1024;
|
|
22
|
+
const BUFFER_MAX = 512;
|
|
23
|
+
const RETRY_MAX_MS = 8000;
|
|
24
|
+
const REVOKED = new Set([401, 403]);
|
|
25
|
+
const STATE_GONE = new Set([401, 403, 404]);
|
|
26
|
+
const encoder = new TextEncoder();
|
|
27
|
+
const byteSize = (value) => encoder.encode(JSON.stringify(value)).length;
|
|
28
|
+
|
|
29
|
+
const transient = (status) => status === 0 || status === 408 || status === 429 || status >= 500;
|
|
30
|
+
// A refusal sent before the request was handled: the frame was not applied.
|
|
31
|
+
const unhandled = (status) => status === 429 || status === 503;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} options
|
|
35
|
+
* @param {object} options.transport
|
|
36
|
+
* @param {Map<string, {store: object}>} options.entries - the live plugin's open stores, by name
|
|
37
|
+
* @param {(epoch: string) => void} [options.onEpoch] - joined a room (first join or restart)
|
|
38
|
+
* @param {() => void} [options.onWriter] - the room's writer may have changed
|
|
39
|
+
* @param {() => void} [options.onApplied] - a store's room value moved (its entry's last advanced)
|
|
40
|
+
* @param {() => void} [options.onLanded] - how far the file reaches moved (an entry's landed)
|
|
41
|
+
* @param {() => void} [options.onCheckpoint] - the room asks the writer to save now (its log is half full)
|
|
42
|
+
* @param {(name: string) => boolean} [options.held] - a save still on the wire carries some of
|
|
43
|
+
* this store's pending ops in file mode: nothing of the store is framed until release(name),
|
|
44
|
+
* so those ops are framed in order if the save does not land, and never if it does
|
|
45
|
+
* @param {() => void} [options.onStop] - the lane stopped for good
|
|
46
|
+
*/
|
|
47
|
+
export function createFastLane({
|
|
48
|
+
transport,
|
|
49
|
+
entries,
|
|
50
|
+
onEpoch = () => {},
|
|
51
|
+
onLeave = () => {},
|
|
52
|
+
onWriter = () => {},
|
|
53
|
+
onApplied = () => {},
|
|
54
|
+
onLanded = () => {},
|
|
55
|
+
onCheckpoint = () => {},
|
|
56
|
+
held = () => false,
|
|
57
|
+
onStop = () => {},
|
|
58
|
+
now = () => Date.now(),
|
|
59
|
+
setTimer = setTimeout,
|
|
60
|
+
clearTimer = clearTimeout,
|
|
61
|
+
}) {
|
|
62
|
+
let clientId = null;
|
|
63
|
+
let connected = false;
|
|
64
|
+
let synced = false;
|
|
65
|
+
let epoch = null;
|
|
66
|
+
let seq = 0;
|
|
67
|
+
let writer = null;
|
|
68
|
+
let roomNames = new Set();
|
|
69
|
+
let buffered = [];
|
|
70
|
+
let fetching = false;
|
|
71
|
+
let stopped = false;
|
|
72
|
+
let inFlight = false;
|
|
73
|
+
// The frame on the wire now.
|
|
74
|
+
let flying = null;
|
|
75
|
+
let lastSentAt = -Infinity;
|
|
76
|
+
let holdUntil = 0;
|
|
77
|
+
let timer = null;
|
|
78
|
+
let retryDelay = RETRY_MS;
|
|
79
|
+
let resyncDelay = RETRY_MS;
|
|
80
|
+
// Bumped when the stream opens or closes, and when a state is taken: a state
|
|
81
|
+
// request answered after either is obsolete.
|
|
82
|
+
let connection = 0;
|
|
83
|
+
// The stream connection the host knows this tab by (server lane): a frame
|
|
84
|
+
// applies only under it. undefined on a transport without one.
|
|
85
|
+
let conn;
|
|
86
|
+
let stateCount = 0;
|
|
87
|
+
let fetchAgain = false;
|
|
88
|
+
let unsubscribe = null;
|
|
89
|
+
// Per store: the next n, ops not yet in a frame, frames sent but not yet seen
|
|
90
|
+
// back (each { n, ops, through, seq, sent, refused, uncertain }), and the
|
|
91
|
+
// newest op id ever put in a frame.
|
|
92
|
+
const lanes = new Map();
|
|
93
|
+
|
|
94
|
+
function laneOf(name) {
|
|
95
|
+
let lane = lanes.get(name);
|
|
96
|
+
if (!lane) lanes.set(name, (lane = { n: 0, unsent: [], awaiting: [], framed: 0 }));
|
|
97
|
+
return lane;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The store's ops still to frame: pending, past what was framed.
|
|
101
|
+
function unframed(name, lane) {
|
|
102
|
+
const store = entries.get(name)?.store;
|
|
103
|
+
if (!store) return [];
|
|
104
|
+
return store.pendingEntries().filter((pending) => pending.id > lane.framed);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Never applied: every attempt was refused before the room handled it.
|
|
108
|
+
const unapplied = (frame) => frame.refused === true && !frame.uncertain;
|
|
109
|
+
|
|
110
|
+
// Stop sending this store: it leaves the room. A frame the room may have
|
|
111
|
+
// applied is dropped from pending (never replayed over a file that may hold
|
|
112
|
+
// it); a frame refused unapplied, and ops never framed, stay pending for file
|
|
113
|
+
// mode. Refused frames come last: a refusal stops sending until the next state.
|
|
114
|
+
function leaveRoom(name, lane) {
|
|
115
|
+
const store = entries.get(name)?.store;
|
|
116
|
+
let through = 0;
|
|
117
|
+
for (const frame of lane.awaiting) if (!unapplied(frame)) through = frame.through;
|
|
118
|
+
if (store && through) store.rebase(store.authoritative, { source: "state", through });
|
|
119
|
+
onLeave(name, { epoch, seq });
|
|
120
|
+
lane.unsent = [];
|
|
121
|
+
lane.awaiting = [];
|
|
122
|
+
// What is left pending is framed afresh if the store comes back.
|
|
123
|
+
lane.framed = through;
|
|
124
|
+
store?.setMode("file");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function schedule() {
|
|
128
|
+
if (timer !== null || inFlight || stopped) return;
|
|
129
|
+
const t = now();
|
|
130
|
+
const delay = Math.max(0, lastSentAt + MIN_GAP_MS - t, holdUntil - t);
|
|
131
|
+
timer = setTimer(() => {
|
|
132
|
+
timer = null;
|
|
133
|
+
flush();
|
|
134
|
+
}, delay);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A frame whose send failed goes again first, with its own n. Otherwise the
|
|
138
|
+
// oldest unsent ops of one store; stores take turns.
|
|
139
|
+
function nextFrame() {
|
|
140
|
+
for (const [name, lane] of lanes) {
|
|
141
|
+
const retry = lane.awaiting.find((frame) => !frame.sent);
|
|
142
|
+
if (retry) return { name, lane, frame: retry };
|
|
143
|
+
}
|
|
144
|
+
for (const [name, lane] of lanes) {
|
|
145
|
+
if (!lane.unsent.length || !roomNames.has(name) || held(name)) continue;
|
|
146
|
+
// Packed by bytes as well as count: the host refuses a body over 16 KB.
|
|
147
|
+
let size = byteSize({ name, n: lane.n + 1, ops: [], epoch });
|
|
148
|
+
let count = 0;
|
|
149
|
+
while (count < lane.unsent.length && count < MAX_OPS) {
|
|
150
|
+
const more = byteSize(lane.unsent[count].op) + 1;
|
|
151
|
+
if (size + more > FRAME_BYTES) break;
|
|
152
|
+
size += more;
|
|
153
|
+
count++;
|
|
154
|
+
}
|
|
155
|
+
if (count === 0) {
|
|
156
|
+
const [entry] = lane.unsent.splice(0, 1);
|
|
157
|
+
console.warn(`clay.live("${name}"): one update was larger than ${FRAME_BYTES} bytes, so it was undone`);
|
|
158
|
+
const store = entries.get(name)?.store;
|
|
159
|
+
store?.rebase(store.authoritative, { source: "state", through: entry.id });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const take = lane.unsent.splice(0, count);
|
|
163
|
+
const frame = { n: ++lane.n, ops: take.map((entry) => entry.op), through: take[take.length - 1].id, seq: null, sent: false };
|
|
164
|
+
lane.framed = Math.max(lane.framed, frame.through);
|
|
165
|
+
lane.awaiting.push(frame);
|
|
166
|
+
return { name, lane, frame };
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function flush() {
|
|
172
|
+
if (inFlight || stopped || !connected || !synced || conn === null) return;
|
|
173
|
+
const next = nextFrame();
|
|
174
|
+
if (!next) return;
|
|
175
|
+
const { name, lane, frame } = next;
|
|
176
|
+
lanes.delete(name);
|
|
177
|
+
lanes.set(name, lane);
|
|
178
|
+
const sentEpoch = epoch;
|
|
179
|
+
frame.sentOn = connection;
|
|
180
|
+
frame.sent = true;
|
|
181
|
+
// Refusal is evidence about earlier attempts only: this one may apply.
|
|
182
|
+
frame.refused = false;
|
|
183
|
+
inFlight = true;
|
|
184
|
+
flying = frame;
|
|
185
|
+
lastSentAt = now();
|
|
186
|
+
let result;
|
|
187
|
+
try {
|
|
188
|
+
result = await transport.send(clientId, { name, n: frame.n, ops: frame.ops, epoch: sentEpoch, ...(conn !== undefined && { conn }) });
|
|
189
|
+
} catch {
|
|
190
|
+
result = null;
|
|
191
|
+
}
|
|
192
|
+
inFlight = false;
|
|
193
|
+
flying = null;
|
|
194
|
+
if (stopped) return;
|
|
195
|
+
if (frame.undecided) decide(name, lane, frame, result || { status: 0 });
|
|
196
|
+
else if (sentEpoch === epoch && lane.awaiting.includes(frame)) answered(name, lane, frame, result || { status: 0 });
|
|
197
|
+
schedule();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The answer to a frame sent to the room before this one. Refused unapplied
|
|
201
|
+
// (stale, a 4xx, 429/503): it goes again here. Otherwise the old room applied
|
|
202
|
+
// it or may have: it is dropped, never sent again.
|
|
203
|
+
function decide(name, lane, frame, { status, body }) {
|
|
204
|
+
delete frame.undecided;
|
|
205
|
+
if (!lane.awaiting.includes(frame)) return;
|
|
206
|
+
const refused = body?.stale === true || unhandled(status) || (status >= 400 && status < 500 && !transient(status));
|
|
207
|
+
if (refused && !frame.uncertain) {
|
|
208
|
+
frame.refused = true;
|
|
209
|
+
frame.sent = false;
|
|
210
|
+
frame.seq = null;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
lane.awaiting.splice(lane.awaiting.indexOf(frame), 1);
|
|
214
|
+
const store = entries.get(name)?.store;
|
|
215
|
+
store?.rebase(store.authoritative, { source: "state", through: frame.through });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Every frame the room ordered at or before the applied seq is in the
|
|
219
|
+
// authoritative values now, whether its answer or its event said so.
|
|
220
|
+
function settleAwaiting() {
|
|
221
|
+
for (const [name, lane] of lanes) {
|
|
222
|
+
let through;
|
|
223
|
+
while (lane.awaiting.length && lane.awaiting[0].seq !== null && lane.awaiting[0].seq <= seq) {
|
|
224
|
+
through = lane.awaiting.shift().through;
|
|
225
|
+
}
|
|
226
|
+
if (through === undefined) continue;
|
|
227
|
+
const store = entries.get(name)?.store;
|
|
228
|
+
store?.rebase(store.authoritative, { source: "state", through });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function answered(name, lane, frame, { status, body, retryAfter }) {
|
|
233
|
+
if (status >= 200 && status < 300) {
|
|
234
|
+
retryDelay = RETRY_MS;
|
|
235
|
+
// Made for an earlier room and refused unapplied: it goes again once this
|
|
236
|
+
// tab has the new room's state (which may already be here).
|
|
237
|
+
if (body?.stale) {
|
|
238
|
+
frame.sent = false;
|
|
239
|
+
frame.refused = true;
|
|
240
|
+
if (body.epoch !== epoch) resync();
|
|
241
|
+
else schedule();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (body?.epoch && body.epoch !== epoch) {
|
|
245
|
+
resync();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
frame.seq = Number.isInteger(body?.seq) ? body.seq : null;
|
|
249
|
+
// Its event already went by, or came inside a state this tab took: the ops are in.
|
|
250
|
+
settleAwaiting();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (transient(status)) {
|
|
254
|
+
frame.sent = false;
|
|
255
|
+
if (unhandled(status)) frame.refused = true;
|
|
256
|
+
else {
|
|
257
|
+
frame.uncertain = true;
|
|
258
|
+
// The earliest connection an attempt that may have applied was sent on.
|
|
259
|
+
frame.uncertainSince ??= frame.sentOn;
|
|
260
|
+
}
|
|
261
|
+
// Sent under a connection the host no longer has: read the state for the
|
|
262
|
+
// current one, which also settles or resends this frame by its receipt.
|
|
263
|
+
if (status === 503 && body?.code === "not-connected") {
|
|
264
|
+
conn = null;
|
|
265
|
+
resync();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// The room holds a receipt for every connected sender and has no room for
|
|
269
|
+
// this one: this store goes at save speed until the room takes it back.
|
|
270
|
+
if (status === 503 && body?.code === "busy") {
|
|
271
|
+
console.warn(`clay.live("${name}"): the room is full, so it stays at save speed for now`);
|
|
272
|
+
roomNames.delete(name);
|
|
273
|
+
leaveRoom(name, lane);
|
|
274
|
+
setTimer(resync, (retryAfter > 0 ? retryAfter : 60) * 1000);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// Its fate is unknown and the stream reconnected while it was on the wire:
|
|
278
|
+
// a state read on the new connection decides it, by its receipt or none.
|
|
279
|
+
if (frame.uncertain && frame.sentOn !== connection) {
|
|
280
|
+
resync();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
holdUntil = now() + (retryAfter > 0 ? retryAfter * 1000 : retryDelay);
|
|
284
|
+
retryDelay = Math.min(retryDelay * 2, RETRY_MAX_MS);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (REVOKED.has(status)) {
|
|
288
|
+
if (!frame.uncertain) frame.refused = true;
|
|
289
|
+
stop();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const reason = body?.error || body?.code || `status ${status}`;
|
|
293
|
+
if (status === 404) {
|
|
294
|
+
console.warn(`clay.live("${name}"): the room has no such store, so it stays at save speed (${reason})`);
|
|
295
|
+
frame.refused = true;
|
|
296
|
+
roomNames.delete(name);
|
|
297
|
+
leaveRoom(name, lane);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
lane.awaiting.splice(lane.awaiting.indexOf(frame), 1);
|
|
301
|
+
const store = entries.get(name)?.store;
|
|
302
|
+
console.warn(`clay.live("${name}"): the host refused an update, so it was undone (${reason})`);
|
|
303
|
+
store?.rebase(store.authoritative, { source: "state", through: frame.through });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function apply(frame) {
|
|
307
|
+
seq = frame.seq;
|
|
308
|
+
const store = entries.get(frame.name)?.store;
|
|
309
|
+
if (!store || !roomNames.has(frame.name)) return;
|
|
310
|
+
let next;
|
|
311
|
+
try {
|
|
312
|
+
next = applyOps(cloneJson(store.authoritative), frame.ops);
|
|
313
|
+
} catch {
|
|
314
|
+
resync();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
let through;
|
|
318
|
+
const own = frame.from === clientId;
|
|
319
|
+
if (own) {
|
|
320
|
+
const lane = laneOf(frame.name);
|
|
321
|
+
const i = lane.awaiting.findIndex((sent) => sent.n === frame.n);
|
|
322
|
+
if (i >= 0) {
|
|
323
|
+
through = lane.awaiting[i].through;
|
|
324
|
+
lane.awaiting.splice(0, i + 1);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
store.rebase(next, { source: own ? "state" : "remote", through });
|
|
328
|
+
entries.get(frame.name).last = frame.seq;
|
|
329
|
+
onApplied();
|
|
330
|
+
if (frame.checkpoint === true) onCheckpoint();
|
|
331
|
+
settleAwaiting();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function onFrame(frame) {
|
|
335
|
+
if (stopped || !frame || !Number.isInteger(frame.seq)) return;
|
|
336
|
+
if (!synced) {
|
|
337
|
+
buffered.push(frame);
|
|
338
|
+
if (buffered.length > BUFFER_MAX) buffered.shift();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (frame.epoch !== epoch) {
|
|
342
|
+
buffered.push(frame);
|
|
343
|
+
resync();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (frame.seq <= seq) return;
|
|
347
|
+
if (frame.seq !== seq + 1) {
|
|
348
|
+
buffered.push(frame);
|
|
349
|
+
resync();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
apply(frame);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// A new room: what becomes of this tab's frames still awaiting an answer.
|
|
356
|
+
// A frame a local successor's receipts (seen) say it holds settles. A frame
|
|
357
|
+
// every attempt of which was refused unapplied goes again. Any other frame the
|
|
358
|
+
// old room may have applied is dropped, never sent again: a late checkpoint of
|
|
359
|
+
// the old room may bring that move back, so sending it again could count it
|
|
360
|
+
// twice. Frames go one at a time and a refusal stops
|
|
361
|
+
// sending until the next state, so the settled or dropped frames come first.
|
|
362
|
+
// Returns the frames to send again and the op id the rest reach (0: none).
|
|
363
|
+
function sortAwaiting(awaiting, room) {
|
|
364
|
+
const seen = room.seen ? (room.seen[clientId] ?? 0) : null;
|
|
365
|
+
const resend = [];
|
|
366
|
+
let through = 0;
|
|
367
|
+
for (const frame of awaiting) {
|
|
368
|
+
if (seen !== null && frame.n <= seen) through = frame.through;
|
|
369
|
+
else if (frame.sent && frame === flying) {
|
|
370
|
+
// Still on the wire: its answer decides.
|
|
371
|
+
frame.undecided = true;
|
|
372
|
+
resend.push(frame);
|
|
373
|
+
} else if (unapplied(frame)) resend.push(frame);
|
|
374
|
+
else through = frame.through;
|
|
375
|
+
}
|
|
376
|
+
return { resend, through };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function applyState(state) {
|
|
380
|
+
if (stopped || !state || typeof state.epoch !== "string") return;
|
|
381
|
+
stateCount++;
|
|
382
|
+
// Older than what this tab already applied: keep what it has.
|
|
383
|
+
if (state.epoch === epoch && Number.isInteger(state.seq) && state.seq < seq) {
|
|
384
|
+
drain();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const fresh = state.epoch !== epoch;
|
|
388
|
+
let joined = false;
|
|
389
|
+
epoch = state.epoch;
|
|
390
|
+
seq = Number.isInteger(state.seq) ? state.seq : 0;
|
|
391
|
+
writer = state.writer ?? null;
|
|
392
|
+
if ("conn" in state) conn = state.conn;
|
|
393
|
+
const stores = state.stores || {};
|
|
394
|
+
roomNames = new Set(Object.keys(stores));
|
|
395
|
+
for (const [name, entry] of entries) {
|
|
396
|
+
const room = stores[name];
|
|
397
|
+
const lane = laneOf(name);
|
|
398
|
+
if (!room) {
|
|
399
|
+
leaveRoom(name, lane);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const rejoined = entry.store.handle.status.mode === "file";
|
|
403
|
+
entry.store.setMode("fast");
|
|
404
|
+
entry.last = Number.isInteger(room.last) ? room.last : 0;
|
|
405
|
+
entry.landed = Number.isInteger(room.landed) ? room.landed : 0;
|
|
406
|
+
if (fresh) {
|
|
407
|
+
const { resend, through } = sortAwaiting(lane.awaiting, room);
|
|
408
|
+
for (const frame of resend) {
|
|
409
|
+
if (frame.undecided) continue;
|
|
410
|
+
frame.sent = false;
|
|
411
|
+
frame.seq = null;
|
|
412
|
+
}
|
|
413
|
+
lane.awaiting = resend;
|
|
414
|
+
entry.store.rebase(room.value, { source: "state", through: through || undefined });
|
|
415
|
+
lane.unsent = unframed(name, lane);
|
|
416
|
+
} else {
|
|
417
|
+
const seen = room.seen ? room.seen[clientId] : undefined;
|
|
418
|
+
const next = [];
|
|
419
|
+
let through;
|
|
420
|
+
for (const frame of lane.awaiting) {
|
|
421
|
+
if (frame === flying) {
|
|
422
|
+
next.push(frame);
|
|
423
|
+
} else if ((frame.seq !== null && frame.seq <= seq) || (seen !== undefined && frame.n <= seen)) {
|
|
424
|
+
// In this state, by its answer or by the room's receipt.
|
|
425
|
+
through = frame.through;
|
|
426
|
+
} else if (frame.seq !== null) {
|
|
427
|
+
// Answered as applied after this state was read: its event is on its way.
|
|
428
|
+
next.push(frame);
|
|
429
|
+
} else if (!frame.uncertain || seen !== undefined || frame.uncertainSince === connection) {
|
|
430
|
+
// Provably not applied: every attempt refused before handling, above
|
|
431
|
+
// the room's receipt for this tab, or every attempt that may have
|
|
432
|
+
// applied went on this connection, which never dropped (its receipt
|
|
433
|
+
// cannot have gone). It goes again with the same n.
|
|
434
|
+
frame.sent = false;
|
|
435
|
+
next.push(frame);
|
|
436
|
+
} else {
|
|
437
|
+
// Unanswered, and the stream reconnected since: its receipt may be gone.
|
|
438
|
+
through = frame.through;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
lane.awaiting = next;
|
|
442
|
+
entry.store.rebase(room.value, { source: "state", through });
|
|
443
|
+
// Back in the room after a spell at save speed: frame what was made meanwhile.
|
|
444
|
+
if (rejoined) {
|
|
445
|
+
lane.unsent = unframed(name, lane);
|
|
446
|
+
joined = true;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (fresh || joined) onEpoch(epoch);
|
|
451
|
+
onApplied();
|
|
452
|
+
onWriter();
|
|
453
|
+
drain();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Back in step: apply what arrived while the state was being read.
|
|
457
|
+
function drain() {
|
|
458
|
+
synced = true;
|
|
459
|
+
const later = buffered.filter((frame) => frame.epoch === epoch && frame.seq > seq).sort((a, b) => a.seq - b.seq);
|
|
460
|
+
buffered = [];
|
|
461
|
+
for (const frame of later) onFrame(frame);
|
|
462
|
+
schedule();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Read the room's whole state. Frames wait in the buffer meanwhile, so the
|
|
466
|
+
// answer and the frames after it apply in order.
|
|
467
|
+
async function resync() {
|
|
468
|
+
if (stopped) return;
|
|
469
|
+
if (fetching) {
|
|
470
|
+
fetchAgain = true;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
fetching = true;
|
|
474
|
+
fetchAgain = false;
|
|
475
|
+
synced = false;
|
|
476
|
+
const asked = { connection, stateCount };
|
|
477
|
+
let state = null;
|
|
478
|
+
try {
|
|
479
|
+
state = await transport.fetchState(clientId);
|
|
480
|
+
} catch {}
|
|
481
|
+
fetching = false;
|
|
482
|
+
if (stopped) return;
|
|
483
|
+
if (asked.connection !== connection || fetchAgain) {
|
|
484
|
+
if (connected) resync();
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (asked.stateCount !== stateCount) {
|
|
488
|
+
if (synced) return;
|
|
489
|
+
state = null;
|
|
490
|
+
}
|
|
491
|
+
if (state && typeof state.epoch === "string") {
|
|
492
|
+
resyncDelay = RETRY_MS;
|
|
493
|
+
applyState(state);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (state && STATE_GONE.has(state.status)) {
|
|
497
|
+
stop();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (connected) {
|
|
501
|
+
setTimer(resync, resyncDelay);
|
|
502
|
+
resyncDelay = Math.min(resyncDelay * 2, RETRY_MAX_MS);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function stop() {
|
|
507
|
+
if (stopped) return;
|
|
508
|
+
stopped = true;
|
|
509
|
+
if (timer !== null) clearTimer(timer);
|
|
510
|
+
timer = null;
|
|
511
|
+
unsubscribe?.();
|
|
512
|
+
for (const [name, entry] of entries) {
|
|
513
|
+
const lane = lanes.get(name);
|
|
514
|
+
if (lane) leaveRoom(name, lane);
|
|
515
|
+
else {
|
|
516
|
+
onLeave(name, { epoch, seq });
|
|
517
|
+
entry.store.setMode("file");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
onStop();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const handlers = {
|
|
524
|
+
open(info) {
|
|
525
|
+
clientId = info.clientId;
|
|
526
|
+
connected = true;
|
|
527
|
+
connection++;
|
|
528
|
+
resync();
|
|
529
|
+
},
|
|
530
|
+
close() {
|
|
531
|
+
connected = false;
|
|
532
|
+
connection++;
|
|
533
|
+
synced = false;
|
|
534
|
+
buffered = [];
|
|
535
|
+
},
|
|
536
|
+
frame: onFrame,
|
|
537
|
+
state: applyState,
|
|
538
|
+
// The room's writer, and how far the file reaches when a save landed.
|
|
539
|
+
writer(data) {
|
|
540
|
+
if (!data || data.epoch !== epoch) return;
|
|
541
|
+
writer = data.writer ?? null;
|
|
542
|
+
if (data.landed && typeof data.landed === "object") {
|
|
543
|
+
// A state read in flight was taken before this: its landed is older.
|
|
544
|
+
stateCount++;
|
|
545
|
+
for (const [name, landed] of Object.entries(data.landed)) {
|
|
546
|
+
const entry = entries.get(name);
|
|
547
|
+
if (entry && Number.isInteger(landed)) entry.landed = landed;
|
|
548
|
+
}
|
|
549
|
+
onLanded();
|
|
550
|
+
}
|
|
551
|
+
onWriter();
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
start() {
|
|
557
|
+
if (!unsubscribe && !stopped) unsubscribe = transport.start(handlers);
|
|
558
|
+
},
|
|
559
|
+
// A store committed ops; lastId is the id of the last one (store.mark).
|
|
560
|
+
local(name, ops, lastId) {
|
|
561
|
+
if (stopped || epoch === null || !roomNames.has(name)) return;
|
|
562
|
+
const lane = laneOf(name);
|
|
563
|
+
const first = lastId - ops.length + 1;
|
|
564
|
+
ops.forEach((op, i) => lane.unsent.push({ id: first + i, op }));
|
|
565
|
+
schedule();
|
|
566
|
+
},
|
|
567
|
+
// The save that held this store ended: frame whatever is still pending past
|
|
568
|
+
// what was framed (an accepted save's ops have left pending).
|
|
569
|
+
release(name) {
|
|
570
|
+
if (stopped || epoch === null || !roomNames.has(name)) return;
|
|
571
|
+
laneOf(name).unsent = unframed(name, laneOf(name));
|
|
572
|
+
schedule();
|
|
573
|
+
},
|
|
574
|
+
// Read the room again: a store opened after this tab joined, or a save the
|
|
575
|
+
// room merged whose state may not have arrived yet.
|
|
576
|
+
refresh() {
|
|
577
|
+
if (epoch !== null && connected) resync();
|
|
578
|
+
},
|
|
579
|
+
storeOpened() {
|
|
580
|
+
if (epoch !== null && connected) resync();
|
|
581
|
+
},
|
|
582
|
+
isWriter() {
|
|
583
|
+
return writer !== null && writer === clientId;
|
|
584
|
+
},
|
|
585
|
+
get status() {
|
|
586
|
+
return { connected, synced, epoch, seq, writer, clientId, stopped };
|
|
587
|
+
},
|
|
588
|
+
stop,
|
|
589
|
+
};
|
|
590
|
+
}
|