@panphora/clayjs 1.1.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.
@@ -44,7 +44,7 @@ import { serializeForSync, captureForComparisonAndDirty, captureSnapshot } from
44
44
  import { isTabLocalRootAttr } from '../lib/root-attrs.js';
45
45
  import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
46
46
  import { hostMeta } from '../core/host-meta.js';
47
- import { recordEtag, seedEtag } from '../core/etag.js';
47
+ import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
48
48
  import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
49
49
 
50
50
  // The page just took a frame verified clean against its baseline, so it now IS
@@ -114,6 +114,15 @@ class LiveSync {
114
114
 
115
115
  this.debounceMs = 150;
116
116
  this.debounceTimer = null;
117
+
118
+ // The sync serialization of the clone the CURRENT save was captured from,
119
+ // held so the commit relay can pair the host's stamp with the content that
120
+ // stamp actually describes. Set at snapshot-ready, which fires before the
121
+ // save POST goes out, and consumed by _relayCommit when the response lands.
122
+ // Never reconstructed from lastHtml: that is the last relay that COMPLETED,
123
+ // which lags the save whenever the response beats the 150ms debounce, and
124
+ // pairing a fresh stamp with older bytes is the one thing spec §10 forbids.
125
+ this._savedSnapshot = null;
117
126
  this._sendInFlight = false;
118
127
  this._queuedSend = null;
119
128
 
@@ -155,13 +164,17 @@ class LiveSync {
155
164
  this._pendingHtml = null;
156
165
  this._pendingSeq = null;
157
166
  this._pendingIdentityMap = null;
167
+ // The stamp a pending frame carries, adopted only if that frame applies
168
+ // cleanly. It rides in the slot beside the bytes it describes so the two can
169
+ // never be separated, which is the whole rule (§6, and §22 of the plan).
170
+ this._pendingEtag = null;
158
171
  this._morphInFlight = false;
159
172
  this._rafHandle = null;
160
173
 
161
174
  // Disk-sourced external changes (htmlclay watcher content) get their own
162
175
  // one-deep slot so a peer frame arriving in the same window can't
163
176
  // displace them. Drained in seq order alongside the peer slot.
164
- this._pendingExternal = null; // { html, seq, saveEpoch }
177
+ this._pendingExternal = null; // { html, seq, saveEpoch, etag }
165
178
 
166
179
  // Newest external-change seq accepted for apply. Replayed or reordered
167
180
  // disk frames (and their fetch fallbacks) are dropped against this.
@@ -274,6 +287,7 @@ class LiveSync {
274
287
  // so mint a fresh resume id — this stream must not resume the previous one.
275
288
  this.lastHtml = null;
276
289
  this._lastIdentityMap = null;
290
+ this._savedSnapshot = null;
277
291
  this._applyGen++;
278
292
  this.lastSeenSeq = 0;
279
293
  this._lastExternalSeq = 0;
@@ -295,7 +309,10 @@ class LiveSync {
295
309
  // snapshot listener would never fire — skip registering it.
296
310
  if (this.lane === 'live') {
297
311
  this.listenForSnapshots();
298
- this._saveSavedHandler = () => { this._saveEpoch++; };
312
+ this._saveSavedHandler = () => {
313
+ this._saveEpoch++;
314
+ this._relayCommit();
315
+ };
299
316
  document.addEventListener('clay:save-saved', this._saveSavedHandler);
300
317
  }
301
318
  }
@@ -534,31 +551,32 @@ class LiveSync {
534
551
  }
535
552
 
536
553
  /**
537
- * Take a stamp that arrived with no document (spec §6).
554
+ * Drop a stamp that arrived with no document (spec §6).
538
555
  *
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.
556
+ * ⚠️ This used to TAKE it, and taking it was wrong. A stamp says "disk is at this
557
+ * version"; adopting it says "and I hold those bytes". Only the second claim
558
+ * makes the next save safe, and a frame with no content is no evidence for it.
542
559
  *
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.
560
+ * The race is real and it loses work. A peer's save and that peer's snapshot
561
+ * relay are two concurrent requests, so a stamp travelling on its own can arrive
562
+ * first. This tab would record it while its DOM still held the older content,
563
+ * and its next save would then pass If-Match and overwrite the save it had never
564
+ * received. The hold flags cannot cover that: a lane holds when an incoming
565
+ * change could NOT be merged, not when one has yet to arrive.
546
566
  *
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.
567
+ * So a stamp now rides on the snapshot the saving tab relays after its save
568
+ * returns, and `_doApplyUpdate` records it only once that content has merged.
569
+ * Nothing sends a bare stamp any more — hyperclay's was deleted, and it had
570
+ * never reached anyone, because the relay library refuses a payload with no
571
+ * document in it. This stays as the rule rather than as compatibility: if some
572
+ * host does send one, dropping it is correct.
555
573
  *
556
574
  * @param {Object} data - The decoded frame
557
- * @returns {boolean} True when this was a stamp frame and nothing should morph
575
+ * @returns {boolean} True when this was a stamp-only frame and nothing should morph
558
576
  */
559
577
  _applyEtagFrame(data) {
560
578
  if (typeof data.etag !== 'string' || typeof data.html === 'string') return false;
561
- if (!this._heldLive && !this._heldExt) recordEtag(data.etag);
579
+ this._log('Dropping a stamp that arrived with no document: nothing proves this tab holds those bytes');
562
580
  return true;
563
581
  }
564
582
 
@@ -665,9 +683,21 @@ class LiveSync {
665
683
  return;
666
684
  }
667
685
 
686
+ const etag = typeof data.etag === 'string' && data.etag ? data.etag : null;
687
+
688
+ // The common case, and it costs nothing: a peer saved bytes this tab has
689
+ // already applied, so the frame's only news is the stamp. Taking it without
690
+ // a morph is exactly as safe as taking it after one, because the baseline
691
+ // being equal IS the proof that this tab holds those bytes.
692
+ if (etag && html === this.lastHtml) {
693
+ this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
694
+ recordEtag(etag);
695
+ return;
696
+ }
697
+
668
698
  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 });
699
+ this.applyUpdate(html, seq, identityMap, etag);
700
+ if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap, etag });
671
701
  };
