@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.
- package/dist/clay.standalone.js +6278 -5396
- package/package.json +2 -1
- package/src/core/etag.js +67 -3
- package/src/core/host-attrs.js +14 -0
- package/src/core/save.js +13 -5
- package/src/lib/root-attrs.js +14 -0
- package/src/plugins/ai-edit.js +307 -89
- package/src/sync/conflict-footprints.js +272 -35
- package/src/sync/conflict-revert.js +18 -8
- package/src/sync/conflicts.js +2 -0
- package/src/sync/live-sync.js +328 -37
- package/src/sync/stream.js +17 -4
- package/src/vendor/hyper-morph.vendor.js +4 -4
package/src/sync/live-sync.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
744
|
-
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1604
|
-
|
|
1605
|
-
|
|
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
|
-
//
|
|
1767
|
-
// etag.js falls back to asking the host.
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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 (
|
package/src/sync/stream.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
128
|
+
this._startup = false;
|
|
129
|
+
this._emit('cursor', JSON.stringify({ resync, startup }));
|
|
117
130
|
}
|
|
118
131
|
};
|
|
119
132
|
source.onerror = () => {
|