@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.d.ts CHANGED
@@ -1,11 +1,57 @@
1
- import { S as SyncMessage, a as SerializedOperation, W as WireFormat, b as SyncTransport, T as TransportOptions, c as TransportMessageHandler, d as TransportCloseHandler, e as TransportErrorHandler, Q as QueueStorage, f as SyncConfig, g as SyncStatusInfo, h as SyncState } from './transport-B5EFsr5F.js';
2
- export { A as AcknowledgmentMessage, E as ErrorMessage, H as HandshakeMessage, i as HandshakeResponseMessage, O as OperationBatchMessage, j as SYNC_STATES, k as SYNC_STATUSES, l as SyncScopeContext, m as SyncStatus, n as isAcknowledgmentMessage, o as isErrorMessage, p as isHandshakeMessage, q as isHandshakeResponseMessage, r as isOperationBatchMessage, s as isSyncMessage } from './transport-B5EFsr5F.js';
3
- import { VersionVector, Operation, KoraEventEmitter, TimeSource, ConnectionQuality } from '@korajs/core';
1
+ import { S as SyncScopeMap, D as DeltaCursor, a as SyncMessage, b as SerializedOperation, W as WireFormat, c as SyncTransport, T as TransportOptions, d as TransportMessageHandler, e as TransportCloseHandler, f as TransportErrorHandler, g as SyncEncryptionConfig, V as VersionedKey, Y as YjsDocUpdateMessage, Q as QueueStorage, h as SyncState, i as SyncStatusInfo, j as SyncConfig, k as SyncStatePersistence, l as SyncQuerySubset } from './transport-BrdBj7rH.js';
2
+ export { A as AcknowledgmentMessage, m as AwarenessStateWire, n as AwarenessUpdateMessage, E as EncryptedPayload, o as ErrorMessage, H as HandshakeMessage, p as HandshakeResponseMessage, I as InvalidScopeError, O as OperationBatchMessage, q as SYNC_STATES, r as SYNC_STATUSES, s as ScopeViolationError, t as SyncEncryptionAlgorithm, u as SyncScopeContext, v as SyncStatus, w as dedupeQuerySubsets, x as isAcknowledgmentMessage, y as isAwarenessUpdateMessage, z as isErrorMessage, B as isHandshakeMessage, C as isHandshakeResponseMessage, F as isOperationBatchMessage, G as isSyncMessage, J as isYjsDocUpdateMessage, K as operationMatchesQuerySubsets } from './transport-BrdBj7rH.js';
3
+ import * as _korajs_core from '@korajs/core';
4
+ import { Operation, VersionVector, ApplyResult, KoraEventEmitter, TimeSource, SyncError, ConnectionQuality } from '@korajs/core';
5
+ export { ApplyFailureReason, ApplyResult } from '@korajs/core';
4
6
 
5
7
  /**
6
- * Result of applying a remote operation to the store.
8
+ * Check whether an operation matches the given scope map.
9
+ *
10
+ * Rules:
11
+ * - No scope map configured: operation is always in scope.
12
+ * - Collection not present in scope map: operation is out of scope.
13
+ * - Empty scope for a collection `{}`: no field restrictions, operation is in scope.
14
+ * - Scope has field/value pairs: all must match in the operation's data snapshot.
15
+ *
16
+ * For updates, the snapshot is built by merging `previousData` and `data` (data wins),
17
+ * which represents the record's state after the operation is applied. When an optional
18
+ * `fullRecord` is provided (e.g., from the local store), its values fill in scope fields
19
+ * that weren't included in the operation's data (critical for update operations where
20
+ * `data` only contains changed fields).
21
+ *
22
+ * @param op - The operation to check
23
+ * @param scopeMap - Per-collection scope filters, or undefined for no filtering
24
+ * @param fullRecord - Optional full record state from the store, used to fill in scope
25
+ * fields not present in the operation's partial data
26
+ * @returns true if the operation is within scope
27
+ */
28
+ declare function operationMatchesScope(op: Operation, scopeMap: SyncScopeMap | undefined, fullRecord?: Record<string, unknown> | null): boolean;
29
+ /**
30
+ * Filter operations to only those matching the given scope map.
31
+ *
32
+ * @param operations - Array of operations to filter
33
+ * @param scopeMap - Per-collection scope filters
34
+ * @returns Operations that match the scope
35
+ */
36
+ declare function filterOperationsByScope(operations: Operation[], scopeMap: SyncScopeMap | undefined): Operation[];
37
+
38
+ /**
39
+ * Encode a delta cursor for wire transfer or `_kora_meta` persistence.
40
+ */
41
+ declare function encodeDeltaCursor(cursor: DeltaCursor): string;
42
+ /**
43
+ * Decode a delta cursor string. Returns null when malformed.
44
+ */
45
+ declare function decodeDeltaCursor(value: string | undefined | null): DeltaCursor | null;
46
+ /**
47
+ * Slice a causally sorted operation list to resume after a cursor.
48
+ */
49
+ declare function sliceOperationsAfterCursor(operations: _korajs_core.Operation[], cursor: DeltaCursor | null): _korajs_core.Operation[];
50
+ /**
51
+ * Build a cursor from the last operation in a delta batch.
7
52
  */
