@panphora/clayjs 1.13.1 → 1.14.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/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69667 -41708
- package/dist/server/checkpoint.cjs +70690 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/rooms.cjs +72039 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +94 -93
- package/entries/clay.js +22 -0
- package/package.json +16 -2
- package/packed-contract.json +17 -3
- package/src/core/conflict-download.js +11 -2
- package/src/core/host-meta.js +75 -0
- package/src/core/persist.js +11 -0
- package/src/core/save-core.js +266 -31
- package/src/core/save.js +62 -32
- package/src/core/snapshot.js +39 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/effect.js +130 -0
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +44 -0
- package/src/live/fast-lane.js +347 -60
- package/src/live/local-lane.js +32 -6
- package/src/live/ops.js +77 -0
- package/src/live/provenance.js +129 -0
- package/src/live/save-history.js +188 -0
- package/src/live/server-lane.js +9 -2
- package/src/live/store.js +194 -16
- package/src/loader-logic.js +7 -3
- package/src/loader.js +6 -0
- package/src/page/address.js +20 -0
- package/src/page/adopt-boot.js +189 -0
- package/src/page/adopt.js +240 -0
- package/src/page/ai-tool.js +188 -0
- package/src/page/announce.js +21 -0
- package/src/page/blocks.js +1121 -0
- package/src/page/blocksel.js +269 -0
- package/src/page/board-drag.js +226 -0
- package/src/page/board-panel.js +353 -0
- package/src/page/board-style.js +146 -0
- package/src/page/board-view.js +447 -0
- package/src/page/board.js +41 -0
- package/src/page/chart.js +220 -0
- package/src/page/checklist.js +143 -0
- package/src/page/chrome.js +587 -0
- package/src/page/csv-import.js +169 -0
- package/src/page/drag.js +202 -0
- package/src/page/drop.js +112 -0
- package/src/page/editor.js +384 -0
- package/src/page/highlight.js +268 -0
- package/src/page/inline.js +60 -1
- package/src/page/items.js +159 -0
- package/src/page/link.js +247 -0
- package/src/page/markdown.js +71 -0
- package/src/page/media.js +570 -0
- package/src/page/merge.js +130 -0
- package/src/page/model.js +71 -9
- package/src/page/placeholder.js +23 -0
- package/src/page/projection.js +647 -0
- package/src/page/recover.js +193 -0
- package/src/page/rich/cell.js +16 -0
- package/src/page/rich/chart.js +104 -0
- package/src/page/rich/csv.js +118 -0
- package/src/page/rich/page-tool.js +86 -0
- package/src/page/rich/tool-runtime.js +195 -0
- package/src/page/save-projection.js +53 -0
- package/src/page/save-states.js +85 -0
- package/src/page/schema.js +12 -5
- package/src/page/table-kinds.js +20 -0
- package/src/page/table-paste.js +57 -0
- package/src/page/table-select.js +244 -0
- package/src/page/table-style.js +25 -0
- package/src/page/table.js +650 -0
- package/src/page/themes/content.css +46 -0
- package/src/page/themes/light-1.css +269 -0
- package/src/page/themes/light-2.css +211 -0
- package/src/page/themes/light-3.css +130 -0
- package/src/page/themes/light-4.css +299 -0
- package/src/page/tool-data.js +25 -0
- package/src/page/tool-view.js +68 -0
- package/src/page/tools.js +133 -0
- package/src/page/view.js +136 -0
- package/src/page/ybind/author.js +25 -0
- package/src/page/ybind/binding.js +92 -0
- package/src/page/ybind/board-commands.js +170 -0
- package/src/page/ybind/board.js +89 -0
- package/src/page/ybind/bridge.js +382 -0
- package/src/page/ybind/conversion.js +26 -0
- package/src/page/ybind/core.js +7 -0
- package/src/page/ybind/decode.js +208 -0
- package/src/page/ybind/doc.js +58 -0
- package/src/page/ybind/external.js +190 -0
- package/src/page/ybind/group.js +124 -0
- package/src/page/ybind/index.js +12 -0
- package/src/page/ybind/layout.js +27 -0
- package/src/page/ybind/marks.js +18 -0
- package/src/page/ybind/migrate.js +152 -0
- package/src/page/ybind/moves.js +288 -0
- package/src/page/ybind/origins.js +257 -0
- package/src/page/ybind/parts.js +44 -0
- package/src/page/ybind/projection-map.js +92 -0
- package/src/page/ybind/provider.js +285 -0
- package/src/page/ybind/recovery.js +411 -0
- package/src/page/ybind/seed.js +27 -0
- package/src/page/ybind/table.js +166 -0
- package/src/page/ybind/tokens.js +93 -0
- package/src/page/ybind/walk.js +333 -0
- package/src/page/ybind/writer.js +307 -0
- package/src/plugins/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +60 -3
- package/src/server/checkpoint/index.js +218 -0
- package/src/server/rooms/index.js +59 -0
- package/src/server/rooms/live-data-ops.js +369 -0
- package/src/server/rooms/live-data-rooms.js +965 -0
- package/src/server/rooms/live-data-tags.js +49 -0
- package/src/server/rooms/live-page-history.js +41 -0
- package/src/server/rooms/live-provenance.js +142 -0
- package/src/server/rooms/page-admission.js +79 -0
- package/src/server/rooms/page-conversion.js +62 -0
- package/src/server/rooms/page-rebase.js +108 -0
- package/src/server/rooms/page-state.js +91 -0
- package/src/server/rooms/page-write-admission.js +63 -0
- package/src/server/rooms/page-write-guard.js +23 -0
- package/src/server/rooms/recent.js +18 -0
- package/src/server/rooms/spec-wire.js +17 -0
- package/src/standalone.js +22 -0
- package/src/sync/live-sync.js +13 -2
- package/src/sync/stream.js +62 -16
- package/src/ui/index.js +4 -0
- package/src/ui/toast.js +21 -5
- package/src/vendor/pierre/pierre.vendor.js +3 -0
- package/src/vendor/yjs.vendor.js +7 -0
- package/src/page/binding.js +0 -647
- package/src/page/replay.js +0 -578
package/src/plugins/live.js
CHANGED
|
@@ -5,27 +5,394 @@
|
|
|
5
5
|
// ops when the save that carried them is accepted (never from an incoming frame),
|
|
6
6
|
// and takes the values other tabs saved or relayed from each live-sync frame.
|
|
7
7
|
|
|
8
|
+
import { contains, parseBy, frameSet, descendants, unionRanges, formatBy } from "../live/provenance.js";
|
|
8
9
|
import { createStore } from "../live/store.js";
|
|
9
|
-
import { serializeLive, stampOf, parseStamp } from "../live/ops.js";
|
|
10
|
+
import { serializeLive, stampOf, parseStamp, textHash } from "../live/ops.js";
|
|
10
11
|
import { onSnapshot } from "../core/snapshot.js";
|
|
11
|
-
import { onSaveAccepted, isSaveInProgress } from "../core/save-core.js";
|
|
12
|
+
import { onSaveAccepted, onSaveAttempt, onSaveOutcome, isSaveInProgress } from "../core/save-core.js";
|
|
12
13
|
import { savePageForce, baselineSettled, isSaveConflicted, autosaveIsSuspended } from "../core/save.js";
|
|
13
14
|
import { withKeepalive } from "../core/save-core.js";
|
|
14
15
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
16
|
+
import { registerUnsavedState } from "../lib/unsaved-state.js";
|
|
15
17
|
import { createCheckpoints } from "../live/checkpoint.js";
|
|
16
18
|
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
17
|
-
import
|
|
19
|
+
import * as hostMeta from "../core/host-meta.js";
|
|
18
20
|
import { createFastLane } from "../live/fast-lane.js";
|
|
21
|
+
import { createSaveHistoryRecovery, RECOVERY_WINDOW_MS } from "../live/save-history.js";
|
|
19
22
|
import { serverTransport } from "../live/server-lane.js";
|
|
20
23
|
import { localTransport, localLaneSupported } from "../live/local-lane.js";
|
|
24
|
+
import { captureYValue } from "../page/ybind/parts.js";
|
|
21
25
|
|
|
22
26
|
const NAME = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
23
27
|
const entries = new Map();
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
const captureAuthor = 'save_' + [...crypto.getRandomValues(new Uint32Array(4))].map((n) => n.toString(16).padStart(8, '0')).join('');
|
|
30
|
+
let captureNumber = 0;
|
|
31
|
+
let anonymousCaptures = 0;
|
|
32
|
+
|
|
33
|
+
export const HOLD_MS = 45_000;
|
|
34
|
+
|
|
35
|
+
function lineageOf(element) {
|
|
36
|
+
const at = parseStamp(element.getAttribute('live-at'));
|
|
37
|
+
if (!at || at.hash !== hashOfText(element.textContent)) return null;
|
|
38
|
+
const raw = element.getAttribute('live-by');
|
|
39
|
+
const by = raw === null ? null : parseBy(raw);
|
|
40
|
+
if (raw !== null && !by) return null;
|
|
41
|
+
if (by && by.v !== 2) return { disk: true };
|
|
42
|
+
return { epoch: at.epoch, seq: at.seq, frames: frameSet(by), ancestors: descendants(by) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function captureLineage(entry, forSave) {
|
|
46
|
+
const base = entry.leftAt;
|
|
47
|
+
if (!base || base.disk) return null;
|
|
48
|
+
const pending = fast?.pendingFrames(entry.store.name, base, { reserve: forSave }) ?? { frames: [], complete: true };
|
|
49
|
+
if (!pending.complete) return null;
|
|
50
|
+
const frames = unionRanges(base.frames ?? [], pending.frames);
|
|
51
|
+
const ancestors = base.ancestors ?? [];
|
|
52
|
+
const text = serializeLive(entry.store.value);
|
|
53
|
+
const key = JSON.stringify([base.epoch, base.seq, frames, ancestors, entry.store.mark, text]);
|
|
54
|
+
if (entry.capture?.key === key) return entry.capture.value;
|
|
55
|
+
const by = { v: 2, frames, ancestors, capture: [captureAuthor, captureNumber + 1] };
|
|
56
|
+
const encoded = formatBy(by);
|
|
57
|
+
const value = encoded ? { at: stampOf(base.epoch, base.seq, text), by: encoded } : null;
|
|
58
|
+
entry.capture = { key, value };
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
// Pins for the captures this tab has made but not settled, keyed by the capture
|
|
64
|
+
// id the core announced at clay:snapshot-ready. Each value is a Map of store
|
|
65
|
+
// name -> { mode, mark, canonical, at, by, key, ids, ... }. At most two survive:
|
|
66
|
+
// the capture whose exact body is on the wire, and the newest capture not yet
|
|
67
|
+
// sent.
|
|
68
|
+
const capturePins = new Map();
|
|
69
|
+
// The capture whose exact body is on the wire, and the newest capture that has
|
|
70
|
+
// not been sent. A newer unsent snapshot never displaces the sending capture.
|
|
71
|
+
let wireCaptureId = null;
|
|
72
|
+
let unsentCaptureId = null;
|
|
73
|
+
// The sending capture's exact evidence: the file-mode operation IDs its body was
|
|
74
|
+
// proven to carry, and the body itself until its recovery has hashed it.
|
|
75
|
+
let sending = null;
|
|
76
|
+
// One coordinator for every historical lookup (it owns the hashing, the query
|
|
77
|
+
// rounds and the absolute deadline, never a store), and the lookups in flight by
|
|
78
|
+
// the capture whose lost answer they are resolving.
|
|
79
|
+
const history = createSaveHistoryRecovery({ query: (request) => hostMeta.querySaveOutcome?.(request) ?? null });
|
|
80
|
+
const recoveries = new Map();
|
|
81
|
+
|
|
82
|
+
function pruneCaptures(entry) {
|
|
83
|
+
if (!entry.captureOps.size) return;
|
|
84
|
+
const pending = new Set(entry.store.pendingEntries().map((item) => item.id));
|
|
85
|
+
for (const [key, captured] of entry.captureOps) {
|
|
86
|
+
captured.ids = captured.ids.filter((id) => pending.has(id));
|
|
87
|
+
if (!captured.ids.length) entry.captureOps.delete(key);
|
|
88
|
+
}
|
|
89
|
+
if (![...entry.captureOps.values()].some((captured) => captured.ended)) disarm(entry);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const onWire = (name, key) => {
|
|
93
|
+
if (wireCaptureId === null || !isSaveInProgress()) return false;
|
|
94
|
+
return capturePins.get(wireCaptureId)?.get(name)?.key === key;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function heldIds(name) {
|
|
98
|
+
const entry = entries.get(name);
|
|
99
|
+
if (!entry || !entry.captureOps.size) return [];
|
|
100
|
+
const pending = new Set(entry.store.pendingEntries().map((item) => item.id));
|
|
101
|
+
const ids = new Set();
|
|
102
|
+
for (const [key, captured] of entry.captureOps) {
|
|
103
|
+
if (!captured.ended && !onWire(name, key)) continue;
|
|
104
|
+
for (const id of captured.ids) if (pending.has(id)) ids.add(id);
|
|
105
|
+
}
|
|
106
|
+
return [...ids];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function endedCapture(entry) {
|
|
110
|
+
for (const [key, captured] of entry.captureOps) if (captured.ended) return [key, captured];
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
registerUnsavedState({ id: "live:held", isPending: () => [...entries.values()].some((entry) => endedCapture(entry)) });
|
|
115
|
+
|
|
116
|
+
const inFlight = (entry) => [...entry.captureOps].some(([key, captured]) => !captured.ended && onWire(entry.store.name, key));
|
|
117
|
+
|
|
118
|
+
function describeOp({ op, path, n, value }) {
|
|
119
|
+
const where = path.length ? path.join(".") : "the whole value";
|
|
120
|
+
const text = op === "add" ? `${where} + ${n}` : op === "del" ? `${where} removed` : `${where} = ${JSON.stringify(value)}`;
|
|
121
|
+
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function arm(entry) {
|
|
125
|
+
clearTimeout(entry.timer);
|
|
126
|
+
entry.timer = setTimeout(() => {
|
|
127
|
+
entry.timer = null;
|
|
128
|
+
giveUp(entry);
|
|
129
|
+
}, HOLD_MS);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function disarm(entry) {
|
|
133
|
+
clearTimeout(entry.timer);
|
|
134
|
+
entry.timer = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function giveUp(entry, { read = true } = {}) {
|
|
138
|
+
const name = entry.store.name;
|
|
139
|
+
const ids = heldIds(name);
|
|
140
|
+
disarm(entry);
|
|
141
|
+
entry.withheld = false;
|
|
142
|
+
entry.reading = false;
|
|
143
|
+
// The operations these IDs named are given up: no answer about them can settle
|
|
144
|
+
// anything now.
|
|
145
|
+
cancelRecovery(entry);
|
|
146
|
+
if (ids.length) {
|
|
147
|
+
const lost = entry.store.pendingEntries().filter((item) => ids.includes(item.id)).map((item) => item.op);
|
|
148
|
+
if (entry.store.handle.status.mode === "file") entry.leftAt = null;
|
|
149
|
+
entry.store.rebase(entry.store.authoritative, { source: "state", dropIds: ids });
|
|
150
|
+
const shown = lost.slice(0, 5).map(describeOp).join("; ");
|
|
151
|
+
const more = lost.length > 5 ? ` and ${lost.length - 5} more` : "";
|
|
152
|
+
const message = `A save of "${name}" ended without an answer, so ${lost.length === 1 ? "this edit was" : "these edits were"} not sent again: ${shown}${more}. Check the value, and redo ${lost.length === 1 ? "it" : "any"} if missing.`;
|
|
153
|
+
console.warn(`clay.live("${name}"): ${message}`, lost);
|
|
154
|
+
document.dispatchEvent(new CustomEvent("clay:live-notice", { detail: { name, code: "save-uncertain", ops: lost, message } }));
|
|
155
|
+
}
|
|
156
|
+
if (read) fast?.release(name, { read: fast.roomBound(name) });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function settle(entry) {
|
|
160
|
+
pruneCaptures(entry);
|
|
161
|
+
const name = entry.store.name;
|
|
162
|
+
// Authority already holds every operation this lookup was asking about.
|
|
163
|
+
if (entry.recovery && !stillPending(entry)) cancelRecovery(entry);
|
|
164
|
+
if (!entry.withheld || entry.reading || heldIds(name).length) return;
|
|
165
|
+
if (!fast?.roomBound(name)) {
|
|
166
|
+
entry.withheld = false;
|
|
167
|
+
disarm(entry);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
entry.reading = true;
|
|
171
|
+
arm(entry);
|
|
172
|
+
fast.release(name, { read: true });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function taken(name) {
|
|
176
|
+
const entry = entries.get(name);
|
|
177
|
+
if (!entry) return;
|
|
178
|
+
// A state that passed the lane's read barrier is the fresh authority a
|
|
179
|
+
// historical proof waited for: only newer edits may be saved from here.
|
|
180
|
+
endRefresh(entry);
|
|
181
|
+
if (!entry.withheld && !entry.reading) return;
|
|
182
|
+
entry.withheld = false;
|
|
183
|
+
entry.reading = false;
|
|
184
|
+
if (![...entry.captureOps.values()].some((captured) => captured.ended)) disarm(entry);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// A store whose lost save durable history is still resolving. None of it is saved
|
|
188
|
+
// or checkpointed meanwhile: its own captured operations are withheld, and an
|
|
189
|
+
// answer that proves them is followed by a fresh room read before anything newer
|
|
190
|
+
// may be saved.
|
|
191
|
+
function historyPending() {
|
|
192
|
+
for (const entry of entries.values()) {
|
|
193
|
+
if (entry.recovery || entry.historicalRefreshPending) return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function stillPending(entry) {
|
|
199
|
+
const pending = new Set(entry.store.pendingEntries().map((item) => item.id));
|
|
200
|
+
const target = entry.recovery.targets.find(({ name }) => name === entry.store.name);
|
|
201
|
+
return target?.ids.some((id) => pending.has(id)) ?? false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The file-mode stores a lost answer left in doubt, with the exact body whose
|
|
205
|
+
// attempts can still prove them. Null when this capture bound no such store.
|
|
206
|
+
function historyPlan(captureId) {
|
|
207
|
+
if (!sending || sending.captureId !== captureId || typeof sending.html !== "string") return null;
|
|
208
|
+
const targets = [];
|
|
209
|
+
for (const [name, ids] of sending.qualified) {
|
|
210
|
+
const entry = entries.get(name);
|
|
211
|
+
if (!entry || !ids.length) continue;
|
|
212
|
+
targets.push({ name, ids: [...ids], saved: sending.saved.get(name) });
|
|
213
|
+
}
|
|
214
|
+
if (!targets.length) return null;
|
|
215
|
+
return { captureId, html: sending.html, targets };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The body is needed until the one hash of it is taken, and no longer.
|
|
219
|
+
function releaseBody() {
|
|
220
|
+
if (sending) sending.html = null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The barrier outlives the window: a store still waiting for its fresh read at
|
|
224
|
+
// the original deadline says so once, and stays blocked until one arrives.
|
|
225
|
+
function armRefresh(entry, deadline) {
|
|
226
|
+
disarmRefresh(entry);
|
|
227
|
+
const name = entry.store.name;
|
|
228
|
+
entry.refreshTimer = setTimeout(() => {
|
|
229
|
+
entry.refreshTimer = null;
|
|
230
|
+
if (!entry.historicalRefreshPending) return;
|
|
231
|
+
const message = `The save of "${name}" that ended without an answer did happen, but the value the file holds now must load before newer edits can be saved.`;
|
|
232
|
+
console.warn(`clay.live("${name}"): ${message}`);
|
|
233
|
+
document.dispatchEvent(new CustomEvent("clay:live-notice", { detail: { name, code: "save-history-refresh-required", message } }));
|
|
234
|
+
}, Math.max(0, deadline - Date.now()));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Only a room can hold state newer than the proven save. A fresh read that shows
|
|
238
|
+
// no room holds the store, or a lane that stopped, leaves the file as authority,
|
|
239
|
+
// and its conditional saves refuse a write they have not seen.
|
|
240
|
+
function endRefresh(entry) {
|
|
241
|
+
if (!entry.historicalRefreshPending) return;
|
|
242
|
+
entry.historicalRefreshPending = false;
|
|
243
|
+
disarmRefresh(entry);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function disarmRefresh(entry) {
|
|
247
|
+
if (entry.refreshTimer === null) return;
|
|
248
|
+
clearTimeout(entry.refreshTimer);
|
|
249
|
+
entry.refreshTimer = null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Another proof settled this store's capture first (its operations left pending
|
|
253
|
+
// through authority, or an accepted body confirmed them): the lookup is redundant.
|
|
254
|
+
function cancelRecovery(entry) {
|
|
255
|
+
const record = entry.recovery;
|
|
256
|
+
if (!record) return;
|
|
257
|
+
entry.recovery = null;
|
|
258
|
+
if (record.targets.some((target) => entries.get(target.name)?.recovery === record)) return;
|
|
259
|
+
recoveries.delete(record.captureId);
|
|
260
|
+
record.run?.cancel();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function cancelCaptureRecovery(captureId) {
|
|
264
|
+
if (captureId === null || captureId === undefined) return;
|
|
265
|
+
const record = recoveries.get(captureId);
|
|
266
|
+
if (!record) return;
|
|
267
|
+
recoveries.delete(captureId);
|
|
268
|
+
for (const target of record.targets) {
|
|
269
|
+
const entry = entries.get(target.name);
|
|
270
|
+
if (entry && entry.recovery === record) entry.recovery = null;
|
|
271
|
+
}
|
|
272
|
+
record.run?.cancel();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// One absolute window from the logical save's settlement, over the body it sent
|
|
276
|
+
// and the at most two IDs its attempts used. The host has to advertise the
|
|
277
|
+
// capability first: without it there is nothing to ask, and the existing
|
|
278
|
+
// uncertainty path is the truthful one.
|
|
279
|
+
function startHistoryRecovery(plan, { result, attempts, startedAt }) {
|
|
280
|
+
const { captureId, html } = plan;
|
|
281
|
+
const record = { captureId, deadline: startedAt + RECOVERY_WINDOW_MS, targets: plan.targets, run: null };
|
|
282
|
+
recoveries.set(captureId, record);
|
|
283
|
+
for (const target of plan.targets) {
|
|
284
|
+
const entry = entries.get(target.name);
|
|
285
|
+
disarm(entry);
|
|
286
|
+
entry.recovery = record;
|
|
287
|
+
}
|
|
288
|
+
hostMeta.hostSupports("save-history").then(
|
|
289
|
+
(supported) => {
|
|
290
|
+
if (recoveries.get(captureId) !== record) return;
|
|
291
|
+
plan.html = null;
|
|
292
|
+
if (!supported) return fallBackToRoom(captureId);
|
|
293
|
+
record.run = history.start({
|
|
294
|
+
captureId,
|
|
295
|
+
html,
|
|
296
|
+
attempts,
|
|
297
|
+
initialOutcome: result?.saveOutcome ?? null,
|
|
298
|
+
startedAt,
|
|
299
|
+
onApplied: ({ saveId, requestHash }) => applyHistory(captureId, saveId, requestHash),
|
|
300
|
+
onUnknown: ({ reason }) => failHistory(captureId, reason),
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
() => {
|
|
304
|
+
plan.html = null;
|
|
305
|
+
fallBackToRoom(captureId);
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Durable history proved the capture happened. Its own operations leave pending
|
|
311
|
+
// without ever being sent again, and the store waits for a fresh room read before
|
|
312
|
+
// anything newer is saved.
|
|
313
|
+
function applyHistory(captureId, saveId, requestHash) {
|
|
314
|
+
const record = recoveries.get(captureId);
|
|
315
|
+
if (!record) return;
|
|
316
|
+
recoveries.delete(captureId);
|
|
317
|
+
for (const target of record.targets) {
|
|
318
|
+
const entry = entries.get(target.name);
|
|
319
|
+
if (!entry || entry.recovery !== record) continue;
|
|
320
|
+
entry.recovery = null;
|
|
321
|
+
// The flag first: nothing may start a save while these IDs move.
|
|
322
|
+
entry.historicalRefreshPending = true;
|
|
323
|
+
const pending = new Set(entry.store.pendingEntries().map((item) => item.id));
|
|
324
|
+
const dropIds = target.ids.filter((id) => pending.has(id));
|
|
325
|
+
// The file now holds the captured value: it is the authority until a room says otherwise.
|
|
326
|
+
entry.store.rebase(JSON.parse(target.saved), { source: "state", dropIds });
|
|
327
|
+
if (!fast) {
|
|
328
|
+
endRefresh(entry);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
fast.release(entry.store.name, { read: true });
|
|
332
|
+
armRefresh(entry, record.deadline);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The host never advertised save-history: there is nothing to ask, so the
|
|
337
|
+
// operations wait for this room to prove them, as any unanswered save does.
|
|
338
|
+
function fallBackToRoom(captureId) {
|
|
339
|
+
const record = recoveries.get(captureId);
|
|
340
|
+
if (!record) return;
|
|
341
|
+
recoveries.delete(captureId);
|
|
342
|
+
for (const target of record.targets) {
|
|
343
|
+
const entry = entries.get(target.name);
|
|
344
|
+
if (!entry || entry.recovery !== record) continue;
|
|
345
|
+
entry.recovery = null;
|
|
346
|
+
if (!fast?.roomBound(entry.store.name)) {
|
|
347
|
+
giveUp(entry, { read: false });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
entry.withheld = true;
|
|
351
|
+
entry.reading = true;
|
|
352
|
+
arm(entry);
|
|
353
|
+
fast.release(entry.store.name, { read: true });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// No usable answer (the window expired, the body could not be hashed, the host
|
|
358
|
+
// answered nothing): the unresolved operations take the existing uncertainty
|
|
359
|
+
// path once, and are never replayed.
|
|
360
|
+
function failHistory(captureId, reason) {
|
|
361
|
+
const record = recoveries.get(captureId);
|
|
362
|
+
if (!record) return;
|
|
363
|
+
recoveries.delete(captureId);
|
|
364
|
+
for (const target of record.targets) {
|
|
365
|
+
const entry = entries.get(target.name);
|
|
366
|
+
if (!entry || entry.recovery !== record) continue;
|
|
367
|
+
entry.recovery = null;
|
|
368
|
+
giveUp(entry);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function ended(entry, key, plan) {
|
|
373
|
+
const captured = entry.captureOps.get(key);
|
|
374
|
+
const name = entry.store.name;
|
|
375
|
+
if (!captured) {
|
|
376
|
+
if (entry.store.handle.status.mode === "file") entry.leftAt = null;
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
captured.ended = true;
|
|
380
|
+
if (plan?.has(name)) {
|
|
381
|
+
// Durable history is the only thing that can still prove these operations:
|
|
382
|
+
// they wait for its answer, never for this room's receipts.
|
|
383
|
+
entry.withheld = true;
|
|
384
|
+
entry.reading = true;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (!fast?.roomBound(name)) {
|
|
388
|
+
giveUp(entry, { read: false });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
entry.withheld = true;
|
|
392
|
+
entry.reading = true;
|
|
393
|
+
arm(entry);
|
|
394
|
+
fast.release(name, { read: true });
|
|
395
|
+
}
|
|
29
396
|
|
|
30
397
|
// A JSON text as the value it holds, so formatting (spacing, key order) never
|
|
31
398
|
// decides a match.
|
|
@@ -70,7 +437,7 @@ function fileMoved() {
|
|
|
70
437
|
|
|
71
438
|
const checkpoints = createCheckpoints({
|
|
72
439
|
needsSave: unsavedLiveChanges,
|
|
73
|
-
blocked: () => !baselineSettled() || isSaveConflicted() || autosaveIsSuspended(),
|
|
440
|
+
blocked: () => !baselineSettled() || isSaveConflicted() || autosaveIsSuspended() || historyPending(),
|
|
74
441
|
save: () => savePageForce().finally(fileMoved),
|
|
75
442
|
saveNow: () => withKeepalive(true, () => savePageForce()),
|
|
76
443
|
});
|
|
@@ -79,11 +446,38 @@ const checkpoints = createCheckpoints({
|
|
|
79
446
|
// clay.live() call in an edit-mode tab.
|
|
80
447
|
let fast = null;
|
|
81
448
|
let laneRequested = false;
|
|
449
|
+
let serverLane = null;
|
|
450
|
+
// The host merges exact save provenance (live-by v2); without it a save that
|
|
451
|
+
// needs provenance is refused rather than risk applying an edit twice.
|
|
452
|
+
let provenanceHost = false;
|
|
82
453
|
let local = null;
|
|
83
454
|
// True from pagehide until the page is shown again (a page kept for back and
|
|
84
455
|
// forward): only a closing page's save is reported to the local room at capture.
|
|
85
456
|
let closing = false;
|
|
86
457
|
|
|
458
|
+
// The page plugin's one-shot gates: the fast lane's setup, and the room's
|
|
459
|
+
// writer election. Both resolve null when this tab has no lane at all.
|
|
460
|
+
let laneReady = null;
|
|
461
|
+
let writerReady = null;
|
|
462
|
+
let settleLane = null;
|
|
463
|
+
let settleWriter = null;
|
|
464
|
+
|
|
465
|
+
function laneGates() {
|
|
466
|
+
if (!laneReady) {
|
|
467
|
+
laneReady = new Promise((resolve) => { settleLane = resolve; });
|
|
468
|
+
writerReady = new Promise((resolve) => { settleWriter = resolve; });
|
|
469
|
+
}
|
|
470
|
+
return { laneReady, writerReady };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function pageLaneReady() {
|
|
474
|
+
return laneGates().laneReady;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function pageWriterReady() {
|
|
478
|
+
return laneGates().writerReady;
|
|
479
|
+
}
|
|
480
|
+
|
|
87
481
|
// The room's numbers moved: a file the room cannot rebuild from (landed -1) is
|
|
88
482
|
// saved over now, anything else on the usual deadline.
|
|
89
483
|
function liveMoved() {
|
|
@@ -96,16 +490,6 @@ function liveMoved() {
|
|
|
96
490
|
checkpoints.changed();
|
|
97
491
|
}
|
|
98
492
|
|
|
99
|
-
// The fast lane frames nothing of a store while some of its pending ops are in
|
|
100
|
-
// a file-mode save on the wire: the file may hold them, and the room merges the file.
|
|
101
|
-
function heldBySave(name) {
|
|
102
|
-
const entry = entries.get(name);
|
|
103
|
-
if (!entry) return false;
|
|
104
|
-
const saved = pinned?.get(name);
|
|
105
|
-
const through = saved?.mode === "file" && isSaveInProgress() ? saved.mark : 0;
|
|
106
|
-
return through > 0 && entry.store.pendingEntries().some((pending) => pending.id <= through);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
493
|
// Drop a store's ops through mark: a save that may have landed holds them.
|
|
110
494
|
function dropThrough(entry, mark) {
|
|
111
495
|
entry.store.rebase(entry.store.authoritative, { source: "state", through: mark });
|
|
@@ -114,22 +498,57 @@ function dropThrough(entry, mark) {
|
|
|
114
498
|
// A save ended without being accepted. maybeLanded: it may have reached the
|
|
115
499
|
// file (an error or a lost answer). Ops it carried in file mode go on (to the
|
|
116
500
|
// room, or the next save) when it surely did not land. When it may have, they
|
|
117
|
-
// are
|
|
118
|
-
|
|
501
|
+
// are never sent again, and are given up unless proof of the file's value
|
|
502
|
+
// settles them first (see ended).
|
|
503
|
+
function endPin(pin, maybeLanded, plan = null) {
|
|
119
504
|
if (!pin) return;
|
|
120
505
|
for (const [name, saved] of pin) {
|
|
121
506
|
const entry = entries.get(name);
|
|
122
507
|
if (!entry || saved.mode !== "file") continue;
|
|
123
|
-
if (maybeLanded)
|
|
508
|
+
if (maybeLanded || saved.maybeLanded) ended(entry, saved.key, plan);
|
|
509
|
+
else {
|
|
510
|
+
entry.captureOps.delete(saved.key);
|
|
511
|
+
settle(entry);
|
|
512
|
+
}
|
|
124
513
|
if (entry.store.handle.status.mode === "fast") fast?.release(name);
|
|
125
514
|
}
|
|
126
515
|
}
|
|
127
516
|
|
|
517
|
+
function dropCapture(captureId) {
|
|
518
|
+
const pin = capturePins.get(captureId);
|
|
519
|
+
capturePins.delete(captureId);
|
|
520
|
+
if (wireCaptureId === captureId) wireCaptureId = null;
|
|
521
|
+
if (unsentCaptureId === captureId) unsentCaptureId = null;
|
|
522
|
+
return pin;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// A DOM result event is the shadow of a save the hooks already reported: the
|
|
526
|
+
// capture it settled is theirs to end. So this only ends a capture that never
|
|
527
|
+
// reached an attempt, and a capture made between the two keeps its pin.
|
|
528
|
+
function endDomPin(maybeLanded, ownerId = null) {
|
|
529
|
+
const captureId = ownerId ?? wireCaptureId ?? unsentCaptureId;
|
|
530
|
+
if (captureId === null) return;
|
|
531
|
+
endPin(dropCapture(captureId), maybeLanded);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// A capture that sent nothing is definitely unsent. Otherwise only a 4xx proves
|
|
535
|
+
// the host wrote nothing, and only when no attempt timed out. A lost answer, a
|
|
536
|
+
// timeout, a 5xx or a historical duplicate may have landed.
|
|
537
|
+
function mayHaveLanded(result, sent) {
|
|
538
|
+
const code = result?.code;
|
|
539
|
+
const precommit = code === "page-converting" || code === "page-capacity";
|
|
540
|
+
return code === "save-already-applied" || result?.hadTimeout === true ||
|
|
541
|
+
(sent !== false && !precommit && !(result?.status >= 400 && result?.status < 500));
|
|
542
|
+
}
|
|
543
|
+
|
|
128
544
|
function laneOptions(transport) {
|
|
129
545
|
return {
|
|
130
546
|
transport,
|
|
131
547
|
entries,
|
|
132
|
-
onWriter: () =>
|
|
548
|
+
onWriter: () => {
|
|
549
|
+
checkpoints.changed();
|
|
550
|
+
if (fast && fast.status.writer !== null) settleWriter?.(fast);
|
|
551
|
+
},
|
|
133
552
|
onApplied: liveMoved,
|
|
134
553
|
onLanded: () => {
|
|
135
554
|
liveMoved();
|
|
@@ -138,9 +557,31 @@ function laneOptions(transport) {
|
|
|
138
557
|
onCheckpoint: () => checkpoints.soon(),
|
|
139
558
|
onLeave: (name, at) => {
|
|
140
559
|
const entry = entries.get(name);
|
|
141
|
-
if (entry) entry.leftAt = at;
|
|
560
|
+
if (entry?.store.handle.status.mode === "fast") entry.leftAt = { ...at, frames: [], ancestors: [] };
|
|
561
|
+
},
|
|
562
|
+
held: (name) => heldIds(name).length > 0,
|
|
563
|
+
heldIds,
|
|
564
|
+
hold: (name) => {
|
|
565
|
+
const entry = entries.get(name);
|
|
566
|
+
if (!entry) return false;
|
|
567
|
+
// A lost answer durable history is still resolving is not this room's to
|
|
568
|
+
// decide: its window outlives the request and the room's receipts.
|
|
569
|
+
if (entry.recovery) return true;
|
|
570
|
+
if (inFlight(entry)) {
|
|
571
|
+
entry.withheld = true;
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
giveUp(entry, { read: false });
|
|
575
|
+
return false;
|
|
576
|
+
},
|
|
577
|
+
taken,
|
|
578
|
+
onStop: () => {
|
|
579
|
+
for (const entry of entries.values()) {
|
|
580
|
+
endRefresh(entry);
|
|
581
|
+
if (entry.recovery) continue;
|
|
582
|
+
if (!inFlight(entry)) giveUp(entry, { read: false });
|
|
583
|
+
}
|
|
142
584
|
},
|
|
143
|
-
held: heldBySave,
|
|
144
585
|
};
|
|
145
586
|
}
|
|
146
587
|
|
|
@@ -164,16 +605,22 @@ function seedLocalRoom() {
|
|
|
164
605
|
}
|
|
165
606
|
|
|
166
607
|
function ensureLane() {
|
|
608
|
+
laneGates();
|
|
167
609
|
if (laneRequested || !isEditMode) return;
|
|
168
610
|
laneRequested = true;
|
|
169
|
-
hostSupports("live-data").then(
|
|
170
|
-
(supported) => {
|
|
611
|
+
hostMeta.hostSupports("live-data").then(
|
|
612
|
+
async (supported) => {
|
|
613
|
+
const provenance = supported && (await hostMeta.hostSupports("live-provenance"));
|
|
614
|
+
serverLane = supported;
|
|
615
|
+
provenanceHost = provenance;
|
|
171
616
|
if (supported) {
|
|
172
617
|
fast = createFastLane(laneOptions(serverTransport()));
|
|
173
618
|
} else if (localLaneSupported()) {
|
|
174
619
|
local = localTransport({ seed: seedLocalRoom });
|
|
175
620
|
fast = createFastLane(laneOptions(local));
|
|
176
621
|
} else {
|
|
622
|
+
settleLane?.(null);
|
|
623
|
+
settleWriter?.(null);
|
|
177
624
|
return;
|
|
178
625
|
}
|
|
179
626
|
// The writer saves every store in the room, so every live tag is opened,
|
|
@@ -184,10 +631,20 @@ function ensureLane() {
|
|
|
184
631
|
live(name);
|
|
185
632
|
} catch {}
|
|
186
633
|
}
|
|
634
|
+
// A store still waiting for its fresh read needs its barrier on this lane
|
|
635
|
+
// too: a lane that starts after the proof must not take its first state
|
|
636
|
+
// blindly.
|
|
637
|
+
for (const [name, entry] of entries) {
|
|
638
|
+
if (entry.historicalRefreshPending) fast.release(name, { read: true });
|
|
639
|
+
}
|
|
187
640
|
fast.start();
|
|
641
|
+
settleLane?.(fast);
|
|
642
|
+
if (fast.status.writer !== null) settleWriter?.(fast);
|
|
188
643
|
},
|
|
189
|
-
|
|
190
|
-
|
|
644
|
+
).catch(() => {
|
|
645
|
+
settleLane?.(null);
|
|
646
|
+
settleWriter?.(null);
|
|
647
|
+
});
|
|
191
648
|
}
|
|
192
649
|
|
|
193
650
|
function isJsonType(type) {
|
|
@@ -224,20 +681,46 @@ function parseHtml(html) {
|
|
|
224
681
|
// In fast mode a capture writes the room's value, never this tab's unconfirmed
|
|
225
682
|
// ops (the room may still refuse them).
|
|
226
683
|
function textOf(entry) {
|
|
227
|
-
const
|
|
228
|
-
|
|
684
|
+
const fastMode = entry.store.handle.status.mode === "fast";
|
|
685
|
+
const current = fastMode ? entry.store.authoritative : entry.store.value;
|
|
686
|
+
const value = fastMode && /^page(\.[0-9]+)?$/.test(entry.store.name) ? captureYValue(current) : current;
|
|
687
|
+
if (entry.layout === "lines") return serializeLines(value);
|
|
688
|
+
return entry.layout === "pretty" ? JSON.stringify(value, null, 2).replace(/</g, "\\u003c") : serializeLive(value);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
692
|
+
|
|
693
|
+
// live-format="lines": each entry of each top-level object on its own line, so
|
|
694
|
+
// a version diff of a large store (a page's blocks) shows only the entries
|
|
695
|
+
// that changed.
|
|
696
|
+
export function serializeLines(value) {
|
|
697
|
+
if (!isObject(value)) return serializeLive(value);
|
|
698
|
+
const parts = Object.entries(value).map(([key, inner]) => {
|
|
699
|
+
if (!isObject(inner) || !Object.keys(inner).length) return `${serializeLive(key)}:${serializeLive(inner)}`;
|
|
700
|
+
return `${serializeLive(key)}:{\n${Object.entries(inner).map(([k, v]) => `${serializeLive(k)}:${serializeLive(v)}`).join(",\n")}\n}`;
|
|
701
|
+
});
|
|
702
|
+
return `{${parts.join(",\n")}}`;
|
|
229
703
|
}
|
|
230
704
|
|
|
231
705
|
// A value another tab saved or relayed. It never confirms this tab's ops: equal
|
|
232
|
-
// text from a peer is not this tab's save.
|
|
233
|
-
|
|
706
|
+
// text from a peer is not this tab's save. through: this tab's ops the value
|
|
707
|
+
// already holds (its frames that landed before the value's stamp).
|
|
708
|
+
function hashOfText(text) {
|
|
709
|
+
try {
|
|
710
|
+
return textHash(serializeLive(HyperMorph.parseJsonRelaxed(text)));
|
|
711
|
+
} catch {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function receive(entry, text, source, dropIds) {
|
|
234
717
|
let value;
|
|
235
718
|
try {
|
|
236
719
|
value = HyperMorph.parseJsonRelaxed(text);
|
|
237
720
|
} catch {
|
|
238
721
|
return;
|
|
239
722
|
}
|
|
240
|
-
if (entry.store.rebase(value, { source })) entry.framesSince = true;
|
|
723
|
+
if (entry.store.rebase(value, { source, ...(dropIds?.length && { dropIds }) })) entry.framesSince = true;
|
|
241
724
|
}
|
|
242
725
|
|
|
243
726
|
export function live(name) {
|
|
@@ -253,8 +736,26 @@ export function live(name) {
|
|
|
253
736
|
throw new SyntaxError(`clay.live("${name}"): the tag's JSON does not parse: ${err.message}`);
|
|
254
737
|
}
|
|
255
738
|
const store = createStore({ name, value, ephemeral: tag.ephemeral });
|
|
256
|
-
const
|
|
739
|
+
const layout = tag.element.getAttribute("live-format") === "lines" ? "lines" : text.includes("\n") ? "pretty" : "line";
|
|
740
|
+
const entry = { store, layout, framesSince: false, last: 0, landed: 0, leftAt: lineageOf(tag.element), captureOps: new Map(), withheld: false, reading: false, timer: null, recovery: null, historicalRefreshPending: false, refreshTimer: null };
|
|
257
741
|
entries.set(name, entry);
|
|
742
|
+
store.onDelivery((event) => {
|
|
743
|
+
if (event.code === "receipt-expired") document.dispatchEvent(new CustomEvent("clay:live-notice", {
|
|
744
|
+
detail: { name, code: event.code, ops: event.ops, message: "The room retired an update receipt. Check the saved value before restoring this update." },
|
|
745
|
+
}));
|
|
746
|
+
if (event.phase === "refused" && event.code === "too-large" && !/^page(\.[0-9]+)?$/.test(name)) document.dispatchEvent(new CustomEvent("clay:live-notice", {
|
|
747
|
+
detail: { name, code: "op-too-large", message: "That change was too large to share, so it was undone." },
|
|
748
|
+
}));
|
|
749
|
+
// The room was opened from a document with no store of this name, so the
|
|
750
|
+
// page it holds is not the one this tab is editing.
|
|
751
|
+
if (event.phase === "left" && event.code === "no-store" && /^page(\.[0-9]+)?$/.test(name)) document.dispatchEvent(new CustomEvent("clay:live-notice", {
|
|
752
|
+
detail: { name, code: "unknown-store", message: `The room has no "${name}" store, so the page it holds is not the one this tab is editing.` },
|
|
753
|
+
}));
|
|
754
|
+
});
|
|
755
|
+
store.onRebase((event) => {
|
|
756
|
+
settle(entry);
|
|
757
|
+
if (store.handle.status.mode === "file" && ["undone", "refused", "decided"].includes(event.cause)) entry.leftAt = null;
|
|
758
|
+
});
|
|
258
759
|
if (!tag.ephemeral) store.onLocal(() => checkpoints.changed());
|
|
259
760
|
store.onLocal((ops) => fast?.local(name, ops, store.mark));
|
|
260
761
|
store.handle.on("change", () => {
|
|
@@ -267,22 +768,61 @@ export function live(name) {
|
|
|
267
768
|
|
|
268
769
|
// Every capture carries each saved store's current value. A never-saved store
|
|
269
770
|
// writes nothing: its tag keeps the text the file has.
|
|
270
|
-
onSnapshot((clone) => {
|
|
771
|
+
onSnapshot((clone, { forSave, requestRefresh }) => {
|
|
271
772
|
if (!entries.size) return;
|
|
272
773
|
const tags = liveTags(clone, { warn: false });
|
|
273
774
|
for (const entry of entries.values()) {
|
|
274
775
|
if (entry.store.ephemeral) continue;
|
|
275
776
|
const tag = tags.get(entry.store.name);
|
|
276
777
|
if (!tag) continue;
|
|
778
|
+
// A recovering store's own operations are not written again while the answer
|
|
779
|
+
// about them is still open, and not before the value the file holds now has
|
|
780
|
+
// loaded: this capture is refused, exactly as an unsupported host's is.
|
|
781
|
+
if (forSave && (entry.recovery || entry.historicalRefreshPending)) {
|
|
782
|
+
const needsRefresh = entry.historicalRefreshPending;
|
|
783
|
+
if (needsRefresh && requestRefresh) fast?.release(entry.store.name, { read: true });
|
|
784
|
+
const code = needsRefresh ? "save-history-refresh-required" : "save-history-pending";
|
|
785
|
+
const message = needsRefresh
|
|
786
|
+
? `clay.live("${entry.store.name}"): the earlier save happened, but the current value must load before newer edits can be saved. Your edits are still in this tab.`
|
|
787
|
+
: `clay.live("${entry.store.name}"): a save ended without an answer, so the saved value must be checked before this one is sent. Your edits are still in this tab.`;
|
|
788
|
+
console.warn(`clay.live("${entry.store.name}"): ${message}`);
|
|
789
|
+
if (!needsRefresh) document.dispatchEvent(new CustomEvent("clay:live-notice", { detail: { name: entry.store.name, code, message } }));
|
|
790
|
+
throw Object.assign(new Error(message), { code });
|
|
791
|
+
}
|
|
792
|
+
const resent = forSave && entry.store.handle.status.mode === "file" ? endedCapture(entry)?.[1] : null;
|
|
793
|
+
if (resent) {
|
|
794
|
+
tag.element.textContent = resent.text;
|
|
795
|
+
for (const [attribute, value] of [["live-at", resent.at], ["live-by", resent.by]]) {
|
|
796
|
+
if (value === null) tag.element.removeAttribute(attribute);
|
|
797
|
+
else tag.element.setAttribute(attribute, value);
|
|
798
|
+
}
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
277
801
|
const text = textOf(entry);
|
|
278
802
|
tag.element.textContent = text;
|
|
279
803
|
// The room position this value was written at, and a hash of the value: the
|
|
280
804
|
// host merges a later write of the file from that position.
|
|
281
805
|
if (entry.store.handle.status.mode === "fast" && fast) {
|
|
282
806
|
tag.element.setAttribute("live-at", stampOf(fast.status.epoch, fast.status.seq, serializeLive(entry.store.authoritative)));
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
807
|
+
tag.element.removeAttribute("live-by");
|
|
808
|
+
} else {
|
|
809
|
+
const needsProvenance = entry.store.pending.length > 0 || (entry.leftAt && !entry.leftAt.disk);
|
|
810
|
+
const unknown = serverLane === null && entry.leftAt && !entry.leftAt.disk;
|
|
811
|
+
if (forSave && needsProvenance && (unknown || (serverLane && !provenanceHost))) {
|
|
812
|
+
const message = "This host must update its live data protocol before this save can be sent. Your edits are still in this tab.";
|
|
813
|
+
console.warn(`clay.live("${entry.store.name}"): ${message}`);
|
|
814
|
+
document.dispatchEvent(new CustomEvent("clay:live-notice", { detail: { name: entry.store.name, code: "provenance-unsupported", message } }));
|
|
815
|
+
fast?.refresh();
|
|
816
|
+
throw Object.assign(new Error(message), { code: "provenance-unsupported" });
|
|
817
|
+
}
|
|
818
|
+
const capture = captureLineage(entry, forSave);
|
|
819
|
+
if (capture) {
|
|
820
|
+
tag.element.setAttribute("live-at", capture.at);
|
|
821
|
+
tag.element.setAttribute("live-by", capture.by);
|
|
822
|
+
} else {
|
|
823
|
+
tag.element.removeAttribute("live-at");
|
|
824
|
+
tag.element.removeAttribute("live-by");
|
|
825
|
+
}
|
|
286
826
|
}
|
|
287
827
|
}
|
|
288
828
|
});
|
|
@@ -293,6 +833,7 @@ onSnapshot((clone) => {
|
|
|
293
833
|
// belonged to a save that ended without acceptance.
|
|
294
834
|
document.addEventListener("clay:snapshot-ready", (event) => {
|
|
295
835
|
if (event.detail?.forSave !== true || !entries.size) return;
|
|
836
|
+
const captureId = event.detail.captureId ?? null;
|
|
296
837
|
const tags = liveTags(event.detail.documentElement, { warn: false });
|
|
297
838
|
const stores = new Map();
|
|
298
839
|
for (const entry of entries.values()) {
|
|
@@ -301,37 +842,53 @@ document.addEventListener("clay:snapshot-ready", (event) => {
|
|
|
301
842
|
if (!tag) continue;
|
|
302
843
|
const mode = entry.store.handle.status.mode === "fast" && fast ? "fast" : "file";
|
|
303
844
|
const at = mode === "fast" ? { epoch: fast.status.epoch, seq: fast.status.seq } : null;
|
|
304
|
-
|
|
845
|
+
const lineage = mode === "file" ? lineageOf(tag.element) : null;
|
|
846
|
+
const proof = parseBy(tag.element.getAttribute("live-by"));
|
|
847
|
+
const resent = mode === "file" ? endedCapture(entry) : null;
|
|
848
|
+
const pending = entry.store.pendingEntries().map((e) => e.id);
|
|
849
|
+
const carried = resent ? resent[1].ids.filter((id) => pending.includes(id)) : mode === "file" ? pending : [];
|
|
850
|
+
const capture = mode === "file" && proof?.v === 2 ? proof.capture : null;
|
|
851
|
+
const key = !carried.length ? null : resent ? resent[0] : capture ? JSON.stringify(capture) : `anonymous:${++anonymousCaptures}`;
|
|
852
|
+
const mark = resent ? resent[1].mark : entry.store.mark;
|
|
853
|
+
stores.set(entry.store.name, { mode, mark, canonical: canonical(tag.element.textContent), at, lineage, by: tag.element.getAttribute("live-by"), key, maybeLanded: !!resent, ids: carried });
|
|
854
|
+
if (lineage && !lineage.disk && entry.leftAt) entry.leftAt = { ...entry.leftAt, ancestors: lineage.ancestors };
|
|
855
|
+
if (capture && capture[0] === captureAuthor) captureNumber = Math.max(captureNumber, capture[1]);
|
|
856
|
+
if (key) {
|
|
857
|
+
entry.captureOps.set(key, { capture, ids: carried, ended: false, mark, text: tag.element.textContent, at: tag.element.getAttribute("live-at"), by: tag.element.getAttribute("live-by") });
|
|
858
|
+
pruneCaptures(entry);
|
|
859
|
+
}
|
|
305
860
|
entry.framesSince = false;
|
|
306
861
|
// A close save is answered after the page is gone: the local room learns
|
|
307
862
|
// how far the file reaches now. Should the save fail, a later merge drops
|
|
308
863
|
// those moves, never counts them twice.
|
|
309
864
|
if (mode === "fast" && closing && checkpoints.flushing) local?.landed(entry.store.name, at);
|
|
310
865
|
}
|
|
311
|
-
// A
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
866
|
+
// A newer capture replaces only an older capture that was never sent; the one
|
|
867
|
+
// on the wire stays until its own outcome.
|
|
868
|
+
if (unsentCaptureId !== null && unsentCaptureId !== wireCaptureId) {
|
|
869
|
+
endPin(dropCapture(unsentCaptureId), false);
|
|
870
|
+
}
|
|
871
|
+
if (captureId !== null) {
|
|
872
|
+
capturePins.set(captureId, stores);
|
|
873
|
+
unsentCaptureId = captureId;
|
|
874
|
+
}
|
|
317
875
|
});
|
|
318
876
|
|
|
319
877
|
// A refusal proves the host wrote nothing, unless an earlier attempt of the
|
|
320
878
|
// same save timed out: that one may have landed.
|
|
321
879
|
document.addEventListener("clay:save-conflict", (event) => {
|
|
322
|
-
|
|
323
|
-
pinned = null;
|
|
324
|
-
endPin(previous, event.detail?.hadTimeout === true);
|
|
880
|
+
endDomPin(event.detail?.hadTimeout === true, event.detail?.captureId);
|
|
325
881
|
});
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
// follow a write) may have landed.
|
|
882
|
+
// A failed capture sent nothing. Otherwise only a 4xx proves the host wrote
|
|
883
|
+
// nothing, and only when no attempt timed out. A lost connection, a timeout
|
|
884
|
+
// or a 5xx (a proxy's 502 can follow a write) may have landed.
|
|
329
885
|
for (const type of ["clay:save-error", "clay:save-offline"]) {
|
|
330
886
|
document.addEventListener(type, (event) => {
|
|
331
887
|
const status = event.detail?.status;
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
888
|
+
const code = event.detail?.code;
|
|
889
|
+
const precommit = code === "page-converting" || code === "page-capacity";
|
|
890
|
+
endDomPin(code === "save-already-applied" || event.detail?.hadTimeout === true ||
|
|
891
|
+
(event.detail?.sent !== false && !precommit && !(status >= 400 && status < 500)), event.detail?.captureId);
|
|
335
892
|
});
|
|
336
893
|
}
|
|
337
894
|
|
|
@@ -344,9 +901,7 @@ for (const type of ["clay:save-error", "clay:save-offline"]) {
|
|
|
344
901
|
// frames what is left.
|
|
345
902
|
// captured in fast mode: the file holds the room's value, none of this tab's
|
|
346
903
|
// own ops. The host room sees the file land; a local room is told.
|
|
347
|
-
|
|
348
|
-
const pin = pinned;
|
|
349
|
-
pinned = null;
|
|
904
|
+
function confirmPin(html, pin) {
|
|
350
905
|
if (!pin || typeof html !== "string") return;
|
|
351
906
|
const tags = liveTags(parseHtml(html), { warn: false });
|
|
352
907
|
for (const [name, saved] of pin) {
|
|
@@ -355,7 +910,11 @@ export function confirmSaved(html) {
|
|
|
355
910
|
const tag = tags.get(name);
|
|
356
911
|
const mode = entry.store.handle.status.mode;
|
|
357
912
|
if (!tag || canonical(tag.element.textContent) !== saved.canonical) {
|
|
358
|
-
if (saved.mode === "file"
|
|
913
|
+
if (saved.mode === "file") {
|
|
914
|
+
if (fast?.roomBound(name)) dropThrough(entry, saved.mark);
|
|
915
|
+
if (mode === "fast") fast?.release(name);
|
|
916
|
+
settle(entry);
|
|
917
|
+
}
|
|
359
918
|
continue;
|
|
360
919
|
}
|
|
361
920
|
if (saved.mode === "fast") {
|
|
@@ -373,14 +932,104 @@ export function confirmSaved(html) {
|
|
|
373
932
|
// after this answer: read it, so the view holds these ops again.
|
|
374
933
|
fast?.refresh();
|
|
375
934
|
} else if (entry.framesSince) {
|
|
376
|
-
dropThrough(entry, saved.mark);
|
|
935
|
+
if (!entry.leftAt || entry.leftAt.disk || fast?.roomBound(name)) dropThrough(entry, saved.mark);
|
|
377
936
|
} else {
|
|
378
937
|
entry.store.confirm(saved.mark);
|
|
938
|
+
entry.leftAt = saved.lineage && tag.element.getAttribute("live-by") === saved.by ? saved.lineage : { disk: true };
|
|
379
939
|
}
|
|
940
|
+
settle(entry);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// The attempted body, parsed against the capture that claims it. Only a file-mode
|
|
945
|
+
// store whose tag still holds the captured canonical text, both provenance
|
|
946
|
+
// attributes and the exact captured operation IDs may have its IDs retired; a
|
|
947
|
+
// missing, duplicated or altered tag carries no proof.
|
|
948
|
+
function qualifyPin(captureId, pin, html) {
|
|
949
|
+
const tags = liveTags(parseHtml(html), { warn: false });
|
|
950
|
+
const qualified = new Map();
|
|
951
|
+
const savedValues = new Map();
|
|
952
|
+
for (const [name, saved] of pin) {
|
|
953
|
+
saved.qualifiedIds = null;
|
|
954
|
+
if (saved.mode !== "file" || saved.key === null) continue;
|
|
955
|
+
const entry = entries.get(name);
|
|
956
|
+
if (!entry) continue;
|
|
957
|
+
const tag = tags.get(name);
|
|
958
|
+
if (!tag) continue;
|
|
959
|
+
if (canonical(tag.element.textContent) !== saved.canonical) continue;
|
|
960
|
+
const captured = entry.captureOps.get(saved.key);
|
|
961
|
+
if (!captured) continue;
|
|
962
|
+
if (tag.element.getAttribute("live-at") !== captured.at) continue;
|
|
963
|
+
if (tag.element.getAttribute("live-by") !== saved.by) continue;
|
|
964
|
+
const pending = new Set(entry.store.pendingEntries().map((item) => item.id));
|
|
965
|
+
const currentIds = new Set(captured.ids);
|
|
966
|
+
const ids = saved.ids.filter((id) => pending.has(id) && currentIds.has(id));
|
|
967
|
+
if (!ids.length) continue;
|
|
968
|
+
saved.qualifiedIds = ids;
|
|
969
|
+
qualified.set(name, ids);
|
|
970
|
+
savedValues.set(name, saved.canonical);
|
|
380
971
|
}
|
|
972
|
+
sending = { captureId, qualified, saved: savedValues, html };
|
|
381
973
|
}
|
|
382
974
|
|
|
383
|
-
|
|
975
|
+
// The exact body is about to leave, under the capture that owns it. Find only
|
|
976
|
+
// that capture, keep it as the sending one, and qualify its stores against the
|
|
977
|
+
// body. A raw save has no capture and binds nothing.
|
|
978
|
+
onSaveAttempt(({ captureId, html }) => {
|
|
979
|
+
if (captureId === null || captureId === undefined) return;
|
|
980
|
+
const pin = capturePins.get(captureId);
|
|
981
|
+
if (!pin) return;
|
|
982
|
+
wireCaptureId = captureId;
|
|
983
|
+
if (unsentCaptureId === captureId) unsentCaptureId = null;
|
|
984
|
+
qualifyPin(captureId, pin, html);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
// One logical save settled. End only its capture, and classify what may have
|
|
988
|
+
// landed. An accepted save was already confirmed by the accepted hook, so it is
|
|
989
|
+
// left alone here.
|
|
990
|
+
onSaveOutcome(({ captureId, result, attempts, sent }) => {
|
|
991
|
+
if (captureId === null || captureId === undefined) return;
|
|
992
|
+
if (result?.ok) return;
|
|
993
|
+
const pin = capturePins.get(captureId);
|
|
994
|
+
if (!pin) return;
|
|
995
|
+
dropCapture(captureId);
|
|
996
|
+
const landed = mayHaveLanded(result, sent);
|
|
997
|
+
const plan = landed ? historyPlan(captureId) : null;
|
|
998
|
+
releaseBody();
|
|
999
|
+
endPin(pin, landed, plan ? new Set(plan.targets.map((target) => target.name)) : null);
|
|
1000
|
+
if (plan) startHistoryRecovery(plan, { result, attempts, startedAt: Date.now() });
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
// An accepted body confirms the ops of the capture that sent it, and only those.
|
|
1004
|
+
// A null context (a raw save) cannot consume a captured pin.
|
|
1005
|
+
function handleAccepted(html, context) {
|
|
1006
|
+
const captureId = context?.captureId;
|
|
1007
|
+
if (captureId === null || captureId === undefined) return;
|
|
1008
|
+
const pin = capturePins.get(captureId);
|
|
1009
|
+
if (!pin) return;
|
|
1010
|
+
dropCapture(captureId);
|
|
1011
|
+
releaseBody();
|
|
1012
|
+
cancelCaptureRecovery(captureId);
|
|
1013
|
+
confirmPin(html, pin);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
onSaveAccepted(handleAccepted);
|
|
1017
|
+
|
|
1018
|
+
// The explicit legacy call: confirm the newest capture against these bytes, as
|
|
1019
|
+
// direct callers and tests have always done.
|
|
1020
|
+
export function confirmSaved(html) {
|
|
1021
|
+
const captureId = unsentCaptureId ?? wireCaptureId;
|
|
1022
|
+
const pin = captureId === null ? null : dropCapture(captureId);
|
|
1023
|
+
releaseBody();
|
|
1024
|
+
cancelCaptureRecovery(captureId);
|
|
1025
|
+
confirmPin(html, pin);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Internal: the sending capture's qualified file-mode IDs, for the historical
|
|
1029
|
+
// recovery that builds on this binding. Not published on window.clay.
|
|
1030
|
+
export function sendingCapture() {
|
|
1031
|
+
return sending;
|
|
1032
|
+
}
|
|
384
1033
|
|
|
385
1034
|
// A live-sync frame. A saved store in file mode takes the frame's value and
|
|
386
1035
|
// replays its own unconfirmed ops over it. Every other live tag (never-saved
|
|
@@ -404,16 +1053,42 @@ export function receiveFrame(html, seq = null) {
|
|
|
404
1053
|
const text = tag.element.textContent;
|
|
405
1054
|
const entry = entries.get(name);
|
|
406
1055
|
const at = tag.element.getAttribute("live-at");
|
|
1056
|
+
const by = tag.element.getAttribute("live-by");
|
|
407
1057
|
const pageTag = page.get(name)?.element;
|
|
1058
|
+
if (entry && !entry.store.ephemeral && entry.store.handle.status.mode === "file" && at !== null && by === null && (entry.withheld || heldIds(name).length)) {
|
|
1059
|
+
entry.withheld = true;
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
408
1062
|
// The page's tag keeps the lineage of the version this tab last took, so a
|
|
409
|
-
// file-mode save claims what it descends from.
|
|
410
|
-
|
|
1063
|
+
// file-mode save claims what it descends from. A store out of the room
|
|
1064
|
+
// descends from the incoming value's room position when it is of this
|
|
1065
|
+
// room: its frames that landed at or before it are in that value and leave
|
|
1066
|
+
// pending, and its save stamps that position with its own live-by.
|
|
1067
|
+
// Only a capture counts (the value is the one its stamp hashes); for
|
|
1068
|
+
// anything else the page's tag takes the incoming lineage, as for a store
|
|
1069
|
+
// that was never in the room.
|
|
1070
|
+
let dropIds = [];
|
|
1071
|
+
if (entry && entry.store.handle.status.mode === "file") {
|
|
1072
|
+
const lineage = lineageOf(tag.element);
|
|
1073
|
+
const sameEpoch = lineage && !lineage.disk && (!fast?.status.epoch || lineage.epoch === fast.status.epoch);
|
|
1074
|
+
if (sameEpoch) dropIds = fast?.captured(name, lineage, parseBy(by)) ?? [];
|
|
1075
|
+
else if (lineage?.disk) dropIds = fast?.captured(name, parseStamp(at), null) ?? [];
|
|
1076
|
+
if (lineage && !lineage.disk) {
|
|
1077
|
+
for (const [key, captured] of entry.captureOps) if (captured.capture && contains(lineage.ancestors, ...captured.capture)) {
|
|
1078
|
+
dropIds.push(...captured.ids);
|
|
1079
|
+
entry.captureOps.delete(key);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
entry.leftAt = sameEpoch ? lineage : lineage?.disk ? lineage : null;
|
|
1083
|
+
}
|
|
411
1084
|
if (pageTag && (!entry || entry.store.handle.status.mode === "file")) {
|
|
412
1085
|
if (at === null) pageTag.removeAttribute("live-at");
|
|
413
1086
|
else if (pageTag.getAttribute("live-at") !== at) pageTag.setAttribute("live-at", at);
|
|
1087
|
+
if (by === null) pageTag.removeAttribute("live-by");
|
|
1088
|
+
else if (pageTag.getAttribute("live-by") !== by) pageTag.setAttribute("live-by", by);
|
|
414
1089
|
}
|
|
415
1090
|
if (entry && !entry.store.ephemeral) {
|
|
416
|
-
if (entry.store.handle.status.mode === "file") receive(entry, text, "remote");
|
|
1091
|
+
if (entry.store.handle.status.mode === "file") receive(entry, text, "remote", dropIds);
|
|
417
1092
|
continue;
|
|
418
1093
|
}
|
|
419
1094
|
if (pageTag && pageTag.textContent !== text) pageTag.textContent = text;
|
|
@@ -430,8 +1105,10 @@ document.addEventListener("visibilitychange", () => {
|
|
|
430
1105
|
window.addEventListener("pagehide", () => {
|
|
431
1106
|
closing = true;
|
|
432
1107
|
// A hide save still on the wire is answered after the page is gone too.
|
|
433
|
-
|
|
434
|
-
|
|
1108
|
+
const latest = unsentCaptureId ?? wireCaptureId;
|
|
1109
|
+
const pin = checkpoints.flushing && latest !== null ? capturePins.get(latest) : null;
|
|
1110
|
+
if (pin) {
|
|
1111
|
+
for (const [name, saved] of pin) if (saved.mode === "fast" && saved.at) local?.landed(name, saved.at);
|
|
435
1112
|
}
|
|
436
1113
|
checkpoints.flush();
|
|
437
1114
|
});
|