@korajs/sync 0.3.2 → 0.5.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,60 @@ 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
+ DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD: () => DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD,
37
+ DecryptionError: () => DecryptionError,
38
+ EncryptionError: () => EncryptionError,
35
39
  HttpLongPollingTransport: () => HttpLongPollingTransport,
40
+ InvalidScopeError: () => InvalidScopeError,
36
41
  JsonMessageSerializer: () => JsonMessageSerializer,
42
+ KeyDerivationError: () => KeyDerivationError,
37
43
  NegotiatedMessageSerializer: () => NegotiatedMessageSerializer,
38
44
  OutboundQueue: () => OutboundQueue,
39
45
  ProtobufMessageSerializer: () => ProtobufMessageSerializer,
40
46
  ReconnectionManager: () => ReconnectionManager,
47
+ RichtextDocChannel: () => RichtextDocChannel,
48
+ SCHEMA_MISMATCH_PREFIX: () => SCHEMA_MISMATCH_PREFIX,
41
49
  SYNC_STATES: () => SYNC_STATES,
42
50
  SYNC_STATUSES: () => SYNC_STATUSES,
51
+ ScopeViolationError: () => ScopeViolationError,
52
+ SyncEncryptor: () => SyncEncryptor,
43
53
  SyncEngine: () => SyncEngine,
44
54
  WebSocketTransport: () => WebSocketTransport,
55
+ createDeltaCursorFromBatch: () => createDeltaCursorFromBatch,
56
+ decodeDeltaCursor: () => decodeDeltaCursor,
57
+ decodeYjsUpdate: () => decodeYjsUpdate,
58
+ dedupeQuerySubsets: () => dedupeQuerySubsets,
59
+ deriveKey: () => deriveKey,
60
+ deriveVersionedKey: () => deriveVersionedKey,
61
+ encodeDeltaCursor: () => encodeDeltaCursor,
62
+ encodeYjsUpdate: () => encodeYjsUpdate,
63
+ filterOperationsByScope: () => filterOperationsByScope,
64
+ generateSalt: () => generateSalt,
45
65
  isAcknowledgmentMessage: () => isAcknowledgmentMessage,
66
+ isAwarenessUpdateMessage: () => isAwarenessUpdateMessage,
67
+ isClientSchemaVersionSupported: () => isClientSchemaVersionSupported,
68
+ isEncryptedPayload: () => isEncryptedPayload,
46
69
  isErrorMessage: () => isErrorMessage,
47
70
  isHandshakeMessage: () => isHandshakeMessage,
48
71
  isHandshakeResponseMessage: () => isHandshakeResponseMessage,
49
72
  isOperationBatchMessage: () => isOperationBatchMessage,
73
+ isSchemaMismatchReject: () => isSchemaMismatchReject,
50
74
  isSyncMessage: () => isSyncMessage,
75
+ isYjsDocUpdateMessage: () => isYjsDocUpdateMessage,
76
+ operationMatchesQuerySubsets: () => operationMatchesQuerySubsets,
77
+ operationMatchesScope: () => operationMatchesScope,
78
+ richtextDocKey: () => richtextDocKey,
79
+ sliceOperationsAfterCursor: () => sliceOperationsAfterCursor,
51
80
  versionVectorToWire: () => versionVectorToWire,
52
81
  wireToVersionVector: () => wireToVersionVector
53
82
  });
54
83
  module.exports = __toCommonJS(index_exports);
55
84
 
56
85
  // src/types.ts
