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

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,199 @@
1
- import { SchemaDefinition, KoraEventEmitter, Operation, BlobRef, VersionVector } from '@korajs/core';
1
+ import { SchemaDefinition, Operation, BlobRef, KoraEventEmitter, VersionVector } 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
+
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
+ * Options for querying a materialized collection table.
49
+ */
50
+ interface CollectionQueryOptions {
51
+ /** Exact-match filters on field values */
52
+ where?: Record<string, unknown>;
53
+ /** Field name to order results by */
54
+ orderBy?: string;
55
+ /** Sort direction (default: 'asc') */
56
+ orderDirection?: 'asc' | 'desc';
57
+ /** Maximum number of records to return */
58
+ limit?: number;
59
+ /** Number of records to skip (for pagination) */
60
+ offset?: number;
61
+ /** Include soft-deleted records (default: false) */
62
+ includeDeleted?: boolean;
63
+ }
64
+ /**
65
+ * Server-side store interface. Extends SyncStore with lifecycle,
66
+ * introspection, and materialization methods needed by the sync server.
67
+ */
68
+ interface ServerStore extends SyncStore {
69
+ /** Close the store and release resources */
70
+ close(): Promise<void>;
71
+ /** Get the total number of stored operations */
72
+ getOperationCount(): Promise<number>;
73
+ /**
74
+ * Set the schema for materialized collection tables.
75
+ * Creates collection tables and indexes based on the schema definition.
76
+ * If operations already exist in the store, backfills the materialized
77
+ * tables from the operation log.
78
+ *
79
+ * @param schema - The schema definition describing all collections
80
+ */
81
+ setSchema(schema: SchemaDefinition): Promise<void>;
82
+ /** Schema used for materialized tables and server-side validation, if set. */
83
+ getSchema(): SchemaDefinition | null;
84
+ /**
85
+ * Get all records from a materialized collection.
86
+ * When schema is set, reads directly from the collection table (O(1) indexed).
87
+ * When schema is not set, falls back to replaying the operation log.
88
+ * Deleted records are excluded.
89
+ *
90
+ * @param collection - The collection name to query
91
+ * @returns Array of records with their current state
92
+ */
93
+ materializeCollection(collection: string): Promise<MaterializedRecord[]>;
94
+ /**
95
+ * Query records from a materialized collection with filtering, ordering,
96
+ * and pagination. Requires schema to be set via setSchema().
97
+ *
98
+ * @param collection - The collection name to query
99
+ * @param options - Query options (where, orderBy, limit, offset)
100
+ * @returns Array of matching records
101
+ */
102
+ queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
103
+ /**
104
+ * Find a single record by ID from a materialized collection.
105
+ * Requires schema to be set via setSchema().
106
+ *
107
+ * @param collection - The collection name
108
+ * @param id - The record ID
109
+ * @returns The record or null if not found (or deleted)
110
+ */
111
+ findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
112
+ /**
113
+ * Count records in a materialized collection, optionally filtered.
114
+ * Requires schema to be set via setSchema().
115
+ *
116
+ * @param collection - The collection name
117
+ * @param where - Optional exact-match filters
118
+ * @returns Number of matching records
119
+ */
120
+ countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
121
+ /**
122
+ * Export all data as a portable backup binary.
123
+ * Ships operations, version vector, and metadata in a self-describing format
124
+ * with SHA-256 checksum.
125
+ */
126
+ exportBackup(): Promise<Uint8Array>;
127
+ /**
128
+ * Restore data from a portable backup binary.
129
+ * Operations are applied through applyRemoteOperation for safe merge.
130
+ *
131
+ * @param data - Backup binary
132
+ * @param merge - If true, merge with existing operations; if false, replace all
133
+ */
134
+ importBackup(data: Uint8Array, merge?: boolean): Promise<{
135
+ operationsRestored: number;
136
+ success: boolean;
137
+ }>;
138
+ }
139
+
140
+ /**
141
+ * Shared vocabulary for why the server refused an operation.
142
+ *
143
+ * The `retriable` flag answers one question for the caller: is it worth
144
+ * submitting this SAME operation again later?
145
+ *
146
+ * - `true` — the rejection is transient (for example a rate limit). The
147
+ * identical operation may succeed once the condition clears, so a
148
+ * client is right to back off and retry the same bytes.
149
+ * - `false` — the rejection is permanent for the operation as written (a
150
+ * constraint violation, a referential conflict, a malformed
151
+ * mutation, a scope violation). Resubmitting the same bytes will
152
+ * always fail; the caller must change the operation or give up.
153
+ *
154
+ * This is deliberately defined once, in one place, so the trusted server
155
+ * data-plane (route context / `server.kora`) and the untrusted client-ingestion
156
+ * path speak the same language. `retriable` (this spelling) matches the wire
157
+ * `ErrorMessage.retriable` field the sync protocol already ships, so a single
158
+ * flag flows from the pipeline out to connected clients unchanged.
159
+ */
160
+ interface OperationRejection {
161
+ /** Stable, machine-readable reason code (for example `CONSTRAINT_VIOLATION`). */
162
+ code: string;
163
+ /** Human-readable explanation with enough context to debug without reproduction. */
164
+ message: string;
165
+ /** Whether resubmitting the identical operation may later succeed. */
166
+ retriable: boolean;
167
+ }
168
+ /**
169
+ * Reason codes whose underlying condition is transient, so the identical
170
+ * operation may succeed on a later attempt.
171
+ *
172
+ * Everything NOT listed here is treated as permanent. Permanent is the safe
173
+ * default: a client should never be told to keep hammering a genuinely invalid
174
+ * operation, so a new code is non-retriable until it is deliberately added here.
175
+ *
176
+ * `RATE_LIMIT` is the code the session emits when a client exceeds its
177
+ * per-minute operation budget (see `client-session.ts`).
178
+ */
179
+ declare const RETRIABLE_REJECTION_CODES: ReadonlySet<string>;
180
+ /**
181
+ * Classify a reason code as retriable (transient) or not (permanent).
182
+ *
183
+ * @param code - A rejection reason code.
184
+ * @returns `true` when resubmitting the identical operation may later succeed.
185
+ */
186
+ declare function isRetriableRejection(code: string): boolean;
187
+
188
+ interface ApplyServerOperationResult {
189
+ /** Result of applying the primary operation */
190
+ result: ApplyResult;
191
+ /** Primary op when applied, plus any server-generated side-effect operations */
192
+ appliedOperations: Operation[];
193
+ /** Rejection reason when the operation was not applied */
194
+ rejection?: OperationRejection;
195
+ }
196
+
8
197
  /**
9
198
  * Snapshot of server-side sync metrics at a point in time.
10
199
  */
