@korajs/sync 1.0.0-beta.11 → 1.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { S as SyncScopeMap, D as DeltaCursor, a as SyncMessage, b as SerializedOperation, W as WireFormat, c as SyncTransport, T as TransportOptions, d as TransportMessageHandler, e as TransportCloseHandler, f as TransportErrorHandler, B as BlobChunkRequestMessage, g as BlobChunkResponseMessage, h as BlobChunkPushMessage, i as SyncEncryptionConfig, V as VersionedKey, Y as YjsDocUpdateMessage, Q as QueueStorage, j as SyncState, k as SyncStatusInfo, l as SyncConfig, R as RejectedOperationStorage, m as SyncStatePersistence, n as SyncQuerySubset, o as RejectedOperation } from './transport-iXX0KA_W.js';
2
- export { A as AcknowledgmentMessage, p as ActiveApplyFailure, q as AwarenessStateWire, r as AwarenessUpdateMessage, E as EncryptedPayload, s as ErrorMessage, H as HandshakeMessage, t as HandshakeResponseMessage, I as InvalidScopeError, M as MemoryRejectedOperationStorage, O as OperationBatchMessage, u as OperationRejectedMessage, v as SYNC_STATES, w as SYNC_STATUSES, x as ScopeViolationError, y as SyncEncryptionAlgorithm, z as SyncPhase, C as SyncScopeContext, F as SyncSettlementOptions, G as SyncSettlementResult, J as SyncStatus, K as dedupeQuerySubsets, L as isAcknowledgmentMessage, N as isAwarenessUpdateMessage, P as isBlobChunkPushMessage, U as isBlobChunkRequestMessage, X as isBlobChunkResponseMessage, Z as isErrorMessage, _ as isHandshakeMessage, $ as isHandshakeResponseMessage, a0 as isOperationBatchMessage, a1 as isOperationRejectedMessage, a2 as isSyncMessage, a3 as isYjsDocUpdateMessage, a4 as operationMatchesQuerySubsets, a5 as querySubsetContains } from './transport-iXX0KA_W.js';
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-Bemtqwep.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-Bemtqwep.js';
3
3
  import * as _korajs_core from '@korajs/core';
4
- import { Operation, VersionVector, ApplyResult, KoraEventEmitter, TimeSource, SyncError, ConnectionQuality } from '@korajs/core';
4
+ import { Operation, VersionVector, ApplyResult, OperationTransform, KoraEventEmitter, TimeSource, SyncError, ConnectionQuality, KoraError, SchemaDefinition } from '@korajs/core';
5
5
  export { ApplyFailureReason, ApplyResult } from '@korajs/core';
6
6
 
7
7
  /**
@@ -13,7 +13,8 @@ export { ApplyFailureReason, ApplyResult } from '@korajs/core';
13
13
  * - Empty scope for a collection `{}`: no field restrictions, operation is in scope.
14
14
  * - Scope has field/value pairs: all must match in the operation's data snapshot.
15
15
  *
16
- * For updates, the snapshot is built by merging `previousData` and `data` (data wins),
16
+ * The snapshot is the operation's `data` (layered over `previousData` only with
17
+ * `options.includePreviousData`), with `id` always taken from `op.recordId`,
17
18
  * which represents the record's state after the operation is applied. When an optional
18
19
  * `fullRecord` is provided (e.g., from the local store), its values fill in scope fields
19
20
  * that weren't included in the operation's data (critical for update operations where
@@ -23,9 +24,11 @@ export { ApplyFailureReason, ApplyResult } from '@korajs/core';
23
24
  * @param scopeMap - Per-collection scope filters, or undefined for no filtering
24
25
  * @param fullRecord - Optional full record state from the store, used to fill in scope
25
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
26
29
  * @returns true if the operation is within scope
27
30
  */
28
- declare function operationMatchesScope(op: Operation, scopeMap: SyncScopeMap | undefined, fullRecord?: Record<string, unknown> | null): boolean;
31
+ declare function operationMatchesScope(op: Operation, scopeMap: SyncScopeMap | undefined, fullRecord?: Record<string, unknown> | null, options?: ScopeSnapshotOptions): boolean;
29
32
  /**
30
33
  * Filter operations to only those matching the given scope map.
31
34
  *
@@ -73,6 +76,12 @@ interface SyncStore {
73
76
  * @returns 'applied' if the operation was new, 'duplicate' if already seen, 'skipped' if filtered
74
77
  */
75
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[];
76
85
  /**
77
86
  * Get operations from a specific node within a sequence range.
78
87
  * Used for computing deltas during sync.
@@ -90,11 +99,75 @@ interface SyncStore {
90
99
  * @param ids - Operation ids that are candidates for re-stamping
91
100
  * @param correctedNowMs - Trusted "now" (server time at handshake) in ms
92
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: move the device back to a node id this database authored under before
113
+ * (RT-38), without rewriting anything. Used after the server refused the current node
114
+ * (another principal owns it) to try a node the signed-in principal owns.
115
+ */
116
+ switchNodeId?(nodeId: string): Promise<void>;
117
+ /**
118
+ * Optional: bind local writes to the signed-in user (RT-42). Moves the store to the
119
+ * user's own node (or a fresh one) when the current node belongs to another user.
120
+ * `conflict`: the node id is pinned and belongs to another user.
121
+ */
122
+ bindPrincipal?(principal: string): Promise<{
123
+ nodeId: string;
124
+ previousNodeId: string;
125
+ switched: boolean;
126
+ conflict: boolean;
127
+ }>;
128
+ /**
129
+ * Optional durability barrier (RT-35): resolve once every write committed so far is
130
+ * durable on this device; reject when it cannot be made durable. The engine awaits it
131
+ * before any operation leaves the device, so the server never holds an operation the
132
+ * device could lose on reload.
133
+ */
134
+ ensureDurable?(): Promise<void>;
135
+ /**
136
+ * Optional: raise a local node's sequence counter to at least `floor`, in a
137
+ * transaction (RT-35: the server holds operations of this node the device lost).
138
+ * @returns Whether the counter moved
139
+ */
140
+ raiseSequenceFloor?(nodeId: string, floor: number): Promise<boolean>;
141
+ /**
142
+ * Optional: give a local operation a fresh sequence number above `floor` (RT-35: the
143
+ * server refused it with `SEQUENCE_CONFLICT` because it holds another operation of
144
+ * this node, lost locally, under that number). A version-2 operation is re-hashed
145
+ * under a new id; its never-sent dependents (`rewritableDependents`) are rewritten
146
+ * to name the new id, transitively.
147
+ * @returns The renumbered operation, the rewritten dependents and the id mapping,
148
+ * or null when it is not in the log
149
+ */
150
+ resequenceOperation?(operationId: string, nodeId: string, floor: number, rewritableDependents?: readonly string[]): Promise<{
151
+ operation: Operation;
152
+ dependents: Operation[];
153
+ idMapping: Record<string, string>;
154
+ } | null>;
155
+ /**
156
+ * Optional: take over another local node id's unsynced writes (RT-40). Resolves to a
157
+ * release function when no live tab uses it, or null when one does.
158
+ */
159
+ claimLocalNode?(nodeId: string): Promise<(() => void) | null>;
93
160
  rebaseUnsyncedOperations?(ids: string[], correctedNowMs: number): Promise<{
94
161
  operations: Operation[];
95
162
  idMapping: Record<string, string>;
96
163
  rebasedCount: number;
97
164
  }>;
165
+ /**
166
+ * Optional (W7): the server's node ids whose writes win `merge('server-
167
+ * authoritative')` fields, from the handshake response. The store persists them
168
+ * and folds with them (re-folding affected records when the set changes).
169
+ */
170
+ setAuthoritativeNodeIds?(nodeIds: readonly string[], revokedNodeIds?: readonly string[]): Promise<void>;
98
171
  /**
99
172
  * Optional: read a record's current materialized field values, used to backfill
100
173
  * scope / query-subset fields that a partial update (or a delete) does not carry
@@ -106,6 +179,35 @@ interface SyncStore {
106
179
  * @returns the record's fields, or null when it cannot be read.
107
180
  */
108
181
  readRecordFields?(collection: string, recordId: string): Promise<Record<string, unknown> | null>;
182
+ /**
183
+ * Optional: whether the local schema defines this collection. Lets the engine tell a
184
+ * merge-decided `'skipped'` (the record is settled, nothing to keep) from a skip
185
+ * because the collection is unknown (the operation must be quarantined and replayed
186
+ * after a schema upgrade). Without it every `'skipped'` result is quarantined.
187
+ */
188
+ hasCollection?(collection: string): boolean;
189
+ /**
190
+ * Remove a record from this client's materialized authorization view without
191
+ * writing a domain delete to the replicated operation log.
192
+ */
193
+ applyScopeRetraction?(collection: string, recordId: string): Promise<void>;
194
+ /** Retract every currently materialized row outside a newly accepted scope. */
195
+ applyScopeNarrowing?(scopes: Record<string, Record<string, unknown>>): Promise<Array<{
196
+ collection: string;
197
+ recordId: string;
198
+ }>>;
199
+ /**
200
+ * Optional (W7): the delivery stream caught up (its final batch fully applied).
201
+ * The store retires local-only provisional cascades of remote deletes (RT-69) and
202
+ * drops row snapshots whose history a full resync brought back (RT-68). With
203
+ * `provisionalOnly` it only retires the provisional cascades: a stream batch that is
204
+ * not the end of a resync must not settle row snapshots (RT-93).
205
+ *
206
+ * @returns How many row snapshots were dropped
207
+ */
208
+ settleAfterCatchUp?(options?: {
209
+ provisionalOnly?: boolean;
210
+ }): Promise<number>;
109
211
  }
110
212
 
111
213
  /** Prefix for handshake rejection when client schema version is unsupported. */
