@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
@@ -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 { hostSupports } from "../core/host-meta.js";
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
- // What the save on the wire captured, per saved store: { mode, mark, canonical,
26
- // at }. Set by the save's own capture, taken by its acceptance or its failure.
27
- // Only one save is on the wire at a time.
28
- let pinned = null;
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 dropped, never sent again: if it landed, the file brings them back.
118
- function endPin(pin, maybeLanded) {
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) dropThrough(entry, saved.mark);
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: () => checkpoints.changed(),
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 value = entry.store.handle.status.mode === "fast" ? entry.store.authoritative : entry.store.value;
228
- return entry.pretty ? JSON.stringify(value, null, 2).replace(/</g, "\\u003c") : serializeLive(value);
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
- function receive(entry, text, source) {
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 entry = { store, pretty: text.includes("\n"), framesSince: false, last: 0, landed: 0, leftAt: null };
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
- } else if (entry.leftAt) {
284
- // Left the room: the value descends from the room position it left at.
285
- tag.element.setAttribute("live-at", stampOf(entry.leftAt.epoch, entry.leftAt.seq, serializeLive(entry.store.value)));
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
- stores.set(entry.store.name, { mode, mark: entry.store.mark, canonical: canonical(tag.element.textContent), at });
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 pin still set at the next capture is a save that sent nothing (a clean
312
- // page's save is skipped without an event): an accepted or failed save
313
- // clears its pin.
314
- const previous = pinned;
315
- pinned = stores;
316
- endPin(previous, false);
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
- const previous = pinned;
323
- pinned = null;
324
- endPin(previous, event.detail?.hadTimeout === true);
880
+ endDomPin(event.detail?.hadTimeout === true, event.detail?.captureId);
325
881
  });
326
- // Only a 4xx answer proves the host wrote nothing, and only when no attempt of
327
- // the save timed out. A lost connection, a timeout or a 5xx (a proxy's 502 can
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 previous = pinned;
333
- pinned = null;
334
- endPin(previous, event.detail?.hadTimeout === true || !(status >= 400 && status < 500));
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
- export function confirmSaved(html) {
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" && mode === "fast") fast?.release(name);
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
- onSaveAccepted(confirmSaved);
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
- if (entry && entry.store.handle.status.mode === "file") entry.leftAt = null;
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
- if (checkpoints.flushing && pinned) {
434
- for (const [name, saved] of pinned) if (saved.mode === "fast" && saved.at) local?.landed(name, saved.at);
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
  });