@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.
@@ -44,6 +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, lastSeenEtag } from '../core/etag.js';
47
48
  import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
48
49
 
49
50
  // The page just took a frame verified clean against its baseline, so it now IS
@@ -113,6 +114,15 @@ class LiveSync {
113
114
 
114
115
  this.debounceMs = 150;
115
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;
116
126
  this._sendInFlight = false;
117
127
  this._queuedSend = null;
118
128
 
@@ -154,13 +164,17 @@ class LiveSync {
154
164
  this._pendingHtml = null;
155
165
  this._pendingSeq = null;
156
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;
157
171
  this._morphInFlight = false;
158
172
  this._rafHandle = null;
159
173
 
160
174
  // Disk-sourced external changes (htmlclay watcher content) get their own
161
175
  // one-deep slot so a peer frame arriving in the same window can't
162
176
  // displace them. Drained in seq order alongside the peer slot.
163
- this._pendingExternal = null; // { html, seq, saveEpoch }
177
+ this._pendingExternal = null; // { html, seq, saveEpoch, etag }
164
178
 
165
179
  // Newest external-change seq accepted for apply. Replayed or reordered
166
180
  // disk frames (and their fetch fallbacks) are dropped against this.
@@ -273,6 +287,7 @@ class LiveSync {
273
287
  // so mint a fresh resume id — this stream must not resume the previous one.
274
288
  this.lastHtml = null;
275
289
  this._lastIdentityMap = null;
290
+ this._savedSnapshot = null;
276
291
  this._applyGen++;
277
292
  this.lastSeenSeq = 0;
278
293
  this._lastExternalSeq = 0;
@@ -294,7 +309,10 @@ class LiveSync {
294
309
  // snapshot listener would never fire — skip registering it.
295
310
  if (this.lane === 'live') {
296
311
  this.listenForSnapshots();
297
- this._saveSavedHandler = () => { this._saveEpoch++; };
312
+ this._saveSavedHandler = () => {
313
+ this._saveEpoch++;
314
+ this._relayCommit();
315
+ };
298
316
  document.addEventListener('clay:save-saved', this._saveSavedHandler);
299
317
  }
300
318
  }
@@ -517,11 +535,51 @@ class LiveSync {
517
535
  const key = lane === 'live' ? '_heldLive' : '_heldExt';
518
536
  if (this[key] === isHeld) return;
519
537
  this[key] = isHeld;
538
+ // A hold that clears leaves this tab back in step, but every stamp frame that
539
+ // arrived while it was held was refused and nothing re-sends them: stamps are
540
+ // broadcast on a save, not on a merge. So the tab would go on holding a stamp
541
+ // from before the hold and have its next save refused over a conflict that has
542
+ // already resolved itself, which is this notice crying wolf. Ask the host for
543
+ // the current one instead. Only once BOTH lanes are clear, because one lane
544
+ // resuming while the other still holds means the tab is still behind.
545
+ if (!isHeld && !this._heldLive && !this._heldExt) {
546
+ seedEtag({ fresh: true, clearIfMissing: false });
547
+ }
520
548
  document.dispatchEvent(new CustomEvent(isHeld ? 'clay:sync-held' : 'clay:sync-resumed', {
521
549
  detail: { lane, el: el || null },
522
550
  }));
523
551
  }
524
552
 
553
+ /**
554
+ * Drop a stamp that arrived with no document (spec §6).
555
+ *
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.
559
+ *
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.
566
+ *
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.
573
+ *
574
+ * @param {Object} data - The decoded frame
575
+ * @returns {boolean} True when this was a stamp-only frame and nothing should morph
576
+ */
577
+ _applyEtagFrame(data) {
578
+ if (typeof data.etag !== 'string' || typeof data.html === 'string') return false;
579
+ this._log('Dropping a stamp that arrived with no document: nothing proves this tab holds those bytes');
580
+ return true;
581
+ }
582
+
525
583
  /**
526
584
  * Connect to the SSE endpoint
527
585
  * Uses native EventSource reconnection behavior
@@ -609,6 +667,8 @@ class LiveSync {
609
667
  return;
610
668
  }
611
669
 
670
+ if (this._applyEtagFrame(data)) return;
671
+
612
672
  const { html, sender, identityMap } = data;
613
673
 
614
674
  // Ignore own changes — already reflected in the DOM, nothing to morph
@@ -623,9 +683,21 @@ class LiveSync {
623
683
  return;
624
684
  }
625
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
+
626
698
  this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
627
- this.applyUpdate(html, seq, identityMap);
628
- 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 });
629
701
  };
630
702
 
631
703
  // Native EventSource auto-reconnects on transient errors
@@ -662,6 +734,12 @@ class LiveSync {
662
734
  this._log('snapshot-ready received, preparing to send');
663
735
  const html = serializeForSync(clone);
664
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
+
665
743
  this.sendUpdate(html, identityMap);
666
744
  };
667
745
 
@@ -708,6 +786,77 @@ class LiveSync {
708
786
  this._postUpdate(queued.html, queued.identityMap);
709
787
  }
710
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
+
711
860
  _postUpdate(html, identityMap) {
712
861
  // Skip if unchanged
713
862
  if (html === this.lastHtml) {
@@ -775,7 +924,7 @@ class LiveSync {
775
924
  const info = data.data;
776
925
  if (info && info.kind === 'external-change') {
777
926
  if (typeof info.html === 'string') {
778
- this._enqueueExternal(info.html, data.seq);
927
+ this._enqueueExternal(info.html, data.seq, info.etag);
779
928
  } else {
780
929
  this._fetchExternalChange(data.seq);
781
930
  }
@@ -788,7 +937,7 @@ class LiveSync {
788
937
  return false;
789
938
  }
790
939
 
791
- _enqueueExternal(html, seq) {
940
+ _enqueueExternal(html, seq, etag) {
792
941
  if (typeof seq === 'number') {
793
942
  if (seq <= this._lastExternalSeq) {
794
943
  this._log(`Dropping replayed external change: seq=${seq}`);
@@ -796,7 +945,12 @@ class LiveSync {
796
945
  }
797
946
  this._lastExternalSeq = seq;
798
947
  }
799
- 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
+ };
800
954
  this._scheduleNextFrame();
801
955
  }
802
956
 
@@ -847,7 +1001,10 @@ class LiveSync {
847
1001
  }
848
1002
  return;
849
1003
  }
850
- 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 };
851
1008
  this._scheduleNextFrame();
852
1009
  })
853
1010
  .catch((err) => {
@@ -875,11 +1032,15 @@ class LiveSync {
875
1032
  * @param {number} [seq] - Optional monotonic seq from the server
876
1033
  * @param {Object} [identityMap] - Optional element-identity map from sender
877
1034
  */
878
- applyUpdate(html, seq, identityMap) {
1035
+ applyUpdate(html, seq, identityMap, etag) {
879
1036
  if (this.isDestroyed) return;
880
1037
  this._pendingHtml = html;
881
1038
  this._pendingSeq = seq;
882
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;
883
1044
  this._scheduleNextFrame();
884
1045
  }
885
1046
 
@@ -937,7 +1098,7 @@ class LiveSync {
937
1098
  } else {
938
1099
  this._morphInFlight = true;
939
1100
  try {
940
- await this._doApplyExternal(ext.html, ext.seq);
1101
+ await this._doApplyExternal(ext.html, ext.seq, ext.etag);
941
1102
  } catch (err) {
942
1103
  console.error('[LiveSync] applyExternal failed:', err);
943
1104
  } finally {
@@ -948,14 +1109,16 @@ class LiveSync {
948
1109
  const html = this._pendingHtml;
949
1110
  const seq = this._pendingSeq;
950
1111
  const identityMap = this._pendingIdentityMap;
1112
+ const etag = this._pendingEtag;
951
1113
  this._pendingHtml = null;
952
1114
  this._pendingSeq = null;
953
1115
  this._pendingIdentityMap = null;
1116
+ this._pendingEtag = null;
954
1117
  if (html == null) return;
955
1118
 
956
1119
  this._morphInFlight = true;
957
1120
  try {
958
- await this._doApplyUpdate(html, seq, identityMap);
1121
+ await this._doApplyUpdate(html, seq, identityMap, etag);
959
1122
  } catch (err) {
960
1123
  console.error('[LiveSync] applyUpdate failed:', err);
961
1124
  } finally {
@@ -996,7 +1159,7 @@ class LiveSync {
996
1159
  * @param {Object} [identityMap]
997
1160
  * @returns {Promise<void>}
998
1161
  */
999
- async _doApplyUpdate(html, seq, identityMap) {
1162
+ async _doApplyUpdate(html, seq, identityMap, etag) {
1000
1163
  this._log('applyUpdate - pausing mutations and morphing');
1001
1164
  this.isPaused = true;
1002
1165
 
@@ -1093,6 +1256,18 @@ class LiveSync {
1093
1256
 
1094
1257
  this._setHeld('live', false);
1095
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
+
1096
1271
  // Morph entire document. We MUST await — HyperMorph.morph returns a
1097
1272
  // Promise when `scripts: { handle: true }` needs to wait for external
1098
1273
  // scripts to load. If we don't await, Mutation.resume() fires before
@@ -1148,6 +1323,13 @@ class LiveSync {
1148
1323
  : null;
1149
1324
  this._applyGen++;
1150
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
+
1151
1333
  // Cross-lane baseline: the DOM now holds this frame's content, but the
1152
1334
  // DISK baseline (lastSavedContents) still describes pre-frame state. A
1153
1335
  // later dirty disk apply diffing against that stale baseline would
@@ -1210,7 +1392,7 @@ class LiveSync {
1210
1392
  * holds or a later dirty peer apply misclassifies this frame's content as
1211
1393
  * local edits.
1212
1394
  */
1213
- async _doApplyExternal(html, seq) {
1395
+ async _doApplyExternal(html, seq, etag = null) {
1214
1396
  this._log(`applyExternal - external disk change (seq=${seq})`);
1215
1397
  this.isPaused = true;
1216
1398
 
@@ -1250,7 +1432,7 @@ class LiveSync {
1250
1432
  this._holdRetryExt = setTimeout(() => {
1251
1433
  this._holdRetryExt = null;
1252
1434
  if (this.isDestroyed || this._pendingExternal != null) return;
1253
- this._pendingExternal = { html, seq, saveEpoch: epochAtHold };
1435
+ this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag };
1254
1436
  this._scheduleNextFrame();
1255
1437
  }, 3000);
1256
1438
  return;
@@ -1287,6 +1469,17 @@ class LiveSync {
1287
1469
 
1288
1470
  window.scrollTo(scrollX, scrollY);
1289
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
+
1290
1483
  if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
1291
1484
  // Clean apply: the DOM now IS the disk state, so a local comparison
1292
1485
  // capture of it is the truthful baseline. The next no-op save skips,
@@ -1308,7 +1501,7 @@ class LiveSync {
1308
1501
  }
1309
1502
 
1310
1503
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1311
- detail: { seq, source: 'disk' }
1504
+ detail: { seq, source: 'disk', etag: typeof etag === 'string' && etag ? etag : null }
1312
1505
  }));
1313
1506
  } finally {
1314
1507
  this._log('applyExternal - morph complete, resuming mutations');
package/src/ui/index.js CHANGED
@@ -23,6 +23,13 @@ if (typeof window.toastPersistent === "undefined") window.toastPersistent = toas
23
23
  document.addEventListener("clay:save-saved", (e) =>
24
24
  toast(e.detail?.msg || "Saved", e.detail?.msgType === "warning" ? "warning" : "success"));
25
25
  document.addEventListener("clay:save-error", () => toastPersistent("Couldn't save", "error"));
26
+ // Spec §6. Persistent, and carrying the host's own words, because this is the one
27
+ // save outcome the person has to act on: their edits are safe and unsaved, and
28
+ // autosave has stopped until they choose. Deliberately a toast and not a dialog:
29
+ // most conflicts surface on an autosave nobody asked for, and a modal thrown over
30
+ // the page a person is typing into is worse than the problem it reports.
31
+ document.addEventListener("clay:save-conflict", (e) =>
32
+ toastPersistent(e.detail?.msg || "This document changed since you opened it", "warning"));
26
33
  document.addEventListener("clay:save-offline", () => toastPersistent("Offline, not saved", "warning"));
27
34
 
28
35
  export { toast, toastPersistent, ask, consent, tell, snippet, themodal };