@mengine/sync 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.
- package/dist/index.d.ts +37 -0
- package/dist/index.js +145 -14
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -106,6 +106,43 @@ declare class ClientServerSynchronizer implements Synchronizer {
|
|
|
106
106
|
private consumeJobs;
|
|
107
107
|
private updateStatus;
|
|
108
108
|
private waitConnectionReady;
|
|
109
|
+
/**
|
|
110
|
+
* Send everything the remote is missing as ONE update derived from the doc,
|
|
111
|
+
* rather than forwarding each queued update as its own request.
|
|
112
|
+
*
|
|
113
|
+
* A single `export({ mode: 'update', from })` carries every commit the remote
|
|
114
|
+
* lacks as one causally complete blob, so no second request exists to arrive out
|
|
115
|
+
* of order and there is no gap for a validating peer to reject —
|
|
116
|
+
* `mengine-server` answers 409 `missing_dependency` for such a gap and discards
|
|
117
|
+
* the bytes, since its reads (snapshot replay, log folding, version-vector
|
|
118
|
+
* diffs) all assume every stored update is applicable. `syncWithRemote` already
|
|
119
|
+
* worked this way for catch-up; the per-commit fan-out this replaces was N
|
|
120
|
+
* requests in which a later update could overtake the one it depended on.
|
|
121
|
+
*
|
|
122
|
+
* The batch's bytes are still imported first, because local storage is a peer
|
|
123
|
+
* in its own right: another writer (a second tab, a foreign process) can append
|
|
124
|
+
* an update this doc has never seen, and relaying local writes upstream is part
|
|
125
|
+
* of the contract. Import is idempotent, so ops the doc already holds cost
|
|
126
|
+
* nothing, and folding them in is what lets the export cover them.
|
|
127
|
+
*
|
|
128
|
+
* The watermark advances to what the remote reports it now holds, and only
|
|
129
|
+
* failing that to the version this blob actually carried — read BEFORE the
|
|
130
|
+
* export, so a commit landing during the await is not marked sent. A failure
|
|
131
|
+
* leaves it untouched, so the next push re-derives the same ops. Rethrowing is
|
|
132
|
+
* what makes that next push happen: `consumeJobs` marks the doc `retrying` with
|
|
133
|
+
* the message and the cycle retries. Swallowing it made a refusal or a dead
|
|
134
|
+
* network look exactly like a successful sync — the doc went `synced: true`
|
|
135
|
+
* having pushed nothing.
|
|
136
|
+
*
|
|
137
|
+
* A readonly server is the one case that returns without pushing rather than
|
|
138
|
+
* failing. It is not a transient error, so routing it through the retry path
|
|
139
|
+
* wedges the doc: every cycle would refuse, mark `retrying`, wait 5s and refuse
|
|
140
|
+
* again, forever, never reaching `synced`. Nothing is queued by that loop either —
|
|
141
|
+
* `consumeJobs` removes each job before awaiting it, so the retry only works
|
|
142
|
+
* because the next push re-derives the ops from the doc. Skipping keeps the
|
|
143
|
+
* watermark untouched, so if the peer ever becomes writable the very next export
|
|
144
|
+
* still carries everything it never received.
|
|
145
|
+
*/
|
|
109
146
|
private pushUpdatesToRemote;
|
|
110
147
|
private pullUpdatesFromRemote;
|
|
111
148
|
private saveDocUpdates;
|
package/dist/index.js
CHANGED
|
@@ -288,6 +288,37 @@ var SynchronizingDoc = class extends Disposable {
|
|
|
288
288
|
doc;
|
|
289
289
|
state;
|
|
290
290
|
jobs = [];
|
|
291
|
+
/**
|
|
292
|
+
* Encoded version vector the remote is known to hold, or null before anything
|
|
293
|
+
* has been confirmed.
|
|
294
|
+
*
|
|
295
|
+
* Strictly a LOWER BOUND, and the asymmetry is what makes it safe: too low only
|
|
296
|
+
* means the next push re-sends ops the remote already has, which a CRDT import
|
|
297
|
+
* dedups and a validating peer answers `duplicate`. Too high would mean ops are
|
|
298
|
+
* never sent at all, so it may only advance to a version some request actually
|
|
299
|
+
* carried — never to the doc's current version at an arbitrary moment.
|
|
300
|
+
*
|
|
301
|
+
* Two kinds of evidence may set it. A complete statement by the remote about
|
|
302
|
+
* itself — `getDocDiff`'s version, or a push receipt's — REPLACES it, including
|
|
303
|
+
* downward: a remote that lost an op it once acked says so by reporting less,
|
|
304
|
+
* and adopting that lower version is what makes the next push re-send it. Absent
|
|
305
|
+
* such a statement, a push that resolved raises it to the version that request
|
|
306
|
+
* carried, the only bound provable without the remote's cooperation. A relayed
|
|
307
|
+
* update is neither — a broadcast is not proof of a durable write, and crediting
|
|
308
|
+
* one drops that op and every local op behind it while the push still reports
|
|
309
|
+
* success.
|
|
310
|
+
*
|
|
311
|
+
* A statement is believed about OTHER peers and never about our own. It may LEAD
|
|
312
|
+
* this doc on peers we have not seen, and adopting that is the point — those
|
|
313
|
+
* concurrent ops are then never re-sent. On our OWN peer the same lead is silent
|
|
314
|
+
* loss, because we author that history: a claim beyond what we actually sent makes
|
|
315
|
+
* `export({ from })` skip exactly the ops it names while the push still reports
|
|
316
|
+
* success, so the entry is clamped to what a request provably carried. Against a
|
|
317
|
+
* peer that reports nothing the bound instead lags on other peers' ops between
|
|
318
|
+
* syncs, so a push re-sends ops the remote already has — a duplicate row in its
|
|
319
|
+
* update log until the next sync corrects it, the deliberate side of the asymmetry.
|
|
320
|
+
*/
|
|
321
|
+
remoteVersion = null;
|
|
291
322
|
eventBus = new EventBus();
|
|
292
323
|
disposables = new DisposableSet();
|
|
293
324
|
constructor(docId, doc) {
|
|
@@ -330,6 +361,54 @@ const SYNCHRONIZER_ORIGIN_PREFIX = "ClientServerSynchronizer:";
|
|
|
330
361
|
const isSynchronizerOrigin = (origin) => {
|
|
331
362
|
return typeof origin === "string" && origin.startsWith(SYNCHRONIZER_ORIGIN_PREFIX);
|
|
332
363
|
};
|
|
364
|
+
/**
|
|
365
|
+
* Turn a remote's claimed version into one it is safe to diff against, or null
|
|
366
|
+
* when the bytes are unusable.
|
|
367
|
+
*
|
|
368
|
+
* A remote's report is trusted about OTHER peers — that is the whole value of it,
|
|
369
|
+
* and where a lead legitimately covers ops we have never seen. It is not trusted
|
|
370
|
+
* about `doc`'s own peer, because there the same lead is silent data loss:
|
|
371
|
+
* `export({ from })` skips every local op at or below the counter in `from`, so a
|
|
372
|
+
* version claiming more of our peer than we actually sent makes those ops
|
|
373
|
+
* unsendable while the push still reports success. Measured: exporting from a VV
|
|
374
|
+
* leading on the doc's own peer yields a 22-byte envelope carrying nothing, while
|
|
375
|
+
* the same lead on a foreign peer still carries the local op.
|
|
376
|
+
*
|
|
377
|
+
* `carried`, not the doc's current version, is the ceiling. The doc may have
|
|
378
|
+
* committed more while the push was in flight, and those ops were never sent, so
|
|
379
|
+
* clamping to the doc would strand exactly them.
|
|
380
|
+
*
|
|
381
|
+
* Only the own-peer lead is checked, because it is the only direction that loses
|
|
382
|
+
* data. `VersionVector.decode` is lenient — `[1, 2, 3]` yields a peer with counter
|
|
383
|
+
* `-2` rather than throwing — but such garbage exports MORE, not less: a version
|
|
384
|
+
* that undershoots or names peers we do not have re-sends ops the remote already
|
|
385
|
+
* holds, which its import dedups. Rejecting it would buy nothing, so it is left
|
|
386
|
+
* alone; null is returned only when the bytes will not decode at all.
|
|
387
|
+
*/
|
|
388
|
+
const usableRemoteVersion = (reported, doc, carried) => {
|
|
389
|
+
let version;
|
|
390
|
+
try {
|
|
391
|
+
version = VersionVector.decode(reported);
|
|
392
|
+
} catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
const ownPeer = doc.peerIdStr;
|
|
396
|
+
const provenOwnCounter = carried.get(ownPeer);
|
|
397
|
+
const claimedOwnCounter = version.get(ownPeer);
|
|
398
|
+
if (claimedOwnCounter != null && (provenOwnCounter == null || claimedOwnCounter > provenOwnCounter)) if (provenOwnCounter == null) version.remove(ownPeer);
|
|
399
|
+
else version.setEnd({
|
|
400
|
+
peer: ownPeer,
|
|
401
|
+
counter: provenOwnCounter
|
|
402
|
+
});
|
|
403
|
+
return version;
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* The watermark to keep after a push resolved: the remote's own vetted report when
|
|
407
|
+
* it gave a usable one, and otherwise the version this request provably carried.
|
|
408
|
+
*/
|
|
409
|
+
const confirmedRemoteVersion = (reported, doc, carried) => {
|
|
410
|
+
return ((reported == null ? null : usableRemoteVersion(reported, doc, carried)) ?? carried).encode();
|
|
411
|
+
};
|
|
333
412
|
var ClientServerSynchronizer = class {
|
|
334
413
|
local;
|
|
335
414
|
server;
|
|
@@ -566,15 +645,59 @@ var ClientServerSynchronizer = class {
|
|
|
566
645
|
await Task.all([localConnected, serverConnected]).timeout(30 * 1e3).abortOn(signal);
|
|
567
646
|
});
|
|
568
647
|
}
|
|
648
|
+
/**
|
|
649
|
+
* Send everything the remote is missing as ONE update derived from the doc,
|
|
650
|
+
* rather than forwarding each queued update as its own request.
|
|
651
|
+
*
|
|
652
|
+
* A single `export({ mode: 'update', from })` carries every commit the remote
|
|
653
|
+
* lacks as one causally complete blob, so no second request exists to arrive out
|
|
654
|
+
* of order and there is no gap for a validating peer to reject —
|
|
655
|
+
* `mengine-server` answers 409 `missing_dependency` for such a gap and discards
|
|
656
|
+
* the bytes, since its reads (snapshot replay, log folding, version-vector
|
|
657
|
+
* diffs) all assume every stored update is applicable. `syncWithRemote` already
|
|
658
|
+
* worked this way for catch-up; the per-commit fan-out this replaces was N
|
|
659
|
+
* requests in which a later update could overtake the one it depended on.
|
|
660
|
+
*
|
|
661
|
+
* The batch's bytes are still imported first, because local storage is a peer
|
|
662
|
+
* in its own right: another writer (a second tab, a foreign process) can append
|
|
663
|
+
* an update this doc has never seen, and relaying local writes upstream is part
|
|
664
|
+
* of the contract. Import is idempotent, so ops the doc already holds cost
|
|
665
|
+
* nothing, and folding them in is what lets the export cover them.
|
|
666
|
+
*
|
|
667
|
+
* The watermark advances to what the remote reports it now holds, and only
|
|
668
|
+
* failing that to the version this blob actually carried — read BEFORE the
|
|
669
|
+
* export, so a commit landing during the await is not marked sent. A failure
|
|
670
|
+
* leaves it untouched, so the next push re-derives the same ops. Rethrowing is
|
|
671
|
+
* what makes that next push happen: `consumeJobs` marks the doc `retrying` with
|
|
672
|
+
* the message and the cycle retries. Swallowing it made a refusal or a dead
|
|
673
|
+
* network look exactly like a successful sync — the doc went `synced: true`
|
|
674
|
+
* having pushed nothing.
|
|
675
|
+
*
|
|
676
|
+
* A readonly server is the one case that returns without pushing rather than
|
|
677
|
+
* failing. It is not a transient error, so routing it through the retry path
|
|
678
|
+
* wedges the doc: every cycle would refuse, mark `retrying`, wait 5s and refuse
|
|
679
|
+
* again, forever, never reaching `synced`. Nothing is queued by that loop either —
|
|
680
|
+
* `consumeJobs` removes each job before awaiting it, so the retry only works
|
|
681
|
+
* because the next push re-derives the ops from the doc. Skipping keeps the
|
|
682
|
+
* watermark untouched, so if the peer ever becomes writable the very next export
|
|
683
|
+
* still carries everything it never received.
|
|
684
|
+
*/
|
|
569
685
|
async pushUpdatesToRemote(docState, updates, signal) {
|
|
570
686
|
return runWithCheckpoint(signal, async () => {
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
687
|
+
for (const update of updates.filter(hasUpdateData)) docState.doc.import(update);
|
|
688
|
+
if (this.server.isReadonly) return;
|
|
689
|
+
const from = docState.remoteVersion;
|
|
690
|
+
if (from == null) return;
|
|
691
|
+
const carried = docState.doc.oplogVersion();
|
|
692
|
+
const missing = docState.doc.export({
|
|
693
|
+
mode: "update",
|
|
694
|
+
from: VersionVector.decode(from)
|
|
695
|
+
});
|
|
696
|
+
if (!hasUpdateData(missing)) return;
|
|
697
|
+
docState.remoteVersion = confirmedRemoteVersion((await this.server.pushDocUpdate({
|
|
698
|
+
docId: docState.docId,
|
|
699
|
+
data: missing
|
|
700
|
+
}, this.id))?.version, docState.doc, carried);
|
|
578
701
|
});
|
|
579
702
|
}
|
|
580
703
|
async pullUpdatesFromRemote(docState, signal) {
|
|
@@ -582,7 +705,8 @@ var ClientServerSynchronizer = class {
|
|
|
582
705
|
const doc = docState.doc;
|
|
583
706
|
const serverDoc = await this.server.getDocDiff(docState.docId, doc.version().encode());
|
|
584
707
|
if (!serverDoc) return;
|
|
585
|
-
const { missing } = serverDoc;
|
|
708
|
+
const { missing, version } = serverDoc;
|
|
709
|
+
docState.remoteVersion = usableRemoteVersion(version, doc, doc.oplogVersion())?.encode() ?? docState.remoteVersion;
|
|
586
710
|
if (hasUpdateData(missing)) {
|
|
587
711
|
doc.import(missing);
|
|
588
712
|
await this.local.pushDocUpdate({
|
|
@@ -605,6 +729,7 @@ var ClientServerSynchronizer = class {
|
|
|
605
729
|
const localDoc = docState.doc;
|
|
606
730
|
const response = await this.server.getDocDiff(docState.docId, localDoc.version().encode());
|
|
607
731
|
let serverMissing = null;
|
|
732
|
+
let carried = null;
|
|
608
733
|
if (response) {
|
|
609
734
|
const { missing, version } = response;
|
|
610
735
|
if (hasUpdateData(missing)) {
|
|
@@ -614,15 +739,21 @@ var ClientServerSynchronizer = class {
|
|
|
614
739
|
data: missing
|
|
615
740
|
}, this.id);
|
|
616
741
|
}
|
|
617
|
-
|
|
742
|
+
carried = localDoc.oplogVersion();
|
|
743
|
+
const vetted = usableRemoteVersion(version, localDoc, carried);
|
|
744
|
+
docState.remoteVersion = vetted?.encode() ?? docState.remoteVersion;
|
|
745
|
+
serverMissing = vetted ? localDoc.export({
|
|
618
746
|
mode: "update",
|
|
619
|
-
from:
|
|
620
|
-
});
|
|
621
|
-
} else
|
|
622
|
-
|
|
747
|
+
from: vetted
|
|
748
|
+
}) : localDoc.export({ mode: "update" });
|
|
749
|
+
} else {
|
|
750
|
+
carried = localDoc.oplogVersion();
|
|
751
|
+
serverMissing = localDoc.export({ mode: "update" });
|
|
752
|
+
}
|
|
753
|
+
if (serverMissing && hasUpdateData(serverMissing) && !this.server.isReadonly) docState.remoteVersion = confirmedRemoteVersion((await this.server.pushDocUpdate({
|
|
623
754
|
docId: docState.docId,
|
|
624
755
|
data: serverMissing
|
|
625
|
-
}, this.id);
|
|
756
|
+
}, this.id))?.version, localDoc, carried);
|
|
626
757
|
});
|
|
627
758
|
}
|
|
628
759
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mengine/sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"lodash-es": "^4.18.1",
|
|
24
24
|
"nanoid": "^5.1.11",
|
|
25
|
-
"@mengine/utils": "1.
|
|
26
|
-
"@mengine/storage": "1.
|
|
25
|
+
"@mengine/utils": "1.2.0",
|
|
26
|
+
"@mengine/storage": "1.2.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/lodash-es": "^4.17.12",
|