@korajs/server 1.0.0-beta.12 → 1.0.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -78
- package/dist/chunk-IRRXJ7YN.js +188 -0
- package/dist/chunk-IRRXJ7YN.js.map +1 -0
- package/dist/index.cjs +10017 -1216
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2686 -139
- package/dist/index.d.ts +2686 -139
- package/dist/index.js +9733 -1212
- package/dist/index.js.map +1 -1
- package/dist/internal.d.cts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/server-backup-WBIYHWYQ.js +249 -0
- package/dist/server-backup-WBIYHWYQ.js.map +1 -0
- package/dist/{server-transport-CU1BLWgr.d.cts → server-transport-BGQkRCI4.d.cts} +7 -0
- package/dist/{server-transport-CU1BLWgr.d.ts → server-transport-BGQkRCI4.d.ts} +7 -0
- package/package.json +4 -4
- package/dist/server-backup-MOICK6MI.js +0 -116
- package/dist/server-backup-MOICK6MI.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { Operation, HybridLogicalClock, SchemaDefinition, BlobRef, KoraEventEmitter,
|
|
1
|
+
import { KoraError, Operation, VersionVector, HLCTimestamp, RecordFieldVersions, FoldState, HybridLogicalClock, SchemaDefinition, OperationTransform, BlobRef, KoraEventEmitter, TimeSource, ScopeMap as ScopeMap$1 } from '@korajs/core';
|
|
2
|
+
export { SCOPE_CLAIMS_KEY, claimScopes } from '@korajs/core';
|
|
2
3
|
import * as _korajs_sync from '@korajs/sync';
|
|
3
|
-
import { SyncStore, ApplyResult, MessageSerializer, SyncMessage, AwarenessUpdateMessage, YjsDocUpdateMessage, BlobChunkRequestMessage, BlobChunkResponseMessage } from '@korajs/sync';
|
|
4
|
-
import { S as ServerTransport, a as ServerMessageHandler, b as ServerCloseHandler, c as ServerErrorHandler } from './server-transport-
|
|
4
|
+
import { SyncStore, ApplyResult, MessageSerializer, SyncMessage, EncryptionKeyRequestMessage, EncryptionKeyPutMessage, EncryptionKeyResponseMessage, WrappedKeyRecord, AwarenessUpdateMessage, YjsDocUpdateMessage, BlobChunkRequestMessage, BlobChunkResponseMessage } from '@korajs/sync';
|
|
5
|
+
import { S as ServerTransport, a as ServerMessageHandler, b as ServerCloseHandler, c as ServerErrorHandler } from './server-transport-BGQkRCI4.js';
|
|
6
|
+
import { SideEffectOp } from '@korajs/merge';
|
|
5
7
|
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
|
6
8
|
import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
|
7
9
|
|
|
@@ -36,7 +38,15 @@ type RoutePredicate = Record<string, RoutePredicateOperators>;
|
|
|
36
38
|
*/
|
|
37
39
|
type ScopeMap = Record<string, Record<string, unknown>>;
|
|
38
40
|
declare const DEFAULT_MAX_SCOPE_PREDICATE_VALUES = 100;
|
|
39
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Canonicalize bounded `$in` predicates so equivalent authorization has one signature.
|
|
43
|
+
*
|
|
44
|
+
* Fails closed: an `undefined`/`null` predicate value (also inside `$in`) would match
|
|
45
|
+
* every record lacking the field, so it is refused (RT-8).
|
|
46
|
+
*
|
|
47
|
+
* @throws {InvalidScopePredicateError} On an undefined/null predicate value
|
|
48
|
+
* @throws {ScopePredicateLimitError} On a malformed or oversized `$in`
|
|
49
|
+
*/
|
|
40
50
|
declare function normalizeScopeMap(scopes: ScopeMap, maxValues?: number): ScopeMap;
|
|
41
51
|
/**
|
|
42
52
|
* Returns true if an operation is visible to a session based on its scopes.
|
|
@@ -47,7 +57,91 @@ declare function normalizeScopeMap(scopes: ScopeMap, maxValues?: number): ScopeM
|
|
|
47
57
|
* - All scoped field/value pairs must match the operation snapshot
|
|
48
58
|
*/
|
|
49
59
|
declare function operationMatchesScopes(op: Operation, scopes: ScopeMap | undefined, fullRecord?: Record<string, unknown> | null): boolean;
|
|
60
|
+
/** Why {@link authorizeUplinkWrite} refused a write. */
|
|
61
|
+
type UplinkAuthorizationCode = 'SCOPE_VIOLATION' | 'INVALID_OPERATION';
|
|
62
|
+
/** Result of {@link authorizeUplinkWrite}. */
|
|
63
|
+
type UplinkAuthorizationResult = {
|
|
64
|
+
allowed: true;
|
|
65
|
+
} | {
|
|
66
|
+
allowed: false;
|
|
67
|
+
code: UplinkAuthorizationCode;
|
|
68
|
+
message: string;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Thrown by a server store when the uplink authorization re-check, evaluated inside
|
|
72
|
+
* the store's apply critical section against the row as it is at commit time,
|
|
73
|
+
* refuses an operation. Nothing was written.
|
|
74
|
+
*/
|
|
75
|
+
declare class UplinkAuthorizationError extends KoraError {
|
|
76
|
+
readonly rejectionCode: UplinkAuthorizationCode;
|
|
77
|
+
constructor(rejectionCode: UplinkAuthorizationCode, message: string, context?: Record<string, unknown>);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Decides whether an untrusted writer (a sync session or a scoped route) may apply
|
|
81
|
+
* `op`, given the record as it is currently stored and the writer's scope.
|
|
82
|
+
*
|
|
83
|
+
* This is the single authorization rule for every untrusted write path (sync
|
|
84
|
+
* uploads, `request.kora` routes, the conditional store path and the Yjs doc
|
|
85
|
+
* channel). It never reads `op.previousData` for scope decisions, because it is
|
|
86
|
+
* attacker-controlled, and it never lets op fields name a different record:
|
|
87
|
+
*
|
|
88
|
+
* 1. `op.data` / `op.previousData` must not carry an `id` other than `op.recordId`
|
|
89
|
+
* (`INVALID_OPERATION`), whether or not scopes are configured.
|
|
90
|
+
* 2. Without scopes every (well-formed) write is allowed.
|
|
91
|
+
* 3. If a stored row exists (including a soft-deleted one) it must be in scope,
|
|
92
|
+
* judged on the stored values only, for inserts, updates and deletes alike. This
|
|
93
|
+
* stops editing, deleting or overwriting (same-id insert) another tenant's record.
|
|
94
|
+
* 4. The post-image must be in scope: `{...stored, ...op.data}` for updates,
|
|
95
|
+
* `op.data` for inserts and the stored row for deletes. This stops moving a
|
|
96
|
+
* record out of scope (ownership transfer is a trusted server-route operation).
|
|
97
|
+
*
|
|
98
|
+
* In both images `id` is `op.recordId`, assigned last.
|
|
99
|
+
*
|
|
100
|
+
* @param op - The operation the writer wants to apply
|
|
101
|
+
* @param storedRow - The record as stored now (including soft-deleted), or null
|
|
102
|
+
* @param scopes - The writer's uplink scope map, or undefined for no restriction
|
|
103
|
+
* @returns Whether the write is allowed, and why not when it is refused
|
|
104
|
+
*/
|
|
105
|
+
declare function authorizeUplinkWrite(op: Operation, storedRow: Record<string, unknown> | null | undefined, scopes: ScopeMap | undefined): UplinkAuthorizationResult;
|
|
50
106
|
|
|
107
|
+
/** A stored legacy pair, reported to {@link ApplyRemoteOptions.onLegacySequencePair}. */
|
|
108
|
+
interface LegacySequencePair {
|
|
109
|
+
operationId: string;
|
|
110
|
+
nodeId: string;
|
|
111
|
+
sequenceNumber: number;
|
|
112
|
+
/** The operations that already held the sequence. */
|
|
113
|
+
holderIds: string[];
|
|
114
|
+
/** True when accepted because the writer is a legacy client (not a pre-epoch holder). */
|
|
115
|
+
legacyWriter: boolean;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* How the server resolved an uploaded operation it did NOT store under the submitted
|
|
119
|
+
* (node, sequence) (RT-43, RT-47):
|
|
120
|
+
* - `'ignored'`: the validator answered `ignore` (handled out of band, nothing stored);
|
|
121
|
+
* - `'refused'`: a terminal (non-retriable) rejection; the same id is answered with the
|
|
122
|
+
* original rejection forever after;
|
|
123
|
+
* - `'stored-elsewhere'`: the id is stored under another sequence of the same node (a
|
|
124
|
+
* client sequence repair renumbered it), acknowledged as a duplicate.
|
|
125
|
+
* A retriable rejection, and a `SEQUENCE_CONFLICT` (the client renumbers and resubmits
|
|
126
|
+
* under the same id), are never resolutions.
|
|
127
|
+
*/
|
|
128
|
+
type OperationResolutionOutcome = 'ignored' | 'refused' | 'stored-elsewhere';
|
|
129
|
+
/** A durable record of how an uploaded operation was resolved without being stored. */
|
|
130
|
+
interface OperationResolution {
|
|
131
|
+
operationId: string;
|
|
132
|
+
nodeId: string;
|
|
133
|
+
sequenceNumber: number;
|
|
134
|
+
outcome: OperationResolutionOutcome;
|
|
135
|
+
/** Rejection code, for `'refused'`. */
|
|
136
|
+
code: string | null;
|
|
137
|
+
/** Rejection message, for `'refused'`. */
|
|
138
|
+
message: string | null;
|
|
139
|
+
}
|
|
140
|
+
/** Where a stored operation sits in its node's sequence space. */
|
|
141
|
+
interface StoredOperationKey {
|
|
142
|
+
nodeId: string;
|
|
143
|
+
sequenceNumber: number;
|
|
144
|
+
}
|
|
51
145
|
/**
|
|
52
146
|
* A materialized record reconstructed from the operation log
|
|
53
147
|
* or read from a materialized collection table.
|
|
@@ -100,6 +194,12 @@ interface ConditionalApplyContext {
|
|
|
100
194
|
* it into operation creation) so they sort strictly after every prior write.
|
|
101
195
|
*/
|
|
102
196
|
clock: HybridLogicalClock;
|
|
197
|
+
/**
|
|
198
|
+
* Read a record as stored (including a soft-deleted one) inside the store's
|
|
199
|
+
* conditional-apply transaction, for authorizing the built operations against the
|
|
200
|
+
* same state they commit over. Optional; callers fall back to a plain read.
|
|
201
|
+
*/
|
|
202
|
+
readStoredRow?: (collection: string, id: string) => Promise<MaterializedRecord | null>;
|
|
103
203
|
}
|
|
104
204
|
/** Result of {@link ServerStore.applyConditional}. */
|
|
105
205
|
interface ConditionalApplyResult {
|
|
@@ -119,6 +219,30 @@ interface ConditionalApplyResult {
|
|
|
119
219
|
interface DeliveredOperation {
|
|
120
220
|
operation: Operation;
|
|
121
221
|
deliverySequence: number;
|
|
222
|
+
/**
|
|
223
|
+
* The record's scope-relevant values around this operation, captured from the
|
|
224
|
+
* server's own rows when it was applied (RT-14, RT-15). Absent (or null) for
|
|
225
|
+
* operations stored before snapshots existed that could not be backfilled.
|
|
226
|
+
*/
|
|
227
|
+
scopeSnapshot?: OperationScopeSnapshot | null;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Scope-relevant field values of a record, captured by the store from its own rows
|
|
231
|
+
* when an operation is applied (never from the writer's `previousData`). Download
|
|
232
|
+
* visibility of a historical operation is judged on `post`, so a later ownership
|
|
233
|
+
* transfer does not disclose the record's earlier history to the new owner (RT-14),
|
|
234
|
+
* and a scope exit is judged from `pre` to `post` (RT-15).
|
|
235
|
+
*
|
|
236
|
+
* Only scalar fields (string, number, boolean, enum, timestamp) and `id` are kept:
|
|
237
|
+
* scope predicates compare with `Object.is`, so other kinds can never match. A
|
|
238
|
+
* string longer than {@link MAX_SCOPE_SNAPSHOT_STRING_LENGTH} is left out (fails
|
|
239
|
+
* closed for a scope on such a field) so a snapshot never copies large text.
|
|
240
|
+
*/
|
|
241
|
+
interface OperationScopeSnapshot {
|
|
242
|
+
/** Values before the operation; null when the record did not exist or was deleted. */
|
|
243
|
+
pre: Record<string, unknown> | null;
|
|
244
|
+
/** Values after it; a delete keeps the record's last values; null when unknown. */
|
|
245
|
+
post: Record<string, unknown> | null;
|
|
122
246
|
}
|
|
123
247
|
/**
|
|
124
248
|
* Options for querying a materialized collection table.
|
|
@@ -137,11 +261,266 @@ interface CollectionQueryOptions {
|
|
|
137
261
|
/** Include soft-deleted records (default: false) */
|
|
138
262
|
includeDeleted?: boolean;
|
|
139
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Options for {@link ServerStore.applyRemoteOperation}.
|
|
266
|
+
*/
|
|
267
|
+
interface ApplyRemoteOptions {
|
|
268
|
+
/**
|
|
269
|
+
* Authorization re-check for an operation submitted by an untrusted writer.
|
|
270
|
+
*
|
|
271
|
+
* The store calls it inside its apply critical section (the SQLite write
|
|
272
|
+
* transaction, the in-memory store's synchronous apply, or a Postgres
|
|
273
|
+
* transaction holding the per-record advisory lock shared with conditional
|
|
274
|
+
* applies) with the record as stored at that moment, including a soft-deleted
|
|
275
|
+
* one, or null when none exists. Returning a refusal aborts the apply and the
|
|
276
|
+
* store throws {@link UplinkAuthorizationError}; nothing is written.
|
|
277
|
+
*
|
|
278
|
+
* This closes the window between a caller's own pre-check and the write, where an
|
|
279
|
+
* ownership change or a same-id insert committed by another writer (or another
|
|
280
|
+
* server instance) could otherwise slip in.
|
|
281
|
+
*/
|
|
282
|
+
authorize?: (storedRow: MaterializedRecord | null) => UplinkAuthorizationResult;
|
|
283
|
+
/**
|
|
284
|
+
* The writer does not reserve its sequence numbers inside the write transaction
|
|
285
|
+
* (a client that did not advertise the `sequenceReservation` handshake capability,
|
|
286
|
+
* Kora <= beta.12; RT-37). A different operation already holding the
|
|
287
|
+
* `(nodeId, sequenceNumber)` then does not refuse this one: both are stored as a
|
|
288
|
+
* legacy pair (each keeps its own delivery sequence, so both are delivered) instead
|
|
289
|
+
* of throwing {@link SequenceConflictError}. Default false: enforce.
|
|
290
|
+
*/
|
|
291
|
+
legacySequenceWriter?: boolean;
|
|
292
|
+
/**
|
|
293
|
+
* Called once the operation was committed as the second holder of its sequence (a
|
|
294
|
+
* legacy pair), for logging and diagnostics. Never called for a refused write.
|
|
295
|
+
*/
|
|
296
|
+
onLegacySequencePair?: (pair: LegacySequencePair) => void;
|
|
297
|
+
}
|
|
298
|
+
/** Options of {@link ServerStore.setSchema}. */
|
|
299
|
+
interface ServerSchemaOptions {
|
|
300
|
+
/** Schema transforms the store folds with (transforms at fold time, RT-84). */
|
|
301
|
+
operationTransforms?: readonly OperationTransform[];
|
|
302
|
+
}
|
|
303
|
+
/** One stored end-to-end key record (opaque JSON) and its owner, as backups carry it. */
|
|
304
|
+
interface EncryptionKeyRecordRow {
|
|
305
|
+
owner: string;
|
|
306
|
+
keyring: string;
|
|
307
|
+
revision: number;
|
|
308
|
+
/** The record's JSON, exactly as stored. */
|
|
309
|
+
record: string;
|
|
310
|
+
}
|
|
140
311
|
/**
|
|
141
312
|
* Server-side store interface. Extends SyncStore with lifecycle,
|
|
142
313
|
* introspection, and materialization methods needed by the sync server.
|
|
143
314
|
*/
|
|
144
315
|
interface ServerStore extends SyncStore {
|
|
316
|
+
/**
|
|
317
|
+
* Apply an operation to the log and materialized state. With
|
|
318
|
+
* `options.authorize`, the authorization is re-checked atomically with the write
|
|
319
|
+
* (see {@link ApplyRemoteOptions}). Stores written before this option existed
|
|
320
|
+
* may ignore it; callers always pre-check as well.
|
|
321
|
+
*
|
|
322
|
+
* Built-in stores throw {@link SequenceConflictError} when a different operation
|
|
323
|
+
* already holds the operation's `(nodeId, sequenceNumber)` under enforcement
|
|
324
|
+
* (unless `options.legacySequenceWriter`, see {@link judgeSequenceHolders}), and return
|
|
325
|
+
* `'duplicate'` (writing nothing) for an operation id already stored, decided
|
|
326
|
+
* atomically with the write.
|
|
327
|
+
*/
|
|
328
|
+
applyRemoteOperation(op: Operation, options?: ApplyRemoteOptions): Promise<ApplyResult>;
|
|
329
|
+
/**
|
|
330
|
+
* The version vector as committed in the shared database, read fresh (SRV-4). A
|
|
331
|
+
* store shared by several server instances must implement it: its synchronous
|
|
332
|
+
* {@link SyncStore.getVersionVector} can only reflect this instance's own writes.
|
|
333
|
+
* Optional; callers fall back to `getVersionVector()`.
|
|
334
|
+
*/
|
|
335
|
+
readVersionVector?(): Promise<VersionVector>;
|
|
336
|
+
/**
|
|
337
|
+
* Several records of one collection by id, as stored (soft-deleted ones included),
|
|
338
|
+
* in one read: `id = ANY(...)` on Postgres, chunked `IN (...)` on SQLite (LMS #11).
|
|
339
|
+
* Ids with no row are absent from the result. Optional; the delivery stream falls
|
|
340
|
+
* back to one lookup per operation without it.
|
|
341
|
+
*/
|
|
342
|
+
findRecordsByIds?(collection: string, ids: string[]): Promise<Map<string, MaterializedRecord>>;
|
|
343
|
+
/**
|
|
344
|
+
* Which of `ids` the operation log already holds, with the node and sequence each is
|
|
345
|
+
* stored under, in one read per batch (chunked `IN (...)`). The sync session asks before any other per-operation check, so a
|
|
346
|
+
* device re-uploading history the server already stores (the one-time upgrade
|
|
347
|
+
* re-upload, or an op the client's sequence repair renumbered under its original
|
|
348
|
+
* id) is acknowledged as a duplicate instead of being re-judged by today's
|
|
349
|
+
* authorization and validators. Optional; sessions fall back to a per-operation
|
|
350
|
+
* (node, sequence) lookup without it.
|
|
351
|
+
*/
|
|
352
|
+
findStoredOperations?(ids: string[]): Promise<Map<string, StoredOperationKey>>;
|
|
353
|
+
/**
|
|
354
|
+
* Durably record how an uploaded operation was resolved without being stored under
|
|
355
|
+
* its sequence (RT-43, RT-47; see {@link OperationResolution}). Idempotent per
|
|
356
|
+
* operation id: the first resolution wins. The session awaits it BEFORE answering the
|
|
357
|
+
* client, so a client never acts on a resolution the server could forget. Optional
|
|
358
|
+
* for custom stores: without it an ignored tail op may be re-validated at reconnect
|
|
359
|
+
* and a refused id re-judged on resubmission.
|
|
360
|
+
*/
|
|
361
|
+
recordOperationResolution?(resolution: OperationResolution): Promise<void>;
|
|
362
|
+
/**
|
|
363
|
+
* The resolutions recorded for `ids` under `nodeId` (ids resolved under another node
|
|
364
|
+
* are absent: the answer never crosses devices or tenants).
|
|
365
|
+
*/
|
|
366
|
+
findOperationResolutions?(nodeId: string, ids: string[]): Promise<Map<string, OperationResolution>>;
|
|
367
|
+
/** The highest sequence number of `nodeId` with a recorded resolution (0 when none). */
|
|
368
|
+
getResolvedThrough?(nodeId: string): Promise<number>;
|
|
369
|
+
/**
|
|
370
|
+
* Forget the resolution recorded for `operationId` under `nodeId` (no-op when none).
|
|
371
|
+
* The session calls it for a stale `stored-elsewhere` record: one whose id the batch
|
|
372
|
+
* lookup did not find stored (RT-51). The operation is then judged normally, and its
|
|
373
|
+
* real outcome is recorded in place of the stale one. Optional for custom stores:
|
|
374
|
+
* without it the stale record is ignored but kept.
|
|
375
|
+
*/
|
|
376
|
+
deleteOperationResolution?(nodeId: string, operationId: string): Promise<void>;
|
|
377
|
+
/**
|
|
378
|
+
* Every operation of `nodeId` at or below `throughSequence` that shares its sequence
|
|
379
|
+
* with another stored operation (a legacy pair, RT-37), ordered by sequence then
|
|
380
|
+
* delivery. A version-vector client reporting `throughSequence` for the node may hold
|
|
381
|
+
* only one of a pair, and a range read above its entry can never return the other
|
|
382
|
+
* (RT-48). Optional; without it such clients miss the second op of a pair.
|
|
383
|
+
*/
|
|
384
|
+
getSequencePairOperations?(nodeId: string, throughSequence: number): Promise<Operation[]>;
|
|
385
|
+
/**
|
|
386
|
+
* The distinct node ids of the operations with `deliverySequence >
|
|
387
|
+
* afterDeliverySequence`. Optional; lets a handshake stop judging which nodes a
|
|
388
|
+
* scoped client will hear from as soon as every candidate was found visible.
|
|
389
|
+
*/
|
|
390
|
+
getNodeIdsAfterDelivery?(afterDeliverySequence: number): Promise<string[]>;
|
|
391
|
+
/**
|
|
392
|
+
* Bind a client node id to the authenticated principal that first used it.
|
|
393
|
+
* Returns true when the node id is already owned by `userId`, was released by an
|
|
394
|
+
* admin (see {@link releaseNodeClaim}; the caller takes it over), or is unclaimed
|
|
395
|
+
* AND has no operation history. Returns false when another principal owns it, or
|
|
396
|
+
* when it is unclaimed but already has operations in the log: history written
|
|
397
|
+
* before node claims existed has no recorded writer, so nobody may adopt it
|
|
398
|
+
* until an admin releases it (RT-5). Called at handshake when auth is configured,
|
|
399
|
+
* so one user cannot upload operations under another user's device id. Every
|
|
400
|
+
* write after this check is preceded by a claim, so going forward the claim row
|
|
401
|
+
* records the writer of a node's history. Must be atomic per node id. Optional
|
|
402
|
+
* for custom stores; the built-in stores persist the claim.
|
|
403
|
+
*/
|
|
404
|
+
claimNode?(nodeId: string, userId: string): Promise<boolean>;
|
|
405
|
+
/**
|
|
406
|
+
* Admin release of a node id (RT-5): the next principal to claim it takes it
|
|
407
|
+
* over, even when the node has operation history. Returns true when the node had
|
|
408
|
+
* a claim or history to release, false when it was unknown.
|
|
409
|
+
*/
|
|
410
|
+
releaseNodeClaim?(nodeId: string): Promise<boolean>;
|
|
411
|
+
/**
|
|
412
|
+
* The owner a node id is currently claimed by, or null when unclaimed (RT-21).
|
|
413
|
+
* Optional; anonymous provisional claims need it together with
|
|
414
|
+
* {@link replaceNodeClaim}.
|
|
415
|
+
*/
|
|
416
|
+
getNodeClaimOwner?(nodeId: string): Promise<string | null>;
|
|
417
|
+
/**
|
|
418
|
+
* Atomically replace the owner of a node claim, only if it is still
|
|
419
|
+
* `expectedOwner` (compare-and-set, RT-21). Returns true when replaced. Used to
|
|
420
|
+
* confirm or re-issue an anonymous device's provisional claim; never creates one.
|
|
421
|
+
*/
|
|
422
|
+
replaceNodeClaim?(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
|
|
423
|
+
/**
|
|
424
|
+
* The wrapped encryption key record (JSON) of one owner's keyring, or null (ENC-1).
|
|
425
|
+
* The record holds only salt, KDF parameters and wrapped keys: never a usable key.
|
|
426
|
+
* Optional for custom stores; without it the server answers key requests with
|
|
427
|
+
* `unsupported` (it never keeps key records in memory only, which would fork a
|
|
428
|
+
* user's keys after a restart).
|
|
429
|
+
*/
|
|
430
|
+
getEncryptionKeyRecord?(owner: string, keyring: string): Promise<string | null>;
|
|
431
|
+
/**
|
|
432
|
+
* Write a key record with compare-and-set: store `record` at `revision` only when the
|
|
433
|
+
* stored revision is `expectedRevision` (0: no record yet). Returns false when
|
|
434
|
+
* another write won. Must be atomic per (owner, keyring) across server instances.
|
|
435
|
+
*/
|
|
436
|
+
putEncryptionKeyRecord?(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
|
|
437
|
+
/**
|
|
438
|
+
* Stored key records: every one (for `exportBackup`; RT-104: a server restored from
|
|
439
|
+
* its backup must still hold the records its encrypted history needs), or one owner's
|
|
440
|
+
* (the key service tells another keyring's history from a lost record with it).
|
|
441
|
+
* Optional.
|
|
442
|
+
*/
|
|
443
|
+
listEncryptionKeyRecords?(owner?: string): Promise<EncryptionKeyRecordRow[]>;
|
|
444
|
+
/**
|
|
445
|
+
* Key ids named by stored encrypted operations (their envelope's `keyId`), a sample
|
|
446
|
+
* of at most `limit` distinct ids (RT-104). `nodeOwner` restricts it to operations of
|
|
447
|
+
* nodes claimed by that principal (see {@link claimNode}); null means every node (a
|
|
448
|
+
* server without authentication, whose clients share one keyring). The key service
|
|
449
|
+
* reports them when an owner has no key record, so a new device can tell a lost
|
|
450
|
+
* record (encrypted history exists) from a first one. Optional; without it a new
|
|
451
|
+
* device cannot tell, and a fork it creates is merged later by a device holding the
|
|
452
|
+
* old ring.
|
|
453
|
+
*/
|
|
454
|
+
getEncryptedKeyIds?(nodeOwner: string | null, limit: number): Promise<string[]>;
|
|
455
|
+
/**
|
|
456
|
+
* Record that `owner` holds the bytes behind a blob content hash (it pushed them,
|
|
457
|
+
* proving possession) (RT-11). Idempotent. Optional; without it the sync server
|
|
458
|
+
* keeps ownership in memory (lost on restart, not shared between instances).
|
|
459
|
+
*/
|
|
460
|
+
recordBlobOwner?(hash: string, owner: string): Promise<void>;
|
|
461
|
+
/** The owners recorded for each hash (an empty list for unowned hashes). */
|
|
462
|
+
getBlobOwners?(hashes: string[]): Promise<Map<string, string[]>>;
|
|
463
|
+
/**
|
|
464
|
+
* Atomically make `owner` the first owner of a hash nobody owns yet. Returns true
|
|
465
|
+
* when `owner` owns the hash afterwards (newly, or already), false when someone
|
|
466
|
+
* else does. Must be atomic per hash across server instances.
|
|
467
|
+
*/
|
|
468
|
+
claimBlobIfUnowned?(hash: string, owner: string): Promise<boolean>;
|
|
469
|
+
/**
|
|
470
|
+
* The scope snapshots captured when the given operations were applied (RT-14).
|
|
471
|
+
* Operations without one are absent from the result.
|
|
472
|
+
*/
|
|
473
|
+
getOperationScopeSnapshots?(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
|
|
474
|
+
/**
|
|
475
|
+
* The greatest HLC timestamp among every stored operation of one record (its
|
|
476
|
+
* newest field write), or null when the record has no operations (RT-19). A
|
|
477
|
+
* scope-entry operation is stamped with it so it never overrides newer client data.
|
|
478
|
+
*/
|
|
479
|
+
getRecordLatestTimestamp?(collection: string, recordId: string): Promise<HLCTimestamp | null>;
|
|
480
|
+
/**
|
|
481
|
+
* Per-field versions of one live record, folded from its stored operations exactly
|
|
482
|
+
* as the materialization folds its values (RT-27): each field's version is the HLC
|
|
483
|
+
* of the write that produced its current value, plus the record's first and newest
|
|
484
|
+
* operation. Null when the record is deleted or has no operations. A scope-entry
|
|
485
|
+
* operation carries them so a receiver resolves every field on its own.
|
|
486
|
+
*/
|
|
487
|
+
getRecordFieldVersions?(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
|
|
488
|
+
/**
|
|
489
|
+
* The record's fold state (W7): the per-field CRDT state its row is projected from.
|
|
490
|
+
* Null when the record has no operations or its collection is not materialized. A
|
|
491
|
+
* scope-entry operation carries it (filtered to the fields the receiver may see) so
|
|
492
|
+
* the receiver merges richtext, counter and resolver fields exactly (RT-29).
|
|
493
|
+
*/
|
|
494
|
+
getRecordFoldState?(collection: string, recordId: string): Promise<FoldState | null>;
|
|
495
|
+
/** Every stored operation of one record, in delivery (commit) order. */
|
|
496
|
+
getRecordOperations?(collection: string, recordId: string): Promise<Operation[]>;
|
|
497
|
+
/**
|
|
498
|
+
* The record's row as it would be after merging `op` into its fold state, with
|
|
499
|
+
* nothing written: the candidate a Tier-2 constraint check judges at ingest. Null
|
|
500
|
+
* when the record would not be live (deleted, or never inserted).
|
|
501
|
+
*/
|
|
502
|
+
previewOperation?(op: Operation): Promise<MaterializedRecord | null>;
|
|
503
|
+
/**
|
|
504
|
+
* Node ids whose operations win `merge('server-authoritative')` fields in the fold:
|
|
505
|
+
* this store's own node id (every server-originated operation is authored by it)
|
|
506
|
+
* plus any configured extras. The sync server advertises them in the handshake so
|
|
507
|
+
* clients fold with the same authority.
|
|
508
|
+
*/
|
|
509
|
+
getAuthoritativeNodeIds?(): string[];
|
|
510
|
+
/** Explicit authoritative ids the deployment revoked (RT-81); advertised at handshake. */
|
|
511
|
+
getRevokedAuthoritativeNodeIds?(): string[];
|
|
512
|
+
/**
|
|
513
|
+
* Every explicit id the deployment ever held authoritative, revoked ones included
|
|
514
|
+
* (RT-81). The session never accepts one as a device node id.
|
|
515
|
+
*/
|
|
516
|
+
getEverAuthoritativeNodeIds?(): string[];
|
|
517
|
+
/**
|
|
518
|
+
* Keyed id of a server-derived operation (cascade, set-null, constraint correction):
|
|
519
|
+
* deterministic across every instance of the deployment, unpredictable to clients
|
|
520
|
+
* (RT-64). Built-in stores key it with a persisted deployment secret. A store
|
|
521
|
+
* without it falls back to the unkeyed `deriveSideEffectOpId`.
|
|
522
|
+
*/
|
|
523
|
+
deriveServerOperationId?(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
|
|
145
524
|
/** Close the store and release resources */
|
|
146
525
|
close(): Promise<void>;
|
|
147
526
|
/** Get the total number of stored operations */
|
|
@@ -188,10 +567,23 @@ interface ServerStore extends SyncStore {
|
|
|
188
567
|
* tables from the operation log.
|
|
189
568
|
*
|
|
190
569
|
* @param schema - The schema definition describing all collections
|
|
570
|
+
* @param options - `operationTransforms`: the schema transforms the store folds with
|
|
571
|
+
* (transforms at fold time, RT-84). Pass the same list as the sync server's
|
|
572
|
+
* `operationTransforms`, so the startup re-materialization folds with them once.
|
|
191
573
|
*/
|
|
192
|
-
setSchema(schema: SchemaDefinition): Promise<void>;
|
|
574
|
+
setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
|
|
193
575
|
/** Schema used for materialized tables and server-side validation, if set. */
|
|
194
576
|
getSchema(): SchemaDefinition | null;
|
|
577
|
+
/**
|
|
578
|
+
* Set the schema transforms the store folds with (RT-84). Operations are stored
|
|
579
|
+
* exactly as uploaded; the fold merges each operation as
|
|
580
|
+
* `operationSchemaView(op, schema.version, transforms)` reads it. Transforms are part
|
|
581
|
+
* of the fold plan fingerprint: a change re-folds every record (once). The sync
|
|
582
|
+
* server calls this with its `operationTransforms` at construction.
|
|
583
|
+
*/
|
|
584
|
+
setOperationTransforms?(transforms: readonly OperationTransform[]): Promise<void>;
|
|
585
|
+
/** The schema transforms the store folds with (empty when none). */
|
|
586
|
+
getOperationTransforms?(): readonly OperationTransform[];
|
|
195
587
|
/**
|
|
196
588
|
* Get all records from a materialized collection.
|
|
197
589
|
* When schema is set, reads directly from the collection table (O(1) indexed).
|
|
@@ -303,6 +695,68 @@ interface ApplyServerOperationResult {
|
|
|
303
695
|
appliedOperations: Operation[];
|
|
304
696
|
/** Rejection reason when the operation was not applied */
|
|
305
697
|
rejection?: OperationRejection;
|
|
698
|
+
/**
|
|
699
|
+
* Side effects of an applied delete that were NOT derived because
|
|
700
|
+
* {@link ApplyServerOperationOptions.isAuthoredSideEffect} claimed them (RT-69). The
|
|
701
|
+
* caller must derive each one with {@link deriveServerSideEffects} unless the
|
|
702
|
+
* author's own copy is stored.
|
|
703
|
+
*/
|
|
704
|
+
deferredSideEffects?: SideEffectOp[];
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Decides whether an untrusted writer may cause one referential side effect of its
|
|
708
|
+
* delete. `effect` is the side effect shaped as the operation the server would
|
|
709
|
+
* write; `stored` is the affected record as stored now (including soft-deleted).
|
|
710
|
+
*/
|
|
711
|
+
type SideEffectAuthorizer = (effect: Operation, stored: MaterializedRecord | null) => UplinkAuthorizationResult;
|
|
712
|
+
/** Options for {@link applyServerOperation}. */
|
|
713
|
+
interface ApplyServerOperationOptions {
|
|
714
|
+
/**
|
|
715
|
+
* Uplink authorization for an untrusted writer, re-evaluated by the store inside
|
|
716
|
+
* its apply critical section against the row as stored at commit time (see
|
|
717
|
+
* `ApplyRemoteOptions.authorize`). A refusal becomes a non-retriable rejection and
|
|
718
|
+
* nothing is written.
|
|
719
|
+
*/
|
|
720
|
+
authorize?: ApplyRemoteOptions['authorize'];
|
|
721
|
+
/**
|
|
722
|
+
* Authorization of the referential side effects (cascade delete, set-null) an
|
|
723
|
+
* untrusted writer's delete would cause (RT-10). Every side effect is checked
|
|
724
|
+
* against the writer's own scope BEFORE the delete is applied; if any falls
|
|
725
|
+
* outside it, the whole delete is refused and nothing is written.
|
|
726
|
+
*
|
|
727
|
+
* Design: integrity is judged on ALL children, in every tenant (a restrict or a
|
|
728
|
+
* cross-tenant cascade is never silently skipped, which would leave dangling
|
|
729
|
+
* references), but the writer learns nothing about children it cannot see: a
|
|
730
|
+
* restrict violation and an unauthorized side effect are refused with the same
|
|
731
|
+
* generic `RESTRICTED` rejection, with no ids and no counts. Setting `authorize`
|
|
732
|
+
* alone also switches restrict refusals to the generic form.
|
|
733
|
+
*/
|
|
734
|
+
authorizeSideEffect?: SideEffectAuthorizer;
|
|
735
|
+
/**
|
|
736
|
+
* The writer is a legacy client that does not reserve sequence numbers (RT-37): a
|
|
737
|
+
* different operation under a held `(nodeId, sequenceNumber)` is stored as a legacy
|
|
738
|
+
* pair instead of being refused with `SEQUENCE_CONFLICT`. Applies to the primary
|
|
739
|
+
* operation only; server-originated side effects are always enforced.
|
|
740
|
+
*/
|
|
741
|
+
legacySequenceWriter?: boolean;
|
|
742
|
+
/** See `ApplyRemoteOptions.onLegacySequencePair`; for the primary operation. */
|
|
743
|
+
onLegacySequencePair?: ApplyRemoteOptions['onLegacySequencePair'];
|
|
744
|
+
/**
|
|
745
|
+
* True for a referential side effect of this delete that the writer authored itself
|
|
746
|
+
* (its own cascade or set-null, later in the same upload, RT-69). Such an effect is
|
|
747
|
+
* not derived now but returned in `deferredSideEffects`, so the log stores one copy
|
|
748
|
+
* per child instead of the author's and the server's. Effects it does not claim (a
|
|
749
|
+
* child the author did not know, a legacy client that authors no cascades) are
|
|
750
|
+
* derived as before.
|
|
751
|
+
*/
|
|
752
|
+
isAuthoredSideEffect?: (effect: SideEffectOp) => boolean;
|
|
753
|
+
/**
|
|
754
|
+
* The operation as the server schema reads it (transforms at fold time, RT-84), when
|
|
755
|
+
* the caller already computed it. Shape, constraint and referential checks judge the
|
|
756
|
+
* view; the store keeps `op` exactly as given and folds the same view. Default: the
|
|
757
|
+
* store's own view (`serverOperationView`).
|
|
758
|
+
*/
|
|
759
|
+
view?: Operation;
|
|
306
760
|
}
|
|
307
761
|
|
|
308
762
|
/**
|
|
@@ -318,6 +772,13 @@ interface ServerMetricsSnapshot {
|
|
|
318
772
|
uniqueOperationsReceived: number;
|
|
319
773
|
duplicateOperationsReceived: number;
|
|
320
774
|
rejectedOperations: number;
|
|
775
|
+
/**
|
|
776
|
+
* Operations from protocol-1 (beta.12) clients stored with an id that could not be
|
|
777
|
+
* verified (RT-71). Non-zero only while beta.12 clients write `undefined` members.
|
|
778
|
+
*/
|
|
779
|
+
unverifiedLegacyOperations: number;
|
|
780
|
+
/** Uploads refused for reusing a stored operation id with other content (RT-77). */
|
|
781
|
+
forgedDuplicates: number;
|
|
321
782
|
operationsSent: number;
|
|
322
783
|
bytesReceived: number;
|
|
323
784
|
bytesSent: number;
|
|
@@ -352,6 +813,8 @@ declare class ServerMetricsCollector {
|
|
|
352
813
|
private uniqueOperationsReceived;
|
|
353
814
|
private duplicateOperationsReceived;
|
|
354
815
|
private rejectedOperations;
|
|
816
|
+
private unverifiedLegacyOperations;
|
|
817
|
+
private forgedDuplicates;
|
|
355
818
|
private operationsSent;
|
|
356
819
|
private bytesReceived;
|
|
357
820
|
private bytesSent;
|
|
@@ -376,6 +839,10 @@ declare class ServerMetricsCollector {
|
|
|
376
839
|
}): void;
|
|
377
840
|
/** Record operations sent to a client. */
|
|
378
841
|
recordSent(sessionId: string, count: number, byteSize: number): void;
|
|
842
|
+
/** Record an operation stored with an unverified legacy id (RT-71). */
|
|
843
|
+
recordUnverifiedLegacyOperation(): void;
|
|
844
|
+
/** Record an upload refused for reusing a stored id with other content (RT-77). */
|
|
845
|
+
recordForgedDuplicate(): void;
|
|
379
846
|
/** Record an error. */
|
|
380
847
|
recordError(): void;
|
|
381
848
|
/** Set the schema version. */
|
|
@@ -440,6 +907,210 @@ declare function createSilentLogger(): Logger;
|
|
|
440
907
|
*/
|
|
441
908
|
declare function createDefaultLogger(): Logger;
|
|
442
909
|
|
|
910
|
+
/**
|
|
911
|
+
* Resolves a blob chunk by content hash from server-side storage. Optional: when
|
|
912
|
+
* provided, the server can answer chunk requests directly from its own store
|
|
913
|
+
* (central-store deployment). When absent, the server acts as a pure relay,
|
|
914
|
+
* forwarding chunk requests to peer sessions and their responses back.
|
|
915
|
+
*/
|
|
916
|
+
type ResolveBlobChunk = (hash: string) => Promise<Uint8Array | null>;
|
|
917
|
+
|
|
918
|
+
/** Input to {@link BlobAccessIndex.authorizeReference}. */
|
|
919
|
+
interface BlobReferenceRequest {
|
|
920
|
+
/** The content hash (blob, manifest or chunk) the writer wants to reference. */
|
|
921
|
+
hash: string;
|
|
922
|
+
/** The writer's download scope, or undefined for an unscoped writer. */
|
|
923
|
+
scopes: ScopeMap | undefined;
|
|
924
|
+
/**
|
|
925
|
+
* Stable ownership key of the writer: the user id for a signed-in principal, the
|
|
926
|
+
* node-claim owner for an anonymous device, a `kora:route:` key for a scoped route.
|
|
927
|
+
*/
|
|
928
|
+
owner: string;
|
|
929
|
+
/** Hashes the written record already references as stored (unchanged references). */
|
|
930
|
+
alreadyReferenced?: ReadonlySet<string>;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Answers "does a live record inside this download scope reference this blob
|
|
934
|
+
* hash?" for the blob relay (RT-1), and "may this writer reference this hash?" at
|
|
935
|
+
* ingest (RT-11).
|
|
936
|
+
*
|
|
937
|
+
* **A content hash is not a secret.** Hashes of well-known content can be computed
|
|
938
|
+
* by anyone, and they travel in records, manifests and logs. So knowing a hash never
|
|
939
|
+
* grants access: a write may reference a hash only when the writer can already read
|
|
940
|
+
* a record that references it, or has proven possession of the bytes (pushed them).
|
|
941
|
+
* Only in peer-relay mode (no central store, so nothing can be pre-claimed and then
|
|
942
|
+
* read back from the server) may the first writer of a hash nobody references or
|
|
943
|
+
* owns claim it by reference alone.
|
|
944
|
+
*
|
|
945
|
+
* A hash is referenced in a scope when a live (not deleted) record in scope has a
|
|
946
|
+
* blob field whose `BlobRef.manifestHash` (or, for a bare reference without a
|
|
947
|
+
* manifest, `BlobRef.hash`) equals it, or when it is a chunk listed in such a manifest and the chunk is unowned or shares an owner with
|
|
948
|
+
* the manifest (so a crafted manifest listing someone else's chunk hashes exposes
|
|
949
|
+
* nothing). Manifests are read through `resolveBlobChunk` (central store) or learned
|
|
950
|
+
* from manifest bytes verified in transit.
|
|
951
|
+
*
|
|
952
|
+
* Cost: the referenced set of a scope is built by a scoped query over every
|
|
953
|
+
* collection that declares a blob field (O(records in scope with blobs)). It is
|
|
954
|
+
* cached per scope, rebuilt after a write to one of the blob collections it scanned
|
|
955
|
+
* ({@link invalidate}) or after `ttlMs` (writes through another server instance),
|
|
956
|
+
* and concurrent lookups for one scope share a single rebuild (RT-17). A full hash
|
|
957
|
+
* index in the store would make this O(1); this is the interim, correctness-first
|
|
958
|
+
* form.
|
|
959
|
+
*/
|
|
960
|
+
declare class BlobAccessIndex {
|
|
961
|
+
private readonly store;
|
|
962
|
+
private readonly resolveBlobChunk;
|
|
963
|
+
private readonly ttlMs;
|
|
964
|
+
private readonly byScope;
|
|
965
|
+
/** Rebuilds in progress per scope key, shared by concurrent lookups (RT-17). */
|
|
966
|
+
private readonly inflight;
|
|
967
|
+
/** manifestHash -> chunk hashes it lists (only from bytes verified against the hash). */
|
|
968
|
+
private readonly manifestChunks;
|
|
969
|
+
/** chunkHash -> manifests listing it. */
|
|
970
|
+
private readonly chunkManifests;
|
|
971
|
+
/** Ownership fallback for stores without blob-owner persistence. */
|
|
972
|
+
private readonly memoryOwners;
|
|
973
|
+
private globalGeneration;
|
|
974
|
+
private readonly collectionGeneration;
|
|
975
|
+
constructor(store: ServerStore, resolveBlobChunk: ResolveBlobChunk | null, ttlMs?: number);
|
|
976
|
+
/**
|
|
977
|
+
* Forget cached scope sets because records changed. With `collections`, only sets
|
|
978
|
+
* built from those collections are dropped (a write to a collection without blob
|
|
979
|
+
* fields drops nothing); without, every set is dropped (for example writes made
|
|
980
|
+
* through another server instance).
|
|
981
|
+
*/
|
|
982
|
+
invalidate(collections?: Iterable<string>): void;
|
|
983
|
+
/**
|
|
984
|
+
* True when a live record inside `scopes` references `hash`.
|
|
985
|
+
*
|
|
986
|
+
* @param scopes - The session's download scope, or undefined for unscoped
|
|
987
|
+
* @param hash - The requested content hash
|
|
988
|
+
*/
|
|
989
|
+
isReferenced(scopes: ScopeMap | undefined, hash: string): Promise<boolean>;
|
|
990
|
+
/**
|
|
991
|
+
* Decide whether a writer may reference `hash` in a blob field (RT-11). Allowed
|
|
992
|
+
* when the record already referenced it, when a live record inside the writer's
|
|
993
|
+
* download scope references it, when the writer owns it (pushed the bytes, or
|
|
994
|
+
* claimed it first), or when nobody references, owns or stores it yet: then the
|
|
995
|
+
* writer claims it, atomically, so a later forger cannot.
|
|
996
|
+
*/
|
|
997
|
+
authorizeReference(request: BlobReferenceRequest): Promise<boolean>;
|
|
998
|
+
/** True when `owner` pushed (or first claimed) the bytes behind `hash`. */
|
|
999
|
+
isOwnedBy(hash: string, owner: string): Promise<boolean>;
|
|
1000
|
+
/**
|
|
1001
|
+
* Record that `owner` pushed bytes verified to hash to `hash` (proof of
|
|
1002
|
+
* possession), and learn the chunk list when the bytes are a manifest.
|
|
1003
|
+
*/
|
|
1004
|
+
recordPush(hash: string, bytes: Uint8Array, owner: string): Promise<void>;
|
|
1005
|
+
/**
|
|
1006
|
+
* When `bytes` are a manifest, true only if the pusher may reference every chunk
|
|
1007
|
+
* it lists (the same rule as {@link authorizeReference}); true for non-manifest
|
|
1008
|
+
* bytes. A manifest listing someone else's chunks is refused before it is stored.
|
|
1009
|
+
*/
|
|
1010
|
+
authorizeManifestPush(bytes: Uint8Array, scopes: ScopeMap | undefined, owner: string): Promise<boolean>;
|
|
1011
|
+
/**
|
|
1012
|
+
* Learn a manifest's chunk list from bytes already verified to hash to `hash`.
|
|
1013
|
+
* Bytes that are not a manifest are ignored. Unowned chunks of an owned manifest
|
|
1014
|
+
* are claimed for the manifest's owner, so a manifest crafted later cannot adopt
|
|
1015
|
+
* them.
|
|
1016
|
+
*/
|
|
1017
|
+
observeVerifiedBytes(hash: string, bytes: Uint8Array): void;
|
|
1018
|
+
private claimChunksForManifestOwner;
|
|
1019
|
+
private currentStamp;
|
|
1020
|
+
private isCurrent;
|
|
1021
|
+
private blobCollections;
|
|
1022
|
+
private directHashes;
|
|
1023
|
+
private build;
|
|
1024
|
+
/** Read unknown manifests from the central store, verifying them against their hash. */
|
|
1025
|
+
private loadManifests;
|
|
1026
|
+
private ownersOf;
|
|
1027
|
+
private addOwner;
|
|
1028
|
+
private claim;
|
|
1029
|
+
private rememberMemoryOwner;
|
|
1030
|
+
private rememberManifest;
|
|
1031
|
+
private forgetManifest;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Minimal interface for a ws.WebSocket instance.
|
|
1036
|
+
* Allows dependency injection for testing without importing ws directly.
|
|
1037
|
+
*/
|
|
1038
|
+
interface WsWebSocket {
|
|
1039
|
+
readyState: number;
|
|
1040
|
+
send(data: string | Uint8Array, callback?: (err?: Error) => void): void;
|
|
1041
|
+
close(code?: number, reason?: string): void;
|
|
1042
|
+
on(event: string, listener: (...args: unknown[]) => void): void;
|
|
1043
|
+
removeAllListeners(): void;
|
|
1044
|
+
/** Send a ping frame (ws). Without it, no liveness probing happens. */
|
|
1045
|
+
ping?(): void;
|
|
1046
|
+
/** Destroy the socket at once, emitting `close` (ws). Falls back to `close()`. */
|
|
1047
|
+
terminate?(): void;
|
|
1048
|
+
/** Bytes queued and not yet written to the network (ws). */
|
|
1049
|
+
readonly bufferedAmount?: number;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Options for WsServerTransport.
|
|
1053
|
+
*/
|
|
1054
|
+
interface WsServerTransportOptions {
|
|
1055
|
+
/** Message serializer. Defaults to JsonMessageSerializer. */
|
|
1056
|
+
serializer?: MessageSerializer;
|
|
1057
|
+
/**
|
|
1058
|
+
* Interval between WebSocket pings, in ms (SRV-6). A connection that leaves
|
|
1059
|
+
* `maxMissedPongs` pings in a row unanswered (and sends nothing else meanwhile) is
|
|
1060
|
+
* terminated, which runs Kora's normal close path. Defaults to 25 seconds; 0
|
|
1061
|
+
* disables probing. Keep it at 15 seconds or more on mobile networks.
|
|
1062
|
+
*/
|
|
1063
|
+
heartbeatIntervalMs?: number;
|
|
1064
|
+
/** Unanswered pings before the connection is terminated. Defaults to 2. */
|
|
1065
|
+
maxMissedPongs?: number;
|
|
1066
|
+
/**
|
|
1067
|
+
* Bytes that may be queued for this client before it is disconnected as a slow
|
|
1068
|
+
* consumer. Defaults to 32 MiB; 0 disables the ceiling.
|
|
1069
|
+
*/
|
|
1070
|
+
maxBufferedBytes?: number;
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* Server-side transport wrapping a ws.WebSocket connection.
|
|
1074
|
+
* Created for each incoming client connection.
|
|
1075
|
+
*
|
|
1076
|
+
* It probes liveness with WebSocket pings (both the standalone server and
|
|
1077
|
+
* `createProductionServer` create their connections through it), so a half-open
|
|
1078
|
+
* connection, a peer that vanished without a FIN, is detected and closed instead of
|
|
1079
|
+
* holding a session forever.
|
|
1080
|
+
*/
|
|
1081
|
+
declare class WsServerTransport implements ServerTransport {
|
|
1082
|
+
private readonly ws;
|
|
1083
|
+
private readonly serializer;
|
|
1084
|
+
private messageHandler;
|
|
1085
|
+
private closeHandler;
|
|
1086
|
+
private errorHandler;
|
|
1087
|
+
private readonly maxMissedPongs;
|
|
1088
|
+
private readonly maxBufferedBytes;
|
|
1089
|
+
private heartbeatTimer;
|
|
1090
|
+
private missedPongs;
|
|
1091
|
+
private terminated;
|
|
1092
|
+
constructor(ws: WsWebSocket, options?: WsServerTransportOptions);
|
|
1093
|
+
send(message: SyncMessage): void;
|
|
1094
|
+
onMessage(handler: ServerMessageHandler): void;
|
|
1095
|
+
onClose(handler: ServerCloseHandler): void;
|
|
1096
|
+
onError(handler: ServerErrorHandler): void;
|
|
1097
|
+
isConnected(): boolean;
|
|
1098
|
+
close(code?: number, reason?: string): void;
|
|
1099
|
+
bufferedAmount(): number;
|
|
1100
|
+
/**
|
|
1101
|
+
* Ping every `intervalMs`; terminate once `maxMissedPongs` pings in a row went
|
|
1102
|
+
* unanswered. A pong or any inbound message proves the peer alive.
|
|
1103
|
+
*/
|
|
1104
|
+
private startHeartbeat;
|
|
1105
|
+
private stopHeartbeat;
|
|
1106
|
+
/**
|
|
1107
|
+
* Drop the connection at once. ws emits `close` from `terminate()`, which runs
|
|
1108
|
+
* Kora's normal close path (session, relays and side channels are released).
|
|
1109
|
+
*/
|
|
1110
|
+
private terminate;
|
|
1111
|
+
private setupListeners;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
443
1114
|
/**
|
|
444
1115
|
* Minimal interface for a ws.WebSocketServer instance.
|
|
445
1116
|
* Allows dependency injection for testing without importing ws directly.
|
|
@@ -458,6 +1129,8 @@ type WsServerConstructor = new (options: {
|
|
|
458
1129
|
port?: number;
|
|
459
1130
|
host?: string;
|
|
460
1131
|
path?: string;
|
|
1132
|
+
maxPayload?: number;
|
|
1133
|
+
perMessageDeflate?: false | Record<string, unknown>;
|
|
461
1134
|
}) => WsServerLike;
|
|
462
1135
|
/**
|
|
463
1136
|
* Self-hosted sync server. Accepts WebSocket connections from clients,
|
|
@@ -480,6 +1153,8 @@ declare class KoraSyncServer {
|
|
|
480
1153
|
private readonly schemaVersion;
|
|
481
1154
|
private readonly supportedSchemaVersions;
|
|
482
1155
|
private readonly operationTransforms;
|
|
1156
|
+
/** The store's re-fold for the configured transforms (RT-84), awaited by start(). */
|
|
1157
|
+
private storeTransformsReady;
|
|
483
1158
|
private readonly port;
|
|
484
1159
|
private readonly host;
|
|
485
1160
|
private readonly path;
|
|
@@ -488,14 +1163,49 @@ declare class KoraSyncServer {
|
|
|
488
1163
|
private readonly awarenessRelay;
|
|
489
1164
|
private readonly yjsDocRelay;
|
|
490
1165
|
private readonly blobChunkRelay;
|
|
1166
|
+
/** Wrapped encryption key records (ENC-1, D4b), persisted in the server store. */
|
|
1167
|
+
private readonly encryptionKeys;
|
|
1168
|
+
/** Which blob hashes each download scope may obtain (RT-1). */
|
|
1169
|
+
private readonly blobAccess;
|
|
491
1170
|
private readonly persistBlobChunk;
|
|
492
1171
|
private readonly maxOperationBytes;
|
|
493
1172
|
private readonly maxOpsPerMinute;
|
|
1173
|
+
/** Per-principal ingest budget per minute; 0 when disabled. */
|
|
1174
|
+
private readonly maxOpsPerMinutePerUser;
|
|
1175
|
+
private readonly allowLegacyAnonymousClaims;
|
|
1176
|
+
private readonly anonymousClaimTtlMs;
|
|
1177
|
+
private readonly maxOpsPerBatch;
|
|
1178
|
+
private readonly maxMessageBytes;
|
|
1179
|
+
private readonly heartbeatIntervalMs;
|
|
1180
|
+
private readonly appHeartbeatIntervalMs;
|
|
1181
|
+
private readonly encryptionPolicy;
|
|
1182
|
+
private readonly handshakeTimeoutMs;
|
|
1183
|
+
private readonly maxBufferedBytes;
|
|
1184
|
+
private readonly deliveryHighWaterBytes;
|
|
1185
|
+
private readonly perMessageDeflate;
|
|
1186
|
+
/**
|
|
1187
|
+
* Ingest rate limiters by device node id, shared by that node's sessions so a
|
|
1188
|
+
* reconnect does not reset the budget (SRV-6). Entries idle past the window are
|
|
1189
|
+
* dropped by the background tick; insertion order doubles as LRU order.
|
|
1190
|
+
*/
|
|
1191
|
+
private readonly rateLimiters;
|
|
1192
|
+
/**
|
|
1193
|
+
* Ingest budgets by authenticated principal, shared by every node of that user, so
|
|
1194
|
+
* minting node ids does not multiply the per-node budget. Expired like the node map.
|
|
1195
|
+
*/
|
|
1196
|
+
private readonly userRateLimiters;
|
|
1197
|
+
private readonly blobLimits;
|
|
494
1198
|
private readonly validateOperation;
|
|
495
1199
|
private readonly koraContext;
|
|
496
1200
|
private readonly sessions;
|
|
497
|
-
|
|
498
|
-
|
|
1201
|
+
/**
|
|
1202
|
+
* HTTP long-poll sessions by their server-issued, high-entropy id (RT-2). Each is
|
|
1203
|
+
* bound to the principal that opened it; every request must re-authenticate as it.
|
|
1204
|
+
*/
|
|
1205
|
+
private readonly httpSessions;
|
|
1206
|
+
/** Internal ClientSession id -> HTTP session id, for cleanup on close. */
|
|
1207
|
+
private readonly httpSessionIdBySession;
|
|
1208
|
+
private readonly httpSessionIdleTimeoutMs;
|
|
499
1209
|
private readonly serverVersion;
|
|
500
1210
|
private wsServer;
|
|
501
1211
|
private running;
|
|
@@ -508,6 +1218,17 @@ declare class KoraSyncServer {
|
|
|
508
1218
|
private deliveryPollTimer;
|
|
509
1219
|
private lastObservedDeliverySequence;
|
|
510
1220
|
private deliveryPollInFlight;
|
|
1221
|
+
/**
|
|
1222
|
+
* Re-validates every live session's credential against the auth provider, so a
|
|
1223
|
+
* revocation persisted by ANOTHER server instance (whose in-process revocation
|
|
1224
|
+
* listeners never reach this one) still ends the session here (RT-18).
|
|
1225
|
+
*/
|
|
1226
|
+
private readonly sessionRevalidationIntervalMs;
|
|
1227
|
+
private sessionRevalidationTimer;
|
|
1228
|
+
private lastSessionRevalidationAtMs;
|
|
1229
|
+
private sessionRevalidationInFlight;
|
|
1230
|
+
/** Unsubscribes from the auth provider's revocation feed (AUTH-11). */
|
|
1231
|
+
private revocationUnsubscribe;
|
|
511
1232
|
/**
|
|
512
1233
|
* Relay operations a client never acknowledged before it disconnected, buffered by
|
|
513
1234
|
* client node id so they can be redelivered on its next connection. Bounded per
|
|
@@ -517,19 +1238,113 @@ declare class KoraSyncServer {
|
|
|
517
1238
|
private static readonly MAX_ORPHANED_RELAY_OPS_PER_NODE;
|
|
518
1239
|
private static readonly ORPHANED_RELAY_TTL_MS;
|
|
519
1240
|
constructor(config: KoraSyncServerConfig);
|
|
1241
|
+
/**
|
|
1242
|
+
* Follow the auth provider's revocation feed, when it has one, so revoking a
|
|
1243
|
+
* device or user ends its live sessions without any wiring by the app.
|
|
1244
|
+
*/
|
|
1245
|
+
private ensureRevocationSubscribed;
|
|
1246
|
+
/**
|
|
1247
|
+
* End live sync sessions whose credential was revoked (AUTH-11).
|
|
1248
|
+
*
|
|
1249
|
+
* Each matching session receives a retriable `AUTH_REVOKED` (or `AUTH_EXPIRED`)
|
|
1250
|
+
* error and is closed. The client refreshes its credentials and re-handshakes,
|
|
1251
|
+
* so the server authenticates it again: a still-valid device reconnects, a
|
|
1252
|
+
* revoked one is refused. Sessions still in their handshake are refused as soon
|
|
1253
|
+
* as their principal is known.
|
|
1254
|
+
*
|
|
1255
|
+
* `createKoraAuthServer().bindSyncServer(server)` calls this on device revoke,
|
|
1256
|
+
* sign-out, password reset or change, and admin revoke. A provider exposing
|
|
1257
|
+
* `onRevoke` (the built-in one does) is followed automatically.
|
|
1258
|
+
*
|
|
1259
|
+
* @param filter - `userId` ends every session of that user; with `deviceId`,
|
|
1260
|
+
* only that device's sessions. At least one of the two is required.
|
|
1261
|
+
* @returns The number of established sessions that were closed
|
|
1262
|
+
*
|
|
1263
|
+
* @example
|
|
1264
|
+
* ```typescript
|
|
1265
|
+
* server.terminateSessions({ userId: 'u1', deviceId: 'laptop' })
|
|
1266
|
+
* ```
|
|
1267
|
+
*/
|
|
1268
|
+
terminateSessions(filter: TerminateSessionsFilter): number;
|
|
1269
|
+
/**
|
|
1270
|
+
* Admin release of a device node id (RT-5). The next principal to handshake with
|
|
1271
|
+
* this node id claims it, even when the node already has operation history.
|
|
1272
|
+
*
|
|
1273
|
+
* Use it to hand over operation history written before node claims existed (an
|
|
1274
|
+
* upgrade from beta.12, where nobody may adopt such a node until it is released),
|
|
1275
|
+
* or to reassign a lost device's node id. Live sessions on that node id are ended
|
|
1276
|
+
* with a retriable `NODE_RELEASED` error so the handover starts clean.
|
|
1277
|
+
*
|
|
1278
|
+
* @param nodeId - The device node id to release
|
|
1279
|
+
* @returns True when the node had a claim or history to release; false when it
|
|
1280
|
+
* was unknown or the store does not support node claims
|
|
1281
|
+
*
|
|
1282
|
+
* @example
|
|
1283
|
+
* ```typescript
|
|
1284
|
+
* await server.releaseNodeClaim('0190a1b2-...')
|
|
1285
|
+
* ```
|
|
1286
|
+
*/
|
|
1287
|
+
releaseNodeClaim(nodeId: string): Promise<boolean>;
|
|
520
1288
|
private ensureBackgroundTimersStarted;
|
|
1289
|
+
/**
|
|
1290
|
+
* Re-validate the credential of every live session with the auth provider and end
|
|
1291
|
+
* the sessions whose credential is no longer accepted (RT-18).
|
|
1292
|
+
*
|
|
1293
|
+
* Revocations are persisted by the auth stores (token, device and per-user
|
|
1294
|
+
* cut-offs), but `onRevoke` listeners only run in the process that handled the
|
|
1295
|
+
* revocation. Every instance therefore re-checks its own live sessions
|
|
1296
|
+
* periodically (`sessionRevalidationIntervalMs`, default 30 seconds, also driven by
|
|
1297
|
+
* the delivery poll tick) by authenticating the session's credential again. A
|
|
1298
|
+
* session whose credential is refused, or now resolves to another principal, gets
|
|
1299
|
+
* a retriable `AUTH_REVOKED` (or `AUTH_EXPIRED`) and is closed. A provider error
|
|
1300
|
+
* (for example the auth database is unreachable) ends nothing: it is logged and the
|
|
1301
|
+
* next pass retries, so an outage does not disconnect every client.
|
|
1302
|
+
*
|
|
1303
|
+
* @returns The number of sessions ended by this pass
|
|
1304
|
+
*
|
|
1305
|
+
* @example
|
|
1306
|
+
* ```typescript
|
|
1307
|
+
* // After revoking through another instance's admin API:
|
|
1308
|
+
* await server.revalidateSessions()
|
|
1309
|
+
* ```
|
|
1310
|
+
*/
|
|
1311
|
+
revalidateSessions(): Promise<number>;
|
|
1312
|
+
/** Run {@link revalidateSessions} from the delivery poll tick once the interval elapsed. */
|
|
1313
|
+
private maybeRevalidateSessions;
|
|
521
1314
|
/**
|
|
522
1315
|
* Retransmit relay batches that connected clients have not acknowledged within
|
|
523
1316
|
* `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
|
|
524
1317
|
* already-applied operation is harmless (clients dedup by content-addressed id).
|
|
525
1318
|
*/
|
|
526
1319
|
retransmitPendingRelays(staleMs?: number): void;
|
|
1320
|
+
/**
|
|
1321
|
+
* The ingest rate limiter of a device node, created on first use and shared by
|
|
1322
|
+
* every session of that node, so reconnecting does not buy a fresh budget (SRV-6).
|
|
1323
|
+
*/
|
|
1324
|
+
private rateLimiterFor;
|
|
1325
|
+
/**
|
|
1326
|
+
* Forget rate limiters of nodes with no live session whose window has long
|
|
1327
|
+
* passed: their budget would be full again anyway.
|
|
1328
|
+
*/
|
|
1329
|
+
private expireIdleRateLimiters;
|
|
1330
|
+
/** Close HTTP long-poll sessions that sent no request within the idle timeout. */
|
|
1331
|
+
private expireIdleHttpSessions;
|
|
527
1332
|
/**
|
|
528
1333
|
* Check whether the authoritative delivery log advanced without passing through
|
|
529
1334
|
* this server instance, then wake delivery-watermark sessions to scan from their
|
|
530
1335
|
* own acknowledged cursors. Session-level filtering remains the only fan-out path.
|
|
531
1336
|
*/
|
|
532
1337
|
pollDeliveryLog(): Promise<void>;
|
|
1338
|
+
/**
|
|
1339
|
+
* Invalidate cached blob access sets for the collections written between two
|
|
1340
|
+
* delivery frontiers. Falls back to dropping every set when the gap is large.
|
|
1341
|
+
*/
|
|
1342
|
+
private invalidateBlobAccessSince;
|
|
1343
|
+
/**
|
|
1344
|
+
* The blob reference authority shared by every session and route (RT-11).
|
|
1345
|
+
* @internal Used by the route context; not part of the public API.
|
|
1346
|
+
*/
|
|
1347
|
+
getBlobAccessIndex(): BlobAccessIndex;
|
|
533
1348
|
/**
|
|
534
1349
|
* Buffer relay operations a disconnected client never acknowledged, keyed by its
|
|
535
1350
|
* node id, deduped by operation id and bounded by count. Redelivered when the
|
|
@@ -572,9 +1387,40 @@ declare class KoraSyncServer {
|
|
|
572
1387
|
/**
|
|
573
1388
|
* Handle one HTTP sync request for a long-polling client.
|
|
574
1389
|
*
|
|
575
|
-
*
|
|
1390
|
+
* The POST that opens a session (the handshake, sent without `sessionId`) creates
|
|
1391
|
+
* it and answers with a server-issued, high-entropy session id in the
|
|
1392
|
+
* `x-kora-session` response header. Every later request must name that id.
|
|
1393
|
+
*
|
|
1394
|
+
* With an auth provider, EVERY request is authenticated from its `authorization`
|
|
1395
|
+
* header and must resolve to the same principal (user, device and anonymity) as
|
|
1396
|
+
* the request that opened the session and as the session's handshake, so knowing
|
|
1397
|
+
* a session id alone grants nothing (RT-2). Sessions with no request for
|
|
1398
|
+
* `httpSessionIdleTimeoutMs` are closed.
|
|
1399
|
+
*
|
|
1400
|
+
* Responses: 202 (POST accepted), 200/204/304 (GET), 400 (malformed), 401
|
|
1401
|
+
* (credential missing or invalid), 403 (credential of another principal), 404
|
|
1402
|
+
* (unknown or expired session), 405 (method), 410 (session closed).
|
|
576
1403
|
*/
|
|
577
1404
|
handleHttpRequest(request: HttpSyncRequest): Promise<HttpSyncResponse>;
|
|
1405
|
+
/**
|
|
1406
|
+
* Authenticate one HTTP request. Returns the principal identity, null when the
|
|
1407
|
+
* server has no real auth provider, or 'unauthorized'.
|
|
1408
|
+
*/
|
|
1409
|
+
private authenticateHttpRequest;
|
|
1410
|
+
/**
|
|
1411
|
+
* True when a request's principal is the one that opened the session and, once
|
|
1412
|
+
* the handshake authenticated, the session's own principal.
|
|
1413
|
+
*/
|
|
1414
|
+
private httpPrincipalMatches;
|
|
1415
|
+
/**
|
|
1416
|
+
* Handle an accepted WebSocket (attach mode with `ws`): wraps it in a
|
|
1417
|
+
* {@link WsServerTransport} configured like the standalone server's (serializer,
|
|
1418
|
+
* ping/pong liveness probing, send-buffer ceiling) and starts a session.
|
|
1419
|
+
*
|
|
1420
|
+
* @param ws - A `ws` WebSocket from `WebSocketServer.handleUpgrade` or `connection`
|
|
1421
|
+
* @returns The session ID
|
|
1422
|
+
*/
|
|
1423
|
+
handleWebSocket(ws: WsWebSocket): string;
|
|
578
1424
|
/**
|
|
579
1425
|
* Handle an incoming client connection (attach mode).
|
|
580
1426
|
* Creates a new ClientSession for the transport.
|
|
@@ -599,9 +1445,10 @@ declare class KoraSyncServer {
|
|
|
599
1445
|
* client only receives the operation if it falls within that client's scope.
|
|
600
1446
|
*
|
|
601
1447
|
* @param op - A fully-formed, server-originated operation to apply
|
|
1448
|
+
* @param options - Optional in-store authorization (used by scoped routes)
|
|
602
1449
|
* @returns The apply result, including any server-generated side-effect ops
|
|
603
1450
|
*/
|
|
604
|
-
applyLocalOperation(op: Operation): Promise<ApplyServerOperationResult>;
|
|
1451
|
+
applyLocalOperation(op: Operation, options?: ApplyServerOperationOptions): Promise<ApplyServerOperationResult>;
|
|
605
1452
|
/**
|
|
606
1453
|
* Relay already-applied, server-originated operations to every connected client.
|
|
607
1454
|
* Used by the conditional route apply, which commits its operations atomically
|
|
@@ -626,11 +1473,40 @@ declare class KoraSyncServer {
|
|
|
626
1473
|
* Get the number of currently connected clients.
|
|
627
1474
|
*/
|
|
628
1475
|
getConnectionCount(): number;
|
|
1476
|
+
/**
|
|
1477
|
+
* Node ids whose operations win `merge('server-authoritative')` fields in the fold
|
|
1478
|
+
* (W7): the store's own node id, which authors every server-originated operation
|
|
1479
|
+
* (side effects, constraint corrections, route writes), plus any extras the store
|
|
1480
|
+
* was configured with (`authoritativeNodeIds` store option). This is the one source
|
|
1481
|
+
* of truth: every session advertises exactly this list in the handshake, so clients
|
|
1482
|
+
* fold with the same authority as the server's stores.
|
|
1483
|
+
*/
|
|
1484
|
+
get authoritativeNodeIds(): string[];
|
|
1485
|
+
/**
|
|
1486
|
+
* Blob access policy (RT-1): true when the session is streaming and a live record
|
|
1487
|
+
* inside its download scope references the hash. Gates the central store.
|
|
1488
|
+
*/
|
|
1489
|
+
private sessionReferencesBlob;
|
|
1490
|
+
/** True when a session other than `exceptSessionId` is connected as `nodeId`. */
|
|
1491
|
+
private isNodeLive;
|
|
1492
|
+
/**
|
|
1493
|
+
* Central-store read policy (RT-1, RT-25): the session owns the hash (it pushed
|
|
1494
|
+
* the bytes) or a live record inside its download scope references it.
|
|
1495
|
+
*/
|
|
1496
|
+
private sessionMayReadStoredBlob;
|
|
1497
|
+
/**
|
|
1498
|
+
* Blob access policy (RT-1): a request may be forwarded to a peer that shares the
|
|
1499
|
+
* requester's exact download scope (the same tenant view, as for presence), which
|
|
1500
|
+
* keeps manifests handed over out of band working; across scopes, only when both
|
|
1501
|
+
* scopes reference the hash, so neither the hash nor the bytes cross a tenant.
|
|
1502
|
+
*/
|
|
1503
|
+
private mayForwardBlobRequest;
|
|
629
1504
|
private handleRelay;
|
|
630
1505
|
private handleSessionClose;
|
|
631
1506
|
private handleAwarenessRelay;
|
|
632
1507
|
private handleYjsDocRelay;
|
|
633
|
-
|
|
1508
|
+
/** Open a new HTTP long-poll session bound to `principal`, under a fresh random id. */
|
|
1509
|
+
private openHttpSession;
|
|
634
1510
|
}
|
|
635
1511
|
|
|
636
1512
|
/**
|
|
@@ -820,6 +1696,19 @@ interface AuthContext {
|
|
|
820
1696
|
uplinkScopes?: Record<string, Record<string, unknown>>;
|
|
821
1697
|
/** Arbitrary metadata about the authenticated user */
|
|
822
1698
|
metadata?: Record<string, unknown>;
|
|
1699
|
+
/**
|
|
1700
|
+
* True for an unauthenticated (anonymous) principal, such as the fallback of
|
|
1701
|
+
* `MixedAuthProvider`. Its `userId` is not stable across connections, so node-id
|
|
1702
|
+
* claims are keyed by the shared anonymous owner instead: an anonymous device can
|
|
1703
|
+
* reconnect with its node id, but can never take a signed-in user's node id.
|
|
1704
|
+
*/
|
|
1705
|
+
anonymous?: boolean;
|
|
1706
|
+
/**
|
|
1707
|
+
* When the credential that authenticated this session expires (ms since
|
|
1708
|
+
* epoch). A session must not outlive its credential: the sync server can
|
|
1709
|
+
* close it with a retriable AUTH_EXPIRED so the client refreshes (AUTH-11).
|
|
1710
|
+
*/
|
|
1711
|
+
expiresAt?: number;
|
|
823
1712
|
}
|
|
824
1713
|
/**
|
|
825
1714
|
* Interface for authenticating incoming client connections.
|
|
@@ -832,6 +1721,33 @@ interface AuthProvider {
|
|
|
832
1721
|
* @returns AuthContext if valid, null if rejected
|
|
833
1722
|
*/
|
|
834
1723
|
authenticate(token: string): Promise<AuthContext | null>;
|
|
1724
|
+
/**
|
|
1725
|
+
* Optional revocation feed. When present, `KoraSyncServer` subscribes on
|
|
1726
|
+
* construction and calls {@link KoraSyncServer.terminateSessions} for every
|
|
1727
|
+
* event, so a revoked device or user loses its live sync sessions immediately
|
|
1728
|
+
* (AUTH-11). The built-in `createKoraAuthServer().auth` provides it.
|
|
1729
|
+
*
|
|
1730
|
+
* @returns An unsubscribe function
|
|
1731
|
+
*/
|
|
1732
|
+
onRevoke?(listener: (event: SessionRevocation) => void | Promise<void>): () => void;
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Which live sync sessions a credential revocation ends. `userId` alone ends
|
|
1736
|
+
* every session of that user; with `deviceId` only that device's sessions
|
|
1737
|
+
* (matched against `AuthContext.metadata.deviceId`).
|
|
1738
|
+
*/
|
|
1739
|
+
interface SessionRevocation {
|
|
1740
|
+
userId?: string;
|
|
1741
|
+
deviceId?: string;
|
|
1742
|
+
}
|
|
1743
|
+
/** Options for {@link KoraSyncServer.terminateSessions}. */
|
|
1744
|
+
interface TerminateSessionsFilter extends SessionRevocation {
|
|
1745
|
+
/**
|
|
1746
|
+
* Error code sent to the client before the session closes. Both are retriable:
|
|
1747
|
+
* the client refreshes its credentials and re-handshakes, and the server then
|
|
1748
|
+
* decides again. Defaults to `'AUTH_REVOKED'`.
|
|
1749
|
+
*/
|
|
1750
|
+
code?: 'AUTH_REVOKED' | 'AUTH_EXPIRED';
|
|
835
1751
|
}
|
|
836
1752
|
/**
|
|
837
1753
|
* Configuration for creating a KoraSyncServer.
|
|
@@ -849,8 +1765,60 @@ interface KoraSyncServerConfig {
|
|
|
849
1765
|
serializer?: MessageSerializer;
|
|
850
1766
|
/** Event emitter for DevTools integration */
|
|
851
1767
|
emitter?: KoraEventEmitter;
|
|
852
|
-
/**
|
|
1768
|
+
/**
|
|
1769
|
+
* Maximum concurrent client connections (sessions, including ones still
|
|
1770
|
+
* handshaking). A connection over the limit gets a retriable `MAX_CONNECTIONS` error
|
|
1771
|
+
* and is closed. 0 = unlimited. Defaults to 10,000.
|
|
1772
|
+
*/
|
|
853
1773
|
maxConnections?: number;
|
|
1774
|
+
/**
|
|
1775
|
+
* Interval between WebSocket pings, in ms (SRV-6, LMS #12). A connection that
|
|
1776
|
+
* leaves two pings in a row unanswered is terminated, which ends its session.
|
|
1777
|
+
* Applies to the standalone server and `createProductionServer`. Defaults to 25
|
|
1778
|
+
* seconds; 0 disables probing.
|
|
1779
|
+
*/
|
|
1780
|
+
heartbeatIntervalMs?: number;
|
|
1781
|
+
/**
|
|
1782
|
+
* Interval of the application-level `heartbeat` message sent to clients that
|
|
1783
|
+
* advertise support for it (browsers cannot see WebSocket pings), in ms. A client
|
|
1784
|
+
* that hears nothing for about 2.5 intervals reconnects. Defaults to 25 seconds; 0
|
|
1785
|
+
* disables it.
|
|
1786
|
+
*/
|
|
1787
|
+
appHeartbeatIntervalMs?: number;
|
|
1788
|
+
/**
|
|
1789
|
+
* End-to-end encryption policy (protocol v2, ENC-3). With `required: true` an
|
|
1790
|
+
* uploaded plaintext operation that carries data is refused non-retriably
|
|
1791
|
+
* (`PLAINTEXT_REJECTED`), unless `allowPlaintextMigration` opens a migration window.
|
|
1792
|
+
* Encrypted operations (envelope v2) are always stored opaquely, never transformed.
|
|
1793
|
+
*/
|
|
1794
|
+
encryption?: {
|
|
1795
|
+
required: boolean;
|
|
1796
|
+
allowPlaintextMigration?: boolean;
|
|
1797
|
+
};
|
|
1798
|
+
/**
|
|
1799
|
+
* Time a new connection has to send its handshake, in ms. Defaults to 10 seconds;
|
|
1800
|
+
* 0 disables the deadline.
|
|
1801
|
+
*/
|
|
1802
|
+
handshakeTimeoutMs?: number;
|
|
1803
|
+
/**
|
|
1804
|
+
* Bytes that may wait unsent for one client (WebSocket send buffer, or the queue of
|
|
1805
|
+
* an HTTP long-poll client that stopped polling) before it is disconnected as a slow
|
|
1806
|
+
* consumer. Defaults to 32 MiB; 0 disables the ceiling. The delivery stream pauses
|
|
1807
|
+
* well before this (`deliveryHighWaterBytes`).
|
|
1808
|
+
*/
|
|
1809
|
+
maxBufferedBytes?: number;
|
|
1810
|
+
/**
|
|
1811
|
+
* Queued outbound bytes above which a client's delivery stream pauses until the
|
|
1812
|
+
* client drains them (backpressure). Defaults to 1 MiB.
|
|
1813
|
+
*/
|
|
1814
|
+
deliveryHighWaterBytes?: number;
|
|
1815
|
+
/**
|
|
1816
|
+
* WebSocket permessage-deflate compression. `true` (the default) compresses messages
|
|
1817
|
+
* of 1 KiB or more without keeping a compression context between messages, which
|
|
1818
|
+
* cuts sync payloads several-fold on 2G/3G links at a bounded memory cost per
|
|
1819
|
+
* connection. `false` disables it; an object is passed to `ws` as is.
|
|
1820
|
+
*/
|
|
1821
|
+
perMessageDeflate?: boolean | Record<string, unknown>;
|
|
854
1822
|
/** Maximum operations per sync batch. Defaults to 100. */
|
|
855
1823
|
batchSize?: number;
|
|
856
1824
|
/**
|
|
@@ -878,10 +1846,13 @@ interface KoraSyncServerConfig {
|
|
|
878
1846
|
max: number;
|
|
879
1847
|
};
|
|
880
1848
|
/**
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
1849
|
+
* Schema transforms from older client schema versions to the server schema version.
|
|
1850
|
+
* Required when `supportedSchemaVersions.min` is lower than `schemaVersion` and
|
|
1851
|
+
* operation shapes changed across versions. They run at fold time (RT-84): every
|
|
1852
|
+
* operation is stored and delivered exactly as uploaded; authorization, validators
|
|
1853
|
+
* and the store's fold use its transformed view. The server hands this list to its
|
|
1854
|
+
* store (`setOperationTransforms`); pass it to `store.setSchema` too to fold with it
|
|
1855
|
+
* from the first start. Transforms must be pure and deterministic.
|
|
885
1856
|
*/
|
|
886
1857
|
operationTransforms?: OperationTransform[];
|
|
887
1858
|
/** WebSocket path (standalone mode). Defaults to '/'. */
|
|
@@ -909,6 +1880,32 @@ interface KoraSyncServerConfig {
|
|
|
909
1880
|
* `@korajs/store` derives both from a `ContentAddressedBlobStore`.
|
|
910
1881
|
*/
|
|
911
1882
|
persistBlobChunk?: (hash: string, bytes: Uint8Array) => Promise<void> | void;
|
|
1883
|
+
/**
|
|
1884
|
+
* Limits on the blob side channel. `maxChunkBytes` (default 1 MiB) caps one
|
|
1885
|
+
* pushed chunk or manifest, `maxBytesPerSession` (default 256 MiB) caps the total
|
|
1886
|
+
* a session may push for central persistence, `maxPendingRequestsPerSession`
|
|
1887
|
+
* (default 256) and `pendingRequestTtlMs` (default 60s) bound the chunk requests
|
|
1888
|
+
* the relay remembers per session.
|
|
1889
|
+
*/
|
|
1890
|
+
blobLimits?: {
|
|
1891
|
+
maxChunkBytes?: number;
|
|
1892
|
+
maxBytesPerSession?: number;
|
|
1893
|
+
maxPendingRequestsPerSession?: number;
|
|
1894
|
+
pendingRequestTtlMs?: number;
|
|
1895
|
+
/**
|
|
1896
|
+
* Blob chunk requests one session may make per minute, a budget separate from
|
|
1897
|
+
* `maxOpsPerMinute` (one request per chunk, so a large blob makes many). Over
|
|
1898
|
+
* it, a request is answered with a retriable `throttled` response the client
|
|
1899
|
+
* backs off on (RT-24). Defaults to 6000.
|
|
1900
|
+
*/
|
|
1901
|
+
maxRequestsPerMinute?: number;
|
|
1902
|
+
};
|
|
1903
|
+
/**
|
|
1904
|
+
* Largest WebSocket message the standalone server (and `createProductionServer`)
|
|
1905
|
+
* accepts, in bytes. Larger frames are refused by the socket layer before they are
|
|
1906
|
+
* buffered. Defaults to 32 MiB (the `ws` library default is 100 MiB).
|
|
1907
|
+
*/
|
|
1908
|
+
maxMessageBytes?: number;
|
|
912
1909
|
/**
|
|
913
1910
|
* Maximum serialized byte size of a single client operation accepted at sync
|
|
914
1911
|
* ingest. Operations larger than this are rejected before materialization.
|
|
@@ -917,11 +1914,64 @@ interface KoraSyncServerConfig {
|
|
|
917
1914
|
*/
|
|
918
1915
|
maxOperationBytes?: number;
|
|
919
1916
|
/**
|
|
920
|
-
* Maximum operations accepted per
|
|
921
|
-
*
|
|
922
|
-
*
|
|
1917
|
+
* Maximum operations accepted per device node per minute (fixed window). The budget
|
|
1918
|
+
* belongs to the node id (bound to its principal when auth is configured), not to
|
|
1919
|
+
* the connection, so reconnecting does not reset it (SRV-6). Operations beyond the
|
|
1920
|
+
* limit get a retriable `RATE_LIMIT` until the window resets. Defaults to 600.
|
|
923
1921
|
*/
|
|
924
1922
|
maxOpsPerMinute?: number;
|
|
1923
|
+
/**
|
|
1924
|
+
* Maximum operations accepted per authenticated principal per minute, across all of
|
|
1925
|
+
* its device nodes, on top of the per-node `maxOpsPerMinute`. Without it a user could
|
|
1926
|
+
* multiply the per-node budget by minting node ids. Keyed by the provider's user id;
|
|
1927
|
+
* anonymous and unauthenticated sessions (no stable principal) have only the per-node
|
|
1928
|
+
* budget. Defaults to 4 x `maxOpsPerMinute` (2400), so one busy device never meets it
|
|
1929
|
+
* and a user's devices share it. `0` disables the per-user budget.
|
|
1930
|
+
*/
|
|
1931
|
+
maxOpsPerMinutePerUser?: number;
|
|
1932
|
+
/**
|
|
1933
|
+
* Largest operation batch accepted from a client in one message. A larger batch is
|
|
1934
|
+
* refused whole with `BATCH_TOO_LARGE` before the server decodes it or reads the
|
|
1935
|
+
* store, so one message cannot buy unbounded work. Defaults to 1000 (the client
|
|
1936
|
+
* sends batches of 100 by default).
|
|
1937
|
+
*/
|
|
1938
|
+
maxOpsPerBatch?: number;
|
|
1939
|
+
/**
|
|
1940
|
+
* Accept anonymous devices whose node claim predates confirmed claims (RT-21):
|
|
1941
|
+
* nodes whose operation history predates node claims (a database written by a
|
|
1942
|
+
* beta.12 or older server, which recorded no claims; RT-91), nodes held by the
|
|
1943
|
+
* pre-release shared anonymous owner, and provisional claims that expired without
|
|
1944
|
+
* the device ever confirming its node token (beta.12 and older clients have no
|
|
1945
|
+
* token support). Accepted with a deprecation warning and re-issued a token.
|
|
1946
|
+
* Defaults to `true` for 1.0.0-beta.13; the default flips to `false` in the next
|
|
1947
|
+
* release, after which such devices rotate to a fresh node id instead (beta.12
|
|
1948
|
+
* clients cannot rotate and stay refused).
|
|
1949
|
+
*
|
|
1950
|
+
* With `MixedAuthProvider`, a node with pre-claims history may also be a signed-in
|
|
1951
|
+
* user's beta.12 device: an anonymous device can take such a node, as it could on
|
|
1952
|
+
* beta.12. Set this to `false` if that matters more than keeping anonymous beta.12
|
|
1953
|
+
* devices syncing.
|
|
1954
|
+
*/
|
|
1955
|
+
allowLegacyAnonymousClaims?: boolean;
|
|
1956
|
+
/**
|
|
1957
|
+
* How long an anonymous device's provisional node claim may stay unconfirmed and
|
|
1958
|
+
* still be re-issued to a device that presents no token (a handshake response lost
|
|
1959
|
+
* in transit), in milliseconds (RT-21). Defaults to 24 hours.
|
|
1960
|
+
*/
|
|
1961
|
+
anonymousClaimTtlMs?: number;
|
|
1962
|
+
/**
|
|
1963
|
+
* How often every live session's credential is re-validated with the auth
|
|
1964
|
+
* provider, in milliseconds (RT-18). A revocation persisted by another server
|
|
1965
|
+
* instance ends the session here within this interval. Defaults to 30 seconds; 0
|
|
1966
|
+
* disables it (sessions then end only through this process's `onRevoke` feed,
|
|
1967
|
+
* `terminateSessions`, or credential expiry).
|
|
1968
|
+
*/
|
|
1969
|
+
sessionRevalidationIntervalMs?: number;
|
|
1970
|
+
/**
|
|
1971
|
+
* How long an HTTP long-poll session may go without any request before the server
|
|
1972
|
+
* closes it, in milliseconds. Defaults to 2 minutes; 0 disables expiry.
|
|
1973
|
+
*/
|
|
1974
|
+
httpSessionIdleTimeoutMs?: number;
|
|
925
1975
|
/**
|
|
926
1976
|
* Adjudicate untrusted client operations before they become authoritative.
|
|
927
1977
|
*
|
|
@@ -937,12 +1987,25 @@ interface KoraSyncServerConfig {
|
|
|
937
1987
|
}
|
|
938
1988
|
/**
|
|
939
1989
|
* Request envelope for the server-side HTTP sync endpoint.
|
|
1990
|
+
*
|
|
1991
|
+
* Map it from your HTTP framework: `sessionId` from the `x-kora-session` header and
|
|
1992
|
+
* `authorization` from the `Authorization` header, on every request.
|
|
940
1993
|
*/
|
|
941
1994
|
interface HttpSyncRequest {
|
|
942
|
-
/** Stable client identifier for binding HTTP requests to a server session */
|
|
943
|
-
clientId: string;
|
|
944
1995
|
/** HTTP method */
|
|
945
1996
|
method: 'GET' | 'POST';
|
|
1997
|
+
/**
|
|
1998
|
+
* The server-issued session id (`x-kora-session` request header). Absent only on
|
|
1999
|
+
* the POST that opens a session (the handshake); the response to that POST
|
|
2000
|
+
* carries the new id in its `x-kora-session` header. Never chosen by the client.
|
|
2001
|
+
*/
|
|
2002
|
+
sessionId?: string;
|
|
2003
|
+
/**
|
|
2004
|
+
* The raw `Authorization` header (`Bearer <token>`). With an auth provider
|
|
2005
|
+
* configured, EVERY request is authenticated and must resolve to the same
|
|
2006
|
+
* principal and device as the session it names (RT-2).
|
|
2007
|
+
*/
|
|
2008
|
+
authorization?: string;
|
|
946
2009
|
/** Optional raw request payload for POST */
|
|
947
2010
|
body?: string | Uint8Array;
|
|
948
2011
|
/** Value of the Content-Type header for POST payloads */
|
|
@@ -955,7 +2018,7 @@ interface HttpSyncRequest {
|
|
|
955
2018
|
*/
|
|
956
2019
|
interface HttpSyncResponse {
|
|
957
2020
|
/** HTTP status code */
|
|
958
|
-
status: 200 | 202 | 204 | 304 | 400 | 405 | 410;
|
|
2021
|
+
status: 200 | 202 | 204 | 304 | 400 | 401 | 403 | 404 | 405 | 410;
|
|
959
2022
|
/** Optional raw response payload */
|
|
960
2023
|
body?: string | Uint8Array;
|
|
961
2024
|
/** Optional response headers */
|
|
@@ -1002,6 +2065,20 @@ interface ServerStatus {
|
|
|
1002
2065
|
errorCount: number;
|
|
1003
2066
|
}
|
|
1004
2067
|
|
|
2068
|
+
/**
|
|
2069
|
+
* Which reverse proxies in front of the server are trusted to report the client
|
|
2070
|
+
* address in `X-Forwarded-For`.
|
|
2071
|
+
*
|
|
2072
|
+
* - `number`: trust that many hops, counting the socket peer as the first. `1` means
|
|
2073
|
+
* "one proxy (for example a load balancer) sits directly in front of the server".
|
|
2074
|
+
* - `string[]`: trust peers whose address is in one of these IPs or CIDR ranges
|
|
2075
|
+
* (IPv4 and IPv6, for example `['10.0.0.0/8', '::1']`).
|
|
2076
|
+
*
|
|
2077
|
+
* When unset, `X-Forwarded-For` is ignored and the socket address is the client
|
|
2078
|
+
* address, so a directly connected client cannot choose its own address.
|
|
2079
|
+
*/
|
|
2080
|
+
type TrustProxySetting = number | string[];
|
|
2081
|
+
|
|
1005
2082
|
interface HttpPollResponse {
|
|
1006
2083
|
status: 200 | 204 | 304 | 410;
|
|
1007
2084
|
body?: string | Uint8Array;
|
|
@@ -1021,56 +2098,104 @@ declare class HttpServerTransport implements ServerTransport {
|
|
|
1021
2098
|
private connected;
|
|
1022
2099
|
private nextSequence;
|
|
1023
2100
|
private readonly queue;
|
|
1024
|
-
|
|
2101
|
+
private queuedBytes;
|
|
2102
|
+
private readonly maxQueuedBytes;
|
|
2103
|
+
/**
|
|
2104
|
+
* @param serializer - Message serializer
|
|
2105
|
+
* @param options - `maxQueuedBytes`: close the session once this many bytes wait
|
|
2106
|
+
* unpolled (default 32 MiB; 0 disables the ceiling)
|
|
2107
|
+
*/
|
|
2108
|
+
constructor(serializer: MessageSerializer, options?: {
|
|
2109
|
+
maxQueuedBytes?: number;
|
|
2110
|
+
});
|
|
1025
2111
|
send(message: _korajs_sync.SyncMessage): void;
|
|
2112
|
+
/** Bytes queued for the client and not yet polled (delivery-stream backpressure). */
|
|
2113
|
+
bufferedAmount(): number;
|
|
1026
2114
|
onMessage(handler: ServerMessageHandler): void;
|
|
1027
2115
|
onClose(handler: ServerCloseHandler): void;
|
|
1028
2116
|
onError(handler: ServerErrorHandler): void;
|
|
1029
2117
|
isConnected(): boolean;
|
|
2118
|
+
/**
|
|
2119
|
+
* Close the session. Messages already queued (typically the final error that
|
|
2120
|
+
* explains the close, for example a refused handshake) stay pollable, so a
|
|
2121
|
+
* long-polling client learns why, exactly as a WebSocket client receives the error
|
|
2122
|
+
* frame before the close; once drained, polls answer 410.
|
|
2123
|
+
*/
|
|
1030
2124
|
close(code?: number, reason?: string): void;
|
|
2125
|
+
/** True while messages queued before the close are still waiting to be polled. */
|
|
2126
|
+
hasPending(): boolean;
|
|
1031
2127
|
receive(payload: string | Uint8Array): void;
|
|
1032
2128
|
poll(ifNoneMatch?: string): HttpPollResponse;
|
|
1033
2129
|
private makeEtag;
|
|
1034
2130
|
}
|
|
1035
2131
|
|
|
1036
|
-
/**
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
*/
|
|
1040
|
-
|
|
1041
|
-
readyState: number;
|
|
1042
|
-
send(data: string | Uint8Array, callback?: (err?: Error) => void): void;
|
|
1043
|
-
close(code?: number, reason?: string): void;
|
|
1044
|
-
on(event: string, listener: (...args: unknown[]) => void): void;
|
|
1045
|
-
removeAllListeners(): void;
|
|
2132
|
+
/** Outcome of one key-service message. */
|
|
2133
|
+
interface KeyServiceOutcome {
|
|
2134
|
+
response: EncryptionKeyResponseMessage;
|
|
2135
|
+
/** The record written, when the message was a successful write (to push to siblings). */
|
|
2136
|
+
written: WrappedKeyRecord | null;
|
|
1046
2137
|
}
|
|
1047
2138
|
/**
|
|
1048
|
-
*
|
|
2139
|
+
* The sync server's key service (ENC-1, decision D4b). Stores each owner's wrapped key
|
|
2140
|
+
* record and serves it back. It never sees a passphrase or a data key: a record is
|
|
2141
|
+
* salt, KDF parameters, AES-GCM-wrapped data keys, key ids and versions.
|
|
2142
|
+
*
|
|
2143
|
+
* Rules it enforces:
|
|
2144
|
+
* - **Owner from the session only.** The owner is the session's authenticated
|
|
2145
|
+
* principal (or the shared anonymous owner on a server without auth); a message
|
|
2146
|
+
* cannot name a user, so no session reads or writes another user's record.
|
|
2147
|
+
* - **Compare-and-set.** A write names the revision it replaces; a concurrent write
|
|
2148
|
+
* loses with `conflict` and the current record, so two devices that both create a
|
|
2149
|
+
* first key converge on one.
|
|
2150
|
+
* - **History is append-only.** A write must raise the revision, keep the ring id and
|
|
2151
|
+
* every key version (same key id), so no device can make old operations unreadable
|
|
2152
|
+
* for the others. A first write (or a re-upload after the record was lost) keeps the
|
|
2153
|
+
* revision it carries, since devices refuse revisions below the one they accepted.
|
|
2154
|
+
* - **Lost records are visible.** A fetch with no record reports key ids of the owner's
|
|
2155
|
+
* stored encrypted operations, so a new device waits for one holding the ring.
|
|
2156
|
+
*
|
|
2157
|
+
* It cannot check the record MAC or that wraps open (only a holder of the ring's master
|
|
2158
|
+
* key can): devices authenticate every record themselves and act on nothing else. A
|
|
2159
|
+
* principal can still overwrite its own record with garbage; devices refuse it and
|
|
2160
|
+
* keep their keys.
|
|
1049
2161
|
*/
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
2162
|
+
declare class EncryptionKeyService {
|
|
2163
|
+
private readonly store;
|
|
2164
|
+
constructor(store: ServerStore);
|
|
2165
|
+
/** Whether the store can persist key records (all built-in stores can). */
|
|
2166
|
+
isSupported(): boolean;
|
|
2167
|
+
/**
|
|
2168
|
+
* Answer one key-service message for `owner`.
|
|
2169
|
+
*
|
|
2170
|
+
* @param owner - The session's owner key, or null when the session may not hold keys
|
|
2171
|
+
* @param message - A request or a write
|
|
2172
|
+
*/
|
|
2173
|
+
handle(owner: string | null, message: EncryptionKeyRequestMessage | EncryptionKeyPutMessage): Promise<KeyServiceOutcome>;
|
|
2174
|
+
/**
|
|
2175
|
+
* Key ids of the owner's stored encrypted operations (a sample). Reported only while
|
|
2176
|
+
* no record exists: then non-empty means the record was lost, and a new device must
|
|
2177
|
+
* wait for one that holds the ring instead of starting another (RT-104).
|
|
2178
|
+
*/
|
|
2179
|
+
private knownKeyIds;
|
|
2180
|
+
private read;
|
|
1053
2181
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
*/
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
private errorHandler;
|
|
1064
|
-
constructor(ws: WsWebSocket, options?: WsServerTransportOptions);
|
|
1065
|
-
send(message: SyncMessage): void;
|
|
1066
|
-
onMessage(handler: ServerMessageHandler): void;
|
|
1067
|
-
onClose(handler: ServerCloseHandler): void;
|
|
1068
|
-
onError(handler: ServerErrorHandler): void;
|
|
1069
|
-
isConnected(): boolean;
|
|
1070
|
-
close(code?: number, reason?: string): void;
|
|
1071
|
-
private setupListeners;
|
|
2182
|
+
|
|
2183
|
+
/** What a session charges uploaded operations to. */
|
|
2184
|
+
interface IngestRateLimiter {
|
|
2185
|
+
/** Units allowed per window (the tightest applicable budget). */
|
|
2186
|
+
readonly limit: number;
|
|
2187
|
+
/** Charge `count` units; false when a budget is exceeded (the units are refused). */
|
|
2188
|
+
allow(count?: number): boolean;
|
|
2189
|
+
/** Milliseconds until a refused unit may be retried. */
|
|
2190
|
+
retryAfterMs(): number;
|
|
1072
2191
|
}
|
|
1073
2192
|
|
|
2193
|
+
/**
|
|
2194
|
+
* Session-ending error codes, all retriable. `AUTH_REVOKED` / `AUTH_EXPIRED`: the
|
|
2195
|
+
* client refreshes its credential and re-handshakes. `SCOPE_CHANGED` (RT-26): the
|
|
2196
|
+
* principal's grant changed; the client simply reconnects and gets the new scope.
|
|
2197
|
+
*/
|
|
2198
|
+
type SessionTerminationCode = 'AUTH_REVOKED' | 'AUTH_EXPIRED' | 'SCOPE_CHANGED';
|
|
1074
2199
|
/**
|
|
1075
2200
|
* Possible states for a client session.
|
|
1076
2201
|
*/
|
|
@@ -1086,7 +2211,7 @@ type AwarenessRelayCallback = (sourceSessionId: string, message: AwarenessUpdate
|
|
|
1086
2211
|
/**
|
|
1087
2212
|
* Callback invoked when a session receives a Yjs doc channel update to relay.
|
|
1088
2213
|
*/
|
|
1089
|
-
type YjsDocRelayCallback = (sourceSessionId: string, message: YjsDocUpdateMessage) => void;
|
|
2214
|
+
type YjsDocRelayCallback = (sourceSessionId: string, message: YjsDocUpdateMessage, storedRecord: MaterializedRecord | null) => void;
|
|
1090
2215
|
/**
|
|
1091
2216
|
* Callback invoked when a session receives a blob chunk request to route.
|
|
1092
2217
|
*/
|
|
@@ -1128,7 +2253,7 @@ interface ClientSessionOptions {
|
|
|
1128
2253
|
min: number;
|
|
1129
2254
|
max: number;
|
|
1130
2255
|
};
|
|
1131
|
-
/**
|
|
2256
|
+
/** Schema transforms: judged views of older operations (transforms at fold time, RT-84). */
|
|
1132
2257
|
operationTransforms?: OperationTransform[];
|
|
1133
2258
|
/** Called when this session has operations to relay to other sessions */
|
|
1134
2259
|
onRelay?: RelayCallback;
|
|
@@ -1140,8 +2265,24 @@ interface ClientSessionOptions {
|
|
|
1140
2265
|
onBlobChunkRequest?: BlobChunkRequestCallback;
|
|
1141
2266
|
/** Called when this session receives a blob chunk response to route back */
|
|
1142
2267
|
onBlobChunkResponse?: BlobChunkResponseCallback;
|
|
2268
|
+
/** The key service storing wrapped encryption key records (ENC-1). */
|
|
2269
|
+
encryptionKeys?: EncryptionKeyService;
|
|
2270
|
+
/** Called after this session wrote a key record, to push it to the owner's other sessions. */
|
|
2271
|
+
onEncryptionKeyWritten?: (sessionId: string, owner: string, keyring: string, record: WrappedKeyRecord) => void;
|
|
2272
|
+
/** Key-service messages allowed per session per minute. Defaults to 60. */
|
|
2273
|
+
maxKeyRequestsPerMinute?: number;
|
|
1143
2274
|
/** Persist a client-uploaded blob chunk centrally (keyed by content hash) */
|
|
1144
2275
|
persistBlobChunk?: PersistBlobChunk;
|
|
2276
|
+
/**
|
|
2277
|
+
* Called once the session completes an accepted handshake and reaches streaming.
|
|
2278
|
+
* Side channels (Yjs doc, blob and awareness relays) register the session here,
|
|
2279
|
+
* never at connect time, so an unauthenticated connection is never on a relay.
|
|
2280
|
+
*/
|
|
2281
|
+
onReady?: (sessionId: string) => void;
|
|
2282
|
+
/** Largest single blob chunk this session may push. Defaults to 1 MiB. */
|
|
2283
|
+
maxBlobChunkBytes?: number;
|
|
2284
|
+
/** Total blob bytes this session may push. Defaults to 256 MiB. */
|
|
2285
|
+
maxBlobBytesPerSession?: number;
|
|
1145
2286
|
/** Called when this session closes */
|
|
1146
2287
|
onClose?: (sessionId: string) => void;
|
|
1147
2288
|
/**
|
|
@@ -1161,6 +2302,18 @@ interface ClientSessionOptions {
|
|
|
1161
2302
|
maxOperationBytes?: number;
|
|
1162
2303
|
/** Maximum operations accepted per minute for this session. Defaults to 600. */
|
|
1163
2304
|
maxOpsPerMinute?: number;
|
|
2305
|
+
/**
|
|
2306
|
+
* Blob chunk requests accepted per minute, separate from the operation budget
|
|
2307
|
+
* (RT-24). Defaults to 6000.
|
|
2308
|
+
*/
|
|
2309
|
+
maxBlobRequestsPerMinute?: number;
|
|
2310
|
+
/** Length of the blob request window in ms. One minute; shorter only in tests. */
|
|
2311
|
+
blobRequestWindowMs?: number;
|
|
2312
|
+
/**
|
|
2313
|
+
* Largest operation batch accepted in one message. A larger batch is refused whole
|
|
2314
|
+
* (`BATCH_TOO_LARGE`) before it is decoded or the store is read. Defaults to 1000.
|
|
2315
|
+
*/
|
|
2316
|
+
maxOpsPerBatch?: number;
|
|
1164
2317
|
/**
|
|
1165
2318
|
* Adjudicate untrusted client operations before materialization. When present,
|
|
1166
2319
|
* each incoming operation is passed to this validator; a `reject` decision
|
|
@@ -1169,6 +2322,67 @@ interface ClientSessionOptions {
|
|
|
1169
2322
|
validateOperation?: OperationValidator;
|
|
1170
2323
|
/** Trusted data-plane context handed to the validator (read state, author derived ops). */
|
|
1171
2324
|
koraContext?: ProductionHttpRouteContext;
|
|
2325
|
+
/**
|
|
2326
|
+
* Blob reference authority (RT-11): decides which content hashes this session may
|
|
2327
|
+
* reference in blob fields and records the bytes it pushes. Without it, blob
|
|
2328
|
+
* references are not checked at ingest.
|
|
2329
|
+
*/
|
|
2330
|
+
blobAccess?: BlobAccessIndex;
|
|
2331
|
+
/** See `KoraSyncServerConfig.allowLegacyAnonymousClaims` (RT-21). Defaults to true. */
|
|
2332
|
+
allowLegacyAnonymousClaims?: boolean;
|
|
2333
|
+
/** See `KoraSyncServerConfig.anonymousClaimTtlMs` (RT-21). Defaults to 24 hours. */
|
|
2334
|
+
anonymousClaimTtlMs?: number;
|
|
2335
|
+
/**
|
|
2336
|
+
* True when another live session (not `exceptSessionId`) is connected as `nodeId`.
|
|
2337
|
+
* A provisional anonymous claim is never re-issued away from a connected device.
|
|
2338
|
+
*/
|
|
2339
|
+
isNodeLive?: (nodeId: string, exceptSessionId: string) => boolean;
|
|
2340
|
+
/**
|
|
2341
|
+
* Time a new connection has to deliver its handshake, in ms (SRV-6). A connection
|
|
2342
|
+
* that sends none is closed with `HANDSHAKE_TIMEOUT`. Defaults to 10 seconds; 0
|
|
2343
|
+
* disables the deadline.
|
|
2344
|
+
*/
|
|
2345
|
+
handshakeTimeoutMs?: number;
|
|
2346
|
+
/**
|
|
2347
|
+
* Queued outbound bytes above which the delivery stream pauses until the client
|
|
2348
|
+
* drains its socket (transports that report `bufferedAmount`). Defaults to 1 MiB.
|
|
2349
|
+
*/
|
|
2350
|
+
deliveryHighWaterBytes?: number;
|
|
2351
|
+
/**
|
|
2352
|
+
* The ingest rate limiter for a node, shared across that node's sessions so a
|
|
2353
|
+
* reconnect does not reset it (SRV-6). Called once the handshake binds the node,
|
|
2354
|
+
* with the authenticated principal's user id (null for anonymous and
|
|
2355
|
+
* unauthenticated sessions) so a per-user budget can be layered on top: a user
|
|
2356
|
+
* minting node ids must not multiply the per-node budget.
|
|
2357
|
+
* Without it the session keeps a private limiter.
|
|
2358
|
+
*/
|
|
2359
|
+
rateLimiterFor?: (nodeId: string, principal: string | null) => IngestRateLimiter;
|
|
2360
|
+
/**
|
|
2361
|
+
* Interval of the application-level heartbeat sent to clients that advertised
|
|
2362
|
+
* support for it, in ms (LMS #12). The handshake response tells the client the
|
|
2363
|
+
* interval so it can declare the connection dead after missing two. 0 disables it.
|
|
2364
|
+
*/
|
|
2365
|
+
appHeartbeatIntervalMs?: number;
|
|
2366
|
+
/**
|
|
2367
|
+
* Node ids whose operations this server authors (protocol v2). Sent to clients in
|
|
2368
|
+
* the handshake response (`authoritativeNodeIds`); only operations from these nodes
|
|
2369
|
+
* may carry server-authored metadata (`fieldVersions`, `foldState`). Defaults to the
|
|
2370
|
+
* ids the store folds with (`ServerStore.getAuthoritativeNodeIds`), which is what
|
|
2371
|
+
* `KoraSyncServer` always passes. Scope-entry operations (`kora:scope-entry`) are not
|
|
2372
|
+
* listed: they carry the server's fold state and are joined, never folded as writes.
|
|
2373
|
+
*/
|
|
2374
|
+
authoritativeNodeIds?: readonly string[];
|
|
2375
|
+
/**
|
|
2376
|
+
* End-to-end encryption policy (protocol v2, ENC-3). With `required`, every uploaded
|
|
2377
|
+
* data-bearing operation must carry the encryption envelope; a plaintext one is
|
|
2378
|
+
* refused non-retriably (`PLAINTEXT_REJECTED`), unless `allowPlaintextMigration` is
|
|
2379
|
+
* set for a migration window. Without it, the server stores whatever it receives
|
|
2380
|
+
* (envelope operations always opaquely).
|
|
2381
|
+
*/
|
|
2382
|
+
encryption?: {
|
|
2383
|
+
required: boolean;
|
|
2384
|
+
allowPlaintextMigration?: boolean;
|
|
2385
|
+
};
|
|
1172
2386
|
}
|
|
1173
2387
|
/**
|
|
1174
2388
|
* Handles the sync protocol for a single connected client.
|
|
@@ -1188,6 +2402,29 @@ declare class ClientSession {
|
|
|
1188
2402
|
private state;
|
|
1189
2403
|
private clientNodeId;
|
|
1190
2404
|
private authContext;
|
|
2405
|
+
/**
|
|
2406
|
+
* The context the auth provider returned, kept apart from {@link authContext}
|
|
2407
|
+
* (which an unauthenticated session also gets, keyed by node id) so revocation
|
|
2408
|
+
* only ever matches a verified principal.
|
|
2409
|
+
*/
|
|
2410
|
+
private principal;
|
|
2411
|
+
/** Closes the session when its credential expires (AUTH-11). */
|
|
2412
|
+
private expiryTimer;
|
|
2413
|
+
/** Revocations that arrived while the handshake was still authenticating. */
|
|
2414
|
+
private readonly pendingRevocations;
|
|
2415
|
+
/** Node token issued by this handshake's first anonymous claim, sent once in the response (RT-12). */
|
|
2416
|
+
private issuedNodeToken;
|
|
2417
|
+
/** The node-claim owner this session holds its node id under (userId, or an anonymous device key). */
|
|
2418
|
+
private nodeOwnerKey;
|
|
2419
|
+
/** A provisional anonymous claim awaiting the device's confirmation (RT-21). */
|
|
2420
|
+
private pendingNodeClaim;
|
|
2421
|
+
private readonly allowLegacyAnonymousClaims;
|
|
2422
|
+
private readonly anonymousClaimTtlMs;
|
|
2423
|
+
private readonly isNodeLive;
|
|
2424
|
+
/** The credential presented at handshake, kept to re-validate the session (RT-18). */
|
|
2425
|
+
private credential;
|
|
2426
|
+
/** The scope the client asked for at handshake, kept to re-resolve scopes (RT-26). */
|
|
2427
|
+
private handshakeScope;
|
|
1191
2428
|
private syncQuerySubsets;
|
|
1192
2429
|
private scopeExitPolicy;
|
|
1193
2430
|
private resumeDeltaCursor;
|
|
@@ -1200,23 +2437,70 @@ declare class ClientSession {
|
|
|
1200
2437
|
private clientDeliveryWatermark;
|
|
1201
2438
|
/**
|
|
1202
2439
|
* The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
|
|
1203
|
-
* watermark, as reported in acks).
|
|
1204
|
-
*
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1207
|
-
* is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
|
|
1208
|
-
* client's reported watermark at handshake.
|
|
2440
|
+
* watermark, as reported in acks). A retransmission rewinds the send cursor to here,
|
|
2441
|
+
* so a dropped or unapplied batch is always re-included by a re-scan from the
|
|
2442
|
+
* acknowledged position: recovery never depends on a bounded retransmit buffer.
|
|
2443
|
+
* Seeded from the client's reported watermark at handshake.
|
|
1209
2444
|
*/
|
|
1210
2445
|
private lastAckedDeliverySeq;
|
|
1211
2446
|
/**
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
2447
|
+
* The delivery send cursor (SRV-3): the max of the last batch sent. Live pushes
|
|
2448
|
+
* chain from it (base = lastSent), so each operation goes out once per retransmit
|
|
2449
|
+
* epoch instead of the whole unacknowledged backlog on every write. Invariant:
|
|
2450
|
+
* lastAcked <= lastSent. It rewinds to lastAcked only when the outstanding delivery
|
|
2451
|
+
* went unacknowledged for the (backed-off) stale window.
|
|
2452
|
+
*/
|
|
2453
|
+
private lastSentDeliverySeq;
|
|
2454
|
+
/** The highest batch max ever sent to this client (acks are capped by it). */
|
|
2455
|
+
private highestSentDeliverySeq;
|
|
2456
|
+
/**
|
|
2457
|
+
* Own-node operations at or below this delivery sequence are streamed to this
|
|
2458
|
+
* client; above it they are skipped (it uploaded them itself). Set at handshake: a
|
|
2459
|
+
* full resync (watermark 0) recovers the client's own history up to the frontier
|
|
2460
|
+
* at that moment; a resume excludes all of them.
|
|
1215
2461
|
*/
|
|
1216
|
-
private
|
|
1217
|
-
|
|
1218
|
-
|
|
2462
|
+
private ownOperationsIncludedThrough;
|
|
2463
|
+
/** When the delivery last made progress (an ack advanced, or a fresh send/rewind). */
|
|
2464
|
+
private lastDeliveryProgressAtMs;
|
|
2465
|
+
/** Consecutive rewinds without acknowledgment progress (drives the backoff, LMS #12). */
|
|
2466
|
+
private deliveryRewinds;
|
|
2467
|
+
/**
|
|
2468
|
+
* Sent, not yet acknowledged delivery batches in send order (their max and send
|
|
2469
|
+
* time), for round-trip samples. `resent` marks a re-sent range, which yields no
|
|
2470
|
+
* sample (Karn's rule: its ack cannot be matched to one transmission).
|
|
2471
|
+
*/
|
|
2472
|
+
private readonly unackedBatches;
|
|
2473
|
+
private smoothedRttMs;
|
|
2474
|
+
private rttVarianceMs;
|
|
2475
|
+
/**
|
|
2476
|
+
* Retransmit timeout of the delivery stream (RFC 6298 style: smoothed round trip
|
|
2477
|
+
* plus four deviations, floored). An outstanding delivery with no acknowledgment
|
|
2478
|
+
* progress for this long (doubled per consecutive re-send) is re-sent from the
|
|
2479
|
+
* acknowledged position. Round trips include the client's apply time, so a slow
|
|
2480
|
+
* device earns a longer timeout instead of spurious re-sends.
|
|
2481
|
+
*/
|
|
2482
|
+
private retransmitTimeoutMs;
|
|
2483
|
+
private retransmitTimer;
|
|
2484
|
+
/** A delivery push is queued but not started: further wake-ups coalesce into it. */
|
|
2485
|
+
private deliveryPushQueued;
|
|
2486
|
+
/** Serializes delivery pushes so their batches never interleave. */
|
|
1219
2487
|
private deliveryPushChain;
|
|
2488
|
+
/**
|
|
2489
|
+
* Records prefetched for the delivery chunk being filtered (LMS #11: one batched
|
|
2490
|
+
* lookup per collection per chunk instead of one query per operation). Keyed
|
|
2491
|
+
* `collection\u0000id`; null marks a record known to be absent. Only set while a
|
|
2492
|
+
* chunk is filtered.
|
|
2493
|
+
*/
|
|
2494
|
+
private recordLookupCache;
|
|
2495
|
+
private readonly deliveryHighWaterBytes;
|
|
2496
|
+
private readonly handshakeTimeoutMs;
|
|
2497
|
+
private handshakeTimer;
|
|
2498
|
+
private handshakeReceived;
|
|
2499
|
+
private readonly rateLimiterFor;
|
|
2500
|
+
private readonly appHeartbeatIntervalMs;
|
|
2501
|
+
private appHeartbeatTimer;
|
|
2502
|
+
/** Last time anything was sent to the client (a heartbeat is skipped when recent). */
|
|
2503
|
+
private lastClientSendAtMs;
|
|
1220
2504
|
/**
|
|
1221
2505
|
* Relay batches sent to this client but not yet acknowledged, keyed by messageId.
|
|
1222
2506
|
* The client acks every operation-batch it applies; on ack the entry is cleared.
|
|
@@ -1243,15 +2527,55 @@ declare class ClientSession {
|
|
|
1243
2527
|
private readonly onYjsDocUpdate;
|
|
1244
2528
|
private readonly onBlobChunkRequest;
|
|
1245
2529
|
private readonly onBlobChunkResponse;
|
|
2530
|
+
private readonly encryptionKeys;
|
|
2531
|
+
private readonly onEncryptionKeyWritten;
|
|
2532
|
+
/** Budget of key-service messages (each may cost a store read and write). */
|
|
2533
|
+
private readonly keyRateLimiter;
|
|
1246
2534
|
private readonly persistBlobChunk;
|
|
1247
2535
|
private readonly onClose;
|
|
2536
|
+
private readonly onReady;
|
|
2537
|
+
private readonly maxBlobChunkBytes;
|
|
2538
|
+
private readonly maxBlobBytesPerSession;
|
|
2539
|
+
private blobBytesPushed;
|
|
1248
2540
|
private readonly onOrphanedRelays;
|
|
1249
2541
|
private readonly takeOrphanedRelays;
|
|
1250
2542
|
private readonly maxOperationBytes;
|
|
1251
2543
|
private readonly maxOpsPerMinute;
|
|
1252
|
-
private readonly
|
|
2544
|
+
private readonly maxOpsPerBatch;
|
|
2545
|
+
private rateLimiter;
|
|
2546
|
+
/** Separate budget for blob chunk requests (RT-24). */
|
|
2547
|
+
private readonly blobRateLimiter;
|
|
2548
|
+
/** Operations refused by the rate limiter (RT-6), for diagnostics. */
|
|
2549
|
+
private rateLimitedOperations;
|
|
2550
|
+
/** Batches refused whole for exceeding {@link maxOpsPerBatch} (RT-6). */
|
|
2551
|
+
private rejectedBatches;
|
|
2552
|
+
/** Blob chunk requests answered "not held" because the session was over budget (RT-17). */
|
|
2553
|
+
private rateLimitedBlobRequests;
|
|
2554
|
+
/**
|
|
2555
|
+
* The client advertised the `sequenceReservation` handshake capability (RT-37): it
|
|
2556
|
+
* reserves sequence numbers in-transaction, so a second operation under a held
|
|
2557
|
+
* (node, sequence) is refused with SEQUENCE_CONFLICT. False for a legacy client
|
|
2558
|
+
* (Kora <= beta.12), whose duplicate pairs are stored instead.
|
|
2559
|
+
*/
|
|
2560
|
+
private sequenceReservation;
|
|
2561
|
+
/** Legacy duplicate pairs this session stored (RT-37), for diagnostics. */
|
|
2562
|
+
private legacySequencePairs;
|
|
2563
|
+
/** Protocol version the client declared in its handshake (1 when absent). */
|
|
2564
|
+
private clientProtocolVersion;
|
|
2565
|
+
private readonly authoritativeNodeIds;
|
|
2566
|
+
/** Hash version an undeclared uploaded id was verified as (RT-64), by operation object. */
|
|
2567
|
+
private readonly matchedHashVersions;
|
|
2568
|
+
/** Uploads whose data is stored as the beta.12 writer held it (RT-71). */
|
|
2569
|
+
private readonly restoredLegacyData;
|
|
2570
|
+
/** Operations accepted with an unverified legacy (beta.12) id (RT-71). */
|
|
2571
|
+
private unverifiedLegacyOperations;
|
|
2572
|
+
private forgedDuplicates;
|
|
2573
|
+
private readonly encryptionPolicy;
|
|
2574
|
+
/** Uploaded operations refused because their id is not their content hash (CORE-1). */
|
|
2575
|
+
private invalidOperationIds;
|
|
1253
2576
|
private readonly validateOperation;
|
|
1254
2577
|
private readonly koraContext;
|
|
2578
|
+
private readonly blobAccess;
|
|
1255
2579
|
constructor(options: ClientSessionOptions);
|
|
1256
2580
|
/**
|
|
1257
2581
|
* Start handling messages from the client transport.
|
|
@@ -1263,9 +2587,13 @@ declare class ClientSession {
|
|
|
1263
2587
|
*/
|
|
1264
2588
|
relayOperations(operations: Operation[]): void;
|
|
1265
2589
|
/**
|
|
1266
|
-
* Push newly-available in-scope operations to a delivery-watermark client,
|
|
1267
|
-
* from the
|
|
1268
|
-
*
|
|
2590
|
+
* Push newly-available in-scope operations to a delivery-watermark client, chained
|
|
2591
|
+
* from the send cursor (SRV-3): only what was not sent yet goes out. `rewind`
|
|
2592
|
+
* first moves the cursor back to the acknowledged position (a retransmission), and
|
|
2593
|
+
* `initial` is the handshake stream (it always ends in a final batch, even an empty
|
|
2594
|
+
* one, so the client completes initial sync). Pushes are serialized so their batches
|
|
2595
|
+
* never interleave (which would break the base/max chain); wake-ups that arrive
|
|
2596
|
+
* while one is queued coalesce into it.
|
|
1269
2597
|
*/
|
|
1270
2598
|
private pushDeliveryStream;
|
|
1271
2599
|
private relayVisibleOperations;
|
|
@@ -1275,18 +2603,63 @@ declare class ClientSession {
|
|
|
1275
2603
|
* Retransmit relay batches this client has not acknowledged within `staleMs`.
|
|
1276
2604
|
* Called on a periodic tick by the server (and directly by tests). Redelivering an
|
|
1277
2605
|
* already-applied op is harmless: the client dedups by content-addressed id.
|
|
2606
|
+
*
|
|
2607
|
+
* For a delivery-watermark client it re-sends an outstanding (sent, unacknowledged)
|
|
2608
|
+
* delivery from the acknowledged position, under the same backed-off window as the
|
|
2609
|
+
* retransmit timer; `staleMs` 0 re-sends at once (a deterministic trigger for tests).
|
|
1278
2610
|
*/
|
|
1279
2611
|
retransmitPendingRelays(staleMs?: number): void;
|
|
1280
2612
|
/**
|
|
1281
2613
|
* Wake the durable delivery stream for clients that negotiated delivery
|
|
1282
|
-
* watermarks
|
|
1283
|
-
*
|
|
1284
|
-
*
|
|
2614
|
+
* watermarks: for operations appended by another server/store instance (sent from
|
|
2615
|
+
* the send cursor), and for a delivery that went unacknowledged (re-sent from the
|
|
2616
|
+
* acknowledged position once it made no progress for `staleMs`, backed off while it
|
|
2617
|
+
* stays stuck). `staleMs` 0 re-sends at once.
|
|
2618
|
+
*
|
|
2619
|
+
* @param staleMs - Base stale window before an unacknowledged delivery is re-sent
|
|
2620
|
+
* @param options - `serverFrontier`: the store's max delivery sequence, when known;
|
|
2621
|
+
* `trackStall`: emit `sync:delivery-stalled` after repeated re-sends
|
|
1285
2622
|
*/
|
|
1286
2623
|
pushDeliveryStreamIfSupported(staleMs?: number, options?: {
|
|
1287
2624
|
trackStall?: boolean;
|
|
1288
2625
|
serverFrontier?: number;
|
|
1289
2626
|
}): void;
|
|
2627
|
+
/** Book-keeping for one sent delivery batch ending at `max`. */
|
|
2628
|
+
private recordDeliverySent;
|
|
2629
|
+
/**
|
|
2630
|
+
* A delivery acknowledgment: advance the confirmed watermark (a retransmission
|
|
2631
|
+
* rewinds to it), take a round-trip sample, and reset the backoff. The watermark
|
|
2632
|
+
* never passes what was sent: a client cannot acknowledge what it never received.
|
|
2633
|
+
*/
|
|
2634
|
+
private noteDeliveryAcked;
|
|
2635
|
+
/** Fold one round-trip sample into the retransmit timeout (RFC 6298). */
|
|
2636
|
+
private recordRoundTrip;
|
|
2637
|
+
/**
|
|
2638
|
+
* The current retransmit window: the timeout, which each re-send doubled (up to
|
|
2639
|
+
* five minutes) and which only a fresh round-trip sample brings back down.
|
|
2640
|
+
*/
|
|
2641
|
+
private retransmitWindowMs;
|
|
2642
|
+
/**
|
|
2643
|
+
* (Re)arm the retransmit timer for the outstanding delivery: it fires once no
|
|
2644
|
+
* acknowledgment progress was made for the retransmit window.
|
|
2645
|
+
*/
|
|
2646
|
+
private armRetransmitTimer;
|
|
2647
|
+
/**
|
|
2648
|
+
* No acknowledgment progress for the retransmit window: re-send from the
|
|
2649
|
+
* acknowledged position (the batch was dropped, or the client could not apply it),
|
|
2650
|
+
* unless the socket is still draining what was sent.
|
|
2651
|
+
*/
|
|
2652
|
+
private onRetransmitTimeout;
|
|
2653
|
+
/** Re-send the outstanding delivery from the acknowledged position. */
|
|
2654
|
+
private rewindDelivery;
|
|
2655
|
+
/** Bytes queued on the transport and not yet written to the network (0 if unknown). */
|
|
2656
|
+
private transportBufferedAmount;
|
|
2657
|
+
/**
|
|
2658
|
+
* Wait until the transport's queued bytes drop to the high-water mark (SRV-5
|
|
2659
|
+
* backpressure), so a slow client never makes the server buffer the whole stream
|
|
2660
|
+
* in socket memory. Resolves early when the session closes.
|
|
2661
|
+
*/
|
|
2662
|
+
private waitForSendWindow;
|
|
1290
2663
|
/**
|
|
1291
2664
|
* Close this session.
|
|
1292
2665
|
*/
|
|
@@ -1297,11 +2670,95 @@ declare class ClientSession {
|
|
|
1297
2670
|
*/
|
|
1298
2671
|
private flushOrphanedRelays;
|
|
1299
2672
|
close(reason?: string): void;
|
|
2673
|
+
/**
|
|
2674
|
+
* End this session if its verified principal matches a credential revocation
|
|
2675
|
+
* (AUTH-11): send a retriable `code` error so the client refreshes and
|
|
2676
|
+
* re-handshakes, then close. A session still authenticating remembers the
|
|
2677
|
+
* revocation and applies it as soon as its principal is known.
|
|
2678
|
+
*
|
|
2679
|
+
* @returns True when the session was closed by this call
|
|
2680
|
+
*/
|
|
2681
|
+
terminateIfMatches(filter: SessionRevocation, code: SessionTerminationCode): boolean;
|
|
2682
|
+
/**
|
|
2683
|
+
* Re-authenticate the credential that opened this session (RT-18). The session is
|
|
2684
|
+
* ended with a retriable `AUTH_REVOKED` (`AUTH_EXPIRED` once past its expiry) when
|
|
2685
|
+
* the provider now refuses the credential or resolves it to another principal, for
|
|
2686
|
+
* example after a revocation persisted by another server instance.
|
|
2687
|
+
*
|
|
2688
|
+
* @returns `'terminated'` when this call ended the session, `'error'` when the
|
|
2689
|
+
* provider threw (nothing is ended; the caller retries later), else `'valid'`
|
|
2690
|
+
*/
|
|
2691
|
+
revalidateCredential(): Promise<'valid' | 'terminated' | 'error'>;
|
|
2692
|
+
/**
|
|
2693
|
+
* True when `context` (a fresh authentication of this session's principal)
|
|
2694
|
+
* resolves to download or upload scopes other than the ones this session holds.
|
|
2695
|
+
* A grant that no longer resolves at all counts as changed.
|
|
2696
|
+
*/
|
|
2697
|
+
private scopesChangedFor;
|
|
2698
|
+
/**
|
|
2699
|
+
* End this session because an admin released its node id (RT-5). Retriable: a
|
|
2700
|
+
* client that still owns the node simply reconnects and claims it again.
|
|
2701
|
+
*/
|
|
2702
|
+
endForNodeRelease(): void;
|
|
2703
|
+
private terminate;
|
|
2704
|
+
/** Clear every timer this session owns (expiry, handshake deadline, heartbeat). */
|
|
2705
|
+
private clearSessionTimers;
|
|
2706
|
+
/**
|
|
2707
|
+
* Start the application-level heartbeat for a client that advertised support for it
|
|
2708
|
+
* (LMS #12): a `heartbeat` message whenever nothing else was sent for an interval,
|
|
2709
|
+
* so the client can detect a dead connection it cannot see (browsers never surface
|
|
2710
|
+
* WebSocket pings).
|
|
2711
|
+
*/
|
|
2712
|
+
private startAppHeartbeat;
|
|
2713
|
+
/**
|
|
2714
|
+
* Arm the expiry timer from `AuthContext.expiresAt`. Returns false when the
|
|
2715
|
+
* credential has already expired (the caller refuses the handshake).
|
|
2716
|
+
*/
|
|
2717
|
+
private armExpiryTimer;
|
|
2718
|
+
private clearExpiryTimer;
|
|
1300
2719
|
getState(): SessionState;
|
|
1301
2720
|
getSessionId(): string;
|
|
1302
2721
|
getClientNodeId(): string | null;
|
|
1303
2722
|
getAuthContext(): AuthContext | null;
|
|
2723
|
+
/**
|
|
2724
|
+
* The principal the auth provider verified at handshake, or null before (or
|
|
2725
|
+
* without) authentication. Unlike {@link getAuthContext}, never a context
|
|
2726
|
+
* synthesized for an unauthenticated session.
|
|
2727
|
+
*/
|
|
2728
|
+
getPrincipal(): AuthContext | null;
|
|
1304
2729
|
isStreaming(): boolean;
|
|
2730
|
+
/**
|
|
2731
|
+
* Ingest refusals that happen before any store work (RT-6): operations refused by
|
|
2732
|
+
* the per-minute rate limiter and batches refused for exceeding the per-batch cap.
|
|
2733
|
+
*/
|
|
2734
|
+
getIngestLimitCounts(): {
|
|
2735
|
+
rateLimitedOperations: number;
|
|
2736
|
+
rejectedBatches: number;
|
|
2737
|
+
rateLimitedBlobRequests: number;
|
|
2738
|
+
};
|
|
2739
|
+
/**
|
|
2740
|
+
* True when the client did not advertise the `sequenceReservation` capability
|
|
2741
|
+
* (RT-37): a legacy client (Kora <= beta.12) that may give two operations one
|
|
2742
|
+
* sequence number. Such a pair is stored and delivered, never refused with
|
|
2743
|
+
* SEQUENCE_CONFLICT. Meaningful once the handshake was accepted.
|
|
2744
|
+
*/
|
|
2745
|
+
isLegacySequenceClient(): boolean;
|
|
2746
|
+
/** Legacy duplicate (node, sequence) pairs this session stored (RT-37). */
|
|
2747
|
+
getLegacySequencePairCount(): number;
|
|
2748
|
+
/**
|
|
2749
|
+
* True when a stored record is inside this session's download scope, so a side
|
|
2750
|
+
* channel (for example a Yjs doc update) about it may be delivered here. A record
|
|
2751
|
+
* not stored yet is judged by its id alone.
|
|
2752
|
+
*/
|
|
2753
|
+
canReceiveRecord(collection: string, recordId: string, storedRecord: Record<string, unknown> | null): boolean;
|
|
2754
|
+
/**
|
|
2755
|
+
* A stable key for this session's download scope. Presence (awareness) is only
|
|
2756
|
+
* relayed between sessions that share exactly the same key, so it never crosses a
|
|
2757
|
+
* tenant boundary. Unscoped sessions share the key `"*"`.
|
|
2758
|
+
*/
|
|
2759
|
+
/** This session's download scope, or undefined when it is unscoped. */
|
|
2760
|
+
getDownlinkScopes(): ScopeMap | undefined;
|
|
2761
|
+
getScopePartitionKey(): string;
|
|
1305
2762
|
/**
|
|
1306
2763
|
* Get the transport for this session.
|
|
1307
2764
|
* Used by the awareness relay to send messages to this client.
|
|
@@ -1312,41 +2769,321 @@ declare class ClientSession {
|
|
|
1312
2769
|
private sendToClient;
|
|
1313
2770
|
private enqueueMessage;
|
|
1314
2771
|
private handleMessageAsync;
|
|
2772
|
+
/**
|
|
2773
|
+
* The owner of this session's encryption key records (ENC-1), or null when it may
|
|
2774
|
+
* hold none: the authenticated user, or on a server without auth the one shared
|
|
2775
|
+
* anonymous owner. An anonymous principal of a mixed provider has no stable
|
|
2776
|
+
* identity, so it holds no keys.
|
|
2777
|
+
*/
|
|
2778
|
+
getEncryptionKeyOwner(): string | null;
|
|
2779
|
+
/** Push a key record another session of the same owner wrote (rotation, new passphrase). */
|
|
2780
|
+
pushEncryptionKeyRecord(keyring: string, record: WrappedKeyRecord): void;
|
|
2781
|
+
private handleEncryptionKeyMessage;
|
|
1315
2782
|
/**
|
|
1316
2783
|
* Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
|
|
1317
2784
|
* verified to hash to the declared hash before storing, so a corrupt or
|
|
1318
2785
|
* mislabeled upload is rejected rather than served later as trusted content.
|
|
1319
2786
|
*/
|
|
1320
2787
|
private handleBlobChunkPush;
|
|
2788
|
+
/**
|
|
2789
|
+
* Claim (or re-claim) a node id for an anonymous device (RT-12, RT-21).
|
|
2790
|
+
*
|
|
2791
|
+
* A device presenting a token holds the node when the claim is (or can become)
|
|
2792
|
+
* `kora:anon-node:<hash(token)>`: its confirmed claim, a fresh claim, or its own
|
|
2793
|
+
* provisional claim, which presenting the token confirms.
|
|
2794
|
+
*
|
|
2795
|
+
* A device presenting no token gets a fresh token and a PROVISIONAL claim, which it
|
|
2796
|
+
* confirms by acknowledging the response with the token once saved (or by
|
|
2797
|
+
* presenting it next time). Until then the claim may be re-issued to a device that
|
|
2798
|
+
* again presents no token (the response was lost in transit), but only while no
|
|
2799
|
+
* session is connected as that node and before the claim expires. Legacy claims
|
|
2800
|
+
* (expired provisional ones, and nodes of the pre-release shared anonymous owner)
|
|
2801
|
+
* are re-issued only with `allowLegacyAnonymousClaims`, with a deprecation warning.
|
|
2802
|
+
*/
|
|
2803
|
+
private claimAnonymousNode;
|
|
2804
|
+
/** The deprecation warning for an anonymous device adopted under a legacy claim (RT-21, RT-91). */
|
|
2805
|
+
private warnLegacyAnonymousClaim;
|
|
2806
|
+
/**
|
|
2807
|
+
* The device proved it saved the node token issued by this handshake: make the
|
|
2808
|
+
* provisional claim permanent (RT-21). A mismatched token is ignored.
|
|
2809
|
+
*/
|
|
2810
|
+
private confirmNodeClaim;
|
|
2811
|
+
/**
|
|
2812
|
+
* The download scope references are authorized against (RT-11, RT-13), or
|
|
2813
|
+
* undefined when they are not checked: without a real auth provider there is no
|
|
2814
|
+
* tenant boundary to protect.
|
|
2815
|
+
*/
|
|
2816
|
+
private referenceScopes;
|
|
2817
|
+
/**
|
|
2818
|
+
* Stable blob-ownership key of this session's principal: the user id, or for an
|
|
2819
|
+
* anonymous device its node-claim owner (never shared by two anonymous devices).
|
|
2820
|
+
*/
|
|
2821
|
+
getBlobOwnerKey(): string;
|
|
2822
|
+
/**
|
|
2823
|
+
* Partition key for peer-to-peer blob forwarding. Sessions with the same download
|
|
2824
|
+
* scope share a tenant view, except anonymous devices: every anonymous session has
|
|
2825
|
+
* the same grant but they are unrelated people, so each is its own partition.
|
|
2826
|
+
*/
|
|
2827
|
+
getBlobPartitionKey(): string;
|
|
2828
|
+
/** Authorize foreign-key targets and blob references of an untrusted write. */
|
|
2829
|
+
private authorizeReferences;
|
|
1321
2830
|
private handleMessageFailure;
|
|
1322
2831
|
private handleHandshake;
|
|
2832
|
+
/**
|
|
2833
|
+
* The nodes whose operations after `fromDeliverySeq` this scoped session will
|
|
2834
|
+
* receive, for the handshake vector (RT-7). Scans the delivery log without holding
|
|
2835
|
+
* it (memory is O(nodes)), and judges each node only until one of its operations is
|
|
2836
|
+
* visible, so most operations cost one scope comparison and no record read.
|
|
2837
|
+
*/
|
|
2838
|
+
private collectVisibleNodeIds;
|
|
2839
|
+
/**
|
|
2840
|
+
* The version vector as committed in the store (SRV-4). A store shared by several
|
|
2841
|
+
* instances reads it fresh; others serve their own (complete) vector.
|
|
2842
|
+
*/
|
|
2843
|
+
private readServerVector;
|
|
2844
|
+
/**
|
|
2845
|
+
* Resolve this session's download and upload scopes from an auth context and the
|
|
2846
|
+
* scope the client asked for at handshake. Side-effect free, so revalidation can
|
|
2847
|
+
* recompute it and compare (RT-26). The legacy `scopes` contract remains shorthand
|
|
2848
|
+
* for both directions.
|
|
2849
|
+
*/
|
|
2850
|
+
private computeSessionScopes;
|
|
1323
2851
|
private handleOperationBatch;
|
|
1324
|
-
private transformForServerSchema;
|
|
1325
|
-
private sendDelta;
|
|
1326
2852
|
/**
|
|
1327
|
-
*
|
|
2853
|
+
* For each delete of this session's node in an upload batch, the batch's later
|
|
2854
|
+
* operations of the same node that name it as a causal parent: candidates for the
|
|
2855
|
+
* author's own copies of its cascades and set-nulls (RT-69). Indexed by the delete's
|
|
2856
|
+
* id, in the server's schema (field names as the server derives effects).
|
|
2857
|
+
*/
|
|
2858
|
+
private indexAuthoredSideEffectCopies;
|
|
2859
|
+
/**
|
|
2860
|
+
* The referential effects of a delete that arrived again as a stored duplicate and
|
|
2861
|
+
* are still undone (RT-73): effects the author covers later in this batch are
|
|
2862
|
+
* deferred like a fresh delete's (derived after the batch unless a copy is stored);
|
|
2863
|
+
* the rest are derived now. Derived ids are deterministic, so a concurrent retry or
|
|
2864
|
+
* another instance stores the same operations.
|
|
2865
|
+
*
|
|
2866
|
+
* @returns The derived operations (to relay)
|
|
2867
|
+
*/
|
|
2868
|
+
private resumeStoredDeleteEffects;
|
|
2869
|
+
/**
|
|
2870
|
+
* Derive the server's copy of each deferred side effect unless one of the author's
|
|
2871
|
+
* copies is stored (applied or already held). A store with the batch lookup is asked
|
|
2872
|
+
* again, so a copy stored by a concurrent session counts too.
|
|
2873
|
+
*/
|
|
2874
|
+
private deriveUncoveredSideEffects;
|
|
2875
|
+
/** {@link ServerStore.findStoredOperations} by id; empty without it or on a failed read. */
|
|
2876
|
+
private findStoredOperationIds;
|
|
2877
|
+
/**
|
|
2878
|
+
* The operation as the server schema reads it (transforms at fold time, RT-84): the
|
|
2879
|
+
* view authorization, validators, constraint checks and scope filters judge, and the
|
|
2880
|
+
* one the server stores fold (core `operationSchemaView`, same transforms). Never
|
|
2881
|
+
* stored: the store keeps the operation as uploaded. An envelope operation is opaque
|
|
2882
|
+
* to the server (NEW-ENC-1) and judged as is; devices transform after decryption.
|
|
2883
|
+
*/
|
|
2884
|
+
private schemaView;
|
|
2885
|
+
/**
|
|
2886
|
+
* Integrity checks on an uploaded operation before anything else judges it:
|
|
2887
|
+
* - a version-2 plaintext id must be the content hash (`INVALID_OPERATION_ID`);
|
|
2888
|
+
* - an unknown declared hash version is refused the same way;
|
|
2889
|
+
* - with required encryption, a data-bearing plaintext operation is refused
|
|
2890
|
+
* (`PLAINTEXT_REJECTED`) unless the plaintext migration window is open.
|
|
2891
|
+
*
|
|
2892
|
+
* @returns null when the operation passes, or the refusal
|
|
2893
|
+
*/
|
|
2894
|
+
private checkUploadIntegrity;
|
|
2895
|
+
/**
|
|
2896
|
+
* Whether an undeclared version-1 id that does not verify is accepted unverified
|
|
2897
|
+
* (RT-71): only from a protocol-1 session (Kora <= beta.12), and only for the
|
|
2898
|
+
* session's own node.
|
|
2899
|
+
*/
|
|
2900
|
+
private acceptsUnverifiedLegacyId;
|
|
2901
|
+
/** Log, count and emit an operation accepted with an unverified legacy id (RT-71). */
|
|
2902
|
+
private reportUnverifiedLegacyOperation;
|
|
2903
|
+
/** Operations this session accepted with an unverified legacy id (RT-71). */
|
|
2904
|
+
getUnverifiedLegacyOperationCount(): number;
|
|
2905
|
+
/**
|
|
2906
|
+
* The operation as the server stores it: an undeclared plaintext id this server
|
|
2907
|
+
* verified declares the version it matched (1, or 2 when the declaration was lost),
|
|
2908
|
+
* so receivers verify it too (RT-64). An absent version is left only on ids nobody
|
|
2909
|
+
* could verify (protocol-1 encrypted payloads, server-side transforms).
|
|
2910
|
+
*/
|
|
2911
|
+
private declareVerifiedHashVersion;
|
|
2912
|
+
/**
|
|
2913
|
+
* True for a node id that is (or was) the server's own: the `kora:` namespace, the
|
|
2914
|
+
* store's node id, and every id the server advertises or folds as authoritative
|
|
2915
|
+
* (other instances, legacy server node ids, configured extras). No device may use one
|
|
2916
|
+
* (RT-61), whether or not it has history yet.
|
|
2917
|
+
*/
|
|
2918
|
+
private isServerAuthorNodeId;
|
|
2919
|
+
/**
|
|
2920
|
+
* The authoritative ids a handshake advertises (RT-75): only explicit ones (legacy
|
|
2921
|
+
* server node ids, configured extras). Every `kora:server:` id is authoritative by
|
|
2922
|
+
* prefix on every replica, and listing instance ids would make the list differ per
|
|
2923
|
+
* instance and per start, which devices would have to treat as news.
|
|
2924
|
+
*/
|
|
2925
|
+
/** The handshake's explicit revocations (RT-81), when the deployment has any. */
|
|
2926
|
+
private advertisedRevocations;
|
|
2927
|
+
private advertisedAuthoritativeNodeIds;
|
|
2928
|
+
/** Node ids this server authors operations under (protocol v2 handshake response). */
|
|
2929
|
+
private serverAuthoritativeNodeIds;
|
|
2930
|
+
/**
|
|
2931
|
+
* A protocol-1 client (Kora <= beta.12) is served for one release (beta.13) with a
|
|
2932
|
+
* deprecation warning: its operation ids are version-1 hashes and its encrypted
|
|
2933
|
+
* payloads have no envelope binding.
|
|
2934
|
+
*/
|
|
2935
|
+
private warnLegacyProtocol;
|
|
2936
|
+
/** The in-scope operations a version-vector client is missing (not yet sent). */
|
|
2937
|
+
private collectDeltaOperations;
|
|
2938
|
+
/** Send a collected version-vector delta in paginated, cursor-carrying batches. */
|
|
2939
|
+
private sendCollectedDelta;
|
|
2940
|
+
/**
|
|
2941
|
+
* Stream the gap-free server->client delivery stream, resuming just after
|
|
1328
2942
|
* `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
|
|
1329
2943
|
* (commit order), scope-filtered for this session, and sent in batches that chain:
|
|
1330
2944
|
* each batch's `baseDeliverySequence` equals the previous batch's
|
|
1331
2945
|
* `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
|
|
1332
2946
|
* applies a batch only when its watermark equals the base and advances the
|
|
1333
2947
|
* watermark to the max, so a dropped batch stalls the watermark and is recovered by
|
|
1334
|
-
* the
|
|
1335
|
-
* (a dependency is always committed, and thus sequenced, before its
|
|
1336
|
-
* topological sort is needed
|
|
1337
|
-
*
|
|
2948
|
+
* a re-send from the acknowledged position. Because delivery-sequence order respects
|
|
2949
|
+
* causal order (a dependency is always committed, and thus sequenced, before its
|
|
2950
|
+
* dependent), no topological sort is needed.
|
|
2951
|
+
*
|
|
2952
|
+
* Batches go out as each scan chunk is filtered (SRV-5): the server holds at most
|
|
2953
|
+
* one scan chunk plus two batches per stream, never the whole backlog, and the
|
|
2954
|
+
* first batch reaches the client after one chunk. One full batch is held back so
|
|
2955
|
+
* the stream's last batch can be marked `isFinal` (one-batch lookahead). The final
|
|
2956
|
+
* batch carries the highest sequence scanned, not merely the last in-scope one, so
|
|
2957
|
+
* an out-of-scope tail is not re-scanned. Every sent batch advances the send cursor
|
|
2958
|
+
* ({@link lastSentDeliverySeq}). Between batches the stream waits for the client to
|
|
2959
|
+
* drain its socket (backpressure).
|
|
1338
2960
|
*
|
|
1339
|
-
* The
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
2961
|
+
* @param fromDeliverySeq - The chain base: the client's watermark or the send cursor
|
|
2962
|
+
* @param finalizeWhenEmpty - Send an empty final batch even when nothing new was
|
|
2963
|
+
* scanned (the handshake stream, so the client completes initial sync)
|
|
2964
|
+
*/
|
|
2965
|
+
private streamDelivery;
|
|
2966
|
+
/**
|
|
2967
|
+
* Filter one scan chunk of the delivery log down to what this session receives:
|
|
2968
|
+
* visible operations (each preceded by its scope-entry insert, RT-19) and
|
|
2969
|
+
* retractions. The records the decisions need are fetched for the whole chunk at
|
|
2970
|
+
* once (LMS #11), one batched read per collection instead of one query per op.
|
|
1342
2971
|
*/
|
|
1343
|
-
private
|
|
2972
|
+
private filterDeliveryChunk;
|
|
2973
|
+
/**
|
|
2974
|
+
* Read, in one batched query per collection, every record the visibility decisions
|
|
2975
|
+
* for these operations may look up, when the store supports batched reads. Returns
|
|
2976
|
+
* null (per-operation lookups) otherwise. Over-fetching is harmless: the chunk
|
|
2977
|
+
* bounds it.
|
|
2978
|
+
*/
|
|
2979
|
+
private prefetchRecordsFor;
|
|
1344
2980
|
/** Build, track, and send one chained delivery-stream batch. */
|
|
1345
2981
|
private sendDeliveryBatch;
|
|
2982
|
+
/** The scope snapshots of these operations, when the store keeps them (RT-14). */
|
|
2983
|
+
private scopeSnapshotsFor;
|
|
2984
|
+
/**
|
|
2985
|
+
* Download visibility of one operation. With a scope snapshot (RT-14) the
|
|
2986
|
+
* operation is judged on the record's scope values right after it was applied, so
|
|
2987
|
+
* history written while a record belonged to someone else stays hidden after an
|
|
2988
|
+
* ownership transfer, and a previous owner still receives its own history. Query
|
|
2989
|
+
* subsets (a client-chosen narrowing, not an authorization) keep using the current
|
|
2990
|
+
* row. Operations without a snapshot (legacy rows, custom stores) fall back to the
|
|
2991
|
+
* current row plus `op.data`.
|
|
2992
|
+
*/
|
|
1346
2993
|
private operationVisibleToClient;
|
|
1347
|
-
/**
|
|
1348
|
-
|
|
2994
|
+
/**
|
|
2995
|
+
* Where the store already holds each of `operations` (by id), in one store read.
|
|
2996
|
+
* Null when the store cannot answer by id (a custom store without
|
|
2997
|
+
* `findStoredOperations`, or the read failed): callers then fall back to a
|
|
2998
|
+
* per-operation (node, sequence) lookup and the store's own dedup at apply.
|
|
2999
|
+
*/
|
|
3000
|
+
private findStoredOperations;
|
|
3001
|
+
/**
|
|
3002
|
+
* True when the batch lookup alone shows `op` is already stored and may be
|
|
3003
|
+
* acknowledged as a duplicate with no further store work (RT-31, RT-39): this
|
|
3004
|
+
* device's own operation stored under its node (its sequence repair may have
|
|
3005
|
+
* renumbered it, keeping the id), or any other operation stored under exactly its
|
|
3006
|
+
* (node, sequence, id), such as one the server itself delivered and the client
|
|
3007
|
+
* echoed back. Never true for a store without the batch lookup.
|
|
3008
|
+
*/
|
|
3009
|
+
private isStoredDuplicate;
|
|
3010
|
+
/**
|
|
3011
|
+
* The operation the store holds under `op`'s id (at the node and sequence the batch
|
|
3012
|
+
* lookup found), or null when it cannot be read (the upload is then judged like any
|
|
3013
|
+
* other: verified, validated, charged, and deduplicated by the store at apply).
|
|
3014
|
+
*/
|
|
3015
|
+
private loadStoredOperation;
|
|
3016
|
+
/**
|
|
3017
|
+
* Refuse an upload that reuses a stored operation's id with different content (RT-77):
|
|
3018
|
+
* terminal, nothing recorded or derived, logged and emitted as tampering. An honest
|
|
3019
|
+
* client never does this (ids are content hashes), so the rejection only reaches a
|
|
3020
|
+
* forger. No resolution is recorded: the id belongs to the stored operation.
|
|
3021
|
+
*/
|
|
3022
|
+
private refuseForgedDuplicate;
|
|
3023
|
+
/** Uploads refused for reusing a stored id with other content (RT-77). */
|
|
3024
|
+
getForgedDuplicateCount(): number;
|
|
3025
|
+
/**
|
|
3026
|
+
* The recorded resolutions of the batch's own-node operations that the lookup did
|
|
3027
|
+
* not find stored (RT-43, RT-47). Asked only for this session's own node, so the
|
|
3028
|
+
* answer never reveals another device's (or tenant's) refusals.
|
|
3029
|
+
*/
|
|
3030
|
+
private findResolutions;
|
|
3031
|
+
/** The highest resolved sequence of `nodeId` (0 without the store method or on error). */
|
|
3032
|
+
private resolvedThrough;
|
|
3033
|
+
/**
|
|
3034
|
+
* Durably record how this session's own operation was resolved without being stored
|
|
3035
|
+
* under its sequence. Awaited before the answer is sent: a failure propagates (the
|
|
3036
|
+
* batch fails and is not acknowledged), so the client never acts on a resolution the
|
|
3037
|
+
* server could forget.
|
|
3038
|
+
*/
|
|
3039
|
+
private recordResolution;
|
|
3040
|
+
/**
|
|
3041
|
+
* Refuse an operation for good (non-retriable): remember the refusal (RT-47), then
|
|
3042
|
+
* tell the client. A resubmission of the same id is answered with this rejection
|
|
3043
|
+
* without being judged again.
|
|
3044
|
+
*/
|
|
3045
|
+
private refuseTerminally;
|
|
3046
|
+
/**
|
|
3047
|
+
* An own operation acknowledged as a duplicate of the copy stored under ANOTHER
|
|
3048
|
+
* sequence (the client's sequence repair renumbered it, keeping its id): record the
|
|
3049
|
+
* submitted sequence as resolved, so the handshake does not read it as lost (RT-43).
|
|
3050
|
+
*/
|
|
3051
|
+
private noteStoredElsewhere;
|
|
3052
|
+
/** Refuse the rest of a batch for the per-minute ingest budget (retriable). */
|
|
3053
|
+
private sendRateLimited;
|
|
3054
|
+
/**
|
|
3055
|
+
* True when the store already holds exactly this operation (same node, sequence and
|
|
3056
|
+
* id). Answered from the batch lookup when the store supports it.
|
|
3057
|
+
*/
|
|
3058
|
+
private isStoredOperation;
|
|
3059
|
+
/**
|
|
3060
|
+
* The scopes this session may write under. Fails closed: with auth configured but
|
|
3061
|
+
* no accepted authentication, the session may write nothing (an empty scope map),
|
|
3062
|
+
* never everything.
|
|
3063
|
+
*/
|
|
3064
|
+
private uplinkScopes;
|
|
3065
|
+
/**
|
|
3066
|
+
* Upload authorization, independent from the client's downloaded/query view. The
|
|
3067
|
+
* stored row (including a soft-deleted one) is always loaded when scopes apply and
|
|
3068
|
+
* is authoritative; client-supplied previousData is never consulted.
|
|
3069
|
+
*/
|
|
3070
|
+
private authorizeClientOperation;
|
|
3071
|
+
/**
|
|
3072
|
+
* True when this update moved the record out of the session's scope, judged on the
|
|
3073
|
+
* store's own before/after values (RT-15), never on the writer's previousData. An
|
|
3074
|
+
* operation without a snapshot never produces a retraction (fail quiet: the
|
|
3075
|
+
* client keeps what it already had, and no other tenant's record id leaks).
|
|
3076
|
+
*/
|
|
1349
3077
|
private scopeRetractionFor;
|
|
3078
|
+
/**
|
|
3079
|
+
* The scope-entry operation to send before `op` when `op` moved an existing record
|
|
3080
|
+
* into this session's download scope (RT-19), or null. Judged on the store's own
|
|
3081
|
+
* pre/post snapshot; built from the record's CURRENT row, and only while that row
|
|
3082
|
+
* is live and still inside the scope (a record that has since left again, or was
|
|
3083
|
+
* deleted, gets no entry: its later operations retract or delete it anyway).
|
|
3084
|
+
* Operations without a snapshot (legacy rows, custom stores) never produce one.
|
|
3085
|
+
*/
|
|
3086
|
+
private scopeEntryFor;
|
|
1350
3087
|
/**
|
|
1351
3088
|
* Read a record's current field values from the materialized store for scope /
|
|
1352
3089
|
* query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
|
|
@@ -1355,7 +3092,15 @@ declare class ClientSession {
|
|
|
1355
3092
|
*/
|
|
1356
3093
|
private lookupRecordFields;
|
|
1357
3094
|
private handleAwarenessUpdate;
|
|
3095
|
+
/**
|
|
3096
|
+
* A Yjs doc-channel update is a write to a richtext field: the sender must be
|
|
3097
|
+
* allowed to write the stored record (same rule as operation uploads). The stored
|
|
3098
|
+
* row is handed to the relay so delivery can be limited to sessions whose download
|
|
3099
|
+
* scope contains the record.
|
|
3100
|
+
*/
|
|
1358
3101
|
private handleYjsDocUpdate;
|
|
3102
|
+
/** A legacy client's duplicate pair was stored (RT-37): count it and log it. */
|
|
3103
|
+
private recordLegacySequencePair;
|
|
1359
3104
|
private sendError;
|
|
1360
3105
|
/**
|
|
1361
3106
|
* Reject one specific client operation, tied to its id, so the submitter can
|
|
@@ -1364,7 +3109,6 @@ declare class ClientSession {
|
|
|
1364
3109
|
* replica ever sees it.
|
|
1365
3110
|
*/
|
|
1366
3111
|
private sendOperationRejected;
|
|
1367
|
-
private setSerializerWireFormat;
|
|
1368
3112
|
private handleTransportClose;
|
|
1369
3113
|
}
|
|
1370
3114
|
|
|
@@ -1406,6 +3150,25 @@ interface ProductionServerConfig {
|
|
|
1406
3150
|
* backupToken.
|
|
1407
3151
|
*/
|
|
1408
3152
|
operationalAuth?: ProductionOperationalAuth;
|
|
3153
|
+
/**
|
|
3154
|
+
* Reverse proxies trusted to report the client address in `X-Forwarded-For`,
|
|
3155
|
+
* which becomes `request.ip` (the key `@korajs/auth` rate-limits sign-in by).
|
|
3156
|
+
* A hop count (`1` for a single load balancer in front) or a list of trusted
|
|
3157
|
+
* proxy IPs / CIDR ranges. When unset, the header is ignored and `request.ip`
|
|
3158
|
+
* is the socket address, so a client cannot pick its own rate-limit bucket.
|
|
3159
|
+
*/
|
|
3160
|
+
trustProxy?: TrustProxySetting;
|
|
3161
|
+
/**
|
|
3162
|
+
* Largest request body a custom HTTP route (`httpRoutes`) accepts, in bytes. A
|
|
3163
|
+
* larger body is refused with 413 before it is buffered (SRV-6), so an
|
|
3164
|
+
* unauthenticated client cannot make the server hold arbitrary amounts of memory.
|
|
3165
|
+
* Defaults to 1 MiB.
|
|
3166
|
+
*/
|
|
3167
|
+
maxRequestBodyBytes?: number;
|
|
3168
|
+
/**
|
|
3169
|
+
* Largest backup accepted by `/__kora/backup/import`, in bytes. Defaults to 256 MiB.
|
|
3170
|
+
*/
|
|
3171
|
+
maxBackupBytes?: number;
|
|
1409
3172
|
}
|
|
1410
3173
|
interface ProductionOperationalAuth {
|
|
1411
3174
|
/** Protects /__kora, /__kora/status, and /__kora/events. */
|
|
@@ -1444,7 +3207,10 @@ interface ProductionHttpRoute {
|
|
|
1444
3207
|
* A production server handle returned by createProductionServer.
|
|
1445
3208
|
*/
|
|
1446
3209
|
interface ProductionServer {
|
|
1447
|
-
/**
|
|
3210
|
+
/**
|
|
3211
|
+
* Start listening. Returns the URL the server is available at, with the port it
|
|
3212
|
+
* actually bound (pass `port: 0` to let the OS pick a free one).
|
|
3213
|
+
*/
|
|
1448
3214
|
start(): Promise<string>;
|
|
1449
3215
|
/** Stop the server gracefully. */
|
|
1450
3216
|
stop(): Promise<void>;
|
|
@@ -1496,6 +3262,10 @@ interface TokenAuthProviderOptions {
|
|
|
1496
3262
|
/**
|
|
1497
3263
|
* Validate a token and return an AuthContext if valid, or null if rejected.
|
|
1498
3264
|
* This is where you implement your auth logic (JWT verification, database lookup, etc.).
|
|
3265
|
+
*
|
|
3266
|
+
* Return `scopes` to grant sync access explicitly. When the returned context
|
|
3267
|
+
* has no scopes, schema-scoped collections are bound to `{ userId }` and any
|
|
3268
|
+
* collection scoped by another key is denied.
|
|
1499
3269
|
*/
|
|
1500
3270
|
validate: (token: string) => Promise<AuthContext | null>;
|
|
1501
3271
|
}
|
|
@@ -1528,11 +3298,13 @@ interface TokenValidator {
|
|
|
1528
3298
|
sub: string;
|
|
1529
3299
|
dev: string;
|
|
1530
3300
|
type: string;
|
|
3301
|
+
exp?: number;
|
|
1531
3302
|
} | null;
|
|
1532
3303
|
validateTokenWithRevocation?(token: string): Promise<{
|
|
1533
3304
|
sub: string;
|
|
1534
3305
|
dev: string;
|
|
1535
3306
|
type: string;
|
|
3307
|
+
exp?: number;
|
|
1536
3308
|
} | null>;
|
|
1537
3309
|
}
|
|
1538
3310
|
/**
|
|
@@ -1575,8 +3347,15 @@ interface KoraAuthProviderOptions {
|
|
|
1575
3347
|
*/
|
|
1576
3348
|
deviceTracker?: DeviceToucher;
|
|
1577
3349
|
/**
|
|
1578
|
-
* Optional scope resolver. Called with the user ID to determine
|
|
1579
|
-
* which collections/records the user can sync.
|
|
3350
|
+
* Optional scope resolver. Called with the verified user ID to determine
|
|
3351
|
+
* which collections/records the user can sync. Its result is the complete
|
|
3352
|
+
* server grant: a collection it omits is not visible, and the client
|
|
3353
|
+
* handshake can only narrow it. Use `claimScopes({ userId, orgId })` from
|
|
3354
|
+
* `@korajs/core` to bind every schema-scoped collection from verified values.
|
|
3355
|
+
*
|
|
3356
|
+
* When omitted, the grant binds every schema-scoped collection from
|
|
3357
|
+
* `{ userId: <verified sub> }`; collections scoped by any other key are denied
|
|
3358
|
+
* until you supply it.
|
|
1580
3359
|
*/
|
|
1581
3360
|
resolveScopes?: (userId: string) => Promise<Record<string, Record<string, unknown>>>;
|
|
1582
3361
|
}
|
|
@@ -1630,7 +3409,9 @@ interface MixedAuthProviderOptions {
|
|
|
1630
3409
|
*/
|
|
1631
3410
|
primary: AuthProvider;
|
|
1632
3411
|
/**
|
|
1633
|
-
* Scopes to apply to anonymous connections.
|
|
3412
|
+
* Scopes to apply to anonymous connections. This is the complete anonymous
|
|
3413
|
+
* grant: anonymous sessions can sync exactly these collections, and the
|
|
3414
|
+
* client handshake can only narrow them, never add one.
|
|
1634
3415
|
* Each key is a collection name; the value is a filter object
|
|
1635
3416
|
* (use `{}` for unrestricted access to that collection).
|
|
1636
3417
|
*
|
|
@@ -1654,10 +3435,10 @@ interface MixedAuthProviderOptions {
|
|
|
1654
3435
|
/**
|
|
1655
3436
|
* Auth provider that supports both authenticated and anonymous connections.
|
|
1656
3437
|
*
|
|
1657
|
-
* When a client connects with a
|
|
1658
|
-
*
|
|
1659
|
-
* (
|
|
1660
|
-
*
|
|
3438
|
+
* When a client connects with a token, the primary auth provider decides:
|
|
3439
|
+
* a valid token authenticates normally and an invalid, expired or revoked one
|
|
3440
|
+
* is rejected (the client refreshes and reconnects). Only a client that sends
|
|
3441
|
+
* no token at all is accepted as anonymous, with exactly `anonymousScopes`.
|
|
1661
3442
|
*
|
|
1662
3443
|
* This is the recommended pattern for apps that need public data access
|
|
1663
3444
|
* alongside authenticated users — for example, a form builder where
|
|
@@ -1698,9 +3479,24 @@ declare class MixedAuthProvider implements AuthProvider {
|
|
|
1698
3479
|
private readonly anonymousPrefix;
|
|
1699
3480
|
private anonymousCounter;
|
|
1700
3481
|
constructor(options: MixedAuthProviderOptions);
|
|
1701
|
-
|
|
3482
|
+
/** The primary provider's revocation feed, when it has one. */
|
|
3483
|
+
onRevoke?: AuthProvider['onRevoke'];
|
|
3484
|
+
authenticate(token: string): Promise<AuthContext | null>;
|
|
1702
3485
|
}
|
|
1703
3486
|
|
|
3487
|
+
/** Options for {@link MemoryServerStore}. */
|
|
3488
|
+
interface MemoryServerStoreOptions {
|
|
3489
|
+
/**
|
|
3490
|
+
* Node ids, besides this store's own, whose operations win
|
|
3491
|
+
* `merge('server-authoritative')` fields (legacy server node ids). Every
|
|
3492
|
+
* `kora:server:` node id is authoritative without being listed.
|
|
3493
|
+
*/
|
|
3494
|
+
authoritativeNodeIds?: string[];
|
|
3495
|
+
/** Revoked explicit authorities (see `ServerIdentityOptions.revokedAuthoritativeNodeIds`). */
|
|
3496
|
+
revokedAuthoritativeNodeIds?: string[];
|
|
3497
|
+
/** Instance id within the deployment (see `ServerIdentityOptions.instanceId`). */
|
|
3498
|
+
instanceId?: string;
|
|
3499
|
+
}
|
|
1704
3500
|
/**
|
|
1705
3501
|
* In-memory server store for testing and quick prototyping.
|
|
1706
3502
|
* Not suitable for production — data does not survive process restart.
|
|
@@ -1712,6 +3508,13 @@ declare class MemoryServerStore implements ServerStore {
|
|
|
1712
3508
|
private readonly nodeId;
|
|
1713
3509
|
private readonly operations;
|
|
1714
3510
|
private readonly operationIndex;
|
|
3511
|
+
/** (nodeId, sequenceNumber) -> the id of the operation holding it (W3 step 4). */
|
|
3512
|
+
/** (node, sequence) -> ids holding it, in store order (several only for a legacy pair). */
|
|
3513
|
+
private readonly operationIdsBySequence;
|
|
3514
|
+
/** Node -> sequences held by more than one operation (legacy pairs, RT-48). */
|
|
3515
|
+
private readonly pairSequencesByNode;
|
|
3516
|
+
/** Operation id -> how it was resolved without being stored (RT-43, RT-47). */
|
|
3517
|
+
private readonly resolutions;
|
|
1715
3518
|
private readonly versionVector;
|
|
1716
3519
|
/**
|
|
1717
3520
|
* Server-assigned delivery sequence per operation id. Single-process, so a plain
|
|
@@ -1722,24 +3525,117 @@ declare class MemoryServerStore implements ServerStore {
|
|
|
1722
3525
|
private readonly deliverySeqByOpId;
|
|
1723
3526
|
private deliverySeqCounter;
|
|
1724
3527
|
private schema;
|
|
3528
|
+
/** Node id -> the authenticated principal that claimed it (see claimNode). */
|
|
3529
|
+
private readonly nodeOwners;
|
|
3530
|
+
/** Operation id -> scope snapshot captured at apply time (RT-14). */
|
|
3531
|
+
private readonly scopeSnapshots;
|
|
3532
|
+
/** Fields the current snapshots were captured with (RT-20). */
|
|
3533
|
+
private snapshotFingerprint;
|
|
3534
|
+
/** Blob content hash -> owners that pushed or first claimed it (RT-11). */
|
|
3535
|
+
private readonly blobOwners;
|
|
3536
|
+
/** Wrapped encryption key records (ENC-1): owner + keyring -> record JSON and revision. */
|
|
3537
|
+
private readonly encryptionKeyRecords;
|
|
1725
3538
|
/** Materialized records: collection -> recordId -> record data */
|
|
1726
3539
|
private readonly materializedRecords;
|
|
3540
|
+
/** Fold state per materialized record (W7): rows are projected from it. */
|
|
3541
|
+
private readonly foldStates;
|
|
3542
|
+
/** Every operation of a record, in store (delivery) order. */
|
|
3543
|
+
private readonly recordOps;
|
|
3544
|
+
private readonly authoritativeNodeIds;
|
|
3545
|
+
/** Legacy and configured authorities (the `kora:server:` prefix needs no listing). */
|
|
3546
|
+
private readonly explicitAuthorities;
|
|
3547
|
+
/** Explicit authority over time: revoked and ever-held ids (RT-81). */
|
|
3548
|
+
private readonly authorityHistory;
|
|
3549
|
+
private foldOptions;
|
|
3550
|
+
/** Schema transforms the fold applies (transforms at fold time, RT-84). */
|
|
3551
|
+
private operationTransforms;
|
|
1727
3552
|
private closed;
|
|
1728
|
-
|
|
3553
|
+
/**
|
|
3554
|
+
* Sequence-enforcement epoch (see SEQUENCE_ENFORCEMENT_EPOCH_KEY). A memory store
|
|
3555
|
+
* starts empty under this release, so 0: every holder was stored under enforcement.
|
|
3556
|
+
* A replace-mode backup import moves it to the restored log's end.
|
|
3557
|
+
*/
|
|
3558
|
+
private sequenceEpoch;
|
|
3559
|
+
/** Derivation secret of server-derived ids (RT-64). Per process: nothing persists. */
|
|
3560
|
+
private readonly derivationSecret;
|
|
3561
|
+
/**
|
|
3562
|
+
* @param nodeId - Deprecated: a plain id is recorded as a legacy authoritative id; a
|
|
3563
|
+
* `kora:server:` id is used verbatim. Leave unset: the store authors under
|
|
3564
|
+
* `kora:server:<deployment>:<instance>` (a fresh deployment per process, since a
|
|
3565
|
+
* memory store persists nothing).
|
|
3566
|
+
* @param options - Extra authoritative ids and the instance id
|
|
3567
|
+
*/
|
|
3568
|
+
constructor(nodeId?: string, options?: MemoryServerStoreOptions);
|
|
3569
|
+
deriveServerOperationId(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
|
|
3570
|
+
getAuthoritativeNodeIds(): string[];
|
|
3571
|
+
/** Explicit authoritative ids the deployment revoked (RT-81), advertised at handshake. */
|
|
3572
|
+
getRevokedAuthoritativeNodeIds(): string[];
|
|
3573
|
+
/**
|
|
3574
|
+
* Every explicit id the deployment ever held authoritative, revoked ones included
|
|
3575
|
+
* (RT-81): never accepted as a device node id.
|
|
3576
|
+
*/
|
|
3577
|
+
getEverAuthoritativeNodeIds(): string[];
|
|
1729
3578
|
getVersionVector(): VersionVector;
|
|
1730
3579
|
getNodeId(): string;
|
|
1731
3580
|
getSchema(): SchemaDefinition | null;
|
|
1732
|
-
setSchema(schema: SchemaDefinition): Promise<void>;
|
|
1733
|
-
|
|
3581
|
+
setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
|
|
3582
|
+
getOperationTransforms(): readonly OperationTransform[];
|
|
3583
|
+
setOperationTransforms(transforms: readonly OperationTransform[]): Promise<void>;
|
|
3584
|
+
applyRemoteOperation(op: Operation, options?: ApplyRemoteOptions): Promise<ApplyResult>;
|
|
3585
|
+
private addSequenceHolder;
|
|
3586
|
+
getSequencePairOperations(nodeId: string, throughSequence: number): Promise<Operation[]>;
|
|
3587
|
+
recordOperationResolution(resolution: OperationResolution): Promise<void>;
|
|
3588
|
+
findOperationResolutions(nodeId: string, ids: string[]): Promise<Map<string, OperationResolution>>;
|
|
3589
|
+
deleteOperationResolution(nodeId: string, operationId: string): Promise<void>;
|
|
3590
|
+
getResolvedThrough(nodeId: string): Promise<number>;
|
|
3591
|
+
findStoredOperations(ids: string[]): Promise<Map<string, StoredOperationKey>>;
|
|
3592
|
+
getOperationScopeSnapshots(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
|
|
3593
|
+
getRecordLatestTimestamp(collection: string, recordId: string): Promise<HLCTimestamp | null>;
|
|
3594
|
+
getRecordFieldVersions(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
|
|
3595
|
+
getRecordFoldState(collection: string, recordId: string): Promise<FoldState | null>;
|
|
3596
|
+
getRecordOperations(collection: string, recordId: string): Promise<Operation[]>;
|
|
3597
|
+
previewOperation(op: Operation): Promise<MaterializedRecord | null>;
|
|
3598
|
+
/**
|
|
3599
|
+
* The record's fold state: the stored one for a materialized collection, else
|
|
3600
|
+
* folded from the record's operations (a collection outside the schema).
|
|
3601
|
+
*/
|
|
3602
|
+
private readFoldState;
|
|
3603
|
+
private addRecordOp;
|
|
3604
|
+
recordBlobOwner(hash: string, owner: string): Promise<void>;
|
|
3605
|
+
getBlobOwners(hashes: string[]): Promise<Map<string, string[]>>;
|
|
3606
|
+
claimBlobIfUnowned(hash: string, owner: string): Promise<boolean>;
|
|
3607
|
+
/** Scope values of a live record before a write; null when absent or deleted. */
|
|
3608
|
+
private scopeValuesBefore;
|
|
3609
|
+
/**
|
|
3610
|
+
* Rebuild missing scope snapshots from the log (migration from a store without
|
|
3611
|
+
* them, or a replace-mode backup import), replaying each record in commit order.
|
|
3612
|
+
*/
|
|
3613
|
+
private backfillScopeSnapshots;
|
|
1734
3614
|
getOperationRange(nodeId: string, fromSeq: number, toSeq: number): Promise<Operation[]>;
|
|
1735
3615
|
getOperationCount(): Promise<number>;
|
|
1736
3616
|
getMaxDeliverySequence(): Promise<number>;
|
|
1737
3617
|
getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
|
|
1738
3618
|
materializeCollection(collection: string): Promise<MaterializedRecord[]>;
|
|
3619
|
+
getNodeIdsAfterDelivery(afterDeliverySequence: number): Promise<string[]>;
|
|
3620
|
+
findRecordsByIds(collection: string, ids: string[]): Promise<Map<string, MaterializedRecord>>;
|
|
1739
3621
|
queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
|
|
1740
3622
|
findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
|
|
1741
3623
|
countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
|
|
1742
3624
|
close(): Promise<void>;
|
|
3625
|
+
getEncryptionKeyRecord(owner: string, keyring: string): Promise<string | null>;
|
|
3626
|
+
putEncryptionKeyRecord(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
|
|
3627
|
+
listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
|
|
3628
|
+
getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
|
|
3629
|
+
claimNode(nodeId: string, userId: string): Promise<boolean>;
|
|
3630
|
+
getNodeClaimOwner(nodeId: string): Promise<string | null>;
|
|
3631
|
+
replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
|
|
3632
|
+
releaseNodeClaim(nodeId: string): Promise<boolean>;
|
|
3633
|
+
/**
|
|
3634
|
+
* The record as currently stored, including a soft-deleted one (whose last field
|
|
3635
|
+
* values are kept), or null when it was never written. Without a materialized
|
|
3636
|
+
* table, the last known values are replayed from the operation log.
|
|
3637
|
+
*/
|
|
3638
|
+
private readStoredRow;
|
|
1743
3639
|
/**
|
|
1744
3640
|
* Wipes all in-memory state. For tests and E2E isolation only.
|
|
1745
3641
|
*/
|
|
@@ -1753,7 +3649,16 @@ declare class MemoryServerStore implements ServerStore {
|
|
|
1753
3649
|
* Get all stored operations (for test assertions).
|
|
1754
3650
|
*/
|
|
1755
3651
|
getAllOperations(): Operation[];
|
|
1756
|
-
|
|
3652
|
+
/**
|
|
3653
|
+
* Merge one just-appended operation into its record's fold state and project the
|
|
3654
|
+
* row: O(fields the operation touches), independent of the record's history
|
|
3655
|
+
* (SRV-7). Falls back to a full re-fold only when the stored state cannot take the
|
|
3656
|
+
* operation (a field's fold kind changed with the schema).
|
|
3657
|
+
*/
|
|
3658
|
+
private mergeIntoRecord;
|
|
3659
|
+
/** Persist a record's fold state and project it onto its materialized row. */
|
|
3660
|
+
private writeRecord;
|
|
3661
|
+
/** Re-fold every record of every materialized collection (schema set, backup restore). */
|
|
1757
3662
|
private backfillAllCollections;
|
|
1758
3663
|
private materializeFromOps;
|
|
1759
3664
|
private assertOpen;
|
|
@@ -1761,6 +3666,123 @@ declare class MemoryServerStore implements ServerStore {
|
|
|
1761
3666
|
private assertCollection;
|
|
1762
3667
|
}
|
|
1763
3668
|
|
|
3669
|
+
/**
|
|
3670
|
+
* Server materialization through the one deterministic fold (W7 Stage B2).
|
|
3671
|
+
*
|
|
3672
|
+
* Every server store (memory, SQLite, Postgres) keeps, per materialized record, the
|
|
3673
|
+
* record's serialized {@link FoldState} next to its collection row. Applying an
|
|
3674
|
+
* operation is "append, then `mergeOp`": O(fields the operation touches), never a
|
|
3675
|
+
* replay of the record's history (SRV-7). The collection row is a pure projection of
|
|
3676
|
+
* the fold state ({@link projectFoldState}), so the server's rows are exactly what a
|
|
3677
|
+
* client that holds the same operations materializes (SRV-1).
|
|
3678
|
+
*
|
|
3679
|
+
* The fold is pure JavaScript: ordering uses HLC stamps compared in JS, never a
|
|
3680
|
+
* database collation (the Phase 1 COLLATE "C" concern disappears for materialization).
|
|
3681
|
+
*
|
|
3682
|
+
* Old helpers (`replayOperationsForRecord`, `mergeArraySet`) are no longer used to
|
|
3683
|
+
* materialize; they stay exported from core for legacy comparison only.
|
|
3684
|
+
*/
|
|
3685
|
+
|
|
3686
|
+
/** What a store's startup re-materialization did (W7 step 7). */
|
|
3687
|
+
interface FoldMigrationReport {
|
|
3688
|
+
/** False when no schema was set (nothing is materialized). */
|
|
3689
|
+
ran: boolean;
|
|
3690
|
+
/** Records re-folded and rewritten. */
|
|
3691
|
+
records: number;
|
|
3692
|
+
/**
|
|
3693
|
+
* Records that own quarantined operations (see `ServerLogIntegrityReport`): their log
|
|
3694
|
+
* is incomplete, so their pre-fold rows were kept as a snapshot base with the
|
|
3695
|
+
* remaining operations folded onto it (RT-70), never re-folded from the log alone.
|
|
3696
|
+
*/
|
|
3697
|
+
skippedUnclean: number;
|
|
3698
|
+
/** True when the fold plan (field kinds, strategies, resolvers) changed: every record was re-folded. */
|
|
3699
|
+
fullRefold: boolean;
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
/** A row the scan quarantined. */
|
|
3703
|
+
interface ServerLogIntegrityRow {
|
|
3704
|
+
operationId: string;
|
|
3705
|
+
problem: string;
|
|
3706
|
+
detail: string;
|
|
3707
|
+
}
|
|
3708
|
+
/** Result of the server scan. */
|
|
3709
|
+
interface ServerLogIntegrityReport {
|
|
3710
|
+
/** Rows checked by this scan (0 when it already ran on this database). */
|
|
3711
|
+
checkedRows: number;
|
|
3712
|
+
/** Rows this scan moved to the quarantine table. */
|
|
3713
|
+
quarantined: ServerLogIntegrityRow[];
|
|
3714
|
+
/** False when the one-time scan had already run (only the quarantine is reported). */
|
|
3715
|
+
ran: boolean;
|
|
3716
|
+
/** Every row in the quarantine table. */
|
|
3717
|
+
totalQuarantined: number;
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
/**
|
|
3721
|
+
* Stable server identity (RT-61, RT-62, RT-64).
|
|
3722
|
+
*
|
|
3723
|
+
* Every operation the server authors (route writes, cascades, set-nulls, constraint
|
|
3724
|
+
* corrections) is authored by a node id in the reserved namespace
|
|
3725
|
+
* `kora:server:<deploymentId>:<instanceId>`:
|
|
3726
|
+
*
|
|
3727
|
+
* - `deploymentId` is generated once per database and persisted in `kora_server_meta`,
|
|
3728
|
+
* so every instance that shares the database (and every restart) belongs to one
|
|
3729
|
+
* deployment.
|
|
3730
|
+
* - `instanceId` is distinct per running instance, so two instances never author the
|
|
3731
|
+
* same `(nodeId, sequenceNumber)`: SQLite persists one (a SQLite database has one
|
|
3732
|
+
* writer process), Postgres allocates a fresh one from a counter in the database on
|
|
3733
|
+
* every start unless the operator configures a stable `instanceId` per instance.
|
|
3734
|
+
*
|
|
3735
|
+
* Authority (`merge('server-authoritative')`) is the prefix rule: any operation whose
|
|
3736
|
+
* node id starts with {@link SERVER_NODE_PREFIX} is authoritative, on the server and on
|
|
3737
|
+
* every device. Devices can never use a `kora:` node id (the session refuses it at
|
|
3738
|
+
* handshake). Server node ids written before this release (random per-process ids, or
|
|
3739
|
+
* an id the operator configured) are recorded once, at the first start of this release,
|
|
3740
|
+
* in a persisted list of legacy authoritative ids that the handshake keeps advertising,
|
|
3741
|
+
* so earlier server decisions keep winning everywhere.
|
|
3742
|
+
*
|
|
3743
|
+
* Server-derived operation ids (cascades, corrections) are keyed with a persisted,
|
|
3744
|
+
* deployment-wide secret (HMAC-SHA-256), so a writer cannot predict them and occupy one
|
|
3745
|
+
* first; every instance of the deployment derives the same id for the same effect.
|
|
3746
|
+
*/
|
|
3747
|
+
|
|
3748
|
+
/** Prefix of every node id the server authors under. */
|
|
3749
|
+
declare const SERVER_NODE_PREFIX = "kora:server:";
|
|
3750
|
+
/** Thrown for an invalid server identity configuration. */
|
|
3751
|
+
declare class ServerIdentityError extends KoraError {
|
|
3752
|
+
constructor(message: string, context?: Record<string, unknown>);
|
|
3753
|
+
}
|
|
3754
|
+
/** Identity options every server store accepts. */
|
|
3755
|
+
interface ServerIdentityOptions {
|
|
3756
|
+
/**
|
|
3757
|
+
* Deprecated. A plain id (for example one set before beta.13) is no longer the id the
|
|
3758
|
+
* server authors under: it is recorded as a legacy authoritative id, so operations
|
|
3759
|
+
* already stored under it keep their authority, and no device may use it. An id in
|
|
3760
|
+
* the `kora:server:` namespace is used verbatim (it must then be unique per instance).
|
|
3761
|
+
*/
|
|
3762
|
+
nodeId?: string;
|
|
3763
|
+
/**
|
|
3764
|
+
* Stable id of this instance within the deployment (`[A-Za-z0-9._-]`, at most 64
|
|
3765
|
+
* characters). Must be distinct per running instance. SQLite persists one on its own;
|
|
3766
|
+
* Postgres allocates a new one per start when it is not configured.
|
|
3767
|
+
*/
|
|
3768
|
+
instanceId?: string;
|
|
3769
|
+
/**
|
|
3770
|
+
* Extra node ids whose operations win `merge('server-authoritative')` fields. Every id
|
|
3771
|
+
* listed here once stays authoritative (devices keep it too) until it is revoked with
|
|
3772
|
+
* {@link revokedAuthoritativeNodeIds}: removing it from this list does not revoke it.
|
|
3773
|
+
*/
|
|
3774
|
+
authoritativeNodeIds?: string[];
|
|
3775
|
+
/**
|
|
3776
|
+
* Explicit authoritative node ids to revoke (RT-81): they stop being authoritative on
|
|
3777
|
+
* the server, the handshake tells every device to drop them (devices re-fold), and they
|
|
3778
|
+
* are still never accepted as device node ids. Revocation is permanent (persisted); an
|
|
3779
|
+
* id may not be both listed in `authoritativeNodeIds` and revoked.
|
|
3780
|
+
*/
|
|
3781
|
+
revokedAuthoritativeNodeIds?: string[];
|
|
3782
|
+
}
|
|
3783
|
+
/** True for a node id the server authors under (current or any instance of any deployment). */
|
|
3784
|
+
declare function isServerNodeId(nodeId: string): boolean;
|
|
3785
|
+
|
|
1764
3786
|
/**
|
|
1765
3787
|
* PostgreSQL-backed server store using Drizzle ORM.
|
|
1766
3788
|
* All reads and writes go through Drizzle's typed query builder.
|
|
@@ -1769,7 +3791,18 @@ declare class MemoryServerStore implements ServerStore {
|
|
|
1769
3791
|
* collection tables for efficient indexed queries (dual-write).
|
|
1770
3792
|
*/
|
|
1771
3793
|
declare class PostgresServerStore implements ServerStore {
|
|
1772
|
-
|
|
3794
|
+
/** This instance's server node id; loaded from the database during startup (RT-62). */
|
|
3795
|
+
private loadedNodeId;
|
|
3796
|
+
private readonly configuredIdentity;
|
|
3797
|
+
/** Derivation secret of server-derived ids, shared by the deployment (RT-64). */
|
|
3798
|
+
private derivationSecret;
|
|
3799
|
+
/** Legacy and configured authorities (the `kora:server:` prefix needs no listing). */
|
|
3800
|
+
private explicitAuthorities;
|
|
3801
|
+
/** Explicit authority over time: revoked and ever-held ids (RT-81). */
|
|
3802
|
+
private authorityHistory;
|
|
3803
|
+
/** Other `kora:server:` nodes with stored operations (advertised for older clients). */
|
|
3804
|
+
/** Records owning quarantined operations: folded onto their kept rows (RT-70). */
|
|
3805
|
+
private quarantine;
|
|
1773
3806
|
private readonly db;
|
|
1774
3807
|
private readonly versionVector;
|
|
1775
3808
|
private readonly ready;
|
|
@@ -1783,6 +3816,9 @@ declare class PostgresServerStore implements ServerStore {
|
|
|
1783
3816
|
* null after a backup import so it re-seeds from the restored version vector.
|
|
1784
3817
|
*/
|
|
1785
3818
|
private sequenceCounter;
|
|
3819
|
+
/** See {@link SEQUENCE_ENFORCEMENT_EPOCH_KEY}; fixed per database at first start. */
|
|
3820
|
+
private sequenceEpoch;
|
|
3821
|
+
private logIntegrity;
|
|
1786
3822
|
/**
|
|
1787
3823
|
* Time source for HLC timestamps on server-originated operations. Injectable so a
|
|
1788
3824
|
* test can freeze wall-clock time and prove the conditional-apply ordering holds
|
|
@@ -1790,7 +3826,54 @@ declare class PostgresServerStore implements ServerStore {
|
|
|
1790
3826
|
* exist to make correct). Defaults to the system clock in production.
|
|
1791
3827
|
*/
|
|
1792
3828
|
private readonly timeSource;
|
|
1793
|
-
|
|
3829
|
+
private foldOptions;
|
|
3830
|
+
/** Schema transforms the fold applies (transforms at fold time, RT-84). */
|
|
3831
|
+
private operationTransforms;
|
|
3832
|
+
private foldMigration;
|
|
3833
|
+
/**
|
|
3834
|
+
* @param db - The Drizzle database
|
|
3835
|
+
* @param nodeId - Deprecated: a plain id is recorded as a legacy authoritative id; a
|
|
3836
|
+
* `kora:server:` id is used verbatim (it must then be unique per instance). Leave
|
|
3837
|
+
* unset: the store authors under `kora:server:<deployment>:<instance>`, with the
|
|
3838
|
+
* deployment persisted in the database and a distinct instance per process (RT-62).
|
|
3839
|
+
* @param timeSource - Clock for server-originated operations (tests)
|
|
3840
|
+
* @param options - Extra authoritative ids and a stable instance id
|
|
3841
|
+
*/
|
|
3842
|
+
constructor(db: PostgresJsDatabase, nodeId?: string, timeSource?: TimeSource, options?: Omit<ServerIdentityOptions, 'nodeId'>);
|
|
3843
|
+
/** Resolves once the store's tables and identity are loaded. */
|
|
3844
|
+
whenReady(): Promise<void>;
|
|
3845
|
+
/**
|
|
3846
|
+
* This store's node id and the legacy and configured ids (explicit authorities).
|
|
3847
|
+
* Other `kora:server:` instances are not listed: the prefix rule makes every one of
|
|
3848
|
+
* them authoritative, and a list that grew by one instance id per start would make
|
|
3849
|
+
* every device re-fold after each deploy (RT-75). Handshakes advertise only the
|
|
3850
|
+
* explicit (non-prefixed) ids.
|
|
3851
|
+
*/
|
|
3852
|
+
getAuthoritativeNodeIds(): string[];
|
|
3853
|
+
/** Legacy and configured authorities (beside the `kora:server:` namespace). */
|
|
3854
|
+
getLegacyAuthoritativeNodeIds(): string[];
|
|
3855
|
+
/** Explicit authoritative ids the deployment revoked (RT-81), advertised at handshake. */
|
|
3856
|
+
getRevokedAuthoritativeNodeIds(): string[];
|
|
3857
|
+
/**
|
|
3858
|
+
* Every explicit id the deployment ever held authoritative, revoked ones included
|
|
3859
|
+
* (RT-81): never accepted as a device node id.
|
|
3860
|
+
*/
|
|
3861
|
+
getEverAuthoritativeNodeIds(): string[];
|
|
3862
|
+
deriveServerOperationId(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
|
|
3863
|
+
private get nodeId();
|
|
3864
|
+
/** Remember another `kora:server:` node that authored stored operations. */
|
|
3865
|
+
/**
|
|
3866
|
+
* Load (creating on first start) the persisted server identity under an advisory
|
|
3867
|
+
* lock: the deployment id and derivation secret (one per database, shared by every
|
|
3868
|
+
* instance), a fresh instance id from the database counter (unless configured), and
|
|
3869
|
+
* the legacy authoritative ids. At the first start of this release, every node id
|
|
3870
|
+
* whose operations hold an authority class in a stored fold state (a server decision
|
|
3871
|
+
* folded under an earlier, per-process server node id) is recorded as a legacy
|
|
3872
|
+
* authority, before any re-fold could drop that class.
|
|
3873
|
+
*/
|
|
3874
|
+
private loadIdentity;
|
|
3875
|
+
/** What the last startup re-materialization did (W7 step 7). */
|
|
3876
|
+
getFoldMigrationReport(): FoldMigrationReport;
|
|
1794
3877
|
getVersionVector(): VersionVector;
|
|
1795
3878
|
/**
|
|
1796
3879
|
* Atomically reserve this node's next server-operation sequence number. The
|
|
@@ -1800,8 +3883,51 @@ declare class PostgresServerStore implements ServerStore {
|
|
|
1800
3883
|
reserveSequenceNumber(): number;
|
|
1801
3884
|
getNodeId(): string;
|
|
1802
3885
|
getSchema(): SchemaDefinition | null;
|
|
1803
|
-
|
|
1804
|
-
|
|
3886
|
+
/**
|
|
3887
|
+
* One-time migration (RT-101): drop the enum `CHECK` constraints (and any `NOT NULL`
|
|
3888
|
+
* on a schema field) that beta.12 and earlier DDL put on collection tables, in one
|
|
3889
|
+
* transaction. A check is dropped when it parses as an enum check (one column against
|
|
3890
|
+
* string literals, in every form Postgres normalizes `IN (...)` to, the single-value
|
|
3891
|
+
* `col = 'x'` included, RT-108) on an enum field of the schema, or as the multi-value
|
|
3892
|
+
* `= ANY (ARRAY[...])` shape on another non-internal column; checks added by hand are
|
|
3893
|
+
* kept. Idempotent and resumable: a later start finds nothing to drop.
|
|
3894
|
+
*/
|
|
3895
|
+
private relaxValueDomainConstraints;
|
|
3896
|
+
/**
|
|
3897
|
+
* Throws {@link OperationTransformCoverageError} when a schema version in the stored
|
|
3898
|
+
* log has no transform path to `version` (RT-103): those operations would fold as
|
|
3899
|
+
* absent, silently erasing them from their records.
|
|
3900
|
+
*/
|
|
3901
|
+
private assertTransformCoverage;
|
|
3902
|
+
getOperationTransforms(): readonly OperationTransform[];
|
|
3903
|
+
setOperationTransforms(transforms: readonly OperationTransform[]): Promise<void>;
|
|
3904
|
+
setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
|
|
3905
|
+
/**
|
|
3906
|
+
* One-time migration of each collection table to the lossless text encoding (RT-65):
|
|
3907
|
+
* rows written before it that contain U+FFFF (the escape introducer) are re-encoded,
|
|
3908
|
+
* so the decoder returns them unchanged. Per table, under an advisory lock, recorded
|
|
3909
|
+
* in `kora_server_meta`.
|
|
3910
|
+
*/
|
|
3911
|
+
private migrateTextCodec;
|
|
3912
|
+
applyRemoteOperation(op: Operation, options?: ApplyRemoteOptions): Promise<ApplyResult>;
|
|
3913
|
+
/**
|
|
3914
|
+
* The version vector as committed in `sync_state`, shared by every instance
|
|
3915
|
+
* (SRV-4). Also refreshes this instance's cache, which `getVersionVector()` serves.
|
|
3916
|
+
*/
|
|
3917
|
+
readVersionVector(): Promise<VersionVector>;
|
|
3918
|
+
/**
|
|
3919
|
+
* Insert one operation row inside an append transaction that already holds the
|
|
3920
|
+
* delivery-counter lock. Returns `'duplicate'` (writing nothing) when an operation
|
|
3921
|
+
* with the same id is stored; throws {@link SequenceConflictError} when a DIFFERENT
|
|
3922
|
+
* operation stored after the sequence-enforcement epoch holds its (node, sequence)
|
|
3923
|
+
* and the writer reserves its sequences (W3 step 4); a legacy holder or a legacy
|
|
3924
|
+
* writer (RT-37) is accepted and the row is stored unflagged. Otherwise returns the
|
|
3925
|
+
* verdict the row was stored under. The partial unique index over sole-holder rows
|
|
3926
|
+
* backs the check against a writer that bypasses the counter lock.
|
|
3927
|
+
*/
|
|
3928
|
+
private insertOperationRow;
|
|
3929
|
+
/** Advance `sync_state` (the persisted version vector) for an appended operation. */
|
|
3930
|
+
private advanceSyncState;
|
|
1805
3931
|
/**
|
|
1806
3932
|
* Conditionally apply operations atomically, serialized across server instances
|
|
1807
3933
|
* on the target record via a transaction-scoped advisory lock. The lock makes
|
|
@@ -1813,7 +3939,37 @@ declare class PostgresServerStore implements ServerStore {
|
|
|
1813
3939
|
getOperationCount(): Promise<number>;
|
|
1814
3940
|
getMaxDeliverySequence(): Promise<number>;
|
|
1815
3941
|
getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
|
|
3942
|
+
getOperationScopeSnapshots(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
|
|
3943
|
+
getRecordLatestTimestamp(collection: string, recordId: string): Promise<HLCTimestamp | null>;
|
|
3944
|
+
getRecordFieldVersions(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
|
|
3945
|
+
getRecordFoldState(collection: string, recordId: string): Promise<FoldState | null>;
|
|
3946
|
+
getRecordOperations(collection: string, recordId: string): Promise<Operation[]>;
|
|
3947
|
+
previewOperation(op: Operation): Promise<MaterializedRecord | null>;
|
|
3948
|
+
recordBlobOwner(hash: string, owner: string): Promise<void>;
|
|
3949
|
+
getBlobOwners(hashes: string[]): Promise<Map<string, string[]>>;
|
|
3950
|
+
claimBlobIfUnowned(hash: string, owner: string): Promise<boolean>;
|
|
3951
|
+
/**
|
|
3952
|
+
* Scope values of a record as stored, for an operation's scope snapshot. With
|
|
3953
|
+
* `includeDeleted` false a soft-deleted row counts as absent; with true it keeps
|
|
3954
|
+
* its last values (a delete's post-image).
|
|
3955
|
+
*/
|
|
3956
|
+
private readScopeValues;
|
|
3957
|
+
/** Persist the scope snapshot of a just-applied operation (RT-14). */
|
|
3958
|
+
private writeScopeSnapshot;
|
|
3959
|
+
/**
|
|
3960
|
+
* Rebuild missing scope snapshots from the log (migration from a database written
|
|
3961
|
+
* before snapshots existed), replaying each affected record in commit order.
|
|
3962
|
+
*/
|
|
3963
|
+
private backfillScopeSnapshots;
|
|
1816
3964
|
materializeCollection(collection: string): Promise<MaterializedRecord[]>;
|
|
3965
|
+
getNodeIdsAfterDelivery(afterDeliverySequence: number): Promise<string[]>;
|
|
3966
|
+
getSequencePairOperations(nodeId: string, throughSequence: number): Promise<Operation[]>;
|
|
3967
|
+
recordOperationResolution(resolution: OperationResolution): Promise<void>;
|
|
3968
|
+
findOperationResolutions(nodeId: string, ids: string[]): Promise<Map<string, OperationResolution>>;
|
|
3969
|
+
deleteOperationResolution(nodeId: string, operationId: string): Promise<void>;
|
|
3970
|
+
getResolvedThrough(nodeId: string): Promise<number>;
|
|
3971
|
+
findStoredOperations(ids: string[]): Promise<Map<string, StoredOperationKey>>;
|
|
3972
|
+
findRecordsByIds(collection: string, ids: string[]): Promise<Map<string, MaterializedRecord>>;
|
|
1817
3973
|
queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
|
|
1818
3974
|
findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
|
|
1819
3975
|
countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
|
|
@@ -1830,28 +3986,105 @@ declare class PostgresServerStore implements ServerStore {
|
|
|
1830
3986
|
* write so newly built operations sort strictly after it.
|
|
1831
3987
|
*/
|
|
1832
3988
|
private latestOperationTimestamp;
|
|
3989
|
+
getEncryptionKeyRecord(owner: string, keyring: string): Promise<string | null>;
|
|
3990
|
+
putEncryptionKeyRecord(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
|
|
3991
|
+
listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
|
|
3992
|
+
getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
|
|
3993
|
+
claimNode(nodeId: string, userId: string): Promise<boolean>;
|
|
3994
|
+
getNodeClaimOwner(nodeId: string): Promise<string | null>;
|
|
3995
|
+
replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
|
|
3996
|
+
releaseNodeClaim(nodeId: string): Promise<boolean>;
|
|
1833
3997
|
/**
|
|
1834
|
-
*
|
|
1835
|
-
*
|
|
3998
|
+
* The record as currently stored, including a soft-deleted one (whose last field
|
|
3999
|
+
* values are kept), or null when it was never written. Without a materialized
|
|
4000
|
+
* table, the last known values are replayed from the operation log.
|
|
4001
|
+
*/
|
|
4002
|
+
private readStoredRow;
|
|
4003
|
+
/**
|
|
4004
|
+
* Merge a just-appended operation (stored under `deliverySeq`) into its record's
|
|
4005
|
+
* fold state and project the row, inside the append transaction. O(fields the
|
|
4006
|
+
* operation touches): the state is read by primary key and the freshness check is
|
|
4007
|
+
* one index probe (SRV-7).
|
|
4008
|
+
*
|
|
4009
|
+
* Concurrency: every append on every instance holds the delivery-counter row lock
|
|
4010
|
+
* until commit (see nextDeliverySeq), and applyConditional takes its per-record
|
|
4011
|
+
* advisory lock before it, so this read-merge-write of one record's state is
|
|
4012
|
+
* serialized across instances; the `FOR UPDATE` on the state row is defense in
|
|
4013
|
+
* depth. A record whose log grew without this state (an older release during a
|
|
4014
|
+
* rolling upgrade) has its missing tail merged first; an unreadable, newer-than-log
|
|
4015
|
+
* or plan-incompatible state is re-folded from the log.
|
|
1836
4016
|
*/
|
|
1837
|
-
private
|
|
4017
|
+
private mergeIntoRecord;
|
|
4018
|
+
/**
|
|
4019
|
+
* The fold state of a record that owns quarantined operations (RT-70): its stored
|
|
4020
|
+
* state, or its kept row as a snapshot base, with every remaining operation of the
|
|
4021
|
+
* record merged on top (`rebuildFromSnapshot`). Nothing is written.
|
|
4022
|
+
*/
|
|
4023
|
+
private rebuildAffected;
|
|
4024
|
+
/** True when the record's operation at delivery sequence `seq` has id `opId`. */
|
|
4025
|
+
private coversStoredOperation;
|
|
4026
|
+
/**
|
|
4027
|
+
* The record's current fold state for a read: the stored one when it covers the
|
|
4028
|
+
* record's whole log, else folded from the log (nothing is written).
|
|
4029
|
+
*/
|
|
4030
|
+
private readFoldState;
|
|
4031
|
+
/** A record's operations stored after `afterDeliverySeq`, in delivery order. */
|
|
4032
|
+
private readRecordOperations;
|
|
4033
|
+
/**
|
|
4034
|
+
* Persist a record's fold state (covering the log up to `coveredSeq`) and project
|
|
4035
|
+
* it onto the materialized row.
|
|
4036
|
+
*/
|
|
4037
|
+
private writeFoldedRecord;
|
|
1838
4038
|
/**
|
|
1839
4039
|
* UPSERT a record into the materialized collection table.
|
|
1840
4040
|
*/
|
|
1841
4041
|
private upsertMaterializedRecord;
|
|
1842
4042
|
/**
|
|
1843
|
-
*
|
|
4043
|
+
* {@link writeFoldedRecord} for a batch of records of one collection, in a handful
|
|
4044
|
+
* of multi-row statements (the migration's write path). Rows are chunked so a
|
|
4045
|
+
* statement never nears the 65,535 bind-parameter limit, however wide the collection.
|
|
1844
4046
|
*/
|
|
1845
|
-
private
|
|
4047
|
+
private writeFoldedRecords;
|
|
1846
4048
|
/**
|
|
1847
|
-
*
|
|
4049
|
+
* Re-materialization migration (W7 step 7), run by setSchema and after a
|
|
4050
|
+
* replace-mode restore: every record whose fold state is missing, stale or built
|
|
4051
|
+
* under a different fold plan is re-folded from its log; nothing else is touched.
|
|
4052
|
+
* See the SQLite store's `rematerialize` for the rules (a record owning quarantined
|
|
4053
|
+
* operations folds onto its kept row, RT-70; idempotent; resumable).
|
|
4054
|
+
*
|
|
4055
|
+
* Postgres specifics: records are paged in batches of
|
|
4056
|
+
* {@link FOLD_MIGRATION_BATCH} through the (collection, record_id, delivery_seq)
|
|
4057
|
+
* index, and every batch is one transaction that first takes the delivery-counter
|
|
4058
|
+
* row lock. Appends on every instance take that lock too, so a batch reads each
|
|
4059
|
+
* record's complete log and writes its state while no append to it can commit: a
|
|
4060
|
+
* live write on another instance during a rolling deploy is never overwritten by a
|
|
4061
|
+
* stale re-fold (NEW-SRV-5), it either committed before the batch (and is folded)
|
|
4062
|
+
* or commits after it (and is merged into the batch's state).
|
|
4063
|
+
*/
|
|
4064
|
+
/**
|
|
4065
|
+
* Write the beta.12 clears the ids prove into the stored bodies, once per database
|
|
4066
|
+
* (RT-85, see `provenLegacyClears`), and mark their records' fold states stale.
|
|
4067
|
+
* Idempotent: concurrent first starts write the same bodies.
|
|
1848
4068
|
*/
|
|
1849
|
-
private
|
|
4069
|
+
private canonicalizeLegacyBodies;
|
|
4070
|
+
private rematerialize;
|
|
1850
4071
|
private buildSelectQuery;
|
|
1851
4072
|
private buildWhereClause;
|
|
1852
4073
|
private deserializeRow;
|
|
1853
4074
|
private materializeFromOpsLog;
|
|
1854
4075
|
private initialize;
|
|
4076
|
+
/**
|
|
4077
|
+
* The startup log-integrity scan's result (W8 step 0): rows that could not be read
|
|
4078
|
+
* back into an operation are moved to `operations_quarantine` once per database,
|
|
4079
|
+
* before any fold reads them.
|
|
4080
|
+
*/
|
|
4081
|
+
getLogIntegrityReport(): Promise<ServerLogIntegrityReport>;
|
|
4082
|
+
/**
|
|
4083
|
+
* One-time scan under the setup advisory lock (concurrent starts run it once). Keyset
|
|
4084
|
+
* pages by id, so a large log is read in bounded chunks; like the delivery-sequence
|
|
4085
|
+
* backfill it runs on the first start of this release only.
|
|
4086
|
+
*/
|
|
4087
|
+
private scanLogIntegrityOnce;
|
|
1855
4088
|
private ensureTables;
|
|
1856
4089
|
/**
|
|
1857
4090
|
* Reserve the next delivery sequence inside an open append transaction. The
|
|
@@ -1859,6 +4092,11 @@ declare class PostgresServerStore implements ServerStore {
|
|
|
1859
4092
|
* serializing assignment into commit order (see the counter table comment).
|
|
1860
4093
|
*/
|
|
1861
4094
|
private nextDeliverySeq;
|
|
4095
|
+
/**
|
|
4096
|
+
* @param soleHolder - Nothing else held the (node, sequence) at insert: the row is
|
|
4097
|
+
* flagged `seq_unique` and covered by {@link NODE_SEQ_UNIQUE_INDEX}. False for a
|
|
4098
|
+
* legacy pair's second operation and for restored backup rows.
|
|
4099
|
+
*/
|
|
1862
4100
|
private serializeOperation;
|
|
1863
4101
|
private deserializeOperation;
|
|
1864
4102
|
private assertOpen;
|
|
@@ -1870,8 +4108,14 @@ declare class PostgresServerStore implements ServerStore {
|
|
|
1870
4108
|
*/
|
|
1871
4109
|
declare function createPostgresServerStore(options: {
|
|
1872
4110
|
connectionString: string;
|
|
1873
|
-
|
|
1874
|
-
|
|
4111
|
+
} & ServerIdentityOptions): Promise<PostgresServerStore>;
|
|
4112
|
+
/**
|
|
4113
|
+
* The Postgres server store could not start: the database is unreachable, refused the
|
|
4114
|
+
* credentials, or its tables could not be created. `cause` is the driver's error.
|
|
4115
|
+
*/
|
|
4116
|
+
declare class ServerStoreUnavailableError extends KoraError {
|
|
4117
|
+
constructor(connectionString: string, cause: unknown);
|
|
4118
|
+
}
|
|
1875
4119
|
|
|
1876
4120
|
/**
|
|
1877
4121
|
* SQLite-backed server store using Drizzle ORM.
|
|
@@ -1886,46 +4130,206 @@ declare class SqliteServerStore implements ServerStore {
|
|
|
1886
4130
|
private readonly db;
|
|
1887
4131
|
private schema;
|
|
1888
4132
|
private closed;
|
|
1889
|
-
|
|
4133
|
+
/** See {@link SEQUENCE_ENFORCEMENT_EPOCH_KEY}; fixed per database at first start. */
|
|
4134
|
+
private sequenceEpoch;
|
|
4135
|
+
private logIntegrity;
|
|
4136
|
+
/** Derivation secret of server-derived ids, shared by the deployment (RT-64). */
|
|
4137
|
+
private readonly derivationSecret;
|
|
4138
|
+
/** Legacy and configured authorities (the `kora:server:` prefix needs no listing). */
|
|
4139
|
+
private readonly explicitAuthorities;
|
|
4140
|
+
/** Explicit authority over time: revoked and ever-held ids (RT-81). */
|
|
4141
|
+
private readonly authorityHistory;
|
|
4142
|
+
/** Other `kora:server:` nodes with stored operations (advertised for older clients). */
|
|
4143
|
+
/** Records owning quarantined operations: folded onto their kept rows (RT-70). */
|
|
4144
|
+
private quarantine;
|
|
4145
|
+
private foldOptions;
|
|
4146
|
+
/** Schema transforms the fold applies (transforms at fold time, RT-84). */
|
|
4147
|
+
private operationTransforms;
|
|
4148
|
+
private foldMigration;
|
|
4149
|
+
/**
|
|
4150
|
+
* @param db - The Drizzle database
|
|
4151
|
+
* @param nodeId - Deprecated: a plain id is recorded as a legacy authoritative id; a
|
|
4152
|
+
* `kora:server:` id is used verbatim. Leave unset: the store authors under
|
|
4153
|
+
* `kora:server:<deployment>:<instance>`, both persisted in the database (RT-62).
|
|
4154
|
+
* @param options - Extra authoritative ids and the instance id
|
|
4155
|
+
*/
|
|
4156
|
+
constructor(db: BetterSQLite3Database, nodeId?: string, options?: Omit<ServerIdentityOptions, 'nodeId'>);
|
|
4157
|
+
/**
|
|
4158
|
+
* This store's node id and the legacy and configured ids (explicit authorities).
|
|
4159
|
+
* Other `kora:server:` instances are not listed: the prefix rule makes every one of
|
|
4160
|
+
* them authoritative, and a list that grew by one instance id per start would make
|
|
4161
|
+
* every device re-fold after each deploy (RT-75). Handshakes advertise only the
|
|
4162
|
+
* explicit (non-prefixed) ids.
|
|
4163
|
+
*/
|
|
4164
|
+
getAuthoritativeNodeIds(): string[];
|
|
4165
|
+
/** Legacy and configured authorities (beside the `kora:server:` namespace). */
|
|
4166
|
+
getLegacyAuthoritativeNodeIds(): string[];
|
|
4167
|
+
/** Explicit authoritative ids the deployment revoked (RT-81), advertised at handshake. */
|
|
4168
|
+
getRevokedAuthoritativeNodeIds(): string[];
|
|
4169
|
+
/**
|
|
4170
|
+
* Every explicit id the deployment ever held authoritative, revoked ones included
|
|
4171
|
+
* (RT-81): never accepted as a device node id.
|
|
4172
|
+
*/
|
|
4173
|
+
getEverAuthoritativeNodeIds(): string[];
|
|
4174
|
+
deriveServerOperationId(parentOpId: string, ruleId: string, targetRecordId: string): Promise<string>;
|
|
4175
|
+
/** Remember another `kora:server:` node that authored stored operations. */
|
|
4176
|
+
/**
|
|
4177
|
+
* Load (creating on first start) the persisted server identity: the deployment id,
|
|
4178
|
+
* the derivation secret, this database's instance id and the legacy authoritative
|
|
4179
|
+
* ids. At the first start of this release, every node id whose operations hold an
|
|
4180
|
+
* authority class in a stored fold state (a server decision folded under an earlier,
|
|
4181
|
+
* per-process server node id) is recorded as a legacy authority, before any re-fold
|
|
4182
|
+
* could drop that class.
|
|
4183
|
+
*/
|
|
4184
|
+
private loadIdentity;
|
|
4185
|
+
/** What the last startup re-materialization did (W7 step 7). */
|
|
4186
|
+
getFoldMigrationReport(): FoldMigrationReport;
|
|
1890
4187
|
getVersionVector(): VersionVector;
|
|
1891
4188
|
getNodeId(): string;
|
|
1892
4189
|
getSchema(): SchemaDefinition | null;
|
|
1893
|
-
setSchema(schema: SchemaDefinition): Promise<void>;
|
|
1894
|
-
|
|
4190
|
+
setSchema(schema: SchemaDefinition, options?: ServerSchemaOptions): Promise<void>;
|
|
4191
|
+
/**
|
|
4192
|
+
* One-time migration (RT-101): rebuild collection tables that still carry an enum
|
|
4193
|
+
* `CHECK` (or `NOT NULL` on a schema field), in one transaction, keeping rows, indexes
|
|
4194
|
+
* and triggers. Idempotent (a relaxed table is left alone) and resumable (an
|
|
4195
|
+
* interrupted rebuild rolls back and runs again at the next start).
|
|
4196
|
+
*/
|
|
4197
|
+
private relaxValueDomainConstraints;
|
|
4198
|
+
/**
|
|
4199
|
+
* Throws {@link OperationTransformCoverageError} when a schema version in the stored
|
|
4200
|
+
* log has no transform path to `version` (RT-103): those operations would fold as
|
|
4201
|
+
* absent, silently erasing them from their records.
|
|
4202
|
+
*/
|
|
4203
|
+
private assertTransformCoverage;
|
|
4204
|
+
getOperationTransforms(): readonly OperationTransform[];
|
|
4205
|
+
setOperationTransforms(transforms: readonly OperationTransform[]): Promise<void>;
|
|
4206
|
+
applyRemoteOperation(op: Operation, options?: ApplyRemoteOptions): Promise<ApplyResult>;
|
|
4207
|
+
private applyInTransaction;
|
|
1895
4208
|
getOperationRange(nodeId: string, fromSeq: number, toSeq: number): Promise<Operation[]>;
|
|
1896
4209
|
getOperationCount(): Promise<number>;
|
|
1897
4210
|
getMaxDeliverySequence(): Promise<number>;
|
|
1898
4211
|
getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
|
|
4212
|
+
getOperationScopeSnapshots(operationIds: string[]): Promise<Map<string, OperationScopeSnapshot>>;
|
|
4213
|
+
getRecordLatestTimestamp(collection: string, recordId: string): Promise<HLCTimestamp | null>;
|
|
4214
|
+
getRecordFieldVersions(collection: string, recordId: string): Promise<RecordFieldVersions | null>;
|
|
4215
|
+
getRecordFoldState(collection: string, recordId: string): Promise<FoldState | null>;
|
|
4216
|
+
getRecordOperations(collection: string, recordId: string): Promise<Operation[]>;
|
|
4217
|
+
previewOperation(op: Operation): Promise<MaterializedRecord | null>;
|
|
4218
|
+
recordBlobOwner(hash: string, owner: string): Promise<void>;
|
|
4219
|
+
getBlobOwners(hashes: string[]): Promise<Map<string, string[]>>;
|
|
4220
|
+
claimBlobIfUnowned(hash: string, owner: string): Promise<boolean>;
|
|
4221
|
+
/**
|
|
4222
|
+
* Scope values of a record as stored, for an operation's scope snapshot. With
|
|
4223
|
+
* `includeDeleted` false a soft-deleted row counts as absent (the pre-image of a
|
|
4224
|
+
* write to a deleted record); with true it keeps its last values (a delete's
|
|
4225
|
+
* post-image).
|
|
4226
|
+
*/
|
|
4227
|
+
private readScopeValues;
|
|
4228
|
+
/**
|
|
4229
|
+
* Rebuild missing scope snapshots from the log (migration from a database written
|
|
4230
|
+
* before snapshots existed), replaying each affected record in commit order.
|
|
4231
|
+
*/
|
|
4232
|
+
private backfillScopeSnapshots;
|
|
1899
4233
|
materializeCollection(collection: string): Promise<MaterializedRecord[]>;
|
|
4234
|
+
getNodeIdsAfterDelivery(afterDeliverySequence: number): Promise<string[]>;
|
|
4235
|
+
getSequencePairOperations(nodeId: string, throughSequence: number): Promise<Operation[]>;
|
|
4236
|
+
recordOperationResolution(resolution: OperationResolution): Promise<void>;
|
|
4237
|
+
findOperationResolutions(nodeId: string, ids: string[]): Promise<Map<string, OperationResolution>>;
|
|
4238
|
+
deleteOperationResolution(nodeId: string, operationId: string): Promise<void>;
|
|
4239
|
+
getResolvedThrough(nodeId: string): Promise<number>;
|
|
4240
|
+
findStoredOperations(ids: string[]): Promise<Map<string, StoredOperationKey>>;
|
|
4241
|
+
findRecordsByIds(collection: string, ids: string[]): Promise<Map<string, MaterializedRecord>>;
|
|
1900
4242
|
queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
|
|
1901
4243
|
findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
|
|
1902
4244
|
countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
|
|
1903
4245
|
close(): Promise<void>;
|
|
4246
|
+
getEncryptionKeyRecord(owner: string, keyring: string): Promise<string | null>;
|
|
4247
|
+
putEncryptionKeyRecord(owner: string, keyring: string, record: string, revision: number, expectedRevision: number): Promise<boolean>;
|
|
4248
|
+
listEncryptionKeyRecords(owner?: string): Promise<EncryptionKeyRecordRow[]>;
|
|
4249
|
+
getEncryptedKeyIds(nodeOwner: string | null, limit: number): Promise<string[]>;
|
|
4250
|
+
claimNode(nodeId: string, userId: string): Promise<boolean>;
|
|
4251
|
+
getNodeClaimOwner(nodeId: string): Promise<string | null>;
|
|
4252
|
+
replaceNodeClaim(nodeId: string, expectedOwner: string, newOwner: string): Promise<boolean>;
|
|
4253
|
+
releaseNodeClaim(nodeId: string): Promise<boolean>;
|
|
4254
|
+
/**
|
|
4255
|
+
* The record as currently stored, including a soft-deleted one (whose last field
|
|
4256
|
+
* values are kept), or null when it was never written. Without a materialized
|
|
4257
|
+
* table, the last known values are replayed from the operation log.
|
|
4258
|
+
*/
|
|
4259
|
+
private readStoredRow;
|
|
4260
|
+
/**
|
|
4261
|
+
* The fold state of a record that owns quarantined operations (RT-70): its stored
|
|
4262
|
+
* state, or its kept row as a snapshot base, with every remaining operation of the
|
|
4263
|
+
* record merged on top (`rebuildFromSnapshot`). Nothing is written.
|
|
4264
|
+
*/
|
|
4265
|
+
private rebuildAffected;
|
|
1904
4266
|
exportBackup(): Promise<Uint8Array>;
|
|
1905
4267
|
importBackup(data: Uint8Array, merge?: boolean): Promise<{
|
|
1906
4268
|
operationsRestored: number;
|
|
1907
4269
|
success: boolean;
|
|
1908
4270
|
}>;
|
|
1909
4271
|
/**
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
4272
|
+
* Merge a just-appended operation (stored under `deliverySeq`) into its record's
|
|
4273
|
+
* fold state and project the row, inside the append transaction. O(fields the
|
|
4274
|
+
* operation touches): the stored state is read by primary key and the freshness
|
|
4275
|
+
* check is one index probe, never a scan of the record's history (SRV-7).
|
|
4276
|
+
*
|
|
4277
|
+
* The stored state records the highest delivery sequence it covers. If another
|
|
4278
|
+
* writer appended operations to this record without folding them (an older release
|
|
4279
|
+
* during a rolling upgrade), the missing tail is merged first; if the stored state
|
|
4280
|
+
* is unreadable, newer than the log, or built under an incompatible field kind,
|
|
4281
|
+
* the record is re-folded from its log.
|
|
4282
|
+
*/
|
|
4283
|
+
private mergeIntoRecord;
|
|
4284
|
+
/** True when the record's operation at delivery sequence `seq` has id `opId`. */
|
|
4285
|
+
private coversStoredOperation;
|
|
4286
|
+
/**
|
|
4287
|
+
* The record's current fold state for a read: the stored one when it covers the
|
|
4288
|
+
* record's whole log, else folded from the log (nothing is written).
|
|
1913
4289
|
*/
|
|
1914
|
-
private
|
|
4290
|
+
private readFoldState;
|
|
4291
|
+
/** A record's operations stored after `afterDeliverySeq`, in delivery order. */
|
|
4292
|
+
private readRecordOperations;
|
|
4293
|
+
/**
|
|
4294
|
+
* Persist a record's fold state (covering the log up to `coveredSeq`) and project
|
|
4295
|
+
* it onto the materialized row.
|
|
4296
|
+
*/
|
|
4297
|
+
private writeFoldedRecord;
|
|
1915
4298
|
/**
|
|
1916
4299
|
* UPSERT a record into the materialized collection table.
|
|
1917
4300
|
* Uses INSERT ... ON CONFLICT (id) DO UPDATE SET for atomic upsert.
|
|
1918
4301
|
*/
|
|
1919
4302
|
private upsertMaterializedRecord;
|
|
1920
4303
|
/**
|
|
1921
|
-
*
|
|
1922
|
-
*
|
|
4304
|
+
* Re-materialization migration (W7 step 7), run by setSchema and after a
|
|
4305
|
+
* replace-mode restore. Every record whose fold state is missing, stale (it covers
|
|
4306
|
+
* less, or more, of the log than is stored) or built under a different fold plan
|
|
4307
|
+
* (field kind, merge strategy, resolver) is re-folded from its log and its row
|
|
4308
|
+
* rewritten; nothing else is touched, so a restart on an up-to-date database does
|
|
4309
|
+
* one aggregate read.
|
|
4310
|
+
*
|
|
4311
|
+
* - Runs only on a log the W8b integrity scan reports clean. With quarantined rows,
|
|
4312
|
+
* rows materialized before the fold existed are kept as they are (re-folding a
|
|
4313
|
+
* log that lost operations would bake the loss in) and the skip is logged loudly;
|
|
4314
|
+
* such a record is folded from its remaining log on its next write. Records with
|
|
4315
|
+
* no row yet, and fold states this release already wrote, are still processed.
|
|
4316
|
+
* - Idempotent and resumable: each batch commits on its own, and staleness is
|
|
4317
|
+
* re-derived from the tables at every start, so a crash resumes where it stopped.
|
|
4318
|
+
* A plan change first marks every stored state stale (one statement), then
|
|
4319
|
+
* records the new fingerprint, so it is resumable too.
|
|
1923
4320
|
*/
|
|
1924
|
-
private backfillAllCollections;
|
|
1925
4321
|
/**
|
|
1926
|
-
*
|
|
4322
|
+
* One-time migration of each collection table to the lossless text encoding (RT-65):
|
|
4323
|
+
* rows written before it whose raw-string columns contain U+FFFF (the escape
|
|
4324
|
+
* introducer) are re-encoded, so the decoder returns them unchanged.
|
|
1927
4325
|
*/
|
|
1928
|
-
private
|
|
4326
|
+
private migrateTextCodec;
|
|
4327
|
+
/**
|
|
4328
|
+
* Write the beta.12 clears the ids prove into the stored bodies, once per database
|
|
4329
|
+
* (RT-85, see `provenLegacyClears`), and mark their records' fold states stale.
|
|
4330
|
+
*/
|
|
4331
|
+
private canonicalizeLegacyBodies;
|
|
4332
|
+
private rematerialize;
|
|
1929
4333
|
private buildSelectQuery;
|
|
1930
4334
|
private buildWhereClause;
|
|
1931
4335
|
private deserializeRow;
|
|
@@ -1934,6 +4338,29 @@ declare class SqliteServerStore implements ServerStore {
|
|
|
1934
4338
|
* Create the operations and sync_state tables if they don't exist.
|
|
1935
4339
|
*/
|
|
1936
4340
|
private ensureTables;
|
|
4341
|
+
/**
|
|
4342
|
+
* The startup log-integrity scan's result (W8 step 0): rows that could not be read
|
|
4343
|
+
* back into an operation are moved to `operations_quarantine` once per database,
|
|
4344
|
+
* before any fold reads them.
|
|
4345
|
+
*/
|
|
4346
|
+
getLogIntegrityReport(): ServerLogIntegrityReport;
|
|
4347
|
+
private scanLogIntegrityOnce;
|
|
4348
|
+
/**
|
|
4349
|
+
* Index the legacy pairs already in the log once (history written before the
|
|
4350
|
+
* `sequence_pairs` table existed); appends maintain it from then on.
|
|
4351
|
+
*/
|
|
4352
|
+
private backfillSequencePairs;
|
|
4353
|
+
/**
|
|
4354
|
+
* Record the sequence-enforcement epoch on the first start of this release (the log's
|
|
4355
|
+
* highest delivery sequence then; see {@link SEQUENCE_ENFORCEMENT_EPOCH_KEY}) and
|
|
4356
|
+
* back the append check with the partial unique index over sole-holder rows
|
|
4357
|
+
* ({@link NODE_SEQ_UNIQUE_INDEX}), which therefore always exists: a legacy duplicate
|
|
4358
|
+
* pair is stored unflagged. Replaces the earlier indexes (a full one, created only
|
|
4359
|
+
* when the log had no duplicates, and one over the rows past the epoch, which a
|
|
4360
|
+
* legacy client's pair would violate). Runs in one write transaction, so stores
|
|
4361
|
+
* sharing a file agree on the epoch.
|
|
4362
|
+
*/
|
|
4363
|
+
private ensureSequenceEnforcement;
|
|
1937
4364
|
/**
|
|
1938
4365
|
* Assign delivery sequences to any operations written before the column existed,
|
|
1939
4366
|
* then seed the durable counter from the current max. Ordered by
|
|
@@ -1947,6 +4374,11 @@ declare class SqliteServerStore implements ServerStore {
|
|
|
1947
4374
|
* allocation shared by every SqliteServerStore instance using the same file.
|
|
1948
4375
|
*/
|
|
1949
4376
|
private nextDeliverySeq;
|
|
4377
|
+
/**
|
|
4378
|
+
* @param soleHolder - Nothing else held the (node, sequence) at insert: the row is
|
|
4379
|
+
* flagged `seq_unique` and covered by {@link NODE_SEQ_UNIQUE_INDEX}. False for a
|
|
4380
|
+
* legacy pair's second operation and for restored backup rows.
|
|
4381
|
+
*/
|
|
1950
4382
|
private serializeOperation;
|
|
1951
4383
|
private deserializeOperation;
|
|
1952
4384
|
private assertOpen;
|
|
@@ -1959,7 +4391,9 @@ declare class SqliteServerStore implements ServerStore {
|
|
|
1959
4391
|
*
|
|
1960
4392
|
* @param options - Configuration options
|
|
1961
4393
|
* @param options.filename - Path to SQLite database file. Defaults to ':memory:' for testing.
|
|
1962
|
-
* @param options.nodeId -
|
|
4394
|
+
* @param options.nodeId - Deprecated (a plain id becomes a legacy authoritative id). Leave
|
|
4395
|
+
* unset: the server authors under the persisted `kora:server:<deployment>:<instance>`.
|
|
4396
|
+
* @param options.instanceId - Optional stable instance id
|
|
1963
4397
|
* @returns A ready-to-use SqliteServerStore
|
|
1964
4398
|
*
|
|
1965
4399
|
* @example
|
|
@@ -1976,8 +4410,7 @@ declare class SqliteServerStore implements ServerStore {
|
|
|
1976
4410
|
*/
|
|
1977
4411
|
declare function createSqliteServerStore(options: {
|
|
1978
4412
|
filename?: string;
|
|
1979
|
-
|
|
1980
|
-
}): SqliteServerStore;
|
|
4413
|
+
} & ServerIdentityOptions): SqliteServerStore;
|
|
1981
4414
|
|
|
1982
4415
|
/**
|
|
1983
4416
|
* Auth provider that accepts all connections.
|
|
@@ -1988,33 +4421,147 @@ declare class NoAuthProvider implements AuthProvider {
|
|
|
1988
4421
|
authenticate(_token: string): Promise<AuthContext>;
|
|
1989
4422
|
}
|
|
1990
4423
|
|
|
4424
|
+
/**
|
|
4425
|
+
* Thrown when a sync scope predicate value is `undefined` or `null` (also inside a
|
|
4426
|
+
* `$in` list). Such a value matches every record that lacks the field, so a grant
|
|
4427
|
+
* built from a failed lookup (for example `{ orgId: user.orgId }` with no org)
|
|
4428
|
+
* would expose every unowned record. Kora refuses it instead (fail closed).
|
|
4429
|
+
*/
|
|
4430
|
+
declare class InvalidScopePredicateError extends KoraError {
|
|
4431
|
+
readonly collection: string;
|
|
4432
|
+
readonly field: string;
|
|
4433
|
+
constructor(collection: string, field: string);
|
|
4434
|
+
}
|
|
4435
|
+
/** Thrown when a `$in` scope predicate is malformed or larger than the configured limit. */
|
|
4436
|
+
declare class ScopePredicateLimitError extends KoraError {
|
|
4437
|
+
constructor(message: string, context: Record<string, unknown>);
|
|
4438
|
+
}
|
|
4439
|
+
|
|
4440
|
+
interface ResolveSessionScopesOptions {
|
|
4441
|
+
/** Scope map sent by the client during handshake. It can only NARROW the grant. */
|
|
4442
|
+
handshakeScope?: ScopeMap$1;
|
|
4443
|
+
/**
|
|
4444
|
+
* The server grant from the auth provider. Its collection set is authoritative:
|
|
4445
|
+
* a collection it does not list is not visible, whatever the handshake asks for.
|
|
4446
|
+
* It may carry verified claim values under `$claims` (see `claimScopes` in
|
|
4447
|
+
* `@korajs/core`), which are bound to every schema-scoped collection.
|
|
4448
|
+
*/
|
|
4449
|
+
authScopes?: ScopeMap$1;
|
|
4450
|
+
/**
|
|
4451
|
+
* Verified, server-derived scope values (for example `{ userId: sub }`). Treated
|
|
4452
|
+
* exactly like `$claims` in `authScopes`. Never pass client-supplied values here.
|
|
4453
|
+
*/
|
|
4454
|
+
scopeValues?: Record<string, unknown>;
|
|
4455
|
+
/**
|
|
4456
|
+
* True when an auth provider authenticated this session. An authenticated
|
|
4457
|
+
* session whose provider returned no grant at all is denied every
|
|
4458
|
+
* schema-scoped collection instead of trusting the client handshake.
|
|
4459
|
+
*
|
|
4460
|
+
* Leave it false only for servers without an auth provider (or with
|
|
4461
|
+
* `NoAuthProvider`), where the handshake is a client-side filter, not a grant.
|
|
4462
|
+
* @default false
|
|
4463
|
+
*/
|
|
4464
|
+
authenticated?: boolean;
|
|
4465
|
+
/**
|
|
4466
|
+
* What to do when a schema-scoped collection's bindings cannot all be resolved
|
|
4467
|
+
* from the verified claims. `'deny'` hides the collection (fail closed);
|
|
4468
|
+
* `'throw'` raises {@link ScopeRequiredError} so the caller can reject the
|
|
4469
|
+
* handshake with `SCOPE_REQUIRED`.
|
|
4470
|
+
* @default 'deny'
|
|
4471
|
+
*/
|
|
4472
|
+
onUnresolved?: 'deny' | 'throw';
|
|
4473
|
+
}
|
|
4474
|
+
/** A schema-scoped collection the session was denied because a binding was unresolved. */
|
|
4475
|
+
interface DeniedScopeCollection {
|
|
4476
|
+
collection: string;
|
|
4477
|
+
/** Scope binding keys (for example `orgId`) that had no verified value. */
|
|
4478
|
+
missingKeys: string[];
|
|
4479
|
+
}
|
|
4480
|
+
/** Detailed result of {@link resolveSessionScopeGrant}. */
|
|
4481
|
+
interface ResolvedSessionScopeGrant {
|
|
4482
|
+
/**
|
|
4483
|
+
* Effective scope map. `undefined` means "no scoping" and is only ever returned
|
|
4484
|
+
* for unauthenticated servers without a grant. An empty object means "nothing
|
|
4485
|
+
* visible".
|
|
4486
|
+
*/
|
|
4487
|
+
scopes: ScopeMap$1 | undefined;
|
|
4488
|
+
/** Collections denied because their scope bindings could not be resolved. */
|
|
4489
|
+
denied: DeniedScopeCollection[];
|
|
4490
|
+
}
|
|
4491
|
+
/**
|
|
4492
|
+
* Thrown (with `onUnresolved: 'throw'`) when an authenticated session cannot be
|
|
4493
|
+
* bound to a schema-scoped collection because the server grant lacks a value.
|
|
4494
|
+
*/
|
|
4495
|
+
declare class ScopeRequiredError extends KoraError {
|
|
4496
|
+
readonly denied: DeniedScopeCollection[];
|
|
4497
|
+
constructor(denied: DeniedScopeCollection[]);
|
|
4498
|
+
}
|
|
4499
|
+
/**
|
|
4500
|
+
* Resolve the effective per-session sync scope map.
|
|
4501
|
+
*
|
|
4502
|
+
* The result is the INTERSECTION of the server grant and the client handshake:
|
|
4503
|
+
* - The collection set is the grant's collection set. A handshake can never add
|
|
4504
|
+
* a collection.
|
|
4505
|
+
* - Per collection, handshake predicates only narrow: grant fields win, a
|
|
4506
|
+
* handshake field on an unconstrained field is added, and for an `$in` grant a
|
|
4507
|
+
* handshake value is used only when it is a subset of the granted values.
|
|
4508
|
+
* - Schema-scoped collections bound from verified claims fail closed: an
|
|
4509
|
+
* unresolved binding denies the collection instead of producing `{}`.
|
|
4510
|
+
*
|
|
4511
|
+
* Invariant: the effective scope is a subset of the server grant, and no
|
|
4512
|
+
* schema-scoped collection resolves to `{}` from claims for an authenticated
|
|
4513
|
+
* principal.
|
|
4514
|
+
*
|
|
4515
|
+
* @returns The effective scope map; `undefined` only for unauthenticated servers
|
|
4516
|
+
* with neither a grant nor a handshake scope
|
|
4517
|
+
*/
|
|
4518
|
+
declare function resolveSessionScopes(schema: SchemaDefinition | null, options: ResolveSessionScopesOptions): ScopeMap$1 | undefined;
|
|
4519
|
+
/**
|
|
4520
|
+
* Same as {@link resolveSessionScopes}, but also reports which collections were
|
|
4521
|
+
* denied for unresolved bindings, so a caller can surface `SCOPE_REQUIRED`.
|
|
4522
|
+
*/
|
|
4523
|
+
declare function resolveSessionScopeGrant(schema: SchemaDefinition | null, options: ResolveSessionScopesOptions): ResolvedSessionScopeGrant;
|
|
4524
|
+
|
|
1991
4525
|
/**
|
|
1992
4526
|
* Server-side awareness relay. Broadcasts ephemeral awareness states
|
|
1993
4527
|
* (cursor positions, user presence) between connected clients.
|
|
1994
4528
|
*
|
|
1995
|
-
* Awareness data is never persisted.
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
4529
|
+
* Awareness data is never persisted. Each session's awareness `clientId` is bound on
|
|
4530
|
+
* registration: a session may only publish (or clear) its own state, never another
|
|
4531
|
+
* client's, and its updates reach only clients in the same partition.
|
|
1998
4532
|
*/
|
|
1999
4533
|
declare class AwarenessRelay {
|
|
2000
4534
|
private readonly clients;
|
|
2001
4535
|
/**
|
|
2002
|
-
* Register a client for awareness broadcasting.
|
|
4536
|
+
* Register a client for awareness broadcasting. A session registers once; later
|
|
4537
|
+
* calls for an already-registered session are ignored, so a client cannot rebind
|
|
4538
|
+
* itself to another client's id.
|
|
2003
4539
|
*
|
|
2004
4540
|
* @param sessionId - Unique session identifier
|
|
2005
4541
|
* @param clientId - Client-assigned awareness ID
|
|
2006
4542
|
* @param transport - Transport for sending messages to this client
|
|
4543
|
+
* @param partition - Presence partition (clients only see their own partition)
|
|
4544
|
+
*/
|
|
4545
|
+
addClient(sessionId: string, clientId: number, transport: ServerTransport, partition?: string): void;
|
|
4546
|
+
/**
|
|
4547
|
+
* True when the session is registered with the relay.
|
|
4548
|
+
*
|
|
4549
|
+
* @param sessionId - Session to check
|
|
2007
4550
|
*/
|
|
2008
|
-
|
|
4551
|
+
hasClient(sessionId: string): boolean;
|
|
2009
4552
|
/**
|
|
2010
|
-
* Remove a client and broadcast its removal to
|
|
4553
|
+
* Remove a client and broadcast its removal to the remaining clients in its partition.
|
|
2011
4554
|
*
|
|
2012
4555
|
* @param sessionId - Session ID of the disconnecting client
|
|
2013
4556
|
*/
|
|
2014
4557
|
removeClient(sessionId: string): void;
|
|
2015
4558
|
/**
|
|
2016
|
-
* Handle an incoming awareness update from a client.
|
|
2017
|
-
*
|
|
4559
|
+
* Handle an incoming awareness update from a registered client.
|
|
4560
|
+
*
|
|
4561
|
+
* Only the sender's own entry is accepted: the message must be stamped with the
|
|
4562
|
+
* sender's bound `clientId`, and only `states[clientId]` is stored and relayed.
|
|
4563
|
+
* Entries for other clients are dropped, so a sender cannot overwrite or remove
|
|
4564
|
+
* anyone else's presence. The sanitized update reaches only the sender's partition.
|
|
2018
4565
|
*
|
|
2019
4566
|
* @param sessionId - Session ID of the sending client
|
|
2020
4567
|
* @param message - The awareness update message
|
|
@@ -2048,4 +4595,4 @@ declare class AwarenessRelay {
|
|
|
2048
4595
|
*/
|
|
2049
4596
|
declare function createKoraServer(config: KoraSyncServerConfig): KoraSyncServer;
|
|
2050
4597
|
|
|
2051
|
-
export { type AuthContext, type AuthProvider, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, DEFAULT_MAX_SCOPE_PREDICATE_VALUES, type HttpPollResponse, HttpServerTransport, type HttpSyncRequest, type HttpSyncResponse, KoraAuthProvider, type KoraAuthProviderOptions, KoraSyncServer, type KoraSyncServerConfig, type LogEntry, type LogLevel, type Logger, type MaterializedRecord, MemoryServerStore, MixedAuthProvider, type MixedAuthProviderOptions, NoAuthProvider, type OperationDecision, type OperationRejection, type OperationValidationContext, type OperationValidator, PostgresServerStore, type ProductionHttpRoute, type ProductionHttpRouteContext, type ProductionHttpRouteRequest, type ProductionHttpRouteResponse, type ProductionServer, type ProductionServerConfig, RETRIABLE_REJECTION_CODES, type RelayCallback, type RouteApplyResult, type RouteConditionalApply, type RouteConditionalResult, type RouteMutation, type RoutePredicate, type RoutePredicateOperators, type RouteScopeOptions, ServerCloseHandler, ServerErrorHandler, ServerMessageHandler, ServerMetricsCollector, type ServerMetricsSnapshot, type ServerStatus, type ServerStore, ServerTransport, type SessionState, SqliteServerStore, TokenAuthProvider, type TokenAuthProviderOptions, type WsServerConstructor, type WsServerLike, WsServerTransport, type WsServerTransportOptions, type WsWebSocket, createDefaultLogger, createJsonLogger, createKoraServer, createPostgresServerStore, createPrettyLogger, createProductionServer, createSilentLogger, createSqliteServerStore, isRetriableRejection, normalizeScopeMap, operationMatchesScopes };
|
|
4598
|
+
export { type ApplyRemoteOptions, type AuthContext, type AuthProvider, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, DEFAULT_MAX_SCOPE_PREDICATE_VALUES, type DeniedScopeCollection, type FoldMigrationReport, type HttpPollResponse, HttpServerTransport, type HttpSyncRequest, type HttpSyncResponse, InvalidScopePredicateError, KoraAuthProvider, type KoraAuthProviderOptions, KoraSyncServer, type KoraSyncServerConfig, type LogEntry, type LogLevel, type Logger, type MaterializedRecord, MemoryServerStore, type MemoryServerStoreOptions, MixedAuthProvider, type MixedAuthProviderOptions, NoAuthProvider, type OperationDecision, type OperationRejection, type OperationValidationContext, type OperationValidator, PostgresServerStore, type ProductionHttpRoute, type ProductionHttpRouteContext, type ProductionHttpRouteRequest, type ProductionHttpRouteResponse, type ProductionServer, type ProductionServerConfig, RETRIABLE_REJECTION_CODES, type RelayCallback, type ResolveSessionScopesOptions, type ResolvedSessionScopeGrant, type RouteApplyResult, type RouteConditionalApply, type RouteConditionalResult, type RouteMutation, type RoutePredicate, type RoutePredicateOperators, type RouteScopeOptions, SERVER_NODE_PREFIX, ScopePredicateLimitError, ScopeRequiredError, ServerCloseHandler, ServerErrorHandler, ServerIdentityError, type ServerIdentityOptions, ServerMessageHandler, ServerMetricsCollector, type ServerMetricsSnapshot, type ServerSchemaOptions, type ServerStatus, type ServerStore, ServerStoreUnavailableError, ServerTransport, type SessionRevocation, type SessionState, type SessionTerminationCode, SqliteServerStore, type StoredOperationKey, type TerminateSessionsFilter, TokenAuthProvider, type TokenAuthProviderOptions, type TrustProxySetting, type UplinkAuthorizationCode, UplinkAuthorizationError, type UplinkAuthorizationResult, type WsServerConstructor, type WsServerLike, WsServerTransport, type WsServerTransportOptions, type WsWebSocket, authorizeUplinkWrite, createDefaultLogger, createJsonLogger, createKoraServer, createPostgresServerStore, createPrettyLogger, createProductionServer, createSilentLogger, createSqliteServerStore, isRetriableRejection, isServerNodeId, normalizeScopeMap, operationMatchesScopes, resolveSessionScopeGrant, resolveSessionScopes };
|