8
- type ApplyResult = 'applied' | 'duplicate' | 'skipped';
53
+ declare function createDeltaCursorFromBatch(operations: _korajs_core.Operation[], batchIndex: number): DeltaCursor | null;
54
+
9
55
  /**
10
56
  * Interface that the local store must implement for sync.
11
57
  * This decouples @korajs/sync from @korajs/store — the store satisfies this interface.
@@ -37,6 +83,20 @@ interface SyncStore {
37
83
  getOperationRange(nodeId: string, fromSeq: number, toSeq: number): Promise<Operation[]>;
38
84
  }
39
85
 
86
+ /** Prefix for handshake rejection when client schema version is unsupported. */
87
+ declare const SCHEMA_MISMATCH_PREFIX = "SCHEMA_MISMATCH";
88
+ /**
89
+ * Returns true when a handshake `rejectReason` indicates unsupported schema version.
90
+ */
91
+ declare function isSchemaMismatchReject(rejectReason: string | undefined): boolean;
92
+ /**
93
+ * Returns true when the client schema version is within the inclusive supported range.
94
+ */
95
+ declare function isClientSchemaVersionSupported(clientVersion: number, supported: {
96
+ min: number;
97
+ max: number;
98
+ }): boolean;
99
+
40
100
  type EncodedMessage = string | Uint8Array;
