@panphora/clayjs 1.13.1 → 1.15.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.
Files changed (146) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69385 -40992
  3. package/dist/server/checkpoint.cjs +1 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/core.cjs +72210 -0
  6. package/dist/server/rooms.cjs +1 -0
  7. package/dist/server/rooms.mjs +66 -0
  8. package/docs/guide.md +127 -99
  9. package/entries/clay.js +22 -0
  10. package/entries/sap.js +5 -5
  11. package/package.json +16 -2
  12. package/packed-contract.json +18 -3
  13. package/src/core/boot-phase.js +6 -0
  14. package/src/core/conflict-download.js +12 -3
  15. package/src/core/host-meta.js +75 -0
  16. package/src/core/persist.js +11 -0
  17. package/src/core/save-core.js +266 -31
  18. package/src/core/save.js +154 -36
  19. package/src/core/snapshot.js +51 -13
  20. package/src/core/style-comparisons.js +37 -0
  21. package/src/dom/insert-style-tag.js +11 -12
  22. package/src/lib/derived.js +116 -0
  23. package/src/lib/effect.js +130 -0
  24. package/src/lib/mutation.js +3 -1
  25. package/src/lib/style-adoption.js +9 -0
  26. package/src/lib/style.js +47 -0
  27. package/src/live/fast-lane.js +347 -60
  28. package/src/live/local-lane.js +32 -6
  29. package/src/live/ops.js +77 -0
  30. package/src/live/provenance.js +129 -0
  31. package/src/live/save-history.js +188 -0
  32. package/src/live/server-lane.js +9 -2
  33. package/src/live/store.js +194 -16
  34. package/src/loader-logic.js +16 -4
  35. package/src/loader.js +37 -1
  36. package/src/page/address.js +20 -0
  37. package/src/page/adopt-boot.js +189 -0
  38. package/src/page/adopt.js +240 -0
  39. package/src/page/ai-tool.js +188 -0
  40. package/src/page/announce.js +21 -0
  41. package/src/page/blocks.js +1170 -0
  42. package/src/page/blocksel.js +269 -0
  43. package/src/page/board-drag.js +226 -0
  44. package/src/page/board-panel.js +353 -0
  45. package/src/page/board-style.js +146 -0
  46. package/src/page/board-view.js +447 -0
  47. package/src/page/board.js +41 -0
  48. package/src/page/chart.js +220 -0
  49. package/src/page/checklist.js +143 -0
  50. package/src/page/chrome.js +587 -0
  51. package/src/page/csv-import.js +169 -0
  52. package/src/page/drag.js +202 -0
  53. package/src/page/drop.js +112 -0
  54. package/src/page/editor.js +386 -0
  55. package/src/page/highlight.js +268 -0
  56. package/src/page/inline.js +60 -1
  57. package/src/page/items.js +159 -0
  58. package/src/page/link.js +247 -0
  59. package/src/page/markdown.js +71 -0
  60. package/src/page/media.js +570 -0
  61. package/src/page/merge.js +130 -0
  62. package/src/page/model.js +71 -9
  63. package/src/page/placeholder.js +23 -0
  64. package/src/page/projection.js +647 -0
  65. package/src/page/recover.js +193 -0
  66. package/src/page/rich/cell.js +16 -0
  67. package/src/page/rich/chart.js +104 -0
  68. package/src/page/rich/csv.js +118 -0
  69. package/src/page/rich/page-tool.js +86 -0
  70. package/src/page/rich/tool-runtime.js +195 -0
  71. package/src/page/save-projection.js +53 -0
  72. package/src/page/save-states.js +85 -0
  73. package/src/page/schema.js +12 -5
  74. package/src/page/table-kinds.js +20 -0
  75. package/src/page/table-paste.js +57 -0
  76. package/src/page/table-select.js +244 -0
  77. package/src/page/table-style.js +25 -0
  78. package/src/page/table.js +650 -0
  79. package/src/page/themes/content.css +46 -0
  80. package/src/page/themes/light-1.css +269 -0
  81. package/src/page/themes/light-2.css +211 -0
  82. package/src/page/themes/light-3.css +130 -0
  83. package/src/page/themes/light-4.css +299 -0
  84. package/src/page/tool-data.js +25 -0
  85. package/src/page/tool-view.js +68 -0
  86. package/src/page/tools.js +133 -0
  87. package/src/page/view.js +136 -0
  88. package/src/page/ybind/author.js +25 -0
  89. package/src/page/ybind/binding.js +92 -0
  90. package/src/page/ybind/board-commands.js +170 -0
  91. package/src/page/ybind/board.js +89 -0
  92. package/src/page/ybind/bridge.js +382 -0
  93. package/src/page/ybind/conversion.js +26 -0
  94. package/src/page/ybind/core.js +7 -0
  95. package/src/page/ybind/decode.js +208 -0
  96. package/src/page/ybind/doc.js +58 -0
  97. package/src/page/ybind/external.js +190 -0
  98. package/src/page/ybind/group.js +124 -0
  99. package/src/page/ybind/index.js +12 -0
  100. package/src/page/ybind/layout.js +27 -0
  101. package/src/page/ybind/marks.js +18 -0
  102. package/src/page/ybind/migrate.js +152 -0
  103. package/src/page/ybind/moves.js +288 -0
  104. package/src/page/ybind/origins.js +257 -0
  105. package/src/page/ybind/parts.js +44 -0
  106. package/src/page/ybind/projection-map.js +92 -0
  107. package/src/page/ybind/provider.js +285 -0
  108. package/src/page/ybind/recovery.js +411 -0
  109. package/src/page/ybind/seed.js +27 -0
  110. package/src/page/ybind/table.js +166 -0
  111. package/src/page/ybind/tokens.js +93 -0
  112. package/src/page/ybind/walk.js +333 -0
  113. package/src/page/ybind/writer.js +307 -0
  114. package/src/plugins/derive.js +12 -0
  115. package/src/plugins/indicator.js +16 -6
  116. package/src/plugins/live.js +742 -65
  117. package/src/plugins/page.js +547 -0
  118. package/src/plugins/undo.js +62 -4
  119. package/src/server/checkpoint/index.js +218 -0
  120. package/src/server/rooms/index.js +59 -0
  121. package/src/server/rooms/live-data-ops.js +369 -0
  122. package/src/server/rooms/live-data-rooms.js +965 -0
  123. package/src/server/rooms/live-data-tags.js +49 -0
  124. package/src/server/rooms/live-page-history.js +41 -0
  125. package/src/server/rooms/live-provenance.js +142 -0
  126. package/src/server/rooms/page-admission.js +79 -0
  127. package/src/server/rooms/page-conversion.js +62 -0
  128. package/src/server/rooms/page-rebase.js +108 -0
  129. package/src/server/rooms/page-state.js +91 -0
  130. package/src/server/rooms/page-write-admission.js +63 -0
  131. package/src/server/rooms/page-write-guard.js +23 -0
  132. package/src/server/rooms/recent.js +18 -0
  133. package/src/server/rooms/spec-wire.js +17 -0
  134. package/src/standalone.js +22 -0
  135. package/src/sync/conflict-revert.js +2 -0
  136. package/src/sync/conflicts.js +2 -2
  137. package/src/sync/live-sync.js +36 -8
  138. package/src/sync/stream.js +62 -16
  139. package/src/ui/index.js +4 -0
  140. package/src/ui/toast.js +21 -5
  141. package/src/vendor/pierre/pierre.vendor.js +3 -0
  142. package/src/vendor/sap-core.vendor.js +9 -0
  143. package/src/vendor/sap-ownership.vendor.js +111 -0
  144. package/src/vendor/yjs.vendor.js +7 -0
  145. package/src/page/binding.js +0 -647
  146. package/src/page/replay.js +0 -578
