@panphora/clayjs 0.5.0 → 0.6.1

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.
@@ -423,6 +423,34 @@ class LiveSync {
423
423
  if (this.onConnect) this.onConnect();
424
424
  };
425
425
 
426
+ // The cursor frame is a NAMED SSE event, so it never reaches onmessage and
427
+ // never looks like data. Its `resync` flag says the server could not retain
428
+ // everything between where this client resumed and the baseline it is
429
+ // sending: what this page holds is stale in a way no replay will fix.
430
+ //
431
+ // The repair is the token-free fetch of the served document this class
432
+ // already runs for a change too large to send, so noticing the flag is the
433
+ // whole of the work.
434
+ this.sse.addEventListener('cursor', (event) => {
435
+ let data;
436
+ try {
437
+ data = JSON.parse(event.data);
438
+ } catch {
439
+ return;
440
+ }
441
+ if (!data || data.resync !== true) return;
442
+ console.log('[LiveSync] Server could not replay everything; refetching the document');
443
+ // _fetchServedDocument, deliberately, and not _fetchExternalChange: that
444
+ // one drops a fetch whose seq is at or below the external watermark, and
445
+ // the cursor baseline routinely is, since it is the server's high-water
446
+ // mark and our own last applied change may already have reached it. A
447
+ // resync that skipped itself for being "already seen" would leave the page
448
+ // permanently stale, which is the exact failure the flag exists to report.
449
+ this._fetchServedDocument(typeof data.seq === 'number' ? data.seq : undefined, {
450
+ repair: true,
451
+ });
452
+ });
453
+
426
454
  this.sse.onmessage = (event) => {
427
455
  const data = JSON.parse(event.data);
428
456
 
@@ -654,20 +682,29 @@ class LiveSync {
654
682
  * frame the epoch check refused (the fetched body is whatever disk holds
655
683
  * NOW, which is always safe to apply).
656
684
  */
657
- _fetchServedDocument(seq, attempt = 0) {
685
+ _fetchServedDocument(seq, { attempt = 0, repair = false } = {}) {
658
686
  const epoch = this._saveEpoch;
659
687
  fetch(new URL(window.location.href), { cache: 'no-store' })
660
688
  .then((response) => (response.ok ? response.text() : null))
661
689
  .then((html) => {
662
690
  if (this.isDestroyed || html == null) return;
663
- if (typeof seq === 'number' && seq < this._lastExternalSeq) return;
691
+ if (typeof seq === 'number' && seq < this._lastExternalSeq) {
692
+ // A newer external change superseded this one, and its own fetch will
693
+ // queue a body. Except for a repair: that one exists because the server
694
+ // said replay cannot fix this page, so if the newer fetch fails there is
695
+ // nothing else coming. Refetch rather than drop the only repair.
696
+ if (repair && attempt < 3) {
697
+ this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
698
+ }
699
+ return;
700
+ }
664
701
  if (this._saveEpoch > epoch) {
665
702
  // An own save landed while the GET was in flight, so this body may
666
703
  // predate it. Save-response order proves nothing about disk-write
667
704
  // order — refetch for the newest bytes instead of dropping.
668
705
  if (attempt < 3) {
669
706
  console.log('[LiveSync] Refetching external change: own save landed mid-fetch');
670
- this._fetchServedDocument(seq, attempt + 1);
707
+ this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
671
708
  }
672
709
  return;
673
710
  }
@@ -989,8 +1026,12 @@ class LiveSync {
989
1026
  // onmessage before applyUpdate is ever called. Covers every SSE morph
990
1027
  // source (peer edit, version restore, body-swap) since they all funnel
991
1028
  // through this single choke point.
1029
+ //
1030
+ // `source` is what lets a listener tell the two apply paths apart. clay.wire
1031
+ // waits for a DISK frame to call an agent's write landed, and another tab's
1032
+ // edit arriving first would otherwise report the wrong bytes as delivered.
992
1033
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
993
- detail: { seq }
1034
+ detail: { seq, source: 'peer' }
994
1035
  }));
995
1036
  } finally {
996
1037
  this._log('applyUpdate - morph complete, resuming mutations');
@@ -1042,7 +1083,14 @@ class LiveSync {
1042
1083
  const parser = new DOMParser();
1043
1084
  const newDoc = parser.parseFromString(html, 'text/html');
1044
1085
 
1045
- if (pageMaybeDirty()) {
1086
+ // Lane-guarded exactly like the peer path. A view-mode tab has no save
1087
+ // baseline — every writer of lastSavedContents is edit-gated — so
1088
+ // protectDiskDoc can only ever refuse, and the frame would hold, retry
1089
+ // every 3s, and hold again forever. The gate still reads dirty there,
1090
+ // because persistProbeDirty inspects the live DOM and a visitor can type
1091
+ // into a [persist] field. Before the resync repair this path was
1092
+ // unreachable outside the live lane; now it is the repair's own route.
1093
+ if (this.lane === 'live' && pageMaybeDirty()) {
1046
1094
  const protection = protectDiskDoc({ newDoc });
1047
1095
  if (!protection.ok) {
1048
1096
  // Hold: nothing morphs, no baseline moves, and deliberately NO
@@ -1094,7 +1142,7 @@ class LiveSync {
1094
1142
 
1095
1143
  window.scrollTo(scrollX, scrollY);
1096
1144
 
1097
- if (retainedRoots === 0 && !pageMaybeDirty()) {
1145
+ if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
1098
1146
  // Clean apply: the DOM now IS the disk state, so a local comparison
1099
1147
  // capture of it is the truthful baseline. The next no-op save skips,
1100
1148
  // beforeunload stays quiet. The dirty re-check matters: typing that
@@ -1115,7 +1163,7 @@ class LiveSync {
1115
1163
  }
1116
1164
 
1117
1165
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1118
- detail: { seq }
1166
+ detail: { seq, source: 'disk' }
1119
1167
  }));
1120
1168
  } finally {
1121
1169
  this._log('applyExternal - morph complete, resuming mutations');