@@ -125,105 +314,318 @@ declare function createSilentLogger(): Logger;
125
314
  declare function createDefaultLogger(): Logger;
126
315
 
127
316
  /**
128
- * A materialized record reconstructed from the operation log
129
- * or read from a materialized collection table.
317
+ * Minimal interface for a ws.WebSocketServer instance.
318
+ * Allows dependency injection for testing without importing ws directly.
130
319
  */
131
- interface MaterializedRecord {
132
- id: string;
133
- [key: string]: unknown;
320
+ interface WsServerLike {
321
+ on(event: string, listener: (...args: unknown[]) => void): void;
322
+ close(callback?: (err?: Error) => void): void;
323
+ address(): {
324
+ port: number;
325
+ } | string | null;
134
326
  }
135
327
  /**
136
- * Options for querying a materialized collection table.
328
+ * Constructor type for creating a WebSocket server.
137
329
  */
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
- }
330
+ type WsServerConstructor = new (options: {
331
+ port?: number;
332
+ host?: string;
333
+ path?: string;
334
+ }) => WsServerLike;
152
335
  /**
153
- * Server-side store interface. Extends SyncStore with lifecycle,
154
- * introspection, and materialization methods needed by the sync server.
336
+ * Self-hosted sync server. Accepts WebSocket connections from clients,
337
+ * handles the sync protocol, stores operations, and relays changes
338
+ * between connected clients.
339
+ *
340
+ * Two modes of operation:
341
+ * 1. **Standalone**: Call `start()` with a port — creates its own WebSocket server.
342
+ * 2. **Attach**: Call `handleConnection(transport)` — attach to an existing HTTP server.
155
343
  */
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>;
344
+ declare class KoraSyncServer {
345
+ private readonly store;
346
+ private readonly auth;
347
+ private readonly serializer;
348
+ private readonly emitter;
349
+ private readonly maxConnections;
350
+ private readonly batchSize;
351
+ private readonly schemaVersion;
352
+ private readonly supportedSchemaVersions;
353
+ private readonly port;
354
+ private readonly host;
355
+ private readonly path;
356
+ private readonly logger;
357
+ private readonly metrics;
358
+ private readonly awarenessRelay;
359
+ private readonly yjsDocRelay;
360
+ private readonly blobChunkRelay;
361
+ private readonly persistBlobChunk;
362
+ private readonly maxOperationBytes;
363
+ private readonly maxOpsPerMinute;
364
+ private readonly validateOperation;
365
+ private readonly koraContext;
366
+ private readonly sessions;
367
+ private readonly httpClients;
368
+ private readonly httpSessionToClient;
369
+ private readonly serverVersion;
370
+ private wsServer;
371
+ private running;
372
+ constructor(config: KoraSyncServerConfig);
161
373
  /**
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.
374
+ * The trusted, scoped data-plane context (`apply` / `query` / `findById`).
375
+ * Shared with the production server so HTTP routes, the operation validator,
376
+ * and `server.kora` are all the same object over the same pipeline.
377
+ */
378
+ getKoraContext(): ProductionHttpRouteContext;
379
+ /**
380
+ * Subscribe to session-level events for metrics collection and logging.
381
+ * Called when a new session is created.
382
+ */
383
+ private attachSessionEvents;
384
+ /**
385
+ * Get the metrics collector for external access (e.g., HTTP endpoints).
386
+ */
387
+ getMetricsCollector(): ServerMetricsCollector;
388
+ /**
389
+ * Get the logger for external access (e.g., event streaming).
390
+ */
391
+ getLogger(): Logger;
392
+ /**
393
+ * Start the WebSocket server in standalone mode.
166
394
  *
167
- * @param schema - The schema definition describing all collections
395
+ * @param wsServerImpl - Optional WebSocket server constructor for testing
168
396
  */
169
- setSchema(schema: SchemaDefinition): Promise<void>;
170
- /** Schema used for materialized tables and server-side validation, if set. */
171
- getSchema(): SchemaDefinition | null;
397
+ start(wsServerImpl?: WsServerConstructor): Promise<void>;
172
398
  /**
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.
399
+ * Stop the server. Closes all sessions and the WebSocket server.
400
+ */
401
+ stop(): Promise<void>;
402
+ /**
403
+ * Handle one HTTP sync request for a long-polling client.
177
404
  *
178
- * @param collection - The collection name to query
179
- * @returns Array of records with their current state
405
+ * A stable `clientId` identifies the logical connection across requests.
180
406
  */
181
- materializeCollection(collection: string): Promise<MaterializedRecord[]>;
407
+ handleHttpRequest(request: HttpSyncRequest): Promise<HttpSyncResponse>;
182
408
  /**
183
- * Query records from a materialized collection with filtering, ordering,
184
- * and pagination. Requires schema to be set via setSchema().
409
+ * Handle an incoming client connection (attach mode).
410
+ * Creates a new ClientSession for the transport.
185
411
  *
186
- * @param collection - The collection name to query
187
- * @param options - Query options (where, orderBy, limit, offset)
188
- * @returns Array of matching records
412
+ * @param transport - The server transport for the new connection
413
+ * @returns The session ID
189
414
  */
190
- queryCollection(collection: string, options?: CollectionQueryOptions): Promise<MaterializedRecord[]>;
415
+ handleConnection(transport: ServerTransport): string;
191
416
  /**
192
- * Find a single record by ID from a materialized collection.
193
- * Requires schema to be set via setSchema().
417
+ * Get the current server status.
418
+ */
419
+ getStatus(): Promise<ServerStatus>;
420
+ /**
421
+ * Apply a server-originated operation (for example one created by a custom
422
+ * HTTP route) through the same validated pipeline that incoming client
423
+ * operations use — Tier 2 constraints, referential integrity, and cascade
424
+ * side effects — then relay every applied operation to connected clients.
194
425
  *
195
- * @param collection - The collection name
196
- * @param id - The record ID
197
- * @returns The record or null if not found (or deleted)
426
+ * Because the operation did not come from a client session, it is relayed to
427
+ * ALL sessions (there is no source session to exclude). Each session still
428
+ * applies its own per-scope visibility filter in `relayOperations`, so a
429
+ * client only receives the operation if it falls within that client's scope.
430
+ *
431
+ * @param op - A fully-formed, server-originated operation to apply
432
+ * @returns The apply result, including any server-generated side-effect ops
198
433
  */
199
- findRecord(collection: string, id: string): Promise<MaterializedRecord | null>;
434
+ applyLocalOperation(op: Operation): Promise<ApplyServerOperationResult>;
200
435
  /**
201
- * Count records in a materialized collection, optionally filtered.
202
- * Requires schema to be set via setSchema().
436
+ * Collect every blob reference still reachable from live records on the server.
203
437
  *
204
- * @param collection - The collection name
205
- * @param where - Optional exact-match filters
206
- * @returns Number of matching records
438
+ * This is the live set for garbage-collecting the server's central blob store:
439
+ * pass the result to `collectBlobGarbage(blobStore, liveRefs)` from
440
+ * `@korajs/store` to reclaim bytes no record references any more. Only
441
+ * collections that declare a `blob` field are scanned.
442
+ *
443
+ * @returns Every live blob reference across all collections
444
+ */
445
+ getLiveBlobRefs(): Promise<BlobRef[]>;
446
+ /**
447
+ * Get the number of currently connected clients.
448
+ */
449
+ getConnectionCount(): number;
450
+ private handleRelay;
451
+ private handleSessionClose;
452
+ private handleAwarenessRelay;
453
+ private handleYjsDocRelay;
454
+ private getOrCreateHttpClient;
455
+ }
456
+
457
+ /**
458
+ * A mutation a custom HTTP route asks the server to apply on the data plane.
459
+ *
460
+ * `recordId` is optional for inserts (a UUID v7 is generated when omitted) and
461
+ * required for updates and deletes. For updates, `data` holds only the changed
462
+ * fields; the server reads the current record to capture the previous values so
463
+ * the change three-way merges correctly with concurrent client edits.
464
+ */
465
+ interface RouteMutation {
466
+ collection: string;
467
+ type: 'insert' | 'update' | 'delete';
468
+ recordId?: string;
469
+ data?: Record<string, unknown> | null;
470
+ }
471
+ /**
472
+ * Scope the route derived from the request's authenticated actor. When present,
473
+ * it is enforced exactly like a sync session's scope: `apply()` rejects a
474
+ * mutation whose resulting record falls outside the scope, and `query()` /
475
+ * `findById()` only return records inside it. Omit it for genuinely public
476
+ * routes (for example a signup endpoint) where no per-actor isolation applies.
477
+ */
478
+ interface RouteScopeOptions {
479
+ scope?: ScopeMap;
480
+ }
481
+ /** Result of {@link ProductionHttpRouteContext.apply}. */
482
+ type RouteApplyResult = {
483
+ ok: true;
484
+ operation: Operation;
485
+ record: MaterializedRecord | null;
486
+ } | {
487
+ ok: false;
488
+ code: string;
489
+ message: string;
490
+ retriable: boolean;
491
+ };
492
+ /**
493
+ * A conditional, multi-collection admission gate applied atomically per instance.
494
+ *
495
+ * The gate reads the current state of the target record (`collection` + `id`),
496
+ * checks the `if` predicate against it, and only then applies the `update` to the
497
+ * target plus every mutation in `also` as one set. If the predicate fails it
498
+ * applies nothing and returns the structured `reject`. `idempotencyKey` names a
499
+ * record whose prior existence proves the set already committed, so a retry
500
+ * returns the earlier outcome instead of applying the set (and its non-idempotent
501
+ * counter increments) again.
502
+ *
503
+ * Atomicity note: within one server instance the check and the writes do not
504
+ * interleave with other route mutations. Cross-instance, race-free admission and
505
+ * all-or-nothing across a mid-set crash require the store-level conditional
506
+ * transaction (a Postgres `WHERE ... < cap` commit), which is the next increment.
507
+ */
508
+ interface RouteConditionalApply {
509
+ /** Collection of the record the predicate reads and the `update` targets. */
510
+ collection: string;
511
+ /** Id of the target record. */
512
+ id: string;
513
+ /** Admission predicate evaluated against the target's current state. */
514
+ if?: RoutePredicate;
515
+ /** Partial update applied to the target record when the predicate holds. */
516
+ update?: Record<string, unknown> | null;
517
+ /** Additional mutations committed atomically with the target update. */
518
+ also?: RouteMutation[];
519
+ /** Structured rejection returned when the predicate does not hold. */
520
+ reject?: {
521
+ code: string;
522
+ message: string;
523
+ };
524
+ /**
525
+ * Names a record whose existence means this set already committed. When it is
526
+ * present the set is skipped and the prior outcome returned. Use a stable,
527
+ * client-generated id (for example the accepted record's own id).
528
+ */
529
+ idempotencyKey?: {
530
+ collection: string;
531
+ id: string;
532
+ };
533
+ }
534
+ /** Result of {@link ProductionHttpRouteContext.applyConditional}. */
535
+ type RouteConditionalResult = {
536
+ ok: true;
537
+ /** True when the set was already committed and this call was a no-op. */
538
+ idempotent: boolean;
539
+ operations: Operation[];
540
+ records: (MaterializedRecord | null)[];
541
+ } | {
542
+ ok: false;
543
+ code: string;
544
+ message: string;
545
+ retriable: boolean;
546
+ };
547
+ /**
548
+ * Scoped, validated data-plane access handed to custom HTTP route handlers.
549
+ *
550
+ * Every mutation goes through the same pipeline as sync (Tier 2 constraints,
551
+ * referential integrity, materialization, and fan-out to connected clients), so
552
+ * REST endpoints cannot accidentally bypass validation, constraints, or tenant
553
+ * isolation the way hand-rolled store writes can.
554
+ */
555
+ interface ProductionHttpRouteContext {
556
+ /**
557
+ * Apply a mutation through the validated server pipeline and relay the
558
+ * resulting operation(s) to connected clients.
559
+ */
560
+ apply(mutation: RouteMutation, options?: RouteScopeOptions): Promise<RouteApplyResult>;
561
+ /**
562
+ * Conditionally apply a multi-collection mutation set as one admission gate:
563
+ * read the target, check the predicate, and commit the update plus every `also`
564
+ * mutation only if it holds, with at-most-once semantics via `idempotencyKey`.
207
565
  */
208
- countCollection(collection: string, where?: Record<string, unknown>): Promise<number>;
566
+ applyConditional(spec: RouteConditionalApply, options?: RouteScopeOptions): Promise<RouteConditionalResult>;
567
+ /** Read materialized records from a collection, optionally scoped. */
568
+ query(collection: string, options?: CollectionQueryOptions & RouteScopeOptions): Promise<MaterializedRecord[]>;
569
+ /** Read a single materialized record by id, optionally scoped. */
570
+ findById(collection: string, id: string, options?: RouteScopeOptions): Promise<MaterializedRecord | null>;
571
+ }
572
+
573
+ /**
574
+ * What the server hands an operation validator so it can adjudicate an untrusted
575
+ * client operation before that operation becomes authoritative.
576
+ *
577
+ * The validator is POLICY (yours); the framework owns the MECHANISM around it —
578
+ * running it at ingestion, and routing its decision deterministically.
579
+ */
580
+ interface OperationValidationContext {
209
581
  /**
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.
582
+ * The authenticated actor for the submitting session, or `null` for an
583
+ * anonymous / unauthenticated connection. This is how a public submission
584
+ * endpoint tells "signed-in owner" from "anonymous respondent" apart.
213
585
  */
214
- exportBackup(): Promise<Uint8Array>;
586
+ auth: AuthContext | null;
215
587
  /**
216
- * Restore data from a portable backup binary.
217
- * Operations are applied through applyRemoteOperation for safe merge.
218
- *
219
- * @param data - Backup binary
220
- * @param merge - If true, merge with existing operations; if false, replace all
588
+ * Trusted, scoped data-plane access — the same context available as
589
+ * `server.kora` / `request.kora`. Use `kora.query` / `kora.findById` to read
590
+ * current authoritative state while deciding, and `kora.apply` to AUTHOR a
591
+ * derived server operation (for example promoting a validated anonymous
592
+ * submission into an owner-visible collection). Authoring a new operation is
593
+ * always preferred over mutating the incoming one, which must stay immutable
594
+ * so content-addressing and convergence hold.
221
595
  */
222
- importBackup(data: Uint8Array, merge?: boolean): Promise<{
223
- operationsRestored: number;
224
- success: boolean;
225
- }>;
596
+ kora: ProductionHttpRouteContext;
226
597
  }