41
101
  /**
42
102
  * Interface for encoding/decoding sync protocol messages.
@@ -229,6 +289,396 @@ declare class ChaosTransport implements SyncTransport {
229
289
  private flushReorderBuffer;
230
290
  }
231
291
 
292
+ /**
293
+ * Cursor position within a richtext field.
294
+ * Uses Yjs-compatible anchor/head positions for editor interop.
295
+ */
296
+ interface AwarenessCursor {
297
+ /** Collection containing the record being edited */
298
+ collection: string;
299
+ /** ID of the record being edited */
300
+ recordId: string;
301
+ /** Field name of the richtext field */
302
+ field: string;
303
+ /** Cursor anchor position in Y.Text (start of selection) */
304
+ anchor: number;
305
+ /** Cursor head position in Y.Text (end of selection, same as anchor if no selection) */
306
+ head: number;
307
+ }
308
+ /**
309
+ * User identity information for presence display.
310
+ */
311
+ interface AwarenessUser {
312
+ /** Display name of the user */
313
+ name: string;
314
+ /** Hex color for cursor/selection rendering (e.g. '#ff0000') */
315
+ color: string;
316
+ /** Optional avatar URL */
317
+ avatar?: string;
318
+ }
319
+ /**
320
+ * Per-client awareness state. Ephemeral -- not persisted, only shared with connected peers.
321
+ * Compatible with Yjs awareness protocol format for interop with existing editors.
322
+ */
323
+ interface AwarenessState {
324
+ /** User identity information */
325
+ user: AwarenessUser;
326
+ /** Current cursor position, if any */
327
+ cursor?: AwarenessCursor;
328
+ }
329
+ /**
330
+ * Internal awareness message format used between AwarenessManager and transport layer.
331
+ */
332
+ interface AwarenessMessage {
333
+ type: 'awareness';
334
+ /** Client ID of the sender */
335
+ clientId: number;
336
+ /** All known awareness states. null value means removal. */
337
+ states: Record<number, AwarenessState | null>;
338
+ }
339
+ /**
340
+ * Describes a change in awareness states.
341
+ * Emitted when remote clients update or remove their presence.
342
+ */
343
+ interface AwarenessChange {
344
+ /** Client IDs whose states were added */
345
+ added: number[];
346
+ /** Client IDs whose states were updated */
347
+ updated: number[];
348
+ /** Client IDs whose states were removed */
349
+ removed: number[];
350
+ }
351
+ /**
352
+ * Cursor information for rendering in editors.
353
+ * This is the developer-facing type -- simplified from internal awareness state.
354
+ * Editor-agnostic: provides data that can be rendered by TipTap, ProseMirror, Quill, etc.
355
+ */
356
+ interface CursorInfo {
357
+ /** Unique client ID */
358
+ clientId: number;
359
+ /** User display name */
360
+ userName: string;
361
+ /** Hex color for cursor rendering */
362
+ color: string;
363
+ /** Cursor anchor position (start of selection) */
364
+ anchor: number;
365
+ /** Cursor head position (end of selection) */
366
+ head: number;
367
+ }
368
+
369
+ /**
370
+ * Callback for awareness change events.
371
+ */
372
+ type AwarenessChangeListener = (change: AwarenessChange) => void;
373
+ /**
374
+ * Manages collaborative awareness state (cursor positions, user presence).
375
+ *
376
+ * Awareness is ephemeral -- states are never persisted, only shared with
377
+ * currently connected peers via the sync transport. This implements a
378
+ * lightweight protocol compatible with Yjs awareness semantics.
379
+ *
380
+ * Each AwarenessManager has a unique client ID and tracks both its own
381
+ * local state and all remote clients' states.
382
+ */
383
+ declare class AwarenessManager {
384
+ /** Unique client ID for this instance */
385
+ readonly clientId: number;
386
+ private localState;
387
+ private readonly remoteStates;
388
+ private readonly listeners;
389
+ private readonly emitter;
390
+ private readonly timeoutMs;
391
+ private readonly lastUpdated;
392
+ private cleanupTimer;
393
+ private sendHandler;
394
+ private destroyed;
395
+ constructor(options?: {
396
+ clientId?: number;
397
+ emitter?: KoraEventEmitter;
398
+ timeoutMs?: number;
399
+ });
400
+ /**
401
+ * Set the local user's awareness state and broadcast to peers.
402
+ *
403
+ * @param state - The awareness state to share. Pass null to clear presence.
404
+ */
405
+ setLocalState(state: AwarenessState | null): void;
406
+ /**
407
+ * Get the local awareness state.
408
+ */
409
+ getLocalState(): AwarenessState | null;
410
+ /**
411
+ * Get all known awareness states (local + remote).
412
+ * Returns a new Map on each call.
413
+ */
414
+ getStates(): Map<number, AwarenessState>;
415
+ /**
416
+ * Handle an incoming awareness message from the transport.
417
+ * This processes remote state updates and notifies listeners.
418
+ */
419
+ handleRemoteMessage(message: AwarenessMessage): void;
420
+ /**
421
+ * Remove a specific remote client's awareness state.
422
+ * Called when the server notifies that a client has disconnected.
423
+ */
424
+ removeClient(clientId: number): void;
425
+ /**
426
+ * Register a handler for sending awareness messages through the transport.
427
+ * The sync engine calls this to wire outgoing awareness messages to the transport.
428
+ */
429
+ onSend(handler: (message: AwarenessMessage) => void): void;
430
+ /**
431
+ * Register a listener for awareness state changes.
432
+ * Returns an unsubscribe function.
433
+ */
434
+ on(_event: 'change', listener: AwarenessChangeListener): () => void;
435
+ /**
436
+ * Remove a specific change listener.
437
+ */
438
+ off(_event: 'change', listener: AwarenessChangeListener): void;
439
+ /**
440
+ * Start the cleanup timer that removes stale remote states.
441
+ * Called when the sync engine transitions to streaming state.
442
+ */
443
+ startCleanupTimer(): void;
444
+ /**
445
+ * Stop the cleanup timer.
446
+ */
447
+ stopCleanupTimer(): void;
448
+ /**
449
+ * Clean up all resources. After calling destroy(), the manager
450
+ * will no longer send or receive awareness updates.
451
+ * Broadcasts removal of local state before shutting down.
452
+ */
453
+ destroy(): void;
454
+ private cleanupStaleStates;
455
+ private notifyListeners;
456
+ private emitAwarenessEvent;
457
+ }
458
+
459
+ /**
460
+ * Configuration for the SyncMetricsCollector.
461
+ */
462
+ interface MetricsCollectorConfig {
463
+ /** Number of RTT samples to keep for percentile calculations. Defaults to 100. */
464
+ rttWindowSize?: number;
465
+ /** Number of bandwidth samples to keep. Defaults to 20. */
466
+ bandwidthWindowSize?: number;
467
+ /** Interval in ms between periodic diagnostics emissions. Defaults to 5000. */
468
+ diagnosticsInterval?: number;
469
+ /** Injectable time source for deterministic testing. */
470
+ timeSource?: TimeSource;
471
+ }
472
+
473
+ /**
474
+ * Thrown when encryption of operation data fails.
475
+ */
476
+ declare class EncryptionError extends SyncError {
477
+ constructor(message: string, context?: Record<string, unknown>);
478
+ }
479
+ /**
480
+ * Thrown when decryption of operation data fails.
481
+ * This typically indicates a wrong key, tampered ciphertext, or corrupted data.
482
+ */
483
+ declare class DecryptionError extends SyncError {
484
+ constructor(message: string, context?: Record<string, unknown>);
485
+ }
486
+ /**
487
+ * Encrypts and decrypts operation `data` and `previousData` fields for
488
+ * end-to-end encryption in the sync layer.
489
+ *
490
+ * **Design principles:**
491
+ * - Only `data` and `previousData` are encrypted. Metadata (id, nodeId,
492
+ * collection, timestamps, causalDeps, etc.) stays in cleartext so the
493
+ * server can route, deduplicate, and order operations.
494
+ * - Each field encryption uses a unique random IV (12 bytes for AES-GCM),
495
+ * ensuring that encrypting the same data twice produces different ciphertext.
496
+ * - Key rotation is supported via versioned keys. The key version is embedded
497
+ * in the {@link EncryptedPayload} so the decryptor can select the correct key.
498
+ * - Unencrypted fields pass through during decryption (backward compatibility).
499
+ *
500
+ * @example
501
+ * ```typescript
502
+ * const encryptor = await SyncEncryptor.create({
503
+ * enabled: true,
504
+ * key: 'user-passphrase'
505
+ * })
506
+ *
507
+ * // Encrypt before sending
508
+ * const encrypted = await encryptor.encryptOperation(operation)
509
+ *
510
+ * // Decrypt after receiving
511
+ * const decrypted = await encryptor.decryptOperation(encrypted)
512
+ * ```
513
+ */
514
+ declare class SyncEncryptor {
515
+ /**
516
+ * Map of key version -> VersionedKey. The current (latest) version is used
517
+ * for encryption. All versions are available for decryption (key rotation).
518
+ */
519
+ private readonly keys;
520
+ /** The current key version used for encryption. */
521
+ private currentVersion;
522
+ private constructor();
523
+ /**
524
+ * Creates a SyncEncryptor from a {@link SyncEncryptionConfig}.
525
+ *
526
+ * Derives the encryption key from the passphrase using PBKDF2. The key
527
+ * derivation is async because it uses the Web Crypto API.
528
+ *
529
+ * @param config - Encryption configuration with passphrase
530
+ * @param salt - Optional salt for deterministic key derivation (mainly for testing).
531
+ * If omitted, a random salt is generated.
532
+ * @returns A configured SyncEncryptor instance
533
+ * @throws {EncryptionError} If configuration is invalid
534
+ * @throws {KeyDerivationError} If key derivation fails
535
+ */
536
+ static create(config: SyncEncryptionConfig, salt?: Uint8Array): Promise<SyncEncryptor>;
537
+ /**
538
+ * Creates a SyncEncryptor from pre-derived versioned keys.
539
+ *
540
+ * Use this when you need to support multiple key versions for key rotation,
541
+ * or when you have already derived the keys externally.
542
+ *
543
+ * @param versionedKeys - Array of versioned keys. The highest version is used for encryption.
544
+ * @returns A configured SyncEncryptor instance
545
+ * @throws {EncryptionError} If no keys are provided
546
+ */
547
+ static fromKeys(versionedKeys: VersionedKey[]): SyncEncryptor;
548
+ /**
549
+ * Add a new key version for key rotation.
550
+ *
551
+ * After adding, the new key becomes the current version used for encryption
552
+ * if its version number is higher than the current version. Previously-versioned
553
+ * keys remain available for decrypting older operations.
554
+ *
555
+ * @param key - The new versioned key to add
556
+ * @throws {EncryptionError} If the key version already exists
557
+ */
558
+ addKey(key: VersionedKey): void;
559
+ /**
560
+ * Get the current encryption key version number.
561
+ */
562
+ getCurrentKeyVersion(): number;
563
+ /**
564
+ * Encrypt an operation's `data` and `previousData` fields.
565
+ *
566
+ * Returns a new Operation with encrypted field values. The original
567
+ * operation is not mutated (operations are immutable).
568
+ *
569
+ * Fields that are null (e.g., delete operations) remain null.
570
+ *
571
+ * @param operation - The operation to encrypt
572
+ * @returns A new operation with encrypted data fields
573
+ * @throws {EncryptionError} If encryption fails
574
+ */
575
+ encryptOperation(operation: Operation): Promise<Operation>;
576
+ /**
577
+ * Decrypt an operation's `data` and `previousData` fields.
578
+ *
579
+ * Returns a new Operation with the original field values restored.
580
+ * The original operation is not mutated.
581
+ *
582
+ * If a field is null or not encrypted (no marker), it passes through unchanged.
583
+ * This enables mixed plaintext/encrypted operations during migration.
584
+ *
585
+ * @param operation - The operation to decrypt
586
+ * @returns A new operation with decrypted data fields
587
+ * @throws {DecryptionError} If decryption fails (wrong key, tampered data, unsupported version)
588
+ */
589
+ decryptOperation(operation: Operation): Promise<Operation>;
590
+ /**
591
+ * Encrypt a serialized operation's `data` and `previousData` fields.
592
+ *
593
+ * Same as {@link encryptOperation} but works with the wire-format
594
+ * {@link SerializedOperation} type used by the serializer.
595
+ *
596
+ * @param serialized - The serialized operation to encrypt
597
+ * @returns A new serialized operation with encrypted data fields
598
+ * @throws {EncryptionError} If encryption fails
599
+ */
600
+ encryptSerializedOperation(serialized: SerializedOperation): Promise<SerializedOperation>;
601
+ /**
602
+ * Decrypt a serialized operation's `data` and `previousData` fields.
603
+ *
604
+ * Same as {@link decryptOperation} but works with the wire-format
605
+ * {@link SerializedOperation} type used by the serializer.
606
+ *
607
+ * @param serialized - The serialized operation to decrypt
608
+ * @returns A new serialized operation with decrypted data fields
609
+ * @throws {DecryptionError} If decryption fails
610
+ */
611
+ decryptSerializedOperation(serialized: SerializedOperation): Promise<SerializedOperation>;
612
+ /**
613
+ * Encrypt a batch of operations in parallel.
614
+ *
615
+ * @param operations - Operations to encrypt
616
+ * @returns New operations with encrypted data fields
617
+ */
618
+ encryptBatch(operations: Operation[]): Promise<Operation[]>;
619
+ /**
620
+ * Decrypt a batch of operations in parallel.
621
+ *
622
+ * @param operations - Operations to decrypt
623
+ * @returns New operations with decrypted data fields
624
+ */
625
+ decryptBatch(operations: Operation[]): Promise<Operation[]>;
626
+ /**
627
+ * Check if a field value contains an encrypted payload.
628
+ *
629
+ * @param field - An operation's `data` or `previousData` field
630
+ * @returns true if the field contains an encrypted payload
631
+ */
632
+ static isEncryptedPayload(field: Record<string, unknown> | null): boolean;
633
+ private encryptField;
634
+ private decryptField;
635
+ }
636
+ /**
637
+ * Check if a field value contains an encrypted payload.
638
+ *
639
+ * @param field - An operation's `data` or `previousData` field
640
+ * @returns true if the field contains an encrypted payload
641
+ */
642
+ declare function isEncryptedPayload(field: Record<string, unknown> | null): boolean;
643
+
644
+ /** Default snapshot size above which the doc channel is preferred. */
645
+ declare const DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD = 4096;
646
+ type RichtextDocListener = (update: Uint8Array) => void;
647
+ interface RichtextDocChannelOptions {
648
+ /** Snapshot byte length at which incremental channel sync is recommended. */
649
+ largeDocThreshold?: number;
650
+ /** Called when a local update should be sent on the wire. */
651
+ onSend?: (message: YjsDocUpdateMessage) => void;
652
+ }
653
+ /**
654
+ * Side channel for incremental Yjs updates on large richtext fields.
655
+ * Ephemeral — not persisted in the operation log (durable state still flows via ops).
656
+ */
657
+ declare class RichtextDocChannel {
658
+ private readonly threshold;
659
+ private readonly onSend;
660
+ private readonly listeners;
661
+ constructor(options?: RichtextDocChannelOptions);
662
+ /**
663
+ * Whether the doc channel should be used for a field.
664
+ * @param preference - `true` forces on, `false` forces off, `undefined` uses size threshold
665
+ */
666
+ shouldUseChannel(snapshotByteLength: number, preference?: boolean): boolean;
667
+ getThreshold(): number;
668
+ /**
669
+ * Subscribe to incremental updates for a document key.
670
+ */
671
+ subscribe(collection: string, recordId: string, field: string, listener: RichtextDocListener): () => void;
672
+ /**
673
+ * Publish a local incremental update to peers.
674
+ */
675
+ send(collection: string, recordId: string, field: string, update: Uint8Array): void;
676
+ /**
677
+ * Deliver a remote incremental update to local subscribers.
678
+ */
679
+ deliver(message: YjsDocUpdateMessage): void;
680
+ }
681
+
232
682
  /**
233
683
  * A batch of operations taken from the queue for sending.
234
684
  */
