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