598
+ /**
599
+ * The verdict a validator returns for one incoming operation.
600
+ *
601
+ * - `accept` — let the operation materialize on the server as-is and relay to
602
+ * connected clients, exactly as an untrusted op would flow without a validator.
603
+ * - `reject` — refuse it. The operation never enters the authoritative log, so no
604
+ * other replica ever sees it; a structured rejection travels back to the
605
+ * submitter tied to the operation id, and the submitter keeps the op in a
606
+ * durable rejected store (recoverable and explainable) rather than losing it or
607
+ * retrying forever. `retriable` defaults from the shared taxonomy for the code.
608
+ * - `ignore` — the server takes responsibility for the operation out of band (for
609
+ * example the validator already authored a derived op via `context.kora`) and
610
+ * does not want the raw incoming op materialized. No rejection is sent, so the
611
+ * submitter treats it as handled and drops it from its pending queue.
612
+ */
613
+ type OperationDecision = {
614
+ action: 'accept';
615
+ } | {
616
+ action: 'reject';
617
+ code: string;
618
+ message: string;
619
+ retriable?: boolean;
620
+ } | {
621
+ action: 'ignore';
622
+ };
623
+ /**
624
+ * An app-provided function that adjudicates an untrusted client operation at sync
625
+ * ingestion, after HLC ordering and the built-in guards (timestamp, rate, size),
626
+ * and before the operation is materialized into authoritative state.
627
+ */
628
+ type OperationValidator = (operation: Operation, context: OperationValidationContext) => Promise<OperationDecision> | OperationDecision;
227
629
 