@@ -299,6 +749,11 @@ declare class OutboundQueue {
299
749
  * Whether initialize() has been called.
300
750
  */
301
751
  get isInitialized(): boolean;
752
+ /**
753
+ * Remove operations by id from queue and persistent storage.
754
+ * Used when ops were already sent during handshake delta exchange.
755
+ */
756
+ removeByIds(ids: string[]): Promise<void>;
302
757
  }
303
758
 
304
759
  /**
@@ -317,6 +772,34 @@ interface SyncEngineOptions {
317
772
  emitter?: KoraEventEmitter;
318
773
  /** Queue storage for persistent outbound queue. Defaults to in-memory. */
319
774
  queueStorage?: QueueStorage;
775
+ /**
776
+ * Optional encryptor for end-to-end encryption.
777
+ * When provided, `data` and `previousData` fields of operations are encrypted
778
+ * before sending and decrypted after receiving. The server never sees plaintext data.
779
+ */
780
+ encryptor?: SyncEncryptor;
781
+ /** Optional configuration for the metrics collector. */
782
+ metricsConfig?: MetricsCollectorConfig;
783
+ /** Op-log backed sync state (last acked server vector, unsynced counts). */
784
+ syncState?: SyncStatePersistence;
785
+ }
786
+ /**
787
+ * Diagnostics snapshot for debugging and support.
788
+ */
789
+ interface SyncDiagnostics {
790
+ state: SyncState;
791
+ status: SyncStatusInfo;
792
+ nodeId: string;
793
+ url: string;
794
+ schemaVersion: number;
795
+ lastSyncedAt: number | null;
796
+ lastSuccessfulPush: number | null;
797
+ lastSuccessfulPull: number | null;
798
+ conflicts: number;
799
+ pendingOperations: number;
800
+ hasInFlightBatch: boolean;
801
+ reconnecting: boolean;
802
+ timestamp: number;
320
803
  }
