@korajs/sync 1.0.0-beta.1 → 1.0.0-beta.10
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 +591 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +145 -3
- package/dist/index.d.ts +145 -3
- package/dist/index.js +589 -53
- package/dist/index.js.map +1 -1
- package/dist/internal.d.cts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/{transport-CZLMdZ0k.d.cts → transport-B2GQerqJ.d.cts} +137 -3
- package/dist/{transport-CZLMdZ0k.d.ts → transport-B2GQerqJ.d.ts} +137 -3
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ var SYNC_STATES = [
|
|
|
14
14
|
];
|
|
15
15
|
var SYNC_STATUSES = [
|
|
16
16
|
"connected",
|
|
17
|
+
"reconnecting",
|
|
17
18
|
"syncing",
|
|
18
19
|
"synced",
|
|
19
20
|
"offline",
|
|
@@ -21,6 +22,20 @@ var SYNC_STATUSES = [
|
|
|
21
22
|
"error",
|
|
22
23
|
"schema-mismatch"
|
|
23
24
|
];
|
|
25
|
+
var MemoryRejectedOperationStorage = class {
|
|
26
|
+
rejected = /* @__PURE__ */ new Map();
|
|
27
|
+
async record(rejected) {
|
|
28
|
+
this.rejected.set(rejected.operationId, rejected);
|
|
29
|
+
}
|
|
30
|
+
async list() {
|
|
31
|
+
return [...this.rejected.values()];
|
|
32
|
+
}
|
|
33
|
+
async remove(operationIds) {
|
|
34
|
+
for (const id of operationIds) {
|
|
35
|
+
this.rejected.delete(id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
24
39
|
var ScopeViolationError = class extends KoraError {
|
|
25
40
|
constructor(operationId, collection, scope, message) {
|
|
26
41
|
super(
|
|
@@ -218,6 +233,8 @@ function isSyncMessage(value) {
|
|
|
218
233
|
return isAcknowledgmentMessage(value);
|
|
219
234
|
case "error":
|
|
220
235
|
return isErrorMessage(value);
|
|
236
|
+
case "operation-rejected":
|
|
237
|
+
return isOperationRejectedMessage(value);
|
|
221
238
|
case "awareness-update":
|
|
222
239
|
return isAwarenessUpdateMessage(value);
|
|
223
240
|
case "yjs-doc-update":
|
|
@@ -257,6 +274,11 @@ function isErrorMessage(value) {
|
|
|
257
274
|
const msg = value;
|
|
258
275
|
return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
|
|
259
276
|
}
|
|
277
|
+
function isOperationRejectedMessage(value) {
|
|
278
|
+
if (typeof value !== "object" || value === null) return false;
|
|
279
|
+
const msg = value;
|
|
280
|
+
return msg.type === "operation-rejected" && typeof msg.messageId === "string" && typeof msg.operationId === "string" && typeof msg.collection === "string" && typeof msg.recordId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
|
|
281
|
+
}
|
|
260
282
|
function isAwarenessUpdateMessage(value) {
|
|
261
283
|
if (typeof value !== "object" || value === null) return false;
|
|
262
284
|
const msg = value;
|
|
@@ -507,7 +529,8 @@ function toProtoEnvelope(message) {
|
|
|
507
529
|
})),
|
|
508
530
|
schemaVersion: message.schemaVersion,
|
|
509
531
|
authToken: message.authToken,
|
|
510
|
-
supportedWireFormats: message.supportedWireFormats
|
|
532
|
+
supportedWireFormats: message.supportedWireFormats,
|
|
533
|
+
...message.lastDeliverySequence !== void 0 ? { lastDeliverySequence: message.lastDeliverySequence } : {}
|
|
511
534
|
};
|
|
512
535
|
case "handshake-response":
|
|
513
536
|
return {
|
|
@@ -523,7 +546,8 @@ function toProtoEnvelope(message) {
|
|
|
523
546
|
rejectReason: message.rejectReason,
|
|
524
547
|
selectedWireFormat: message.selectedWireFormat,
|
|
525
548
|
serverTime: message.serverTime,
|
|
526
|
-
blobStorageEnabled: message.blobStorageEnabled
|
|
549
|
+
blobStorageEnabled: message.blobStorageEnabled,
|
|
550
|
+
...message.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: message.serverMaxDeliverySequence } : {}
|
|
527
551
|
};
|
|
528
552
|
case "operation-batch":
|
|
529
553
|
return {
|
|
@@ -531,14 +555,17 @@ function toProtoEnvelope(message) {
|
|
|
531
555
|
messageId: message.messageId,
|
|
532
556
|
operations: message.operations.map(serializeProtoOperation),
|
|
533
557
|
isFinal: message.isFinal,
|
|
534
|
-
batchIndex: message.batchIndex
|
|
558
|
+
batchIndex: message.batchIndex,
|
|
559
|
+
...message.baseDeliverySequence !== void 0 ? { baseDeliverySequence: message.baseDeliverySequence } : {},
|
|
560
|
+
...message.maxDeliverySequence !== void 0 ? { maxDeliverySequence: message.maxDeliverySequence } : {}
|
|
535
561
|
};
|
|
536
562
|
case "acknowledgment":
|
|
537
563
|
return {
|
|
538
564
|
type: message.type,
|
|
539
565
|
messageId: message.messageId,
|
|
540
566
|
acknowledgedMessageId: message.acknowledgedMessageId,
|
|
541
|
-
lastSequenceNumber: message.lastSequenceNumber
|
|
567
|
+
lastSequenceNumber: message.lastSequenceNumber,
|
|
568
|
+
...message.deliverySequence !== void 0 ? { deliverySequence: message.deliverySequence } : {}
|
|
542
569
|
};
|
|
543
570
|
case "error":
|
|
544
571
|
return {
|
|
@@ -548,6 +575,17 @@ function toProtoEnvelope(message) {
|
|
|
548
575
|
errorMessage: message.message,
|
|
549
576
|
retriable: message.retriable
|
|
550
577
|
};
|
|
578
|
+
case "operation-rejected":
|
|
579
|
+
return {
|
|
580
|
+
type: message.type,
|
|
581
|
+
messageId: message.messageId,
|
|
582
|
+
operationId: message.operationId,
|
|
583
|
+
collection: message.collection,
|
|
584
|
+
recordId: message.recordId,
|
|
585
|
+
errorCode: message.code,
|
|
586
|
+
errorMessage: message.message,
|
|
587
|
+
retriable: message.retriable
|
|
588
|
+
};
|
|
551
589
|
case "awareness-update":
|
|
552
590
|
case "yjs-doc-update":
|
|
553
591
|
return {
|
|
@@ -592,7 +630,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
592
630
|
authToken: envelope.authToken,
|
|
593
631
|
supportedWireFormats: envelope.supportedWireFormats?.filter(
|
|
594
632
|
(format) => format === "json" || format === "protobuf"
|
|
595
|
-
)
|
|
633
|
+
),
|
|
634
|
+
...envelope.lastDeliverySequence !== void 0 ? { lastDeliverySequence: envelope.lastDeliverySequence } : {}
|
|
596
635
|
};
|
|
597
636
|
case "handshake-response":
|
|
598
637
|
return {
|
|
@@ -609,7 +648,8 @@ function fromProtoEnvelope(envelope) {
|
|
|
609
648
|
// Preserve the server's wall-clock time so clock-skew detection and
|
|
610
649
|
// automatic timestamp rebase work over the protobuf wire, not just JSON.
|
|
611
650
|
...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {},
|
|
612
|
-
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {}
|
|
651
|
+
...envelope.blobStorageEnabled !== void 0 ? { blobStorageEnabled: envelope.blobStorageEnabled } : {},
|
|
652
|
+
...envelope.serverMaxDeliverySequence !== void 0 ? { serverMaxDeliverySequence: envelope.serverMaxDeliverySequence } : {}
|
|
613
653
|
};
|
|
614
654
|
case "operation-batch":
|
|
615
655
|
return {
|
|
@@ -617,14 +657,17 @@ function fromProtoEnvelope(envelope) {
|
|
|
617
657
|
messageId: envelope.messageId,
|
|
618
658
|
operations: (envelope.operations ?? []).map(deserializeProtoOperation),
|
|
619
659
|
isFinal: envelope.isFinal ?? false,
|
|
620
|
-
batchIndex: envelope.batchIndex ?? 0
|
|
660
|
+
batchIndex: envelope.batchIndex ?? 0,
|
|
661
|
+
...envelope.baseDeliverySequence !== void 0 ? { baseDeliverySequence: envelope.baseDeliverySequence } : {},
|
|
662
|
+
...envelope.maxDeliverySequence !== void 0 ? { maxDeliverySequence: envelope.maxDeliverySequence } : {}
|
|
621
663
|
};
|
|
622
664
|
case "acknowledgment":
|
|
623
665
|
return {
|
|
624
666
|
type: "acknowledgment",
|
|
625
667
|
messageId: envelope.messageId,
|
|
626
668
|
acknowledgedMessageId: envelope.acknowledgedMessageId ?? "",
|
|
627
|
-
lastSequenceNumber: envelope.lastSequenceNumber ?? 0
|
|
669
|
+
lastSequenceNumber: envelope.lastSequenceNumber ?? 0,
|
|
670
|
+
...envelope.deliverySequence !== void 0 ? { deliverySequence: envelope.deliverySequence } : {}
|
|
628
671
|
};
|
|
629
672
|
case "error":
|
|
630
673
|
return {
|
|
@@ -634,6 +677,17 @@ function fromProtoEnvelope(envelope) {
|
|
|
634
677
|
message: envelope.errorMessage ?? "Unknown error",
|
|
635
678
|
retriable: envelope.retriable ?? false
|
|
636
679
|
};
|
|
680
|
+
case "operation-rejected":
|
|
681
|
+
return {
|
|
682
|
+
type: "operation-rejected",
|
|
683
|
+
messageId: envelope.messageId,
|
|
684
|
+
operationId: envelope.operationId ?? "",
|
|
685
|
+
collection: envelope.collection ?? "",
|
|
686
|
+
recordId: envelope.recordId ?? "",
|
|
687
|
+
code: envelope.errorCode ?? "REJECTED",
|
|
688
|
+
message: envelope.errorMessage ?? "",
|
|
689
|
+
retriable: envelope.retriable ?? false
|
|
690
|
+
};
|
|
637
691
|
case "blob-chunk-request":
|
|
638
692
|
return {
|
|
639
693
|
type: "blob-chunk-request",
|
|
@@ -808,6 +862,21 @@ function encodeEnvelope(envelope) {
|
|
|
808
862
|
if (envelope.hasBytes !== void 0) writer.uint32(184).bool(envelope.hasBytes);
|
|
809
863
|
if (envelope.blobStorageEnabled !== void 0)
|
|
810
864
|
writer.uint32(200).bool(envelope.blobStorageEnabled);
|
|
865
|
+
if (envelope.operationId && envelope.operationId.length > 0)
|
|
866
|
+
writer.uint32(210).string(envelope.operationId);
|
|
867
|
+
if (envelope.collection && envelope.collection.length > 0)
|
|
868
|
+
writer.uint32(218).string(envelope.collection);
|
|
869
|
+
if (envelope.recordId && envelope.recordId.length > 0)
|
|
870
|
+
writer.uint32(226).string(envelope.recordId);
|
|
871
|
+
if (envelope.lastDeliverySequence !== void 0)
|
|
872
|
+
writer.uint32(232).int64(envelope.lastDeliverySequence);
|
|
873
|
+
if (envelope.baseDeliverySequence !== void 0)
|
|
874
|
+
writer.uint32(240).int64(envelope.baseDeliverySequence);
|
|
875
|
+
if (envelope.maxDeliverySequence !== void 0)
|
|
876
|
+
writer.uint32(248).int64(envelope.maxDeliverySequence);
|
|
877
|
+
if (envelope.deliverySequence !== void 0) writer.uint32(256).int64(envelope.deliverySequence);
|
|
878
|
+
if (envelope.serverMaxDeliverySequence !== void 0)
|
|
879
|
+
writer.uint32(264).int64(envelope.serverMaxDeliverySequence);
|
|
811
880
|
return writer.finish();
|
|
812
881
|
}
|
|
813
882
|
function decodeEnvelope(bytes) {
|
|
@@ -894,6 +963,30 @@ function decodeEnvelope(bytes) {
|
|
|
894
963
|
case 25:
|
|
895
964
|
envelope.blobStorageEnabled = reader.bool();
|
|
896
965
|
break;
|
|
966
|
+
case 26:
|
|
967
|
+
envelope.operationId = reader.string();
|
|
968
|
+
break;
|
|
969
|
+
case 27:
|
|
970
|
+
envelope.collection = reader.string();
|
|
971
|
+
break;
|
|
972
|
+
case 28:
|
|
973
|
+
envelope.recordId = reader.string();
|
|
974
|
+
break;
|
|
975
|
+
case 29:
|
|
976
|
+
envelope.lastDeliverySequence = longToNumber(reader.int64());
|
|
977
|
+
break;
|
|
978
|
+
case 30:
|
|
979
|
+
envelope.baseDeliverySequence = longToNumber(reader.int64());
|
|
980
|
+
break;
|
|
981
|
+
case 31:
|
|
982
|
+
envelope.maxDeliverySequence = longToNumber(reader.int64());
|
|
983
|
+
break;
|
|
984
|
+
case 32:
|
|
985
|
+
envelope.deliverySequence = longToNumber(reader.int64());
|
|
986
|
+
break;
|
|
987
|
+
case 33:
|
|
988
|
+
envelope.serverMaxDeliverySequence = longToNumber(reader.int64());
|
|
989
|
+
break;
|
|
897
990
|
default:
|
|
898
991
|
reader.skipType(tag & 7);
|
|
899
992
|
}
|
|
@@ -1393,6 +1486,7 @@ var ChaosTransport = class {
|
|
|
1393
1486
|
reorderRate;
|
|
1394
1487
|
maxLatency;
|
|
1395
1488
|
random;
|
|
1489
|
+
dropPredicate;
|
|
1396
1490
|
messageHandler = null;
|
|
1397
1491
|
reorderBuffer = [];
|
|
1398
1492
|
pending = [];
|
|
@@ -1403,6 +1497,7 @@ var ChaosTransport = class {
|
|
|
1403
1497
|
this.reorderRate = config?.reorderRate ?? 0;
|
|
1404
1498
|
this.maxLatency = config?.maxLatency ?? 0;
|
|
1405
1499
|
this.random = config?.randomSource ?? Math.random;
|
|
1500
|
+
this.dropPredicate = config?.dropPredicate ?? null;
|
|
1406
1501
|
}
|
|
1407
1502
|
async connect(url, options) {
|
|
1408
1503
|
this.inner.onMessage((msg) => this.handleIncoming(msg));
|
|
@@ -1421,6 +1516,9 @@ var ChaosTransport = class {
|
|
|
1421
1516
|
return this.inner.disconnect();
|
|
1422
1517
|
}
|
|
1423
1518
|
send(message) {
|
|
1519
|
+
if (this.dropPredicate?.(message, "outgoing")) {
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1424
1522
|
if (this.random() < this.dropRate) {
|
|
1425
1523
|
return;
|
|
1426
1524
|
}
|
|
@@ -1448,6 +1546,9 @@ var ChaosTransport = class {
|
|
|
1448
1546
|
}
|
|
1449
1547
|
handleIncoming(message) {
|
|
1450
1548
|
if (!this.messageHandler) return;
|
|
1549
|
+
if (this.dropPredicate?.(message, "incoming")) {
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1451
1552
|
if (this.random() < this.dropRate) {
|
|
1452
1553
|
return;
|
|
1453
1554
|
}
|
|
@@ -2400,6 +2501,32 @@ var OutboundQueue = class {
|
|
|
2400
2501
|
const ids = ops.map((op) => op.id);
|
|
2401
2502
|
await this.storage.dequeue(ids);
|
|
2402
2503
|
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Acknowledge only operations in an in-flight batch whose local sequence number is
|
|
2506
|
+
* covered by the server's ack. Any later operations return to the front of the
|
|
2507
|
+
* queue for retry. This is required when the server accepts a prefix but rejects a
|
|
2508
|
+
* later operation retriably (for example a stale-scope push after auth changed).
|
|
2509
|
+
*/
|
|
2510
|
+
async acknowledgeThrough(batchId, lastSequenceNumber) {
|
|
2511
|
+
const ops = this.inFlight.get(batchId);
|
|
2512
|
+
if (!ops) return;
|
|
2513
|
+
this.inFlight.delete(batchId);
|
|
2514
|
+
const acknowledged = ops.filter((op) => op.sequenceNumber <= lastSequenceNumber);
|
|
2515
|
+
const retry = ops.filter((op) => op.sequenceNumber > lastSequenceNumber);
|
|
2516
|
+
if (retry.length > 0) {
|
|
2517
|
+
this.queue.unshift(...retry);
|
|
2518
|
+
if (this.queue.length > 1) {
|
|
2519
|
+
this.queue = topologicalSort(this.queue);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
if (acknowledged.length > 0) {
|
|
2523
|
+
const ids = acknowledged.map((op) => op.id);
|
|
2524
|
+
for (const id of ids) {
|
|
2525
|
+
this.seen.delete(id);
|
|
2526
|
+
}
|
|
2527
|
+
await this.storage.dequeue(ids);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2403
2530
|
/**
|
|
2404
2531
|
* Return a failed batch to the front of the queue for retry.
|
|
2405
2532
|
* Prepends the operations to maintain priority.
|
|
@@ -2482,6 +2609,37 @@ var OutboundQueue = class {
|
|
|
2482
2609
|
get isInitialized() {
|
|
2483
2610
|
return this.initialized;
|
|
2484
2611
|
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Remove a single operation the server permanently rejected — from the queue,
|
|
2614
|
+
* from any in-flight batch, and from durable storage — so a later batch ack or
|
|
2615
|
+
* a reconnect `returnBatch` can never resend or resurrect it. Returns the
|
|
2616
|
+
* removed operation so the caller can record it in a durable rejected store,
|
|
2617
|
+
* or null if it was not present (already acknowledged and removed).
|
|
2618
|
+
*/
|
|
2619
|
+
async reject(opId) {
|
|
2620
|
+
let removed = null;
|
|
2621
|
+
for (const [batchId, ops] of this.inFlight) {
|
|
2622
|
+
const idx = ops.findIndex((op) => op.id === opId);
|
|
2623
|
+
if (idx !== -1) {
|
|
2624
|
+
removed = ops[idx] ?? null;
|
|
2625
|
+
ops.splice(idx, 1);
|
|
2626
|
+
if (ops.length === 0) {
|
|
2627
|
+
this.inFlight.delete(batchId);
|
|
2628
|
+
}
|
|
2629
|
+
break;
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
if (!removed) {
|
|
2633
|
+
const idx = this.queue.findIndex((op) => op.id === opId);
|
|
2634
|
+
if (idx !== -1) {
|
|
2635
|
+
removed = this.queue[idx] ?? null;
|
|
2636
|
+
this.queue.splice(idx, 1);
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
this.seen.delete(opId);
|
|
2640
|
+
await this.storage.dequeue([opId]);
|
|
2641
|
+
return removed;
|
|
2642
|
+
}
|
|
2485
2643
|
/**
|
|
2486
2644
|
* Remove operations by id from queue and persistent storage.
|
|
2487
2645
|
* Used when ops were already sent during handshake delta exchange.
|
|
@@ -2500,6 +2658,7 @@ var OutboundQueue = class {
|
|
|
2500
2658
|
// src/engine/sync-engine.ts
|
|
2501
2659
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2502
2660
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2661
|
+
var DEFAULT_OUTBOUND_ACK_TIMEOUT_MS = 3e4;
|
|
2503
2662
|
var VALID_TRANSITIONS = {
|
|
2504
2663
|
disconnected: ["connecting"],
|
|
2505
2664
|
connecting: ["handshaking", "error", "disconnected"],
|
|
@@ -2513,6 +2672,17 @@ var nextQuerySubsetId = 0;
|
|
|
2513
2672
|
function generateMessageId() {
|
|
2514
2673
|
return `msg-${Date.now()}-${nextMessageId++}`;
|
|
2515
2674
|
}
|
|
2675
|
+
var MAX_DELIVERY_VIEW_WATERMARKS = 64;
|
|
2676
|
+
function stableStringify(value) {
|
|
2677
|
+
if (value === null || typeof value !== "object") {
|
|
2678
|
+
return JSON.stringify(value) ?? "null";
|
|
2679
|
+
}
|
|
2680
|
+
if (Array.isArray(value)) {
|
|
2681
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
2682
|
+
}
|
|
2683
|
+
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)}`);
|
|
2684
|
+
return `{${entries.join(",")}}`;
|
|
2685
|
+
}
|
|
2516
2686
|
var SyncEngine = class {
|
|
2517
2687
|
state = "disconnected";
|
|
2518
2688
|
transport;
|
|
@@ -2521,7 +2691,9 @@ var SyncEngine = class {
|
|
|
2521
2691
|
serializer;
|
|
2522
2692
|
emitter;
|
|
2523
2693
|
outboundQueue;
|
|
2694
|
+
rejectedStorage;
|
|
2524
2695
|
batchSize;
|
|
2696
|
+
outboundAckTimeoutMs;
|
|
2525
2697
|
encryptor;
|
|
2526
2698
|
awarenessManager;
|
|
2527
2699
|
richtextDocChannel;
|
|
@@ -2536,11 +2708,16 @@ var SyncEngine = class {
|
|
|
2536
2708
|
lastSuccessfulPull = null;
|
|
2537
2709
|
conflictCount = 0;
|
|
2538
2710
|
currentBatch = null;
|
|
2711
|
+
currentBatchRequiresPartialAck = false;
|
|
2539
2712
|
reconnecting = false;
|
|
2540
2713
|
schemaBlocked = false;
|
|
2541
2714
|
clockBlocked = false;
|
|
2542
2715
|
clockSkewMs = null;
|
|
2543
2716
|
blobStorageEnabled = false;
|
|
2717
|
+
outboundAckTimer = null;
|
|
2718
|
+
startPromise = null;
|
|
2719
|
+
stopPromise = null;
|
|
2720
|
+
reconnectPromise = null;
|
|
2544
2721
|
// Track delta exchange state
|
|
2545
2722
|
deltaBatchesReceived = 0;
|
|
2546
2723
|
deltaReceiveComplete = false;
|
|
@@ -2561,6 +2738,27 @@ var SyncEngine = class {
|
|
|
2561
2738
|
/** Resume cursor for paginated initial sync (persisted across reconnects) */
|
|
2562
2739
|
resumeDeltaCursor = null;
|
|
2563
2740
|
initialSyncTotalBatches = 0;
|
|
2741
|
+
/**
|
|
2742
|
+
* The delivery watermark for the CURRENT sync view: the highest server delivery
|
|
2743
|
+
* sequence up to which every operation in the current view (scope + active query
|
|
2744
|
+
* subscriptions) has been contiguously applied. Advanced only through the server's
|
|
2745
|
+
* gap-free delivery stream (never through live relay), so it is a durable lower bound
|
|
2746
|
+
* on what this client holds for this view. Reported at handshake to resume the stream.
|
|
2747
|
+
*
|
|
2748
|
+
* The watermark is keyed by view: switching views (a scope or subscription change)
|
|
2749
|
+
* saves the current view's watermark and loads the target view's, so returning to a
|
|
2750
|
+
* previously-synced view resumes exactly where it left off instead of re-syncing. This
|
|
2751
|
+
* is correct because a view's watermark is a lower bound specific to that view's
|
|
2752
|
+
* filter; a widened view is simply a different (initially unsynced) view.
|
|
2753
|
+
*/
|
|
2754
|
+
deliveryWatermark = 0;
|
|
2755
|
+
/**
|
|
2756
|
+
* In-memory cache of watermark per view signature, preloaded from persistence at start
|
|
2757
|
+
* so a view switch resolves synchronously (no race with the reconnect it triggers).
|
|
2758
|
+
*/
|
|
2759
|
+
deliverySignatureWatermarks = /* @__PURE__ */ new Map();
|
|
2760
|
+
deliveryGapRepeatCount = 0;
|
|
2761
|
+
lastDeliveryGapKey = null;
|
|
2564
2762
|
constructor(options) {
|
|
2565
2763
|
this.transport = options.transport;
|
|
2566
2764
|
this.store = options.store;
|
|
@@ -2568,11 +2766,13 @@ var SyncEngine = class {
|
|
|
2568
2766
|
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
2569
2767
|
this.emitter = options.emitter ?? null;
|
|
2570
2768
|
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2769
|
+
this.outboundAckTimeoutMs = options.config.outboundAckTimeoutMs ?? DEFAULT_OUTBOUND_ACK_TIMEOUT_MS;
|
|
2571
2770
|
this.encryptor = options.encryptor ?? null;
|
|
2572
2771
|
this.syncState = options.syncState ?? null;
|
|
2573
2772
|
this.activeScope = options.config.scopeMap;
|
|
2574
2773
|
const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
|
|
2575
2774
|
this.outboundQueue = new OutboundQueue(queueStorage);
|
|
2775
|
+
this.rejectedStorage = options.rejectedStorage ?? new MemoryRejectedOperationStorage();
|
|
2576
2776
|
this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
|
|
2577
2777
|
if (this.emitter) {
|
|
2578
2778
|
this.metricsCollector.attachEmitter(this.emitter);
|
|
@@ -2612,10 +2812,20 @@ var SyncEngine = class {
|
|
|
2612
2812
|
* Start the sync engine: connect → handshake → delta exchange → streaming.
|
|
2613
2813
|
*/
|
|
2614
2814
|
async start() {
|
|
2815
|
+
if (this.state === "streaming") return;
|
|
2816
|
+
if (this.startPromise) return this.startPromise;
|
|
2817
|
+
this.startPromise = this.startInternal().finally(() => {
|
|
2818
|
+
this.startPromise = null;
|
|
2819
|
+
});
|
|
2820
|
+
return this.startPromise;
|
|
2821
|
+
}
|
|
2822
|
+
async startInternal() {
|
|
2823
|
+
if (this.stopPromise) {
|
|
2824
|
+
await this.stopPromise;
|
|
2825
|
+
}
|
|
2826
|
+
if (this.state === "streaming") return;
|
|
2615
2827
|
if (this.state !== "disconnected") {
|
|
2616
|
-
|
|
2617
|
-
currentState: this.state
|
|
2618
|
-
});
|
|
2828
|
+
await this.stop();
|
|
2619
2829
|
}
|
|
2620
2830
|
await this.outboundQueue.initialize();
|
|
2621
2831
|
if (this.syncState) {
|
|
@@ -2623,6 +2833,16 @@ var SyncEngine = class {
|
|
|
2623
2833
|
if (this.syncState.loadDeltaCursor) {
|
|
2624
2834
|
this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
|
|
2625
2835
|
}
|
|
2836
|
+
if (this.syncState.loadAllDeliveryWatermarks) {
|
|
2837
|
+
const all = await this.syncState.loadAllDeliveryWatermarks();
|
|
2838
|
+
for (const [signature, watermark] of Object.entries(all)) {
|
|
2839
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
2840
|
+
}
|
|
2841
|
+
} else if (this.syncState.loadDeliveryWatermark) {
|
|
2842
|
+
this.deliverySignatureWatermarks.set("", await this.syncState.loadDeliveryWatermark(""));
|
|
2843
|
+
}
|
|
2844
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(this.deliverySignature()) ?? 0;
|
|
2845
|
+
this.evictColdViewWatermarks();
|
|
2626
2846
|
}
|
|
2627
2847
|
await this.reconcileOutboundFromOpLog();
|
|
2628
2848
|
await this.refreshPendingCount();
|
|
@@ -2638,7 +2858,12 @@ var SyncEngine = class {
|
|
|
2638
2858
|
this.transitionTo("connecting");
|
|
2639
2859
|
try {
|
|
2640
2860
|
const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
|
|
2861
|
+
if (this.state !== "connecting") return;
|
|
2641
2862
|
await this.transport.connect(this.config.url, { authToken });
|
|
2863
|
+
if (this.state !== "connecting") {
|
|
2864
|
+
await this.transport.disconnect();
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2642
2867
|
this.transitionTo("handshaking");
|
|
2643
2868
|
const localVector = this.store.getVersionVector();
|
|
2644
2869
|
const activeQuerySubsets = this.getActiveQuerySubsets();
|
|
@@ -2652,7 +2877,11 @@ var SyncEngine = class {
|
|
|
2652
2877
|
supportedWireFormats: ["json", "protobuf"],
|
|
2653
2878
|
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
|
|
2654
2879
|
...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
|
|
2655
|
-
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
|
|
2880
|
+
...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {},
|
|
2881
|
+
// Resume the server's gap-free delivery stream from our watermark. A server
|
|
2882
|
+
// that understands it drives server->client sync from delivery sequences; an
|
|
2883
|
+
// older server ignores it and falls back to the version-vector delta.
|
|
2884
|
+
lastDeliverySequence: this.deliveryWatermark
|
|
2656
2885
|
};
|
|
2657
2886
|
this.transport.send(handshake);
|
|
2658
2887
|
} catch (err) {
|
|
@@ -2664,11 +2893,20 @@ var SyncEngine = class {
|
|
|
2664
2893
|
* Stop the sync engine. Disconnects the transport.
|
|
2665
2894
|
*/
|
|
2666
2895
|
async stop() {
|
|
2896
|
+
if (this.stopPromise) return this.stopPromise;
|
|
2897
|
+
this.stopPromise = this.stopInternal().finally(() => {
|
|
2898
|
+
this.stopPromise = null;
|
|
2899
|
+
});
|
|
2900
|
+
return this.stopPromise;
|
|
2901
|
+
}
|
|
2902
|
+
async stopInternal() {
|
|
2667
2903
|
if (this.state === "disconnected") return;
|
|
2904
|
+
this.clearOutboundAckTimer();
|
|
2668
2905
|
this.awarenessManager.stopCleanupTimer();
|
|
2669
2906
|
if (this.currentBatch) {
|
|
2670
2907
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
2671
2908
|
this.currentBatch = null;
|
|
2909
|
+
this.currentBatchRequiresPartialAck = false;
|
|
2672
2910
|
}
|
|
2673
2911
|
try {
|
|
2674
2912
|
await this.transport.disconnect();
|
|
@@ -2689,7 +2927,7 @@ var SyncEngine = class {
|
|
|
2689
2927
|
* because they should remain local-only and not be sent to the server.
|
|
2690
2928
|
*/
|
|
2691
2929
|
async pushOperation(op) {
|
|
2692
|
-
if (!this.operationAllowedForSync(op)) {
|
|
2930
|
+
if (!await this.operationAllowedForSync(op)) {
|
|
2693
2931
|
return;
|
|
2694
2932
|
}
|
|
2695
2933
|
if (this.syncState) {
|
|
@@ -2718,6 +2956,7 @@ var SyncEngine = class {
|
|
|
2718
2956
|
getStatus() {
|
|
2719
2957
|
const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
|
|
2720
2958
|
const base = {
|
|
2959
|
+
reconnecting: this.reconnecting,
|
|
2721
2960
|
pendingOperations,
|
|
2722
2961
|
lastSyncedAt: this.lastSyncedAt,
|
|
2723
2962
|
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
@@ -2737,7 +2976,7 @@ var SyncEngine = class {
|
|
|
2737
2976
|
case "connecting":
|
|
2738
2977
|
case "handshaking":
|
|
2739
2978
|
case "syncing":
|
|
2740
|
-
return { ...base, status: this.reconnecting ? "
|
|
2979
|
+
return { ...base, status: this.reconnecting ? "reconnecting" : "syncing" };
|
|
2741
2980
|
case "streaming":
|
|
2742
2981
|
return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
|
|
2743
2982
|
case "error":
|
|
@@ -2822,6 +3061,24 @@ var SyncEngine = class {
|
|
|
2822
3061
|
await this.start();
|
|
2823
3062
|
}
|
|
2824
3063
|
}
|
|
3064
|
+
/**
|
|
3065
|
+
* Atomically refresh the transport session. Overlapping callers share one
|
|
3066
|
+
* reconnect, in-flight outbound batches are returned to the queue by stop(), and the
|
|
3067
|
+
* next start handshakes with the latest auth token, sync scope, and query subsets.
|
|
3068
|
+
*/
|
|
3069
|
+
async reconnect() {
|
|
3070
|
+
if (this.schemaBlocked) return;
|
|
3071
|
+
if (this.reconnectPromise) return this.reconnectPromise;
|
|
3072
|
+
this.reconnecting = false;
|
|
3073
|
+
this.reconnectPromise = this.reconnectInternal().finally(() => {
|
|
3074
|
+
this.reconnectPromise = null;
|
|
3075
|
+
});
|
|
3076
|
+
return this.reconnectPromise;
|
|
3077
|
+
}
|
|
3078
|
+
async reconnectInternal() {
|
|
3079
|
+
await this.stop();
|
|
3080
|
+
await this.start();
|
|
3081
|
+
}
|
|
2825
3082
|
/**
|
|
2826
3083
|
* Export a diagnostics snapshot for debugging and support tickets.
|
|
2827
3084
|
* Contains connection state, timing info, and queue metrics.
|
|
@@ -2840,6 +3097,8 @@ var SyncEngine = class {
|
|
|
2840
3097
|
pendingOperations: this.outboundQueue.totalPending,
|
|
2841
3098
|
hasInFlightBatch: this.currentBatch !== null,
|
|
2842
3099
|
reconnecting: this.reconnecting,
|
|
3100
|
+
deliveryWatermark: this.deliveryWatermark,
|
|
3101
|
+
deliveryGapRepeatCount: this.deliveryGapRepeatCount,
|
|
2843
3102
|
timestamp: Date.now()
|
|
2844
3103
|
};
|
|
2845
3104
|
}
|
|
@@ -2868,8 +3127,84 @@ var SyncEngine = class {
|
|
|
2868
3127
|
* @param scopeMap - New per-collection scope filters, or undefined to remove scope
|
|
2869
3128
|
*/
|
|
2870
3129
|
updateScope(scopeMap) {
|
|
3130
|
+
const previousSignature = this.deliverySignature();
|
|
2871
3131
|
this.activeScope = scopeMap;
|
|
2872
3132
|
this.config.scopeMap = scopeMap;
|
|
3133
|
+
this.switchDeliveryView(previousSignature);
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
3136
|
+
* A stable identifier for the current sync view: the auth/tenant scope plus the set of
|
|
3137
|
+
* active query subscriptions, canonicalized so the same view always produces the same
|
|
3138
|
+
* string. The delivery watermark is keyed by this, so each distinct view resumes from
|
|
3139
|
+
* its own last position.
|
|
3140
|
+
*/
|
|
3141
|
+
deliverySignature() {
|
|
3142
|
+
const subsets = this.getActiveQuerySubsets();
|
|
3143
|
+
if (!this.activeScope && subsets.length === 0) {
|
|
3144
|
+
return "";
|
|
3145
|
+
}
|
|
3146
|
+
const normalizedSubsets = subsets.map((s) => `${s.collection}:${stableStringify(s.where)}`).sort();
|
|
3147
|
+
return stableStringify({ scope: this.activeScope ?? null, subsets: normalizedSubsets });
|
|
3148
|
+
}
|
|
3149
|
+
/**
|
|
3150
|
+
* Switch the active delivery watermark to the current view after a scope or subscription
|
|
3151
|
+
* change. The prior view's watermark is saved so returning to it resumes; the new view's
|
|
3152
|
+
* watermark is restored from the in-memory cache (0 for a never-synced view, which then
|
|
3153
|
+
* does a one-time resync under that view). No data is lost: a view's watermark is a lower
|
|
3154
|
+
* bound for that specific view's filter.
|
|
3155
|
+
*/
|
|
3156
|
+
switchDeliveryView(previousSignature) {
|
|
3157
|
+
const nextSignature = this.deliverySignature();
|
|
3158
|
+
if (nextSignature === previousSignature) {
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
this.setViewWatermark(previousSignature, this.deliveryWatermark);
|
|
3162
|
+
void this.persistDeliveryWatermark(this.deliveryWatermark, previousSignature);
|
|
3163
|
+
this.deliveryWatermark = this.deliverySignatureWatermarks.get(nextSignature) ?? 0;
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* Record a view's watermark in the in-memory cache as the most-recently-used entry (the
|
|
3167
|
+
* Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
|
|
3168
|
+
* views past the retention cap. This is the single place the cache is written, so LRU
|
|
3169
|
+
* order and the size bound are always maintained together.
|
|
3170
|
+
*/
|
|
3171
|
+
setViewWatermark(signature, watermark) {
|
|
3172
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3173
|
+
this.deliverySignatureWatermarks.set(signature, watermark);
|
|
3174
|
+
this.evictColdViewWatermarks();
|
|
3175
|
+
}
|
|
3176
|
+
/**
|
|
3177
|
+
* Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
|
|
3178
|
+
* least-recently-used views. The default view ('') and the live view are never evicted
|
|
3179
|
+
* (evicting the live view would force an immediate re-scan of what we are actively
|
|
3180
|
+
* syncing). Each eviction also removes the persisted row, bounding storage. Safe by
|
|
3181
|
+
* construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
|
|
3182
|
+
*/
|
|
3183
|
+
evictColdViewWatermarks() {
|
|
3184
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3185
|
+
return;
|
|
3186
|
+
}
|
|
3187
|
+
const liveSignature = this.deliverySignature();
|
|
3188
|
+
for (const signature of this.deliverySignatureWatermarks.keys()) {
|
|
3189
|
+
if (this.deliverySignatureWatermarks.size <= MAX_DELIVERY_VIEW_WATERMARKS) {
|
|
3190
|
+
break;
|
|
3191
|
+
}
|
|
3192
|
+
if (signature === "" || signature === liveSignature) {
|
|
3193
|
+
continue;
|
|
3194
|
+
}
|
|
3195
|
+
this.deliverySignatureWatermarks.delete(signature);
|
|
3196
|
+
void this.deleteDeliveryWatermark(signature);
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
/**
|
|
3200
|
+
* Remove a view's persisted watermark row (best-effort; a persistence layer that omits
|
|
3201
|
+
* the delete simply keeps the row, which is harmless).
|
|
3202
|
+
*/
|
|
3203
|
+
async deleteDeliveryWatermark(signature) {
|
|
3204
|
+
if (!this.syncState?.deleteDeliveryWatermark) {
|
|
3205
|
+
return;
|
|
3206
|
+
}
|
|
3207
|
+
await this.syncState.deleteDeliveryWatermark(signature);
|
|
2873
3208
|
}
|
|
2874
3209
|
/**
|
|
2875
3210
|
* Get the currently active scope map. Returns undefined if no scope is configured.
|
|
@@ -2883,10 +3218,14 @@ var SyncEngine = class {
|
|
|
2883
3218
|
*/
|
|
2884
3219
|
registerQuerySubset(subset) {
|
|
2885
3220
|
const id = `query-${nextQuerySubsetId++}`;
|
|
3221
|
+
const previousSignature = this.deliverySignature();
|
|
2886
3222
|
this.querySubsets.set(id, subset);
|
|
3223
|
+
this.switchDeliveryView(previousSignature);
|
|
2887
3224
|
this.scheduleQuerySubsetReconnect();
|
|
2888
3225
|
return () => {
|
|
3226
|
+
const sigBeforeRemove = this.deliverySignature();
|
|
2889
3227
|
this.querySubsets.delete(id);
|
|
3228
|
+
this.switchDeliveryView(sigBeforeRemove);
|
|
2890
3229
|
this.scheduleQuerySubsetReconnect();
|
|
2891
3230
|
};
|
|
2892
3231
|
}
|
|
@@ -2949,11 +3288,14 @@ var SyncEngine = class {
|
|
|
2949
3288
|
await this.handleOperationBatch(message);
|
|
2950
3289
|
break;
|
|
2951
3290
|
case "acknowledgment":
|
|
2952
|
-
this.handleAcknowledgment(message);
|
|
3291
|
+
await this.handleAcknowledgment(message);
|
|
2953
3292
|
break;
|
|
2954
3293
|
case "error":
|
|
2955
3294
|
this.handleError(message);
|
|
2956
3295
|
break;
|
|
3296
|
+
case "operation-rejected":
|
|
3297
|
+
await this.handleOperationRejected(message);
|
|
3298
|
+
break;
|
|
2957
3299
|
case "awareness-update":
|
|
2958
3300
|
this.handleAwarenessUpdate(message);
|
|
2959
3301
|
break;
|
|
@@ -3036,6 +3378,11 @@ var SyncEngine = class {
|
|
|
3036
3378
|
}
|
|
3037
3379
|
this.remoteVector = wireToVersionVector(msg.versionVector);
|
|
3038
3380
|
void this.persistLastAckedServerVector(this.remoteVector);
|
|
3381
|
+
if (typeof msg.serverMaxDeliverySequence === "number" && this.deliveryWatermark > msg.serverMaxDeliverySequence) {
|
|
3382
|
+
this.deliveryWatermark = 0;
|
|
3383
|
+
this.setViewWatermark(this.deliverySignature(), 0);
|
|
3384
|
+
await this.persistDeliveryWatermark(0);
|
|
3385
|
+
}
|
|
3039
3386
|
if (msg.selectedWireFormat) {
|
|
3040
3387
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
3041
3388
|
}
|
|
@@ -3101,7 +3448,7 @@ var SyncEngine = class {
|
|
|
3101
3448
|
async sendDelta() {
|
|
3102
3449
|
const localVector = this.store.getVersionVector();
|
|
3103
3450
|
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
3104
|
-
const missingOps =
|
|
3451
|
+
const missingOps = await this.filterAllowedForSync(allMissingOps);
|
|
3105
3452
|
this.deltaSentOpIds = missingOps.map((op) => op.id);
|
|
3106
3453
|
if (missingOps.length === 0) {
|
|
3107
3454
|
const messageId = generateMessageId();
|
|
@@ -3176,11 +3523,34 @@ var SyncEngine = class {
|
|
|
3176
3523
|
return missing;
|
|
3177
3524
|
}
|
|
3178
3525
|
async handleOperationBatch(msg) {
|
|
3526
|
+
const isDeliveryBatch = msg.maxDeliverySequence !== void 0;
|
|
3527
|
+
if (isDeliveryBatch) {
|
|
3528
|
+
const base = msg.baseDeliverySequence ?? 0;
|
|
3529
|
+
if (base > this.deliveryWatermark) {
|
|
3530
|
+
const gapKey = `${this.deliveryWatermark}:${base}`;
|
|
3531
|
+
this.deliveryGapRepeatCount = this.lastDeliveryGapKey === gapKey ? this.deliveryGapRepeatCount + 1 : 1;
|
|
3532
|
+
this.lastDeliveryGapKey = gapKey;
|
|
3533
|
+
this.emitter?.emit({
|
|
3534
|
+
type: "sync:delivery-gap",
|
|
3535
|
+
expectedBase: this.deliveryWatermark,
|
|
3536
|
+
receivedBase: base,
|
|
3537
|
+
currentWatermark: this.deliveryWatermark,
|
|
3538
|
+
messageId: msg.messageId,
|
|
3539
|
+
repeatCount: this.deliveryGapRepeatCount
|
|
3540
|
+
});
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
if (base < this.deliveryWatermark) {
|
|
3544
|
+
this.sendDeliveryAck(msg.messageId, this.deliveryWatermark);
|
|
3545
|
+
return;
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3179
3548
|
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
3180
3549
|
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
3181
|
-
const inScopeOps =
|
|
3550
|
+
const inScopeOps = await this.filterAllowedForSync(operations);
|
|
3182
3551
|
const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
3183
3552
|
const transforms = this.config.operationTransforms ?? [];
|
|
3553
|
+
let fullyApplied = true;
|
|
3184
3554
|
for (const op of inScopeOps) {
|
|
3185
3555
|
const transformed = transforms.length > 0 ? applyOperationTransforms(op, targetSchemaVersion, transforms) : op;
|
|
3186
3556
|
if (transformed === null) {
|
|
@@ -3192,6 +3562,7 @@ var SyncEngine = class {
|
|
|
3192
3562
|
this.emitApplyFailure(transformed, result);
|
|
3193
3563
|
}
|
|
3194
3564
|
} catch (error) {
|
|
3565
|
+
fullyApplied = false;
|
|
3195
3566
|
if (error instanceof ClockDriftError) {
|
|
3196
3567
|
this.emitApplyFailure(transformed, "rejected", {
|
|
3197
3568
|
code: APPLY_FAILURE_CODES.CLOCK_DRIFT,
|
|
@@ -3202,11 +3573,8 @@ var SyncEngine = class {
|
|
|
3202
3573
|
}
|
|
3203
3574
|
const message = error instanceof Error ? error.message : "Apply failed";
|
|
3204
3575
|
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;
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
message,
|
|
3208
|
-
retriable: code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
|
|
3209
|
-
});
|
|
3576
|
+
const retriable = code !== APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY;
|
|
3577
|
+
this.emitApplyFailure(transformed, "rejected", { code, message, retriable });
|
|
3210
3578
|
}
|
|
3211
3579
|
}
|
|
3212
3580
|
if (inScopeOps.length > 0) {
|
|
@@ -3217,18 +3585,29 @@ var SyncEngine = class {
|
|
|
3217
3585
|
batchSize: inScopeOps.length
|
|
3218
3586
|
});
|
|
3219
3587
|
}
|
|
3220
|
-
const
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3588
|
+
const deliveryFullyApplied = isDeliveryBatch && fullyApplied;
|
|
3589
|
+
if (!isDeliveryBatch || deliveryFullyApplied) {
|
|
3590
|
+
const lastOp = operations[operations.length - 1];
|
|
3591
|
+
const ack = {
|
|
3592
|
+
type: "acknowledgment",
|
|
3593
|
+
messageId: generateMessageId(),
|
|
3594
|
+
acknowledgedMessageId: msg.messageId,
|
|
3595
|
+
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0,
|
|
3596
|
+
...deliveryFullyApplied && msg.maxDeliverySequence !== void 0 ? { deliverySequence: msg.maxDeliverySequence } : {}
|
|
3597
|
+
};
|
|
3598
|
+
this.transport.send(ack);
|
|
3599
|
+
this.emitter?.emit({
|
|
3600
|
+
type: "sync:acknowledged",
|
|
3601
|
+
sequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
3602
|
+
});
|
|
3603
|
+
}
|
|
3604
|
+
if (isDeliveryBatch && fullyApplied && msg.maxDeliverySequence !== void 0) {
|
|
3605
|
+
this.deliveryGapRepeatCount = 0;
|
|
3606
|
+
this.lastDeliveryGapKey = null;
|
|
3607
|
+
this.deliveryWatermark = msg.maxDeliverySequence;
|
|
3608
|
+
this.setViewWatermark(this.deliverySignature(), this.deliveryWatermark);
|
|
3609
|
+
await this.persistDeliveryWatermark(this.deliveryWatermark);
|
|
3610
|
+
}
|
|
3232
3611
|
if (this.state === "syncing") {
|
|
3233
3612
|
this.deltaBatchesReceived++;
|
|
3234
3613
|
const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
|
|
@@ -3236,15 +3615,17 @@ var SyncEngine = class {
|
|
|
3236
3615
|
this.initialSyncTotalBatches = msg.totalBatches;
|
|
3237
3616
|
}
|
|
3238
3617
|
this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3618
|
+
if (!isDeliveryBatch) {
|
|
3619
|
+
const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
|
|
3620
|
+
inScopeOps.length > 0 ? inScopeOps : operations,
|
|
3621
|
+
msg.batchIndex
|
|
3622
|
+
);
|
|
3623
|
+
if (cursorFromBatch) {
|
|
3624
|
+
this.resumeDeltaCursor = cursorFromBatch;
|
|
3625
|
+
await this.persistDeltaCursor(cursorFromBatch);
|
|
3626
|
+
}
|
|
3246
3627
|
}
|
|
3247
|
-
if (msg.isFinal) {
|
|
3628
|
+
if (msg.isFinal && (!isDeliveryBatch || fullyApplied)) {
|
|
3248
3629
|
this.deltaReceiveComplete = true;
|
|
3249
3630
|
this.resumeDeltaCursor = null;
|
|
3250
3631
|
await this.persistDeltaCursor(null);
|
|
@@ -3253,26 +3634,51 @@ var SyncEngine = class {
|
|
|
3253
3634
|
}
|
|
3254
3635
|
}
|
|
3255
3636
|
}
|
|
3256
|
-
|
|
3637
|
+
/** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
|
|
3638
|
+
sendDeliveryAck(acknowledgedMessageId, deliverySequence) {
|
|
3639
|
+
const ack = {
|
|
3640
|
+
type: "acknowledgment",
|
|
3641
|
+
messageId: generateMessageId(),
|
|
3642
|
+
acknowledgedMessageId,
|
|
3643
|
+
lastSequenceNumber: 0,
|
|
3644
|
+
deliverySequence
|
|
3645
|
+
};
|
|
3646
|
+
this.transport.send(ack);
|
|
3647
|
+
}
|
|
3648
|
+
async handleAcknowledgment(msg) {
|
|
3649
|
+
this.clearOutboundAckTimer();
|
|
3257
3650
|
if (this.state === "syncing" && this.config.strictHandshake) {
|
|
3258
3651
|
this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
|
|
3259
3652
|
this.markDeltaSendCompleteIfReady();
|
|
3260
3653
|
}
|
|
3261
3654
|
if (this.currentBatch) {
|
|
3262
|
-
|
|
3655
|
+
if (this.currentBatchRequiresPartialAck) {
|
|
3656
|
+
await this.outboundQueue.acknowledgeThrough(
|
|
3657
|
+
this.currentBatch.batchId,
|
|
3658
|
+
msg.lastSequenceNumber
|
|
3659
|
+
);
|
|
3660
|
+
} else {
|
|
3661
|
+
await this.outboundQueue.acknowledge(this.currentBatch.batchId);
|
|
3662
|
+
}
|
|
3263
3663
|
this.currentBatch = null;
|
|
3664
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3264
3665
|
const now = Date.now();
|
|
3265
3666
|
this.lastSyncedAt = now;
|
|
3266
3667
|
this.lastSuccessfulPush = now;
|
|
3267
3668
|
}
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
);
|
|
3669
|
+
await this.advanceLastAckedForLocalNode(msg.lastSequenceNumber);
|
|
3670
|
+
await this.refreshPendingCount();
|
|
3271
3671
|
if (this.state === "streaming" && this.outboundQueue.hasOperations) {
|
|
3272
3672
|
this.flushQueue();
|
|
3273
3673
|
}
|
|
3274
3674
|
}
|
|
3275
3675
|
handleError(msg) {
|
|
3676
|
+
this.clearOutboundAckTimer();
|
|
3677
|
+
if (this.currentBatch) {
|
|
3678
|
+
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3679
|
+
this.currentBatch = null;
|
|
3680
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3681
|
+
}
|
|
3276
3682
|
this.transitionTo("error");
|
|
3277
3683
|
if (msg.code === "AUTH_FAILED") {
|
|
3278
3684
|
this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
|
|
@@ -3291,6 +3697,64 @@ var SyncEngine = class {
|
|
|
3291
3697
|
this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
|
|
3292
3698
|
this.transitionTo("disconnected");
|
|
3293
3699
|
}
|
|
3700
|
+
/**
|
|
3701
|
+
* Handle a per-operation rejection: divert the op out of the pending outbound
|
|
3702
|
+
* queue (so it is never retried or resurrected on reconnect), record it in the
|
|
3703
|
+
* durable rejected store (so it is kept and explainable), and emit an event so
|
|
3704
|
+
* the app can reconcile. Unlike {@link handleError}, this is a normal per-op
|
|
3705
|
+
* signal, so the connection stays up.
|
|
3706
|
+
*/
|
|
3707
|
+
async handleOperationRejected(msg) {
|
|
3708
|
+
if (msg.retriable) {
|
|
3709
|
+
this.currentBatchRequiresPartialAck = true;
|
|
3710
|
+
await this.refreshPendingCount();
|
|
3711
|
+
this.emitter?.emit({
|
|
3712
|
+
type: "sync:operation-rejected",
|
|
3713
|
+
operationId: msg.operationId,
|
|
3714
|
+
collection: msg.collection,
|
|
3715
|
+
recordId: msg.recordId,
|
|
3716
|
+
code: msg.code,
|
|
3717
|
+
message: msg.message,
|
|
3718
|
+
retriable: true
|
|
3719
|
+
});
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
await this.outboundQueue.reject(msg.operationId);
|
|
3723
|
+
await this.rejectedStorage.record({
|
|
3724
|
+
operationId: msg.operationId,
|
|
3725
|
+
collection: msg.collection,
|
|
3726
|
+
recordId: msg.recordId,
|
|
3727
|
+
code: msg.code,
|
|
3728
|
+
message: msg.message,
|
|
3729
|
+
retriable: msg.retriable,
|
|
3730
|
+
rejectedAt: Date.now()
|
|
3731
|
+
});
|
|
3732
|
+
await this.refreshPendingCount();
|
|
3733
|
+
this.emitter?.emit({
|
|
3734
|
+
type: "sync:operation-rejected",
|
|
3735
|
+
operationId: msg.operationId,
|
|
3736
|
+
collection: msg.collection,
|
|
3737
|
+
recordId: msg.recordId,
|
|
3738
|
+
code: msg.code,
|
|
3739
|
+
message: msg.message,
|
|
3740
|
+
retriable: msg.retriable
|
|
3741
|
+
});
|
|
3742
|
+
}
|
|
3743
|
+
/**
|
|
3744
|
+
* Every operation the server rejected that has not yet been reconciled. The app
|
|
3745
|
+
* uses this (alongside the `sync:operation-rejected` event) to surface failed
|
|
3746
|
+
* submissions and decide whether to roll back or resubmit.
|
|
3747
|
+
*/
|
|
3748
|
+
getRejectedOperations() {
|
|
3749
|
+
return this.rejectedStorage.list();
|
|
3750
|
+
}
|
|
3751
|
+
/**
|
|
3752
|
+
* Forget rejected operations by id once the app has reconciled them (rolled the
|
|
3753
|
+
* optimistic write back or resubmitted a corrected op).
|
|
3754
|
+
*/
|
|
3755
|
+
clearRejectedOperations(operationIds) {
|
|
3756
|
+
return this.rejectedStorage.remove(operationIds);
|
|
3757
|
+
}
|
|
3294
3758
|
async checkDeltaComplete() {
|
|
3295
3759
|
if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
|
|
3296
3760
|
return;
|
|
@@ -3389,7 +3853,7 @@ var SyncEngine = class {
|
|
|
3389
3853
|
return;
|
|
3390
3854
|
}
|
|
3391
3855
|
const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
|
|
3392
|
-
const inScope =
|
|
3856
|
+
const inScope = await this.filterAllowedForSync(ops);
|
|
3393
3857
|
for (const op of inScope) {
|
|
3394
3858
|
await this.outboundQueue.enqueue(op);
|
|
3395
3859
|
}
|
|
@@ -3400,6 +3864,7 @@ var SyncEngine = class {
|
|
|
3400
3864
|
const batch = this.outboundQueue.takeBatch(this.batchSize);
|
|
3401
3865
|
if (!batch) return;
|
|
3402
3866
|
this.currentBatch = batch;
|
|
3867
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3403
3868
|
if (this.encryptor) {
|
|
3404
3869
|
this.encryptor.encryptBatch(batch.operations).then(
|
|
3405
3870
|
(encrypted) => {
|
|
@@ -3412,6 +3877,7 @@ var SyncEngine = class {
|
|
|
3412
3877
|
batchIndex: 0
|
|
3413
3878
|
};
|
|
3414
3879
|
this.transport.send(batchMsg);
|
|
3880
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3415
3881
|
this.emitter?.emit({
|
|
3416
3882
|
type: "sync:sent",
|
|
3417
3883
|
operations: batch.operations,
|
|
@@ -3421,6 +3887,7 @@ var SyncEngine = class {
|
|
|
3421
3887
|
(err) => {
|
|
3422
3888
|
this.outboundQueue.returnBatch(batch.batchId);
|
|
3423
3889
|
this.currentBatch = null;
|
|
3890
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3424
3891
|
this.emitter?.emit({
|
|
3425
3892
|
type: "sync:disconnected",
|
|
3426
3893
|
reason: err instanceof Error ? err.message : "Encryption failed"
|
|
@@ -3437,6 +3904,7 @@ var SyncEngine = class {
|
|
|
3437
3904
|
batchIndex: 0
|
|
3438
3905
|
};
|
|
3439
3906
|
this.transport.send(batchMsg);
|
|
3907
|
+
this.startOutboundAckTimer(batch.batchId);
|
|
3440
3908
|
this.emitter?.emit({
|
|
3441
3909
|
type: "sync:sent",
|
|
3442
3910
|
operations: batch.operations,
|
|
@@ -3444,6 +3912,34 @@ var SyncEngine = class {
|
|
|
3444
3912
|
});
|
|
3445
3913
|
}
|
|
3446
3914
|
}
|
|
3915
|
+
startOutboundAckTimer(batchId) {
|
|
3916
|
+
this.clearOutboundAckTimer();
|
|
3917
|
+
if (this.outboundAckTimeoutMs <= 0) return;
|
|
3918
|
+
this.outboundAckTimer = setTimeout(() => {
|
|
3919
|
+
void this.handleOutboundAckTimeout(batchId);
|
|
3920
|
+
}, this.outboundAckTimeoutMs);
|
|
3921
|
+
}
|
|
3922
|
+
clearOutboundAckTimer() {
|
|
3923
|
+
if (!this.outboundAckTimer) return;
|
|
3924
|
+
clearTimeout(this.outboundAckTimer);
|
|
3925
|
+
this.outboundAckTimer = null;
|
|
3926
|
+
}
|
|
3927
|
+
async handleOutboundAckTimeout(batchId) {
|
|
3928
|
+
if (!this.currentBatch || this.currentBatch.batchId !== batchId) return;
|
|
3929
|
+
this.outboundQueue.returnBatch(batchId);
|
|
3930
|
+
this.currentBatch = null;
|
|
3931
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3932
|
+
this.clearOutboundAckTimer();
|
|
3933
|
+
await this.refreshPendingCount();
|
|
3934
|
+
if (this.state === "disconnected") return;
|
|
3935
|
+
const reason = `Timed out waiting for acknowledgment of outbound batch ${batchId}`;
|
|
3936
|
+
try {
|
|
3937
|
+
await this.transport.disconnect();
|
|
3938
|
+
} finally {
|
|
3939
|
+
this.ensureDisconnected();
|
|
3940
|
+
this.emitter?.emit({ type: "sync:disconnected", reason });
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3447
3943
|
handleAwarenessUpdate(msg) {
|
|
3448
3944
|
const awarenessMessage = {
|
|
3449
3945
|
type: "awareness",
|
|
@@ -3453,9 +3949,11 @@ var SyncEngine = class {
|
|
|
3453
3949
|
this.awarenessManager.handleRemoteMessage(awarenessMessage);
|
|
3454
3950
|
}
|
|
3455
3951
|
handleTransportClose(reason) {
|
|
3952
|
+
this.clearOutboundAckTimer();
|
|
3456
3953
|
if (this.currentBatch) {
|
|
3457
3954
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
3458
3955
|
this.currentBatch = null;
|
|
3956
|
+
this.currentBatchRequiresPartialAck = false;
|
|
3459
3957
|
}
|
|
3460
3958
|
if (this.schemaBlocked) {
|
|
3461
3959
|
return;
|
|
@@ -3487,11 +3985,40 @@ var SyncEngine = class {
|
|
|
3487
3985
|
this.serializer.setWireFormat(format);
|
|
3488
3986
|
}
|
|
3489
3987
|
}
|
|
3490
|
-
operationAllowedForSync(op) {
|
|
3491
|
-
if (
|
|
3988
|
+
async operationAllowedForSync(op) {
|
|
3989
|
+
if (this.matchesScopeAndSubsets(op)) {
|
|
3990
|
+
return true;
|
|
3991
|
+
}
|
|
3992
|
+
const fullRecord = await this.readRecordForBackfill(op);
|
|
3993
|
+
if (!fullRecord) {
|
|
3994
|
+
return false;
|
|
3995
|
+
}
|
|
3996
|
+
return this.matchesScopeAndSubsets(op, fullRecord);
|
|
3997
|
+
}
|
|
3998
|
+
matchesScopeAndSubsets(op, fullRecord) {
|
|
3999
|
+
if (!operationMatchesScope(op, this.activeScope, fullRecord)) {
|
|
3492
4000
|
return false;
|
|
3493
4001
|
}
|
|
3494
|
-
return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets());
|
|
4002
|
+
return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets(), fullRecord);
|
|
4003
|
+
}
|
|
4004
|
+
async readRecordForBackfill(op) {
|
|
4005
|
+
if (!this.store.readRecordFields) {
|
|
4006
|
+
return null;
|
|
4007
|
+
}
|
|
4008
|
+
try {
|
|
4009
|
+
return await this.store.readRecordFields(op.collection, op.recordId);
|
|
4010
|
+
} catch {
|
|
4011
|
+
return null;
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
async filterAllowedForSync(ops) {
|
|
4015
|
+
const allowed = [];
|
|
4016
|
+
for (const op of ops) {
|
|
4017
|
+
if (await this.operationAllowedForSync(op)) {
|
|
4018
|
+
allowed.push(op);
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
return allowed;
|
|
3495
4022
|
}
|
|
3496
4023
|
async persistDeltaCursor(cursor) {
|
|
3497
4024
|
if (!this.syncState?.saveDeltaCursor) {
|
|
@@ -3499,6 +4026,12 @@ var SyncEngine = class {
|
|
|
3499
4026
|
}
|
|
3500
4027
|
await this.syncState.saveDeltaCursor(cursor);
|
|
3501
4028
|
}
|
|
4029
|
+
async persistDeliveryWatermark(watermark, signature = this.deliverySignature()) {
|
|
4030
|
+
if (!this.syncState?.saveDeliveryWatermark) {
|
|
4031
|
+
return;
|
|
4032
|
+
}
|
|
4033
|
+
await this.syncState.saveDeliveryWatermark(signature, watermark);
|
|
4034
|
+
}
|
|
3502
4035
|
scheduleQuerySubsetReconnect() {
|
|
3503
4036
|
if (this.querySubsetReconnectTimer) {
|
|
3504
4037
|
clearTimeout(this.querySubsetReconnectTimer);
|
|
@@ -3511,8 +4044,7 @@ var SyncEngine = class {
|
|
|
3511
4044
|
}, 500);
|
|
3512
4045
|
}
|
|
3513
4046
|
async reconnectForQuerySubsets() {
|
|
3514
|
-
await this.
|
|
3515
|
-
await this.start();
|
|
4047
|
+
await this.reconnect();
|
|
3516
4048
|
}
|
|
3517
4049
|
};
|
|
3518
4050
|
function awarenessStatesToWire(states) {
|
|
@@ -4193,6 +4725,7 @@ function fromBase642(str) {
|
|
|
4193
4725
|
// src/reactivity/sync-status-controller.ts
|
|
4194
4726
|
var OFFLINE_SYNC_STATUS = Object.freeze({
|
|
4195
4727
|
status: "offline",
|
|
4728
|
+
reconnecting: false,
|
|
4196
4729
|
pendingOperations: 0,
|
|
4197
4730
|
lastSyncedAt: null,
|
|
4198
4731
|
lastSuccessfulPush: null,
|
|
@@ -4210,6 +4743,7 @@ var SYNC_STATUS_EVENT_TYPES = [
|
|
|
4210
4743
|
"sync:received",
|
|
4211
4744
|
"sync:acknowledged",
|
|
4212
4745
|
"sync:apply-failed",
|
|
4746
|
+
"sync:delivery-gap",
|
|
4213
4747
|
"sync:diagnostics",
|
|
4214
4748
|
"sync:initial-sync-progress"
|
|
4215
4749
|
];
|
|
@@ -4355,6 +4889,7 @@ export {
|
|
|
4355
4889
|
InvalidScopeError,
|
|
4356
4890
|
JsonMessageSerializer,
|
|
4357
4891
|
KeyDerivationError,
|
|
4892
|
+
MemoryRejectedOperationStorage,
|
|
4358
4893
|
NegotiatedMessageSerializer,
|
|
4359
4894
|
OFFLINE_SYNC_STATUS,
|
|
4360
4895
|
OutboundQueue,
|
|
@@ -4393,6 +4928,7 @@ export {
|
|
|
4393
4928
|
isHandshakeMessage,
|
|
4394
4929
|
isHandshakeResponseMessage,
|
|
4395
4930
|
isOperationBatchMessage,
|
|
4931
|
+
isOperationRejectedMessage,
|
|
4396
4932
|
isSchemaMismatchReject,
|
|
4397
4933
|
isSyncMessage,
|
|
4398
4934
|
isYjsDocUpdateMessage,
|