@korajs/sync 0.0.0-canary-20261008174129

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.
@@ -0,0 +1,3089 @@
1
+ import { S as SyncScopeMap, a as ScopeSnapshotOptions, D as DeltaCursor, b as SyncMessage, c as SerializedOperation, W as WireFormat, d as SyncTransport, T as TransportOptions, e as TransportMessageHandler, f as TransportCloseHandler, g as TransportErrorHandler, B as BlobChunkRequestMessage, h as BlobChunkResponseMessage, i as BlobChunkPushMessage, j as WrappedKeyRecord, k as SyncEncryptionConfig, V as VersionedKey, Y as YjsDocUpdateMessage, Q as QueueStorage, l as SyncState, m as SyncStatusInfo, n as SyncConfig, R as RejectedOperationStorage, o as SyncStatePersistence, p as SyncQuerySubset, q as QuarantinedOperation, H as HeldNodeInfo, r as RejectedOperation } from './transport-CoPFFarn.js';
2
+ export { A as AcknowledgmentMessage, s as ActiveApplyFailure, t as AdoptionScheduleInfo, u as AwarenessStateWire, v as AwarenessUpdateMessage, w as DEFAULT_KEYRING, E as EncryptedPayload, x as EncryptionKeyMessage, y as EncryptionKeyPutMessage, z as EncryptionKeyRequestMessage, C as EncryptionKeyResponseMessage, F as EncryptionKeyResponseStatus, G as ErrorMessage, I as HandshakeMessage, J as HandshakeResponseMessage, K as HeartbeatMessage, L as HeldReason, M as INVALID_OPERATION_ID, N as InvalidScopeError, O as KEY_RECORD_FORMAT, P as KeyRecordKdf, U as KeyRecordRecovery, X as KeyRecordValidation, Z as LEGACY_SYNC_PROTOCOL_VERSION, _ as LocalNodeInfo, $ as MAX_KDF_ITERATIONS, a0 as MAX_KEY_RECORD_BYTES, a1 as MemoryRejectedOperationStorage, a2 as OperationBatchMessage, a3 as OperationRejectedMessage, a4 as PLAINTEXT_REJECTED, a5 as PROTOCOL_V1_DEPRECATED, a6 as SYNC_PROTOCOL_VERSION, a7 as SYNC_STATES, a8 as SYNC_STATUSES, a9 as ScopeViolationError, aa as SyncAuthRequest, ab as SyncEncryptionAlgorithm, ac as SyncPhase, ad as SyncScopeContext, ae as SyncSettlementOptions, af as SyncSettlementResult, ag as SyncStatus, ah as TerminalRejectionRecord, ai as WrappedDataKey, aj as WrappedMasterKey, ak as declaredProtocolVersion, al as dedupeQuerySubsets, am as isAcknowledgmentMessage, an as isAwarenessUpdateMessage, ao as isBlobChunkPushMessage, ap as isBlobChunkRequestMessage, aq as isBlobChunkResponseMessage, ar as isEncryptionKeyMessage, as as isErrorMessage, at as isHandshakeMessage, au as isHandshakeResponseMessage, av as isKeyId, aw as isKeyRecordSuccessor, ax as isOperationBatchMessage, ay as isOperationRejectedMessage, az as isSyncMessage, aA as isValidKeyringName, aB as isYjsDocUpdateMessage, aC as operationMatchesQuerySubsets, aD as querySubsetContains, aE as validateKeyRecord } from './transport-CoPFFarn.js';
3
+ import * as _korajs_core from '@korajs/core';
4
+ import { Operation, VersionVector, ApplyResult, OperationTransform, KoraEventEmitter, TimeSource, SyncError, ConnectionQuality, KoraError, SchemaDefinition } from '@korajs/core';
5
+ export { ApplyFailureReason, ApplyResult } from '@korajs/core';
6
+
7
+ /**
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
+ * The snapshot is the operation's `data` (layered over `previousData` only with
17
+ * `options.includePreviousData`), with `id` always taken from `op.recordId`,
18
+ * which represents the record's state after the operation is applied. When an optional
19
+ * `fullRecord` is provided (e.g., from the local store), its values fill in scope fields
20
+ * that weren't included in the operation's data (critical for update operations where
21
+ * `data` only contains changed fields).
22
+ *
23
+ * @param op - The operation to check
24
+ * @param scopeMap - Per-collection scope filters, or undefined for no filtering
25
+ * @param fullRecord - Optional full record state from the store, used to fill in scope
26
+ * fields not present in the operation's partial data
27
+ * @param options - Snapshot options; `includePreviousData` trusts the writer's
28
+ * `previousData`, which is only safe for a client judging its own local view
29
+ * @returns true if the operation is within scope
30
+ */
31
+ declare function operationMatchesScope(op: Operation, scopeMap: SyncScopeMap | undefined, fullRecord?: Record<string, unknown> | null, options?: ScopeSnapshotOptions): boolean;
32
+ /**
33
+ * Filter operations to only those matching the given scope map.
34
+ *
35
+ * @param operations - Array of operations to filter
36
+ * @param scopeMap - Per-collection scope filters
37
+ * @returns Operations that match the scope
38
+ */
39
+ declare function filterOperationsByScope(operations: Operation[], scopeMap: SyncScopeMap | undefined): Operation[];
40
+
41
+ /**
42
+ * Encode a delta cursor for wire transfer or `_kora_meta` persistence.
43
+ */
44
+ declare function encodeDeltaCursor(cursor: DeltaCursor): string;
45
+ /**
46
+ * Decode a delta cursor string. Returns null when malformed.
47
+ */
48
+ declare function decodeDeltaCursor(value: string | undefined | null): DeltaCursor | null;
49
+ /**
50
+ * Slice a causally sorted operation list to resume after a cursor.
51
+ */
52
+ declare function sliceOperationsAfterCursor(operations: _korajs_core.Operation[], cursor: DeltaCursor | null): _korajs_core.Operation[];
53
+ /**
54
+ * Build a cursor from the last operation in a delta batch.
55
+ */
56
+ declare function createDeltaCursorFromBatch(operations: _korajs_core.Operation[], batchIndex: number): DeltaCursor | null;
57
+
58
+ /**
59
+ * Interface that the local store must implement for sync.
60
+ * This decouples @korajs/sync from @korajs/store — the store satisfies this interface.
61
+ *
62
+ * @korajs/store's Store class already implements these methods:
63
+ * - getVersionVector() — returns the current version vector
64
+ * - getNodeId() — returns this instance's nodeId
65
+ * - applyRemoteOperation(op) — applies a remote op with dedup and merge
66
+ * - getOperationRange(nodeId, fromSeq, toSeq) — fetches operations from the log
67
+ */
68
+ interface SyncStore {
69
+ /** Get the current version vector for this store */
70
+ getVersionVector(): VersionVector;
71
+ /** Get the node ID for this store instance */
72
+ getNodeId(): string;
73
+ /**
74
+ * Apply a remote operation to the local store.
75
+ * Must handle deduplication (content-addressed) and merge resolution.
76
+ * @returns 'applied' if the operation was new, 'duplicate' if already seen, 'skipped' if filtered
77
+ */
78
+ applyRemoteOperation(op: Operation): Promise<ApplyResult>;
79
+ /**
80
+ * Optional: the schema transforms the store folds with (transforms at fold time,
81
+ * RT-84). The engine hands the store every operation exactly as delivered; the store
82
+ * folds its view. When present, it must match the engine's `operationTransforms`.
83
+ */
84
+ getOperationTransforms?(): readonly OperationTransform[];
85
+ /**
86
+ * Get operations from a specific node within a sequence range.
87
+ * Used for computing deltas during sync.
88
+ * @param nodeId - The originating node
89
+ * @param fromSeq - Start sequence number (inclusive)
90
+ * @param toSeq - End sequence number (inclusive)
91
+ */
92
+ getOperationRange(nodeId: string, fromSeq: number, toSeq: number): Promise<Operation[]>;
93
+ /**
94
+ * Optional: re-stamp never-acknowledged local operations after a fast device
95
+ * clock was corrected (timestamp rebase). Optional so hand-rolled SyncStore
96
+ * implementations keep working; when absent the engine simply skips the
97
+ * rebase and falls back to the clock-block behavior.
98
+ *
99
+ * @param ids - Operation ids that are candidates for re-stamping
100
+ * @param correctedNowMs - Trusted "now" (server time at handshake) in ms
101
+ */
102
+ /**
103
+ * Optional: move the device to a fresh node id, re-authoring the given unsynced
104
+ * operations under it (RT-21). Called after the server refused the node id
105
+ * (`NODE_ID_CLAIMED`). Returns the new node id and the rewritten operations.
106
+ */
107
+ rotateNodeId?(ids: string[]): Promise<{
108
+ nodeId: string;
109
+ operations: Operation[];
110
+ }>;
111
+ /**
112
+ * Optional: the server refused the store's pinned node id as another user's (`true`),
113
+ * or accepted it again (`false`). A pinned store refuses local writes meanwhile (F9).
114
+ */
115
+ setPinnedNodeOwnedElsewhere?(owned: boolean): void;
116
+ /**
117
+ * Optional: re-author another local node's given unsynced operations under a fresh
118
+ * node id bound to `principal`, without moving the store's own node. Called when the
119
+ * server refused an adopted node whose writes belong to the signed-in user but that
120
+ * no claims-aware server ever accepted (a beta.12 node with unowned history).
121
+ */
122
+ reauthorLocalNode?(fromNodeId: string, ids: string[], principal: string): Promise<{
123
+ nodeId: string;
124
+ operations: Operation[];
125
+ }>;
126
+ /**
127
+ * Optional: move the device back to a node id this database authored under before
128
+ * (RT-38), without rewriting anything. Used after the server refused the current node
129
+ * (another principal owns it) to try a node the signed-in principal owns.
130
+ */
131
+ switchNodeId?(nodeId: string): Promise<void>;
132
+ /**
133
+ * Optional: bind local writes to the signed-in user (RT-42). Moves the store to the
134
+ * user's own node (or a fresh one) when the current node belongs to another user.
135
+ * `conflict`: the node id is pinned and belongs to another user.
136
+ */
137
+ bindPrincipal?(principal: string): Promise<{
138
+ nodeId: string;
139
+ previousNodeId: string;
140
+ switched: boolean;
141
+ conflict: boolean;
142
+ }>;
143
+ /**
144
+ * Optional durability barrier (RT-35): resolve once every write committed so far is
145
+ * durable on this device; reject when it cannot be made durable. The engine awaits it
146
+ * before any operation leaves the device, so the server never holds an operation the
147
+ * device could lose on reload.
148
+ */
149
+ ensureDurable?(): Promise<void>;
150
+ /**
151
+ * Optional: raise a local node's sequence counter to at least `floor`, in a
152
+ * transaction (RT-35: the server holds operations of this node the device lost).
153
+ * @returns Whether the counter moved
154
+ */
155
+ raiseSequenceFloor?(nodeId: string, floor: number): Promise<boolean>;
156
+ /**
157
+ * Optional: give a local operation a fresh sequence number above `floor` (RT-35: the
158
+ * server refused it with `SEQUENCE_CONFLICT` because it holds another operation of
159
+ * this node, lost locally, under that number). A version-2 operation is re-hashed
160
+ * under a new id; its never-sent dependents (`rewritableDependents`) are rewritten
161
+ * to name the new id, transitively.
162
+ * @returns The renumbered operation, the rewritten dependents and the id mapping,
163
+ * or null when it is not in the log
164
+ */
165
+ resequenceOperation?(operationId: string, nodeId: string, floor: number, rewritableDependents?: readonly string[]): Promise<{
166
+ operation: Operation;
167
+ dependents: Operation[];
168
+ idMapping: Record<string, string>;
169
+ } | null>;
170
+ /**
171
+ * Optional: take over another local node id's unsynced writes (RT-40). Resolves to a
172
+ * release function when no live tab uses it, or null when one does.
173
+ */
174
+ claimLocalNode?(nodeId: string): Promise<(() => void) | null>;
175
+ rebaseUnsyncedOperations?(ids: string[], correctedNowMs: number): Promise<{
176
+ operations: Operation[];
177
+ idMapping: Record<string, string>;
178
+ rebasedCount: number;
179
+ }>;
180
+ /**
181
+ * Optional (W7): the server's node ids whose writes win `merge('server-
182
+ * authoritative')` fields, from the handshake response. The store persists them
183
+ * and folds with them (re-folding affected records when the set changes).
184
+ */
185
+ setAuthoritativeNodeIds?(nodeIds: readonly string[], revokedNodeIds?: readonly string[]): Promise<void>;
186
+ /**
187
+ * Optional: read a record's current materialized field values, used to backfill
188
+ * scope / query-subset fields that a partial update (or a delete) does not carry
189
+ * in its own data. Without it, such an operation would be judged out of scope by
190
+ * its changed fields alone and wrongly dropped from sync. Optional so hand-rolled
191
+ * SyncStore implementations keep working; when absent, the engine falls back to
192
+ * judging visibility from the operation's own data.
193
+ *
194
+ * @returns the record's fields, or null when it cannot be read.
195
+ */
196
+ readRecordFields?(collection: string, recordId: string): Promise<Record<string, unknown> | null>;
197
+ /**
198
+ * Optional: whether the local schema defines this collection. Lets the engine tell a
199
+ * merge-decided `'skipped'` (the record is settled, nothing to keep) from a skip
200
+ * because the collection is unknown (the operation must be quarantined and replayed
201
+ * after a schema upgrade). Without it every `'skipped'` result is quarantined.
202
+ */
203
+ hasCollection?(collection: string): boolean;
204
+ /**
205
+ * Remove a record from this client's materialized authorization view without
206
+ * writing a domain delete to the replicated operation log.
207
+ */
208
+ applyScopeRetraction?(collection: string, recordId: string): Promise<void>;
209
+ /** Retract every currently materialized row outside a newly accepted scope. */
210
+ applyScopeNarrowing?(scopes: Record<string, Record<string, unknown>>): Promise<Array<{
211
+ collection: string;
212
+ recordId: string;
213
+ }>>;
214
+ /**
215
+ * Optional (W7): the delivery stream caught up (its final batch fully applied).
216
+ * The store retires local-only provisional cascades of remote deletes (RT-69) and
217
+ * drops row snapshots whose history a full resync brought back (RT-68). With
218
+ * `provisionalOnly` it only retires the provisional cascades: a stream batch that is
219
+ * not the end of a resync must not settle row snapshots (RT-93).
220
+ *
221
+ * @returns How many row snapshots were dropped
222
+ */
223
+ settleAfterCatchUp?(options?: {
224
+ provisionalOnly?: boolean;
225
+ }): Promise<number>;
226
+ }
227
+
228
+ /** Prefix for handshake rejection when client schema version is unsupported. */
229
+ declare const SCHEMA_MISMATCH_PREFIX = "SCHEMA_MISMATCH";
230
+ /**
231
+ * Returns true when a handshake `rejectReason` indicates unsupported schema version.
232
+ */
233
+ declare function isSchemaMismatchReject(rejectReason: string | undefined): boolean;
234
+ /**
235
+ * Returns true when the client schema version is within the inclusive supported range.
236
+ */
237
+ declare function isClientSchemaVersionSupported(clientVersion: number, supported: {
238
+ min: number;
239
+ max: number;
240
+ }): boolean;
241
+
242
+ type EncodedMessage = string | Uint8Array;
243
+ /**
244
+ * Interface for encoding/decoding sync protocol messages.
245
+ */
246
+ interface MessageSerializer {
247
+ encode(message: SyncMessage): EncodedMessage;
248
+ decode(data: string | Uint8Array | ArrayBuffer): SyncMessage;
249
+ encodeOperation(op: Operation): SerializedOperation;
250
+ decodeOperation(serialized: SerializedOperation): Operation;
251
+ setWireFormat?(format: WireFormat): void;
252
+ getWireFormat?(): WireFormat;
253
+ }
254
+ /**
255
+ * Convert a VersionVector (Map) to a plain object for wire transmission.
256
+ */
257
+ declare function versionVectorToWire(vector: VersionVector): Record<string, number>;
258
+ /**
259
+ * Convert a wire-format version vector (plain object) back to a VersionVector (Map).
260
+ */
261
+ declare function wireToVersionVector(wire: Record<string, number>): VersionVector;
262
+ /**
263
+ * JSON-based message serializer.
264
+ */
265
+ declare class JsonMessageSerializer implements MessageSerializer {
266
+ encode(message: SyncMessage): string;
267
+ decode(data: string | Uint8Array | ArrayBuffer): SyncMessage;
268
+ encodeOperation(op: Operation): SerializedOperation;
269
+ decodeOperation(serialized: SerializedOperation): Operation;
270
+ }
271
+ /**
272
+ * Protobuf-based serializer for sync messages.
273
+ */
274
+ /**
275
+ * Protobuf message serializer.
276
+ *
277
+ * Lossless by construction (SYNC-9): fields with a native protobuf slot are written there;
278
+ * every other top-level member of the message (fields added after the native mapping,
279
+ * and the bodies of the JSON-shaped awareness and Yjs messages) travels in the `extJson`
280
+ * envelope field (49). Decoding therefore yields exactly what the JSON wire would, for
281
+ * every message type, which a property test over every message type pins.
282
+ *
283
+ * It is not negotiated: the sync engine advertises `['json']` only and the server
284
+ * reports the format its transport actually frames with. Use it explicitly, on both
285
+ * ends of a transport, or not at all.
286
+ */
287
+ declare class ProtobufMessageSerializer implements MessageSerializer {
288
+ encode(message: SyncMessage): Uint8Array;
289
+ decode(data: string | Uint8Array | ArrayBuffer): SyncMessage;
290
+ encodeOperation(op: Operation): SerializedOperation;
291
+ decodeOperation(serialized: SerializedOperation): Operation;
292
+ }
293
+ /**
294
+ * Negotiated serializer that supports runtime wire-format switching.
295
+ */
296
+ declare class NegotiatedMessageSerializer implements MessageSerializer {
297
+ private readonly json;
298
+ private readonly protobuf;
299
+ private wireFormat;
300
+ constructor(initialWireFormat?: WireFormat);
301
+ encode(message: SyncMessage): EncodedMessage;
302
+ decode(data: string | Uint8Array | ArrayBuffer): SyncMessage;
303
+ encodeOperation(op: Operation): SerializedOperation;
304
+ decodeOperation(serialized: SerializedOperation): Operation;
305
+ setWireFormat(format: WireFormat): void;
306
+ getWireFormat(): WireFormat;
307
+ }
308
+
309
+ /**
310
+ * WebSocket event interface for dependency injection.
311
+ * Matches the subset of the browser WebSocket API that we need.
312
+ */
313
+ interface WebSocketLike {
314
+ readonly readyState: number;
315
+ send(data: string | Uint8Array): void;
316
+ close(code?: number, reason?: string): void;
317
+ onopen: ((event: unknown) => void) | null;
318
+ onmessage: ((event: {
319
+ data: unknown;
320
+ }) => void) | null;
321
+ onclose: ((event: {
322
+ reason: string;
323
+ code: number;
324
+ }) => void) | null;
325
+ onerror: ((event: unknown) => void) | null;
326
+ }
327
+ /**
328
+ * Constructor for WebSocket-like objects. Allows injection of mock WebSocket for testing.
329
+ */
330
+ type WebSocketConstructor = new (url: string, protocols?: string | string[]) => WebSocketLike;
331
+ /**
332
+ * Options for the WebSocket transport.
333
+ */
334
+ interface WebSocketTransportOptions {
335
+ /** Custom serializer. Defaults to JSON. */
336
+ serializer?: MessageSerializer;
337
+ /** Injectable WebSocket constructor for testing. Defaults to globalThis.WebSocket. */
338
+ WebSocketImpl?: WebSocketConstructor;
339
+ /** Connection timeout in ms. Defaults to 10000 (10s). */
340
+ connectTimeout?: number;
341
+ /**
342
+ * Also append the auth token to the connection URL as `?token=`. Off by
343
+ * default: the token already travels in the handshake message, and URLs are
344
+ * recorded by reverse proxies, load balancers and access logs. Enable only for an
345
+ * intermediary that authenticates the WebSocket upgrade by query parameter.
346
+ */
347
+ tokenInUrl?: boolean;
348
+ /**
349
+ * Detect dead connections with the server's application-level heartbeat (LMS #12).
350
+ * The transport advertises support in the handshake; a server that sends
351
+ * heartbeats states their interval, and a connection that then receives nothing
352
+ * for `heartbeatTimeoutFactor` intervals is closed as dead, which starts a
353
+ * reconnect. Browsers cannot observe WebSocket pings, so without this a half-open
354
+ * connection (a phone that changed networks) looks alive until TCP gives up, often
355
+ * minutes later. Defaults to true.
356
+ */
357
+ heartbeat?: boolean;
358
+ /** Silent intervals tolerated before the connection is declared dead. Defaults to 2.5. */
359
+ heartbeatTimeoutFactor?: number;
360
+ }
361
+ /**
362
+ * WebSocket-based sync transport implementation.
363
+ *
364
+ * Every socket is bound to a generation (SYNC-5): `connect()` closes and detaches the
365
+ * previous socket first, and callbacks of a superseded socket are ignored, so a stale
366
+ * socket closing late can never sever (or feed messages into) the live session.
367
+ */
368
+ declare class WebSocketTransport implements SyncTransport {
369
+ private ws;
370
+ /** Incremented for every socket; callbacks check theirs is still current. */
371
+ private generation;
372
+ private messageHandler;
373
+ private closeHandler;
374
+ private errorHandler;
375
+ private readonly serializer;
376
+ private readonly WebSocketImpl;
377
+ private readonly connectTimeout;
378
+ private readonly tokenInUrl;
379
+ private readonly heartbeat;
380
+ private readonly heartbeatTimeoutFactor;
381
+ /** Silence (ms) after which the current connection is declared dead; 0 = unarmed. */
382
+ private livenessTimeoutMs;
383
+ private livenessTimer;
384
+ constructor(options?: WebSocketTransportOptions);
385
+ connect(url: string, options?: TransportOptions): Promise<void>;
386
+ disconnect(): Promise<void>;
387
+ send(message: SyncMessage): void;
388
+ onMessage(handler: TransportMessageHandler): void;
389
+ onClose(handler: TransportCloseHandler): void;
390
+ onError(handler: TransportErrorHandler): void;
391
+ isConnected(): boolean;
392
+ /** Close and detach the current socket, if any, without firing the close handler. */
393
+ private releaseSocket;
394
+ /** Start watching for silence once the server stated its heartbeat interval. */
395
+ private armLiveness;
396
+ private touchLiveness;
397
+ private clearLiveness;
398
+ }
399
+
400
+ /**
401
+ * Header carrying the server-issued HTTP sync session id. The server returns it on
402
+ * the response to the first POST (the handshake); the client sends it on every
403
+ * later request, together with its bearer credential.
404
+ */
405
+ declare const HTTP_SYNC_SESSION_HEADER = "x-kora-session";
406
+ interface HttpLongPollingTransportOptions {
407
+ serializer?: MessageSerializer;
408
+ fetchImpl?: typeof fetch;
409
+ retryDelayMs?: number;
410
+ preferWebSocket?: boolean;
411
+ webSocketFactory?: () => SyncTransport;
412
+ }
413
+ /**
414
+ * HTTP long-polling transport with optional WebSocket upgrade.
415
+ *
416
+ * The first POST (the handshake) opens a server-side session; the server answers
417
+ * with a high-entropy session id in the `x-kora-session` header. Every later
418
+ * request carries that id and the bearer credential, and the long-poll loop starts
419
+ * only once the id is known. POSTs are sent one at a time, in order. A 401, 403,
420
+ * 404 or 410 means the server-side session is gone: the transport closes so the
421
+ * sync engine reconnects (and re-authenticates) from scratch.
422
+ */
423
+ declare class HttpLongPollingTransport implements SyncTransport {
424
+ private readonly serializer;
425
+ private readonly fetchImpl;
426
+ private readonly retryDelayMs;
427
+ private readonly preferWebSocket;
428
+ private readonly webSocketFactory;
429
+ private messageHandler;
430
+ private closeHandler;
431
+ private errorHandler;
432
+ private connected;
433
+ private polling;
434
+ private url;
435
+ private authToken;
436
+ private pollAbort;
437
+ private upgradedTransport;
438
+ /** Server-issued session id; null until the handshake POST is answered. */
439
+ private sessionId;
440
+ /** Serializes POSTs so the server receives messages in send order. */
441
+ private postChain;
442
+ constructor(options?: HttpLongPollingTransportOptions);
443
+ connect(url: string, options?: TransportOptions): Promise<void>;
444
+ disconnect(): Promise<void>;
445
+ send(message: SyncMessage): void;
446
+ onMessage(handler: TransportMessageHandler): void;
447
+ onClose(handler: TransportCloseHandler): void;
448
+ onError(handler: TransportErrorHandler): void;
449
+ isConnected(): boolean;
450
+ private tryUpgradeToWebSocket;
451
+ private postMessage;
452
+ private startPolling;
453
+ /** The server-side session is gone: stop and report a close so the engine reconnects. */
454
+ private endSession;
455
+ private runPollLoop;
456
+ /** Accept, bearer credential and (once issued) the session id: sent on every request. */
457
+ private makeHeaders;
458
+ }
459
+
460
+ /**
461
+ * Configuration for the chaos transport.
462
+ */
463
+ interface ChaosConfig {
464
+ /** Probability of dropping a message (0-1). Defaults to 0. */
465
+ dropRate?: number;
466
+ /** Probability of duplicating a message (0-1). Defaults to 0. */
467
+ duplicateRate?: number;
468
+ /** Probability of reordering messages (0-1). Defaults to 0. */
469
+ reorderRate?: number;
470
+ /** Maximum latency in ms for delayed messages. Defaults to 0. */
471
+ maxLatency?: number;
472
+ /** Injectable random source for deterministic testing. Returns value in [0, 1). */
473
+ randomSource?: () => number;
474
+ /**
475
+ * Deterministic targeted drop. When it returns true for a message and direction,
476
+ * that message is dropped unconditionally (independent of `dropRate`). Enables
477
+ * reproducing an exact gap (e.g. drop one specific operation) instead of relying
478
+ * on probabilistic drops. `direction` is 'outgoing' for `send` and 'incoming' for
479
+ * messages arriving from the inner transport.
480
+ */
481
+ dropPredicate?: (message: SyncMessage, direction: 'outgoing' | 'incoming') => boolean;
482
+ }
483
+ /**
484
+ * Chaos transport that wraps another transport and injects faults.
485
+ * Used for testing sync convergence under unreliable network conditions.
486
+ *
487
+ * Supports message dropping, duplication, reordering, and latency injection.
488
+ * All random behavior is injectable for deterministic, reproducible tests.
489
+ */
490
+ declare class ChaosTransport implements SyncTransport {
491
+ private readonly inner;
492
+ private readonly dropRate;
493
+ private readonly duplicateRate;
494
+ private readonly reorderRate;
495
+ private readonly maxLatency;
496
+ private readonly random;
497
+ private readonly dropPredicate;
498
+ private messageHandler;
499
+ private reorderBuffer;
500
+ private pending;
501
+ constructor(inner: SyncTransport, config?: ChaosConfig);
502
+ connect(url: string, options?: TransportOptions): Promise<void>;
503
+ disconnect(): Promise<void>;
504
+ send(message: SyncMessage): void;
505
+ onMessage(handler: TransportMessageHandler): void;
506
+ onClose(handler: TransportCloseHandler): void;
507
+ onError(handler: TransportErrorHandler): void;
508
+ isConnected(): boolean;
509
+ private handleIncoming;
510
+ private deliverIncoming;
511
+ private flushReorderBuffer;
512
+ }
513
+
514
+ /**
515
+ * Cursor position within a richtext field.
516
+ * Uses Yjs-compatible anchor/head positions for editor interop.
517
+ */
518
+ interface AwarenessCursor {
519
+ /** Collection containing the record being edited */
520
+ collection: string;
521
+ /** ID of the record being edited */
522
+ recordId: string;
523
+ /** Field name of the richtext field */
524
+ field: string;
525
+ /** Cursor anchor position in Y.Text (start of selection) */
526
+ anchor: number;
527
+ /** Cursor head position in Y.Text (end of selection, same as anchor if no selection) */
528
+ head: number;
529
+ }
530
+ /**
531
+ * User identity information for presence display.
532
+ */
533
+ interface AwarenessUser {
534
+ /** Display name of the user */
535
+ name: string;
536
+ /** Hex color for cursor/selection rendering (e.g. '#ff0000') */
537
+ color: string;
538
+ /** Optional avatar URL */
539
+ avatar?: string;
540
+ }
541
+ /**
542
+ * Per-client awareness state. Ephemeral -- not persisted, only shared with connected peers.
543
+ * Compatible with Yjs awareness protocol format for interop with existing editors.
544
+ */
545
+ interface AwarenessState {
546
+ /** User identity information */
547
+ user: AwarenessUser;
548
+ /** Current cursor position, if any */
549
+ cursor?: AwarenessCursor;
550
+ }
551
+ /**
552
+ * Internal awareness message format used between AwarenessManager and transport layer.
553
+ */
554
+ interface AwarenessMessage {
555
+ type: 'awareness';
556
+ /** Client ID of the sender */
557
+ clientId: number;
558
+ /** All known awareness states. null value means removal. */
559
+ states: Record<number, AwarenessState | null>;
560
+ }
561
+ /**
562
+ * Describes a change in awareness states.
563
+ * Emitted when remote clients update or remove their presence.
564
+ */
565
+ interface AwarenessChange {
566
+ /** Client IDs whose states were added */
567
+ added: number[];
568
+ /** Client IDs whose states were updated */
569
+ updated: number[];
570
+ /** Client IDs whose states were removed */
571
+ removed: number[];
572
+ }
573
+ /**
574
+ * Cursor information for rendering in editors.
575
+ * This is the developer-facing type -- simplified from internal awareness state.
576
+ * Editor-agnostic: provides data that can be rendered by TipTap, ProseMirror, Quill, etc.
577
+ */
578
+ interface CursorInfo {
579
+ /** Unique client ID */
580
+ clientId: number;
581
+ /** User display name */
582
+ userName: string;
583
+ /** Hex color for cursor rendering */
584
+ color: string;
585
+ /** Cursor anchor position (start of selection) */
586
+ anchor: number;
587
+ /** Cursor head position (end of selection) */
588
+ head: number;
589
+ }
590
+
591
+ /**
592
+ * Callback for awareness change events.
593
+ */
594
+ type AwarenessChangeListener = (change: AwarenessChange) => void;
595
+ /**
596
+ * Manages collaborative awareness state (cursor positions, user presence).
597
+ *
598
+ * Awareness is ephemeral -- states are never persisted, only shared with
599
+ * currently connected peers via the sync transport. This implements a
600
+ * lightweight protocol compatible with Yjs awareness semantics.
601
+ *
602
+ * Each AwarenessManager has a unique client ID and tracks both its own
603
+ * local state and all remote clients' states.
604
+ */
605
+ declare class AwarenessManager {
606
+ /** Unique client ID for this instance */
607
+ readonly clientId: number;
608
+ private localState;
609
+ private readonly remoteStates;
610
+ private readonly listeners;
611
+ private readonly emitter;
612
+ private readonly timeoutMs;
613
+ private readonly lastUpdated;
614
+ private cleanupTimer;
615
+ private sendHandler;
616
+ private destroyed;
617
+ constructor(options?: {
618
+ clientId?: number;
619
+ emitter?: KoraEventEmitter;
620
+ timeoutMs?: number;
621
+ });
622
+ /**
623
+ * Set the local user's awareness state and broadcast to peers.
624
+ *
625
+ * @param state - The awareness state to share. Pass null to clear presence.
626
+ */
627
+ setLocalState(state: AwarenessState | null): void;
628
+ /**
629
+ * Get the local awareness state.
630
+ */
631
+ getLocalState(): AwarenessState | null;
632
+ /**
633
+ * Get all known awareness states (local + remote).
634
+ * Returns a new Map on each call.
635
+ */
636
+ getStates(): Map<number, AwarenessState>;
637
+ /**
638
+ * Handle an incoming awareness message from the transport.
639
+ * This processes remote state updates and notifies listeners.
640
+ */
641
+ handleRemoteMessage(message: AwarenessMessage): void;
642
+ /**
643
+ * Remove a specific remote client's awareness state.
644
+ * Called when the server notifies that a client has disconnected.
645
+ */
646
+ removeClient(clientId: number): void;
647
+ /**
648
+ * Register a handler for sending awareness messages through the transport.
649
+ * The sync engine calls this to wire outgoing awareness messages to the transport.
650
+ */
651
+ onSend(handler: (message: AwarenessMessage) => void): void;
652
+ /**
653
+ * Register a listener for awareness state changes.
654
+ * Returns an unsubscribe function.
655
+ */
656
+ on(_event: 'change', listener: AwarenessChangeListener): () => void;
657
+ /**
658
+ * Remove a specific change listener.
659
+ */
660
+ off(_event: 'change', listener: AwarenessChangeListener): void;
661
+ /**
662
+ * Start the cleanup timer that removes stale remote states.
663
+ * Called when the sync engine transitions to streaming state.
664
+ */
665
+ startCleanupTimer(): void;
666
+ /**
667
+ * Stop the cleanup timer.
668
+ */
669
+ stopCleanupTimer(): void;
670
+ /**
671
+ * Clean up all resources. After calling destroy(), the manager
672
+ * will no longer send or receive awareness updates.
673
+ * Broadcasts removal of local state before shutting down.
674
+ */
675
+ destroy(): void;
676
+ private cleanupStaleStates;
677
+ private notifyListeners;
678
+ private emitAwarenessEvent;
679
+ }
680
+
681
+ /**
682
+ * A blob-chunk message in decoded form (chunk bytes as a `Uint8Array` rather
683
+ * than the base64 wire string). This is the shape callers of the channel work
684
+ * with; the channel handles base64 encode/decode at the wire boundary.
685
+ *
686
+ * The shape mirrors `@korajs/store`'s `ChunkMessage` so an app-layer adapter can
687
+ * bridge the two with no field translation.
688
+ */
689
+ type BlobChunkChannelMessage = {
690
+ type: 'blob-chunk-request';
691
+ requestId: string;
692
+ hash: string;
693
+ } | {
694
+ type: 'blob-chunk-response';
695
+ requestId: string;
696
+ bytes: Uint8Array | null;
697
+ /** The server refused the request for rate; retry after `retryAfterMs` (RT-24). */
698
+ throttled?: boolean;
699
+ retryAfterMs?: number;
700
+ } | {
701
+ type: 'blob-chunk-push';
702
+ hash: string;
703
+ bytes: Uint8Array;
704
+ };
705
+ /** Encode chunk bytes to a base64 string for the JSON wire. */
706
+ declare function encodeBlobChunkBytes(bytes: Uint8Array): string;
707
+ /** Decode base64 chunk bytes from the wire back to a `Uint8Array`. */
708
+ declare function decodeBlobChunkBytes(encoded: string): Uint8Array;
709
+ interface BlobChunkChannelOptions {
710
+ /**
711
+ * Called when a local blob-chunk message should go out on the wire. The
712
+ * caller (the sync engine) decides whether the connection is ready to send.
713
+ */
714
+ onSend?: (message: BlobChunkRequestMessage | BlobChunkResponseMessage | BlobChunkPushMessage) => void;
715
+ }
716
+ /**
717
+ * Ephemeral side channel for out-of-band blob chunk transfer over the sync
718
+ * connection. Symmetric: a peer can both request chunks it needs and answer
719
+ * requests for chunks it holds. Never persisted in the operation log — durable
720
+ * state is the `BlobRef` inside a record's fields; this channel only moves the
721
+ * referenced bytes.
722
+ *
723
+ * The channel owns the base64 wire encoding so callers work purely in
724
+ * `Uint8Array`, and its {@link BlobChunkChannelMessage} shape mirrors
725
+ * `@korajs/store`'s `ChunkMessage` so the app layer can expose it as a
726
+ * `ChunkMessagePort` without translating fields.
727
+ */
728
+ declare class BlobChunkChannel {
729
+ private readonly onSend;
730
+ private readonly handlers;
731
+ constructor(options?: BlobChunkChannelOptions);
732
+ /**
733
+ * Send a blob-chunk message to the peer. Response bytes are base64-encoded
734
+ * onto the wire here; callers pass a `Uint8Array`.
735
+ */
736
+ send(message: BlobChunkChannelMessage): void;
737
+ /**
738
+ * Register a handler for inbound blob-chunk messages. Multiple handlers are
739
+ * allowed; each receives every delivered message.
740
+ */
741
+ onMessage(handler: (message: BlobChunkChannelMessage) => void): () => void;
742
+ /**
743
+ * Deliver an inbound wire message to registered handlers, decoding response
744
+ * bytes from base64 back to a `Uint8Array` first.
745
+ */
746
+ deliver(message: BlobChunkRequestMessage | BlobChunkResponseMessage | BlobChunkPushMessage): void;
747
+ }
748
+
749
+ /**
750
+ * Configuration for the SyncMetricsCollector.
751
+ */
752
+ interface MetricsCollectorConfig {
753
+ /** Number of RTT samples to keep for percentile calculations. Defaults to 100. */
754
+ rttWindowSize?: number;
755
+ /** Number of bandwidth samples to keep. Defaults to 20. */
756
+ bandwidthWindowSize?: number;
757
+ /** Interval in ms between periodic diagnostics emissions. Defaults to 5000. */
758
+ diagnosticsInterval?: number;
759
+ /** Injectable time source for deterministic testing. */
760
+ timeSource?: TimeSource;
761
+ }
762
+
763
+ /**
764
+ * WebCrypto primitives of the key hierarchy (ENC-1, D4b, record format 2):
765
+ *
766
+ * passphrase --PBKDF2-SHA256(salt, iterations)--> KEK (AES-256-GCM, non-extractable)
767
+ * KEK --AES-GCM (AAD: keyring, ring)--> master key (32 random bytes)
768
+ * recovery public key --ECDH P-256, HKDF-SHA256, AES-GCM--> master key (optional)
769
+ * master key --HKDF-SHA256 "data-key-wrap"--> wrapping key (AES-256-GCM)
770
+ * master key --HKDF-SHA256 "record-mac"--> MAC key (HMAC-SHA256)
771
+ * wrapping key --AES-GCM wrap (AAD: keyring, version, keyId)--> data key v1, v2, ...
772
+ * master key --HKDF-SHA256 "master-id"--> master id (names a master a passphrase change retires)
773
+ * recovery anchor key (a data key created WITH the recovery key) --HMAC-SHA256 over
774
+ * (keyring, ring id, key id, recovery public key)--> anchor (16 bytes, in the recovery key)
775
+ *
776
+ * Raw master-key bytes exist only inside the calls that create, re-wrap or open the
777
+ * master key, and are zeroed afterwards. What a device keeps is the two derived keys
778
+ * ({@link MasterKeys}), both non-extractable. Data keys are extractable only for the
779
+ * moment they are wrapped; the copy a device encrypts with is non-extractable.
780
+ */
781
+ /** Thrown when a key record cannot be opened with the given passphrase or recovery key. */
782
+ declare class KeyUnwrapError extends SyncError {
783
+ constructor(message: string, context?: Record<string, unknown>);
784
+ }
785
+ /** The keys a device derives from (and keeps instead of) a ring's master key. */
786
+ interface MasterKeys {
787
+ /** Wraps and unwraps the ring's data keys. */
788
+ wrapKey: CryptoKey;
789
+ /** Authenticates the whole key record. */
790
+ macKey: CryptoKey;
791
+ /**
792
+ * Public name of the master key (`m-` and 32 hex digits, an HKDF output of the master
793
+ * key): what a record's `retiredMasters` lists after a passphrase change. Reveals
794
+ * nothing about the key; only a holder of the master key can compute it.
795
+ */
796
+ id: string;
797
+ }
798
+
799
+ /**
800
+ * What a device keeps of an unlocked keyring: the last key record it authenticated and
801
+ * accepted (also the pin: its ring id and revision are the floor for every later
802
+ * record), the passphrase KEK when known, the keys derived from the ring's master key
803
+ * (to authenticate later records and wrap new data keys), and every data key it
804
+ * opened. Keys are NON-EXTRACTABLE CryptoKeys: script on the page can use them while it
805
+ * runs, but cannot read their bytes. A locked keyring keeps the record (the pin) and
806
+ * nothing else.
807
+ */
808
+ interface CachedKeyring {
809
+ record: WrappedKeyRecord;
810
+ kek: CryptoKey | null;
811
+ /** Derived from the ring's master key; null while locked. */
812
+ master?: MasterKeys | null;
813
+ keys: Array<{
814
+ keyVersion: number;
815
+ keyId: string;
816
+ key: CryptoKey;
817
+ }>;
818
+ /**
819
+ * Ids of master keys a passphrase change retired, from every record this device
820
+ * authenticated (RT-107). Kept when locked: records authenticated only by one of them
821
+ * stay refused across restarts.
822
+ */
823
+ retiredMasters?: string[];
824
+ /**
825
+ * Ids of master keys this device held when the server served a NEWER revision of the
826
+ * same ring that they do not authenticate (a passphrase change this device could not
827
+ * read yet). Records authenticated only by one of them are not adopted automatically;
828
+ * an explicit `unlock(passphrase)` decides (RT-107).
829
+ */
830
+ supersededMasters?: string[];
831
+ }
832
+ /** Where a device keeps unlocked keyrings between app starts. */
833
+ interface KeyCache {
834
+ /** Which kind of cache this is (for status and docs). */
835
+ readonly kind: 'indexeddb' | 'memory' | 'none';
836
+ load(id: string): Promise<CachedKeyring | null>;
837
+ save(id: string, entry: CachedKeyring): Promise<void>;
838
+ clear(id: string): Promise<void>;
839
+ }
840
+ /** Keeps unlocked keyrings for the lifetime of the process only. */
841
+ declare class MemoryKeyCache implements KeyCache {
842
+ readonly kind: "memory";
843
+ private readonly entries;
844
+ load(id: string): Promise<CachedKeyring | null>;
845
+ save(id: string, entry: CachedKeyring): Promise<void>;
846
+ clear(id: string): Promise<void>;
847
+ }
848
+ /** Keeps nothing: the device asks for the passphrase on every start. */
849
+ declare class NoKeyCache implements KeyCache {
850
+ readonly kind: "none";
851
+ load(): Promise<CachedKeyring | null>;
852
+ save(): Promise<void>;
853
+ clear(): Promise<void>;
854
+ }
855
+ /**
856
+ * Keeps unlocked keyrings in IndexedDB (structured clone keeps a CryptoKey
857
+ * non-extractable). Survives reloads, so a device unlocks once and then works offline.
858
+ */
859
+ declare class IndexedDbKeyCache implements KeyCache {
860
+ private readonly databaseName;
861
+ readonly kind: "indexeddb";
862
+ private db;
863
+ /** @param databaseName - IndexedDB database name (one per app database) */
864
+ constructor(databaseName: string);
865
+ load(id: string): Promise<CachedKeyring | null>;
866
+ save(id: string, entry: CachedKeyring): Promise<void>;
867
+ clear(id: string): Promise<void>;
868
+ private open;
869
+ }
870
+ /**
871
+ * The default cache: IndexedDB where it exists (browsers), memory otherwise (Node,
872
+ * tests). See the encryption guide for what each means for offline unlock.
873
+ *
874
+ * @param mode - 'auto' (default), 'indexeddb', 'memory' or 'none'
875
+ * @param databaseName - IndexedDB database name
876
+ */
877
+ declare function createKeyCache(mode?: 'auto' | 'indexeddb' | 'memory' | 'none', databaseName?: string): KeyCache;
878
+
879
+ /**
880
+ * Thrown when encryption of operation data fails.
881
+ */
882
+ declare class EncryptionError extends SyncError {
883
+ constructor(message: string, context?: Record<string, unknown>);
884
+ }
885
+ /**
886
+ * Thrown when decryption of operation data fails.
887
+ * This typically indicates a wrong key, tampered ciphertext, or corrupted data.
888
+ */
889
+ declare class DecryptionError extends SyncError {
890
+ constructor(message: string, context?: Record<string, unknown>);
891
+ }
892
+ /**
893
+ * End-to-end encryption of operations, envelope v2 (protocol v2; ENC-3, NEW-ENC-1).
894
+ *
895
+ * **Envelope.** The plaintext `data`, `previousData` and `atomicOps` move into
896
+ * `op.encrypted` ({@link EncryptedOperationEnvelope}: alg, keyId, keyVersion and one
897
+ * AES-256-GCM ciphertext per member). On the wire `data` is null, or holds only the
898
+ * collection's documented cleartext scope fields (`cleartextFields`), so a schema-aware
899
+ * server stores the operation opaquely and can still evaluate scopes. Metadata (id,
900
+ * nodeId, collection, recordId, timestamp, sequence, causalDeps, schemaVersion) stays in
901
+ * cleartext so the server can route, deduplicate and order.
902
+ *
903
+ * **Binding (ENC-3).** Every ciphertext is authenticated with AES-GCM additional data
904
+ * canonical(nodeId, collection, recordId, type, timestamp, sequenceNumber, field,
905
+ * keyVersion, hashVersion): a ciphertext moved to another operation, record or member,
906
+ * or an envelope whose metadata was rewritten, fails authentication. `data` and
907
+ * `previousData` are always encrypted, even when null, so a delete is authenticated too.
908
+ * The operation id (the version-2 content hash of the PLAINTEXT) is verified after
909
+ * decryption by the sync engine (`verify-inbound.ts`).
910
+ *
911
+ * **Plaintext.** With encryption enabled an operation without an envelope is refused
912
+ * (a server must not be able to inject unauthenticated writes), unless the config opens
913
+ * a migration window (`allowPlaintextMigration`). Protocol-1 payloads (ciphertext inside
914
+ * `data`, no binding) are refused the same way.
915
+ *
916
+ * **Key id.** The envelope names its key material (`keyId`: the keyring's random key
917
+ * id, ENC-1), so a device holding different material reports a diagnosable
918
+ * `KEY_ID_MISMATCH` rather than a bare authentication failure.
919
+ *
920
+ * @example
921
+ * ```typescript
922
+ * const encryptor = SyncEncryptor.fromKeys([{ version: 1, key: dataKey, keyId }])
923
+ * const sealed = await encryptor.encryptOperation(operation)
924
+ * const opened = await encryptor.decryptOperation(sealed)
925
+ * ```
926
+ */
927
+ declare class SyncEncryptor {
928
+ /**
929
+ * Map of key version -> VersionedKey. The current (latest) version is used
930
+ * for encryption. All versions are available for decryption (key rotation).
931
+ */
932
+ private readonly keys;
933
+ /** The current key version used for encryption. */
934
+ private currentVersion;
935
+ /** Fingerprint of each key version's material (cached). */
936
+ private readonly keyIds;
937
+ private readonly cleartextFields;
938
+ private readonly allowPlaintextMigration;
939
+ private constructor();
940
+ /**
941
+ * Creates a SyncEncryptor from a passphrase and an explicit, shared salt.
942
+ *
943
+ * Low-level: apps use the keyring (`createApp` with `sync.encryption`), which
944
+ * distributes random data keys wrapped by the passphrase. This derives the data key
945
+ * directly from the passphrase, so every device must pass the same salt; there is
946
+ * no random-salt default any more (ENC-1: a per-process random salt made every
947
+ * device derive a different key).
948
+ *
949
+ * @param config - Encryption configuration with the passphrase in `key`
950
+ * @param salt - The key-derivation salt, shared by every device (16 bytes or more)
951
+ * @param iterations - Optional PBKDF2 iteration count. Lower it only in tests.
952
+ * @returns A configured SyncEncryptor instance
953
+ * @throws {EncryptionError} If configuration is invalid or the salt is missing
954
+ * @throws {KeyDerivationError} If key derivation fails
955
+ */
956
+ static create(config: SyncEncryptionConfig, salt: Uint8Array, iterations?: number): Promise<SyncEncryptor>;
957
+ /**
958
+ * Creates a SyncEncryptor from pre-derived versioned keys.
959
+ *
960
+ * Use this when you need to support multiple key versions for key rotation,
961
+ * or when you have already derived the keys externally.
962
+ *
963
+ * @param versionedKeys - Array of versioned keys. The highest version is used for encryption.
964
+ * @param options - Cleartext scope fields and the plaintext migration window
965
+ * @returns A configured SyncEncryptor instance
966
+ * @throws {EncryptionError} If no keys are provided
967
+ */
968
+ static fromKeys(versionedKeys: VersionedKey[], options?: EncryptorOptions): SyncEncryptor;
969
+ /**
970
+ * Add a new key version for key rotation.
971
+ *
972
+ * After adding, the new key becomes the current version used for encryption
973
+ * if its version number is higher than the current version. Previously-versioned
974
+ * keys remain available for decrypting older operations.
975
+ *
976
+ * @param key - The new versioned key to add
977
+ * @throws {EncryptionError} If the key version already exists
978
+ */
979
+ addKey(key: VersionedKey): void;
980
+ /** The key registered under an explicit key id (keyring keys), if any. */
981
+ private keyById;
982
+ /**
983
+ * Get the current encryption key version number.
984
+ */
985
+ getCurrentKeyVersion(): number;
986
+ /**
987
+ * The key id written into envelopes for a key version: the keyring's random key id,
988
+ * or for a passphrase-derived key a fingerprint of its salt (never the key itself).
989
+ *
990
+ * @param version - Key version (defaults to the current one)
991
+ */
992
+ getKeyId(version?: number): Promise<string>;
993
+ /**
994
+ * Seal an operation into an envelope v2.
995
+ *
996
+ * Returns a new operation (operations are immutable): `encrypted` holds the
997
+ * ciphertexts, `data` is null or the collection's cleartext scope fields, and
998
+ * `previousData`/`atomicOps` are removed. An operation that already carries an
999
+ * envelope is returned unchanged.
1000
+ *
1001
+ * @param operation - The plaintext operation
1002
+ * @returns The sealed operation
1003
+ * @throws {EncryptionError} If encryption fails
1004
+ */
1005
+ encryptOperation(operation: Operation): Promise<Operation>;
1006
+ /**
1007
+ * Open an envelope v2 and restore the plaintext `data`, `previousData` and
1008
+ * `atomicOps`. The returned operation has no `encrypted` member.
1009
+ *
1010
+ * @param operation - The sealed operation
1011
+ * @returns The plaintext operation (its id is NOT verified here; the caller does)
1012
+ * @throws {DecryptionError} On a plaintext operation (unless the migration window
1013
+ * is open), a protocol-1 payload, an unknown key, a key-id mismatch, or a
1014
+ * ciphertext that fails authentication (tampered, transplanted, wrong key)
1015
+ */
1016
+ decryptOperation(operation: Operation): Promise<Operation>;
1017
+ /**
1018
+ * Same as {@link encryptOperation} for the wire form.
1019
+ *
1020
+ * @param serialized - The serialized operation to encrypt
1021
+ * @returns The sealed serialized operation
1022
+ */
1023
+ encryptSerializedOperation(serialized: SerializedOperation): Promise<SerializedOperation>;
1024
+ /**
1025
+ * Same as {@link decryptOperation} for the wire form.
1026
+ *
1027
+ * @param serialized - The serialized operation to decrypt
1028
+ * @returns The plaintext serialized operation
1029
+ */
1030
+ decryptSerializedOperation(serialized: SerializedOperation): Promise<SerializedOperation>;
1031
+ /**
1032
+ * Encrypt a batch of operations in parallel.
1033
+ *
1034
+ * @param operations - Operations to encrypt
1035
+ * @returns New operations with encrypted data fields
1036
+ */
1037
+ encryptBatch(operations: Operation[]): Promise<Operation[]>;
1038
+ /**
1039
+ * Decrypt a batch of operations in parallel.
1040
+ *
1041
+ * @param operations - Operations to decrypt
1042
+ * @returns New operations with decrypted data fields
1043
+ */
1044
+ decryptBatch(operations: Operation[]): Promise<Operation[]>;
1045
+ /**
1046
+ * Check if an operation `data` field holds a protocol-1 encrypted payload (the
1047
+ * pre-envelope format, refused since protocol v2).
1048
+ *
1049
+ * @param field - An operation's `data` or `previousData` field
1050
+ */
1051
+ static isEncryptedPayload(field: Record<string, unknown> | null): boolean;
1052
+ /**
1053
+ * Whether a plaintext operation touches only data the server can already read: its
1054
+ * `data` (and, except for a delete, its `previousData`) names only the collection's
1055
+ * cleartext scope fields, and it has no atomic ops. The sync engine accepts such an
1056
+ * operation from a server-authored node (a cascade, a set-null of a cleartext
1057
+ * reference, a constraint correction of a cleartext field) under end-to-end
1058
+ * encryption: the server cannot seal it (it has no key), and it reveals or forges
1059
+ * nothing the server does not already hold in cleartext. Any sealed field makes it
1060
+ * false, so the operation is refused as plaintext.
1061
+ *
1062
+ * @param operation - A delivered operation without an envelope
1063
+ */
1064
+ isCleartextOnly(operation: Operation): boolean;
1065
+ /** Whether an operation carries an envelope v2. */
1066
+ static isEncryptedOperation(operation: {
1067
+ encrypted?: unknown;
1068
+ }): boolean;
1069
+ private seal;
1070
+ private open;
1071
+ /** The collection's documented cleartext scope fields, or null when none. */
1072
+ private cleartextScope;
1073
+ private encryptMember;
1074
+ private decryptMember;
1075
+ }
1076
+ /** Options of {@link SyncEncryptor.fromKeys}. */
1077
+ interface EncryptorOptions {
1078
+ /** Per collection, the data fields kept in cleartext beside the envelope (for scopes). */
1079
+ cleartextFields?: Readonly<Record<string, readonly string[]>>;
1080
+ /** Accept plaintext (envelope-less) inbound operations during a migration window. */
1081
+ allowPlaintextMigration?: boolean;
1082
+ }
1083
+ /**
1084
+ * Check if a field value contains a protocol-1 encrypted payload (ciphertext inside
1085
+ * `data`). Protocol v2 refuses such payloads; the envelope lives in `op.encrypted`.
1086
+ *
1087
+ * @param field - An operation's `data` or `previousData` field
1088
+ * @returns true if the field contains a protocol-1 encrypted payload
1089
+ */
1090
+ declare function isEncryptedPayload(field: Record<string, unknown> | null): boolean;
1091
+
1092
+ /** Lock state of an encryption keyring. */
1093
+ type EncryptionLockState = 'locked' | 'unlocking' | 'unlocked' | 'error';
1094
+ /** Why a keyring is locked or in error (or, while unlocked, what needs attention). */
1095
+ type EncryptionStatusCode = 'NO_PASSPHRASE' | 'AWAITING_SERVER' | 'LOCKED_BY_APP' | 'WRONG_PASSPHRASE' | 'PASSPHRASE_REQUIRED' | 'KEY_RECORD_INVALID' | 'KEY_RECORD_ROLLBACK' | 'KEY_RECORD_MISSING' | 'KEY_RING_FORK' | 'KEY_SERVICE_FORBIDDEN' | 'KEY_SERVICE_UNSUPPORTED' | 'RECOVERY_FAILED';
1096
+ /** Status of an encryption keyring, as `app.encryption.getStatus()` reports it. */
1097
+ interface EncryptionStatus {
1098
+ state: EncryptionLockState;
1099
+ keyring: string;
1100
+ /** Key version new operations are encrypted with, or null while locked. */
1101
+ keyVersion: number | null;
1102
+ /** Key id of that version, or null while locked. */
1103
+ keyId: string | null;
1104
+ /** Every key version this device can decrypt. */
1105
+ availableVersions: number[];
1106
+ /** Where unlocked keys are kept between starts. */
1107
+ cache: KeyCache['kind'];
1108
+ /**
1109
+ * Set while locked or in error. While `unlocked`, `PASSPHRASE_REQUIRED` means the
1110
+ * passphrase changed on another device: this device keeps working with the keys it
1111
+ * holds, and needs the current passphrase for key management.
1112
+ */
1113
+ code?: EncryptionStatusCode;
1114
+ message?: string;
1115
+ }
1116
+ /** The server's answer to a key-record fetch or write. */
1117
+ interface KeyServiceReply {
1118
+ /** 'conflict': the write lost a compare-and-set; `record` is the current one. */
1119
+ status: 'ok' | 'conflict';
1120
+ record: WrappedKeyRecord | null;
1121
+ /**
1122
+ * With no record: key ids the server's stored operations of this owner are encrypted
1123
+ * with (a sample). Non-empty means encrypted history exists, so the record was lost
1124
+ * and a device that holds the ring must re-upload it; nobody may start a new ring.
1125
+ */
1126
+ knownKeyIds?: string[];
1127
+ }
1128
+ /** How a keyring reaches the server's key service (the sync connection). */
1129
+ interface KeyServiceChannel {
1130
+ fetch(keyring: string): Promise<KeyServiceReply>;
1131
+ put(keyring: string, record: WrappedKeyRecord, expectedRevision: number): Promise<KeyServiceReply>;
1132
+ }
1133
+ /** Options of {@link EncryptionKeyring}. */
1134
+ interface EncryptionKeyringOptions {
1135
+ /** Keyring name (one per user by default). */
1136
+ keyring?: string;
1137
+ /** Passphrase source; without one the keyring starts locked until `unlock()`. */
1138
+ passphrase?: string | (() => Promise<string>);
1139
+ /** Where unlocked keys are kept. Defaults to memory (the app picks IndexedDB in browsers). */
1140
+ cache?: KeyCache;
1141
+ /**
1142
+ * PBKDF2 iterations for new records, and the minimum accepted from the server
1143
+ * (a server cannot downgrade the KDF). Defaults to 600,000. Lower only in tests.
1144
+ */
1145
+ kdfIterations?: number;
1146
+ /** Encryptor options (cleartext scope fields, plaintext migration window). */
1147
+ encryptor?: EncryptorOptions;
1148
+ }
1149
+ /** Thrown by keyring management calls. `context.code` names the reason. */
1150
+ declare class EncryptionKeyError extends SyncError {
1151
+ constructor(message: string, context: Record<string, unknown> & {
1152
+ code: string;
1153
+ });
1154
+ /** The reason code (`context.code`), e.g. WRONG_PASSPHRASE. */
1155
+ get keyCode(): string;
1156
+ }
1157
+ /**
1158
+ * A device's view of one encryption keyring (ENC-1, decision D4b; record format 2): the
1159
+ * server-stored, passphrase-wrapped ring, opened and authenticated locally.
1160
+ *
1161
+ * - **Shared key material.** Every device of a user opens the same record, so every
1162
+ * device holds the same data keys and decrypts everything.
1163
+ * - **Authenticated record.** The record carries an HMAC under a key derived from the
1164
+ * ring's master key; a device acts only on records it verified (RT-95). The server
1165
+ * can store, withhold or replay records, nothing more.
1166
+ * - **Forward only.** A device pins the ring id and highest revision it accepted and
1167
+ * refuses a lower revision (`KEY_RECORD_ROLLBACK`, RT-96), keeping its keys and
1168
+ * re-uploading its newer record when connected.
1169
+ * - **Writes re-read.** Every compare-and-set retry adopts the server's current record
1170
+ * first and wraps under that record's master key only (RT-97).
1171
+ * - **Lost records.** A device holding the ring re-uploads it unchanged; a new device
1172
+ * that finds no record but encrypted history waits (`KEY_RECORD_MISSING`); two rings
1173
+ * that exist anyway are merged by the first device holding both (RT-104).
1174
+ * - **Offline.** Once unlocked, keys are cached (non-extractable) and the keyring needs
1175
+ * no server; a cached record also lets `unlock()` work offline.
1176
+ */
1177
+ declare class EncryptionKeyring {
1178
+ readonly name: string;
1179
+ private passphraseSource;
1180
+ private readonly cache;
1181
+ private readonly kdfIterations;
1182
+ private readonly encryptorOptions;
1183
+ private loadedPrincipal;
1184
+ /** The last record this device authenticated and accepted: the pin. */
1185
+ private record;
1186
+ /** Keys derived from the pinned record's master key. */
1187
+ private master;
1188
+ private kek;
1189
+ /** Salt (base64) the KEK was derived with. */
1190
+ private kekSalt;
1191
+ /** Held data keys by key id. Invariant: every held key id is in `record`. */
1192
+ private readonly dataKeys;
1193
+ /**
1194
+ * Ids of master keys a passphrase change retired, from every record this device
1195
+ * authenticated (persisted with the cache): a record authenticated only by one of
1196
+ * them is refused (RT-107).
1197
+ */
1198
+ private readonly retiredMasters;
1199
+ /**
1200
+ * Ids of master keys this device held when the server served a newer revision of the
1201
+ * same ring that they do not authenticate (most likely a passphrase change made
1202
+ * elsewhere). A record authenticated only by one of them is not adopted without an
1203
+ * explicit `unlock(passphrase)`: after a genuine change, no later genuine record is
1204
+ * authenticated by the old master key (RT-107).
1205
+ */
1206
+ private readonly supersededMasters;
1207
+ private encryptor;
1208
+ private status;
1209
+ private readonly listeners;
1210
+ private lockedByApp;
1211
+ private pendingPassphrase;
1212
+ /** A recovery requested while offline, run at the next handshake. */
1213
+ private pendingRecovery;
1214
+ /** The configured passphrase that failed for a salt (not retried for that salt). */
1215
+ private failedSource;
1216
+ /** The current lock is a server-side condition a later session can clear (no user action). */
1217
+ private retryable;
1218
+ /** startNewKeyring() was called: a missing record with encrypted history may be replaced. */
1219
+ private allowNewRing;
1220
+ private failedUnlocks;
1221
+ private nextUnlockAt;
1222
+ private mutex;
1223
+ constructor(options?: EncryptionKeyringOptions);
1224
+ /** The encryptor of the unlocked keyring, or null while locked. */
1225
+ getEncryptor(): SyncEncryptor | null;
1226
+ /** Current status. */
1227
+ getStatus(): EncryptionStatus;
1228
+ /** Whether this device holds the data key with this key id. */
1229
+ holdsKeyId(keyId: string): boolean;
1230
+ /**
1231
+ * Whether the current lock is a server-side condition (a rolled-back, missing,
1232
+ * malformed or forked record) that a later sync session can clear without the user:
1233
+ * sync should reconnect with backoff rather than wait for `unlock()`.
1234
+ */
1235
+ isRetryableLock(): boolean;
1236
+ /** Subscribe to status changes. Returns an unsubscribe function. */
1237
+ onStatusChange(listener: (status: EncryptionStatus) => void): () => void;
1238
+ /**
1239
+ * Whether sync may connect: the keyring is unlocked, or a passphrase is available to
1240
+ * open (or create) the server record during the handshake.
1241
+ */
1242
+ canConnect(): boolean;
1243
+ /**
1244
+ * Load the cached keyring of a principal (the signed-in user, or null). A principal
1245
+ * change drops everything held for the previous one first, so one user's keys never
1246
+ * encrypt another user's operations.
1247
+ */
1248
+ load(principal: string | null): Promise<EncryptionStatus>;
1249
+ /**
1250
+ * Open the keyring against the server's record during a sync handshake: fetch it,
1251
+ * create it on a user's first device, adopt a newer revision, re-upload a lost or
1252
+ * rolled-back one, or merge a forked ring.
1253
+ *
1254
+ * @returns 'ready' when unlocked, 'locked' when a passphrase is needed (or wrong), or
1255
+ * when the server's record must change first ({@link isRetryableLock})
1256
+ * @throws When the key service fails (the session is retried later)
1257
+ */
1258
+ synchronize(channel: KeyServiceChannel, principal: string | null): Promise<'ready' | 'locked'>;
1259
+ /**
1260
+ * Adopt a record the server pushed (another device rotated keys, changed the
1261
+ * passphrase or set up recovery). Without a connection to write to, a rollback or a
1262
+ * fork is reported and resolved at the next handshake.
1263
+ *
1264
+ * @returns 'ready' when still unlocked, 'locked' when the passphrase is needed or the
1265
+ * record was refused
1266
+ */
1267
+ adoptPushed(record: WrappedKeyRecord): Promise<'ready' | 'locked'>;
1268
+ /**
1269
+ * Unlock with a passphrase. With a cached record this works offline; otherwise the
1270
+ * passphrase is kept until the next handshake opens (or creates) the server record,
1271
+ * and the returned promise settles then (pass `channel` when connected).
1272
+ *
1273
+ * Repeated wrong passphrases are slowed down on the device (backoff after three).
1274
+ * This is a usability guard, not a security boundary: whoever holds the record can
1275
+ * try passphrases offline, so passphrase strength and PBKDF2 are the protection.
1276
+ *
1277
+ * @throws {EncryptionKeyError} WRONG_PASSPHRASE, UNLOCK_THROTTLED, KEY_RECORD_MISSING
1278
+ */
1279
+ unlock(passphrase: string, channel: KeyServiceChannel | null): Promise<EncryptionStatus>;
1280
+ /**
1281
+ * Lock: forget the data keys, the master keys and the KEK on this device (cache
1282
+ * included). Sync stops until `unlock()`; local data stays readable (it is not
1283
+ * encrypted at rest by this layer). The pinned record is kept (it is the rollback
1284
+ * floor). The configured passphrase is not used again until `unlock()`.
1285
+ */
1286
+ lock(): Promise<EncryptionStatus>;
1287
+ /**
1288
+ * Rotate: create a new data key version. New operations use it; old versions stay
1289
+ * in the record so history decrypts. Needs the server (compare-and-set). Each retry
1290
+ * adopts the server's current record first and wraps under ITS master key only.
1291
+ */
1292
+ rotate(channel: KeyServiceChannel): Promise<EncryptionStatus>;
1293
+ /**
1294
+ * Change the passphrase: a NEW master key, sealed under a KEK derived from the new
1295
+ * passphrase and a new salt, re-wraps every data key version (and the recovery wrap,
1296
+ * if any). No operation is re-encrypted. The record lists the old master key as
1297
+ * retired (`retiredMasters`): a device that authenticated this record (or a later
1298
+ * one) refuses every record authenticated only by the old master key, however it is
1299
+ * opened. Other devices keep working with their cached data keys (status
1300
+ * `PASSPHRASE_REQUIRED` while unlocked); they need the new passphrase for a key they
1301
+ * do not hold and for key management. A device that was served the new record but
1302
+ * could not read it no longer adopts, without an explicit `unlock()`, a record that
1303
+ * only its old master key authenticates.
1304
+ *
1305
+ * **A passphrase change alone does not contain a leaked passphrase.** The ring is a
1306
+ * shared secret: a device that still holds only the OLD master key cannot tell a
1307
+ * record the server forged under it (with a data key of the attacker's) from a
1308
+ * genuine one, and the server decides which records it forwards. After a suspected
1309
+ * leak: change the passphrase, `rotate()`, call `enableRecovery()` again (a recovery
1310
+ * key made before the change can be steered to a ring the old passphrase built) and
1311
+ * re-unlock EVERY other device with the new passphrase (`lock()`, then
1312
+ * `unlock(newPassphrase)`, and update any configured `key`; never type the old
1313
+ * passphrase again: an explicit unlock with it is the user's decision). A device that
1314
+ * is not re-unlocked, or an old recovery key that is still used, remains exposed.
1315
+ *
1316
+ * @param newPassphrase - The new passphrase
1317
+ * @param channel - The key service
1318
+ * @param currentPassphrase - Optional: verified against the record first
1319
+ */
1320
+ changePassphrase(newPassphrase: string, channel: KeyServiceChannel, currentPassphrase?: string): Promise<EncryptionStatus>;
1321
+ /**
1322
+ * Set up (or replace) the recovery key: the ring's master key is also wrapped to a
1323
+ * new recovery public key, and the private half is returned ONCE. Store it offline;
1324
+ * it recovers the data after a lost passphrase. Without it, a lost passphrase means
1325
+ * the encrypted data is unrecoverable. Needs the passphrase key of the CURRENT
1326
+ * record (the recovery block is authenticated with the record, so only a holder of
1327
+ * the master key can add or replace it).
1328
+ *
1329
+ * The same write adds a new data key version (a rotation) that anchors the recovery
1330
+ * key to this ring: `recover()` accepts only a ring holding that key, which no older
1331
+ * passphrase ever opened (RT-107). After a suspected passphrase leak, call this again
1332
+ * once the passphrase was changed, and destroy the previous recovery key.
1333
+ *
1334
+ * @returns The recovery key (`kora-rk3-<key>.<anchor>`)
1335
+ */
1336
+ enableRecovery(channel: KeyServiceChannel): Promise<string>;
1337
+ /**
1338
+ * Recover after a lost passphrase: open the ring's master key with the recovery key,
1339
+ * verify the whole record with it, and seal the master key under a new passphrase.
1340
+ * Unlocks this device. Without a channel (sync not connected) the recovery runs at
1341
+ * the next handshake. Devices that hold the ring keep working without a prompt (the
1342
+ * master key is unchanged).
1343
+ */
1344
+ recover(recoveryKey: string, newPassphrase: string, channel: KeyServiceChannel | null): Promise<EncryptionStatus>;
1345
+ /**
1346
+ * Last resort after the server lost the key record and no device that holds the ring
1347
+ * will come back (`KEY_RECORD_MISSING`): allow this device to create a new ring.
1348
+ * Operations encrypted under the lost ring stay unreadable on this device. If a device
1349
+ * holding the old ring reconnects later, it merges both rings and the history becomes
1350
+ * readable again. Without a channel (sync is not connected: a missing record ends the
1351
+ * session) the ring is created at the next handshake.
1352
+ *
1353
+ * @throws {EncryptionKeyError} KEY_RECORD_EXISTS when the server has a record (unlock it)
1354
+ */
1355
+ startNewKeyring(channel: KeyServiceChannel | null): Promise<EncryptionStatus>;
1356
+ private recoverLocked;
1357
+ private loadLocked;
1358
+ /**
1359
+ * No record on the server. A device that holds a ring re-uploads it unchanged (same
1360
+ * revision and MAC, so every device's pin still accepts it). A device without one
1361
+ * creates the first ring, unless the server reports encrypted history: then the
1362
+ * record was lost, and it waits for a device that holds the ring.
1363
+ */
1364
+ private createOrRestoreLocked;
1365
+ /** Create a brand-new ring (the user's first device). */
1366
+ private createRingLocked;
1367
+ /**
1368
+ * Accept a server record, after authenticating it.
1369
+ *
1370
+ * - Lower revision of the pinned ring: refused (rollback); re-upload the pin.
1371
+ * - Authenticated by the held master key, or by the passphrase (explicit, else the
1372
+ * configured one): forward when it keeps every key this device holds, else merge.
1373
+ * - Not authenticated: nothing is adopted. A device that holds every key of a newer
1374
+ * revision of its ring keeps working (the passphrase changed elsewhere); otherwise
1375
+ * it needs the passphrase.
1376
+ */
1377
+ private adoptLocked;
1378
+ /** An authenticated record: move forward to it, or merge it with the held ring. */
1379
+ private acceptLocked;
1380
+ /** A lower revision of the pinned ring: refuse it, keep the keys, re-upload the pin. */
1381
+ private rollbackLocked;
1382
+ /**
1383
+ * A record authenticated only by a master key a passphrase change retired: whoever
1384
+ * wrote it knew an OLD passphrase (or master key), not the current one (RT-107).
1385
+ */
1386
+ private refuseRetired;
1387
+ /**
1388
+ * A record authenticated only by a master key this device held when the server served
1389
+ * a newer revision of the ring that the key does not authenticate (RT-107).
1390
+ */
1391
+ private refuseSuperseded;
1392
+ /**
1393
+ * Whether `master` was superseded on this device. An explicit `unlock(passphrase)` is
1394
+ * the user's decision and clears the mark (a server that served a junk revision can
1395
+ * delay, never lock out, a device).
1396
+ */
1397
+ private isSupersededMaster;
1398
+ /** A record this device cannot authenticate: adopt nothing. */
1399
+ private unverifiableLocked;
1400
+ /**
1401
+ * Merge the held ring into `base` (the server's current record, which survives: the
1402
+ * server only accepts append-only successors of what it stores). Keys this device
1403
+ * holds that `base` lacks are appended as new versions; operations keep naming them
1404
+ * by key id. The result has a higher revision than both and is written with
1405
+ * compare-and-set; a lost race adopts (or merges) the winner.
1406
+ */
1407
+ private mergeLocked;
1408
+ /**
1409
+ * Re-wrap the held data keys `base` lacks under `target` (appended after base's
1410
+ * highest version). They are extracted from the pinned record with the held master.
1411
+ */
1412
+ private absorbHeldKeys;
1413
+ /** Open every data key of `record` this device does not hold yet. */
1414
+ private openDataKeys;
1415
+ /**
1416
+ * Whether `record` holds the anchor key of a recovery key: data keys are never removed
1417
+ * or re-keyed (a passphrase change, recovery or merge re-wraps them under the same key
1418
+ * id), so the anchor stays valid for the ring.
1419
+ */
1420
+ private holdsAnchor;
1421
+ /**
1422
+ * Whether `master` (which authenticated `record`) is a master key a passphrase change
1423
+ * retired: listed in `record` itself or in any record this device authenticated.
1424
+ */
1425
+ private isRetiredMaster;
1426
+ /** The next revision after `base`, above the pinned one when it is the same ring. */
1427
+ private nextRevision;
1428
+ /** Authenticate a record with the held master key (no passphrase needed). */
1429
+ private authenticateHeld;
1430
+ /**
1431
+ * Open and authenticate a record with a passphrase.
1432
+ *
1433
+ * @throws {EncryptionKeyError} WRONG_PASSPHRASE, or KEY_RECORD_INVALID when the master
1434
+ * key opens but the record does not authenticate under it (tampered)
1435
+ */
1436
+ private openWithPassphrase;
1437
+ /** The configured or pending passphrase, unless it already failed for this salt. */
1438
+ private sourcePassphraseFor;
1439
+ private followConflictLocked;
1440
+ private commitLocked;
1441
+ /** Persist the pinned record, the held keys and the master marks (RT-107). */
1442
+ private saveCacheLocked;
1443
+ private rebuildEncryptor;
1444
+ private availablePassphrase;
1445
+ private requireMaster;
1446
+ /** The KEK of `record`'s own salt (never one derived for another record's salt). */
1447
+ private requireKek;
1448
+ private requireRecord;
1449
+ private noteFailedUnlock;
1450
+ private dropKeys;
1451
+ /** A record refused for what the server sent: the held keys stay, nothing is adopted. */
1452
+ private refuseRecord;
1453
+ /** A server-side condition: keys kept, a later session (or a push) can clear it. */
1454
+ private holdFor;
1455
+ private lockWith;
1456
+ /** Locks that need the user stop encrypting; records refused keep held keys. */
1457
+ private dropKeysIfUnusable;
1458
+ private statusError;
1459
+ private cacheId;
1460
+ private makeStatus;
1461
+ private setStatus;
1462
+ private exclusive;
1463
+ }
1464
+
1465
+ /** Default snapshot size above which the doc channel is preferred. */
1466
+ declare const DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD = 4096;
1467
+ type RichtextDocListener = (update: Uint8Array) => void;
1468
+ interface RichtextDocChannelOptions {
1469
+ /** Snapshot byte length at which incremental channel sync is recommended. */
1470
+ largeDocThreshold?: number;
1471
+ /** Called when a local update should be sent on the wire. */
1472
+ onSend?: (message: YjsDocUpdateMessage) => void;
1473
+ }
1474
+ /**
1475
+ * Side channel for incremental Yjs updates on large richtext fields.
1476
+ * Ephemeral — not persisted in the operation log (durable state still flows via ops).
1477
+ */
1478
+ declare class RichtextDocChannel {
1479
+ private readonly threshold;
1480
+ private readonly onSend;
1481
+ private readonly listeners;
1482
+ constructor(options?: RichtextDocChannelOptions);
1483
+ /**
1484
+ * Whether the doc channel should be used for a field.
1485
+ * @param preference - `true` forces on, `false` forces off, `undefined` uses size threshold
1486
+ */
1487
+ shouldUseChannel(snapshotByteLength: number, preference?: boolean): boolean;
1488
+ getThreshold(): number;
1489
+ /**
1490
+ * Subscribe to incremental updates for a document key.
1491
+ */
1492
+ subscribe(collection: string, recordId: string, field: string, listener: RichtextDocListener): () => void;
1493
+ /**
1494
+ * Publish a local incremental update to peers.
1495
+ */
1496
+ send(collection: string, recordId: string, field: string, update: Uint8Array): void;
1497
+ /**
1498
+ * Deliver a remote incremental update to local subscribers.
1499
+ */
1500
+ deliver(message: YjsDocUpdateMessage): void;
1501
+ }
1502
+
1503
+ /**
1504
+ * A batch of operations taken from the queue for sending.
1505
+ */
1506
+ interface OutboundBatch {
1507
+ /** Unique identifier for this batch */
1508
+ batchId: string;
1509
+ /** Operations in this batch, in causal order */
1510
+ operations: Operation[];
1511
+ }
1512
+ /**
1513
+ * Outbound operation queue with pluggable persistence.
1514
+ * Manages operations waiting to be sent to the sync server.
1515
+ *
1516
+ * Operations are deduplicated by ID (content-addressed) and maintained
1517
+ * in causal order via topological sort.
1518
+ */
1519
+ declare class OutboundQueue {
1520
+ private readonly storage;
1521
+ private queue;
1522
+ private readonly seen;
1523
+ private readonly inFlight;
1524
+ /**
1525
+ * Ids of queued operations that were put on the wire at least once. Such an op may
1526
+ * already be stored on the server (its ack can be lost), so a clock rebase must never
1527
+ * re-stamp it (W3 step 4). Persisted through the storage when it supports it.
1528
+ */
1529
+ private readonly sent;
1530
+ private nextBatchId;
1531
+ private initialized;
1532
+ constructor(storage: QueueStorage);
1533
+ /**
1534
+ * Load persisted operations from storage.
1535
+ * Must be called before using the queue.
1536
+ */
1537
+ initialize(): Promise<void>;
1538
+ /**
1539
+ * Add an operation to the outbound queue.
1540
+ * Deduplicates by operation ID. Persists to storage.
1541
+ */
1542
+ enqueue(op: Operation): Promise<void>;
1543
+ /**
1544
+ * Add several operations at once: deduplicated by id, persisted, and causally
1545
+ * re-sorted once (rather than once per operation).
1546
+ */
1547
+ enqueueMany(ops: Operation[]): Promise<void>;
1548
+ /**
1549
+ * Take a batch of operations from the front of the queue.
1550
+ * Moves them to in-flight status. Returns null if queue is empty.
1551
+ *
1552
+ * @param batchSize - Maximum number of operations in the batch
1553
+ * @param accept - Only operations it accepts are taken (in queue order); the others
1554
+ * stay queued. The engine passes "authored under this session's node id": a session
1555
+ * may only upload its own node's operations (RT-38, RT-40).
1556
+ */
1557
+ takeBatch(batchSize: number, accept?: (op: Operation) => boolean): OutboundBatch | null;
1558
+ /** Whether any queued (not in-flight) operation satisfies `accept`. */
1559
+ hasOperationsMatching(accept: (op: Operation) => boolean): boolean;
1560
+ /** Number of queued (not in-flight) operations that satisfy `accept`. */
1561
+ countMatching(accept: (op: Operation) => boolean): number;
1562
+ /**
1563
+ * Acknowledge a batch, removing its operations permanently.
1564
+ */
1565
+ acknowledge(batchId: string): Promise<void>;
1566
+ /**
1567
+ * Acknowledge only operations in an in-flight batch whose local sequence number is
1568
+ * covered by the server's ack. Any later operations return to the front of the
1569
+ * queue for retry. This is required when the server accepts a prefix but rejects a
1570
+ * later operation retriably (for example a stale-scope push after auth changed).
1571
+ */
1572
+ acknowledgeThrough(batchId: string, lastSequenceNumber: number): Promise<{
1573
+ acknowledged: Operation[];
1574
+ returned: Operation[];
1575
+ }>;
1576
+ /**
1577
+ * Record that a taken batch was put on the wire. Its operations may now be stored on
1578
+ * the server even if no ack ever arrives, so they are excluded from clock rebases.
1579
+ */
1580
+ markSent(batchId: string): Promise<void>;
1581
+ /** Whether this queued operation was ever put on the wire. */
1582
+ wasSent(opId: string): boolean;
1583
+ /**
1584
+ * Forget the sent flag of operations the server explicitly refused without storing
1585
+ * (for example a batch refused with INVALID_TIMESTAMP), so a clock rebase may re-stamp
1586
+ * them. In memory only: after a restart they are conservatively treated as sent.
1587
+ */
1588
+ clearSent(opIds: string[]): void;
1589
+ /** Whether the queue owns this operation (queued or in flight). */
1590
+ has(opId: string): boolean;
1591
+ /** Every operation currently in flight, across batches. */
1592
+ getInFlight(): Operation[];
1593
+ /**
1594
+ * Replace some queued (not in-flight) operations by rewritten ones, for example after
1595
+ * a clock rebase re-stamped never-sent operations under new content-addressed ids.
1596
+ * Operations not named in `oldIds` are untouched.
1597
+ */
1598
+ replace(oldIds: string[], newOps: Operation[]): Promise<void>;
1599
+ /**
1600
+ * Return a failed batch to the front of the queue for retry.
1601
+ * Prepends the operations to maintain priority.
1602
+ */
1603
+ returnBatch(batchId: string): void;
1604
+ /**
1605
+ * Number of operations waiting in the queue (not counting in-flight).
1606
+ */
1607
+ get size(): number;
1608
+ /**
1609
+ * Total operations including in-flight.
1610
+ */
1611
+ get totalPending(): number;
1612
+ /**
1613
+ * Whether the queue has any operations to send.
1614
+ */
1615
+ get hasOperations(): boolean;
1616
+ /**
1617
+ * Peek at the first `count` operations without removing them.
1618
+ */
1619
+ peek(count: number): Operation[];
1620
+ /**
1621
+ * All queued operations (not counting in-flight), in causal order.
1622
+ */
1623
+ getAll(): Operation[];
1624
+ /**
1625
+ * Atomically replace the entire queue with a new set of operations.
1626
+ *
1627
+ * Used after a timestamp rebase rewrote the queued operations under new
1628
+ * content-addressed ids: the old entries must vanish from memory AND from
1629
+ * persistent storage in one step, or a page refresh could resurrect
1630
+ * stale-stamped ops the server would reject. Resets the seen set to exactly
1631
+ * the new ids so the rewritten ops are not treated as duplicates.
1632
+ */
1633
+ replaceAll(ops: Operation[]): Promise<void>;
1634
+ /**
1635
+ * Whether initialize() has been called.
1636
+ */
1637
+ get isInitialized(): boolean;
1638
+ /**
1639
+ * Remove a single operation the server permanently rejected — from the queue,
1640
+ * from any in-flight batch, and from durable storage — so a later batch ack or
1641
+ * a reconnect `returnBatch` can never resend or resurrect it. Returns the
1642
+ * removed operation so the caller can record it in a durable rejected store,
1643
+ * or null if it was not present (already acknowledged and removed).
1644
+ */
1645
+ reject(opId: string): Promise<Operation | null>;
1646
+ /** Remove every pending/in-flight operation for a record so authorization loss cannot upload it. */
1647
+ rejectRecord(collection: string, recordId: string): Promise<Operation[]>;
1648
+ /**
1649
+ * Remove operations by id from queue and persistent storage.
1650
+ * Used when ops were already sent during handshake delta exchange.
1651
+ */
1652
+ removeByIds(ids: string[]): Promise<void>;
1653
+ }
1654
+
1655
+ /**
1656
+ * Options for creating a SyncEngine.
1657
+ */
1658
+ interface SyncEngineOptions {
1659
+ /** Transport implementation (WebSocket, memory, etc.) */
1660
+ transport: SyncTransport;
1661
+ /** Local store implementing SyncStore */
1662
+ store: SyncStore;
1663
+ /** Sync configuration */
1664
+ config: SyncConfig;
1665
+ /** Message serializer. Defaults to JSON. */
1666
+ serializer?: MessageSerializer;
1667
+ /** Event emitter for DevTools integration */
1668
+ emitter?: KoraEventEmitter;
1669
+ /** Queue storage for persistent outbound queue. Defaults to in-memory. */
1670
+ queueStorage?: QueueStorage;
1671
+ /**
1672
+ * Durable storage for operations the server rejected. Defaults to in-memory.
1673
+ * Provide a store-backed implementation so rejections survive a page refresh.
1674
+ */
1675
+ rejectedStorage?: RejectedOperationStorage;
1676
+ /**
1677
+ * Optional encryptor for end-to-end encryption.
1678
+ * When provided, `data` and `previousData` fields of operations are encrypted
1679
+ * before sending and decrypted after receiving. The server never sees plaintext data.
1680
+ */
1681
+ encryptor?: SyncEncryptor;
1682
+ /**
1683
+ * End-to-end encryption keyring (ENC-1): the server-stored, passphrase-wrapped data
1684
+ * keys, opened during each handshake before any operation is exchanged. Takes the
1685
+ * place of `encryptor`. While it is locked, sync does not connect.
1686
+ */
1687
+ keyring?: EncryptionKeyring;
1688
+ /** How long a key-service request may take before the session is retried (ms). */
1689
+ keyServiceTimeoutMs?: number;
1690
+ /** Optional configuration for the metrics collector. */
1691
+ metricsConfig?: MetricsCollectorConfig;
1692
+ /** Op-log backed sync state (last acked server vector, unsynced counts). */
1693
+ syncState?: SyncStatePersistence;
1694
+ }
1695
+ /**
1696
+ * Diagnostics snapshot for debugging and support.
1697
+ */
1698
+ interface SyncDiagnostics {
1699
+ state: SyncState;
1700
+ status: SyncStatusInfo;
1701
+ nodeId: string;
1702
+ url: string;
1703
+ schemaVersion: number;
1704
+ lastSyncedAt: number | null;
1705
+ lastSuccessfulPush: number | null;
1706
+ lastSuccessfulPull: number | null;
1707
+ conflicts: number;
1708
+ pendingOperations: number;
1709
+ hasInFlightBatch: boolean;
1710
+ reconnecting: boolean;
1711
+ deliveryWatermark: number;
1712
+ deliveryGapRepeatCount: number;
1713
+ timestamp: number;
1714
+ }
1715
+ /**
1716
+ * Core sync orchestrator. Manages the sync lifecycle:
1717
+ * disconnected → connecting → handshaking → syncing → streaming
1718
+ *
1719
+ * Coordinates handshake, delta exchange, and real-time streaming
1720
+ * between a local store and a remote sync server.
1721
+ */
1722
+ declare class SyncEngine {
1723
+ private state;
1724
+ /** Listeners registered through {@link onStateChange}. */
1725
+ private readonly stateListeners;
1726
+ private readonly transport;
1727
+ private readonly store;
1728
+ private readonly config;
1729
+ private readonly serializer;
1730
+ private readonly emitter;
1731
+ private readonly outboundQueue;
1732
+ private readonly rejectedStorage;
1733
+ private readonly batchSize;
1734
+ private readonly outboundAckTimeoutMs;
1735
+ private readonly outboundRetryBaseDelayMs;
1736
+ private readonly outboundRetryMaxDelayMs;
1737
+ private readonly encryptor;
1738
+ private readonly keyring;
1739
+ private readonly keyServiceTimeoutMs;
1740
+ /** Key-service requests awaiting their response, by request id. */
1741
+ private readonly keyRequests;
1742
+ /** Sync is paused because the encryption keyring is locked (ENC-1). */
1743
+ private encryptionLocked;
1744
+ /** A key-record refresh for an unknown key version is running. */
1745
+ private keyRefreshInFlight;
1746
+ private readonly awarenessManager;
1747
+ private readonly richtextDocChannel;
1748
+ private readonly blobChunkChannel;
1749
+ private readonly metricsCollector;
1750
+ private readonly syncState;
1751
+ private remoteVector;
1752
+ /** Runs before each outbound operation batch is sent (see setOutboundPreparer). */
1753
+ private outboundPreparer;
1754
+ /**
1755
+ * Per-device node token the server issued at this node id's first anonymous claim
1756
+ * (RT-12). Presented in every handshake; persisted next to the node id when the
1757
+ * sync-state persistence supports it, otherwise kept for this engine's lifetime.
1758
+ */
1759
+ private nodeToken;
1760
+ /**
1761
+ * Node ids the server named authoritative in its last accepted handshake response
1762
+ * (protocol v2). Null until a protocol-2 server answered in this engine's lifetime;
1763
+ * loaded from persistence on start. See {@link SyncEngine.getAuthoritativeNodeIds}.
1764
+ */
1765
+ private authoritativeNodeIds;
1766
+ /** Protocol version of the server in the current session (1 for a beta.12-era server). */
1767
+ private serverProtocolVersion;
1768
+ /** Whether any server answered a handshake (serverProtocolVersion is then real). */
1769
+ private serverProtocolKnown;
1770
+ private lastAckedServerVector;
1771
+ private cachedUnsyncedCount;
1772
+ private lastSyncedAt;
1773
+ private lastSuccessfulPush;
1774
+ private lastSuccessfulPull;
1775
+ private conflictCount;
1776
+ /**
1777
+ * Every outbound batch taken from the queue and not yet resolved, keyed by queue batch
1778
+ * id: handshake-delta and streaming batches alike. An ack resolves only the batch it
1779
+ * names (SYNC-4); a close returns all of them to the queue.
1780
+ */
1781
+ private readonly inFlightUploads;
1782
+ /** Wire message id -> queue batch id, for ack and rejection correlation. */
1783
+ private readonly uploadByMessageId;
1784
+ /** Incremented on every connect and every disconnect; stale async sends check it. */
1785
+ private sessionEpoch;
1786
+ /** Set by destroy(): the engine never connects or arms a timer again. */
1787
+ private destroyed;
1788
+ private readonly statusListeners;
1789
+ /**
1790
+ * Contiguous acknowledged prefix of this device's own operations (W3 step 2): every
1791
+ * own op with sequence <= this is stored on the server, terminally rejected and
1792
+ * recorded, or not upload-eligible. Never advanced by a server-advertised vector.
1793
+ */
1794
+ private ownAckedThrough;
1795
+ /** Node id the prefix belongs to (a rotation starts a new prefix at 0). */
1796
+ private ownTrackingNodeId;
1797
+ /** Highest own sequence read from the op log this session (above the prefix). */
1798
+ private ownScannedThrough;
1799
+ /**
1800
+ * Own sequences above the prefix that this session knows about, mapped to the ids of
1801
+ * their operations that are still unresolved. An empty set marks a sequence resolved.
1802
+ */
1803
+ private readonly ownUnresolved;
1804
+ /** Ids resolved at sequences the log scan has not reached yet (skip on scan). */
1805
+ private readonly ownResolvedAhead;
1806
+ private ownTrackingChain;
1807
+ /** Inbound quarantine kept in memory when the engine has no sync-state persistence. */
1808
+ private readonly memoryQuarantine;
1809
+ private outboundRetryAttempt;
1810
+ private outboundRetryTimer;
1811
+ /** {@link pushOperation} calls not yet enqueued; the streaming flush waits for 0. */
1812
+ private pendingPushes;
1813
+ private reconnecting;
1814
+ private schemaBlocked;
1815
+ private clockBlocked;
1816
+ private clockSkewMs;
1817
+ private blobStorageEnabled;
1818
+ private blobPossessionProof;
1819
+ private suspensionReason;
1820
+ private authRejected;
1821
+ /**
1822
+ * Set when the server ended the session with AUTH_EXPIRED or AUTH_REVOKED. The
1823
+ * next connect asks the auth callback for a refreshed token (never the cached
1824
+ * one the server just refused) and is cleared by an accepted handshake.
1825
+ */
1826
+ private credentialRefreshRequired;
1827
+ private serverFrontier;
1828
+ private hasInFlightDeliveryBatch;
1829
+ private blockedFailure;
1830
+ private outboundAckTimer;
1831
+ private startPromise;
1832
+ private stopPromise;
1833
+ private reconnectPromise;
1834
+ /** A node-id rotation started by NODE_ID_CLAIMED; the next connect waits for it (RT-21). */
1835
+ private nodeRotation;
1836
+ /**
1837
+ * Node id of the current (or last) session. Usually the store's node id; while the
1838
+ * engine adopts the unsynced writes of a node no live tab uses (RT-40) it is that
1839
+ * node. A session uploads only operations authored under its node id.
1840
+ */
1841
+ private sessionNodeId;
1842
+ /** Node id the in-memory node token belongs to. */
1843
+ private nodeTokenNodeId;
1844
+ /** Releases the lock held on an adopted node (RT-40); null when not adopting. */
1845
+ private releaseAdoption;
1846
+ /**
1847
+ * Ids of operations the server refused for good (RT-36). Cache of the durable markers,
1848
+ * and the only record when the persistence layer has none (engine lifetime).
1849
+ */
1850
+ private readonly terminalRejected;
1851
+ /** Nodes accepted at a handshake, when the persistence layer keeps no registry. */
1852
+ private readonly memoryAcceptedNodes;
1853
+ /** Every node id this database authored operations under (loaded at start). */
1854
+ private localNodeIds;
1855
+ /** Local nodes whose unsynced writes are held for another principal (RT-38). */
1856
+ private heldNodeIds;
1857
+ /** Unsynced, unqueued operations of other uploadable local nodes (RT-40), counted pending. */
1858
+ private otherNodesPending;
1859
+ /** Unsynced, unqueued operations of held nodes (RT-38), reported separately. */
1860
+ private heldPending;
1861
+ /** Held local nodes as of the last session start (RT-50), for status.heldNodes. */
1862
+ private heldNodeInfos;
1863
+ /**
1864
+ * The server holds operations of this node that the device lost (RT-35): the delivery
1865
+ * watermark was reset so the next session resyncs from 0, which fetches them back.
1866
+ * The session ends once its in-flight uploads are resolved.
1867
+ */
1868
+ private ownRecoveryPending;
1869
+ /**
1870
+ * The signed-in user as resolved for the current session (RT-42): a user id, null
1871
+ * (nobody signed in) or undefined (the app does not tell the engine).
1872
+ */
1873
+ private principal;
1874
+ /**
1875
+ * The user whose credential the current session's handshake carried (RT-50, RT-52):
1876
+ * re-read after the credential fetch, so an accepted handshake binds a node to the
1877
+ * user the server accepted it for, never to a user who signed out meanwhile.
1878
+ */
1879
+ private sessionPrincipal;
1880
+ /** Serializes store bindings to the signed-in user (RT-52). */
1881
+ private principalChain;
1882
+ /** Adoption schedule kept in memory when the persistence layer has none (RT-46). */
1883
+ private memorySchedule;
1884
+ /** The schedule had parked adoptions when last read or written (RT-46). */
1885
+ private scheduleHasParked;
1886
+ /**
1887
+ * Local nodes with unsynced writes this session did not adopt (RT-46): parked (no
1888
+ * progress last time) or waiting for another local node's unsynced parent.
1889
+ */
1890
+ private deferredAdoptions;
1891
+ /** Operations acknowledged in this session (RT-46: a yield needs progress). */
1892
+ private sessionAcked;
1893
+ /** An upload of the adopted node came back unprocessed in this session (RT-46). */
1894
+ private adoptionStalled;
1895
+ /**
1896
+ * The session node's entry in this session's handshake vector (RT-44). The server held
1897
+ * nothing of the node above it then; a SEQUENCE_CONFLICT above it was caused by an
1898
+ * operation stored during this session by another live copy of this database.
1899
+ */
1900
+ private handshakeOwnEntry;
1901
+ /** When this engine last reset delivery for a SEQUENCE_CONFLICT recovery (RT-44). */
1902
+ private lastFullResyncAt;
1903
+ /** A recovery full resync was rate-limited; the next session start after the interval runs it. */
1904
+ private fullResyncDeferred;
1905
+ /** Consecutive failed durability barriers before an upload (RT-49). */
1906
+ private durabilityFailures;
1907
+ /** Uploads proceed without local durability: the barrier failed persistently (RT-49). */
1908
+ private durabilityDegraded;
1909
+ private deltaBatchesReceived;
1910
+ private deltaReceiveComplete;
1911
+ private deltaSendComplete;
1912
+ /** Outbound delta batch message IDs awaiting ACK when strictHandshake is enabled */
1913
+ private pendingDeltaBatchAcks;
1914
+ /**
1915
+ * The effective scope for this sync session.
1916
+ * Starts as the configured scopeMap. After handshake, may be replaced
1917
+ * with the server-accepted scope (server is authoritative).
1918
+ */
1919
+ private activeScope;
1920
+ /** Server-authoritative upload authorization, separate from the downloaded view. */
1921
+ private activeUplinkScope;
1922
+ /**
1923
+ * The downlink scope the server accepted at the last handshake that named one
1924
+ * (SYNC-11), persisted. The next handshake reports its key and that view's watermark
1925
+ * so the server can resume the stream of an unchanged grant. Never sent as the
1926
+ * requested scope: a later-widened grant must not be narrowed to it.
1927
+ */
1928
+ private lastAcceptedScope;
1929
+ /** Live query subsets registered from reactive subscriptions */
1930
+ private querySubsets;
1931
+ private staticQuerySubsets;
1932
+ private querySubsetReconnectTimer;
1933
+ /**
1934
+ * Fires when the earliest parked adoption's backoff runs out (RT-53): an idle session of
1935
+ * this tab's own node then hands over to it, so a closed tab's deferred writes are
1936
+ * retried even if this tab never writes or reconnects.
1937
+ */
1938
+ private adoptionRetryTimer;
1939
+ /** Resume cursor for paginated initial sync (persisted across reconnects) */
1940
+ private resumeDeltaCursor;
1941
+ private initialSyncTotalBatches;
1942
+ /**
1943
+ * The delivery watermark for the CURRENT sync view: the highest server delivery
1944
+ * sequence up to which every operation in the current view (scope + active query
1945
+ * subscriptions) has been contiguously applied. Advanced only through the server's
1946
+ * gap-free delivery stream (never through live relay), so it is a durable lower bound
1947
+ * on what this client holds for this view. Reported at handshake to resume the stream.
1948
+ *
1949
+ * The watermark is keyed by view: switching views (a scope or subscription change)
1950
+ * saves the current view's watermark and loads the target view's, so returning to a
1951
+ * previously-synced view resumes exactly where it left off instead of re-syncing. This
1952
+ * is correct because a view's watermark is a lower bound specific to that view's
1953
+ * filter; a widened view is simply a different (initially unsynced) view.
1954
+ */
1955
+ private deliveryWatermark;
1956
+ /**
1957
+ * In-memory cache of watermark per view signature, preloaded from persistence at start
1958
+ * so a view switch resolves synchronously (no race with the reconnect it triggers).
1959
+ */
1960
+ private readonly deliverySignatureWatermarks;
1961
+ /** Schema transforms: the engine checks views, the store folds them (RT-84). */
1962
+ private readonly operationTransforms;
1963
+ private deliveryGapRepeatCount;
1964
+ private lastDeliveryGapKey;
1965
+ constructor(options: SyncEngineOptions);
1966
+ /**
1967
+ * Start the sync engine: connect → handshake → delta exchange → streaming.
1968
+ */
1969
+ start(): Promise<void>;
1970
+ private startInternal;
1971
+ /**
1972
+ * Stop the sync engine. Disconnects the transport.
1973
+ */
1974
+ stop(): Promise<void>;
1975
+ private stopInternal;
1976
+ /**
1977
+ * Stop for good (`app.close()`): disconnect, clear every timer and listener, and refuse
1978
+ * any later start. Unlike stop(), nothing the engine scheduled can run afterwards.
1979
+ */
1980
+ destroy(): Promise<void>;
1981
+ /** Clear every timer the engine owns. */
1982
+ private clearAllTimers;
1983
+ private clearAdoptionRetryTimer;
1984
+ /** Close the transport, logging nothing: it may already be closed. */
1985
+ private closeTransportQuietly;
1986
+ /**
1987
+ * The engine gave up on this session (a message it could not process, a transport
1988
+ * error, a server error): move to disconnected and close the transport (SYNC-5). The
1989
+ * close starts synchronously in this same frame, so the socket is released before
1990
+ * auto-reconnect (which is asynchronous) can open another one.
1991
+ */
1992
+ private abandonSession;
1993
+ /**
1994
+ * Subscribe to status changes that no event announces (an upload ack lowering the
1995
+ * pending count, reaching streaming). Status bridges refresh on it, so a waiter such
1996
+ * as `waitForSettled` sees the change immediately (RT-28).
1997
+ */
1998
+ onStatusChange(listener: () => void): () => void;
1999
+ private notifyStatusChange;
2000
+ private ensureDisconnected;
2001
+ /**
2002
+ * Push a local operation to the outbound queue.
2003
+ * If streaming, flushes immediately.
2004
+ *
2005
+ * Uploads are judged against the UPLINK scope only. Query subsets narrow what
2006
+ * this client downloads, never what it uploads: an edit that moves a record out
2007
+ * of a reactive query's view must still reach the server (SYNC-1).
2008
+ *
2009
+ * Operations on collections that do not sync at all stay local-only by design.
2010
+ * An operation on a synced collection that falls outside the uplink scope would
2011
+ * be refused by the server, so it is recorded in the rejected store and
2012
+ * surfaced as `sync:operation-rejected` (code `OUT_OF_UPLINK_SCOPE`) rather than
2013
+ * kept as a silent local fork.
2014
+ */
2015
+ pushOperation(op: Operation): Promise<void>;
2016
+ /**
2017
+ * Mark the engine as being in a reconnection loop. When reconnecting,
2018
+ * `getStatus()` returns 'offline' instead of 'syncing' for intermediate
2019
+ * states (connecting, handshaking, syncing), since the user is effectively
2020
+ * disconnected until reconnection succeeds.
2021
+ */
2022
+ setReconnecting(value: boolean): void;
2023
+ /**
2024
+ * Get the current developer-facing sync status.
2025
+ */
2026
+ /**
2027
+ * Node ids the sync server named authoritative (protocol v2): operations from these
2028
+ * nodes are server-authored and are the only ones that may carry `fieldVersions` or
2029
+ * `foldState`. Null until a protocol-2 server answered (or one answered in an earlier
2030
+ * session and the list was persisted).
2031
+ */
2032
+ getAuthoritativeNodeIds(): readonly string[] | null;
2033
+ /** Protocol version of the server in the current (or last) session; 1 before any. */
2034
+ getServerProtocolVersion(): number;
2035
+ getStatus(): SyncStatusInfo;
2036
+ /**
2037
+ * Sync is paused on a locked keyring (ENC-1): no session runs until the app unlocks
2038
+ * it. Reported as a suspension (`phase: 'suspended'`, `reason: 'encryption-locked'`,
2039
+ * `status: 'encryption-locked'`) rather than as plain offline, so the UI can ask for
2040
+ * the passphrase and `waitForSettled` reports why it cannot settle.
2041
+ */
2042
+ private isLockedSuspension;
2043
+ private resolvePhase;
2044
+ /**
2045
+ * True when the server rejected the client's schema version at handshake.
2046
+ * Sync stays blocked until the app schema is upgraded or sync config changes.
2047
+ */
2048
+ isSchemaBlocked(): boolean;
2049
+ /**
2050
+ * True when sync is blocked because this device's clock is too far ahead.
2051
+ * Local writes continue to work and queue; only sync is paused.
2052
+ */
2053
+ isClockBlocked(): boolean;
2054
+ /**
2055
+ * True when sync is paused because the end-to-end encryption keyring is locked (no
2056
+ * passphrase, a wrong one, or a key record that must not be used). Local writes go
2057
+ * on and queue; `retryNow()` after an unlock resumes sync.
2058
+ */
2059
+ isEncryptionLocked(): boolean;
2060
+ /**
2061
+ * The key service over the live sync session, for keyring management (rotation,
2062
+ * passphrase change, recovery). Null while not connected: those calls need the
2063
+ * server.
2064
+ */
2065
+ getKeyServiceChannel(): KeyServiceChannel | null;
2066
+ /** serverTime - localTime measured at the last handshake, or null before first connect. */
2067
+ getClockSkewMs(): number | null;
2068
+ /**
2069
+ * Clears the clock block after the user corrects the device clock.
2070
+ * Moves the engine back to `disconnected` so `start()` can run again.
2071
+ */
2072
+ clearClockBlock(): void;
2073
+ /**
2074
+ * Clears schema-mismatch block after upgrading the local schema / sync config.
2075
+ * Moves the engine back to `disconnected` so `start()` can run again.
2076
+ */
2077
+ clearSchemaBlock(): void;
2078
+ /**
2079
+ * Record a merge conflict. Called by the merge-aware sync store
2080
+ * to increment the conflict counter for status reporting.
2081
+ */
2082
+ recordConflict(): void;
2083
+ /**
2084
+ * Count of this device's own operations not yet stored on the server (or terminally
2085
+ * rejected and recorded): queued, in flight, or above the acknowledged prefix.
2086
+ */
2087
+ getUnsyncedOperationCount(): Promise<number>;
2088
+ /**
2089
+ * Surface an inbound apply failure. `blocking` marks the stream as stalled on this op
2090
+ * (the watermark does not pass it); `quarantined` means the op is durably recorded and
2091
+ * the stream moves on, so it never marks the stream blocked, whatever `retriable` says
2092
+ * (retriable there means the quarantine replays it later).
2093
+ */
2094
+ private emitApplyFailure;
2095
+ /**
2096
+ * Force an immediate reconnection attempt. If the engine is disconnected
2097
+ * or in error state, restarts the sync. If already connected, no-op.
2098
+ */
2099
+ retryNow(): Promise<void>;
2100
+ /** Wake a permanent auth suspension after the binding reports a new auth state. */
2101
+ notifyAuthChanged(): void;
2102
+ /**
2103
+ * The signed-in user may have changed (RT-42): end the live session (it belongs to the
2104
+ * previous user), then bind the store to the user `config.principal` reports, so the
2105
+ * next local write is authored under that user's own node. Call it before the next
2106
+ * write after an auth change; `createApp` does so for `sync.authClient`. The next
2107
+ * `start()` connects as that user's node.
2108
+ */
2109
+ refreshPrincipal(): Promise<void>;
2110
+ /**
2111
+ * Bind the store to the user the app reports NOW (RT-52), without ending or waiting
2112
+ * for any session: call it on every auth event, before queueing the reconnect that
2113
+ * follows. Writes made from then on are authored under that user's node, even while
2114
+ * an earlier reconnect is still fetching a credential or connecting; that attempt
2115
+ * notices the change before its handshake and starts over. A live session keeps
2116
+ * running as the previous user's node and uploads only that node's writes, until the
2117
+ * queued reconnect replaces it.
2118
+ */
2119
+ bindSignedInUser(): Promise<void>;
2120
+ /** The signed-in user as `config.principal` reports it (undefined when it throws). */
2121
+ private resolvePrincipal;
2122
+ /** Run `fn` after every earlier principal binding (one store binding at a time). */
2123
+ private withPrincipalLock;
2124
+ /**
2125
+ * Resolve the signed-in user and bind the store's node to them (RT-42).
2126
+ *
2127
+ * @returns false when sync must stay suspended: the store's node id is pinned and
2128
+ * belongs to another user, so nothing on this database may upload as this user.
2129
+ */
2130
+ private applyPrincipal;
2131
+ /**
2132
+ * Atomically refresh the transport session. Overlapping callers share one
2133
+ * reconnect, in-flight outbound batches are returned to the queue by stop(), and the
2134
+ * next start handshakes with the latest auth token, sync scope, and query subsets.
2135
+ */
2136
+ reconnect(): Promise<void>;
2137
+ private reconnectInternal;
2138
+ /**
2139
+ * Export a diagnostics snapshot for debugging and support tickets.
2140
+ * Contains connection state, timing info, and queue metrics.
2141
+ */
2142
+ exportDiagnostics(): SyncDiagnostics;
2143
+ /**
2144
+ * Get the current internal state (for testing).
2145
+ */
2146
+ getState(): SyncState;
2147
+ /**
2148
+ * Subscribe to internal state changes (SYNC-8). The reconnection loop uses it to
2149
+ * count an attempt as successful only once the session reaches `streaming`, not
2150
+ * when the handshake is merely sent.
2151
+ *
2152
+ * @param listener - Called with the new state after every transition
2153
+ * @returns Unsubscribe function
2154
+ */
2155
+ onStateChange(listener: (state: SyncState) => void): () => void;
2156
+ /**
2157
+ * Get the outbound queue (for testing).
2158
+ */
2159
+ getOutboundQueue(): OutboundQueue;
2160
+ /**
2161
+ * Update the sync scope map. Takes effect on the next connection attempt.
2162
+ *
2163
+ * When the scope changes (e.g., user switches organization), call this method
2164
+ * then reconnect. The new scope will be sent in the handshake, and the server
2165
+ * will send back data matching the new scope.
2166
+ *
2167
+ * Data that no longer matches the new scope is NOT deleted locally.
2168
+ * It simply stops being synced.
2169
+ *
2170
+ * @param scopeMap - New per-collection scope filters, or undefined to remove scope
2171
+ */
2172
+ updateScope(scopeMap: SyncScopeMap | undefined): void;
2173
+ /**
2174
+ * A stable identifier for the current sync view: the auth/tenant scope plus the set of
2175
+ * active query subscriptions, canonicalized so the same view always produces the same
2176
+ * string. The delivery watermark is keyed by this, so each distinct view resumes from
2177
+ * its own last position.
2178
+ */
2179
+ private deliverySignature;
2180
+ /** The view signature {@link deliverySignature} gives under `scope` and today's subsets. */
2181
+ private deliverySignatureFor;
2182
+ /**
2183
+ * Handshake fields naming the accepted view this client last streamed under (SYNC-11):
2184
+ * its canonical scope key and its delivery watermark. Omitted when no scope was ever
2185
+ * accepted, or when it equals the requested scope (the requested watermark covers it).
2186
+ */
2187
+ private acceptedViewHandshakeFields;
2188
+ /** Persist the downlink scope the server accepted at this handshake (null: none). */
2189
+ private rememberAcceptedScope;
2190
+ /**
2191
+ * Switch the active delivery watermark to the current view after a scope or subscription
2192
+ * change. The prior view's watermark is saved so returning to it resumes; the new view's
2193
+ * watermark is restored from the in-memory cache (0 for a never-synced view, which then
2194
+ * does a one-time resync under that view). No data is lost: a view's watermark is a lower
2195
+ * bound for that specific view's filter.
2196
+ */
2197
+ private switchDeliveryView;
2198
+ /**
2199
+ * Record a view's watermark in the in-memory cache as the most-recently-used entry (the
2200
+ * Map preserves insertion order, so re-inserting moves it to the tail), then evict cold
2201
+ * views past the retention cap. This is the single place the cache is written, so LRU
2202
+ * order and the size bound are always maintained together.
2203
+ */
2204
+ private setViewWatermark;
2205
+ /**
2206
+ * Trim the per-view watermark cache to `MAX_DELIVERY_VIEW_WATERMARKS`, evicting the
2207
+ * least-recently-used views. The default view ('') and the live view are never evicted
2208
+ * (evicting the live view would force an immediate re-scan of what we are actively
2209
+ * syncing). Each eviction also removes the persisted row, bounding storage. Safe by
2210
+ * construction: an evicted view simply back-fills from 0 (deduplicated) when next seen.
2211
+ */
2212
+ private evictColdViewWatermarks;
2213
+ /**
2214
+ * Remove a view's persisted watermark row (best-effort; a persistence layer that omits
2215
+ * the delete simply keeps the row, which is harmless).
2216
+ */
2217
+ private deleteDeliveryWatermark;
2218
+ /**
2219
+ * Get the currently active scope map. Returns undefined if no scope is configured.
2220
+ */
2221
+ getActiveScope(): SyncScopeMap | undefined;
2222
+ /** Get the server-accepted upload authorization scope. */
2223
+ getActiveUplinkScope(): SyncScopeMap | undefined;
2224
+ /**
2225
+ * Register a live query subset that narrows synced data for a collection.
2226
+ * Takes effect on the next connection; reconnects when already connected.
2227
+ */
2228
+ registerQuerySubset(subset: SyncQuerySubset): () => void;
2229
+ private switchDeliveryViewWithInheritance;
2230
+ /** Atomically replace the manifest used by static query-subset mode. */
2231
+ setQuerySubsets(subsets: SyncQuerySubset[]): void;
2232
+ /**
2233
+ * Returns deduplicated active query subsets from registered subscriptions.
2234
+ */
2235
+ getActiveQuerySubsets(): SyncQuerySubset[];
2236
+ /**
2237
+ * Get the awareness manager for collaborative presence.
2238
+ * Use this to set local presence, observe remote collaborators,
2239
+ * and track cursor positions in richtext fields.
2240
+ */
2241
+ getAwarenessManager(): AwarenessManager;
2242
+ /**
2243
+ * Optional side channel for incremental Yjs updates on large richtext fields.
2244
+ */
2245
+ getRichtextDocChannel(): RichtextDocChannel;
2246
+ /**
2247
+ * Side channel for out-of-band blob chunk transfer over the sync connection.
2248
+ * The app layer bridges this to a `ChunkMessagePort` to pull/serve blob bytes.
2249
+ */
2250
+ getBlobChunkChannel(): BlobChunkChannel;
2251
+ /**
2252
+ * Whether the connected server persists blob bytes centrally (learned from the
2253
+ * handshake response). When true, the app uploads the bytes behind `blob`
2254
+ * fields so they stay available after the authoring device goes offline.
2255
+ */
2256
+ isBlobStorageEnabled(): boolean;
2257
+ /**
2258
+ * Whether the connected server (peer-relay mode, no central storage) asks for the
2259
+ * bytes behind blob references as proof of possession (RT-23). The server verifies
2260
+ * and drops them; the app uploads exactly as for central storage.
2261
+ */
2262
+ isBlobPossessionProofRequested(): boolean;
2263
+ /**
2264
+ * Upload a blob chunk (or manifest) to the server, for central persistence or as
2265
+ * proof of possession. A no-op unless the handshake was accepted (syncing or
2266
+ * streaming) and the server advertised blob storage or asked for proofs.
2267
+ */
2268
+ uploadBlobChunk(hash: string, bytes: Uint8Array): void;
2269
+ /**
2270
+ * Register work that must reach the server before each outbound operation batch,
2271
+ * on the same connection (messages are processed in order). Used to upload the
2272
+ * bytes behind blob references first: the server only accepts a reference to
2273
+ * content the writer can read or has uploaded (RT-11). Errors are ignored; the
2274
+ * batch is sent regardless.
2275
+ *
2276
+ * @param preparer - Called with the operations about to be sent; null to remove
2277
+ * @returns A function that removes this preparer
2278
+ */
2279
+ setOutboundPreparer(preparer: ((operations: Operation[]) => Promise<void>) | null): () => void;
2280
+ private messageChain;
2281
+ private enqueueMessage;
2282
+ private handleMessageAsync;
2283
+ /** Adopt a key record the server pushed; stop the session if it locks the keyring. */
2284
+ private handlePushedKeyRecord;
2285
+ /**
2286
+ * An operation sealed under a key version newer than any this device holds: another
2287
+ * device rotated keys and the server's push did not reach this session (another
2288
+ * server instance). Fetch the record once, then replay the quarantine in the chain.
2289
+ */
2290
+ private maybeRefreshKeyring;
2291
+ /**
2292
+ * End the session because the keyring is locked; no reconnect until unlocked. A lock
2293
+ * caused by the server's record (rolled back, lost, malformed, forked: RT-96, RT-104)
2294
+ * needs no user action, so the session ends without pausing and the reconnection
2295
+ * loop retries with backoff; the next handshake re-uploads, merges or opens it.
2296
+ */
2297
+ private pauseForLockedKeyring;
2298
+ /** One key-service round trip over the live session. */
2299
+ private keyServiceRequest;
2300
+ /** Fail every pending key-service request (the session ended). */
2301
+ private rejectKeyRequests;
2302
+ private handleMessageFailure;
2303
+ /**
2304
+ * Compares server wall-clock time from the handshake with local time.
2305
+ * Negative skew = this device's clock is fast (dangerous for LWW and rejected
2306
+ * by server ingest beyond 60s). Positive skew = slow (accepted but surfaced).
2307
+ * Zero developer work required: the result flows into sync status and events.
2308
+ */
2309
+ private evaluateClockSkew;
2310
+ private handleHandshakeResponse;
2311
+ /**
2312
+ * The server refused this session's node id (`NODE_ID_CLAIMED`). What happens to the
2313
+ * node's unsynced writes depends on whether this database was ever accepted as it:
2314
+ *
2315
+ * - Never accepted (RT-21: the claim's token was lost before the device saw it, or
2316
+ * another device took the id first): no principal ever received these writes under
2317
+ * this node, so the never-sent ones are re-authored under a fresh node id and
2318
+ * uploaded. An operation flagged sent is never re-authored (RT-38): it may already
2319
+ * be stored, and a copy under a new id would store the change twice.
2320
+ * - Accepted before (RT-38: another user signed in on a database shared between
2321
+ * users): the node belongs to the principal that used it, so its unsynced writes
2322
+ * are held for that principal, never re-authored or uploaded under another one. The
2323
+ * device moves to a node the signed-in principal may own (a held node not yet tried
2324
+ * in this refusal cycle, typically that user's own earlier node) or to a fresh one.
2325
+ * - An adopted node (RT-40) is held; the store's own node is left as it is.
2326
+ */
2327
+ private rotateNodeIdentity;
2328
+ /**
2329
+ * The server refused an adopted node that belongs to the signed-in user (the app
2330
+ * assigned its held writes, or it was created for them) but that no claims-aware
2331
+ * server ever accepted: typically a beta.12 node whose history the server recorded no
2332
+ * owner for, and whose id is not this device's verified id, so no handover applies.
2333
+ * Holding it would hold the user's own writes forever. Like the store's own node
2334
+ * (RT-21), its never-sent writes are re-authored under a fresh node of the user's,
2335
+ * which a later session adopts and uploads; what a beta.12 server acknowledged under
2336
+ * the node is not re-authored (it is stored there already).
2337
+ *
2338
+ * @returns Whether the node's writes were re-authored (false: the caller holds it)
2339
+ */
2340
+ private reauthorRefusedAdoption;
2341
+ /** RT-38: hold a refused, previously accepted node's writes and move to another node. */
2342
+ private holdNodeAndSwitch;
2343
+ /** RT-21: re-author a never-accepted node's never-sent writes under a fresh node. */
2344
+ private reauthorUnsentAndRotate;
2345
+ /**
2346
+ * Move `nodeId`'s never-sent unsynced operations to a fresh node id (re-authored, so
2347
+ * they upload under it) and start using it. An operation flagged sent stays: it may
2348
+ * already be stored under `nodeId` (RT-38).
2349
+ *
2350
+ * @returns How many unsynced operations stayed under `nodeId` (sent, unacknowledged)
2351
+ */
2352
+ private rotateUnsent;
2353
+ /** A fresh node id belongs to the signed-in user, when the app says who that is (RT-42). */
2354
+ private bindFreshNode;
2355
+ /** The store moved to another node id: the next session starts from it. */
2356
+ private afterIdentityChange;
2357
+ /** Whether a handshake as `nodeId` was ever accepted on this database. */
2358
+ private wasNodeAccepted;
2359
+ /**
2360
+ * Re-stamps queued (never-acknowledged) operations whose timestamps are far
2361
+ * enough in the future that the server would reject them, using the server's
2362
+ * own handshake time as the trusted "now". This is the automatic recovery
2363
+ * path after a user corrects a fast device clock: the queue drains
2364
+ * immediately instead of waiting for real time to catch up.
2365
+ */
2366
+ private maybeRebaseQueuedOperations;
2367
+ /**
2368
+ * Upload this device's unsynced operations as the handshake delta. The delta is the
2369
+ * outbound queue itself (rebuilt from the op log above the acknowledged prefix), sent
2370
+ * as tracked batches: nothing leaves the queue until the server acknowledges the batch
2371
+ * that carried it (SYNC-4).
2372
+ */
2373
+ private sendDelta;
2374
+ private markDeltaSendCompleteIfReady;
2375
+ /** Take the next batch from the queue and track it until it is resolved. */
2376
+ private takeUpload;
2377
+ /** Whether a taken batch still belongs to the live session (SYNC-10). */
2378
+ private isUploadCurrent;
2379
+ /** Return one taken batch to the queue and forget its tracking. */
2380
+ private returnUpload;
2381
+ /** Return every taken batch to the queue (the session ended; nothing was resolved). */
2382
+ private returnAllInFlightUploads;
2383
+ /**
2384
+ * The oldest batch taken from the queue and not yet resolved (diagnostics and
2385
+ * tests); null when nothing is in flight.
2386
+ */
2387
+ private get currentBatch();
2388
+ private inFlightUploadCount;
2389
+ /**
2390
+ * Prepare, encrypt and send one taken batch, then register its message id so the ack
2391
+ * that names it resolves exactly this batch. Every await is followed by a check that
2392
+ * the batch still belongs to the live session; a send that throws returns the batch
2393
+ * to the queue instead of rejecting unhandled (SYNC-10).
2394
+ *
2395
+ * @returns The wire message id, or null when the batch was not sent.
2396
+ */
2397
+ private sendUpload;
2398
+ /**
2399
+ * The durability barrier before an upload (RT-35), with a bounded number of failures
2400
+ * (RT-49). Throws while the failures may be transient; after
2401
+ * {@link DURABILITY_FAILURES_BEFORE_DEGRADED} consecutive failures it returns anyway
2402
+ * (degraded mode, `localDurability: 'degraded'`, `sync:durability-degraded`) so the
2403
+ * server keeps a durable copy. The barrier is still attempted every time: the first
2404
+ * success leaves degraded mode (`sync:durability-restored`).
2405
+ */
2406
+ private awaitUploadDurability;
2407
+ private handleOperationBatch;
2408
+ /**
2409
+ * Initial-sync bookkeeping for a received batch: progress, the legacy resume cursor,
2410
+ * and completion on the final batch. Runs for duplicate batches too (NEW-SYNC-1).
2411
+ */
2412
+ private recordReceivedBatch;
2413
+ /**
2414
+ * Apply one delivered operation. Outcomes:
2415
+ * - `applied`: applied, a duplicate, or a merge-decided skip (the record is settled);
2416
+ * - `quarantine`: deliberately not applied and to be recorded durably (unknown
2417
+ * collection, transform unavailable, rejected or deferred apply, far-future
2418
+ * timestamp, undecryptable payload, referential failure), with `sync:apply-failed`;
2419
+ * - `stall`: a retriable failure; the watermark must not pass it (or the engine
2420
+ * cannot record a quarantine durably), so the server re-sends it.
2421
+ */
2422
+ private applyInbound;
2423
+ /**
2424
+ * The delivery stream caught up: let the store retire provisional cascades of
2425
+ * remote deletes (RT-69) and settle row-snapshot records after a full resync
2426
+ * (RT-68). A failure is reported, never fatal: both are retried on the next
2427
+ * catch-up.
2428
+ */
2429
+ private settleAfterCatchUp;
2430
+ /**
2431
+ * Whether a not-applied operation can be recorded so the watermark may pass it: with
2432
+ * a durable quarantine, or when nothing is durable (no persisted watermark either, so
2433
+ * a restart re-delivers everything anyway).
2434
+ */
2435
+ private canQuarantine;
2436
+ /**
2437
+ * Record quarantined operations and (for a delivery batch) the watermark advance in
2438
+ * one durable step.
2439
+ */
2440
+ private commitDeliveryProgress;
2441
+ /** Every inbound operation held in quarantine (durable when persistence supports it). */
2442
+ getQuarantinedOperations(): Promise<QuarantinedOperation[]>;
2443
+ /**
2444
+ * Try to apply every quarantined operation again, oldest delivery first and in HLC
2445
+ * order within a delivery. Runs on start (after a schema upgrade the unknown
2446
+ * collection now exists; after time catches up a far-future op is acceptable) and on
2447
+ * demand. Applied operations leave the quarantine; the rest stay.
2448
+ *
2449
+ * @returns How many operations left the quarantine.
2450
+ */
2451
+ retryQuarantinedOperations(): Promise<number>;
2452
+ private replayQuarantine;
2453
+ /**
2454
+ * Under end-to-end encryption, a plaintext operation authored by the server (a node
2455
+ * the handshake named authoritative: cascades, set-nulls, constraint corrections,
2456
+ * route writes) that touches only the collection's cleartext fields is accepted
2457
+ * without an envelope: the server holds no key, so it cannot seal its own writes, and
2458
+ * such an operation carries nothing the server cannot already read. Any sealed field
2459
+ * (or an operation from any other node) still has to arrive sealed.
2460
+ */
2461
+ private isServerAuthoredCleartext;
2462
+ /** Whether end-to-end encryption is configured (a keyring or a fixed encryptor). */
2463
+ private encryptionConfigured;
2464
+ /** The encryptor in force: the unlocked keyring's, or the fixed one; null when locked. */
2465
+ private activeEncryptor;
2466
+ /**
2467
+ * The encryptor in force. With encryption configured and the keyring locked this
2468
+ * throws: an operation is never sent or applied unencrypted because a key is missing.
2469
+ */
2470
+ private requireEncryptor;
2471
+ /** The key service over this session (the handshake has been accepted). */
2472
+ private liveKeyServiceChannel;
2473
+ /** Re-apply a quarantined operation; true when it no longer needs to be kept. */
2474
+ private applyInboundQuietly;
2475
+ /** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
2476
+ private sendDeliveryAck;
2477
+ /**
2478
+ * An acknowledgment resolves exactly the upload batch it names (SYNC-4): operations
2479
+ * the server processed (sequence <= `lastSequenceNumber`) leave the queue and count
2480
+ * toward the acknowledged prefix; the rest return to the queue for retry. An ack that
2481
+ * names no tracked batch (a node-token confirmation echo, a batch from an older
2482
+ * session) resolves nothing.
2483
+ */
2484
+ private handleAcknowledgment;
2485
+ private handleError;
2486
+ /**
2487
+ * Handle a per-operation rejection: divert the op out of the pending outbound
2488
+ * queue (so it is never retried or resurrected on reconnect), record it in the
2489
+ * durable rejected store (so it is kept and explainable), and emit an event so
2490
+ * the app can reconcile. Unlike {@link handleError}, this is a normal per-op
2491
+ * signal, so the connection stays up.
2492
+ */
2493
+ private applyScopeRetraction;
2494
+ /**
2495
+ * The local nodes whose unsynced writes are held now (RT-38, RT-50), for the user the
2496
+ * app reports as signed in. See {@link SyncStatusInfo.heldNodes}.
2497
+ */
2498
+ getHeldNodes(): Promise<HeldNodeInfo[]>;
2499
+ /**
2500
+ * Assign the `unassigned` held writes of `nodeId` to the signed-in user (RT-50): writes
2501
+ * made on this device before the app knew who was signed in, on a node that never
2502
+ * synced. Only the app can know whose they are (for example, a single-user device).
2503
+ * They upload on the user's next session (the server then binds the node to them).
2504
+ *
2505
+ * @throws {SyncError} `HELD_ASSIGN_NO_USER` when nobody is signed in;
2506
+ * `HELD_NODE_NOT_ASSIGNABLE` when `nodeId` is not an unassigned held node (it belongs
2507
+ * to a user, or the server refused it for this one)
2508
+ */
2509
+ assignHeld(nodeId: string): Promise<void>;
2510
+ /**
2511
+ * Discard the `unassigned` held writes of `nodeId` from sync (RT-50): they are never
2512
+ * uploaded. They are NOT rolled back: they stay in this device's local database, and
2513
+ * count as synced from now on (`deleteDatabase` no longer protects them). Writes held
2514
+ * for another user (`other-user`) cannot be discarded by this user.
2515
+ *
2516
+ * @returns How many writes were discarded
2517
+ * @throws {SyncError} `HELD_NODE_NOT_DISCARDABLE` when `nodeId` is not an unassigned
2518
+ * held node
2519
+ */
2520
+ discardHeld(nodeId: string): Promise<number>;
2521
+ private handleOperationRejected;
2522
+ /**
2523
+ * Every operation the server rejected that has not yet been reconciled. The app
2524
+ * uses this (alongside the `sync:operation-rejected` event) to surface failed
2525
+ * submissions and decide whether to roll back or resubmit.
2526
+ */
2527
+ getRejectedOperations(): Promise<RejectedOperation[]>;
2528
+ /**
2529
+ * Forget rejected operations by id once the app has reconciled them (rolled the
2530
+ * optimistic write back or resubmitted a corrected op).
2531
+ */
2532
+ clearRejectedOperations(operationIds: string[]): Promise<void>;
2533
+ private checkDeltaComplete;
2534
+ /**
2535
+ * In a session of this tab's own node, arm a timer for the earliest parked adoption's
2536
+ * backoff expiry (RT-53). Without it a parked node is retried only at a session start
2537
+ * or after this tab's own uploads made progress, so an idle tab (a dashboard, a kiosk)
2538
+ * would never retry a closed tab's deferred writes while its connection holds.
2539
+ */
2540
+ private armAdoptionRetryTimer;
2541
+ /**
2542
+ * A parked adoption's backoff ran out during this session (RT-53). An idle own-node
2543
+ * session hands over now: it reconnects, and the next session adopts the node (one
2544
+ * retry per backoff; a node deferred again is parked again for longer). A busy session
2545
+ * hands over when its uploads drain (maybeYieldToDeferredNode sees the expiry).
2546
+ */
2547
+ private onAdoptionRetryTimer;
2548
+ /** Serialize every read-modify-write of the own-operation tracking. */
2549
+ private withOwnTracking;
2550
+ private loadOwnTracking;
2551
+ /**
2552
+ * Load the acknowledged prefix for the current node id, once per node id (an engine
2553
+ * restart keeps its in-memory progress). A persistence layer that has never stored a
2554
+ * prefix under this contract means an upgrade from a release that persisted a MAX:
2555
+ * that value can hide a lost op below it, so the prefix restarts at 0 and the device
2556
+ * re-uploads its own history once, chunk by chunk (the server dedups by id). The
2557
+ * prefix is persisted as it advances, so the re-upload resumes where it stopped.
2558
+ */
2559
+ private loadOwnTrackingLocked;
2560
+ /**
2561
+ * Register an own operation. `unresolved` ops are queued for upload; the others are
2562
+ * already resolved (not upload-eligible). An op at or below the prefix (a sequence
2563
+ * that was a gap when the prefix passed it) lowers the prefix: re-uploading is safe,
2564
+ * skipping is not.
2565
+ */
2566
+ private trackOwnOperation;
2567
+ /** Mark an own operation resolved (stored, terminally rejected, or not eligible). */
2568
+ private markOwnResolved;
2569
+ private isOwnOperationResolved;
2570
+ /**
2571
+ * Lower the prefix (the server holds fewer of this device's operations than it
2572
+ * believed) and forget what this session knew above it: the next advance rescans the
2573
+ * log from there and re-enqueues what is missing.
2574
+ */
2575
+ private lowerOwnPrefixLocked;
2576
+ /**
2577
+ * Advance the prefix over every resolved own sequence, reading the next chunk of the
2578
+ * op log whenever it reaches a sequence this session has not examined yet. Reading a
2579
+ * chunk enqueues its upload-eligible operations (and flushes when streaming), so the
2580
+ * one-time upgrade re-upload proceeds chunk by chunk as acks arrive.
2581
+ */
2582
+ private advanceOwnPrefixLocked;
2583
+ /**
2584
+ * Read own operations [from, to] from the op log and register each sequence: an
2585
+ * upload-eligible op not yet queued is enqueued; an ineligible one is recorded and
2586
+ * resolved; a sequence with no op is resolved. Every sequence in the range ends up
2587
+ * registered, which guarantees the advance loop makes progress.
2588
+ */
2589
+ private scanOwnLog;
2590
+ private persistOwnPrefix;
2591
+ private persistLastAckedServerVector;
2592
+ /**
2593
+ * Refresh the pending count: own operations not yet stored on the server (queued or
2594
+ * in flight), plus the own operations above the prefix not read from the log yet.
2595
+ * Computed from the client's own acknowledgments only, so it drops the moment the
2596
+ * server acknowledges, without waiting for a later handshake (RT-28).
2597
+ */
2598
+ refreshPendingCount(): Promise<void>;
2599
+ /** Synchronous pending count (see {@link refreshPendingCount}). */
2600
+ private computePendingCount;
2601
+ /** Unsynced operations held for a principal that is not signed in (RT-38). */
2602
+ private computeHeldCount;
2603
+ /**
2604
+ * This device's operations not yet stored on the server (queued or in flight).
2605
+ */
2606
+ getPendingSyncOperations(): Promise<Operation[]>;
2607
+ /**
2608
+ * Rebuild the upload set from the op log: every own operation above the acknowledged
2609
+ * prefix that is upload-eligible is queued (first chunk now, the rest as acks move
2610
+ * the prefix). Persisted queue entries that are already resolved are dropped.
2611
+ */
2612
+ private reconcileOutboundFromOpLog;
2613
+ /** Node id of the current session: the store's own, or an adopted node's (RT-40). */
2614
+ private currentNodeId;
2615
+ /** A session uploads only operations authored under its own node id (RT-38, RT-40). */
2616
+ private readonly isSessionOperation;
2617
+ /** Whether the queue holds an operation this session may upload. */
2618
+ private hasUploadable;
2619
+ /** A queued or in-flight operation by id. */
2620
+ private findQueuedOperation;
2621
+ /**
2622
+ * Pick the node this session runs as (RT-40, RT-46). A session uploads under one node
2623
+ * id (the server confines uploads to the session's node), so the database's local
2624
+ * nodes take turns, each for one session:
2625
+ *
2626
+ * - A local node other than the store's own that still has unsynced writes, is not
2627
+ * held for another principal (RT-38) or bound to another user (RT-42), was not
2628
+ * refused in this refusal cycle, and is used by no live tab (its lock is free) may
2629
+ * be adopted: the session runs as that node and uploads its writes with their
2630
+ * original identity, then ends; the next session picks again.
2631
+ * - Never one whose writes depend causally on another local node's unsynced writes:
2632
+ * the parent's node goes first (a child is never uploaded before its parent).
2633
+ * - Never a parked one (its last adoption made no progress, for example the server
2634
+ * kept deferring a write) until something else uploaded or its backoff ran out, so
2635
+ * one stuck node never blocks the others or this tab's own writes.
2636
+ * - Otherwise the session runs as the store's own node; it yields to a deferred node
2637
+ * once its own uploads made that node worth retrying (see maybeEndSessionForNodeWork).
2638
+ *
2639
+ * Also counts the other local nodes' unsynced writes, so they are reported (pending,
2640
+ * or held).
2641
+ */
2642
+ private chooseSessionNode;
2643
+ /**
2644
+ * Why a local node other than the store's own must not upload on this session, or
2645
+ * null when it may (RT-38, RT-42, RT-50):
2646
+ *
2647
+ * - Bound to another user than the signed-in one (or refused after acceptance,
2648
+ * RT-38): `other-user`.
2649
+ * - Unbound (its owner was never recorded, RT-50) while a user is signed in: its
2650
+ * owner is learned from the server, never guessed. If it was accepted before, the
2651
+ * server holds a claim on it: it is tried (adopted) on this user's session unless
2652
+ * the server already refused it for them, and an accepted handshake binds it to
2653
+ * them. If it never synced nobody can tell whose its writes are: `unassigned`,
2654
+ * held until the app assigns or discards them.
2655
+ */
2656
+ private holdReason;
2657
+ /** A node bound to another user than the signed-in one (RT-42). */
2658
+ private belongsToAnotherPrincipal;
2659
+ /**
2660
+ * A local node's own operations above its acknowledged prefix that are not
2661
+ * terminally rejected: `count` all of them, `notQueued` those not in the queue, their
2662
+ * `ids`, and the causal parents they name outside this set (`deps`).
2663
+ */
2664
+ private countUnsyncedOfNode;
2665
+ /** The adoption schedule (RT-46), from persistence when it keeps one. */
2666
+ private loadSchedule;
2667
+ private saveSchedule;
2668
+ /**
2669
+ * Operations of `nodeId` were stored (RT-46): a parked adoption may have waited for
2670
+ * them, so the progress counter moves; the node itself is no longer parked.
2671
+ */
2672
+ private recordUploadProgress;
2673
+ /** Park the adopted node: it made no progress in this session (RT-46). */
2674
+ private parkAdoption;
2675
+ /** Forget a drained, non-held local node no live tab uses (bounds the registry). */
2676
+ private forgetDrainedNode;
2677
+ /** Release the lock on an adopted node (RT-40). */
2678
+ private endAdoption;
2679
+ /**
2680
+ * Record an accepted handshake as `nodeId` (RT-38), durably when supported. The server
2681
+ * accepted it for the session's user, so an unbound node is now theirs (RT-50).
2682
+ */
2683
+ private recordNodeAccepted;
2684
+ /** Which of these operation ids the server refused for good (RT-36). */
2685
+ private findTerminalRejections;
2686
+ /** Record durable terminal-rejection markers (RT-36). */
2687
+ private recordTerminalRejections;
2688
+ /**
2689
+ * Restart server->client delivery from 0 at the next session, for the requested and the
2690
+ * accepted views. A full resync includes this client's own operations (a resumed one
2691
+ * does not), which is how operations the device lost come back (RT-35).
2692
+ */
2693
+ private resetDeliveryForFullResync;
2694
+ /**
2695
+ * The server refused an upload with `SEQUENCE_CONFLICT`: it holds another operation of
2696
+ * this node under that number, one this device lost (RT-35: a reload inside the
2697
+ * IndexedDB snapshot window, a restored older copy). Not a refusal of the write: it
2698
+ * gets a fresh number above everything the server holds (same id and content) and is
2699
+ * uploaded again, and the session ends once its uploads are resolved so the next one
2700
+ * resyncs from 0 and fetches the lost operation back.
2701
+ *
2702
+ * @returns Whether the conflict was recovered (false: treat as a plain rejection)
2703
+ */
2704
+ private recoverSequenceConflict;
2705
+ /**
2706
+ * A SEQUENCE_CONFLICT above this session's handshake entry (RT-44): the server held
2707
+ * nothing of this node above that entry when the session began, so the operation now
2708
+ * holding the number was stored during this session, and not by this session. Another
2709
+ * live copy of this database uses the same node id (app data copied to a new machine,
2710
+ * a restored image used alongside the original). Renumbering would only collide again,
2711
+ * and each copy's writes count as the other's own operations (never delivered on a
2712
+ * resumed stream), so this copy moves to a fresh node id: the refused write (refused,
2713
+ * so certainly not stored) and every never-sent one are re-authored under it, the
2714
+ * session ends, and the next one resyncs from 0 to fetch what the other copy wrote.
2715
+ */
2716
+ private recoverFromClone;
2717
+ /**
2718
+ * End the session when it has done its node work and nothing is in flight:
2719
+ * - an own-history recovery is pending (RT-35: reconnect to resync from 0);
2720
+ * - an adopted node's writes are all stored (RT-40: reconnect as the next node, or
2721
+ * this tab's own);
2722
+ * - an adopted node stalled (RT-46: the server deferred part of it): it is parked and
2723
+ * the next session serves another node;
2724
+ * - this tab's own uploads are drained, made progress, and a deferred node is now
2725
+ * worth retrying (RT-46: its parent was among them, or its park has expired).
2726
+ *
2727
+ * @returns Whether the session was ended
2728
+ */
2729
+ private maybeEndSessionForNodeWork;
2730
+ /**
2731
+ * This tab's own session: once its uploads are drained and it stored something, end
2732
+ * it when a node deferred at session start is worth retrying now (RT-46), so other
2733
+ * nodes' writes do not wait for an unrelated reconnect. Bounded: each yield needs new
2734
+ * own progress, and a parked node at most once per {@link ADOPTION_YIELD_MIN_MS}.
2735
+ */
2736
+ private maybeYieldToDeferredNode;
2737
+ private flushQueue;
2738
+ private startOutboundAckTimer;
2739
+ private clearOutboundAckTimer;
2740
+ private scheduleOutboundRetry;
2741
+ private clearOutboundRetryTimer;
2742
+ /** No ack arrived in time: return every batch in flight and reconnect. */
2743
+ private handleOutboundAckTimeout;
2744
+ private handleAwarenessUpdate;
2745
+ private handleTransportClose;
2746
+ private handleTransportError;
2747
+ private transitionTo;
2748
+ private setSerializerWireFormat;
2749
+ /**
2750
+ * Upload predicate: the UPLINK scope only, never query subsets (SYNC-1). Query
2751
+ * subsets describe what this client wants to download; the server authorizes
2752
+ * uploads independently of the client's downloaded view.
2753
+ */
2754
+ private operationAllowedForUpload;
2755
+ /**
2756
+ * Record a local operation that the uplink scope refuses, so it is visible to
2757
+ * the app instead of silently diverging from the server. Operations on
2758
+ * collections that are not synced in either direction are local-only by design
2759
+ * and are not recorded.
2760
+ *
2761
+ * Such an operation counts as resolved for the contiguous acknowledged prefix: it is
2762
+ * recorded here and never uploaded (W3 step 2).
2763
+ */
2764
+ private recordOutOfUplinkScope;
2765
+ /**
2766
+ * Whether an operation falls inside the uplink scope. Query subsets never apply to
2767
+ * uploads (SYNC-1), and nothing applies to inbound operations: the client applies
2768
+ * what the server delivered (SYNC-2).
2769
+ */
2770
+ private matchesUplinkScope;
2771
+ private readRecordForBackfill;
2772
+ private persistDeltaCursor;
2773
+ private persistDeliveryWatermark;
2774
+ private scheduleQuerySubsetReconnect;
2775
+ private reconnectForQuerySubsets;
2776
+ }
2777
+
2778
+ /**
2779
+ * Configuration for the connection monitor.
2780
+ */
2781
+ interface ConnectionMonitorConfig {
2782
+ /** Number of latency samples to keep in the rolling window. Defaults to 20. */
2783
+ windowSize?: number;
2784
+ /** Time in ms after which the connection is considered stale. Defaults to 30000. */
2785
+ staleThreshold?: number;
2786
+ /** Injectable time source for deterministic testing */
2787
+ timeSource?: TimeSource;
2788
+ }
2789
+ /**
2790
+ * Monitors connection quality based on RTT latency samples,
2791
+ * missed acknowledgments, and activity timestamps.
2792
+ */
2793
+ declare class ConnectionMonitor {
2794
+ private readonly windowSize;
2795
+ private readonly staleThreshold;
2796
+ private readonly timeSource;
2797
+ private latencies;
2798
+ private missedAcks;
2799
+ private lastActivityTime;
2800
+ constructor(config?: ConnectionMonitorConfig);
2801
+ /**
2802
+ * Record a round-trip time sample.
2803
+ */
2804
+ recordLatency(ms: number): void;
2805
+ /**
2806
+ * Record a missed acknowledgment (message sent but no response received).
2807
+ */
2808
+ recordMissedAck(): void;
2809
+ /**
2810
+ * Record any activity (message sent or received).
2811
+ */
2812
+ recordActivity(): void;
2813
+ /**
2814
+ * Assess current connection quality based on collected metrics.
2815
+ *
2816
+ * Quality thresholds (average RTT):
2817
+ * - excellent: < 100ms, 0 missed acks
2818
+ * - good: < 300ms, ≤ 1 missed ack
2819
+ * - fair: < 1000ms, ≤ 3 missed acks
2820
+ * - poor: < 5000ms or > 3 missed acks
2821
+ * - offline: no activity for staleThreshold ms
2822
+ */
2823
+ getQuality(): ConnectionQuality;
2824
+ /**
2825
+ * Reset all metrics. Call on disconnect.
2826
+ */
2827
+ reset(): void;
2828
+ /**
2829
+ * Get the current average latency in ms. Returns null if no samples.
2830
+ */
2831
+ getAverageLatency(): number | null;
2832
+ /**
2833
+ * Get the number of missed acks.
2834
+ */
2835
+ getMissedAcks(): number;
2836
+ }
2837
+
2838
+ /**
2839
+ * Configuration for the reconnection manager.
2840
+ */
2841
+ interface ReconnectionConfig {
2842
+ /** Initial delay in ms before first reconnection attempt. Defaults to 1000. */
2843
+ initialDelay?: number;
2844
+ /** Maximum delay in ms between attempts. Defaults to 30000. */
2845
+ maxDelay?: number;
2846
+ /** Multiplier for exponential backoff. Defaults to 2. */
2847
+ multiplier?: number;
2848
+ /** Maximum number of reconnection attempts. 0 means unlimited. Defaults to 0. */
2849
+ maxAttempts?: number;
2850
+ /** Jitter factor (0-1). Random variation applied to delay. Defaults to 0.25. */
2851
+ jitter?: number;
2852
+ /**
2853
+ * How long a connection must stay up (see {@link ReconnectionManager.reportConnected})
2854
+ * before the backoff resets to the initial delay, in ms (SYNC-8). A server that
2855
+ * accepts sessions and drops them right away therefore keeps backing off instead of
2856
+ * being retried at the initial interval forever. Defaults to 10000.
2857
+ */
2858
+ stableAfterMs?: number;
2859
+ /** Injectable time source for deterministic testing. */
2860
+ timeSource?: TimeSource;
2861
+ /** Injectable random source for deterministic jitter. Returns value in [0, 1). */
2862
+ randomSource?: () => number;
2863
+ }
2864
+ /**
2865
+ * Manages reconnection attempts with exponential backoff and jitter.
2866
+ *
2867
+ * Formula: min(initialDelay * multiplier^attempt, maxDelay) * (1 + jitter * (random - 0.5) * 2)
2868
+ *
2869
+ * The attempt counter belongs to the manager, not to one `start()` run (SYNC-8): it
2870
+ * keeps growing across runs and resets only once a connection stayed up for
2871
+ * `stableAfterMs` ({@link reportConnected}), or on an explicit {@link reset}.
2872
+ */
2873
+ declare class ReconnectionManager {
2874
+ private readonly initialDelay;
2875
+ private readonly maxDelay;
2876
+ private readonly multiplier;
2877
+ private readonly maxAttempts;
2878
+ private readonly jitter;
2879
+ private readonly stableAfterMs;
2880
+ private readonly random;
2881
+ private attempt;
2882
+ private timer;
2883
+ private stableTimer;
2884
+ private stopped;
2885
+ private running;
2886
+ /** A disconnect was reported while an attempt was in flight (NEW-SYNC-2). */
2887
+ private pendingRetry;
2888
+ private waitResolve;
2889
+ constructor(config?: ReconnectionConfig);
2890
+ /**
2891
+ * Start reconnection attempts. Calls `onReconnect` with exponential backoff.
2892
+ *
2893
+ * An attempt counts as successful only when `onReconnect` returns true AND no
2894
+ * disconnect was reported ({@link requestRetry}) while it ran: a session that
2895
+ * drops before the attempt returns is retried, never silently lost (NEW-SYNC-2).
2896
+ *
2897
+ * @param onReconnect - Called on each attempt. Return `true` if reconnection succeeded
2898
+ * (the caller decides what success means; Kora waits for the `streaming` state).
2899
+ * @returns Promise that resolves when reconnection succeeds or maxAttempts reached.
2900
+ */
2901
+ start(onReconnect: () => Promise<boolean>): Promise<boolean>;
2902
+ /**
2903
+ * Report a disconnect. While a run is in flight it makes the current attempt count
2904
+ * as failed, so the loop retries (with backoff) instead of exiting on a session
2905
+ * that already dropped. Returns true when a running loop took the request; false
2906
+ * means the caller should start a new run.
2907
+ */
2908
+ requestRetry(): boolean;
2909
+ /**
2910
+ * Report that a connection is up (it reached streaming). If it is still up after
2911
+ * `stableAfterMs`, the backoff resets to the initial delay. A disconnect reported
2912
+ * before then ({@link requestRetry} or {@link reportDisconnected}) cancels the reset.
2913
+ */
2914
+ reportConnected(): void;
2915
+ /** Report that the connection dropped: a pending backoff reset is cancelled. */
2916
+ reportDisconnected(): void;
2917
+ /**
2918
+ * Cancel the current backoff wait and proceed to the next reconnect attempt immediately.
2919
+ * No-op if not waiting.
2920
+ */
2921
+ wake(): void;
2922
+ /**
2923
+ * Stop any pending reconnection attempt.
2924
+ */
2925
+ stop(): void;
2926
+ /**
2927
+ * Reset the attempt counter to the initial delay. Call after a deliberate
2928
+ * (user-initiated) reconnection. It never clears a {@link stop}: a stopped loop
2929
+ * stays stopped, so a stop racing a reset cannot resurrect it (SYNC-8).
2930
+ */
2931
+ reset(): void;
2932
+ /**
2933
+ * Compute the next delay for the current attempt.
2934
+ * Exposed for testing purposes.
2935
+ */
2936
+ getNextDelay(): number;
2937
+ /**
2938
+ * Whether the reconnection loop is currently running.
2939
+ */
2940
+ isRunning(): boolean;
2941
+ /**
2942
+ * Current attempt number (for testing).
2943
+ */
2944
+ getAttemptCount(): number;
2945
+ private clearStableTimer;
2946
+ private wait;
2947
+ }
2948
+
2949
+ /**
2950
+ * Encode a Yjs update for the doc channel wire (base64).
2951
+ */
2952
+ declare function encodeYjsUpdate(update: Uint8Array): string;
2953
+ /**
2954
+ * Decode a base64 Yjs update from the doc channel wire.
2955
+ */
2956
+ declare function decodeYjsUpdate(encoded: string): Uint8Array;
2957
+ /**
2958
+ * Build a stable key for a richtext field document.
2959
+ */
2960
+ declare function richtextDocKey(collection: string, recordId: string, field: string): string;
2961
+
2962
+ /** The part of the sync encryption config that decides what the server can read. */
2963
+ interface EncryptedRelationConfig {
2964
+ enabled?: boolean;
2965
+ cleartextFields?: Readonly<Record<string, readonly string[]>>;
2966
+ }
2967
+ /**
2968
+ * Thrown at app initialization when end-to-end encryption would seal the foreign-key
2969
+ * field of a relation with a referential `onDelete` policy.
2970
+ */
2971
+ declare class SealedRelationFieldError extends KoraError {
2972
+ readonly relation: string;
2973
+ readonly collection: string;
2974
+ readonly field: string;
2975
+ constructor(message: string, relation: string, collection: string, field: string);
2976
+ }
2977
+ /**
2978
+ * Refuse a configuration where end-to-end encryption seals the foreign key of a
2979
+ * relation whose `onDelete` is `cascade`, `set-null` or `restrict`.
2980
+ *
2981
+ * Referential policies are enforced by the sync server, the one replica that sees every
2982
+ * child: it cascades (or nulls, or refuses) for children no single device knows about,
2983
+ * and relays the result. It can only do so when it can read the foreign key. With the
2984
+ * key sealed, no replica can enforce the policy for everyone, and every device-side
2985
+ * substitute either loses a cascade or deletes a child another device moved away
2986
+ * (RT-74, RT-78, RT-82). So the foreign key of such a relation must travel in cleartext:
2987
+ * it reveals which parent a child belongs to, never the child's other fields.
2988
+ *
2989
+ * Relations with `onDelete: 'no-action'` (or none) are not affected: nothing is
2990
+ * enforced on delete, so their foreign key may stay sealed.
2991
+ *
2992
+ * @param schema - The app schema
2993
+ * @param encryption - The sync encryption config (none, or disabled: nothing to check)
2994
+ * @throws {SealedRelationFieldError} Naming the relation, the field and the fix
2995
+ */
2996
+ declare function validateEncryptedRelations(schema: SchemaDefinition, encryption: EncryptedRelationConfig | null | undefined): void;
2997
+
2998
+ /**
2999
+ * Thrown when key derivation fails.
3000
+ */
3001
+ declare class KeyDerivationError extends SyncError {
3002
+ constructor(message: string, context?: Record<string, unknown>);
3003
+ }
3004
+ /**
3005
+ * Generates a cryptographically random salt for PBKDF2 key derivation.
3006
+ *
3007
+ * @returns A random 32-byte Uint8Array
3008
+ * @throws {KeyDerivationError} If crypto.getRandomValues is unavailable
3009
+ */
3010
+ declare function generateSalt(): Uint8Array;
3011
+ /**
3012
+ * Derives an AES-256-GCM encryption key from a passphrase using PBKDF2.
3013
+ *
3014
+ * Uses PBKDF2 with SHA-256 and 600,000 iterations (OWASP-recommended minimum)
3015
+ * to derive a 256-bit key. The derived key is deterministic: the same passphrase
3016
+ * and salt always produce the same key.
3017
+ *
3018
+ * @param passphrase - The user's passphrase (must be non-empty)
3019
+ * @param salt - Optional salt bytes. If omitted, a random 32-byte salt is generated.
3020
+ * @param iterations - Optional PBKDF2 iteration count. Defaults to
3021
+ * {@link DEFAULT_PBKDF2_ITERATIONS}. Lower it only in tests; production must
3022
+ * keep the default so derived keys stay brute-force resistant.
3023
+ * @returns The derived CryptoKey and the salt used
3024
+ * @throws {KeyDerivationError} If the passphrase is empty, crypto is unavailable, or derivation fails
3025
+ *
3026
+ * @example
3027
+ * ```typescript
3028
+ * // First time: derive key with a new salt
3029
+ * const { key, salt } = await deriveKey('my-secure-passphrase')
3030
+ *
3031
+ * // Later: re-derive the same key using the stored salt
3032
+ * const { key: sameKey } = await deriveKey('my-secure-passphrase', salt)
3033
+ * ```
3034
+ */
3035
+ declare function deriveKey(passphrase: string, salt?: Uint8Array, iterations?: number): Promise<{
3036
+ key: CryptoKey;
3037
+ salt: Uint8Array;
3038
+ }>;
3039
+ /**
3040
+ * Derives a versioned encryption key from a passphrase.
3041
+ *
3042
+ * Wraps {@link deriveKey} with a version number for key rotation support.
3043
+ * When the passphrase changes, create a new versioned key with a higher
3044
+ * version number.
3045
+ *
3046
+ * @param passphrase - The user's passphrase
3047
+ * @param version - Key version number (must be a positive integer)
3048
+ * @param salt - Optional salt bytes. If omitted, a random salt is generated.
3049
+ * @param iterations - Optional PBKDF2 iteration count. Defaults to
3050
+ * {@link DEFAULT_PBKDF2_ITERATIONS}. Lower it only in tests.
3051
+ * @returns A {@link VersionedKey} containing the key, version, and salt
3052
+ * @throws {KeyDerivationError} If parameters are invalid or derivation fails
3053
+ */
3054
+ declare function deriveVersionedKey(passphrase: string, version: number, salt?: Uint8Array, iterations?: number): Promise<VersionedKey>;
3055
+
3056
+ /** Default status when sync is not configured or the engine is unavailable. */
3057
+ declare const OFFLINE_SYNC_STATUS: SyncStatusInfo;
3058
+ interface SyncStatusControllerOptions {
3059
+ syncEngine?: {
3060
+ getStatus(): SyncStatusInfo;
3061
+ } | null;
3062
+ getSyncEngine?: () => {
3063
+ getStatus(): SyncStatusInfo;
3064
+ } | null;
3065
+ subscribeSyncStatus: ((listener: (status: SyncStatusInfo) => void) => () => void) | null;
3066
+ events: KoraEventEmitter | null;
3067
+ }
3068
+ interface SyncStatusController {
3069
+ getSnapshot(): SyncStatusInfo;
3070
+ subscribe(listener: () => void): () => void;
3071
+ refresh(): void;
3072
+ destroy(): void;
3073
+ }
3074
+ /**
3075
+ * Framework-agnostic sync status subscription with live reads when no bridge exists.
3076
+ */
3077
+ declare function createSyncStatusController(options: SyncStatusControllerOptions): SyncStatusController;
3078
+
3079
+ /**
3080
+ * Returns remote collaborators' awareness states (excludes the local client).
3081
+ */
3082
+ declare function getRemoteAwarenessStates(awareness: AwarenessManager): AwarenessState[];
3083
+ /**
3084
+ * Subscribes to awareness changes and invokes `listener` only when the remote
3085
+ * collaborator set changes.
3086
+ */
3087
+ declare function subscribeRemoteAwarenessStates(awareness: AwarenessManager, listener: (states: AwarenessState[]) => void): () => void;
3088
+
3089
+ export { type AwarenessChange, type AwarenessCursor, AwarenessManager, type AwarenessMessage, type AwarenessState, type AwarenessUser, BlobChunkChannel, type BlobChunkChannelMessage, type BlobChunkChannelOptions, BlobChunkPushMessage, BlobChunkRequestMessage, BlobChunkResponseMessage, type CachedKeyring, type ChaosConfig, ChaosTransport, ConnectionMonitor, type ConnectionMonitorConfig, type CursorInfo, DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD, DecryptionError, DeltaCursor, type EncryptedRelationConfig, EncryptionError, EncryptionKeyError, EncryptionKeyring, type EncryptionKeyringOptions, type EncryptionLockState, type EncryptionStatus, type EncryptionStatusCode, HTTP_SYNC_SESSION_HEADER, HeldNodeInfo, HttpLongPollingTransport, type HttpLongPollingTransportOptions, IndexedDbKeyCache, JsonMessageSerializer, type KeyCache, KeyDerivationError, type KeyServiceChannel, type KeyServiceReply, KeyUnwrapError, MemoryKeyCache, type MessageSerializer, NegotiatedMessageSerializer, NoKeyCache, OFFLINE_SYNC_STATUS, type OutboundBatch, OutboundQueue, ProtobufMessageSerializer, QuarantinedOperation, QueueStorage, type ReconnectionConfig, ReconnectionManager, RejectedOperation, RejectedOperationStorage, RichtextDocChannel, type RichtextDocChannelOptions, type RichtextDocListener, SCHEMA_MISMATCH_PREFIX, ScopeSnapshotOptions, SealedRelationFieldError, SerializedOperation, SyncConfig, type SyncDiagnostics, SyncEncryptionConfig, SyncEncryptor, SyncEngine, type SyncEngineOptions, SyncMessage, SyncQuerySubset, SyncScopeMap, SyncState, SyncStatePersistence, type SyncStatusController, type SyncStatusControllerOptions, SyncStatusInfo, type SyncStore, SyncTransport, TransportCloseHandler, TransportErrorHandler, TransportMessageHandler, TransportOptions, VersionedKey, type WebSocketConstructor, type WebSocketLike, WebSocketTransport, type WebSocketTransportOptions, WireFormat, WrappedKeyRecord, YjsDocUpdateMessage, createDeltaCursorFromBatch, createKeyCache, createSyncStatusController, decodeBlobChunkBytes, decodeDeltaCursor, decodeYjsUpdate, deriveKey, deriveVersionedKey, encodeBlobChunkBytes, encodeDeltaCursor, encodeYjsUpdate, filterOperationsByScope, generateSalt, getRemoteAwarenessStates, isClientSchemaVersionSupported, isEncryptedPayload, isSchemaMismatchReject, operationMatchesScope, richtextDocKey, sliceOperationsAfterCursor, subscribeRemoteAwarenessStates, validateEncryptedRelations, versionVectorToWire, wireToVersionVector };