@vuer-ai/vuer-rtc-server 0.2.3 → 0.4.1
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/.env +1 -1
- package/README.md +56 -0
- package/dist/archive/ArchivalService.js +1 -1
- package/dist/archive/ArchivalService.js.map +1 -1
- package/dist/broker/InMemoryBroker.d.ts +2 -2
- package/dist/broker/InMemoryBroker.d.ts.map +1 -1
- package/dist/broker/InMemoryBroker.js +4 -4
- package/dist/broker/InMemoryBroker.js.map +1 -1
- package/dist/broker/types.d.ts +3 -3
- package/dist/broker/types.d.ts.map +1 -1
- package/dist/journal/CoalescingService.d.ts.map +1 -1
- package/dist/journal/CoalescingService.js +18 -208
- package/dist/journal/CoalescingService.js.map +1 -1
- package/dist/journal/GraphJournalService.d.ts +127 -0
- package/dist/journal/GraphJournalService.d.ts.map +1 -0
- package/dist/journal/GraphJournalService.js +491 -0
- package/dist/journal/GraphJournalService.js.map +1 -0
- package/dist/journal/JournalRLE.d.ts +2 -2
- package/dist/journal/JournalRLE.js +14 -14
- package/dist/journal/JournalRLE.js.map +1 -1
- package/dist/journal/JournalRepository.js +7 -7
- package/dist/journal/JournalRepository.js.map +1 -1
- package/dist/journal/JournalService.d.ts.map +1 -1
- package/dist/journal/JournalService.js +6 -40
- package/dist/journal/JournalService.js.map +1 -1
- package/dist/journal/RLECompression.d.ts +9 -9
- package/dist/journal/RLECompression.d.ts.map +1 -1
- package/dist/journal/RLECompression.js +22 -22
- package/dist/journal/RLECompression.js.map +1 -1
- package/dist/journal/TextJournalService.d.ts +98 -0
- package/dist/journal/TextJournalService.d.ts.map +1 -0
- package/dist/journal/TextJournalService.js +401 -0
- package/dist/journal/TextJournalService.js.map +1 -0
- package/dist/journal/index.d.ts +3 -1
- package/dist/journal/index.d.ts.map +1 -1
- package/dist/journal/index.js +4 -1
- package/dist/journal/index.js.map +1 -1
- package/dist/journal/rle-demo.js +11 -11
- package/dist/journal/rle-demo.js.map +1 -1
- package/dist/serve.d.ts +29 -11
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +558 -93
- package/dist/serve.js.map +1 -1
- package/dist/transport/RTCServer.d.ts +2 -2
- package/dist/transport/RTCServer.d.ts.map +1 -1
- package/dist/transport/RTCServer.js +22 -22
- package/dist/transport/RTCServer.js.map +1 -1
- package/docs/API.md +642 -0
- package/examples/compression-example.ts +3 -3
- package/package.json +2 -2
- package/prisma/schema.prisma +124 -6
- package/src/archive/ArchivalService.ts +1 -1
- package/src/broker/InMemoryBroker.ts +4 -4
- package/src/broker/types.ts +3 -3
- package/src/journal/CoalescingService.ts +18 -235
- package/src/journal/{JournalService.ts → GraphJournalService.ts} +34 -74
- package/src/journal/JournalRLE.ts +15 -15
- package/src/journal/JournalRepository.ts +7 -7
- package/src/journal/RLECompression.ts +24 -24
- package/src/journal/TextJournalService.ts +483 -0
- package/src/journal/index.ts +10 -2
- package/src/journal/rle-demo.ts +11 -11
- package/src/serve.ts +598 -94
- package/src/transport/RTCServer.ts +23 -23
- package/tests/benchmark/journal-optimization-benchmark.test.ts +14 -14
- package/tests/compression/compression.test.ts +8 -8
- package/tests/demo.ts +88 -88
- package/tests/e2e/convergence.test.ts +9 -9
- package/tests/e2e/helpers/assertions.ts +22 -0
- package/tests/e2e/helpers/createTestServer.ts +4 -4
- package/tests/e2e/latency.test.ts +47 -41
- package/tests/e2e/packet-loss.test.ts +6 -6
- package/tests/e2e/relay.test.ts +9 -9
- package/tests/e2e/sync-perf.test.ts +5 -5
- package/tests/e2e/sync-reconciliation.test.ts +6 -6
- package/tests/e2e/text-sync.test.ts +14 -14
- package/tests/e2e/tombstone-convergence.test.ts +22 -22
- package/tests/fixtures/array-ops.jsonl +6 -6
- package/tests/fixtures/boolean-ops.jsonl +6 -6
- package/tests/fixtures/color-ops.jsonl +4 -4
- package/tests/fixtures/edit-buffer.jsonl +3 -3
- package/tests/fixtures/messages.jsonl +4 -4
- package/tests/fixtures/node-ops.jsonl +6 -6
- package/tests/fixtures/number-ops.jsonl +7 -7
- package/tests/fixtures/object-ops.jsonl +4 -4
- package/tests/fixtures/operations.jsonl +7 -7
- package/tests/fixtures/string-ops.jsonl +4 -4
- package/tests/fixtures/undo-redo.jsonl +3 -3
- package/tests/fixtures/vector-ops.jsonl +9 -9
- package/tests/integration/repositories.test.ts +8 -9
- package/tests/journal/compaction-load-bug.test.ts +31 -31
- package/tests/journal/compaction.test.ts +26 -26
- package/tests/journal/journal-rle.test.ts +38 -38
- package/tests/journal/journal-service.test.ts +13 -13
- package/tests/journal/lww-ordering-bug.test.ts +39 -39
- package/tests/journal/rle-compression.test.ts +71 -71
- package/tests/journal/text-coalescing.test.ts +34 -34
- package/tests/test-data/datatypes.ts +85 -85
- package/tests/test-data/operations-example.ts +62 -62
- package/tests/test-data/scene-example.ts +11 -11
- package/tests/unit/operations.test.ts +7 -7
- package/tests/unit/s3-compression.test.ts +5 -3
- package/tests/unit/vectorClock.test.ts +2 -2
- package/tests/journal/multi-session-coalescing.test.ts +0 -871
package/src/serve.ts
CHANGED
|
@@ -2,19 +2,37 @@
|
|
|
2
2
|
* serve.ts — HTTP + WebSocket entry point.
|
|
3
3
|
*
|
|
4
4
|
* Wires real WS connections to RTCServer + InMemoryBroker.
|
|
5
|
-
* URL pattern: ws://localhost:{PORT}/ws/{roomId}?sessionId={sessionId}
|
|
6
5
|
*
|
|
7
|
-
*
|
|
6
|
+
* WebSocket URL patterns:
|
|
7
|
+
* ws://localhost:{PORT}/ws/graph/{roomId}?client={client}
|
|
8
|
+
* ws://localhost:{PORT}/ws/text/{docId}?client={client}
|
|
9
|
+
*
|
|
10
|
+
* REST API for inspecting the MongoDB database:
|
|
8
11
|
* GET /api/stats
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* GET /api/
|
|
12
|
-
* GET /api/
|
|
13
|
-
* GET /api/
|
|
14
|
-
* GET /api/
|
|
15
|
-
*
|
|
16
|
-
* POST /api/
|
|
17
|
-
* POST /api/
|
|
12
|
+
*
|
|
13
|
+
* Graph Documents (Scene Graphs):
|
|
14
|
+
* GET /api/graph
|
|
15
|
+
* GET /api/graph/:id
|
|
16
|
+
* GET /api/graph/:id/journal
|
|
17
|
+
* GET /api/graph/:id/operations
|
|
18
|
+
* GET /api/graph/:id/sessions
|
|
19
|
+
* POST /api/graph/:id/compact
|
|
20
|
+
* POST /api/graph/:id/coalesce
|
|
21
|
+
* DELETE /api/graph/:id
|
|
22
|
+
*
|
|
23
|
+
* Text Documents (Standalone Text CRDTs):
|
|
24
|
+
* GET /api/text
|
|
25
|
+
* GET /api/text/:id
|
|
26
|
+
* GET /api/text/:id/journal
|
|
27
|
+
* GET /api/text/:id/operations
|
|
28
|
+
* GET /api/text/:id/sessions
|
|
29
|
+
* POST /api/text/:id/compact
|
|
30
|
+
* POST /api/text/:id/coalesce
|
|
31
|
+
* DELETE /api/text/:id
|
|
32
|
+
*
|
|
33
|
+
* Legacy endpoints (redirect to /api/graph/):
|
|
34
|
+
* GET /api/documents -> /api/graph
|
|
35
|
+
* GET /api/rooms/:roomId/state -> /api/graph/:id/state
|
|
18
36
|
*/
|
|
19
37
|
|
|
20
38
|
import { createServer, type IncomingMessage, type ServerResponse } from 'http';
|
|
@@ -22,7 +40,7 @@ import { WebSocketServer } from 'ws';
|
|
|
22
40
|
import { InMemoryBroker } from './broker/index.js';
|
|
23
41
|
import { RTCServer } from './transport/index.js';
|
|
24
42
|
import { createPrismaClient } from './persistence/PrismaClient.js';
|
|
25
|
-
import {
|
|
43
|
+
import { GraphJournalService, TextJournalService } from './journal/index.js';
|
|
26
44
|
import { SessionRepository } from './persistence/SessionRepository.js';
|
|
27
45
|
import { CoalescingService } from './journal/CoalescingService.js';
|
|
28
46
|
|
|
@@ -30,13 +48,14 @@ const PORT = Number(process.env.PORT) || 8080;
|
|
|
30
48
|
const prisma = createPrismaClient();
|
|
31
49
|
|
|
32
50
|
const broker = new InMemoryBroker();
|
|
33
|
-
const
|
|
51
|
+
const graphJournalService = new GraphJournalService(prisma);
|
|
52
|
+
const textJournalService = new TextJournalService(prisma);
|
|
34
53
|
const sessionRepo = new SessionRepository(prisma);
|
|
35
54
|
const coalescingService = new CoalescingService(prisma);
|
|
36
55
|
|
|
37
|
-
// Wire the broker's member clocks into the journal service so compaction
|
|
56
|
+
// Wire the broker's member clocks into the graph journal service so compaction
|
|
38
57
|
// only folds entries that all connected clients have acknowledged.
|
|
39
|
-
|
|
58
|
+
graphJournalService.setMemberClockProvider(async (docId: string) => {
|
|
40
59
|
// Reverse-lookup: docId -> roomId (room name is used as document name)
|
|
41
60
|
const doc = await prisma.document.findUnique({ where: { id: docId } });
|
|
42
61
|
if (!doc) return [];
|
|
@@ -46,7 +65,19 @@ journalService.setMemberClockProvider(async (docId: string) => {
|
|
|
46
65
|
.map((m) => m.vectorClock);
|
|
47
66
|
});
|
|
48
67
|
|
|
49
|
-
|
|
68
|
+
// Wire the broker's member clocks into the text journal service
|
|
69
|
+
textJournalService.setMemberClockProvider(async (docId: string) => {
|
|
70
|
+
// Reverse-lookup: docId -> document name
|
|
71
|
+
const doc = await prisma.textDocument.findUnique({ where: { id: docId } });
|
|
72
|
+
if (!doc) return [];
|
|
73
|
+
const members = await broker.getMembers(doc.name);
|
|
74
|
+
return Array.from(members.values())
|
|
75
|
+
.filter((m) => m.connected)
|
|
76
|
+
.map((m) => m.vectorClock);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
graphJournalService.startCompactionLoop();
|
|
80
|
+
textJournalService.startCompactionLoop();
|
|
50
81
|
|
|
51
82
|
// ── Session TTL cleanup loop ──
|
|
52
83
|
// Automatically removes disconnected sessions after TTL expires.
|
|
@@ -64,9 +95,9 @@ async function cleanupStaleSessions() {
|
|
|
64
95
|
// Clean up stale sessions from broker memory
|
|
65
96
|
for (const roomId of roomIds) {
|
|
66
97
|
const members = await broker.getMembers(roomId);
|
|
67
|
-
for (const [
|
|
98
|
+
for (const [client, member] of members) {
|
|
68
99
|
if (!member.connected && (now - member.lastSeen) > SESSION_TTL_MS) {
|
|
69
|
-
broker.removeMember(roomId,
|
|
100
|
+
broker.removeMember(roomId, client);
|
|
70
101
|
totalRemoved++;
|
|
71
102
|
}
|
|
72
103
|
}
|
|
@@ -147,6 +178,7 @@ startSessionHeartbeatSync();
|
|
|
147
178
|
|
|
148
179
|
// Promise-based dedup: concurrent calls for the same roomId share one promise.
|
|
149
180
|
const roomDocPromises = new Map<string, Promise<string>>();
|
|
181
|
+
const textDocPromises = new Map<string, Promise<string>>();
|
|
150
182
|
|
|
151
183
|
// Session ID mapping: clientId (from query param) -> DB session ObjectId
|
|
152
184
|
// This allows us to track which database session corresponds to each WebSocket connection
|
|
@@ -157,7 +189,7 @@ function ensureRoomDocument(roomId: string): Promise<string> {
|
|
|
157
189
|
roomDocPromises.set(roomId, (async () => {
|
|
158
190
|
const existing = await prisma.document.findFirst({ where: { name: roomId } });
|
|
159
191
|
if (existing) return existing.id;
|
|
160
|
-
const docId = await
|
|
192
|
+
const docId = await graphJournalService.createDocument(roomId, 'system');
|
|
161
193
|
// console.log(`[journal] created document ${docId} for room "${roomId}"`);
|
|
162
194
|
return docId;
|
|
163
195
|
})());
|
|
@@ -165,18 +197,51 @@ function ensureRoomDocument(roomId: string): Promise<string> {
|
|
|
165
197
|
return roomDocPromises.get(roomId)!;
|
|
166
198
|
}
|
|
167
199
|
|
|
200
|
+
function ensureTextDocument(docId: string): Promise<string> {
|
|
201
|
+
if (!textDocPromises.has(docId)) {
|
|
202
|
+
textDocPromises.set(docId, (async () => {
|
|
203
|
+
const existing = await prisma.textDocument.findFirst({ where: { name: docId } });
|
|
204
|
+
if (existing) return existing.id;
|
|
205
|
+
// Create new text document
|
|
206
|
+
const doc = await prisma.textDocument.create({
|
|
207
|
+
data: {
|
|
208
|
+
name: docId,
|
|
209
|
+
ownerId: 'system',
|
|
210
|
+
currentText: '',
|
|
211
|
+
version: 0,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
// console.log(`[journal] created text document ${doc.id} for "${docId}"`);
|
|
215
|
+
return doc.id;
|
|
216
|
+
})());
|
|
217
|
+
}
|
|
218
|
+
return textDocPromises.get(docId)!;
|
|
219
|
+
}
|
|
220
|
+
|
|
168
221
|
const journalAdapter = {
|
|
169
222
|
async processMessage(roomId: string, msg: any) {
|
|
170
223
|
const docId = await ensureRoomDocument(roomId);
|
|
171
|
-
return
|
|
224
|
+
return graphJournalService.processMessage(docId, msg);
|
|
172
225
|
},
|
|
173
226
|
async getStateForClient(roomId: string) {
|
|
174
227
|
const docId = await ensureRoomDocument(roomId);
|
|
175
|
-
return
|
|
228
|
+
return graphJournalService.getStateForClient(docId);
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const textJournalAdapter = {
|
|
233
|
+
async processMessage(docId: string, msg: any) {
|
|
234
|
+
const textDocId = await ensureTextDocument(docId);
|
|
235
|
+
return textJournalService.processMessage(textDocId, msg);
|
|
236
|
+
},
|
|
237
|
+
async getStateForClient(docId: string) {
|
|
238
|
+
const textDocId = await ensureTextDocument(docId);
|
|
239
|
+
return textJournalService.getStateForClient(textDocId);
|
|
176
240
|
},
|
|
177
241
|
};
|
|
178
242
|
|
|
179
243
|
const rtcServer = new RTCServer(broker, journalAdapter as any);
|
|
244
|
+
const textRtcServer = new RTCServer(broker, textJournalAdapter as any);
|
|
180
245
|
|
|
181
246
|
function json(res: ServerResponse, data: unknown, status = 200) {
|
|
182
247
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
@@ -207,13 +272,26 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
207
272
|
try {
|
|
208
273
|
// GET /api/stats
|
|
209
274
|
if (path === '/api/stats') {
|
|
210
|
-
const [documents, journalBatches, operations, sessions] = await Promise.all([
|
|
275
|
+
const [documents, textDocuments, journalBatches, operations, sessions, textJournalBatches, textOperations, textSessions] = await Promise.all([
|
|
211
276
|
db.document.count(),
|
|
277
|
+
db.textDocument.count(),
|
|
212
278
|
db.journalBatch.count(),
|
|
213
279
|
db.operation.count(),
|
|
214
280
|
db.session.count(),
|
|
281
|
+
db.textJournalBatch.count(),
|
|
282
|
+
db.textOperation.count(),
|
|
283
|
+
db.textSession.count(),
|
|
215
284
|
]);
|
|
216
|
-
json(res, {
|
|
285
|
+
json(res, {
|
|
286
|
+
documents,
|
|
287
|
+
textDocuments,
|
|
288
|
+
journalBatches,
|
|
289
|
+
operations,
|
|
290
|
+
sessions,
|
|
291
|
+
textJournalBatches,
|
|
292
|
+
textOperations,
|
|
293
|
+
textSessions,
|
|
294
|
+
});
|
|
217
295
|
return;
|
|
218
296
|
}
|
|
219
297
|
|
|
@@ -394,7 +472,43 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
394
472
|
return;
|
|
395
473
|
}
|
|
396
474
|
|
|
397
|
-
// GET /api/documents
|
|
475
|
+
// GET /api/graph — list all graph documents
|
|
476
|
+
if (path === '/api/graph') {
|
|
477
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 1000);
|
|
478
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
479
|
+
|
|
480
|
+
const [docs, total] = await Promise.all([
|
|
481
|
+
db.document.findMany({
|
|
482
|
+
orderBy: { updatedAt: 'desc' },
|
|
483
|
+
select: { id: true, name: true, ownerId: true, version: true, createdAt: true, updatedAt: true },
|
|
484
|
+
take: limit,
|
|
485
|
+
skip: offset,
|
|
486
|
+
}),
|
|
487
|
+
db.document.count(),
|
|
488
|
+
]);
|
|
489
|
+
json(res, { documents: docs, total });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// GET /api/text — list all text documents
|
|
494
|
+
if (path === '/api/text') {
|
|
495
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 1000);
|
|
496
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
497
|
+
|
|
498
|
+
const [docs, total] = await Promise.all([
|
|
499
|
+
db.textDocument.findMany({
|
|
500
|
+
orderBy: { updatedAt: 'desc' },
|
|
501
|
+
select: { id: true, name: true, ownerId: true, version: true, createdAt: true, updatedAt: true },
|
|
502
|
+
take: limit,
|
|
503
|
+
skip: offset,
|
|
504
|
+
}),
|
|
505
|
+
db.textDocument.count(),
|
|
506
|
+
]);
|
|
507
|
+
json(res, { documents: docs, total });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// GET /api/documents — legacy redirect to /api/graph
|
|
398
512
|
if (path === '/api/documents') {
|
|
399
513
|
const docs = await db.document.findMany({
|
|
400
514
|
orderBy: { updatedAt: 'desc' },
|
|
@@ -404,6 +518,331 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
404
518
|
return;
|
|
405
519
|
}
|
|
406
520
|
|
|
521
|
+
// ── Graph Document Endpoints ──
|
|
522
|
+
|
|
523
|
+
// GET /api/graph/:id[/sub]
|
|
524
|
+
const graphMatch = path.match(/^\/api\/graph\/([^/]+)(\/.*)?$/);
|
|
525
|
+
if (graphMatch) {
|
|
526
|
+
const docId = graphMatch[1];
|
|
527
|
+
const sub = graphMatch[2] ?? '';
|
|
528
|
+
|
|
529
|
+
// GET /api/graph/:id
|
|
530
|
+
if ((sub === '' || sub === '/') && req.method === 'GET') {
|
|
531
|
+
const doc = await db.document.findUnique({ where: { id: docId } });
|
|
532
|
+
if (!doc) { json(res, { error: 'Not found' }, 404); return; }
|
|
533
|
+
json(res, doc);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// GET /api/graph/:id/journal
|
|
538
|
+
if (sub === '/journal' && req.method === 'GET') {
|
|
539
|
+
const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
|
|
540
|
+
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 100), 1000);
|
|
541
|
+
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
|
|
542
|
+
|
|
543
|
+
const [batches, total] = await Promise.all([
|
|
544
|
+
db.journalBatch.findMany({
|
|
545
|
+
where: { documentId: docId },
|
|
546
|
+
orderBy: { persistedAt: order },
|
|
547
|
+
take: limit,
|
|
548
|
+
skip: offset,
|
|
549
|
+
}),
|
|
550
|
+
db.journalBatch.count({ where: { documentId: docId } }),
|
|
551
|
+
]);
|
|
552
|
+
json(res, { batches, total, limit, offset, order });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// GET /api/graph/:id/operations
|
|
557
|
+
if (sub === '/operations' && req.method === 'GET') {
|
|
558
|
+
const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
|
|
559
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 1000);
|
|
560
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
561
|
+
|
|
562
|
+
const batches = await db.journalBatch.findMany({
|
|
563
|
+
where: { documentId: docId },
|
|
564
|
+
orderBy: { lamportTime: order },
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const allOps: any[] = [];
|
|
568
|
+
for (const batch of batches) {
|
|
569
|
+
const ops = Array.isArray(batch.operations) ? batch.operations : [];
|
|
570
|
+
for (const op of ops) {
|
|
571
|
+
allOps.push({
|
|
572
|
+
...(op as object),
|
|
573
|
+
client: batch.client,
|
|
574
|
+
batchId: batch.batchId,
|
|
575
|
+
lamportTime: batch.lamportTime,
|
|
576
|
+
persistedAt: batch.persistedAt,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (order === 'desc') {
|
|
582
|
+
allOps.sort((a, b) => (b.lamportTime ?? 0) - (a.lamportTime ?? 0));
|
|
583
|
+
} else {
|
|
584
|
+
allOps.sort((a, b) => (a.lamportTime ?? 0) - (b.lamportTime ?? 0));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const total = allOps.length;
|
|
588
|
+
const paginatedOps = allOps.slice(offset, offset + limit);
|
|
589
|
+
|
|
590
|
+
json(res, { operations: paginatedOps, total, limit, offset, order });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// GET /api/graph/:id/sessions
|
|
595
|
+
if (sub === '/sessions' && req.method === 'GET') {
|
|
596
|
+
const sessions = await db.session.findMany({
|
|
597
|
+
where: { documentId: docId },
|
|
598
|
+
orderBy: { lastSeenAt: 'desc' },
|
|
599
|
+
});
|
|
600
|
+
json(res, { sessions });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// POST /api/graph/:id/compact
|
|
605
|
+
if (sub === '/compact' && req.method === 'POST') {
|
|
606
|
+
try {
|
|
607
|
+
const beforeBatches = await db.journalBatch.findMany({ where: { documentId: docId } });
|
|
608
|
+
const beforeOpsCount = beforeBatches.reduce((sum, b) => {
|
|
609
|
+
const ops = Array.isArray(b.operations) ? b.operations : [];
|
|
610
|
+
return sum + ops.length;
|
|
611
|
+
}, 0);
|
|
612
|
+
|
|
613
|
+
await graphJournalService.compact(docId);
|
|
614
|
+
|
|
615
|
+
const afterBatches = await db.journalBatch.findMany({ where: { documentId: docId } });
|
|
616
|
+
const afterOpsCount = afterBatches.reduce((sum, b) => {
|
|
617
|
+
const ops = Array.isArray(b.operations) ? b.operations : [];
|
|
618
|
+
return sum + ops.length;
|
|
619
|
+
}, 0);
|
|
620
|
+
|
|
621
|
+
json(res, {
|
|
622
|
+
message: 'Compaction triggered',
|
|
623
|
+
before: { journal: beforeBatches.length, snapshot: beforeOpsCount },
|
|
624
|
+
after: { journal: afterBatches.length, snapshot: afterOpsCount },
|
|
625
|
+
});
|
|
626
|
+
} catch (e: any) {
|
|
627
|
+
json(res, { error: e.message }, 500);
|
|
628
|
+
}
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// POST /api/graph/:id/coalesce
|
|
633
|
+
if (sub === '/coalesce' && req.method === 'POST') {
|
|
634
|
+
try {
|
|
635
|
+
let config: any = {};
|
|
636
|
+
if (req.headers['content-length'] && parseInt(req.headers['content-length']) > 0) {
|
|
637
|
+
const body = await new Promise<string>((resolve) => {
|
|
638
|
+
let data = '';
|
|
639
|
+
req.on('data', chunk => data += chunk);
|
|
640
|
+
req.on('end', () => resolve(data));
|
|
641
|
+
});
|
|
642
|
+
if (body) {
|
|
643
|
+
try {
|
|
644
|
+
config = JSON.parse(body);
|
|
645
|
+
} catch {
|
|
646
|
+
// Ignore parse errors, use defaults
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const result = await coalescingService.coalesce(docId, config);
|
|
652
|
+
json(res, result);
|
|
653
|
+
} catch (e: any) {
|
|
654
|
+
json(res, { error: e.message }, 500);
|
|
655
|
+
}
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// DELETE /api/graph/:id
|
|
660
|
+
if ((sub === '' || sub === '/') && req.method === 'DELETE') {
|
|
661
|
+
try {
|
|
662
|
+
const sessionsDeleted = await db.session.deleteMany({ where: { documentId: docId } });
|
|
663
|
+
const batchesDeleted = await db.journalBatch.deleteMany({ where: { documentId: docId } });
|
|
664
|
+
const opsDeleted = await db.operation.deleteMany({ where: { documentId: docId } });
|
|
665
|
+
const doc = await db.document.delete({ where: { id: docId } });
|
|
666
|
+
|
|
667
|
+
const roomId = doc.name;
|
|
668
|
+
rtcServer.clearRoom(roomId);
|
|
669
|
+
await broker.clearRoom(roomId);
|
|
670
|
+
roomDocPromises.delete(roomId);
|
|
671
|
+
|
|
672
|
+
json(res, {
|
|
673
|
+
message: 'Document deleted',
|
|
674
|
+
id: docId,
|
|
675
|
+
deleted: {
|
|
676
|
+
sessions: sessionsDeleted.count,
|
|
677
|
+
journalBatches: batchesDeleted.count,
|
|
678
|
+
operations: opsDeleted.count,
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
} catch (e: any) {
|
|
682
|
+
json(res, { error: e.message }, 404);
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── Text Document Endpoints ──
|
|
689
|
+
|
|
690
|
+
// GET /api/text/:id[/sub]
|
|
691
|
+
const textDocMatch = path.match(/^\/api\/text\/([^/]+)(\/.*)?$/);
|
|
692
|
+
if (textDocMatch) {
|
|
693
|
+
const docId = textDocMatch[1];
|
|
694
|
+
const sub = textDocMatch[2] ?? '';
|
|
695
|
+
|
|
696
|
+
// GET /api/text/:id
|
|
697
|
+
if ((sub === '' || sub === '/') && req.method === 'GET') {
|
|
698
|
+
const doc = await db.textDocument.findUnique({ where: { id: docId } });
|
|
699
|
+
if (!doc) { json(res, { error: 'Not found' }, 404); return; }
|
|
700
|
+
json(res, {
|
|
701
|
+
id: doc.id,
|
|
702
|
+
name: doc.name,
|
|
703
|
+
currentText: doc.currentText,
|
|
704
|
+
version: doc.version,
|
|
705
|
+
});
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// GET /api/text/:id/journal
|
|
710
|
+
if (sub === '/journal' && req.method === 'GET') {
|
|
711
|
+
const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
|
|
712
|
+
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 100), 1000);
|
|
713
|
+
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
|
|
714
|
+
|
|
715
|
+
const [batches, total] = await Promise.all([
|
|
716
|
+
db.textJournalBatch.findMany({
|
|
717
|
+
where: { textDocumentId: docId },
|
|
718
|
+
orderBy: { persistedAt: order },
|
|
719
|
+
take: limit,
|
|
720
|
+
skip: offset,
|
|
721
|
+
}),
|
|
722
|
+
db.textJournalBatch.count({ where: { textDocumentId: docId } }),
|
|
723
|
+
]);
|
|
724
|
+
json(res, { batches, total, limit, offset, order });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// GET /api/text/:id/operations
|
|
729
|
+
if (sub === '/operations' && req.method === 'GET') {
|
|
730
|
+
const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
|
|
731
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 1000);
|
|
732
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
733
|
+
|
|
734
|
+
const batches = await db.textJournalBatch.findMany({
|
|
735
|
+
where: { textDocumentId: docId },
|
|
736
|
+
orderBy: { lamportTime: order },
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const allOps: any[] = [];
|
|
740
|
+
for (const batch of batches) {
|
|
741
|
+
const ops = Array.isArray(batch.operations) ? batch.operations : [];
|
|
742
|
+
for (const op of ops) {
|
|
743
|
+
allOps.push({
|
|
744
|
+
...(op as object),
|
|
745
|
+
client: batch.client,
|
|
746
|
+
batchId: batch.batchId,
|
|
747
|
+
lamportTime: batch.lamportTime,
|
|
748
|
+
persistedAt: batch.persistedAt,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (order === 'desc') {
|
|
754
|
+
allOps.sort((a, b) => (b.lamportTime ?? 0) - (a.lamportTime ?? 0));
|
|
755
|
+
} else {
|
|
756
|
+
allOps.sort((a, b) => (a.lamportTime ?? 0) - (b.lamportTime ?? 0));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const total = allOps.length;
|
|
760
|
+
const paginatedOps = allOps.slice(offset, offset + limit);
|
|
761
|
+
|
|
762
|
+
json(res, { operations: paginatedOps, total, limit, offset, order });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// GET /api/text/:id/sessions
|
|
767
|
+
if (sub === '/sessions' && req.method === 'GET') {
|
|
768
|
+
const sessions = await db.textSession.findMany({
|
|
769
|
+
where: { textDocumentId: docId },
|
|
770
|
+
orderBy: { lastSeenAt: 'desc' },
|
|
771
|
+
});
|
|
772
|
+
json(res, { sessions });
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// POST /api/text/:id/compact
|
|
777
|
+
if (sub === '/compact' && req.method === 'POST') {
|
|
778
|
+
try {
|
|
779
|
+
const beforeBatches = await db.textJournalBatch.findMany({ where: { textDocumentId: docId } });
|
|
780
|
+
const beforeOpsCount = beforeBatches.reduce((sum, b) => {
|
|
781
|
+
const ops = Array.isArray(b.operations) ? b.operations : [];
|
|
782
|
+
return sum + ops.length;
|
|
783
|
+
}, 0);
|
|
784
|
+
|
|
785
|
+
await textJournalService.compact(docId);
|
|
786
|
+
|
|
787
|
+
const afterBatches = await db.textJournalBatch.findMany({ where: { textDocumentId: docId } });
|
|
788
|
+
const afterOpsCount = afterBatches.reduce((sum, b) => {
|
|
789
|
+
const ops = Array.isArray(b.operations) ? b.operations : [];
|
|
790
|
+
return sum + ops.length;
|
|
791
|
+
}, 0);
|
|
792
|
+
|
|
793
|
+
json(res, {
|
|
794
|
+
message: 'Compaction triggered',
|
|
795
|
+
before: { journal: beforeBatches.length, operations: beforeOpsCount },
|
|
796
|
+
after: { journal: afterBatches.length, operations: afterOpsCount },
|
|
797
|
+
});
|
|
798
|
+
} catch (e: any) {
|
|
799
|
+
json(res, { error: e.message }, 500);
|
|
800
|
+
}
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// POST /api/text/:id/coalesce
|
|
805
|
+
if (sub === '/coalesce' && req.method === 'POST') {
|
|
806
|
+
try {
|
|
807
|
+
// TODO: Implement text coalescing
|
|
808
|
+
json(res, { error: 'Text coalescing not yet implemented' }, 501);
|
|
809
|
+
} catch (e: any) {
|
|
810
|
+
json(res, { error: e.message }, 500);
|
|
811
|
+
}
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// DELETE /api/text/:id
|
|
816
|
+
if ((sub === '' || sub === '/') && req.method === 'DELETE') {
|
|
817
|
+
try {
|
|
818
|
+
const sessionsDeleted = await db.textSession.deleteMany({ where: { textDocumentId: docId } });
|
|
819
|
+
const batchesDeleted = await db.textJournalBatch.deleteMany({ where: { textDocumentId: docId } });
|
|
820
|
+
const opsDeleted = await db.textOperation.deleteMany({ where: { textDocumentId: docId } });
|
|
821
|
+
const doc = await db.textDocument.delete({ where: { id: docId } });
|
|
822
|
+
|
|
823
|
+
const textDocName = doc.name;
|
|
824
|
+
textRtcServer.clearRoom(textDocName);
|
|
825
|
+
await broker.clearRoom(textDocName);
|
|
826
|
+
textDocPromises.delete(textDocName);
|
|
827
|
+
|
|
828
|
+
json(res, {
|
|
829
|
+
message: 'Text document deleted',
|
|
830
|
+
id: docId,
|
|
831
|
+
deleted: {
|
|
832
|
+
sessions: sessionsDeleted.count,
|
|
833
|
+
journalBatches: batchesDeleted.count,
|
|
834
|
+
operations: opsDeleted.count,
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
} catch (e: any) {
|
|
838
|
+
json(res, { error: e.message }, 404);
|
|
839
|
+
}
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ── Legacy Document Endpoints (redirect to /api/graph/) ──
|
|
845
|
+
|
|
407
846
|
// GET /api/documents/:id[/sub]
|
|
408
847
|
const docMatch = path.match(/^\/api\/documents\/([^/]+)(\/.*)?$/);
|
|
409
848
|
if (docMatch && req.method === 'GET') {
|
|
@@ -455,7 +894,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
455
894
|
for (const op of ops) {
|
|
456
895
|
allOps.push({
|
|
457
896
|
...(op as object),
|
|
458
|
-
|
|
897
|
+
client: batch.client,
|
|
459
898
|
batchId: batch.batchId,
|
|
460
899
|
lamportTime: batch.lamportTime,
|
|
461
900
|
persistedAt: batch.persistedAt,
|
|
@@ -478,7 +917,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
478
917
|
}
|
|
479
918
|
|
|
480
919
|
if (sub === '/sessions') {
|
|
481
|
-
// Sessions are derived from JournalBatch
|
|
920
|
+
// Sessions are derived from JournalBatch clients since Session collection isn't used
|
|
482
921
|
const batches = await db.journalBatch.findMany({
|
|
483
922
|
where: { documentId: docId },
|
|
484
923
|
orderBy: { persistedAt: 'desc' },
|
|
@@ -486,7 +925,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
486
925
|
|
|
487
926
|
// Aggregate session info from batches
|
|
488
927
|
const sessionMap = new Map<string, {
|
|
489
|
-
|
|
928
|
+
client: string;
|
|
490
929
|
batchCount: number;
|
|
491
930
|
operationCount: number;
|
|
492
931
|
firstSeen: Date;
|
|
@@ -495,9 +934,9 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
495
934
|
}>();
|
|
496
935
|
|
|
497
936
|
for (const batch of batches) {
|
|
498
|
-
const
|
|
937
|
+
const cid = batch.client;
|
|
499
938
|
const ops = Array.isArray(batch.operations) ? batch.operations : [];
|
|
500
|
-
const existing = sessionMap.get(
|
|
939
|
+
const existing = sessionMap.get(cid);
|
|
501
940
|
if (existing) {
|
|
502
941
|
existing.batchCount++;
|
|
503
942
|
existing.operationCount += ops.length;
|
|
@@ -505,8 +944,8 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
505
944
|
if (batch.persistedAt > existing.lastSeen) existing.lastSeen = batch.persistedAt;
|
|
506
945
|
existing.lastLamportTime = Math.max(existing.lastLamportTime, batch.lamportTime ?? 0);
|
|
507
946
|
} else {
|
|
508
|
-
sessionMap.set(
|
|
509
|
-
|
|
947
|
+
sessionMap.set(cid, {
|
|
948
|
+
client: cid,
|
|
510
949
|
batchCount: 1,
|
|
511
950
|
operationCount: ops.length,
|
|
512
951
|
firstSeen: batch.persistedAt,
|
|
@@ -546,7 +985,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
546
985
|
if (docPromise) {
|
|
547
986
|
try {
|
|
548
987
|
const docId = await docPromise;
|
|
549
|
-
await
|
|
988
|
+
await graphJournalService.clearDocument(docId);
|
|
550
989
|
// console.log(`[api] cleared document ${docId} for room "${targetRoomId}"`);
|
|
551
990
|
} catch (e: any) {
|
|
552
991
|
console.warn(`[api] clear doc error:`, e);
|
|
@@ -575,7 +1014,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
575
1014
|
console.log(`[compact] Starting compaction for doc ${docId}: ${beforeBatches.length} batches, ${beforeOpsCount} ops`);
|
|
576
1015
|
|
|
577
1016
|
// Run compaction (no watermark = compact everything)
|
|
578
|
-
await
|
|
1017
|
+
await graphJournalService.compact(docId);
|
|
579
1018
|
|
|
580
1019
|
// Get after stats
|
|
581
1020
|
const afterBatches = await db.journalBatch.findMany({ where: { documentId: docId } });
|
|
@@ -675,9 +1114,9 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
675
1114
|
|
|
676
1115
|
const members = await broker.getMembers(roomId);
|
|
677
1116
|
let removed = 0;
|
|
678
|
-
for (const [
|
|
1117
|
+
for (const [client, member] of members) {
|
|
679
1118
|
if (!member.connected && (now - member.lastSeen) > maxAge) {
|
|
680
|
-
broker.removeMember(roomId,
|
|
1119
|
+
broker.removeMember(roomId, client);
|
|
681
1120
|
removed++;
|
|
682
1121
|
}
|
|
683
1122
|
}
|
|
@@ -691,26 +1130,26 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
691
1130
|
if (leaveMatch && req.method === 'POST') {
|
|
692
1131
|
const roomId = leaveMatch[1];
|
|
693
1132
|
|
|
694
|
-
// Parse body for
|
|
1133
|
+
// Parse body for client
|
|
695
1134
|
let body = '';
|
|
696
1135
|
for await (const chunk of req) {
|
|
697
1136
|
body += chunk;
|
|
698
1137
|
}
|
|
699
1138
|
|
|
700
|
-
let
|
|
1139
|
+
let client: string | undefined;
|
|
701
1140
|
try {
|
|
702
1141
|
const data = JSON.parse(body);
|
|
703
|
-
|
|
1142
|
+
client = data.client;
|
|
704
1143
|
} catch {
|
|
705
1144
|
// Ignore parse errors - beacon may send empty body
|
|
706
1145
|
}
|
|
707
1146
|
|
|
708
|
-
if (
|
|
1147
|
+
if (client) {
|
|
709
1148
|
// Mark as disconnected and remove immediately
|
|
710
1149
|
const members = await broker.getMembers(roomId);
|
|
711
|
-
if (members.has(
|
|
712
|
-
broker.removeMember(roomId,
|
|
713
|
-
json(res, { ok: true, removed: true,
|
|
1150
|
+
if (members.has(client)) {
|
|
1151
|
+
broker.removeMember(roomId, client);
|
|
1152
|
+
json(res, { ok: true, removed: true, client });
|
|
714
1153
|
return;
|
|
715
1154
|
}
|
|
716
1155
|
}
|
|
@@ -742,7 +1181,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
742
1181
|
|
|
743
1182
|
// Convert batches to CRDTMessage format for RLE encoding
|
|
744
1183
|
const allMessages: any[] = batches.map(batch => ({
|
|
745
|
-
|
|
1184
|
+
client: batch.client,
|
|
746
1185
|
lamportTime: batch.lamportTime ?? 0,
|
|
747
1186
|
timestamp: new Date(batch.persistedAt).getTime(),
|
|
748
1187
|
ops: Array.isArray(batch.operations) ? batch.operations : [],
|
|
@@ -757,7 +1196,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
757
1196
|
const compressionRatio = rawSize > 0 ? (rawSize - gzipSize) / rawSize : 0;
|
|
758
1197
|
|
|
759
1198
|
// Calculate batching metrics
|
|
760
|
-
const uniqueSessions = new Set(allMessages.map(m => m.
|
|
1199
|
+
const uniqueSessions = new Set(allMessages.map(m => m.client)).size;
|
|
761
1200
|
const totalOps = allMessages.reduce((sum, m) => sum + (m.ops?.length ?? 1), 0);
|
|
762
1201
|
const avgOpsPerBatch = batches.length > 0 ? totalOps / batches.length : 0;
|
|
763
1202
|
const batchingEfficiency = allMessages.length > 0 ? batches.length / allMessages.length : 0;
|
|
@@ -799,8 +1238,8 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
799
1238
|
// Calculate operation type distribution from journal ops
|
|
800
1239
|
const typeDistribution: Record<string, number> = {};
|
|
801
1240
|
for (const op of flatOps) {
|
|
802
|
-
const
|
|
803
|
-
typeDistribution[
|
|
1241
|
+
const ot = op.ot ?? op.type ?? 'unknown';
|
|
1242
|
+
typeDistribution[ot] = (typeDistribution[ot] ?? 0) + 1;
|
|
804
1243
|
}
|
|
805
1244
|
|
|
806
1245
|
// Calculate lamport range from batches
|
|
@@ -810,14 +1249,14 @@ async function handleApi(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|
|
810
1249
|
: [0, 0];
|
|
811
1250
|
|
|
812
1251
|
// Calculate vector clock complexity from batches
|
|
813
|
-
const
|
|
1252
|
+
const uniqueClientsInClocks = new Set<string>();
|
|
814
1253
|
for (const batch of batches) {
|
|
815
1254
|
const clock = batch.vectorClock as Record<string, number> | null;
|
|
816
1255
|
if (clock && typeof clock === 'object') {
|
|
817
|
-
Object.keys(clock).forEach(
|
|
1256
|
+
Object.keys(clock).forEach(client => uniqueClientsInClocks.add(client));
|
|
818
1257
|
}
|
|
819
1258
|
}
|
|
820
|
-
const vectorClockComplexity =
|
|
1259
|
+
const vectorClockComplexity = uniqueClientsInClocks.size;
|
|
821
1260
|
|
|
822
1261
|
json(res, {
|
|
823
1262
|
storage: {
|
|
@@ -881,67 +1320,132 @@ const wss = new WebSocketServer({ server, path: undefined });
|
|
|
881
1320
|
wss.on('connection', (ws, req) => {
|
|
882
1321
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
883
1322
|
|
|
884
|
-
//
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
1323
|
+
// Match either /ws/graph/{roomId} or /ws/text/{docId}
|
|
1324
|
+
const graphMatch = url.pathname.match(/^\/ws\/graph\/([^/]+)$/);
|
|
1325
|
+
const textMatch = url.pathname.match(/^\/ws\/text\/([^/]+)$/);
|
|
1326
|
+
|
|
1327
|
+
if (!graphMatch && !textMatch) {
|
|
1328
|
+
ws.close(4000, 'Invalid path — expected /ws/graph/{roomId} or /ws/text/{docId}');
|
|
888
1329
|
return;
|
|
889
1330
|
}
|
|
890
1331
|
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
ws.close(4001, 'Missing sessionId query parameter');
|
|
1332
|
+
const client = url.searchParams.get('client');
|
|
1333
|
+
if (!client) {
|
|
1334
|
+
ws.close(4001, 'Missing client query parameter');
|
|
895
1335
|
return;
|
|
896
1336
|
}
|
|
897
1337
|
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
1338
|
+
// Handle graph document connection
|
|
1339
|
+
if (graphMatch) {
|
|
1340
|
+
const roomId = decodeURIComponent(graphMatch[1]);
|
|
901
1341
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1342
|
+
// Wire up the connection immediately so messages aren't lost.
|
|
1343
|
+
rtcServer.handleConnection(ws, roomId, client);
|
|
1344
|
+
|
|
1345
|
+
// Create session record in database after ensuring document exists
|
|
1346
|
+
ensureRoomDocument(roomId)
|
|
1347
|
+
.then(async (docId) => {
|
|
1348
|
+
try {
|
|
1349
|
+
const dbSession = await sessionRepo.create({
|
|
1350
|
+
documentId: docId,
|
|
1351
|
+
userId: url.searchParams.get('userId') ?? 'anonymous',
|
|
1352
|
+
clientId: client,
|
|
1353
|
+
});
|
|
1354
|
+
sessionIdMap.set(client, dbSession.id);
|
|
1355
|
+
// console.log(`[session] created graph session ${dbSession.id} for client ${client} in doc ${docId}`);
|
|
1356
|
+
} catch (err) {
|
|
1357
|
+
console.error(`[session] failed to create graph session for ${client}:`, err);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Send stored state
|
|
1361
|
+
return rtcServer.handleStateTransfer(ws, roomId);
|
|
1362
|
+
})
|
|
1363
|
+
.catch((err) => console.error(`[state-transfer] room="${roomId}":`, err));
|
|
1364
|
+
|
|
1365
|
+
// Handle disconnect — mark session as disconnected
|
|
1366
|
+
ws.on('close', () => {
|
|
1367
|
+
const dbSessionId = sessionIdMap.get(client);
|
|
1368
|
+
if (dbSessionId) {
|
|
1369
|
+
sessionRepo.disconnect(dbSessionId)
|
|
1370
|
+
.then(() => {
|
|
1371
|
+
// console.log(`[session] disconnected graph session ${dbSessionId}`);
|
|
1372
|
+
sessionIdMap.delete(client);
|
|
1373
|
+
})
|
|
1374
|
+
.catch((err) => console.error(`[session] failed to disconnect session ${dbSessionId}:`, err));
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Handle text document connection
|
|
1380
|
+
if (textMatch) {
|
|
1381
|
+
const docId = decodeURIComponent(textMatch[1]);
|
|
1382
|
+
|
|
1383
|
+
// Wire up the connection immediately so messages aren't lost.
|
|
1384
|
+
textRtcServer.handleConnection(ws, docId, client);
|
|
1385
|
+
|
|
1386
|
+
// Create session record in database after ensuring text document exists
|
|
1387
|
+
ensureTextDocument(docId)
|
|
1388
|
+
.then(async (textDocId) => {
|
|
1389
|
+
try {
|
|
1390
|
+
const dbSession = await prisma.textSession.create({
|
|
1391
|
+
data: {
|
|
1392
|
+
textDocumentId: textDocId,
|
|
1393
|
+
userId: url.searchParams.get('userId') ?? 'anonymous',
|
|
1394
|
+
clientId: client,
|
|
1395
|
+
clockValue: 0,
|
|
1396
|
+
},
|
|
1397
|
+
});
|
|
1398
|
+
sessionIdMap.set(client, dbSession.id);
|
|
1399
|
+
// console.log(`[session] created text session ${dbSession.id} for client ${client} in doc ${textDocId}`);
|
|
1400
|
+
} catch (err) {
|
|
1401
|
+
console.error(`[session] failed to create text session for ${client}:`, err);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Send stored state
|
|
1405
|
+
return textRtcServer.handleStateTransfer(ws, docId);
|
|
1406
|
+
})
|
|
1407
|
+
.catch((err) => console.error(`[state-transfer] text-doc="${docId}":`, err));
|
|
1408
|
+
|
|
1409
|
+
// Handle disconnect — mark session as disconnected
|
|
1410
|
+
ws.on('close', () => {
|
|
1411
|
+
const dbSessionId = sessionIdMap.get(client);
|
|
1412
|
+
if (dbSessionId) {
|
|
1413
|
+
prisma.textSession.update({
|
|
1414
|
+
where: { id: dbSessionId },
|
|
1415
|
+
data: {
|
|
1416
|
+
connected: false,
|
|
1417
|
+
disconnectedAt: new Date(),
|
|
1418
|
+
},
|
|
931
1419
|
})
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1420
|
+
.then(() => {
|
|
1421
|
+
// console.log(`[session] disconnected text session ${dbSessionId}`);
|
|
1422
|
+
sessionIdMap.delete(client);
|
|
1423
|
+
})
|
|
1424
|
+
.catch((err) => console.error(`[session] failed to disconnect text session ${dbSessionId}:`, err));
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
935
1428
|
});
|
|
936
1429
|
|
|
937
1430
|
server.listen(PORT, () => {
|
|
938
|
-
|
|
1431
|
+
const cyan = '\x1b[36m';
|
|
1432
|
+
const yellow = '\x1b[33m';
|
|
1433
|
+
const green = '\x1b[32m';
|
|
1434
|
+
const bold = '\x1b[1m';
|
|
1435
|
+
const reset = '\x1b[0m';
|
|
1436
|
+
|
|
1437
|
+
console.log(`\n${green}${bold}✓${reset} ${green}vuer-rtc server ready${reset}`);
|
|
1438
|
+
console.log(` ${bold}HTTP:${reset} ${cyan}http://localhost:${yellow}${PORT}${reset}`);
|
|
1439
|
+
console.log(` ${bold}WebSocket:${reset} ${cyan}ws://localhost:${yellow}${PORT}${cyan}/ws/graph/{roomId}${reset}`);
|
|
1440
|
+
console.log(` ${bold} ${cyan}ws://localhost:${yellow}${PORT}${cyan}/ws/text/{docId}${reset}`);
|
|
1441
|
+
console.log(` ${bold}REST API:${reset} ${cyan}http://localhost:${yellow}${PORT}${cyan}/api${reset}\n`);
|
|
939
1442
|
});
|
|
940
1443
|
|
|
941
1444
|
// ── Graceful shutdown ──
|
|
942
1445
|
|
|
943
1446
|
function shutdown() {
|
|
944
|
-
|
|
1447
|
+
graphJournalService.stopCompactionLoop();
|
|
1448
|
+
textJournalService.stopCompactionLoop();
|
|
945
1449
|
stopSessionHeartbeatSync();
|
|
946
1450
|
stopSessionCleanupLoop();
|
|
947
1451
|
server.close(() => {
|