@@ -18,7 +18,7 @@
18
18
  // where in the log it starts, the moves after that go back on top, and the
19
19
  // merge itself is logged as a diff so a later file stamped before it keeps it.
20
20
 
21
- import { applyOps, applyOp, checkOp, cloneJson, validateOp, serializeLive, textHash, diffOps } from "./ops.js";
21
+ import { applyOp, applyTracked, checkOp, cloneJson, undoTracked, validateOp, serializeLive, textHash, diffOps } from "./ops.js";
22
22
  import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
23
23
  import { movesSince } from "./moves.js";
24
24
 
@@ -28,6 +28,28 @@ export const CLOSE_WAIT_MS = 500;
28
28
  export const LOG_ENTRIES = 1024;
29
29
  export const LOG_BYTES = 256 * 1024;
30
30
 
31
+ // ops applied to value in place, all or none: a throw takes back the ones
32
+ // before it. Returns the value to go on with, and how to take the ops back.
33
+ function applyAll(value, ops) {
34
+ const done = [];
35
+ const undo = () => {
36
+ for (let i = done.length - 1; i >= 0; i--) undoTracked(done[i]);
37
+ };
38
+ try {
39
+ for (const op of ops) {
40
+ const rec = applyTracked(value, op);
41
+ if (rec) {
42
+ done.push(rec);
43
+ value = rec.root;
44
+ }
45
+ }
46
+ } catch (err) {
47
+ undo();
48
+ throw err;
49
+ }
50
+ return { value, undo };
51
+ }
52
+
31
53
  const encoder = new TextEncoder();
