@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.js
CHANGED
|
@@ -528,7 +528,8 @@ function toProtoEnvelope(message) {
|
|
|
528
528
|
})),
|
|
529
529
|
schemaVersion: message.schemaVersion,
|
|
530
530
|
authToken: message.authToken,
|
|
531
|
-
supportedWireFormats: message.supportedWireFormats
|
|
531
|
+
supportedWireFormats: message.supportedWireFormats,
|
|
532
|
+
...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
|
|
532
533
|
};
|
|
533
534
|
case "handshake-response":
|
|
534
535
|
return {
|
|
@@ -544,7 +545,8 @@ function toProtoEnvelope(message) {
|
|
|
544
545
|
rejectReason: message.rejectReason,
|
|
545
546
|
selectedWireFormat: message.selectedWireFormat,
|
|
546
547
|
serverTime: message.serverTime,
|
|
547
|
-
blobStorageEnabled: message.blobStorageEnabled
|
|
548
|
+
blobStorageEnabled: message.blobStorageEnabled,
|
|
549
|
+
...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
|
|
548
550
|
};
|
|
549
551
|
case "operation-batch":
|
|
550
552
|
return {
|
|
@@ -552,14 +554,17 @@ function toProtoEnvelope(message) {
|
|
|
552
554
|
messageId: message.messageId,
|
|
553
555
|
operations: message.operations.map(serializeProtoOperation),
|
|
554
556
|
isFinal: message.isFinal,
|
|
555
|
-
batchIndex: message.batchIndex
|
|
557
|
+
batchIndex: message.batchIndex,
|
|
558
|
+
...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
|
|
559
|
+
...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
|
|
556
560
|
};
|
|
557
561
|
case "acknowledgment":
|
|
558
562
|
return {
|
|
559
563
|
type: message.type,
|
|
560
564
|
messageId: message.messageId,
|
|
561
565
|
acknowledgedMessageId: message.acknowledgedMessageId,
|
|
562
|
-
lastSequenceNumber: message.lastSequenceNumber
|
|
566
|
+
lastSequenceNumber: message.lastSequenceNumber,
|
|
567
|
+
...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
|
|
563
568
|
};
|
|
564
569
|
case "error":
|
|
565
570
|
return {
|
|
@@ -624,7 +629,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
624
629
|
authToken: envelope.authToken,
|
|
625
630
|
supportedWireFormats: envelope.supportedWireFormats?.filter(
|
|
626
631
|
(format) => format === "json" || format === "protobuf"
|
|
627
|
-
)
|
|
632
|
+
),
|
|
633
|
+
...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
|
|
628
634
|
};
|
|
629
635
|
case "handshake-response":
|
|
630
636
|
return {
|
|
@@ -641,7 +647,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
641
647
|
// Preserve the server's wall-clock time so clock-skew detection and
|
|
642
648
|
// automatic timestamp rebase work over the protobuf wire, not just JSON.
|
|
643
649
|
...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
|
|
644
|
-
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
|
|
650
|
+
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
|
|
651
|
+
...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
|
|
645
652
|
};
|
|
646
653
|
case "operation-batch":
|
|
647
654
|
return {
|
|
@@ -649,14 +656,17 @@ function fromProtoEnvelope(envelope) {
|
|
|
649
656
|
messageId: envelope.messageId,
|
|
650
657
|
operations: (envelope.operations ?? []).map(deserializeProtoOperation),
|
|
651
658
|
isFinal: envelope.isFinal ?? false,
|
|
652
|
-
batchIndex: envelope.batchIndex ?? 0
|
|
659
|
+
batchIndex: envelope.batchIndex ?? 0,
|
|
660
|
+
...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
|
|
661
|
+
...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
|
|
653
662
|
};
|
|
654
663
|
case "acknowledgment":
|
|
655
664
|
return {
|
|
656
665
|
type: "acknowledgment",
|
|
657
666
|
messageId: envelope.messageId,
|
|
658
667
|
acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
|
|
659
|
-
lastSequenceNumber: envelope.lastSequenceNumber ?? 0
|
|
668
|
+
lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
|
|
669
|
+
...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
|
|
660
670
|
};
|
|
661
671
|
case "error":
|
|
662
672
|
return {
|
|
@@ -857,6 +867,15 @@ function encodeEnvelope(envelope) {
|
|
|
857
867
|
writer.uint32(218).string(envelope.collection);
|
|
858
868
|
if (envelope.recordId && envelope.recordId.length > 0)
|
|
859
869
|
writer.uint32(226).string(envelope.recordId);
|
|
870
|
+
if (envelope.lastDeliverySequence !== void 0)
|
|
871
|
+
writer.uint32(232).int64(envelope.lastDeliverySequence);
|
|
872
|
+
if (envelope.baseDeliverySequence !== void 0)
|
|
873
|
+
writer.uint32(240).int64(envelope.baseDeliverySequence);
|
|
874
|
+
if (envelope.maxDeliverySequence !== void 0)
|
|
875
|
+
writer.uint32(248).int64(envelope.maxDeliverySequence);
|
|
876
|
+
if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
|
|
877
|
+
if (envelope.serverMaxDeliverySequence !== void 0)
|
|
878
|
+
writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
|
|
860
879
|
return writer.finish();
|
|
861
880
|
}
|
|
862
881
|
function decodeEnvelope(bytes) {
|
|
@@ -952,6 +971,21 @@ function decodeEnvelope(bytes) {
|
|
|
952
971
|
case 28:
|
|
953
972
|
envelope.recordId = reader.string();
|
|
954
973
|
break;
|
|
974
|
+
case 29:
|
|
975
|
+
envelope.lastDeliverySequence = longToNumber(reader.int64());
|
|
976
|
+
break;
|
|
977
|
+
case 30:
|
|
978
|
+
envelope.baseDeliverySequence = longToNumber(reader.int64());
|
|
979
|
+
break;
|
|
980
|
+
case 31:
|
|
981
|
+
envelope.maxDeliverySequence = longToNumber(reader.int64());
|
|
982
|
+
break;
|
|
983
|
+
case 32:
|
|
984
|
+
envelope.deliverySequence = longToNumber(reader.int64());
|
|
985
|
+
break;
|
|
986
|
+
case 33:
|
|
987
|
+
envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
|
|
988
|
+
break;
|
|
955
989
|
default:
|
|
956
990
|
reader.skipType(tag & 7);
|
|
957
991
|
}
|
|
@@ -1451,6 +1485,7 @@ var ChaosTransport = class {
|
|
|
1451
1485
|
reorderRate;
|
|
1452
1486
|
maxLatency;
|
|
1453
1487
|
random;
|
|
1488
|
+
dropPredicate;
|
|
1454
1489
|
messageHandler = null;
|
|
1455
1490
|
reorderBuffer = [];
|
|
1456
1491
|
pending = [];
|
|
@@ -1461,6 +1496,7 @@ var ChaosTransport = class {
|
|
|
1461
1496
|
this.reorderRate = config?.reorderRate ?? 0;
|
|
1462
1497
|
this.maxLatency = config?.maxLatency ?? 0;
|
|
1463
1498
|
this.random = config?.randomSource ?? Math.random;
|
|
1499
|
+
this.dropPredicate = config?.dropPredicate ?? null;
|
|
1464
1500
|
}
|
|
1465
1501
|
async connect(url, options) {
|
|
1466
1502
|
this.inner.onMessage((msg) => this.handleIncoming(msg));
|
|
@@ -1479,6 +1515,9 @@ var ChaosTransport = class {
|
|
|
1479
1515
|
return this.inner.disconnect();
|
|
1480
1516
|
}
|
|
1481
1517
|
send(message) {
|
|
1518
|
+
if (this.dropPredicate?.(message, "outgoing")) {
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1482
1521
|
if (this.random() < this.dropRate) {
|
|
1483
1522
|
return;
|
|
1484
1523
|
}
|
|
@@ -1506,6 +1545,9 @@ var ChaosTransport = class {
|
|
|
1506
1545
|
}
|
|
1507
1546
|
handleIncoming(message) {
|
|
1508
1547
|
if (!this.messageHandler) return;
|
|
1548
|
+
if (this.dropPredicate?.(message, "incoming")) {
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1509
1551
|
if (this.random() < this.dropRate) {
|
|
1510
1552
|
return;
|
|
1511
1553
|
}
|
|
@@ -2589,6 +2631,7 @@ var OutboundQueue = class {
|
|
|
2589
2631
|
// src/engine/sync-engine.ts
|
|
2590
2632
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2591
2633
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2634
|
+
var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
|
|
2592
2635
|
var VALID_TRANSITIONS = {
|
|
2593
2636
|
disconnected: ["connecting"],
|
|
2594
2637
|
connecting: ["handshaking", "error", "disconnected"],
|
|
@@ -2602,6 +2645,17 @@ var nextQuerySubsetId = 0;
|
|
|
2602
2645
|
function generateMessageId() {
|
|
2603
2646
|
return `msg-${Date.now()}-${nextMessageId++}`;
|
|
2604
2647
|
}
|
|
2648
|
+
var MAX_DELIVERY_VIEW_WATERMARKS = 64;
|
|
2649
|
+
function stableStringify(value) {
|
|
2650
|
+
if (value === null || typeof value !== "object") {
|
|
2651
|
+
return JSON.stringify(value) ?? "null";
|
|
2652
|
+
}
|
|
2653
|
+
if (Array.isArray(value)) {
|
|
2654
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
2655
|
+
}
|
|
2656
|
+
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)}`);
|
|
2657
|
+
return `{${entries.join(",")}}`;
|
|
2658
|
+
}
|
|
2605
2659
|
var SyncEngine = class {
|
|
2606
2660
|
state = "disconnected";
|
|
2607
2661
|
transport;
|
|
@@ -2612,6 +2666,7 @@ var SyncEngine = class {
|
|
|
2612
2666
|
outboundQueue;
|
|
2613
2667
|
rejectedStorage;
|
|
2614
2668
|
batchSize;
|
|
2669
|
+
outboundAckTimeoutMs;
|
|
2615
2670
|
encryptor;
|
|
2616
2671
|
awarenessManager;
|
|
2617
2672
|
richtextDocChannel;
|
|
@@ -2631,6 +2686,9 @@ var SyncEngine = class {
|
|
|
2631
2686
|
clockBlocked = false;
|
|
2632
2687
|
clockSkewMs = null;
|
|
2633
2688
|
blobStorageEnabled = false;
|
|
2689
|
+
outboundAckTimer = null;
|
|
2690
|
+
startPromise = null;
|
|
2691
|
+
stopPromise = null;
|
|
2634
2692
|
// Track delta exchange state
|
|
2635
2693
|
deltaBatchesReceived = 0;
|
|
2636
2694
|
deltaReceiveComplete = false;
|
|
@@ -2651,6 +2709,25 @@ var SyncEngine = class {
|
|
|
2651
2709
|
/** Resume cursor for paginated initial sync (persisted across reconnects) */
|
|
2652
2710
|
resumeDeltaCursor = null;
|
|
2653
2711
|
initialSyncTotalBatches = 0;
|
|
2712
|
+
/**
|
|
2713
|
+
* The delivery watermark for the CURRENT sync view: the highest server delivery
|
|
2714
|
+
* sequence up to which every operation in the current view (scope + active query
|
|
2715
|
+
* subscriptions) has been contiguously applied. Advanced only through the server's
|
|
2716
|
+
* gap-free delivery stream (never through live relay), so it is a durable lower bound
|
|
2717
|
+
* on what this client holds for this view. Reported at handshake to resume the stream.
|
|
2718
|
+
*
|
|
2719
|
+
* The watermark is keyed by view: switching views (a scope or subscription change)
|
|
2720
|
+
* saves the current view's watermark and loads the target view's, so returning to a
|
|
2721
|
+
* previously-synced view resumes exactly where it left off instead of re-syncing. This
|
|
2722
|
+
* is correct because a view's watermark is a lower bound specific to that view's
|
|
2723
|
+
* filter; a widened view is simply a different (initially unsynced) view.
|
|
2724
|
+
*/
|
|
2725
|
+
deliveryWatermark = 0;
|
|
2726
|
+
/**
|
|
2727
|
+
* In-memory cache of watermark per view signature, preloaded from persistence at start
|
|
2728
|
+
* so a view switch resolves synchronously (no race with the reconnect it triggers).
|
|
2729
|
+
*/
|
|
2730
|
+
deliverySignatureWatermarks = /* @__PURE__ */ new Map();
|
|
2654
2731
|
constructor(options) {
|
|
2655
2732
|
this.transport = options.transport;
|
|
2656
2733
|
this.store = options.store;
|
|
@@ -2658,6 +2735,7 @@ var SyncEngine = class {
|
|
|
2658
2735
|
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
2659
2736
|
this.emitter = options.emitter ?? null;
|
|
2660
2737
|
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2738
|
+
this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
|
|
2661
2739
|
this.encryptor = options.encryptor ?? null;
|
|
2662
2740
|
this.syncState = options.syncState ?? null;
|
|
2663
2741
|
this.activeScope = options.config.scopeMap;
|
|
@@ -2703,10 +2781,20 @@ var SyncEngine = class {
|
|
|
2703
2781
|
* Start the sync engine: connect → handshake → delta exchange → streaming.
|
|
2704
2782
|
*/
|
|
2705
2783
|
async start() {
|
|
2784
|
+
if (this.state === "streaming") return;
|
|
2785
|
+
if (this.startPromise) return this.startPromise;
|
|
2786
|
+
this.startPromise = this.startInternal().finally(() => {
|
|
2787
|
+
this.startPromise = null;
|
|
2788
|
+
});
|
|
2789
|
+
return this.startPromise;
|
|
2790
|
+
}
|
|
2791
|
+
async startInternal() {
|
|
2792
|
+
if (this.stopPromise) {
|
|
2793
|
+
await this.stopPromise;
|
|
2794
|
+
}
|
|
2795
|
+
if (this.state === "streaming") return;
|
|
2706
2796
|
if (this.state !== "disconnected") {
|
|
2707
|
-
|
|
2708
|
-
currentState: this.state
|
|
2709
|
-
});
|
|
2797
|
+
await this.stop();
|
|
2710
2798
|
}
|
|
2711
2799
|
await this.outboundQueue.initialize();
|
|
2712
2800
|
if (this.syncState) {
|
|
@@ -2714,6 +2802,16 @@ var SyncEngine = class {
|
|
|
2714
2802
|
if (this.syncState.loadDeltaCursor) {
|
|
2715
2803
|
this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
|
|
2716
2804
|
}
|
|
2805
|
+
if (this.syncState.loadAllDeliveryWatermarks) {
|
|
2806
|
+
const all = await this.syncState.loadAllDeliveryWatermarks();
|
|
2807
|
+
for (const [signature, watermark] of Object.entries(all)) {
|
|
2808
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
2809
|
+
}
|
|
2810
|
+
} else if (this.syncState.loadDeliveryWatermark) {
|
|
2811
|
+
this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
|
|
2812
|
+
}
|
|
2813
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
|
|
2814
|
+
this.evictColdViewWatermarks();
|
|
2717
2815
|
}
|
|
2718
2816
|
await this.reconcileOutboundFromOpLog();
|
|
2719
2817
|
await this.refreshPendingCount();
|
|
@@ -2729,7 +2827,12 @@ var SyncEngine = class {
|
|
|
2729
2827
|
this.transitionTo("connecting");
|
|
2730
2828
|
try {
|
|
2731
2829
|
const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
|
|
2830
|
+
if (this.state !== "connecting") return;
|
|
2732
2831
|
await this.transport.connect(this.config.url, { authToken });
|
|
2832
|
+
if (this.state !== "connecting") {
|
|
2833
|
+
await this.transport.disconnect();
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2733
2836
|
this.transitionTo("handshaking");
|
|
2734
2837
|
const localVector = this.store.getVersionVector();
|
|
2735
2838
|
const activeQuerySubsets = this.getActiveQuerySubsets();
|
|
@@ -2743,7 +2846,11 @@ var SyncEngine = class {
|
|
|
2743
2846
|
supportedWireFormats: ["json", "protobuf"],
|
|
2744
2847
|
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
|
|
2745
2848
|
...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
|
|
2746
|
-
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
|
|
2849
|
+
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
|
|
2850
|
+
// Resume the server's gap-free delivery stream from our watermark. A server
|
|
2851
|
+
// that understands it drives server->client sync from delivery sequences; an
|
|
2852
|
+
// older server ignores it and falls back to the version-vector delta.
|
|
2853
|
+
lastDeliverySequence: this.deliveryWatermark
|
|
2747
2854
|
};
|
|
2748
2855
|
this.transport.send(handshake);
|
|
2749
2856
|
} catch (err) {
|
|
@@ -2755,7 +2862,15 @@ var SyncEngine = class {
|
|
|
2755
2862
|
* Stop the sync engine. Disconnects the transport.
|
|
2756
2863
|
*/
|
|
2757
2864
|
async stop() {
|
|
2865
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2866
|
+
this.stopPromise = this.stopInternal().finally(() => {
|
|
2867
|
+
this.stopPromise = null;
|
|
2868
|
+
});
|
|
2869
|
+
return this.stopPromise;
|
|
2870
|
+
}
|
|
2871
|
+
async stopInternal() {
|
|
2758
2872
|
if (this.state === "disconnected") return;
|
|
2873
|
+
this.clearOutboundAckTimer();
|
|
2759
2874
|
this.awarenessManager.stopCleanupTimer();
|
|
2760
2875
|
if (this.currentBatch) {
|
|
2761
2876
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
@@ -2780,7 +2895,7 @@ var SyncEngine = class {
|
|
|
2780
2895
|
* because they should remain local-only and not be sent to the server.
|
|
2781
2896
|
*/
|
|
2782
2897
|
async pushOperation(op) {
|
|
2783
|
-
if (!this.operationAllowedForSync(op)) {
|
|
2898
|
+
if (!await this.operationAllowedForSync(op)) {
|
|
2784
2899
|
return;
|
|
2785
2900
|
}
|
|
2786
2901
|
if (this.syncState) {
|
|
@@ -2959,8 +3074,84 @@ var SyncEngine = class {
|
|
|
2959
3074
|
* @param scopeMap - New per-collection scope filters, or undefined to remove scope
|
|
2960
3075
|
*/
|
|
2961
3076
|
updateScope(scopeMap) {
|
|
3077
|
+
const previousSignature = this.deliverySignature();
|
|
2962
3078
|
this.activeScope = scopeMap;
|
|
2963
3079
|
this.config.scopeMap = scopeMap;
|
|
3080
|
+
this.switchDeliveryView(previousSignature);
|
|
3081
|
+
}
|
|
3082
|
+
/**
|
|
3083
|
+
* A stable identifier for the current sync view: the auth/tenant scope plus the set of
|
|
3084
|
+
* active query subscriptions, canonicalized so the same view always produces the same
|
|
3085
|
+
* string. The delivery watermark is keyed by this, so each distinct view resumes from
|
|
3086
|
+
* its own last position.
|
|
3087
|
+
*/
|
|
3088
|
+
deliverySignature() {
|
|
3089
|
+
const subsets = this.getActiveQuerySubsets();
|
|
3090
|
+
if (!this.activeScope && subsets.length === 0) {
|
|
3091
|
+
return "";
|
|
3092
|
+
}
|
|
3093
|
+
const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
|
|
3094
|
+
return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
|
|
3095
|
+
}
|
|
3096
|
+
/**
|
|
3097
|
+
* Switch the active delivery watermark to the current view after a scope or subscription
|
|
3098
|
+
* change. The prior view's watermark is saved so returning to it resumes; the new view's
|
|
3099
|
+
* watermark is restored from the in-memory cache (0 for a never-synced view, which then
|
|
3100
|
+
* does a one-time resync under that view). No data is lost: a view's watermark is a lower
|
|
3101
|
+
* bound for that specific view's filter.
|
|
3102
|
+
*/
|
|
3103
|
+
switchDeliveryView(previousSignature) {
|
|
3104
|
+
const nextSignature = this.deliverySignature();
|
|
3105
|
+
if (nextSignature === previousSignature) {
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
this.setViewWatermark(previousSignature, this.deliveryWatermark);
|
|
3109
|
+
void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
|
|
3110
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
|
|
3111
|
+
}
|
|
3112
|
+
/**
|
|
3113
|
+
* Record a view's watermark in the in-memory cache as the most-recently-used entry (the
|
|
3114
|
+
* Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
|
|
3115
|
+
* views past the retention cap. This is the single place the cache is written, so LRU
|
|
3116
|
+
* order and the size bound are always maintained together.
|
|
3117
|
+
*/
|
|
3118
|
+
setViewWatermark(signature, watermark) {
|
|
3119
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3120
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
3121
|
+
this.evictColdViewWatermarks();
|
|
3122
|
+
}
|
|
3123
|
+
/**
|
|
3124
|
+
* Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
|
|
3125
|
+
* least-recently-used views. The default view ('') and the live view are never evicted
|
|
3126
|
+
* (evicting the live view would force an immediate re-scan of what we are actively
|
|
3127
|
+
* syncing). Each eviction also removes the persisted row, bounding storage. Safe by
|
|
3128
|
+
* construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
|
|
3129
|
+
*/
|
|
3130
|
+
evictColdViewWatermarks() {
|
|
3131
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3132
|
+
return;
|
|
3133
|
+
}
|
|
3134
|
+
const liveSignature = this.deliverySignature();
|
|
3135
|
+
for (const signature of this.deliverySignatureWatermarks.keys()) {
|
|
3136
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3137
|
+
break;
|
|
3138
|
+
}
|
|
3139
|
+
if (signature === "" || signature === liveSignature) {
|
|
3140
|
+
continue;
|
|
3141
|
+
}
|
|
3142
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3143
|
+
void this.deleteDeliveryWatermark(signature);
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
/**
|
|
3147
|
+
* Remove a view's persisted watermark row (best-effort; a persistence layer that omits
|
|
3148
|
+
* the delete simply keeps the row, which is harmless).
|
|
3149
|
+
*/
|
|
3150
|
+
async deleteDeliveryWatermark(signature) {
|
|
3151
|
+
if (!this.syncState?.deleteDeliveryWatermark) {
|
|
3152
|
+
return;
|
|
3153
|
+
}
|
|
3154
|
+
await this.syncState.deleteDeliveryWatermark(signature);
|
|
2964
3155
|
}
|
|
2965
3156
|
/**
|
|
2966
3157
|
* Get the currently active scope map. Returns undefined if no scope is configured.
|
|
@@ -2974,10 +3165,14 @@ var SyncEngine = class {
|
|
|
2974
3165
|
*/
|
|
2975
3166
|
registerQuerySubset(subset) {
|
|
2976
3167
|
const id = `query-${nextQuerySubsetId++}`;
|
|
3168
|
+
const previousSignature = this.deliverySignature();
|
|
2977
3169
|
this.querySubsets.set(id, subset);
|
|
3170
|
+
this.switchDeliveryView(previousSignature);
|
|
2978
3171
|
this.scheduleQuerySubsetReconnect();
|
|
2979
3172
|
return () => {
|
|
3173
|
+
const sigBeforeRemove = this.deliverySignature();
|
|
2980
3174
|
this.querySubsets.delete(id);
|
|
3175
|
+
this.switchDeliveryView(sigBeforeRemove);
|
|
2981
3176
|
this.scheduleQuerySubsetReconnect();
|
|
2982
3177
|
};
|
|
2983
3178
|
}
|
|
@@ -3130,6 +3325,11 @@ var SyncEngine = class {
|
|
|
3130
3325
|
}
|
|
3131
3326
|
this.remoteVector = wireToVersionVector(msg.versionVector);
|
|
3132
3327
|
void this.persistLastAckedServerVector(this.remoteVector);
|
|
3328
|
+
if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
|
|
3329
|
+
this.deliveryWatermark = 0;
|
|
3330
|
+
this.setViewWatermark(this.deliverySignature(), 0);
|
|
3331
|
+
await this.persistDeliveryWatermark(0);
|
|
3332
|
+
}
|
|
3133
3333
|
if (msg.selectedWireFormat) {
|
|
3134
3334
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
3135
3335
|
}
|
|
@@ -3195,7 +3395,7 @@ var SyncEngine = class {
|
|
|
3195
3395
|
async sendDelta() {
|
|
3196
3396
|
const localVector = this.store.getVersionVector();
|
|
3197
3397
|
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
3198
|
-
const missingOps =
|
|
3398
|
+
const missingOps = await this.filterAllowedForSync(allMissingOps);
|
|
3199
3399
|
this.deltaSentOpIds = missingOps.map((op) => op.id);
|
|
3200
3400
|
if (missingOps.length === 0) {
|
|
3201
3401
|
const messageId = generateMessageId();
|
|
@@ -3270,11 +3470,23 @@ var SyncEngine = class {
|
|
|
3270
3470
|
return missing;
|
|
3271
3471
|
}
|
|
3272
3472
|
async handleOperationBatch(msg) {
|
|
3473
|
+
const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
|
|
3474
|
+
if (isDeliveryBatch) {
|
|
3475
|
+
const base = msg.baseDeliverySequence ?? 0;
|
|
3476
|
+
if (base > this.deliveryWatermark) {
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
if (base < this.deliveryWatermark) {
|
|
3480
|
+
this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
|
|
3481
|
+
return;
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3273
3484
|
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
3274
3485
|
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
3275
|
-
const inScopeOps =
|
|
3486
|
+
const inScopeOps = await this.filterAllowedForSync(operations);
|
|
3276
3487
|
const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
3277
3488
|
const transforms = this.config.operationTransforms ?? [];
|
|
3489
|
+
let fullyApplied = true;
|
|
3278
3490
|
for (const op of inScopeOps) {
|
|
3279
3491
|
const transformed = transforms.length > 0 ? applyOperationTransforms(op, targetSchemaVersion, transforms) : op;
|
|
3280
3492
|
if (transformed === null) {
|
|
@@ -3286,6 +3498,7 @@ var SyncEngine = class {
|
|
|
3286
3498
|
this.emitApplyFailure(transformed, result);
|
|
3287
3499
|
}
|
|
3288
3500
|
} catch (error) {
|
|
3501
|
+
fullyApplied = false;
|
|
3289
3502
|
if (error instanceof ClockDriftError) {
|
|
3290
3503
|
this.emitApplyFailure(transformed, "rejected", {
|
|
3291
3504
|
code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
|
|
@@ -3296,11 +3509,8 @@ var SyncEngine = class {
|
|
|
3296
3509
|
}
|
|
3297
3510
|
const message = error instanceof Error ? error.message : "Apply failed";
|
|
3298
3511
|
const code = error instanceof SyncError4 ? error.code : error instanceof KoraError2 ? error.code : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : APPLY_FAILURE_CODES.APPLY_FAILED;
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
message,
|
|
3302
|
-
retriable: code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
|
|
3303
|
-
});
|
|
3512
|
+
const retriable = code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
|
|
3513
|
+
this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
|
|
3304
3514
|
}
|
|
3305
3515
|
}
|
|
3306
3516
|
if (inScopeOps.length > 0) {
|
|
@@ -3311,18 +3521,27 @@ var SyncEngine = class {
|
|
|
3311
3521
|
batchSize: inScopeOps.length
|
|
3312
3522
|
});
|
|
3313
3523
|
}
|
|
3314
|
-
const
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3524
|
+
const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
|
|
3525
|
+
if (!isDeliveryBatch || deliveryFullyApplied) {
|
|
3526
|
+
const lastOp = operations[operations.length - 1];
|
|
3527
|
+
const ack = {
|
|
3528
|
+
type: "acknowledgment",
|
|
3529
|
+
messageId: generateMessageId(),
|
|
3530
|
+
acknowledgedMessageId: msg.messageId,
|
|
3531
|
+
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
|
|
3532
|
+
...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
|
|
3533
|
+
};
|
|
3534
|
+
this.transport.send(ack);
|
|
3535
|
+
this.emitter?.emit({
|
|
3536
|
+
type: "sync:acknowledged",
|
|
3537
|
+
sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3538
|
+
});
|
|
3539
|
+
}
|
|
3540
|
+
if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
|
|
3541
|
+
this.deliveryWatermark = msg.maxDeliverySequence;
|
|
3542
|
+
this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
|
|
3543
|
+
await this.persistDeliveryWatermark(this.deliveryWatermark);
|
|
3544
|
+
}
|
|
3326
3545
|
if (this.state === "syncing") {
|
|
3327
3546
|
this.deltaBatchesReceived++;
|
|
3328
3547
|
const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
|
|
@@ -3330,15 +3549,17 @@ var SyncEngine = class {
|
|
|
3330
3549
|
this.initialSyncTotalBatches = msg.totalBatches;
|
|
3331
3550
|
}
|
|
3332
3551
|
this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3552
|
+
if (!isDeliveryBatch) {
|
|
3553
|
+
const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
|
|
3554
|
+
inScopeOps.length > 0 ? inScopeOps : operations,
|
|
3555
|
+
msg.batchIndex
|
|
3556
|
+
);
|
|
3557
|
+
if (cursorFromBatch) {
|
|
3558
|
+
this.resumeDeltaCursor = cursorFromBatch;
|
|
3559
|
+
await this.persistDeltaCursor(cursorFromBatch);
|
|
3560
|
+
}
|
|
3340
3561
|
}
|
|
3341
|
-
if (msg.isFinal) {
|
|
3562
|
+
if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
|
|
3342
3563
|
this.deltaReceiveComplete = true;
|
|
3343
3564
|
this.resumeDeltaCursor = null;
|
|
3344
3565
|
await this.persistDeltaCursor(null);
|
|
@@ -3347,7 +3568,19 @@ var SyncEngine = class {
|
|
|
3347
3568
|
}
|
|
3348
3569
|
}
|
|
3349
3570
|
}
|
|
3571
|
+
/** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
|
|
3572
|
+
sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
|
|
3573
|
+
const ack = {
|
|
3574
|
+
type: "acknowledgment",
|
|
3575
|
+
messageId: generateMessageId(),
|
|
3576
|
+
acknowledgedMessageId,
|
|
3577
|
+
lastSequenceNumber: 0,
|
|
3578
|
+
deliverySequence
|
|
3579
|
+
};
|
|
3580
|
+
this.transport.send(ack);
|
|
3581
|
+
}
|
|
3350
3582
|
handleAcknowledgment(msg) {
|
|
3583
|
+
this.clearOutboundAckTimer();
|
|
3351
3584
|
if (this.state === "syncing" && this.config.strictHandshake) {
|
|
3352
3585
|
this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
|
|
3353
3586
|
this.markDeltaSendCompleteIfReady();
|
|
@@ -3367,6 +3600,11 @@ var SyncEngine = class {
|
|
|
3367
3600
|
}
|
|
3368
3601
|
}
|
|
3369
3602
|
handleError(msg) {
|
|
3603
|
+
this.clearOutboundAckTimer();
|
|
3604
|
+
if (this.currentBatch) {
|
|
3605
|
+
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3606
|
+
this.currentBatch = null;
|
|
3607
|
+
}
|
|
3370
3608
|
this.transitionTo("error");
|
|
3371
3609
|
if (msg.code === "AUTH_FAILED") {
|
|
3372
3610
|
this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
|
|
@@ -3527,7 +3765,7 @@ var SyncEngine = class {
|
|
|
3527
3765
|
return;
|
|
3528
3766
|
}
|
|
3529
3767
|
const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
|
|
3530
|
-
const inScope =
|
|
3768
|
+
const inScope = await this.filterAllowedForSync(ops);
|
|
3531
3769
|
for (const op of inScope) {
|
|
3532
3770
|
await this.outboundQueue.enqueue(op);
|
|
3533
3771
|
}
|
|
@@ -3550,6 +3788,7 @@ var SyncEngine = class {
|
|
|
3550
3788
|
batchIndex: 0
|
|
3551
3789
|
};
|
|
3552
3790
|
this.transport.send(batchMsg);
|
|
3791
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3553
3792
|
this.emitter?.emit({
|
|
3554
3793
|
type: "sync:sent",
|
|
3555
3794
|
operations: batch.operations,
|
|
@@ -3575,6 +3814,7 @@ var SyncEngine = class {
|
|
|
3575
3814
|
batchIndex: 0
|
|
3576
3815
|
};
|
|
3577
3816
|
this.transport.send(batchMsg);
|
|
3817
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3578
3818
|
this.emitter?.emit({
|
|
3579
3819
|
type: "sync:sent",
|
|
3580
3820
|
operations: batch.operations,
|
|
@@ -3582,6 +3822,33 @@ var SyncEngine = class {
|
|
|
3582
3822
|
});
|
|
3583
3823
|
}
|
|
3584
3824
|
}
|
|
3825
|
+
startOutboundAckTimer(batchId) {
|
|
3826
|
+
this.clearOutboundAckTimer();
|
|
3827
|
+
if (this.outboundAckTimeoutMs <= 0) return;
|
|
3828
|
+
this.outboundAckTimer = setTimeout(() => {
|
|
3829
|
+
void this.handleOutboundAckTimeout(batchId);
|
|
3830
|
+
}, this.outboundAckTimeoutMs);
|
|
3831
|
+
}
|
|
3832
|
+
clearOutboundAckTimer() {
|
|
3833
|
+
if (!this.outboundAckTimer) return;
|
|
3834
|
+
clearTimeout(this.outboundAckTimer);
|
|
3835
|
+
this.outboundAckTimer = null;
|
|
3836
|
+
}
|
|
3837
|
+
async handleOutboundAckTimeout(batchId) {
|
|
3838
|
+
if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
|
|
3839
|
+
this.outboundQueue.returnBatch(batchId);
|
|
3840
|
+
this.currentBatch = null;
|
|
3841
|
+
this.clearOutboundAckTimer();
|
|
3842
|
+
await this.refreshPendingCount();
|
|
3843
|
+
if (this.state === "disconnected") return;
|
|
3844
|
+
const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
|
|
3845
|
+
try {
|
|
3846
|
+
await this.transport.disconnect();
|
|
3847
|
+
} finally {
|
|
3848
|
+
this.ensureDisconnected();
|
|
3849
|
+
this.emitter?.emit({ type: "sync:disconnected", reason });
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3585
3852
|
handleAwarenessUpdate(msg) {
|
|
3586
3853
|
const awarenessMessage = {
|
|
3587
3854
|
type: "awareness",
|
|
@@ -3591,6 +3858,7 @@ var SyncEngine = class {
|
|
|
3591
3858
|
this.awarenessManager.handleRemoteMessage(awarenessMessage);
|
|
3592
3859
|
}
|
|
3593
3860
|
handleTransportClose(reason) {
|
|
3861
|
+
this.clearOutboundAckTimer();
|
|
3594
3862
|
if (this.currentBatch) {
|
|
3595
3863
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3596
3864
|
this.currentBatch = null;
|
|
@@ -3625,11 +3893,40 @@ var SyncEngine = class {
|
|
|
3625
3893
|
this.serializer.setWireFormat(format);
|
|
3626
3894
|
}
|
|
3627
3895
|
}
|
|
3628
|
-
operationAllowedForSync(op) {
|
|
3629
|
-
if (
|
|
3896
|
+
async operationAllowedForSync(op) {
|
|
3897
|
+
if (this.matchesScopeAndSubsets(op)) {
|
|
3898
|
+
return true;
|
|
3899
|
+
}
|
|
3900
|
+
const fullRecord = await this.readRecordForBackfill(op);
|
|
3901
|
+
if (!fullRecord) {
|
|
3630
3902
|
return false;
|
|
3631
3903
|
}
|
|
3632
|
-
return
|
|
3904
|
+
return this.matchesScopeAndSubsets(op, fullRecord);
|
|
3905
|
+
}
|
|
3906
|
+
matchesScopeAndSubsets(op, fullRecord) {
|
|
3907
|
+
if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
|
|
3908
|
+
return false;
|
|
3909
|
+
}
|
|
3910
|
+
return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
|
|
3911
|
+
}
|
|
3912
|
+
async readRecordForBackfill(op) {
|
|
3913
|
+
if (!this.store.readRecordFields) {
|
|
3914
|
+
return null;
|
|
3915
|
+
}
|
|
3916
|
+
try {
|
|
3917
|
+
return await this.store.readRecordFields(op.collection, op.recordId);
|
|
3918
|
+
} catch {
|
|
3919
|
+
return null;
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
async filterAllowedForSync(ops) {
|
|
3923
|
+
const allowed = [];
|
|
3924
|
+
for (const op of ops) {
|
|
3925
|
+
if (await this.operationAllowedForSync(op)) {
|
|
3926
|
+
allowed.push(op);
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
return allowed;
|
|
3633
3930
|
}
|
|
3634
3931
|
async persistDeltaCursor(cursor) {
|
|
3635
3932
|
if (!this.syncState?.saveDeltaCursor) {
|
|
@@ -3637,6 +3934,12 @@ var SyncEngine = class {
|
|
|
3637
3934
|
}
|
|
3638
3935
|
await this.syncState.saveDeltaCursor(cursor);
|
|
3639
3936
|
}
|
|
3937
|
+
async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
|
|
3938
|
+
if (!this.syncState?.saveDeliveryWatermark) {
|
|
3939
|
+
return;
|
|
3940
|
+
}
|
|
3941
|
+
await this.syncState.saveDeliveryWatermark(signature, watermark);
|
|
3942
|
+
}
|
|
3640
3943
|
scheduleQuerySubsetReconnect() {
|
|
3641
3944
|
if (this.querySubsetReconnectTimer) {
|
|
3642
3945
|
clearTimeout(this.querySubsetReconnectTimer);
|