@panphora/clayjs 1.12.2 → 1.13.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.
@@ -0,0 +1,578 @@
1
+ // The replayed merge for one text block: pure functions, no store, no lane, no
2
+ // timers. Every tab feeds a block's ops to `step` (a set or a delete) and
3
+ // `tombstone` in room order, and gets the same agreed value S.
4
+ //
5
+ // A text block is { t, c, m?, ...fields }: c is its flat text, m (optional) one
6
+ // mark key per UTF-16 unit of c ("" for none). A write made by the page binding
7
+ // also carries w, which never reaches S or the saved file:
8
+ // a the writing tab
9
+ // e the epoch it was made in
10
+ // k the seq its editor was last released at: its steps extend S at k
11
+ // s its splices in order, [p, e, removed, n, removedMarks?]: offsets into the
12
+ // text before that splice, whose length is n. The base is derived from the
13
+ // write itself (chainOf), so a tab with no history can still place it.
14
+ // g on a repair: the seq the writer computed S through
15
+ // h on a heal: the seq the healing tab had reached in the lost room
16
+ // u on a heal: per splice, the lost room's seq of its write (null: unknown)
17
+ // f the non-text fields the write changed: S takes only those from it, so a
18
+ // typing write never undoes a peer's toggle or move. Without f, S takes
19
+ // every field of the write.
20
+ //
21
+ // Placement, the first that applies:
22
+ // 1. the step mapper, when this replay's history reaches back to k: positions
23
+ // map through every write after k, ties at one spot go to the lower tab id,
24
+ // and a delete removes only characters its writer had seen;
25
+ // 2. the self-described base placed by a diff (placeSplice), with a notice
26
+ // when a delete cannot be placed exactly;
27
+ // 3. merge3Text, with the losing side in a notice.
28
+ import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
29
+ const { merge3Text, diff } = HyperMorph;
30
+
31
+ export const LIMITS = { logEntries: 1024, logBytes: 256 * 1024 };
32
+
33
+ export const strip = (v) => {
34
+ if (!v) return null;
35
+ const { w, ...rest } = v;
36
+ return rest;
37
+ };
38
+ const canon = (v) => (Array.isArray(v) ? `[${v.map(canon).join(",")}]` : v && typeof v === "object" ? `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canon(v[k])).join(",")}}` : JSON.stringify(v ?? null));
39
+ // 64 bits in two 32-bit lanes, so a divergent value never passes as agreed.
40
+ function hash64(text) {
41
+ let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
42
+ for (let i = 0; i < text.length; i++) {
43
+ const ch = text.charCodeAt(i);
44
+ h1 = Math.imul(h1 ^ ch, 2654435761);
45
+ h2 = Math.imul(h2 ^ ch, 1597334677);
46
+ }
47
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
48
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
49
+ return (h1 >>> 0).toString(16).padStart(8, "0") + (h2 >>> 0).toString(16).padStart(8, "0");
50
+ }
51
+ export const hashOf = (v) => hash64(canon(strip(v)));
52
+
53
+ const blank = (n) => new Array(n).fill("");
54
+ const put = (arr, from, to, items) => [...arr.slice(0, from), ...items, ...arr.slice(to)];
55
+ const marksOf = (v) => (v && Array.isArray(v.m) && v.m.length === v.c.length ? v.m : null);
56
+ // The fields S takes from write X (see f above).
57
+ function fieldsOf(r, X) {
58
+ const f = X.w && X.w.f;
59
+ if (!Array.isArray(f) || !r.S) return strip(X);
60
+ const out = { ...r.S };
61
+ for (const name of f) {
62
+ if (name === "c" || name === "m" || name === "w") continue;
63
+ if (Object.prototype.hasOwnProperty.call(X, name)) out[name] = X[name];
64
+ else delete out[name];
65
+ }
66
+ return out;
67
+ }
68
+
69
+ export function withMarks(fields, c, marks) {
70
+ const { c: _c, m: _m, w: _w, ...rest } = fields;
71
+ return marks && marks.some(Boolean) ? { ...rest, c, m: marks } : { ...rest, c };
72
+ }
73
+ const isHigh = (u) => u >= 0xd800 && u <= 0xdbff;
74
+ const isLow = (u) => u >= 0xdc00 && u <= 0xdfff;
75
+
76
+ // The single splice that turns a into b, never cutting a surrogate pair.
77
+ export function spliceOf(a, b) {
78
+ const n = Math.min(a.length, b.length);
79
+ let p = 0;
80
+ while (p < n && a.charCodeAt(p) === b.charCodeAt(p)) p++;
81
+ if (p > 0 && isHigh(a.charCodeAt(p - 1))) p--;
82
+ let s = 0;
83
+ while (s < n - p && a.charCodeAt(a.length - 1 - s) === b.charCodeAt(b.length - 1 - s)) s++;
84
+ if (s > 0 && isLow(a.charCodeAt(a.length - s))) s--;
85
+ return { p, e: a.length - s, t: b.slice(p, b.length - s) };
86
+ }
87
+
88
+ // The changes from oldText to newText as { from, to, t } in old offsets.
89
+ export function regions(oldText, newText) {
90
+ if (oldText === newText) return [];
91
+ return diff(oldText, newText).map((h) => {
92
+ const sp = spliceOf(oldText.slice(h.bs, h.be), h.text);
93
+ return { from: h.bs + sp.p, to: h.bs + sp.e, t: sp.t };
94
+ });
95
+ }
96
+
97
+ // A caret through those changes; text inserted at the caret goes after it.
98
+ export function mapOffset(oldText, newText, offset) {
99
+ let delta = 0;
100
+ for (const g of regions(oldText, newText)) {
101
+ if (offset <= g.from) break;
102
+ if (offset >= g.to) { delta += g.t.length - (g.to - g.from); continue; }
103
+ return g.from + delta + g.t.length;
104
+ }
105
+ return offset + delta;
106
+ }
107
+
108
+ // Where offset q of the base sits in `side`: a change starting at q stays after
109
+ // it; inside a range the other side removed, the start of that range; inside a
110
+ // range the other side replaced with other text, null.
111
+ function mapHunks(hunks, q) {
112
+ let delta = 0;
113
+ for (const h of hunks) {
114
+ if (h.from >= q) break;
115
+ if (h.to <= q) { delta += h.t.length - (h.to - h.from); continue; }
116
+ if (h.t === "") return h.from + delta;
117
+ return null;
118
+ }
119
+ return q + delta;
120
+ }
121
+
122
+ // A writer's splice { from, to, t } of `base`, placed in `side`, or null when
123
+ // the other side changed text inside its range.
124
+ export function placeSplice(base, side, sp) {
125
+ const hunks = regions(base, side);
126
+ for (const h of hunks) {
127
+ if (h.t !== "" && h.to > h.from && h.from < sp.to && h.to > sp.from) return null;
128
+ if (h.from === h.to && h.from > sp.from && h.from < sp.to) return null;
129
+ }
130
+ const from = mapHunks(hunks, sp.from);
131
+ const to = sp.to === sp.from ? from : mapHunks(hunks, sp.to);
132
+ if (from === null || to === null || to < from) return null;
133
+ return { from, to, t: sp.t };
134
+ }
135
+
136
+ // The write's base and steps, derived backward from its own text and splices.
137
+ // Each step: { from, to, t, tm, removed, rm }, offsets into the text before it.
138
+ export function chainOf(X) {
139
+ const s = X && X.w && X.w.s;
140
+ if (!Array.isArray(s) || !s.length || typeof X.c !== "string") return null;
141
+ let c = X.c;
142
+ let m = marksOf(X) ?? blank(c.length);
143
+ const steps = [];
144
+ for (let i = s.length - 1; i >= 0; i--) {
145
+ if (!Array.isArray(s[i])) return null;
146
+ const [p, e, removed, n, rm] = s[i];
147
+ if (typeof removed !== "string" || !Number.isInteger(p) || !Number.isInteger(n) || p < 0 || e < p || e > n || e - p !== removed.length) return null;
148
+ const len = c.length - p - (n - e);
149
+ if (len < 0 || p + len > c.length) return null;
150
+ const removedMarks = Array.isArray(rm) && rm.length === removed.length ? rm : blank(removed.length);
151
+ steps.unshift({ from: p, to: e, t: c.slice(p, p + len), tm: m.slice(p, p + len), removed, rm: removedMarks });
152
+ c = c.slice(0, p) + removed + c.slice(p + len);
153
+ m = put(m, p, p + len, removedMarks);
154
+ }
155
+ return { base: { c, m }, steps };
156
+ }
157
+
158
+ // The splice a local edit makes: [from, to) of `before` replaced.
159
+ export function spliceFor(before, beforeMarks, from, to) {
160
+ const rm = beforeMarks ? beforeMarks.slice(from, to) : [];
161
+ return [from, to, before.slice(from, to), before.length, ...(rm.some(Boolean) ? [rm] : [])];
162
+ }
163
+
164
+ // Splices that turn fromText into toText, right to left so each one's offsets
165
+ // still hold.
166
+ export function chainFromDiff(fromText, fromMarks, toText) {
167
+ const out = [];
168
+ let n = fromText.length;
169
+ for (const g of regions(fromText, toText).reverse()) {
170
+ out.push(spliceFor(fromText, fromMarks, g.from, g.to).map((v, i) => (i === 3 ? n : v)));
171
+ n += g.t.length - (g.to - g.from);
172
+ }
173
+ return out;
174
+ }
175
+
176
+ // Private-use encoding for merge3Text: every marked UTF-16 unit, and every
177
+ // private-use unit, becomes one code of its own, so text and marks merge
178
+ // together and private-use input never decodes as something else.
179
+ export function encodeForMerge(values) {
180
+ const pua = (u) => u >= 0xe000 && u <= 0xf8ff;
181
+ const keyOf = (v, i) => (v.m[i] || pua(v.c.charCodeAt(i)) ? v.c[i] + "\u0000" + v.m[i] : null);
182
+ const keys = new Set();
183
+ for (const v of values) for (let i = 0; i < v.c.length; i++) { const k = keyOf(v, i); if (k !== null) keys.add(k); }
184
+ const sorted = [...keys].sort();
185
+ if (sorted.length > 0xf8ff - 0xe000 + 1) return null;
186
+ const code = new Map(sorted.map((k, i) => [k, String.fromCharCode(0xe000 + i)]));
187
+ const back = new Map(sorted.map((k, i) => [String.fromCharCode(0xe000 + i), k]));
188
+ const encode = (v) => { let out = ""; for (let i = 0; i < v.c.length; i++) { const k = keyOf(v, i); out += k === null ? v.c[i] : code.get(k); } return out; };
189
+ const decode = (s) => {
190
+ let c = "";
191
+ const m = [];
192
+ for (let i = 0; i < s.length; i++) {
193
+ const k = back.get(s[i]);
194
+ c += k ? k[0] : s[i];
195
+ m.push(k ? k.slice(2) : "");
196
+ }
197
+ return { c, m };
198
+ };
199
+ return { strings: values.map(encode), decode };
200
+ }
201
+
202
+ export function createReplay(seed, { seq = 0, epoch = null, me = null } = {}) {
203
+ const r = {
204
+ me, epoch, seq, S: null, marks: null, prov: null, tomb: false,
205
+ log: [], logBytes: 0, floor: seq, kept: seq, baseHash: null, epochStart: seq, healH: -1, behind: false,
206
+ conflicts: [], stats: { ot: 0, fallback: 0, merge: 0, whole: 0, inexact: 0, rewinds: 0, refused: 0, agreed: 0, healMerges: 0 },
207
+ };
208
+ setWhole(r, seed, null, seq);
209
+ r.baseHash = hashOf(r.S);
210
+ return r;
211
+ }
212
+
213
+ function setWhole(r, v, a, q) {
214
+ if (!v || r.tomb) { r.S = null; r.marks = null; r.prov = null; return; }
215
+ const S = strip(v);
216
+ r.S = withMarks(S, S.c, marksOf(S));
217
+ r.marks = marksOf(S) ? [...S.m] : blank(S.c.length);
218
+ r.prov = Array.from({ length: S.c.length }, () => ({ a, q }));
219
+ }
220
+
221
+ function note(r, c) {
222
+ if (r.conflicts.some((x) => x.kind === c.kind && x.lost === c.lost && x.seq === c.seq && x.by === c.by)) return;
223
+ r.conflicts.push(c);
224
+ }
225
+
226
+ // Text this tab typed that S no longer has, after a change that is not a
227
+ // placed write (a repair, a heal, a join, a whole write): it goes in a notice.
228
+ function noticeJump(r, before, beforeProv, kind, seq) {
229
+ if (!before || !beforeProv || !r.me) return;
230
+ const after = r.S ? r.S.c : "";
231
+ for (const g of regions(before.c, after)) {
232
+ if (g.to <= g.from) continue;
233
+ let mine = false;
234
+ for (let i = g.from; i < g.to; i++) if (beforeProv[i] && beforeProv[i].a === r.me) mine = true;
235
+ const lost = before.c.slice(g.from, g.to);
236
+ if (mine && lost.trim() && !g.t.includes(lost)) note(r, { kind, lost, kept: g.t, seq });
237
+ }
238
+ }
239
+
240
+ function push(r, entry) {
241
+ entry.bytes = entry.X ? JSON.stringify(entry.X).length : 8;
242
+ r.log.push(entry);
243
+ r.logBytes += entry.bytes;
244
+ while (r.log.length > LIMITS.logEntries || r.logBytes > LIMITS.logBytes) {
245
+ const gone = r.log.shift();
246
+ r.logBytes -= gone.bytes;
247
+ r.floor = Math.max(r.floor, gone.seq);
248
+ r.kept = Math.max(r.kept, gone.seq);
249
+ r.baseHash = gone.hash;
250
+ }
251
+ }
252
+
253
+ function hashAt(r, n) {
254
+ for (let i = r.log.length - 1; i >= 0; i--) if (r.log[i].seq <= n) return r.log[i].hash;
255
+ return n >= r.kept ? r.baseHash : null;
256
+ }
257
+
258
+ const newEntry = (seq, X) => ({ seq, a: X && X.w ? X.w.a : null, X, events: [], authored: [], applied: [], whole: false, hash: null });
259
+
260
+ // The shard's tombstone for the block landed: it stays deleted. Writes under it
261
+ // are kept in notices; the writer's repair deletes the block again.
262
+ export function tombstone(r, seq) {
263
+ const entry = newEntry(seq, null);
264
+ entry.tombstone = true;
265
+ entry.whole = true;
266
+ r.tomb = true;
267
+ setWhole(r, null);
268
+ push(r, entry);
269
+ entry.hash = hashOf(r.S);
270
+ r.floor = Math.max(r.floor, seq);
271
+ r.seq = seq;
272
+ return r.S;
273
+ }
274
+
275
+ // One op on the block, in room order: X is the value set there, or null for
276
+ // its delete.
277
+ export function step(r, X, seq) {
278
+ const w = X && X.w;
279
+ const entry = newEntry(seq, X);
280
+ push(r, entry);
281
+ if (w && w.g !== undefined) repair(r, X, seq, entry);
282
+ else if (r.tomb) {
283
+ if (X && w && typeof w.a === "string") note(r, { kind: "removed", lost: X.c, by: w.a, seq });
284
+ entry.skipped = true;
285
+ } else if (w && w.h !== undefined) heal(r, X, seq, entry);
286
+ else apply(r, X, seq, entry, false);
287
+ if (entry.whole) r.floor = Math.max(r.floor, seq);
288
+ entry.hash = hashOf(r.S);
289
+ r.seq = seq;
290
+ return r.S;
291
+ }
292
+
293
+ function apply(r, X, seq, entry, forced) {
294
+ if (!X) { setWhole(r, null); entry.whole = true; return; }
295
+ const w = X.w;
296
+ if (!w || typeof w.a !== "string" || !r.S) {
297
+ if (!r.S && w && typeof w.a === "string") { note(r, { kind: "removed", lost: X.c, by: w.a, seq }); entry.skipped = true; return; }
298
+ // A create, a restore, a data API write: it wins whole.
299
+ return whole(r, X, null, seq, entry);
300
+ }
301
+ // Made in another epoch and sent again by its lane: its tab's heal carries it.
302
+ if (typeof w.e === "string" && w.e !== r.epoch) { entry.skipped = true; return; }
303
+ const ch = chainOf(X);
304
+ if (!ch) return whole(r, X, w.a, seq, entry);
305
+ entry.k = w.k;
306
+ if (!forced && w.e === r.epoch && Number.isInteger(w.k) && w.k >= r.floor && w.k <= r.seq) placeSteps(r, X, entry, ch.steps);
307
+ else placeByBase(r, X, entry, ch, seq);
308
+ }
309
+
310
+ function whole(r, X, a, seq, entry) {
311
+ const before = r.S, prov = r.prov;
312
+ setWhole(r, X, a, seq);
313
+ noticeJump(r, before, prov, "replaced", seq);
314
+ entry.whole = true;
315
+ r.stats.whole++;
316
+ }
317
+
318
+ // Offset q of writer x's editor (S at k, then x's own steps since k) in S now.
319
+ function mapPos(r, x, k, own, q) {
320
+ const rec = new Map();
321
+ for (let j = own.length - 1; j >= 0; j--) {
322
+ const s = own[j], end = s.from + s.t.length;
323
+ if (q >= s.from && q <= end) { rec.set(j, q - s.from); q = s.from; }
324
+ else if (q > end) q -= s.t.length - (s.to - s.from);
325
+ }
326
+ let i = 0;
327
+ for (const e of r.log) {
328
+ if (e.seq <= k) continue;
329
+ for (const ev of e.events) {
330
+ if (ev.own) { if (e.a === x) { if (rec.has(i)) q = ev.at + rec.get(i); i++; } continue; }
331
+ const { from, to, t, a } = ev;
332
+ if (a === x) {
333
+ if (q > from && q < to) q = from;
334
+ else if (q >= to && !(q === from && from === to)) q += t.length - (to - from);
335
+ continue;
336
+ }
337
+ if (q < from) continue;
338
+ if (q > to) { q += t.length - (to - from); continue; }
339
+ if (q === to && to > from) { q = from + t.length; continue; }
340
+ q = x < a ? from : from + t.length;
341
+ }
342
+ }
343
+ return q;
344
+ }
345
+
346
+ const ownSteps = (r, x, k, except) => r.log.filter((e) => e !== except && e.seq > k && e.a === x).flatMap((e) => e.authored);
347
+
348
+ // 1. The step mapper.
349
+ function placeSteps(r, X, entry, steps) {
350
+ const { a: x, k } = X.w;
351
+ const own = ownSteps(r, x, k, entry);
352
+ const fields = fieldsOf(r, X);
353
+ let c = r.S.c, marks = r.marks, prov = r.prov;
354
+ const seen = (pv) => pv.q <= k || pv.a === x;
355
+ for (const s of steps) {
356
+ const F = mapPos(r, x, k, own, s.from);
357
+ const T = s.to === s.from ? F : Math.max(F, mapPos(r, x, k, own, s.to));
358
+ if (s.to > s.from && s.t === s.removed) {
359
+ // Only the marks changed: the range keeps its text, including what peers
360
+ // typed inside it, and takes the writer's marks.
361
+ const uniform = s.tm.every((key) => key === s.tm[0]);
362
+ const next = marks.slice();
363
+ let j = 0;
364
+ for (let p = F; p < T; p++) {
365
+ if (uniform) next[p] = s.tm[0];
366
+ else if (seen(prov[p])) { while (j < s.t.length && s.t[j] !== c[p]) j++; if (j < s.t.length) next[p] = s.tm[j++]; }
367
+ }
368
+ entry.applied.push([F, T, c.slice(F, T), c.length, marks.slice(F, T)]);
369
+ marks = next;
370
+ } else {
371
+ for (let p = T - 1; p >= F; p--) {
372
+ if (!seen(prov[p])) continue;
373
+ let q = p;
374
+ while (q - 1 >= F && seen(prov[q - 1])) q--;
375
+ entry.applied.push([q, p + 1, c.slice(q, p + 1), c.length, marks.slice(q, p + 1)]);
376
+ entry.events.push({ from: q, to: p + 1, t: "", a: x });
377
+ c = c.slice(0, q) + c.slice(p + 1);
378
+ marks = put(marks, q, p + 1, []);
379
+ prov = put(prov, q, p + 1, []);
380
+ p = q;
381
+ }
382
+ if (s.t) {
383
+ entry.applied.push([F, F, "", c.length]);
384
+ entry.events.push({ from: F, to: F, t: s.t, a: x });
385
+ c = c.slice(0, F) + s.t + c.slice(F);
386
+ marks = put(marks, F, F, s.tm);
387
+ prov = put(prov, F, F, Array.from({ length: s.t.length }, () => ({ a: x, q: entry.seq })));
388
+ }
389
+ }
390
+ entry.events.push({ own: true, at: F });
391
+ own.push(s);
392
+ entry.authored.push(s);
393
+ r.S = withMarks(fields, c, marks);
394
+ r.marks = marks;
395
+ r.prov = prov;
396
+ }
397
+ r.stats.ot++;
398
+ }
399
+
400
+ // 2. The base the write describes, placed by a diff; 3. merge3Text.
401
+ function placeByBase(r, X, entry, ch, seq, { skip = () => false, heal = false } = {}) {
402
+ const x = X.w.a;
403
+ const fields = fieldsOf(r, X);
404
+ let base = ch.base.c;
405
+ let c = r.S.c, marks = r.marks, prov = r.prov;
406
+ const events = [], applied = [], notes = [];
407
+ let ok = true;
408
+ ch.steps.forEach((s, i) => {
409
+ if (!ok) return;
410
+ const nextBase = base.slice(0, s.from) + s.t + base.slice(s.to);
411
+ if (skip(i)) { base = nextBase; return; }
412
+ const placed = placeSplice(base, c, s);
413
+ if (!placed) { ok = false; return; }
414
+ const { from, to } = placed;
415
+ const gone = c.slice(from, to);
416
+ if (s.to > s.from && gone !== s.removed && s.t !== s.removed) notes.push({ kind: "inexact", lost: s.removed, kept: gone, by: x, seq });
417
+ applied.push([from, to, gone, c.length, marks.slice(from, to)]);
418
+ events.push({ from, to, t: s.t, a: x }, { own: true, at: from });
419
+ c = c.slice(0, from) + s.t + c.slice(to);
420
+ marks = put(marks, from, to, s.tm);
421
+ prov = put(prov, from, to, Array.from({ length: s.t.length }, () => ({ a: x, q: seq })));
422
+ base = nextBase;
423
+ });
424
+ if (ok) {
425
+ entry.events.push(...events);
426
+ entry.applied.push(...applied);
427
+ if (!heal) entry.authored.push(...ch.steps);
428
+ for (const n of notes) { note(r, n); r.stats.inexact++; }
429
+ r.S = withMarks(fields, c, marks);
430
+ r.marks = marks;
431
+ r.prov = prov;
432
+ r.stats.fallback++;
433
+ return;
434
+ }
435
+ if (heal) {
436
+ ch.steps.forEach((s, i) => { if (!skip(i) && s.t.trim()) note(r, { kind: "heal", lost: s.t, by: x, seq }); });
437
+ return;
438
+ }
439
+ const values = [ch.base, { c: r.S.c, m: r.marks }, { c: X.c, m: marksOf(X) ?? blank(X.c.length) }];
440
+ const enc = encodeForMerge(values);
441
+ const merged = enc ? merge3Text(enc.strings[0], enc.strings[1], enc.strings[2], "remote") : merge3Text(values[0].c, values[1].c, values[2].c, "remote");
442
+ const out = enc ? enc.decode(merged.text) : { c: merged.text, m: blank(merged.text.length) };
443
+ for (const cf of merged.conflicts) {
444
+ const lost = enc ? enc.decode(cf.local).c : cf.local;
445
+ if (lost.trim()) note(r, { kind: "merge", lost, kept: enc ? enc.decode(cf.remote).c : cf.remote, by: x, seq });
446
+ }
447
+ if (!enc) note(r, { kind: "marks", lost: "", by: x, seq });
448
+ r.S = withMarks(fields, out.c, out.m);
449
+ r.marks = out.m;
450
+ r.prov = Array.from({ length: out.c.length }, () => ({ a: null, q: seq }));
451
+ entry.whole = true;
452
+ r.stats.merge++;
453
+ }
454
+
455
+ // A repair: S as the writer computed it through seq g. A replay with the same
456
+ // hash at g changes nothing. One that differs takes it and places every later
457
+ // write it holds on top, when it holds them all (g at or above `kept`);
458
+ // otherwise it refuses the repair and is behind until it can take one.
459
+ function repair(r, X, seq, entry) {
460
+ const n = X.w.g;
461
+ entry.repair = n;
462
+ if (X.w.e !== r.epoch || r.tomb) return;
463
+ const mine = hashAt(r, n);
464
+ if (mine !== null && mine === hashOf(X)) { r.stats.agreed++; settled(r, n); return; }
465
+ if (Number.isInteger(n) && n >= r.kept && n <= r.seq) {
466
+ rewind(r, n, X, seq, entry);
467
+ r.behind = false;
468
+ settled(r, n);
469
+ return;
470
+ }
471
+ r.stats.refused++;
472
+ r.behind = true;
473
+ }
474
+
475
+ // The writer placed every write through seq n exactly: a delete this replay
476
+ // could only place by a diff before then is no longer in doubt.
477
+ function settled(r, n) {
478
+ r.conflicts = r.conflicts.filter((c) => !(c.kind === "inexact" && c.seq <= n));
479
+ }
480
+
481
+ // S at seq n becomes V, and every later op this replay holds is applied again
482
+ // on top, by its base (its steps were made against the old S). The step mapper
483
+ // resumes for writes made at or after seq m.
484
+ function rewind(r, n, V, m, trigger) {
485
+ r.stats.rewinds++;
486
+ const before = r.S, beforeProv = r.prov;
487
+ const again = r.log.filter((e) => e.seq > n && e !== trigger);
488
+ r.log = [];
489
+ r.logBytes = 0;
490
+ r.tomb = false;
491
+ setWhole(r, V, null, n);
492
+ r.floor = n;
493
+ r.kept = n;
494
+ r.baseHash = hashOf(r.S);
495
+ for (const old of again) {
496
+ const entry = newEntry(old.seq, old.X);
497
+ push(r, entry);
498
+ const w = old.X && old.X.w;
499
+ if (old.tombstone) { entry.tombstone = true; entry.whole = true; r.tomb = true; setWhole(r, null); }
500
+ else if (w && w.g !== undefined) entry.repair = w.g;
501
+ else if (r.tomb) entry.skipped = true;
502
+ else if (w && w.h !== undefined) { entry.heal = true; healMerge(r, old.X, old.seq, entry); }
503
+ else apply(r, old.X, old.seq, entry, true);
504
+ entry.hash = hashOf(r.S);
505
+ }
506
+ push(r, trigger);
507
+ trigger.whole = true;
508
+ r.floor = Math.max(r.floor, m);
509
+ noticeJump(r, before, beforeProv, "repair", m);
510
+ }
511
+
512
+ // A heal after a room loss: the heal from the longest stream of the lost room
513
+ // is S at the new epoch's start, and every other op of the epoch is applied on
514
+ // it again; a shorter heal adds only its writes the longer stream had not seen.
515
+ function heal(r, X, seq, entry) {
516
+ entry.heal = true;
517
+ if (X.w.e !== r.epoch) { entry.skipped = true; return; }
518
+ if (X.w.h > r.healH) {
519
+ r.healH = X.w.h;
520
+ if (r.kept <= r.epochStart) return rewind(r, r.epochStart, X, seq, entry);
521
+ whole(r, X, null, seq, entry);
522
+ r.behind = true;
523
+ return;
524
+ }
525
+ healMerge(r, X, seq, entry);
526
+ }
527
+
528
+ function healMerge(r, X, seq, entry) {
529
+ const ch = chainOf(X);
530
+ if (!ch || !r.S) return;
531
+ const u = Array.isArray(X.w.u) ? X.w.u : [];
532
+ r.stats.healMerges++;
533
+ placeByBase(r, X, entry, ch, seq, { heal: true, skip: (i) => Number.isInteger(u[i]) && u[i] <= r.healH });
534
+ }
535
+
536
+ // The block's value from a state read: S becomes it. `moves` are the room's ops
537
+ // after `since` that the value already holds ({ seq, X } or { seq, tombstone }),
538
+ // kept so a later repair or heal can apply them again. In a new epoch whose
539
+ // moves hold a heal, the ops from the first heal on are stepped as every other
540
+ // tab stepped them, so this tab computes the same S as one that was there.
541
+ export function join(r, X, seq, { epoch = r.epoch, moves = null, since = seq, tomb = false } = {}) {
542
+ const before = r.S, beforeProv = r.prov;
543
+ const fresh = epoch !== r.epoch;
544
+ r.epoch = epoch;
545
+ r.tomb = tomb;
546
+ setWhole(r, X, null, seq);
547
+ r.log = [];
548
+ r.logBytes = 0;
549
+ r.seq = seq;
550
+ r.floor = seq;
551
+ r.kept = moves ? since : seq;
552
+ r.baseHash = hashOf(r.S);
553
+ r.behind = false;
554
+ if (fresh) { r.healH = -1; r.epochStart = r.kept; }
555
+ const list = moves ?? [];
556
+ const first = fresh ? list.findIndex((mv) => mv.X && mv.X.w && mv.X.w.h !== undefined) : -1;
557
+ const held = first < 0 ? list : list.slice(0, first);
558
+ for (const mv of held) {
559
+ const entry = newEntry(mv.seq, mv.X ?? null);
560
+ entry.whole = true;
561
+ entry.tombstone = mv.tombstone === true;
562
+ push(r, entry);
563
+ }
564
+ if (held.length) { r.log[r.log.length - 1].hash = first < 0 ? hashOf(r.S) : null; r.baseHash = null; }
565
+ if (first >= 0) {
566
+ r.seq = held.length ? held[held.length - 1].seq : since;
567
+ for (const mv of list.slice(first)) mv.tombstone ? tombstone(r, mv.seq) : step(r, mv.X ?? null, mv.seq);
568
+ r.seq = Math.max(r.seq, seq);
569
+ }
570
+ if (!fresh) noticeJump(r, before, beforeProv, "joined", seq);
571
+ }
572
+
573
+ // The caret of writer `me`, whose editor is S at k plus its own steps since.
574
+ export function mapCaret(r, me, k, q, viewText) {
575
+ if (!r.S) return 0;
576
+ if (Number.isInteger(k) && k >= r.floor && k <= r.seq) return Math.max(0, Math.min(r.S.c.length, mapPos(r, me, k, ownSteps(r, me, k), q)));
577
+ return Math.max(0, Math.min(r.S.c.length, mapOffset(viewText ?? r.S.c, r.S.c, q)));
578
+ }
@@ -80,7 +80,7 @@ export const schema = new model.Schema({
80
80
  group: "block",
81
81
  atom: true,
82
82
  draggable: true,
83
- attrs: { ...block, src: { default: "" }, alt: { default: "" }, caption: { default: "" }, w: { default: null } },
83
+ attrs: { ...block, src: { default: "" }, alt: { default: "" }, caption: { default: "" }, width: { default: null } },
84
84
  toDOM: (n) => ["figure", ref(n), ["img", { src: n.attrs.src, alt: n.attrs.alt }], ["figcaption", n.attrs.caption]],
85
85
  },
86
86
  table: {
@@ -61,10 +61,17 @@ function unsavedLiveChanges() {
61
61
  return false;
62
62
  }
63
63
 
64
+ // Callbacks waiting for the file to catch up, run after every checkpoint save
65
+ // and every time the room reports a landing.
66
+ const fileWaiters = new Set();
67
+ function fileMoved() {
68
+ for (const fn of [...fileWaiters]) fn();
69
+ }
70
+
64
71
  const checkpoints = createCheckpoints({
65
72
  needsSave: unsavedLiveChanges,
66
73
  blocked: () => !baselineSettled() || isSaveConflicted() || autosaveIsSuspended(),
67
- save: () => savePageForce(),
74
+ save: () => savePageForce().finally(fileMoved),
68
75
  saveNow: () => withKeepalive(true, () => savePageForce()),
69
76
  });
70
77
 
@@ -124,7 +131,10 @@ function laneOptions(transport) {
124
131
  entries,
125
132
  onWriter: () => checkpoints.changed(),
126
133
  onApplied: liveMoved,
127
- onLanded: liveMoved,
134
+ onLanded: () => {
135
+ liveMoved();
136
+ fileMoved();
137
+ },
128
138
  onCheckpoint: () => checkpoints.soon(),
129
139
  onLeave: (name, at) => {
130
140
  const entry = entries.get(name);
@@ -443,4 +453,41 @@ export function fastLane() {
443
453
  return fast;
444
454
  }
445
455
 
456
+ // The page editor's pause save: checkpoint within ms of now. Only the room's
457
+ // writer saves, so in other tabs this changes nothing.
458
+ export function checkpointWithin(ms) {
459
+ checkpoints.within(ms);
460
+ }
461
+
462
+ // Resolves { ok: true } once the file holds every live change this tab knows
463
+ // of now, or { ok: false, code: "timeout" }. The writer saves as soon as it
464
+ // can; another tab waits for the writer's save to land.
465
+ export function requestCheckpoint({ timeoutMs = 10_000 } = {}) {
466
+ const target = new Map();
467
+ for (const [name, entry] of entries) {
468
+ if (entry.store.ephemeral) continue;
469
+ target.set(name, entry.store.handle.status.mode === "fast" ? entry.last ?? 0 : null);
470
+ }
471
+ const reached = () => [...target].every(([name, last]) => {
472
+ const entry = entries.get(name);
473
+ if (!entry) return true;
474
+ if (last === null || entry.store.handle.status.mode !== "fast") return entry.store.pending.length === 0;
475
+ return (entry.landed ?? 0) >= last;
476
+ });
477
+ if (reached()) return Promise.resolve({ ok: true });
478
+ return new Promise((resolve) => {
479
+ const finish = (result) => {
480
+ clearTimeout(timer);
481
+ fileWaiters.delete(check);
482
+ resolve(result);
483
+ };
484
+ const check = () => {
485
+ if (reached()) finish({ ok: true });
486
+ };
487
+ const timer = setTimeout(() => finish({ ok: false, code: "timeout" }), timeoutMs);
488
+ fileWaiters.add(check);
489
+ if (isEditMode && (!fast || fast.isWriter())) checkpoints.soon();
490
+ });
491
+ }
492
+
446
493
  export default live;