@panphora/clayjs 1.7.0 → 1.8.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.
@@ -55,12 +55,13 @@ import { presence } from './presence.js';
55
55
  // `clay:sync-applied`, which this file is the only dispatcher of.
56
56
  import './section-notice.js';
57
57
  import { hostMeta } from '../core/host-meta.js';
58
- import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves } from '../core/etag.js';
58
+ import { servedDocumentEtag } from '../core/host-attrs.js';
59
+ import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves, representedEtag, forgetRepresentedEtag } from '../core/etag.js';
59
60
  import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged } from '../lib/dirty-gate.js';
60
61
  import { gestureSeen } from '../lib/user-gesture.js';
61
62
  import { SyncStream } from './stream.js';
62
63
  import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
63
- import { trackConflictFootprints } from './conflict-footprints.js';
64
+ import { beginLineageCapture } from './conflict-footprints.js';
64
65
 
65
66
  // What a live-sync merge never reads or touches on any side: editor chrome,
66
67
  // content kept out of the save or the snapshot, frozen regions, and nodes
@@ -122,6 +123,23 @@ const uniqueAuthoredIdentity = (memo) => (el) => {
122
123
 
123
124
  const isIdMap = (m) => !!m && typeof m === 'object' && !Array.isArray(m);
124
125
 
126
+ /**
127
+ * The version a fetched served document names on its own root, or null.
128
+ *
129
+ * The host injects this attribute into the response it builds from one read of
130
+ * the file, so a served-document GET answers which version the body it just
131
+ * returned actually is. Parsed detached: this reads one attribute and never
132
+ * touches the live page, and a body that cannot be parsed simply offers no stamp.
133
+ *
134
+ * @param {string} html
135
+ * @returns {?string}
136
+ */
137
+ function readServedStamp(html) {
138
+ if (typeof html !== 'string' || html === '') return null;
139
+ const parsed = new DOMParser().parseFromString(html, 'text/html');
140
+ return parsed.documentElement?.getAttribute('documentetag') || null;
141
+ }
142
+
125
143
  // The page just took a frame verified clean against its baseline, so it now IS
126
144
  // the file on disk. Both saved baselines move together from one capture: leaving
127
145
  // the dirty baseline behind would make the close warning fire on the frame's own
@@ -325,6 +343,16 @@ class LiveSync {
325
343
  this._holdRetryPeer = null;
326
344
  this._holdRetryExt = null;
327
345
 
346
+ // The startup check: the one fetch this class runs against the page it was
347
+ // itself served, started once by the transport's first cursor and never
348
+ // again. `_servedFetchId` numbers every served-document GET, so a response
349
+ // that arrives after a newer GET was issued can tell it is superseded — only
350
+ // the startup check acts on that, since a repair is re-issued rather than
351
+ // re-read. `_startupRetry` is the single pending re-ask, coalesced so two
352
+ // retries cannot pile up behind one another.
353
+ this._servedFetchId = 0;
354
+ this._startupRetry = null;
355
+
328
356
  // Whether each lane is currently holding. A hold is a safe outcome but a
329
357
  // silent one: without an event the page simply stops updating and nothing
330
358
  // can say why. These make the transitions observable in both directions.
@@ -587,6 +615,12 @@ class LiveSync {
587
615
  clearTimeout(this._holdRetryExt);
588
616
  this._holdRetryPeer = null;
589
617
  this._holdRetryExt = null;
618
+
619
+ // A stopped run's startup check must not act on what it finds: the next
620
+ // fetch, if any, owns the question.
621
+ clearTimeout(this._startupRetry);
622
+ this._startupRetry = null;
623
+ this._servedFetchId++;
590
624
  }
591
625
 
592
626
  /**
@@ -718,6 +752,10 @@ class LiveSync {
718
752
  shared: this._sharedSync,
719
753
  documentURL: window.location.href,
720
754
  lane: this.lane,
755
+ // Only a response that named its own version gives this tab anything to
756
+ // compare against. On a host that served none there is no provenance to
757
+ // check, and the page keeps its pre-1.9.0 startup behavior.
758
+ startupCheck: Boolean(servedDocumentEtag),
721
759
  });
722
760
 
723
761
  this.sse.onopen = () => {
@@ -740,8 +778,22 @@ class LiveSync {
740
778
  } catch {
741
779
  return;
742
780
  }
743
- if (!data || data.resync !== true) return;
744
- console.log('[LiveSync] Server could not replay everything; refetching the document');
781
+ // Two different reasons for one refetch. `resync` is the server saying it
782
+ // could not replay everything between where this client resumed and the
783
+ // baseline it is sending. `startup` is this tab's own check of the page
784
+ // it was served: the file may have changed between that response and this
785
+ // first subscription, and a stamped response is the only thing that can
786
+ // tell. One fetch covers both: a body that names its own version is
787
+ // compared whatever asked for it, and the flag only decides what an
788
+ // unstamped answer may do.
789
+ if (!data || (data.resync !== true && data.startup !== true)) return;
790
+ const repair = data.resync === true;
791
+ const startup = data.startup === true && !repair;
792
+ console.log(
793
+ repair
794
+ ? '[LiveSync] Server could not replay everything; refetching the document'
795
+ : '[LiveSync] Checking the served document against the version this tab was served'
796
+ );
745
797
  // _fetchServedDocument, deliberately, and not _fetchExternalChange: that
746
798
  // one drops a fetch whose seq is at or below the external watermark, and
747
799
  // the cursor baseline routinely is, since it is the server's high-water
@@ -749,7 +801,8 @@ class LiveSync {
749
801
  // resync that skipped itself for being "already seen" would leave the page
750
802
  // permanently stale, which is the exact failure the flag exists to report.
751
803
  this._fetchServedDocument(typeof data.seq === 'number' ? data.seq : undefined, {
752
- repair: true,
804
+ repair,
805
+ startup,
753
806
  });
754
807
  });
755
808
 
@@ -1100,52 +1153,183 @@ class LiveSync {
1100
1153
  * check of its own — callers own that — so it can also re-materialize a
1101
1154
  * frame the epoch check refused (the fetched body is whatever disk holds
1102
1155
  * NOW, which is always safe to apply).
1156
+ *
1157
+ * `startup` marks the one fetch that asks whether the page this tab was served
1158
+ * is still what disk holds. It differs from every other reason to fetch in that
1159
+ * an unchanged answer must do NOTHING: no queue, no apply, no baseline move, no
1160
+ * event, no stamp change. A no-op is not a missed frame here, it is the answer.
1161
+ * A body that names its own version is judged the same way, and a repair that
1162
+ * carries one is no exception.
1163
+ *
1164
+ * @param {number} [seq]
1165
+ * @param {Object} [options]
1166
+ * @param {number} [options.attempt] - Bounded retry count, carried through
1167
+ * every re-ask so a page under constant churn stops asking.
1168
+ * @param {boolean} [options.repair] - The server could not replay everything.
1169
+ * @param {boolean} [options.startup] - The served page's version check.
1103
1170
  */
1104
- _fetchServedDocument(seq, { attempt = 0, repair = false } = {}) {
1171
+ _fetchServedDocument(seq, { attempt = 0, repair = false, startup = false } = {}) {
1172
+ // Content already on its way in is about to change what this check would be
1173
+ // comparing, and its version question is newer than this one. Let it land and
1174
+ // ask again; a check that judged the pre-frame DOM would answer about a page
1175
+ // that no longer exists.
1176
+ if (
1177
+ startup &&
1178
+ (this._morphInFlight || this._pendingHtml != null || this._pendingExternal != null)
1179
+ ) {
1180
+ this._retryStartup(attempt);
1181
+ return;
1182
+ }
1105
1183
  const epoch = this._saveEpoch;
1184
+ const startGen = this._startGen;
1185
+ const seenSeq = this.lastSeenSeq;
1186
+ const applyGen = this._applyGen;
1187
+ const fetchId = ++this._servedFetchId;
1106
1188
  if (repair && typeof seq !== 'number') seq = this._lastExternalSeq;
1107
1189
  fetch(new URL(window.location.href), { cache: 'no-store' })
1108
1190
  .then((response) => (response.ok ? response.text() : null))
1109
1191
  .then((html) => {
1110
1192
  if (this.isDestroyed || html == null) return;
1111
- if (typeof seq === 'number' && seq < this._lastExternalSeq) {
1193
+ if (this._startGen !== startGen) return;
1194
+ const stamp = readServedStamp(html);
1195
+ // A version question is asked by the startup check and by a repair whose
1196
+ // body names its own version. Every other fetch — including an ordinary
1197
+ // fallback whose body happens to carry a stamp — is content, and keeps
1198
+ // the hold/retry behavior the external lane has always had.
1199
+ const versionCheck = startup || (repair && Boolean(stamp));
1200
+ if (versionCheck) {
1201
+ // A newer GET owns this question: whatever it is reading describes a
1202
+ // later moment than this response does. Except for a repair, which is
1203
+ // the only answer a page the server could not replay has: the newer
1204
+ // GET may yet fail, and dropping this body would leave nothing to ask
1205
+ // again. Refetch it at the watermark the page has reached instead,
1206
+ // under the same bounded count the retry always had.
1207
+ if (fetchId !== this._servedFetchId) {
1208
+ if (repair) {
1209
+ this._retryStartup(
1210
+ attempt + 1,
1211
+ { repair, startup },
1212
+ typeof seq === 'number' ? Math.max(seq, this._lastExternalSeq) : seq
1213
+ );
1214
+ }
1215
+ return;
1216
+ }
1217
+ // The page moved while the GET was in flight — an own save, a frame,
1218
+ // or a morph. The comparison this check exists to make is against a
1219
+ // stationary page, so ask again rather than judge a version the tab has
1220
+ // already left. A failed startup GET is the one exception below: it
1221
+ // changes nothing and has nothing to retry toward.
1222
+ if (
1223
+ epoch !== this._saveEpoch ||
1224
+ seenSeq !== this.lastSeenSeq ||
1225
+ applyGen !== this._applyGen ||
1226
+ this._morphInFlight ||
1227
+ this._pendingHtml != null ||
1228
+ this._pendingExternal != null
1229
+ ) {
1230
+ this._retryStartup(attempt + 1, { repair, startup }, seq);
1231
+ return;
1232
+ }
1233
+ if (!stamp) {
1234
+ // No stamp on the body means the host did not answer the version
1235
+ // question, and an answer nobody gave must not be invented: a
1236
+ // startup check keeps exactly the stamp it holds and applies
1237
+ // nothing, fetched or forgotten. A repair still has a page the
1238
+ // server could not replay, so it keeps its unstamped fallback.
1239
+ if (!repair) return;
1240
+ } else if (stamp === representedEtag()) {
1241
+ // The file this tab was served, and nothing to do about it.
1242
+ return;
1243
+ }
1244
+ } else if (typeof seq === 'number' && seq < this._lastExternalSeq) {
1112
1245
  // A newer external change superseded this one, and its own fetch will
1113
1246
  // queue a body. Except for a repair: that one exists because the server
1114
1247
  // said replay cannot fix this page, so if the newer fetch fails there is
1115
1248
  // nothing else coming. Refetch rather than drop the only repair.
1116
1249
  if (repair && attempt < 3) {
1117
- this._fetchServedDocument(this._lastExternalSeq, { attempt: attempt + 1, repair });
1250
+ this._fetchServedDocument(this._lastExternalSeq, { attempt: attempt + 1, repair, startup });
1118
1251
  }
1119
1252
  return;
1120
- }
1121
- if (this._saveEpoch > epoch) {
1253
+ } else if (this._saveEpoch > epoch) {
1122
1254
  // An own save landed while the GET was in flight, so this body may
1123
1255
  // predate it. Save-response order proves nothing about disk-write
1124
1256
  // order — refetch for the newest bytes instead of dropping.
1125
1257
  if (attempt < 3) {
1126
1258
  console.log('[LiveSync] Refetching external change: own save landed mid-fetch');
1127
- this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
1259
+ this._fetchServedDocument(seq, { attempt: attempt + 1, repair, startup });
1128
1260
  }
1129
1261
  return;
1130
1262
  }
1131
- // No stamp: this body came from a GET of the served page, which nobody
1132
- // stamped, so the apply leaves the held stamp alone and etag.js asks the
1133
- // host for a replacement. No author either, for the same reason — a GET
1134
- // answers what disk holds, not who put it there.
1135
- this._pendingExternal = { html, seq, saveEpoch: epoch, etag: null, by: null };
1263
+ // The stamp, when the body carries one, is the version of THESE bytes:
1264
+ // the same claim the navigation made about its own response, read from
1265
+ // the body rather than from discovery, which answers about a later
1266
+ // moment than any response. A body without one keeps the old fallback
1267
+ // — the apply leaves the stamp alone and etag.js asks the host. No
1268
+ // author either way, for the same reason: a GET answers what disk holds,
1269
+ // not who put it there.
1270
+ this._pendingExternal = {
1271
+ html,
1272
+ seq,
1273
+ saveEpoch: epoch,
1274
+ etag: stamp,
1275
+ by: null,
1276
+ // Why this body was fetched, kept beside it so the drain and the apply
1277
+ // can still tell a version check from an ordinary change. A body that
1278
+ // named its own version was judged against this tab's version, and the
1279
+ // drain must go on judging it that way. The epoch refetch below
1280
+ // re-issues a fetch whose caller may have been a repair.
1281
+ fetchOptions: { repair, startup, attempt, versionCheck },
1282
+ // What the check was made against, so the drain can tell whether the
1283
+ // answer it holds still describes the page that asked.
1284
+ startGen,
1285
+ seenSeq,
1286
+ applyGen,
1287
+ fetchId,
1288
+ };
1136
1289
  this._scheduleNextFrame();
1137
1290
  })
1138
1291
  .catch((err) => {
1139
1292
  this._log('External-change fetch failed', err);
1140
1293
  // The watermark already advanced for this seq; leaving it there
1141
1294
  // would drop the change forever. Roll back so a replay or a later
1142
- // duplicate can redeliver it.
1295
+ // duplicate can redeliver it. A startup check has no seq to roll back
1296
+ // and no state to restore: it simply did not happen.
1143
1297
  if (typeof seq === 'number' && this._lastExternalSeq === seq) {
1144
1298
  this._lastExternalSeq = seq - 1;
1145
1299
  }
1146
1300
  });
1147
1301
  }
1148
1302
 
1303
+ /**
1304
+ * Ask the served-document question again, coalesced and bounded.
1305
+ *
1306
+ * A version check that finds the page mid-morph, mid-queue or mid-change cannot
1307
+ * answer, and the answer is the whole point of the fetch, so it re-asks. The
1308
+ * single timer means three arrivals in one frame collapse to one re-ask, and
1309
+ * `attempt` counts only the re-asks that got as far as a GET, so a page that
1310
+ * never settles stops asking rather than looping forever.
1311
+ *
1312
+ * The re-ask carries the options and seq of the fetch that could not answer,
1313
+ * so a re-asked repair is still a repair and still owns its cursor seq.
1314
+ *
1315
+ * @param {number} [attempt]
1316
+ * @param {Object} [options] - The original fetch options.
1317
+ * @param {number} [seq]
1318
+ */
1319
+ _retryStartup(attempt = 0, options = { startup: true }, seq) {
1320
+ if (this.isDestroyed || attempt > 3) return;
1321
+ clearTimeout(this._startupRetry);
1322
+ this._startupRetry = setTimeout(() => {
1323
+ this._startupRetry = null;
1324
+ if (this.isDestroyed) return;
1325
+ if (this._morphInFlight || this._pendingHtml != null || this._pendingExternal != null) {
1326
+ this._retryStartup(attempt, options, seq);
1327
+ return;
1328
+ }
1329
+ this._fetchServedDocument(seq, { ...options, attempt });
1330
+ }, 16);
1331
+ }
1332
+
1149
1333
  /**
1150
1334
  * Apply an update received from the server. Morphs the entire document.
1151
1335
  *
@@ -1200,9 +1384,21 @@ class LiveSync {
1200
1384
  if (this.isDestroyed) return;
1201
1385
 
1202
1386
  const ext = this._pendingExternal;
1387
+ const opts = ext?.fetchOptions || null;
1388
+ // A startup check and a stamped repair are both version checks: each one
1389
+ // judges its body against the version this tab represents, and each one
1390
+ // answers "nothing to do" when the two agree. An ordinary fetch, and an
1391
+ // unstamped repair, keep the pre-existing content behavior.
1392
+ const versionChecked = Boolean(opts?.startup || opts?.versionCheck);
1203
1393
  let runExternal = false;
1204
1394
  if (ext != null) {
1205
- if (this._pendingHtml == null) {
1395
+ if (versionChecked && this._pendingHtml != null) {
1396
+ // A version check and a peer frame have no order to compare: the peer
1397
+ // slot's seq is a subscription cursor, not the version of the bytes it
1398
+ // carries, and the answer is only worth anything once the frame it
1399
+ // would be judging has landed. Drain the peer first and ask again.
1400
+ runExternal = false;
1401
+ } else if (this._pendingHtml == null) {
1206
1402
  runExternal = true;
1207
1403
  } else {
1208
1404
  runExternal = !(
@@ -1216,20 +1412,59 @@ class LiveSync {
1216
1412
  if (runExternal) {
1217
1413
  this._pendingExternal = null;
1218
1414
  // Stale-at-drain checks: a newer external change already superseded
1219
- // this frame, or our own save landed after it was queued.
1415
+ // this frame, our own save landed after it was queued, or the page moved
1416
+ // past what a version check was made against.
1220
1417
  if (typeof ext.seq === 'number' && ext.seq < this._lastExternalSeq) {
1221
- this._log('Dropping superseded external change at drain');
1222
- } else if (ext.saveEpoch !== this._saveEpoch) {
1418
+ if (versionChecked && opts?.repair) {
1419
+ // The repair is the only answer a page the server could not replay
1420
+ // has, and the newer change's own fetch may yet fail. Ask again once
1421
+ // that content has drained instead of dropping it here — at the
1422
+ // current watermark, since the seq this body carried is already
1423
+ // superseded and a re-ask that kept it would only be dropped again.
1424
+ this._retryStartup((opts.attempt || 0) + 1, opts, this._lastExternalSeq);
1425
+ } else {
1426
+ this._log('Dropping superseded external change at drain');
1427
+ }
1428
+ } else if (versionChecked && ext.startGen !== this._startGen) {
1429
+ // The run that asked is gone: this body answers nobody.
1430
+ this._log('Dropping superseded startup check at drain');
1431
+ } else if (versionChecked && ext.fetchId !== this._servedFetchId) {
1432
+ // A newer GET is already re-reading the document, so this body is
1433
+ // superseded — unless it is a repair, the one answer a page the server
1434
+ // could not replay has. The newer GET may yet fail, so ask again at the
1435
+ // watermark the page has reached rather than discard it; the bounded
1436
+ // count stops a page under constant churn.
1437
+ if (opts?.repair) {
1438
+ this._retryStartup((opts.attempt || 0) + 1, opts, this._lastExternalSeq);
1439
+ } else {
1440
+ this._log('Dropping superseded startup check at drain');
1441
+ }
1442
+ } else if (
1443
+ versionChecked &&
1444
+ (ext.saveEpoch !== this._saveEpoch ||
1445
+ ext.seenSeq !== this.lastSeenSeq ||
1446
+ ext.applyGen !== this._applyGen)
1447
+ ) {
1448
+ // The page changed after this check was made. Comparing the version it
1449
+ // holds against a page that has since moved would answer about neither,
1450
+ // so ask again rather than apply a body that may already be obsolete.
1451
+ this._retryStartup((opts.attempt || 0) + 1, opts, ext.seq);
1452
+ } else if (versionChecked && ext.etag != null && ext.etag === representedEtag()) {
1453
+ // Checked once more at the moment of truth, because a frame that applied
1454
+ // while this waited can have brought the very version disk holds.
1455
+ this._log('Startup check found the same version: nothing to apply');
1456
+ } else if (!versionChecked && ext.saveEpoch !== this._saveEpoch) {
1223
1457
  // The epoch moved, but save-response order does not prove disk-write
1224
1458
  // order: the frame's content may still be newer than our save.
1225
1459
  // Refetch the served document — applying what disk holds NOW is
1226
- // always safe — instead of dropping the frame.
1460
+ // always safe — instead of dropping the frame. Mode rides along: a
1461
+ // repair that comes back this way is still a repair.
1227
1462
  console.log('[LiveSync] Refetching external change: own save landed after queue');
1228
- this._fetchServedDocument(ext.seq);
1463
+ this._fetchServedDocument(ext.seq, opts || {});
1229
1464
  } else {
1230
1465
  this._morphInFlight = true;
1231
1466
  try {
1232
- await this._doApplyExternal(ext.html, ext.seq, ext.etag, ext.by);
1467
+ await this._doApplyExternal(ext.html, ext.seq, ext.etag, ext.by, opts);
1233
1468
  } catch (err) {
1234
1469
  console.error('[LiveSync] applyExternal failed:', err);
1235
1470
  } finally {
@@ -1340,7 +1575,19 @@ class LiveSync {
1340
1575
  * @param {object} [lane.extra] - extra mergeDocument options (the disk lane's beforeApply)
1341
1576
  */
1342
1577
  async _mergeIncoming(html, identityMap, { base, baseIdentityMap = null, captureLocal, synthetic, source = 'peer', seq = null, etag = null, extra = {} }) {
1343
- const mergeDocument = (options) => trackConflictFootprints(() => HyperMorph.mergeDocument(options));
1578
+ let lineageCapture;
1579
+ const mergeDocument = (options) => {
1580
+ lineageCapture = beginLineageCapture(conflicts.list());
1581
+ try {
1582
+ const pending = HyperMorph.mergeDocument({ ...options, lineage: lineageCapture.lineage });
1583
+ lineageCapture.returned();
1584
+ return pending;
1585
+ } catch (error) {
1586
+ lineageCapture.invalidate(conflicts.list());
1587
+ lineageCapture.finish(conflicts.list());
1588
+ throw error;
1589
+ }
1590
+ };
1344
1591
  const store = this.identity;
1345
1592
  // A frame may neither write these onto our root nor, by not carrying them,
1346
1593
  // take ours away. Returning false is hyper-morph's veto for both directions.
@@ -1459,7 +1706,12 @@ class LiveSync {
1459
1706
  try {
1460
1707
  report = await pending;
1461
1708
  } catch (err) {
1462
- if (applyId) failApply(applyId, err, { ticket });
1709
+ lineageCapture.invalidate(conflicts.list());
1710
+ try {
1711
+ if (applyId) failApply(applyId, err, { ticket });
1712
+ } finally {
1713
+ lineageCapture.finish(conflicts.list());
1714
+ }
1463
1715
  throw err;
1464
1716
  }
1465
1717
  const typedDuringWait = gateCaptureToken().gen !== token.gen;
@@ -1467,7 +1719,13 @@ class LiveSync {
1467
1719
  // What the frame won over this tab's unsaved edits goes to the ledger with
1468
1720
  // the clone the merge read as "mine". Earlier losses live in the ledger,
1469
1721
  // not in the page's dirty state.
1470
- const conflictIds = applyId ? completeApply(applyId, report.conflicts, { ticket }) : [];
1722
+ let conflictIds;
1723
+ try {
1724
+ lineageCapture.prepareComplete(report, conflicts.list());
1725
+ conflictIds = applyId ? completeApply(applyId, report.conflicts, { ticket }) : [];
1726
+ } finally {
1727
+ lineageCapture.finish(conflicts.list());
1728
+ }
1471
1729
  if (dirty && !report.localDiverged) {
1472
1730
  gateClearIfUnchanged(token);
1473
1731
  }
@@ -1600,9 +1858,16 @@ class LiveSync {
1600
1858
  // above, and deliberately the same moment: the two claims a page makes
1601
1859
  // when it adopts a stamp are "the host stores this version" and "I hold
1602
1860
  // it", and only the second one is this tab's to make.
1603
- if (!(this._acceptedSaveTicket > ticket) && typeof etag === 'string' && etag) {
1604
- recordEtag(etag);
1605
- stamped = true;
1861
+ if (!(this._acceptedSaveTicket > ticket)) {
1862
+ if (typeof etag === 'string' && etag) {
1863
+ recordEtag(etag);
1864
+ stamped = true;
1865
+ } else {
1866
+ // Content nobody stamped: the DOM no longer holds the version this
1867
+ // tab was claiming, so the claim goes. A save accepted since this
1868
+ // frame captured keeps its own newer stamp.
1869
+ forgetRepresentedEtag();
1870
+ }
1606
1871
  }
1607
1872
 
1608
1873
  // Cross-lane baseline: the DOM now holds this frame's content, but the
@@ -1693,7 +1958,7 @@ class LiveSync {
1693
1958
  * holds or a later dirty peer apply misclassifies this frame's content as
1694
1959
  * local edits.
1695
1960
  */
1696
- async _doApplyExternal(html, seq, etag = null, by = null) {
1961
+ async _doApplyExternal(html, seq, etag = null, by = null, fetchOptions = null) {
1697
1962
  this._log(`applyExternal - external disk change (seq=${seq})`);
1698
1963
  this.isPaused = true;
1699
1964
 
@@ -1721,6 +1986,26 @@ class LiveSync {
1721
1986
  if (this.lane === 'live' && pageMaybeDirty() && this._diskBase === null) {
1722
1987
  console.log('[LiveSync] Holding external change: unsaved local edits and no baseline to merge against');
1723
1988
  this._setHeld('external', true, null);
1989
+ // A version check holds nothing: it has no frame to re-queue, and the
1990
+ // body it fetched describes disk as of its GET rather than a version
1991
+ // anyone named. Re-queueing those bytes would apply them over an edit
1992
+ // the person has not saved yet, and could even put a version back over
1993
+ // one a newer peer has already passed. So the hold takes the ordinary
1994
+ // three-second wait and then fetches afresh: whatever disk holds by then
1995
+ // is answered against the page as it stands by then. The hold is not a
1996
+ // retry attempt, so a person who stays dirty never exhausts the bounded
1997
+ // retries; the options ride along so a repair is still a repair.
1998
+ if (fetchOptions?.startup || fetchOptions?.versionCheck) {
1999
+ clearTimeout(this._holdRetryExt);
2000
+ this._holdRetryExt = setTimeout(() => {
2001
+ this._holdRetryExt = null;
2002
+ if (this.isDestroyed) return;
2003
+ const currentSeq =
2004
+ typeof seq === 'number' ? Math.max(seq, this._lastExternalSeq) : seq;
2005
+ this._retryStartup(0, fetchOptions, currentSeq);
2006
+ }, 3000);
2007
+ return;
2008
+ }
1724
2009
  const epochAtHold = this._saveEpoch;
1725
2010
  clearTimeout(this._holdRetryExt);
1726
2011
  this._holdRetryExt = setTimeout(() => {
@@ -1762,12 +2047,18 @@ class LiveSync {
1762
2047
  // bytes, and the convergence save at the bottom needs a stamp the host
1763
2048
  // will accept or the merge is refused and lost.
1764
2049
  //
1765
- // A frame with no stamp (an older host, or the content-less fetch fallback,
1766
- // which serves bytes nobody stamped) leaves this alone, and the listener in
1767
- // etag.js falls back to asking the host.
1768
- if (typeof etag === 'string' && etag) {
1769
- recordEtag(etag);
1770
- stamped = true;
2050
+ // A frame with no stamp (an older host, or the content-less fetch fallback
2051
+ // on a host that does not answer the version question) leaves this alone,
2052
+ // and the listener in etag.js falls back to asking the host. A save
2053
+ // accepted since this frame captured already recorded a newer stamp, and
2054
+ // this body, older than that save, must not rewind it — nor clear it.
2055
+ if (!(this._acceptedSaveTicket > ticket)) {
2056
+ if (typeof etag === 'string' && etag) {
2057
+ recordEtag(etag);
2058
+ stamped = true;
2059
+ } else {
2060
+ forgetRepresentedEtag();
2061
+ }
1771
2062
  }
1772
2063
 
1773
2064
  if (
@@ -3,7 +3,7 @@ const HIDDEN_DELAY = 5000;
3
3
  const PING_INTERVAL = 15000;
4
4
 
5
5
  export class SyncStream extends EventTarget {
6
- constructor(url, { shared = false, documentURL, lane } = {}) {
6
+ constructor(url, { shared = false, documentURL, lane, startupCheck = false } = {}) {
7
7
  super();
8
8
  this.url = url;
9
9
  this.readyState = 0;
@@ -16,7 +16,14 @@ export class SyncStream extends EventTarget {
16
16
  this._since = 0;
17
17
  this._closed = false;
18
18
  this._suspended = false;
19
+ // Two different questions, asked once each at the first subscription and
20
+ // never again: `_repair` is a real reconnect whose replay the host could not
21
+ // retain, and `_startup` is the caller's own check of a page that was served
22
+ // before this stream existed. Only the caller can know whether the response
23
+ // it was served named a version, so the startup check is opt-in and off by
24
+ // default: a stream nobody told about provenance must not manufacture one.
19
25
  this._repair = false;
26
+ this._startup = startupCheck;
20
27
  this._worker = null;
21
28
  this._source = null;
22
29
  this._visibility = () => {
@@ -77,8 +84,11 @@ export class SyncStream extends EventTarget {
77
84
  } else if (data.type === 'cursor') {
78
85
  clearTimeout(this._startTimer);
79
86
  if (Number.isSafeInteger(data.seq)) this._since = Math.max(this._since, data.seq);
80
- this._emit('cursor', JSON.stringify({ ...data, resync: data.resync === true || this._repair }));
87
+ const resync = data.resync === true || this._repair;
88
+ const startup = this._startup;
81
89
  this._repair = false;
90
+ this._startup = false;
91
+ this._emit('cursor', JSON.stringify({ ...data, resync, startup }));
82
92
  } else if (data.type === 'frame' && typeof data.data === 'string') {
83
93
  this._remember(data.data);
84
94
  this._emit('message', data.data);
@@ -111,9 +121,12 @@ export class SyncStream extends EventTarget {
111
121
  if (this._source !== source) return;
112
122
  this.readyState = 1;
113
123
  this._emit('open');
114
- if (this._repair) {
124
+ if (this._repair || this._startup) {
125
+ const resync = this._repair;
126
+ const startup = this._startup;
115
127
  this._repair = false;
116
- this._emit('cursor', JSON.stringify({ resync: true }));
128
+ this._startup = false;
129
+ this._emit('cursor', JSON.stringify({ resync, startup }));
117
130
  }
118
131
  };
119
132
  source.onerror = () => {