@korajs/sync 0.3.2 → 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 CHANGED
@@ -30,30 +30,45 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ AwarenessManager: () => AwarenessManager,
33
34
  ChaosTransport: () => ChaosTransport,
34
35
  ConnectionMonitor: () => ConnectionMonitor,
36
+ DecryptionError: () => DecryptionError,
37
+ EncryptionError: () => EncryptionError,
35
38
  HttpLongPollingTransport: () => HttpLongPollingTransport,
39
+ InvalidScopeError: () => InvalidScopeError,
36
40
  JsonMessageSerializer: () => JsonMessageSerializer,
41
+ KeyDerivationError: () => KeyDerivationError,
37
42
  NegotiatedMessageSerializer: () => NegotiatedMessageSerializer,
38
43
  OutboundQueue: () => OutboundQueue,
39
44
  ProtobufMessageSerializer: () => ProtobufMessageSerializer,
40
45
  ReconnectionManager: () => ReconnectionManager,
41
46
  SYNC_STATES: () => SYNC_STATES,
42
47
  SYNC_STATUSES: () => SYNC_STATUSES,
48
+ ScopeViolationError: () => ScopeViolationError,
49
+ SyncEncryptor: () => SyncEncryptor,
43
50
  SyncEngine: () => SyncEngine,
44
51
  WebSocketTransport: () => WebSocketTransport,
52
+ deriveKey: () => deriveKey,
53
+ deriveVersionedKey: () => deriveVersionedKey,
54
+ filterOperationsByScope: () => filterOperationsByScope,
55
+ generateSalt: () => generateSalt,
45
56
  isAcknowledgmentMessage: () => isAcknowledgmentMessage,
57
+ isAwarenessUpdateMessage: () => isAwarenessUpdateMessage,
58
+ isEncryptedPayload: () => isEncryptedPayload,
46
59
  isErrorMessage: () => isErrorMessage,
47
60
  isHandshakeMessage: () => isHandshakeMessage,
48
61
  isHandshakeResponseMessage: () => isHandshakeResponseMessage,
49
62
  isOperationBatchMessage: () => isOperationBatchMessage,
50
63
  isSyncMessage: () => isSyncMessage,
64
+ operationMatchesScope: () => operationMatchesScope,
51
65
  versionVectorToWire: () => versionVectorToWire,
52
66
  wireToVersionVector: () => wireToVersionVector
53
67
  });
54
68
  module.exports = __toCommonJS(index_exports);
55
69
 
56
70
  // src/types.ts
71
+ var import_core = require("@korajs/core");
57
72
  var SYNC_STATES = [
58
73
  "disconnected",
59
74
  "connecting",
@@ -63,6 +78,63 @@ var SYNC_STATES = [
63
78
  "error"
64
79
  ];
65
80
  var SYNC_STATUSES = ["connected", "syncing", "synced", "offline", "error"];
81
+ var ScopeViolationError = class extends import_core.KoraError {
82
+ constructor(operationId, collection, scope, message) {
83
+ super(
84
+ message ?? `Operation "${operationId}" in collection "${collection}" violates sync scope`,
85
+ "SCOPE_VIOLATION",
86
+ { operationId, collection, scope }
87
+ );
88
+ this.operationId = operationId;
89
+ this.collection = collection;
90
+ this.scope = scope;
91
+ this.name = "ScopeViolationError";
92
+ }
93
+ operationId;
94
+ collection;
95
+ scope;
96
+ };
97
+ var InvalidScopeError = class extends import_core.KoraError {
98
+ constructor(message, context) {
99
+ super(message, "INVALID_SCOPE", context);
100
+ this.name = "InvalidScopeError";
101
+ }
102
+ };
103
+
104
+ // src/scopes/scope-filter.ts
105
+ function operationMatchesScope(op, scopeMap) {
106
+ if (!scopeMap) return true;
107
+ const collectionScope = scopeMap[op.collection];
108
+ if (!collectionScope) return false;
109
+ if (Object.keys(collectionScope).length === 0) return true;
110
+ const snapshot = buildSnapshot(op);
111
+ if (!snapshot) return false;
112
+ for (const [field, expected] of Object.entries(collectionScope)) {
113
+ if (snapshot[field] !== expected) {
114
+ return false;
115
+ }
116
+ }
117
+ return true;
118
+ }
119
+ function filterOperationsByScope(operations, scopeMap) {
120
+ if (!scopeMap) return operations;
121
+ return operations.filter((op) => operationMatchesScope(op, scopeMap));
122
+ }
123
+ function buildSnapshot(op) {
124
+ const previous = asRecord(op.previousData);
125
+ const next = asRecord(op.data);
126
+ if (!previous && !next) return null;
127
+ return {
128
+ ...previous ?? {},
129
+ ...next ?? {}
130
+ };
131
+ }
132
+ function asRecord(value) {
133
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
134
+ return null;
135
+ }
136
+ return value;
137
+ }
66
138
 
67
139
  // src/protocol/messages.ts
68
140
  function isSyncMessage(value) {
@@ -80,6 +152,8 @@ function isSyncMessage(value) {
80
152
  return isAcknowledgmentMessage(value);
81
153
  case "error":
82
154
  return isErrorMessage(value);
155
+ case "awareness-update":
156
+ return isAwarenessUpdateMessage(value);
83
157
  default:
84
158
  return false;
85
159
  }
@@ -109,9 +183,14 @@ function isErrorMessage(value) {
109
183
  const msg = value;
110
184
  return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
111
185
  }
186
+ function isAwarenessUpdateMessage(value) {
187
+ if (typeof value !== "object" || value === null) return false;
188
+ const msg = value;
189
+ return msg.type === "awareness-update" && typeof msg.messageId === "string" && typeof msg.clientId === "number" && typeof msg.states === "object" && msg.states !== null && !Array.isArray(msg.states);
190
+ }
112
191
 
113
192
  // src/protocol/serializer.ts
114
- var import_core = require("@korajs/core");
193
+ var import_core2 = require("@korajs/core");
115
194
  var import_minimal = __toESM(require("protobufjs/minimal"), 1);
116
195
  var { Reader, Writer } = import_minimal.default;
117
196
  function versionVectorToWire(vector) {
@@ -134,12 +213,12 @@ var JsonMessageSerializer = class {
134
213
  try {
135
214
  parsed = JSON.parse(text);
136
215
  } catch {
137
- throw new import_core.SyncError("Failed to decode sync message: invalid JSON", {
216
+ throw new import_core2.SyncError("Failed to decode sync message: invalid JSON", {
138
217
  dataLength: text.length
139
218
  });
140
219
  }
141
220
  if (!isSyncMessage(parsed)) {
142
- throw new import_core.SyncError("Failed to decode sync message: invalid message structure", {
221
+ throw new import_core2.SyncError("Failed to decode sync message: invalid message structure", {
143
222
  receivedType: typeof parsed === "object" && parsed !== null ? parsed.type : typeof parsed
144
223
  });
145
224
  }
@@ -161,7 +240,10 @@ var JsonMessageSerializer = class {
161
240
  },
162
241
  sequenceNumber: op.sequenceNumber,
163
242
  causalDeps: [...op.causalDeps],
164
- schemaVersion: op.schemaVersion
243
+ schemaVersion: op.schemaVersion,
244
+ ...op.atomicOps !== void 0 ? { atomicOps: op.atomicOps } : {},
245
+ ...op.transactionId !== void 0 ? { transactionId: op.transactionId } : {},
246
+ ...op.mutationName !== void 0 ? { mutationName: op.mutationName } : {}
165
247
  };
166
248
  }
167
249
  decodeOperation(serialized) {
@@ -180,7 +262,10 @@ var JsonMessageSerializer = class {
180
262
  },
181
263
  sequenceNumber: serialized.sequenceNumber,
182
264
  causalDeps: [...serialized.causalDeps],
183
- schemaVersion: serialized.schemaVersion
265
+ schemaVersion: serialized.schemaVersion,
266
+ ...serialized.atomicOps !== void 0 ? { atomicOps: serialized.atomicOps } : {},
267
+ ...serialized.transactionId !== void 0 ? { transactionId: serialized.transactionId } : {},
268
+ ...serialized.mutationName !== void 0 ? { mutationName: serialized.mutationName } : {}
184
269
  };
185
270
  }
186
271
  };
@@ -244,7 +329,10 @@ function toProtoEnvelope(message) {
244
329
  type: message.type,
245
330
  messageId: message.messageId,
246
331
  nodeId: message.nodeId,
247
- versionVector: Object.entries(message.versionVector).map(([key, value]) => ({ key, value })),
332
+ versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
333
+ key,
334
+ value
335
+ })),
248
336
  schemaVersion: message.schemaVersion,
249
337
  authToken: message.authToken,
250
338
  supportedWireFormats: message.supportedWireFormats
@@ -254,7 +342,10 @@ function toProtoEnvelope(message) {
254
342
  type: message.type,
255
343
  messageId: message.messageId,
256
344
  nodeId: message.nodeId,
257
- versionVector: Object.entries(message.versionVector).map(([key, value]) => ({ key, value })),
345
+ versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
346
+ key,
347
+ value
348
+ })),
258
349
  schemaVersion: message.schemaVersion,
259
350
  accepted: message.accepted,
260
351
  rejectReason: message.rejectReason,
@@ -283,6 +374,11 @@ function toProtoEnvelope(message) {
283
374
  errorMessage: message.message,
284
375
  retriable: message.retriable
285
376
  };
377
+ case "awareness-update":
378
+ return {
379
+ type: message.type,
380
+ messageId: message.messageId
381
+ };
286
382
  }
287
383
  }
288
384
  function fromProtoEnvelope(envelope) {
@@ -292,7 +388,9 @@ function fromProtoEnvelope(envelope) {
292
388
  type: "handshake",
293
389
  messageId: envelope.messageId,
294
390
  nodeId: envelope.nodeId ?? "",
295
- versionVector: Object.fromEntries((envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])),
391
+ versionVector: Object.fromEntries(
392
+ (envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])
393
+ ),
296
394
  schemaVersion: envelope.schemaVersion ?? 0,
297
395
  authToken: envelope.authToken,
298
396
  supportedWireFormats: envelope.supportedWireFormats?.filter(
@@ -304,7 +402,9 @@ function fromProtoEnvelope(envelope) {
304
402
  type: "handshake-response",
305
403
  messageId: envelope.messageId,
306
404
  nodeId: envelope.nodeId ?? "",
307
- versionVector: Object.fromEntries((envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])),
405
+ versionVector: Object.fromEntries(
406
+ (envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])
407
+ ),
308
408
  schemaVersion: envelope.schemaVersion ?? 0,
309
409
  accepted: envelope.accepted ?? false,
310
410
  rejectReason: envelope.rejectReason,
@@ -334,19 +434,39 @@ function fromProtoEnvelope(envelope) {
334
434
  retriable: envelope.retriable ?? false
335
435
  };
336
436
  default:
337
- throw new import_core.SyncError("Failed to decode sync message: unknown protobuf type", {
437
+ throw new import_core2.SyncError("Failed to decode sync message: unknown protobuf type", {
338
438
  type: envelope.type
339
439
  });
340
440
  }
341
441
  }