321
804
  /**
322
805
  * Core sync orchestrator. Manages the sync lifecycle:
@@ -334,13 +817,40 @@ declare class SyncEngine {
334
817
  private readonly emitter;
335
818
  private readonly outboundQueue;
336
819
  private readonly batchSize;
820
+ private readonly encryptor;
821
+ private readonly awarenessManager;
822
+ private readonly richtextDocChannel;
823
+ private readonly metricsCollector;
824
+ private readonly syncState;
337
825
  private remoteVector;
826
+ private lastAckedServerVector;
827
+ private cachedUnsyncedCount;
338
828
  private lastSyncedAt;
829
+ private lastSuccessfulPush;
830
+ private lastSuccessfulPull;
831
+ private conflictCount;
339
832
  private currentBatch;
340
833
  private reconnecting;
834
+ private schemaBlocked;
341
835
  private deltaBatchesReceived;
342
836
  private deltaReceiveComplete;
343
837
  private deltaSendComplete;
838
+ /** Op ids sent during handshake delta — removed from outbound queue to avoid duplicate send */
839
+ private deltaSentOpIds;
840
+ /** Outbound delta batch message IDs awaiting ACK when strictHandshake is enabled */
841
+ private pendingDeltaBatchAcks;
842
+ /**
843
+ * The effective scope for this sync session.
844
+ * Starts as the configured scopeMap. After handshake, may be replaced
845
+ * with the server-accepted scope (server is authoritative).
846
+ */
847
+ private activeScope;
848
+ /** Live query subsets registered from reactive subscriptions */
849
+ private querySubsets;
850
+ private querySubsetReconnectTimer;
851
+ /** Resume cursor for paginated initial sync (persisted across reconnects) */
852
+ private resumeDeltaCursor;
853
+ private initialSyncTotalBatches;
344
854
  constructor(options: SyncEngineOptions);
