@korajs/sync 1.0.0-beta.4 → 1.0.0-beta.6
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.cjs +347 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +91 -2
- package/dist/index.d.ts +91 -2
- package/dist/index.js +347 -44
- package/dist/index.js.map +1 -1
- package/dist/internal.d.cts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/{transport-BGr-_eH8.d.cts → transport-ByWfWBbB.d.cts} +59 -0
- package/dist/{transport-BGr-_eH8.d.ts → transport-ByWfWBbB.d.ts} +59 -0
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -620,7 +620,8 @@ function toProtoEnvelope(message) {
|
|
|
620
620
|
})),
|
|
621
621
|
schemaVersion: message.schemaVersion,
|
|
622
622
|
authToken: message.authToken,
|
|
623
|
-
supportedWireFormats: message.supportedWireFormats
|
|
623
|
+
supportedWireFormats: message.supportedWireFormats,
|
|
624
|
+
...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
|
|
624
625
|
};
|
|
625
626
|
case "handshake-response":
|
|
626
627
|
return {
|
|
@@ -636,7 +637,8 @@ function toProtoEnvelope(message) {
|
|
|
636
637
|
rejectReason: message.rejectReason,
|
|
637
638
|
selectedWireFormat: message.selectedWireFormat,
|
|
638
639
|
serverTime: message.serverTime,
|
|
639
|
-
blobStorageEnabled: message.blobStorageEnabled
|
|
640
|
+
blobStorageEnabled: message.blobStorageEnabled,
|
|
641
|
+
...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
|
|
640
642
|
};
|
|
641
643
|
case "operation-batch":
|
|
642
644
|
return {
|
|
@@ -644,14 +646,17 @@ function toProtoEnvelope(message) {
|
|
|
644
646
|
messageId: message.messageId,
|
|
645
647
|
operations: message.operations.map(serializeProtoOperation),
|
|
646
648
|
isFinal: message.isFinal,
|
|
647
|
-
batchIndex: message.batchIndex
|
|
649
|
+
batchIndex: message.batchIndex,
|
|
650
|
+
...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
|
|
651
|
+
...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
|
|
648
652
|
};
|
|
649
653
|
case "acknowledgment":
|
|
650
654
|
return {
|
|
651
655
|
type: message.type,
|
|
652
656
|
messageId: message.messageId,
|
|
653
657
|
acknowledgedMessageId: message.acknowledgedMessageId,
|
|
654
|
-
lastSequenceNumber: message.lastSequenceNumber
|
|
658
|
+
lastSequenceNumber: message.lastSequenceNumber,
|
|
659
|
+
...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
|
|
655
660
|
};
|
|
656
661
|
case "error":
|
|
657
662
|
return {
|
|
@@ -716,7 +721,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
716
721
|
authToken: envelope.authToken,
|
|
717
722
|
supportedWireFormats: envelope.supportedWireFormats?.filter(
|
|
718
723
|
(format) => format === "json" || format === "protobuf"
|
|
719
|
-
)
|
|
724
|
+
),
|
|
725
|
+
...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
|
|
720
726
|
};
|
|
721
727
|
case "handshake-response":
|
|
722
728
|
return {
|
|
@@ -733,7 +739,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
733
739
|
// Preserve the server's wall-clock time so clock-skew detection and
|
|
734
740
|
// automatic timestamp rebase work over the protobuf wire, not just JSON.
|
|
735
741
|
...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
|
|
736
|
-
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
|
|
742
|
+
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
|
|
743
|
+
...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
|
|
737
744
|
};
|
|
738
745
|
case "operation-batch":
|
|
739
746
|
return {
|
|
@@ -741,14 +748,17 @@ function fromProtoEnvelope(envelope) {
|
|
|
741
748
|
messageId: envelope.messageId,
|
|
742
749
|
operations: (envelope.operations ?? []).map(deserializeProtoOperation),
|
|
743
750
|
isFinal: envelope.isFinal ?? false,
|
|
744
|
-
batchIndex: envelope.batchIndex ?? 0
|
|
751
|
+
batchIndex: envelope.batchIndex ?? 0,
|
|
752
|
+
...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
|
|
753
|
+
...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
|
|
745
754
|
};
|
|
746
755
|
case "acknowledgment":
|
|
747
756
|
return {
|
|
748
757
|
type: "acknowledgment",
|
|
749
758
|
messageId: envelope.messageId,
|
|
750
759
|
acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
|
|
751
|
-
lastSequenceNumber: envelope.lastSequenceNumber ?? 0
|
|
760
|
+
lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
|
|
761
|
+
...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
|
|
752
762
|
};
|
|
753
763
|
case "error":
|
|
754
764
|
return {
|
|
@@ -949,6 +959,15 @@ function encodeEnvelope(envelope) {
|
|
|
949
959
|
writer.uint32(218).string(envelope.collection);
|
|
950
960
|
if (envelope.recordId && envelope.recordId.length > 0)
|
|
951
961
|
writer.uint32(226).string(envelope.recordId);
|
|
962
|
+
if (envelope.lastDeliverySequence !== void 0)
|
|
963
|
+
writer.uint32(232).int64(envelope.lastDeliverySequence);
|
|
964
|
+
if (envelope.baseDeliverySequence !== void 0)
|
|
965
|
+
writer.uint32(240).int64(envelope.baseDeliverySequence);
|
|
966
|
+
if (envelope.maxDeliverySequence !== void 0)
|
|
967
|
+
writer.uint32(248).int64(envelope.maxDeliverySequence);
|
|
968
|
+
if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
|
|
969
|
+
if (envelope.serverMaxDeliverySequence !== void 0)
|
|
970
|
+
writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
|
|
952
971
|
return writer.finish();
|
|
953
972
|
}
|
|
954
973
|
function decodeEnvelope(bytes) {
|
|
@@ -1044,6 +1063,21 @@ function decodeEnvelope(bytes) {
|
|
|
1044
1063
|
case 28:
|
|
1045
1064
|
envelope.recordId = reader.string();
|
|
1046
1065
|
break;
|
|
1066
|
+
case 29:
|
|
1067
|
+
envelope.lastDeliverySequence = longToNumber(reader.int64());
|
|
1068
|
+
break;
|
|
1069
|
+
case 30:
|
|
1070
|
+
envelope.baseDeliverySequence = longToNumber(reader.int64());
|
|
1071
|
+
break;
|
|
1072
|
+
case 31:
|
|
1073
|
+
envelope.maxDeliverySequence = longToNumber(reader.int64());
|
|
1074
|
+
break;
|
|
1075
|
+
case 32:
|
|
1076
|
+
envelope.deliverySequence = longToNumber(reader.int64());
|
|
1077
|
+
break;
|
|
1078
|
+
case 33:
|
|
1079
|
+
envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
|
|
1080
|
+
break;
|
|
1047
1081
|
default:
|
|
1048
1082
|
reader.skipType(tag & 7);
|
|
1049
1083
|
}
|
|
@@ -1543,6 +1577,7 @@ var ChaosTransport = class {
|
|
|
1543
1577
|
reorderRate;
|
|
1544
1578
|
maxLatency;
|
|
1545
1579
|
random;
|
|
1580
|
+
dropPredicate;
|
|
1546
1581
|
messageHandler = null;
|
|
1547
1582
|
reorderBuffer = [];
|
|
1548
1583
|
pending = [];
|
|
@@ -1553,6 +1588,7 @@ var ChaosTransport = class {
|
|
|
1553
1588
|
this.reorderRate = config?.reorderRate ?? 0;
|
|
1554
1589
|
this.maxLatency = config?.maxLatency ?? 0;
|
|
1555
1590
|
this.random = config?.randomSource ?? Math.random;
|
|
1591
|
+
this.dropPredicate = config?.dropPredicate ?? null;
|
|
1556
1592
|
}
|
|
1557
1593
|
async connect(url, options) {
|
|
1558
1594
|
this.inner.onMessage((msg) => this.handleIncoming(msg));
|
|
@@ -1571,6 +1607,9 @@ var ChaosTransport = class {
|
|
|
1571
1607
|
return this.inner.disconnect();
|
|
1572
1608
|
}
|
|
1573
1609
|
send(message) {
|
|
1610
|
+
if (this.dropPredicate?.(message, "outgoing")) {
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1574
1613
|
if (this.random() < this.dropRate) {
|
|
1575
1614
|
return;
|
|
1576
1615
|
}
|
|
@@ -1598,6 +1637,9 @@ var ChaosTransport = class {
|
|
|
1598
1637
|
}
|
|
1599
1638
|
handleIncoming(message) {
|
|
1600
1639
|
if (!this.messageHandler) return;
|
|
1640
|
+
if (this.dropPredicate?.(message, "incoming")) {
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1601
1643
|
if (this.random() < this.dropRate) {
|
|
1602
1644
|
return;
|
|
1603
1645
|
}
|
|
@@ -2693,6 +2735,7 @@ var OutboundQueue = class {
|
|
|
2693
2735
|
// src/engine/sync-engine.ts
|
|
2694
2736
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2695
2737
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2738
|
+
var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
|
|
2696
2739
|
var VALID_TRANSITIONS = {
|
|
2697
2740
|
disconnected: ["connecting"],
|
|
2698
2741
|
connecting: ["handshaking", "error", "disconnected"],
|
|
@@ -2706,6 +2749,17 @@ var nextQuerySubsetId = 0;
|
|
|
2706
2749
|
function generateMessageId() {
|
|
2707
2750
|
return `msg-${Date.now()}-${nextMessageId++}`;
|
|
2708
2751
|
}
|
|
2752
|
+
var MAX_DELIVERY_VIEW_WATERMARKS = 64;
|
|
2753
|
+
function stableStringify(value) {
|
|
2754
|
+
if (value === null || typeof value !== "object") {
|
|
2755
|
+
return JSON.stringify(value) ?? "null";
|
|
2756
|
+
}
|
|
2757
|
+
if (Array.isArray(value)) {
|
|
2758
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
2759
|
+
}
|
|
2760
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
|
|
2761
|
+
return `{${entries.join(",")}}`;
|
|
2762
|
+
}
|
|
2709
2763
|
var SyncEngine = class {
|
|
2710
2764
|
state = "disconnected";
|
|
2711
2765
|
transport;
|
|
@@ -2716,6 +2770,7 @@ var SyncEngine = class {
|
|
|
2716
2770
|
outboundQueue;
|
|
2717
2771
|
rejectedStorage;
|
|
2718
2772
|
batchSize;
|
|
2773
|
+
outboundAckTimeoutMs;
|
|
2719
2774
|
encryptor;
|
|
2720
2775
|
awarenessManager;
|
|
2721
2776
|
richtextDocChannel;
|
|
@@ -2735,6 +2790,9 @@ var SyncEngine = class {
|
|
|
2735
2790
|
clockBlocked = false;
|
|
2736
2791
|
clockSkewMs = null;
|
|
2737
2792
|
blobStorageEnabled = false;
|
|
2793
|
+
outboundAckTimer = null;
|
|
2794
|
+
startPromise = null;
|
|
2795
|
+
stopPromise = null;
|
|
2738
2796
|
// Track delta exchange state
|
|
2739
2797
|
deltaBatchesReceived = 0;
|
|
2740
2798
|
deltaReceiveComplete = false;
|
|
@@ -2755,6 +2813,25 @@ var SyncEngine = class {
|
|
|
2755
2813
|
/** Resume cursor for paginated initial sync (persisted across reconnects) */
|
|
2756
2814
|
resumeDeltaCursor = null;
|
|
2757
2815
|
initialSyncTotalBatches = 0;
|
|
2816
|
+
/**
|
|
2817
|
+
* The delivery watermark for the CURRENT sync view: the highest server delivery
|
|
2818
|
+
* sequence up to which every operation in the current view (scope + active query
|
|
2819
|
+
* subscriptions) has been contiguously applied. Advanced only through the server's
|
|
2820
|
+
* gap-free delivery stream (never through live relay), so it is a durable lower bound
|
|
2821
|
+
* on what this client holds for this view. Reported at handshake to resume the stream.
|
|
2822
|
+
*
|
|
2823
|
+
* The watermark is keyed by view: switching views (a scope or subscription change)
|
|
2824
|
+
* saves the current view's watermark and loads the target view's, so returning to a
|
|
2825
|
+
* previously-synced view resumes exactly where it left off instead of re-syncing. This
|
|
2826
|
+
* is correct because a view's watermark is a lower bound specific to that view's
|
|
2827
|
+
* filter; a widened view is simply a different (initially unsynced) view.
|
|
2828
|
+
*/
|
|
2829
|
+
deliveryWatermark = 0;
|
|
2830
|
+
/**
|
|
2831
|
+
* In-memory cache of watermark per view signature, preloaded from persistence at start
|
|
2832
|
+
* so a view switch resolves synchronously (no race with the reconnect it triggers).
|
|
2833
|
+
*/
|
|
2834
|
+
deliverySignatureWatermarks = /* @__PURE__ */ new Map();
|
|
2758
2835
|
constructor(options) {
|
|
2759
2836
|
this.transport = options.transport;
|
|
2760
2837
|
this.store = options.store;
|
|
@@ -2762,6 +2839,7 @@ var SyncEngine = class {
|
|
|
2762
2839
|
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
2763
2840
|
this.emitter = options.emitter ?? null;
|
|
2764
2841
|
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2842
|
+
this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
|
|
2765
2843
|
this.encryptor = options.encryptor ?? null;
|
|
2766
2844
|
this.syncState = options.syncState ?? null;
|
|
2767
2845
|
this.activeScope = options.config.scopeMap;
|
|
@@ -2807,10 +2885,20 @@ var SyncEngine = class {
|
|
|
2807
2885
|
* Start the sync engine: connect → handshake → delta exchange → streaming.
|
|
2808
2886
|
*/
|
|
2809
2887
|
async start() {
|
|
2888
|
+
if (this.state === "streaming") return;
|
|
2889
|
+
if (this.startPromise) return this.startPromise;
|
|
2890
|
+
this.startPromise = this.startInternal().finally(() => {
|
|
2891
|
+
this.startPromise = null;
|
|
2892
|
+
});
|
|
2893
|
+
return this.startPromise;
|
|
2894
|
+
}
|
|
2895
|
+
async startInternal() {
|
|
2896
|
+
if (this.stopPromise) {
|
|
2897
|
+
await this.stopPromise;
|
|
2898
|
+
}
|
|
2899
|
+
if (this.state === "streaming") return;
|
|
2810
2900
|
if (this.state !== "disconnected") {
|
|
2811
|
-
|
|
2812
|
-
currentState: this.state
|
|
2813
|
-
});
|
|
2901
|
+
await this.stop();
|
|
2814
2902
|
}
|
|
2815
2903
|
await this.outboundQueue.initialize();
|
|
2816
2904
|
if (this.syncState) {
|
|
@@ -2818,6 +2906,16 @@ var SyncEngine = class {
|
|
|
2818
2906
|
if (this.syncState.loadDeltaCursor) {
|
|
2819
2907
|
this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
|
|
2820
2908
|
}
|
|
2909
|
+
if (this.syncState.loadAllDeliveryWatermarks) {
|
|
2910
|
+
const all = await this.syncState.loadAllDeliveryWatermarks();
|
|
2911
|
+
for (const [signature, watermark] of Object.entries(all)) {
|
|
2912
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
2913
|
+
}
|
|
2914
|
+
} else if (this.syncState.loadDeliveryWatermark) {
|
|
2915
|
+
this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
|
|
2916
|
+
}
|
|
2917
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
|
|
2918
|
+
this.evictColdViewWatermarks();
|
|
2821
2919
|
}
|
|
2822
2920
|
await this.reconcileOutboundFromOpLog();
|
|
2823
2921
|
await this.refreshPendingCount();
|
|
@@ -2833,7 +2931,12 @@ var SyncEngine = class {
|
|
|
2833
2931
|
this.transitionTo("connecting");
|
|
2834
2932
|
try {
|
|
2835
2933
|
const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
|
|
2934
|
+
if (this.state !== "connecting") return;
|
|
2836
2935
|
await this.transport.connect(this.config.url, { authToken });
|
|
2936
|
+
if (this.state !== "connecting") {
|
|
2937
|
+
await this.transport.disconnect();
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2837
2940
|
this.transitionTo("handshaking");
|
|
2838
2941
|
const localVector = this.store.getVersionVector();
|
|
2839
2942
|
const activeQuerySubsets = this.getActiveQuerySubsets();
|
|
@@ -2847,7 +2950,11 @@ var SyncEngine = class {
|
|
|
2847
2950
|
supportedWireFormats: ["json", "protobuf"],
|
|
2848
2951
|
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
|
|
2849
2952
|
...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
|
|
2850
|
-
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
|
|
2953
|
+
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
|
|
2954
|
+
// Resume the server's gap-free delivery stream from our watermark. A server
|
|
2955
|
+
// that understands it drives server->client sync from delivery sequences; an
|
|
2956
|
+
// older server ignores it and falls back to the version-vector delta.
|
|
2957
|
+
lastDeliverySequence: this.deliveryWatermark
|
|
2851
2958
|
};
|
|
2852
2959
|
this.transport.send(handshake);
|
|
2853
2960
|
} catch (err) {
|
|
@@ -2859,7 +2966,15 @@ var SyncEngine = class {
|
|
|
2859
2966
|
* Stop the sync engine. Disconnects the transport.
|
|
2860
2967
|
*/
|
|
2861
2968
|
async stop() {
|
|
2969
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2970
|
+
this.stopPromise = this.stopInternal().finally(() => {
|
|
2971
|
+
this.stopPromise = null;
|
|
2972
|
+
});
|
|
2973
|
+
return this.stopPromise;
|
|
2974
|
+
}
|
|
2975
|
+
async stopInternal() {
|
|
2862
2976
|
if (this.state === "disconnected") return;
|
|
2977
|
+
this.clearOutboundAckTimer();
|
|
2863
2978
|
this.awarenessManager.stopCleanupTimer();
|
|
2864
2979
|
if (this.currentBatch) {
|
|
2865
2980
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
@@ -2884,7 +2999,7 @@ var SyncEngine = class {
|
|
|
2884
2999
|
* because they should remain local-only and not be sent to the server.
|
|
2885
3000
|
*/
|
|
2886
3001
|
async pushOperation(op) {
|
|
2887
|
-
if (!this.operationAllowedForSync(op)) {
|
|
3002
|
+
if (!await this.operationAllowedForSync(op)) {
|
|
2888
3003
|
return;
|
|
2889
3004
|
}
|
|
2890
3005
|
if (this.syncState) {
|
|
@@ -3063,8 +3178,84 @@ var SyncEngine = class {
|
|
|
3063
3178
|
* @param scopeMap - New per-collection scope filters, or undefined to remove scope
|
|
3064
3179
|
*/
|
|
3065
3180
|
updateScope(scopeMap) {
|
|
3181
|
+
const previousSignature = this.deliverySignature();
|
|
3066
3182
|
this.activeScope = scopeMap;
|
|
3067
3183
|
this.config.scopeMap = scopeMap;
|
|
3184
|
+
this.switchDeliveryView(previousSignature);
|
|
3185
|
+
}
|
|
3186
|
+
/**
|
|
3187
|
+
* A stable identifier for the current sync view: the auth/tenant scope plus the set of
|
|
3188
|
+
* active query subscriptions, canonicalized so the same view always produces the same
|
|
3189
|
+
* string. The delivery watermark is keyed by this, so each distinct view resumes from
|
|
3190
|
+
* its own last position.
|
|
3191
|
+
*/
|
|
3192
|
+
deliverySignature() {
|
|
3193
|
+
const subsets = this.getActiveQuerySubsets();
|
|
3194
|
+
if (!this.activeScope && subsets.length === 0) {
|
|
3195
|
+
return "";
|
|
3196
|
+
}
|
|
3197
|
+
const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
|
|
3198
|
+
return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
|
|
3199
|
+
}
|
|
3200
|
+
/**
|
|
3201
|
+
* Switch the active delivery watermark to the current view after a scope or subscription
|
|
3202
|
+
* change. The prior view's watermark is saved so returning to it resumes; the new view's
|
|
3203
|
+
* watermark is restored from the in-memory cache (0 for a never-synced view, which then
|
|
3204
|
+
* does a one-time resync under that view). No data is lost: a view's watermark is a lower
|
|
3205
|
+
* bound for that specific view's filter.
|
|
3206
|
+
*/
|
|
3207
|
+
switchDeliveryView(previousSignature) {
|
|
3208
|
+
const nextSignature = this.deliverySignature();
|
|
3209
|
+
if (nextSignature === previousSignature) {
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
this.setViewWatermark(previousSignature, this.deliveryWatermark);
|
|
3213
|
+
void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
|
|
3214
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
|
|
3215
|
+
}
|
|
3216
|
+
/**
|
|
3217
|
+
* Record a view's watermark in the in-memory cache as the most-recently-used entry (the
|
|
3218
|
+
* Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
|
|
3219
|
+
* views past the retention cap. This is the single place the cache is written, so LRU
|
|
3220
|
+
* order and the size bound are always maintained together.
|
|
3221
|
+
*/
|
|
3222
|
+
setViewWatermark(signature, watermark) {
|
|
3223
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3224
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
3225
|
+
this.evictColdViewWatermarks();
|
|
3226
|
+
}
|
|
3227
|
+
/**
|
|
3228
|
+
* Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
|
|
3229
|
+
* least-recently-used views. The default view ('') and the live view are never evicted
|
|
3230
|
+
* (evicting the live view would force an immediate re-scan of what we are actively
|
|
3231
|
+
* syncing). Each eviction also removes the persisted row, bounding storage. Safe by
|
|
3232
|
+
* construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
|
|
3233
|
+
*/
|
|
3234
|
+
evictColdViewWatermarks() {
|
|
3235
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3236
|
+
return;
|
|
3237
|
+
}
|
|
3238
|
+
const liveSignature = this.deliverySignature();
|
|
3239
|
+
for (const signature of this.deliverySignatureWatermarks.keys()) {
|
|
3240
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3241
|
+
break;
|
|
3242
|
+
}
|
|
3243
|
+
if (signature === "" || signature === liveSignature) {
|
|
3244
|
+
continue;
|
|
3245
|
+
}
|
|
3246
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3247
|
+
void this.deleteDeliveryWatermark(signature);
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
/**
|
|
3251
|
+
* Remove a view's persisted watermark row (best-effort; a persistence layer that omits
|
|
3252
|
+
* the delete simply keeps the row, which is harmless).
|
|
3253
|
+
*/
|
|
3254
|
+
async deleteDeliveryWatermark(signature) {
|
|
3255
|
+
if (!this.syncState?.deleteDeliveryWatermark) {
|
|
3256
|
+
return;
|
|
3257
|
+
}
|
|
3258
|
+
await this.syncState.deleteDeliveryWatermark(signature);
|
|
3068
3259
|
}
|
|
3069
3260
|
/**
|
|
3070
3261
|
* Get the currently active scope map. Returns undefined if no scope is configured.
|
|
@@ -3078,10 +3269,14 @@ var SyncEngine = class {
|
|
|
3078
3269
|
*/
|
|
3079
3270
|
registerQuerySubset(subset) {
|
|
3080
3271
|
const id = `query-${nextQuerySubsetId++}`;
|
|
3272
|
+
const previousSignature = this.deliverySignature();
|
|
3081
3273
|
this.querySubsets.set(id, subset);
|
|
3274
|
+
this.switchDeliveryView(previousSignature);
|
|
3082
3275
|
this.scheduleQuerySubsetReconnect();
|
|
3083
3276
|
return () => {
|
|
3277
|
+
const sigBeforeRemove = this.deliverySignature();
|
|
3084
3278
|
this.querySubsets.delete(id);
|
|
3279
|
+
this.switchDeliveryView(sigBeforeRemove);
|
|
3085
3280
|
this.scheduleQuerySubsetReconnect();
|
|
3086
3281
|
};
|
|
3087
3282
|
}
|
|
@@ -3234,6 +3429,11 @@ var SyncEngine = class {
|
|
|
3234
3429
|
}
|
|
3235
3430
|
this.remoteVector = wireToVersionVector(msg.versionVector);
|
|
3236
3431
|
void this.persistLastAckedServerVector(this.remoteVector);
|
|
3432
|
+
if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
|
|
3433
|
+
this.deliveryWatermark = 0;
|
|
3434
|
+
this.setViewWatermark(this.deliverySignature(), 0);
|
|
3435
|
+
await this.persistDeliveryWatermark(0);
|
|
3436
|
+
}
|
|
3237
3437
|
if (msg.selectedWireFormat) {
|
|
3238
3438
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
3239
3439
|
}
|
|
@@ -3299,7 +3499,7 @@ var SyncEngine = class {
|
|
|
3299
3499
|
async sendDelta() {
|
|
3300
3500
|
const localVector = this.store.getVersionVector();
|
|
3301
3501
|
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
3302
|
-
const missingOps =
|
|
3502
|
+
const missingOps = await this.filterAllowedForSync(allMissingOps);
|
|
3303
3503
|
this.deltaSentOpIds = missingOps.map((op) => op.id);
|
|
3304
3504
|
if (missingOps.length === 0) {
|
|
3305
3505
|
const messageId = generateMessageId();
|
|
@@ -3374,11 +3574,23 @@ var SyncEngine = class {
|
|
|
3374
3574
|
return missing;
|
|
3375
3575
|
}
|
|
3376
3576
|
async handleOperationBatch(msg) {
|
|
3577
|
+
const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
|
|
3578
|
+
if (isDeliveryBatch) {
|
|
3579
|
+
const base = msg.baseDeliverySequence ?? 0;
|
|
3580
|
+
if (base > this.deliveryWatermark) {
|
|
3581
|
+
return;
|
|
3582
|
+
}
|
|
3583
|
+
if (base < this.deliveryWatermark) {
|
|
3584
|
+
this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
|
|
3585
|
+
return;
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3377
3588
|
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
3378
3589
|
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
3379
|
-
const inScopeOps =
|
|
3590
|
+
const inScopeOps = await this.filterAllowedForSync(operations);
|
|
3380
3591
|
const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
3381
3592
|
const transforms = this.config.operationTransforms ?? [];
|
|
3593
|
+
let fullyApplied = true;
|
|
3382
3594
|
for (const op of inScopeOps) {
|
|
3383
3595
|
const transformed = transforms.length > 0 ? (0, import_core7.applyOperationTransforms)(op, targetSchemaVersion, transforms) : op;
|
|
3384
3596
|
if (transformed === null) {
|
|
@@ -3390,6 +3602,7 @@ var SyncEngine = class {
|
|
|
3390
3602
|
this.emitApplyFailure(transformed, result);
|
|
3391
3603
|
}
|
|
3392
3604
|
} catch (error) {
|
|
3605
|
+
fullyApplied = false;
|
|
3393
3606
|
if (error instanceof import_core7.ClockDriftError) {
|
|
3394
3607
|
this.emitApplyFailure(transformed, "rejected", {
|
|
3395
3608
|
code: import_core7.APPLY_FAILURE_CODES.CLOCK_DRIFT,
|
|
@@ -3400,11 +3613,8 @@ var SyncEngine = class {
|
|
|
3400
3613
|
}
|
|
3401
3614
|
const message = error instanceof Error ? error.message : "Apply failed";
|
|
3402
3615
|
const code = error instanceof import_core7.SyncError ? error.code : error instanceof import_core7.KoraError ? error.code : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : import_core7.APPLY_FAILURE_CODES.APPLY_FAILED;
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
message,
|
|
3406
|
-
retriable: code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
|
|
3407
|
-
});
|
|
3616
|
+
const retriable = code !== import_core7.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
|
|
3617
|
+
this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
|
|
3408
3618
|
}
|
|
3409
3619
|
}
|
|
3410
3620
|
if (inScopeOps.length > 0) {
|
|
@@ -3415,18 +3625,27 @@ var SyncEngine = class {
|
|
|
3415
3625
|
batchSize: inScopeOps.length
|
|
3416
3626
|
});
|
|
3417
3627
|
}
|
|
3418
|
-
const
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3628
|
+
const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
|
|
3629
|
+
if (!isDeliveryBatch || deliveryFullyApplied) {
|
|
3630
|
+
const lastOp = operations[operations.length - 1];
|
|
3631
|
+
const ack = {
|
|
3632
|
+
type: "acknowledgment",
|
|
3633
|
+
messageId: generateMessageId(),
|
|
3634
|
+
acknowledgedMessageId: msg.messageId,
|
|
3635
|
+
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
|
|
3636
|
+
...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
|
|
3637
|
+
};
|
|
3638
|
+
this.transport.send(ack);
|
|
3639
|
+
this.emitter?.emit({
|
|
3640
|
+
type: "sync:acknowledged",
|
|
3641
|
+
sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3642
|
+
});
|
|
3643
|
+
}
|
|
3644
|
+
if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
|
|
3645
|
+
this.deliveryWatermark = msg.maxDeliverySequence;
|
|
3646
|
+
this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
|
|
3647
|
+
await this.persistDeliveryWatermark(this.deliveryWatermark);
|
|
3648
|
+
}
|
|
3430
3649
|
if (this.state === "syncing") {
|
|
3431
3650
|
this.deltaBatchesReceived++;
|
|
3432
3651
|
const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
|
|
@@ -3434,15 +3653,17 @@ var SyncEngine = class {
|
|
|
3434
3653
|
this.initialSyncTotalBatches = msg.totalBatches;
|
|
3435
3654
|
}
|
|
3436
3655
|
this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3656
|
+
if (!isDeliveryBatch) {
|
|
3657
|
+
const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
|
|
3658
|
+
inScopeOps.length > 0 ? inScopeOps : operations,
|
|
3659
|
+
msg.batchIndex
|
|
3660
|
+
);
|
|
3661
|
+
if (cursorFromBatch) {
|
|
3662
|
+
this.resumeDeltaCursor = cursorFromBatch;
|
|
3663
|
+
await this.persistDeltaCursor(cursorFromBatch);
|
|
3664
|
+
}
|
|
3444
3665
|
}
|
|
3445
|
-
if (msg.isFinal) {
|
|
3666
|
+
if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
|
|
3446
3667
|
this.deltaReceiveComplete = true;
|
|
3447
3668
|
this.resumeDeltaCursor = null;
|
|
3448
3669
|
await this.persistDeltaCursor(null);
|
|
@@ -3451,7 +3672,19 @@ var SyncEngine = class {
|
|
|
3451
3672
|
}
|
|
3452
3673
|
}
|
|
3453
3674
|
}
|
|
3675
|
+
/** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
|
|
3676
|
+
sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
|
|
3677
|
+
const ack = {
|
|
3678
|
+
type: "acknowledgment",
|
|
3679
|
+
messageId: generateMessageId(),
|
|
3680
|
+
acknowledgedMessageId,
|
|
3681
|
+
lastSequenceNumber: 0,
|
|
3682
|
+
deliverySequence
|
|
3683
|
+
};
|
|
3684
|
+
this.transport.send(ack);
|
|
3685
|
+
}
|
|
3454
3686
|
handleAcknowledgment(msg) {
|
|
3687
|
+
this.clearOutboundAckTimer();
|
|
3455
3688
|
if (this.state === "syncing" && this.config.strictHandshake) {
|
|
3456
3689
|
this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
|
|
3457
3690
|
this.markDeltaSendCompleteIfReady();
|
|
@@ -3471,6 +3704,11 @@ var SyncEngine = class {
|
|
|
3471
3704
|
}
|
|
3472
3705
|
}
|
|
3473
3706
|
handleError(msg) {
|
|
3707
|
+
this.clearOutboundAckTimer();
|
|
3708
|
+
if (this.currentBatch) {
|
|
3709
|
+
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3710
|
+
this.currentBatch = null;
|
|
3711
|
+
}
|
|
3474
3712
|
this.transitionTo("error");
|
|
3475
3713
|
if (msg.code === "AUTH_FAILED") {
|
|
3476
3714
|
this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
|
|
@@ -3631,7 +3869,7 @@ var SyncEngine = class {
|
|
|
3631
3869
|
return;
|
|
3632
3870
|
}
|
|
3633
3871
|
const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
|
|
3634
|
-
const inScope =
|
|
3872
|
+
const inScope = await this.filterAllowedForSync(ops);
|
|
3635
3873
|
for (const op of inScope) {
|
|
3636
3874
|
await this.outboundQueue.enqueue(op);
|
|
3637
3875
|
}
|
|
@@ -3654,6 +3892,7 @@ var SyncEngine = class {
|
|
|
3654
3892
|
batchIndex: 0
|
|
3655
3893
|
};
|
|
3656
3894
|
this.transport.send(batchMsg);
|
|
3895
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3657
3896
|
this.emitter?.emit({
|
|
3658
3897
|
type: "sync:sent",
|
|
3659
3898
|
operations: batch.operations,
|
|
@@ -3679,6 +3918,7 @@ var SyncEngine = class {
|
|
|
3679
3918
|
batchIndex: 0
|
|
3680
3919
|
};
|
|
3681
3920
|
this.transport.send(batchMsg);
|
|
3921
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3682
3922
|
this.emitter?.emit({
|
|
3683
3923
|
type: "sync:sent",
|
|
3684
3924
|
operations: batch.operations,
|
|
@@ -3686,6 +3926,33 @@ var SyncEngine = class {
|
|
|
3686
3926
|
});
|
|
3687
3927
|
}
|
|
3688
3928
|
}
|
|
3929
|
+
startOutboundAckTimer(batchId) {
|
|
3930
|
+
this.clearOutboundAckTimer();
|
|
3931
|
+
if (this.outboundAckTimeoutMs <= 0) return;
|
|
3932
|
+
this.outboundAckTimer = setTimeout(() => {
|
|
3933
|
+
void this.handleOutboundAckTimeout(batchId);
|
|
3934
|
+
}, this.outboundAckTimeoutMs);
|
|
3935
|
+
}
|
|
3936
|
+
clearOutboundAckTimer() {
|
|
3937
|
+
if (!this.outboundAckTimer) return;
|
|
3938
|
+
clearTimeout(this.outboundAckTimer);
|
|
3939
|
+
this.outboundAckTimer = null;
|
|
3940
|
+
}
|
|
3941
|
+
async handleOutboundAckTimeout(batchId) {
|
|
3942
|
+
if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
|
|
3943
|
+
this.outboundQueue.returnBatch(batchId);
|
|
3944
|
+
this.currentBatch = null;
|
|
3945
|
+
this.clearOutboundAckTimer();
|
|
3946
|
+
await this.refreshPendingCount();
|
|
3947
|
+
if (this.state === "disconnected") return;
|
|
3948
|
+
const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
|
|
3949
|
+
try {
|
|
3950
|
+
await this.transport.disconnect();
|
|
3951
|
+
} finally {
|
|
3952
|
+
this.ensureDisconnected();
|
|
3953
|
+
this.emitter?.emit({ type: "sync:disconnected", reason });
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3689
3956
|
handleAwarenessUpdate(msg) {
|
|
3690
3957
|
const awarenessMessage = {
|
|
3691
3958
|
type: "awareness",
|
|
@@ -3695,6 +3962,7 @@ var SyncEngine = class {
|
|
|
3695
3962
|
this.awarenessManager.handleRemoteMessage(awarenessMessage);
|
|
3696
3963
|
}
|
|
3697
3964
|
handleTransportClose(reason) {
|
|
3965
|
+
this.clearOutboundAckTimer();
|
|
3698
3966
|
if (this.currentBatch) {
|
|
3699
3967
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3700
3968
|
this.currentBatch = null;
|
|
@@ -3729,11 +3997,40 @@ var SyncEngine = class {
|
|
|
3729
3997
|
this.serializer.setWireFormat(format);
|
|
3730
3998
|
}
|
|
3731
3999
|
}
|
|
3732
|
-
operationAllowedForSync(op) {
|
|
3733
|
-
if (
|
|
4000
|
+
async operationAllowedForSync(op) {
|
|
4001
|
+
if (this.matchesScopeAndSubsets(op)) {
|
|
4002
|
+
return true;
|
|
4003
|
+
}
|
|
4004
|
+
const fullRecord = await this.readRecordForBackfill(op);
|
|
4005
|
+
if (!fullRecord) {
|
|
3734
4006
|
return false;
|
|
3735
4007
|
}
|
|
3736
|
-
return
|
|
4008
|
+
return this.matchesScopeAndSubsets(op, fullRecord);
|
|
4009
|
+
}
|
|
4010
|
+
matchesScopeAndSubsets(op, fullRecord) {
|
|
4011
|
+
if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
|
|
4012
|
+
return false;
|
|
4013
|
+
}
|
|
4014
|
+
return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
|
|
4015
|
+
}
|
|
4016
|
+
async readRecordForBackfill(op) {
|
|
4017
|
+
if (!this.store.readRecordFields) {
|
|
4018
|
+
return null;
|
|
4019
|
+
}
|
|
4020
|
+
try {
|
|
4021
|
+
return await this.store.readRecordFields(op.collection, op.recordId);
|
|
4022
|
+
} catch {
|
|
4023
|
+
return null;
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
async filterAllowedForSync(ops) {
|
|
4027
|
+
const allowed = [];
|
|
4028
|
+
for (const op of ops) {
|
|
4029
|
+
if (await this.operationAllowedForSync(op)) {
|
|
4030
|
+
allowed.push(op);
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
return allowed;
|
|
3737
4034
|
}
|
|
3738
4035
|
async persistDeltaCursor(cursor) {
|
|
3739
4036
|
if (!this.syncState?.saveDeltaCursor) {
|
|
@@ -3741,6 +4038,12 @@ var SyncEngine = class {
|
|
|
3741
4038
|
}
|
|
3742
4039
|
await this.syncState.saveDeltaCursor(cursor);
|
|
3743
4040
|
}
|
|
4041
|
+
async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
|
|
4042
|
+
if (!this.syncState?.saveDeliveryWatermark) {
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
await this.syncState.saveDeliveryWatermark(signature, watermark);
|
|
4046
|
+
}
|
|
3744
4047
|
scheduleQuerySubsetReconnect() {
|
|
3745
4048
|
if (this.querySubsetReconnectTimer) {
|
|
3746
4049
|
clearTimeout(this.querySubsetReconnectTimer);
|