@korajs/sync 1.0.0-beta.12 → 1.0.0-beta.14

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