345
855
  /**
346
856
  * Start the sync engine: connect → handshake → delta exchange → streaming.
@@ -354,6 +864,9 @@ declare class SyncEngine {
354
864
  /**
355
865
  * Push a local operation to the outbound queue.
356
866
  * If streaming, flushes immediately.
867
+ *
868
+ * Operations outside the configured sync scope are silently skipped
869
+ * because they should remain local-only and not be sent to the server.
357
870
  */
358
871
  pushOperation(op: Operation): Promise<void>;
359
872
  /**
@@ -367,6 +880,36 @@ declare class SyncEngine {
367
880
  * Get the current developer-facing sync status.
368
881
  */
369
882
  getStatus(): SyncStatusInfo;
883
+ /**
884
+ * True when the server rejected the client's schema version at handshake.
885
+ * Sync stays blocked until the app schema is upgraded or sync config changes.
886
+ */
887
+ isSchemaBlocked(): boolean;
888
+ /**
889
+ * Clears schema-mismatch block after upgrading the local schema / sync config.
890
+ * Moves the engine back to `disconnected` so `start()` can run again.
891
+ */
892
+ clearSchemaBlock(): void;
893
+ /**
894
+ * Record a merge conflict. Called by the merge-aware sync store
895
+ * to increment the conflict counter for status reporting.
896
+ */
897
+ recordConflict(): void;
898
+ /**
899
+ * Count of local operations not yet covered by the last acked server vector (op-log source of truth).
900
+ */
901
+ getUnsyncedOperationCount(): Promise<number>;
902
+ private emitApplyFailure;
903
+ /**
904
+ * Force an immediate reconnection attempt. If the engine is disconnected
905
+ * or in error state, restarts the sync. If already connected, no-op.
906
+ */
907
+ retryNow(): Promise<void>;
908
+ /**
909
+ * Export a diagnostics snapshot for debugging and support tickets.
910
+ * Contains connection state, timing info, and queue metrics.
911
+ */
912
+ exportDiagnostics(): SyncDiagnostics;
370
913
  /**
371
914
  * Get the current internal state (for testing).
372
915
  */