672
702
 
673
703
  // Native EventSource auto-reconnects on transient errors
@@ -704,6 +734,12 @@ class LiveSync {
704
734
  this._log('snapshot-ready received, preparing to send');
705
735
  const html = serializeForSync(clone);
706
736
  const identityMap = this._buildIdentityMap(document.documentElement, clone);
737
+
738
+ // The save that follows this capture stores these bytes, so this is the
739
+ // content its stamp will describe. Held for _relayCommit, and overwritten
740
+ // by the next capture, so it always names the save currently in flight.
741
+ this._savedSnapshot = { html, identityMap };
742
+
707
743
  this.sendUpdate(html, identityMap);
708
744
  };
709
745
 
@@ -750,6 +786,77 @@ class LiveSync {
750
786
  this._postUpdate(queued.html, queued.identityMap);
751
787
  }
752
788
 
789
+ /**
790
+ * Tell the other editors what this tab's save stored, and under which stamp.
791
+ *
792
+ * Runs on clay:save-saved, by which point the save response has already been
793
+ * recorded, so `lastSeenEtag()` is the stamp for the bytes that just landed.
794
+ *
795
+ * The content is `lastHtml`, the snapshot this tab most recently relayed, which
796
+ * is the state the save was taken from: the save pipeline dispatches
797
+ * clay:snapshot-ready on its way to capturing what to send, so that relay has
798
+ * already gone out. Re-sending those same bytes is not redundant, because the
799
+ * stamp is the new information and a stamp may never travel without the content
800
+ * it describes. A receiver whose baseline already matches takes the stamp and
801
+ * skips the morph.
802
+ */
803
+ _relayCommit() {
804
+ // Holding a stamp is the whole condition. It is set only from a save response
805
+ // that carried one, and a host that does not do conditional saves returns
806
+ // none, so this is the same test as "the host stamps what it stores" without
807
+ // a second flag that would have to be reached through discovery to exercise.
808
+ const etag = lastSeenEtag();
809
+ if (!etag) return;
810
+ if (this.isDestroyed || this.isPaused) return;
811
+
812
+ // The content this save stored, captured with it. Consumed rather than left
813
+ // behind, so a save-saved with no capture of its own can never reuse an
814
+ // older save's bytes under a newer stamp.
815
+ const pending = this._savedSnapshot;
816
+ this._savedSnapshot = null;
817
+
818
+ // No captured snapshot means nothing here knows which bytes this stamp
819
+ // describes, and §10 is explicit that a stamp must never travel on its own.
820
+ // Staying silent costs the other editors one refusal they recover from;
821
+ // guessing costs somebody their work.
822
+ if (!pending || typeof pending.html !== 'string') return;
823
+
824
+ this._postCommit(pending.html, etag, pending.identityMap);
825
+ }
826
+
827
+ /**
828
+ * Post a snapshot whose point is the stamp attached to it.
829
+ *
830
+ * Deliberately not `_postUpdate`: that one returns early when the html matches
831
+ * `lastHtml`, and the whole point here is that the stamp is the new information
832
+ * even when the bytes are not. It also does not touch `lastHtml` or the
833
+ * single-flight queue, because it must not displace a real snapshot waiting to
834
+ * go out. It carries the identityMap captured with these bytes, so a receiver
835
+ * that morphs on this frame pairs elements the same way it would on the
836
+ * ordinary relay of the same content.
837
+ */
838
+ _postCommit(html, etag, identityMap) {
839
+ const profile = this._profile;
840
+ if (!profile) return;
841
+ fetch(new URL(profile.relayPath, window.location.origin).href, {
842
+ method: 'POST',
843
+ headers: {
844
+ 'Content-Type': 'application/json',
845
+ [profile.documentHeader]: window.location.href,
846
+ },
847
+ body: JSON.stringify({
848
+ [profile.snapshotKey]: html,
849
+ sender: this.clientId,
850
+ identityMap,
851
+ etag
852
+ })
853
+ }).catch(err => {
854
+ // Losing this costs the other editors one spurious refusal on their next
855
+ // save, which they recover from. It is not worth surfacing as an error.
856
+ this._log('Commit relay failed: ' + (err && err.message));
857
+ });
858
+ }
859
+
753
860
  _postUpdate(html, identityMap) {
754
861
  // Skip if unchanged
755
862
  if (html === this.lastHtml) {
@@ -817,7 +924,7 @@ class LiveSync {
817
924
  const info = data.data;
818
925
  if (info && info.kind === 'external-change') {
819
926
  if (typeof info.html === 'string') {
820
- this._enqueueExternal(info.html, data.seq);
927
+ this._enqueueExternal(info.html, data.seq, info.etag);
821
928
  } else {
822
929
  this._fetchExternalChange(data.seq);
823
930
  }
@@ -830,7 +937,7 @@ class LiveSync {
830
937
  return false;
831
938
  }
832
939
 
833
- _enqueueExternal(html, seq) {
940
+ _enqueueExternal(html, seq, etag) {
834
941
  if (typeof seq === 'number') {
835
942
  if (seq <= this._lastExternalSeq) {
836
943
  this._log(`Dropping replayed external change: seq=${seq}`);
@@ -838,7 +945,12 @@ class LiveSync {
838
945
  }
839
946
  this._lastExternalSeq = seq;
840
947
  }
841
- this._pendingExternal = { html, seq, saveEpoch: this._saveEpoch };
948
+ this._pendingExternal = {
949
+ html,
950
+ seq,
951
+ saveEpoch: this._saveEpoch,
952
+ etag: typeof etag === 'string' && etag ? etag : null,
953
+ };
842
954
  this._scheduleNextFrame();
843
955
  }
844
956
 
@@ -889,7 +1001,10 @@ class LiveSync {
889
1001
  }
890
1002
  return;
891
1003
  }
892
- this._pendingExternal = { html, seq, saveEpoch: epoch };
1004
+ // No stamp: this body came from a GET of the served page, which nobody
1005
+ // stamped, so the apply leaves the held stamp alone and etag.js asks the
1006
+ // host for a replacement.
1007
+ this._pendingExternal = { html, seq, saveEpoch: epoch, etag: null };
893
1008
  this._scheduleNextFrame();
894
1009
  })
895
1010
  .catch((err) => {
@@ -917,11 +1032,15 @@ class LiveSync {
917
1032
  * @param {number} [seq] - Optional monotonic seq from the server
918
1033
  * @param {Object} [identityMap] - Optional element-identity map from sender
919
1034
  */
920
- applyUpdate(html, seq, identityMap) {
1035
+ applyUpdate(html, seq, identityMap, etag) {
921
1036
  if (this.isDestroyed) return;
922
1037
  this._pendingHtml = html;
923
1038
  this._pendingSeq = seq;
924
1039
  this._pendingIdentityMap = identityMap;
1040
+ // A newer frame replacing this one takes its stamp with it, and that is
1041
+ // correct: the stamp belongs to bytes that are no longer what will apply.
1042
+ // Losing it costs one honest 412 later, which is the safe direction.
1043
+ this._pendingEtag = etag ?? null;
925
1044
  this._scheduleNextFrame();
926
1045
  }
927
1046
 
@@ -979,7 +1098,7 @@ class LiveSync {
979
1098
  } else {
980
1099
  this._morphInFlight = true;
981
1100
  try {
982
- await this._doApplyExternal(ext.html, ext.seq);
1101
+ await this._doApplyExternal(ext.html, ext.seq, ext.etag);
983
1102
  } catch (err) {
984
1103
  console.error('[LiveSync] applyExternal failed:', err);
985
1104
  } finally {
@@ -990,14 +1109,16 @@ class LiveSync {
990
1109
  const html = this._pendingHtml;
991
1110
  const seq = this._pendingSeq;
992
1111
  const identityMap = this._pendingIdentityMap;
1112
+ const etag = this._pendingEtag;
993
1113
  this._pendingHtml = null;
994
1114
  this._pendingSeq = null;
995
1115
  this._pendingIdentityMap = null;
1116
+ this._pendingEtag = null;
996
1117
  if (html == null) return;
997
1118
 
998
1119
  this._morphInFlight = true;
999
1120
  try {
1000
- await this._doApplyUpdate(html, seq, identityMap);
1121
+ await this._doApplyUpdate(html, seq, identityMap, etag);
1001
1122
  } catch (err) {
1002
1123
  console.error('[LiveSync] applyUpdate failed:', err);
1003
1124
  } finally {
@@ -1038,7 +1159,7 @@ class LiveSync {
1038
1159
  * @param {Object} [identityMap]
1039
1160
  * @returns {Promise<void>}
1040
1161
  */
1041
- async _doApplyUpdate(html, seq, identityMap) {
1162
+ async _doApplyUpdate(html, seq, identityMap, etag) {
1042
1163
  this._log('applyUpdate - pausing mutations and morphing');
1043
1164
  this.isPaused = true;
1044
1165
 
@@ -1135,6 +1256,18 @@ class LiveSync {
1135
1256
 
1136
1257
  this._setHeld('live', false);
1137
1258
 
1259
+ // The frame merged, so this tab may take its stamp — but not yet. Adopting
1260
+ // the stamp and applying the bytes have to be the same event, and they only
1261
+ // are once the apply has actually happened. The morph below is awaited and
1262
+ // can reject, and a stamp recorded before it would sit on the old DOM with
1263
+ // nothing to undo it, so the next save would pass If-Match and replace a
1264
+ // version this tab never received. It is recorded after the await, beside
1265
+ // `lastHtml`, on the one path where the bytes definitely landed.
1266
+ //
1267
+ // The hold path above returns before that point, deliberately. A held tab is
1268
+ // knowingly missing what disk holds, and a stamp there would let its next
1269
+ // save replace that change with nobody told.
1270
+
1138
1271
  // Morph entire document. We MUST await — HyperMorph.morph returns a
1139
1272
  // Promise when `scripts: { handle: true }` needs to wait for external
1140
1273
  // scripts to load. If we don't await, Mutation.resume() fires before
@@ -1190,6 +1323,13 @@ class LiveSync {
1190
1323
  : null;
1191
1324
  this._applyGen++;
1192
1325
 
1326
+ // The stamp of section 6, taken now that the bytes it describes are the
1327
+ // bytes this tab is holding. Same line of reasoning as lastHtml directly
1328
+ // above, and deliberately the same moment: the two claims a page makes
1329
+ // when it adopts a stamp are "the host stores this version" and "I hold
1330
+ // it", and only the second one is this tab's to make.
1331
+ if (typeof etag === 'string' && etag) recordEtag(etag);
1332
+
1193
1333
  // Cross-lane baseline: the DOM now holds this frame's content, but the
1194
1334
  // DISK baseline (lastSavedContents) still describes pre-frame state. A
1195
1335
  // later dirty disk apply diffing against that stale baseline would
@@ -1252,7 +1392,7 @@ class LiveSync {
1252
1392
  * holds or a later dirty peer apply misclassifies this frame's content as
1253
1393
  * local edits.
1254
1394
  */
1255
- async _doApplyExternal(html, seq) {
1395
+ async _doApplyExternal(html, seq, etag = null) {
1256
1396
  this._log(`applyExternal - external disk change (seq=${seq})`);
1257
1397
  this.isPaused = true;
1258
1398
 
@@ -1292,7 +1432,7 @@ class LiveSync {
1292
1432
  this._holdRetryExt = setTimeout(() => {
1293
1433
  this._holdRetryExt = null;
1294
1434
  if (this.isDestroyed || this._pendingExternal != null) return;
1295
- this._pendingExternal = { html, seq, saveEpoch: epochAtHold };
1435
+ this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag };
1296
1436
  this._scheduleNextFrame();
1297
1437
  }, 3000);
1298
1438
  return;
@@ -1329,6 +1469,17 @@ class LiveSync {
1329
1469
 
1330
1470
  window.scrollTo(scrollX, scrollY);
1331
1471
 
1472
+ // The stamp of section 6, adopted here and nowhere else: this is the moment
1473
+ // the bytes it describes reached this tab. It is taken on the retained-roots
1474
+ // path too, because a merge still incorporates the disk bytes, and the
1475
+ // convergence save at the bottom needs a stamp the host will accept or the
1476
+ // merge is refused and lost.
1477
+ //
1478
+ // A frame with no stamp (an older host, or the content-less fetch fallback,
1479
+ // which serves bytes nobody stamped) leaves this alone, and the listener in
1480
+ // etag.js falls back to asking the host.
1481
+ if (typeof etag === 'string' && etag) recordEtag(etag);
1482
+
1332
1483
  if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
1333
1484
  // Clean apply: the DOM now IS the disk state, so a local comparison
1334
1485
  // capture of it is the truthful baseline. The next no-op save skips,
@@ -1350,7 +1501,7 @@ class LiveSync {
1350
1501
  }
1351
1502
 
1352
1503
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1353
- detail: { seq, source: 'disk' }
1504
+ detail: { seq, source: 'disk', etag: typeof etag === 'string' && etag ? etag : null }
1354
1505
  }));
1355
1506
  } finally {
1356
1507
  this._log('applyExternal - morph complete, resuming mutations');