@panphora/clayjs 1.1.0 → 1.3.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 (43) hide show
  1. package/README.md +4 -1
  2. package/THIRD-PARTY-NOTICES.md +10 -0
  3. package/dist/clay.standalone.js +21751 -13942
  4. package/entries/clay-data.js +1 -1
  5. package/package.json +7 -2
  6. package/packed-contract.json +17 -0
  7. package/src/attrs/save-freeze.js +10 -18
  8. package/src/core/admin-contenteditable.js +5 -6
  9. package/src/core/etag.js +17 -4
  10. package/src/core/host-attrs.js +52 -2
  11. package/src/core/is-edit-mode.js +14 -5
  12. package/src/core/persist.js +5 -10
  13. package/src/core/save-conflict-notice.js +22 -27
  14. package/src/core/save-core.js +326 -26
  15. package/src/core/save.js +19 -4
  16. package/src/core/snapshot.js +148 -15
  17. package/src/core/source-map.js +817 -0
  18. package/src/core/stale-host-notice.js +95 -0
  19. package/src/core/unsaved-warning.js +3 -0
  20. package/src/dom/dom-helpers.js +5 -1
  21. package/src/lib/content-dom.js +108 -0
  22. package/src/lib/hostile-css.js +49 -0
  23. package/src/lib/mutation.js +26 -3
  24. package/src/lib/region-capabilities.js +69 -0
  25. package/src/lib/region-policy.js +18 -13
  26. package/src/lib/root-attrs.js +52 -13
  27. package/src/loader-logic.js +25 -4
  28. package/src/loader.js +4 -0
  29. package/src/plugins/demo.js +3 -0
  30. package/src/plugins/sortable.js +6 -1
  31. package/src/plugins/source.js +326 -0
  32. package/src/plugins/wire.js +248 -47
  33. package/src/sync/live-sync.js +275 -63
  34. package/src/sync/presence.js +303 -0
  35. package/src/sync/section-notice.js +230 -0
  36. package/src/sync/splice-merge.js +7 -10
  37. package/src/sync/stream.js +190 -0
  38. package/src/vendor/hyper-morph.vendor.js +2 -2
  39. package/src/vendor/hyper-undo.vendor.js +1 -1
  40. package/src/vendor/hypercms.vendor.js +438 -45
  41. package/src/vendor/parse5.vendor.js +3 -0
  42. package/src/vendor/quickcrop.vendor.js +1 -1
  43. package/src/vendor/richclay.vendor.js +22 -15
@@ -40,12 +40,17 @@ import Mutation from "../lib/mutation.js";
40
40
  import { isSnapshotRemoved } from "../lib/region-policy.js";
41
41
  import { isEditMode } from "../core/is-edit-mode.js";
42
42
  import { mergeTagRecognizers } from "./merge-tags.js";
43
- import { serializeForSync, captureForComparisonAndDirty, captureSnapshot } from '../core/snapshot.js';
43
+ import { serializeForSync, captureForComparisonAndDirty, captureSnapshot, originalSnapshotNode } from '../core/snapshot.js';
44
44
  import { isTabLocalRootAttr } from '../lib/root-attrs.js';
45
45
  import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
46
+ import { presence } from './presence.js';
47
+ // Side-effect import: the section-changed notice wires itself to
48
+ // `clay:sync-applied`, which this file is the only dispatcher of.
49
+ import './section-notice.js';
46
50
  import { hostMeta } from '../core/host-meta.js';
47
- import { recordEtag, seedEtag } from '../core/etag.js';
51
+ import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
48
52
  import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
53
+ import { SyncStream } from './stream.js';
49
54
 
50
55
  // The page just took a frame verified clean against its baseline, so it now IS
51
56
  // the file on disk. Both saved baselines move together from one capture: leaving
@@ -75,6 +80,12 @@ import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges } from '../
75
80
  * One profile is chosen once and used for BOTH directions for the life of the page.
76
81
  * Letting send and receive decide independently is exactly the bug this replaces: a
77
82
  * client that posted to the spec address while streaming from the legacy one.
83
+ *
84
+ * Both stream addresses carry `client-id`, in the same kebab spelling as the rest of
85
+ * the query. It is the tab's own sender id — the one every outbound frame already
86
+ * carries — so a host that reads it at admission knows this connection by the value
87
+ * its frames arrive under, and can tell two tabs of one cookie-less guest apart. A
88
+ * host that does not read it ignores an unknown parameter.
78
89
  */
