@korajs/server 1.0.0-beta.1 → 1.0.0-beta.11

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