@@ -375,19 +918,82 @@ declare class SyncEngine {
375
918
  * Get the outbound queue (for testing).
376
919
  */
377
920
  getOutboundQueue(): OutboundQueue;
378
- private handleMessage;
921
+ /**
922
+ * Update the sync scope map. Takes effect on the next connection attempt.
923
+ *
924
+ * When the scope changes (e.g., user switches organization), call this method
925
+ * then reconnect. The new scope will be sent in the handshake, and the server
926
+ * will send back data matching the new scope.
927
+ *
928
+ * Data that no longer matches the new scope is NOT deleted locally.
929
+ * It simply stops being synced.
930
+ *
931
+ * @param scopeMap - New per-collection scope filters, or undefined to remove scope
932
+ */
933
+ updateScope(scopeMap: SyncScopeMap | undefined): void;
934
+ /**
935
+ * Get the currently active scope map. Returns undefined if no scope is configured.
936
+ */
937
+ getActiveScope(): SyncScopeMap | undefined;
938
+ /**
939
+ * Register a live query subset that narrows synced data for a collection.
940
+ * Takes effect on the next connection; reconnects when already connected.
941
+ */
942
+ registerQuerySubset(subset: SyncQuerySubset): () => void;
943
+ /**
944
+ * Returns deduplicated active query subsets from registered subscriptions.
945
+ */
946
+ getActiveQuerySubsets(): SyncQuerySubset[];
947
+ /**
948
+ * Get the awareness manager for collaborative presence.
949
+ * Use this to set local presence, observe remote collaborators,
950
+ * and track cursor positions in richtext fields.
951
+ */
952
+ getAwarenessManager(): AwarenessManager;
953
+ /**
954
+ * Optional side channel for incremental Yjs updates on large richtext fields.
955
+ */
956
+ getRichtextDocChannel(): RichtextDocChannel;
957
+ private messageChain;
958
+ private enqueueMessage;
959
+ private handleMessageAsync;
960
+ private handleMessageFailure;
379
961
  private handleHandshakeResponse;
380
962
  private sendDelta;
963
+ private markDeltaSendCompleteIfReady;
381
964
  private collectDelta;
382
965
  private handleOperationBatch;
383
966
  private handleAcknowledgment;
384
967
  private handleError;
385
968
  private checkDeltaComplete;
969
+ /**
970
+ * Effective server vector: persisted last-ack merged with live handshake remote vector.
971
+ */
972
+ private getEffectiveServerVector;
973
+ private persistLastAckedServerVector;
974
+ private advanceLastAckedForLocalNode;
975
+ /**
976
+ * Refresh cached unsynced count from op log vs effective server vector.
977
+ */
978
+ refreshPendingCount(): Promise<void>;
979
+ /**
980
+ * Returns operations the server has not yet acknowledged (op-log source of truth).
981
+ */
982
+ getPendingSyncOperations(): Promise<Operation[]>;
983
+ /**
984
+ * Hydrate the outbound queue from unsynced ops in the op log (deduped by operation id).
985
+ */
986
+ private reconcileOutboundFromOpLog;
386
987
  private flushQueue;
988
+ private handleAwarenessUpdate;
387
989
  private handleTransportClose;
388
990
  private handleTransportError;
389
991
  private transitionTo;
390
992
  private setSerializerWireFormat;
993
+ private operationAllowedForSync;
994
+ private persistDeltaCursor;
995
+ private scheduleQuerySubsetReconnect;
996
+ private reconnectForQuerySubsets;
391
997
  }
392
998
 
393
999
  /**
@@ -494,6 +1100,11 @@ declare class ReconnectionManager {
494
1100
  * @returns Promise that resolves when reconnection succeeds or maxAttempts reached.
495
1101
  */
496
1102
  start(onReconnect: () => Promise<boolean>): Promise<boolean>;
1103
+ /**
1104
+ * Cancel the current backoff wait and proceed to the next reconnect attempt immediately.
1105
+ * No-op if not waiting.
1106
+ */
1107
+ wake(): void;
497
1108
  /**
498
1109
  * Stop any pending reconnection attempt.
499
1110
  */
@@ -518,4 +1129,70 @@ declare class ReconnectionManager {
518
1129
  private wait;
519
1130
  }
520
1131
 
