@korajs/server 1.0.0-beta.0 → 1.0.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1957 -219
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +962 -183
- package/dist/index.d.ts +962 -183
- package/dist/index.js +1919 -178
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
package/dist/index.d.cts
CHANGED
|
@@ -1,10 +1,298 @@
|
|
|
1
|
-
import { SchemaDefinition, KoraEventEmitter,
|
|
1
|
+
import { Operation, HybridLogicalClock, SchemaDefinition, BlobRef, KoraEventEmitter, OperationTransform, VersionVector, TimeSource } from '@korajs/core';
|
|
2
2
|
import * as _korajs_sync from '@korajs/sync';
|
|
3
|
-
import { SyncStore, MessageSerializer, SyncMessage, AwarenessUpdateMessage, YjsDocUpdateMessage,
|
|
3
|
+
import { SyncStore, ApplyResult, MessageSerializer, SyncMessage, AwarenessUpdateMessage, YjsDocUpdateMessage, BlobChunkRequestMessage, BlobChunkResponseMessage } from '@korajs/sync';
|
|
4
4
|
import { S as ServerTransport, a as ServerMessageHandler, b as ServerCloseHandler, c as ServerErrorHandler } from './server-transport-CU1BLWgr.cjs';
|
|
5
5
|
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
|
6
6
|
import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Predicate evaluation for conditional route mutations.
|
|
10
|
+
*
|
|
11
|
+
* A predicate is a small, total comparison language evaluated against the current
|
|
12
|
+
* materialized state of a single record at admission time. It is deliberately not
|
|
13
|
+
* a general query language: it exists so a server route can express an atomic
|
|
14
|
+
* admission guard (for example "accept this response only while the form is still
|
|
15
|
+
* below its cap") without pulling a query DSL into the write path.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Comparison operators applied to one field. All operators present on a field
|
|
19
|
+
* must hold for the field to match. Numeric operators (`$lt`, `$lte`, `$gt`,
|
|
20
|
+
* `$gte`) require the record value to be a number; a non-numeric value fails.
|
|
21
|
+
*/
|
|
22
|
+
interface RoutePredicateOperators {
|
|
23
|
+
$eq?: unknown;
|
|
24
|
+
$ne?: unknown;
|
|
25
|
+
$lt?: number;
|
|
26
|
+
$lte?: number;
|
|
27
|
+
$gt?: number;
|
|
28
|
+
$gte?: number;
|
|
29
|
+
$in?: readonly unknown[];
|
|
30
|
+
}
|
|
31
|
+
/** A predicate: every listed field must satisfy all of its operators. */
|
|
32
|
+
type RoutePredicate = Record<string, RoutePredicateOperators>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-collection scope map from auth context.
|
|
36
|
+
*/
|
|
37
|
+
type ScopeMap = Record<string, Record<string, unknown>>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A materialized record reconstructed from the operation log
|
|
41
|
+
* or read from a materialized collection table.
|
|
42
|
+
*/
|
|
43
|
+
interface MaterializedRecord {
|
|
44
|
+
id: string;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A conditional, atomic multi-operation apply. The store reads the target under a
|
|
49
|
+
* cross-instance lock, lets the caller decide admission and build the operations
|
|
50
|
+
* against that locked state, and applies them in one transaction, or applies
|
|
51
|
+
* nothing. `admit` and `buildOperations` run inside the locked transaction so the
|
|
52
|
+
* predicate check and any atomic-op resolution see the authoritative current value
|
|
53
|
+
* even when multiple server instances race on the same target.
|
|
54
|
+
*/
|
|
55
|
+
interface ConditionalApplyInput {
|
|
56
|
+
/** Record the admission lock and predicate are keyed on. */
|
|
57
|
+
target: {
|
|
58
|
+
collection: string;
|
|
59
|
+
id: string;
|
|
60
|
+
};
|
|
61
|
+
/** Decides admission against the target's locked current state. */
|
|
62
|
+
admit: (current: MaterializedRecord | null) => boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Builds the operations to apply, given the locked current state and a clock the
|
|
65
|
+
* store has already advanced past the target record's latest committed operation.
|
|
66
|
+
* Stamping the built operations with this clock guarantees they sort strictly
|
|
67
|
+
* after every prior write to the target, so last-write-wins materialization
|
|
68
|
+
* reflects the serialized commit order even when two instances commit in the same
|
|
69
|
+
* millisecond. Without it, a same-millisecond tie could let materialization pick
|
|
70
|
+
* an earlier resolved value and undercount an atomic counter, admitting past a cap.
|
|
71
|
+
*/
|
|
72
|
+
buildOperations: (current: MaterializedRecord | null, context: ConditionalApplyContext) => Promise<Operation[]>;
|
|
73
|
+
/**
|
|
74
|
+
* When set, a record whose prior existence proves this set already committed.
|
|
75
|
+
* Checked under the same lock as admission, so a retry is idempotent even across
|
|
76
|
+
* instances: it returns `idempotent: true` without re-running the operations.
|
|
77
|
+
*/
|
|
78
|
+
idempotencyKey?: {
|
|
79
|
+
collection: string;
|
|
80
|
+
id: string;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Context passed to {@link ConditionalApplyInput.buildOperations}. */
|
|
84
|
+
interface ConditionalApplyContext {
|
|
85
|
+
/**
|
|
86
|
+
* A clock the store has advanced past the target record's latest committed
|
|
87
|
+
* operation. Operations built here must be stamped with it (route context threads
|
|
88
|
+
* it into operation creation) so they sort strictly after every prior write.
|
|
89
|
+
*/
|
|
90
|
+
clock: HybridLogicalClock;
|
|
91
|
+
}
|
|
92
|
+
/** Result of {@link ServerStore.applyConditional}. */
|
|
93
|
+
interface ConditionalApplyResult {
|
|
94
|
+
/** True when the predicate held (or the set was already committed). */
|
|
95
|
+
admitted: boolean;
|
|
96
|
+
/** True when the idempotency key already existed and nothing was re-applied. */
|
|
97
|
+
idempotent: boolean;
|
|
98
|
+
/** The operations that were applied (empty when not admitted or idempotent). */
|
|
99
|
+
applied: Operation[];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A stored operation paired with its server-assigned delivery sequence. The
|
|
103
|
+
* delivery sequence is monotonic in commit order, so a batch of these can be
|
|
104
|
+
* chained (each batch's base links to the previous batch's max) to give the
|
|
105
|
+
* client a gap-free, scope-agnostic recovery stream.
|
|
106
|
+
*/
|
|
107
|
+
interface DeliveredOperation {
|
|
108
|
+
operation: Operation;
|
|
109
|
+
deliverySequence: number;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Options for querying a materialized collection table.
|
|
113
|
+
*/
|
|
114
|
+
interface CollectionQueryOptions {
|
|
115
|
+
/** Exact-match filters on field values */
|
|
116
|
+
where?: Record<string, unknown>;
|
|
117
|
+
/** Field name to order results by */
|
|
118
|
+
orderBy?: string;
|
|
119
|
+
/** Sort direction (default: 'asc') */
|
|
120
|
+
orderDirection?: 'asc' | 'desc';
|
|
121
|
+
/** Maximum number of records to return */
|
|
122
|
+
limit?: number;
|
|
123
|
+
/** Number of records to skip (for pagination) */
|
|
124
|
+
offset?: number;
|
|
125
|
+
/** Include soft-deleted records (default: false) */
|
|
126
|
+
includeDeleted?: boolean;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Server-side store interface. Extends SyncStore with lifecycle,
|
|
130
|
+
* introspection, and materialization methods needed by the sync server.
|
|
131
|
+
*/
|
|
132
|
+
interface ServerStore extends SyncStore {
|
|
133
|
+
/** Close the store and release resources */
|
|
134
|
+
close(): Promise<void>;
|
|
135
|
+
/** Get the total number of stored operations */
|
|
136
|
+
getOperationCount(): Promise<number>;
|
|
137
|
+
/**
|
|
138
|
+
* The highest delivery sequence currently visible in the store (0 when empty).
|
|
139
|
+
* Used to initialize a fresh client's watermark baseline and for diagnostics.
|
|
140
|
+
*/
|
|
141
|
+
getMaxDeliverySequence(): Promise<number>;
|
|
142
|
+
/**
|
|
143
|
+
* Operations with `deliverySequence > afterDeliverySequence`, ordered ascending
|
|
144
|
+
* by delivery sequence, at most `limit` of them. This is the substrate for the
|
|
145
|
+
* gap-free server->client stream: the caller (a client session) applies its own
|
|
146
|
+
* scope filter and chains the results into batches, advancing the client's
|
|
147
|
+
* durable watermark as batches are acknowledged. Because delivery sequence is
|
|
148
|
+
* assigned in commit order, a lower sequence is always visible before any higher
|
|
149
|
+
* one, so this scan can never skip an operation that later appears below the
|
|
150
|
+
* cursor.
|
|
151
|
+
*/
|
|
152
|
+
getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
|
|
153
|
+
/**
|
|
154
|
+
* Conditionally apply a set of operations atomically, serialized across server
|
|
155
|
+
* instances on the target record. Optional: stores that can only run in a single
|
|
156
|
+
* process (or do not back a shared database) may omit it, and callers fall back
|
|
157
|
+
* to per-instance serialization. The Postgres store implements it with a
|
|
158
|
+
* transaction-scoped advisory lock so concurrent admissions cannot both pass a
|
|
159
|
+
* cap check (`applyConditional` returns `admitted: false` for the loser).
|
|
160
|
+
*/
|
|
161
|
+
applyConditional?(input: ConditionalApplyInput): Promise<ConditionalApplyResult>;
|
|
162
|
+
/**
|
|
163
|
+
* Atomically reserve the next sequence number for a server-originated operation
|
|
164
|
+
* on this store's own node. Stores that serve conditional applies concurrently
|
|
165
|
+
* (the Postgres store, which does not serialize behind a single mutation tail)
|
|
166
|
+
* must implement this so two in-flight server operations cannot be handed the
|
|
167
|
+
* same sequence number, which would let one shadow the other during version-vector
|
|
168
|
+
* delta sync. Stores whose server writes are fully serialized may omit it, and
|
|
169
|
+
* callers fall back to reading the version vector.
|
|
170
|
+
*/
|
|
171
|
+
reserveSequenceNumber?(): number;
|
|
172
|
+
/**
|
|
173
|
+
* Set the schema for materialized collection tables.
|
|
174
|
+
* Creates collection tables and indexes based on the schema definition.
|
|
175
|
+
* If operations already exist in the store, backfills the materialized
|
|
176
|
+
* tables from the operation log.
|
|
177
|
+
*
|
|
178
|
+
* @param schema - The schema definition describing all collections
|
|
179
|
+
*/
|
|
180
|
+
setSchema(schema: SchemaDefinition): Promise<void>;
|
|
181
|
+
/** Schema used for materialized tables and server-side validation, if set. */
|
|
182
|
+
getSchema(): SchemaDefinition | null;
|
|
183
|
+
/**
|
|
184
|
+
* Get all records from a materialized collection.
|
|
185
|
+
* When schema is set, reads directly from the collection table (O(1) indexed).
|
|
186
|
+
* When schema is not set, falls back to replaying the operation log.
|
|
187
|
+
* Deleted records are excluded.
|
|
188
|
+
*
|
|
189
|
+
* @param collection - The collection name to query
|
|
190
|
+
* @returns Array of records with their current state
|
|
191
|
+
*/
|
|
192
|
+
materializeCollection(collection: string): Promise<MaterializedRecord[]>;
|
|
193
|
+
/**
|
|
194
|
+
* Query records from a materialized collection with filtering, ordering,
|
|
195
|
+
* and pagination. Requires schema to be set via setSchema().
|
|
196
|
+
*
|
|
197
|
+
* @param collection - The collection name to query
|
|
198
|
+
* @param options - Query options (where, orderBy, limit, offset)
|
|
199
|
+
* @returns Array of matching records
|
|
200
|
+
*/
|
|
201
|
+
queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
|
|
202
|
+
/**
|
|
203
|
+
* Find a single record by ID from a materialized collection.
|
|
204
|
+
* Requires schema to be set via setSchema().
|
|
205
|
+
*
|
|
206
|
+
* @param collection - The collection name
|
|
207
|
+
* @param id - The record ID
|
|
208
|
+
* @returns The record or null if not found (or deleted)
|
|
209
|
+
*/
|
|
210
|
+
findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
|
|
211
|
+
/**
|
|
212
|
+
* Count records in a materialized collection, optionally filtered.
|
|
213
|
+
* Requires schema to be set via setSchema().
|
|
214
|
+
*
|
|
215
|
+
* @param collection - The collection name
|
|
216
|
+
* @param where - Optional exact-match filters
|
|
217
|
+
* @returns Number of matching records
|
|
218
|
+
*/
|
|
219
|
+
countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
|
|
220
|
+
/**
|
|
221
|
+
* Export all data as a portable backup binary.
|
|
222
|
+
* Ships operations, version vector, and metadata in a self-describing format
|
|
223
|
+
* with SHA-256 checksum.
|
|
224
|
+
*/
|
|
225
|
+
exportBackup(): Promise<Uint8Array>;
|
|
226
|
+
/**
|
|
227
|
+
* Restore data from a portable backup binary.
|
|
228
|
+
* Operations are applied through applyRemoteOperation for safe merge.
|
|
229
|
+
*
|
|
230
|
+
* @param data - Backup binary
|
|
231
|
+
* @param merge - If true, merge with existing operations; if false, replace all
|
|
232
|
+
*/
|
|
233
|
+
importBackup(data: Uint8Array, merge?: boolean): Promise<{
|
|
234
|
+
operationsRestored: number;
|
|
235
|
+
success: boolean;
|
|
236
|
+
}>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Shared vocabulary for why the server refused an operation.
|
|
241
|
+
*
|
|
242
|
+
* The `retriable` flag answers one question for the caller: is it worth
|
|
243
|
+
* submitting this SAME operation again later?
|
|
244
|
+
*
|
|
245
|
+
* - `true` — the rejection is transient (for example a rate limit). The
|
|
246
|
+
* identical operation may succeed once the condition clears, so a
|
|
247
|
+
* client is right to back off and retry the same bytes.
|
|
248
|
+
* - `false` — the rejection is permanent for the operation as written (a
|
|
249
|
+
* constraint violation, a referential conflict, a malformed
|
|
250
|
+
* mutation, a scope violation). Resubmitting the same bytes will
|
|
251
|
+
* always fail; the caller must change the operation or give up.
|
|
252
|
+
*
|
|
253
|
+
* This is deliberately defined once, in one place, so the trusted server
|
|
254
|
+
* data-plane (route context / `server.kora`) and the untrusted client-ingestion
|
|
255
|
+
* path speak the same language. `retriable` (this spelling) matches the wire
|
|
256
|
+
* `ErrorMessage.retriable` field the sync protocol already ships, so a single
|
|
257
|
+
* flag flows from the pipeline out to connected clients unchanged.
|
|
258
|
+
*/
|
|
259
|
+
interface OperationRejection {
|
|
260
|
+
/** Stable, machine-readable reason code (for example `CONSTRAINT_VIOLATION`). */
|
|
261
|
+
code: string;
|
|
262
|
+
/** Human-readable explanation with enough context to debug without reproduction. */
|
|
263
|
+
message: string;
|
|
264
|
+
/** Whether resubmitting the identical operation may later succeed. */
|
|
265
|
+
retriable: boolean;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Reason codes whose underlying condition is transient, so the identical
|
|
269
|
+
* operation may succeed on a later attempt.
|
|
270
|
+
*
|
|
271
|
+
* Everything NOT listed here is treated as permanent. Permanent is the safe
|
|
272
|
+
* default: a client should never be told to keep hammering a genuinely invalid
|
|
273
|
+
* operation, so a new code is non-retriable until it is deliberately added here.
|
|
274
|
+
*
|
|
275
|
+
* `RATE_LIMIT` is the code the session emits when a client exceeds its
|
|
276
|
+
* per-minute operation budget (see `client-session.ts`).
|
|
277
|
+
*/
|
|
278
|
+
declare const RETRIABLE_REJECTION_CODES: ReadonlySet<string>;
|
|
279
|
+
/**
|
|
280
|
+
* Classify a reason code as retriable (transient) or not (permanent).
|
|
281
|
+
*
|
|
282
|
+
* @param code - A rejection reason code.
|
|
283
|
+
* @returns `true` when resubmitting the identical operation may later succeed.
|
|
284
|
+
*/
|
|
285
|
+
declare function isRetriableRejection(code: string): boolean;
|
|
286
|
+
|
|
287
|
+
interface ApplyServerOperationResult {
|
|
288
|
+
/** Result of applying the primary operation */
|
|
289
|
+
result: ApplyResult;
|
|
290
|
+
/** Primary op when applied, plus any server-generated side-effect operations */
|
|
291
|
+
appliedOperations: Operation[];
|
|
292
|
+
/** Rejection reason when the operation was not applied */
|
|
293
|
+
rejection?: OperationRejection;
|
|
294
|
+
}
|
|
295
|
+
|
|
8
296
|
/**
|
|
9
297
|
* Snapshot of server-side sync metrics at a point in time.
|
|
10
298
|
*/
|
|
@@ -125,105 +413,370 @@ declare function createSilentLogger(): Logger;
|
|
|
125
413
|
declare function createDefaultLogger(): Logger;
|
|
126
414
|
|
|
127
415
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
416
|
+
* Minimal interface for a ws.WebSocketServer instance.
|
|
417
|
+
* Allows dependency injection for testing without importing ws directly.
|
|
130
418
|
*/
|
|
131
|
-
interface
|
|
132
|
-
|
|
133
|
-
|
|
419
|
+
interface WsServerLike {
|
|
420
|
+
on(event: string, listener: (...args: unknown[]) => void): void;
|
|
421
|
+
close(callback?: (err?: Error) => void): void;
|
|
422
|
+
address(): {
|
|
423
|
+
port: number;
|
|
424
|
+
} | string | null;
|
|
134
425
|
}
|
|
135
426
|
/**
|
|
136
|
-
*
|
|
427
|
+
* Constructor type for creating a WebSocket server.
|
|
137
428
|
*/
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
/** Sort direction (default: 'asc') */
|
|
144
|
-
orderDirection?: 'asc' | 'desc';
|
|
145
|
-
/** Maximum number of records to return */
|
|
146
|
-
limit?: number;
|
|
147
|
-
/** Number of records to skip (for pagination) */
|
|
148
|
-
offset?: number;
|
|
149
|
-
/** Include soft-deleted records (default: false) */
|
|
150
|
-
includeDeleted?: boolean;
|
|
151
|
-
}
|
|
429
|
+
type WsServerConstructor = new (options: {
|
|
430
|
+
port?: number;
|
|
431
|
+
host?: string;
|
|
432
|
+
path?: string;
|
|
433
|
+
}) => WsServerLike;
|
|
152
434
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
435
|
+
* Self-hosted sync server. Accepts WebSocket connections from clients,
|
|
436
|
+
* handles the sync protocol, stores operations, and relays changes
|
|
437
|
+
* between connected clients.
|
|
438
|
+
*
|
|
439
|
+
* Two modes of operation:
|
|
440
|
+
* 1. **Standalone**: Call `start()` with a port — creates its own WebSocket server.
|
|
441
|
+
* 2. **Attach**: Call `handleConnection(transport)` — attach to an existing HTTP server.
|
|
155
442
|
*/
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
443
|
+
declare class KoraSyncServer {
|
|
444
|
+
private readonly store;
|
|
445
|
+
private readonly auth;
|
|
446
|
+
private readonly serializer;
|
|
447
|
+
private readonly emitter;
|
|
448
|
+
private readonly maxConnections;
|
|
449
|
+
private readonly batchSize;
|
|
450
|
+
private readonly relayRetransmitIntervalMs;
|
|
451
|
+
private readonly deliveryPollIntervalMs;
|
|
452
|
+
private readonly schemaVersion;
|
|
453
|
+
private readonly supportedSchemaVersions;
|
|
454
|
+
private readonly operationTransforms;
|
|
455
|
+
private readonly port;
|
|
456
|
+
private readonly host;
|
|
457
|
+
private readonly path;
|
|
458
|
+
private readonly logger;
|
|
459
|
+
private readonly metrics;
|
|
460
|
+
private readonly awarenessRelay;
|
|
461
|
+
private readonly yjsDocRelay;
|
|
462
|
+
private readonly blobChunkRelay;
|
|
463
|
+
private readonly persistBlobChunk;
|
|
464
|
+
private readonly maxOperationBytes;
|
|
465
|
+
private readonly maxOpsPerMinute;
|
|
466
|
+
private readonly validateOperation;
|
|
467
|
+
private readonly koraContext;
|
|
468
|
+
private readonly sessions;
|
|
469
|
+
private readonly httpClients;
|
|
470
|
+
private readonly httpSessionToClient;
|
|
471
|
+
private readonly serverVersion;
|
|
472
|
+
private wsServer;
|
|
473
|
+
private running;
|
|
161
474
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
|
|
475
|
+
* Periodic tick that retransmits relay batches connected clients have not
|
|
476
|
+
* acknowledged, so a relay dropped by a lossy transport is redelivered instead of
|
|
477
|
+
* leaving that client with a permanent version-vector gap (a lost operation).
|
|
478
|
+
*/
|
|
479
|
+
private relayRetransmitTimer;
|
|
480
|
+
private deliveryPollTimer;
|
|
481
|
+
private lastObservedDeliverySequence;
|
|
482
|
+
private deliveryPollInFlight;
|
|
483
|
+
/**
|
|
484
|
+
* Relay operations a client never acknowledged before it disconnected, buffered by
|
|
485
|
+
* client node id so they can be redelivered on its next connection. Bounded per
|
|
486
|
+
* node and expired by age so a client that never returns cannot grow this forever.
|
|
487
|
+
*/
|
|
488
|
+
private readonly orphanedRelaysByNode;
|
|
489
|
+
private static readonly MAX_ORPHANED_RELAY_OPS_PER_NODE;
|
|
490
|
+
private static readonly ORPHANED_RELAY_TTL_MS;
|
|
491
|
+
constructor(config: KoraSyncServerConfig);
|
|
492
|
+
private ensureBackgroundTimersStarted;
|
|
493
|
+
/**
|
|
494
|
+
* Retransmit relay batches that connected clients have not acknowledged within
|
|
495
|
+
* `staleMs`. Called on a periodic tick and directly by tests. Redelivering an
|
|
496
|
+
* already-applied operation is harmless (clients dedup by content-addressed id).
|
|
497
|
+
*/
|
|
498
|
+
retransmitPendingRelays(staleMs?: number): void;
|
|
499
|
+
/**
|
|
500
|
+
* Check whether the authoritative delivery log advanced without passing through
|
|
501
|
+
* this server instance, then wake delivery-watermark sessions to scan from their
|
|
502
|
+
* own acknowledged cursors. Session-level filtering remains the only fan-out path.
|
|
503
|
+
*/
|
|
504
|
+
pollDeliveryLog(): Promise<void>;
|
|
505
|
+
/**
|
|
506
|
+
* Buffer relay operations a disconnected client never acknowledged, keyed by its
|
|
507
|
+
* node id, deduped by operation id and bounded by count. Redelivered when the
|
|
508
|
+
* client reconnects (see {@link takeOrphanedRelays}).
|
|
509
|
+
*/
|
|
510
|
+
private bufferOrphanedRelays;
|
|
511
|
+
/** Remove and return a node's buffered orphaned relay operations, if any. */
|
|
512
|
+
private takeOrphanedRelays;
|
|
513
|
+
/** Drop buffered orphaned relays older than the TTL (client never returned). */
|
|
514
|
+
private expireOrphanedRelays;
|
|
515
|
+
/**
|
|
516
|
+
* The trusted, scoped data-plane context (`apply` / `query` / `findById`).
|
|
517
|
+
* Shared with the production server so HTTP routes, the operation validator,
|
|
518
|
+
* and `server.kora` are all the same object over the same pipeline.
|
|
519
|
+
*/
|
|
520
|
+
getKoraContext(): ProductionHttpRouteContext;
|
|
521
|
+
/**
|
|
522
|
+
* Subscribe to session-level events for metrics collection and logging.
|
|
523
|
+
* Called when a new session is created.
|
|
524
|
+
*/
|
|
525
|
+
private attachSessionEvents;
|
|
526
|
+
/**
|
|
527
|
+
* Get the metrics collector for external access (e.g., HTTP endpoints).
|
|
528
|
+
*/
|
|
529
|
+
getMetricsCollector(): ServerMetricsCollector;
|
|
530
|
+
/**
|
|
531
|
+
* Get the logger for external access (e.g., event streaming).
|
|
532
|
+
*/
|
|
533
|
+
getLogger(): Logger;
|
|
534
|
+
/**
|
|
535
|
+
* Start the WebSocket server in standalone mode.
|
|
166
536
|
*
|
|
167
|
-
* @param
|
|
537
|
+
* @param wsServerImpl - Optional WebSocket server constructor for testing
|
|
168
538
|
*/
|
|
169
|
-
|
|
170
|
-
/** Schema used for materialized tables and server-side validation, if set. */
|
|
171
|
-
getSchema(): SchemaDefinition | null;
|
|
539
|
+
start(wsServerImpl?: WsServerConstructor): Promise<void>;
|
|
172
540
|
/**
|
|
173
|
-
*
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
541
|
+
* Stop the server. Closes all sessions and the WebSocket server.
|
|
542
|
+
*/
|
|
543
|
+
stop(): Promise<void>;
|
|
544
|
+
/**
|
|
545
|
+
* Handle one HTTP sync request for a long-polling client.
|
|
177
546
|
*
|
|
178
|
-
*
|
|
179
|
-
* @returns Array of records with their current state
|
|
547
|
+
* A stable `clientId` identifies the logical connection across requests.
|
|
180
548
|
*/
|
|
181
|
-
|
|
549
|
+
handleHttpRequest(request: HttpSyncRequest): Promise<HttpSyncResponse>;
|
|
550
|
+
/**
|
|
551
|
+
* Handle an incoming client connection (attach mode).
|
|
552
|
+
* Creates a new ClientSession for the transport.
|
|
553
|
+
*
|
|
554
|
+
* @param transport - The server transport for the new connection
|
|
555
|
+
* @returns The session ID
|
|
556
|
+
*/
|
|
557
|
+
handleConnection(transport: ServerTransport): string;
|
|
558
|
+
/**
|
|
559
|
+
* Get the current server status.
|
|
560
|
+
*/
|
|
561
|
+
getStatus(): Promise<ServerStatus>;
|
|
562
|
+
/**
|
|
563
|
+
* Apply a server-originated operation (for example one created by a custom
|
|
564
|
+
* HTTP route) through the same validated pipeline that incoming client
|
|
565
|
+
* operations use — Tier 2 constraints, referential integrity, and cascade
|
|
566
|
+
* side effects — then relay every applied operation to connected clients.
|
|
567
|
+
*
|
|
568
|
+
* Because the operation did not come from a client session, it is relayed to
|
|
569
|
+
* ALL sessions (there is no source session to exclude). Each session still
|
|
570
|
+
* applies its own per-scope visibility filter in `relayOperations`, so a
|
|
571
|
+
* client only receives the operation if it falls within that client's scope.
|
|
572
|
+
*
|
|
573
|
+
* @param op - A fully-formed, server-originated operation to apply
|
|
574
|
+
* @returns The apply result, including any server-generated side-effect ops
|
|
575
|
+
*/
|
|
576
|
+
applyLocalOperation(op: Operation): Promise<ApplyServerOperationResult>;
|
|
577
|
+
/**
|
|
578
|
+
* Relay already-applied, server-originated operations to every connected client.
|
|
579
|
+
* Used by the conditional route apply, which commits its operations atomically
|
|
580
|
+
* through the store (bypassing {@link applyLocalOperation}) and then fans them
|
|
581
|
+
* out. Each session still enforces its own per-scope visibility filter.
|
|
582
|
+
*
|
|
583
|
+
* @param operations - Operations that have already been committed to the store
|
|
584
|
+
*/
|
|
585
|
+
relayServerOperations(operations: Operation[]): void;
|
|
586
|
+
/**
|
|
587
|
+
* Collect every blob reference still reachable from live records on the server.
|
|
588
|
+
*
|
|
589
|
+
* This is the live set for garbage-collecting the server's central blob store:
|
|
590
|
+
* pass the result to `collectBlobGarbage(blobStore, liveRefs)` from
|
|
591
|
+
* `@korajs/store` to reclaim bytes no record references any more. Only
|
|
592
|
+
* collections that declare a `blob` field are scanned.
|
|
593
|
+
*
|
|
594
|
+
* @returns Every live blob reference across all collections
|
|
595
|
+
*/
|
|
596
|
+
getLiveBlobRefs(): Promise<BlobRef[]>;
|
|
597
|
+
/**
|
|
598
|
+
* Get the number of currently connected clients.
|
|
599
|
+
*/
|
|
600
|
+
getConnectionCount(): number;
|
|
601
|
+
private handleRelay;
|
|
602
|
+
private handleSessionClose;
|
|
603
|
+
private handleAwarenessRelay;
|
|
604
|
+
private handleYjsDocRelay;
|
|
605
|
+
private getOrCreateHttpClient;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* A mutation a custom HTTP route asks the server to apply on the data plane.
|
|
610
|
+
*
|
|
611
|
+
* `recordId` is optional for inserts (a UUID v7 is generated when omitted) and
|
|
612
|
+
* required for updates and deletes. For updates, `data` holds only the changed
|
|
613
|
+
* fields; the server reads the current record to capture the previous values so
|
|
614
|
+
* the change three-way merges correctly with concurrent client edits.
|
|
615
|
+
*/
|
|
616
|
+
interface RouteMutation {
|
|
617
|
+
collection: string;
|
|
618
|
+
type: 'insert' | 'update' | 'delete';
|
|
619
|
+
recordId?: string;
|
|
620
|
+
data?: Record<string, unknown> | null;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Scope the route derived from the request's authenticated actor. When present,
|
|
624
|
+
* it is enforced exactly like a sync session's scope: `apply()` rejects a
|
|
625
|
+
* mutation whose resulting record falls outside the scope, and `query()` /
|
|
626
|
+
* `findById()` only return records inside it. Omit it for genuinely public
|
|
627
|
+
* routes (for example a signup endpoint) where no per-actor isolation applies.
|
|
628
|
+
*/
|
|
629
|
+
interface RouteScopeOptions {
|
|
630
|
+
scope?: ScopeMap;
|
|
631
|
+
}
|
|
632
|
+
/** Result of {@link ProductionHttpRouteContext.apply}. */
|
|
633
|
+
type RouteApplyResult = {
|
|
634
|
+
ok: true;
|
|
635
|
+
operation: Operation;
|
|
636
|
+
record: MaterializedRecord | null;
|
|
637
|
+
} | {
|
|
638
|
+
ok: false;
|
|
639
|
+
code: string;
|
|
640
|
+
message: string;
|
|
641
|
+
retriable: boolean;
|
|
642
|
+
};
|
|
643
|
+
/**
|
|
644
|
+
* A conditional, multi-collection admission gate applied atomically per instance.
|
|
645
|
+
*
|
|
646
|
+
* The gate reads the current state of the target record (`collection` + `id`),
|
|
647
|
+
* checks the `if` predicate against it, and only then applies the `update` to the
|
|
648
|
+
* target plus every mutation in `also` as one set. If the predicate fails it
|
|
649
|
+
* applies nothing and returns the structured `reject`. `idempotencyKey` names a
|
|
650
|
+
* record whose prior existence proves the set already committed, so a retry
|
|
651
|
+
* returns the earlier outcome instead of applying the set (and its non-idempotent
|
|
652
|
+
* counter increments) again.
|
|
653
|
+
*
|
|
654
|
+
* Atomicity note: within one server instance the check and the writes do not
|
|
655
|
+
* interleave with other route mutations. Cross-instance, race-free admission and
|
|
656
|
+
* all-or-nothing across a mid-set crash require the store-level conditional
|
|
657
|
+
* transaction (a Postgres `WHERE ... < cap` commit), which is the next increment.
|
|
658
|
+
*/
|
|
659
|
+
interface RouteConditionalApply {
|
|
660
|
+
/** Collection of the record the predicate reads and the `update` targets. */
|
|
661
|
+
collection: string;
|
|
662
|
+
/** Id of the target record. */
|
|
663
|
+
id: string;
|
|
664
|
+
/** Admission predicate evaluated against the target's current state. */
|
|
665
|
+
if?: RoutePredicate;
|
|
666
|
+
/** Partial update applied to the target record when the predicate holds. */
|
|
667
|
+
update?: Record<string, unknown> | null;
|
|
668
|
+
/** Additional mutations committed atomically with the target update. */
|
|
669
|
+
also?: RouteMutation[];
|
|
670
|
+
/** Structured rejection returned when the predicate does not hold. */
|
|
671
|
+
reject?: {
|
|
672
|
+
code: string;
|
|
673
|
+
message: string;
|
|
674
|
+
};
|
|
182
675
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* @param collection - The collection name to query
|
|
187
|
-
* @param options - Query options (where, orderBy, limit, offset)
|
|
188
|
-
* @returns Array of matching records
|
|
676
|
+
* Names a record whose existence means this set already committed. When it is
|
|
677
|
+
* present the set is skipped and the prior outcome returned. Use a stable,
|
|
678
|
+
* client-generated id (for example the accepted record's own id).
|
|
189
679
|
*/
|
|
190
|
-
|
|
680
|
+
idempotencyKey?: {
|
|
681
|
+
collection: string;
|
|
682
|
+
id: string;
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
/** Result of {@link ProductionHttpRouteContext.applyConditional}. */
|
|
686
|
+
type RouteConditionalResult = {
|
|
687
|
+
ok: true;
|
|
688
|
+
/** True when the set was already committed and this call was a no-op. */
|
|
689
|
+
idempotent: boolean;
|
|
690
|
+
operations: Operation[];
|
|
691
|
+
records: (MaterializedRecord | null)[];
|
|
692
|
+
} | {
|
|
693
|
+
ok: false;
|
|
694
|
+
code: string;
|
|
695
|
+
message: string;
|
|
696
|
+
retriable: boolean;
|
|
697
|
+
};
|
|
698
|
+
/**
|
|
699
|
+
* Scoped, validated data-plane access handed to custom HTTP route handlers.
|
|
700
|
+
*
|
|
701
|
+
* Every mutation goes through the same pipeline as sync (Tier 2 constraints,
|
|
702
|
+
* referential integrity, materialization, and fan-out to connected clients), so
|
|
703
|
+
* REST endpoints cannot accidentally bypass validation, constraints, or tenant
|
|
704
|
+
* isolation the way hand-rolled store writes can.
|
|
705
|
+
*/
|
|
706
|
+
interface ProductionHttpRouteContext {
|
|
191
707
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* @param collection - The collection name
|
|
196
|
-
* @param id - The record ID
|
|
197
|
-
* @returns The record or null if not found (or deleted)
|
|
708
|
+
* Apply a mutation through the validated server pipeline and relay the
|
|
709
|
+
* resulting operation(s) to connected clients.
|
|
198
710
|
*/
|
|
199
|
-
|
|
711
|
+
apply(mutation: RouteMutation, options?: RouteScopeOptions): Promise<RouteApplyResult>;
|
|
200
712
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* @param collection - The collection name
|
|
205
|
-
* @param where - Optional exact-match filters
|
|
206
|
-
* @returns Number of matching records
|
|
713
|
+
* Conditionally apply a multi-collection mutation set as one admission gate:
|
|
714
|
+
* read the target, check the predicate, and commit the update plus every `also`
|
|
715
|
+
* mutation only if it holds, with at-most-once semantics via `idempotencyKey`.
|
|
207
716
|
*/
|
|
208
|
-
|
|
717
|
+
applyConditional(spec: RouteConditionalApply, options?: RouteScopeOptions): Promise<RouteConditionalResult>;
|
|
718
|
+
/** Read materialized records from a collection, optionally scoped. */
|
|
719
|
+
query(collection: string, options?: CollectionQueryOptions & RouteScopeOptions): Promise<MaterializedRecord[]>;
|
|
720
|
+
/** Read a single materialized record by id, optionally scoped. */
|
|
721
|
+
findById(collection: string, id: string, options?: RouteScopeOptions): Promise<MaterializedRecord | null>;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* What the server hands an operation validator so it can adjudicate an untrusted
|
|
726
|
+
* client operation before that operation becomes authoritative.
|
|
727
|
+
*
|
|
728
|
+
* The validator is POLICY (yours); the framework owns the MECHANISM around it —
|
|
729
|
+
* running it at ingestion, and routing its decision deterministically.
|
|
730
|
+
*/
|
|
731
|
+
interface OperationValidationContext {
|
|
209
732
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
733
|
+
* The authenticated actor for the submitting session, or `null` for an
|
|
734
|
+
* anonymous / unauthenticated connection. This is how a public submission
|
|
735
|
+
* endpoint tells "signed-in owner" from "anonymous respondent" apart.
|
|
213
736
|
*/
|
|
214
|
-
|
|
737
|
+
auth: AuthContext | null;
|
|
215
738
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
739
|
+
* Trusted, scoped data-plane access — the same context available as
|
|
740
|
+
* `server.kora` / `request.kora`. Use `kora.query` / `kora.findById` to read
|
|
741
|
+
* current authoritative state while deciding, and `kora.apply` to AUTHOR a
|
|
742
|
+
* derived server operation (for example promoting a validated anonymous
|
|
743
|
+
* submission into an owner-visible collection). Authoring a new operation is
|
|
744
|
+
* always preferred over mutating the incoming one, which must stay immutable
|
|
745
|
+
* so content-addressing and convergence hold.
|
|
221
746
|
*/
|
|
222
|
-
|
|
223
|
-
operationsRestored: number;
|
|
224
|
-
success: boolean;
|
|
225
|
-
}>;
|
|
747
|
+
kora: ProductionHttpRouteContext;
|
|
226
748
|
}
|
|
749
|
+
/**
|
|
750
|
+
* The verdict a validator returns for one incoming operation.
|
|
751
|
+
*
|
|
752
|
+
* - `accept` — let the operation materialize on the server as-is and relay to
|
|
753
|
+
* connected clients, exactly as an untrusted op would flow without a validator.
|
|
754
|
+
* - `reject` — refuse it. The operation never enters the authoritative log, so no
|
|
755
|
+
* other replica ever sees it; a structured rejection travels back to the
|
|
756
|
+
* submitter tied to the operation id, and the submitter keeps the op in a
|
|
757
|
+
* durable rejected store (recoverable and explainable) rather than losing it or
|
|
758
|
+
* retrying forever. `retriable` defaults from the shared taxonomy for the code.
|
|
759
|
+
* - `ignore` — the server takes responsibility for the operation out of band (for
|
|
760
|
+
* example the validator already authored a derived op via `context.kora`) and
|
|
761
|
+
* does not want the raw incoming op materialized. No rejection is sent, so the
|
|
762
|
+
* submitter treats it as handled and drops it from its pending queue.
|
|
763
|
+
*/
|
|
764
|
+
type OperationDecision = {
|
|
765
|
+
action: 'accept';
|
|
766
|
+
} | {
|
|
767
|
+
action: 'reject';
|
|
768
|
+
code: string;
|
|
769
|
+
message: string;
|
|
770
|
+
retriable?: boolean;
|
|
771
|
+
} | {
|
|
772
|
+
action: 'ignore';
|
|
773
|
+
};
|
|
774
|
+
/**
|
|
775
|
+
* An app-provided function that adjudicates an untrusted client operation at sync
|
|
776
|
+
* ingestion, after HLC ordering and the built-in guards (timestamp, rate, size),
|
|
777
|
+
* and before the operation is materialized into authoritative state.
|
|
778
|
+
*/
|
|
779
|
+
type OperationValidator = (operation: Operation, context: OperationValidationContext) => Promise<OperationDecision> | OperationDecision;
|
|
227
780
|
|
|
228
781
|
/**
|
|
229
782
|
* Authenticated client context. Returned by an AuthProvider after validation.
|
|
@@ -268,7 +821,21 @@ interface KoraSyncServerConfig {
|
|
|
268
821
|
maxConnections?: number;
|
|
269
822
|
/** Maximum operations per sync batch. Defaults to 100. */
|
|
270
823
|
batchSize?: number;
|
|
271
|
-
/**
|
|
824
|
+
/**
|
|
825
|
+
* How often to retransmit unacknowledged non-watermark relay batches, in
|
|
826
|
+
* milliseconds. Defaults to 2000. Set to 0 to disable periodic retransmit.
|
|
827
|
+
*/
|
|
828
|
+
relayRetransmitIntervalMs?: number;
|
|
829
|
+
/**
|
|
830
|
+
* How often the server checks the authoritative delivery log for writes that did
|
|
831
|
+
* not pass through this KoraSyncServer instance, in milliseconds. Defaults to
|
|
832
|
+
* 2000. Set to 0 to disable polling.
|
|
833
|
+
*/
|
|
834
|
+
deliveryPollIntervalMs?: number;
|
|
835
|
+
/**
|
|
836
|
+
* Schema version the server expects. Defaults to `store.getSchema()?.version`
|
|
837
|
+
* when the store has been configured with a schema, otherwise `1`.
|
|
838
|
+
*/
|
|
272
839
|
schemaVersion?: number;
|
|
273
840
|
/**
|
|
274
841
|
* Inclusive range of client schema versions accepted at handshake.
|
|
@@ -278,6 +845,13 @@ interface KoraSyncServerConfig {
|
|
|
278
845
|
min: number;
|
|
279
846
|
max: number;
|
|
280
847
|
};
|
|
848
|
+
/**
|
|
849
|
+
* Transform accepted legacy client operations into the server schema version
|
|
850
|
+
* before validation and materialization. Required when
|
|
851
|
+
* `supportedSchemaVersions.min` is lower than `schemaVersion` and operation
|
|
852
|
+
* shapes changed across versions.
|
|
853
|
+
*/
|
|
854
|
+
operationTransforms?: OperationTransform[];
|
|
281
855
|
/** WebSocket path (standalone mode). Defaults to '/'. */
|
|
282
856
|
path?: string;
|
|
283
857
|
/** Structured logger. Defaults to pretty-print in dev, JSON lines in production. */
|
|
@@ -286,6 +860,48 @@ interface KoraSyncServerConfig {
|
|
|
286
860
|
metricsCollector?: ServerMetricsCollector;
|
|
287
861
|
/** Enable built-in dashboard and metrics endpoints. Defaults to true. */
|
|
288
862
|
enableDashboard?: boolean;
|
|
863
|
+
/**
|
|
864
|
+
* Resolve a blob chunk by content hash from server-side storage. Optional.
|
|
865
|
+
* When provided, the server answers blob chunk requests directly from its own
|
|
866
|
+
* store; when absent, the server relays chunk requests among connected peers
|
|
867
|
+
* without storing any blob bytes itself.
|
|
868
|
+
*/
|
|
869
|
+
resolveBlobChunk?: (hash: string) => Promise<Uint8Array | null>;
|
|
870
|
+
/**
|
|
871
|
+
* Persist a client-uploaded blob chunk (or manifest) centrally, keyed by its
|
|
872
|
+
* content hash. Optional. When provided, the server advertises central blob
|
|
873
|
+
* storage at handshake, clients upload the bytes behind their `blob` fields,
|
|
874
|
+
* and those bytes remain available to other devices after the authoring device
|
|
875
|
+
* goes offline. Pair with `resolveBlobChunk` (both backed by the same store)
|
|
876
|
+
* so uploaded blobs can then be served. `toServerBlobCallbacks` in
|
|
877
|
+
* `@korajs/store` derives both from a `ContentAddressedBlobStore`.
|
|
878
|
+
*/
|
|
879
|
+
persistBlobChunk?: (hash: string, bytes: Uint8Array) => Promise<void> | void;
|
|
880
|
+
/**
|
|
881
|
+
* Maximum serialized byte size of a single client operation accepted at sync
|
|
882
|
+
* ingest. Operations larger than this are rejected before materialization.
|
|
883
|
+
* Defaults to 256 KiB. Set once here to enforce one payload cap across every
|
|
884
|
+
* connected client instead of configuring each session.
|
|
885
|
+
*/
|
|
886
|
+
maxOperationBytes?: number;
|
|
887
|
+
/**
|
|
888
|
+
* Maximum operations accepted per connected client per minute (sliding window).
|
|
889
|
+
* Operations beyond the limit are rejected until the window resets. Defaults to
|
|
890
|
+
* 600. Set once here to enforce one rate cap across every connected client.
|
|
891
|
+
*/
|
|
892
|
+
maxOpsPerMinute?: number;
|
|
893
|
+
/**
|
|
894
|
+
* Adjudicate untrusted client operations before they become authoritative.
|
|
895
|
+
*
|
|
896
|
+
* Runs at sync ingestion for every incoming client operation, after HLC
|
|
897
|
+
* ordering and the built-in guards, and before materialization. Return
|
|
898
|
+
* `accept` to let it through, `reject` to refuse it (a structured rejection
|
|
899
|
+
* travels back to the submitter and the op never enters the authoritative
|
|
900
|
+
* log), or `ignore` when the server has handled it out of band. This is what
|
|
901
|
+
* lets Kora serve public / multi-tenant apps where the client is not trusted.
|
|
902
|
+
* Omit it and every operation is accepted, as before.
|
|
903
|
+
*/
|
|
904
|
+
validateOperation?: OperationValidator;
|
|
289
905
|
}
|
|
290
906
|
/**
|
|
291
907
|
* Request envelope for the server-side HTTP sync endpoint.
|
|
@@ -439,6 +1055,20 @@ type AwarenessRelayCallback = (sourceSessionId: string, message: AwarenessUpdate
|
|
|
439
1055
|
* Callback invoked when a session receives a Yjs doc channel update to relay.
|
|
440
1056
|
*/
|
|
441
1057
|
type YjsDocRelayCallback = (sourceSessionId: string, message: YjsDocUpdateMessage) => void;
|
|
1058
|
+
/**
|
|
1059
|
+
* Callback invoked when a session receives a blob chunk request to route.
|
|
1060
|
+
*/
|
|
1061
|
+
type BlobChunkRequestCallback = (sourceSessionId: string, message: BlobChunkRequestMessage) => void;
|
|
1062
|
+
/**
|
|
1063
|
+
* Callback invoked when a session receives a blob chunk response to route back.
|
|
1064
|
+
*/
|
|
1065
|
+
type BlobChunkResponseCallback = (sourceSessionId: string, message: BlobChunkResponseMessage) => void;
|
|
1066
|
+
/**
|
|
1067
|
+
* Persist a blob chunk (or manifest) uploaded by a client, keyed by its content
|
|
1068
|
+
* hash. Provided by the server operator; enables central blob storage so bytes
|
|
1069
|
+
* survive the authoring device going offline.
|
|
1070
|
+
*/
|
|
1071
|
+
type PersistBlobChunk = (hash: string, bytes: Uint8Array) => Promise<void> | void;
|
|
442
1072
|
/**
|
|
443
1073
|
* Options for creating a ClientSession.
|
|
444
1074
|
*/
|
|
@@ -455,6 +1085,8 @@ interface ClientSessionOptions {
|
|
|
455
1085
|
serializer?: MessageSerializer;
|
|
456
1086
|
/** Event emitter for DevTools integration */
|
|
457
1087
|
emitter?: KoraEventEmitter;
|
|
1088
|
+
/** Structured logger used for protocol and persistence failures */
|
|
1089
|
+
logger?: Logger;
|
|
458
1090
|
/** Max operations per sync batch */
|
|
459
1091
|
batchSize?: number;
|
|
460
1092
|
/** Schema version the server expects */
|
|
@@ -464,18 +1096,47 @@ interface ClientSessionOptions {
|
|
|
464
1096
|
min: number;
|
|
465
1097
|
max: number;
|
|
466
1098
|
};
|
|
1099
|
+
/** Transform accepted legacy operations into the server schema before validation. */
|
|
1100
|
+
operationTransforms?: OperationTransform[];
|
|
467
1101
|
/** Called when this session has operations to relay to other sessions */
|
|
468
1102
|
onRelay?: RelayCallback;
|
|
469
1103
|
/** Called when this session receives an awareness update to broadcast */
|
|
470
1104
|
onAwarenessUpdate?: AwarenessRelayCallback;
|
|
471
1105
|
/** Called when this session receives a Yjs doc channel update to broadcast */
|
|
472
1106
|
onYjsDocUpdate?: YjsDocRelayCallback;
|
|
1107
|
+
/** Called when this session receives a blob chunk request to route */
|
|
1108
|
+
onBlobChunkRequest?: BlobChunkRequestCallback;
|
|
1109
|
+
/** Called when this session receives a blob chunk response to route back */
|
|
1110
|
+
onBlobChunkResponse?: BlobChunkResponseCallback;
|
|
1111
|
+
/** Persist a client-uploaded blob chunk centrally (keyed by content hash) */
|
|
1112
|
+
persistBlobChunk?: PersistBlobChunk;
|
|
473
1113
|
/** Called when this session closes */
|
|
474
1114
|
onClose?: (sessionId: string) => void;
|
|
1115
|
+
/**
|
|
1116
|
+
* Called on close with relay operations this client never acknowledged, so the
|
|
1117
|
+
* server can buffer them per node id and redeliver on the client's next
|
|
1118
|
+
* connection. Closes the window where a relay dropped just before a reconnect
|
|
1119
|
+
* would otherwise be lost (the per-session retransmit tick never fires in time).
|
|
1120
|
+
*/
|
|
1121
|
+
onOrphanedRelays?: (nodeId: string, ops: Operation[]) => void;
|
|
1122
|
+
/**
|
|
1123
|
+
* Called once this session reaches streaming to pull any relay operations buffered
|
|
1124
|
+
* for its node id while it was disconnected. They are re-sent through the normal
|
|
1125
|
+
* relay path (re-filtered by this session's current scope).
|
|
1126
|
+
*/
|
|
1127
|
+
takeOrphanedRelays?: (nodeId: string) => Operation[];
|
|
475
1128
|
/** Maximum serialized operation size in bytes. Defaults to 256 KiB. */
|
|
476
1129
|
maxOperationBytes?: number;
|
|
477
1130
|
/** Maximum operations accepted per minute for this session. Defaults to 600. */
|
|
478
1131
|
maxOpsPerMinute?: number;
|
|
1132
|
+
/**
|
|
1133
|
+
* Adjudicate untrusted client operations before materialization. When present,
|
|
1134
|
+
* each incoming operation is passed to this validator; a `reject` decision
|
|
1135
|
+
* sends an operation-rejected message and skips materialization.
|
|
1136
|
+
*/
|
|
1137
|
+
validateOperation?: OperationValidator;
|
|
1138
|
+
/** Trusted data-plane context handed to the validator (read state, author derived ops). */
|
|
1139
|
+
koraContext?: ProductionHttpRouteContext;
|
|
479
1140
|
}
|
|
480
1141
|
/**
|
|
481
1142
|
* Handles the sync protocol for a single connected client.
|
|
@@ -497,22 +1158,67 @@ declare class ClientSession {
|
|
|
497
1158
|
private authContext;
|
|
498
1159
|
private syncQuerySubsets;
|
|
499
1160
|
private resumeDeltaCursor;
|
|
1161
|
+
/**
|
|
1162
|
+
* The delivery watermark the client reported at handshake, or null when the client
|
|
1163
|
+
* does not use the delivery watermark (old client, or no watermark yet). When set,
|
|
1164
|
+
* the server drives the gap-free server->client stream from delivery sequences
|
|
1165
|
+
* instead of the version-vector delta.
|
|
1166
|
+
*/
|
|
1167
|
+
private clientDeliveryWatermark;
|
|
1168
|
+
/**
|
|
1169
|
+
* The highest delivery sequence this client has ACKNOWLEDGED (its confirmed
|
|
1170
|
+
* watermark, as reported in acks). Incremental streaming pushes resume from here, not
|
|
1171
|
+
* from the highest sequence sent, so a dropped or unapplied batch is always
|
|
1172
|
+
* re-included by the next push (which re-scans from the acknowledged position). This
|
|
1173
|
+
* is what makes streaming recovery independent of any bounded retransmit buffer: there
|
|
1174
|
+
* is no sent-but-unacked window that a buffer eviction could strand. Seeded from the
|
|
1175
|
+
* client's reported watermark at handshake.
|
|
1176
|
+
*/
|
|
1177
|
+
private lastAckedDeliverySeq;
|
|
1178
|
+
/**
|
|
1179
|
+
* Timestamp of the last delivery-stream send attempt. Periodic retransmission uses
|
|
1180
|
+
* this as its stale window so a slow client does not receive duplicate full-stream
|
|
1181
|
+
* batches every server tick while the first batch is still being applied.
|
|
1182
|
+
*/
|
|
1183
|
+
private lastDeliveryPushAttemptAtMs;
|
|
1184
|
+
private deliveryStallRepeatCount;
|
|
1185
|
+
/** Serializes incremental delivery pushes so their batches never interleave. */
|
|
1186
|
+
private deliveryPushChain;
|
|
1187
|
+
/**
|
|
1188
|
+
* Relay batches sent to this client but not yet acknowledged, keyed by messageId.
|
|
1189
|
+
* The client acks every operation-batch it applies; on ack the entry is cleared.
|
|
1190
|
+
* {@link retransmitPendingRelays} re-sends anything still unacked, so a relay
|
|
1191
|
+
* dropped by a lossy transport is redelivered instead of leaving the client with a
|
|
1192
|
+
* permanent version-vector gap (a lost operation) that delta sync cannot recover.
|
|
1193
|
+
* Bounded so a silent client cannot grow it without limit.
|
|
1194
|
+
*/
|
|
1195
|
+
private readonly pendingRelays;
|
|
1196
|
+
private static readonly MAX_PENDING_RELAYS;
|
|
500
1197
|
private readonly sessionId;
|
|
501
1198
|
private readonly transport;
|
|
502
1199
|
private readonly store;
|
|
503
1200
|
private readonly auth;
|
|
504
1201
|
private readonly serializer;
|
|
505
1202
|
private readonly emitter;
|
|
1203
|
+
private readonly logger;
|
|
506
1204
|
private readonly batchSize;
|
|
507
1205
|
private readonly schemaVersion;
|
|
508
1206
|
private readonly supportedSchemaVersions;
|
|
1207
|
+
private readonly operationTransforms;
|
|
509
1208
|
private readonly onRelay;
|
|
510
1209
|
private readonly onAwarenessUpdate;
|
|
511
1210
|
private readonly onYjsDocUpdate;
|
|
1211
|
+
private readonly onBlobChunkRequest;
|
|
1212
|
+
private readonly onBlobChunkResponse;
|
|
1213
|
+
private readonly persistBlobChunk;
|
|
512
1214
|
private readonly onClose;
|
|
1215
|
+
private readonly onOrphanedRelays;
|
|
1216
|
+
private readonly takeOrphanedRelays;
|
|
513
1217
|
private readonly maxOperationBytes;
|
|
514
1218
|
private readonly maxOpsPerMinute;
|
|
515
1219
|
private readonly rateLimiter;
|
|
1220
|
+
private readonly validateOperation;
|
|
1221
|
+
private readonly koraContext;
|
|
516
1222
|
constructor(options: ClientSessionOptions);
|
|
517
1223
|
/**
|
|
518
1224
|
* Start handling messages from the client transport.
|
|
@@ -523,9 +1229,39 @@ declare class ClientSession {
|
|
|
523
1229
|
* Only relays if the session is in streaming state and transport is connected.
|
|
524
1230
|
*/
|
|
525
1231
|
relayOperations(operations: Operation[]): void;
|
|
1232
|
+
/**
|
|
1233
|
+
* Push newly-available in-scope operations to a delivery-watermark client, resuming
|
|
1234
|
+
* from the highest delivery sequence already sent. Pushes are serialized so their
|
|
1235
|
+
* batches never interleave (which would break the base/max chain).
|
|
1236
|
+
*/
|
|
1237
|
+
private pushDeliveryStream;
|
|
1238
|
+
private relayVisibleOperations;
|
|
1239
|
+
/** Record a relay batch as awaiting acknowledgment, evicting the oldest if full. */
|
|
1240
|
+
private trackPendingRelay;
|
|
1241
|
+
/**
|
|
1242
|
+
* Retransmit relay batches this client has not acknowledged within `staleMs`.
|
|
1243
|
+
* Called on a periodic tick by the server (and directly by tests). Redelivering an
|
|
1244
|
+
* already-applied op is harmless: the client dedups by content-addressed id.
|
|
1245
|
+
*/
|
|
1246
|
+
retransmitPendingRelays(staleMs?: number): void;
|
|
1247
|
+
/**
|
|
1248
|
+
* Wake the durable delivery stream for clients that negotiated delivery
|
|
1249
|
+
* watermarks. This is used both for dropped watermark batches and for operations
|
|
1250
|
+
* appended by another server/store instance: the session always scans from its
|
|
1251
|
+
* own acknowledged cursor and applies its visibility filter before sending.
|
|
1252
|
+
*/
|
|
1253
|
+
pushDeliveryStreamIfSupported(staleMs?: number, options?: {
|
|
1254
|
+
trackStall?: boolean;
|
|
1255
|
+
}): void;
|
|
526
1256
|
/**
|
|
527
1257
|
* Close this session.
|
|
528
1258
|
*/
|
|
1259
|
+
/**
|
|
1260
|
+
* Hand any unacknowledged relays to the server so they survive this session and are
|
|
1261
|
+
* redelivered when this client reconnects (per-node buffer), then clear them. Called
|
|
1262
|
+
* from every session-teardown path (explicit close and transport close).
|
|
1263
|
+
*/
|
|
1264
|
+
private flushOrphanedRelays;
|
|
529
1265
|
close(reason?: string): void;
|
|
530
1266
|
getState(): SessionState;
|
|
531
1267
|
getSessionId(): string;
|
|
@@ -542,119 +1278,57 @@ declare class ClientSession {
|
|
|
542
1278
|
private sendToClient;
|
|
543
1279
|
private enqueueMessage;
|
|
544
1280
|
private handleMessageAsync;
|
|
1281
|
+
/**
|
|
1282
|
+
* Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
|
|
1283
|
+
* verified to hash to the declared hash before storing, so a corrupt or
|
|
1284
|
+
* mislabeled upload is rejected rather than served later as trusted content.
|
|
1285
|
+
*/
|
|
1286
|
+
private handleBlobChunkPush;
|
|
545
1287
|
private handleMessageFailure;
|
|
546
1288
|
private handleHandshake;
|
|
547
1289
|
private handleOperationBatch;
|
|
1290
|
+
private transformForServerSchema;
|
|
548
1291
|
private sendDelta;
|
|
549
|
-
private operationVisibleToClient;
|
|
550
|
-
private handleAwarenessUpdate;
|
|
551
|
-
private handleYjsDocUpdate;
|
|
552
|
-
private sendError;
|
|
553
|
-
private setSerializerWireFormat;
|
|
554
|
-
private handleTransportClose;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* Minimal interface for a ws.WebSocketServer instance.
|
|
559
|
-
* Allows dependency injection for testing without importing ws directly.
|
|
560
|
-
*/
|
|
561
|
-
interface WsServerLike {
|
|
562
|
-
on(event: string, listener: (...args: unknown[]) => void): void;
|
|
563
|
-
close(callback?: (err?: Error) => void): void;
|
|
564
|
-
address(): {
|
|
565
|
-
port: number;
|
|
566
|
-
} | string | null;
|
|
567
|
-
}
|
|
568
|
-
/**
|
|
569
|
-
* Constructor type for creating a WebSocket server.
|
|
570
|
-
*/
|
|
571
|
-
type WsServerConstructor = new (options: {
|
|
572
|
-
port?: number;
|
|
573
|
-
host?: string;
|
|
574
|
-
path?: string;
|
|
575
|
-
}) => WsServerLike;
|
|
576
|
-
/**
|
|
577
|
-
* Self-hosted sync server. Accepts WebSocket connections from clients,
|
|
578
|
-
* handles the sync protocol, stores operations, and relays changes
|
|
579
|
-
* between connected clients.
|
|
580
|
-
*
|
|
581
|
-
* Two modes of operation:
|
|
582
|
-
* 1. **Standalone**: Call `start()` with a port — creates its own WebSocket server.
|
|
583
|
-
* 2. **Attach**: Call `handleConnection(transport)` — attach to an existing HTTP server.
|
|
584
|
-
*/
|
|
585
|
-
declare class KoraSyncServer {
|
|
586
|
-
private readonly store;
|
|
587
|
-
private readonly auth;
|
|
588
|
-
private readonly serializer;
|
|
589
|
-
private readonly emitter;
|
|
590
|
-
private readonly maxConnections;
|
|
591
|
-
private readonly batchSize;
|
|
592
|
-
private readonly schemaVersion;
|
|
593
|
-
private readonly supportedSchemaVersions;
|
|
594
|
-
private readonly port;
|
|
595
|
-
private readonly host;
|
|
596
|
-
private readonly path;
|
|
597
|
-
private readonly logger;
|
|
598
|
-
private readonly metrics;
|
|
599
|
-
private readonly awarenessRelay;
|
|
600
|
-
private readonly yjsDocRelay;
|
|
601
|
-
private readonly sessions;
|
|
602
|
-
private readonly httpClients;
|
|
603
|
-
private readonly httpSessionToClient;
|
|
604
|
-
private readonly serverVersion;
|
|
605
|
-
private wsServer;
|
|
606
|
-
private running;
|
|
607
|
-
constructor(config: KoraSyncServerConfig);
|
|
608
|
-
/**
|
|
609
|
-
* Subscribe to session-level events for metrics collection and logging.
|
|
610
|
-
* Called when a new session is created.
|
|
611
|
-
*/
|
|
612
|
-
private attachSessionEvents;
|
|
613
|
-
/**
|
|
614
|
-
* Get the metrics collector for external access (e.g., HTTP endpoints).
|
|
615
|
-
*/
|
|
616
|
-
getMetricsCollector(): ServerMetricsCollector;
|
|
617
|
-
/**
|
|
618
|
-
* Get the logger for external access (e.g., event streaming).
|
|
619
|
-
*/
|
|
620
|
-
getLogger(): Logger;
|
|
621
1292
|
/**
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
*
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
* A stable `clientId` identifies the logical connection across requests.
|
|
635
|
-
*/
|
|
636
|
-
handleHttpRequest(request: HttpSyncRequest): Promise<HttpSyncResponse>;
|
|
637
|
-
/**
|
|
638
|
-
* Handle an incoming client connection (attach mode).
|
|
639
|
-
* Creates a new ClientSession for the transport.
|
|
1293
|
+
* Send the gap-free server->client delivery stream, resuming just after
|
|
1294
|
+
* `fromDeliverySeq`. Operations are scanned in server delivery-sequence order
|
|
1295
|
+
* (commit order), scope-filtered for this session, and sent in batches that chain:
|
|
1296
|
+
* each batch's `baseDeliverySequence` equals the previous batch's
|
|
1297
|
+
* `maxDeliverySequence` (the first batch bases on `fromDeliverySeq`). The client
|
|
1298
|
+
* applies a batch only when its watermark equals the base and advances the
|
|
1299
|
+
* watermark to the max, so a dropped batch stalls the watermark and is recovered by
|
|
1300
|
+
* the next handshake resend. Because delivery-sequence order respects causal order
|
|
1301
|
+
* (a dependency is always committed, and thus sequenced, before its dependent), no
|
|
1302
|
+
* topological sort is needed and the client never defers on a missing dependency
|
|
1303
|
+
* that is itself in this stream.
|
|
640
1304
|
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
1305
|
+
* The final batch advances the watermark to the highest delivery sequence scanned,
|
|
1306
|
+
* not merely the last in-scope one, so an out-of-scope tail is not re-scanned on the
|
|
1307
|
+
* next reconnect. Returns the number of operations actually sent.
|
|
643
1308
|
*/
|
|
644
|
-
|
|
1309
|
+
private sendDeliveryStream;
|
|
1310
|
+
/** Build, track, and send one chained delivery-stream batch. */
|
|
1311
|
+
private sendDeliveryBatch;
|
|
1312
|
+
private operationVisibleToClient;
|
|
645
1313
|
/**
|
|
646
|
-
*
|
|
1314
|
+
* Read a record's current field values from the materialized store for scope /
|
|
1315
|
+
* query-subset backfill. Includes soft-deleted rows so a relayed delete (whose op
|
|
1316
|
+
* carries no fields) is still judged against the record's actual scope. Returns
|
|
1317
|
+
* undefined when the record cannot be read (never materialized, or no schema).
|
|
647
1318
|
*/
|
|
648
|
-
|
|
1319
|
+
private lookupRecordFields;
|
|
1320
|
+
private handleAwarenessUpdate;
|
|
1321
|
+
private handleYjsDocUpdate;
|
|
1322
|
+
private sendError;
|
|
649
1323
|
/**
|
|
650
|
-
*
|
|
1324
|
+
* Reject one specific client operation, tied to its id, so the submitter can
|
|
1325
|
+
* divert it out of its pending queue into a durable rejected store rather than
|
|
1326
|
+
* losing it or retrying forever. The op is NOT materialized, so no other
|
|
1327
|
+
* replica ever sees it.
|
|
651
1328
|
*/
|
|
652
|
-
|
|
653
|
-
private
|
|
654
|
-
private
|
|
655
|
-
private handleAwarenessRelay;
|
|
656
|
-
private handleYjsDocRelay;
|
|
657
|
-
private getOrCreateHttpClient;
|
|
1329
|
+
private sendOperationRejected;
|
|
1330
|
+
private setSerializerWireFormat;
|
|
1331
|
+
private handleTransportClose;
|
|
658
1332
|
}
|
|
659
1333
|
|
|
660
1334
|
/**
|
|
@@ -672,6 +1346,13 @@ interface ProductionServerConfig {
|
|
|
672
1346
|
syncPath?: string;
|
|
673
1347
|
/** Additional KoraSyncServer options */
|
|
674
1348
|
syncOptions?: Omit<KoraSyncServerConfig, 'store' | 'port' | 'host' | 'path'>;
|
|
1349
|
+
/**
|
|
1350
|
+
* Cross-Origin-Embedder-Policy for static and route responses. Defaults to
|
|
1351
|
+
* `credentialless`, which keeps third-party embeds usable while allowing
|
|
1352
|
+
* capable browsers to enable cross-origin isolation. Use `require-corp` only
|
|
1353
|
+
* when every embedded resource explicitly opts in with CORP/CORS headers.
|
|
1354
|
+
*/
|
|
1355
|
+
crossOriginEmbedderPolicy?: 'credentialless' | 'require-corp' | 'unsafe-none';
|
|
675
1356
|
/**
|
|
676
1357
|
* Optional HTTP route handlers mounted before static file serving.
|
|
677
1358
|
*
|
|
@@ -704,6 +1385,13 @@ interface ProductionHttpRouteRequest {
|
|
|
704
1385
|
headers?: Record<string, string | string[] | undefined>;
|
|
705
1386
|
query?: Record<string, string | string[] | undefined>;
|
|
706
1387
|
ip?: string;
|
|
1388
|
+
/**
|
|
1389
|
+
* Scoped, validated data-plane access. Use `kora.apply()` to mutate through
|
|
1390
|
+
* the same pipeline as sync (constraints, referential integrity, fan-out) and
|
|
1391
|
+
* `kora.query()` / `kora.findById()` to read materialized state, instead of
|
|
1392
|
+
* writing to the store directly and bypassing those guarantees.
|
|
1393
|
+
*/
|
|
1394
|
+
kora: ProductionHttpRouteContext;
|
|
707
1395
|
}
|
|
708
1396
|
interface ProductionHttpRouteResponse {
|
|
709
1397
|
status: number;
|
|
@@ -723,6 +1411,29 @@ interface ProductionServer {
|
|
|
723
1411
|
start(): Promise<string>;
|
|
724
1412
|
/** Stop the server gracefully. */
|
|
725
1413
|
stop(): Promise<void>;
|
|
1414
|
+
/**
|
|
1415
|
+
* Trusted, scoped data-plane access for server-side callers that have no HTTP
|
|
1416
|
+
* request — background jobs, scheduled tasks, seeding scripts.
|
|
1417
|
+
*
|
|
1418
|
+
* `kora.apply()` runs the mutation through the exact same validated pipeline as
|
|
1419
|
+
* sync (Tier 2 constraints, referential integrity, materialization, and fan-out
|
|
1420
|
+
* to connected clients), and `kora.query()` / `kora.findById()` read
|
|
1421
|
+
* materialized state. This is the same context handed to custom HTTP routes as
|
|
1422
|
+
* `request.kora`, so a job and a request share one code path and one set of
|
|
1423
|
+
* guarantees instead of the job writing to the store directly and silently
|
|
1424
|
+
* bypassing validation.
|
|
1425
|
+
*/
|
|
1426
|
+
kora: ProductionHttpRouteContext;
|
|
1427
|
+
/**
|
|
1428
|
+
* Every blob reference still reachable from a live record across all
|
|
1429
|
+
* collections that declare a `blob` field. This is the live set for
|
|
1430
|
+
* garbage-collecting the server's central blob store: pass it to
|
|
1431
|
+
* `collectBlobGarbage(blobStore, refs)` from `@korajs/store` on a schedule to
|
|
1432
|
+
* reclaim bytes no record points at any more.
|
|
1433
|
+
*
|
|
1434
|
+
* @returns Every live blob reference the server can currently see.
|
|
1435
|
+
*/
|
|
1436
|
+
getLiveBlobRefs(): Promise<BlobRef[]>;
|
|
726
1437
|
}
|
|
727
1438
|
/**
|
|
728
1439
|
* Creates a production server that serves both static files and WebSocket sync
|
|
@@ -965,6 +1676,14 @@ declare class MemoryServerStore implements ServerStore {
|
|
|
965
1676
|
private readonly operations;
|
|
966
1677
|
private readonly operationIndex;
|
|
967
1678
|
private readonly versionVector;
|
|
1679
|
+
/**
|
|
1680
|
+
* Server-assigned delivery sequence per operation id. Single-process, so a plain
|
|
1681
|
+
* counter incremented at insert time gives commit-order delivery sequence with no
|
|
1682
|
+
* gaps. `operations` is kept in insertion (== delivery) order, so a delivery scan
|
|
1683
|
+
* is a forward walk.
|
|
1684
|
+
*/
|
|
1685
|
+
private readonly deliverySeqByOpId;
|
|
1686
|
+
private deliverySeqCounter;
|
|
968
1687
|
private schema;
|
|
969
1688
|
/** Materialized records: collection -> recordId -> record data */
|
|
970
1689
|
private readonly materializedRecords;
|
|
@@ -977,6 +1696,8 @@ declare class MemoryServerStore implements ServerStore {
|
|
|
977
1696
|
applyRemoteOperation(op: Operation): Promise<ApplyResult>;
|
|
978
1697
|
getOperationRange(nodeId: string, fromSeq: number, toSeq: number): Promise<Operation[]>;
|
|
979
1698
|
getOperationCount(): Promise<number>;
|
|
1699
|
+
getMaxDeliverySequence(): Promise<number>;
|
|
1700
|
+
getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
|
|
980
1701
|
materializeCollection(collection: string): Promise<MaterializedRecord[]>;
|
|
981
1702
|
queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
|
|
982
1703
|
findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
|
|
@@ -1017,14 +1738,44 @@ declare class PostgresServerStore implements ServerStore {
|
|
|
1017
1738
|
private readonly ready;
|
|
1018
1739
|
private schema;
|
|
1019
1740
|
private closed;
|
|
1020
|
-
|
|
1741
|
+
/**
|
|
1742
|
+
* Monotonic counter for this node's server-originated sequence numbers. Lazily
|
|
1743
|
+
* seeded from the persisted version vector on first use, then advanced in memory
|
|
1744
|
+
* (a synchronous `++`, atomic on the single JS thread) so concurrent conditional
|
|
1745
|
+
* applies never receive the same number. Null means "not yet seeded"; reset to
|
|
1746
|
+
* null after a backup import so it re-seeds from the restored version vector.
|
|
1747
|
+
*/
|
|
1748
|
+
private sequenceCounter;
|
|
1749
|
+
/**
|
|
1750
|
+
* Time source for HLC timestamps on server-originated operations. Injectable so a
|
|
1751
|
+
* test can freeze wall-clock time and prove the conditional-apply ordering holds
|
|
1752
|
+
* under same-millisecond commits (the case the advisory lock and clock advance
|
|
1753
|
+
* exist to make correct). Defaults to the system clock in production.
|
|
1754
|
+
*/
|
|
1755
|
+
private readonly timeSource;
|
|
1756
|
+
constructor(db: PostgresJsDatabase, nodeId?: string, timeSource?: TimeSource);
|
|
1021
1757
|
getVersionVector(): VersionVector;
|
|
1758
|
+
/**
|
|
1759
|
+
* Atomically reserve this node's next server-operation sequence number. The
|
|
1760
|
+
* synchronous increment cannot interleave on the single JS thread, so two
|
|
1761
|
+
* concurrent conditional applies are always handed distinct numbers.
|
|
1762
|
+
*/
|
|
1763
|
+
reserveSequenceNumber(): number;
|
|
1022
1764
|
getNodeId(): string;
|
|
1023
1765
|
getSchema(): SchemaDefinition | null;
|
|
1024
1766
|
setSchema(schema: SchemaDefinition): Promise<void>;
|
|
1025
1767
|
applyRemoteOperation(op: Operation): Promise<ApplyResult>;
|
|
1768
|
+
/**
|
|
1769
|
+
* Conditionally apply operations atomically, serialized across server instances
|
|
1770
|
+
* on the target record via a transaction-scoped advisory lock. The lock makes
|
|
1771
|
+
* the read-decide-write cycle atomic against concurrent instances, so a cap
|
|
1772
|
+
* check like `responseCount < max` cannot be passed by two admissions at once.
|
|
1773
|
+
*/
|
|
1774
|
+
applyConditional(input: ConditionalApplyInput): Promise<ConditionalApplyResult>;
|
|
1026
1775
|
getOperationRange(nodeId: string, fromSeq: number, toSeq: number): Promise<Operation[]>;
|
|
1027
1776
|
getOperationCount(): Promise<number>;
|
|
1777
|
+
getMaxDeliverySequence(): Promise<number>;
|
|
1778
|
+
getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
|
|
1028
1779
|
materializeCollection(collection: string): Promise<MaterializedRecord[]>;
|
|
1029
1780
|
queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
|
|
1030
1781
|
findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
|
|
@@ -1035,6 +1786,13 @@ declare class PostgresServerStore implements ServerStore {
|
|
|
1035
1786
|
operationsRestored: number;
|
|
1036
1787
|
success: boolean;
|
|
1037
1788
|
}>;
|
|
1789
|
+
/**
|
|
1790
|
+
* The HLC timestamp of the most recent operation on a record, by total order
|
|
1791
|
+
* (wallTime, logical, nodeId), or null when the record has no operations. Used
|
|
1792
|
+
* by {@link applyConditional} to advance its clock past the record's latest
|
|
1793
|
+
* write so newly built operations sort strictly after it.
|
|
1794
|
+
*/
|
|
1795
|
+
private latestOperationTimestamp;
|
|
1038
1796
|
/**
|
|
1039
1797
|
* Rebuild a single record in the materialized collection table by replaying
|
|
1040
1798
|
* all operations for that record.
|
|
@@ -1058,6 +1816,12 @@ declare class PostgresServerStore implements ServerStore {
|
|
|
1058
1816
|
private materializeFromOpsLog;
|
|
1059
1817
|
private initialize;
|
|
1060
1818
|
private ensureTables;
|
|
1819
|
+
/**
|
|
1820
|
+
* Reserve the next delivery sequence inside an open append transaction. The
|
|
1821
|
+
* `UPDATE ... RETURNING` locks the counter row until this transaction commits,
|
|
1822
|
+
* serializing assignment into commit order (see the counter table comment).
|
|
1823
|
+
*/
|
|
1824
|
+
private nextDeliverySeq;
|
|
1061
1825
|
private serializeOperation;
|
|
1062
1826
|
private deserializeOperation;
|
|
1063
1827
|
private assertOpen;
|
|
@@ -1093,6 +1857,8 @@ declare class SqliteServerStore implements ServerStore {
|
|
|
1093
1857
|
applyRemoteOperation(op: Operation): Promise<ApplyResult>;
|
|
1094
1858
|
getOperationRange(nodeId: string, fromSeq: number, toSeq: number): Promise<Operation[]>;
|
|
1095
1859
|
getOperationCount(): Promise<number>;
|
|
1860
|
+
getMaxDeliverySequence(): Promise<number>;
|
|
1861
|
+
getOperationsAfterDelivery(afterDeliverySequence: number, limit: number): Promise<DeliveredOperation[]>;
|
|
1096
1862
|
materializeCollection(collection: string): Promise<MaterializedRecord[]>;
|
|
1097
1863
|
queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
|
|
1098
1864
|
findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
|
|
@@ -1131,6 +1897,19 @@ declare class SqliteServerStore implements ServerStore {
|
|
|
1131
1897
|
* Create the operations and sync_state tables if they don't exist.
|
|
1132
1898
|
*/
|
|
1133
1899
|
private ensureTables;
|
|
1900
|
+
/**
|
|
1901
|
+
* Assign delivery sequences to any operations written before the column existed,
|
|
1902
|
+
* then seed the durable counter from the current max. Ordered by
|
|
1903
|
+
* (received_at, sequence_number, id) so the backfill is deterministic and stable
|
|
1904
|
+
* across restarts. A one-time migration: after it runs, all rows have a value.
|
|
1905
|
+
*/
|
|
1906
|
+
private backfillDeliverySequence;
|
|
1907
|
+
/**
|
|
1908
|
+
* Reserve the next delivery sequence inside the append transaction. SQLite locks
|
|
1909
|
+
* the database for the write transaction, and this counter row keeps sequence
|
|
1910
|
+
* allocation shared by every SqliteServerStore instance using the same file.
|
|
1911
|
+
*/
|
|
1912
|
+
private nextDeliverySeq;
|
|
1134
1913
|
private serializeOperation;
|
|
1135
1914
|
private deserializeOperation;
|
|
1136
1915
|
private assertOpen;
|
|
@@ -1232,4 +2011,4 @@ declare class AwarenessRelay {
|
|
|
1232
2011
|
*/
|
|
1233
2012
|
declare function createKoraServer(config: KoraSyncServerConfig): KoraSyncServer;
|
|
1234
2013
|
|
|
1235
|
-
export { type AuthContext, type AuthProvider, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, 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, PostgresServerStore, type ProductionHttpRoute, type ProductionHttpRouteRequest, type ProductionHttpRouteResponse, type ProductionServer, type ProductionServerConfig, type RelayCallback, 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 };
|
|
2014
|
+
export { type AuthContext, type AuthProvider, AwarenessRelay, type AwarenessRelayCallback, type ClientMetrics, ClientSession, type ClientSessionOptions, type CollectionQueryOptions, 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 };
|