228
630
  /**
229
631
  * Authenticated client context. Returned by an AuthProvider after validation.
@@ -303,6 +705,31 @@ interface KoraSyncServerConfig {
303
705
  * `@korajs/store` derives both from a `ContentAddressedBlobStore`.
304
706
  */
305
707
  persistBlobChunk?: (hash: string, bytes: Uint8Array) => Promise<void> | void;
708
+ /**
709
+ * Maximum serialized byte size of a single client operation accepted at sync
710
+ * ingest. Operations larger than this are rejected before materialization.
711
+ * Defaults to 256 KiB. Set once here to enforce one payload cap across every
712
+ * connected client instead of configuring each session.
713
+ */
714
+ maxOperationBytes?: number;
715
+ /**
716
+ * Maximum operations accepted per connected client per minute (sliding window).
717
+ * Operations beyond the limit are rejected until the window resets. Defaults to
718
+ * 600. Set once here to enforce one rate cap across every connected client.
719
+ */
720
+ maxOpsPerMinute?: number;
721
+ /**
722
+ * Adjudicate untrusted client operations before they become authoritative.
723
+ *
724
+ * Runs at sync ingestion for every incoming client operation, after HLC
725
+ * ordering and the built-in guards, and before materialization. Return
726
+ * `accept` to let it through, `reject` to refuse it (a structured rejection
727
+ * travels back to the submitter and the op never enters the authoritative
728
+ * log), or `ignore` when the server has handled it out of band. This is what
729
+ * lets Kora serve public / multi-tenant apps where the client is not trusted.
730
+ * Omit it and every operation is accepted, as before.
731
+ */
732
+ validateOperation?: OperationValidator;
306
733
  }
