@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/chunk-DGUM43GV.js +11 -0
- package/dist/chunk-DGUM43GV.js.map +1 -0
- package/dist/index.cjs +1201 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +513 -0
- package/dist/index.d.ts +513 -0
- package/dist/index.js +1161 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.cjs +176 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +82 -0
- package/dist/internal.d.ts +82 -0
- package/dist/internal.js +150 -0
- package/dist/internal.js.map +1 -0
- package/dist/server-transport-CU1BLWgr.d.cts +35 -0
- package/dist/server-transport-CU1BLWgr.d.ts +35 -0
- package/package.json +67 -0
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
|