@panphora/clayjs 1.0.0 → 1.2.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.
@@ -10,6 +10,8 @@
10
10
  import { isEditMode } from "./is-edit-mode.js";
11
11
  import { consumeUserDriven, consumeExplicitSave, markUserDriven } from "../lib/user-gesture.js";
12
12
  import { saveToken } from "./host-attrs.js";
13
+ import { lastSeenEtag, conditionalSaves, recordEtag } from "./etag.js";
14
+ import { hostMeta } from "./host-meta.js";
13
15
  import {
14
16
  getPageContents,
15
17
  onSnapshot,
@@ -60,15 +62,118 @@ function successResult(data) {
60
62
  };
61
63
  }
62
64
 
65
+ // =============================================================================
66
+ // THE ATTEMPT ID (spec §6, `receipts`)
67
+ // =============================================================================
68
+ // A save that times out leaves two questions open at once: did our write land,
69
+ // and did somebody else write. The client cannot answer either from the outside.
70
+ // Guessing "it was me" is what the old reconcile-on-timeout did, and when the
71
+ // guess was wrong it adopted a stamp describing a peer's bytes this tab had never
72
+ // seen, so the next save overwrote them with no refusal and no notice. Refusing to
73
+ // guess is safe but shows a person a conflict bar for a few seconds of bad wifi.
74
+ //
75
+ // So this tab stamps every attempt with an opaque id and asks the host the one
76
+ // question only the host can answer: are the bytes you are storing the ones this
77
+ // save sent? A host that answers turns an indeterminate save into a determinate
78
+ // one, and the person sees nothing at all.
79
+ //
80
+ // The ids this tab has sent, newest last. A 412 carrying one of them is the host
81
+ // saying "your OWN earlier save is what moved this document", which is a late
82
+ // duplicate and not a conflict with anybody. Bounded because it is only ever
83
+ // searched for a recent send.
84
+ const sentIds = [];
85
+ const SENT_ID_MEMORY = 8;
86
+
87
+ // The attempt whose answer never arrived, or null. It holds only what is needed to
88
+ // ask about it later: which id it carried, and which stamp it was sent against.
89
+ //
90
+ // It survives ONLY while the question is genuinely open. A host that answers, in
91
+ // either direction, closes it, which is what keeps the conflict bar from claiming
92
+ // a refusal might be the person's own save when the host has already said it is
93
+ // not. An unreachable host leaves it set, and that is the honest old behaviour:
94
+ // the stamp is kept, the next save is refused rather than accepted, and the notice
95
+ // says so.
96
+ let unknownAttempt = null;
97
+
98
+ /** True while an earlier save may or may not have landed (spec §7). */
99
+ export function saveFateIsUnknown() {
100
+ return unknownAttempt !== null;
101
+ }
102
+
103
+ /**
104
+ * A fresh id for one save attempt.
105
+ *
106
+ * Uniqueness per attempt is the whole property: two tabs sharing an id would each
107
+ * read the other's receipt as proof of their own write. `getRandomValues` into a
108
+ * zeroed array is the failure that would do exactly that, silently, so a missing
109
+ * crypto implementation falls back to something random rather than to zeros.
110
+ */
111
+ function mintSaveId() {
112
+ const uuid = window.crypto?.randomUUID?.();
113
+ if (uuid) return uuid;
114
+ if (window.crypto?.getRandomValues) {
115
+ const bytes = new Uint8Array(16);
116
+ window.crypto.getRandomValues(bytes);
117
+ return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
118
+ }
119
+ // Three segments, not one: §6 asks for at least 128 bits, and one `Math.random()`
120
+ // truncated to ten base-36 characters carries about 52. A timestamp is not entropy,
121
+ // so it cannot make up the difference between two tabs minting in the same
122
+ // millisecond, which is the collision that matters.
123
+ const segment = () => Math.random().toString(36).slice(2, 12).padEnd(10, "0");
124
+ return `${Date.now().toString(36)}-${segment()}${segment()}${segment()}`;
125
+ }
126
+
127
+ function rememberSentId(saveId) {
128
+ sentIds.push(saveId);
129
+ if (sentIds.length > SENT_ID_MEMORY) sentIds.shift();
130
+ }
131
+
132
+ /**
133
+ * Did this tab send this id at all?
134
+ *
135
+ * The weaker of the two questions asked of a receipt, and it belongs only to the
136
+ * late-duplicate rule: a 412 naming ANY id this tab sent means this tab's own
137
+ * earlier save is what moved the document, which is enough to adopt that stamp and
138
+ * send again, because sending again is all that follows. It is never enough to
139
+ * conclude that a particular save landed. That needs identity with that save's own
140
+ * id, which is what recoverUnknownSave asks.
141
+ */
142
+ function sentByThisTab(saveId) {
143
+ return typeof saveId === "string" && saveId !== "" && sentIds.includes(saveId);
144
+ }
145
+
146
+ /** Test-only: forget every id and any open question. */
147
+ export function resetSaveAttempts() {
148
+ sentIds.length = 0;
149
+ unknownAttempt = null;
150
+ }
151
+
63
152
  function errorResult(err) {
64
153
  const timedOut = err.name === 'AbortError';
154
+ // Spec §6: a 412 is a REFUSED save, not a failed one. The host wrote nothing and
155
+ // its document is byte-identical to what it was, and the bytes we tried to write
156
+ // are still on this page. Reporting that as 'error' would give the one outcome
157
+ // where nothing went wrong the one severity that means something did, and would
158
+ // put it through the same retry-and-toast path as a dead server.
159
+ // The status is authoritative (§3), so the 412 alone decides this. Reading `code`
160
+ // as well let any other status claim a conflict, and it bought nothing: a proxy
161
+ // answering 412 with no body carries no code to read, so the fallback never
162
+ // covered the case it was written for. What it did cover was htmlclay's
163
+ // truncation refusal, a 409 that lifts itself within a second, which suspended
164
+ // autosave over a condition that had already cleared.
165
+ const conflicted = !timedOut && err.status === 412;
65
166
  return {
66
167
  ok: false,
67
168
  msg: timedOut ? 'Server not responding' : (err.message || 'Save failed'),
68
169
  // A timeout is not evidence the write failed. The request may well have landed,
69
170
  // so this reports "we do not know" rather than asserting something false.
70
- msgType: timedOut ? 'unknown' : 'error',
71
- code: timedOut ? 'timeout' : (err.code ?? null),
171
+ msgType: timedOut ? 'unknown' : (conflicted ? 'conflict' : 'error'),
172
+ code: timedOut ? 'timeout' : (conflicted ? 'conflict' : (err.code ?? null)),
173
+ changedBy: conflicted ? (err.changedBy ?? null) : null,
174
+ // A refusal that may be answering this tab's own timed-out write. Only the
175
+ // notice uses it, and only to word itself; nothing decides anything on it.
176
+ afterTimeout: conflicted && unknownAttempt !== null,
72
177
  etag: null
73
178
  };
74
179
  }
