@korajs/sync 0.6.0 → 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.cjs +197 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +70 -6
- package/dist/index.d.ts +70 -6
- package/dist/index.js +197 -18
- package/dist/index.js.map +1 -1
- package/dist/internal.d.cts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/{transport-BrdBj7rH.d.cts → transport-BMesMB6s.d.cts} +5 -1
- package/dist/{transport-BrdBj7rH.d.ts → transport-BMesMB6s.d.ts} +5 -1
- package/package.json +3 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dr. Obed Ehoneah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.cjs
CHANGED
|
@@ -101,6 +101,7 @@ var SYNC_STATUSES = [
|
|
|
101
101
|
"syncing",
|
|
102
102
|
"synced",
|
|
103
103
|
"offline",
|
|
104
|
+
"clock-error",
|
|
104
105
|
"error",
|
|
105
106
|
"schema-mismatch"
|
|
106
107
|
];
|
|
@@ -347,7 +348,7 @@ function isYjsDocUpdateMessage(value) {
|
|
|
347
348
|
|
|
348
349
|
// src/protocol/serializer.ts
|
|
349
350
|
var import_core2 = require("@korajs/core");
|
|
350
|
-
var import_minimal = __toESM(require("protobufjs/minimal"), 1);
|
|
351
|
+
var import_minimal = __toESM(require("protobufjs/minimal.js"), 1);
|
|
351
352
|
var { Reader, Writer } = import_minimal.default;
|
|
352
353
|
function versionVectorToWire(vector) {
|
|
353
354
|
const wire = {};
|
|
@@ -583,7 +584,8 @@ function toProtoEnvelope(message) {
|
|
|
583
584
|
schemaVersion: message.schemaVersion,
|
|
584
585
|
accepted: message.accepted,
|
|
585
586
|
rejectReason: message.rejectReason,
|
|
586
|
-
selectedWireFormat: message.selectedWireFormat
|
|
587
|
+
selectedWireFormat: message.selectedWireFormat,
|
|
588
|
+
serverTime: message.serverTime
|
|
587
589
|
};
|
|
588
590
|
case "operation-batch":
|
|
589
591
|
return {
|
|
@@ -643,7 +645,10 @@ function fromProtoEnvelope(envelope) {
|
|
|
643
645
|
schemaVersion: envelope.schemaVersion ?? 0,
|
|
644
646
|
accepted: envelope.accepted ?? false,
|
|
645
647
|
rejectReason: envelope.rejectReason,
|
|
646
|
-
selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0
|
|
648
|
+
selectedWireFormat: envelope.selectedWireFormat === "json" || envelope.selectedWireFormat === "protobuf" ? envelope.selectedWireFormat : void 0,
|
|
649
|
+
// Preserve the server's wall-clock time so clock-skew detection and
|
|
650
|
+
// automatic timestamp rebase work over the protobuf wire, not just JSON.
|
|
651
|
+
...envelope.serverTime !== void 0 ? { serverTime: envelope.serverTime } : {}
|
|
647
652
|
};
|
|
648
653
|
case "operation-batch":
|
|
649
654
|
return {
|
|
@@ -812,6 +817,7 @@ function encodeEnvelope(envelope) {
|
|
|
812
817
|
writer.uint32(138).string(envelope.errorMessage);
|
|
813
818
|
}
|
|
814
819
|
if (envelope.retriable !== void 0) writer.uint32(144).bool(envelope.retriable);
|
|
820
|
+
if (envelope.serverTime !== void 0) writer.uint32(152).int64(envelope.serverTime);
|
|
815
821
|
return writer.finish();
|
|
816
822
|
}
|
|
817
823
|
function decodeEnvelope(bytes) {
|
|
@@ -880,6 +886,9 @@ function decodeEnvelope(bytes) {
|
|
|
880
886
|
case 18:
|
|
881
887
|
envelope.retriable = reader.bool();
|
|
882
888
|
break;
|
|
889
|
+
case 19:
|
|
890
|
+
envelope.serverTime = longToNumber(reader.int64());
|
|
891
|
+
break;
|
|
883
892
|
default:
|
|
884
893
|
reader.skipType(tag & 7);
|
|
885
894
|
}
|
|
@@ -1381,7 +1390,7 @@ var ChaosTransport = class {
|
|
|
1381
1390
|
random;
|
|
1382
1391
|
messageHandler = null;
|
|
1383
1392
|
reorderBuffer = [];
|
|
1384
|
-
|
|
1393
|
+
pending = [];
|
|
1385
1394
|
constructor(inner, config) {
|
|
1386
1395
|
this.inner = inner;
|
|
1387
1396
|
this.dropRate = config?.dropRate ?? 0;
|
|
@@ -1396,10 +1405,14 @@ var ChaosTransport = class {
|
|
|
1396
1405
|
}
|
|
1397
1406
|
async disconnect() {
|
|
1398
1407
|
this.flushReorderBuffer();
|
|
1399
|
-
|
|
1408
|
+
const flushed = this.pending;
|
|
1409
|
+
this.pending = [];
|
|
1410
|
+
for (const { timer } of flushed) {
|
|
1400
1411
|
clearTimeout(timer);
|
|
1401
1412
|
}
|
|
1402
|
-
|
|
1413
|
+
for (const { message } of flushed) {
|
|
1414
|
+
this.deliverIncoming(message);
|
|
1415
|
+
}
|
|
1403
1416
|
return this.inner.disconnect();
|
|
1404
1417
|
}
|
|
1405
1418
|
send(message) {
|
|
@@ -1436,9 +1449,10 @@ var ChaosTransport = class {
|
|
|
1436
1449
|
if (this.maxLatency > 0) {
|
|
1437
1450
|
const delay = Math.floor(this.random() * this.maxLatency);
|
|
1438
1451
|
const timer = setTimeout(() => {
|
|
1452
|
+
this.pending = this.pending.filter((p) => p.timer !== timer);
|
|
1439
1453
|
this.deliverIncoming(message);
|
|
1440
1454
|
}, delay);
|
|
1441
|
-
this.
|
|
1455
|
+
this.pending.push({ timer, message });
|
|
1442
1456
|
return;
|
|
1443
1457
|
}
|
|
1444
1458
|
this.deliverIncoming(message);
|
|
@@ -2345,6 +2359,41 @@ var OutboundQueue = class {
|
|
|
2345
2359
|
peek(count) {
|
|
2346
2360
|
return this.queue.slice(0, count);
|
|
2347
2361
|
}
|
|
2362
|
+
/**
|
|
2363
|
+
* All queued operations (not counting in-flight), in causal order.
|
|
2364
|
+
*/
|
|
2365
|
+
getAll() {
|
|
2366
|
+
return [...this.queue];
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Atomically replace the entire queue with a new set of operations.
|
|
2370
|
+
*
|
|
2371
|
+
* Used after a timestamp rebase rewrote the queued operations under new
|
|
2372
|
+
* content-addressed ids: the old entries must vanish from memory AND from
|
|
2373
|
+
* persistent storage in one step, or a page refresh could resurrect
|
|
2374
|
+
* stale-stamped ops the server would reject. Resets the seen set to exactly
|
|
2375
|
+
* the new ids so the rewritten ops are not treated as duplicates.
|
|
2376
|
+
*/
|
|
2377
|
+
async replaceAll(ops) {
|
|
2378
|
+
const removeIds = this.queue.map((op) => op.id);
|
|
2379
|
+
for (const batch of this.inFlight.values()) {
|
|
2380
|
+
for (const op of batch) {
|
|
2381
|
+
removeIds.push(op.id);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
this.inFlight.clear();
|
|
2385
|
+
this.queue = ops.length > 1 ? (0, import_internal.topologicalSort)([...ops]) : [...ops];
|
|
2386
|
+
this.seen.clear();
|
|
2387
|
+
for (const op of this.queue) {
|
|
2388
|
+
this.seen.add(op.id);
|
|
2389
|
+
}
|
|
2390
|
+
if (removeIds.length > 0) {
|
|
2391
|
+
await this.storage.dequeue(removeIds);
|
|
2392
|
+
}
|
|
2393
|
+
for (const op of this.queue) {
|
|
2394
|
+
await this.storage.enqueue(op);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2348
2397
|
/**
|
|
2349
2398
|
* Whether initialize() has been called.
|
|
2350
2399
|
*/
|
|
@@ -2406,6 +2455,8 @@ var SyncEngine = class {
|
|
|
2406
2455
|
currentBatch = null;
|
|
2407
2456
|
reconnecting = false;
|
|
2408
2457
|
schemaBlocked = false;
|
|
2458
|
+
clockBlocked = false;
|
|
2459
|
+
clockSkewMs = null;
|
|
2409
2460
|
// Track delta exchange state
|
|
2410
2461
|
deltaBatchesReceived = 0;
|
|
2411
2462
|
deltaReceiveComplete = false;
|
|
@@ -2579,10 +2630,17 @@ var SyncEngine = class {
|
|
|
2579
2630
|
lastSyncedAt: this.lastSyncedAt,
|
|
2580
2631
|
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
2581
2632
|
lastSuccessfulPull: this.lastSuccessfulPull,
|
|
2582
|
-
conflicts: this.conflictCount
|
|
2633
|
+
conflicts: this.conflictCount,
|
|
2634
|
+
clockSkewMs: this.clockSkewMs
|
|
2583
2635
|
};
|
|
2584
2636
|
switch (this.state) {
|
|
2585
2637
|
case "disconnected":
|
|
2638
|
+
if (this.clockBlocked) {
|
|
2639
|
+
return { ...base, status: "clock-error" };
|
|
2640
|
+
}
|
|
2641
|
+
if (this.schemaBlocked) {
|
|
2642
|
+
return { ...base, status: "schema-mismatch" };
|
|
2643
|
+
}
|
|
2586
2644
|
return { ...base, status: "offline" };
|
|
2587
2645
|
case "connecting":
|
|
2588
2646
|
case "handshaking":
|
|
@@ -2591,6 +2649,9 @@ var SyncEngine = class {
|
|
|
2591
2649
|
case "streaming":
|
|
2592
2650
|
return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
|
|
2593
2651
|
case "error":
|
|
2652
|
+
if (this.clockBlocked) {
|
|
2653
|
+
return { ...base, status: "clock-error" };
|
|
2654
|
+
}
|
|
2594
2655
|
return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
|
|
2595
2656
|
}
|
|
2596
2657
|
}
|
|
@@ -2601,6 +2662,27 @@ var SyncEngine = class {
|
|
|
2601
2662
|
isSchemaBlocked() {
|
|
2602
2663
|
return this.schemaBlocked;
|
|
2603
2664
|
}
|
|
2665
|
+
/**
|
|
2666
|
+
* True when sync is blocked because this device's clock is too far ahead.
|
|
2667
|
+
* Local writes continue to work and queue; only sync is paused.
|
|
2668
|
+
*/
|
|
2669
|
+
isClockBlocked() {
|
|
2670
|
+
return this.clockBlocked;
|
|
2671
|
+
}
|
|
2672
|
+
/** serverTime - localTime measured at the last handshake, or null before first connect. */
|
|
2673
|
+
getClockSkewMs() {
|
|
2674
|
+
return this.clockSkewMs;
|
|
2675
|
+
}
|
|
2676
|
+
/**
|
|
2677
|
+
* Clears the clock block after the user corrects the device clock.
|
|
2678
|
+
* Moves the engine back to `disconnected` so `start()` can run again.
|
|
2679
|
+
*/
|
|
2680
|
+
clearClockBlock() {
|
|
2681
|
+
this.clockBlocked = false;
|
|
2682
|
+
if (this.state === "error") {
|
|
2683
|
+
this.transitionTo("disconnected");
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2604
2686
|
/**
|
|
2605
2687
|
* Clears schema-mismatch block after upgrading the local schema / sync config.
|
|
2606
2688
|
* Moves the engine back to `disconnected` so `start()` can run again.
|
|
@@ -2744,7 +2826,7 @@ var SyncEngine = class {
|
|
|
2744
2826
|
async handleMessageAsync(message) {
|
|
2745
2827
|
switch (message.type) {
|
|
2746
2828
|
case "handshake-response":
|
|
2747
|
-
this.handleHandshakeResponse(message);
|
|
2829
|
+
await this.handleHandshakeResponse(message);
|
|
2748
2830
|
break;
|
|
2749
2831
|
case "operation-batch":
|
|
2750
2832
|
await this.handleOperationBatch(message);
|
|
@@ -2767,8 +2849,41 @@ var SyncEngine = class {
|
|
|
2767
2849
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
2768
2850
|
this.handleTransportClose(reason);
|
|
2769
2851
|
}
|
|
2770
|
-
|
|
2852
|
+
/**
|
|
2853
|
+
* Compares server wall-clock time from the handshake with local time.
|
|
2854
|
+
* Negative skew = this device's clock is fast (dangerous for LWW and rejected
|
|
2855
|
+
* by server ingest beyond 60s). Positive skew = slow (accepted but surfaced).
|
|
2856
|
+
* Zero developer work required: the result flows into sync status and events.
|
|
2857
|
+
*/
|
|
2858
|
+
evaluateClockSkew(serverTime) {
|
|
2859
|
+
const skewMs = serverTime - Date.now();
|
|
2860
|
+
this.clockSkewMs = skewMs;
|
|
2861
|
+
const FAST_BLOCK_MS = 6e4;
|
|
2862
|
+
const SLOW_WARN_MS = 10 * 6e4;
|
|
2863
|
+
let severity = "info";
|
|
2864
|
+
if (skewMs < -FAST_BLOCK_MS) {
|
|
2865
|
+
severity = "fast-blocked";
|
|
2866
|
+
} else if (skewMs > SLOW_WARN_MS) {
|
|
2867
|
+
severity = "slow-warning";
|
|
2868
|
+
}
|
|
2869
|
+
this.emitter?.emit({ type: "sync:clock-skew", skewMs, severity, source: "handshake" });
|
|
2870
|
+
if (severity === "fast-blocked") {
|
|
2871
|
+
this.clockBlocked = true;
|
|
2872
|
+
} else {
|
|
2873
|
+
this.clockBlocked = false;
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
async handleHandshakeResponse(msg) {
|
|
2771
2877
|
if (this.state !== "handshaking") return;
|
|
2878
|
+
if (typeof msg.serverTime === "number") {
|
|
2879
|
+
this.evaluateClockSkew(msg.serverTime);
|
|
2880
|
+
if (this.clockBlocked) {
|
|
2881
|
+
this.metricsCollector.updateStatus("error");
|
|
2882
|
+
this.transitionTo("error");
|
|
2883
|
+
void this.transport.disconnect();
|
|
2884
|
+
return;
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2772
2887
|
if (!msg.accepted) {
|
|
2773
2888
|
const reason = msg.rejectReason ?? "Handshake rejected";
|
|
2774
2889
|
if (isSchemaMismatchReject(msg.rejectReason)) {
|
|
@@ -2815,8 +2930,51 @@ var SyncEngine = class {
|
|
|
2815
2930
|
this.deltaSentOpIds = [];
|
|
2816
2931
|
this.pendingDeltaBatchAcks.clear();
|
|
2817
2932
|
this.initialSyncTotalBatches = 0;
|
|
2933
|
+
if (typeof msg.serverTime === "number") {
|
|
2934
|
+
await this.maybeRebaseQueuedOperations(msg.serverTime);
|
|
2935
|
+
}
|
|
2818
2936
|
this.sendDelta();
|
|
2819
2937
|
}
|
|
2938
|
+
/**
|
|
2939
|
+
* Re-stamps queued (never-acknowledged) operations whose timestamps are far
|
|
2940
|
+
* enough in the future that the server would reject them, using the server's
|
|
2941
|
+
* own handshake time as the trusted "now". This is the automatic recovery
|
|
2942
|
+
* path after a user corrects a fast device clock: the queue drains
|
|
2943
|
+
* immediately instead of waiting for real time to catch up.
|
|
2944
|
+
*/
|
|
2945
|
+
async maybeRebaseQueuedOperations(serverTime) {
|
|
2946
|
+
const rebase = this.store.rebaseUnsyncedOperations?.bind(this.store);
|
|
2947
|
+
if (!rebase) return;
|
|
2948
|
+
const queued = this.outboundQueue.getAll();
|
|
2949
|
+
if (queued.length === 0) return;
|
|
2950
|
+
let maxQueuedWallTime = Number.NEGATIVE_INFINITY;
|
|
2951
|
+
for (const op of queued) {
|
|
2952
|
+
if (op.timestamp.wallTime > maxQueuedWallTime) {
|
|
2953
|
+
maxQueuedWallTime = op.timestamp.wallTime;
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
const SERVER_FUTURE_TOLERANCE_MS = 6e4;
|
|
2957
|
+
if (maxQueuedWallTime <= serverTime + SERVER_FUTURE_TOLERANCE_MS) return;
|
|
2958
|
+
try {
|
|
2959
|
+
const result = await rebase(
|
|
2960
|
+
queued.map((op) => op.id),
|
|
2961
|
+
serverTime
|
|
2962
|
+
);
|
|
2963
|
+
await this.outboundQueue.replaceAll(result.operations);
|
|
2964
|
+
this.emitter?.emit({
|
|
2965
|
+
type: "sync:clock-rebase",
|
|
2966
|
+
rebasedCount: result.rebasedCount,
|
|
2967
|
+
maxSkewMs: maxQueuedWallTime - serverTime
|
|
2968
|
+
});
|
|
2969
|
+
} catch (error) {
|
|
2970
|
+
this.emitter?.emit({
|
|
2971
|
+
type: "store:persistence-error",
|
|
2972
|
+
dbName: "kora-oplog",
|
|
2973
|
+
message: error instanceof Error ? error.message : "Timestamp rebase failed",
|
|
2974
|
+
code: "CLOCK_REBASE_FAILED"
|
|
2975
|
+
});
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2820
2978
|
async sendDelta() {
|
|
2821
2979
|
const localVector = this.store.getVersionVector();
|
|
2822
2980
|
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
@@ -2996,6 +3154,17 @@ var SyncEngine = class {
|
|
|
2996
3154
|
if (msg.code === "AUTH_FAILED") {
|
|
2997
3155
|
this.emitter?.emit({ type: "sync:auth-failed", reason: msg.message });
|
|
2998
3156
|
}
|
|
3157
|
+
if (msg.code === "INVALID_TIMESTAMP") {
|
|
3158
|
+
this.clockBlocked = true;
|
|
3159
|
+
this.emitter?.emit({
|
|
3160
|
+
type: "sync:clock-skew",
|
|
3161
|
+
skewMs: this.clockSkewMs ?? Number.NaN,
|
|
3162
|
+
severity: "fast-blocked",
|
|
3163
|
+
source: "server-reject"
|
|
3164
|
+
});
|
|
3165
|
+
this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
|
|
3166
|
+
return;
|
|
3167
|
+
}
|
|
2999
3168
|
this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
|
|
3000
3169
|
this.transitionTo("disconnected");
|
|
3001
3170
|
}
|
|
@@ -3476,7 +3645,7 @@ var KeyDerivationError = class extends import_core7.SyncError {
|
|
|
3476
3645
|
}
|
|
3477
3646
|
};
|
|
3478
3647
|
var SALT_LENGTH = 32;
|
|
3479
|
-
var
|
|
3648
|
+
var DEFAULT_PBKDF2_ITERATIONS = 6e5;
|
|
3480
3649
|
var DERIVED_KEY_LENGTH = 256;
|
|
3481
3650
|
function assertCryptoAvailable() {
|
|
3482
3651
|
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
@@ -3493,13 +3662,19 @@ function generateSalt() {
|
|
|
3493
3662
|
}
|
|
3494
3663
|
return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
3495
3664
|
}
|
|
3496
|
-
async function deriveKey(passphrase, salt) {
|
|
3665
|
+
async function deriveKey(passphrase, salt, iterations = DEFAULT_PBKDF2_ITERATIONS) {
|
|
3497
3666
|
assertCryptoAvailable();
|
|
3498
3667
|
if (passphrase.length === 0) {
|
|
3499
3668
|
throw new KeyDerivationError(
|
|
3500
3669
|
"Passphrase must not be empty. Provide a non-empty string for encryption key derivation."
|
|
3501
3670
|
);
|
|
3502
3671
|
}
|
|
3672
|
+
if (!Number.isInteger(iterations) || iterations < 1) {
|
|
3673
|
+
throw new KeyDerivationError(
|
|
3674
|
+
`PBKDF2 iteration count must be a positive integer, received: ${iterations}`,
|
|
3675
|
+
{ iterations }
|
|
3676
|
+
);
|
|
3677
|
+
}
|
|
3503
3678
|
const usedSalt = salt ?? generateSalt();
|
|
3504
3679
|
try {
|
|
3505
3680
|
const passphraseBytes = new TextEncoder().encode(passphrase);
|
|
@@ -3514,7 +3689,7 @@ async function deriveKey(passphrase, salt) {
|
|
|
3514
3689
|
{
|
|
3515
3690
|
name: "PBKDF2",
|
|
3516
3691
|
salt: usedSalt,
|
|
3517
|
-
iterations
|
|
3692
|
+
iterations,
|
|
3518
3693
|
hash: "SHA-256"
|
|
3519
3694
|
},
|
|
3520
3695
|
baseKey,
|
|
@@ -3534,13 +3709,13 @@ async function deriveKey(passphrase, salt) {
|
|
|
3534
3709
|
);
|
|
3535
3710
|
}
|
|
3536
3711
|
}
|
|
3537
|
-
async function deriveVersionedKey(passphrase, version, salt) {
|
|
3712
|
+
async function deriveVersionedKey(passphrase, version, salt, iterations = DEFAULT_PBKDF2_ITERATIONS) {
|
|
3538
3713
|
if (!Number.isInteger(version) || version < 1) {
|
|
3539
3714
|
throw new KeyDerivationError(`Key version must be a positive integer, received: ${version}`, {
|
|
3540
3715
|
version
|
|
3541
3716
|
});
|
|
3542
3717
|
}
|
|
3543
|
-
const { key, salt: usedSalt } = await deriveKey(passphrase, salt);
|
|
3718
|
+
const { key, salt: usedSalt } = await deriveKey(passphrase, salt, iterations);
|
|
3544
3719
|
return { version, key, salt: usedSalt };
|
|
3545
3720
|
}
|
|
3546
3721
|
|
|
@@ -3580,11 +3755,13 @@ var SyncEncryptor = class _SyncEncryptor {
|
|
|
3580
3755
|
* @param config - Encryption configuration with passphrase
|
|
3581
3756
|
* @param salt - Optional salt for deterministic key derivation (mainly for testing).
|
|
3582
3757
|
* If omitted, a random salt is generated.
|
|
3758
|
+
* @param iterations - Optional PBKDF2 iteration count. Defaults to the
|
|
3759
|
+
* production-strength value. Lower it only in tests.
|
|
3583
3760
|
* @returns A configured SyncEncryptor instance
|
|
3584
3761
|
* @throws {EncryptionError} If configuration is invalid
|
|
3585
3762
|
* @throws {KeyDerivationError} If key derivation fails
|
|
3586
3763
|
*/
|
|
3587
|
-
static async create(config, salt) {
|
|
3764
|
+
static async create(config, salt, iterations) {
|
|
3588
3765
|
if (!config.enabled) {
|
|
3589
3766
|
throw new EncryptionError(
|
|
3590
3767
|
"Cannot create SyncEncryptor with encryption disabled. Set enabled: true in the encryption config."
|
|
@@ -3596,7 +3773,7 @@ var SyncEncryptor = class _SyncEncryptor {
|
|
|
3596
3773
|
"Encryption key/passphrase must not be empty. Provide a non-empty string or key provider function."
|
|
3597
3774
|
);
|
|
3598
3775
|
}
|
|
3599
|
-
const versionedKey = await deriveVersionedKey(passphrase, 1, salt);
|
|
3776
|
+
const versionedKey = await deriveVersionedKey(passphrase, 1, salt, iterations);
|
|
3600
3777
|
const keys = /* @__PURE__ */ new Map();
|
|
3601
3778
|
keys.set(1, versionedKey);
|
|
3602
3779
|
return new _SyncEncryptor(keys, 1);
|
|
@@ -3897,10 +4074,12 @@ var OFFLINE_SYNC_STATUS = Object.freeze({
|
|
|
3897
4074
|
lastSyncedAt: null,
|
|
3898
4075
|
lastSuccessfulPush: null,
|
|
3899
4076
|
lastSuccessfulPull: null,
|
|
3900
|
-
conflicts: 0
|
|
4077
|
+
conflicts: 0,
|
|
4078
|
+
clockSkewMs: null
|
|
3901
4079
|
});
|
|
3902
4080
|
var SYNC_STATUS_EVENT_TYPES = [
|
|
3903
4081
|
"sync:connected",
|
|
4082
|
+
"sync:clock-skew",
|
|
3904
4083
|
"sync:disconnected",
|
|
3905
4084
|
"sync:schema-mismatch",
|
|
3906
4085
|
"sync:auth-failed",
|