@@ -154,6 +256,19 @@ declare class JsonMessageSerializer implements MessageSerializer {
154
256
  /**
155
257
  * Protobuf-based serializer for sync messages.
156
258
  */
259
+ /**
260
+ * Protobuf message serializer.
261
+ *
262
+ * Lossless by construction (SYNC-9): fields with a native protobuf slot are written there;
263
+ * every other top-level member of the message (fields added after the native mapping,
264
+ * and the bodies of the JSON-shaped awareness and Yjs messages) travels in the `extJson`
265
+ * envelope field (49). Decoding therefore yields exactly what the JSON wire would, for
266
+ * every message type, which a property test over every message type pins.
267
+ *
268
+ * It is not negotiated: the sync engine advertises `['json']` only and the server
269
+ * reports the format its transport actually frames with. Use it explicitly, on both
270
+ * ends of a transport, or not at all.
271
+ */
157
272
  declare class ProtobufMessageSerializer implements MessageSerializer {
158
273
  encode(message: SyncMessage): Uint8Array;
159
274
  decode(data: string | Uint8Array | ArrayBuffer): SyncMessage;
@@ -208,18 +323,49 @@ interface WebSocketTransportOptions {
208
323
  WebSocketImpl?: WebSocketConstructor;
209
324
  /** Connection timeout in ms. Defaults to 10000 (10s). */
210
325
  connectTimeout?: number;
326
+ /**
327
+ * Also append the auth token to the connection URL as `?token=`. Off by
328
+ * default: the token already travels in the handshake message, and URLs are
329
+ * recorded by reverse proxies, load balancers and access logs. Enable only for an
330
+ * intermediary that authenticates the WebSocket upgrade by query parameter.
331
+ */
332
+ tokenInUrl?: boolean;
333
+ /**
334
+ * Detect dead connections with the server's application-level heartbeat (LMS #12).
335
+ * The transport advertises support in the handshake; a server that sends
336
+ * heartbeats states their interval, and a connection that then receives nothing
337
+ * for `heartbeatTimeoutFactor` intervals is closed as dead, which starts a
338
+ * reconnect. Browsers cannot observe WebSocket pings, so without this a half-open
339
+ * connection (a phone that changed networks) looks alive until TCP gives up, often
340
+ * minutes later. Defaults to true.
341
+ */
342
+ heartbeat?: boolean;
343
+ /** Silent intervals tolerated before the connection is declared dead. Defaults to 2.5. */
344
+ heartbeatTimeoutFactor?: number;
211
345
  }
212
346
  /**
213
347
  * WebSocket-based sync transport implementation.
348
+ *
349
+ * Every socket is bound to a generation (SYNC-5): `connect()` closes and detaches the
350
+ * previous socket first, and callbacks of a superseded socket are ignored, so a stale
351
+ * socket closing late can never sever (or feed messages into) the live session.
214
352
  */
215
353
  declare class WebSocketTransport implements SyncTransport {
216
354
  private ws;
355
+ /** Incremented for every socket; callbacks check theirs is still current. */
356
+ private generation;
217
357
  private messageHandler;
218
358
  private closeHandler;
219
359
  private errorHandler;
220
360
  private readonly serializer;
221
361
  private readonly WebSocketImpl;
222
362
  private readonly connectTimeout;
363
+ private readonly tokenInUrl;
364
+ private readonly heartbeat;
365
+ private readonly heartbeatTimeoutFactor;
366
+ /** Silence (ms) after which the current connection is declared dead; 0 = unarmed. */
367
+ private livenessTimeoutMs;
368
+ private livenessTimer;
223
369
  constructor(options?: WebSocketTransportOptions);
224
370
  connect(url: string, options?: TransportOptions): Promise<void>;
225
371
  disconnect(): Promise<void>;
@@ -228,8 +374,20 @@ declare class WebSocketTransport implements SyncTransport {
228
374
  onClose(handler: TransportCloseHandler): void;
229
375
  onError(handler: TransportErrorHandler): void;
230
376
  isConnected(): boolean;
377
+ /** Close and detach the current socket, if any, without firing the close handler. */
378
+ private releaseSocket;
379
+ /** Start watching for silence once the server stated its heartbeat interval. */
380
+ private armLiveness;
381
+ private touchLiveness;
382
+ private clearLiveness;
231
383
  }
232
384
 
385
+ /**
386
+ * Header carrying the server-issued HTTP sync session id. The server returns it on
387
+ * the response to the first POST (the handshake); the client sends it on every
388
+ * later request, together with its bearer credential.
389
+ */
390
+ declare const HTTP_SYNC_SESSION_HEADER = "x-kora-session";
233
391
  interface HttpLongPollingTransportOptions {
234
392
  serializer?: MessageSerializer;
235
393
  fetchImpl?: typeof fetch;
@@ -239,6 +397,13 @@ interface HttpLongPollingTransportOptions {
239
397
  }
240
398
  /**
241
399
  * HTTP long-polling transport with optional WebSocket upgrade.
400
+ *
401
+ * The first POST (the handshake) opens a server-side session; the server answers
402
+ * with a high-entropy session id in the `x-kora-session` header. Every later
403
+ * request carries that id and the bearer credential, and the long-poll loop starts
404
+ * only once the id is known. POSTs are sent one at a time, in order. A 401, 403,
405
+ * 404 or 410 means the server-side session is gone: the transport closes so the
406
+ * sync engine reconnects (and re-authenticates) from scratch.
242
407
  */
243
408
  declare class HttpLongPollingTransport implements SyncTransport {
244
409
  private readonly serializer;
@@ -255,6 +420,10 @@ declare class HttpLongPollingTransport implements SyncTransport {
255
420
  private authToken;
256
421
  private pollAbort;
257
422
  private upgradedTransport;
423
+ /** Server-issued session id; null until the handshake POST is answered. */
424
+ private sessionId;
425
+ /** Serializes POSTs so the server receives messages in send order. */
426
+ private postChain;
258
427
  constructor(options?: HttpLongPollingTransportOptions);
259
428
  connect(url: string, options?: TransportOptions): Promise<void>;
260
429
  disconnect(): Promise<void>;
@@ -265,8 +434,12 @@ declare class HttpLongPollingTransport implements SyncTransport {
265
434
  isConnected(): boolean;
266
435
  private tryUpgradeToWebSocket;
267
436
  private postMessage;
437
+ private startPolling;
438
+ /** The server-side session is gone: stop and report a close so the engine reconnects. */
439
+ private endSession;
268
440
  private runPollLoop;
269
- private makePollHeaders;
441
+ /** Accept, bearer credential and (once issued) the session id: sent on every request. */
442
+ private makeHeaders;
270
443
  }
271
444
 
272
445
  /**
@@ -506,6 +679,9 @@ type BlobChunkChannelMessage = {
506
679
  type: 'blob-chunk-response';
507
680
  requestId: string;
508
681
  bytes: Uint8Array | null;
682
+ /** The server refused the request for rate; retry after `retryAfterMs` (RT-24). */
683
+ throttled?: boolean;
684
+ retryAfterMs?: number;
509
685
  } | {
510
686
  type: 'blob-chunk-push';
511
687
  hash: string;
@@ -569,6 +745,122 @@ interface MetricsCollectorConfig {
569
745
  timeSource?: TimeSource;
570
746
  }
571
747
 
748
+ /**
749
+ * WebCrypto primitives of the key hierarchy (ENC-1, D4b, record format 2):
750
+ *
751
+ * passphrase --PBKDF2-SHA256(salt, iterations)--> KEK (AES-256-GCM, non-extractable)
752
+ * KEK --AES-GCM (AAD: keyring, ring)--> master key (32 random bytes)
753
+ * recovery public key --ECDH P-256, HKDF-SHA256, AES-GCM--> master key (optional)
754
+ * master key --HKDF-SHA256 "data-key-wrap"--> wrapping key (AES-256-GCM)
755
+ * master key --HKDF-SHA256 "record-mac"--> MAC key (HMAC-SHA256)
756
+ * wrapping key --AES-GCM wrap (AAD: keyring, version, keyId)--> data key v1, v2, ...
757
+ * master key --HKDF-SHA256 "master-id"--> master id (names a master a passphrase change retires)
758
+ * recovery anchor key (a data key created WITH the recovery key) --HMAC-SHA256 over
759
+ * (keyring, ring id, key id, recovery public key)--> anchor (16 bytes, in the recovery key)
760
+ *
761
+ * Raw master-key bytes exist only inside the calls that create, re-wrap or open the
762
+ * master key, and are zeroed afterwards. What a device keeps is the two derived keys
763
+ * ({@link MasterKeys}), both non-extractable. Data keys are extractable only for the
764
+ * moment they are wrapped; the copy a device encrypts with is non-extractable.
765
+ */
766
+ /** Thrown when a key record cannot be opened with the given passphrase or recovery key. */
767
+ declare class KeyUnwrapError extends SyncError {
768
+ constructor(message: string, context?: Record<string, unknown>);
769
+ }
770
+ /** The keys a device derives from (and keeps instead of) a ring's master key. */
771
+ interface MasterKeys {
772
+ /** Wraps and unwraps the ring's data keys. */
773
+ wrapKey: CryptoKey;
774
+ /** Authenticates the whole key record. */
775
+ macKey: CryptoKey;
776
+ /**
777
+ * Public name of the master key (`m-` and 32 hex digits, an HKDF output of the master
778
+ * key): what a record's `retiredMasters` lists after a passphrase change. Reveals
779
+ * nothing about the key; only a holder of the master key can compute it.
780
+ */
781
+ id: string;
782
+ }
783
+
784
+ /**
785
+ * What a device keeps of an unlocked keyring: the last key record it authenticated and
786
+ * accepted (also the pin: its ring id and revision are the floor for every later
787
+ * record), the passphrase KEK when known, the keys derived from the ring's master key
788
+ * (to authenticate later records and wrap new data keys), and every data key it
789
+ * opened. Keys are NON-EXTRACTABLE CryptoKeys: script on the page can use them while it
790
+ * runs, but cannot read their bytes. A locked keyring keeps the record (the pin) and
791
+ * nothing else.
792
+ */
793
+ interface CachedKeyring {
794
+ record: WrappedKeyRecord;
795
+ kek: CryptoKey | null;
796
+ /** Derived from the ring's master key; null while locked. */
797
+ master?: MasterKeys | null;
798
+ keys: Array<{
799
+ keyVersion: number;
800
+ keyId: string;
801
+ key: CryptoKey;
802
+ }>;
803
+ /**
804
+ * Ids of master keys a passphrase change retired, from every record this device
805
+ * authenticated (RT-107). Kept when locked: records authenticated only by one of them
806
+ * stay refused across restarts.
807
+ */
808
+ retiredMasters?: string[];
809
+ /**
810
+ * Ids of master keys this device held when the server served a NEWER revision of the
811
+ * same ring that they do not authenticate (a passphrase change this device could not
812
+ * read yet). Records authenticated only by one of them are not adopted automatically;
813
+ * an explicit `unlock(passphrase)` decides (RT-107).
814
+ */
815
+ supersededMasters?: string[];
816
+ }
817
+ /** Where a device keeps unlocked keyrings between app starts. */
818
+ interface KeyCache {
819
+ /** Which kind of cache this is (for status and docs). */
820
+ readonly kind: 'indexeddb' | 'memory' | 'none';
821
+ load(id: string): Promise<CachedKeyring | null>;
822
+ save(id: string, entry: CachedKeyring): Promise<void>;
823
+ clear(id: string): Promise<void>;
824
+ }
825
+ /** Keeps unlocked keyrings for the lifetime of the process only. */
826
+ declare class MemoryKeyCache implements KeyCache {
827
+ readonly kind: "memory";
828
+ private readonly entries;
829
+ load(id: string): Promise<CachedKeyring | null>;
830
+ save(id: string, entry: CachedKeyring): Promise<void>;
831
+ clear(id: string): Promise<void>;
832
+ }
833
+ /** Keeps nothing: the device asks for the passphrase on every start. */
834
+ declare class NoKeyCache implements KeyCache {
835
+ readonly kind: "none";
836
+ load(): Promise<CachedKeyring | null>;
837
+ save(): Promise<void>;
838
+ clear(): Promise<void>;
839
+ }
840
+ /**
841
+ * Keeps unlocked keyrings in IndexedDB (structured clone keeps a CryptoKey
842
+ * non-extractable). Survives reloads, so a device unlocks once and then works offline.
843
+ */
844
+ declare class IndexedDbKeyCache implements KeyCache {
845
+ private readonly databaseName;
846
+ readonly kind: "indexeddb";
847
+ private db;
848
+ /** @param databaseName - IndexedDB database name (one per app database) */
849
+ constructor(databaseName: string);
850
+ load(id: string): Promise<CachedKeyring | null>;
851
+ save(id: string, entry: CachedKeyring): Promise<void>;
852
+ clear(id: string): Promise<void>;
853
+ private open;
854
+ }
855
+ /**
856
+ * The default cache: IndexedDB where it exists (browsers), memory otherwise (Node,
857
+ * tests). See the encryption guide for what each means for offline unlock.
858
+ *
859
+ * @param mode - 'auto' (default), 'indexeddb', 'memory' or 'none'
860
+ * @param databaseName - IndexedDB database name
861
+ */
862
+ declare function createKeyCache(mode?: 'auto' | 'indexeddb' | 'memory' | 'none', databaseName?: string): KeyCache;
863
+
572
864
  /**
573
865
  * Thrown when encryption of operation data fails.
574
866
  */
@@ -583,31 +875,38 @@ declare class DecryptionError extends SyncError {
583
875
  constructor(message: string, context?: Record<string, unknown>);
584
876
  }
585
877
  /**
586
- * Encrypts and decrypts operation `data` and `previousData` fields for
587
- * end-to-end encryption in the sync layer.
878
+ * End-to-end encryption of operations, envelope v2 (protocol v2; ENC-3, NEW-ENC-1).
588
879
  *
589
- * **Design principles:**
590
- * - Only `data` and `previousData` are encrypted. Metadata (id, nodeId,
591
- * collection, timestamps, causalDeps, etc.) stays in cleartext so the
592
- * server can route, deduplicate, and order operations.
593
- * - Each field encryption uses a unique random IV (12 bytes for AES-GCM),
594
- * ensuring that encrypting the same data twice produces different ciphertext.
595
- * - Key rotation is supported via versioned keys. The key version is embedded
596
- * in the {@link EncryptedPayload} so the decryptor can select the correct key.
597
- * - Unencrypted fields pass through during decryption (backward compatibility).
880
+ * **Envelope.** The plaintext `data`, `previousData` and `atomicOps` move into
881
+ * `op.encrypted` ({@link EncryptedOperationEnvelope}: alg, keyId, keyVersion and one
882
+ * AES-256-GCM ciphertext per member). On the wire `data` is null, or holds only the
883
+ * collection's documented cleartext scope fields (`cleartextFields`), so a schema-aware
884
+ * server stores the operation opaquely and can still evaluate scopes. Metadata (id,
885
+ * nodeId, collection, recordId, timestamp, sequence, causalDeps, schemaVersion) stays in
886
+ * cleartext so the server can route, deduplicate and order.
598
887
  *
599
- * @example
600
- * ```typescript
601
- * const encryptor = await SyncEncryptor.create({
602
- * enabled: true,
603
- * key: 'user-passphrase'
604
- * })
888
+ * **Binding (ENC-3).** Every ciphertext is authenticated with AES-GCM additional data
889
+ * canonical(nodeId, collection, recordId, type, timestamp, sequenceNumber, field,
890
+ * keyVersion, hashVersion): a ciphertext moved to another operation, record or member,
891
+ * or an envelope whose metadata was rewritten, fails authentication. `data` and
892
+ * `previousData` are always encrypted, even when null, so a delete is authenticated too.
893
+ * The operation id (the version-2 content hash of the PLAINTEXT) is verified after
894
+ * decryption by the sync engine (`verify-inbound.ts`).
895
+ *
896
+ * **Plaintext.** With encryption enabled an operation without an envelope is refused
897
+ * (a server must not be able to inject unauthenticated writes), unless the config opens
898
+ * a migration window (`allowPlaintextMigration`). Protocol-1 payloads (ciphertext inside
899
+ * `data`, no binding) are refused the same way.
605
900
  *
606
- * // Encrypt before sending
607
- * const encrypted = await encryptor.encryptOperation(operation)
901
+ * **Key id.** The envelope names its key material (`keyId`: the keyring's random key
902
+ * id, ENC-1), so a device holding different material reports a diagnosable
903
+ * `KEY_ID_MISMATCH` rather than a bare authentication failure.
608
904
  *
609
- * // Decrypt after receiving
610
- * const decrypted = await encryptor.decryptOperation(encrypted)
905
+ * @example
906
+ * ```typescript
907
+ * const encryptor = SyncEncryptor.fromKeys([{ version: 1, key: dataKey, keyId }])
908
+ * const sealed = await encryptor.encryptOperation(operation)
909
+ * const opened = await encryptor.decryptOperation(sealed)
611
910
  * ```
612
911
  */
613
912
  declare class SyncEncryptor {
@@ -618,23 +917,28 @@ declare class SyncEncryptor {
618
917
  private readonly keys;
619
918
  /** The current key version used for encryption. */
620
919
  private currentVersion;
920
+ /** Fingerprint of each key version's material (cached). */
921
+ private readonly keyIds;
922
+ private readonly cleartextFields;
923
+ private readonly allowPlaintextMigration;
621
924
  private constructor();
622
925
  /**
623
- * Creates a SyncEncryptor from a {@link SyncEncryptionConfig}.
926
+ * Creates a SyncEncryptor from a passphrase and an explicit, shared salt.
624
927
  *
625
- * Derives the encryption key from the passphrase using PBKDF2. The key
626
- * derivation is async because it uses the Web Crypto API.
928
+ * Low-level: apps use the keyring (`createApp` with `sync.encryption`), which
929
+ * distributes random data keys wrapped by the passphrase. This derives the data key
930
+ * directly from the passphrase, so every device must pass the same salt; there is
931
+ * no random-salt default any more (ENC-1: a per-process random salt made every
932
+ * device derive a different key).
627
933
  *
628
- * @param config - Encryption configuration with passphrase
629
- * @param salt - Optional salt for deterministic key derivation (mainly for testing).
630
- * If omitted, a random salt is generated.
631
- * @param iterations - Optional PBKDF2 iteration count. Defaults to the
632
- * production-strength value. Lower it only in tests.
934
+ * @param config - Encryption configuration with the passphrase in `key`
935
+ * @param salt - The key-derivation salt, shared by every device (16 bytes or more)
936
+ * @param iterations - Optional PBKDF2 iteration count. Lower it only in tests.
633
937
  * @returns A configured SyncEncryptor instance
634
- * @throws {EncryptionError} If configuration is invalid
938
+ * @throws {EncryptionError} If configuration is invalid or the salt is missing
635
939
  * @throws {KeyDerivationError} If key derivation fails
636
940
  */
637
- static create(config: SyncEncryptionConfig, salt?: Uint8Array, iterations?: number): Promise<SyncEncryptor>;
941
+ static create(config: SyncEncryptionConfig, salt: Uint8Array, iterations?: number): Promise<SyncEncryptor>;
638
942
  /**
639
943
  * Creates a SyncEncryptor from pre-derived versioned keys.
640
944
  *
@@ -642,10 +946,11 @@ declare class SyncEncryptor {
642
946
  * or when you have already derived the keys externally.
643
947
  *
644
948
  * @param versionedKeys - Array of versioned keys. The highest version is used for encryption.
949
+ * @param options - Cleartext scope fields and the plaintext migration window
645
950
  * @returns A configured SyncEncryptor instance
646
951
  * @throws {EncryptionError} If no keys are provided
647
952
  */
648
- static fromKeys(versionedKeys: VersionedKey[]): SyncEncryptor;
953
+ static fromKeys(versionedKeys: VersionedKey[], options?: EncryptorOptions): SyncEncryptor;
649
954
  /**
650
955
  * Add a new key version for key rotation.
651
956
  *
@@ -657,57 +962,55 @@ declare class SyncEncryptor {
657
962
  * @throws {EncryptionError} If the key version already exists
658
963
  */
659
964
  addKey(key: VersionedKey): void;
965
+ /** The key registered under an explicit key id (keyring keys), if any. */
966
+ private keyById;
660
967
  /**
661
968
  * Get the current encryption key version number.
662
969
  */
663
970
  getCurrentKeyVersion(): number;
664
971
  /**
665
- * Encrypt an operation's `data` and `previousData` fields.
972
+ * The key id written into envelopes for a key version: the keyring's random key id,
973
+ * or for a passphrase-derived key a fingerprint of its salt (never the key itself).
666
974
  *
667
- * Returns a new Operation with encrypted field values. The original
668
- * operation is not mutated (operations are immutable).
975
+ * @param version - Key version (defaults to the current one)
976
+ */
977
+ getKeyId(version?: number): Promise<string>;
978
+ /**
979
+ * Seal an operation into an envelope v2.
669
980
  *
670
- * Fields that are null (e.g., delete operations) remain null.
981
+ * Returns a new operation (operations are immutable): `encrypted` holds the
982
+ * ciphertexts, `data` is null or the collection's cleartext scope fields, and
983
+ * `previousData`/`atomicOps` are removed. An operation that already carries an
984
+ * envelope is returned unchanged.
671
985
  *
672
- * @param operation - The operation to encrypt
673
- * @returns A new operation with encrypted data fields
986
+ * @param operation - The plaintext operation
987
+ * @returns The sealed operation
674
988
  * @throws {EncryptionError} If encryption fails
675
989
  */
676
990
  encryptOperation(operation: Operation): Promise<Operation>;
677
991
  /**
678
- * Decrypt an operation's `data` and `previousData` fields.
679
- *
680
- * Returns a new Operation with the original field values restored.
681
- * The original operation is not mutated.
992
+ * Open an envelope v2 and restore the plaintext `data`, `previousData` and
993
+ * `atomicOps`. The returned operation has no `encrypted` member.
682
994
  *
683
- * If a field is null or not encrypted (no marker), it passes through unchanged.
684
- * This enables mixed plaintext/encrypted operations during migration.
685
- *
686
- * @param operation - The operation to decrypt
687
- * @returns A new operation with decrypted data fields
688
- * @throws {DecryptionError} If decryption fails (wrong key, tampered data, unsupported version)
995
+ * @param operation - The sealed operation
996
+ * @returns The plaintext operation (its id is NOT verified here; the caller does)
997
+ * @throws {DecryptionError} On a plaintext operation (unless the migration window
998
+ * is open), a protocol-1 payload, an unknown key, a key-id mismatch, or a
999
+ * ciphertext that fails authentication (tampered, transplanted, wrong key)
689
1000
  */
690
1001
  decryptOperation(operation: Operation): Promise<Operation>;
691
1002
  /**
692
- * Encrypt a serialized operation's `data` and `previousData` fields.
693
- *
694
- * Same as {@link encryptOperation} but works with the wire-format
695
- * {@link SerializedOperation} type used by the serializer.
1003
+ * Same as {@link encryptOperation} for the wire form.
696
1004
  *
697
1005
  * @param serialized - The serialized operation to encrypt
698
- * @returns A new serialized operation with encrypted data fields
699
- * @throws {EncryptionError} If encryption fails
1006
+ * @returns The sealed serialized operation
700
1007
  */
701
1008
  encryptSerializedOperation(serialized: SerializedOperation): Promise<SerializedOperation>;
702
1009
  /**
703
- * Decrypt a serialized operation's `data` and `previousData` fields.
704
- *
705
- * Same as {@link decryptOperation} but works with the wire-format
706
- * {@link SerializedOperation} type used by the serializer.
1010
+ * Same as {@link decryptOperation} for the wire form.
707
1011
  *
708
1012
  * @param serialized - The serialized operation to decrypt
709
- * @returns A new serialized operation with decrypted data fields
710
- * @throws {DecryptionError} If decryption fails
1013
+ * @returns The plaintext serialized operation
711
1014
  */
712
1015
  decryptSerializedOperation(serialized: SerializedOperation): Promise<SerializedOperation>;
713
1016
  /**
@@ -725,23 +1028,425 @@ declare class SyncEncryptor {
725
1028
  */
726
1029
  decryptBatch(operations: Operation[]): Promise<Operation[]>;
727
1030
  /**
728
- * Check if a field value contains an encrypted payload.
1031
+ * Check if an operation `data` field holds a protocol-1 encrypted payload (the
1032
+ * pre-envelope format, refused since protocol v2).
729
1033
  *
730
1034
  * @param field - An operation's `data` or `previousData` field
731
- * @returns true if the field contains an encrypted payload
732
1035
  */
733
1036
  static isEncryptedPayload(field: Record<string, unknown> | null): boolean;
734
- private encryptField;
735
- private decryptField;
1037
+ /**
1038
+ * Whether a plaintext operation touches only data the server can already read: its
1039
+ * `data` (and, except for a delete, its `previousData`) names only the collection's
1040
+ * cleartext scope fields, and it has no atomic ops. The sync engine accepts such an
1041
+ * operation from a server-authored node (a cascade, a set-null of a cleartext
1042
+ * reference, a constraint correction of a cleartext field) under end-to-end
1043
+ * encryption: the server cannot seal it (it has no key), and it reveals or forges
1044
+ * nothing the server does not already hold in cleartext. Any sealed field makes it
1045
+ * false, so the operation is refused as plaintext.
1046
+ *
1047
+ * @param operation - A delivered operation without an envelope
1048
+ */
1049
+ isCleartextOnly(operation: Operation): boolean;
1050
+ /** Whether an operation carries an envelope v2. */
1051
+ static isEncryptedOperation(operation: {
1052
+ encrypted?: unknown;
1053
+ }): boolean;
1054
+ private seal;
1055
+ private open;
1056
+ /** The collection's documented cleartext scope fields, or null when none. */
1057
+ private cleartextScope;
1058
+ private encryptMember;
1059
+ private decryptMember;
1060
+ }
1061
+ /** Options of {@link SyncEncryptor.fromKeys}. */
1062
+ interface EncryptorOptions {
1063
+ /** Per collection, the data fields kept in cleartext beside the envelope (for scopes). */
1064
+ cleartextFields?: Readonly<Record<string, readonly string[]>>;
1065
+ /** Accept plaintext (envelope-less) inbound operations during a migration window. */
1066
+ allowPlaintextMigration?: boolean;
736
1067
  }
737
1068
  /**
738
- * Check if a field value contains an encrypted payload.
1069
+ * Check if a field value contains a protocol-1 encrypted payload (ciphertext inside
1070
+ * `data`). Protocol v2 refuses such payloads; the envelope lives in `op.encrypted`.
739
1071
  *
740
1072
  * @param field - An operation's `data` or `previousData` field
741
- * @returns true if the field contains an encrypted payload
1073
+ * @returns true if the field contains a protocol-1 encrypted payload
742
1074
  */
743
1075
  declare function isEncryptedPayload(field: Record<string, unknown> | null): boolean;
744
1076
 
1077
+ /** Lock state of an encryption keyring. */
1078
+ type EncryptionLockState = 'locked' | 'unlocking' | 'unlocked' | 'error';
1079
+ /** Why a keyring is locked or in error (or, while unlocked, what needs attention). */
1080
+ 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';
1081
+ /** Status of an encryption keyring, as `app.encryption.getStatus()` reports it. */
1082
+ interface EncryptionStatus {
1083
+ state: EncryptionLockState;
1084
+ keyring: string;
1085
+ /** Key version new operations are encrypted with, or null while locked. */
1086
+ keyVersion: number | null;
1087
+ /** Key id of that version, or null while locked. */
1088
+ keyId: string | null;
1089
+ /** Every key version this device can decrypt. */
1090
+ availableVersions: number[];
1091
+ /** Where unlocked keys are kept between starts. */
1092
+ cache: KeyCache['kind'];
1093
+ /**
1094
+ * Set while locked or in error. While `unlocked`, `PASSPHRASE_REQUIRED` means the
1095
+ * passphrase changed on another device: this device keeps working with the keys it
1096
+ * holds, and needs the current passphrase for key management.
1097
+ */
1098
+ code?: EncryptionStatusCode;
1099
+ message?: string;
1100
+ }
1101
+ /** The server's answer to a key-record fetch or write. */
1102
+ interface KeyServiceReply {
1103
+ /** 'conflict': the write lost a compare-and-set; `record` is the current one. */
1104
+ status: 'ok' | 'conflict';
1105
+ record: WrappedKeyRecord | null;
1106
+ /**
1107
+ * With no record: key ids the server's stored operations of this owner are encrypted
1108
+ * with (a sample). Non-empty means encrypted history exists, so the record was lost
1109
+ * and a device that holds the ring must re-upload it; nobody may start a new ring.
1110
+ */
1111
+ knownKeyIds?: string[];
1112
+ }
1113
+ /** How a keyring reaches the server's key service (the sync connection). */
1114
+ interface KeyServiceChannel {
1115
+ fetch(keyring: string): Promise<KeyServiceReply>;
1116
+ put(keyring: string, record: WrappedKeyRecord, expectedRevision: number): Promise<KeyServiceReply>;
1117
+ }
1118
+ /** Options of {@link EncryptionKeyring}. */
1119
+ interface EncryptionKeyringOptions {
1120
+ /** Keyring name (one per user by default). */
1121
+ keyring?: string;
1122
+ /** Passphrase source; without one the keyring starts locked until `unlock()`. */
1123
+ passphrase?: string | (() => Promise<string>);
1124
+ /** Where unlocked keys are kept. Defaults to memory (the app picks IndexedDB in browsers). */
1125
+ cache?: KeyCache;
1126
+ /**
1127
+ * PBKDF2 iterations for new records, and the minimum accepted from the server
1128
+ * (a server cannot downgrade the KDF). Defaults to 600,000. Lower only in tests.
1129
+ */
1130
+ kdfIterations?: number;
1131
+ /** Encryptor options (cleartext scope fields, plaintext migration window). */
1132
+ encryptor?: EncryptorOptions;
1133
+ }
1134
+ /** Thrown by keyring management calls. `context.code` names the reason. */
1135
+ declare class EncryptionKeyError extends SyncError {
1136
+ constructor(message: string, context: Record<string, unknown> & {
1137
+ code: string;
1138
+ });
1139
+ /** The reason code (`context.code`), e.g. WRONG_PASSPHRASE. */
1140
+ get keyCode(): string;
1141
+ }
1142
+ /**
1143
+ * A device's view of one encryption keyring (ENC-1, decision D4b; record format 2): the
1144
+ * server-stored, passphrase-wrapped ring, opened and authenticated locally.
1145
+ *
1146
+ * - **Shared key material.** Every device of a user opens the same record, so every
1147
+ * device holds the same data keys and decrypts everything.
1148
+ * - **Authenticated record.** The record carries an HMAC under a key derived from the
1149
+ * ring's master key; a device acts only on records it verified (RT-95). The server
1150
+ * can store, withhold or replay records, nothing more.
1151
+ * - **Forward only.** A device pins the ring id and highest revision it accepted and
1152
+ * refuses a lower revision (`KEY_RECORD_ROLLBACK`, RT-96), keeping its keys and
1153
+ * re-uploading its newer record when connected.
1154
+ * - **Writes re-read.** Every compare-and-set retry adopts the server's current record
1155
+ * first and wraps under that record's master key only (RT-97).
1156
+ * - **Lost records.** A device holding the ring re-uploads it unchanged; a new device
1157
+ * that finds no record but encrypted history waits (`KEY_RECORD_MISSING`); two rings
1158
+ * that exist anyway are merged by the first device holding both (RT-104).
1159
+ * - **Offline.** Once unlocked, keys are cached (non-extractable) and the keyring needs
1160
+ * no server; a cached record also lets `unlock()` work offline.
1161
+ */
1162
+ declare class EncryptionKeyring {
1163
+ readonly name: string;
1164
+ private passphraseSource;
1165
+ private readonly cache;
1166
+ private readonly kdfIterations;
1167
+ private readonly encryptorOptions;
1168
+ private loadedPrincipal;
1169
+ /** The last record this device authenticated and accepted: the pin. */
1170
+ private record;
1171
+ /** Keys derived from the pinned record's master key. */
1172
+ private master;
1173
+ private kek;
1174
+ /** Salt (base64) the KEK was derived with. */
1175
+ private kekSalt;
1176
+ /** Held data keys by key id. Invariant: every held key id is in `record`. */
1177
+ private readonly dataKeys;
1178
+ /**
1179
+ * Ids of master keys a passphrase change retired, from every record this device
1180
+ * authenticated (persisted with the cache): a record authenticated only by one of
1181
+ * them is refused (RT-107).
1182
+ */
1183
+ private readonly retiredMasters;
1184
+ /**
1185
+ * Ids of master keys this device held when the server served a newer revision of the
1186
+ * same ring that they do not authenticate (most likely a passphrase change made
1187
+ * elsewhere). A record authenticated only by one of them is not adopted without an
1188
+ * explicit `unlock(passphrase)`: after a genuine change, no later genuine record is
1189
+ * authenticated by the old master key (RT-107).
1190
+ */
1191
+ private readonly supersededMasters;
1192
+ private encryptor;
1193
+ private status;
1194
+ private readonly listeners;
1195
+ private lockedByApp;
1196
+ private pendingPassphrase;
1197
+ /** A recovery requested while offline, run at the next handshake. */
1198
+ private pendingRecovery;
1199
+ /** The configured passphrase that failed for a salt (not retried for that salt). */
1200
+ private failedSource;
1201
+ /** The current lock is a server-side condition a later session can clear (no user action). */
1202
+ private retryable;
1203
+ /** startNewKeyring() was called: a missing record with encrypted history may be replaced. */
1204
+ private allowNewRing;
1205
+ private failedUnlocks;
1206
+ private nextUnlockAt;
1207
+ private mutex;
1208
+ constructor(options?: EncryptionKeyringOptions);
1209
+ /** The encryptor of the unlocked keyring, or null while locked. */
1210
+ getEncryptor(): SyncEncryptor | null;
1211
+ /** Current status. */
1212
+ getStatus(): EncryptionStatus;
1213
+ /** Whether this device holds the data key with this key id. */
1214
+ holdsKeyId(keyId: string): boolean;
1215
+ /**
1216
+ * Whether the current lock is a server-side condition (a rolled-back, missing,
1217
+ * malformed or forked record) that a later sync session can clear without the user:
1218
+ * sync should reconnect with backoff rather than wait for `unlock()`.
1219
+ */
1220
+ isRetryableLock(): boolean;
1221
+ /** Subscribe to status changes. Returns an unsubscribe function. */
1222
+ onStatusChange(listener: (status: EncryptionStatus) => void): () => void;
1223
+ /**
1224
+ * Whether sync may connect: the keyring is unlocked, or a passphrase is available to
1225
+ * open (or create) the server record during the handshake.
1226
+ */
1227
+ canConnect(): boolean;
1228
+ /**
1229
+ * Load the cached keyring of a principal (the signed-in user, or null). A principal
1230
+ * change drops everything held for the previous one first, so one user's keys never
1231
+ * encrypt another user's operations.
1232
+ */
1233
+ load(principal: string | null): Promise<EncryptionStatus>;
1234
+ /**
1235
+ * Open the keyring against the server's record during a sync handshake: fetch it,
1236
+ * create it on a user's first device, adopt a newer revision, re-upload a lost or
1237
+ * rolled-back one, or merge a forked ring.
1238
+ *
1239
+ * @returns 'ready' when unlocked, 'locked' when a passphrase is needed (or wrong), or
1240
+ * when the server's record must change first ({@link isRetryableLock})
1241
+ * @throws When the key service fails (the session is retried later)
1242
+ */
1243
+ synchronize(channel: KeyServiceChannel, principal: string | null): Promise<'ready' | 'locked'>;
1244
+ /**
1245
+ * Adopt a record the server pushed (another device rotated keys, changed the
1246
+ * passphrase or set up recovery). Without a connection to write to, a rollback or a
1247
+ * fork is reported and resolved at the next handshake.
1248
+ *
1249
+ * @returns 'ready' when still unlocked, 'locked' when the passphrase is needed or the
1250
+ * record was refused
1251
+ */
1252
+ adoptPushed(record: WrappedKeyRecord): Promise<'ready' | 'locked'>;
1253
+ /**
1254
+ * Unlock with a passphrase. With a cached record this works offline; otherwise the
1255
+ * passphrase is kept until the next handshake opens (or creates) the server record,
1256
+ * and the returned promise settles then (pass `channel` when connected).
1257
+ *
1258
+ * Repeated wrong passphrases are slowed down on the device (backoff after three).
1259
+ * This is a usability guard, not a security boundary: whoever holds the record can
1260
+ * try passphrases offline, so passphrase strength and PBKDF2 are the protection.
1261
+ *
1262
+ * @throws {EncryptionKeyError} WRONG_PASSPHRASE, UNLOCK_THROTTLED, KEY_RECORD_MISSING
1263
+ */
1264
+ unlock(passphrase: string, channel: KeyServiceChannel | null): Promise<EncryptionStatus>;
1265
+ /**
1266
+ * Lock: forget the data keys, the master keys and the KEK on this device (cache
1267
+ * included). Sync stops until `unlock()`; local data stays readable (it is not
1268
+ * encrypted at rest by this layer). The pinned record is kept (it is the rollback
1269
+ * floor). The configured passphrase is not used again until `unlock()`.
1270
+ */
1271
+ lock(): Promise<EncryptionStatus>;
1272
+ /**
1273
+ * Rotate: create a new data key version. New operations use it; old versions stay
1274
+ * in the record so history decrypts. Needs the server (compare-and-set). Each retry
1275
+ * adopts the server's current record first and wraps under ITS master key only.
1276
+ */
1277
+ rotate(channel: KeyServiceChannel): Promise<EncryptionStatus>;
1278
+ /**
1279
+ * Change the passphrase: a NEW master key, sealed under a KEK derived from the new
1280
+ * passphrase and a new salt, re-wraps every data key version (and the recovery wrap,
1281
+ * if any). No operation is re-encrypted. The record lists the old master key as
1282
+ * retired (`retiredMasters`): a device that authenticated this record (or a later
1283
+ * one) refuses every record authenticated only by the old master key, however it is
1284
+ * opened. Other devices keep working with their cached data keys (status
1285
+ * `PASSPHRASE_REQUIRED` while unlocked); they need the new passphrase for a key they
1286
+ * do not hold and for key management. A device that was served the new record but
1287
+ * could not read it no longer adopts, without an explicit `unlock()`, a record that
1288
+ * only its old master key authenticates.
1289
+ *
1290
+ * **A passphrase change alone does not contain a leaked passphrase.** The ring is a
1291
+ * shared secret: a device that still holds only the OLD master key cannot tell a
1292
+ * record the server forged under it (with a data key of the attacker's) from a
1293
+ * genuine one, and the server decides which records it forwards. After a suspected
1294
+ * leak: change the passphrase, `rotate()`, call `enableRecovery()` again (a recovery
1295
+ * key made before the change can be steered to a ring the old passphrase built) and
1296
+ * re-unlock EVERY other device with the new passphrase (`lock()`, then
1297
+ * `unlock(newPassphrase)`, and update any configured `key`; never type the old
1298
+ * passphrase again: an explicit unlock with it is the user's decision). A device that
1299
+ * is not re-unlocked, or an old recovery key that is still used, remains exposed.
1300
+ *
1301
+ * @param newPassphrase - The new passphrase
1302
+ * @param channel - The key service
1303
+ * @param currentPassphrase - Optional: verified against the record first
1304
+ */
1305
+ changePassphrase(newPassphrase: string, channel: KeyServiceChannel, currentPassphrase?: string): Promise<EncryptionStatus>;
1306
+ /**
1307
+ * Set up (or replace) the recovery key: the ring's master key is also wrapped to a
1308
+ * new recovery public key, and the private half is returned ONCE. Store it offline;
1309
+ * it recovers the data after a lost passphrase. Without it, a lost passphrase means
1310
+ * the encrypted data is unrecoverable. Needs the passphrase key of the CURRENT
1311
+ * record (the recovery block is authenticated with the record, so only a holder of
1312
+ * the master key can add or replace it).
1313
+ *
1314
+ * The same write adds a new data key version (a rotation) that anchors the recovery
1315
+ * key to this ring: `recover()` accepts only a ring holding that key, which no older
1316
+ * passphrase ever opened (RT-107). After a suspected passphrase leak, call this again
1317
+ * once the passphrase was changed, and destroy the previous recovery key.
1318
+ *
1319
+ * @returns The recovery key (`kora-rk3-<key>.<anchor>`)
1320
+ */
1321
+ enableRecovery(channel: KeyServiceChannel): Promise<string>;
1322
+ /**
1323
+ * Recover after a lost passphrase: open the ring's master key with the recovery key,
1324
+ * verify the whole record with it, and seal the master key under a new passphrase.
1325
+ * Unlocks this device. Without a channel (sync not connected) the recovery runs at
1326
+ * the next handshake. Devices that hold the ring keep working without a prompt (the
1327
+ * master key is unchanged).
1328
+ */
1329
+ recover(recoveryKey: string, newPassphrase: string, channel: KeyServiceChannel | null): Promise<EncryptionStatus>;
1330
+ /**
1331
+ * Last resort after the server lost the key record and no device that holds the ring
1332
+ * will come back (`KEY_RECORD_MISSING`): allow this device to create a new ring.
1333
+ * Operations encrypted under the lost ring stay unreadable on this device. If a device
1334
+ * holding the old ring reconnects later, it merges both rings and the history becomes
1335
+ * readable again. Without a channel (sync is not connected: a missing record ends the
1336
+ * session) the ring is created at the next handshake.
1337
+ *
1338
+ * @throws {EncryptionKeyError} KEY_RECORD_EXISTS when the server has a record (unlock it)
1339
+ */
1340
+ startNewKeyring(channel: KeyServiceChannel | null): Promise<EncryptionStatus>;
1341
+ private recoverLocked;
1342
+ private loadLocked;
1343
+ /**
1344
+ * No record on the server. A device that holds a ring re-uploads it unchanged (same
1345
+ * revision and MAC, so every device's pin still accepts it). A device without one
1346
+ * creates the first ring, unless the server reports encrypted history: then the
1347
+ * record was lost, and it waits for a device that holds the ring.
1348
+ */
1349
+ private createOrRestoreLocked;
1350
+ /** Create a brand-new ring (the user's first device). */
1351
+ private createRingLocked;
1352
+ /**
1353
+ * Accept a server record, after authenticating it.
1354
+ *
1355
+ * - Lower revision of the pinned ring: refused (rollback); re-upload the pin.
1356
+ * - Authenticated by the held master key, or by the passphrase (explicit, else the
1357
+ * configured one): forward when it keeps every key this device holds, else merge.
1358
+ * - Not authenticated: nothing is adopted. A device that holds every key of a newer
1359
+ * revision of its ring keeps working (the passphrase changed elsewhere); otherwise
1360
+ * it needs the passphrase.
1361
+ */
1362
+ private adoptLocked;
1363
+ /** An authenticated record: move forward to it, or merge it with the held ring. */
1364
+ private acceptLocked;
1365
+ /** A lower revision of the pinned ring: refuse it, keep the keys, re-upload the pin. */
1366
+ private rollbackLocked;
1367
+ /**
1368
+ * A record authenticated only by a master key a passphrase change retired: whoever
1369
+ * wrote it knew an OLD passphrase (or master key), not the current one (RT-107).
1370
+ */
1371
+ private refuseRetired;
1372
+ /**
1373
+ * A record authenticated only by a master key this device held when the server served
1374
+ * a newer revision of the ring that the key does not authenticate (RT-107).
1375
+ */
1376
+ private refuseSuperseded;
1377
+ /**
1378
+ * Whether `master` was superseded on this device. An explicit `unlock(passphrase)` is
1379
+ * the user's decision and clears the mark (a server that served a junk revision can
1380
+ * delay, never lock out, a device).
1381
+ */
1382
+ private isSupersededMaster;
1383
+ /** A record this device cannot authenticate: adopt nothing. */
1384
+ private unverifiableLocked;
1385
+ /**
1386
+ * Merge the held ring into `base` (the server's current record, which survives: the
1387
+ * server only accepts append-only successors of what it stores). Keys this device
1388
+ * holds that `base` lacks are appended as new versions; operations keep naming them
1389
+ * by key id. The result has a higher revision than both and is written with
1390
+ * compare-and-set; a lost race adopts (or merges) the winner.
1391
+ */
1392
+ private mergeLocked;
1393
+ /**
1394
+ * Re-wrap the held data keys `base` lacks under `target` (appended after base's
1395
+ * highest version). They are extracted from the pinned record with the held master.
1396
+ */
1397
+ private absorbHeldKeys;
1398
+ /** Open every data key of `record` this device does not hold yet. */
1399
+ private openDataKeys;
1400
+ /**
1401
+ * Whether `record` holds the anchor key of a recovery key: data keys are never removed
1402
+ * or re-keyed (a passphrase change, recovery or merge re-wraps them under the same key
1403
+ * id), so the anchor stays valid for the ring.
1404
+ */
1405
+ private holdsAnchor;
1406
+ /**
1407
+ * Whether `master` (which authenticated `record`) is a master key a passphrase change
1408
+ * retired: listed in `record` itself or in any record this device authenticated.
1409
+ */
1410
+ private isRetiredMaster;
1411
+ /** The next revision after `base`, above the pinned one when it is the same ring. */
1412
+ private nextRevision;
1413
+ /** Authenticate a record with the held master key (no passphrase needed). */
1414
+ private authenticateHeld;
1415
+ /**
1416
+ * Open and authenticate a record with a passphrase.
1417
+ *
1418
+ * @throws {EncryptionKeyError} WRONG_PASSPHRASE, or KEY_RECORD_INVALID when the master
1419
+ * key opens but the record does not authenticate under it (tampered)
1420
+ */
1421
+ private openWithPassphrase;
1422
+ /** The configured or pending passphrase, unless it already failed for this salt. */
1423
+ private sourcePassphraseFor;
1424
+ private followConflictLocked;
1425
+ private commitLocked;
1426
+ /** Persist the pinned record, the held keys and the master marks (RT-107). */
1427
+ private saveCacheLocked;
1428
+ private rebuildEncryptor;
1429
+ private availablePassphrase;
1430
+ private requireMaster;
1431
+ /** The KEK of `record`'s own salt (never one derived for another record's salt). */
1432
+ private requireKek;
1433
+ private requireRecord;
1434
+ private noteFailedUnlock;
1435
+ private dropKeys;
1436
+ /** A record refused for what the server sent: the held keys stay, nothing is adopted. */
1437
+ private refuseRecord;
1438
+ /** A server-side condition: keys kept, a later session (or a push) can clear it. */
1439
+ private holdFor;
1440
+ private lockWith;
1441
+ /** Locks that need the user stop encrypting; records refused keep held keys. */
1442
+ private dropKeysIfUnusable;
1443
+ private statusError;
1444
+ private cacheId;
1445
+ private makeStatus;
1446
+ private setStatus;
1447
+ private exclusive;
1448
+ }
1449
+
745
1450
  /** Default snapshot size above which the doc channel is preferred. */
746
1451
  declare const DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD = 4096;
747
1452
  type RichtextDocListener = (update: Uint8Array) => void;
@@ -801,6 +1506,12 @@ declare class OutboundQueue {
801
1506
  private queue;
802
1507
  private readonly seen;
803
1508
  private readonly inFlight;
1509
+ /**
1510
+ * Ids of queued operations that were put on the wire at least once. Such an op may
1511
+ * already be stored on the server (its ack can be lost), so a clock rebase must never
1512
+ * re-stamp it (W3 step 4). Persisted through the storage when it supports it.
1513
+ */
1514
+ private readonly sent;
804
1515
  private nextBatchId;
805
1516
  private initialized;
806
1517
  constructor(storage: QueueStorage);
@@ -814,13 +1525,25 @@ declare class OutboundQueue {
814
1525
  * Deduplicates by operation ID. Persists to storage.
815
1526
  */
816
1527
  enqueue(op: Operation): Promise<void>;
1528
+ /**
1529
+ * Add several operations at once: deduplicated by id, persisted, and causally
1530
+ * re-sorted once (rather than once per operation).
1531
+ */
1532
+ enqueueMany(ops: Operation[]): Promise<void>;
817
1533
  /**
818
1534
  * Take a batch of operations from the front of the queue.
819
1535
  * Moves them to in-flight status. Returns null if queue is empty.
820
1536
  *
821
1537
  * @param batchSize - Maximum number of operations in the batch
1538
+ * @param accept - Only operations it accepts are taken (in queue order); the others
1539
+ * stay queued. The engine passes "authored under this session's node id": a session
1540
+ * may only upload its own node's operations (RT-38, RT-40).
822
1541
  */
823
- takeBatch(batchSize: number): OutboundBatch | null;
1542
+ takeBatch(batchSize: number, accept?: (op: Operation) => boolean): OutboundBatch | null;
1543
+ /** Whether any queued (not in-flight) operation satisfies `accept`. */
1544
+ hasOperationsMatching(accept: (op: Operation) => boolean): boolean;
1545
+ /** Number of queued (not in-flight) operations that satisfy `accept`. */
1546
+ countMatching(accept: (op: Operation) => boolean): number;
824
1547
  /**
825
1548
  * Acknowledge a batch, removing its operations permanently.
826
1549
  */
@@ -831,7 +1554,33 @@ declare class OutboundQueue {
831
1554
  * queue for retry. This is required when the server accepts a prefix but rejects a
832
1555
  * later operation retriably (for example a stale-scope push after auth changed).
833
1556
  */
834
- acknowledgeThrough(batchId: string, lastSequenceNumber: number): Promise<void>;
1557
+ acknowledgeThrough(batchId: string, lastSequenceNumber: number): Promise<{
1558
+ acknowledged: Operation[];
1559
+ returned: Operation[];
1560
+ }>;
1561
+ /**
1562
+ * Record that a taken batch was put on the wire. Its operations may now be stored on
1563
+ * the server even if no ack ever arrives, so they are excluded from clock rebases.
1564
+ */
1565
+ markSent(batchId: string): Promise<void>;
1566
+ /** Whether this queued operation was ever put on the wire. */
1567
+ wasSent(opId: string): boolean;
1568
+ /**
1569
+ * Forget the sent flag of operations the server explicitly refused without storing
1570
+ * (for example a batch refused with INVALID_TIMESTAMP), so a clock rebase may re-stamp
1571
+ * them. In memory only: after a restart they are conservatively treated as sent.
1572
+ */
1573
+ clearSent(opIds: string[]): void;
1574
+ /** Whether the queue owns this operation (queued or in flight). */
1575
+ has(opId: string): boolean;
1576
+ /** Every operation currently in flight, across batches. */
1577
+ getInFlight(): Operation[];
1578
+ /**
1579
+ * Replace some queued (not in-flight) operations by rewritten ones, for example after
1580
+ * a clock rebase re-stamped never-sent operations under new content-addressed ids.
1581
+ * Operations not named in `oldIds` are untouched.
1582
+ */
1583
+ replace(oldIds: string[], newOps: Operation[]): Promise<void>;
835
1584
  /**
836
1585
  * Return a failed batch to the front of the queue for retry.
837
1586
  * Prepends the operations to maintain priority.
@@ -879,6 +1628,8 @@ declare class OutboundQueue {
879
1628
  * or null if it was not present (already acknowledged and removed).
880
1629
  */
881
1630
  reject(opId: string): Promise<Operation | null>;
1631
+ /** Remove every pending/in-flight operation for a record so authorization loss cannot upload it. */
1632
+ rejectRecord(collection: string, recordId: string): Promise<Operation[]>;
882
1633
  /**
883
1634
  * Remove operations by id from queue and persistent storage.
884
1635
  * Used when ops were already sent during handshake delta exchange.
@@ -913,6 +1664,14 @@ interface SyncEngineOptions {
913
1664
  * before sending and decrypted after receiving. The server never sees plaintext data.
914
1665
  */
915
1666
  encryptor?: SyncEncryptor;
1667
+ /**
1668
+ * End-to-end encryption keyring (ENC-1): the server-stored, passphrase-wrapped data
1669
+ * keys, opened during each handshake before any operation is exchanged. Takes the
1670
+ * place of `encryptor`. While it is locked, sync does not connect.
1671
+ */
1672
+ keyring?: EncryptionKeyring;
1673
+ /** How long a key-service request may take before the session is retried (ms). */
1674
+ keyServiceTimeoutMs?: number;
916
1675
  /** Optional configuration for the metrics collector. */
917
1676
  metricsConfig?: MetricsCollectorConfig;
918
1677
  /** Op-log backed sync state (last acked server vector, unsynced counts). */
@@ -947,6 +1706,8 @@ interface SyncDiagnostics {
947
1706
  */
948
1707
  declare class SyncEngine {
949
1708
  private state;
1709
+ /** Listeners registered through {@link onStateChange}. */
1710
+ private readonly stateListeners;
950
1711
  private readonly transport;
951
1712
  private readonly store;
952
1713
  private readonly config;
@@ -956,28 +1717,98 @@ declare class SyncEngine {
956
1717
  private readonly rejectedStorage;
957
1718
  private readonly batchSize;
958
1719
  private readonly outboundAckTimeoutMs;
1720
+ private readonly outboundRetryBaseDelayMs;
1721
+ private readonly outboundRetryMaxDelayMs;
959
1722
  private readonly encryptor;
1723
+ private readonly keyring;
1724
+ private readonly keyServiceTimeoutMs;
1725
+ /** Key-service requests awaiting their response, by request id. */
1726
+ private readonly keyRequests;
1727
+ /** Sync is paused because the encryption keyring is locked (ENC-1). */
1728
+ private encryptionLocked;
1729
+ /** A key-record refresh for an unknown key version is running. */
1730
+ private keyRefreshInFlight;
960
1731
  private readonly awarenessManager;
961
1732
  private readonly richtextDocChannel;
962
1733
  private readonly blobChunkChannel;
963
1734
  private readonly metricsCollector;
964
1735
  private readonly syncState;
965
1736
  private remoteVector;
1737
+ /** Runs before each outbound operation batch is sent (see setOutboundPreparer). */
1738
+ private outboundPreparer;
1739
+ /**
1740
+ * Per-device node token the server issued at this node id's first anonymous claim
1741
+ * (RT-12). Presented in every handshake; persisted next to the node id when the
1742
+ * sync-state persistence supports it, otherwise kept for this engine's lifetime.
1743
+ */
1744
+ private nodeToken;
1745
+ /**
1746
+ * Node ids the server named authoritative in its last accepted handshake response
1747
+ * (protocol v2). Null until a protocol-2 server answered in this engine's lifetime;
1748
+ * loaded from persistence on start. See {@link SyncEngine.getAuthoritativeNodeIds}.
1749
+ */
1750
+ private authoritativeNodeIds;
1751
+ /** Protocol version of the server in the current session (1 for a beta.12-era server). */
1752
+ private serverProtocolVersion;
1753
+ /** Whether any server answered a handshake (serverProtocolVersion is then real). */
1754
+ private serverProtocolKnown;
966
1755
  private lastAckedServerVector;
967
1756
  private cachedUnsyncedCount;
968
1757
  private lastSyncedAt;
969
1758
  private lastSuccessfulPush;
970
1759
  private lastSuccessfulPull;
971
1760
  private conflictCount;
972
- private currentBatch;
973
- private currentBatchRequiresPartialAck;
1761
+ /**
1762
+ * Every outbound batch taken from the queue and not yet resolved, keyed by queue batch
1763
+ * id: handshake-delta and streaming batches alike. An ack resolves only the batch it
1764
+ * names (SYNC-4); a close returns all of them to the queue.
1765
+ */
1766
+ private readonly inFlightUploads;
1767
+ /** Wire message id -> queue batch id, for ack and rejection correlation. */
1768
+ private readonly uploadByMessageId;
1769
+ /** Incremented on every connect and every disconnect; stale async sends check it. */
1770
+ private sessionEpoch;
1771
+ /** Set by destroy(): the engine never connects or arms a timer again. */
1772
+ private destroyed;
1773
+ private readonly statusListeners;
1774
+ /**
1775
+ * Contiguous acknowledged prefix of this device's own operations (W3 step 2): every
1776
+ * own op with sequence <= this is stored on the server, terminally rejected and
1777
+ * recorded, or not upload-eligible. Never advanced by a server-advertised vector.
1778
+ */
1779
+ private ownAckedThrough;
1780
+ /** Node id the prefix belongs to (a rotation starts a new prefix at 0). */
1781
+ private ownTrackingNodeId;
1782
+ /** Highest own sequence read from the op log this session (above the prefix). */
1783
+ private ownScannedThrough;
1784
+ /**
1785
+ * Own sequences above the prefix that this session knows about, mapped to the ids of
1786
+ * their operations that are still unresolved. An empty set marks a sequence resolved.
1787
+ */
1788
+ private readonly ownUnresolved;
1789
+ /** Ids resolved at sequences the log scan has not reached yet (skip on scan). */
1790
+ private readonly ownResolvedAhead;
1791
+ private ownTrackingChain;
1792
+ /** Inbound quarantine kept in memory when the engine has no sync-state persistence. */
1793
+ private readonly memoryQuarantine;
1794
+ private outboundRetryAttempt;
1795
+ private outboundRetryTimer;
1796
+ /** {@link pushOperation} calls not yet enqueued; the streaming flush waits for 0. */
1797
+ private pendingPushes;
974
1798
  private reconnecting;
975
1799
  private schemaBlocked;
976
1800
  private clockBlocked;
977
1801
  private clockSkewMs;
978
1802
  private blobStorageEnabled;
1803
+ private blobPossessionProof;
979
1804
  private suspensionReason;
980
1805
  private authRejected;
1806
+ /**
1807
+ * Set when the server ended the session with AUTH_EXPIRED or AUTH_REVOKED. The
1808
+ * next connect asks the auth callback for a refreshed token (never the cached
1809
+ * one the server just refused) and is cleared by an accepted handshake.
1810
+ */
1811
+ private credentialRefreshRequired;
981
1812
  private serverFrontier;
982
1813
  private hasInFlightDeliveryBatch;
983
1814
  private blockedFailure;
@@ -985,11 +1816,84 @@ declare class SyncEngine {
985
1816
  private startPromise;
986
1817
  private stopPromise;
987
1818
  private reconnectPromise;
1819
+ /** A node-id rotation started by NODE_ID_CLAIMED; the next connect waits for it (RT-21). */
1820
+ private nodeRotation;
1821
+ /**
1822
+ * Node id of the current (or last) session. Usually the store's node id; while the
1823
+ * engine adopts the unsynced writes of a node no live tab uses (RT-40) it is that
1824
+ * node. A session uploads only operations authored under its node id.
1825
+ */
1826
+ private sessionNodeId;
1827
+ /** Node id the in-memory node token belongs to. */
1828
+ private nodeTokenNodeId;
1829
+ /** Releases the lock held on an adopted node (RT-40); null when not adopting. */
1830
+ private releaseAdoption;
1831
+ /**
1832
+ * Ids of operations the server refused for good (RT-36). Cache of the durable markers,
1833
+ * and the only record when the persistence layer has none (engine lifetime).
1834
+ */
1835
+ private readonly terminalRejected;
1836
+ /** Nodes accepted at a handshake, when the persistence layer keeps no registry. */
1837
+ private readonly memoryAcceptedNodes;
1838
+ /** Every node id this database authored operations under (loaded at start). */
1839
+ private localNodeIds;
1840
+ /** Local nodes whose unsynced writes are held for another principal (RT-38). */
1841
+ private heldNodeIds;
1842
+ /** Unsynced, unqueued operations of other uploadable local nodes (RT-40), counted pending. */
1843
+ private otherNodesPending;
1844
+ /** Unsynced, unqueued operations of held nodes (RT-38), reported separately. */
1845
+ private heldPending;
1846
+ /** Held local nodes as of the last session start (RT-50), for status.heldNodes. */
1847
+ private heldNodeInfos;
1848
+ /**
1849
+ * The server holds operations of this node that the device lost (RT-35): the delivery
1850
+ * watermark was reset so the next session resyncs from 0, which fetches them back.
1851
+ * The session ends once its in-flight uploads are resolved.
1852
+ */
1853
+ private ownRecoveryPending;
1854
+ /**
1855
+ * The signed-in user as resolved for the current session (RT-42): a user id, null
1856
+ * (nobody signed in) or undefined (the app does not tell the engine).
1857
+ */
1858
+ private principal;
1859
+ /**
1860
+ * The user whose credential the current session's handshake carried (RT-50, RT-52):
1861
+ * re-read after the credential fetch, so an accepted handshake binds a node to the
1862
+ * user the server accepted it for, never to a user who signed out meanwhile.
1863
+ */
1864
+ private sessionPrincipal;
1865
+ /** Serializes store bindings to the signed-in user (RT-52). */
1866
+ private principalChain;
1867
+ /** Adoption schedule kept in memory when the persistence layer has none (RT-46). */
1868
+ private memorySchedule;
1869
+ /** The schedule had parked adoptions when last read or written (RT-46). */
1870
+ private scheduleHasParked;
1871
+ /**
1872
+ * Local nodes with unsynced writes this session did not adopt (RT-46): parked (no
1873
+ * progress last time) or waiting for another local node's unsynced parent.
1874
+ */
1875
+ private deferredAdoptions;
1876
+ /** Operations acknowledged in this session (RT-46: a yield needs progress). */
1877
+ private sessionAcked;
1878
+ /** An upload of the adopted node came back unprocessed in this session (RT-46). */
1879
+ private adoptionStalled;
1880
+ /**
1881
+ * The session node's entry in this session's handshake vector (RT-44). The server held
1882
+ * nothing of the node above it then; a SEQUENCE_CONFLICT above it was caused by an
1883
+ * operation stored during this session by another live copy of this database.
1884
+ */
1885
+ private handshakeOwnEntry;
1886
+ /** When this engine last reset delivery for a SEQUENCE_CONFLICT recovery (RT-44). */
1887
+ private lastFullResyncAt;
1888
+ /** A recovery full resync was rate-limited; the next session start after the interval runs it. */
1889
+ private fullResyncDeferred;
1890
+ /** Consecutive failed durability barriers before an upload (RT-49). */
1891
+ private durabilityFailures;
1892
+ /** Uploads proceed without local durability: the barrier failed persistently (RT-49). */
1893
+ private durabilityDegraded;
988
1894
  private deltaBatchesReceived;
989
1895
  private deltaReceiveComplete;
990
1896
  private deltaSendComplete;
991
- /** Op ids sent during handshake delta — removed from outbound queue to avoid duplicate send */
992
- private deltaSentOpIds;
993
1897
  /** Outbound delta batch message IDs awaiting ACK when strictHandshake is enabled */
994
1898
  private pendingDeltaBatchAcks;
995
1899
  /**
@@ -998,10 +1902,25 @@ declare class SyncEngine {
998
1902
  * with the server-accepted scope (server is authoritative).
999
1903
  */
1000
1904
  private activeScope;
1905
+ /** Server-authoritative upload authorization, separate from the downloaded view. */
1906
+ private activeUplinkScope;
1907
+ /**
1908
+ * The downlink scope the server accepted at the last handshake that named one
1909
+ * (SYNC-11), persisted. The next handshake reports its key and that view's watermark
1910
+ * so the server can resume the stream of an unchanged grant. Never sent as the
1911
+ * requested scope: a later-widened grant must not be narrowed to it.
1912
+ */
1913
+ private lastAcceptedScope;
1001
1914
  /** Live query subsets registered from reactive subscriptions */
1002
1915
  private querySubsets;
1003
1916
  private staticQuerySubsets;
1004
1917
  private querySubsetReconnectTimer;
1918
+ /**
1919
+ * Fires when the earliest parked adoption's backoff runs out (RT-53): an idle session of
1920
+ * this tab's own node then hands over to it, so a closed tab's deferred writes are
1921
+ * retried even if this tab never writes or reconnects.
1922
+ */
1923
+ private adoptionRetryTimer;
1005
1924
  /** Resume cursor for paginated initial sync (persisted across reconnects) */
1006
1925
  private resumeDeltaCursor;
1007
1926
  private initialSyncTotalBatches;
@@ -1024,6 +1943,8 @@ declare class SyncEngine {
1024
1943
  * so a view switch resolves synchronously (no race with the reconnect it triggers).
1025
1944
  */
1026
1945
  private readonly deliverySignatureWatermarks;
1946
+ /** Schema transforms: the engine checks views, the store folds them (RT-84). */
1947
+ private readonly operationTransforms;
1027
1948
  private deliveryGapRepeatCount;
1028
1949
  private lastDeliveryGapKey;
1029
1950
  constructor(options: SyncEngineOptions);
@@ -1037,13 +1958,44 @@ declare class SyncEngine {
1037
1958
  */
1038
1959
  stop(): Promise<void>;
1039
1960
  private stopInternal;
1961
+ /**
1962
+ * Stop for good (`app.close()`): disconnect, clear every timer and listener, and refuse
1963
+ * any later start. Unlike stop(), nothing the engine scheduled can run afterwards.
1964
+ */
1965
+ destroy(): Promise<void>;
1966
+ /** Clear every timer the engine owns. */
1967
+ private clearAllTimers;
1968
+ private clearAdoptionRetryTimer;
1969
+ /** Close the transport, logging nothing: it may already be closed. */
1970
+ private closeTransportQuietly;
1971
+ /**
1972
+ * The engine gave up on this session (a message it could not process, a transport
1973
+ * error, a server error): move to disconnected and close the transport (SYNC-5). The
1974
+ * close starts synchronously in this same frame, so the socket is released before
1975
+ * auto-reconnect (which is asynchronous) can open another one.
1976
+ */
1977
+ private abandonSession;
1978
+ /**
1979
+ * Subscribe to status changes that no event announces (an upload ack lowering the
1980
+ * pending count, reaching streaming). Status bridges refresh on it, so a waiter such
1981
+ * as `waitForSettled` sees the change immediately (RT-28).
1982
+ */
1983
+ onStatusChange(listener: () => void): () => void;
1984
+ private notifyStatusChange;
1040
1985
  private ensureDisconnected;
1041
1986
  /**
1042
1987
  * Push a local operation to the outbound queue.
1043
1988
  * If streaming, flushes immediately.
1044
1989
  *
1045
- * Operations outside the configured sync scope are silently skipped
1046
- * because they should remain local-only and not be sent to the server.
1990
+ * Uploads are judged against the UPLINK scope only. Query subsets narrow what
1991
+ * this client downloads, never what it uploads: an edit that moves a record out
1992
+ * of a reactive query's view must still reach the server (SYNC-1).
1993
+ *
1994
+ * Operations on collections that do not sync at all stay local-only by design.
1995
+ * An operation on a synced collection that falls outside the uplink scope would
1996
+ * be refused by the server, so it is recorded in the rejected store and
1997
+ * surfaced as `sync:operation-rejected` (code `OUT_OF_UPLINK_SCOPE`) rather than
1998
+ * kept as a silent local fork.
1047
1999
  */
1048
2000
  pushOperation(op: Operation): Promise<void>;
1049
2001
  /**
@@ -1056,7 +2008,23 @@ declare class SyncEngine {
1056
2008
  /**
1057
2009
  * Get the current developer-facing sync status.
1058
2010
  */
2011
+ /**
2012
+ * Node ids the sync server named authoritative (protocol v2): operations from these
2013
+ * nodes are server-authored and are the only ones that may carry `fieldVersions` or
2014
+ * `foldState`. Null until a protocol-2 server answered (or one answered in an earlier
2015
+ * session and the list was persisted).
2016
+ */
2017
+ getAuthoritativeNodeIds(): readonly string[] | null;
2018
+ /** Protocol version of the server in the current (or last) session; 1 before any. */
2019
+ getServerProtocolVersion(): number;
1059
2020
  getStatus(): SyncStatusInfo;
2021
+ /**
2022
+ * Sync is paused on a locked keyring (ENC-1): no session runs until the app unlocks
2023
+ * it. Reported as a suspension (`phase: 'suspended'`, `reason: 'encryption-locked'`,
2024
+ * `status: 'encryption-locked'`) rather than as plain offline, so the UI can ask for
2025
+ * the passphrase and `waitForSettled` reports why it cannot settle.
2026
+ */
2027
+ private isLockedSuspension;
1060
2028
  private resolvePhase;
1061
2029
  /**
1062
2030
  * True when the server rejected the client's schema version at handshake.
@@ -1068,6 +2036,18 @@ declare class SyncEngine {
1068
2036
  * Local writes continue to work and queue; only sync is paused.
1069
2037
  */
1070
2038
  isClockBlocked(): boolean;
2039
+ /**
2040
+ * True when sync is paused because the end-to-end encryption keyring is locked (no
2041
+ * passphrase, a wrong one, or a key record that must not be used). Local writes go
2042
+ * on and queue; `retryNow()` after an unlock resumes sync.
2043
+ */
2044
+ isEncryptionLocked(): boolean;
2045
+ /**
2046
+ * The key service over the live sync session, for keyring management (rotation,
2047
+ * passphrase change, recovery). Null while not connected: those calls need the
2048
+ * server.
2049
+ */
2050
+ getKeyServiceChannel(): KeyServiceChannel | null;
1071
2051
  /** serverTime - localTime measured at the last handshake, or null before first connect. */
1072
2052
  getClockSkewMs(): number | null;
1073
2053
  /**
@@ -1086,9 +2066,16 @@ declare class SyncEngine {
1086
2066
  */
1087
2067
  recordConflict(): void;
1088
2068
  /**
1089
- * Count of local operations not yet covered by the last acked server vector (op-log source of truth).
2069
+ * Count of this device's own operations not yet stored on the server (or terminally
2070
+ * rejected and recorded): queued, in flight, or above the acknowledged prefix.
1090
2071
  */
1091
2072
  getUnsyncedOperationCount(): Promise<number>;
2073
+ /**
2074
+ * Surface an inbound apply failure. `blocking` marks the stream as stalled on this op
2075
+ * (the watermark does not pass it); `quarantined` means the op is durably recorded and
2076
+ * the stream moves on, so it never marks the stream blocked, whatever `retriable` says
2077
+ * (retriable there means the quarantine replays it later).
2078
+ */
1092
2079
  private emitApplyFailure;
1093
2080
  /**
1094
2081
  * Force an immediate reconnection attempt. If the engine is disconnected
@@ -1097,6 +2084,35 @@ declare class SyncEngine {
1097
2084
  retryNow(): Promise<void>;
1098
2085
  /** Wake a permanent auth suspension after the binding reports a new auth state. */
1099
2086
  notifyAuthChanged(): void;
2087
+ /**
2088
+ * The signed-in user may have changed (RT-42): end the live session (it belongs to the
2089
+ * previous user), then bind the store to the user `config.principal` reports, so the
2090
+ * next local write is authored under that user's own node. Call it before the next
2091
+ * write after an auth change; `createApp` does so for `sync.authClient`. The next
2092
+ * `start()` connects as that user's node.
2093
+ */
2094
+ refreshPrincipal(): Promise<void>;
2095
+ /**
2096
+ * Bind the store to the user the app reports NOW (RT-52), without ending or waiting
2097
+ * for any session: call it on every auth event, before queueing the reconnect that
2098
+ * follows. Writes made from then on are authored under that user's node, even while
2099
+ * an earlier reconnect is still fetching a credential or connecting; that attempt
2100
+ * notices the change before its handshake and starts over. A live session keeps
2101
+ * running as the previous user's node and uploads only that node's writes, until the
2102
+ * queued reconnect replaces it.
2103
+ */
2104
+ bindSignedInUser(): Promise<void>;
2105
+ /** The signed-in user as `config.principal` reports it (undefined when it throws). */
2106
+ private resolvePrincipal;
2107
+ /** Run `fn` after every earlier principal binding (one store binding at a time). */
2108
+ private withPrincipalLock;
2109
+ /**
2110
+ * Resolve the signed-in user and bind the store's node to them (RT-42).
2111
+ *
2112
+ * @returns false when sync must stay suspended: the store's node id is pinned and
2113
+ * belongs to another user, so nothing on this database may upload as this user.
2114
+ */
2115
+ private applyPrincipal;
1100
2116
  /**
1101
2117
  * Atomically refresh the transport session. Overlapping callers share one
1102
2118
  * reconnect, in-flight outbound batches are returned to the queue by stop(), and the
@@ -1113,6 +2129,15 @@ declare class SyncEngine {
1113
2129
  * Get the current internal state (for testing).
1114
2130
  */
1115
2131
  getState(): SyncState;
2132
+ /**
2133
+ * Subscribe to internal state changes (SYNC-8). The reconnection loop uses it to
2134
+ * count an attempt as successful only once the session reaches `streaming`, not
2135
+ * when the handshake is merely sent.
2136
+ *
2137
+ * @param listener - Called with the new state after every transition
2138
+ * @returns Unsubscribe function
2139
+ */
2140
+ onStateChange(listener: (state: SyncState) => void): () => void;
1116
2141
  /**
1117
2142
  * Get the outbound queue (for testing).
1118
2143
  */
@@ -1137,6 +2162,16 @@ declare class SyncEngine {
1137
2162
  * its own last position.
1138
2163
  */
1139
2164
  private deliverySignature;
2165
+ /** The view signature {@link deliverySignature} gives under `scope` and today's subsets. */
2166
+ private deliverySignatureFor;
2167
+ /**
2168
+ * Handshake fields naming the accepted view this client last streamed under (SYNC-11):
2169
+ * its canonical scope key and its delivery watermark. Omitted when no scope was ever
2170
+ * accepted, or when it equals the requested scope (the requested watermark covers it).
2171
+ */
2172
+ private acceptedViewHandshakeFields;
2173
+ /** Persist the downlink scope the server accepted at this handshake (null: none). */
2174
+ private rememberAcceptedScope;
1140
2175
  /**
1141
2176
  * Switch the active delivery watermark to the current view after a scope or subscription
1142
2177
  * change. The prior view's watermark is saved so returning to it resumes; the new view's
@@ -1169,6 +2204,8 @@ declare class SyncEngine {
1169
2204
  * Get the currently active scope map. Returns undefined if no scope is configured.
1170
2205
  */
1171
2206
  getActiveScope(): SyncScopeMap | undefined;
2207
+ /** Get the server-accepted upload authorization scope. */
2208
+ getActiveUplinkScope(): SyncScopeMap | undefined;
1172
2209
  /**
1173
2210
  * Register a live query subset that narrows synced data for a collection.
1174
2211
  * Takes effect on the next connection; reconnects when already connected.
@@ -1203,13 +2240,50 @@ declare class SyncEngine {
1203
2240
  */
1204
2241
  isBlobStorageEnabled(): boolean;
1205
2242
  /**
1206
- * Upload a blob chunk (or manifest) to the server for central persistence.
1207
- * A no-op unless connected and the server advertised blob storage.
2243
+ * Whether the connected server (peer-relay mode, no central storage) asks for the
2244
+ * bytes behind blob references as proof of possession (RT-23). The server verifies
2245
+ * and drops them; the app uploads exactly as for central storage.
2246
+ */
2247
+ isBlobPossessionProofRequested(): boolean;
2248
+ /**
2249
+ * Upload a blob chunk (or manifest) to the server, for central persistence or as
2250
+ * proof of possession. A no-op unless the handshake was accepted (syncing or
2251
+ * streaming) and the server advertised blob storage or asked for proofs.
1208
2252
  */
1209
2253
  uploadBlobChunk(hash: string, bytes: Uint8Array): void;
2254
+ /**
2255
+ * Register work that must reach the server before each outbound operation batch,
2256
+ * on the same connection (messages are processed in order). Used to upload the
2257
+ * bytes behind blob references first: the server only accepts a reference to
2258
+ * content the writer can read or has uploaded (RT-11). Errors are ignored; the
2259
+ * batch is sent regardless.
2260
+ *
2261
+ * @param preparer - Called with the operations about to be sent; null to remove
2262
+ * @returns A function that removes this preparer
2263
+ */
2264
+ setOutboundPreparer(preparer: ((operations: Operation[]) => Promise<void>) | null): () => void;
1210
2265
  private messageChain;
1211
2266
  private enqueueMessage;
1212
2267
  private handleMessageAsync;
2268
+ /** Adopt a key record the server pushed; stop the session if it locks the keyring. */
2269
+ private handlePushedKeyRecord;
2270
+ /**
2271
+ * An operation sealed under a key version newer than any this device holds: another
2272
+ * device rotated keys and the server's push did not reach this session (another
2273
+ * server instance). Fetch the record once, then replay the quarantine in the chain.
2274
+ */
2275
+ private maybeRefreshKeyring;
2276
+ /**
2277
+ * End the session because the keyring is locked; no reconnect until unlocked. A lock
2278
+ * caused by the server's record (rolled back, lost, malformed, forked: RT-96, RT-104)
2279
+ * needs no user action, so the session ends without pausing and the reconnection
2280
+ * loop retries with backoff; the next handshake re-uploads, merges or opens it.
2281
+ */
2282
+ private pauseForLockedKeyring;
2283
+ /** One key-service round trip over the live session. */
2284
+ private keyServiceRequest;
2285
+ /** Fail every pending key-service request (the session ended). */
2286
+ private rejectKeyRequests;
1213
2287
  private handleMessageFailure;
1214
2288
  /**
1215
2289
  * Compares server wall-clock time from the handshake with local time.
@@ -1219,6 +2293,41 @@ declare class SyncEngine {
1219
2293
  */
1220
2294
  private evaluateClockSkew;
1221
2295
  private handleHandshakeResponse;
2296
+ /**
2297
+ * The server refused this session's node id (`NODE_ID_CLAIMED`). What happens to the
2298
+ * node's unsynced writes depends on whether this database was ever accepted as it:
2299
+ *
2300
+ * - Never accepted (RT-21: the claim's token was lost before the device saw it, or
2301
+ * another device took the id first): no principal ever received these writes under
2302
+ * this node, so the never-sent ones are re-authored under a fresh node id and
2303
+ * uploaded. An operation flagged sent is never re-authored (RT-38): it may already
2304
+ * be stored, and a copy under a new id would store the change twice.
2305
+ * - Accepted before (RT-38: another user signed in on a database shared between
2306
+ * users): the node belongs to the principal that used it, so its unsynced writes
2307
+ * are held for that principal, never re-authored or uploaded under another one. The
2308
+ * device moves to a node the signed-in principal may own (a held node not yet tried
2309
+ * in this refusal cycle, typically that user's own earlier node) or to a fresh one.
2310
+ * - An adopted node (RT-40) is held; the store's own node is left as it is.
2311
+ */
2312
+ private rotateNodeIdentity;
2313
+ /** RT-38: hold a refused, previously accepted node's writes and move to another node. */
2314
+ private holdNodeAndSwitch;
2315
+ /** RT-21: re-author a never-accepted node's never-sent writes under a fresh node. */
2316
+ private reauthorUnsentAndRotate;
2317
+ /**
2318
+ * Move `nodeId`'s never-sent unsynced operations to a fresh node id (re-authored, so
2319
+ * they upload under it) and start using it. An operation flagged sent stays: it may
2320
+ * already be stored under `nodeId` (RT-38).
2321
+ *
2322
+ * @returns How many unsynced operations stayed under `nodeId` (sent, unacknowledged)
2323
+ */
2324
+ private rotateUnsent;
2325
+ /** A fresh node id belongs to the signed-in user, when the app says who that is (RT-42). */
2326
+ private bindFreshNode;
2327
+ /** The store moved to another node id: the next session starts from it. */
2328
+ private afterIdentityChange;
2329
+ /** Whether a handshake as `nodeId` was ever accepted on this database. */
2330
+ private wasNodeAccepted;
1222
2331
  /**
1223
2332
  * Re-stamps queued (never-acknowledged) operations whose timestamps are far
1224
2333
  * enough in the future that the server would reject them, using the server's
@@ -1227,12 +2336,123 @@ declare class SyncEngine {
1227
2336
  * immediately instead of waiting for real time to catch up.
1228
2337
  */
1229
2338
  private maybeRebaseQueuedOperations;
2339
+ /**
2340
+ * Upload this device's unsynced operations as the handshake delta. The delta is the
2341
+ * outbound queue itself (rebuilt from the op log above the acknowledged prefix), sent
2342
+ * as tracked batches: nothing leaves the queue until the server acknowledges the batch
2343
+ * that carried it (SYNC-4).
2344
+ */
1230
2345
  private sendDelta;
1231
2346
  private markDeltaSendCompleteIfReady;
1232
- private collectDelta;
2347
+ /** Take the next batch from the queue and track it until it is resolved. */
2348
+ private takeUpload;
2349
+ /** Whether a taken batch still belongs to the live session (SYNC-10). */
2350
+ private isUploadCurrent;
2351
+ /** Return one taken batch to the queue and forget its tracking. */
2352
+ private returnUpload;
2353
+ /** Return every taken batch to the queue (the session ended; nothing was resolved). */
2354
+ private returnAllInFlightUploads;
2355
+ /**
2356
+ * The oldest batch taken from the queue and not yet resolved (diagnostics and
2357
+ * tests); null when nothing is in flight.
2358
+ */
2359
+ private get currentBatch();
2360
+ private inFlightUploadCount;
2361
+ /**
2362
+ * Prepare, encrypt and send one taken batch, then register its message id so the ack
2363
+ * that names it resolves exactly this batch. Every await is followed by a check that
2364
+ * the batch still belongs to the live session; a send that throws returns the batch
2365
+ * to the queue instead of rejecting unhandled (SYNC-10).
2366
+ *
2367
+ * @returns The wire message id, or null when the batch was not sent.
2368
+ */
2369
+ private sendUpload;
2370
+ /**
2371
+ * The durability barrier before an upload (RT-35), with a bounded number of failures
2372
+ * (RT-49). Throws while the failures may be transient; after
2373
+ * {@link DURABILITY_FAILURES_BEFORE_DEGRADED} consecutive failures it returns anyway
2374
+ * (degraded mode, `localDurability: 'degraded'`, `sync:durability-degraded`) so the
2375
+ * server keeps a durable copy. The barrier is still attempted every time: the first
2376
+ * success leaves degraded mode (`sync:durability-restored`).
2377
+ */
2378
+ private awaitUploadDurability;
1233
2379
  private handleOperationBatch;
2380
+ /**
2381
+ * Initial-sync bookkeeping for a received batch: progress, the legacy resume cursor,
2382
+ * and completion on the final batch. Runs for duplicate batches too (NEW-SYNC-1).
2383
+ */
2384
+ private recordReceivedBatch;
2385
+ /**
2386
+ * Apply one delivered operation. Outcomes:
2387
+ * - `applied`: applied, a duplicate, or a merge-decided skip (the record is settled);
2388
+ * - `quarantine`: deliberately not applied and to be recorded durably (unknown
2389
+ * collection, transform unavailable, rejected or deferred apply, far-future
2390
+ * timestamp, undecryptable payload, referential failure), with `sync:apply-failed`;
2391
+ * - `stall`: a retriable failure; the watermark must not pass it (or the engine
2392
+ * cannot record a quarantine durably), so the server re-sends it.
2393
+ */
2394
+ private applyInbound;
2395
+ /**
2396
+ * The delivery stream caught up: let the store retire provisional cascades of
2397
+ * remote deletes (RT-69) and settle row-snapshot records after a full resync
2398
+ * (RT-68). A failure is reported, never fatal: both are retried on the next
2399
+ * catch-up.
2400
+ */
2401
+ private settleAfterCatchUp;
2402
+ /**
2403
+ * Whether a not-applied operation can be recorded so the watermark may pass it: with
2404
+ * a durable quarantine, or when nothing is durable (no persisted watermark either, so
2405
+ * a restart re-delivers everything anyway).
2406
+ */
2407
+ private canQuarantine;
2408
+ /**
2409
+ * Record quarantined operations and (for a delivery batch) the watermark advance in
2410
+ * one durable step.
2411
+ */
2412
+ private commitDeliveryProgress;
2413
+ /** Every inbound operation held in quarantine (durable when persistence supports it). */
2414
+ getQuarantinedOperations(): Promise<QuarantinedOperation[]>;
2415
+ /**
2416
+ * Try to apply every quarantined operation again, oldest delivery first and in HLC
2417
+ * order within a delivery. Runs on start (after a schema upgrade the unknown
2418
+ * collection now exists; after time catches up a far-future op is acceptable) and on
2419
+ * demand. Applied operations leave the quarantine; the rest stay.
2420
+ *
2421
+ * @returns How many operations left the quarantine.
2422
+ */
2423
+ retryQuarantinedOperations(): Promise<number>;
2424
+ private replayQuarantine;
2425
+ /**
2426
+ * Under end-to-end encryption, a plaintext operation authored by the server (a node
2427
+ * the handshake named authoritative: cascades, set-nulls, constraint corrections,
2428
+ * route writes) that touches only the collection's cleartext fields is accepted
2429
+ * without an envelope: the server holds no key, so it cannot seal its own writes, and
2430
+ * such an operation carries nothing the server cannot already read. Any sealed field
2431
+ * (or an operation from any other node) still has to arrive sealed.
2432
+ */
2433
+ private isServerAuthoredCleartext;
2434
+ /** Whether end-to-end encryption is configured (a keyring or a fixed encryptor). */
2435
+ private encryptionConfigured;
2436
+ /** The encryptor in force: the unlocked keyring's, or the fixed one; null when locked. */
2437
+ private activeEncryptor;
2438
+ /**
2439
+ * The encryptor in force. With encryption configured and the keyring locked this
2440
+ * throws: an operation is never sent or applied unencrypted because a key is missing.
2441
+ */
2442
+ private requireEncryptor;
2443
+ /** The key service over this session (the handshake has been accepted). */
2444
+ private liveKeyServiceChannel;
2445
+ /** Re-apply a quarantined operation; true when it no longer needs to be kept. */
2446
+ private applyInboundQuietly;
1234
2447
  /** Acknowledge a delivery batch (used for duplicates) without re-applying it. */
1235
2448
  private sendDeliveryAck;
2449
+ /**
2450
+ * An acknowledgment resolves exactly the upload batch it names (SYNC-4): operations
2451
+ * the server processed (sequence <= `lastSequenceNumber`) leave the queue and count
2452
+ * toward the acknowledged prefix; the rest return to the queue for retry. An ack that
2453
+ * names no tracked batch (a node-token confirmation echo, a batch from an older
2454
+ * session) resolves nothing.
2455
+ */
1236
2456
  private handleAcknowledgment;
1237
2457
  private handleError;
1238
2458
  /**
@@ -1242,6 +2462,34 @@ declare class SyncEngine {
1242
2462
  * the app can reconcile. Unlike {@link handleError}, this is a normal per-op
1243
2463
  * signal, so the connection stays up.
1244
2464
  */
2465
+ private applyScopeRetraction;
2466
+ /**
2467
+ * The local nodes whose unsynced writes are held now (RT-38, RT-50), for the user the
2468
+ * app reports as signed in. See {@link SyncStatusInfo.heldNodes}.
2469
+ */
2470
+ getHeldNodes(): Promise<HeldNodeInfo[]>;
2471
+ /**
2472
+ * Assign the `unassigned` held writes of `nodeId` to the signed-in user (RT-50): writes
2473
+ * made on this device before the app knew who was signed in, on a node that never
2474
+ * synced. Only the app can know whose they are (for example, a single-user device).
2475
+ * They upload on the user's next session (the server then binds the node to them).
2476
+ *
2477
+ * @throws {SyncError} `HELD_ASSIGN_NO_USER` when nobody is signed in;
2478
+ * `HELD_NODE_NOT_ASSIGNABLE` when `nodeId` is not an unassigned held node (it belongs
2479
+ * to a user, or the server refused it for this one)
2480
+ */
2481
+ assignHeld(nodeId: string): Promise<void>;
2482
+ /**
2483
+ * Discard the `unassigned` held writes of `nodeId` from sync (RT-50): they are never
2484
+ * uploaded. They are NOT rolled back: they stay in this device's local database, and
2485
+ * count as synced from now on (`deleteDatabase` no longer protects them). Writes held
2486
+ * for another user (`other-user`) cannot be discarded by this user.
2487
+ *
2488
+ * @returns How many writes were discarded
2489
+ * @throws {SyncError} `HELD_NODE_NOT_DISCARDABLE` when `nodeId` is not an unassigned
2490
+ * held node
2491
+ */
2492
+ discardHeld(nodeId: string): Promise<number>;
1245
2493
  private handleOperationRejected;
1246
2494
  /**
1247
2495
  * Every operation the server rejected that has not yet been reconciled. The app
@@ -1256,36 +2504,243 @@ declare class SyncEngine {
1256
2504
  clearRejectedOperations(operationIds: string[]): Promise<void>;
1257
2505
  private checkDeltaComplete;
1258
2506
  /**
1259
- * Effective server vector: persisted last-ack merged with live handshake remote vector.
2507
+ * In a session of this tab's own node, arm a timer for the earliest parked adoption's
2508
+ * backoff expiry (RT-53). Without it a parked node is retried only at a session start
2509
+ * or after this tab's own uploads made progress, so an idle tab (a dashboard, a kiosk)
2510
+ * would never retry a closed tab's deferred writes while its connection holds.
2511
+ */
2512
+ private armAdoptionRetryTimer;
2513
+ /**
2514
+ * A parked adoption's backoff ran out during this session (RT-53). An idle own-node
2515
+ * session hands over now: it reconnects, and the next session adopts the node (one
2516
+ * retry per backoff; a node deferred again is parked again for longer). A busy session
2517
+ * hands over when its uploads drain (maybeYieldToDeferredNode sees the expiry).
2518
+ */
2519
+ private onAdoptionRetryTimer;
2520
+ /** Serialize every read-modify-write of the own-operation tracking. */
2521
+ private withOwnTracking;
2522
+ private loadOwnTracking;
2523
+ /**
2524
+ * Load the acknowledged prefix for the current node id, once per node id (an engine
2525
+ * restart keeps its in-memory progress). A persistence layer that has never stored a
2526
+ * prefix under this contract means an upgrade from a release that persisted a MAX:
2527
+ * that value can hide a lost op below it, so the prefix restarts at 0 and the device
2528
+ * re-uploads its own history once, chunk by chunk (the server dedups by id). The
2529
+ * prefix is persisted as it advances, so the re-upload resumes where it stopped.
2530
+ */
2531
+ private loadOwnTrackingLocked;
2532
+ /**
2533
+ * Register an own operation. `unresolved` ops are queued for upload; the others are
2534
+ * already resolved (not upload-eligible). An op at or below the prefix (a sequence
2535
+ * that was a gap when the prefix passed it) lowers the prefix: re-uploading is safe,
2536
+ * skipping is not.
2537
+ */
2538
+ private trackOwnOperation;
2539
+ /** Mark an own operation resolved (stored, terminally rejected, or not eligible). */
2540
+ private markOwnResolved;
2541
+ private isOwnOperationResolved;
2542
+ /**
2543
+ * Lower the prefix (the server holds fewer of this device's operations than it
2544
+ * believed) and forget what this session knew above it: the next advance rescans the
2545
+ * log from there and re-enqueues what is missing.
2546
+ */
2547
+ private lowerOwnPrefixLocked;
2548
+ /**
2549
+ * Advance the prefix over every resolved own sequence, reading the next chunk of the
2550
+ * op log whenever it reaches a sequence this session has not examined yet. Reading a
2551
+ * chunk enqueues its upload-eligible operations (and flushes when streaming), so the
2552
+ * one-time upgrade re-upload proceeds chunk by chunk as acks arrive.
2553
+ */
2554
+ private advanceOwnPrefixLocked;
2555
+ /**
2556
+ * Read own operations [from, to] from the op log and register each sequence: an
2557
+ * upload-eligible op not yet queued is enqueued; an ineligible one is recorded and
2558
+ * resolved; a sequence with no op is resolved. Every sequence in the range ends up
2559
+ * registered, which guarantees the advance loop makes progress.
1260
2560
  */
1261
- private getEffectiveServerVector;
2561
+ private scanOwnLog;
2562
+ private persistOwnPrefix;
1262
2563
  private persistLastAckedServerVector;
1263
- private advanceLastAckedForLocalNode;
1264
2564
  /**
1265
- * Refresh cached unsynced count from op log vs effective server vector.
2565
+ * Refresh the pending count: own operations not yet stored on the server (queued or
2566
+ * in flight), plus the own operations above the prefix not read from the log yet.
2567
+ * Computed from the client's own acknowledgments only, so it drops the moment the
2568
+ * server acknowledges, without waiting for a later handshake (RT-28).
1266
2569
  */
1267
2570
  refreshPendingCount(): Promise<void>;
2571
+ /** Synchronous pending count (see {@link refreshPendingCount}). */
2572
+ private computePendingCount;
2573
+ /** Unsynced operations held for a principal that is not signed in (RT-38). */
2574
+ private computeHeldCount;
1268
2575
  /**
1269
- * Returns operations the server has not yet acknowledged (op-log source of truth).
2576
+ * This device's operations not yet stored on the server (queued or in flight).
1270
2577
  */
1271
2578
  getPendingSyncOperations(): Promise<Operation[]>;
1272
2579
  /**
1273
- * Hydrate the outbound queue from unsynced ops in the op log (deduped by operation id).
2580
+ * Rebuild the upload set from the op log: every own operation above the acknowledged
2581
+ * prefix that is upload-eligible is queued (first chunk now, the rest as acks move
2582
+ * the prefix). Persisted queue entries that are already resolved are dropped.
1274
2583
  */
1275
2584
  private reconcileOutboundFromOpLog;
2585
+ /** Node id of the current session: the store's own, or an adopted node's (RT-40). */
2586
+ private currentNodeId;
2587
+ /** A session uploads only operations authored under its own node id (RT-38, RT-40). */
2588
+ private readonly isSessionOperation;
2589
+ /** Whether the queue holds an operation this session may upload. */
2590
+ private hasUploadable;
2591
+ /** A queued or in-flight operation by id. */
2592
+ private findQueuedOperation;
2593
+ /**
2594
+ * Pick the node this session runs as (RT-40, RT-46). A session uploads under one node
2595
+ * id (the server confines uploads to the session's node), so the database's local
2596
+ * nodes take turns, each for one session:
2597
+ *
2598
+ * - A local node other than the store's own that still has unsynced writes, is not
2599
+ * held for another principal (RT-38) or bound to another user (RT-42), was not
2600
+ * refused in this refusal cycle, and is used by no live tab (its lock is free) may
2601
+ * be adopted: the session runs as that node and uploads its writes with their
2602
+ * original identity, then ends; the next session picks again.
2603
+ * - Never one whose writes depend causally on another local node's unsynced writes:
2604
+ * the parent's node goes first (a child is never uploaded before its parent).
2605
+ * - Never a parked one (its last adoption made no progress, for example the server
2606
+ * kept deferring a write) until something else uploaded or its backoff ran out, so
2607
+ * one stuck node never blocks the others or this tab's own writes.
2608
+ * - Otherwise the session runs as the store's own node; it yields to a deferred node
2609
+ * once its own uploads made that node worth retrying (see maybeEndSessionForNodeWork).
2610
+ *
2611
+ * Also counts the other local nodes' unsynced writes, so they are reported (pending,
2612
+ * or held).
2613
+ */
2614
+ private chooseSessionNode;
2615
+ /**
2616
+ * Why a local node other than the store's own must not upload on this session, or
2617
+ * null when it may (RT-38, RT-42, RT-50):
2618
+ *
2619
+ * - Bound to another user than the signed-in one (or refused after acceptance,
2620
+ * RT-38): `other-user`.
2621
+ * - Unbound (its owner was never recorded, RT-50) while a user is signed in: its
2622
+ * owner is learned from the server, never guessed. If it was accepted before, the
2623
+ * server holds a claim on it: it is tried (adopted) on this user's session unless
2624
+ * the server already refused it for them, and an accepted handshake binds it to
2625
+ * them. If it never synced nobody can tell whose its writes are: `unassigned`,
2626
+ * held until the app assigns or discards them.
2627
+ */
2628
+ private holdReason;
2629
+ /** A node bound to another user than the signed-in one (RT-42). */
2630
+ private belongsToAnotherPrincipal;
2631
+ /**
2632
+ * A local node's own operations above its acknowledged prefix that are not
2633
+ * terminally rejected: `count` all of them, `notQueued` those not in the queue, their
2634
+ * `ids`, and the causal parents they name outside this set (`deps`).
2635
+ */
2636
+ private countUnsyncedOfNode;
2637
+ /** The adoption schedule (RT-46), from persistence when it keeps one. */
2638
+ private loadSchedule;
2639
+ private saveSchedule;
2640
+ /**
2641
+ * Operations of `nodeId` were stored (RT-46): a parked adoption may have waited for
2642
+ * them, so the progress counter moves; the node itself is no longer parked.
2643
+ */
2644
+ private recordUploadProgress;
2645
+ /** Park the adopted node: it made no progress in this session (RT-46). */
2646
+ private parkAdoption;
2647
+ /** Forget a drained, non-held local node no live tab uses (bounds the registry). */
2648
+ private forgetDrainedNode;
2649
+ /** Release the lock on an adopted node (RT-40). */
2650
+ private endAdoption;
2651
+ /**
2652
+ * Record an accepted handshake as `nodeId` (RT-38), durably when supported. The server
2653
+ * accepted it for the session's user, so an unbound node is now theirs (RT-50).
2654
+ */
2655
+ private recordNodeAccepted;
2656
+ /** Which of these operation ids the server refused for good (RT-36). */
2657
+ private findTerminalRejections;
2658
+ /** Record durable terminal-rejection markers (RT-36). */
2659
+ private recordTerminalRejections;
2660
+ /**
2661
+ * Restart server->client delivery from 0 at the next session, for the requested and the
2662
+ * accepted views. A full resync includes this client's own operations (a resumed one
2663
+ * does not), which is how operations the device lost come back (RT-35).
2664
+ */
2665
+ private resetDeliveryForFullResync;
2666
+ /**
2667
+ * The server refused an upload with `SEQUENCE_CONFLICT`: it holds another operation of
2668
+ * this node under that number, one this device lost (RT-35: a reload inside the
2669
+ * IndexedDB snapshot window, a restored older copy). Not a refusal of the write: it
2670
+ * gets a fresh number above everything the server holds (same id and content) and is
2671
+ * uploaded again, and the session ends once its uploads are resolved so the next one
2672
+ * resyncs from 0 and fetches the lost operation back.
2673
+ *
2674
+ * @returns Whether the conflict was recovered (false: treat as a plain rejection)
2675
+ */
2676
+ private recoverSequenceConflict;
2677
+ /**
2678
+ * A SEQUENCE_CONFLICT above this session's handshake entry (RT-44): the server held
2679
+ * nothing of this node above that entry when the session began, so the operation now
2680
+ * holding the number was stored during this session, and not by this session. Another
2681
+ * live copy of this database uses the same node id (app data copied to a new machine,
2682
+ * a restored image used alongside the original). Renumbering would only collide again,
2683
+ * and each copy's writes count as the other's own operations (never delivered on a
2684
+ * resumed stream), so this copy moves to a fresh node id: the refused write (refused,
2685
+ * so certainly not stored) and every never-sent one are re-authored under it, the
2686
+ * session ends, and the next one resyncs from 0 to fetch what the other copy wrote.
2687
+ */
2688
+ private recoverFromClone;
2689
+ /**
2690
+ * End the session when it has done its node work and nothing is in flight:
2691
+ * - an own-history recovery is pending (RT-35: reconnect to resync from 0);
2692
+ * - an adopted node's writes are all stored (RT-40: reconnect as the next node, or
2693
+ * this tab's own);
2694
+ * - an adopted node stalled (RT-46: the server deferred part of it): it is parked and
2695
+ * the next session serves another node;
2696
+ * - this tab's own uploads are drained, made progress, and a deferred node is now
2697
+ * worth retrying (RT-46: its parent was among them, or its park has expired).
2698
+ *
2699
+ * @returns Whether the session was ended
2700
+ */
2701
+ private maybeEndSessionForNodeWork;
2702
+ /**
2703
+ * This tab's own session: once its uploads are drained and it stored something, end
2704
+ * it when a node deferred at session start is worth retrying now (RT-46), so other
2705
+ * nodes' writes do not wait for an unrelated reconnect. Bounded: each yield needs new
2706
+ * own progress, and a parked node at most once per {@link ADOPTION_YIELD_MIN_MS}.
2707
+ */
2708
+ private maybeYieldToDeferredNode;
1276
2709
  private flushQueue;
1277
2710
  private startOutboundAckTimer;
1278
2711
  private clearOutboundAckTimer;
2712
+ private scheduleOutboundRetry;
2713
+ private clearOutboundRetryTimer;
2714
+ /** No ack arrived in time: return every batch in flight and reconnect. */
1279
2715
  private handleOutboundAckTimeout;
1280
2716
  private handleAwarenessUpdate;
1281
2717
  private handleTransportClose;
1282
2718
  private handleTransportError;
1283
2719
  private transitionTo;
1284
2720
  private setSerializerWireFormat;
1285
- private operationAllowedForSync;
1286
- private matchesScopeAndSubsets;
2721
+ /**
2722
+ * Upload predicate: the UPLINK scope only, never query subsets (SYNC-1). Query
2723
+ * subsets describe what this client wants to download; the server authorizes
2724
+ * uploads independently of the client's downloaded view.
2725
+ */
2726
+ private operationAllowedForUpload;
2727
+ /**
2728
+ * Record a local operation that the uplink scope refuses, so it is visible to
2729
+ * the app instead of silently diverging from the server. Operations on
2730
+ * collections that are not synced in either direction are local-only by design
2731
+ * and are not recorded.
2732
+ *
2733
+ * Such an operation counts as resolved for the contiguous acknowledged prefix: it is
2734
+ * recorded here and never uploaded (W3 step 2).
2735
+ */
2736
+ private recordOutOfUplinkScope;
2737
+ /**
2738
+ * Whether an operation falls inside the uplink scope. Query subsets never apply to
2739
+ * uploads (SYNC-1), and nothing applies to inbound operations: the client applies
2740
+ * what the server delivered (SYNC-2).
2741
+ */
2742
+ private matchesUplinkScope;
1287
2743
  private readRecordForBackfill;
1288
- private filterAllowedForSync;
1289
2744
  private persistDeltaCursor;
1290
2745
  private persistDeliveryWatermark;
1291
2746
  private scheduleQuerySubsetReconnect;
@@ -1366,6 +2821,13 @@ interface ReconnectionConfig {
1366
2821
  maxAttempts?: number;
1367
2822
  /** Jitter factor (0-1). Random variation applied to delay. Defaults to 0.25. */
1368
2823
  jitter?: number;
2824
+ /**
2825
+ * How long a connection must stay up (see {@link ReconnectionManager.reportConnected})
2826
+ * before the backoff resets to the initial delay, in ms (SYNC-8). A server that
2827
+ * accepts sessions and drops them right away therefore keeps backing off instead of
2828
+ * being retried at the initial interval forever. Defaults to 10000.
2829
+ */
2830
+ stableAfterMs?: number;
1369
2831
  /** Injectable time source for deterministic testing. */
1370
2832
  timeSource?: TimeSource;
1371
2833
  /** Injectable random source for deterministic jitter. Returns value in [0, 1). */
@@ -1375,6 +2837,10 @@ interface ReconnectionConfig {
1375
2837
  * Manages reconnection attempts with exponential backoff and jitter.
1376
2838
  *
1377
2839
  * Formula: min(initialDelay * multiplier^attempt, maxDelay) * (1 + jitter * (random - 0.5) * 2)
2840
+ *
2841
+ * The attempt counter belongs to the manager, not to one `start()` run (SYNC-8): it
2842
+ * keeps growing across runs and resets only once a connection stayed up for
2843
+ * `stableAfterMs` ({@link reportConnected}), or on an explicit {@link reset}.
1378
2844
  */
1379
2845
  declare class ReconnectionManager {
1380
2846
  private readonly initialDelay;
@@ -1382,20 +2848,44 @@ declare class ReconnectionManager {
1382
2848
  private readonly multiplier;
1383
2849
  private readonly maxAttempts;
1384
2850
  private readonly jitter;
2851
+ private readonly stableAfterMs;
1385
2852
  private readonly random;
1386
2853
  private attempt;
1387
2854
  private timer;
2855
+ private stableTimer;
1388
2856
  private stopped;
1389
2857
  private running;
2858
+ /** A disconnect was reported while an attempt was in flight (NEW-SYNC-2). */
2859
+ private pendingRetry;
1390
2860
  private waitResolve;
1391
2861
  constructor(config?: ReconnectionConfig);
1392
2862
  /**
1393
2863
  * Start reconnection attempts. Calls `onReconnect` with exponential backoff.
1394
2864
  *
1395
- * @param onReconnect - Called on each attempt. Return `true` if reconnection succeeded.
2865
+ * An attempt counts as successful only when `onReconnect` returns true AND no
2866
+ * disconnect was reported ({@link requestRetry}) while it ran: a session that
2867
+ * drops before the attempt returns is retried, never silently lost (NEW-SYNC-2).
2868
+ *
2869
+ * @param onReconnect - Called on each attempt. Return `true` if reconnection succeeded
2870
+ * (the caller decides what success means; Kora waits for the `streaming` state).
1396
2871
  * @returns Promise that resolves when reconnection succeeds or maxAttempts reached.
1397
2872
  */
1398
2873
  start(onReconnect: () => Promise<boolean>): Promise<boolean>;
2874
+ /**
2875
+ * Report a disconnect. While a run is in flight it makes the current attempt count
2876
+ * as failed, so the loop retries (with backoff) instead of exiting on a session
2877
+ * that already dropped. Returns true when a running loop took the request; false
2878
+ * means the caller should start a new run.
2879
+ */
2880
+ requestRetry(): boolean;
2881
+ /**
2882
+ * Report that a connection is up (it reached streaming). If it is still up after
2883
+ * `stableAfterMs`, the backoff resets to the initial delay. A disconnect reported
2884
+ * before then ({@link requestRetry} or {@link reportDisconnected}) cancels the reset.
2885
+ */
2886
+ reportConnected(): void;
2887
+ /** Report that the connection dropped: a pending backoff reset is cancelled. */
2888
+ reportDisconnected(): void;
1399
2889
  /**
1400
2890
  * Cancel the current backoff wait and proceed to the next reconnect attempt immediately.
1401
2891
  * No-op if not waiting.
@@ -1406,7 +2896,9 @@ declare class ReconnectionManager {
1406
2896
  */
1407
2897
  stop(): void;
1408
2898
  /**
1409
- * Reset the attempt counter. Call after a successful manual reconnection.
2899
+ * Reset the attempt counter to the initial delay. Call after a deliberate
2900
+ * (user-initiated) reconnection. It never clears a {@link stop}: a stopped loop
2901
+ * stays stopped, so a stop racing a reset cannot resurrect it (SYNC-8).
1410
2902
  */
1411
2903
  reset(): void;
1412
2904
  /**
@@ -1422,6 +2914,7 @@ declare class ReconnectionManager {
1422
2914
  * Current attempt number (for testing).
1423
2915
  */
1424
2916
  getAttemptCount(): number;
2917
+ private clearStableTimer;
1425
2918
  private wait;
1426
2919
  }
1427
2920
 
@@ -1438,6 +2931,42 @@ declare function decodeYjsUpdate(encoded: string): Uint8Array;
1438
2931
  */
1439
2932
  declare function richtextDocKey(collection: string, recordId: string, field: string): string;
1440
2933
 
2934
+ /** The part of the sync encryption config that decides what the server can read. */
2935
+ interface EncryptedRelationConfig {
2936
+ enabled?: boolean;
2937
+ cleartextFields?: Readonly<Record<string, readonly string[]>>;
2938
+ }
2939
+ /**
2940
+ * Thrown at app initialization when end-to-end encryption would seal the foreign-key
2941
+ * field of a relation with a referential `onDelete` policy.
2942
+ */
2943
+ declare class SealedRelationFieldError extends KoraError {
2944
+ readonly relation: string;
2945
+ readonly collection: string;
2946
+ readonly field: string;
2947
+ constructor(message: string, relation: string, collection: string, field: string);
2948
+ }
2949
+ /**
2950
+ * Refuse a configuration where end-to-end encryption seals the foreign key of a
2951
+ * relation whose `onDelete` is `cascade`, `set-null` or `restrict`.
2952
+ *
2953
+ * Referential policies are enforced by the sync server, the one replica that sees every
2954
+ * child: it cascades (or nulls, or refuses) for children no single device knows about,
2955
+ * and relays the result. It can only do so when it can read the foreign key. With the
2956
+ * key sealed, no replica can enforce the policy for everyone, and every device-side
2957
+ * substitute either loses a cascade or deletes a child another device moved away
2958
+ * (RT-74, RT-78, RT-82). So the foreign key of such a relation must travel in cleartext:
2959
+ * it reveals which parent a child belongs to, never the child's other fields.
2960
+ *
2961
+ * Relations with `onDelete: 'no-action'` (or none) are not affected: nothing is
2962
+ * enforced on delete, so their foreign key may stay sealed.
2963
+ *
2964
+ * @param schema - The app schema
2965
+ * @param encryption - The sync encryption config (none, or disabled: nothing to check)
2966
+ * @throws {SealedRelationFieldError} Naming the relation, the field and the fix
2967
+ */
2968
+ declare function validateEncryptedRelations(schema: SchemaDefinition, encryption: EncryptedRelationConfig | null | undefined): void;
2969
+
1441
2970
  /**
1442
2971
  * Thrown when key derivation fails.
1443
2972
  */
@@ -1529,4 +3058,4 @@ declare function getRemoteAwarenessStates(awareness: AwarenessManager): Awarenes
1529
3058
  */
1530
3059
  declare function subscribeRemoteAwarenessStates(awareness: AwarenessManager, listener: (states: AwarenessState[]) => void): () => void;
1531
3060
 
1532
- export { type AwarenessChange, type AwarenessCursor, AwarenessManager, type AwarenessMessage, type AwarenessState, type AwarenessUser, BlobChunkChannel, type BlobChunkChannelMessage, type BlobChunkChannelOptions, BlobChunkPushMessage, BlobChunkRequestMessage, BlobChunkResponseMessage, type ChaosConfig, ChaosTransport, ConnectionMonitor, type ConnectionMonitorConfig, type CursorInfo, DEFAULT_RICHTEXT_DOC_CHANNEL_THRESHOLD, DecryptionError, DeltaCursor, EncryptionError, HttpLongPollingTransport, type HttpLongPollingTransportOptions, JsonMessageSerializer, KeyDerivationError, type MessageSerializer, NegotiatedMessageSerializer, OFFLINE_SYNC_STATUS, type OutboundBatch, OutboundQueue, ProtobufMessageSerializer, QueueStorage, type ReconnectionConfig, ReconnectionManager, RejectedOperation, RejectedOperationStorage, RichtextDocChannel, type RichtextDocChannelOptions, type RichtextDocListener, SCHEMA_MISMATCH_PREFIX, 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, YjsDocUpdateMessage, createDeltaCursorFromBatch, createSyncStatusController, decodeBlobChunkBytes, decodeDeltaCursor, decodeYjsUpdate, deriveKey, deriveVersionedKey, encodeBlobChunkBytes, encodeDeltaCursor, encodeYjsUpdate, filterOperationsByScope, generateSalt, getRemoteAwarenessStates, isClientSchemaVersionSupported, isEncryptedPayload, isSchemaMismatchReject, operationMatchesScope, richtextDocKey, sliceOperationsAfterCursor, subscribeRemoteAwarenessStates, versionVectorToWire, wireToVersionVector };
3061
+ 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 };