@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.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]) => ({ 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]) => ({ 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((envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])),
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((envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])),
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: operation.data === null ? "" : JSON.stringify(operation.data),
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: operation.hasData ? JSON.parse(operation.dataJson) : null,
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) writer.uint32(50).string(envelope.authToken);
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) writer.uint32(74).string(envelope.rejectReason);
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) writer.uint32(120).int64(envelope.lastSequenceNumber);
379
- if (envelope.errorCode && envelope.errorCode.length > 0) writer.uint32(130).string(envelope.errorCode);
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 = [...envelope.versionVector ?? [], decodeVectorEntry(reader, reader.uint32())];
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 = [...envelope.operations ?? [], decodeProtoOperation(reader, reader.uint32())];
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", pendingOperations, lastSyncedAt: this.lastSyncedAt };
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", pendingOperations, lastSyncedAt: this.lastSyncedAt };
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 missingOps = await this.collectDelta(localVector, this.remoteVector);
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 serializedOps = batchOps.map((op) => this.serializer.encodeOperation(op));
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 operations = msg.operations.map((s) => this.serializer.decodeOperation(s));
1393
- for (const op of operations) {
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 (operations.length > 0) {
2299
+ if (inScopeOps.length > 0) {
2300
+ this.lastSuccessfulPull = Date.now();
1397
2301
  this.emitter?.emit({
1398
2302
  type: "sync:received",
1399
- operations,
1400
- batchSize: operations.length
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
- this.lastSyncedAt = Date.now();
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
- const serializedOps = batch.operations.map((op) => this.serializer.encodeOperation(op));
1457
- const batchMsg = {
1458
- type: "operation-batch",
1459
- messageId: generateMessageId(),
1460
- operations: serializedOps,
1461
- isFinal: true,
1462
- batchIndex: 0
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.transport.send(batchMsg);
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
  };