307
734
  /**
308
735
  * Request envelope for the server-side HTTP sync endpoint.
@@ -513,6 +940,14 @@ interface ClientSessionOptions {
513
940
  maxOperationBytes?: number;
514
941
  /** Maximum operations accepted per minute for this session. Defaults to 600. */
515
942
  maxOpsPerMinute?: number;
943
+ /**
944
+ * Adjudicate untrusted client operations before materialization. When present,
945
+ * each incoming operation is passed to this validator; a `reject` decision
946
+ * sends an operation-rejected message and skips materialization.
947
+ */
948
+ validateOperation?: OperationValidator;
949
+ /** Trusted data-plane context handed to the validator (read state, author derived ops). */
950
+ koraContext?: ProductionHttpRouteContext;
516
951
  }
517
952
  /**
518
953
  * Handles the sync protocol for a single connected client.
@@ -553,6 +988,8 @@ declare class ClientSession {
553
988
  private readonly maxOperationBytes;
554
989
  private readonly maxOpsPerMinute;
555
990
  private readonly rateLimiter;
991
+ private readonly validateOperation;
992
+ private readonly koraContext;
556
993
  constructor(options: ClientSessionOptions);
557
994
  /**
558
995
  * Start handling messages from the client transport.
@@ -596,210 +1033,15 @@ declare class ClientSession {
596
1033
  private handleAwarenessUpdate;
597
1034
  private handleYjsDocUpdate;
598
1035
  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
- /**
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.
731
- *
732
- * @returns Every live blob reference across all collections
733
- */
734
- getLiveBlobRefs(): Promise<BlobRef[]>;
735
- /**
736
- * Get the number of currently connected clients.
737
- */
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 {
794
1036
  /**
795
- * Apply a mutation through the validated server pipeline and relay the
796
- * resulting operation(s) to connected clients.
1037
+ * Reject one specific client operation, tied to its id, so the submitter can
1038
+ * divert it out of its pending queue into a durable rejected store rather than
1039
+ * losing it or retrying forever. The op is NOT materialized, so no other
1040
+ * replica ever sees it.
797
1041
  */
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>;
1042
+ private sendOperationRejected;
1043
+ private setSerializerWireFormat;
1044
+ private handleTransportClose;
803
1045
  }