521
- export { type ApplyResult, type ChaosConfig, ChaosTransport, ConnectionMonitor, type ConnectionMonitorConfig, HttpLongPollingTransport, type HttpLongPollingTransportOptions, JsonMessageSerializer, type MessageSerializer, NegotiatedMessageSerializer, type OutboundBatch, OutboundQueue, ProtobufMessageSerializer, QueueStorage, type ReconnectionConfig, ReconnectionManager, SerializedOperation, SyncConfig, SyncEngine, type SyncEngineOptions, SyncMessage, SyncState, SyncStatusInfo, type SyncStore, SyncTransport, TransportCloseHandler, TransportErrorHandler, TransportMessageHandler, TransportOptions, type WebSocketConstructor, type WebSocketLike, WebSocketTransport, type WebSocketTransportOptions, WireFormat, versionVectorToWire, wireToVersionVector };
1132
+ /**
1133
+ * Encode a Yjs update for the doc channel wire (base64).
1134
+ */
1135
+ declare function encodeYjsUpdate(update: Uint8Array): string;
1136
+ /**
1137
+ * Decode a base64 Yjs update from the doc channel wire.
1138
+ */
1139
+ declare function decodeYjsUpdate(encoded: string): Uint8Array;
1140
+ /**
1141
+ * Build a stable key for a richtext field document.
1142
+ */
1143
+ declare function richtextDocKey(collection: string, recordId: string, field: string): string;
1144
+
1145
+ /**
1146
+ * Thrown when key derivation fails.
1147
+ */
1148
+ declare class KeyDerivationError extends SyncError {
1149
+ constructor(message: string, context?: Record<string, unknown>);
1150
+ }
1151
+ /**
1152
+ * Generates a cryptographically random salt for PBKDF2 key derivation.
1153
+ *
1154
+ * @returns A random 32-byte Uint8Array
1155
+ * @throws {KeyDerivationError} If crypto.getRandomValues is unavailable
1156
+ */
1157
+ declare function generateSalt(): Uint8Array;
1158
+ /**
1159
+ * Derives an AES-256-GCM encryption key from a passphrase using PBKDF2.
1160
+ *
1161
+ * Uses PBKDF2 with SHA-256 and 600,000 iterations (OWASP-recommended minimum)
1162
+ * to derive a 256-bit key. The derived key is deterministic: the same passphrase
1163
+ * and salt always produce the same key.
1164
+ *
1165
+ * @param passphrase - The user's passphrase (must be non-empty)
1166
+ * @param salt - Optional salt bytes. If omitted, a random 32-byte salt is generated.
1167
+ * @returns The derived CryptoKey and the salt used
1168
+ * @throws {KeyDerivationError} If the passphrase is empty, crypto is unavailable, or derivation fails
1169
+ *
1170
+ * @example
1171
+ * ```typescript
1172
+ * // First time: derive key with a new salt
1173
+ * const { key, salt } = await deriveKey('my-secure-passphrase')
1174
+ *
1175
+ * // Later: re-derive the same key using the stored salt
1176
+ * const { key: sameKey } = await deriveKey('my-secure-passphrase', salt)
1177
+ * ```
1178
+ */
1179
+ declare function deriveKey(passphrase: string, salt?: Uint8Array): Promise<{
1180
+ key: CryptoKey;
1181
+ salt: Uint8Array;
1182
+ }>;
1183
+ /**
1184
+ * Derives a versioned encryption key from a passphrase.
1185
+ *
1186
+ * Wraps {@link deriveKey} with a version number for key rotation support.
1187
+ * When the passphrase changes, create a new versioned key with a higher
1188
+ * version number.
1189
+ *
1190
+ * @param passphrase - The user's passphrase
1191
+ * @param version - Key version number (must be a positive integer)
1192
+ * @param salt - Optional salt bytes. If omitted, a random salt is generated.
1193
+ * @returns A {@link VersionedKey} containing the key, version, and salt
1194
+ * @throws {KeyDerivationError} If parameters are invalid or derivation fails
1195
+ */
1196
+ declare function deriveVersionedKey(passphrase: string, version: number, salt?: Uint8Array): Promise<VersionedKey>;
1197
+
1198
+ export { type AwarenessChange, type AwarenessCursor, AwarenessManager, type AwarenessMessage, type AwarenessState, type AwarenessUser, type ChaosConfig, ChaosTransport, ConnectionMonitor, type ConnectionMonitorConfig, type CursorInfo, DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD, DecryptionError, DeltaCursor, EncryptionError, HttpLongPollingTransport, type HttpLongPollingTransportOptions, JsonMessageSerializer, KeyDerivationError, type MessageSerializer, NegotiatedMessageSerializer, type OutboundBatch, OutboundQueue, ProtobufMessageSerializer, QueueStorage, type ReconnectionConfig, ReconnectionManager, RichtextDocChannel, type RichtextDocChannelOptions, type RichtextDocListener, SCHEMA_MISMATCH_PREFIX, SerializedOperation, SyncConfig, type SyncDiagnostics, SyncEncryptionConfig, SyncEncryptor, SyncEngine, type SyncEngineOptions, SyncMessage, SyncQuerySubset, SyncScopeMap, SyncState, SyncStatePersistence, SyncStatusInfo, type SyncStore, SyncTransport, TransportCloseHandler, TransportErrorHandler, TransportMessageHandler, TransportOptions, VersionedKey, type WebSocketConstructor, type WebSocketLike, WebSocketTransport, type WebSocketTransportOptions, WireFormat, YjsDocUpdateMessage, createDeltaCursorFromBatch, decodeDeltaCursor, decodeYjsUpdate, deriveKey, deriveVersionedKey, encodeDeltaCursor, encodeYjsUpdate, filterOperationsByScope, generateSalt, isClientSchemaVersionSupported, isEncryptedPayload, isSchemaMismatchReject, operationMatchesScope, richtextDocKey, sliceOperationsAfterCursor, versionVectorToWire, wireToVersionVector };