@korajs/server 0.1.0

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.js ADDED
@@ -0,0 +1,1161 @@
1
+ import {
2
+ __require
3
+ } from "./chunk-DGUM43GV.js";
4
+
5
+ // src/store/memory-server-store.ts
6
+ import { generateUUIDv7 } from "@korajs/core";
7
+ var MemoryServerStore = class {
8
+ nodeId;
9
+ operations = [];
10
+ operationIndex = /* @__PURE__ */ new Map();
11
+ versionVector = /* @__PURE__ */ new Map();
12
+ closed = false;
13
+ constructor(nodeId) {
14
+ this.nodeId = nodeId ?? generateUUIDv7();
15
+ }
16
+ getVersionVector() {
17
+ return new Map(this.versionVector);
18
+ }
19
+ getNodeId() {
20
+ return this.nodeId;
21
+ }
22
+ async applyRemoteOperation(op) {
23
+ this.assertOpen();
24
+ if (this.operationIndex.has(op.id)) {
25
+ return "duplicate";
26
+ }
27
+ this.operations.push(op);
28
+ this.operationIndex.set(op.id, op);
29
+ const currentSeq = this.versionVector.get(op.nodeId) ?? 0;
30
+ if (op.sequenceNumber > currentSeq) {
31
+ this.versionVector.set(op.nodeId, op.sequenceNumber);
32
+ }
33
+ return "applied";
34
+ }
35
+ async getOperationRange(nodeId, fromSeq, toSeq) {
36
+ this.assertOpen();
37
+ return this.operations.filter(
38
+ (op) => op.nodeId === nodeId && op.sequenceNumber >= fromSeq && op.sequenceNumber <= toSeq
39
+ ).sort((a, b) => a.sequenceNumber - b.sequenceNumber);
40
+ }
41
+ async getOperationCount() {
42
+ this.assertOpen();
43
+ return this.operations.length;
44
+ }
45
+ async close() {
46
+ this.closed = true;
47
+ }
48
+ // --- Testing helpers (not on interface) ---
49
+ /**
50
+ * Get all stored operations (for test assertions).
51
+ */
52
+ getAllOperations() {
53
+ return [...this.operations];
54
+ }
55
+ assertOpen() {
56
+ if (this.closed) {
57
+ throw new Error("MemoryServerStore is closed");
58
+ }
59
+ }
60
+ };
61
+
62
+ // src/store/postgres-server-store.ts
63
+ import { generateUUIDv7 as generateUUIDv72 } from "@korajs/core";
64
+ import { and, asc, between, count, eq, sql } from "drizzle-orm";
65
+
66
+ // src/store/drizzle-pg-schema.ts
67
+ import { index, integer, pgTable, text } from "drizzle-orm/pg-core";
68
+ var pgOperations = pgTable(
69
+ "operations",
70
+ {
71
+ id: text("id").primaryKey(),
72
+ nodeId: text("node_id").notNull(),
73
+ type: text("type").notNull(),
74
+ collection: text("collection").notNull(),
75
+ recordId: text("record_id").notNull(),
76
+ data: text("data"),
77
+ // JSON-serialized, null for deletes
78
+ previousData: text("previous_data"),
79
+ // JSON-serialized, null for insert/delete
80
+ wallTime: integer("wall_time").notNull(),
81
+ logical: integer("logical").notNull(),
82
+ timestampNodeId: text("timestamp_node_id").notNull(),
83
+ sequenceNumber: integer("sequence_number").notNull(),
84
+ causalDeps: text("causal_deps").notNull().default("[]"),
85
+ // JSON array of op IDs
86
+ schemaVersion: integer("schema_version").notNull(),
87
+ receivedAt: integer("received_at").notNull()
88
+ },
89
+ (table) => ({
90
+ nodeSeqIdx: index("idx_pg_node_seq").on(table.nodeId, table.sequenceNumber),
91
+ collectionIdx: index("idx_pg_collection").on(table.collection),
92
+ receivedIdx: index("idx_pg_received").on(table.receivedAt)
93
+ })
94
+ );
95
+ var pgSyncState = pgTable("sync_state", {
96
+ nodeId: text("node_id").primaryKey(),
97
+ maxSequenceNumber: integer("max_sequence_number").notNull(),
98
+ lastSeenAt: integer("last_seen_at").notNull()
99
+ });
100
+
101
+ // src/store/postgres-server-store.ts
102
+ var PostgresServerStore = class {
103
+ nodeId;
104
+ db;
105
+ versionVector = /* @__PURE__ */ new Map();
106
+ ready;
107
+ closed = false;
108
+ constructor(db, nodeId) {
109
+ this.db = db;
110
+ this.nodeId = nodeId ?? generateUUIDv72();
111
+ this.ready = this.initialize();
112
+ }
113
+ getVersionVector() {
114
+ this.assertOpen();
115
+ return new Map(this.versionVector);
116
+ }
117
+ getNodeId() {
118
+ return this.nodeId;
119
+ }
120
+ async applyRemoteOperation(op) {
121
+ this.assertOpen();
122
+ await this.ready;
123
+ const existing = await this.db.select({ id: pgOperations.id }).from(pgOperations).where(eq(pgOperations.id, op.id)).limit(1);
124
+ if (existing.length > 0) {
125
+ return "duplicate";
126
+ }
127
+ const now = Date.now();
128
+ const row = this.serializeOperation(op, now);
129
+ await this.db.transaction(async (tx) => {
130
+ await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
131
+ await tx.insert(pgSyncState).values({
132
+ nodeId: op.nodeId,
133
+ maxSequenceNumber: op.sequenceNumber,
134
+ lastSeenAt: now
135
+ }).onConflictDoUpdate({
136
+ target: pgSyncState.nodeId,
137
+ set: {
138
+ maxSequenceNumber: sql`GREATEST(${pgSyncState.maxSequenceNumber}, ${op.sequenceNumber})`,
139
+ lastSeenAt: sql`${now}`
140
+ }
141
+ });
142
+ });
143
+ const currentMax = this.versionVector.get(op.nodeId) ?? 0;
144
+ if (op.sequenceNumber > currentMax) {
145
+ this.versionVector.set(op.nodeId, op.sequenceNumber);
146
+ }
147
+ return "applied";
148
+ }
149
+ async getOperationRange(nodeId, fromSeq, toSeq) {
150
+ this.assertOpen();
151
+ await this.ready;
152
+ const rows = await this.db.select().from(pgOperations).where(
153
+ and(
154
+ eq(pgOperations.nodeId, nodeId),
155
+ between(pgOperations.sequenceNumber, fromSeq, toSeq)
156
+ )
157
+ ).orderBy(asc(pgOperations.sequenceNumber));
158
+ return rows.map((row) => this.deserializeOperation(row));
159
+ }
160
+ async getOperationCount() {
161
+ this.assertOpen();
162
+ await this.ready;
163
+ const result = await this.db.select({ value: count() }).from(pgOperations);
164
+ return result[0]?.value ?? 0;
165
+ }
166
+ async close() {
167
+ this.closed = true;
168
+ }
169
+ async initialize() {
170
+ await this.ensureTables();
171
+ const rows = await this.db.select({
172
+ nodeId: pgSyncState.nodeId,
173
+ maxSequenceNumber: pgSyncState.maxSequenceNumber
174
+ }).from(pgSyncState);
175
+ for (const row of rows) {
176
+ this.versionVector.set(row.nodeId, row.maxSequenceNumber);
177
+ }
178
+ }
179
+ /**
180
+ * Create tables if they don't exist.
181
+ * Uses raw SQL via Drizzle's sql template — standard DDL practice without drizzle-kit.
182
+ */
183
+ async ensureTables() {
184
+ await this.db.execute(sql`
185
+ CREATE TABLE IF NOT EXISTS operations (
186
+ id TEXT PRIMARY KEY,
187
+ node_id TEXT NOT NULL,
188
+ type TEXT NOT NULL,
189
+ collection TEXT NOT NULL,
190
+ record_id TEXT NOT NULL,
191
+ data TEXT,
192
+ previous_data TEXT,
193
+ wall_time INTEGER NOT NULL,
194
+ logical INTEGER NOT NULL,
195
+ timestamp_node_id TEXT NOT NULL,
196
+ sequence_number INTEGER NOT NULL,
197
+ causal_deps TEXT NOT NULL DEFAULT '[]',
198
+ schema_version INTEGER NOT NULL,
199
+ received_at INTEGER NOT NULL
200
+ )
201
+ `);
202
+ await this.db.execute(
203
+ sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
204
+ );
205
+ await this.db.execute(
206
+ sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`
207
+ );
208
+ await this.db.execute(
209
+ sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`
210
+ );
211
+ await this.db.execute(sql`
212
+ CREATE TABLE IF NOT EXISTS sync_state (
213
+ node_id TEXT PRIMARY KEY,
214
+ max_sequence_number INTEGER NOT NULL,
215
+ last_seen_at INTEGER NOT NULL
216
+ )
217
+ `);
218
+ }
219
+ serializeOperation(op, receivedAt) {
220
+ return {
221
+ id: op.id,
222
+ nodeId: op.nodeId,
223
+ type: op.type,
224
+ collection: op.collection,
225
+ recordId: op.recordId,
226
+ data: op.data !== null ? JSON.stringify(op.data) : null,
227
+ previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
228
+ wallTime: op.timestamp.wallTime,
229
+ logical: op.timestamp.logical,
230
+ timestampNodeId: op.timestamp.nodeId,
231
+ sequenceNumber: op.sequenceNumber,
232
+ causalDeps: JSON.stringify(op.causalDeps),
233
+ schemaVersion: op.schemaVersion,
234
+ receivedAt
235
+ };
236
+ }
237
+ deserializeOperation(row) {
238
+ return {
239
+ id: row.id,
240
+ nodeId: row.nodeId,
241
+ type: row.type,
242
+ collection: row.collection,
243
+ recordId: row.recordId,
244
+ data: row.data !== null ? JSON.parse(row.data) : null,
245
+ previousData: row.previousData !== null ? JSON.parse(row.previousData) : null,
246
+ timestamp: {
247
+ wallTime: row.wallTime,
248
+ logical: row.logical,
249
+ nodeId: row.timestampNodeId
250
+ },
251
+ sequenceNumber: row.sequenceNumber,
252
+ causalDeps: JSON.parse(row.causalDeps),
253
+ schemaVersion: row.schemaVersion
254
+ };
255
+ }
256
+ assertOpen() {
257
+ if (this.closed) {
258
+ throw new Error("PostgresServerStore is closed");
259
+ }
260
+ }
261
+ };
262
+ async function createPostgresServerStore(options) {
263
+ const { postgresClient, drizzleFn } = await loadPostgresDeps();
264
+ const client = postgresClient(options.connectionString);
265
+ const db = drizzleFn(client);
266
+ return new PostgresServerStore(db, options.nodeId);
267
+ }
268
+ async function loadPostgresDeps() {
269
+ try {
270
+ const dynamicImport = new Function("specifier", "return import(specifier)");
271
+ const postgresMod = await dynamicImport("postgres");
272
+ const drizzleMod = await dynamicImport("drizzle-orm/postgres-js");
273
+ return {
274
+ postgresClient: postgresMod.default,
275
+ drizzleFn: drizzleMod.drizzle
276
+ };
277
+ } catch {
278
+ throw new Error(
279
+ 'PostgreSQL backend requires the "postgres" package. Install it in your project dependencies.'
280
+ );
281
+ }
282
+ }
283
+
284
+ // src/store/sqlite-server-store.ts
285
+ import { generateUUIDv7 as generateUUIDv73 } from "@korajs/core";
286
+ import { and as and2, asc as asc2, between as between2, count as count2, eq as eq2, sql as sql2 } from "drizzle-orm";
287
+
288
+ // src/store/drizzle-schema.ts
289
+ import { index as index2, integer as integer2, sqliteTable, text as text2 } from "drizzle-orm/sqlite-core";
290
+ var operations = sqliteTable(
291
+ "operations",
292
+ {
293
+ id: text2("id").primaryKey(),
294
+ nodeId: text2("node_id").notNull(),
295
+ type: text2("type").notNull(),
296
+ collection: text2("collection").notNull(),
297
+ recordId: text2("record_id").notNull(),
298
+ data: text2("data"),
299
+ // JSON-serialized, null for deletes
300
+ previousData: text2("previous_data"),
301
+ // JSON-serialized, null for insert/delete
302
+ wallTime: integer2("wall_time").notNull(),
303
+ logical: integer2("logical").notNull(),
304
+ timestampNodeId: text2("timestamp_node_id").notNull(),
305
+ sequenceNumber: integer2("sequence_number").notNull(),
306
+ causalDeps: text2("causal_deps").notNull().default("[]"),
307
+ // JSON array of op IDs
308
+ schemaVersion: integer2("schema_version").notNull(),
309
+ receivedAt: integer2("received_at").notNull()
310
+ },
311
+ (table) => ({
312
+ nodeSeqIdx: index2("idx_node_seq").on(table.nodeId, table.sequenceNumber),
313
+ collectionIdx: index2("idx_collection").on(table.collection),
314
+ receivedIdx: index2("idx_received").on(table.receivedAt)
315
+ })
316
+ );
317
+ var syncState = sqliteTable("sync_state", {
318
+ nodeId: text2("node_id").primaryKey(),
319
+ maxSequenceNumber: integer2("max_sequence_number").notNull(),
320
+ lastSeenAt: integer2("last_seen_at").notNull()
321
+ });
322
+
323
+ // src/store/sqlite-server-store.ts
324
+ var SqliteServerStore = class {
325
+ nodeId;
326
+ db;
327
+ closed = false;
328
+ constructor(db, nodeId) {
329
+ this.db = db;
330
+ this.nodeId = nodeId ?? generateUUIDv73();
331
+ this.ensureTables();
332
+ }
333
+ getVersionVector() {
334
+ this.assertOpen();
335
+ const rows = this.db.select().from(syncState).all();
336
+ const vv = /* @__PURE__ */ new Map();
337
+ for (const row of rows) {
338
+ vv.set(row.nodeId, row.maxSequenceNumber);
339
+ }
340
+ return vv;
341
+ }
342
+ getNodeId() {
343
+ return this.nodeId;
344
+ }
345
+ async applyRemoteOperation(op) {
346
+ this.assertOpen();
347
+ const now = Date.now();
348
+ const row = this.serializeOperation(op, now);
349
+ const result = this.db.transaction((tx) => {
350
+ const insertResult = tx.insert(operations).values(row).onConflictDoNothing({ target: operations.id }).run();
351
+ if (insertResult.changes === 0) {
352
+ return "duplicate";
353
+ }
354
+ tx.insert(syncState).values({
355
+ nodeId: op.nodeId,
356
+ maxSequenceNumber: op.sequenceNumber,
357
+ lastSeenAt: now
358
+ }).onConflictDoUpdate({
359
+ target: syncState.nodeId,
360
+ set: {
361
+ maxSequenceNumber: sql2`MAX(${syncState.maxSequenceNumber}, ${op.sequenceNumber})`,
362
+ lastSeenAt: sql2`${now}`
363
+ }
364
+ }).run();
365
+ return "applied";
366
+ });
367
+ return result;
368
+ }
369
+ async getOperationRange(nodeId, fromSeq, toSeq) {
370
+ this.assertOpen();
371
+ const rows = this.db.select().from(operations).where(and2(eq2(operations.nodeId, nodeId), between2(operations.sequenceNumber, fromSeq, toSeq))).orderBy(asc2(operations.sequenceNumber)).all();
372
+ return rows.map((row) => this.deserializeOperation(row));
373
+ }
374
+ async getOperationCount() {
375
+ this.assertOpen();
376
+ const result = this.db.select({ value: count2() }).from(operations).all();
377
+ return result[0]?.value ?? 0;
378
+ }
379
+ async close() {
380
+ this.closed = true;
381
+ }
382
+ /**
383
+ * Create the operations and sync_state tables if they don't exist.
384
+ * Uses raw SQL via Drizzle's sql template — standard practice for DDL without drizzle-kit.
385
+ */
386
+ ensureTables() {
387
+ this.db.run(sql2`
388
+ CREATE TABLE IF NOT EXISTS operations (
389
+ id TEXT PRIMARY KEY,
390
+ node_id TEXT NOT NULL,
391
+ type TEXT NOT NULL,
392
+ collection TEXT NOT NULL,
393
+ record_id TEXT NOT NULL,
394
+ data TEXT,
395
+ previous_data TEXT,
396
+ wall_time INTEGER NOT NULL,
397
+ logical INTEGER NOT NULL,
398
+ timestamp_node_id TEXT NOT NULL,
399
+ sequence_number INTEGER NOT NULL,
400
+ causal_deps TEXT NOT NULL DEFAULT '[]',
401
+ schema_version INTEGER NOT NULL,
402
+ received_at INTEGER NOT NULL
403
+ )
404
+ `);
405
+ this.db.run(sql2`
406
+ CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)
407
+ `);
408
+ this.db.run(sql2`
409
+ CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)
410
+ `);
411
+ this.db.run(sql2`
412
+ CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)
413
+ `);
414
+ this.db.run(sql2`
415
+ CREATE TABLE IF NOT EXISTS sync_state (
416
+ node_id TEXT PRIMARY KEY,
417
+ max_sequence_number INTEGER NOT NULL,
418
+ last_seen_at INTEGER NOT NULL
419
+ )
420
+ `);
421
+ }
422
+ serializeOperation(op, receivedAt) {
423
+ return {
424
+ id: op.id,
425
+ nodeId: op.nodeId,
426
+ type: op.type,
427
+ collection: op.collection,
428
+ recordId: op.recordId,
429
+ data: op.data !== null ? JSON.stringify(op.data) : null,
430
+ previousData: op.previousData !== null ? JSON.stringify(op.previousData) : null,
431
+ wallTime: op.timestamp.wallTime,
432
+ logical: op.timestamp.logical,
433
+ timestampNodeId: op.timestamp.nodeId,
434
+ sequenceNumber: op.sequenceNumber,
435
+ causalDeps: JSON.stringify(op.causalDeps),
436
+ schemaVersion: op.schemaVersion,
437
+ receivedAt
438
+ };
439
+ }
440
+ deserializeOperation(row) {
441
+ return {
442
+ id: row.id,
443
+ nodeId: row.nodeId,
444
+ type: row.type,
445
+ collection: row.collection,
446
+ recordId: row.recordId,
447
+ data: row.data !== null ? JSON.parse(row.data) : null,
448
+ previousData: row.previousData !== null ? JSON.parse(row.previousData) : null,
449
+ timestamp: {
450
+ wallTime: row.wallTime,
451
+ logical: row.logical,
452
+ nodeId: row.timestampNodeId
453
+ },
454
+ sequenceNumber: row.sequenceNumber,
455
+ causalDeps: JSON.parse(row.causalDeps),
456
+ schemaVersion: row.schemaVersion
457
+ };
458
+ }
459
+ assertOpen() {
460
+ if (this.closed) {
461
+ throw new Error("SqliteServerStore is closed");
462
+ }
463
+ }
464
+ };
465
+ function createSqliteServerStore(options) {
466
+ const Database = __require("better-sqlite3");
467
+ const { drizzle } = __require("drizzle-orm/better-sqlite3");
468
+ const filename = options.filename ?? ":memory:";
469
+ const sqlite = new Database(filename);
470
+ sqlite.pragma("journal_mode = WAL");
471
+ const db = drizzle(sqlite);
472
+ return new SqliteServerStore(db, options.nodeId);
473
+ }
474
+
475
+ // src/transport/http-server-transport.ts
476
+ import { SyncError } from "@korajs/core";
477
+ var HttpServerTransport = class {
478
+ serializer;
479
+ messageHandler = null;
480
+ closeHandler = null;
481
+ errorHandler = null;
482
+ connected = true;
483
+ nextSequence = 1;
484
+ queue = [];
485
+ constructor(serializer) {
486
+ this.serializer = serializer;
487
+ }
488
+ send(message) {
489
+ if (!this.connected) return;
490
+ const encoded = this.serializer.encode(message);
491
+ const isBinary = encoded instanceof Uint8Array;
492
+ this.queue.push({
493
+ etag: this.makeEtag(this.nextSequence++),
494
+ contentType: isBinary ? "application/x-protobuf" : "application/json",
495
+ payload: encoded
496
+ });
497
+ }
498
+ onMessage(handler) {
499
+ this.messageHandler = handler;
500
+ }
501
+ onClose(handler) {
502
+ this.closeHandler = handler;
503
+ }
504
+ onError(handler) {
505
+ this.errorHandler = handler;
506
+ }
507
+ isConnected() {
508
+ return this.connected;
509
+ }
510
+ close(code = 1e3, reason = "transport closed") {
511
+ if (!this.connected) return;
512
+ this.connected = false;
513
+ this.queue.length = 0;
514
+ this.closeHandler?.(code, reason);
515
+ }
516
+ receive(payload) {
517
+ if (!this.connected) {
518
+ throw new SyncError("HTTP server transport is closed");
519
+ }
520
+ try {
521
+ const message = this.serializer.decode(payload);
522
+ this.messageHandler?.(message);
523
+ } catch (error) {
524
+ this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
525
+ }
526
+ }
527
+ poll(ifNoneMatch) {
528
+ if (!this.connected) {
529
+ return { status: 410 };
530
+ }
531
+ const next = this.queue[0];
532
+ if (!next) {
533
+ return { status: 204 };
534
+ }
535
+ if (ifNoneMatch && ifNoneMatch === next.etag) {
536
+ return {
537
+ status: 304,
538
+ headers: { etag: next.etag }
539
+ };
540
+ }
541
+ this.queue.shift();
542
+ return {
543
+ status: 200,
544
+ body: next.payload,
545
+ headers: {
546
+ "content-type": next.contentType,
547
+ etag: next.etag
548
+ }
549
+ };
550
+ }
551
+ makeEtag(sequence) {
552
+ return `W/"${sequence}"`;
553
+ }
554
+ };
555
+
556
+ // src/transport/ws-server-transport.ts
557
+ import { SyncError as SyncError2 } from "@korajs/core";
558
+ import { JsonMessageSerializer } from "@korajs/sync";
559
+ var WS_OPEN = 1;
560
+ var WsServerTransport = class {
561
+ ws;
562
+ serializer;
563
+ messageHandler = null;
564
+ closeHandler = null;
565
+ errorHandler = null;
566
+ constructor(ws, options) {
567
+ this.ws = ws;
568
+ this.serializer = options?.serializer ?? new JsonMessageSerializer();
569
+ this.setupListeners();
570
+ }
571
+ send(message) {
572
+ if (this.ws.readyState !== WS_OPEN) {
573
+ throw new SyncError2("Cannot send message: WebSocket is not open", {
574
+ readyState: this.ws.readyState,
575
+ messageType: message.type
576
+ });
577
+ }
578
+ const encoded = this.serializer.encode(message);
579
+ this.ws.send(encoded);
580
+ }
581
+ onMessage(handler) {
582
+ this.messageHandler = handler;
583
+ }
584
+ onClose(handler) {
585
+ this.closeHandler = handler;
586
+ }
587
+ onError(handler) {
588
+ this.errorHandler = handler;
589
+ }
590
+ isConnected() {
591
+ return this.ws.readyState === WS_OPEN;
592
+ }
593
+ close(code, reason) {
594
+ this.ws.close(code ?? 1e3, reason ?? "server closing");
595
+ }
596
+ setupListeners() {
597
+ this.ws.on("message", (data) => {
598
+ try {
599
+ if (typeof data !== "string" && !(data instanceof Uint8Array) && !(data instanceof ArrayBuffer)) {
600
+ throw new SyncError2("Unsupported WebSocket payload type", {
601
+ payloadType: typeof data
602
+ });
603
+ }
604
+ const decoded = this.serializer.decode(data);
605
+ this.messageHandler?.(decoded);
606
+ } catch (err) {
607
+ this.errorHandler?.(err instanceof Error ? err : new Error(String(err)));
608
+ }
609
+ });
610
+ this.ws.on("close", (code, reason) => {
611
+ this.closeHandler?.(Number(code) || 1006, String(reason || "connection closed"));
612
+ });
613
+ this.ws.on("error", (err) => {
614
+ this.errorHandler?.(err instanceof Error ? err : new Error(String(err)));
615
+ });
616
+ }
617
+ };
618
+
619
+ // src/session/client-session.ts
620
+ import { generateUUIDv7 as generateUUIDv74 } from "@korajs/core";
621
+ import { topologicalSort } from "@korajs/core/internal";
622
+ import {
623
+ NegotiatedMessageSerializer,
624
+ versionVectorToWire,
625
+ wireToVersionVector
626
+ } from "@korajs/sync";
627
+
628
+ // src/scopes/server-scope-filter.ts
629
+ function operationMatchesScopes(op, scopes) {
630
+ if (!scopes) return true;
631
+ const collectionScope = scopes[op.collection];
632
+ if (!collectionScope) return false;
633
+ const snapshot = buildSnapshot(op);
634
+ if (!snapshot) return false;
635
+ for (const [field, expected] of Object.entries(collectionScope)) {
636
+ if (snapshot[field] !== expected) {
637
+ return false;
638
+ }
639
+ }
640
+ return true;
641
+ }
642
+ function buildSnapshot(op) {
643
+ const previous = asRecord(op.previousData);
644
+ const next = asRecord(op.data);
645
+ if (!previous && !next) return null;
646
+ return {
647
+ ...previous ?? {},
648
+ ...next ?? {}
649
+ };
650
+ }
651
+ function asRecord(value) {
652
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
653
+ return null;
654
+ }
655
+ return value;
656
+ }
657
+
658
+ // src/session/client-session.ts
659
+ var DEFAULT_BATCH_SIZE = 100;
660
+ var DEFAULT_SCHEMA_VERSION = 1;
661
+ var ClientSession = class {
662
+ state = "connected";
663
+ clientNodeId = null;
664
+ authContext = null;
665
+ sessionId;
666
+ transport;
667
+ store;
668
+ auth;
669
+ serializer;
670
+ emitter;
671
+ batchSize;
672
+ schemaVersion;
673
+ onRelay;
674
+ onClose;
675
+ constructor(options) {
676
+ this.sessionId = options.sessionId;
677
+ this.transport = options.transport;
678
+ this.store = options.store;
679
+ this.auth = options.auth ?? null;
680
+ this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
681
+ this.emitter = options.emitter ?? null;
682
+ this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
683
+ this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
684
+ this.onRelay = options.onRelay ?? null;
685
+ this.onClose = options.onClose ?? null;
686
+ }
687
+ /**
688
+ * Start handling messages from the client transport.
689
+ */
690
+ start() {
691
+ this.transport.onMessage((msg) => this.handleMessage(msg));
692
+ this.transport.onClose((_code, _reason) => this.handleTransportClose());
693
+ this.transport.onError((_err) => {
694
+ if (this.state !== "closed") {
695
+ this.handleTransportClose();
696
+ }
697
+ });
698
+ }
699
+ /**
700
+ * Relay operations from another session to this client.
701
+ * Only relays if the session is in streaming state and transport is connected.
702
+ */
703
+ relayOperations(operations2) {
704
+ if (this.state !== "streaming" || !this.transport.isConnected()) return;
705
+ if (operations2.length === 0) return;
706
+ const visibleOperations = operations2.filter(
707
+ (op) => operationMatchesScopes(op, this.authContext?.scopes)
708
+ );
709
+ if (visibleOperations.length === 0) return;
710
+ const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
711
+ const msg = {
712
+ type: "operation-batch",
713
+ messageId: generateUUIDv74(),
714
+ operations: serializedOps,
715
+ isFinal: true,
716
+ batchIndex: 0
717
+ };
718
+ this.transport.send(msg);
719
+ }
720
+ /**
721
+ * Close this session.
722
+ */
723
+ close(reason) {
724
+ if (this.state === "closed") return;
725
+ this.state = "closed";
726
+ if (this.transport.isConnected()) {
727
+ this.transport.close(1e3, reason ?? "session closed");
728
+ }
729
+ this.onClose?.(this.sessionId);
730
+ }
731
+ // --- Getters ---
732
+ getState() {
733
+ return this.state;
734
+ }
735
+ getSessionId() {
736
+ return this.sessionId;
737
+ }
738
+ getClientNodeId() {
739
+ return this.clientNodeId;
740
+ }
741
+ getAuthContext() {
742
+ return this.authContext;
743
+ }
744
+ isStreaming() {
745
+ return this.state === "streaming";
746
+ }
747
+ // --- Private protocol handlers ---
748
+ handleMessage(message) {
749
+ switch (message.type) {
750
+ case "handshake":
751
+ this.handleHandshake(message);
752
+ break;
753
+ case "operation-batch":
754
+ this.handleOperationBatch(message);
755
+ break;
756
+ // Acknowledgments from clients are noted but no action needed on server
757
+ case "acknowledgment":
758
+ break;
759
+ case "error":
760
+ break;
761
+ }
762
+ }
763
+ async handleHandshake(msg) {
764
+ if (this.state !== "connected") {
765
+ this.sendError("DUPLICATE_HANDSHAKE", "Handshake already completed", false);
766
+ return;
767
+ }
768
+ this.clientNodeId = msg.nodeId;
769
+ if (this.auth) {
770
+ const token = msg.authToken ?? "";
771
+ const context = await this.auth.authenticate(token);
772
+ if (!context) {
773
+ this.sendError("AUTH_FAILED", "Authentication failed", false);
774
+ this.close("authentication failed");
775
+ return;
776
+ }
777
+ this.authContext = context;
778
+ this.state = "authenticated";
779
+ }
780
+ const serverVector = this.store.getVersionVector();
781
+ const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
782
+ this.setSerializerWireFormat(selectedWireFormat);
783
+ const response = {
784
+ type: "handshake-response",
785
+ messageId: generateUUIDv74(),
786
+ nodeId: this.store.getNodeId(),
787
+ versionVector: versionVectorToWire(serverVector),
788
+ schemaVersion: this.schemaVersion,
789
+ accepted: true,
790
+ selectedWireFormat
791
+ };
792
+ this.transport.send(response);
793
+ this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
794
+ this.state = "syncing";
795
+ const clientVector = wireToVersionVector(msg.versionVector);
796
+ await this.sendDelta(clientVector);
797
+ this.state = "streaming";
798
+ }
799
+ async handleOperationBatch(msg) {
800
+ const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
801
+ const applied = [];
802
+ for (const op of operations2) {
803
+ if (!operationMatchesScopes(op, this.authContext?.scopes)) {
804
+ continue;
805
+ }
806
+ const result = await this.store.applyRemoteOperation(op);
807
+ if (result === "applied") {
808
+ applied.push(op);
809
+ }
810
+ }
811
+ if (operations2.length > 0) {
812
+ this.emitter?.emit({
813
+ type: "sync:received",
814
+ operations: operations2,
815
+ batchSize: operations2.length
816
+ });
817
+ }
818
+ const lastOp = operations2[operations2.length - 1];
819
+ const ack = {
820
+ type: "acknowledgment",
821
+ messageId: generateUUIDv74(),
822
+ acknowledgedMessageId: msg.messageId,
823
+ lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
824
+ };
825
+ this.transport.send(ack);
826
+ if (applied.length > 0) {
827
+ this.onRelay?.(this.sessionId, applied);
828
+ }
829
+ }
830
+ async sendDelta(clientVector) {
831
+ const serverVector = this.store.getVersionVector();
832
+ const missing = [];
833
+ for (const [nodeId, serverSeq] of serverVector) {
834
+ const clientSeq = clientVector.get(nodeId) ?? 0;
835
+ if (serverSeq > clientSeq) {
836
+ const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
837
+ const visible = ops.filter((op) => operationMatchesScopes(op, this.authContext?.scopes));
838
+ missing.push(...visible);
839
+ }
840
+ }
841
+ if (missing.length === 0) {
842
+ const emptyBatch = {
843
+ type: "operation-batch",
844
+ messageId: generateUUIDv74(),
845
+ operations: [],
846
+ isFinal: true,
847
+ batchIndex: 0
848
+ };
849
+ this.transport.send(emptyBatch);
850
+ return;
851
+ }
852
+ const sorted = topologicalSort(missing);
853
+ const totalBatches = Math.ceil(sorted.length / this.batchSize);
854
+ for (let i = 0; i < totalBatches; i++) {
855
+ const start = i * this.batchSize;
856
+ const batchOps = sorted.slice(start, start + this.batchSize);
857
+ const serializedOps = batchOps.map((op) => this.serializer.encodeOperation(op));
858
+ const batchMsg = {
859
+ type: "operation-batch",
860
+ messageId: generateUUIDv74(),
861
+ operations: serializedOps,
862
+ isFinal: i === totalBatches - 1,
863
+ batchIndex: i
864
+ };
865
+ this.transport.send(batchMsg);
866
+ this.emitter?.emit({
867
+ type: "sync:sent",
868
+ operations: batchOps,
869
+ batchSize: batchOps.length
870
+ });
871
+ }
872
+ }
873
+ sendError(code, message, retriable) {
874
+ const errorMsg = {
875
+ type: "error",
876
+ messageId: generateUUIDv74(),
877
+ code,
878
+ message,
879
+ retriable
880
+ };
881
+ this.transport.send(errorMsg);
882
+ }
883
+ setSerializerWireFormat(format) {
884
+ if (typeof this.serializer.setWireFormat === "function") {
885
+ this.serializer.setWireFormat(format);
886
+ }
887
+ }
888
+ handleTransportClose() {
889
+ if (this.state === "closed") return;
890
+ this.state = "closed";
891
+ this.emitter?.emit({ type: "sync:disconnected", reason: "transport closed" });
892
+ this.onClose?.(this.sessionId);
893
+ }
894
+ };
895
+ function selectWireFormat(supportedWireFormats) {
896
+ if (supportedWireFormats?.includes("protobuf")) {
897
+ return "protobuf";
898
+ }
899
+ return "json";
900
+ }
901
+
902
+ // src/server/kora-sync-server.ts
903
+ import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv75 } from "@korajs/core";
904
+ import { JsonMessageSerializer as JsonMessageSerializer2 } from "@korajs/sync";
905
+ var DEFAULT_MAX_CONNECTIONS = 0;
906
+ var DEFAULT_BATCH_SIZE2 = 100;
907
+ var DEFAULT_SCHEMA_VERSION2 = 1;
908
+ var DEFAULT_HOST = "0.0.0.0";
909
+ var DEFAULT_PATH = "/";
910
+ var KoraSyncServer = class {
911
+ store;
912
+ auth;
913
+ serializer;
914
+ emitter;
915
+ maxConnections;
916
+ batchSize;
917
+ schemaVersion;
918
+ port;
919
+ host;
920
+ path;
921
+ sessions = /* @__PURE__ */ new Map();
922
+ httpClients = /* @__PURE__ */ new Map();
923
+ httpSessionToClient = /* @__PURE__ */ new Map();
924
+ wsServer = null;
925
+ running = false;
926
+ constructor(config) {
927
+ this.store = config.store;
928
+ this.auth = config.auth ?? null;
929
+ this.serializer = config.serializer ?? new JsonMessageSerializer2();
930
+ this.emitter = config.emitter ?? null;
931
+ this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
932
+ this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
933
+ this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
934
+ this.port = config.port;
935
+ this.host = config.host ?? DEFAULT_HOST;
936
+ this.path = config.path ?? DEFAULT_PATH;
937
+ }
938
+ /**
939
+ * Start the WebSocket server in standalone mode.
940
+ *
941
+ * @param wsServerImpl - Optional WebSocket server constructor for testing
942
+ */
943
+ async start(wsServerImpl) {
944
+ if (this.running) {
945
+ throw new SyncError4("Server is already running", { port: this.port });
946
+ }
947
+ if (!wsServerImpl && this.port === void 0) {
948
+ throw new SyncError4(
949
+ "Port is required for standalone mode. Provide port in config or use handleConnection() for attach mode.",
950
+ {}
951
+ );
952
+ }
953
+ if (wsServerImpl) {
954
+ this.wsServer = new wsServerImpl({
955
+ port: this.port,
956
+ host: this.host,
957
+ path: this.path
958
+ });
959
+ } else {
960
+ const { WebSocketServer } = await import("ws");
961
+ this.wsServer = new WebSocketServer({
962
+ port: this.port,
963
+ host: this.host,
964
+ path: this.path
965
+ });
966
+ }
967
+ this.wsServer.on("connection", (ws) => {
968
+ const transport = new WsServerTransport(
969
+ ws,
970
+ {
971
+ serializer: this.serializer
972
+ }
973
+ );
974
+ this.handleConnection(transport);
975
+ });
976
+ this.running = true;
977
+ }
978
+ /**
979
+ * Stop the server. Closes all sessions and the WebSocket server.
980
+ */
981
+ async stop() {
982
+ for (const session of this.sessions.values()) {
983
+ session.close("server shutting down");
984
+ }
985
+ this.sessions.clear();
986
+ this.httpClients.clear();
987
+ this.httpSessionToClient.clear();
988
+ if (this.wsServer) {
989
+ await new Promise((resolve) => {
990
+ this.wsServer?.close(() => resolve());
991
+ });
992
+ this.wsServer = null;
993
+ }
994
+ this.running = false;
995
+ }
996
+ /**
997
+ * Handle one HTTP sync request for a long-polling client.
998
+ *
999
+ * A stable `clientId` identifies the logical connection across requests.
1000
+ */
1001
+ async handleHttpRequest(request) {
1002
+ if (!request.clientId || request.clientId.trim().length === 0) {
1003
+ return { status: 400 };
1004
+ }
1005
+ const client = this.getOrCreateHttpClient(request.clientId);
1006
+ if (request.method === "POST") {
1007
+ if (request.body === void 0) {
1008
+ return { status: 400 };
1009
+ }
1010
+ const payload = normalizeHttpBody(request.body, request.contentType);
1011
+ client.transport.receive(payload);
1012
+ return { status: 202 };
1013
+ }
1014
+ if (request.method === "GET") {
1015
+ const polled = client.transport.poll(request.ifNoneMatch);
1016
+ return {
1017
+ status: polled.status,
1018
+ body: polled.body,
1019
+ headers: polled.headers
1020
+ };
1021
+ }
1022
+ return {
1023
+ status: 405,
1024
+ headers: { allow: "GET, POST" }
1025
+ };
1026
+ }
1027
+ /**
1028
+ * Handle an incoming client connection (attach mode).
1029
+ * Creates a new ClientSession for the transport.
1030
+ *
1031
+ * @param transport - The server transport for the new connection
1032
+ * @returns The session ID
1033
+ */
1034
+ handleConnection(transport) {
1035
+ if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
1036
+ transport.send({
1037
+ type: "error",
1038
+ messageId: generateUUIDv75(),
1039
+ code: "MAX_CONNECTIONS",
1040
+ message: `Server has reached maximum connections (${this.maxConnections})`,
1041
+ retriable: true
1042
+ });
1043
+ transport.close(4029, "max connections reached");
1044
+ throw new SyncError4("Maximum connections reached", {
1045
+ current: this.sessions.size,
1046
+ max: this.maxConnections
1047
+ });
1048
+ }
1049
+ const sessionId = generateUUIDv75();
1050
+ const session = new ClientSession({
1051
+ sessionId,
1052
+ transport,
1053
+ store: this.store,
1054
+ auth: this.auth ?? void 0,
1055
+ serializer: this.serializer,
1056
+ emitter: this.emitter ?? void 0,
1057
+ batchSize: this.batchSize,
1058
+ schemaVersion: this.schemaVersion,
1059
+ onRelay: (sourceSessionId, operations2) => {
1060
+ this.handleRelay(sourceSessionId, operations2);
1061
+ },
1062
+ onClose: (sid) => {
1063
+ this.handleSessionClose(sid);
1064
+ }
1065
+ });
1066
+ this.sessions.set(sessionId, session);
1067
+ session.start();
1068
+ return sessionId;
1069
+ }
1070
+ /**
1071
+ * Get the current server status.
1072
+ */
1073
+ async getStatus() {
1074
+ return {
1075
+ running: this.running,
1076
+ connectedClients: this.sessions.size,
1077
+ port: this.port ?? null,
1078
+ totalOperations: await this.store.getOperationCount()
1079
+ };
1080
+ }
1081
+ /**
1082
+ * Get the number of currently connected clients.
1083
+ */
1084
+ getConnectionCount() {
1085
+ return this.sessions.size;
1086
+ }
1087
+ // --- Private ---
1088
+ handleRelay(sourceSessionId, operations2) {
1089
+ for (const [sessionId, session] of this.sessions) {
1090
+ if (sessionId === sourceSessionId) continue;
1091
+ session.relayOperations(operations2);
1092
+ }
1093
+ }
1094
+ handleSessionClose(sessionId) {
1095
+ this.sessions.delete(sessionId);
1096
+ const clientId = this.httpSessionToClient.get(sessionId);
1097
+ if (clientId) {
1098
+ this.httpSessionToClient.delete(sessionId);
1099
+ this.httpClients.delete(clientId);
1100
+ }
1101
+ }
1102
+ getOrCreateHttpClient(clientId) {
1103
+ const existing = this.httpClients.get(clientId);
1104
+ if (existing) {
1105
+ return existing;
1106
+ }
1107
+ const transport = new HttpServerTransport(this.serializer);
1108
+ const sessionId = this.handleConnection(transport);
1109
+ const client = { sessionId, transport };
1110
+ this.httpClients.set(clientId, client);
1111
+ this.httpSessionToClient.set(sessionId, clientId);
1112
+ return client;
1113
+ }
1114
+ };
1115
+ function normalizeHttpBody(body, contentType) {
1116
+ if (body instanceof Uint8Array) {
1117
+ return body;
1118
+ }
1119
+ if (contentType?.includes("application/x-protobuf")) {
1120
+ return new TextEncoder().encode(body);
1121
+ }
1122
+ return body;
1123
+ }
1124
+
1125
+ // src/auth/no-auth.ts
1126
+ var NoAuthProvider = class {
1127
+ async authenticate(_token) {
1128
+ return { userId: "anonymous" };
1129
+ }
1130
+ };
1131
+
1132
+ // src/auth/token-auth.ts
1133
+ var TokenAuthProvider = class {
1134
+ validate;
1135
+ constructor(options) {
1136
+ this.validate = options.validate;
1137
+ }
1138
+ async authenticate(token) {
1139
+ return this.validate(token);
1140
+ }
1141
+ };
1142
+
1143
+ // src/server/create-server.ts
1144
+ function createKoraServer(config) {
1145
+ return new KoraSyncServer(config);
1146
+ }
1147
+ export {
1148
+ ClientSession,
1149
+ HttpServerTransport,
1150
+ KoraSyncServer,
1151
+ MemoryServerStore,
1152
+ NoAuthProvider,
1153
+ PostgresServerStore,
1154
+ SqliteServerStore,
1155
+ TokenAuthProvider,
1156
+ WsServerTransport,
1157
+ createKoraServer,
1158
+ createPostgresServerStore,
1159
+ createSqliteServerStore
1160
+ };
1161
+ //# sourceMappingURL=index.js.map