804
1046
 
805
1047
  /**
@@ -875,6 +1117,29 @@ interface ProductionServer {
875
1117
  start(): Promise<string>;
876
1118
  /** Stop the server gracefully. */
877
1119
  stop(): Promise<void>;
1120
+ /**
1121
+ * Trusted, scoped data-plane access for server-side callers that have no HTTP
1122
+ * request — background jobs, scheduled tasks, seeding scripts.
1123
+ *
1124
+ * `kora.apply()` runs the mutation through the exact same validated pipeline as
1125
+ * sync (Tier 2 constraints, referential integrity, materialization, and fan-out
1126
+ * to connected clients), and `kora.query()` / `kora.findById()` read
1127
+ * materialized state. This is the same context handed to custom HTTP routes as
1128
+ * `request.kora`, so a job and a request share one code path and one set of
1129
+ * guarantees instead of the job writing to the store directly and silently
1130
+ * bypassing validation.
1131
+ */
1132
+ kora: ProductionHttpRouteContext;
1133
+ /**
1134
+ * Every blob reference still reachable from a live record across all
1135
+ * collections that declare a `blob` field. This is the live set for
1136
+ * garbage-collecting the server's central blob store: pass it to
1137
+ * `collectBlobGarbage(blobStore, refs)` from `@korajs/store` on a schedule to
1138
+ * reclaim bytes no record points at any more.
1139
+ *
1140
+ * @returns Every live blob reference the server can currently see.
1141
+ */
1142
+ getLiveBlobRefs(): Promise<BlobRef[]>;
878
1143
  }
879
1144
  /**
880
1145
  * Creates a production server that serves both static files and WebSocket sync
@@ -1384,4 +1649,4 @@ declare class AwarenessRelay {
1384
1649
  */
1385
1650
  declare function createKoraServer(config: KoraSyncServerConfig): KoraSyncServer;
1386
1651
 
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 };
1652
+ 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 };