@mengine/sync 1.1.0 → 1.2.1-alpha.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 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 failure = (await Promise.allSettled(updates.filter(hasUpdateData).map((update) => {
572
- return this.server.pushDocUpdate({
573
- docId: docState.docId,
574
- data: update
575
- }, this.id);
576
- }))).find((result) => result.status === "rejected");
577
- if (failure != null) throw failure.reason;
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
- serverMissing = localDoc.export({
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: VersionVector.decode(version)
620
- });
621
- } else serverMissing = localDoc.export({ mode: "update" });
622
- if (serverMissing && hasUpdateData(serverMissing)) await this.server.pushDocUpdate({
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.1.0",
3
+ "version": "1.2.1-alpha.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.1.0",
26
- "@mengine/storage": "1.1.0"
25
+ "@mengine/storage": "1.2.1-alpha.0",
26
+ "@mengine/utils": "1.2.1-alpha.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/lodash-es": "^4.17.12",