342
442
  function serializeProtoOperation(operation) {
443
+ const hasMetadata = operation.transactionId !== void 0 || operation.mutationName !== void 0;
444
+ let dataJson = "";
445
+ if (operation.data !== null) {
446
+ const dataPayload = { ...operation.data };
447
+ if (operation.atomicOps !== void 0 && Object.keys(operation.atomicOps).length > 0) {
448
+ dataPayload.__kora_atomic_ops__ = operation.atomicOps;
449
+ }
450
+ if (operation.transactionId !== void 0) {
451
+ dataPayload.__kora_tx_id__ = operation.transactionId;
452
+ }
453
+ if (operation.mutationName !== void 0) {
454
+ dataPayload.__kora_mutation__ = operation.mutationName;
455
+ }
456
+ dataJson = JSON.stringify(dataPayload);
457
+ } else if (hasMetadata) {
458
+ const meta = {};
459
+ if (operation.transactionId !== void 0) meta.__kora_tx_id__ = operation.transactionId;
460
+ if (operation.mutationName !== void 0) meta.__kora_mutation__ = operation.mutationName;
461
+ dataJson = JSON.stringify(meta);
462
+ }
343
463
  return {
344
464
  id: operation.id,
345
465
  nodeId: operation.nodeId,
346
466
  type: operation.type,
347
467
  collection: operation.collection,
348
468
  recordId: operation.recordId,
349
- dataJson: operation.data === null ? "" : JSON.stringify(operation.data),
469
+ dataJson,
350
470
  previousDataJson: operation.previousData === null ? "" : JSON.stringify(operation.previousData),
351
471
  timestamp: {
352
472
  wallTime: operation.timestamp.wallTime,
@@ -361,13 +481,31 @@ function serializeProtoOperation(operation) {
361
481
  };
362
482
  }
363
483
  function deserializeProtoOperation(operation) {
484
+ let data = null;
485
+ let atomicOps;
486
+ let transactionId;
487
+ let mutationName;
488
+ if (operation.hasData || operation.dataJson.length > 0) {
489
+ const parsed = JSON.parse(operation.dataJson);
490
+ if ("__kora_atomic_ops__" in parsed) {
491
+ atomicOps = parsed.__kora_atomic_ops__;
492
+ }
493
+ if ("__kora_tx_id__" in parsed) {
494
+ transactionId = parsed.__kora_tx_id__;
495
+ }
496
+ if ("__kora_mutation__" in parsed) {
497
+ mutationName = parsed.__kora_mutation__;
498
+ }
499
+ const { __kora_atomic_ops__: _a, __kora_tx_id__: _t, __kora_mutation__: _m, ...rest } = parsed;
500
+ data = operation.hasData && Object.keys(rest).length > 0 ? rest : null;
501
+ }
364
502
  return {
365
503
  id: operation.id,
366
504
  nodeId: operation.nodeId,
367
505
  type: operation.type,
368
506
  collection: operation.collection,
369
507
  recordId: operation.recordId,
370
- data: operation.hasData ? JSON.parse(operation.dataJson) : null,
508
+ data,
371
509
  previousData: operation.hasPreviousData ? JSON.parse(operation.previousDataJson) : null,
372
510
  timestamp: {
373
511
  wallTime: operation.timestamp.wallTime,
@@ -376,7 +514,10 @@ function deserializeProtoOperation(operation) {
376
514
  },
377
515
  sequenceNumber: operation.sequenceNumber,
378
516
  causalDeps: [...operation.causalDeps],
379
- schemaVersion: operation.schemaVersion
517
+ schemaVersion: operation.schemaVersion,
518
+ ...atomicOps !== void 0 ? { atomicOps } : {},
519
+ ...transactionId !== void 0 ? { transactionId } : {},
520
+ ...mutationName !== void 0 ? { mutationName } : {}
380
521
  };
381
522
  }
382
523
  function decodeTextPayload(data) {
@@ -393,7 +534,7 @@ function toBytes(data) {
393
534
  if (data instanceof ArrayBuffer) {
394
535
  return new Uint8Array(data);
395
536
  }
396
- throw new import_core.SyncError("Unsupported sync payload type", { receivedType: typeof data });
537
+ throw new import_core2.SyncError("Unsupported sync payload type", { receivedType: typeof data });
397
538
  }
398
539
  function encodeEnvelope(envelope) {
399
540
  const writer = Writer.create();
@@ -407,12 +548,14 @@ function encodeEnvelope(envelope) {
407
548
  writer.ldelim();
408
549
  }
409
550
  if (envelope.schemaVersion !== void 0) writer.uint32(40).int32(envelope.schemaVersion);
410
- if (envelope.authToken && envelope.authToken.length > 0) writer.uint32(50).string(envelope.authToken);
551
+ if (envelope.authToken && envelope.authToken.length > 0)
552
+ writer.uint32(50).string(envelope.authToken);
411
553
  for (const format of envelope.supportedWireFormats ?? []) {
412
554
  writer.uint32(58).string(format);
413
555
  }
414
556
  if (envelope.accepted !== void 0) writer.uint32(64).bool(envelope.accepted);
415
- if (envelope.rejectReason && envelope.rejectReason.length > 0) writer.uint32(74).string(envelope.rejectReason);
557
+ if (envelope.rejectReason && envelope.rejectReason.length > 0)
558
+ writer.uint32(74).string(envelope.rejectReason);
416
559
  if (envelope.selectedWireFormat && envelope.selectedWireFormat.length > 0) {
417
560
  writer.uint32(82).string(envelope.selectedWireFormat);
418
561
  }
@@ -426,8 +569,10 @@ function encodeEnvelope(envelope) {
426
569
  if (envelope.acknowledgedMessageId && envelope.acknowledgedMessageId.length > 0) {
427
570
  writer.uint32(114).string(envelope.acknowledgedMessageId);
428
571
  }
429
- if (envelope.lastSequenceNumber !== void 0) writer.uint32(120).int64(envelope.lastSequenceNumber);
430
- if (envelope.errorCode && envelope.errorCode.length > 0) writer.uint32(130).string(envelope.errorCode);
572
+ if (envelope.lastSequenceNumber !== void 0)
573
+ writer.uint32(120).int64(envelope.lastSequenceNumber);
574
+ if (envelope.errorCode && envelope.errorCode.length > 0)
575
+ writer.uint32(130).string(envelope.errorCode);
431
576
  if (envelope.errorMessage && envelope.errorMessage.length > 0) {
432
577
  writer.uint32(138).string(envelope.errorMessage);
433
578
  }
@@ -450,7 +595,10 @@ function decodeEnvelope(bytes) {
450
595
  envelope.nodeId = reader.string();
451
596
  break;
452
597
  case 4:
453
- envelope.versionVector = [...envelope.versionVector ?? [], decodeVectorEntry(reader, reader.uint32())];
598
+ envelope.versionVector = [
599
+ ...envelope.versionVector ?? [],
600
+ decodeVectorEntry(reader, reader.uint32())
601
+ ];
454
602
  break;
455
603
  case 5:
456
604
  envelope.schemaVersion = reader.int32();
@@ -471,7 +619,10 @@ function decodeEnvelope(bytes) {
471
619
  envelope.selectedWireFormat = reader.string();
472
620
  break;
473
621
  case 11:
474
- envelope.operations = [...envelope.operations ?? [], decodeProtoOperation(reader, reader.uint32())];
622
+ envelope.operations = [
623
+ ...envelope.operations ?? [],
624
+ decodeProtoOperation(reader, reader.uint32())
625
+ ];
475
626
  break;
476
627
  case 12:
477
628
  envelope.isFinal = reader.bool();
@@ -627,13 +778,13 @@ function longToNumber(value) {
627
778
  if (typeof value === "object" && value !== null && "toNumber" in value && typeof value.toNumber === "function") {
628
779
  return value.toNumber();
629
780
  }
630
- throw new import_core.SyncError("Failed to decode int64 value", {
781
+ throw new import_core2.SyncError("Failed to decode int64 value", {
631
782
  receivedType: typeof value
632
783
  });
633
784
  }
634
785
 
635
786
  // src/transport/websocket-transport.ts
636
- var import_core2 = require("@korajs/core");
787
+ var import_core3 = require("@korajs/core");
637
788
  var WS_OPEN = 1;
638
789
  var WebSocketTransport = class {
639
790
  ws = null;
@@ -656,7 +807,7 @@ var WebSocketTransport = class {
656
807
  }
657
808
  async connect(url, options) {
658
809
  if (!this.WebSocketImpl) {
659
- throw new import_core2.SyncError("WebSocket is not available in this environment", {
810
+ throw new import_core3.SyncError("WebSocket is not available in this environment", {
660
811
  hint: "Provide a WebSocketImpl option or use a polyfill"
661
812
  });
662
813
  }
@@ -670,7 +821,7 @@ var WebSocketTransport = class {
670
821
  };
671
822
  const timer = setTimeout(() => {
672
823
  settle(() => {
673
- const err = new import_core2.SyncError("WebSocket connection timed out", {
824
+ const err = new import_core3.SyncError("WebSocket connection timed out", {
674
825
  url,
675
826
  timeout: this.connectTimeout
676
827
  });
@@ -702,7 +853,7 @@ var WebSocketTransport = class {
702
853
  const message = this.serializer.decode(event.data);
703
854
  this.messageHandler?.(message);
704
855
  } catch {
705
- this.errorHandler?.(new import_core2.SyncError("Failed to decode incoming message"));
856
+ this.errorHandler?.(new import_core3.SyncError("Failed to decode incoming message"));
706
857
  }
707
858
  };
708
859
  ws.onclose = (event) => {
@@ -710,7 +861,7 @@ var WebSocketTransport = class {
710
861
  this.closeHandler?.(event.reason || `WebSocket closed with code ${event.code}`);
711
862
  };
712
863
  ws.onerror = (event) => {
713
- const err = new import_core2.SyncError("WebSocket error", {
864
+ const err = new import_core3.SyncError("WebSocket error", {
714
865
  url
715
866
  });
716
867
  this.errorHandler?.(err);
@@ -722,7 +873,7 @@ var WebSocketTransport = class {
722
873
  } catch (err) {
723
874
  settle(
724
875
  () => reject(
725
- err instanceof import_core2.SyncError ? err : new import_core2.SyncError("Failed to create WebSocket", {
876
+ err instanceof import_core3.SyncError ? err : new import_core3.SyncError("Failed to create WebSocket", {
726
877
  url,
727
878
  error: String(err)
728
879
  })
@@ -740,7 +891,7 @@ var WebSocketTransport = class {
740
891
  }
741
892
  send(message) {
742
893
  if (!this.ws || this.ws.readyState !== WS_OPEN) {
743
- throw new import_core2.SyncError("Cannot send message: WebSocket is not connected", {
894
+ throw new import_core3.SyncError("Cannot send message: WebSocket is not connected", {
744
895
  messageType: message.type
745
896
  });
746
897
  }
@@ -762,7 +913,7 @@ var WebSocketTransport = class {
762
913
  };
763
914
 
764
915
  // src/transport/http-long-polling-transport.ts
765
- var import_core3 = require("@korajs/core");
916
+ var import_core4 = require("@korajs/core");
766
917
  var DEFAULT_RETRY_DELAY_MS = 250;
767
918
  var HttpLongPollingTransport = class {
768
919
  serializer;
@@ -788,7 +939,7 @@ var HttpLongPollingTransport = class {
788
939
  }
789
940
  async connect(url, options) {
790
941
  if (this.connected) {
791
- throw new import_core3.SyncError("HTTP long-poll transport already connected", { url });
942
+ throw new import_core4.SyncError("HTTP long-poll transport already connected", { url });
792
943
  }
793
944
  this.url = normalizeHttpUrl(url);
794
945
  this.authToken = options?.authToken;
@@ -818,7 +969,7 @@ var HttpLongPollingTransport = class {
818
969
  }
819
970
  send(message) {
820
971
  if (!this.connected) {
821
- throw new import_core3.SyncError("Cannot send message: HTTP long-poll transport is not connected", {
972
+ throw new import_core4.SyncError("Cannot send message: HTTP long-poll transport is not connected", {
822
973
  messageType: message.type
823
974
  });
824
975
  }
@@ -882,7 +1033,7 @@ var HttpLongPollingTransport = class {
882
1033
  body: requestBody
883
1034
  });
884
1035
  if (!response.ok) {
885
- throw new import_core3.SyncError("HTTP transport send failed", {
1036
+ throw new import_core4.SyncError("HTTP transport send failed", {
886
1037
  status: response.status,
887
1038
  messageType: message.type
888
1039
  });
@@ -904,7 +1055,7 @@ var HttpLongPollingTransport = class {
904
1055
  continue;
905
1056
  }
906
1057
  if (!response.ok) {
907
- throw new import_core3.SyncError("HTTP long-poll request failed", {
1058
+ throw new import_core4.SyncError("HTTP long-poll request failed", {
908
1059
  status: response.status
909
1060
  });
910
1061
  }
@@ -1080,9 +1231,656 @@ var ChaosTransport = class {
1080
1231
  };
1081
1232
 
1082
1233
  // src/engine/sync-engine.ts
1083
- var import_core4 = require("@korajs/core");
1234
+ var import_core5 = require("@korajs/core");
1084
1235
  var import_internal2 = require("@korajs/core/internal");
1085
1236
 
1237
+ // src/diagnostics/bandwidth-estimator.ts
1238
+ var BandwidthEstimator = class {
1239
+ maxSamples;
1240
+ timeSource;
1241
+ samples = [];
1242
+ /**
1243
+ * @param maxSamples - Maximum number of samples to retain. Defaults to 20.
1244
+ * @param timeSource - Injectable time source for deterministic testing.
1245
+ */
1246
+ constructor(maxSamples = 20, timeSource) {
1247
+ this.maxSamples = maxSamples;
1248
+ this.timeSource = timeSource ?? { now: () => Date.now() };
1249
+ }
1250
+ /**
1251
+ * Record a transfer of `bytes` that took `durationMs` to complete.
1252
+ * Samples with zero or negative duration are ignored to avoid division errors.
1253
+ */
1254
+ recordTransfer(bytes, durationMs) {
1255
+ if (durationMs <= 0 || bytes <= 0) return;
1256
+ this.samples.push({
1257
+ bytes,
1258
+ durationMs,
1259
+ timestamp: this.timeSource.now()
1260
+ });
1261
+ while (this.samples.length > this.maxSamples) {
1262
+ this.samples.shift();
1263
+ }
1264
+ }
1265
+ /**
1266
+ * Estimate current effective bandwidth in bytes per second.
1267
+ *
1268
+ * Uses exponential weighting so recent samples have more influence.
1269
+ * Returns null if fewer than 2 samples exist (not enough data).
1270
+ */
1271
+ estimate() {
1272
+ if (this.samples.length < 2) return null;
1273
+ let weightedSum = 0;
1274
+ let totalWeight = 0;
1275
+ const decayFactor = 0.9;
1276
+ for (let i = this.samples.length - 1; i >= 0; i--) {
1277
+ const sample = this.samples[i];
1278
+ const bytesPerSec = sample.bytes / sample.durationMs * 1e3;
1279
+ const age = this.samples.length - 1 - i;
1280
+ const weight = decayFactor ** age;
1281
+ weightedSum += bytesPerSec * weight;
1282
+ totalWeight += weight;
1283
+ }
1284
+ if (totalWeight === 0) return null;
1285
+ return Math.round(weightedSum / totalWeight);
1286
+ }
1287
+ /**
1288
+ * Reset all recorded samples.
1289
+ */
1290
+ reset() {
1291
+ this.samples.length = 0;
1292
+ }
1293
+ /**
1294
+ * Get the number of samples currently stored.
1295
+ */
1296
+ sampleCount() {
1297
+ return this.samples.length;
1298
+ }
1299
+ };
1300
+
1301
+ // src/diagnostics/percentile.ts
1302
+ var SlidingWindowPercentile = class {
1303
+ samples;
1304
+ maxSize;
1305
+ writeIndex = 0;
1306
+ count = 0;
1307
+ /**
1308
+ * @param maxSize - Maximum number of samples in the sliding window.
1309
+ * Older samples are overwritten when the buffer is full.
1310
+ */
1311
+ constructor(maxSize) {
1312
+ if (maxSize < 1) {
1313
+ throw new Error(`SlidingWindowPercentile maxSize must be >= 1, got ${maxSize}`);
1314
+ }
1315
+ this.maxSize = maxSize;
1316
+ this.samples = new Array(maxSize);
1317
+ }
1318
+ /**
1319
+ * Add a sample to the sliding window.
1320
+ * If the window is full, the oldest sample is overwritten.
1321
+ */
1322
+ addSample(value) {
1323
+ this.samples[this.writeIndex] = value;
1324
+ this.writeIndex = (this.writeIndex + 1) % this.maxSize;
1325
+ if (this.count < this.maxSize) {
1326
+ this.count++;
1327
+ }
1328
+ }
1329
+ /**
1330
+ * Compute a percentile value from the current window.
1331
+ *
1332
+ * Uses the nearest-rank method: the percentile value is the smallest
1333
+ * value in the dataset such that at least p% of the data is <= that value.
1334
+ *
1335
+ * @param p - Percentile to compute (0-100). E.g., 50 for median, 95 for p95.
1336
+ * @returns The percentile value, or 0 if no samples have been recorded.
1337
+ */
1338
+ percentile(p) {
1339
+ if (this.count === 0) return 0;
1340
+ const sorted = this.samples.slice(0, this.count).sort((a, b) => a - b);
1341
+ const rank = Math.ceil(p / 100 * sorted.length) - 1;
1342
+ const index = Math.max(0, Math.min(rank, sorted.length - 1));
1343
+ return sorted[index];
1344
+ }
1345
+ /**
1346
+ * Get the most recently added sample, or 0 if no samples exist.
1347
+ */
1348
+ latest() {
1349
+ if (this.count === 0) return 0;
1350
+ const lastIndex = (this.writeIndex - 1 + this.maxSize) % this.maxSize;
1351
+ return this.samples[lastIndex];
1352
+ }
1353
+ /**
1354
+ * Get the number of samples currently in the window.
1355
+ */
1356
+ size() {
1357
+ return this.count;
1358
+ }
1359
+ /**
1360
+ * Reset the sliding window, clearing all samples.
1361
+ */
1362
+ reset() {
1363
+ this.writeIndex = 0;
1364
+ this.count = 0;
1365
+ }
1366
+ };
1367
+
1368
+ // src/diagnostics/metrics-collector.ts
1369
+ var SyncMetricsCollector = class {
1370
+ rttWindow;
1371
+ inboundBandwidth;
1372
+ outboundBandwidth;
1373
+ timeSource;
1374
+ diagnosticsInterval;
1375
+ // Connection
1376
+ connectedAt = null;
1377
+ disconnectedAt = null;
1378
+ reconnectAttempts = 0;
1379
+ // Throughput
1380
+ operationsSent = 0;
1381
+ operationsReceived = 0;
1382
+ bytesSent = 0;
1383
+ bytesReceived = 0;
1384
+ // Queue
1385
+ pendingOperations = 0;
1386
+ outboundQueueSize = 0;
1387
+ // Sync progress
1388
+ lastSyncedAt = null;
1389
+ syncStartTime = null;
1390
+ syncDuration = null;
1391
+ initialSyncComplete = false;
1392
+ initialSyncTotalBatches = 0;
1393
+ initialSyncReceivedBatches = 0;
1394
+ // Errors
1395
+ lastError = null;
1396
+ errorCount = 0;
1397
+ // Status
1398
+ currentStatus = "offline";
1399
+ currentQuality = "offline";
1400
+ // Periodic emission
1401
+ emitter = null;
1402
+ periodicTimer = null;
1403
+ constructor(config) {
1404
+ this.rttWindow = new SlidingWindowPercentile(config?.rttWindowSize ?? 100);
1405
+ this.inboundBandwidth = new BandwidthEstimator(
1406
+ config?.bandwidthWindowSize ?? 20,
1407
+ config?.timeSource
1408
+ );
1409
+ this.outboundBandwidth = new BandwidthEstimator(
1410
+ config?.bandwidthWindowSize ?? 20,
1411
+ config?.timeSource
1412
+ );
1413
+ this.timeSource = config?.timeSource ?? { now: () => Date.now() };
1414
+ this.diagnosticsInterval = config?.diagnosticsInterval ?? 5e3;
1415
+ }
1416
+ /**
1417
+ * Attach an event emitter for periodic diagnostics emission.
1418
+ * Starts emitting `sync:diagnostics` events at the configured interval
1419
+ * while connected.
1420
+ */
1421
+ attachEmitter(emitter) {
1422
+ this.emitter = emitter;
1423
+ }
1424
+ /**
1425
+ * Record that a connection has been established.
1426
+ */
1427
+ recordConnected() {
1428
+ this.connectedAt = this.timeSource.now();
1429
+ this.reconnectAttempts = 0;
1430
+ this.startPeriodicEmission();
1431
+ }
1432
+ /**
1433
+ * Record that the connection has been lost.
1434
+ */
1435
+ recordDisconnected() {
1436
+ this.disconnectedAt = this.timeSource.now();
1437
+ this.stopPeriodicEmission();
1438
+ }
1439
+ /**
1440
+ * Record a reconnection attempt.
1441
+ */
1442
+ recordReconnectAttempt() {
1443
+ this.reconnectAttempts++;
1444
+ }
1445
+ /**
1446
+ * Record a round-trip time measurement.
1447
+ */
1448
+ recordRtt(ms) {
1449
+ this.rttWindow.addSample(ms);
1450
+ }
1451
+ /**
1452
+ * Record operations sent with their serialized byte size.
1453
+ */
1454
+ recordSent(operationCount, byteSize, durationMs) {
1455
+ this.operationsSent += operationCount;
1456
+ this.bytesSent += byteSize;
1457
+ if (durationMs > 0 && byteSize > 0) {
1458
+ this.outboundBandwidth.recordTransfer(byteSize, durationMs);
1459
+ this.emitBandwidthEvent("out");
1460
+ }
1461
+ }
1462
+ /**
1463
+ * Record operations received with their serialized byte size.
1464
+ */
1465
+ recordReceived(operationCount, byteSize, durationMs) {
1466
+ this.operationsReceived += operationCount;
1467
+ this.bytesReceived += byteSize;
1468
+ if (durationMs > 0 && byteSize > 0) {
1469
+ this.inboundBandwidth.recordTransfer(byteSize, durationMs);
1470
+ this.emitBandwidthEvent("in");
1471
+ }
1472
+ }
1473
+ /**
1474
+ * Update the pending operations count and estimated outbound queue size.
1475
+ */
1476
+ updateQueue(pendingOps, estimatedBytes) {
1477
+ this.pendingOperations = pendingOps;
1478
+ this.outboundQueueSize = estimatedBytes;
1479
+ }
1480
+ /**
1481
+ * Record that sync has started (for measuring sync duration).
1482
+ */
1483
+ recordSyncStarted() {
1484
+ this.syncStartTime = this.timeSource.now();
1485
+ }
1486
+ /**
1487
+ * Record that a sync cycle has completed.
1488
+ */
1489
+ recordSyncCompleted() {
1490
+ if (this.syncStartTime !== null) {
1491
+ this.syncDuration = this.timeSource.now() - this.syncStartTime;
1492
+ this.syncStartTime = null;
1493
+ }
1494
+ this.lastSyncedAt = this.timeSource.now();
1495
+ if (!this.initialSyncComplete) {
1496
+ this.initialSyncComplete = true;
1497
+ }
1498
+ }
1499
+ /**
1500
+ * Update initial sync progress.
1501
+ * @param receivedBatches - Number of delta batches received so far.
1502
+ * @param totalBatches - Estimated total batches (0 if unknown).
1503
+ */
1504
+ updateInitialSyncProgress(receivedBatches, totalBatches) {
1505
+ this.initialSyncReceivedBatches = receivedBatches;
1506
+ this.initialSyncTotalBatches = totalBatches;
1507
+ const progress = totalBatches > 0 ? Math.min(1, receivedBatches / totalBatches) : receivedBatches > 0 ? 0.5 : 0;
1508
+ this.emitter?.emit({
1509
+ type: "sync:initial-sync-progress",
1510
+ progress,
1511
+ totalBatches,
1512
+ receivedBatches
1513
+ });
1514
+ }
1515
+ /**
1516
+ * Record an error.
1517
+ */
1518
+ recordError(message) {
1519
+ this.lastError = message;
1520
+ this.errorCount++;
1521
+ }
1522
+ /**
1523
+ * Update the current developer-facing status.
1524
+ */
1525
+ updateStatus(status) {
1526
+ this.currentStatus = status;
1527
+ }
1528
+ /**
1529
+ * Update the connection quality.
1530
+ */
1531
+ updateQuality(quality) {
1532
+ this.currentQuality = quality;
1533
+ }
1534
+ /**
1535
+ * Compute and return a full diagnostics snapshot.
1536
+ */
1537
+ getSnapshot() {
1538
+ return {
1539
+ // Connection
1540
+ status: this.currentStatus,
1541
+ connectedAt: this.connectedAt,
1542
+ disconnectedAt: this.disconnectedAt,
1543
+ reconnectAttempts: this.reconnectAttempts,
1544
+ // Latency
1545
+ rttMs: this.rttWindow.latest(),
1546
+ rttP50Ms: this.rttWindow.percentile(50),
1547
+ rttP95Ms: this.rttWindow.percentile(95),
1548
+ rttP99Ms: this.rttWindow.percentile(99),
1549
+ // Throughput
1550
+ operationsSent: this.operationsSent,
1551
+ operationsReceived: this.operationsReceived,
1552
+ bytesSent: this.bytesSent,
1553
+ bytesReceived: this.bytesReceived,
1554
+ // Queue
1555
+ pendingOperations: this.pendingOperations,
1556
+ outboundQueueSize: this.outboundQueueSize,
1557
+ // Sync Progress
1558
+ lastSyncedAt: this.lastSyncedAt,
1559
+ syncDuration: this.syncDuration,
1560
+ initialSyncComplete: this.initialSyncComplete,
1561
+ initialSyncProgress: this.computeInitialSyncProgress(),
1562
+ // Errors
1563
+ lastError: this.lastError,
1564
+ errorCount: this.errorCount,
1565
+ // Connection Quality
1566
+ quality: this.currentQuality,
1567
+ effectiveBandwidth: this.computeEffectiveBandwidth()
1568
+ };
1569
+ }
1570
+ /**
1571
+ * Assess connection quality from current metrics.
1572
+ *
1573
+ * Quality is derived from RTT percentiles, error rate, and bandwidth:
1574
+ * - excellent: p95 < 100ms, no errors
1575
+ * - good: p95 < 300ms, errorCount <= 1
1576
+ * - fair: p95 < 1000ms, errorCount <= 3
1577
+ * - poor: p95 >= 1000ms or errorCount > 3
1578
+ * - offline: no connection
1579
+ */
1580
+ assessQuality() {
1581
+ if (this.currentStatus === "offline") return "offline";
1582
+ const p95 = this.rttWindow.percentile(95);
1583
+ const hasSamples = this.rttWindow.size() > 0;
1584
+ if (!hasSamples) {
1585
+ if (this.errorCount > 3) return "poor";
1586
+ if (this.errorCount > 0) return "fair";
1587
+ return "good";
1588
+ }
1589
+ if (this.errorCount > 3) return "poor";
1590
+ if (p95 < 100 && this.errorCount === 0) return "excellent";
1591
+ if (p95 < 300 && this.errorCount <= 1) return "good";
1592
+ if (p95 < 1e3 && this.errorCount <= 3) return "fair";
1593
+ return "poor";
1594
+ }
1595
+ /**
1596
+ * Reset all collected metrics. Call when starting a new session.
1597
+ */
1598
+ reset() {
1599
+ this.rttWindow.reset();
1600
+ this.inboundBandwidth.reset();
1601
+ this.outboundBandwidth.reset();
1602
+ this.connectedAt = null;
1603
+ this.disconnectedAt = null;
1604
+ this.reconnectAttempts = 0;
1605
+ this.operationsSent = 0;
1606
+ this.operationsReceived = 0;
1607
+ this.bytesSent = 0;
1608
+ this.bytesReceived = 0;
1609
+ this.pendingOperations = 0;
1610
+ this.outboundQueueSize = 0;
1611
+ this.lastSyncedAt = null;
1612
+ this.syncStartTime = null;
1613
+ this.syncDuration = null;
1614
+ this.initialSyncComplete = false;
1615
+ this.initialSyncTotalBatches = 0;
1616
+ this.initialSyncReceivedBatches = 0;
1617
+ this.lastError = null;
1618
+ this.errorCount = 0;
1619
+ this.currentStatus = "offline";
1620
+ this.currentQuality = "offline";
1621
+ this.stopPeriodicEmission();
1622
+ }
1623
+ /**
1624
+ * Stop periodic emission and clean up resources.
1625
+ */
1626
+ dispose() {
1627
+ this.stopPeriodicEmission();
1628
+ this.emitter = null;
1629
+ }
1630
+ // --- Private helpers ---
1631
+ computeInitialSyncProgress() {
1632
+ if (this.initialSyncComplete) return 1;
1633
+ if (this.initialSyncTotalBatches > 0) {
1634
+ return Math.min(1, this.initialSyncReceivedBatches / this.initialSyncTotalBatches);
1635
+ }
1636
+ return this.initialSyncReceivedBatches > 0 ? 0.5 : 0;
1637
+ }
1638
+ computeEffectiveBandwidth() {
1639
+ const inbound = this.inboundBandwidth.estimate();
1640
+ const outbound = this.outboundBandwidth.estimate();
1641
+ if (inbound === null && outbound === null) return null;
1642
+ if (inbound === null) return outbound;
1643
+ if (outbound === null) return inbound;
1644
+ return Math.min(inbound, outbound);
1645
+ }
1646
+ startPeriodicEmission() {
1647
+ if (this.periodicTimer !== null) return;
1648
+ if (!this.emitter) return;
1649
+ this.periodicTimer = setInterval(() => {
1650
+ this.emitDiagnostics();
1651
+ }, this.diagnosticsInterval);
1652
+ }
1653
+ stopPeriodicEmission() {
1654
+ if (this.periodicTimer !== null) {
1655
+ clearInterval(this.periodicTimer);
1656
+ this.periodicTimer = null;
1657
+ }
1658
+ }
1659
+ emitDiagnostics() {
1660
+ this.emitter?.emit({
1661
+ type: "sync:diagnostics",
1662
+ diagnostics: this.getSnapshot()
1663
+ });
1664
+ }
1665
+ emitBandwidthEvent(direction) {
1666
+ const estimator = direction === "in" ? this.inboundBandwidth : this.outboundBandwidth;
1667
+ const bps = estimator.estimate();
1668
+ if (bps !== null) {
1669
+ this.emitter?.emit({
1670
+ type: "sync:bandwidth",
1671
+ bytesPerSecond: bps,
1672
+ direction
1673
+ });
1674
+ }
1675
+ }
1676
+ };
1677
+
1678
+ // src/awareness/awareness-manager.ts
1679
+ var DEFAULT_TIMEOUT_MS = 3e4;
1680
+ var nextLocalClientId = 1;
1681
+ var AwarenessManager = class {
1682
+ /** Unique client ID for this instance */
1683
+ clientId;
1684
+ localState = null;
1685
+ remoteStates = /* @__PURE__ */ new Map();
1686
+ listeners = /* @__PURE__ */ new Set();
1687
+ emitter;
1688
+ timeoutMs;
1689
+ // Track when we last received an update from each remote client.
1690
+ // Used for timeout-based cleanup as a safety net in case the server
1691
+ // does not send an explicit removal on disconnect.
1692
+ lastUpdated = /* @__PURE__ */ new Map();
1693
+ cleanupTimer = null;
1694
+ sendHandler = null;
1695
+ destroyed = false;
1696
+ constructor(options) {
1697
+ this.clientId = options?.clientId ?? nextLocalClientId++;
1698
+ this.emitter = options?.emitter ?? null;
1699
+ this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1700
+ }
1701
+ /**
1702
+ * Set the local user's awareness state and broadcast to peers.
1703
+ *
1704
+ * @param state - The awareness state to share. Pass null to clear presence.
1705
+ */
1706
+ setLocalState(state) {
1707
+ if (this.destroyed) return;
1708
+ this.localState = state;
1709
+ const message = {
1710
+ type: "awareness",
1711
+ clientId: this.clientId,
1712
+ states: { [this.clientId]: state }
1713
+ };
1714
+ this.sendHandler?.(message);
1715
+ this.emitAwarenessEvent();
1716
+ }
1717
+ /**
1718
+ * Get the local awareness state.
1719
+ */
1720
+ getLocalState() {
1721
+ return this.localState;
1722
+ }
1723
+ /**
1724
+ * Get all known awareness states (local + remote).
1725
+ * Returns a new Map on each call.
1726
+ */
1727
+ getStates() {
1728
+ const result = /* @__PURE__ */ new Map();
1729
+ if (this.localState) {
1730
+ result.set(this.clientId, this.localState);
1731
+ }
1732
+ for (const [id, state] of this.remoteStates) {
1733
+ result.set(id, state);
1734
+ }
1735
+ return result;
1736
+ }
1737
+ /**
1738
+ * Handle an incoming awareness message from the transport.
1739
+ * This processes remote state updates and notifies listeners.
1740
+ */
1741
+ handleRemoteMessage(message) {
1742
+ if (this.destroyed) return;
1743
+ const added = [];
1744
+ const updated = [];
1745
+ const removed = [];
1746
+ const now = Date.now();
1747
+ for (const [clientIdStr, state] of Object.entries(message.states)) {
1748
+ const clientId = Number(clientIdStr);
1749
+ if (clientId === this.clientId) continue;
1750
+ if (state === null) {
1751
+ if (this.remoteStates.has(clientId)) {
1752
+ this.remoteStates.delete(clientId);
1753
+ this.lastUpdated.delete(clientId);
1754
+ removed.push(clientId);
1755
+ }
1756
+ } else {
1757
+ if (this.remoteStates.has(clientId)) {
1758
+ this.remoteStates.set(clientId, state);
1759
+ this.lastUpdated.set(clientId, now);
1760
+ updated.push(clientId);
1761
+ } else {
1762
+ this.remoteStates.set(clientId, state);
1763
+ this.lastUpdated.set(clientId, now);
1764
+ added.push(clientId);
1765
+ }
1766
+ }
1767
+ }
1768
+ if (added.length > 0 || updated.length > 0 || removed.length > 0) {
1769
+ const change = { added, updated, removed };
1770
+ this.notifyListeners(change);
1771
+ this.emitAwarenessEvent();
1772
+ }
1773
+ }
1774
+ /**
1775
+ * Remove a specific remote client's awareness state.
1776
+ * Called when the server notifies that a client has disconnected.
1777
+ */
1778
+ removeClient(clientId) {
1779
+ if (this.destroyed) return;
1780
+ if (!this.remoteStates.has(clientId)) return;
1781
+ this.remoteStates.delete(clientId);
1782
+ this.lastUpdated.delete(clientId);
1783
+ const change = {
1784
+ added: [],
1785
+ updated: [],
1786
+ removed: [clientId]
1787
+ };
1788
+ this.notifyListeners(change);
1789
+ this.emitAwarenessEvent();
1790
+ }
1791
+ /**
1792
+ * Register a handler for sending awareness messages through the transport.
1793
+ * The sync engine calls this to wire outgoing awareness messages to the transport.
1794
+ */
1795
+ onSend(handler) {
1796
+ this.sendHandler = handler;
1797
+ }
1798
+ /**
1799
+ * Register a listener for awareness state changes.
1800
+ * Returns an unsubscribe function.
1801
+ */
1802
+ on(_event, listener) {
1803
+ this.listeners.add(listener);
1804
+ return () => {
1805
+ this.listeners.delete(listener);
1806
+ };
1807
+ }
1808
+ /**
1809
+ * Remove a specific change listener.
1810
+ */
1811
+ off(_event, listener) {
1812
+ this.listeners.delete(listener);
1813
+ }
1814
+ /**
1815
+ * Start the cleanup timer that removes stale remote states.
1816
+ * Called when the sync engine transitions to streaming state.
1817
+ */
1818
+ startCleanupTimer() {
1819
+ if (this.cleanupTimer) return;
1820
+ this.cleanupTimer = setInterval(() => {
1821
+ this.cleanupStaleStates();
1822
+ }, this.timeoutMs);
1823
+ }
1824
+ /**
1825
+ * Stop the cleanup timer.
1826
+ */
1827
+ stopCleanupTimer() {
1828
+ if (this.cleanupTimer) {
1829
+ clearInterval(this.cleanupTimer);
1830
+ this.cleanupTimer = null;
1831
+ }
1832
+ }
1833
+ /**
1834
+ * Clean up all resources. After calling destroy(), the manager
1835
+ * will no longer send or receive awareness updates.
1836
+ * Broadcasts removal of local state before shutting down.
1837
+ */
1838
+ destroy() {
1839
+ if (this.destroyed) return;
1840
+ this.destroyed = true;
1841
+ if (this.localState) {
1842
+ const message = {
1843
+ type: "awareness",
1844
+ clientId: this.clientId,
1845
+ states: { [this.clientId]: null }
1846
+ };
1847
+ this.sendHandler?.(message);
1848
+ }
1849
+ this.localState = null;
1850
+ this.remoteStates.clear();
1851
+ this.lastUpdated.clear();
1852
+ this.listeners.clear();
1853
+ this.sendHandler = null;
1854
+ this.stopCleanupTimer();
1855
+ }
1856
+ // --- Private ---
1857
+ cleanupStaleStates() {
1858
+ const now = Date.now();
1859
+ const removed = [];
1860
+ for (const [clientId, lastTime] of this.lastUpdated) {
1861
+ if (now - lastTime > this.timeoutMs) {
1862
+ this.remoteStates.delete(clientId);
1863
+ this.lastUpdated.delete(clientId);
1864
+ removed.push(clientId);
1865
+ }
1866
+ }
1867
+ if (removed.length > 0) {
1868
+ const change = { added: [], updated: [], removed };
1869
+ this.notifyListeners(change);
1870
+ this.emitAwarenessEvent();
1871
+ }
1872
+ }
1873
+ notifyListeners(change) {
1874
+ for (const listener of this.listeners) {
1875
+ listener(change);
1876
+ }
1877
+ }
1878
+ emitAwarenessEvent() {
1879
+ const states = this.getStates();
1880
+ this.emitter?.emit({ type: "awareness:updated", states });
1881
+ }
1882
+ };
1883
+
1086
1884
  // src/engine/memory-queue-storage.ts
1087
1885
  var MemoryQueueStorage = class {
1088
1886
  operations = /* @__PURE__ */ new Map();
@@ -1240,14 +2038,26 @@ var SyncEngine = class {
1240
2038
  emitter;
1241
2039
  outboundQueue;
1242
2040
  batchSize;
2041
+ encryptor;
2042
+ awarenessManager;
2043
+ metricsCollector;
1243
2044
  remoteVector = /* @__PURE__ */ new Map();
1244
2045
  lastSyncedAt = null;
2046
+ lastSuccessfulPush = null;
2047
+ lastSuccessfulPull = null;
2048
+ conflictCount = 0;
1245
2049
  currentBatch = null;
1246
2050
  reconnecting = false;
1247
2051
  // Track delta exchange state
1248
2052
  deltaBatchesReceived = 0;
1249
2053
  deltaReceiveComplete = false;
1250
2054
  deltaSendComplete = false;
2055
+ /**
2056
+ * The effective scope for this sync session.
2057
+ * Starts as the configured scopeMap. After handshake, may be replaced
2058
+ * with the server-accepted scope (server is authoritative).
2059
+ */
2060
+ activeScope;
1251
2061
  constructor(options) {
1252
2062
  this.transport = options.transport;
1253
2063
  this.store = options.store;
@@ -1255,15 +2065,34 @@ var SyncEngine = class {
1255
2065
  this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
1256
2066
  this.emitter = options.emitter ?? null;
1257
2067
  this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
2068
+ this.encryptor = options.encryptor ?? null;
2069
+ this.activeScope = options.config.scopeMap;
1258
2070
  const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
1259
2071
  this.outboundQueue = new OutboundQueue(queueStorage);
2072
+ this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
2073
+ if (this.emitter) {
2074
+ this.metricsCollector.attachEmitter(this.emitter);
2075
+ }
2076
+ this.awarenessManager = new AwarenessManager({
2077
+ emitter: this.emitter ?? void 0
2078
+ });
2079
+ this.awarenessManager.onSend((message) => {
2080
+ if (this.state !== "streaming") return;
2081
+ const wireMessage = {
2082
+ type: "awareness-update",
2083
+ messageId: generateMessageId(),
2084
+ clientId: message.clientId,
2085
+ states: awarenessStatesToWire(message.states)
2086
+ };
2087
+ this.transport.send(wireMessage);
2088
+ });
1260
2089
  }
1261
2090
  /**
1262
2091
  * Start the sync engine: connect → handshake → delta exchange → streaming.
1263
2092
  */
1264
2093
  async start() {
1265
2094
  if (this.state !== "disconnected") {
1266
- throw new import_core4.SyncError("Cannot start sync engine: not in disconnected state", {
2095
+ throw new import_core5.SyncError("Cannot start sync engine: not in disconnected state", {
1267
2096
  currentState: this.state
1268
2097
  });
1269
2098
  }
@@ -1284,7 +2113,8 @@ var SyncEngine = class {
1284
2113
  versionVector: versionVectorToWire(localVector),
1285
2114
  schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
1286
2115
  authToken,
1287
- supportedWireFormats: ["json", "protobuf"]
2116
+ supportedWireFormats: ["json", "protobuf"],
2117
+ ...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {}
1288
2118
  };
1289
2119
  this.transport.send(handshake);
1290
2120
  } catch (err) {
@@ -1297,6 +2127,7 @@ var SyncEngine = class {
1297
2127
  */
1298
2128
  async stop() {
1299
2129
  if (this.state === "disconnected") return;
2130
+ this.awarenessManager.stopCleanupTimer();
1300
2131
  if (this.currentBatch) {
1301
2132
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
1302
2133
  this.currentBatch = null;
@@ -1315,8 +2146,14 @@ var SyncEngine = class {
1315
2146
  /**
1316
2147
  * Push a local operation to the outbound queue.
1317
2148
  * If streaming, flushes immediately.
2149
+ *
2150
+ * Operations outside the configured sync scope are silently skipped
2151
+ * because they should remain local-only and not be sent to the server.
1318
2152
  */
1319
2153
  async pushOperation(op) {
2154
+ if (!operationMatchesScope(op, this.activeScope)) {
2155
+ return;
2156
+ }
1320
2157
  await this.outboundQueue.enqueue(op);
1321
2158
  if (this.state === "streaming") {
1322
2159
  this.flushQueue();
@@ -1336,27 +2173,64 @@ var SyncEngine = class {
1336
2173
  */
1337
2174
  getStatus() {
1338
2175
  const pendingOperations = this.outboundQueue.totalPending;
2176
+ const base = {
2177
+ pendingOperations,
2178
+ lastSyncedAt: this.lastSyncedAt,
2179
+ lastSuccessfulPush: this.lastSuccessfulPush,
2180
+ lastSuccessfulPull: this.lastSuccessfulPull,
2181
+ conflicts: this.conflictCount
2182
+ };
1339
2183
  switch (this.state) {
1340
2184
  case "disconnected":
1341
- return { status: "offline", pendingOperations, lastSyncedAt: this.lastSyncedAt };
2185
+ return { ...base, status: "offline" };
1342
2186
  case "connecting":
1343
2187
  case "handshaking":
1344
2188
  case "syncing":
1345
- return {
1346
- status: this.reconnecting ? "offline" : "syncing",
1347
- pendingOperations,
1348
- lastSyncedAt: this.lastSyncedAt
1349
- };
2189
+ return { ...base, status: this.reconnecting ? "offline" : "syncing" };
1350
2190
  case "streaming":
1351
- return {
1352
- status: pendingOperations > 0 ? "syncing" : "synced",
1353
- pendingOperations,
1354
- lastSyncedAt: this.lastSyncedAt
1355
- };
2191
+ return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
1356
2192
  case "error":
1357
- return { status: "error", pendingOperations, lastSyncedAt: this.lastSyncedAt };
2193
+ return { ...base, status: "error" };
1358
2194
  }
1359
2195
  }
2196
+ /**
2197
+ * Record a merge conflict. Called by the merge-aware sync store
2198
+ * to increment the conflict counter for status reporting.
2199
+ */
2200
+ recordConflict() {
2201
+ this.conflictCount++;
2202
+ }
2203
+ /**
2204
+ * Force an immediate reconnection attempt. If the engine is disconnected
2205
+ * or in error state, restarts the sync. If already connected, no-op.
2206
+ */
2207
+ async retryNow() {
2208
+ if (this.state === "disconnected" || this.state === "error") {
2209
+ this.reconnecting = false;
2210
+ await this.start();
2211
+ }
2212
+ }
2213
+ /**
2214
+ * Export a diagnostics snapshot for debugging and support tickets.
2215
+ * Contains connection state, timing info, and queue metrics.
2216
+ */
2217
+ exportDiagnostics() {
2218
+ return {
2219
+ state: this.state,
2220
+ status: this.getStatus(),
2221
+ nodeId: this.store.getNodeId(),
2222
+ url: this.config.url,
2223
+ schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
2224
+ lastSyncedAt: this.lastSyncedAt,
2225
+ lastSuccessfulPush: this.lastSuccessfulPush,
2226
+ lastSuccessfulPull: this.lastSuccessfulPull,
2227
+ conflicts: this.conflictCount,
2228
+ pendingOperations: this.outboundQueue.totalPending,
2229
+ hasInFlightBatch: this.currentBatch !== null,
2230
+ reconnecting: this.reconnecting,
2231
+ timestamp: Date.now()
2232
+ };
2233
+ }
1360
2234
  /**
1361
2235
  * Get the current internal state (for testing).
1362
2236
  */
@@ -1369,6 +2243,36 @@ var SyncEngine = class {
1369
2243
  getOutboundQueue() {
1370
2244
  return this.outboundQueue;
1371
2245
  }
2246
+ /**
2247
+ * Update the sync scope map. Takes effect on the next connection attempt.
2248
+ *
2249
+ * When the scope changes (e.g., user switches organization), call this method
2250
+ * then reconnect. The new scope will be sent in the handshake, and the server
2251
+ * will send back data matching the new scope.
2252
+ *
2253
+ * Data that no longer matches the new scope is NOT deleted locally.
2254
+ * It simply stops being synced.
2255
+ *
2256
+ * @param scopeMap - New per-collection scope filters, or undefined to remove scope
2257
+ */
2258
+ updateScope(scopeMap) {
2259
+ this.activeScope = scopeMap;
2260
+ this.config.scopeMap = scopeMap;
2261
+ }
2262
+ /**
2263
+ * Get the currently active scope map. Returns undefined if no scope is configured.
2264
+ */
2265
+ getActiveScope() {
2266
+ return this.activeScope;
2267
+ }
2268
+ /**
2269
+ * Get the awareness manager for collaborative presence.
2270
+ * Use this to set local presence, observe remote collaborators,
2271
+ * and track cursor positions in richtext fields.
2272
+ */
2273
+ getAwarenessManager() {
2274
+ return this.awarenessManager;
2275
+ }
1372
2276
  // --- Private methods ---
1373
2277
  handleMessage(message) {
1374
2278
  switch (message.type) {
@@ -1384,6 +2288,9 @@ var SyncEngine = class {
1384
2288
  case "error":
1385
2289
  this.handleError(message);
1386
2290
  break;
2291
+ case "awareness-update":
2292
+ this.handleAwarenessUpdate(message);
2293
+ break;
1387
2294
  }
1388
2295
  }
1389
2296
  handleHandshakeResponse(msg) {
@@ -1401,7 +2308,13 @@ var SyncEngine = class {
1401
2308
  if (msg.selectedWireFormat) {
1402
2309
  this.setSerializerWireFormat(msg.selectedWireFormat);
1403
2310
  }
2311
+ if (msg.acceptedScope) {
2312
+ this.activeScope = msg.acceptedScope;
2313
+ }
1404
2314
  this.emitter?.emit({ type: "sync:connected", nodeId: this.store.getNodeId() });
2315
+ this.metricsCollector.recordConnected();
2316
+ this.metricsCollector.updateStatus("syncing");
2317
+ this.metricsCollector.recordSyncStarted();
1405
2318
  this.transitionTo("syncing");
1406
2319
  this.deltaBatchesReceived = 0;
1407
2320
  this.deltaReceiveComplete = false;
@@ -1410,7 +2323,8 @@ var SyncEngine = class {
1410
2323
  }
1411
2324
  async sendDelta() {
1412
2325
  const localVector = this.store.getVersionVector();
1413
- const missingOps = await this.collectDelta(localVector, this.remoteVector);
2326
+ const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
2327
+ const missingOps = allMissingOps.filter((op) => operationMatchesScope(op, this.activeScope));
1414
2328
  if (missingOps.length === 0) {
1415
2329
  const emptyBatch = {
1416
2330
  type: "operation-batch",
@@ -1429,7 +2343,8 @@ var SyncEngine = class {
1429
2343
  for (let i = 0; i < totalBatches; i++) {
1430
2344
  const start = i * this.batchSize;
1431
2345
  const batchOps = sorted.slice(start, start + this.batchSize);
1432
- const serializedOps = batchOps.map((op) => this.serializer.encodeOperation(op));
2346
+ const opsToSerialize = this.encryptor ? await this.encryptor.encryptBatch(batchOps) : batchOps;
2347
+ const serializedOps = opsToSerialize.map((op) => this.serializer.encodeOperation(op));
1433
2348
  const batchMsg = {
1434
2349
  type: "operation-batch",
1435
2350
  messageId: generateMessageId(),
@@ -1459,15 +2374,18 @@ var SyncEngine = class {
1459
2374
  return missing;
1460
2375
  }
1461
2376
  async handleOperationBatch(msg) {
1462
- const operations = msg.operations.map((s) => this.serializer.decodeOperation(s));
1463
- for (const op of operations) {
2377
+ const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
2378
+ const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
2379
+ const inScopeOps = operations.filter((op) => operationMatchesScope(op, this.activeScope));
2380
+ for (const op of inScopeOps) {
1464
2381
  await this.store.applyRemoteOperation(op);
1465
2382
  }
1466
- if (operations.length > 0) {
2383
+ if (inScopeOps.length > 0) {
2384
+ this.lastSuccessfulPull = Date.now();
1467
2385
  this.emitter?.emit({
1468
2386
  type: "sync:received",
1469
- operations,
1470
- batchSize: operations.length
2387
+ operations: inScopeOps,
2388
+ batchSize: inScopeOps.length
1471
2389
  });
1472
2390
  }
1473
2391
  const lastOp = operations[operations.length - 1];
@@ -1494,7 +2412,9 @@ var SyncEngine = class {
1494
2412
  if (this.currentBatch) {
1495
2413
  this.outboundQueue.acknowledge(this.currentBatch.batchId);
1496
2414
  this.currentBatch = null;
1497
- this.lastSyncedAt = Date.now();
2415
+ const now = Date.now();
2416
+ this.lastSyncedAt = now;
2417
+ this.lastSuccessfulPush = now;
1498
2418
  }
1499
2419
  if (this.state === "streaming" && this.outboundQueue.hasOperations) {
1500
2420
  this.flushQueue();
@@ -1512,6 +2432,11 @@ var SyncEngine = class {
1512
2432
  if (this.deltaSendComplete && this.deltaReceiveComplete) {
1513
2433
  this.lastSyncedAt = Date.now();
1514
2434
  this.transitionTo("streaming");
2435
+ this.awarenessManager.startCleanupTimer();
2436
+ const localState = this.awarenessManager.getLocalState();
2437
+ if (localState) {
2438
+ this.awarenessManager.setLocalState(localState);
2439
+ }
1515
2440
  if (this.outboundQueue.hasOperations) {
1516
2441
  this.flushQueue();
1517
2442
  }
@@ -1523,20 +2448,57 @@ var SyncEngine = class {
1523
2448
  const batch = this.outboundQueue.takeBatch(this.batchSize);
1524
2449
  if (!batch) return;
1525
2450
  this.currentBatch = batch;
1526
- const serializedOps = batch.operations.map((op) => this.serializer.encodeOperation(op));
1527
- const batchMsg = {
1528
- type: "operation-batch",
1529
- messageId: generateMessageId(),
1530
- operations: serializedOps,
1531
- isFinal: true,
1532
- batchIndex: 0
2451
+ if (this.encryptor) {
2452
+ this.encryptor.encryptBatch(batch.operations).then(
2453
+ (encrypted) => {
2454
+ const serializedOps = encrypted.map((op) => this.serializer.encodeOperation(op));
2455
+ const batchMsg = {
2456
+ type: "operation-batch",
2457
+ messageId: generateMessageId(),
2458
+ operations: serializedOps,
2459
+ isFinal: true,
2460
+ batchIndex: 0
2461
+ };
2462
+ this.transport.send(batchMsg);
2463
+ this.emitter?.emit({
2464
+ type: "sync:sent",
2465
+ operations: batch.operations,
2466
+ batchSize: batch.operations.length
2467
+ });
2468
+ },
2469
+ (err) => {
2470
+ this.outboundQueue.returnBatch(batch.batchId);
2471
+ this.currentBatch = null;
2472
+ this.emitter?.emit({
2473
+ type: "sync:disconnected",
2474
+ reason: err instanceof Error ? err.message : "Encryption failed"
2475
+ });
2476
+ }
2477
+ );
2478
+ } else {
2479
+ const serializedOps = batch.operations.map((op) => this.serializer.encodeOperation(op));
2480
+ const batchMsg = {
2481
+ type: "operation-batch",
2482
+ messageId: generateMessageId(),
2483
+ operations: serializedOps,
2484
+ isFinal: true,
2485
+ batchIndex: 0
2486
+ };
2487
+ this.transport.send(batchMsg);
2488
+ this.emitter?.emit({
2489
+ type: "sync:sent",
2490
+ operations: batch.operations,
2491
+ batchSize: batch.operations.length
2492
+ });
2493
+ }
2494
+ }
2495
+ handleAwarenessUpdate(msg) {
2496
+ const awarenessMessage = {
2497
+ type: "awareness",
2498
+ clientId: msg.clientId,
2499
+ states: wireToAwarenessStates(msg.states)
1533
2500
  };
1534
- this.transport.send(batchMsg);
1535
- this.emitter?.emit({
1536
- type: "sync:sent",
1537
- operations: batch.operations,
1538
- batchSize: batch.operations.length
1539
- });
2501
+ this.awarenessManager.handleRemoteMessage(awarenessMessage);
1540
2502
  }
1541
2503
  handleTransportClose(reason) {
1542
2504
  if (this.currentBatch) {
@@ -1558,7 +2520,7 @@ var SyncEngine = class {
1558
2520
  transitionTo(newState) {
1559
2521
  const validTargets = VALID_TRANSITIONS[this.state];
1560
2522
  if (!validTargets.includes(newState)) {
1561
- throw new import_core4.SyncError(`Invalid sync state transition: ${this.state} \u2192 ${newState}`, {
2523
+ throw new import_core5.SyncError(`Invalid sync state transition: ${this.state} \u2192 ${newState}`, {
1562
2524
  from: this.state,
1563
2525
  to: newState
1564
2526
  });
@@ -1571,6 +2533,40 @@ var SyncEngine = class {
1571
2533
  }
1572
2534
  }
1573
2535
  };
2536
+ function awarenessStatesToWire(states) {
2537
+ const wire = {};
2538
+ for (const [clientId, state] of Object.entries(states)) {
2539
+ if (state === null) {
2540
+ wire[clientId] = null;
2541
+ } else {
2542
+ const wireState = {
2543
+ user: { ...state.user }
2544
+ };
2545
+ if (state.cursor) {
2546
+ wireState.cursor = { ...state.cursor };
2547
+ }
2548
+ wire[clientId] = wireState;
2549
+ }
2550
+ }
2551
+ return wire;
2552
+ }
2553
+ function wireToAwarenessStates(wire) {
2554
+ const states = {};
2555
+ for (const [clientId, wireState] of Object.entries(wire)) {
2556
+ if (wireState === null) {
2557
+ states[Number(clientId)] = null;
2558
+ } else {
2559
+ const state = {
2560
+ user: { ...wireState.user }
2561
+ };
2562
+ if (wireState.cursor) {
2563
+ state.cursor = { ...wireState.cursor };
2564
+ }
2565
+ states[Number(clientId)] = state;
2566
+ }
2567
+ }
2568
+ return states;
2569
+ }
1574
2570
 
1575
2571
  // src/engine/connection-monitor.ts
1576
2572
  var ConnectionMonitor = class {
@@ -1763,26 +2759,466 @@ var ReconnectionManager = class {
1763
2759
  });
1764
2760
  }
1765
2761
  };
2762
+
2763
+ // src/encryption/sync-encryptor.ts
2764
+ var import_core7 = require("@korajs/core");
2765
+
2766
+ // src/encryption/key-derivation.ts
2767
+ var import_core6 = require("@korajs/core");
2768
+ var KeyDerivationError = class extends import_core6.SyncError {
2769
+ constructor(message, context) {
2770
+ super(message, { ...context, errorType: "KEY_DERIVATION_ERROR" });
2771
+ this.name = "KeyDerivationError";
2772
+ }
2773
+ };
2774
+ var SALT_LENGTH = 32;
2775
+ var PBKDF2_ITERATIONS = 6e5;
2776
+ var DERIVED_KEY_LENGTH = 256;
2777
+ function assertCryptoAvailable() {
2778
+ if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
2779
+ throw new KeyDerivationError(
2780
+ "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+."
2781
+ );
2782
+ }
2783
+ }
2784
+ function generateSalt() {
2785
+ if (typeof globalThis.crypto === "undefined") {
2786
+ throw new KeyDerivationError(
2787
+ "Web Crypto API (crypto) is not available. Cannot generate random salt."
2788
+ );
2789
+ }
2790
+ return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
2791
+ }
2792
+ async function deriveKey(passphrase, salt) {
2793
+ assertCryptoAvailable();
2794
+ if (passphrase.length === 0) {
2795
+ throw new KeyDerivationError(
2796
+ "Passphrase must not be empty. Provide a non-empty string for encryption key derivation."
2797
+ );
2798
+ }
2799
+ const usedSalt = salt ?? generateSalt();
2800
+ try {
2801
+ const passphraseBytes = new TextEncoder().encode(passphrase);
2802
+ const baseKey = await globalThis.crypto.subtle.importKey(
2803
+ "raw",
2804
+ passphraseBytes,
2805
+ "PBKDF2",
2806
+ false,
2807
+ ["deriveBits", "deriveKey"]
2808
+ );
2809
+ const derivedKey = await globalThis.crypto.subtle.deriveKey(
2810
+ {
2811
+ name: "PBKDF2",
2812
+ salt: usedSalt,
2813
+ iterations: PBKDF2_ITERATIONS,
2814
+ hash: "SHA-256"
2815
+ },
2816
+ baseKey,
2817
+ { name: "AES-GCM", length: DERIVED_KEY_LENGTH },
2818
+ // extractable: true so the derived key can be exported for testing/debugging
2819
+ true,
2820
+ ["encrypt", "decrypt"]
2821
+ );
2822
+ return { key: derivedKey, salt: usedSalt };
2823
+ } catch (cause) {
2824
+ if (cause instanceof KeyDerivationError) {
2825
+ throw cause;
2826
+ }
2827
+ throw new KeyDerivationError(
2828
+ "Failed to derive encryption key from passphrase using PBKDF2. Ensure the runtime supports PBKDF2 with SHA-256.",
2829
+ { cause: cause instanceof Error ? cause.message : String(cause) }
2830
+ );
2831
+ }
2832
+ }
2833
+ async function deriveVersionedKey(passphrase, version, salt) {
2834
+ if (!Number.isInteger(version) || version < 1) {
2835
+ throw new KeyDerivationError(`Key version must be a positive integer, received: ${version}`, {
2836
+ version
2837
+ });
2838
+ }
2839
+ const { key, salt: usedSalt } = await deriveKey(passphrase, salt);
2840
+ return { version, key, salt: usedSalt };
2841
+ }
2842
+
2843
+ // src/encryption/sync-encryptor.ts
2844
+ var EncryptionError = class extends import_core7.SyncError {
2845
+ constructor(message, context) {
2846
+ super(message, { ...context, errorType: "ENCRYPTION_ERROR" });
2847
+ this.name = "EncryptionError";
2848
+ }
2849
+ };
2850
+ var DecryptionError = class extends import_core7.SyncError {
2851
+ constructor(message, context) {
2852
+ super(message, { ...context, errorType: "DECRYPTION_ERROR" });
2853
+ this.name = "DecryptionError";
2854
+ }
2855
+ };
2856
+ var IV_LENGTH = 12;
2857
+ var ENCRYPTED_MARKER = "__kora_e2e_encrypted";
2858
+ var SyncEncryptor = class _SyncEncryptor {
2859
+ /**
2860
+ * Map of key version -> VersionedKey. The current (latest) version is used
2861
+ * for encryption. All versions are available for decryption (key rotation).
2862
+ */
2863
+ keys;
2864
+ /** The current key version used for encryption. */
2865
+ currentVersion;
2866
+ constructor(keys, currentVersion) {
2867
+ this.keys = keys;
2868
+ this.currentVersion = currentVersion;
2869
+ }
2870
+ /**
2871
+ * Creates a SyncEncryptor from a {@link SyncEncryptionConfig}.
2872
+ *
2873
+ * Derives the encryption key from the passphrase using PBKDF2. The key
2874
+ * derivation is async because it uses the Web Crypto API.
2875
+ *
2876
+ * @param config - Encryption configuration with passphrase
2877
+ * @param salt - Optional salt for deterministic key derivation (mainly for testing).
2878
+ * If omitted, a random salt is generated.
2879
+ * @returns A configured SyncEncryptor instance
2880
+ * @throws {EncryptionError} If configuration is invalid
2881
+ * @throws {KeyDerivationError} If key derivation fails
2882
+ */
2883
+ static async create(config, salt) {
2884
+ if (!config.enabled) {
2885
+ throw new EncryptionError(
2886
+ "Cannot create SyncEncryptor with encryption disabled. Set enabled: true in the encryption config."
2887
+ );
2888
+ }
2889
+ const passphrase = typeof config.key === "function" ? await config.key() : config.key;
2890
+ if (passphrase.length === 0) {
2891
+ throw new EncryptionError(
2892
+ "Encryption key/passphrase must not be empty. Provide a non-empty string or key provider function."
2893
+ );
2894
+ }
2895
+ const versionedKey = await deriveVersionedKey(passphrase, 1, salt);
2896
+ const keys = /* @__PURE__ */ new Map();
2897
+ keys.set(1, versionedKey);
2898
+ return new _SyncEncryptor(keys, 1);
2899
+ }
2900
+ /**
2901
+ * Creates a SyncEncryptor from pre-derived versioned keys.
2902
+ *
2903
+ * Use this when you need to support multiple key versions for key rotation,
2904
+ * or when you have already derived the keys externally.
2905
+ *
2906
+ * @param versionedKeys - Array of versioned keys. The highest version is used for encryption.
2907
+ * @returns A configured SyncEncryptor instance
2908
+ * @throws {EncryptionError} If no keys are provided
2909
+ */
2910
+ static fromKeys(versionedKeys) {
2911
+ if (versionedKeys.length === 0) {
2912
+ throw new EncryptionError("At least one versioned key must be provided.");
2913
+ }
2914
+ const keys = /* @__PURE__ */ new Map();
2915
+ let maxVersion = 0;
2916
+ for (const vk of versionedKeys) {
2917
+ keys.set(vk.version, vk);
2918
+ if (vk.version > maxVersion) {
2919
+ maxVersion = vk.version;
2920
+ }
2921
+ }
2922
+ return new _SyncEncryptor(keys, maxVersion);
2923
+ }
2924
+ /**
2925
+ * Add a new key version for key rotation.
2926
+ *
2927
+ * After adding, the new key becomes the current version used for encryption
2928
+ * if its version number is higher than the current version. Previously-versioned
2929
+ * keys remain available for decrypting older operations.
2930
+ *
2931
+ * @param key - The new versioned key to add
2932
+ * @throws {EncryptionError} If the key version already exists
2933
+ */
2934
+ addKey(key) {
2935
+ if (this.keys.has(key.version)) {
2936
+ throw new EncryptionError(
2937
+ `Key version ${key.version} already exists. Use a higher version number for rotation.`,
2938
+ { existingVersion: key.version }
2939
+ );
2940
+ }
2941
+ this.keys.set(key.version, key);
2942
+ if (key.version > this.currentVersion) {
2943
+ this.currentVersion = key.version;
2944
+ }
2945
+ }
2946
+ /**
2947
+ * Get the current encryption key version number.
2948
+ */
2949
+ getCurrentKeyVersion() {
2950
+ return this.currentVersion;
2951
+ }
2952
+ /**
2953
+ * Encrypt an operation's `data` and `previousData` fields.
2954
+ *
2955
+ * Returns a new Operation with encrypted field values. The original
2956
+ * operation is not mutated (operations are immutable).
2957
+ *
2958
+ * Fields that are null (e.g., delete operations) remain null.
2959
+ *
2960
+ * @param operation - The operation to encrypt
2961
+ * @returns A new operation with encrypted data fields
2962
+ * @throws {EncryptionError} If encryption fails
2963
+ */
2964
+ async encryptOperation(operation) {
2965
+ const [encryptedData, encryptedPreviousData] = await Promise.all([
2966
+ this.encryptField(operation.data, operation.id, "data"),
2967
+ this.encryptField(operation.previousData, operation.id, "previousData")
2968
+ ]);
2969
+ return {
2970
+ ...operation,
2971
+ data: encryptedData,
2972
+ previousData: encryptedPreviousData
2973
+ };
2974
+ }
2975
+ /**
2976
+ * Decrypt an operation's `data` and `previousData` fields.
2977
+ *
2978
+ * Returns a new Operation with the original field values restored.
2979
+ * The original operation is not mutated.
2980
+ *
2981
+ * If a field is null or not encrypted (no marker), it passes through unchanged.
2982
+ * This enables mixed plaintext/encrypted operations during migration.
2983
+ *
2984
+ * @param operation - The operation to decrypt
2985
+ * @returns A new operation with decrypted data fields
2986
+ * @throws {DecryptionError} If decryption fails (wrong key, tampered data, unsupported version)
2987
+ */
2988
+ async decryptOperation(operation) {
2989
+ const [decryptedData, decryptedPreviousData] = await Promise.all([
2990
+ this.decryptField(operation.data, operation.id, "data"),
2991
+ this.decryptField(operation.previousData, operation.id, "previousData")
2992
+ ]);
2993
+ return {
2994
+ ...operation,
2995
+ data: decryptedData,
2996
+ previousData: decryptedPreviousData
2997
+ };
2998
+ }
2999
+ /**
3000
+ * Encrypt a serialized operation's `data` and `previousData` fields.
3001
+ *
3002
+ * Same as {@link encryptOperation} but works with the wire-format
3003
+ * {@link SerializedOperation} type used by the serializer.
3004
+ *
3005
+ * @param serialized - The serialized operation to encrypt
3006
+ * @returns A new serialized operation with encrypted data fields
3007
+ * @throws {EncryptionError} If encryption fails
3008
+ */
3009
+ async encryptSerializedOperation(serialized) {
3010
+ const [encryptedData, encryptedPreviousData] = await Promise.all([
3011
+ this.encryptField(serialized.data, serialized.id, "data"),
3012
+ this.encryptField(serialized.previousData, serialized.id, "previousData")
3013
+ ]);
3014
+ return {
3015
+ ...serialized,
3016
+ data: encryptedData,
3017
+ previousData: encryptedPreviousData
3018
+ };
3019
+ }
3020
+ /**
3021
+ * Decrypt a serialized operation's `data` and `previousData` fields.
3022
+ *
3023
+ * Same as {@link decryptOperation} but works with the wire-format
3024
+ * {@link SerializedOperation} type used by the serializer.
3025
+ *
3026
+ * @param serialized - The serialized operation to decrypt
3027
+ * @returns A new serialized operation with decrypted data fields
3028
+ * @throws {DecryptionError} If decryption fails
3029
+ */
3030
+ async decryptSerializedOperation(serialized) {
3031
+ const [decryptedData, decryptedPreviousData] = await Promise.all([
3032
+ this.decryptField(serialized.data, serialized.id, "data"),
3033
+ this.decryptField(serialized.previousData, serialized.id, "previousData")
3034
+ ]);
3035
+ return {
3036
+ ...serialized,
3037
+ data: decryptedData,
3038
+ previousData: decryptedPreviousData
3039
+ };
3040
+ }
3041
+ /**
3042
+ * Encrypt a batch of operations in parallel.
3043
+ *
3044
+ * @param operations - Operations to encrypt
3045
+ * @returns New operations with encrypted data fields
3046
+ */
3047
+ async encryptBatch(operations) {
3048
+ return Promise.all(operations.map((op) => this.encryptOperation(op)));
3049
+ }
3050
+ /**
3051
+ * Decrypt a batch of operations in parallel.
3052
+ *
3053
+ * @param operations - Operations to decrypt
3054
+ * @returns New operations with decrypted data fields
3055
+ */
3056
+ async decryptBatch(operations) {
3057
+ return Promise.all(operations.map((op) => this.decryptOperation(op)));
3058
+ }
3059
+ /**
3060
+ * Check if a field value contains an encrypted payload.
3061
+ *
3062
+ * @param field - An operation's `data` or `previousData` field
3063
+ * @returns true if the field contains an encrypted payload
3064
+ */
3065
+ static isEncryptedPayload(field) {
3066
+ return isEncryptedPayload(field);
3067
+ }
3068
+ // --- Private helpers ---
3069
+ async encryptField(field, operationId, fieldName) {
3070
+ if (field === null) {
3071
+ return null;
3072
+ }
3073
+ const currentKey = this.keys.get(this.currentVersion);
3074
+ if (!currentKey) {
3075
+ throw new EncryptionError(
3076
+ `Current encryption key version ${this.currentVersion} not found.`,
3077
+ { operationId, fieldName }
3078
+ );
3079
+ }
3080
+ try {
3081
+ const plaintext = new TextEncoder().encode(JSON.stringify(field));
3082
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
3083
+ const ciphertextBuffer = await globalThis.crypto.subtle.encrypt(
3084
+ { name: "AES-GCM", iv },
3085
+ currentKey.key,
3086
+ plaintext
3087
+ );
3088
+ const payload = {
3089
+ v: this.currentVersion,
3090
+ iv: toBase64(iv),
3091
+ ct: toBase64(new Uint8Array(ciphertextBuffer)),
3092
+ alg: "aes-256-gcm"
3093
+ };
3094
+ return {
3095
+ [ENCRYPTED_MARKER]: true,
3096
+ ...payload
3097
+ };
3098
+ } catch (cause) {
3099
+ if (cause instanceof EncryptionError) {
3100
+ throw cause;
3101
+ }
3102
+ throw new EncryptionError(
3103
+ `Failed to encrypt operation ${fieldName} field. Ensure the encryption key is valid and crypto.subtle is available.`,
3104
+ {
3105
+ operationId,
3106
+ fieldName,
3107
+ cause: cause instanceof Error ? cause.message : String(cause)
3108
+ }
3109
+ );
3110
+ }
3111
+ }
3112
+ async decryptField(field, operationId, fieldName) {
3113
+ if (field === null) {
3114
+ return null;
3115
+ }
3116
+ if (!isEncryptedPayload(field)) {
3117
+ return field;
3118
+ }
3119
+ const payload = field;
3120
+ const keyVersion = payload.v;
3121
+ const key = this.keys.get(keyVersion);
3122
+ if (!key) {
3123
+ throw new DecryptionError(
3124
+ `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.`,
3125
+ { operationId, fieldName, keyVersion, availableVersions: [...this.keys.keys()] }
3126
+ );
3127
+ }
3128
+ if (payload.alg !== "aes-256-gcm") {
3129
+ throw new DecryptionError(
3130
+ `Unsupported encryption algorithm: "${payload.alg}". Only "aes-256-gcm" is supported. Update your @korajs/sync package.`,
3131
+ { operationId, fieldName, algorithm: payload.alg }
3132
+ );
3133
+ }
3134
+ try {
3135
+ const iv = fromBase64(payload.iv);
3136
+ const ciphertext = fromBase64(payload.ct);
3137
+ const plaintextBuffer = await globalThis.crypto.subtle.decrypt(
3138
+ { name: "AES-GCM", iv },
3139
+ key.key,
3140
+ ciphertext
3141
+ );
3142
+ const json = new TextDecoder().decode(plaintextBuffer);
3143
+ const parsed = JSON.parse(json);
3144
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3145
+ throw new DecryptionError(`Decrypted ${fieldName} is not a valid record object.`, {
3146
+ operationId,
3147
+ fieldName
3148
+ });
3149
+ }
3150
+ return parsed;
3151
+ } catch (cause) {
3152
+ if (cause instanceof DecryptionError) {
3153
+ throw cause;
3154
+ }
3155
+ throw new DecryptionError(
3156
+ `Failed to decrypt operation ${fieldName} field. This may indicate a wrong encryption key, tampered ciphertext, or corrupted data.`,
3157
+ {
3158
+ operationId,
3159
+ fieldName,
3160
+ keyVersion,
3161
+ cause: cause instanceof Error ? cause.message : String(cause)
3162
+ }
3163
+ );
3164
+ }
3165
+ }
3166
+ };
3167
+ function isEncryptedPayload(field) {
3168
+ if (field === null || typeof field !== "object") {
3169
+ return false;
3170
+ }
3171
+ return field[ENCRYPTED_MARKER] === true && typeof field.v === "number" && typeof field.iv === "string" && typeof field.ct === "string" && typeof field.alg === "string";
3172
+ }
3173
+ function toBase64(bytes) {
3174
+ let binary = "";
3175
+ for (let i = 0; i < bytes.length; i++) {
3176
+ binary += String.fromCharCode(bytes[i]);
3177
+ }
3178
+ return btoa(binary);
3179
+ }
3180
+ function fromBase64(str) {
3181
+ const binary = atob(str);
3182
+ const bytes = new Uint8Array(binary.length);
3183
+ for (let i = 0; i < binary.length; i++) {
3184
+ bytes[i] = binary.charCodeAt(i);
3185
+ }
3186
+ return bytes;
3187
+ }
1766
3188
  // Annotate the CommonJS export names for ESM import in node:
1767
3189
  0 && (module.exports = {
3190
+ AwarenessManager,
1768
3191
  ChaosTransport,
1769
3192
  ConnectionMonitor,
3193
+ DecryptionError,
3194
+ EncryptionError,
1770
3195
  HttpLongPollingTransport,
3196
+ InvalidScopeError,
1771
3197
  JsonMessageSerializer,
3198
+ KeyDerivationError,
1772
3199
  NegotiatedMessageSerializer,
1773
3200
  OutboundQueue,
1774
3201
  ProtobufMessageSerializer,
1775
3202
  ReconnectionManager,
1776
3203
  SYNC_STATES,
1777
3204
  SYNC_STATUSES,
3205
+ ScopeViolationError,
3206
+ SyncEncryptor,
1778
3207
  SyncEngine,
1779
3208
  WebSocketTransport,
3209
+ deriveKey,
3210
+ deriveVersionedKey,
3211
+ filterOperationsByScope,
3212
+ generateSalt,
1780
3213
  isAcknowledgmentMessage,
3214
+ isAwarenessUpdateMessage,
3215
+ isEncryptedPayload,
1781
3216
  isErrorMessage,
1782
3217
  isHandshakeMessage,
1783
3218
  isHandshakeResponseMessage,
1784
3219
  isOperationBatchMessage,
1785
3220
  isSyncMessage,
3221
+ operationMatchesScope,
1786
3222
  versionVectorToWire,
1787
3223
  wireToVersionVector
1788
3224
  });