79
90
  const WIRE_PROFILES = {
80
91
  spec: {
@@ -82,8 +93,8 @@ const WIRE_PROFILES = {
82
93
  relayPath: '/_/sync',
83
94
  documentHeader: 'Document-URL',
84
95
  snapshotKey: 'snapshot',
85
- streamPath: (href, lane, resumeId) =>
86
- `/_/sync?document-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
96
+ streamPath: (href, lane, resumeId, clientId) =>
97
+ `/_/sync?document-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}&client-id=${encodeURIComponent(clientId)}`,
87
98
  },
88
99
  // What every published clayjs speaks, and what hyperclayjs and the inline script in
89
100
  // every Collection dashboard hardcode. Hosts keep these addresses permanently.
@@ -92,8 +103,8 @@ const WIRE_PROFILES = {
92
103
  relayPath: '/_/live-sync/save',
93
104
  documentHeader: 'Page-URL',
94
105
  snapshotKey: 'html',
95
- streamPath: (href, lane, resumeId) =>
96
- `/_/live-sync/stream?page-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
106
+ streamPath: (href, lane, resumeId, clientId) =>
107
+ `/_/live-sync/stream?page-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}&client-id=${encodeURIComponent(clientId)}`,
97
108
  },
98
109
  };
99
110
 