@@ -113,9 +218,11 @@ function skippedResult(msg) {
113
218
  * @param {string} content - HTML to save
114
219
  * @param {boolean} userDriven - Whether a human gesture is behind this save
115
220
  * @param {AbortSignal} signal
221
+ * @param {string} saveId - this attempt's §6 receipt id
222
+ * @param {?string} etag - the stamp to send as If-Match, or null
116
223
  * @returns {{url: string, options: Object}}
117
224
  */
118
- function buildSaveRequest(content, userDriven, signal) {
225
+ function buildSaveRequest(content, userDriven, signal, saveId, etag) {
119
226
  const token = saveToken();
120
227
  const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
121
228
  const options = {
@@ -129,12 +236,59 @@ function buildSaveRequest(content, userDriven, signal) {
129
236
  // Spec §9's name for the provenance bit. `X-Hyperclay-User-Driven` was the
130
237
  // pre-spec spelling; every host reads Save-Trigger first and falls back to
131
238
  // it, so stored documents running an older client keep working.
132
- 'Save-Trigger': userDriven ? 'user' : 'auto'
239
+ 'Save-Trigger': userDriven ? 'user' : 'auto',
240
+ // Spec §6 (`receipts`): which attempt this is. Sent to every host, because a
241
+ // host that does not implement receipts ignores an unknown header, and
242
+ // gating it on discovery would mean holding the save until an async lookup
243
+ // resolved. Nothing is inferred from the header being accepted: only a host
244
+ // that ANSWERS with the id has said anything.
245
+ 'Save-ID': saveId
133
246
  },
134
247
  body: content
135
248
  };
136
249
 
137
- return { url: new URL(path, window.location.origin).href, options };
250
+ if (etag) options.headers['If-Match'] = etag;
251
+
252
+ return { url: resolveSaveUrl(path), options };
253
+ }
254
+
255
+ /**
256
+ * The stamp to send as If-Match, or null for a save that goes out unconditional.
257
+ *
258
+ * Two gates, and both are the point. The capability must have been announced by
259
+ * name, because §5 forbids inferring one any other way, and a host that never
260
+ * promised to honour If-Match may do anything at all with it. And a stamp must
261
+ * actually be held: a host reads the header's PRESENCE, so an empty value is not a
262
+ * softer version of the request, it is a save asking to be refused.
263
+ */
264
+ function stampToSend() {
265
+ const etag = lastSeenEtag();
266
+ return conditionalSaves() && etag ? etag : null;
267
+ }
268
+
269
+ /**
270
+ * The absolute URL a save goes to.
271
+ *
272
+ * A relative path is resolved by fetch against the DOCUMENT's base URL, which
273
+ * `<base href>` sets and the author of a malleable document controls. Left
274
+ * relative, a `<base href="https://elsewhere.example/">` sends the document and
275
+ * the per-document token in the path to an origin the document picked. Pinning to
276
+ * the real origin is the whole fix.
277
+ *
278
+ * The guard is not defensive noise. `window.location.origin` is the STRING "null"
279
+ * on a file:// document, and `new URL(path, "null")` throws a TypeError, which
280
+ * would escape synchronously here rather than becoming a failed save. Documents
281
+ * opened from disk are a first-class case (an exported app is just a file), and
282
+ * there is no origin to pin to and no host to save to there anyway, so the
283
+ * relative path is both the honest answer and the one that cannot throw.
284
+ *
285
+ * @param {string} path - Root-relative save path
286
+ * @returns {string}
287
+ */
288
+ function resolveSaveUrl(path) {
289
+ const origin = window.location.origin;
290
+ if (!origin || origin === "null") return path;
291
+ return new URL(path, origin).href;
138
292
  }
139
293
 
140
294
  /**
@@ -152,7 +306,7 @@ function buildSaveRequest(content, userDriven, signal) {
152
306
  * @param {string} content - HTML to save
153
307
  * @returns {Promise<Object>} The server's response body
154
308
  */
155
- function sendSave(content) {
309
+ function sendOnce(content, saveId, etag) {
156
310
  const controller = new AbortController();
157
311
  const timeoutId = setTimeout(() => controller.abort(), SAVE_TIMEOUT_MS);
158
312
 
@@ -165,7 +319,8 @@ function sendSave(content) {
165
319
  const gestureDriven = consumeUserDriven();
166
320
  const explicitlyAsked = consumeExplicitSave();
167
321
  const userDriven = gestureDriven || explicitlyAsked;
168
- const { url, options } = buildSaveRequest(content, userDriven, controller.signal);
322
+ const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag);
323
+ rememberSentId(saveId);
169
324
 
170
325
  return fetch(url, options)
171
326
  .then(res => res.text().then(text => {
@@ -181,12 +336,35 @@ function sendSave(content) {
181
336
  const error = new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
182
337
  error.code = data.code ?? null;
183
338
  error.status = res.status;
339
+ // Spec §6: a conflict may name what moved the document. Only the host can
340
+ // know, and it often cannot, so this rides through as-is and the notice
341
+ // falls back to a phrase that is true either way.
342
+ error.changedBy = data.changedBy ?? null;
343
+ // §6: the stamp of the bytes that caused the refusal, and the receipt for
344
+ // them. Both are what let a refusal be recovered from instead of merely
345
+ // reported: the stamp is what the retry has to carry, and the receipt says
346
+ // whether the document was moved by this tab's own earlier save.
347
+ error.etag = data.etag ?? null;
348
+ error.saveId = data.saveId ?? null;
184
349
  throw error;
185
350
  }
351
+ // The one place a stamp is ever learned from a save (§6). A response with no
352
+ // etag clears it rather than keeping the old one, because the write we just
353
+ // made means any stamp held here describes bytes the host no longer stores.
354
+ recordEtag(data.etag ?? null);
355
+ // A write the host answered: whatever an earlier timeout left uncertain,
356
+ // this document's version is known again.
357
+ unknownAttempt = null;
186
358
  return data;
187
359
  }))
188
360
  .catch(err => {
189
361
  console.error('Failed to save page:', err);
362
+ // Spec §7: a timeout is INDETERMINATE. The stamp is deliberately NOT
363
+ // reconciled against the host here; what this records is the question, so
364
+ // recovery below can ask it. The old code adopted the host's current stamp
365
+ // on the guess that our own write was what moved the document, which was
366
+ // wrong exactly when somebody else wrote.
367
+ if (err.name === 'AbortError') unknownAttempt = { saveId, etag };
190
368
  // The save never landed: re-arm the user-driven bit so the next (retry)
191
369
  // save still reports the human gesture instead of reading as background.
192
370
  if (userDriven) markUserDriven();
@@ -197,6 +375,150 @@ function sendSave(content) {
197
375
  });
198
376
  }
199
377
 
378
+ /**
379
+ * Ask the host what became of the attempt whose answer never arrived (spec §7).
380
+ *
381
+ * One question, one round trip, and only two things to do with the answer:
382
+ *
383
+ * - the host's receipt is ours, so the bytes it stores are the ones this save
384
+ * sent. The save landed. Adopt the stamp for those bytes and report success:
385
+ * the person sees a save that worked, because one did.
386
+ * - anything else, including a host that keeps no receipts at all: send the save
387
+ * again, under the ORIGINAL If-Match.
388
+ *
389
+ * The second case looks like it needs to know more than it does, and it does not,
390
+ * because the stamp is what makes it safe. If our write did land, the stamp is now
391
+ * stale and the re-send is REFUSED, carrying the host's receipt for the bytes that
392
+ * refused it; that receipt is ours, and the late-duplicate rule in sendSave turns
393
+ * it back into a finished save without a word to anybody. If somebody else wrote,
394
+ * the same refusal carries their bytes and becomes an honest conflict. And if
395
+ * nothing landed, the stamp still matches and the save simply goes through.
396
+ *
397
+ * So there is no branch here that guesses. Every outcome is decided by a host
398
+ * answering a conditional request, which is the one thing in this protocol that
399
+ * cannot be wrong.
400
+ *
401
+ * A host that cannot be reached leaves the question open, which is the older
402
+ * behaviour and the honest one: the stamp is kept, the next save is refused rather
403
+ * than accepted, and the notice says the refusal may be answering this tab's own
404
+ * timed-out save.
405
+ *
406
+ * @returns {Promise<{landed: ?Object, resend: boolean}>}
407
+ */
408
+ async function recoverUnknownSave() {
409
+ const attempt = unknownAttempt;
410
+ if (!attempt) return { landed: null, resend: false };
411
+
412
+ const meta = await hostMeta({ fresh: true });
413
+ const doc = meta.document;
414
+ const stored = typeof doc?.etag === 'string' && doc.etag ? doc.etag : null;
415
+ if (!stored) return { landed: null, resend: false };
416
+
417
+ // THIS attempt's id, and no other. A receipt is proof about the save that carried
418
+ // that exact id, so an earlier save of this same tab naming itself proves only
419
+ // that the earlier save landed, which is a thing already known and says nothing
420
+ // about this one. Matching any id this tab has sent turns the most ordinary
421
+ // failure there is, a request that never left the browser, into a reported
422
+ // success: the tab shows Saved, advances its baselines, drops the close warning,
423
+ // and the bytes are on no disk anywhere. Membership is the right test one line
424
+ // down in sendSave, where a 412 only ever leads to another conditional send.
425
+ if (doc.saveId && doc.saveId === attempt.saveId) {
426
+ // Proof, not inference: the host is saying the bytes it stores are the stored
427
+ // form of the body THIS save sent. §6 lets a client adopt a stamp on exactly that.
428
+ recordEtag(stored);
429
+ unknownAttempt = null;
430
+ return { landed: { msg: 'Saved', msgType: 'success', etag: stored }, resend: false };
431
+ }
432
+
433
+ // Positive proof, and nothing weaker, closes the question. A non-empty id that is
434
+ // not ours names another save as the author of these bytes. An ABSENT id proves
435
+ // nothing at all: §6 lets a host lose its pair to a restart or an eviction and stay
436
+ // conforming, so a host that keeps receipts can report none for bytes this tab
437
+ // really did write. Reading absence as "somebody else wrote" makes the notice tell
438
+ // a person their own save was somebody else's change.
439
+ if (doc.saveId) unknownAttempt = null;
440
+
441
+ // The re-send carries the stamp the ORIGINAL save carried, which is the normative
442
+ // rule and not a detail: the stamp this tab holds is mutable and moves for reasons
443
+ // that have nothing to do with this save, because a disk-sourced live-sync frame
444
+ // records the stamp of the bytes it applied. Re-sending under that one is a write
445
+ // conditional on a version this save was never judged against, and the host accepts
446
+ // it, replacing bytes the person has just been shown with bytes captured before
447
+ // they arrived.
448
+ //
449
+ // No stamp at all means there is nothing to re-send under, so nothing is re-sent.
450
+ // An unconditional recovery write has no comparison to refuse it and would replace
451
+ // whatever landed while this tab was waiting, which is the loss this whole
452
+ // capability exists to prevent.
453
+ return { landed: null, resend: !!attempt.etag, etag: attempt.etag };
454
+ }
455
+
456
+ /**
457
+ * Send one save, and see it through.
458
+ *
459
+ * Two things can happen that are not the save's own outcome, and both are handled
460
+ * here rather than reported:
461
+ *
462
+ * - the request timed out, so the host is asked what became of it,
463
+ * - the host refused with a receipt this tab recognises, which means its own
464
+ * earlier save is what moved the document. That is a late duplicate, not a
465
+ * conflict with anybody: take the stamp the refusal handed back and finish
466
+ * the save that was refused.
467
+ *
468
+ * Each recovery sends at most one further request, so a save can never loop.
469
+ *
470
+ * @param {string} content - HTML to save
471
+ * @returns {Promise<Object>} The server's response body
472
+ */
473
+ async function sendSave(content) {
474
+ // The re-send below is a RETRY of this attempt, so it carries this same id:
475
+ // whichever of the two requests the host ends up storing, its receipt then
476
+ // answers for this save. A fresh id on the re-send would leave the first
477
+ // request's landing unprovable.
478
+ let saveId = mintSaveId();
479
+ let etag = stampToSend();
480
+ // Each recovery runs at most once, and they are counted apart because they are
481
+ // different things. Re-sending after a timeout is worth doing once: a second
482
+ // timeout says the host is not answering this document in time, and sending a
483
+ // whole document a third time will not change that. Resolving a late duplicate
484
+ // is fast and conclusive, so it gets its own turn regardless.
485
+ let reSent = false;
486
+ let duplicateResolved = false;
487
+ for (;;) {
488
+ try {
489
+ return await sendOnce(content, saveId, etag);
490
+ } catch (err) {
491
+ if (err.name === 'AbortError') {
492
+ if (reSent) throw err;
493
+ const { landed, resend, etag: original } = await recoverUnknownSave();
494
+ if (landed) return landed;
495
+ if (!resend) throw err;
496
+ // Explicitly the original, never `stampToSend()` again.
497
+ etag = original;
498
+ reSent = true;
499
+ continue;
500
+ }
501
+
502
+ if (err.status === 412 && !duplicateResolved && sentByThisTab(err.saveId)) {
503
+ // Our own earlier save is what the host is refusing us against: a late
504
+ // duplicate, not a conflict with anybody. Adopt the stamp for those bytes
505
+ // and send the current ones on top of it. The retry is still conditional,
506
+ // so anything that arrives in between still refuses it.
507
+ recordEtag(err.etag ?? null);
508
+ unknownAttempt = null;
509
+ saveId = mintSaveId();
510
+ // A new attempt, deliberately on the base the host just proved is this tab's
511
+ // own work, rather than on the stamp the refused attempt carried.
512
+ etag = stampToSend();
513
+ duplicateResolved = true;
514
+ continue;
515
+ }
516
+
517
+ throw err;
518
+ }
519
+ }
520
+ }
521
+
200
522
  // =============================================================================
201
523
  // SAVE FUNCTIONS
202
524
  // =============================================================================
package/src/core/save.js CHANGED
@@ -17,9 +17,11 @@ import {
17
17
  getPageContents,
18
18
  replacePageWith as replacePageWithCore,
19
19
  addDocumentTransform,
20
- isSaveInProgress
20
+ isSaveInProgress,
21
+ saveFateIsUnknown
21
22
  } from "./save-core.js";
22
23
  import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
24
+ import { seedEtag } from "./etag.js";
23
25
  import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
24
26
  import { ROOT_LIBRARY_ATTRS } from "../lib/root-attrs.js";
25
27
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
@@ -50,11 +52,12 @@ let savingTimeout = null;
50
52
  /**
51
53
  * Sets the save status on <html> and dispatches an event.
52
54
  *
53
- * @param {string} state - One of: 'saving', 'saved', 'offline', 'error'
55
+ * @param {string} state - One of: 'saving', 'saved', 'offline', 'error', 'conflict'
54
56
  * @param {string} msg - Optional message (e.g., error details)
55
57
  * @param {string} msgType - Optional severity from the server (e.g., 'warning')
58
+ * @param {Object} [extra] - Extra detail fields for the event (e.g. `changedBy`)
56
59
  */
57
- function setSaveState(state, msg = '', msgType = '') {
60
+ function setSaveState(state, msg = '', msgType = '', extra = null) {
58
61
  if (savingTimeout) {
59
62
  clearTimeout(savingTimeout);
60
63
  savingTimeout = null;
@@ -63,7 +66,7 @@ function setSaveState(state, msg = '', msgType = '') {
63
66
  document.documentElement.setAttribute('savestatus', state);
64
67
 
65
68
  const event = new CustomEvent(`clay:save-${state}`, {
66
- detail: { msg, msgType, timestamp: Date.now() }
69
+ detail: { msg, msgType, timestamp: Date.now(), ...(extra || {}) }
67
70
  });
68
71
  document.dispatchEvent(event);
69
72
  }
@@ -190,6 +193,46 @@ export function resumeAutosave() {
190
193
  savePageThrottled();
191
194
  }
192
195
 
196
+ // ============================================
197
+ // THE CONFLICT HOLD
198
+ // ============================================
199
+ //
200
+ // A 412 refuses this tab's bytes because the document changed since this tab last
201
+ // saw it (spec §6). Two things have to follow, and the second is the one that is
202
+ // easy to leave out.
203
+ //
204
+ // Nothing may be thrown away. The baselines do not advance on a refused save, so
205
+ // the edits stay dirty, the close warning still fires, and the person keeps what
206
+ // they typed. That falls out of applySaveResult and needs no special case.
207
+ //
208
+ // And autosave has to stop. Every autosave from here sends the same stamp and is
209
+ // refused for the same reason, so leaving it running means a save attempt every
210
+ // throttle window, forever, each one toasting a failure the person can do nothing
211
+ // about. The suspension clay.wire already owns is exactly the right lever: it
212
+ // stops AUTOsave only, so an explicit Cmd+S still goes out, and it replays one
213
+ // missed save on release so nothing typed during the hold is stranded.
214
+ //
215
+ // The hold is released when a save lands, whatever produced it: clay.save.overwrite,
216
+ // a live-sync frame that brought the page back in step, or the other tab going away.
217
+ let conflictHold = false;
218
+
219
+ function holdForConflict() {
220
+ if (conflictHold) return;
221
+ conflictHold = true;
222
+ suspendAutosave();
223
+ }
224
+
225
+ function releaseConflictHold() {
226
+ if (!conflictHold) return;
227
+ conflictHold = false;
228
+ resumeAutosave();
229
+ }
230
+
231
+ /** True while this tab is refusing to autosave over a version it has not seen. */
232
+ export function isSaveConflicted() {
233
+ return conflictHold;
234
+ }
235
+
193
236
  function skipped_(msg) {
194
237
  return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
195
238
  }
@@ -217,6 +260,13 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken) {
217
260
  // warning, and the UI module is what decides how to render that.
218
261
  setSaveState('saved', result.msg || 'Saved', result.msgType);
219
262
  logBaseline(label, `${lastSavedContents.length} chars`);
263
+ releaseConflictHold();
264
+ } else if (result.msgType === 'conflict') {
265
+ holdForConflict();
266
+ setSaveState('conflict', result.msg, result.msgType, {
267
+ changedBy: result.changedBy ?? null,
268
+ afterTimeout: result.afterTimeout === true,
269
+ });
220
270
  } else if (result.msgType !== 'skipped') {
221
271
  if (!navigator.onLine) {
222
272
  setSaveState('offline', result.msg);
@@ -229,8 +279,18 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken) {
229
279
  // Run the save that arrived while this one was in flight. savePage does its own
230
280
  // dirty check, so if nothing actually changed it resolves 'skipped' and stops:
231
281
  // this cannot spin.
282
+ //
283
+ // Not while the last save's fate is still unknown, which happens only when the
284
+ // save timed out AND the host could not be asked what became of it. Sending then
285
+ // is sending into a question: the host is unreachable, so the save is most likely
286
+ // to time out too, and if it does reach a host that took the first write it is
287
+ // refused, which puts a conflict bar on screen seconds after a person typed,
288
+ // unprompted, over what is really a network problem. The queued state is KEPT, not
289
+ // dropped, so the newer bytes still go out on the next save; the page is still
290
+ // dirty, so an edit or a close warning will produce one.
232
291
  function drainPendingSave() {
233
292
  if (!pendingSave) return;
293
+ if (saveFateIsUnknown()) return;
234
294
  pendingSave = false;
235
295
  savePage();
236
296
  }
@@ -405,6 +465,30 @@ export function savePageForce(callback = () => {}) {
405
465
  });
406
466
  }
407
467
 
468
+ /**
469
+ * Keep this tab's version, over the one the host is holding.
470
+ *
471
+ * The only exit from a conflict that keeps what is on screen. It asks the host for
472
+ * the document's current stamp and force-saves with it, so the save that follows
473
+ * carries a value the host will accept. If the host answers with no stamp at all,
474
+ * the save goes out unconditional, which is last write wins, which is what the
475
+ * person just asked for by name.
476
+ *
477
+ * Deliberately not automatic, and deliberately not what a second Cmd+S does. A
478
+ * person pressing Save again has not been shown the other version, and reading
479
+ * that as consent to replace it destroys exactly the copy this capability exists
480
+ * to protect. The other two answers to a conflict need nothing from this library:
481
+ * `location.reload()` takes the host's version, and a page that wants to merge
482
+ * merges into its own DOM and then calls this.
483
+ *
484
+ * @param {Function} callback - Optional callback for custom handling
485
+ * @returns {Promise<{ok: boolean, msg: string, msgType: string}>}
486
+ */
487
+ export async function saveOverwritingConflict(callback = () => {}) {
488
+ await seedEtag({ fresh: true });
489
+ return savePageForce(callback);
490
+ }
491
+
408
492
  /**
409
493
  * Fetch HTML from a URL and save it, then reload
410
494
  * Emits error event if save fails
@@ -646,6 +730,16 @@ export function initHyperclaySaveButton() {
646
730
  export function init() {
647
731
  if (!isEditMode) return;
648
732
 
733
+ // §6's stamp for a page that has never saved. Fired here and never awaited: the
734
+ // request a person feels is the save, and a host with no /_/meta would make every
735
+ // first save wait out a discovery timeout for an answer that was never coming.
736
+ // Until the seed lands the first save goes out unconditional, which is last write
737
+ // wins, which is what every save did before this existed. This is also the only
738
+ // moment the seed can be useful at all: ask for it lazily at the first save and
739
+ // it can never arrive in time for that save, and from the second save onward the
740
+ // first save's own response has already supplied one.
741
+ seedEtag();
742
+
649
743
  // Every editable page, not just autosave pages. A manual-save page makes
650
744
  // exactly the saves a person asked for, and used to report all of them as
651
745
  // background writes because this was installed behind the autosave gate.
@@ -109,10 +109,10 @@ export function addDocumentTransform(callback) {
109
109
  *
110
110
  * @returns {HTMLElement} Cloned document element with snapshot hooks applied
111
111
  */
112
- function clonePreventingOnclone(node) {
112
+ function clonePreventingOnclone(node, deep = true) {
113
113
  const prev = window.__preventOnclone;
114
114
  window.__preventOnclone = true;
115
- try { return node.cloneNode(true); }
115
+ try { return node.cloneNode(deep); }
116
116
  finally { window.__preventOnclone = prev; }
117
117
  }
118
118
 
@@ -293,7 +293,13 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
293
293
  export function captureForSaveAndComparison({ emitForSync = true } = {}) {
294
294
  const clone = captureSnapshot();
295
295
 
296
- // Emit for live-sync before any stripping
296
+ // Emit for live-sync before any stripping.
297
+ //
298
+ // A listener gets the very clone this function goes on to serialize into
299
+ // forSave, forComparison and forDirty — that sharing is the point, it saves a
300
+ // second full DOM clone per save. So a listener may READ it and must not write
301
+ // to it: anything it changes lands in the saved bytes and in both baselines,
302
+ // and a baseline the dirty check cannot reproduce warns on close forever.
297
303
  if (emitForSync) {
298
304
  document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
299
305
  detail: { documentElement: clone }
@@ -445,22 +451,37 @@ export function captureForSave({ emitForSync = true } = {}) {
445
451
  * (Not to be confused with captureBodyForSync below, which is the older
446
452
  * body-innerHTML helper and unrelated to the live-sync lane.)
447
453
  *
448
- * The clone is detached and unobserved, so removing the attributes in place and
449
- * putting them back is exact, and far cheaper than cloning the tree again. The
450
- * finally is load-bearing: the save path reads this same clone afterwards.
454
+ * It must not write to the clone. The caller derives forSave and both comparison
455
+ * baselines from this same object once every listener has returned, so anything
456
+ * left behind lands in the saved bytes and in both baselines.
457
+ *
458
+ * This used to strip the tab-local attributes in place and set them again in a
459
+ * finally, which looked exact and was not: an attribute list is ordered by
460
+ * insertion, so putting a name back appended it. On htmlclay, whose injectAttr
461
+ * splices the token and file id in right after `<html`, every save then installed
462
+ * a baseline whose root tag was ordered differently from the one any later dirty
463
+ * check builds off the live DOM — same names, same values, same length, never
464
+ * equal again. Closing an already-saved document warned every time, and
465
+ * savePageThrottled's "no changes to save" short-circuit never fired.
466
+ *
467
+ * The open tag is serialized from a childless copy instead, so the shared clone is
468
+ * never touched and there is no restore to get wrong. The copy is one element, not
469
+ * the tree.
451
470
  */
452
471
  export function serializeForSync(clone) {
453
- const removed = [];
454
- for (const name of TAB_LOCAL_ROOT_ATTRS) {
455
- if (!clone.hasAttribute(name)) continue;
456
- removed.push([name, clone.getAttribute(name)]);
457
- clone.removeAttribute(name);
458
- }
459
- try {
460
- return clone.outerHTML;
461
- } finally {
462
- for (const [name, value] of removed) clone.setAttribute(name, value);
463
- }
472
+ const bareRoot = clonePreventingOnclone(clone, false);
473
+ for (const name of TAB_LOCAL_ROOT_ATTRS) bareRoot.removeAttribute(name);
474
+
475
+ // Split the end tag off by LENGTH rather than searching for one: an authored
476
+ // attribute value holding "</html>" would fool any indexOf-based split and
477
+ // truncate the broadcast.
478
+ const shell = bareRoot.outerHTML;
479
+ const endTag = `</${bareRoot.localName}>`;
480
+ // The doctype, so this artifact is a complete document like every other one this
481
+ // module produces. Spec section 2 asks for that, and it costs nothing on the wire:
482
+ // a receiver parses the string and morphs documentElement against documentElement,
483
+ // so the prologue is consumed by the parser and never reaches the morph.
484
+ return "<!DOCTYPE html>" + shell.slice(0, shell.length - endTag.length) + clone.innerHTML + endTag;
464
485
  }
465
486
 
466
487
  /**