@korajs/sync 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1506 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +515 -4
- package/dist/index.d.ts +515 -4
- package/dist/index.js +1471 -49
- package/dist/index.js.map +1 -1
- package/dist/internal.d.cts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/transport-B-BIguq9.d.cts +385 -0
- package/dist/transport-B-BIguq9.d.ts +385 -0
- package/package.json +3 -3
- package/dist/transport-B5EFsr5F.d.cts +0 -215
- package/dist/transport-B5EFsr5F.d.ts +0 -215
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-TU345YUC.js";
|
|
4
4
|
|
|
5
5
|
// src/types.ts
|
|
6
|
+
import { KoraError } from "@korajs/core";
|
|
6
7
|
var SYNC_STATES = [
|
|
7
8
|
"disconnected",
|
|
8
9
|
"connecting",
|
|
@@ -12,6 +13,63 @@ var SYNC_STATES = [
|
|
|
12
13
|
"error"
|
|
13
14
|
];
|
|
14
15
|
var SYNC_STATUSES = ["connected", "syncing", "synced", "offline", "error"];
|
|
16
|
+
var ScopeViolationError = class extends KoraError {
|
|
17
|
+
constructor(operationId, collection, scope, message) {
|
|
18
|
+
super(
|
|
19
|
+
message ?? `Operation "${operationId}" in collection "${collection}" violates sync scope`,
|
|
20
|
+
"SCOPE_VIOLATION",
|
|
21
|
+
{ operationId, collection, scope }
|
|
22
|
+
);
|
|
23
|
+
this.operationId = operationId;
|
|
24
|
+
this.collection = collection;
|
|
25
|
+
this.scope = scope;
|
|
26
|
+
this.name = "ScopeViolationError";
|
|
27
|
+
}
|
|
28
|
+
operationId;
|
|
29
|
+
collection;
|
|
30
|
+
scope;
|
|
31
|
+
};
|
|
32
|
+
var InvalidScopeError = class extends KoraError {
|
|
33
|
+
constructor(message, context) {
|
|
34
|
+
super(message, "INVALID_SCOPE", context);
|
|
35
|
+
this.name = "InvalidScopeError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/scopes/scope-filter.ts
|
|
40
|
+
function operationMatchesScope(op, scopeMap) {
|
|
41
|
+
if (!scopeMap) return true;
|
|
42
|
+
const collectionScope = scopeMap[op.collection];
|
|
43
|
+
if (!collectionScope) return false;
|
|
44
|
+
if (Object.keys(collectionScope).length === 0) return true;
|
|
45
|
+
const snapshot = buildSnapshot(op);
|
|
46
|
+
if (!snapshot) return false;
|
|
47
|
+
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
48
|
+
if (snapshot[field] !== expected) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
function filterOperationsByScope(operations, scopeMap) {
|
|
55
|
+
if (!scopeMap) return operations;
|
|
56
|
+
return operations.filter((op) => operationMatchesScope(op, scopeMap));
|
|
57
|
+
}
|
|
58
|
+
function buildSnapshot(op) {
|
|
59
|
+
const previous = asRecord(op.previousData);
|
|
60
|
+
const next = asRecord(op.data);
|
|
61
|
+
if (!previous && !next) return null;
|
|
62
|
+
return {
|
|
63
|
+
...previous ?? {},
|
|
64
|
+
...next ?? {}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function asRecord(value) {
|
|
68
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
15
73
|
|
|
16
74
|
// src/protocol/messages.ts
|
|
17
75
|
function isSyncMessage(value) {
|
|
@@ -29,6 +87,8 @@ function isSyncMessage(value) {
|
|
|
29
87
|
return isAcknowledgmentMessage(value);
|
|
30
88
|
case "error":
|
|
31
89
|
return isErrorMessage(value);
|
|
90
|
+
case "awareness-update":
|
|
91
|
+
return isAwarenessUpdateMessage(value);
|
|
32
92
|
default:
|
|
33
93
|
return false;
|
|
34
94
|
}
|
|
@@ -58,6 +118,11 @@ function isErrorMessage(value) {
|
|
|
58
118
|
const msg = value;
|
|
59
119
|
return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
|
|
60
120
|
}
|
|
121
|
+
function isAwarenessUpdateMessage(value) {
|
|
122
|
+
if (typeof value !== "object" || value === null) return false;
|
|
123
|
+
const msg = value;
|
|
124
|
+
return msg.type === "awareness-update" && typeof msg.messageId === "string" && typeof msg.clientId === "number" && typeof msg.states === "object" && msg.states !== null && !Array.isArray(msg.states);
|
|
125
|
+
}
|
|
61
126
|
|
|
62
127
|
// src/protocol/serializer.ts
|
|
63
128
|
import { SyncError } from "@korajs/core";
|
|
@@ -110,7 +175,10 @@ var JsonMessageSerializer = class {
|
|
|
110
175
|
},
|
|
111
176
|
sequenceNumber: op.sequenceNumber,
|
|
112
177
|
causalDeps: [...op.causalDeps],
|
|
113
|
-
schemaVersion: op.schemaVersion
|
|
178
|
+
schemaVersion: op.schemaVersion,
|
|
179
|
+
...op.atomicOps !== void 0 ? { atomicOps: op.atomicOps } : {},
|
|
180
|
+
...op.transactionId !== void 0 ? { transactionId: op.transactionId } : {},
|
|
181
|
+
...op.mutationName !== void 0 ? { mutationName: op.mutationName } : {}
|
|
114
182
|
};
|
|
115
183
|
}
|
|
116
184
|
decodeOperation(serialized) {
|
|
@@ -129,7 +197,10 @@ var JsonMessageSerializer = class {
|
|
|
129
197
|
},
|
|
130
198
|
sequenceNumber: serialized.sequenceNumber,
|
|
131
199
|
causalDeps: [...serialized.causalDeps],
|
|
132
|
-
schemaVersion: serialized.schemaVersion
|
|
200
|
+
schemaVersion: serialized.schemaVersion,
|
|
201
|
+
...serialized.atomicOps !== void 0 ? { atomicOps: serialized.atomicOps } : {},
|
|
202
|
+
...serialized.transactionId !== void 0 ? { transactionId: serialized.transactionId } : {},
|
|
203
|
+
...serialized.mutationName !== void 0 ? { mutationName: serialized.mutationName } : {}
|
|
133
204
|
};
|
|
134
205
|
}
|
|
135
206
|
};
|
|
@@ -193,7 +264,10 @@ function toProtoEnvelope(message) {
|
|
|
193
264
|
type: message.type,
|
|
194
265
|
messageId: message.messageId,
|
|
195
266
|
nodeId: message.nodeId,
|
|
196
|
-
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
267
|
+
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
268
|
+
key,
|
|
269
|
+
value
|
|
270
|
+
})),
|
|
197
271
|
schemaVersion: message.schemaVersion,
|
|
198
272
|
authToken: message.authToken,
|
|
199
273
|
supportedWireFormats: message.supportedWireFormats
|
|
@@ -203,7 +277,10 @@ function toProtoEnvelope(message) {
|
|
|
203
277
|
type: message.type,
|
|
204
278
|
messageId: message.messageId,
|
|
205
279
|
nodeId: message.nodeId,
|
|
206
|
-
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
280
|
+
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
281
|
+
key,
|
|
282
|
+
value
|
|
283
|
+
})),
|
|
207
284
|
schemaVersion: message.schemaVersion,
|
|
208
285
|
accepted: message.accepted,
|
|
209
286
|
rejectReason: message.rejectReason,
|
|
@@ -232,6 +309,11 @@ function toProtoEnvelope(message) {
|
|
|
232
309
|
errorMessage: message.message,
|
|
233
310
|
retriable: message.retriable
|
|
234
311
|
};
|
|
312
|
+
case "awareness-update":
|
|
313
|
+
return {
|
|
314
|
+
type: message.type,
|
|
315
|
+
messageId: message.messageId
|
|
316
|
+
};
|
|
235
317
|
}
|
|
236
318
|
}
|
|
237
319
|
function fromProtoEnvelope(envelope) {
|
|
@@ -241,7 +323,9 @@ function fromProtoEnvelope(envelope) {
|
|
|
241
323
|
type: "handshake",
|
|
242
324
|
messageId: envelope.messageId,
|
|
243
325
|
nodeId: envelope.nodeId ?? "",
|
|
244
|
-
versionVector: Object.fromEntries(
|
|
326
|
+
versionVector: Object.fromEntries(
|
|
327
|
+
(envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])
|
|
328
|
+
),
|
|
245
329
|
schemaVersion: envelope.schemaVersion ?? 0,
|
|
246
330
|
authToken: envelope.authToken,
|
|
247
331
|
supportedWireFormats: envelope.supportedWireFormats?.filter(
|
|
@@ -253,7 +337,9 @@ function fromProtoEnvelope(envelope) {
|
|
|
253
337
|
type: "handshake-response",
|
|
254
338
|
messageId: envelope.messageId,
|
|
255
339
|
nodeId: envelope.nodeId ?? "",
|
|
256
|
-
versionVector: Object.fromEntries(
|
|
340
|
+
versionVector: Object.fromEntries(
|
|
341
|
+
(envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])
|
|
342
|
+
),
|
|
257
343
|
schemaVersion: envelope.schemaVersion ?? 0,
|
|
258
344
|
accepted: envelope.accepted ?? false,
|
|
259
345
|
rejectReason: envelope.rejectReason,
|
|
@@ -289,13 +375,33 @@ function fromProtoEnvelope(envelope) {
|
|
|
289
375
|
}
|
|
290
376
|
}
|
|
291
377
|
function serializeProtoOperation(operation) {
|
|
378
|
+
const hasMetadata = operation.transactionId !== void 0 || operation.mutationName !== void 0;
|
|
379
|
+
let dataJson = "";
|
|
380
|
+
if (operation.data !== null) {
|
|
381
|
+
const dataPayload = { ...operation.data };
|
|
382
|
+
if (operation.atomicOps !== void 0 && Object.keys(operation.atomicOps).length > 0) {
|
|
383
|
+
dataPayload.__kora_atomic_ops__ = operation.atomicOps;
|
|
384
|
+
}
|
|
385
|
+
if (operation.transactionId !== void 0) {
|
|
386
|
+
dataPayload.__kora_tx_id__ = operation.transactionId;
|
|
387
|
+
}
|
|
388
|
+
if (operation.mutationName !== void 0) {
|
|
389
|
+
dataPayload.__kora_mutation__ = operation.mutationName;
|
|
390
|
+
}
|
|
391
|
+
dataJson = JSON.stringify(dataPayload);
|
|
392
|
+
} else if (hasMetadata) {
|
|
393
|
+
const meta = {};
|
|
394
|
+
if (operation.transactionId !== void 0) meta.__kora_tx_id__ = operation.transactionId;
|
|
395
|
+
if (operation.mutationName !== void 0) meta.__kora_mutation__ = operation.mutationName;
|
|
396
|
+
dataJson = JSON.stringify(meta);
|
|
397
|
+
}
|
|
292
398
|
return {
|
|
293
399
|
id: operation.id,
|
|
294
400
|
nodeId: operation.nodeId,
|
|
295
401
|
type: operation.type,
|
|
296
402
|
collection: operation.collection,
|
|
297
403
|
recordId: operation.recordId,
|
|
298
|
-
dataJson
|
|
404
|
+
dataJson,
|
|
299
405
|
previousDataJson: operation.previousData === null ? "" : JSON.stringify(operation.previousData),
|
|
300
406
|
timestamp: {
|
|
301
407
|
wallTime: operation.timestamp.wallTime,
|
|
@@ -310,13 +416,31 @@ function serializeProtoOperation(operation) {
|
|
|
310
416
|
};
|
|
311
417
|
}
|
|
312
418
|
function deserializeProtoOperation(operation) {
|
|
419
|
+
let data = null;
|
|
420
|
+
let atomicOps;
|
|
421
|
+
let transactionId;
|
|
422
|
+
let mutationName;
|
|
423
|
+
if (operation.hasData || operation.dataJson.length > 0) {
|
|
424
|
+
const parsed = JSON.parse(operation.dataJson);
|
|
425
|
+
if ("__kora_atomic_ops__" in parsed) {
|
|
426
|
+
atomicOps = parsed.__kora_atomic_ops__;
|
|
427
|
+
}
|
|
428
|
+
if ("__kora_tx_id__" in parsed) {
|
|
429
|
+
transactionId = parsed.__kora_tx_id__;
|
|
430
|
+
}
|
|
431
|
+
if ("__kora_mutation__" in parsed) {
|
|
432
|
+
mutationName = parsed.__kora_mutation__;
|
|
433
|
+
}
|
|
434
|
+
const { __kora_atomic_ops__: _a, __kora_tx_id__: _t, __kora_mutation__: _m, ...rest } = parsed;
|
|
435
|
+
data = operation.hasData && Object.keys(rest).length > 0 ? rest : null;
|
|
436
|
+
}
|
|
313
437
|
return {
|
|
314
438
|
id: operation.id,
|
|
315
439
|
nodeId: operation.nodeId,
|
|
316
440
|
type: operation.type,
|
|
317
441
|
collection: operation.collection,
|
|
318
442
|
recordId: operation.recordId,
|
|
319
|
-
data
|
|
443
|
+
data,
|
|
320
444
|
previousData: operation.hasPreviousData ? JSON.parse(operation.previousDataJson) : null,
|
|
321
445
|
timestamp: {
|
|
322
446
|
wallTime: operation.timestamp.wallTime,
|
|
@@ -325,7 +449,10 @@ function deserializeProtoOperation(operation) {
|
|
|
325
449
|
},
|
|
326
450
|
sequenceNumber: operation.sequenceNumber,
|
|
327
451
|
causalDeps: [...operation.causalDeps],
|
|
328
|
-
schemaVersion: operation.schemaVersion
|
|
452
|
+
schemaVersion: operation.schemaVersion,
|
|
453
|
+
...atomicOps !== void 0 ? { atomicOps } : {},
|
|
454
|
+
...transactionId !== void 0 ? { transactionId } : {},
|
|
455
|
+
...mutationName !== void 0 ? { mutationName } : {}
|
|
329
456
|
};
|
|
330
457
|
}
|
|
331
458
|
function decodeTextPayload(data) {
|
|
@@ -356,12 +483,14 @@ function encodeEnvelope(envelope) {
|
|
|
356
483
|
writer.ldelim();
|
|
357
484
|
}
|
|
358
485
|
if (envelope.schemaVersion !== void 0) writer.uint32(40).int32(envelope.schemaVersion);
|
|
359
|
-
if (envelope.authToken && envelope.authToken.length > 0)
|
|
486
|
+
if (envelope.authToken && envelope.authToken.length > 0)
|
|
487
|
+
writer.uint32(50).string(envelope.authToken);
|
|
360
488
|
for (const format of envelope.supportedWireFormats ?? []) {
|
|
361
489
|
writer.uint32(58).string(format);
|
|
362
490
|
}
|
|
363
491
|
if (envelope.accepted !== void 0) writer.uint32(64).bool(envelope.accepted);
|
|
364
|
-
if (envelope.rejectReason && envelope.rejectReason.length > 0)
|
|
492
|
+
if (envelope.rejectReason && envelope.rejectReason.length > 0)
|
|
493
|
+
writer.uint32(74).string(envelope.rejectReason);
|
|
365
494
|
if (envelope.selectedWireFormat && envelope.selectedWireFormat.length > 0) {
|
|
366
495
|
writer.uint32(82).string(envelope.selectedWireFormat);
|
|
367
496
|
}
|
|
@@ -375,8 +504,10 @@ function encodeEnvelope(envelope) {
|
|
|
375
504
|
if (envelope.acknowledgedMessageId && envelope.acknowledgedMessageId.length > 0) {
|
|
376
505
|
writer.uint32(114).string(envelope.acknowledgedMessageId);
|
|
377
506
|
}
|
|
378
|
-
if (envelope.lastSequenceNumber !== void 0)
|
|
379
|
-
|
|
507
|
+
if (envelope.lastSequenceNumber !== void 0)
|
|
508
|
+
writer.uint32(120).int64(envelope.lastSequenceNumber);
|
|
509
|
+
if (envelope.errorCode && envelope.errorCode.length > 0)
|
|
510
|
+
writer.uint32(130).string(envelope.errorCode);
|
|
380
511
|
if (envelope.errorMessage && envelope.errorMessage.length > 0) {
|
|
381
512
|
writer.uint32(138).string(envelope.errorMessage);
|
|
382
513
|
}
|
|
@@ -399,7 +530,10 @@ function decodeEnvelope(bytes) {
|
|
|
399
530
|
envelope.nodeId = reader.string();
|
|
400
531
|
break;
|
|
401
532
|
case 4:
|
|
402
|
-
envelope.versionVector = [
|
|
533
|
+
envelope.versionVector = [
|
|
534
|
+
...envelope.versionVector ?? [],
|
|
535
|
+
decodeVectorEntry(reader, reader.uint32())
|
|
536
|
+
];
|
|
403
537
|
break;
|
|
404
538
|
case 5:
|
|
405
539
|
envelope.schemaVersion = reader.int32();
|
|
@@ -420,7 +554,10 @@ function decodeEnvelope(bytes) {
|
|
|
420
554
|
envelope.selectedWireFormat = reader.string();
|
|
421
555
|
break;
|
|
422
556
|
case 11:
|
|
423
|
-
envelope.operations = [
|
|
557
|
+
envelope.operations = [
|
|
558
|
+
...envelope.operations ?? [],
|
|
559
|
+
decodeProtoOperation(reader, reader.uint32())
|
|
560
|
+
];
|
|
424
561
|
break;
|
|
425
562
|
case 12:
|
|
426
563
|
envelope.isFinal = reader.bool();
|
|
@@ -1032,6 +1169,653 @@ var ChaosTransport = class {
|
|
|
1032
1169
|
import { SyncError as SyncError4 } from "@korajs/core";
|
|
1033
1170
|
import { topologicalSort as topologicalSort2 } from "@korajs/core/internal";
|
|
1034
1171
|
|
|
1172
|
+
// src/diagnostics/bandwidth-estimator.ts
|
|
1173
|
+
var BandwidthEstimator = class {
|
|
1174
|
+
maxSamples;
|
|
1175
|
+
timeSource;
|
|
1176
|
+
samples = [];
|
|
1177
|
+
/**
|
|
1178
|
+
* @param maxSamples - Maximum number of samples to retain. Defaults to 20.
|
|
1179
|
+
* @param timeSource - Injectable time source for deterministic testing.
|
|
1180
|
+
*/
|
|
1181
|
+
constructor(maxSamples = 20, timeSource) {
|
|
1182
|
+
this.maxSamples = maxSamples;
|
|
1183
|
+
this.timeSource = timeSource ?? { now: () => Date.now() };
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Record a transfer of `bytes` that took `durationMs` to complete.
|
|
1187
|
+
* Samples with zero or negative duration are ignored to avoid division errors.
|
|
1188
|
+
*/
|
|
1189
|
+
recordTransfer(bytes, durationMs) {
|
|
1190
|
+
if (durationMs <= 0 || bytes <= 0) return;
|
|
1191
|
+
this.samples.push({
|
|
1192
|
+
bytes,
|
|
1193
|
+
durationMs,
|
|
1194
|
+
timestamp: this.timeSource.now()
|
|
1195
|
+
});
|
|
1196
|
+
while (this.samples.length > this.maxSamples) {
|
|
1197
|
+
this.samples.shift();
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Estimate current effective bandwidth in bytes per second.
|
|
1202
|
+
*
|
|
1203
|
+
* Uses exponential weighting so recent samples have more influence.
|
|
1204
|
+
* Returns null if fewer than 2 samples exist (not enough data).
|
|
1205
|
+
*/
|
|
1206
|
+
estimate() {
|
|
1207
|
+
if (this.samples.length < 2) return null;
|
|
1208
|
+
let weightedSum = 0;
|
|
1209
|
+
let totalWeight = 0;
|
|
1210
|
+
const decayFactor = 0.9;
|
|
1211
|
+
for (let i = this.samples.length - 1; i >= 0; i--) {
|
|
1212
|
+
const sample = this.samples[i];
|
|
1213
|
+
const bytesPerSec = sample.bytes / sample.durationMs * 1e3;
|
|
1214
|
+
const age = this.samples.length - 1 - i;
|
|
1215
|
+
const weight = decayFactor ** age;
|
|
1216
|
+
weightedSum += bytesPerSec * weight;
|
|
1217
|
+
totalWeight += weight;
|
|
1218
|
+
}
|
|
1219
|
+
if (totalWeight === 0) return null;
|
|
1220
|
+
return Math.round(weightedSum / totalWeight);
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Reset all recorded samples.
|
|
1224
|
+
*/
|
|
1225
|
+
reset() {
|
|
1226
|
+
this.samples.length = 0;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Get the number of samples currently stored.
|
|
1230
|
+
*/
|
|
1231
|
+
sampleCount() {
|
|
1232
|
+
return this.samples.length;
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
// src/diagnostics/percentile.ts
|
|
1237
|
+
var SlidingWindowPercentile = class {
|
|
1238
|
+
samples;
|
|
1239
|
+
maxSize;
|
|
1240
|
+
writeIndex = 0;
|
|
1241
|
+
count = 0;
|
|
1242
|
+
/**
|
|
1243
|
+
* @param maxSize - Maximum number of samples in the sliding window.
|
|
1244
|
+
* Older samples are overwritten when the buffer is full.
|
|
1245
|
+
*/
|
|
1246
|
+
constructor(maxSize) {
|
|
1247
|
+
if (maxSize < 1) {
|
|
1248
|
+
throw new Error(`SlidingWindowPercentile maxSize must be >= 1, got ${maxSize}`);
|
|
1249
|
+
}
|
|
1250
|
+
this.maxSize = maxSize;
|
|
1251
|
+
this.samples = new Array(maxSize);
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Add a sample to the sliding window.
|
|
1255
|
+
* If the window is full, the oldest sample is overwritten.
|
|
1256
|
+
*/
|
|
1257
|
+
addSample(value) {
|
|
1258
|
+
this.samples[this.writeIndex] = value;
|
|
1259
|
+
this.writeIndex = (this.writeIndex + 1) % this.maxSize;
|
|
1260
|
+
if (this.count < this.maxSize) {
|
|
1261
|
+
this.count++;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Compute a percentile value from the current window.
|
|
1266
|
+
*
|
|
1267
|
+
* Uses the nearest-rank method: the percentile value is the smallest
|
|
1268
|
+
* value in the dataset such that at least p% of the data is <= that value.
|
|
1269
|
+
*
|
|
1270
|
+
* @param p - Percentile to compute (0-100). E.g., 50 for median, 95 for p95.
|
|
1271
|
+
* @returns The percentile value, or 0 if no samples have been recorded.
|
|
1272
|
+
*/
|
|
1273
|
+
percentile(p) {
|
|
1274
|
+
if (this.count === 0) return 0;
|
|
1275
|
+
const sorted = this.samples.slice(0, this.count).sort((a, b) => a - b);
|
|
1276
|
+
const rank = Math.ceil(p / 100 * sorted.length) - 1;
|
|
1277
|
+
const index = Math.max(0, Math.min(rank, sorted.length - 1));
|
|
1278
|
+
return sorted[index];
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Get the most recently added sample, or 0 if no samples exist.
|
|
1282
|
+
*/
|
|
1283
|
+
latest() {
|
|
1284
|
+
if (this.count === 0) return 0;
|
|
1285
|
+
const lastIndex = (this.writeIndex - 1 + this.maxSize) % this.maxSize;
|
|
1286
|
+
return this.samples[lastIndex];
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Get the number of samples currently in the window.
|
|
1290
|
+
*/
|
|
1291
|
+
size() {
|
|
1292
|
+
return this.count;
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Reset the sliding window, clearing all samples.
|
|
1296
|
+
*/
|
|
1297
|
+
reset() {
|
|
1298
|
+
this.writeIndex = 0;
|
|
1299
|
+
this.count = 0;
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
// src/diagnostics/metrics-collector.ts
|
|
1304
|
+
var SyncMetricsCollector = class {
|
|
1305
|
+
rttWindow;
|
|
1306
|
+
inboundBandwidth;
|
|
1307
|
+
outboundBandwidth;
|
|
1308
|
+
timeSource;
|
|
1309
|
+
diagnosticsInterval;
|
|
1310
|
+
// Connection
|
|
1311
|
+
connectedAt = null;
|
|
1312
|
+
disconnectedAt = null;
|
|
1313
|
+
reconnectAttempts = 0;
|
|
1314
|
+
// Throughput
|
|
1315
|
+
operationsSent = 0;
|
|
1316
|
+
operationsReceived = 0;
|
|
1317
|
+
bytesSent = 0;
|
|
1318
|
+
bytesReceived = 0;
|
|
1319
|
+
// Queue
|
|
1320
|
+
pendingOperations = 0;
|
|
1321
|
+
outboundQueueSize = 0;
|
|
1322
|
+
// Sync progress
|
|
1323
|
+
lastSyncedAt = null;
|
|
1324
|
+
syncStartTime = null;
|
|
1325
|
+
syncDuration = null;
|
|
1326
|
+
initialSyncComplete = false;
|
|
1327
|
+
initialSyncTotalBatches = 0;
|
|
1328
|
+
initialSyncReceivedBatches = 0;
|
|
1329
|
+
// Errors
|
|
1330
|
+
lastError = null;
|
|
1331
|
+
errorCount = 0;
|
|
1332
|
+
// Status
|
|
1333
|
+
currentStatus = "offline";
|
|
1334
|
+
currentQuality = "offline";
|
|
1335
|
+
// Periodic emission
|
|
1336
|
+
emitter = null;
|
|
1337
|
+
periodicTimer = null;
|
|
1338
|
+
constructor(config) {
|
|
1339
|
+
this.rttWindow = new SlidingWindowPercentile(config?.rttWindowSize ?? 100);
|
|
1340
|
+
this.inboundBandwidth = new BandwidthEstimator(
|
|
1341
|
+
config?.bandwidthWindowSize ?? 20,
|
|
1342
|
+
config?.timeSource
|
|
1343
|
+
);
|
|
1344
|
+
this.outboundBandwidth = new BandwidthEstimator(
|
|
1345
|
+
config?.bandwidthWindowSize ?? 20,
|
|
1346
|
+
config?.timeSource
|
|
1347
|
+
);
|
|
1348
|
+
this.timeSource = config?.timeSource ?? { now: () => Date.now() };
|
|
1349
|
+
this.diagnosticsInterval = config?.diagnosticsInterval ?? 5e3;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Attach an event emitter for periodic diagnostics emission.
|
|
1353
|
+
* Starts emitting `sync:diagnostics` events at the configured interval
|
|
1354
|
+
* while connected.
|
|
1355
|
+
*/
|
|
1356
|
+
attachEmitter(emitter) {
|
|
1357
|
+
this.emitter = emitter;
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Record that a connection has been established.
|
|
1361
|
+
*/
|
|
1362
|
+
recordConnected() {
|
|
1363
|
+
this.connectedAt = this.timeSource.now();
|
|
1364
|
+
this.reconnectAttempts = 0;
|
|
1365
|
+
this.startPeriodicEmission();
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Record that the connection has been lost.
|
|
1369
|
+
*/
|
|
1370
|
+
recordDisconnected() {
|
|
1371
|
+
this.disconnectedAt = this.timeSource.now();
|
|
1372
|
+
this.stopPeriodicEmission();
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Record a reconnection attempt.
|
|
1376
|
+
*/
|
|
1377
|
+
recordReconnectAttempt() {
|
|
1378
|
+
this.reconnectAttempts++;
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Record a round-trip time measurement.
|
|
1382
|
+
*/
|
|
1383
|
+
recordRtt(ms) {
|
|
1384
|
+
this.rttWindow.addSample(ms);
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Record operations sent with their serialized byte size.
|
|
1388
|
+
*/
|
|
1389
|
+
recordSent(operationCount, byteSize, durationMs) {
|
|
1390
|
+
this.operationsSent += operationCount;
|
|
1391
|
+
this.bytesSent += byteSize;
|
|
1392
|
+
if (durationMs > 0 && byteSize > 0) {
|
|
1393
|
+
this.outboundBandwidth.recordTransfer(byteSize, durationMs);
|
|
1394
|
+
this.emitBandwidthEvent("out");
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Record operations received with their serialized byte size.
|
|
1399
|
+
*/
|
|
1400
|
+
recordReceived(operationCount, byteSize, durationMs) {
|
|
1401
|
+
this.operationsReceived += operationCount;
|
|
1402
|
+
this.bytesReceived += byteSize;
|
|
1403
|
+
if (durationMs > 0 && byteSize > 0) {
|
|
1404
|
+
this.inboundBandwidth.recordTransfer(byteSize, durationMs);
|
|
1405
|
+
this.emitBandwidthEvent("in");
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Update the pending operations count and estimated outbound queue size.
|
|
1410
|
+
*/
|
|
1411
|
+
updateQueue(pendingOps, estimatedBytes) {
|
|
1412
|
+
this.pendingOperations = pendingOps;
|
|
1413
|
+
this.outboundQueueSize = estimatedBytes;
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Record that sync has started (for measuring sync duration).
|
|
1417
|
+
*/
|
|
1418
|
+
recordSyncStarted() {
|
|
1419
|
+
this.syncStartTime = this.timeSource.now();
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Record that a sync cycle has completed.
|
|
1423
|
+
*/
|
|
1424
|
+
recordSyncCompleted() {
|
|
1425
|
+
if (this.syncStartTime !== null) {
|
|
1426
|
+
this.syncDuration = this.timeSource.now() - this.syncStartTime;
|
|
1427
|
+
this.syncStartTime = null;
|
|
1428
|
+
}
|
|
1429
|
+
this.lastSyncedAt = this.timeSource.now();
|
|
1430
|
+
if (!this.initialSyncComplete) {
|
|
1431
|
+
this.initialSyncComplete = true;
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Update initial sync progress.
|
|
1436
|
+
* @param receivedBatches - Number of delta batches received so far.
|
|
1437
|
+
* @param totalBatches - Estimated total batches (0 if unknown).
|
|
1438
|
+
*/
|
|
1439
|
+
updateInitialSyncProgress(receivedBatches, totalBatches) {
|
|
1440
|
+
this.initialSyncReceivedBatches = receivedBatches;
|
|
1441
|
+
this.initialSyncTotalBatches = totalBatches;
|
|
1442
|
+
const progress = totalBatches > 0 ? Math.min(1, receivedBatches / totalBatches) : receivedBatches > 0 ? 0.5 : 0;
|
|
1443
|
+
this.emitter?.emit({
|
|
1444
|
+
type: "sync:initial-sync-progress",
|
|
1445
|
+
progress,
|
|
1446
|
+
totalBatches,
|
|
1447
|
+
receivedBatches
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Record an error.
|
|
1452
|
+
*/
|
|
1453
|
+
recordError(message) {
|
|
1454
|
+
this.lastError = message;
|
|
1455
|
+
this.errorCount++;
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Update the current developer-facing status.
|
|
1459
|
+
*/
|
|
1460
|
+
updateStatus(status) {
|
|
1461
|
+
this.currentStatus = status;
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Update the connection quality.
|
|
1465
|
+
*/
|
|
1466
|
+
updateQuality(quality) {
|
|
1467
|
+
this.currentQuality = quality;
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Compute and return a full diagnostics snapshot.
|
|
1471
|
+
*/
|
|
1472
|
+
getSnapshot() {
|
|
1473
|
+
return {
|
|
1474
|
+
// Connection
|
|
1475
|
+
status: this.currentStatus,
|
|
1476
|
+
connectedAt: this.connectedAt,
|
|
1477
|
+
disconnectedAt: this.disconnectedAt,
|
|
1478
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
1479
|
+
// Latency
|
|
1480
|
+
rttMs: this.rttWindow.latest(),
|
|
1481
|
+
rttP50Ms: this.rttWindow.percentile(50),
|
|
1482
|
+
rttP95Ms: this.rttWindow.percentile(95),
|
|
1483
|
+
rttP99Ms: this.rttWindow.percentile(99),
|
|
1484
|
+
// Throughput
|
|
1485
|
+
operationsSent: this.operationsSent,
|
|
1486
|
+
operationsReceived: this.operationsReceived,
|
|
1487
|
+
bytesSent: this.bytesSent,
|
|
1488
|
+
bytesReceived: this.bytesReceived,
|
|
1489
|
+
// Queue
|
|
1490
|
+
pendingOperations: this.pendingOperations,
|
|
1491
|
+
outboundQueueSize: this.outboundQueueSize,
|
|
1492
|
+
// Sync Progress
|
|
1493
|
+
lastSyncedAt: this.lastSyncedAt,
|
|
1494
|
+
syncDuration: this.syncDuration,
|
|
1495
|
+
initialSyncComplete: this.initialSyncComplete,
|
|
1496
|
+
initialSyncProgress: this.computeInitialSyncProgress(),
|
|
1497
|
+
// Errors
|
|
1498
|
+
lastError: this.lastError,
|
|
1499
|
+
errorCount: this.errorCount,
|
|
1500
|
+
// Connection Quality
|
|
1501
|
+
quality: this.currentQuality,
|
|
1502
|
+
effectiveBandwidth: this.computeEffectiveBandwidth()
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* Assess connection quality from current metrics.
|
|
1507
|
+
*
|
|
1508
|
+
* Quality is derived from RTT percentiles, error rate, and bandwidth:
|
|
1509
|
+
* - excellent: p95 < 100ms, no errors
|
|
1510
|
+
* - good: p95 < 300ms, errorCount <= 1
|
|
1511
|
+
* - fair: p95 < 1000ms, errorCount <= 3
|
|
1512
|
+
* - poor: p95 >= 1000ms or errorCount > 3
|
|
1513
|
+
* - offline: no connection
|
|
1514
|
+
*/
|
|
1515
|
+
assessQuality() {
|
|
1516
|
+
if (this.currentStatus === "offline") return "offline";
|
|
1517
|
+
const p95 = this.rttWindow.percentile(95);
|
|
1518
|
+
const hasSamples = this.rttWindow.size() > 0;
|
|
1519
|
+
if (!hasSamples) {
|
|
1520
|
+
if (this.errorCount > 3) return "poor";
|
|
1521
|
+
if (this.errorCount > 0) return "fair";
|
|
1522
|
+
return "good";
|
|
1523
|
+
}
|
|
1524
|
+
if (this.errorCount > 3) return "poor";
|
|
1525
|
+
if (p95 < 100 && this.errorCount === 0) return "excellent";
|
|
1526
|
+
if (p95 < 300 && this.errorCount <= 1) return "good";
|
|
1527
|
+
if (p95 < 1e3 && this.errorCount <= 3) return "fair";
|
|
1528
|
+
return "poor";
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Reset all collected metrics. Call when starting a new session.
|
|
1532
|
+
*/
|
|
1533
|
+
reset() {
|
|
1534
|
+
this.rttWindow.reset();
|
|
1535
|
+
this.inboundBandwidth.reset();
|
|
1536
|
+
this.outboundBandwidth.reset();
|
|
1537
|
+
this.connectedAt = null;
|
|
1538
|
+
this.disconnectedAt = null;
|
|
1539
|
+
this.reconnectAttempts = 0;
|
|
1540
|
+
this.operationsSent = 0;
|
|
1541
|
+
this.operationsReceived = 0;
|
|
1542
|
+
this.bytesSent = 0;
|
|
1543
|
+
this.bytesReceived = 0;
|
|
1544
|
+
this.pendingOperations = 0;
|
|
1545
|
+
this.outboundQueueSize = 0;
|
|
1546
|
+
this.lastSyncedAt = null;
|
|
1547
|
+
this.syncStartTime = null;
|
|
1548
|
+
this.syncDuration = null;
|
|
1549
|
+
this.initialSyncComplete = false;
|
|
1550
|
+
this.initialSyncTotalBatches = 0;
|
|
1551
|
+
this.initialSyncReceivedBatches = 0;
|
|
1552
|
+
this.lastError = null;
|
|
1553
|
+
this.errorCount = 0;
|
|
1554
|
+
this.currentStatus = "offline";
|
|
1555
|
+
this.currentQuality = "offline";
|
|
1556
|
+
this.stopPeriodicEmission();
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Stop periodic emission and clean up resources.
|
|
1560
|
+
*/
|
|
1561
|
+
dispose() {
|
|
1562
|
+
this.stopPeriodicEmission();
|
|
1563
|
+
this.emitter = null;
|
|
1564
|
+
}
|
|
1565
|
+
// --- Private helpers ---
|
|
1566
|
+
computeInitialSyncProgress() {
|
|
1567
|
+
if (this.initialSyncComplete) return 1;
|
|
1568
|
+
if (this.initialSyncTotalBatches > 0) {
|
|
1569
|
+
return Math.min(1, this.initialSyncReceivedBatches / this.initialSyncTotalBatches);
|
|
1570
|
+
}
|
|
1571
|
+
return this.initialSyncReceivedBatches > 0 ? 0.5 : 0;
|
|
1572
|
+
}
|
|
1573
|
+
computeEffectiveBandwidth() {
|
|
1574
|
+
const inbound = this.inboundBandwidth.estimate();
|
|
1575
|
+
const outbound = this.outboundBandwidth.estimate();
|
|
1576
|
+
if (inbound === null && outbound === null) return null;
|
|
1577
|
+
if (inbound === null) return outbound;
|
|
1578
|
+
if (outbound === null) return inbound;
|
|
1579
|
+
return Math.min(inbound, outbound);
|
|
1580
|
+
}
|
|
1581
|
+
startPeriodicEmission() {
|
|
1582
|
+
if (this.periodicTimer !== null) return;
|
|
1583
|
+
if (!this.emitter) return;
|
|
1584
|
+
this.periodicTimer = setInterval(() => {
|
|
1585
|
+
this.emitDiagnostics();
|
|
1586
|
+
}, this.diagnosticsInterval);
|
|
1587
|
+
}
|
|
1588
|
+
stopPeriodicEmission() {
|
|
1589
|
+
if (this.periodicTimer !== null) {
|
|
1590
|
+
clearInterval(this.periodicTimer);
|
|
1591
|
+
this.periodicTimer = null;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
emitDiagnostics() {
|
|
1595
|
+
this.emitter?.emit({
|
|
1596
|
+
type: "sync:diagnostics",
|
|
1597
|
+
diagnostics: this.getSnapshot()
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
emitBandwidthEvent(direction) {
|
|
1601
|
+
const estimator = direction === "in" ? this.inboundBandwidth : this.outboundBandwidth;
|
|
1602
|
+
const bps = estimator.estimate();
|
|
1603
|
+
if (bps !== null) {
|
|
1604
|
+
this.emitter?.emit({
|
|
1605
|
+
type: "sync:bandwidth",
|
|
1606
|
+
bytesPerSecond: bps,
|
|
1607
|
+
direction
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
|
|
1613
|
+
// src/awareness/awareness-manager.ts
|
|
1614
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1615
|
+
var nextLocalClientId = 1;
|
|
1616
|
+
var AwarenessManager = class {
|
|
1617
|
+
/** Unique client ID for this instance */
|
|
1618
|
+
clientId;
|
|
1619
|
+
localState = null;
|
|
1620
|
+
remoteStates = /* @__PURE__ */ new Map();
|
|
1621
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1622
|
+
emitter;
|
|
1623
|
+
timeoutMs;
|
|
1624
|
+
// Track when we last received an update from each remote client.
|
|
1625
|
+
// Used for timeout-based cleanup as a safety net in case the server
|
|
1626
|
+
// does not send an explicit removal on disconnect.
|
|
1627
|
+
lastUpdated = /* @__PURE__ */ new Map();
|
|
1628
|
+
cleanupTimer = null;
|
|
1629
|
+
sendHandler = null;
|
|
1630
|
+
destroyed = false;
|
|
1631
|
+
constructor(options) {
|
|
1632
|
+
this.clientId = options?.clientId ?? nextLocalClientId++;
|
|
1633
|
+
this.emitter = options?.emitter ?? null;
|
|
1634
|
+
this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Set the local user's awareness state and broadcast to peers.
|
|
1638
|
+
*
|
|
1639
|
+
* @param state - The awareness state to share. Pass null to clear presence.
|
|
1640
|
+
*/
|
|
1641
|
+
setLocalState(state) {
|
|
1642
|
+
if (this.destroyed) return;
|
|
1643
|
+
this.localState = state;
|
|
1644
|
+
const message = {
|
|
1645
|
+
type: "awareness",
|
|
1646
|
+
clientId: this.clientId,
|
|
1647
|
+
states: { [this.clientId]: state }
|
|
1648
|
+
};
|
|
1649
|
+
this.sendHandler?.(message);
|
|
1650
|
+
this.emitAwarenessEvent();
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Get the local awareness state.
|
|
1654
|
+
*/
|
|
1655
|
+
getLocalState() {
|
|
1656
|
+
return this.localState;
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Get all known awareness states (local + remote).
|
|
1660
|
+
* Returns a new Map on each call.
|
|
1661
|
+
*/
|
|
1662
|
+
getStates() {
|
|
1663
|
+
const result = /* @__PURE__ */ new Map();
|
|
1664
|
+
if (this.localState) {
|
|
1665
|
+
result.set(this.clientId, this.localState);
|
|
1666
|
+
}
|
|
1667
|
+
for (const [id, state] of this.remoteStates) {
|
|
1668
|
+
result.set(id, state);
|
|
1669
|
+
}
|
|
1670
|
+
return result;
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* Handle an incoming awareness message from the transport.
|
|
1674
|
+
* This processes remote state updates and notifies listeners.
|
|
1675
|
+
*/
|
|
1676
|
+
handleRemoteMessage(message) {
|
|
1677
|
+
if (this.destroyed) return;
|
|
1678
|
+
const added = [];
|
|
1679
|
+
const updated = [];
|
|
1680
|
+
const removed = [];
|
|
1681
|
+
const now = Date.now();
|
|
1682
|
+
for (const [clientIdStr, state] of Object.entries(message.states)) {
|
|
1683
|
+
const clientId = Number(clientIdStr);
|
|
1684
|
+
if (clientId === this.clientId) continue;
|
|
1685
|
+
if (state === null) {
|
|
1686
|
+
if (this.remoteStates.has(clientId)) {
|
|
1687
|
+
this.remoteStates.delete(clientId);
|
|
1688
|
+
this.lastUpdated.delete(clientId);
|
|
1689
|
+
removed.push(clientId);
|
|
1690
|
+
}
|
|
1691
|
+
} else {
|
|
1692
|
+
if (this.remoteStates.has(clientId)) {
|
|
1693
|
+
this.remoteStates.set(clientId, state);
|
|
1694
|
+
this.lastUpdated.set(clientId, now);
|
|
1695
|
+
updated.push(clientId);
|
|
1696
|
+
} else {
|
|
1697
|
+
this.remoteStates.set(clientId, state);
|
|
1698
|
+
this.lastUpdated.set(clientId, now);
|
|
1699
|
+
added.push(clientId);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
if (added.length > 0 || updated.length > 0 || removed.length > 0) {
|
|
1704
|
+
const change = { added, updated, removed };
|
|
1705
|
+
this.notifyListeners(change);
|
|
1706
|
+
this.emitAwarenessEvent();
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Remove a specific remote client's awareness state.
|
|
1711
|
+
* Called when the server notifies that a client has disconnected.
|
|
1712
|
+
*/
|
|
1713
|
+
removeClient(clientId) {
|
|
1714
|
+
if (this.destroyed) return;
|
|
1715
|
+
if (!this.remoteStates.has(clientId)) return;
|
|
1716
|
+
this.remoteStates.delete(clientId);
|
|
1717
|
+
this.lastUpdated.delete(clientId);
|
|
1718
|
+
const change = {
|
|
1719
|
+
added: [],
|
|
1720
|
+
updated: [],
|
|
1721
|
+
removed: [clientId]
|
|
1722
|
+
};
|
|
1723
|
+
this.notifyListeners(change);
|
|
1724
|
+
this.emitAwarenessEvent();
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Register a handler for sending awareness messages through the transport.
|
|
1728
|
+
* The sync engine calls this to wire outgoing awareness messages to the transport.
|
|
1729
|
+
*/
|
|
1730
|
+
onSend(handler) {
|
|
1731
|
+
this.sendHandler = handler;
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Register a listener for awareness state changes.
|
|
1735
|
+
* Returns an unsubscribe function.
|
|
1736
|
+
*/
|
|
1737
|
+
on(_event, listener) {
|
|
1738
|
+
this.listeners.add(listener);
|
|
1739
|
+
return () => {
|
|
1740
|
+
this.listeners.delete(listener);
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Remove a specific change listener.
|
|
1745
|
+
*/
|
|
1746
|
+
off(_event, listener) {
|
|
1747
|
+
this.listeners.delete(listener);
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Start the cleanup timer that removes stale remote states.
|
|
1751
|
+
* Called when the sync engine transitions to streaming state.
|
|
1752
|
+
*/
|
|
1753
|
+
startCleanupTimer() {
|
|
1754
|
+
if (this.cleanupTimer) return;
|
|
1755
|
+
this.cleanupTimer = setInterval(() => {
|
|
1756
|
+
this.cleanupStaleStates();
|
|
1757
|
+
}, this.timeoutMs);
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Stop the cleanup timer.
|
|
1761
|
+
*/
|
|
1762
|
+
stopCleanupTimer() {
|
|
1763
|
+
if (this.cleanupTimer) {
|
|
1764
|
+
clearInterval(this.cleanupTimer);
|
|
1765
|
+
this.cleanupTimer = null;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Clean up all resources. After calling destroy(), the manager
|
|
1770
|
+
* will no longer send or receive awareness updates.
|
|
1771
|
+
* Broadcasts removal of local state before shutting down.
|
|
1772
|
+
*/
|
|
1773
|
+
destroy() {
|
|
1774
|
+
if (this.destroyed) return;
|
|
1775
|
+
this.destroyed = true;
|
|
1776
|
+
if (this.localState) {
|
|
1777
|
+
const message = {
|
|
1778
|
+
type: "awareness",
|
|
1779
|
+
clientId: this.clientId,
|
|
1780
|
+
states: { [this.clientId]: null }
|
|
1781
|
+
};
|
|
1782
|
+
this.sendHandler?.(message);
|
|
1783
|
+
}
|
|
1784
|
+
this.localState = null;
|
|
1785
|
+
this.remoteStates.clear();
|
|
1786
|
+
this.lastUpdated.clear();
|
|
1787
|
+
this.listeners.clear();
|
|
1788
|
+
this.sendHandler = null;
|
|
1789
|
+
this.stopCleanupTimer();
|
|
1790
|
+
}
|
|
1791
|
+
// --- Private ---
|
|
1792
|
+
cleanupStaleStates() {
|
|
1793
|
+
const now = Date.now();
|
|
1794
|
+
const removed = [];
|
|
1795
|
+
for (const [clientId, lastTime] of this.lastUpdated) {
|
|
1796
|
+
if (now - lastTime > this.timeoutMs) {
|
|
1797
|
+
this.remoteStates.delete(clientId);
|
|
1798
|
+
this.lastUpdated.delete(clientId);
|
|
1799
|
+
removed.push(clientId);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
if (removed.length > 0) {
|
|
1803
|
+
const change = { added: [], updated: [], removed };
|
|
1804
|
+
this.notifyListeners(change);
|
|
1805
|
+
this.emitAwarenessEvent();
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
notifyListeners(change) {
|
|
1809
|
+
for (const listener of this.listeners) {
|
|
1810
|
+
listener(change);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
emitAwarenessEvent() {
|
|
1814
|
+
const states = this.getStates();
|
|
1815
|
+
this.emitter?.emit({ type: "awareness:updated", states });
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1818
|
+
|
|
1035
1819
|
// src/engine/outbound-queue.ts
|
|
1036
1820
|
import { topologicalSort } from "@korajs/core/internal";
|
|
1037
1821
|
var OutboundQueue = class {
|
|
@@ -1170,14 +1954,26 @@ var SyncEngine = class {
|
|
|
1170
1954
|
emitter;
|
|
1171
1955
|
outboundQueue;
|
|
1172
1956
|
batchSize;
|
|
1957
|
+
encryptor;
|
|
1958
|
+
awarenessManager;
|
|
1959
|
+
metricsCollector;
|
|
1173
1960
|
remoteVector = /* @__PURE__ */ new Map();
|
|
1174
1961
|
lastSyncedAt = null;
|
|
1962
|
+
lastSuccessfulPush = null;
|
|
1963
|
+
lastSuccessfulPull = null;
|
|
1964
|
+
conflictCount = 0;
|
|
1175
1965
|
currentBatch = null;
|
|
1176
1966
|
reconnecting = false;
|
|
1177
1967
|
// Track delta exchange state
|
|
1178
1968
|
deltaBatchesReceived = 0;
|
|
1179
1969
|
deltaReceiveComplete = false;
|
|
1180
1970
|
deltaSendComplete = false;
|
|
1971
|
+
/**
|
|
1972
|
+
* The effective scope for this sync session.
|
|
1973
|
+
* Starts as the configured scopeMap. After handshake, may be replaced
|
|
1974
|
+
* with the server-accepted scope (server is authoritative).
|
|
1975
|
+
*/
|
|
1976
|
+
activeScope;
|
|
1181
1977
|
constructor(options) {
|
|
1182
1978
|
this.transport = options.transport;
|
|
1183
1979
|
this.store = options.store;
|
|
@@ -1185,8 +1981,27 @@ var SyncEngine = class {
|
|
|
1185
1981
|
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
1186
1982
|
this.emitter = options.emitter ?? null;
|
|
1187
1983
|
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
1984
|
+
this.encryptor = options.encryptor ?? null;
|
|
1985
|
+
this.activeScope = options.config.scopeMap;
|
|
1188
1986
|
const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
|
|
1189
1987
|
this.outboundQueue = new OutboundQueue(queueStorage);
|
|
1988
|
+
this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
|
|
1989
|
+
if (this.emitter) {
|
|
1990
|
+
this.metricsCollector.attachEmitter(this.emitter);
|
|
1991
|
+
}
|
|
1992
|
+
this.awarenessManager = new AwarenessManager({
|
|
1993
|
+
emitter: this.emitter ?? void 0
|
|
1994
|
+
});
|
|
1995
|
+
this.awarenessManager.onSend((message) => {
|
|
1996
|
+
if (this.state !== "streaming") return;
|
|
1997
|
+
const wireMessage = {
|
|
1998
|
+
type: "awareness-update",
|
|
1999
|
+
messageId: generateMessageId(),
|
|
2000
|
+
clientId: message.clientId,
|
|
2001
|
+
states: awarenessStatesToWire(message.states)
|
|
2002
|
+
};
|
|
2003
|
+
this.transport.send(wireMessage);
|
|
2004
|
+
});
|
|
1190
2005
|
}
|
|
1191
2006
|
/**
|
|
1192
2007
|
* Start the sync engine: connect → handshake → delta exchange → streaming.
|
|
@@ -1214,7 +2029,8 @@ var SyncEngine = class {
|
|
|
1214
2029
|
versionVector: versionVectorToWire(localVector),
|
|
1215
2030
|
schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
|
|
1216
2031
|
authToken,
|
|
1217
|
-
supportedWireFormats: ["json", "protobuf"]
|
|
2032
|
+
supportedWireFormats: ["json", "protobuf"],
|
|
2033
|
+
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {}
|
|
1218
2034
|
};
|
|
1219
2035
|
this.transport.send(handshake);
|
|
1220
2036
|
} catch (err) {
|
|
@@ -1227,6 +2043,7 @@ var SyncEngine = class {
|
|
|
1227
2043
|
*/
|
|
1228
2044
|
async stop() {
|
|
1229
2045
|
if (this.state === "disconnected") return;
|
|
2046
|
+
this.awarenessManager.stopCleanupTimer();
|
|
1230
2047
|
if (this.currentBatch) {
|
|
1231
2048
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
1232
2049
|
this.currentBatch = null;
|
|
@@ -1245,8 +2062,14 @@ var SyncEngine = class {
|
|
|
1245
2062
|
/**
|
|
1246
2063
|
* Push a local operation to the outbound queue.
|
|
1247
2064
|
* If streaming, flushes immediately.
|
|
2065
|
+
*
|
|
2066
|
+
* Operations outside the configured sync scope are silently skipped
|
|
2067
|
+
* because they should remain local-only and not be sent to the server.
|
|
1248
2068
|
*/
|
|
1249
2069
|
async pushOperation(op) {
|
|
2070
|
+
if (!operationMatchesScope(op, this.activeScope)) {
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
1250
2073
|
await this.outboundQueue.enqueue(op);
|
|
1251
2074
|
if (this.state === "streaming") {
|
|
1252
2075
|
this.flushQueue();
|
|
@@ -1266,27 +2089,64 @@ var SyncEngine = class {
|
|
|
1266
2089
|
*/
|
|
1267
2090
|
getStatus() {
|
|
1268
2091
|
const pendingOperations = this.outboundQueue.totalPending;
|
|
2092
|
+
const base = {
|
|
2093
|
+
pendingOperations,
|
|
2094
|
+
lastSyncedAt: this.lastSyncedAt,
|
|
2095
|
+
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
2096
|
+
lastSuccessfulPull: this.lastSuccessfulPull,
|
|
2097
|
+
conflicts: this.conflictCount
|
|
2098
|
+
};
|
|
1269
2099
|
switch (this.state) {
|
|
1270
2100
|
case "disconnected":
|
|
1271
|
-
return { status: "offline"
|
|
2101
|
+
return { ...base, status: "offline" };
|
|
1272
2102
|
case "connecting":
|
|
1273
2103
|
case "handshaking":
|
|
1274
2104
|
case "syncing":
|
|
1275
|
-
return {
|
|
1276
|
-
status: this.reconnecting ? "offline" : "syncing",
|
|
1277
|
-
pendingOperations,
|
|
1278
|
-
lastSyncedAt: this.lastSyncedAt
|
|
1279
|
-
};
|
|
2105
|
+
return { ...base, status: this.reconnecting ? "offline" : "syncing" };
|
|
1280
2106
|
case "streaming":
|
|
1281
|
-
return {
|
|
1282
|
-
status: pendingOperations > 0 ? "syncing" : "synced",
|
|
1283
|
-
pendingOperations,
|
|
1284
|
-
lastSyncedAt: this.lastSyncedAt
|
|
1285
|
-
};
|
|
2107
|
+
return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
|
|
1286
2108
|
case "error":
|
|
1287
|
-
return { status: "error"
|
|
2109
|
+
return { ...base, status: "error" };
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* Record a merge conflict. Called by the merge-aware sync store
|
|
2114
|
+
* to increment the conflict counter for status reporting.
|
|
2115
|
+
*/
|
|
2116
|
+
recordConflict() {
|
|
2117
|
+
this.conflictCount++;
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
2120
|
+
* Force an immediate reconnection attempt. If the engine is disconnected
|
|
2121
|
+
* or in error state, restarts the sync. If already connected, no-op.
|
|
2122
|
+
*/
|
|
2123
|
+
async retryNow() {
|
|
2124
|
+
if (this.state === "disconnected" || this.state === "error") {
|
|
2125
|
+
this.reconnecting = false;
|
|
2126
|
+
await this.start();
|
|
1288
2127
|
}
|
|
1289
2128
|
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Export a diagnostics snapshot for debugging and support tickets.
|
|
2131
|
+
* Contains connection state, timing info, and queue metrics.
|
|
2132
|
+
*/
|
|
2133
|
+
exportDiagnostics() {
|
|
2134
|
+
return {
|
|
2135
|
+
state: this.state,
|
|
2136
|
+
status: this.getStatus(),
|
|
2137
|
+
nodeId: this.store.getNodeId(),
|
|
2138
|
+
url: this.config.url,
|
|
2139
|
+
schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
|
|
2140
|
+
lastSyncedAt: this.lastSyncedAt,
|
|
2141
|
+
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
2142
|
+
lastSuccessfulPull: this.lastSuccessfulPull,
|
|
2143
|
+
conflicts: this.conflictCount,
|
|
2144
|
+
pendingOperations: this.outboundQueue.totalPending,
|
|
2145
|
+
hasInFlightBatch: this.currentBatch !== null,
|
|
2146
|
+
reconnecting: this.reconnecting,
|
|
2147
|
+
timestamp: Date.now()
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
1290
2150
|
/**
|
|
1291
2151
|
* Get the current internal state (for testing).
|
|
1292
2152
|
*/
|
|
@@ -1299,6 +2159,36 @@ var SyncEngine = class {
|
|
|
1299
2159
|
getOutboundQueue() {
|
|
1300
2160
|
return this.outboundQueue;
|
|
1301
2161
|
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Update the sync scope map. Takes effect on the next connection attempt.
|
|
2164
|
+
*
|
|
2165
|
+
* When the scope changes (e.g., user switches organization), call this method
|
|
2166
|
+
* then reconnect. The new scope will be sent in the handshake, and the server
|
|
2167
|
+
* will send back data matching the new scope.
|
|
2168
|
+
*
|
|
2169
|
+
* Data that no longer matches the new scope is NOT deleted locally.
|
|
2170
|
+
* It simply stops being synced.
|
|
2171
|
+
*
|
|
2172
|
+
* @param scopeMap - New per-collection scope filters, or undefined to remove scope
|
|
2173
|
+
*/
|
|
2174
|
+
updateScope(scopeMap) {
|
|
2175
|
+
this.activeScope = scopeMap;
|
|
2176
|
+
this.config.scopeMap = scopeMap;
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Get the currently active scope map. Returns undefined if no scope is configured.
|
|
2180
|
+
*/
|
|
2181
|
+
getActiveScope() {
|
|
2182
|
+
return this.activeScope;
|
|
2183
|
+
}
|
|
2184
|
+
/**
|
|
2185
|
+
* Get the awareness manager for collaborative presence.
|
|
2186
|
+
* Use this to set local presence, observe remote collaborators,
|
|
2187
|
+
* and track cursor positions in richtext fields.
|
|
2188
|
+
*/
|
|
2189
|
+
getAwarenessManager() {
|
|
2190
|
+
return this.awarenessManager;
|
|
2191
|
+
}
|
|
1302
2192
|
// --- Private methods ---
|
|
1303
2193
|
handleMessage(message) {
|
|
1304
2194
|
switch (message.type) {
|
|
@@ -1314,6 +2204,9 @@ var SyncEngine = class {
|
|
|
1314
2204
|
case "error":
|
|
1315
2205
|
this.handleError(message);
|
|
1316
2206
|
break;
|
|
2207
|
+
case "awareness-update":
|
|
2208
|
+
this.handleAwarenessUpdate(message);
|
|
2209
|
+
break;
|
|
1317
2210
|
}
|
|
1318
2211
|
}
|
|
1319
2212
|
handleHandshakeResponse(msg) {
|
|
@@ -1331,7 +2224,13 @@ var SyncEngine = class {
|
|
|
1331
2224
|
if (msg.selectedWireFormat) {
|
|
1332
2225
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
1333
2226
|
}
|
|
2227
|
+
if (msg.acceptedScope) {
|
|
2228
|
+
this.activeScope = msg.acceptedScope;
|
|
2229
|
+
}
|
|
1334
2230
|
this.emitter?.emit({ type: "sync:connected", nodeId: this.store.getNodeId() });
|
|
2231
|
+
this.metricsCollector.recordConnected();
|
|
2232
|
+
this.metricsCollector.updateStatus("syncing");
|
|
2233
|
+
this.metricsCollector.recordSyncStarted();
|
|
1335
2234
|
this.transitionTo("syncing");
|
|
1336
2235
|
this.deltaBatchesReceived = 0;
|
|
1337
2236
|
this.deltaReceiveComplete = false;
|
|
@@ -1340,7 +2239,8 @@ var SyncEngine = class {
|
|
|
1340
2239
|
}
|
|
1341
2240
|
async sendDelta() {
|
|
1342
2241
|
const localVector = this.store.getVersionVector();
|
|
1343
|
-
const
|
|
2242
|
+
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
2243
|
+
const missingOps = allMissingOps.filter((op) => operationMatchesScope(op, this.activeScope));
|
|
1344
2244
|
if (missingOps.length === 0) {
|
|
1345
2245
|
const emptyBatch = {
|
|
1346
2246
|
type: "operation-batch",
|
|
@@ -1359,7 +2259,8 @@ var SyncEngine = class {
|
|
|
1359
2259
|
for (let i = 0; i < totalBatches; i++) {
|
|
1360
2260
|
const start = i * this.batchSize;
|
|
1361
2261
|
const batchOps = sorted.slice(start, start + this.batchSize);
|
|
1362
|
-
const
|
|
2262
|
+
const opsToSerialize = this.encryptor ? await this.encryptor.encryptBatch(batchOps) : batchOps;
|
|
2263
|
+
const serializedOps = opsToSerialize.map((op) => this.serializer.encodeOperation(op));
|
|
1363
2264
|
const batchMsg = {
|
|
1364
2265
|
type: "operation-batch",
|
|
1365
2266
|
messageId: generateMessageId(),
|
|
@@ -1389,15 +2290,18 @@ var SyncEngine = class {
|
|
|
1389
2290
|
return missing;
|
|
1390
2291
|
}
|
|
1391
2292
|
async handleOperationBatch(msg) {
|
|
1392
|
-
const
|
|
1393
|
-
|
|
2293
|
+
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2294
|
+
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
2295
|
+
const inScopeOps = operations.filter((op) => operationMatchesScope(op, this.activeScope));
|
|
2296
|
+
for (const op of inScopeOps) {
|
|
1394
2297
|
await this.store.applyRemoteOperation(op);
|
|
1395
2298
|
}
|
|
1396
|
-
if (
|
|
2299
|
+
if (inScopeOps.length > 0) {
|
|
2300
|
+
this.lastSuccessfulPull = Date.now();
|
|
1397
2301
|
this.emitter?.emit({
|
|
1398
2302
|
type: "sync:received",
|
|
1399
|
-
operations,
|
|
1400
|
-
batchSize:
|
|
2303
|
+
operations: inScopeOps,
|
|
2304
|
+
batchSize: inScopeOps.length
|
|
1401
2305
|
});
|
|
1402
2306
|
}
|
|
1403
2307
|
const lastOp = operations[operations.length - 1];
|
|
@@ -1424,7 +2328,9 @@ var SyncEngine = class {
|
|
|
1424
2328
|
if (this.currentBatch) {
|
|
1425
2329
|
this.outboundQueue.acknowledge(this.currentBatch.batchId);
|
|
1426
2330
|
this.currentBatch = null;
|
|
1427
|
-
|
|
2331
|
+
const now = Date.now();
|
|
2332
|
+
this.lastSyncedAt = now;
|
|
2333
|
+
this.lastSuccessfulPush = now;
|
|
1428
2334
|
}
|
|
1429
2335
|
if (this.state === "streaming" && this.outboundQueue.hasOperations) {
|
|
1430
2336
|
this.flushQueue();
|
|
@@ -1442,6 +2348,11 @@ var SyncEngine = class {
|
|
|
1442
2348
|
if (this.deltaSendComplete && this.deltaReceiveComplete) {
|
|
1443
2349
|
this.lastSyncedAt = Date.now();
|
|
1444
2350
|
this.transitionTo("streaming");
|
|
2351
|
+
this.awarenessManager.startCleanupTimer();
|
|
2352
|
+
const localState = this.awarenessManager.getLocalState();
|
|
2353
|
+
if (localState) {
|
|
2354
|
+
this.awarenessManager.setLocalState(localState);
|
|
2355
|
+
}
|
|
1445
2356
|
if (this.outboundQueue.hasOperations) {
|
|
1446
2357
|
this.flushQueue();
|
|
1447
2358
|
}
|
|
@@ -1453,20 +2364,57 @@ var SyncEngine = class {
|
|
|
1453
2364
|
const batch = this.outboundQueue.takeBatch(this.batchSize);
|
|
1454
2365
|
if (!batch) return;
|
|
1455
2366
|
this.currentBatch = batch;
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
2367
|
+
if (this.encryptor) {
|
|
2368
|
+
this.encryptor.encryptBatch(batch.operations).then(
|
|
2369
|
+
(encrypted) => {
|
|
2370
|
+
const serializedOps = encrypted.map((op) => this.serializer.encodeOperation(op));
|
|
2371
|
+
const batchMsg = {
|
|
2372
|
+
type: "operation-batch",
|
|
2373
|
+
messageId: generateMessageId(),
|
|
2374
|
+
operations: serializedOps,
|
|
2375
|
+
isFinal: true,
|
|
2376
|
+
batchIndex: 0
|
|
2377
|
+
};
|
|
2378
|
+
this.transport.send(batchMsg);
|
|
2379
|
+
this.emitter?.emit({
|
|
2380
|
+
type: "sync:sent",
|
|
2381
|
+
operations: batch.operations,
|
|
2382
|
+
batchSize: batch.operations.length
|
|
2383
|
+
});
|
|
2384
|
+
},
|
|
2385
|
+
(err) => {
|
|
2386
|
+
this.outboundQueue.returnBatch(batch.batchId);
|
|
2387
|
+
this.currentBatch = null;
|
|
2388
|
+
this.emitter?.emit({
|
|
2389
|
+
type: "sync:disconnected",
|
|
2390
|
+
reason: err instanceof Error ? err.message : "Encryption failed"
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
);
|
|
2394
|
+
} else {
|
|
2395
|
+
const serializedOps = batch.operations.map((op) => this.serializer.encodeOperation(op));
|
|
2396
|
+
const batchMsg = {
|
|
2397
|
+
type: "operation-batch",
|
|
2398
|
+
messageId: generateMessageId(),
|
|
2399
|
+
operations: serializedOps,
|
|
2400
|
+
isFinal: true,
|
|
2401
|
+
batchIndex: 0
|
|
2402
|
+
};
|
|
2403
|
+
this.transport.send(batchMsg);
|
|
2404
|
+
this.emitter?.emit({
|
|
2405
|
+
type: "sync:sent",
|
|
2406
|
+
operations: batch.operations,
|
|
2407
|
+
batchSize: batch.operations.length
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
handleAwarenessUpdate(msg) {
|
|
2412
|
+
const awarenessMessage = {
|
|
2413
|
+
type: "awareness",
|
|
2414
|
+
clientId: msg.clientId,
|
|
2415
|
+
states: wireToAwarenessStates(msg.states)
|
|
1463
2416
|
};
|
|
1464
|
-
this.
|
|
1465
|
-
this.emitter?.emit({
|
|
1466
|
-
type: "sync:sent",
|
|
1467
|
-
operations: batch.operations,
|
|
1468
|
-
batchSize: batch.operations.length
|
|
1469
|
-
});
|
|
2417
|
+
this.awarenessManager.handleRemoteMessage(awarenessMessage);
|
|
1470
2418
|
}
|
|
1471
2419
|
handleTransportClose(reason) {
|
|
1472
2420
|
if (this.currentBatch) {
|
|
@@ -1501,6 +2449,40 @@ var SyncEngine = class {
|
|
|
1501
2449
|
}
|
|
1502
2450
|
}
|
|
1503
2451
|
};
|
|
2452
|
+
function awarenessStatesToWire(states) {
|
|
2453
|
+
const wire = {};
|
|
2454
|
+
for (const [clientId, state] of Object.entries(states)) {
|
|
2455
|
+
if (state === null) {
|
|
2456
|
+
wire[clientId] = null;
|
|
2457
|
+
} else {
|
|
2458
|
+
const wireState = {
|
|
2459
|
+
user: { ...state.user }
|
|
2460
|
+
};
|
|
2461
|
+
if (state.cursor) {
|
|
2462
|
+
wireState.cursor = { ...state.cursor };
|
|
2463
|
+
}
|
|
2464
|
+
wire[clientId] = wireState;
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
return wire;
|
|
2468
|
+
}
|
|
2469
|
+
function wireToAwarenessStates(wire) {
|
|
2470
|
+
const states = {};
|
|
2471
|
+
for (const [clientId, wireState] of Object.entries(wire)) {
|
|
2472
|
+
if (wireState === null) {
|
|
2473
|
+
states[Number(clientId)] = null;
|
|
2474
|
+
} else {
|
|
2475
|
+
const state = {
|
|
2476
|
+
user: { ...wireState.user }
|
|
2477
|
+
};
|
|
2478
|
+
if (wireState.cursor) {
|
|
2479
|
+
state.cursor = { ...wireState.cursor };
|
|
2480
|
+
}
|
|
2481
|
+
states[Number(clientId)] = state;
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
return states;
|
|
2485
|
+
}
|
|
1504
2486
|
|
|
1505
2487
|
// src/engine/connection-monitor.ts
|
|
1506
2488
|
var ConnectionMonitor = class {
|
|
@@ -1693,25 +2675,465 @@ var ReconnectionManager = class {
|
|
|
1693
2675
|
});
|
|
1694
2676
|
}
|
|
1695
2677
|
};
|
|
2678
|
+
|
|
2679
|
+
// src/encryption/sync-encryptor.ts
|
|
2680
|
+
import { SyncError as SyncError6 } from "@korajs/core";
|
|
2681
|
+
|
|
2682
|
+
// src/encryption/key-derivation.ts
|
|
2683
|
+
import { SyncError as SyncError5 } from "@korajs/core";
|
|
2684
|
+
var KeyDerivationError = class extends SyncError5 {
|
|
2685
|
+
constructor(message, context) {
|
|
2686
|
+
super(message, { ...context, errorType: "KEY_DERIVATION_ERROR" });
|
|
2687
|
+
this.name = "KeyDerivationError";
|
|
2688
|
+
}
|
|
2689
|
+
};
|
|
2690
|
+
var SALT_LENGTH = 32;
|
|
2691
|
+
var PBKDF2_ITERATIONS = 6e5;
|
|
2692
|
+
var DERIVED_KEY_LENGTH = 256;
|
|
2693
|
+
function assertCryptoAvailable() {
|
|
2694
|
+
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
2695
|
+
throw new KeyDerivationError(
|
|
2696
|
+
"Web Crypto API (crypto.subtle) is not available in this environment. Sync encryption requires crypto.subtle, which is available in modern browsers and Node.js 20+."
|
|
2697
|
+
);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
function generateSalt() {
|
|
2701
|
+
if (typeof globalThis.crypto === "undefined") {
|
|
2702
|
+
throw new KeyDerivationError(
|
|
2703
|
+
"Web Crypto API (crypto) is not available. Cannot generate random salt."
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
2706
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
2707
|
+
}
|
|
2708
|
+
async function deriveKey(passphrase, salt) {
|
|
2709
|
+
assertCryptoAvailable();
|
|
2710
|
+
if (passphrase.length === 0) {
|
|
2711
|
+
throw new KeyDerivationError(
|
|
2712
|
+
"Passphrase must not be empty. Provide a non-empty string for encryption key derivation."
|
|
2713
|
+
);
|
|
2714
|
+
}
|
|
2715
|
+
const usedSalt = salt ?? generateSalt();
|
|
2716
|
+
try {
|
|
2717
|
+
const passphraseBytes = new TextEncoder().encode(passphrase);
|
|
2718
|
+
const baseKey = await globalThis.crypto.subtle.importKey(
|
|
2719
|
+
"raw",
|
|
2720
|
+
passphraseBytes,
|
|
2721
|
+
"PBKDF2",
|
|
2722
|
+
false,
|
|
2723
|
+
["deriveBits", "deriveKey"]
|
|
2724
|
+
);
|
|
2725
|
+
const derivedKey = await globalThis.crypto.subtle.deriveKey(
|
|
2726
|
+
{
|
|
2727
|
+
name: "PBKDF2",
|
|
2728
|
+
salt: usedSalt,
|
|
2729
|
+
iterations: PBKDF2_ITERATIONS,
|
|
2730
|
+
hash: "SHA-256"
|
|
2731
|
+
},
|
|
2732
|
+
baseKey,
|
|
2733
|
+
{ name: "AES-GCM", length: DERIVED_KEY_LENGTH },
|
|
2734
|
+
// extractable: true so the derived key can be exported for testing/debugging
|
|
2735
|
+
true,
|
|
2736
|
+
["encrypt", "decrypt"]
|
|
2737
|
+
);
|
|
2738
|
+
return { key: derivedKey, salt: usedSalt };
|
|
2739
|
+
} catch (cause) {
|
|
2740
|
+
if (cause instanceof KeyDerivationError) {
|
|
2741
|
+
throw cause;
|
|
2742
|
+
}
|
|
2743
|
+
throw new KeyDerivationError(
|
|
2744
|
+
"Failed to derive encryption key from passphrase using PBKDF2. Ensure the runtime supports PBKDF2 with SHA-256.",
|
|
2745
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
2746
|
+
);
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
async function deriveVersionedKey(passphrase, version, salt) {
|
|
2750
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
2751
|
+
throw new KeyDerivationError(`Key version must be a positive integer, received: ${version}`, {
|
|
2752
|
+
version
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
const { key, salt: usedSalt } = await deriveKey(passphrase, salt);
|
|
2756
|
+
return { version, key, salt: usedSalt };
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// src/encryption/sync-encryptor.ts
|
|
2760
|
+
var EncryptionError = class extends SyncError6 {
|
|
2761
|
+
constructor(message, context) {
|
|
2762
|
+
super(message, { ...context, errorType: "ENCRYPTION_ERROR" });
|
|
2763
|
+
this.name = "EncryptionError";
|
|
2764
|
+
}
|
|
2765
|
+
};
|
|
2766
|
+
var DecryptionError = class extends SyncError6 {
|
|
2767
|
+
constructor(message, context) {
|
|
2768
|
+
super(message, { ...context, errorType: "DECRYPTION_ERROR" });
|
|
2769
|
+
this.name = "DecryptionError";
|
|
2770
|
+
}
|
|
2771
|
+
};
|
|
2772
|
+
var IV_LENGTH = 12;
|
|
2773
|
+
var ENCRYPTED_MARKER = "__kora_e2e_encrypted";
|
|
2774
|
+
var SyncEncryptor = class _SyncEncryptor {
|
|
2775
|
+
/**
|
|
2776
|
+
* Map of key version -> VersionedKey. The current (latest) version is used
|
|
2777
|
+
* for encryption. All versions are available for decryption (key rotation).
|
|
2778
|
+
*/
|
|
2779
|
+
keys;
|
|
2780
|
+
/** The current key version used for encryption. */
|
|
2781
|
+
currentVersion;
|
|
2782
|
+
constructor(keys, currentVersion) {
|
|
2783
|
+
this.keys = keys;
|
|
2784
|
+
this.currentVersion = currentVersion;
|
|
2785
|
+
}
|
|
2786
|
+
/**
|
|
2787
|
+
* Creates a SyncEncryptor from a {@link SyncEncryptionConfig}.
|
|
2788
|
+
*
|
|
2789
|
+
* Derives the encryption key from the passphrase using PBKDF2. The key
|
|
2790
|
+
* derivation is async because it uses the Web Crypto API.
|
|
2791
|
+
*
|
|
2792
|
+
* @param config - Encryption configuration with passphrase
|
|
2793
|
+
* @param salt - Optional salt for deterministic key derivation (mainly for testing).
|
|
2794
|
+
* If omitted, a random salt is generated.
|
|
2795
|
+
* @returns A configured SyncEncryptor instance
|
|
2796
|
+
* @throws {EncryptionError} If configuration is invalid
|
|
2797
|
+
* @throws {KeyDerivationError} If key derivation fails
|
|
2798
|
+
*/
|
|
2799
|
+
static async create(config, salt) {
|
|
2800
|
+
if (!config.enabled) {
|
|
2801
|
+
throw new EncryptionError(
|
|
2802
|
+
"Cannot create SyncEncryptor with encryption disabled. Set enabled: true in the encryption config."
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
const passphrase = typeof config.key === "function" ? await config.key() : config.key;
|
|
2806
|
+
if (passphrase.length === 0) {
|
|
2807
|
+
throw new EncryptionError(
|
|
2808
|
+
"Encryption key/passphrase must not be empty. Provide a non-empty string or key provider function."
|
|
2809
|
+
);
|
|
2810
|
+
}
|
|
2811
|
+
const versionedKey = await deriveVersionedKey(passphrase, 1, salt);
|
|
2812
|
+
const keys = /* @__PURE__ */ new Map();
|
|
2813
|
+
keys.set(1, versionedKey);
|
|
2814
|
+
return new _SyncEncryptor(keys, 1);
|
|
2815
|
+
}
|
|
2816
|
+
/**
|
|
2817
|
+
* Creates a SyncEncryptor from pre-derived versioned keys.
|
|
2818
|
+
*
|
|
2819
|
+
* Use this when you need to support multiple key versions for key rotation,
|
|
2820
|
+
* or when you have already derived the keys externally.
|
|
2821
|
+
*
|
|
2822
|
+
* @param versionedKeys - Array of versioned keys. The highest version is used for encryption.
|
|
2823
|
+
* @returns A configured SyncEncryptor instance
|
|
2824
|
+
* @throws {EncryptionError} If no keys are provided
|
|
2825
|
+
*/
|
|
2826
|
+
static fromKeys(versionedKeys) {
|
|
2827
|
+
if (versionedKeys.length === 0) {
|
|
2828
|
+
throw new EncryptionError("At least one versioned key must be provided.");
|
|
2829
|
+
}
|
|
2830
|
+
const keys = /* @__PURE__ */ new Map();
|
|
2831
|
+
let maxVersion = 0;
|
|
2832
|
+
for (const vk of versionedKeys) {
|
|
2833
|
+
keys.set(vk.version, vk);
|
|
2834
|
+
if (vk.version > maxVersion) {
|
|
2835
|
+
maxVersion = vk.version;
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
return new _SyncEncryptor(keys, maxVersion);
|
|
2839
|
+
}
|
|
2840
|
+
/**
|
|
2841
|
+
* Add a new key version for key rotation.
|
|
2842
|
+
*
|
|
2843
|
+
* After adding, the new key becomes the current version used for encryption
|
|
2844
|
+
* if its version number is higher than the current version. Previously-versioned
|
|
2845
|
+
* keys remain available for decrypting older operations.
|
|
2846
|
+
*
|
|
2847
|
+
* @param key - The new versioned key to add
|
|
2848
|
+
* @throws {EncryptionError} If the key version already exists
|
|
2849
|
+
*/
|
|
2850
|
+
addKey(key) {
|
|
2851
|
+
if (this.keys.has(key.version)) {
|
|
2852
|
+
throw new EncryptionError(
|
|
2853
|
+
`Key version ${key.version} already exists. Use a higher version number for rotation.`,
|
|
2854
|
+
{ existingVersion: key.version }
|
|
2855
|
+
);
|
|
2856
|
+
}
|
|
2857
|
+
this.keys.set(key.version, key);
|
|
2858
|
+
if (key.version > this.currentVersion) {
|
|
2859
|
+
this.currentVersion = key.version;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
/**
|
|
2863
|
+
* Get the current encryption key version number.
|
|
2864
|
+
*/
|
|
2865
|
+
getCurrentKeyVersion() {
|
|
2866
|
+
return this.currentVersion;
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Encrypt an operation's `data` and `previousData` fields.
|
|
2870
|
+
*
|
|
2871
|
+
* Returns a new Operation with encrypted field values. The original
|
|
2872
|
+
* operation is not mutated (operations are immutable).
|
|
2873
|
+
*
|
|
2874
|
+
* Fields that are null (e.g., delete operations) remain null.
|
|
2875
|
+
*
|
|
2876
|
+
* @param operation - The operation to encrypt
|
|
2877
|
+
* @returns A new operation with encrypted data fields
|
|
2878
|
+
* @throws {EncryptionError} If encryption fails
|
|
2879
|
+
*/
|
|
2880
|
+
async encryptOperation(operation) {
|
|
2881
|
+
const [encryptedData, encryptedPreviousData] = await Promise.all([
|
|
2882
|
+
this.encryptField(operation.data, operation.id, "data"),
|
|
2883
|
+
this.encryptField(operation.previousData, operation.id, "previousData")
|
|
2884
|
+
]);
|
|
2885
|
+
return {
|
|
2886
|
+
...operation,
|
|
2887
|
+
data: encryptedData,
|
|
2888
|
+
previousData: encryptedPreviousData
|
|
2889
|
+
};
|
|
2890
|
+
}
|
|
2891
|
+
/**
|
|
2892
|
+
* Decrypt an operation's `data` and `previousData` fields.
|
|
2893
|
+
*
|
|
2894
|
+
* Returns a new Operation with the original field values restored.
|
|
2895
|
+
* The original operation is not mutated.
|
|
2896
|
+
*
|
|
2897
|
+
* If a field is null or not encrypted (no marker), it passes through unchanged.
|
|
2898
|
+
* This enables mixed plaintext/encrypted operations during migration.
|
|
2899
|
+
*
|
|
2900
|
+
* @param operation - The operation to decrypt
|
|
2901
|
+
* @returns A new operation with decrypted data fields
|
|
2902
|
+
* @throws {DecryptionError} If decryption fails (wrong key, tampered data, unsupported version)
|
|
2903
|
+
*/
|
|
2904
|
+
async decryptOperation(operation) {
|
|
2905
|
+
const [decryptedData, decryptedPreviousData] = await Promise.all([
|
|
2906
|
+
this.decryptField(operation.data, operation.id, "data"),
|
|
2907
|
+
this.decryptField(operation.previousData, operation.id, "previousData")
|
|
2908
|
+
]);
|
|
2909
|
+
return {
|
|
2910
|
+
...operation,
|
|
2911
|
+
data: decryptedData,
|
|
2912
|
+
previousData: decryptedPreviousData
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2916
|
+
* Encrypt a serialized operation's `data` and `previousData` fields.
|
|
2917
|
+
*
|
|
2918
|
+
* Same as {@link encryptOperation} but works with the wire-format
|
|
2919
|
+
* {@link SerializedOperation} type used by the serializer.
|
|
2920
|
+
*
|
|
2921
|
+
* @param serialized - The serialized operation to encrypt
|
|
2922
|
+
* @returns A new serialized operation with encrypted data fields
|
|
2923
|
+
* @throws {EncryptionError} If encryption fails
|
|
2924
|
+
*/
|
|
2925
|
+
async encryptSerializedOperation(serialized) {
|
|
2926
|
+
const [encryptedData, encryptedPreviousData] = await Promise.all([
|
|
2927
|
+
this.encryptField(serialized.data, serialized.id, "data"),
|
|
2928
|
+
this.encryptField(serialized.previousData, serialized.id, "previousData")
|
|
2929
|
+
]);
|
|
2930
|
+
return {
|
|
2931
|
+
...serialized,
|
|
2932
|
+
data: encryptedData,
|
|
2933
|
+
previousData: encryptedPreviousData
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Decrypt a serialized operation's `data` and `previousData` fields.
|
|
2938
|
+
*
|
|
2939
|
+
* Same as {@link decryptOperation} but works with the wire-format
|
|
2940
|
+
* {@link SerializedOperation} type used by the serializer.
|
|
2941
|
+
*
|
|
2942
|
+
* @param serialized - The serialized operation to decrypt
|
|
2943
|
+
* @returns A new serialized operation with decrypted data fields
|
|
2944
|
+
* @throws {DecryptionError} If decryption fails
|
|
2945
|
+
*/
|
|
2946
|
+
async decryptSerializedOperation(serialized) {
|
|
2947
|
+
const [decryptedData, decryptedPreviousData] = await Promise.all([
|
|
2948
|
+
this.decryptField(serialized.data, serialized.id, "data"),
|
|
2949
|
+
this.decryptField(serialized.previousData, serialized.id, "previousData")
|
|
2950
|
+
]);
|
|
2951
|
+
return {
|
|
2952
|
+
...serialized,
|
|
2953
|
+
data: decryptedData,
|
|
2954
|
+
previousData: decryptedPreviousData
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
/**
|
|
2958
|
+
* Encrypt a batch of operations in parallel.
|
|
2959
|
+
*
|
|
2960
|
+
* @param operations - Operations to encrypt
|
|
2961
|
+
* @returns New operations with encrypted data fields
|
|
2962
|
+
*/
|
|
2963
|
+
async encryptBatch(operations) {
|
|
2964
|
+
return Promise.all(operations.map((op) => this.encryptOperation(op)));
|
|
2965
|
+
}
|
|
2966
|
+
/**
|
|
2967
|
+
* Decrypt a batch of operations in parallel.
|
|
2968
|
+
*
|
|
2969
|
+
* @param operations - Operations to decrypt
|
|
2970
|
+
* @returns New operations with decrypted data fields
|
|
2971
|
+
*/
|
|
2972
|
+
async decryptBatch(operations) {
|
|
2973
|
+
return Promise.all(operations.map((op) => this.decryptOperation(op)));
|
|
2974
|
+
}
|
|
2975
|
+
/**
|
|
2976
|
+
* Check if a field value contains an encrypted payload.
|
|
2977
|
+
*
|
|
2978
|
+
* @param field - An operation's `data` or `previousData` field
|
|
2979
|
+
* @returns true if the field contains an encrypted payload
|
|
2980
|
+
*/
|
|
2981
|
+
static isEncryptedPayload(field) {
|
|
2982
|
+
return isEncryptedPayload(field);
|
|
2983
|
+
}
|
|
2984
|
+
// --- Private helpers ---
|
|
2985
|
+
async encryptField(field, operationId, fieldName) {
|
|
2986
|
+
if (field === null) {
|
|
2987
|
+
return null;
|
|
2988
|
+
}
|
|
2989
|
+
const currentKey = this.keys.get(this.currentVersion);
|
|
2990
|
+
if (!currentKey) {
|
|
2991
|
+
throw new EncryptionError(
|
|
2992
|
+
`Current encryption key version ${this.currentVersion} not found.`,
|
|
2993
|
+
{ operationId, fieldName }
|
|
2994
|
+
);
|
|
2995
|
+
}
|
|
2996
|
+
try {
|
|
2997
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(field));
|
|
2998
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
2999
|
+
const ciphertextBuffer = await globalThis.crypto.subtle.encrypt(
|
|
3000
|
+
{ name: "AES-GCM", iv },
|
|
3001
|
+
currentKey.key,
|
|
3002
|
+
plaintext
|
|
3003
|
+
);
|
|
3004
|
+
const payload = {
|
|
3005
|
+
v: this.currentVersion,
|
|
3006
|
+
iv: toBase64(iv),
|
|
3007
|
+
ct: toBase64(new Uint8Array(ciphertextBuffer)),
|
|
3008
|
+
alg: "aes-256-gcm"
|
|
3009
|
+
};
|
|
3010
|
+
return {
|
|
3011
|
+
[ENCRYPTED_MARKER]: true,
|
|
3012
|
+
...payload
|
|
3013
|
+
};
|
|
3014
|
+
} catch (cause) {
|
|
3015
|
+
if (cause instanceof EncryptionError) {
|
|
3016
|
+
throw cause;
|
|
3017
|
+
}
|
|
3018
|
+
throw new EncryptionError(
|
|
3019
|
+
`Failed to encrypt operation ${fieldName} field. Ensure the encryption key is valid and crypto.subtle is available.`,
|
|
3020
|
+
{
|
|
3021
|
+
operationId,
|
|
3022
|
+
fieldName,
|
|
3023
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
3024
|
+
}
|
|
3025
|
+
);
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
async decryptField(field, operationId, fieldName) {
|
|
3029
|
+
if (field === null) {
|
|
3030
|
+
return null;
|
|
3031
|
+
}
|
|
3032
|
+
if (!isEncryptedPayload(field)) {
|
|
3033
|
+
return field;
|
|
3034
|
+
}
|
|
3035
|
+
const payload = field;
|
|
3036
|
+
const keyVersion = payload.v;
|
|
3037
|
+
const key = this.keys.get(keyVersion);
|
|
3038
|
+
if (!key) {
|
|
3039
|
+
throw new DecryptionError(
|
|
3040
|
+
`No encryption key available for version ${keyVersion}. This operation was encrypted with a key that is not registered. If you rotated keys, ensure all previous key versions are provided.`,
|
|
3041
|
+
{ operationId, fieldName, keyVersion, availableVersions: [...this.keys.keys()] }
|
|
3042
|
+
);
|
|
3043
|
+
}
|
|
3044
|
+
if (payload.alg !== "aes-256-gcm") {
|
|
3045
|
+
throw new DecryptionError(
|
|
3046
|
+
`Unsupported encryption algorithm: "${payload.alg}". Only "aes-256-gcm" is supported. Update your @korajs/sync package.`,
|
|
3047
|
+
{ operationId, fieldName, algorithm: payload.alg }
|
|
3048
|
+
);
|
|
3049
|
+
}
|
|
3050
|
+
try {
|
|
3051
|
+
const iv = fromBase64(payload.iv);
|
|
3052
|
+
const ciphertext = fromBase64(payload.ct);
|
|
3053
|
+
const plaintextBuffer = await globalThis.crypto.subtle.decrypt(
|
|
3054
|
+
{ name: "AES-GCM", iv },
|
|
3055
|
+
key.key,
|
|
3056
|
+
ciphertext
|
|
3057
|
+
);
|
|
3058
|
+
const json = new TextDecoder().decode(plaintextBuffer);
|
|
3059
|
+
const parsed = JSON.parse(json);
|
|
3060
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
3061
|
+
throw new DecryptionError(`Decrypted ${fieldName} is not a valid record object.`, {
|
|
3062
|
+
operationId,
|
|
3063
|
+
fieldName
|
|
3064
|
+
});
|
|
3065
|
+
}
|
|
3066
|
+
return parsed;
|
|
3067
|
+
} catch (cause) {
|
|
3068
|
+
if (cause instanceof DecryptionError) {
|
|
3069
|
+
throw cause;
|
|
3070
|
+
}
|
|
3071
|
+
throw new DecryptionError(
|
|
3072
|
+
`Failed to decrypt operation ${fieldName} field. This may indicate a wrong encryption key, tampered ciphertext, or corrupted data.`,
|
|
3073
|
+
{
|
|
3074
|
+
operationId,
|
|
3075
|
+
fieldName,
|
|
3076
|
+
keyVersion,
|
|
3077
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
3078
|
+
}
|
|
3079
|
+
);
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
};
|
|
3083
|
+
function isEncryptedPayload(field) {
|
|
3084
|
+
if (field === null || typeof field !== "object") {
|
|
3085
|
+
return false;
|
|
3086
|
+
}
|
|
3087
|
+
return field[ENCRYPTED_MARKER] === true && typeof field.v === "number" && typeof field.iv === "string" && typeof field.ct === "string" && typeof field.alg === "string";
|
|
3088
|
+
}
|
|
3089
|
+
function toBase64(bytes) {
|
|
3090
|
+
let binary = "";
|
|
3091
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
3092
|
+
binary += String.fromCharCode(bytes[i]);
|
|
3093
|
+
}
|
|
3094
|
+
return btoa(binary);
|
|
3095
|
+
}
|
|
3096
|
+
function fromBase64(str) {
|
|
3097
|
+
const binary = atob(str);
|
|
3098
|
+
const bytes = new Uint8Array(binary.length);
|
|
3099
|
+
for (let i = 0; i < binary.length; i++) {
|
|
3100
|
+
bytes[i] = binary.charCodeAt(i);
|
|
3101
|
+
}
|
|
3102
|
+
return bytes;
|
|
3103
|
+
}
|
|
1696
3104
|
export {
|
|
3105
|
+
AwarenessManager,
|
|
1697
3106
|
ChaosTransport,
|
|
1698
3107
|
ConnectionMonitor,
|
|
3108
|
+
DecryptionError,
|
|
3109
|
+
EncryptionError,
|
|
1699
3110
|
HttpLongPollingTransport,
|
|
3111
|
+
InvalidScopeError,
|
|
1700
3112
|
JsonMessageSerializer,
|
|
3113
|
+
KeyDerivationError,
|
|
1701
3114
|
NegotiatedMessageSerializer,
|
|
1702
3115
|
OutboundQueue,
|
|
1703
3116
|
ProtobufMessageSerializer,
|
|
1704
3117
|
ReconnectionManager,
|
|
1705
3118
|
SYNC_STATES,
|
|
1706
3119
|
SYNC_STATUSES,
|
|
3120
|
+
ScopeViolationError,
|
|
3121
|
+
SyncEncryptor,
|
|
1707
3122
|
SyncEngine,
|
|
1708
3123
|
WebSocketTransport,
|
|
3124
|
+
deriveKey,
|
|
3125
|
+
deriveVersionedKey,
|
|
3126
|
+
filterOperationsByScope,
|
|
3127
|
+
generateSalt,
|
|
1709
3128
|
isAcknowledgmentMessage,
|
|
3129
|
+
isAwarenessUpdateMessage,
|
|
3130
|
+
isEncryptedPayload,
|
|
1710
3131
|
isErrorMessage,
|
|
1711
3132
|
isHandshakeMessage,
|
|
1712
3133
|
isHandshakeResponseMessage,
|
|
1713
3134
|
isOperationBatchMessage,
|
|
1714
3135
|
isSyncMessage,
|
|
3136
|
+
operationMatchesScope,
|
|
1715
3137
|
versionVectorToWire,
|
|
1716
3138
|
wireToVersionVector
|
|
1717
3139
|
};
|