@@ -114,6 +125,15 @@ class LiveSync {
114
125
 
115
126
  this.debounceMs = 150;
116
127
  this.debounceTimer = null;
128
+
129
+ // The sync serialization of the clone the CURRENT save was captured from,
130
+ // held so the commit relay can pair the host's stamp with the content that
131
+ // stamp actually describes. Set at snapshot-ready, which fires before the
132
+ // save POST goes out, and consumed by _relayCommit when the response lands.
133
+ // Never reconstructed from lastHtml: that is the last relay that COMPLETED,
134
+ // which lags the save whenever the response beats the 150ms debounce, and
135
+ // pairing a fresh stamp with older bytes is the one thing spec §10 forbids.
136
+ this._savedSnapshot = null;
117
137
  this._sendInFlight = false;
118
138
  this._queuedSend = null;
119
139
 
@@ -155,13 +175,22 @@ class LiveSync {
155
175
  this._pendingHtml = null;
156
176
  this._pendingSeq = null;
157
177
  this._pendingIdentityMap = null;
178
+ // The stamp a pending frame carries, adopted only if that frame applies
179
+ // cleanly. It rides in the slot beside the bytes it describes so the two can
180
+ // never be separated, which is the whole rule (§6, and §22 of the plan).
181
+ this._pendingEtag = null;
182
+ // Who the server says sent the pending frame, `{ id, name }`. Rides in the
183
+ // slot for the same reason the stamp does: it describes these bytes and
184
+ // nothing else, so a newer frame replacing them takes its author with it.
185
+ // Reported on `clay:sync-applied` once the frame has actually applied.
186
+ this._pendingBy = null;
158
187
  this._morphInFlight = false;
159
188
  this._rafHandle = null;
160
189
 
161
190
  // Disk-sourced external changes (htmlclay watcher content) get their own
162
191
  // one-deep slot so a peer frame arriving in the same window can't
163
192
  // displace them. Drained in seq order alongside the peer slot.
164
- this._pendingExternal = null; // { html, seq, saveEpoch }
193
+ this._pendingExternal = null; // { html, seq, saveEpoch, etag }
165
194
 
166
195
  // Newest external-change seq accepted for apply. Replayed or reordered
167
196
  // disk frames (and their fetch fallbacks) are dropped against this.
@@ -274,6 +303,7 @@ class LiveSync {
274
303
  // so mint a fresh resume id — this stream must not resume the previous one.
275
304
  this.lastHtml = null;
276
305
  this._lastIdentityMap = null;
306
+ this._savedSnapshot = null;
277
307
  this._applyGen++;
278
308
  this.lastSeenSeq = 0;
279
309
  this._lastExternalSeq = 0;
@@ -295,7 +325,10 @@ class LiveSync {
295
325
  // snapshot listener would never fire — skip registering it.
296
326
  if (this.lane === 'live') {
297
327
  this.listenForSnapshots();
298
- this._saveSavedHandler = () => { this._saveEpoch++; };
328
+ this._saveSavedHandler = () => {
329
+ this._saveEpoch++;
330
+ this._relayCommit();
331
+ };
299
332
  document.addEventListener('clay:save-saved', this._saveSavedHandler);
300
333
  }
301
334
  }
@@ -310,6 +343,10 @@ class LiveSync {
310
343
  this.sse = null;
311
344
  }
312
345
 
346
+ // Nothing feeds the roster once the stream is gone, so leaving the stack up
347
+ // would show a list of people who may all have left.
348
+ presence.clear();
349
+
313
350
  if (this._snapshotHandler) {
314
351
  document.removeEventListener('clay:snapshot-ready', this._snapshotHandler);
315
352
  this._snapshotHandler = null;
@@ -333,6 +370,7 @@ class LiveSync {
333
370
  this._pendingHtml = null;
334
371
  this._pendingSeq = null;
335
372
  this._pendingIdentityMap = null;
373
+ this._pendingBy = null;
336
374
  this._pendingExternal = null;
337
375
  clearTimeout(this._holdRetryPeer);
338
376
  clearTimeout(this._holdRetryExt);
@@ -365,33 +403,25 @@ class LiveSync {
365
403
  const map = {};
366
404
  if (!liveRoot || !cloneRoot) return map;
367
405
 
368
- const visit = (live, clone, path) => {
369
- let id = this.liveWeakMap.get(live);
370
- if (!id) {
371
- id = this._mintId();
372
- this.liveWeakMap.set(live, id);
406
+ const visit = (clone, path) => {
407
+ const live = originalSnapshotNode(clone);
408
+ if (live) {
409
+ let id = this.liveWeakMap.get(live);
410
+ if (!id) {
411
+ id = this._mintId();
412
+ this.liveWeakMap.set(live, id);
413
+ }
414
+ map[path] = id;
373
415
  }
374
- map[path] = id;
375
416
 
376
- const liveKids = [];
377
- for (const c of live.children) {
378
- if (!isSnapshotRemoved(c)) liveKids.push(c);
379
- }
380
417
  const cloneKids = clone.children;
381
418
 
382
- if (liveKids.length !== cloneKids.length) {
383
- this._log(
384
- `identity map: subtree skipped at "${path}" (live=${liveKids.length}, clone=${cloneKids.length})`
385
- );
386
- return;
387
- }
388
-
389
- for (let i = 0; i < liveKids.length; i++) {
390
- visit(liveKids[i], cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
419
+ for (let i = 0; i < cloneKids.length; i++) {
420
+ visit(cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
391
421
  }
392
422
  };
393
423
 
394
- visit(liveRoot, cloneRoot, '');
424
+ visit(cloneRoot, '');
395
425
  return map;
396
426
  }
397
427
 
@@ -494,7 +524,11 @@ class LiveSync {
494
524
  _resolveProfile() {
495
525
  if (!this._profilePromise) {
496
526
  this._profilePromise = hostMeta()
497
- .then((meta) => (meta?.extensions?.includes('sync') ? WIRE_PROFILES.spec : WIRE_PROFILES.legacy))
527
+ .then((meta) => {
528
+ const extensions = meta?.extensions || [];
529
+ this._sharedSync = extensions.includes('sync') && extensions.includes('sync-worker') && !extensions.includes('presence');
530
+ return extensions.includes('sync') ? WIRE_PROFILES.spec : WIRE_PROFILES.legacy;
531
+ })
498
532
  .catch(() => WIRE_PROFILES.legacy)
499
533
  .then((profile) => {
500
534
  this._profile = profile;
@@ -534,31 +568,32 @@ class LiveSync {
534
568
  }
535
569
 
536
570
  /**
537
- * Take a stamp that arrived with no document (spec §6).
571
+ * Drop a stamp that arrived with no document (spec §6).
538
572
  *
539
- * The host sends one whenever the file on disk changes, because an editing tab
540
- * never receives the saved document itself: that lane is for viewers, and
541
- * pushing a saved document onto an editor would replace work in progress.
573
+ * ⚠️ This used to TAKE it, and taking it was wrong. A stamp says "disk is at this
574
+ * version"; adopting it says "and I hold those bytes". Only the second claim
575
+ * makes the next save safe, and a frame with no content is no evidence for it.
542
576
  *
543
- * Taking it is what makes live sync and the conflict check agree. An editor
544
- * whose peer frames are merging is already looking at the other tab's content,
545
- * so refusing its next save would be a conflict about nothing.
577
+ * The race is real and it loses work. A peer's save and that peer's snapshot
578
+ * relay are two concurrent requests, so a stamp travelling on its own can arrive
579
+ * first. This tab would record it while its DOM still held the older content,
580
+ * and its next save would then pass If-Match and overwrite the save it had never
581
+ * received. The hold flags cannot cover that: a lane holds when an incoming
582
+ * change could NOT be merged, not when one has yet to arrive.
546
583
  *
547
- * But only while this tab is in step. A held lane means live sync saw an
548
- * incoming change, could not merge it into an unsaved local edit, and kept this
549
- * tab's version instead, so the DOM here is knowingly missing what disk holds.
550
- * Taking the new stamp there would let this tab's next save replace that change
551
- * with nobody seeing it. That is the one loss live sync cannot prevent on its
552
- * own: a held tab "converges through its own next save", and that convergence
553
- * IS the overwrite. Refusing the stamp turns it into a conflict somebody is
554
- * told about.
584
+ * So a stamp now rides on the snapshot the saving tab relays after its save
585
+ * returns, and `_doApplyUpdate` records it only once that content has merged.
586
+ * Nothing sends a bare stamp any more — hyperclay's was deleted, and it had
587
+ * never reached anyone, because the relay library refuses a payload with no
588
+ * document in it. This stays as the rule rather than as compatibility: if some
589
+ * host does send one, dropping it is correct.
555
590
  *
556
591
  * @param {Object} data - The decoded frame
557
- * @returns {boolean} True when this was a stamp frame and nothing should morph
592
+ * @returns {boolean} True when this was a stamp-only frame and nothing should morph
558
593
  */
559
594
  _applyEtagFrame(data) {
560
595
  if (typeof data.etag !== 'string' || typeof data.html === 'string') return false;
561
- if (!this._heldLive && !this._heldExt) recordEtag(data.etag);
596
+ this._log('Dropping a stamp that arrived with no document: nothing proves this tab holds those bytes');
562
597
  return true;
563
598
  }
564
599
 
@@ -572,10 +607,14 @@ class LiveSync {
572
607
  // Whichever wire discovery selected. start() does not call connect() until the
573
608
  // profile is known, so this is never null on the normal path.
574
609
  const profile = this._profile || WIRE_PROFILES.legacy;
575
- const path = profile.streamPath(window.location.href, this.lane, this.resumeId);
610
+ const path = profile.streamPath(window.location.href, this.lane, this.resumeId, this.clientId);
576
611
  // Resolved against the real origin: a <base href> in the authored document
577
612
  // would otherwise point the sync stream at an origin the document chose.
578
- this.sse = new EventSource(new URL(path, window.location.origin).href);
613
+ this.sse = new SyncStream(new URL(path, window.location.origin).href, {
614
+ shared: this._sharedSync,
615
+ documentURL: window.location.href,
616
+ lane: this.lane,
617
+ });
579
618
 
580
619
  this.sse.onopen = () => {
581
620
  console.log('[LiveSync] Connected');
@@ -610,6 +649,21 @@ class LiveSync {
610
649
  });
611
650
  });
612
651
 
652
+ // The roster is a NAMED event for the same reason the cursor frame is: a tab
653
+ // with no listener for it never sees a bare data line, so every published
654
+ // client is counted in the roster without being sent one. The frame is
655
+ // handed on untouched — who is named is the host's decision, taken per
656
+ // recipient from that recipient's own access, and nothing here can widen it.
657
+ this.sse.addEventListener('presence', (event) => {
658
+ let data;
659
+ try {
660
+ data = JSON.parse(event.data);
661
+ } catch {
662
+ return;
663
+ }
664
+ presence.update(data);
665
+ });
666
+
613
667
  this.sse.onmessage = (event) => {
614
668
  const data = JSON.parse(event.data);
615
669
 
@@ -665,9 +719,21 @@ class LiveSync {
665
719
  return;
666
720
  }
667
721
 
722
+ const etag = typeof data.etag === 'string' && data.etag ? data.etag : null;
723
+
724
+ // The common case, and it costs nothing: a peer saved bytes this tab has
725
+ // already applied, so the frame's only news is the stamp. Taking it without
726
+ // a morph is exactly as safe as taking it after one, because the baseline
727
+ // being equal IS the proof that this tab holds those bytes.
728
+ if (etag && html === this.lastHtml) {
729
+ this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
730
+ recordEtag(etag);
731
+ return;
732
+ }
733
+
668
734
  this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
669
- this.applyUpdate(html, seq, identityMap);
670
- if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap });
735
+ this.applyUpdate(html, seq, identityMap, etag, data.by);
736
+ if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap, etag });
671
737
  };
672
738
 
673
739
  // Native EventSource auto-reconnects on transient errors
@@ -704,6 +770,12 @@ class LiveSync {
704
770
  this._log('snapshot-ready received, preparing to send');
705
771
  const html = serializeForSync(clone);
706
772
  const identityMap = this._buildIdentityMap(document.documentElement, clone);
773
+
774
+ // The save that follows this capture stores these bytes, so this is the
775
+ // content its stamp will describe. Held for _relayCommit, and overwritten
776
+ // by the next capture, so it always names the save currently in flight.
777
+ this._savedSnapshot = { html, identityMap };
778
+
707
779
  this.sendUpdate(html, identityMap);
708
780
  };
709
781
 
@@ -750,6 +822,77 @@ class LiveSync {
750
822
  this._postUpdate(queued.html, queued.identityMap);
751
823
  }
752
824
 
825
+ /**
826
+ * Tell the other editors what this tab's save stored, and under which stamp.
827
+ *
828
+ * Runs on clay:save-saved, by which point the save response has already been
829
+ * recorded, so `lastSeenEtag()` is the stamp for the bytes that just landed.
830
+ *
831
+ * The content is `lastHtml`, the snapshot this tab most recently relayed, which
832
+ * is the state the save was taken from: the save pipeline dispatches
833
+ * clay:snapshot-ready on its way to capturing what to send, so that relay has
834
+ * already gone out. Re-sending those same bytes is not redundant, because the
835
+ * stamp is the new information and a stamp may never travel without the content
836
+ * it describes. A receiver whose baseline already matches takes the stamp and
837
+ * skips the morph.
838
+ */
839
+ _relayCommit() {
840
+ // Holding a stamp is the whole condition. It is set only from a save response
841
+ // that carried one, and a host that does not do conditional saves returns
842
+ // none, so this is the same test as "the host stamps what it stores" without
843
+ // a second flag that would have to be reached through discovery to exercise.
844
+ const etag = lastSeenEtag();
845
+ if (!etag) return;
846
+ if (this.isDestroyed || this.isPaused) return;
847
+
848
+ // The content this save stored, captured with it. Consumed rather than left
849
+ // behind, so a save-saved with no capture of its own can never reuse an
850
+ // older save's bytes under a newer stamp.
851
+ const pending = this._savedSnapshot;
852
+ this._savedSnapshot = null;
853
+
854
+ // No captured snapshot means nothing here knows which bytes this stamp
855
+ // describes, and §10 is explicit that a stamp must never travel on its own.
856
+ // Staying silent costs the other editors one refusal they recover from;
857
+ // guessing costs somebody their work.
858
+ if (!pending || typeof pending.html !== 'string') return;
859
+
860
+ this._postCommit(pending.html, etag, pending.identityMap);
861
+ }
862
+
863
+ /**
864
+ * Post a snapshot whose point is the stamp attached to it.
865
+ *
866
+ * Deliberately not `_postUpdate`: that one returns early when the html matches
867
+ * `lastHtml`, and the whole point here is that the stamp is the new information
868
+ * even when the bytes are not. It also does not touch `lastHtml` or the
869
+ * single-flight queue, because it must not displace a real snapshot waiting to
870
+ * go out. It carries the identityMap captured with these bytes, so a receiver
871
+ * that morphs on this frame pairs elements the same way it would on the
872
+ * ordinary relay of the same content.
873
+ */
874
+ _postCommit(html, etag, identityMap) {
875
+ const profile = this._profile;
876
+ if (!profile) return;
877
+ fetch(new URL(profile.relayPath, window.location.origin).href, {
878
+ method: 'POST',
879
+ headers: {
880
+ 'Content-Type': 'application/json',
881
+ [profile.documentHeader]: window.location.href,
882
+ },
883
+ body: JSON.stringify({
884
+ [profile.snapshotKey]: html,
885
+ sender: this.clientId,
886
+ identityMap,
887
+ etag
888
+ })
889
+ }).catch(err => {
890
+ // Losing this costs the other editors one spurious refusal on their next
891
+ // save, which they recover from. It is not worth surfacing as an error.
892
+ this._log('Commit relay failed: ' + (err && err.message));
893
+ });
894
+ }
895
+
753
896
  _postUpdate(html, identityMap) {
754
897
  // Skip if unchanged
755
898
  if (html === this.lastHtml) {
@@ -817,7 +960,7 @@ class LiveSync {
817
960
  const info = data.data;
818
961
  if (info && info.kind === 'external-change') {
819
962
  if (typeof info.html === 'string') {
820
- this._enqueueExternal(info.html, data.seq);
963
+ this._enqueueExternal(info.html, data.seq, info.etag, info.by);
821
964
  } else {
822
965
  this._fetchExternalChange(data.seq);
823
966
  }
@@ -830,7 +973,7 @@ class LiveSync {
830
973
  return false;
831
974
  }
832
975
 
833
- _enqueueExternal(html, seq) {
976
+ _enqueueExternal(html, seq, etag, by) {
834
977
  if (typeof seq === 'number') {
835
978
  if (seq <= this._lastExternalSeq) {
836
979
  this._log(`Dropping replayed external change: seq=${seq}`);
@@ -838,7 +981,17 @@ class LiveSync {
838
981
  }
839
982
  this._lastExternalSeq = seq;
840
983
  }
841
- this._pendingExternal = { html, seq, saveEpoch: this._saveEpoch };
984
+ this._pendingExternal = {
985
+ html,
986
+ seq,
987
+ saveEpoch: this._saveEpoch,
988
+ etag: typeof etag === 'string' && etag ? etag : null,
989
+ // Carried for the same reason the stamp is, and null on every disk frame
990
+ // today: hyperclay stamps an author on the two live relays and nowhere
991
+ // else, so a change that arrived from the filesystem has no author to
992
+ // name. The slot carries it so a host that does stamp one is believed.
993
+ by: by ?? null,
994
+ };
842
995
  this._scheduleNextFrame();
843
996
  }
844
997
 
@@ -865,6 +1018,7 @@ class LiveSync {
865
1018
  */
866
1019
  _fetchServedDocument(seq, { attempt = 0, repair = false } = {}) {
867
1020
  const epoch = this._saveEpoch;
1021
+ if (repair && typeof seq !== 'number') seq = this._lastExternalSeq;
868
1022
  fetch(new URL(window.location.href), { cache: 'no-store' })
869
1023
  .then((response) => (response.ok ? response.text() : null))
870
1024
  .then((html) => {
@@ -875,7 +1029,7 @@ class LiveSync {
875
1029
  // said replay cannot fix this page, so if the newer fetch fails there is
876
1030
  // nothing else coming. Refetch rather than drop the only repair.
877
1031
  if (repair && attempt < 3) {
878
- this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
1032
+ this._fetchServedDocument(this._lastExternalSeq, { attempt: attempt + 1, repair });
879
1033
  }
880
1034
  return;
881
1035
  }
@@ -889,7 +1043,11 @@ class LiveSync {
889
1043
  }
890
1044
  return;
891
1045
  }
892
- this._pendingExternal = { html, seq, saveEpoch: epoch };
1046
+ // No stamp: this body came from a GET of the served page, which nobody
1047
+ // stamped, so the apply leaves the held stamp alone and etag.js asks the
1048
+ // host for a replacement. No author either, for the same reason — a GET
1049
+ // answers what disk holds, not who put it there.
1050
+ this._pendingExternal = { html, seq, saveEpoch: epoch, etag: null, by: null };
893
1051
  this._scheduleNextFrame();
894
1052
  })
895
1053
  .catch((err) => {
@@ -916,12 +1074,19 @@ class LiveSync {
916
1074
  * @param {string} html - Full document HTML
917
1075
  * @param {number} [seq] - Optional monotonic seq from the server
918
1076
  * @param {Object} [identityMap] - Optional element-identity map from sender
1077
+ * @param {string} [etag] - Optional version stamp for these bytes
1078
+ * @param {Object} [by] - Optional `{ id, name }` author stamp for these bytes
919
1079
  */
920
- applyUpdate(html, seq, identityMap) {
1080
+ applyUpdate(html, seq, identityMap, etag, by) {
921
1081
  if (this.isDestroyed) return;
922
1082
  this._pendingHtml = html;
923
1083
  this._pendingSeq = seq;
924
1084
  this._pendingIdentityMap = identityMap;
1085
+ // A newer frame replacing this one takes its stamp with it, and that is
1086
+ // correct: the stamp belongs to bytes that are no longer what will apply.
1087
+ // Losing it costs one honest 412 later, which is the safe direction.
1088
+ this._pendingEtag = etag ?? null;
1089
+ this._pendingBy = by ?? null;
925
1090
  this._scheduleNextFrame();
926
1091
  }
927
1092
 
@@ -979,7 +1144,7 @@ class LiveSync {
979
1144
  } else {
980
1145
  this._morphInFlight = true;
981
1146
  try {
982
- await this._doApplyExternal(ext.html, ext.seq);
1147
+ await this._doApplyExternal(ext.html, ext.seq, ext.etag, ext.by);
983
1148
  } catch (err) {
984
1149
  console.error('[LiveSync] applyExternal failed:', err);
985
1150
  } finally {
@@ -990,14 +1155,18 @@ class LiveSync {
990
1155
  const html = this._pendingHtml;
991
1156
  const seq = this._pendingSeq;
992
1157
  const identityMap = this._pendingIdentityMap;
1158
+ const etag = this._pendingEtag;
1159
+ const by = this._pendingBy;
993
1160
  this._pendingHtml = null;
994
1161
  this._pendingSeq = null;
995
1162
  this._pendingIdentityMap = null;
1163
+ this._pendingEtag = null;
1164
+ this._pendingBy = null;
996
1165
  if (html == null) return;
997
1166
 
998
1167
  this._morphInFlight = true;
999
1168
  try {
1000
- await this._doApplyUpdate(html, seq, identityMap);
1169
+ await this._doApplyUpdate(html, seq, identityMap, etag, by);
1001
1170
  } catch (err) {
1002
1171
  console.error('[LiveSync] applyUpdate failed:', err);
1003
1172
  } finally {
@@ -1036,9 +1205,11 @@ class LiveSync {
1036
1205
  * @param {string} html
1037
1206
  * @param {number} [seq]
1038
1207
  * @param {Object} [identityMap]
1208
+ * @param {string} [etag]
1209
+ * @param {Object} [by]
1039
1210
  * @returns {Promise<void>}
1040
1211
  */
1041
- async _doApplyUpdate(html, seq, identityMap) {
1212
+ async _doApplyUpdate(html, seq, identityMap, etag, by = null) {
1042
1213
  this._log('applyUpdate - pausing mutations and morphing');
1043
1214
  this.isPaused = true;
1044
1215
 
@@ -1126,6 +1297,7 @@ class LiveSync {
1126
1297
  this._pendingHtml = html;
1127
1298
  this._pendingSeq = seq;
1128
1299
  this._pendingIdentityMap = identityMap;
1300
+ this._pendingBy = by;
1129
1301
  this._scheduleNextFrame();
1130
1302
  }, 3000);
1131
1303
  return;
@@ -1135,6 +1307,18 @@ class LiveSync {
1135
1307
 
1136
1308
  this._setHeld('live', false);
1137
1309
 
1310
+ // The frame merged, so this tab may take its stamp — but not yet. Adopting
1311
+ // the stamp and applying the bytes have to be the same event, and they only
1312
+ // are once the apply has actually happened. The morph below is awaited and
1313
+ // can reject, and a stamp recorded before it would sit on the old DOM with
1314
+ // nothing to undo it, so the next save would pass If-Match and replace a
1315
+ // version this tab never received. It is recorded after the await, beside
1316
+ // `lastHtml`, on the one path where the bytes definitely landed.
1317
+ //
1318
+ // The hold path above returns before that point, deliberately. A held tab is
1319
+ // knowingly missing what disk holds, and a stamp there would let its next
1320
+ // save replace that change with nobody told.
1321
+
1138
1322
  // Morph entire document. We MUST await — HyperMorph.morph returns a
1139
1323
  // Promise when `scripts: { handle: true }` needs to wait for external
1140
1324
  // scripts to load. If we don't await, Mutation.resume() fires before
@@ -1190,6 +1374,13 @@ class LiveSync {
1190
1374
  : null;
1191
1375
  this._applyGen++;
1192
1376
 
1377
+ // The stamp of section 6, taken now that the bytes it describes are the
1378
+ // bytes this tab is holding. Same line of reasoning as lastHtml directly
1379
+ // above, and deliberately the same moment: the two claims a page makes
1380
+ // when it adopts a stamp are "the host stores this version" and "I hold
1381
+ // it", and only the second one is this tab's to make.
1382
+ if (typeof etag === 'string' && etag) recordEtag(etag);
1383
+
1193
1384
  // Cross-lane baseline: the DOM now holds this frame's content, but the
1194
1385
  // DISK baseline (lastSavedContents) still describes pre-frame state. A
1195
1386
  // later dirty disk apply diffing against that stale baseline would
@@ -1214,8 +1405,13 @@ class LiveSync {
1214
1405
  // `source` is what lets a listener tell the two apply paths apart. clay.wire
1215
1406
  // waits for a DISK frame to call an agent's write landed, and another tab's
1216
1407
  // edit arriving first would otherwise report the wrong bytes as delivered.
1408
+ //
1409
+ // `by` is the server's answer about who sent these bytes, and it is on the
1410
+ // event rather than on the frame's arrival for one reason: a frame that held
1411
+ // returns above without reaching here, so nothing can name an author for a
1412
+ // change this tab never took. Null on every frame the host did not stamp.
1217
1413
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1218
- detail: { seq, source: 'peer' }
1414
+ detail: { seq, source: 'peer', by: by || null }
1219
1415
  }));
1220
1416
  } finally {
1221
1417
  this._log('applyUpdate - morph complete, resuming mutations');
@@ -1252,7 +1448,7 @@ class LiveSync {
1252
1448
  * holds or a later dirty peer apply misclassifies this frame's content as
1253
1449
  * local edits.
1254
1450
  */
1255
- async _doApplyExternal(html, seq) {
1451
+ async _doApplyExternal(html, seq, etag = null, by = null) {
1256
1452
  this._log(`applyExternal - external disk change (seq=${seq})`);
1257
1453
  this.isPaused = true;
1258
1454
 
@@ -1292,7 +1488,7 @@ class LiveSync {
1292
1488
  this._holdRetryExt = setTimeout(() => {
1293
1489
  this._holdRetryExt = null;
1294
1490
  if (this.isDestroyed || this._pendingExternal != null) return;
1295
- this._pendingExternal = { html, seq, saveEpoch: epochAtHold };
1491
+ this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag, by };
1296
1492
  this._scheduleNextFrame();
1297
1493
  }, 3000);
1298
1494
  return;
@@ -1329,6 +1525,17 @@ class LiveSync {
1329
1525
 
1330
1526
  window.scrollTo(scrollX, scrollY);
1331
1527
 
1528
+ // The stamp of section 6, adopted here and nowhere else: this is the moment
1529
+ // the bytes it describes reached this tab. It is taken on the retained-roots
1530
+ // path too, because a merge still incorporates the disk bytes, and the
1531
+ // convergence save at the bottom needs a stamp the host will accept or the
1532
+ // merge is refused and lost.
1533
+ //
1534
+ // A frame with no stamp (an older host, or the content-less fetch fallback,
1535
+ // which serves bytes nobody stamped) leaves this alone, and the listener in
1536
+ // etag.js falls back to asking the host.
1537
+ if (typeof etag === 'string' && etag) recordEtag(etag);
1538
+
1332
1539
  if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
1333
1540
  // Clean apply: the DOM now IS the disk state, so a local comparison
1334
1541
  // capture of it is the truthful baseline. The next no-op save skips,
@@ -1350,7 +1557,12 @@ class LiveSync {
1350
1557
  }
1351
1558
 
1352
1559
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1353
- detail: { seq, source: 'disk' }
1560
+ detail: {
1561
+ seq,
1562
+ source: 'disk',
1563
+ etag: typeof etag === 'string' && etag ? etag : null,
1564
+ by: by || null,
1565
+ }
1354
1566
  }));
1355
1567
  } finally {
1356
1568
  this._log('applyExternal - morph complete, resuming mutations');