32
54
 
33
55
  export function localLaneSupported() {
@@ -99,7 +121,7 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
99
121
  function advance(data) {
100
122
  const m = mirror.stores.get(data.name);
101
123
  if (m) {
102
- m.value = applyOps(cloneJson(m.value), data.ops);
124
+ m.value = applyAll(m.value, data.ops).value;
103
125
  m.last = data.seq;
104
126
  if (!m.ephemeral) pushLog(m, { seq: data.seq, ops: data.ops });
105
127
  }
@@ -119,7 +141,10 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
119
141
 
120
142
  const landedIn = (m) => (m.behind || m.landed === null ? -1 : m.landed);
121
143
 
122
- function roomState(since = null) {
144
+ // A client's own read (since is its { epoch, seq }, or null for a first read)
145
+ // carries moves, as the host's state() does: those after its seq in its own
146
+ // epoch, else this epoch's from its start. A broadcast passes no since.
147
+ function roomState(since) {
123
148
  const stores = {};
124
149
  const meta = {};
125
150
  for (const [name, m] of mirror.stores) {
@@ -127,9 +152,10 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
127
152
  meta[name] = { log: m.log, last: m.last, landed: m.landed, floor: m.floor, behind: m.behind, file: m.file, inFile: m.inFile };
128
153
  }
129
154
  const state = { epoch: room.epoch, seq: room.seq, writer: clientId, stores, mirror: { seen: [...mirror.seen], stores: meta } };
130
- if (since && since.epoch === mirror.epoch) {
155
+ if (since !== undefined) {
156
+ const from = since && since.epoch === mirror.epoch && Number.isInteger(since.seq) ? since.seq : 0;
131
157
  const moves = {};
132
- for (const [name, m] of mirror.stores) moves[name] = movesSince(m, since);
158
+ for (const [name, m] of mirror.stores) moves[name] = movesSince(m, { seq: from });
133
159
  state.moves = moves;
134
160
  }
135
161
  return state;
@@ -322,7 +348,7 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
322
348
  let ops;
323
349
  try {
324
350
  ops = frame.ops.map(validateOp);
325
- applyOps(cloneJson(m.value), ops);
351
+ applyAll(m.value, ops).undo();
326
352
  } catch (err) {
327
353
  return { status: 422, body: { error: err.message }, retryAfter: 0 };
328
354
  }
package/src/live/ops.js CHANGED
@@ -176,6 +176,83 @@ export function applyOps(root, ops) {
176
176
  return value;
177
177
  }
178
178
 
179
+ // applyOp, keeping what it changed so a caller can size the change or take it
180
+ // back. null when the op changes nothing. Otherwise root is the value to go on
181
+ // with (a new one only for a whole-value set, where at is null and old is the
182
+ // previous root), and at[key] is the one slot the op changed, at path. kind
183
+ // says how: "put" replaced old, "add" made the slot (a new key, or an item
184
+ // appended to a list of size items), "remove" took old out. For an object, keys
185
+ // lists its keys before a remove, and empty says it had none before an add.
186
+ // Checks the op first, so a throw changes nothing.
187
+ export function applyTracked(root, op) {
188
+ checkOp(root, op);
189
+ const { path } = op;
190
+ if (path.length === 0) {
191
+ return { root: cloneJson(op.value), at: null, key: null, kind: "put", old: root, path: [], size: 0, keys: null, empty: false };
192
+ }
193
+ let at = root;
194
+ let depth = path.length - 1;
195
+ for (let i = 0; i < path.length - 1; i++) {
196
+ const next = read(at, path[i]);
197
+ if (!isContainer(next)) {
198
+ if (op.op === "del") return null;
199
+ depth = i;
200
+ break;
201
+ }
202
+ at = next;
203
+ }
204
+ const list = Array.isArray(at);
205
+ const key = list ? indexOf(at, path[depth]) : String(path[depth]);
206
+ const size = list ? at.length : 0;
207
+ const had = list ? key < size : has(at, key);
208
+ if (op.op === "del" && !had) return null;
209
+ const kind = op.op === "del" ? "remove" : had ? "put" : "add";
210
+ const rec = { root, at, key, kind, old: had ? at[key] : undefined, path: path.slice(0, depth + 1), size, keys: null, empty: false };
211
+ if (!list && kind === "remove") rec.keys = Object.keys(at);
212
+ if (!list && kind === "add") rec.empty = isEmpty(at);
213
+ applyOp(root, op);
214
+ return rec;
215
+ }
216
+
217
+ function isEmpty(obj) {
218
+ for (const key in obj) if (has(obj, key)) return false;
219
+ return true;
220
+ }
221
+
222
+ // Takes back one applyTracked change; a frame's records go back newest first.
223
+ // Returns the root as it was before that op.
224
+ export function undoTracked(rec) {
225
+ const { at, key, kind, old } = rec;
226
+ if (at === null) return old;
227
+ if (Array.isArray(at)) {
228
+ if (kind === "put") at[key] = old;
229
+ else if (kind === "add") at.length = rec.size;
230
+ else at.splice(key, 0, old);
231
+ } else if (kind === "put") setOwn(at, key, old);
232
+ else if (kind === "add") delete at[key];
233
+ else if (rec.keys[rec.keys.length - 1] === key) setOwn(at, key, old);
234
+ else {
235
+ // Back in its place: every key after it is put back behind it.
236
+ const values = rec.keys.map((k) => (k === key ? old : at[k]));
237
+ for (const k of rec.keys) if (k !== key) delete at[k];
238
+ rec.keys.forEach((k, i) => setOwn(at, k, values[i]));
239
+ }
240
+ return rec.root;
241
+ }
242
+
243
+ // What one applyTracked change did to the length of serializeLive(root), with
244
+ // size(v) the length of serializeLive(v) in the caller's unit and total the
245
+ // root's length before. Call it before the next op touches the slot.
246
+ export function sizeChange(rec, size, total) {
247
+ const { at, key, kind, old } = rec;
248
+ if (at === null) return size(rec.root) - total;
249
+ const list = Array.isArray(at);
250
+ const slot = list ? 0 : size(key) + 1;
251
+ if (kind === "put") return size(at[key]) - size(old);
252
+ if (kind === "add") return slot + size(at[key]) + ((list ? rec.size > 0 : !rec.empty) ? 1 : 0);
253
+ return -(slot + size(old)) - ((list ? rec.size : rec.keys.length) > 1 ? 1 : 0);
254
+ }
255
+
179
256
  const samePath = (a, b) => a.length === b.length && a.every((s, i) => String(s) === String(b[i]));
180
257
 
181
258
  // Merges neighbouring ops on the same path, without changing the result:
@@ -0,0 +1,129 @@
1
+ const CLIENT = /^[A-Za-z0-9_-]{1,64}$/;
2
+ const NUMBER = /^[1-9]\d{0,15}$/;
3
+ export const RANGE_LIMIT = 256;
4
+
5
+ const integer = (text) => NUMBER.test(text) && Number.isSafeInteger(Number(text));
6
+
7
+ export function unionRanges(...sets) {
8
+ const clients = new Map();
9
+ for (const set of sets) for (const [client, ranges] of set ?? []) {
10
+ if (!clients.has(client)) clients.set(client, []);
11
+ clients.get(client).push(...ranges.map(([lo, hi]) => [lo, hi]));
12
+ }
13
+ return [...clients].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([client, ranges]) => {
14
+ const out = [];
15
+ for (const [lo, hi] of ranges.sort((a, b) => a[0] - b[0])) {
16
+ const last = out[out.length - 1];
17
+ if (last && lo <= last[1] + 1) last[1] = Math.max(last[1], hi);
18
+ else out.push([lo, hi]);
19
+ }
20
+ return [client, out];
21
+ }).filter(([, ranges]) => ranges.length);
22
+ }
23
+
24
+ export const rangeCount = (set) => set.reduce((n, [, ranges]) => n + ranges.length, 0);
25
+ export const hasNumber = (ranges, n) => !!ranges?.some(([lo, hi]) => lo <= n && n <= hi);
26
+ export const contains = (set, client, n) => hasNumber(set?.find(([id]) => id === client)?.[1], n);
27
+ export const identitySet = ([client, n]) => [[client, [[n, n]]]];
28
+ export const frameSet = (by) => by?.v === 2 ? by.frames : by ? [[by.client, [[1, by.n]]]] : [];
29
+ export const descendants = (by) => by?.v === 2 ? unionRanges(by.ancestors, identitySet(by.capture)) : [];
30
+
31
+ function parseRanges(text) {
32
+ if (!text) return [];
33
+ const out = [];
34
+ let count = 0;
35
+ for (const part of text.split(';')) {
36
+ const fields = part.split(':');
37
+ if (fields.length !== 2 || !CLIENT.test(fields[0]) || !fields[1]) return null;
38
+ const ranges = [];
39
+ for (const raw of fields[1].split(',')) {
40
+ if (++count > RANGE_LIMIT) return null;
41
+ const ends = raw.split('-');
42
+ if (ends.length > 2 || !ends.every(integer)) return null;
43
+ const lo = Number(ends[0]), hi = Number(ends[1] ?? ends[0]);
44
+ if (hi < lo) return null;
45
+ ranges.push([lo, hi]);
46
+ }
47
+ out.push([fields[0], ranges]);
48
+ }
49
+ return unionRanges(out);
50
+ }
51
+
52
+ function printRanges(set) {
53
+ return unionRanges(set).map(([client, ranges]) => `${client}:${ranges.map(([lo, hi]) => lo === hi ? String(lo) : `${lo}-${hi}`).join(',')}`).join(';');
54
+ }
55
+
56
+ export function parseBy(text) {
57
+ if (typeof text !== 'string' || text.length > 32768) return null;
58
+ if (!text.startsWith('v2|')) {
59
+ const m = /^([A-Za-z0-9_-]{1,64}):([1-9]\d{0,15})$/.exec(text);
60
+ return m && integer(m[2]) ? { client: m[1], n: Number(m[2]) } : null;
61
+ }
62
+ const parts = text.split('|');
63
+ if (parts.length !== 4) return null;
64
+ const frames = parseRanges(parts[1]), ancestors = parseRanges(parts[2]);
65
+ const capture = /^([A-Za-z0-9_-]{1,64}):([1-9]\d{0,15})$/.exec(parts[3]);
66
+ if (!frames || !ancestors || !capture || !integer(capture[2])) return null;
67
+ if (rangeCount(frames) + rangeCount(ancestors) + 1 > RANGE_LIMIT) return null;
68
+ const id = [capture[1], Number(capture[2])];
69
+ if (contains(ancestors, ...id)) return null;
70
+ return { v: 2, frames, ancestors, capture: id };
71
+ }
72
+
73
+ export function formatBy(by) {
74
+ const text = `v2|${printRanges(by.frames)}|${printRanges(by.ancestors)}|${by.capture[0]}:${by.capture[1]}`;
75
+ return parseBy(text) ? text : null;
76
+ }
77
+
78
+ export function receiptHas(receipt, n) {
79
+ return !!receipt && (n <= receipt.n || hasNumber(receipt.saved, n));
80
+ }
81
+
82
+ export function receiptBytes(receipt) {
83
+ return receipt?.saved ? JSON.stringify({ saved: receipt.saved, savedSeq: receipt.savedSeq }).length : 0;
84
+ }
85
+
86
+ export function replaceReceipt(room, key, next) {
87
+ const delta = receiptBytes(next) - receiptBytes(room.lastN.get(key));
88
+ room.receiptBytes = (room.receiptBytes ?? 0) + delta;
89
+ room.lastN.delete(key);
90
+ room.lastN.set(key, next);
91
+ return delta;
92
+ }
93
+
94
+ export function retireReceipt(room, key) {
95
+ const bytes = receiptBytes(room.lastN.get(key));
96
+ room.lastN.delete(key);
97
+ room.receiptBytes = (room.receiptBytes ?? 0) - bytes;
98
+ room.receiptGeneration = (room.receiptGeneration ?? 0) + 1;
99
+ return -bytes;
100
+ }
101
+
102
+ export function saveReceipt(room, key, ranges, seq, limit) {
103
+ const last = room.lastN.get(key);
104
+ if (!last && room.lastN.size >= limit) {
105
+ retireReceipt(room, room.lastN.keys().next().value);
106
+ }
107
+ const next = last ? { ...last } : { n: 0, seq: 0 };
108
+ const kept = unionRanges([['r', next.saved ?? []]], [['r', ranges]])[0]?.[1]
109
+ .filter(([, hi]) => hi > next.n).map(([lo, hi]) => [Math.max(lo, next.n + 1), hi]) ?? [];
110
+ if (kept.length > RANGE_LIMIT) {
111
+ retireReceipt(room, key);
112
+ next.saved = kept.slice(-RANGE_LIMIT);
113
+ next.savedSeq = Math.max(next.savedSeq ?? 0, seq);
114
+ } else {
115
+ if (kept.length) {
116
+ next.saved = kept;
117
+ next.savedSeq = Math.max(next.savedSeq ?? 0, seq);
118
+ } else {
119
+ delete next.saved;
120
+ delete next.savedSeq;
121
+ }
122
+ }
123
+ replaceReceipt(room, key, next);
124
+ }
125
+
126
+ export function postReceipt(last, n, seq) {
127
+ const saved = (last?.saved ?? []).filter(([, hi]) => hi > n).map(([lo, hi]) => [Math.max(lo, n + 1), hi]);
128
+ return { n, seq, ...(saved.length && { saved, savedSeq: last.savedSeq }) };
129
+ }
@@ -0,0 +1,188 @@
1
+ export const RECOVERY_WINDOW_MS = 45_000;
2
+ export const RECOVERY_ROUNDS_MS = [0, 10_000, 30_000];
3
+ export const QUERY_TIMEOUT_MS = 6_000;
4
+ export const MAX_HISTORY_IDS = 2;
5
+
6
+ export async function sha256Hex(html) {
7
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
8
+ if (!subtle) return null;
9
+ try {
10
+ const bytes = new TextEncoder().encode(String(html));
11
+ const digest = await subtle.digest("SHA-256", bytes);
12
+ const view = new Uint8Array(digest);
13
+ let hex = "";
14
+ for (let i = 0; i < view.length; i += 1) hex += view[i].toString(16).padStart(2, "0");
15
+ return hex;
16
+ } catch (_) {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function uniqueSaveIds(attempts) {
22
+ const ids = [];
23
+ for (const attempt of attempts || []) {
24
+ const saveId = attempt && attempt.saveId;
25
+ if (typeof saveId === "string" && saveId && !ids.includes(saveId)) ids.push(saveId);
26
+ if (ids.length >= MAX_HISTORY_IDS) break;
27
+ }
28
+ return ids;
29
+ }
30
+
31
+ function positive(answer, saveId, requestHash) {
32
+ return Boolean(
33
+ answer &&
34
+ typeof answer === "object" &&
35
+ answer.saveId === saveId &&
36
+ answer.requestHash === requestHash &&
37
+ answer.status === "applied"
38
+ );
39
+ }
40
+
41
+ export function createSaveHistoryRecovery({
42
+ query,
43
+ digest = sha256Hex,
44
+ now = () => Date.now(),
45
+ setTimer = setTimeout,
46
+ clearTimer = clearTimeout,
47
+ } = {}) {
48
+ function start({ captureId, html, attempts, initialOutcome, startedAt, onApplied, onUnknown }) {
49
+ const deadline = startedAt + RECOVERY_WINDOW_MS;
50
+ const ids = uniqueSaveIds(attempts);
51
+ const timers = new Set();
52
+ const controllers = new Set();
53
+ let body = html;
54
+ let hash = null;
55
+ let terminal = false;
56
+
57
+ function call(fn, arg) {
58
+ try {
59
+ if (typeof fn === "function") fn(arg);
60
+ } catch (err) {
61
+ console.error("clayjs: save-history callback failed:", err);
62
+ }
63
+ }
64
+
65
+ function finish() {
66
+ terminal = true;
67
+ body = null;
68
+ for (const timer of timers) clearTimer(timer);
69
+ timers.clear();
70
+ for (const controller of controllers) controller.abort();
71
+ controllers.clear();
72
+ }
73
+
74
+ function apply(saveId, requestHash) {
75
+ if (terminal) return;
76
+ finish();
77
+ call(onApplied, { captureId, saveId, requestHash });
78
+ }
79
+
80
+ function giveUp(reason) {
81
+ if (terminal) return;
82
+ finish();
83
+ call(onUnknown, { captureId, reason });
84
+ }
85
+
86
+ function addTimer(fn, delay) {
87
+ let id = null;
88
+ id = setTimer(() => {
89
+ if (id !== null) timers.delete(id);
90
+ fn();
91
+ }, Math.max(0, delay));
92
+ timers.add(id);
93
+ return id;
94
+ }
95
+
96
+ function dropTimer(id) {
97
+ if (id !== null) {
98
+ clearTimer(id);
99
+ timers.delete(id);
100
+ }
101
+ }
102
+
103
+ async function probe(saveId) {
104
+ if (terminal) return;
105
+ const remaining = deadline - now();
106
+ if (remaining <= 0) return;
107
+ const controller = new AbortController();
108
+ controllers.add(controller);
109
+ const timer = addTimer(() => controller.abort(), Math.min(QUERY_TIMEOUT_MS, remaining));
110
+ let answer = null;
111
+ try {
112
+ answer = await query({ saveId, requestHash: hash, signal: controller.signal });
113
+ } catch (_) {
114
+ answer = null;
115
+ } finally {
116
+ dropTimer(timer);
117
+ controllers.delete(controller);
118
+ }
119
+ if (terminal) return;
120
+ if (positive(answer, saveId, hash)) apply(saveId, hash);
121
+ }
122
+
123
+ function runRound() {
124
+ if (terminal) return;
125
+ if (now() >= deadline) return;
126
+ for (const saveId of ids) probe(saveId);
127
+ }
128
+
129
+ async function run() {
130
+ let computed = null;
131
+ try {
132
+ computed = await digest(body);
133
+ } catch (_) {
134
+ computed = null;
135
+ }
136
+ body = null;
137
+ if (terminal) return;
138
+ if (typeof computed !== "string" || !computed) {
139
+ giveUp("unavailable");
140
+ return;
141
+ }
142
+ hash = computed;
143
+
144
+ if (
145
+ initialOutcome &&
146
+ typeof initialOutcome === "object" &&
147
+ initialOutcome.status === "applied" &&
148
+ initialOutcome.requestHash === hash &&
149
+ ids.includes(initialOutcome.saveId)
150
+ ) {
151
+ apply(initialOutcome.saveId, hash);
152
+ return;
153
+ }
154
+
155
+ const elapsed = now() - startedAt;
156
+ if (elapsed >= RECOVERY_WINDOW_MS) {
157
+ giveUp("expired");
158
+ return;
159
+ }
160
+
161
+ let due = -1;
162
+ for (let i = 0; i < RECOVERY_ROUNDS_MS.length; i += 1) {
163
+ if (RECOVERY_ROUNDS_MS[i] <= elapsed) due = i;
164
+ }
165
+ if (due < 0) {
166
+ for (let i = 0; i < RECOVERY_ROUNDS_MS.length; i += 1) {
167
+ addTimer(runRound, startedAt + RECOVERY_ROUNDS_MS[i] - now());
168
+ }
169
+ return;
170
+ }
171
+ runRound();
172
+ for (let i = due + 1; i < RECOVERY_ROUNDS_MS.length; i += 1) {
173
+ addTimer(runRound, startedAt + RECOVERY_ROUNDS_MS[i] - now());
174
+ }
175
+ }
176
+
177
+ addTimer(() => giveUp("expired"), deadline - now());
178
+ run();
179
+
180
+ return {
181
+ cancel() {
182
+ finish();
183
+ },
184
+ };
185
+ }
186
+
187
+ return { start };
188
+ }
@@ -39,7 +39,7 @@ export async function relayData(clientId, frame) {
39
39
  * The room's { epoch, seq, writer, stores }; { status } when the host refused;
40
40
  * null when the request never got an answer. clientId marks this tab as taking part.
41
41
  */
42
- export async function fetchLiveState(clientId, since = null) {
42
+ export async function fetchLiveState(clientId, since = null, { signal } = {}) {
43
43
  const url = dataUrl();
44
44
  if (clientId) url.searchParams.set("client-id", clientId);
45
45
  if (since && typeof since.epoch === "string" && Number.isInteger(since.seq)) url.searchParams.set("since", `${since.epoch}:${since.seq}`);
@@ -48,8 +48,15 @@ export async function fetchLiveState(clientId, since = null) {
48
48
  credentials: "same-origin",
49
49
  cache: "no-store",
50
50
  headers: { "Document-URL": window.location.href },
51
+ signal,
51
52
  });
52
- if (!response.ok) return { status: response.status };
53
+ if (!response.ok) {
54
+ let body = null;
55
+ try {
56
+ body = await response.json();
57
+ } catch {}
58
+ return { status: response.status, code: body?.code ?? null, retryAfter: Number(response.headers?.get?.("Retry-After")) || 0 };
59
+ }
53
60
  const state = await response.json();
54
61
  // The stream is open but the host has not seen its subscription yet: not
55
62
  // ready, and the lane reads again.