86
+ var import_core = require("@korajs/core");
57
87
  var SYNC_STATES = [
58
88
  "disconnected",
59
89
  "connecting",
@@ -62,7 +92,194 @@ var SYNC_STATES = [
62
92
  "streaming",
63
93
  "error"
64
94
  ];
65
- var SYNC_STATUSES = ["connected", "syncing", "synced", "offline", "error"];
95
+ var SYNC_STATUSES = [
96
+ "connected",
97
+ "syncing",
98
+ "synced",
99
+ "offline",
100
+ "error",
101
+ "schema-mismatch"
102
+ ];
103
+ var ScopeViolationError = class extends import_core.KoraError {
104
+ constructor(operationId, collection, scope, message) {
105
+ super(
106
+ message ?? `Operation "${operationId}" in collection "${collection}" violates sync scope`,
107
+ "SCOPE_VIOLATION",
108
+ { operationId, collection, scope }
109
+ );
110
+ this.operationId = operationId;
111
+ this.collection = collection;
112
+ this.scope = scope;
113
+ this.name = "ScopeViolationError";
114
+ }
115
+ operationId;
116
+ collection;
117
+ scope;
118
+ };
119
+ var InvalidScopeError = class extends import_core.KoraError {
120
+ constructor(message, context) {
121
+ super(message, "INVALID_SCOPE", context);
122
+ this.name = "InvalidScopeError";
123
+ }
124
+ };
125
+
126
+ // src/scopes/scope-filter.ts
127
+ function operationMatchesScope(op, scopeMap, fullRecord) {
128
+ if (!scopeMap) return true;
129
+ const collectionScope = scopeMap[op.collection];
130
+ if (!collectionScope) return false;
131
+ if (Object.keys(collectionScope).length === 0) return true;
132
+ const snapshot = buildSnapshot(op, fullRecord ?? void 0);
133
+ if (!snapshot) return false;
134
+ for (const [field, expected] of Object.entries(collectionScope)) {
135
+ if (snapshot[field] !== expected) {
136
+ return false;
137
+ }
138
+ }
139
+ return true;
140
+ }
141
+ function filterOperationsByScope(operations, scopeMap) {
142
+ if (!scopeMap) return operations;
143
+ return operations.filter((op) => operationMatchesScope(op, scopeMap));
144
+ }
145
+ function buildSnapshot(op, fullRecord) {
146
+ const previous = asRecord(op.previousData);
147
+ const next = asRecord(op.data);
148
+ if (!previous && !next && !fullRecord) return null;
149
+ return {
150
+ ...fullRecord ?? {},
151
+ ...previous ?? {},
152
+ ...next ?? {}
153
+ };
154
+ }
155
+ function asRecord(value) {
156
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
157
+ return null;
158
+ }
159
+ return value;
160
+ }
161
+
162
+ // src/scopes/query-subset.ts
163
+ function asRecord2(value) {
164
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
165
+ return null;
166
+ }
167
+ return value;
168
+ }
169
+ function buildSnapshot2(op, fullRecord) {
170
+ const previous = asRecord2(op.previousData);
171
+ const next = asRecord2(op.data);
172
+ if (!previous && !next && !fullRecord) {
173
+ return null;
174
+ }
175
+ return {
176
+ ...fullRecord ?? {},
177
+ ...previous ?? {},
178
+ ...next ?? {}
179
+ };
180
+ }
181
+ function recordMatchesWhere(snapshot, where) {
182
+ for (const [field, expected] of Object.entries(where)) {
183
+ if (snapshot[field] !== expected) {
184
+ return false;
185
+ }
186
+ }
187
+ return true;
188
+ }
189
+ function operationMatchesQuerySubsets(op, subsets, fullRecord) {
190
+ if (!subsets || subsets.length === 0) {
191
+ return true;
192
+ }
193
+ const collectionSubsets = subsets.filter((subset) => subset.collection === op.collection);
194
+ if (collectionSubsets.length === 0) {
195
+ return true;
196
+ }
197
+ const snapshot = buildSnapshot2(op, fullRecord);
198
+ if (!snapshot) {
199
+ return false;
200
+ }
201
+ return collectionSubsets.some((subset) => recordMatchesWhere(snapshot, subset.where));
202
+ }
203
+ function dedupeQuerySubsets(subsets) {
204
+ const seen = /* @__PURE__ */ new Set();
205
+ const result = [];
206
+ for (const subset of subsets) {
207
+ const key = `${subset.collection}:${JSON.stringify(subset.where)}`;
208
+ if (seen.has(key)) {
209
+ continue;
210
+ }
211
+ seen.add(key);
212
+ result.push(subset);
213
+ }
214
+ return result;
215
+ }
216
+
217
+ // src/delta/delta-cursor.ts
218
+ function bytesToBase64Url(bytes) {
219
+ let binary = "";
220
+ for (const byte of bytes) {
221
+ binary += String.fromCharCode(byte);
222
+ }
223
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
224
+ }
225
+ function base64UrlToBytes(value) {
226
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
227
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
228
+ const binary = atob(padded);
229
+ const bytes = new Uint8Array(binary.length);
230
+ for (let i = 0; i < binary.length; i++) {
231
+ bytes[i] = binary.charCodeAt(i);
232
+ }
233
+ return bytes;
234
+ }
235
+ function encodeDeltaCursor(cursor) {
236
+ const json = JSON.stringify(cursor);
237
+ return bytesToBase64Url(new TextEncoder().encode(json));
238
+ }
239
+ function decodeDeltaCursor(value) {
240
+ if (!value) {
241
+ return null;
242
+ }
243
+ try {
244
+ const json = new TextDecoder().decode(base64UrlToBytes(value));
245
+ const parsed = JSON.parse(json);
246
+ if (typeof parsed !== "object" || parsed === null || typeof parsed.lastOperationId !== "string" || typeof parsed.batchIndex !== "number") {
247
+ return null;
248
+ }
249
+ return parsed;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+ function sliceOperationsAfterCursor(operations, cursor) {
255
+ if (!cursor) {
256
+ return operations;
257
+ }
258
+ const index = operations.findIndex((op) => op.id === cursor.lastOperationId);
259
+ if (index === -1) {
260
+ return operations;
261
+ }
262
+ return operations.slice(index + 1);
263
+ }
264
+ function createDeltaCursorFromBatch(operations, batchIndex) {
265
+ const last = operations[operations.length - 1];
266
+ if (!last) {
267
+ return null;
268
+ }
269
+ return {
270
+ lastOperationId: last.id,
271
+ batchIndex
272
+ };
273
+ }
274
+
275
+ // src/protocol/schema-version.ts
276
+ var SCHEMA_MISMATCH_PREFIX = "SCHEMA_MISMATCH";
277
+ function isSchemaMismatchReject(rejectReason) {
278
+ return rejectReason?.startsWith(SCHEMA_MISMATCH_PREFIX) === true;
279
+ }
280
+ function isClientSchemaVersionSupported(clientVersion, supported) {
281
+ return clientVersion >= supported.min && clientVersion <= supported.max;
282
+ }
66
283
 
67
284
  // src/protocol/messages.ts
68
285
  function isSyncMessage(value) {
@@ -80,6 +297,10 @@ function isSyncMessage(value) {
80
297
  return isAcknowledgmentMessage(value);
81
298
  case "error":
82
299
  return isErrorMessage(value);
300
+ case "awareness-update":
301
+ return isAwarenessUpdateMessage(value);
302
+ case "yjs-doc-update":
303
+ return isYjsDocUpdateMessage(value);
83
304
  default:
84
305
  return false;
85
306
  }
@@ -109,9 +330,19 @@ function isErrorMessage(value) {
109
330
  const msg = value;
110
331
  return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
111
332
  }
333
+ function isAwarenessUpdateMessage(value) {
334
+ if (typeof value !== "object" || value === null) return false;
335
+ const msg = value;
336
+ return msg.type === "awareness-update" && typeof msg.messageId === "string" && typeof msg.clientId === "number" && typeof msg.states === "object" && msg.states !== null && !Array.isArray(msg.states);
337
+ }
338
+ function isYjsDocUpdateMessage(value) {
339
+ if (typeof value !== "object" || value === null) return false;
340
+ const msg = value;
341
+ return msg.type === "yjs-doc-update" && typeof msg.messageId === "string" && typeof msg.collection === "string" && typeof msg.recordId === "string" && typeof msg.field === "string" && typeof msg.update === "string";
342
+ }
112
343
 
113
344
  // src/protocol/serializer.ts
114
- var import_core = require("@korajs/core");
345
+ var import_core2 = require("@korajs/core");
115
346
  var import_minimal = __toESM(require("protobufjs/minimal"), 1);
116
347
  var { Reader, Writer } = import_minimal.default;
117
348
  function versionVectorToWire(vector) {
@@ -124,6 +355,84 @@ function versionVectorToWire(vector) {
124
355
  function wireToVersionVector(wire) {
125
356
  return new Map(Object.entries(wire));
126
357
  }
358
+ var WIRE_BYTES_KEY = "__kora_bytes__";
359
+ function toBase64(bytes) {
360
+ let binary = "";
361
+ for (const byte of bytes) {
362
+ binary += String.fromCharCode(byte);
363
+ }
364
+ return btoa(binary);
365
+ }
366
+ function fromBase64(value) {
367
+ const binary = atob(value);
368
+ const bytes = new Uint8Array(binary.length);
369
+ for (let index = 0; index < binary.length; index++) {
370
+ bytes[index] = binary.charCodeAt(index);
371
+ }
372
+ return bytes;
373
+ }
374
+ function serializeWireRecord(record) {
375
+ if (!record) {
376
+ return null;
377
+ }
378
+ const out = {};
379
+ for (const [key, value] of Object.entries(record)) {
380
+ out[key] = serializeWireValue(value);
381
+ }
382
+ return out;
383
+ }
384
+ function serializeWireValue(value) {
385
+ if (value instanceof Uint8Array) {
386
+ return { [WIRE_BYTES_KEY]: toBase64(value) };
387
+ }
388
+ if (value instanceof ArrayBuffer) {
389
+ return { [WIRE_BYTES_KEY]: toBase64(new Uint8Array(value)) };
390
+ }
391
+ return value;
392
+ }
393
+ function deserializeWireRecord(record) {
394
+ if (!record) {
395
+ return null;
396
+ }
397
+ const out = {};
398
+ for (const [key, value] of Object.entries(record)) {
399
+ out[key] = deserializeWireValue(value);
400
+ }
401
+ return out;
402
+ }
403
+ function deserializeWireValue(value) {
404
+ if (typeof value === "object" && value !== null && WIRE_BYTES_KEY in value) {
405
+ const encoded = value[WIRE_BYTES_KEY];
406
+ if (typeof encoded === "string") {
407
+ return fromBase64(encoded);
408
+ }
409
+ }
410
+ if (isLegacyIndexedByteRecord(value)) {
411
+ return legacyIndexedByteRecordToUint8Array(value);
412
+ }
413
+ return value;
414
+ }
415
+ function isLegacyIndexedByteRecord(value) {
416
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
417
+ return false;
418
+ }
419
+ const entries = Object.entries(value);
420
+ if (entries.length === 0) {
421
+ return false;
422
+ }
423
+ return entries.every(([key, entryValue]) => {
424
+ const index = Number(key);
425
+ return Number.isInteger(index) && index >= 0 && typeof entryValue === "number";
426
+ });
427
+ }
428
+ function legacyIndexedByteRecordToUint8Array(value) {
429
+ const maxIndex = Math.max(...Object.keys(value).map((key) => Number(key)));
430
+ const bytes = new Uint8Array(maxIndex + 1);
431
+ for (const [key, entryValue] of Object.entries(value)) {
432
+ bytes[Number(key)] = entryValue;
433
+ }
434
+ return bytes;
435
+ }
127
436
  var JsonMessageSerializer = class {
128
437
  encode(message) {
129
438
  return JSON.stringify(message);
@@ -134,12 +443,12 @@ var JsonMessageSerializer = class {
134
443
  try {
135
444
  parsed = JSON.parse(text);
136
445
  } catch {
137
- throw new import_core.SyncError("Failed to decode sync message: invalid JSON", {
446
+ throw new import_core2.SyncError("Failed to decode sync message: invalid JSON", {
138
447
  dataLength: text.length
139
448
  });
140
449
  }
141
450
  if (!isSyncMessage(parsed)) {
142
- throw new import_core.SyncError("Failed to decode sync message: invalid message structure", {
451
+ throw new import_core2.SyncError("Failed to decode sync message: invalid message structure", {
143
452
  receivedType: typeof parsed === "object" && parsed !== null ? parsed.type : typeof parsed
144
453
  });
145
454
  }
@@ -152,8 +461,8 @@ var JsonMessageSerializer = class {
152
461
  type: op.type,
153
462
  collection: op.collection,
154
463
  recordId: op.recordId,
155
- data: op.data,
156
- previousData: op.previousData,
464
+ data: serializeWireRecord(op.data),
465
+ previousData: serializeWireRecord(op.previousData),
157
466
  timestamp: {
158
467
  wallTime: op.timestamp.wallTime,
159
468
  logical: op.timestamp.logical,
@@ -161,7 +470,10 @@ var JsonMessageSerializer = class {
161
470
  },
162
471
  sequenceNumber: op.sequenceNumber,
163
472
  causalDeps: [...op.causalDeps],
164
- schemaVersion: op.schemaVersion
473
+ schemaVersion: op.schemaVersion,
474
+ ...op.atomicOps !== void 0 ? { atomicOps: op.atomicOps } : {},
475
+ ...op.transactionId !== void 0 ? { transactionId: op.transactionId } : {},
476
+ ...op.mutationName !== void 0 ? { mutationName: op.mutationName } : {}
165
477
  };
166
478
  }
167
479
  decodeOperation(serialized) {
@@ -171,8 +483,8 @@ var JsonMessageSerializer = class {
171
483
  type: serialized.type,
172
484
  collection: serialized.collection,
173
485
  recordId: serialized.recordId,
174
- data: serialized.data,
175
- previousData: serialized.previousData,
486
+ data: deserializeWireRecord(serialized.data),
487
+ previousData: deserializeWireRecord(serialized.previousData),
176
488
  timestamp: {
177
489
  wallTime: serialized.timestamp.wallTime,
178
490
  logical: serialized.timestamp.logical,
@@ -180,7 +492,10 @@ var JsonMessageSerializer = class {
180
492
  },
181
493
  sequenceNumber: serialized.sequenceNumber,
182
494
  causalDeps: [...serialized.causalDeps],
183
- schemaVersion: serialized.schemaVersion
495
+ schemaVersion: serialized.schemaVersion,
496
+ ...serialized.atomicOps !== void 0 ? { atomicOps: serialized.atomicOps } : {},
497
+ ...serialized.transactionId !== void 0 ? { transactionId: serialized.transactionId } : {},
498
+ ...serialized.mutationName !== void 0 ? { mutationName: serialized.mutationName } : {}
184
499
  };
185
500
  }
186
501
  };
@@ -244,7 +559,10 @@ function toProtoEnvelope(message) {
244
559
  type: message.type,
245
560
  messageId: message.messageId,
246
561
  nodeId: message.nodeId,
247
- versionVector: Object.entries(message.versionVector).map(([key, value]) => ({ key, value })),
562
+ versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
563
+ key,
564
+ value
565
+ })),
248
566
  schemaVersion: message.schemaVersion,
249
567
  authToken: message.authToken,
250
568
  supportedWireFormats: message.supportedWireFormats
@@ -254,7 +572,10 @@ function toProtoEnvelope(message) {
254
572
  type: message.type,
255
573
  messageId: message.messageId,
256
574
  nodeId: message.nodeId,
257
- versionVector: Object.entries(message.versionVector).map(([key, value]) => ({ key, value })),
575
+ versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
576
+ key,
577
+ value
578
+ })),
258
579
  schemaVersion: message.schemaVersion,
259
580
  accepted: message.accepted,
260
581
  rejectReason: message.rejectReason,
@@ -283,6 +604,12 @@ function toProtoEnvelope(message) {
283
604
  errorMessage: message.message,
284
605
  retriable: message.retriable
285
606
  };
607
+ case "awareness-update":
608
+ case "yjs-doc-update":
609
+ return {
610
+ type: message.type,
611
+ messageId: message.messageId
612
+ };
286
613
  }
287
614
  }
288
615
  function fromProtoEnvelope(envelope) {
@@ -292,7 +619,9 @@ function fromProtoEnvelope(envelope) {
292
619
  type: "handshake",
293
620
  messageId: envelope.messageId,
294
621
  nodeId: envelope.nodeId ?? "",
295
- versionVector: Object.fromEntries((envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])),
622
+ versionVector: Object.fromEntries(
623
+ (envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])
624
+ ),
296
625
  schemaVersion: envelope.schemaVersion ?? 0,
297
626
  authToken: envelope.authToken,
298
627
  supportedWireFormats: envelope.supportedWireFormats?.filter(
@@ -304,7 +633,9 @@ function fromProtoEnvelope(envelope) {
304
633
  type: "handshake-response",
305
634
  messageId: envelope.messageId,
306
635
  nodeId: envelope.nodeId ?? "",
307
- versionVector: Object.fromEntries((envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])),
636
+ versionVector: Object.fromEntries(
637
+ (envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])
638
+ ),
308
639
  schemaVersion: envelope.schemaVersion ?? 0,
309
640
  accepted: envelope.accepted ?? false,
310
641
  rejectReason: envelope.rejectReason,
@@ -334,19 +665,39 @@ function fromProtoEnvelope(envelope) {
334
665
  retriable: envelope.retriable ?? false
335
666
  };
336
667
  default:
337
- throw new import_core.SyncError("Failed to decode sync message: unknown protobuf type", {
668
+ throw new import_core2.SyncError("Failed to decode sync message: unknown protobuf type", {
338
669
  type: envelope.type
339
670
  });
340
671
  }
341
672
  }
342
673
  function serializeProtoOperation(operation) {
674
+ const hasMetadata = operation.transactionId !== void 0 || operation.mutationName !== void 0;
675
+ let dataJson = "";
676
+ if (operation.data !== null) {
677
+ const dataPayload = { ...operation.data };
678
+ if (operation.atomicOps !== void 0 && Object.keys(operation.atomicOps).length > 0) {
679
+ dataPayload.__kora_atomic_ops__ = operation.atomicOps;
680
+ }
681
+ if (operation.transactionId !== void 0) {
682
+ dataPayload.__kora_tx_id__ = operation.transactionId;
683
+ }
684
+ if (operation.mutationName !== void 0) {
685
+ dataPayload.__kora_mutation__ = operation.mutationName;
686
+ }
687
+ dataJson = JSON.stringify(dataPayload);
688
+ } else if (hasMetadata) {
689
+ const meta = {};
690
+ if (operation.transactionId !== void 0) meta.__kora_tx_id__ = operation.transactionId;
691
+ if (operation.mutationName !== void 0) meta.__kora_mutation__ = operation.mutationName;
692
+ dataJson = JSON.stringify(meta);
693
+ }
343
694
  return {
344
695
  id: operation.id,
345
696
  nodeId: operation.nodeId,
346
697
  type: operation.type,
347
698
  collection: operation.collection,
348
699
  recordId: operation.recordId,
349
- dataJson: operation.data === null ? "" : JSON.stringify(operation.data),
700
+ dataJson,
350
701
  previousDataJson: operation.previousData === null ? "" : JSON.stringify(operation.previousData),
351
702
  timestamp: {
352
703
  wallTime: operation.timestamp.wallTime,
@@ -361,13 +712,31 @@ function serializeProtoOperation(operation) {
361
712
  };
362
713
  }
363
714
  function deserializeProtoOperation(operation) {
715
+ let data = null;
716
+ let atomicOps;
717
+ let transactionId;
718
+ let mutationName;
719
+ if (operation.hasData || operation.dataJson.length > 0) {
720
+ const parsed = JSON.parse(operation.dataJson);
721
+ if ("__kora_atomic_ops__" in parsed) {
722
+ atomicOps = parsed.__kora_atomic_ops__;
723
+ }
724
+ if ("__kora_tx_id__" in parsed) {
725
+ transactionId = parsed.__kora_tx_id__;
726
+ }
727
+ if ("__kora_mutation__" in parsed) {
728
+ mutationName = parsed.__kora_mutation__;
729
+ }
730
+ const { __kora_atomic_ops__: _a, __kora_tx_id__: _t, __kora_mutation__: _m, ...rest } = parsed;
731
+ data = operation.hasData && Object.keys(rest).length > 0 ? rest : null;
732
+ }
364
733
  return {
365
734
  id: operation.id,
366
735
  nodeId: operation.nodeId,
367
736
  type: operation.type,
368
737
  collection: operation.collection,
369
738
  recordId: operation.recordId,
370
- data: operation.hasData ? JSON.parse(operation.dataJson) : null,
739
+ data,
371
740
  previousData: operation.hasPreviousData ? JSON.parse(operation.previousDataJson) : null,
372
741
  timestamp: {
373
742
  wallTime: operation.timestamp.wallTime,
@@ -376,7 +745,10 @@ function deserializeProtoOperation(operation) {
376
745
  },
377
746
  sequenceNumber: operation.sequenceNumber,
378
747
  causalDeps: [...operation.causalDeps],
379
- schemaVersion: operation.schemaVersion
748
+ schemaVersion: operation.schemaVersion,
749
+ ...atomicOps !== void 0 ? { atomicOps } : {},
750
+ ...transactionId !== void 0 ? { transactionId } : {},
751
+ ...mutationName !== void 0 ? { mutationName } : {}
380
752
  };
381
753
  }
382
754
  function decodeTextPayload(data) {
@@ -393,7 +765,7 @@ function toBytes(data) {
393
765
  if (data instanceof ArrayBuffer) {
394
766
  return new Uint8Array(data);
395
767
  }
396
- throw new import_core.SyncError("Unsupported sync payload type", { receivedType: typeof data });
768
+ throw new import_core2.SyncError("Unsupported sync payload type", { receivedType: typeof data });
397
769
  }
398
770
  function encodeEnvelope(envelope) {
399
771
  const writer = Writer.create();
@@ -407,12 +779,14 @@ function encodeEnvelope(envelope) {
407
779
  writer.ldelim();
408
780
  }
409
781
  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);
782
+ if (envelope.authToken && envelope.authToken.length > 0)
783
+ writer.uint32(50).string(envelope.authToken);
411
784
  for (const format of envelope.supportedWireFormats ?? []) {
412
785
  writer.uint32(58).string(format);
413
786
  }
414
787
  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);
788
+ if (envelope.rejectReason && envelope.rejectReason.length > 0)
789
+ writer.uint32(74).string(envelope.rejectReason);
416
790
  if (envelope.selectedWireFormat && envelope.selectedWireFormat.length > 0) {
417
791
  writer.uint32(82).string(envelope.selectedWireFormat);
418
792
  }
@@ -426,8 +800,10 @@ function encodeEnvelope(envelope) {
426
800
  if (envelope.acknowledgedMessageId && envelope.acknowledgedMessageId.length > 0) {
427
801
  writer.uint32(114).string(envelope.acknowledgedMessageId);
428
802
  }
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);
803
+ if (envelope.lastSequenceNumber !== void 0)
804
+ writer.uint32(120).int64(envelope.lastSequenceNumber);
805
+ if (envelope.errorCode && envelope.errorCode.length > 0)
806
+ writer.uint32(130).string(envelope.errorCode);
431
807
  if (envelope.errorMessage && envelope.errorMessage.length > 0) {
432
808
  writer.uint32(138).string(envelope.errorMessage);
433
809
  }
@@ -450,7 +826,10 @@ function decodeEnvelope(bytes) {
450
826
  envelope.nodeId = reader.string();
451
827
  break;
452
828
  case 4:
453
- envelope.versionVector = [...envelope.versionVector ?? [], decodeVectorEntry(reader, reader.uint32())];
829
+ envelope.versionVector = [
830
+ ...envelope.versionVector ?? [],
831
+ decodeVectorEntry(reader, reader.uint32())
832
+ ];
454
833
  break;
455
834
  case 5:
456
835
  envelope.schemaVersion = reader.int32();
@@ -471,7 +850,10 @@ function decodeEnvelope(bytes) {
471
850
  envelope.selectedWireFormat = reader.string();
472
851
  break;
473
852
  case 11:
474
- envelope.operations = [...envelope.operations ?? [], decodeProtoOperation(reader, reader.uint32())];
853
+ envelope.operations = [
854
+ ...envelope.operations ?? [],
855
+ decodeProtoOperation(reader, reader.uint32())
856
+ ];
475
857
  break;
476
858
  case 12:
477
859
  envelope.isFinal = reader.bool();
@@ -627,13 +1009,13 @@ function longToNumber(value) {
627
1009
  if (typeof value === "object" && value !== null && "toNumber" in value && typeof value.toNumber === "function") {
628
1010
  return value.toNumber();
629
1011
  }
630
- throw new import_core.SyncError("Failed to decode int64 value", {
1012
+ throw new import_core2.SyncError("Failed to decode int64 value", {
631
1013
  receivedType: typeof value
632
1014
  });
633
1015
  }
634
1016
 
635
1017
  // src/transport/websocket-transport.ts
636
- var import_core2 = require("@korajs/core");
1018
+ var import_core3 = require("@korajs/core");
637
1019
  var WS_OPEN = 1;
638
1020
  var WebSocketTransport = class {
639
1021
  ws = null;
@@ -656,7 +1038,7 @@ var WebSocketTransport = class {
656
1038
  }
657
1039
  async connect(url, options) {
658
1040
  if (!this.WebSocketImpl) {
659
- throw new import_core2.SyncError("WebSocket is not available in this environment", {
1041
+ throw new import_core3.SyncError("WebSocket is not available in this environment", {
660
1042
  hint: "Provide a WebSocketImpl option or use a polyfill"
661
1043
  });
662
1044
  }
@@ -670,7 +1052,7 @@ var WebSocketTransport = class {
670
1052
  };
671
1053
  const timer = setTimeout(() => {
672
1054
  settle(() => {
673
- const err = new import_core2.SyncError("WebSocket connection timed out", {
1055
+ const err = new import_core3.SyncError("WebSocket connection timed out", {
674
1056
  url,
675
1057
  timeout: this.connectTimeout
676
1058
  });
@@ -702,7 +1084,7 @@ var WebSocketTransport = class {
702
1084
  const message = this.serializer.decode(event.data);
703
1085
  this.messageHandler?.(message);
704
1086
  } catch {
705
- this.errorHandler?.(new import_core2.SyncError("Failed to decode incoming message"));
1087
+ this.errorHandler?.(new import_core3.SyncError("Failed to decode incoming message"));
706
1088
  }
707
1089
  };
708
1090
  ws.onclose = (event) => {
@@ -710,7 +1092,7 @@ var WebSocketTransport = class {
710
1092
  this.closeHandler?.(event.reason || `WebSocket closed with code ${event.code}`);
711
1093
  };
712
1094
  ws.onerror = (event) => {
713
- const err = new import_core2.SyncError("WebSocket error", {
1095
+ const err = new import_core3.SyncError("WebSocket error", {
714
1096
  url
715
1097
  });
716
1098
  this.errorHandler?.(err);
@@ -722,7 +1104,7 @@ var WebSocketTransport = class {
722
1104
  } catch (err) {
723
1105
  settle(
724
1106
  () => reject(
725
- err instanceof import_core2.SyncError ? err : new import_core2.SyncError("Failed to create WebSocket", {
1107
+ err instanceof import_core3.SyncError ? err : new import_core3.SyncError("Failed to create WebSocket", {
726
1108
  url,
727
1109
  error: String(err)
728
1110
  })
@@ -740,7 +1122,7 @@ var WebSocketTransport = class {
740
1122
  }
741
1123
  send(message) {
742
1124
  if (!this.ws || this.ws.readyState !== WS_OPEN) {
743
- throw new import_core2.SyncError("Cannot send message: WebSocket is not connected", {
1125
+ throw new import_core3.SyncError("Cannot send message: WebSocket is not connected", {
744
1126
  messageType: message.type
745
1127
  });
746
1128
  }
@@ -762,7 +1144,7 @@ var WebSocketTransport = class {
762
1144
  };
763
1145
 
764
1146
  // src/transport/http-long-polling-transport.ts
765
- var import_core3 = require("@korajs/core");
1147
+ var import_core4 = require("@korajs/core");
766
1148
  var DEFAULT_RETRY_DELAY_MS = 250;
767
1149
  var HttpLongPollingTransport = class {
768
1150
  serializer;
@@ -788,7 +1170,7 @@ var HttpLongPollingTransport = class {
788
1170
  }
789
1171
  async connect(url, options) {
790
1172
  if (this.connected) {
791
- throw new import_core3.SyncError("HTTP long-poll transport already connected", { url });
1173
+ throw new import_core4.SyncError("HTTP long-poll transport already connected", { url });
792
1174
  }
793
1175
  this.url = normalizeHttpUrl(url);
794
1176
  this.authToken = options?.authToken;
@@ -818,7 +1200,7 @@ var HttpLongPollingTransport = class {
818
1200
  }
819
1201
  send(message) {
820
1202
  if (!this.connected) {
821
- throw new import_core3.SyncError("Cannot send message: HTTP long-poll transport is not connected", {
1203
+ throw new import_core4.SyncError("Cannot send message: HTTP long-poll transport is not connected", {
822
1204
  messageType: message.type
823
1205
  });
824
1206
  }
@@ -882,7 +1264,7 @@ var HttpLongPollingTransport = class {
882
1264
  body: requestBody
883
1265
  });
884
1266
  if (!response.ok) {
885
- throw new import_core3.SyncError("HTTP transport send failed", {
1267
+ throw new import_core4.SyncError("HTTP transport send failed", {
886
1268
  status: response.status,
887
1269
  messageType: message.type
888
1270
  });
@@ -904,7 +1286,7 @@ var HttpLongPollingTransport = class {
904
1286
  continue;
905
1287
  }
906
1288
  if (!response.ok) {
907
- throw new import_core3.SyncError("HTTP long-poll request failed", {
1289
+ throw new import_core4.SyncError("HTTP long-poll request failed", {
908
1290
  status: response.status
909
1291
  });
910
1292
  }
@@ -1080,154 +1462,919 @@ var ChaosTransport = class {
1080
1462
  };
1081
1463
 
1082
1464
  // src/engine/sync-engine.ts
1083
- var import_core4 = require("@korajs/core");
1465
+ var import_core6 = require("@korajs/core");
1084
1466
  var import_internal2 = require("@korajs/core/internal");
1085
1467
 
1086
- // src/engine/memory-queue-storage.ts
1087
- var MemoryQueueStorage = class {
1088
- operations = /* @__PURE__ */ new Map();
1089
- async load() {
1090
- return [...this.operations.values()];
1468
+ // src/awareness/awareness-manager.ts
1469
+ var DEFAULT_TIMEOUT_MS = 3e4;
1470
+ var nextLocalClientId = 1;
1471
+ var AwarenessManager = class {
1472
+ /** Unique client ID for this instance */
1473
+ clientId;
1474
+ localState = null;
1475
+ remoteStates = /* @__PURE__ */ new Map();
1476
+ listeners = /* @__PURE__ */ new Set();
1477
+ emitter;
1478
+ timeoutMs;
1479
+ // Track when we last received an update from each remote client.
1480
+ // Used for timeout-based cleanup as a safety net in case the server
1481
+ // does not send an explicit removal on disconnect.
1482
+ lastUpdated = /* @__PURE__ */ new Map();
1483
+ cleanupTimer = null;
1484
+ sendHandler = null;
1485
+ destroyed = false;
1486
+ constructor(options) {
1487
+ this.clientId = options?.clientId ?? nextLocalClientId++;
1488
+ this.emitter = options?.emitter ?? null;
1489
+ this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1091
1490
  }
1092
- async enqueue(op) {
1093
- this.operations.set(op.id, op);
1491
+ /**
1492
+ * Set the local user's awareness state and broadcast to peers.
1493
+ *
1494
+ * @param state - The awareness state to share. Pass null to clear presence.
1495
+ */
1496
+ setLocalState(state) {
1497
+ if (this.destroyed) return;
1498
+ this.localState = state;
1499
+ const message = {
1500
+ type: "awareness",
1501
+ clientId: this.clientId,
1502
+ states: { [this.clientId]: state }
1503
+ };
1504
+ this.sendHandler?.(message);
1505
+ this.emitAwarenessEvent();
1094
1506
  }
1095
- async dequeue(ids) {
1096
- for (const id of ids) {
1097
- this.operations.delete(id);
1507
+ /**
1508
+ * Get the local awareness state.
1509
+ */
1510
+ getLocalState() {
1511
+ return this.localState;
1512
+ }
1513
+ /**
1514
+ * Get all known awareness states (local + remote).
1515
+ * Returns a new Map on each call.
1516
+ */
1517
+ getStates() {
1518
+ const result = /* @__PURE__ */ new Map();
1519
+ if (this.localState) {
1520
+ result.set(this.clientId, this.localState);
1521
+ }
1522
+ for (const [id, state] of this.remoteStates) {
1523
+ result.set(id, state);
1098
1524
  }
1525
+ return result;
1099
1526
  }
1100
- async count() {
1101
- return this.operations.size;
1527
+ /**
1528
+ * Handle an incoming awareness message from the transport.
1529
+ * This processes remote state updates and notifies listeners.
1530
+ */
1531
+ handleRemoteMessage(message) {
1532
+ if (this.destroyed) return;
1533
+ const added = [];
1534
+ const updated = [];
1535
+ const removed = [];
1536
+ const now = Date.now();
1537
+ for (const [clientIdStr, state] of Object.entries(message.states)) {
1538
+ const clientId = Number(clientIdStr);
1539
+ if (clientId === this.clientId) continue;
1540
+ if (state === null) {
1541
+ if (this.remoteStates.has(clientId)) {
1542
+ this.remoteStates.delete(clientId);
1543
+ this.lastUpdated.delete(clientId);
1544
+ removed.push(clientId);
1545
+ }
1546
+ } else {
1547
+ if (this.remoteStates.has(clientId)) {
1548
+ this.remoteStates.set(clientId, state);
1549
+ this.lastUpdated.set(clientId, now);
1550
+ updated.push(clientId);
1551
+ } else {
1552
+ this.remoteStates.set(clientId, state);
1553
+ this.lastUpdated.set(clientId, now);
1554
+ added.push(clientId);
1555
+ }
1556
+ }
1557
+ }
1558
+ if (added.length > 0 || updated.length > 0 || removed.length > 0) {
1559
+ const change = { added, updated, removed };
1560
+ this.notifyListeners(change);
1561
+ this.emitAwarenessEvent();
1562
+ }
1102
1563
  }
1103
- };
1104
-
1105
- // src/engine/outbound-queue.ts
1106
- var import_internal = require("@korajs/core/internal");
1107
- var OutboundQueue = class {
1108
- constructor(storage) {
1109
- this.storage = storage;
1564
+ /**
1565
+ * Remove a specific remote client's awareness state.
1566
+ * Called when the server notifies that a client has disconnected.
1567
+ */
1568
+ removeClient(clientId) {
1569
+ if (this.destroyed) return;
1570
+ if (!this.remoteStates.has(clientId)) return;
1571
+ this.remoteStates.delete(clientId);
1572
+ this.lastUpdated.delete(clientId);
1573
+ const change = {
1574
+ added: [],
1575
+ updated: [],
1576
+ removed: [clientId]
1577
+ };
1578
+ this.notifyListeners(change);
1579
+ this.emitAwarenessEvent();
1110
1580
  }
1111
- storage;
1112
- queue = [];
1113
- seen = /* @__PURE__ */ new Set();
1114
- inFlight = /* @__PURE__ */ new Map();
1115
- nextBatchId = 0;
1116
- initialized = false;
1117
1581
  /**
1118
- * Load persisted operations from storage.
1119
- * Must be called before using the queue.
1582
+ * Register a handler for sending awareness messages through the transport.
1583
+ * The sync engine calls this to wire outgoing awareness messages to the transport.
1120
1584
  */
1121
- async initialize() {
1122
- const stored = await this.storage.load();
1123
- for (const op of stored) {
1124
- if (!this.seen.has(op.id)) {
1125
- this.seen.add(op.id);
1126
- this.queue.push(op);
1585
+ onSend(handler) {
1586
+ this.sendHandler = handler;
1587
+ }
1588
+ /**
1589
+ * Register a listener for awareness state changes.
1590
+ * Returns an unsubscribe function.
1591
+ */
1592
+ on(_event, listener) {
1593
+ this.listeners.add(listener);
1594
+ return () => {
1595
+ this.listeners.delete(listener);
1596
+ };
1597
+ }
1598
+ /**
1599
+ * Remove a specific change listener.
1600
+ */
1601
+ off(_event, listener) {
1602
+ this.listeners.delete(listener);
1603
+ }
1604
+ /**
1605
+ * Start the cleanup timer that removes stale remote states.
1606
+ * Called when the sync engine transitions to streaming state.
1607
+ */
1608
+ startCleanupTimer() {
1609
+ if (this.cleanupTimer) return;
1610
+ this.cleanupTimer = setInterval(() => {
1611
+ this.cleanupStaleStates();
1612
+ }, this.timeoutMs);
1613
+ }
1614
+ /**
1615
+ * Stop the cleanup timer.
1616
+ */
1617
+ stopCleanupTimer() {
1618
+ if (this.cleanupTimer) {
1619
+ clearInterval(this.cleanupTimer);
1620
+ this.cleanupTimer = null;
1621
+ }
1622
+ }
1623
+ /**
1624
+ * Clean up all resources. After calling destroy(), the manager
1625
+ * will no longer send or receive awareness updates.
1626
+ * Broadcasts removal of local state before shutting down.
1627
+ */
1628
+ destroy() {
1629
+ if (this.destroyed) return;
1630
+ this.destroyed = true;
1631
+ if (this.localState) {
1632
+ const message = {
1633
+ type: "awareness",
1634
+ clientId: this.clientId,
1635
+ states: { [this.clientId]: null }
1636
+ };
1637
+ this.sendHandler?.(message);
1638
+ }
1639
+ this.localState = null;
1640
+ this.remoteStates.clear();
1641
+ this.lastUpdated.clear();
1642
+ this.listeners.clear();
1643
+ this.sendHandler = null;
1644
+ this.stopCleanupTimer();
1645
+ }
1646
+ // --- Private ---
1647
+ cleanupStaleStates() {
1648
+ const now = Date.now();
1649
+ const removed = [];
1650
+ for (const [clientId, lastTime] of this.lastUpdated) {
1651
+ if (now - lastTime > this.timeoutMs) {
1652
+ this.remoteStates.delete(clientId);
1653
+ this.lastUpdated.delete(clientId);
1654
+ removed.push(clientId);
1127
1655
  }
1128
1656
  }
1129
- if (this.queue.length > 1) {
1130
- this.queue = (0, import_internal.topologicalSort)(this.queue);
1657
+ if (removed.length > 0) {
1658
+ const change = { added: [], updated: [], removed };
1659
+ this.notifyListeners(change);
1660
+ this.emitAwarenessEvent();
1661
+ }
1662
+ }
1663
+ notifyListeners(change) {
1664
+ for (const listener of this.listeners) {
1665
+ listener(change);
1131
1666
  }
1132
- this.initialized = true;
1133
1667
  }
1668
+ emitAwarenessEvent() {
1669
+ const states = this.getStates();
1670
+ this.emitter?.emit({ type: "awareness:updated", states });
1671
+ }
1672
+ };
1673
+
1674
+ // src/diagnostics/bandwidth-estimator.ts
1675
+ var BandwidthEstimator = class {
1676
+ maxSamples;
1677
+ timeSource;
1678
+ samples = [];
1134
1679
  /**
1135
- * Add an operation to the outbound queue.
1136
- * Deduplicates by operation ID. Persists to storage.
1680
+ * @param maxSamples - Maximum number of samples to retain. Defaults to 20.
1681
+ * @param timeSource - Injectable time source for deterministic testing.
1137
1682
  */
1138
- async enqueue(op) {
1139
- if (this.seen.has(op.id)) return;
1140
- this.seen.add(op.id);
1141
- this.queue.push(op);
1142
- await this.storage.enqueue(op);
1143
- if (this.queue.length > 1) {
1144
- this.queue = (0, import_internal.topologicalSort)(this.queue);
1683
+ constructor(maxSamples = 20, timeSource) {
1684
+ this.maxSamples = maxSamples;
1685
+ this.timeSource = timeSource ?? { now: () => Date.now() };
1686
+ }
1687
+ /**
1688
+ * Record a transfer of `bytes` that took `durationMs` to complete.
1689
+ * Samples with zero or negative duration are ignored to avoid division errors.
1690
+ */
1691
+ recordTransfer(bytes, durationMs) {
1692
+ if (durationMs <= 0 || bytes <= 0) return;
1693
+ this.samples.push({
1694
+ bytes,
1695
+ durationMs,
1696
+ timestamp: this.timeSource.now()
1697
+ });
1698
+ while (this.samples.length > this.maxSamples) {
1699
+ this.samples.shift();
1145
1700
  }
1146
1701
  }
1147
1702
  /**
1148
- * Take a batch of operations from the front of the queue.
1149
- * Moves them to in-flight status. Returns null if queue is empty.
1703
+ * Estimate current effective bandwidth in bytes per second.
1150
1704
  *
1151
- * @param batchSize - Maximum number of operations in the batch
1705
+ * Uses exponential weighting so recent samples have more influence.
1706
+ * Returns null if fewer than 2 samples exist (not enough data).
1152
1707
  */
1153
- takeBatch(batchSize) {
1154
- if (this.queue.length === 0) return null;
1155
- const ops = this.queue.splice(0, batchSize);
1156
- const batchId = `batch-${this.nextBatchId++}`;
1157
- this.inFlight.set(batchId, ops);
1158
- return { batchId, operations: ops };
1708
+ estimate() {
1709
+ if (this.samples.length < 2) return null;
1710
+ let weightedSum = 0;
1711
+ let totalWeight = 0;
1712
+ const decayFactor = 0.9;
1713
+ for (let i = this.samples.length - 1; i >= 0; i--) {
1714
+ const sample = this.samples[i];
1715
+ if (!sample) continue;
1716
+ const bytesPerSec = sample.bytes / sample.durationMs * 1e3;
1717
+ const age = this.samples.length - 1 - i;
1718
+ const weight = decayFactor ** age;
1719
+ weightedSum += bytesPerSec * weight;
1720
+ totalWeight += weight;
1721
+ }
1722
+ if (totalWeight === 0) return null;
1723
+ return Math.round(weightedSum / totalWeight);
1159
1724
  }
1160
1725
  /**
1161
- * Acknowledge a batch, removing its operations permanently.
1726
+ * Reset all recorded samples.
1162
1727
  */
1163
- async acknowledge(batchId) {
1164
- const ops = this.inFlight.get(batchId);
1165
- if (!ops) return;
1166
- this.inFlight.delete(batchId);
1167
- const ids = ops.map((op) => op.id);
1168
- await this.storage.dequeue(ids);
1728
+ reset() {
1729
+ this.samples.length = 0;
1169
1730
  }
1170
1731
  /**
1171
- * Return a failed batch to the front of the queue for retry.
1172
- * Prepends the operations to maintain priority.
1732
+ * Get the number of samples currently stored.
1173
1733
  */
1174
- returnBatch(batchId) {
1175
- const ops = this.inFlight.get(batchId);
1176
- if (!ops) return;
1177
- this.inFlight.delete(batchId);
1178
- this.queue.unshift(...ops);
1179
- if (this.queue.length > 1) {
1180
- this.queue = (0, import_internal.topologicalSort)(this.queue);
1181
- }
1734
+ sampleCount() {
1735
+ return this.samples.length;
1182
1736
  }
1737
+ };
1738
+
1739
+ // src/diagnostics/percentile.ts
1740
+ var SlidingWindowPercentile = class {
1741
+ samples;
1742
+ maxSize;
1743
+ writeIndex = 0;
1744
+ count = 0;
1183
1745
  /**
1184
- * Number of operations waiting in the queue (not counting in-flight).
1746
+ * @param maxSize - Maximum number of samples in the sliding window.
1747
+ * Older samples are overwritten when the buffer is full.
1185
1748
  */
1186
- get size() {
1187
- return this.queue.length;
1749
+ constructor(maxSize) {
1750
+ if (maxSize < 1) {
1751
+ throw new Error(`SlidingWindowPercentile maxSize must be >= 1, got ${maxSize}`);
1752
+ }
1753
+ this.maxSize = maxSize;
1754
+ this.samples = new Array(maxSize);
1188
1755
  }
1189
1756
  /**
1190
- * Total operations including in-flight.
1757
+ * Add a sample to the sliding window.
1758
+ * If the window is full, the oldest sample is overwritten.
1191
1759
  */
1192
- get totalPending() {
1193
- let inFlightCount = 0;
1194
- for (const ops of this.inFlight.values()) {
1195
- inFlightCount += ops.length;
1760
+ addSample(value) {
1761
+ this.samples[this.writeIndex] = value;
1762
+ this.writeIndex = (this.writeIndex + 1) % this.maxSize;
1763
+ if (this.count < this.maxSize) {
1764
+ this.count++;
1196
1765
  }
1197
- return this.queue.length + inFlightCount;
1198
1766
  }
1199
1767
  /**
1200
- * Whether the queue has any operations to send.
1768
+ * Compute a percentile value from the current window.
1769
+ *
1770
+ * Uses the nearest-rank method: the percentile value is the smallest
1771
+ * value in the dataset such that at least p% of the data is <= that value.
1772
+ *
1773
+ * @param p - Percentile to compute (0-100). E.g., 50 for median, 95 for p95.
1774
+ * @returns The percentile value, or 0 if no samples have been recorded.
1201
1775
  */
1202
- get hasOperations() {
1203
- return this.queue.length > 0;
1776
+ percentile(p) {
1777
+ if (this.count === 0) return 0;
1778
+ const sorted = this.samples.slice(0, this.count).sort((a, b) => a - b);
1779
+ const rank = Math.ceil(p / 100 * sorted.length) - 1;
1780
+ const index = Math.max(0, Math.min(rank, sorted.length - 1));
1781
+ return sorted[index] ?? 0;
1204
1782
  }
1205
1783
  /**
1206
- * Peek at the first `count` operations without removing them.
1784
+ * Get the most recently added sample, or 0 if no samples exist.
1207
1785
  */
1208
- peek(count) {
1209
- return this.queue.slice(0, count);
1786
+ latest() {
1787
+ if (this.count === 0) return 0;
1788
+ const lastIndex = (this.writeIndex - 1 + this.maxSize) % this.maxSize;
1789
+ return this.samples[lastIndex] ?? 0;
1210
1790
  }
1211
1791
  /**
1212
- * Whether initialize() has been called.
1792
+ * Get the number of samples currently in the window.
1213
1793
  */
1214
- get isInitialized() {
1215
- return this.initialized;
1794
+ size() {
1795
+ return this.count;
1796
+ }
1797
+ /**
1798
+ * Reset the sliding window, clearing all samples.
1799
+ */
1800
+ reset() {
1801
+ this.writeIndex = 0;
1802
+ this.count = 0;
1216
1803
  }
1217
1804
  };
1218
1805
 
1219
- // src/engine/sync-engine.ts
1220
- var DEFAULT_BATCH_SIZE = 100;
1221
- var DEFAULT_SCHEMA_VERSION = 1;
1222
- var VALID_TRANSITIONS = {
1223
- disconnected: ["connecting"],
1224
- connecting: ["handshaking", "error", "disconnected"],
1225
- handshaking: ["syncing", "error", "disconnected"],
1226
- syncing: ["streaming", "error", "disconnected"],
1227
- streaming: ["disconnected", "error"],
1806
+ // src/diagnostics/metrics-collector.ts
1807
+ var SyncMetricsCollector = class {
1808
+ rttWindow;
1809
+ inboundBandwidth;
1810
+ outboundBandwidth;
1811
+ timeSource;
1812
+ diagnosticsInterval;
1813
+ // Connection
1814
+ connectedAt = null;
1815
+ disconnectedAt = null;
1816
+ reconnectAttempts = 0;
1817
+ // Throughput
1818
+ operationsSent = 0;
1819
+ operationsReceived = 0;
1820
+ bytesSent = 0;
1821
+ bytesReceived = 0;
1822
+ // Queue
1823
+ pendingOperations = 0;
1824
+ outboundQueueSize = 0;
1825
+ // Sync progress
1826
+ lastSyncedAt = null;
1827
+ syncStartTime = null;
1828
+ syncDuration = null;
1829
+ initialSyncComplete = false;
1830
+ initialSyncTotalBatches = 0;
1831
+ initialSyncReceivedBatches = 0;
1832
+ // Errors
1833
+ lastError = null;
1834
+ errorCount = 0;
1835
+ // Status
1836
+ currentStatus = "offline";
1837
+ currentQuality = "offline";
1838
+ // Periodic emission
1839
+ emitter = null;
1840
+ periodicTimer = null;
1841
+ constructor(config) {
1842
+ this.rttWindow = new SlidingWindowPercentile(config?.rttWindowSize ?? 100);
1843
+ this.inboundBandwidth = new BandwidthEstimator(
1844
+ config?.bandwidthWindowSize ?? 20,
1845
+ config?.timeSource
1846
+ );
1847
+ this.outboundBandwidth = new BandwidthEstimator(
1848
+ config?.bandwidthWindowSize ?? 20,
1849
+ config?.timeSource
1850
+ );
1851
+ this.timeSource = config?.timeSource ?? { now: () => Date.now() };
1852
+ this.diagnosticsInterval = config?.diagnosticsInterval ?? 5e3;
1853
+ }
1854
+ /**
1855
+ * Attach an event emitter for periodic diagnostics emission.
1856
+ * Starts emitting `sync:diagnostics` events at the configured interval
1857
+ * while connected.
1858
+ */
1859
+ attachEmitter(emitter) {
1860
+ this.emitter = emitter;
1861
+ }
1862
+ /**
1863
+ * Record that a connection has been established.
1864
+ */
1865
+ recordConnected() {
1866
+ this.connectedAt = this.timeSource.now();
1867
+ this.reconnectAttempts = 0;
1868
+ this.startPeriodicEmission();
1869
+ }
1870
+ /**
1871
+ * Record that the connection has been lost.
1872
+ */
1873
+ recordDisconnected() {
1874
+ this.disconnectedAt = this.timeSource.now();
1875
+ this.stopPeriodicEmission();
1876
+ }
1877
+ /**
1878
+ * Record a reconnection attempt.
1879
+ */
1880
+ recordReconnectAttempt() {
1881
+ this.reconnectAttempts++;
1882
+ }
1883
+ /**
1884
+ * Record a round-trip time measurement.
1885
+ */
1886
+ recordRtt(ms) {
1887
+ this.rttWindow.addSample(ms);
1888
+ }
1889
+ /**
1890
+ * Record operations sent with their serialized byte size.
1891
+ */
1892
+ recordSent(operationCount, byteSize, durationMs) {
1893
+ this.operationsSent += operationCount;
1894
+ this.bytesSent += byteSize;
1895
+ if (durationMs > 0 && byteSize > 0) {
1896
+ this.outboundBandwidth.recordTransfer(byteSize, durationMs);
1897
+ this.emitBandwidthEvent("out");
1898
+ }
1899
+ }
1900
+ /**
1901
+ * Record operations received with their serialized byte size.
1902
+ */
1903
+ recordReceived(operationCount, byteSize, durationMs) {
1904
+ this.operationsReceived += operationCount;
1905
+ this.bytesReceived += byteSize;
1906
+ if (durationMs > 0 && byteSize > 0) {
1907
+ this.inboundBandwidth.recordTransfer(byteSize, durationMs);
1908
+ this.emitBandwidthEvent("in");
1909
+ }
1910
+ }
1911
+ /**
1912
+ * Update the pending operations count and estimated outbound queue size.
1913
+ */
1914
+ updateQueue(pendingOps, estimatedBytes) {
1915
+ this.pendingOperations = pendingOps;
1916
+ this.outboundQueueSize = estimatedBytes;
1917
+ }
1918
+ /**
1919
+ * Record that sync has started (for measuring sync duration).
1920
+ */
1921
+ recordSyncStarted() {
1922
+ this.syncStartTime = this.timeSource.now();
1923
+ }
1924
+ /**
1925
+ * Record that a sync cycle has completed.
1926
+ */
1927
+ recordSyncCompleted() {
1928
+ if (this.syncStartTime !== null) {
1929
+ this.syncDuration = this.timeSource.now() - this.syncStartTime;
1930
+ this.syncStartTime = null;
1931
+ }
1932
+ this.lastSyncedAt = this.timeSource.now();
1933
+ if (!this.initialSyncComplete) {
1934
+ this.initialSyncComplete = true;
1935
+ }
1936
+ }
1937
+ /**
1938
+ * Update initial sync progress.
1939
+ * @param receivedBatches - Number of delta batches received so far.
1940
+ * @param totalBatches - Estimated total batches (0 if unknown).
1941
+ */
1942
+ updateInitialSyncProgress(receivedBatches, totalBatches) {
1943
+ this.initialSyncReceivedBatches = receivedBatches;
1944
+ this.initialSyncTotalBatches = totalBatches;
1945
+ const progress = totalBatches > 0 ? Math.min(1, receivedBatches / totalBatches) : receivedBatches > 0 ? 0.5 : 0;
1946
+ this.emitter?.emit({
1947
+ type: "sync:initial-sync-progress",
1948
+ progress,
1949
+ totalBatches,
1950
+ receivedBatches
1951
+ });
1952
+ }
1953
+ /**
1954
+ * Record an error.
1955
+ */
1956
+ recordError(message) {
1957
+ this.lastError = message;
1958
+ this.errorCount++;
1959
+ }
1960
+ /**
1961
+ * Update the current developer-facing status.
1962
+ */
1963
+ updateStatus(status) {
1964
+ this.currentStatus = status;
1965
+ }
1966
+ /**
1967
+ * Update the connection quality.
1968
+ */
1969
+ updateQuality(quality) {
1970
+ this.currentQuality = quality;
1971
+ }
1972
+ /**
1973
+ * Compute and return a full diagnostics snapshot.
1974
+ */
1975
+ getSnapshot() {
1976
+ return {
1977
+ // Connection
1978
+ status: this.currentStatus,
1979
+ connectedAt: this.connectedAt,
1980
+ disconnectedAt: this.disconnectedAt,
1981
+ reconnectAttempts: this.reconnectAttempts,
1982
+ // Latency
1983
+ rttMs: this.rttWindow.latest(),
1984
+ rttP50Ms: this.rttWindow.percentile(50),
1985
+ rttP95Ms: this.rttWindow.percentile(95),
1986
+ rttP99Ms: this.rttWindow.percentile(99),
1987
+ // Throughput
1988
+ operationsSent: this.operationsSent,
1989
+ operationsReceived: this.operationsReceived,
1990
+ bytesSent: this.bytesSent,
1991
+ bytesReceived: this.bytesReceived,
1992
+ // Queue
1993
+ pendingOperations: this.pendingOperations,
1994
+ outboundQueueSize: this.outboundQueueSize,
1995
+ // Sync Progress
1996
+ lastSyncedAt: this.lastSyncedAt,
1997
+ syncDuration: this.syncDuration,
1998
+ initialSyncComplete: this.initialSyncComplete,
1999
+ initialSyncProgress: this.computeInitialSyncProgress(),
2000
+ // Errors
2001
+ lastError: this.lastError,
2002
+ errorCount: this.errorCount,
2003
+ // Connection Quality
2004
+ quality: this.currentQuality,
2005
+ effectiveBandwidth: this.computeEffectiveBandwidth()
2006
+ };
2007
+ }
2008
+ /**
2009
+ * Assess connection quality from current metrics.
2010
+ *
2011
+ * Quality is derived from RTT percentiles, error rate, and bandwidth:
2012
+ * - excellent: p95 < 100ms, no errors
2013
+ * - good: p95 < 300ms, errorCount <= 1
2014
+ * - fair: p95 < 1000ms, errorCount <= 3
2015
+ * - poor: p95 >= 1000ms or errorCount > 3
2016
+ * - offline: no connection
2017
+ */
2018
+ assessQuality() {
2019
+ if (this.currentStatus === "offline") return "offline";
2020
+ const p95 = this.rttWindow.percentile(95);
2021
+ const hasSamples = this.rttWindow.size() > 0;
2022
+ if (!hasSamples) {
2023
+ if (this.errorCount > 3) return "poor";
2024
+ if (this.errorCount > 0) return "fair";
2025
+ return "good";
2026
+ }
2027
+ if (this.errorCount > 3) return "poor";
2028
+ if (p95 < 100 && this.errorCount === 0) return "excellent";
2029
+ if (p95 < 300 && this.errorCount <= 1) return "good";
2030
+ if (p95 < 1e3 && this.errorCount <= 3) return "fair";
2031
+ return "poor";
2032
+ }
2033
+ /**
2034
+ * Reset all collected metrics. Call when starting a new session.
2035
+ */
2036
+ reset() {
2037
+ this.rttWindow.reset();
2038
+ this.inboundBandwidth.reset();
2039
+ this.outboundBandwidth.reset();
2040
+ this.connectedAt = null;
2041
+ this.disconnectedAt = null;
2042
+ this.reconnectAttempts = 0;
2043
+ this.operationsSent = 0;
2044
+ this.operationsReceived = 0;
2045
+ this.bytesSent = 0;
2046
+ this.bytesReceived = 0;
2047
+ this.pendingOperations = 0;
2048
+ this.outboundQueueSize = 0;
2049
+ this.lastSyncedAt = null;
2050
+ this.syncStartTime = null;
2051
+ this.syncDuration = null;
2052
+ this.initialSyncComplete = false;
2053
+ this.initialSyncTotalBatches = 0;
2054
+ this.initialSyncReceivedBatches = 0;
2055
+ this.lastError = null;
2056
+ this.errorCount = 0;
2057
+ this.currentStatus = "offline";
2058
+ this.currentQuality = "offline";
2059
+ this.stopPeriodicEmission();
2060
+ }
2061
+ /**
2062
+ * Stop periodic emission and clean up resources.
2063
+ */
2064
+ dispose() {
2065
+ this.stopPeriodicEmission();
2066
+ this.emitter = null;
2067
+ }
2068
+ // --- Private helpers ---
2069
+ computeInitialSyncProgress() {
2070
+ if (this.initialSyncComplete) return 1;
2071
+ if (this.initialSyncTotalBatches > 0) {
2072
+ return Math.min(1, this.initialSyncReceivedBatches / this.initialSyncTotalBatches);
2073
+ }
2074
+ return this.initialSyncReceivedBatches > 0 ? 0.5 : 0;
2075
+ }
2076
+ computeEffectiveBandwidth() {
2077
+ const inbound = this.inboundBandwidth.estimate();
2078
+ const outbound = this.outboundBandwidth.estimate();
2079
+ if (inbound === null && outbound === null) return null;
2080
+ if (inbound === null) return outbound;
2081
+ if (outbound === null) return inbound;
2082
+ return Math.min(inbound, outbound);
2083
+ }
2084
+ startPeriodicEmission() {
2085
+ if (this.periodicTimer !== null) return;
2086
+ if (!this.emitter) return;
2087
+ this.periodicTimer = setInterval(() => {
2088
+ this.emitDiagnostics();
2089
+ }, this.diagnosticsInterval);
2090
+ }
2091
+ stopPeriodicEmission() {
2092
+ if (this.periodicTimer !== null) {
2093
+ clearInterval(this.periodicTimer);
2094
+ this.periodicTimer = null;
2095
+ }
2096
+ }
2097
+ emitDiagnostics() {
2098
+ this.emitter?.emit({
2099
+ type: "sync:diagnostics",
2100
+ diagnostics: this.getSnapshot()
2101
+ });
2102
+ }
2103
+ emitBandwidthEvent(direction) {
2104
+ const estimator = direction === "in" ? this.inboundBandwidth : this.outboundBandwidth;
2105
+ const bps = estimator.estimate();
2106
+ if (bps !== null) {
2107
+ this.emitter?.emit({
2108
+ type: "sync:bandwidth",
2109
+ bytesPerSecond: bps,
2110
+ direction
2111
+ });
2112
+ }
2113
+ }
2114
+ };
2115
+
2116
+ // src/richtext/richtext-doc-channel.ts
2117
+ var import_core5 = require("@korajs/core");
2118
+
2119
+ // src/richtext/doc-channel-wire.ts
2120
+ function encodeYjsUpdate(update) {
2121
+ let binary = "";
2122
+ for (let i = 0; i < update.length; i++) {
2123
+ binary += String.fromCharCode(update[i]);
2124
+ }
2125
+ return btoa(binary);
2126
+ }
2127
+ function decodeYjsUpdate(encoded) {
2128
+ const binary = atob(encoded);
2129
+ const bytes = new Uint8Array(binary.length);
2130
+ for (let i = 0; i < binary.length; i++) {
2131
+ bytes[i] = binary.charCodeAt(i);
2132
+ }
2133
+ return bytes;
2134
+ }
2135
+ function richtextDocKey(collection, recordId, field) {
2136
+ return `${collection}:${recordId}:${field}`;
2137
+ }
2138
+
2139
+ // src/richtext/richtext-doc-channel.ts
2140
+ var DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD = 4096;
2141
+ var RichtextDocChannel = class {
2142
+ threshold;
2143
+ onSend;
2144
+ listeners = /* @__PURE__ */ new Map();
2145
+ constructor(options) {
2146
+ this.threshold = options?.largeDocThreshold ?? DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD;
2147
+ this.onSend = options?.onSend ?? null;
2148
+ }
2149
+ /**
2150
+ * Whether the doc channel should be used for a field.
2151
+ * @param preference - `true` forces on, `false` forces off, `undefined` uses size threshold
2152
+ */
2153
+ shouldUseChannel(snapshotByteLength, preference) {
2154
+ if (preference === false) {
2155
+ return false;
2156
+ }
2157
+ if (preference === true) {
2158
+ return true;
2159
+ }
2160
+ return snapshotByteLength >= this.threshold;
2161
+ }
2162
+ getThreshold() {
2163
+ return this.threshold;
2164
+ }
2165
+ /**
2166
+ * Subscribe to incremental updates for a document key.
2167
+ */
2168
+ subscribe(collection, recordId, field, listener) {
2169
+ const key = richtextDocKey(collection, recordId, field);
2170
+ let set = this.listeners.get(key);
2171
+ if (!set) {
2172
+ set = /* @__PURE__ */ new Set();
2173
+ this.listeners.set(key, set);
2174
+ }
2175
+ set.add(listener);
2176
+ return () => {
2177
+ const current = this.listeners.get(key);
2178
+ if (!current) {
2179
+ return;
2180
+ }
2181
+ current.delete(listener);
2182
+ if (current.size === 0) {
2183
+ this.listeners.delete(key);
2184
+ }
2185
+ };
2186
+ }
2187
+ /**
2188
+ * Publish a local incremental update to peers.
2189
+ */
2190
+ send(collection, recordId, field, update) {
2191
+ if (!this.onSend || update.length === 0) {
2192
+ return;
2193
+ }
2194
+ this.onSend({
2195
+ type: "yjs-doc-update",
2196
+ messageId: (0, import_core5.generateUUIDv7)(),
2197
+ collection,
2198
+ recordId,
2199
+ field,
2200
+ update: encodeYjsUpdate(update)
2201
+ });
2202
+ }
2203
+ /**
2204
+ * Deliver a remote incremental update to local subscribers.
2205
+ */
2206
+ deliver(message) {
2207
+ const key = richtextDocKey(message.collection, message.recordId, message.field);
2208
+ const set = this.listeners.get(key);
2209
+ if (!set || set.size === 0) {
2210
+ return;
2211
+ }
2212
+ const update = decodeYjsUpdate(message.update);
2213
+ for (const listener of set) {
2214
+ listener(update);
2215
+ }
2216
+ }
2217
+ };
2218
+
2219
+ // src/engine/memory-queue-storage.ts
2220
+ var MemoryQueueStorage = class {
2221
+ operations = /* @__PURE__ */ new Map();
2222
+ async load() {
2223
+ return [...this.operations.values()];
2224
+ }
2225
+ async enqueue(op) {
2226
+ this.operations.set(op.id, op);
2227
+ }
2228
+ async dequeue(ids) {
2229
+ for (const id of ids) {
2230
+ this.operations.delete(id);
2231
+ }
2232
+ }
2233
+ async count() {
2234
+ return this.operations.size;
2235
+ }
2236
+ };
2237
+
2238
+ // src/engine/outbound-queue.ts
2239
+ var import_internal = require("@korajs/core/internal");
2240
+ var OutboundQueue = class {
2241
+ constructor(storage) {
2242
+ this.storage = storage;
2243
+ }
2244
+ storage;
2245
+ queue = [];
2246
+ seen = /* @__PURE__ */ new Set();
2247
+ inFlight = /* @__PURE__ */ new Map();
2248
+ nextBatchId = 0;
2249
+ initialized = false;
2250
+ /**
2251
+ * Load persisted operations from storage.
2252
+ * Must be called before using the queue.
2253
+ */
2254
+ async initialize() {
2255
+ const stored = await this.storage.load();
2256
+ for (const op of stored) {
2257
+ if (!this.seen.has(op.id)) {
2258
+ this.seen.add(op.id);
2259
+ this.queue.push(op);
2260
+ }
2261
+ }
2262
+ if (this.queue.length > 1) {
2263
+ this.queue = (0, import_internal.topologicalSort)(this.queue);
2264
+ }
2265
+ this.initialized = true;
2266
+ }
2267
+ /**
2268
+ * Add an operation to the outbound queue.
2269
+ * Deduplicates by operation ID. Persists to storage.
2270
+ */
2271
+ async enqueue(op) {
2272
+ if (this.seen.has(op.id)) return;
2273
+ this.seen.add(op.id);
2274
+ this.queue.push(op);
2275
+ await this.storage.enqueue(op);
2276
+ if (this.queue.length > 1) {
2277
+ this.queue = (0, import_internal.topologicalSort)(this.queue);
2278
+ }
2279
+ }
2280
+ /**
2281
+ * Take a batch of operations from the front of the queue.
2282
+ * Moves them to in-flight status. Returns null if queue is empty.
2283
+ *
2284
+ * @param batchSize - Maximum number of operations in the batch
2285
+ */
2286
+ takeBatch(batchSize) {
2287
+ if (this.queue.length === 0) return null;
2288
+ const ops = this.queue.splice(0, batchSize);
2289
+ const batchId = `batch-${this.nextBatchId++}`;
2290
+ this.inFlight.set(batchId, ops);
2291
+ return { batchId, operations: ops };
2292
+ }
2293
+ /**
2294
+ * Acknowledge a batch, removing its operations permanently.
2295
+ */
2296
+ async acknowledge(batchId) {
2297
+ const ops = this.inFlight.get(batchId);
2298
+ if (!ops) return;
2299
+ this.inFlight.delete(batchId);
2300
+ const ids = ops.map((op) => op.id);
2301
+ await this.storage.dequeue(ids);
2302
+ }
2303
+ /**
2304
+ * Return a failed batch to the front of the queue for retry.
2305
+ * Prepends the operations to maintain priority.
2306
+ */
2307
+ returnBatch(batchId) {
2308
+ const ops = this.inFlight.get(batchId);
2309
+ if (!ops) return;
2310
+ this.inFlight.delete(batchId);
2311
+ this.queue.unshift(...ops);
2312
+ if (this.queue.length > 1) {
2313
+ this.queue = (0, import_internal.topologicalSort)(this.queue);
2314
+ }
2315
+ }
2316
+ /**
2317
+ * Number of operations waiting in the queue (not counting in-flight).
2318
+ */
2319
+ get size() {
2320
+ return this.queue.length;
2321
+ }
2322
+ /**
2323
+ * Total operations including in-flight.
2324
+ */
2325
+ get totalPending() {
2326
+ let inFlightCount = 0;
2327
+ for (const ops of this.inFlight.values()) {
2328
+ inFlightCount += ops.length;
2329
+ }
2330
+ return this.queue.length + inFlightCount;
2331
+ }
2332
+ /**
2333
+ * Whether the queue has any operations to send.
2334
+ */
2335
+ get hasOperations() {
2336
+ return this.queue.length > 0;
2337
+ }
2338
+ /**
2339
+ * Peek at the first `count` operations without removing them.
2340
+ */
2341
+ peek(count) {
2342
+ return this.queue.slice(0, count);
2343
+ }
2344
+ /**
2345
+ * Whether initialize() has been called.
2346
+ */
2347
+ get isInitialized() {
2348
+ return this.initialized;
2349
+ }
2350
+ /**
2351
+ * Remove operations by id from queue and persistent storage.
2352
+ * Used when ops were already sent during handshake delta exchange.
2353
+ */
2354
+ async removeByIds(ids) {
2355
+ if (ids.length === 0) return;
2356
+ const idSet = new Set(ids);
2357
+ this.queue = this.queue.filter((op) => !idSet.has(op.id));
2358
+ for (const id of ids) {
2359
+ this.seen.delete(id);
2360
+ }
2361
+ await this.storage.dequeue(ids);
2362
+ }
2363
+ };
2364
+
2365
+ // src/engine/sync-engine.ts
2366
+ var DEFAULT_BATCH_SIZE = 100;
2367
+ var DEFAULT_SCHEMA_VERSION = 1;
2368
+ var VALID_TRANSITIONS = {
2369
+ disconnected: ["connecting"],
2370
+ connecting: ["handshaking", "error", "disconnected"],
2371
+ handshaking: ["syncing", "error", "disconnected"],
2372
+ syncing: ["streaming", "error", "disconnected"],
2373
+ streaming: ["disconnected", "error"],
1228
2374
  error: ["disconnected"]
1229
2375
  };
1230
2376
  var nextMessageId = 0;
2377
+ var nextQuerySubsetId = 0;
1231
2378
  function generateMessageId() {
1232
2379
  return `msg-${Date.now()}-${nextMessageId++}`;
1233
2380
  }
@@ -1240,14 +2387,41 @@ var SyncEngine = class {
1240
2387
  emitter;
1241
2388
  outboundQueue;
1242
2389
  batchSize;
2390
+ encryptor;
2391
+ awarenessManager;
2392
+ richtextDocChannel;
2393
+ metricsCollector;
2394
+ syncState;
1243
2395
  remoteVector = /* @__PURE__ */ new Map();
2396
+ lastAckedServerVector = /* @__PURE__ */ new Map();
2397
+ cachedUnsyncedCount = 0;
1244
2398
  lastSyncedAt = null;
2399
+ lastSuccessfulPush = null;
2400
+ lastSuccessfulPull = null;
2401
+ conflictCount = 0;
1245
2402
  currentBatch = null;
1246
2403
  reconnecting = false;
2404
+ schemaBlocked = false;
1247
2405
  // Track delta exchange state
1248
2406
  deltaBatchesReceived = 0;
1249
2407
  deltaReceiveComplete = false;
1250
2408
  deltaSendComplete = false;
2409
+ /** Op ids sent during handshake delta — removed from outbound queue to avoid duplicate send */
2410
+ deltaSentOpIds = [];
2411
+ /** Outbound delta batch message IDs awaiting ACK when strictHandshake is enabled */
2412
+ pendingDeltaBatchAcks = /* @__PURE__ */ new Set();
2413
+ /**
2414
+ * The effective scope for this sync session.
2415
+ * Starts as the configured scopeMap. After handshake, may be replaced
2416
+ * with the server-accepted scope (server is authoritative).
2417
+ */
2418
+ activeScope;
2419
+ /** Live query subsets registered from reactive subscriptions */
2420
+ querySubsets = /* @__PURE__ */ new Map();
2421
+ querySubsetReconnectTimer = null;
2422
+ /** Resume cursor for paginated initial sync (persisted across reconnects) */
2423
+ resumeDeltaCursor = null;
2424
+ initialSyncTotalBatches = 0;
1251
2425
  constructor(options) {
1252
2426
  this.transport = options.transport;
1253
2427
  this.store = options.store;
@@ -1255,28 +2429,72 @@ var SyncEngine = class {
1255
2429
  this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
1256
2430
  this.emitter = options.emitter ?? null;
1257
2431
  this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
2432
+ this.encryptor = options.encryptor ?? null;
2433
+ this.syncState = options.syncState ?? null;
2434
+ this.activeScope = options.config.scopeMap;
1258
2435
  const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
1259
2436
  this.outboundQueue = new OutboundQueue(queueStorage);
2437
+ this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
2438
+ if (this.emitter) {
2439
+ this.metricsCollector.attachEmitter(this.emitter);
2440
+ }
2441
+ this.awarenessManager = new AwarenessManager({
2442
+ emitter: this.emitter ?? void 0
2443
+ });
2444
+ this.richtextDocChannel = new RichtextDocChannel({
2445
+ largeDocThreshold: options.config.richtextDocChannelThreshold,
2446
+ onSend: (message) => {
2447
+ if (this.state !== "streaming") {
2448
+ return;
2449
+ }
2450
+ this.transport.send(message);
2451
+ }
2452
+ });
2453
+ this.awarenessManager.onSend((message) => {
2454
+ if (this.state !== "streaming") return;
2455
+ const wireMessage = {
2456
+ type: "awareness-update",
2457
+ messageId: generateMessageId(),
2458
+ clientId: message.clientId,
2459
+ states: awarenessStatesToWire(message.states)
2460
+ };
2461
+ this.transport.send(wireMessage);
2462
+ });
1260
2463
  }
1261
2464
  /**
1262
2465
  * Start the sync engine: connect → handshake → delta exchange → streaming.
1263
2466
  */
1264
2467
  async start() {
1265
2468
  if (this.state !== "disconnected") {
1266
- throw new import_core4.SyncError("Cannot start sync engine: not in disconnected state", {
2469
+ throw new import_core6.SyncError("Cannot start sync engine: not in disconnected state", {
1267
2470
  currentState: this.state
1268
2471
  });
1269
2472
  }
1270
2473
  await this.outboundQueue.initialize();
1271
- this.transport.onMessage((msg) => this.handleMessage(msg));
2474
+ if (this.syncState) {
2475
+ this.lastAckedServerVector = await this.syncState.loadLastAckedServerVector();
2476
+ if (this.syncState.loadDeltaCursor) {
2477
+ this.resumeDeltaCursor = await this.syncState.loadDeltaCursor();
2478
+ }
2479
+ }
2480
+ await this.reconcileOutboundFromOpLog();
2481
+ await this.refreshPendingCount();
2482
+ this.transport.onMessage((msg) => this.enqueueMessage(msg));
1272
2483
  this.transport.onClose((reason) => this.handleTransportClose(reason));
1273
2484
  this.transport.onError((err) => this.handleTransportError(err));
2485
+ if (this.schemaBlocked) {
2486
+ throw new import_core6.SyncError(
2487
+ "Sync is blocked due to schema version mismatch. Upgrade the app schema or align sync.schemaVersion with the server.",
2488
+ { code: "SCHEMA_MISMATCH_BLOCKED" }
2489
+ );
2490
+ }
1274
2491
  this.transitionTo("connecting");
1275
2492
  try {
1276
2493
  const authToken = this.config.auth ? (await this.config.auth()).token : void 0;
1277
2494
  await this.transport.connect(this.config.url, { authToken });
1278
2495
  this.transitionTo("handshaking");
1279
2496
  const localVector = this.store.getVersionVector();
2497
+ const activeQuerySubsets = this.getActiveQuerySubsets();
1280
2498
  const handshake = {
1281
2499
  type: "handshake",
1282
2500
  messageId: generateMessageId(),
@@ -1284,7 +2502,10 @@ var SyncEngine = class {
1284
2502
  versionVector: versionVectorToWire(localVector),
1285
2503
  schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
1286
2504
  authToken,
1287
- supportedWireFormats: ["json", "protobuf"]
2505
+ supportedWireFormats: ["json", "protobuf"],
2506
+ ...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {},
2507
+ ...activeQuerySubsets.length > 0 ? { syncQueries: activeQuerySubsets } : {},
2508
+ ...this.resumeDeltaCursor ? { deltaCursor: encodeDeltaCursor(this.resumeDeltaCursor) } : {}
1288
2509
  };
1289
2510
  this.transport.send(handshake);
1290
2511
  } catch (err) {
@@ -1297,6 +2518,7 @@ var SyncEngine = class {
1297
2518
  */
1298
2519
  async stop() {
1299
2520
  if (this.state === "disconnected") return;
2521
+ this.awarenessManager.stopCleanupTimer();
1300
2522
  if (this.currentBatch) {
1301
2523
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
1302
2524
  this.currentBatch = null;
@@ -1315,9 +2537,21 @@ var SyncEngine = class {
1315
2537
  /**
1316
2538
  * Push a local operation to the outbound queue.
1317
2539
  * If streaming, flushes immediately.
2540
+ *
2541
+ * Operations outside the configured sync scope are silently skipped
2542
+ * because they should remain local-only and not be sent to the server.
1318
2543
  */
1319
2544
  async pushOperation(op) {
2545
+ if (!this.operationAllowedForSync(op)) {
2546
+ return;
2547
+ }
2548
+ if (this.syncState) {
2549
+ this.cachedUnsyncedCount++;
2550
+ }
1320
2551
  await this.outboundQueue.enqueue(op);
2552
+ if (this.syncState) {
2553
+ await this.refreshPendingCount();
2554
+ }
1321
2555
  if (this.state === "streaming") {
1322
2556
  this.flushQueue();
1323
2557
  }
@@ -1335,48 +2569,181 @@ var SyncEngine = class {
1335
2569
  * Get the current developer-facing sync status.
1336
2570
  */
1337
2571
  getStatus() {
1338
- const pendingOperations = this.outboundQueue.totalPending;
2572
+ const pendingOperations = this.syncState ? this.cachedUnsyncedCount : this.outboundQueue.totalPending;
2573
+ const base = {
2574
+ pendingOperations,
2575
+ lastSyncedAt: this.lastSyncedAt,
2576
+ lastSuccessfulPush: this.lastSuccessfulPush,
2577
+ lastSuccessfulPull: this.lastSuccessfulPull,
2578
+ conflicts: this.conflictCount
2579
+ };
1339
2580
  switch (this.state) {
1340
2581
  case "disconnected":
1341
- return { status: "offline", pendingOperations, lastSyncedAt: this.lastSyncedAt };
2582
+ return { ...base, status: "offline" };
1342
2583
  case "connecting":
1343
2584
  case "handshaking":
1344
2585
  case "syncing":
1345
- return {
1346
- status: this.reconnecting ? "offline" : "syncing",
1347
- pendingOperations,
1348
- lastSyncedAt: this.lastSyncedAt
1349
- };
2586
+ return { ...base, status: this.reconnecting ? "offline" : "syncing" };
1350
2587
  case "streaming":
1351
- return {
1352
- status: pendingOperations > 0 ? "syncing" : "synced",
1353
- pendingOperations,
1354
- lastSyncedAt: this.lastSyncedAt
1355
- };
2588
+ return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
1356
2589
  case "error":
1357
- return { status: "error", pendingOperations, lastSyncedAt: this.lastSyncedAt };
2590
+ return { ...base, status: this.schemaBlocked ? "schema-mismatch" : "error" };
2591
+ }
2592
+ }
2593
+ /**
2594
+ * True when the server rejected the client's schema version at handshake.
2595
+ * Sync stays blocked until the app schema is upgraded or sync config changes.
2596
+ */
2597
+ isSchemaBlocked() {
2598
+ return this.schemaBlocked;
2599
+ }
2600
+ /**
2601
+ * Clears schema-mismatch block after upgrading the local schema / sync config.
2602
+ * Moves the engine back to `disconnected` so `start()` can run again.
2603
+ */
2604
+ clearSchemaBlock() {
2605
+ this.schemaBlocked = false;
2606
+ if (this.state === "error") {
2607
+ this.transitionTo("disconnected");
1358
2608
  }
1359
2609
  }
1360
2610
  /**
1361
- * Get the current internal state (for testing).
2611
+ * Record a merge conflict. Called by the merge-aware sync store
2612
+ * to increment the conflict counter for status reporting.
2613
+ */
2614
+ recordConflict() {
2615
+ this.conflictCount++;
2616
+ }
2617
+ /**
2618
+ * Count of local operations not yet covered by the last acked server vector (op-log source of truth).
2619
+ */
2620
+ async getUnsyncedOperationCount() {
2621
+ await this.refreshPendingCount();
2622
+ return this.getStatus().pendingOperations;
2623
+ }
2624
+ emitApplyFailure(op, result, overrides) {
2625
+ const reason = (0, import_core6.defaultApplyFailureReason)(result, overrides);
2626
+ this.emitter?.emit({
2627
+ type: "sync:apply-failed",
2628
+ operationId: op.id,
2629
+ collection: op.collection,
2630
+ recordId: op.recordId,
2631
+ code: reason.code,
2632
+ message: reason.message,
2633
+ retriable: reason.retriable
2634
+ });
2635
+ }
2636
+ /**
2637
+ * Force an immediate reconnection attempt. If the engine is disconnected
2638
+ * or in error state, restarts the sync. If already connected, no-op.
2639
+ */
2640
+ async retryNow() {
2641
+ if (this.schemaBlocked) return;
2642
+ if (this.state === "disconnected" || this.state === "error") {
2643
+ this.reconnecting = false;
2644
+ await this.start();
2645
+ }
2646
+ }
2647
+ /**
2648
+ * Export a diagnostics snapshot for debugging and support tickets.
2649
+ * Contains connection state, timing info, and queue metrics.
2650
+ */
2651
+ exportDiagnostics() {
2652
+ return {
2653
+ state: this.state,
2654
+ status: this.getStatus(),
2655
+ nodeId: this.store.getNodeId(),
2656
+ url: this.config.url,
2657
+ schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
2658
+ lastSyncedAt: this.lastSyncedAt,
2659
+ lastSuccessfulPush: this.lastSuccessfulPush,
2660
+ lastSuccessfulPull: this.lastSuccessfulPull,
2661
+ conflicts: this.conflictCount,
2662
+ pendingOperations: this.outboundQueue.totalPending,
2663
+ hasInFlightBatch: this.currentBatch !== null,
2664
+ reconnecting: this.reconnecting,
2665
+ timestamp: Date.now()
2666
+ };
2667
+ }
2668
+ /**
2669
+ * Get the current internal state (for testing).
2670
+ */
2671
+ getState() {
2672
+ return this.state;
2673
+ }
2674
+ /**
2675
+ * Get the outbound queue (for testing).
2676
+ */
2677
+ getOutboundQueue() {
2678
+ return this.outboundQueue;
2679
+ }
2680
+ /**
2681
+ * Update the sync scope map. Takes effect on the next connection attempt.
2682
+ *
2683
+ * When the scope changes (e.g., user switches organization), call this method
2684
+ * then reconnect. The new scope will be sent in the handshake, and the server
2685
+ * will send back data matching the new scope.
2686
+ *
2687
+ * Data that no longer matches the new scope is NOT deleted locally.
2688
+ * It simply stops being synced.
2689
+ *
2690
+ * @param scopeMap - New per-collection scope filters, or undefined to remove scope
2691
+ */
2692
+ updateScope(scopeMap) {
2693
+ this.activeScope = scopeMap;
2694
+ this.config.scopeMap = scopeMap;
2695
+ }
2696
+ /**
2697
+ * Get the currently active scope map. Returns undefined if no scope is configured.
1362
2698
  */
1363
- getState() {
1364
- return this.state;
2699
+ getActiveScope() {
2700
+ return this.activeScope;
1365
2701
  }
1366
2702
  /**
1367
- * Get the outbound queue (for testing).
2703
+ * Register a live query subset that narrows synced data for a collection.
2704
+ * Takes effect on the next connection; reconnects when already connected.
1368
2705
  */
1369
- getOutboundQueue() {
1370
- return this.outboundQueue;
2706
+ registerQuerySubset(subset) {
2707
+ const id = `query-${nextQuerySubsetId++}`;
2708
+ this.querySubsets.set(id, subset);
2709
+ this.scheduleQuerySubsetReconnect();
2710
+ return () => {
2711
+ this.querySubsets.delete(id);
2712
+ this.scheduleQuerySubsetReconnect();
2713
+ };
2714
+ }
2715
+ /**
2716
+ * Returns deduplicated active query subsets from registered subscriptions.
2717
+ */
2718
+ getActiveQuerySubsets() {
2719
+ return dedupeQuerySubsets([...this.querySubsets.values()]);
2720
+ }
2721
+ /**
2722
+ * Get the awareness manager for collaborative presence.
2723
+ * Use this to set local presence, observe remote collaborators,
2724
+ * and track cursor positions in richtext fields.
2725
+ */
2726
+ getAwarenessManager() {
2727
+ return this.awarenessManager;
2728
+ }
2729
+ /**
2730
+ * Optional side channel for incremental Yjs updates on large richtext fields.
2731
+ */
2732
+ getRichtextDocChannel() {
2733
+ return this.richtextDocChannel;
1371
2734
  }
1372
2735
  // --- Private methods ---
1373
- handleMessage(message) {
2736
+ messageChain = Promise.resolve();
2737
+ enqueueMessage(message) {
2738
+ this.messageChain = this.messageChain.then(() => this.handleMessageAsync(message)).catch((error) => this.handleMessageFailure(error));
2739
+ }
2740
+ async handleMessageAsync(message) {
1374
2741
  switch (message.type) {
1375
2742
  case "handshake-response":
1376
2743
  this.handleHandshakeResponse(message);
1377
2744
  break;
1378
2745
  case "operation-batch":
1379
- this.handleOperationBatch(message);
2746
+ await this.handleOperationBatch(message);
1380
2747
  break;
1381
2748
  case "acknowledgment":
1382
2749
  this.handleAcknowledgment(message);
@@ -1384,58 +2751,114 @@ var SyncEngine = class {
1384
2751
  case "error":
1385
2752
  this.handleError(message);
1386
2753
  break;
2754
+ case "awareness-update":
2755
+ this.handleAwarenessUpdate(message);
2756
+ break;
2757
+ case "yjs-doc-update":
2758
+ this.richtextDocChannel.deliver(message);
2759
+ break;
1387
2760
  }
1388
2761
  }
2762
+ handleMessageFailure(error) {
2763
+ const reason = error instanceof Error ? error.message : "Message handling failed";
2764
+ this.handleTransportClose(reason);
2765
+ }
1389
2766
  handleHandshakeResponse(msg) {
1390
2767
  if (this.state !== "handshaking") return;
1391
2768
  if (!msg.accepted) {
2769
+ const reason = msg.rejectReason ?? "Handshake rejected";
2770
+ if (isSchemaMismatchReject(msg.rejectReason)) {
2771
+ this.schemaBlocked = true;
2772
+ const supportedMin = msg.supportedSchemaMin ?? msg.schemaVersion;
2773
+ const supportedMax = msg.supportedSchemaMax ?? msg.schemaVersion;
2774
+ this.emitter?.emit({
2775
+ type: "sync:schema-mismatch",
2776
+ clientSchemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
2777
+ serverSchemaVersion: msg.schemaVersion,
2778
+ supportedMin,
2779
+ supportedMax,
2780
+ reason
2781
+ });
2782
+ this.metricsCollector.updateStatus("error");
2783
+ this.transitionTo("error");
2784
+ void this.transport.disconnect();
2785
+ return;
2786
+ }
1392
2787
  this.transitionTo("error");
1393
2788
  this.emitter?.emit({
1394
2789
  type: "sync:disconnected",
1395
- reason: msg.rejectReason ?? "Handshake rejected"
2790
+ reason
1396
2791
  });
1397
2792
  this.transitionTo("disconnected");
1398
2793
  return;
1399
2794
  }
1400
2795
  this.remoteVector = wireToVersionVector(msg.versionVector);
2796
+ void this.persistLastAckedServerVector(this.remoteVector);
1401
2797
  if (msg.selectedWireFormat) {
1402
2798
  this.setSerializerWireFormat(msg.selectedWireFormat);
1403
2799
  }
2800
+ if (msg.acceptedScope) {
2801
+ this.activeScope = msg.acceptedScope;
2802
+ }
1404
2803
  this.emitter?.emit({ type: "sync:connected", nodeId: this.store.getNodeId() });
2804
+ this.metricsCollector.recordConnected();
2805
+ this.metricsCollector.updateStatus("syncing");
2806
+ this.metricsCollector.recordSyncStarted();
1405
2807
  this.transitionTo("syncing");
1406
2808
  this.deltaBatchesReceived = 0;
1407
2809
  this.deltaReceiveComplete = false;
1408
2810
  this.deltaSendComplete = false;
2811
+ this.deltaSentOpIds = [];
2812
+ this.pendingDeltaBatchAcks.clear();
2813
+ this.initialSyncTotalBatches = 0;
1409
2814
  this.sendDelta();
1410
2815
  }
1411
2816
  async sendDelta() {
1412
2817
  const localVector = this.store.getVersionVector();
1413
- const missingOps = await this.collectDelta(localVector, this.remoteVector);
2818
+ const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
2819
+ const missingOps = allMissingOps.filter((op) => this.operationAllowedForSync(op));
2820
+ this.deltaSentOpIds = missingOps.map((op) => op.id);
1414
2821
  if (missingOps.length === 0) {
2822
+ const messageId = generateMessageId();
2823
+ if (this.config.strictHandshake) {
2824
+ this.pendingDeltaBatchAcks.add(messageId);
2825
+ }
1415
2826
  const emptyBatch = {
1416
2827
  type: "operation-batch",
1417
- messageId: generateMessageId(),
2828
+ messageId,
1418
2829
  operations: [],
1419
2830
  isFinal: true,
1420
- batchIndex: 0
2831
+ batchIndex: 0,
2832
+ totalBatches: 1
1421
2833
  };
1422
2834
  this.transport.send(emptyBatch);
1423
- this.deltaSendComplete = true;
1424
- this.checkDeltaComplete();
2835
+ if (!this.config.strictHandshake) {
2836
+ this.deltaSendComplete = true;
2837
+ void this.checkDeltaComplete();
2838
+ }
1425
2839
  return;
1426
2840
  }
1427
2841
  const sorted = (0, import_internal2.topologicalSort)(missingOps);
1428
2842
  const totalBatches = Math.ceil(sorted.length / this.batchSize);
2843
+ this.initialSyncTotalBatches = Math.max(this.initialSyncTotalBatches, totalBatches);
1429
2844
  for (let i = 0; i < totalBatches; i++) {
1430
2845
  const start = i * this.batchSize;
1431
2846
  const batchOps = sorted.slice(start, start + this.batchSize);
1432
- const serializedOps = batchOps.map((op) => this.serializer.encodeOperation(op));
2847
+ const batchCursor = createDeltaCursorFromBatch(batchOps, i);
2848
+ const opsToSerialize = this.encryptor ? await this.encryptor.encryptBatch(batchOps) : batchOps;
2849
+ const serializedOps = opsToSerialize.map((op) => this.serializer.encodeOperation(op));
2850
+ const messageId = generateMessageId();
2851
+ if (this.config.strictHandshake) {
2852
+ this.pendingDeltaBatchAcks.add(messageId);
2853
+ }
1433
2854
  const batchMsg = {
1434
2855
  type: "operation-batch",
1435
- messageId: generateMessageId(),
2856
+ messageId,
1436
2857
  operations: serializedOps,
1437
2858
  isFinal: i === totalBatches - 1,
1438
- batchIndex: i
2859
+ batchIndex: i,
2860
+ totalBatches,
2861
+ ...batchCursor ? { cursor: encodeDeltaCursor(batchCursor) } : {}
1439
2862
  };
1440
2863
  this.transport.send(batchMsg);
1441
2864
  this.emitter?.emit({
@@ -1444,8 +2867,17 @@ var SyncEngine = class {
1444
2867
  batchSize: batchOps.length
1445
2868
  });
1446
2869
  }
2870
+ if (!this.config.strictHandshake) {
2871
+ this.deltaSendComplete = true;
2872
+ void this.checkDeltaComplete();
2873
+ }
2874
+ }
2875
+ markDeltaSendCompleteIfReady() {
2876
+ if (this.config.strictHandshake && this.pendingDeltaBatchAcks.size > 0) {
2877
+ return;
2878
+ }
1447
2879
  this.deltaSendComplete = true;
1448
- this.checkDeltaComplete();
2880
+ void this.checkDeltaComplete();
1449
2881
  }
1450
2882
  async collectDelta(localVector, remoteVector) {
1451
2883
  const missing = [];
@@ -1459,15 +2891,45 @@ var SyncEngine = class {
1459
2891
  return missing;
1460
2892
  }
1461
2893
  async handleOperationBatch(msg) {
1462
- const operations = msg.operations.map((s) => this.serializer.decodeOperation(s));
1463
- for (const op of operations) {
1464
- await this.store.applyRemoteOperation(op);
2894
+ const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
2895
+ const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
2896
+ const inScopeOps = operations.filter((op) => this.operationAllowedForSync(op));
2897
+ const targetSchemaVersion = this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
2898
+ const transforms = this.config.operationTransforms ?? [];
2899
+ for (const op of inScopeOps) {
2900
+ const transformed = transforms.length > 0 ? (0, import_core6.applyOperationTransforms)(op, targetSchemaVersion, transforms) : op;
2901
+ if (transformed === null) {
2902
+ continue;
2903
+ }
2904
+ try {
2905
+ const result = await this.store.applyRemoteOperation(transformed);
2906
+ if (result === "skipped" || result === "rejected" || result === "deferred") {
2907
+ this.emitApplyFailure(transformed, result);
2908
+ }
2909
+ } catch (error) {
2910
+ if (error instanceof import_core6.ClockDriftError) {
2911
+ this.emitApplyFailure(transformed, "rejected", {
2912
+ code: import_core6.APPLY_FAILURE_CODES.CLOCK_DRIFT,
2913
+ message: error.message,
2914
+ retriable: false
2915
+ });
2916
+ continue;
2917
+ }
2918
+ const message = error instanceof Error ? error.message : "Apply failed";
2919
+ const code = error instanceof import_core6.SyncError ? error.code : error instanceof import_core6.KoraError ? error.code : error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : import_core6.APPLY_FAILURE_CODES.APPLY_FAILED;
2920
+ this.emitApplyFailure(transformed, "rejected", {
2921
+ code,
2922
+ message,
2923
+ retriable: code !== import_core6.APPLY_FAILURE_CODES.REFERENTIAL_INTEGRITY
2924
+ });
2925
+ }
1465
2926
  }
1466
- if (operations.length > 0) {
2927
+ if (inScopeOps.length > 0) {
2928
+ this.lastSuccessfulPull = Date.now();
1467
2929
  this.emitter?.emit({
1468
2930
  type: "sync:received",
1469
- operations,
1470
- batchSize: operations.length
2931
+ operations: inScopeOps,
2932
+ batchSize: inScopeOps.length
1471
2933
  });
1472
2934
  }
1473
2935
  const lastOp = operations[operations.length - 1];
@@ -1484,18 +2946,43 @@ var SyncEngine = class {
1484
2946
  });
1485
2947
  if (this.state === "syncing") {
1486
2948
  this.deltaBatchesReceived++;
2949
+ const totalBatches = msg.totalBatches ?? this.initialSyncTotalBatches;
2950
+ if (msg.totalBatches !== void 0) {
2951
+ this.initialSyncTotalBatches = msg.totalBatches;
2952
+ }
2953
+ this.metricsCollector.updateInitialSyncProgress(this.deltaBatchesReceived, totalBatches);
2954
+ const cursorFromBatch = msg.cursor !== void 0 ? decodeDeltaCursor(msg.cursor) : createDeltaCursorFromBatch(
2955
+ inScopeOps.length > 0 ? inScopeOps : operations,
2956
+ msg.batchIndex
2957
+ );
2958
+ if (cursorFromBatch) {
2959
+ this.resumeDeltaCursor = cursorFromBatch;
2960
+ await this.persistDeltaCursor(cursorFromBatch);
2961
+ }
1487
2962
  if (msg.isFinal) {
1488
2963
  this.deltaReceiveComplete = true;
1489
- this.checkDeltaComplete();
2964
+ this.resumeDeltaCursor = null;
2965
+ await this.persistDeltaCursor(null);
2966
+ this.metricsCollector.recordSyncCompleted();
2967
+ void this.checkDeltaComplete();
1490
2968
  }
1491
2969
  }
1492
2970
  }
1493
2971
  handleAcknowledgment(msg) {
2972
+ if (this.state === "syncing" && this.config.strictHandshake) {
2973
+ this.pendingDeltaBatchAcks.delete(msg.acknowledgedMessageId);
2974
+ this.markDeltaSendCompleteIfReady();
2975
+ }
1494
2976
  if (this.currentBatch) {
1495
2977
  this.outboundQueue.acknowledge(this.currentBatch.batchId);
1496
2978
  this.currentBatch = null;
1497
- this.lastSyncedAt = Date.now();
2979
+ const now = Date.now();
2980
+ this.lastSyncedAt = now;
2981
+ this.lastSuccessfulPush = now;
1498
2982
  }
2983
+ void this.advanceLastAckedForLocalNode(msg.lastSequenceNumber).then(
2984
+ () => this.refreshPendingCount()
2985
+ );
1499
2986
  if (this.state === "streaming" && this.outboundQueue.hasOperations) {
1500
2987
  this.flushQueue();
1501
2988
  }
@@ -1508,13 +2995,107 @@ var SyncEngine = class {
1508
2995
  this.emitter?.emit({ type: "sync:disconnected", reason: msg.message });
1509
2996
  this.transitionTo("disconnected");
1510
2997
  }
1511
- checkDeltaComplete() {
1512
- if (this.deltaSendComplete && this.deltaReceiveComplete) {
1513
- this.lastSyncedAt = Date.now();
1514
- this.transitionTo("streaming");
1515
- if (this.outboundQueue.hasOperations) {
1516
- this.flushQueue();
2998
+ async checkDeltaComplete() {
2999
+ if (!this.deltaSendComplete || !this.deltaReceiveComplete) {
3000
+ return;
3001
+ }
3002
+ if (this.state !== "syncing") {
3003
+ return;
3004
+ }
3005
+ this.lastSyncedAt = Date.now();
3006
+ this.transitionTo("streaming");
3007
+ this.awarenessManager.startCleanupTimer();
3008
+ const localState = this.awarenessManager.getLocalState();
3009
+ if (localState) {
3010
+ this.awarenessManager.setLocalState(localState);
3011
+ }
3012
+ if (this.deltaSentOpIds.length > 0) {
3013
+ await this.outboundQueue.removeByIds(this.deltaSentOpIds);
3014
+ this.deltaSentOpIds = [];
3015
+ }
3016
+ if (this.syncState) {
3017
+ const localNodeId = this.store.getNodeId();
3018
+ const localSeq = this.store.getVersionVector().get(localNodeId) ?? 0;
3019
+ if (localSeq > 0) {
3020
+ await this.advanceLastAckedForLocalNode(localSeq);
3021
+ }
3022
+ }
3023
+ if (this.outboundQueue.hasOperations) {
3024
+ this.flushQueue();
3025
+ }
3026
+ await this.refreshPendingCount();
3027
+ }
3028
+ /**
3029
+ * Effective server vector: persisted last-ack merged with live handshake remote vector.
3030
+ */
3031
+ getEffectiveServerVector() {
3032
+ if (!this.syncState) {
3033
+ return this.remoteVector;
3034
+ }
3035
+ return this.syncState.mergeServerVectors(this.lastAckedServerVector, this.remoteVector);
3036
+ }
3037
+ async persistLastAckedServerVector(vector) {
3038
+ if (!this.syncState) {
3039
+ return;
3040
+ }
3041
+ this.lastAckedServerVector = this.syncState.mergeServerVectors(
3042
+ this.lastAckedServerVector,
3043
+ vector
3044
+ );
3045
+ await this.syncState.saveLastAckedServerVector(this.lastAckedServerVector);
3046
+ }
3047
+ async advanceLastAckedForLocalNode(lastSequenceNumber) {
3048
+ if (!this.syncState || lastSequenceNumber <= 0) {
3049
+ return;
3050
+ }
3051
+ const nodeId = this.store.getNodeId();
3052
+ const merged = new Map(this.lastAckedServerVector);
3053
+ merged.set(nodeId, Math.max(merged.get(nodeId) ?? 0, lastSequenceNumber));
3054
+ this.lastAckedServerVector = merged;
3055
+ await this.syncState.saveLastAckedServerVector(merged);
3056
+ }
3057
+ /**
3058
+ * Refresh cached unsynced count from op log vs effective server vector.
3059
+ */
3060
+ async refreshPendingCount() {
3061
+ if (!this.syncState) {
3062
+ this.cachedUnsyncedCount = this.outboundQueue.totalPending;
3063
+ return;
3064
+ }
3065
+ this.cachedUnsyncedCount = await this.syncState.countUnsyncedOperations(
3066
+ this.getEffectiveServerVector()
3067
+ );
3068
+ }
3069
+ /**
3070
+ * Returns operations the server has not yet acknowledged (op-log source of truth).
3071
+ */
3072
+ async getPendingSyncOperations() {
3073
+ if (!this.syncState) {
3074
+ return this.outboundQueue.peek(Number.MAX_SAFE_INTEGER);
3075
+ }
3076
+ return this.syncState.getUnsyncedOperations(this.getEffectiveServerVector());
3077
+ }
3078
+ /**
3079
+ * Hydrate the outbound queue from unsynced ops in the op log (deduped by operation id).
3080
+ */
3081
+ async reconcileOutboundFromOpLog() {
3082
+ if (this.syncState) {
3083
+ const unsynced = await this.syncState.getUnsyncedOperations(this.getEffectiveServerVector());
3084
+ for (const op of unsynced) {
3085
+ await this.outboundQueue.enqueue(op);
1517
3086
  }
3087
+ return;
3088
+ }
3089
+ const localNodeId = this.store.getNodeId();
3090
+ const localVector = this.store.getVersionVector();
3091
+ const localSeq = localVector.get(localNodeId) ?? 0;
3092
+ if (localSeq === 0) {
3093
+ return;
3094
+ }
3095
+ const ops = await this.store.getOperationRange(localNodeId, 1, localSeq);
3096
+ const inScope = ops.filter((op) => this.operationAllowedForSync(op));
3097
+ for (const op of inScope) {
3098
+ await this.outboundQueue.enqueue(op);
1518
3099
  }
1519
3100
  }
1520
3101
  flushQueue() {
@@ -1523,26 +3104,66 @@ var SyncEngine = class {
1523
3104
  const batch = this.outboundQueue.takeBatch(this.batchSize);
1524
3105
  if (!batch) return;
1525
3106
  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
3107
+ if (this.encryptor) {
3108
+ this.encryptor.encryptBatch(batch.operations).then(
3109
+ (encrypted) => {
3110
+ const serializedOps = encrypted.map((op) => this.serializer.encodeOperation(op));
3111
+ const batchMsg = {
3112
+ type: "operation-batch",
3113
+ messageId: generateMessageId(),
3114
+ operations: serializedOps,
3115
+ isFinal: true,
3116
+ batchIndex: 0
3117
+ };
3118
+ this.transport.send(batchMsg);
3119
+ this.emitter?.emit({
3120
+ type: "sync:sent",
3121
+ operations: batch.operations,
3122
+ batchSize: batch.operations.length
3123
+ });
3124
+ },
3125
+ (err) => {
3126
+ this.outboundQueue.returnBatch(batch.batchId);
3127
+ this.currentBatch = null;
3128
+ this.emitter?.emit({
3129
+ type: "sync:disconnected",
3130
+ reason: err instanceof Error ? err.message : "Encryption failed"
3131
+ });
3132
+ }
3133
+ );
3134
+ } else {
3135
+ const serializedOps = batch.operations.map((op) => this.serializer.encodeOperation(op));
3136
+ const batchMsg = {
3137
+ type: "operation-batch",
3138
+ messageId: generateMessageId(),
3139
+ operations: serializedOps,
3140
+ isFinal: true,
3141
+ batchIndex: 0
3142
+ };
3143
+ this.transport.send(batchMsg);
3144
+ this.emitter?.emit({
3145
+ type: "sync:sent",
3146
+ operations: batch.operations,
3147
+ batchSize: batch.operations.length
3148
+ });
3149
+ }
3150
+ }
3151
+ handleAwarenessUpdate(msg) {
3152
+ const awarenessMessage = {
3153
+ type: "awareness",
3154
+ clientId: msg.clientId,
3155
+ states: wireToAwarenessStates(msg.states)
1533
3156
  };
1534
- this.transport.send(batchMsg);
1535
- this.emitter?.emit({
1536
- type: "sync:sent",
1537
- operations: batch.operations,
1538
- batchSize: batch.operations.length
1539
- });
3157
+ this.awarenessManager.handleRemoteMessage(awarenessMessage);
1540
3158
  }
1541
3159
  handleTransportClose(reason) {
1542
3160
  if (this.currentBatch) {
1543
3161
  this.outboundQueue.returnBatch(this.currentBatch.batchId);
1544
3162
  this.currentBatch = null;
1545
3163
  }
3164
+ if (this.schemaBlocked) {
3165
+ return;
3166
+ }
1546
3167
  if (this.state !== "disconnected") {
1547
3168
  this.emitter?.emit({ type: "sync:disconnected", reason });
1548
3169
  this.transitionTo("disconnected");
@@ -1558,7 +3179,7 @@ var SyncEngine = class {
1558
3179
  transitionTo(newState) {
1559
3180
  const validTargets = VALID_TRANSITIONS[this.state];
1560
3181
  if (!validTargets.includes(newState)) {
1561
- throw new import_core4.SyncError(`Invalid sync state transition: ${this.state} \u2192 ${newState}`, {
3182
+ throw new import_core6.SyncError(`Invalid sync state transition: ${this.state} \u2192 ${newState}`, {
1562
3183
  from: this.state,
1563
3184
  to: newState
1564
3185
  });
@@ -1570,7 +3191,68 @@ var SyncEngine = class {
1570
3191
  this.serializer.setWireFormat(format);
1571
3192
  }
1572
3193
  }
3194
+ operationAllowedForSync(op) {
3195
+ if (!operationMatchesScope(op, this.activeScope)) {
3196
+ return false;
3197
+ }
3198
+ return operationMatchesQuerySubsets(op, this.getActiveQuerySubsets());
3199
+ }
3200
+ async persistDeltaCursor(cursor) {
3201
+ if (!this.syncState?.saveDeltaCursor) {
3202
+ return;
3203
+ }
3204
+ await this.syncState.saveDeltaCursor(cursor);
3205
+ }
3206
+ scheduleQuerySubsetReconnect() {
3207
+ if (this.querySubsetReconnectTimer) {
3208
+ clearTimeout(this.querySubsetReconnectTimer);
3209
+ }
3210
+ this.querySubsetReconnectTimer = setTimeout(() => {
3211
+ this.querySubsetReconnectTimer = null;
3212
+ if (this.state === "streaming" || this.state === "syncing" || this.state === "handshaking") {
3213
+ void this.reconnectForQuerySubsets();
3214
+ }
3215
+ }, 500);
3216
+ }
3217
+ async reconnectForQuerySubsets() {
3218
+ await this.stop();
3219
+ await this.start();
3220
+ }
1573
3221
  };
3222
+ function awarenessStatesToWire(states) {
3223
+ const wire = {};
3224
+ for (const [clientId, state] of Object.entries(states)) {
3225
+ if (state === null) {
3226
+ wire[clientId] = null;
3227
+ } else {
3228
+ const wireState = {
3229
+ user: { ...state.user }
3230
+ };
3231
+ if (state.cursor) {
3232
+ wireState.cursor = { ...state.cursor };
3233
+ }
3234
+ wire[clientId] = wireState;
3235
+ }
3236
+ }
3237
+ return wire;
3238
+ }
3239
+ function wireToAwarenessStates(wire) {
3240
+ const states = {};
3241
+ for (const [clientId, wireState] of Object.entries(wire)) {
3242
+ if (wireState === null) {
3243
+ states[Number(clientId)] = null;
3244
+ } else {
3245
+ const state = {
3246
+ user: { ...wireState.user }
3247
+ };
3248
+ if (wireState.cursor) {
3249
+ state.cursor = { ...wireState.cursor };
3250
+ }
3251
+ states[Number(clientId)] = state;
3252
+ }
3253
+ }
3254
+ return states;
3255
+ }
1574
3256
 
1575
3257
  // src/engine/connection-monitor.ts
1576
3258
  var ConnectionMonitor = class {
@@ -1709,6 +3391,20 @@ var ReconnectionManager = class {
1709
3391
  this.running = false;
1710
3392
  }
1711
3393
  }
3394
+ /**
3395
+ * Cancel the current backoff wait and proceed to the next reconnect attempt immediately.
3396
+ * No-op if not waiting.
3397
+ */
3398
+ wake() {
3399
+ if (this.timer !== null) {
3400
+ clearTimeout(this.timer);
3401
+ this.timer = null;
3402
+ }
3403
+ if (this.waitResolve) {
3404
+ this.waitResolve();
3405
+ this.waitResolve = null;
3406
+ }
3407
+ }
1712
3408
  /**
1713
3409
  * Stop any pending reconnection attempt.
1714
3410
  */
@@ -1763,26 +3459,481 @@ var ReconnectionManager = class {
1763
3459
  });
1764
3460
  }
1765
3461
  };
3462
+
3463
+ // src/encryption/sync-encryptor.ts
3464
+ var import_core8 = require("@korajs/core");
3465
+
3466
+ // src/encryption/key-derivation.ts
3467
+ var import_core7 = require("@korajs/core");
3468
+ var KeyDerivationError = class extends import_core7.SyncError {
3469
+ constructor(message, context) {
3470
+ super(message, { ...context, errorType: "KEY_DERIVATION_ERROR" });
3471
+ this.name = "KeyDerivationError";
3472
+ }
3473
+ };
3474
+ var SALT_LENGTH = 32;
3475
+ var PBKDF2_ITERATIONS = 6e5;
3476
+ var DERIVED_KEY_LENGTH = 256;
3477
+ function assertCryptoAvailable() {
3478
+ if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
3479
+ throw new KeyDerivationError(
3480
+ "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+."
3481
+ );
3482
+ }
3483
+ }
3484
+ function generateSalt() {
3485
+ if (typeof globalThis.crypto === "undefined") {
3486
+ throw new KeyDerivationError(
3487
+ "Web Crypto API (crypto) is not available. Cannot generate random salt."
3488
+ );
3489
+ }
3490
+ return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
3491
+ }
3492
+ async function deriveKey(passphrase, salt) {
3493
+ assertCryptoAvailable();
3494
+ if (passphrase.length === 0) {
3495
+ throw new KeyDerivationError(
3496
+ "Passphrase must not be empty. Provide a non-empty string for encryption key derivation."
3497
+ );
3498
+ }
3499
+ const usedSalt = salt ?? generateSalt();
3500
+ try {
3501
+ const passphraseBytes = new TextEncoder().encode(passphrase);
3502
+ const baseKey = await globalThis.crypto.subtle.importKey(
3503
+ "raw",
3504
+ passphraseBytes,
3505
+ "PBKDF2",
3506
+ false,
3507
+ ["deriveBits", "deriveKey"]
3508
+ );
3509
+ const derivedKey = await globalThis.crypto.subtle.deriveKey(
3510
+ {
3511
+ name: "PBKDF2",
3512
+ salt: usedSalt,
3513
+ iterations: PBKDF2_ITERATIONS,
3514
+ hash: "SHA-256"
3515
+ },
3516
+ baseKey,
3517
+ { name: "AES-GCM", length: DERIVED_KEY_LENGTH },
3518
+ // extractable: true so the derived key can be exported for testing/debugging
3519
+ true,
3520
+ ["encrypt", "decrypt"]
3521
+ );
3522
+ return { key: derivedKey, salt: usedSalt };
3523
+ } catch (cause) {
3524
+ if (cause instanceof KeyDerivationError) {
3525
+ throw cause;
3526
+ }
3527
+ throw new KeyDerivationError(
3528
+ "Failed to derive encryption key from passphrase using PBKDF2. Ensure the runtime supports PBKDF2 with SHA-256.",
3529
+ { cause: cause instanceof Error ? cause.message : String(cause) }
3530
+ );
3531
+ }
3532
+ }
3533
+ async function deriveVersionedKey(passphrase, version, salt) {
3534
+ if (!Number.isInteger(version) || version < 1) {
3535
+ throw new KeyDerivationError(`Key version must be a positive integer, received: ${version}`, {
3536
+ version
3537
+ });
3538
+ }
3539
+ const { key, salt: usedSalt } = await deriveKey(passphrase, salt);
3540
+ return { version, key, salt: usedSalt };
3541
+ }
3542
+
3543
+ // src/encryption/sync-encryptor.ts
3544
+ var EncryptionError = class extends import_core8.SyncError {
3545
+ constructor(message, context) {
3546
+ super(message, { ...context, errorType: "ENCRYPTION_ERROR" });
3547
+ this.name = "EncryptionError";
3548
+ }
3549
+ };
3550
+ var DecryptionError = class extends import_core8.SyncError {
3551
+ constructor(message, context) {
3552
+ super(message, { ...context, errorType: "DECRYPTION_ERROR" });
3553
+ this.name = "DecryptionError";
3554
+ }
3555
+ };
3556
+ var IV_LENGTH = 12;
3557
+ var ENCRYPTED_MARKER = "__kora_e2e_encrypted";
3558
+ var SyncEncryptor = class _SyncEncryptor {
3559
+ /**
3560
+ * Map of key version -> VersionedKey. The current (latest) version is used
3561
+ * for encryption. All versions are available for decryption (key rotation).
3562
+ */
3563
+ keys;
3564
+ /** The current key version used for encryption. */
3565
+ currentVersion;
3566
+ constructor(keys, currentVersion) {
3567
+ this.keys = keys;
3568
+ this.currentVersion = currentVersion;
3569
+ }
3570
+ /**
3571
+ * Creates a SyncEncryptor from a {@link SyncEncryptionConfig}.
3572
+ *
3573
+ * Derives the encryption key from the passphrase using PBKDF2. The key
3574
+ * derivation is async because it uses the Web Crypto API.
3575
+ *
3576
+ * @param config - Encryption configuration with passphrase
3577
+ * @param salt - Optional salt for deterministic key derivation (mainly for testing).
3578
+ * If omitted, a random salt is generated.
3579
+ * @returns A configured SyncEncryptor instance
3580
+ * @throws {EncryptionError} If configuration is invalid
3581
+ * @throws {KeyDerivationError} If key derivation fails
3582
+ */
3583
+ static async create(config, salt) {
3584
+ if (!config.enabled) {
3585
+ throw new EncryptionError(
3586
+ "Cannot create SyncEncryptor with encryption disabled. Set enabled: true in the encryption config."
3587
+ );
3588
+ }
3589
+ const passphrase = typeof config.key === "function" ? await config.key() : config.key;
3590
+ if (passphrase.length === 0) {
3591
+ throw new EncryptionError(
3592
+ "Encryption key/passphrase must not be empty. Provide a non-empty string or key provider function."
3593
+ );
3594
+ }
3595
+ const versionedKey = await deriveVersionedKey(passphrase, 1, salt);
3596
+ const keys = /* @__PURE__ */ new Map();
3597
+ keys.set(1, versionedKey);
3598
+ return new _SyncEncryptor(keys, 1);
3599
+ }
3600
+ /**
3601
+ * Creates a SyncEncryptor from pre-derived versioned keys.
3602
+ *
3603
+ * Use this when you need to support multiple key versions for key rotation,
3604
+ * or when you have already derived the keys externally.
3605
+ *
3606
+ * @param versionedKeys - Array of versioned keys. The highest version is used for encryption.
3607
+ * @returns A configured SyncEncryptor instance
3608
+ * @throws {EncryptionError} If no keys are provided
3609
+ */
3610
+ static fromKeys(versionedKeys) {
3611
+ if (versionedKeys.length === 0) {
3612
+ throw new EncryptionError("At least one versioned key must be provided.");
3613
+ }
3614
+ const keys = /* @__PURE__ */ new Map();
3615
+ let maxVersion = 0;
3616
+ for (const vk of versionedKeys) {
3617
+ keys.set(vk.version, vk);
3618
+ if (vk.version > maxVersion) {
3619
+ maxVersion = vk.version;
3620
+ }
3621
+ }
3622
+ return new _SyncEncryptor(keys, maxVersion);
3623
+ }
3624
+ /**
3625
+ * Add a new key version for key rotation.
3626
+ *
3627
+ * After adding, the new key becomes the current version used for encryption
3628
+ * if its version number is higher than the current version. Previously-versioned
3629
+ * keys remain available for decrypting older operations.
3630
+ *
3631
+ * @param key - The new versioned key to add
3632
+ * @throws {EncryptionError} If the key version already exists
3633
+ */
3634
+ addKey(key) {
3635
+ if (this.keys.has(key.version)) {
3636
+ throw new EncryptionError(
3637
+ `Key version ${key.version} already exists. Use a higher version number for rotation.`,
3638
+ { existingVersion: key.version }
3639
+ );
3640
+ }
3641
+ this.keys.set(key.version, key);
3642
+ if (key.version > this.currentVersion) {
3643
+ this.currentVersion = key.version;
3644
+ }
3645
+ }
3646
+ /**
3647
+ * Get the current encryption key version number.
3648
+ */
3649
+ getCurrentKeyVersion() {
3650
+ return this.currentVersion;
3651
+ }
3652
+ /**
3653
+ * Encrypt an operation's `data` and `previousData` fields.
3654
+ *
3655
+ * Returns a new Operation with encrypted field values. The original
3656
+ * operation is not mutated (operations are immutable).
3657
+ *
3658
+ * Fields that are null (e.g., delete operations) remain null.
3659
+ *
3660
+ * @param operation - The operation to encrypt
3661
+ * @returns A new operation with encrypted data fields
3662
+ * @throws {EncryptionError} If encryption fails
3663
+ */
3664
+ async encryptOperation(operation) {
3665
+ const [encryptedData, encryptedPreviousData] = await Promise.all([
3666
+ this.encryptField(operation.data, operation.id, "data"),
3667
+ this.encryptField(operation.previousData, operation.id, "previousData")
3668
+ ]);
3669
+ return {
3670
+ ...operation,
3671
+ data: encryptedData,
3672
+ previousData: encryptedPreviousData
3673
+ };
3674
+ }
3675
+ /**
3676
+ * Decrypt an operation's `data` and `previousData` fields.
3677
+ *
3678
+ * Returns a new Operation with the original field values restored.
3679
+ * The original operation is not mutated.
3680
+ *
3681
+ * If a field is null or not encrypted (no marker), it passes through unchanged.
3682
+ * This enables mixed plaintext/encrypted operations during migration.
3683
+ *
3684
+ * @param operation - The operation to decrypt
3685
+ * @returns A new operation with decrypted data fields
3686
+ * @throws {DecryptionError} If decryption fails (wrong key, tampered data, unsupported version)
3687
+ */
3688
+ async decryptOperation(operation) {
3689
+ const [decryptedData, decryptedPreviousData] = await Promise.all([
3690
+ this.decryptField(operation.data, operation.id, "data"),
3691
+ this.decryptField(operation.previousData, operation.id, "previousData")
3692
+ ]);
3693
+ return {
3694
+ ...operation,
3695
+ data: decryptedData,
3696
+ previousData: decryptedPreviousData
3697
+ };
3698
+ }
3699
+ /**
3700
+ * Encrypt a serialized operation's `data` and `previousData` fields.
3701
+ *
3702
+ * Same as {@link encryptOperation} but works with the wire-format
3703
+ * {@link SerializedOperation} type used by the serializer.
3704
+ *
3705
+ * @param serialized - The serialized operation to encrypt
3706
+ * @returns A new serialized operation with encrypted data fields
3707
+ * @throws {EncryptionError} If encryption fails
3708
+ */
3709
+ async encryptSerializedOperation(serialized) {
3710
+ const [encryptedData, encryptedPreviousData] = await Promise.all([
3711
+ this.encryptField(serialized.data, serialized.id, "data"),
3712
+ this.encryptField(serialized.previousData, serialized.id, "previousData")
3713
+ ]);
3714
+ return {
3715
+ ...serialized,
3716
+ data: encryptedData,
3717
+ previousData: encryptedPreviousData
3718
+ };
3719
+ }
3720
+ /**
3721
+ * Decrypt a serialized operation's `data` and `previousData` fields.
3722
+ *
3723
+ * Same as {@link decryptOperation} but works with the wire-format
3724
+ * {@link SerializedOperation} type used by the serializer.
3725
+ *
3726
+ * @param serialized - The serialized operation to decrypt
3727
+ * @returns A new serialized operation with decrypted data fields
3728
+ * @throws {DecryptionError} If decryption fails
3729
+ */
3730
+ async decryptSerializedOperation(serialized) {
3731
+ const [decryptedData, decryptedPreviousData] = await Promise.all([
3732
+ this.decryptField(serialized.data, serialized.id, "data"),
3733
+ this.decryptField(serialized.previousData, serialized.id, "previousData")
3734
+ ]);
3735
+ return {
3736
+ ...serialized,
3737
+ data: decryptedData,
3738
+ previousData: decryptedPreviousData
3739
+ };
3740
+ }
3741
+ /**
3742
+ * Encrypt a batch of operations in parallel.
3743
+ *
3744
+ * @param operations - Operations to encrypt
3745
+ * @returns New operations with encrypted data fields
3746
+ */
3747
+ async encryptBatch(operations) {
3748
+ return Promise.all(operations.map((op) => this.encryptOperation(op)));
3749
+ }
3750
+ /**
3751
+ * Decrypt a batch of operations in parallel.
3752
+ *
3753
+ * @param operations - Operations to decrypt
3754
+ * @returns New operations with decrypted data fields
3755
+ */
3756
+ async decryptBatch(operations) {
3757
+ return Promise.all(operations.map((op) => this.decryptOperation(op)));
3758
+ }
3759
+ /**
3760
+ * Check if a field value contains an encrypted payload.
3761
+ *
3762
+ * @param field - An operation's `data` or `previousData` field
3763
+ * @returns true if the field contains an encrypted payload
3764
+ */
3765
+ static isEncryptedPayload(field) {
3766
+ return isEncryptedPayload(field);
3767
+ }
3768
+ // --- Private helpers ---
3769
+ async encryptField(field, operationId, fieldName) {
3770
+ if (field === null) {
3771
+ return null;
3772
+ }
3773
+ const currentKey = this.keys.get(this.currentVersion);
3774
+ if (!currentKey) {
3775
+ throw new EncryptionError(
3776
+ `Current encryption key version ${this.currentVersion} not found.`,
3777
+ { operationId, fieldName }
3778
+ );
3779
+ }
3780
+ try {
3781
+ const plaintext = new TextEncoder().encode(JSON.stringify(field));
3782
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
3783
+ const ciphertextBuffer = await globalThis.crypto.subtle.encrypt(
3784
+ { name: "AES-GCM", iv },
3785
+ currentKey.key,
3786
+ plaintext
3787
+ );
3788
+ const payload = {
3789
+ v: this.currentVersion,
3790
+ iv: toBase642(iv),
3791
+ ct: toBase642(new Uint8Array(ciphertextBuffer)),
3792
+ alg: "aes-256-gcm"
3793
+ };
3794
+ return {
3795
+ [ENCRYPTED_MARKER]: true,
3796
+ ...payload
3797
+ };
3798
+ } catch (cause) {
3799
+ if (cause instanceof EncryptionError) {
3800
+ throw cause;
3801
+ }
3802
+ throw new EncryptionError(
3803
+ `Failed to encrypt operation ${fieldName} field. Ensure the encryption key is valid and crypto.subtle is available.`,
3804
+ {
3805
+ operationId,
3806
+ fieldName,
3807
+ cause: cause instanceof Error ? cause.message : String(cause)
3808
+ }
3809
+ );
3810
+ }
3811
+ }
3812
+ async decryptField(field, operationId, fieldName) {
3813
+ if (field === null) {
3814
+ return null;
3815
+ }
3816
+ if (!isEncryptedPayload(field)) {
3817
+ return field;
3818
+ }
3819
+ const payload = field;
3820
+ const keyVersion = payload.v;
3821
+ const key = this.keys.get(keyVersion);
3822
+ if (!key) {
3823
+ throw new DecryptionError(
3824
+ `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.`,
3825
+ { operationId, fieldName, keyVersion, availableVersions: [...this.keys.keys()] }
3826
+ );
3827
+ }
3828
+ if (payload.alg !== "aes-256-gcm") {
3829
+ throw new DecryptionError(
3830
+ `Unsupported encryption algorithm: "${payload.alg}". Only "aes-256-gcm" is supported. Update your @korajs/sync package.`,
3831
+ { operationId, fieldName, algorithm: payload.alg }
3832
+ );
3833
+ }
3834
+ try {
3835
+ const iv = fromBase642(payload.iv);
3836
+ const ciphertext = fromBase642(payload.ct);
3837
+ const plaintextBuffer = await globalThis.crypto.subtle.decrypt(
3838
+ { name: "AES-GCM", iv },
3839
+ key.key,
3840
+ ciphertext
3841
+ );
3842
+ const json = new TextDecoder().decode(plaintextBuffer);
3843
+ const parsed = JSON.parse(json);
3844
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3845
+ throw new DecryptionError(`Decrypted ${fieldName} is not a valid record object.`, {
3846
+ operationId,
3847
+ fieldName
3848
+ });
3849
+ }
3850
+ return parsed;
3851
+ } catch (cause) {
3852
+ if (cause instanceof DecryptionError) {
3853
+ throw cause;
3854
+ }
3855
+ throw new DecryptionError(
3856
+ `Failed to decrypt operation ${fieldName} field. This may indicate a wrong encryption key, tampered ciphertext, or corrupted data.`,
3857
+ {
3858
+ operationId,
3859
+ fieldName,
3860
+ keyVersion,
3861
+ cause: cause instanceof Error ? cause.message : String(cause)
3862
+ }
3863
+ );
3864
+ }
3865
+ }
3866
+ };
3867
+ function isEncryptedPayload(field) {
3868
+ if (field === null || typeof field !== "object") {
3869
+ return false;
3870
+ }
3871
+ return field[ENCRYPTED_MARKER] === true && typeof field.v === "number" && typeof field.iv === "string" && typeof field.ct === "string" && typeof field.alg === "string";
3872
+ }
3873
+ function toBase642(bytes) {
3874
+ let binary = "";
3875
+ for (let i = 0; i < bytes.length; i++) {
3876
+ binary += String.fromCharCode(bytes[i]);
3877
+ }
3878
+ return btoa(binary);
3879
+ }
3880
+ function fromBase642(str) {
3881
+ const binary = atob(str);
3882
+ const bytes = new Uint8Array(binary.length);
3883
+ for (let i = 0; i < binary.length; i++) {
3884
+ bytes[i] = binary.charCodeAt(i);
3885
+ }
3886
+ return bytes;
3887
+ }
1766
3888
  // Annotate the CommonJS export names for ESM import in node:
1767
3889
  0 && (module.exports = {
3890
+ AwarenessManager,
1768
3891
  ChaosTransport,
1769
3892
  ConnectionMonitor,
3893
+ DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD,
3894
+ DecryptionError,
3895
+ EncryptionError,
1770
3896
  HttpLongPollingTransport,
3897
+ InvalidScopeError,
1771
3898
  JsonMessageSerializer,
3899
+ KeyDerivationError,
1772
3900
  NegotiatedMessageSerializer,
1773
3901
  OutboundQueue,
1774
3902
  ProtobufMessageSerializer,
1775
3903
  ReconnectionManager,
3904
+ RichtextDocChannel,
3905
+ SCHEMA_MISMATCH_PREFIX,
1776
3906
  SYNC_STATES,
1777
3907
  SYNC_STATUSES,
3908
+ ScopeViolationError,
3909
+ SyncEncryptor,
1778
3910
  SyncEngine,
1779
3911
  WebSocketTransport,
3912
+ createDeltaCursorFromBatch,
3913
+ decodeDeltaCursor,
3914
+ decodeYjsUpdate,
3915
+ dedupeQuerySubsets,
3916
+ deriveKey,
3917
+ deriveVersionedKey,
3918
+ encodeDeltaCursor,
3919
+ encodeYjsUpdate,
3920
+ filterOperationsByScope,
3921
+ generateSalt,
1780
3922
  isAcknowledgmentMessage,
3923
+ isAwarenessUpdateMessage,
3924
+ isClientSchemaVersionSupported,
3925
+ isEncryptedPayload,
1781
3926
  isErrorMessage,
1782
3927
  isHandshakeMessage,
1783
3928
  isHandshakeResponseMessage,
1784
3929
  isOperationBatchMessage,
3930
+ isSchemaMismatchReject,
1785
3931
  isSyncMessage,
3932
+ isYjsDocUpdateMessage,
3933
+ operationMatchesQuerySubsets,
3934
+ operationMatchesScope,
3935
+ richtextDocKey,
3936
+ sliceOperationsAfterCursor,
1786
3937
  versionVectorToWire,
1787
3938
  wireToVersionVector
1788
3939
  });