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