@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/dist/serve.js
CHANGED
|
@@ -2,37 +2,56 @@
|
|
|
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
|
import { createServer } from 'http';
|
|
20
38
|
import { WebSocketServer } from 'ws';
|
|
21
39
|
import { InMemoryBroker } from './broker/index.js';
|
|
22
40
|
import { RTCServer } from './transport/index.js';
|
|
23
41
|
import { createPrismaClient } from './persistence/PrismaClient.js';
|
|
24
|
-
import {
|
|
42
|
+
import { GraphJournalService, TextJournalService } from './journal/index.js';
|
|
25
43
|
import { SessionRepository } from './persistence/SessionRepository.js';
|
|
26
44
|
import { CoalescingService } from './journal/CoalescingService.js';
|
|
27
45
|
const PORT = Number(process.env.PORT) || 8080;
|
|
28
46
|
const prisma = createPrismaClient();
|
|
29
47
|
const broker = new InMemoryBroker();
|
|
30
|
-
const
|
|
48
|
+
const graphJournalService = new GraphJournalService(prisma);
|
|
49
|
+
const textJournalService = new TextJournalService(prisma);
|
|
31
50
|
const sessionRepo = new SessionRepository(prisma);
|
|
32
51
|
const coalescingService = new CoalescingService(prisma);
|
|
33
|
-
// Wire the broker's member clocks into the journal service so compaction
|
|
52
|
+
// Wire the broker's member clocks into the graph journal service so compaction
|
|
34
53
|
// only folds entries that all connected clients have acknowledged.
|
|
35
|
-
|
|
54
|
+
graphJournalService.setMemberClockProvider(async (docId) => {
|
|
36
55
|
// Reverse-lookup: docId -> roomId (room name is used as document name)
|
|
37
56
|
const doc = await prisma.document.findUnique({ where: { id: docId } });
|
|
38
57
|
if (!doc)
|
|
@@ -42,7 +61,19 @@ journalService.setMemberClockProvider(async (docId) => {
|
|
|
42
61
|
.filter((m) => m.connected)
|
|
43
62
|
.map((m) => m.vectorClock);
|
|
44
63
|
});
|
|
45
|
-
|
|
64
|
+
// Wire the broker's member clocks into the text journal service
|
|
65
|
+
textJournalService.setMemberClockProvider(async (docId) => {
|
|
66
|
+
// Reverse-lookup: docId -> document name
|
|
67
|
+
const doc = await prisma.textDocument.findUnique({ where: { id: docId } });
|
|
68
|
+
if (!doc)
|
|
69
|
+
return [];
|
|
70
|
+
const members = await broker.getMembers(doc.name);
|
|
71
|
+
return Array.from(members.values())
|
|
72
|
+
.filter((m) => m.connected)
|
|
73
|
+
.map((m) => m.vectorClock);
|
|
74
|
+
});
|
|
75
|
+
graphJournalService.startCompactionLoop();
|
|
76
|
+
textJournalService.startCompactionLoop();
|
|
46
77
|
// ── Session TTL cleanup loop ──
|
|
47
78
|
// Automatically removes disconnected sessions after TTL expires.
|
|
48
79
|
const SESSION_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
|
@@ -57,9 +88,9 @@ async function cleanupStaleSessions() {
|
|
|
57
88
|
// Clean up stale sessions from broker memory
|
|
58
89
|
for (const roomId of roomIds) {
|
|
59
90
|
const members = await broker.getMembers(roomId);
|
|
60
|
-
for (const [
|
|
91
|
+
for (const [client, member] of members) {
|
|
61
92
|
if (!member.connected && (now - member.lastSeen) > SESSION_TTL_MS) {
|
|
62
|
-
broker.removeMember(roomId,
|
|
93
|
+
broker.removeMember(roomId, client);
|
|
63
94
|
totalRemoved++;
|
|
64
95
|
}
|
|
65
96
|
}
|
|
@@ -129,6 +160,7 @@ startSessionHeartbeatSync();
|
|
|
129
160
|
// This adapter auto-creates a Document per room and maps between them.
|
|
130
161
|
// Promise-based dedup: concurrent calls for the same roomId share one promise.
|
|
131
162
|
const roomDocPromises = new Map();
|
|
163
|
+
const textDocPromises = new Map();
|
|
132
164
|
// Session ID mapping: clientId (from query param) -> DB session ObjectId
|
|
133
165
|
// This allows us to track which database session corresponds to each WebSocket connection
|
|
134
166
|
const sessionIdMap = new Map();
|
|
@@ -138,24 +170,56 @@ function ensureRoomDocument(roomId) {
|
|
|
138
170
|
const existing = await prisma.document.findFirst({ where: { name: roomId } });
|
|
139
171
|
if (existing)
|
|
140
172
|
return existing.id;
|
|
141
|
-
const docId = await
|
|
173
|
+
const docId = await graphJournalService.createDocument(roomId, 'system');
|
|
142
174
|
// console.log(`[journal] created document ${docId} for room "${roomId}"`);
|
|
143
175
|
return docId;
|
|
144
176
|
})());
|
|
145
177
|
}
|
|
146
178
|
return roomDocPromises.get(roomId);
|
|
147
179
|
}
|
|
180
|
+
function ensureTextDocument(docId) {
|
|
181
|
+
if (!textDocPromises.has(docId)) {
|
|
182
|
+
textDocPromises.set(docId, (async () => {
|
|
183
|
+
const existing = await prisma.textDocument.findFirst({ where: { name: docId } });
|
|
184
|
+
if (existing)
|
|
185
|
+
return existing.id;
|
|
186
|
+
// Create new text document
|
|
187
|
+
const doc = await prisma.textDocument.create({
|
|
188
|
+
data: {
|
|
189
|
+
name: docId,
|
|
190
|
+
ownerId: 'system',
|
|
191
|
+
currentText: '',
|
|
192
|
+
version: 0,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
// console.log(`[journal] created text document ${doc.id} for "${docId}"`);
|
|
196
|
+
return doc.id;
|
|
197
|
+
})());
|
|
198
|
+
}
|
|
199
|
+
return textDocPromises.get(docId);
|
|
200
|
+
}
|
|
148
201
|
const journalAdapter = {
|
|
149
202
|
async processMessage(roomId, msg) {
|
|
150
203
|
const docId = await ensureRoomDocument(roomId);
|
|
151
|
-
return
|
|
204
|
+
return graphJournalService.processMessage(docId, msg);
|
|
152
205
|
},
|
|
153
206
|
async getStateForClient(roomId) {
|
|
154
207
|
const docId = await ensureRoomDocument(roomId);
|
|
155
|
-
return
|
|
208
|
+
return graphJournalService.getStateForClient(docId);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
const textJournalAdapter = {
|
|
212
|
+
async processMessage(docId, msg) {
|
|
213
|
+
const textDocId = await ensureTextDocument(docId);
|
|
214
|
+
return textJournalService.processMessage(textDocId, msg);
|
|
215
|
+
},
|
|
216
|
+
async getStateForClient(docId) {
|
|
217
|
+
const textDocId = await ensureTextDocument(docId);
|
|
218
|
+
return textJournalService.getStateForClient(textDocId);
|
|
156
219
|
},
|
|
157
220
|
};
|
|
158
221
|
const rtcServer = new RTCServer(broker, journalAdapter);
|
|
222
|
+
const textRtcServer = new RTCServer(broker, textJournalAdapter);
|
|
159
223
|
function json(res, data, status = 200) {
|
|
160
224
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
161
225
|
res.end(JSON.stringify(data));
|
|
@@ -179,13 +243,26 @@ async function handleApi(req, res) {
|
|
|
179
243
|
try {
|
|
180
244
|
// GET /api/stats
|
|
181
245
|
if (path === '/api/stats') {
|
|
182
|
-
const [documents, journalBatches, operations, sessions] = await Promise.all([
|
|
246
|
+
const [documents, textDocuments, journalBatches, operations, sessions, textJournalBatches, textOperations, textSessions] = await Promise.all([
|
|
183
247
|
db.document.count(),
|
|
248
|
+
db.textDocument.count(),
|
|
184
249
|
db.journalBatch.count(),
|
|
185
250
|
db.operation.count(),
|
|
186
251
|
db.session.count(),
|
|
252
|
+
db.textJournalBatch.count(),
|
|
253
|
+
db.textOperation.count(),
|
|
254
|
+
db.textSession.count(),
|
|
187
255
|
]);
|
|
188
|
-
json(res, {
|
|
256
|
+
json(res, {
|
|
257
|
+
documents,
|
|
258
|
+
textDocuments,
|
|
259
|
+
journalBatches,
|
|
260
|
+
operations,
|
|
261
|
+
sessions,
|
|
262
|
+
textJournalBatches,
|
|
263
|
+
textOperations,
|
|
264
|
+
textSessions,
|
|
265
|
+
});
|
|
189
266
|
return;
|
|
190
267
|
}
|
|
191
268
|
// GET /api/stats/sizes — detailed size breakdown
|
|
@@ -348,7 +425,39 @@ async function handleApi(req, res) {
|
|
|
348
425
|
});
|
|
349
426
|
return;
|
|
350
427
|
}
|
|
351
|
-
// GET /api/documents
|
|
428
|
+
// GET /api/graph — list all graph documents
|
|
429
|
+
if (path === '/api/graph') {
|
|
430
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 1000);
|
|
431
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
432
|
+
const [docs, total] = await Promise.all([
|
|
433
|
+
db.document.findMany({
|
|
434
|
+
orderBy: { updatedAt: 'desc' },
|
|
435
|
+
select: { id: true, name: true, ownerId: true, version: true, createdAt: true, updatedAt: true },
|
|
436
|
+
take: limit,
|
|
437
|
+
skip: offset,
|
|
438
|
+
}),
|
|
439
|
+
db.document.count(),
|
|
440
|
+
]);
|
|
441
|
+
json(res, { documents: docs, total });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// GET /api/text — list all text documents
|
|
445
|
+
if (path === '/api/text') {
|
|
446
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 1000);
|
|
447
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
448
|
+
const [docs, total] = await Promise.all([
|
|
449
|
+
db.textDocument.findMany({
|
|
450
|
+
orderBy: { updatedAt: 'desc' },
|
|
451
|
+
select: { id: true, name: true, ownerId: true, version: true, createdAt: true, updatedAt: true },
|
|
452
|
+
take: limit,
|
|
453
|
+
skip: offset,
|
|
454
|
+
}),
|
|
455
|
+
db.textDocument.count(),
|
|
456
|
+
]);
|
|
457
|
+
json(res, { documents: docs, total });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
// GET /api/documents — legacy redirect to /api/graph
|
|
352
461
|
if (path === '/api/documents') {
|
|
353
462
|
const docs = await db.document.findMany({
|
|
354
463
|
orderBy: { updatedAt: 'desc' },
|
|
@@ -357,6 +466,304 @@ async function handleApi(req, res) {
|
|
|
357
466
|
json(res, docs);
|
|
358
467
|
return;
|
|
359
468
|
}
|
|
469
|
+
// ── Graph Document Endpoints ──
|
|
470
|
+
// GET /api/graph/:id[/sub]
|
|
471
|
+
const graphMatch = path.match(/^\/api\/graph\/([^/]+)(\/.*)?$/);
|
|
472
|
+
if (graphMatch) {
|
|
473
|
+
const docId = graphMatch[1];
|
|
474
|
+
const sub = graphMatch[2] ?? '';
|
|
475
|
+
// GET /api/graph/:id
|
|
476
|
+
if ((sub === '' || sub === '/') && req.method === 'GET') {
|
|
477
|
+
const doc = await db.document.findUnique({ where: { id: docId } });
|
|
478
|
+
if (!doc) {
|
|
479
|
+
json(res, { error: 'Not found' }, 404);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
json(res, doc);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
// GET /api/graph/:id/journal
|
|
486
|
+
if (sub === '/journal' && req.method === 'GET') {
|
|
487
|
+
const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
|
|
488
|
+
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 100), 1000);
|
|
489
|
+
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
|
|
490
|
+
const [batches, total] = await Promise.all([
|
|
491
|
+
db.journalBatch.findMany({
|
|
492
|
+
where: { documentId: docId },
|
|
493
|
+
orderBy: { persistedAt: order },
|
|
494
|
+
take: limit,
|
|
495
|
+
skip: offset,
|
|
496
|
+
}),
|
|
497
|
+
db.journalBatch.count({ where: { documentId: docId } }),
|
|
498
|
+
]);
|
|
499
|
+
json(res, { batches, total, limit, offset, order });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
// GET /api/graph/:id/operations
|
|
503
|
+
if (sub === '/operations' && req.method === 'GET') {
|
|
504
|
+
const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
|
|
505
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 1000);
|
|
506
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
507
|
+
const batches = await db.journalBatch.findMany({
|
|
508
|
+
where: { documentId: docId },
|
|
509
|
+
orderBy: { lamportTime: order },
|
|
510
|
+
});
|
|
511
|
+
const allOps = [];
|
|
512
|
+
for (const batch of batches) {
|
|
513
|
+
const ops = Array.isArray(batch.operations) ? batch.operations : [];
|
|
514
|
+
for (const op of ops) {
|
|
515
|
+
allOps.push({
|
|
516
|
+
...op,
|
|
517
|
+
client: batch.client,
|
|
518
|
+
batchId: batch.batchId,
|
|
519
|
+
lamportTime: batch.lamportTime,
|
|
520
|
+
persistedAt: batch.persistedAt,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (order === 'desc') {
|
|
525
|
+
allOps.sort((a, b) => (b.lamportTime ?? 0) - (a.lamportTime ?? 0));
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
allOps.sort((a, b) => (a.lamportTime ?? 0) - (b.lamportTime ?? 0));
|
|
529
|
+
}
|
|
530
|
+
const total = allOps.length;
|
|
531
|
+
const paginatedOps = allOps.slice(offset, offset + limit);
|
|
532
|
+
json(res, { operations: paginatedOps, total, limit, offset, order });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
// GET /api/graph/:id/sessions
|
|
536
|
+
if (sub === '/sessions' && req.method === 'GET') {
|
|
537
|
+
const sessions = await db.session.findMany({
|
|
538
|
+
where: { documentId: docId },
|
|
539
|
+
orderBy: { lastSeenAt: 'desc' },
|
|
540
|
+
});
|
|
541
|
+
json(res, { sessions });
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
// POST /api/graph/:id/compact
|
|
545
|
+
if (sub === '/compact' && req.method === 'POST') {
|
|
546
|
+
try {
|
|
547
|
+
const beforeBatches = await db.journalBatch.findMany({ where: { documentId: docId } });
|
|
548
|
+
const beforeOpsCount = beforeBatches.reduce((sum, b) => {
|
|
549
|
+
const ops = Array.isArray(b.operations) ? b.operations : [];
|
|
550
|
+
return sum + ops.length;
|
|
551
|
+
}, 0);
|
|
552
|
+
await graphJournalService.compact(docId);
|
|
553
|
+
const afterBatches = await db.journalBatch.findMany({ where: { documentId: docId } });
|
|
554
|
+
const afterOpsCount = afterBatches.reduce((sum, b) => {
|
|
555
|
+
const ops = Array.isArray(b.operations) ? b.operations : [];
|
|
556
|
+
return sum + ops.length;
|
|
557
|
+
}, 0);
|
|
558
|
+
json(res, {
|
|
559
|
+
message: 'Compaction triggered',
|
|
560
|
+
before: { journal: beforeBatches.length, snapshot: beforeOpsCount },
|
|
561
|
+
after: { journal: afterBatches.length, snapshot: afterOpsCount },
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
catch (e) {
|
|
565
|
+
json(res, { error: e.message }, 500);
|
|
566
|
+
}
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// POST /api/graph/:id/coalesce
|
|
570
|
+
if (sub === '/coalesce' && req.method === 'POST') {
|
|
571
|
+
try {
|
|
572
|
+
let config = {};
|
|
573
|
+
if (req.headers['content-length'] && parseInt(req.headers['content-length']) > 0) {
|
|
574
|
+
const body = await new Promise((resolve) => {
|
|
575
|
+
let data = '';
|
|
576
|
+
req.on('data', chunk => data += chunk);
|
|
577
|
+
req.on('end', () => resolve(data));
|
|
578
|
+
});
|
|
579
|
+
if (body) {
|
|
580
|
+
try {
|
|
581
|
+
config = JSON.parse(body);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// Ignore parse errors, use defaults
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const result = await coalescingService.coalesce(docId, config);
|
|
589
|
+
json(res, result);
|
|
590
|
+
}
|
|
591
|
+
catch (e) {
|
|
592
|
+
json(res, { error: e.message }, 500);
|
|
593
|
+
}
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
// DELETE /api/graph/:id
|
|
597
|
+
if ((sub === '' || sub === '/') && req.method === 'DELETE') {
|
|
598
|
+
try {
|
|
599
|
+
const sessionsDeleted = await db.session.deleteMany({ where: { documentId: docId } });
|
|
600
|
+
const batchesDeleted = await db.journalBatch.deleteMany({ where: { documentId: docId } });
|
|
601
|
+
const opsDeleted = await db.operation.deleteMany({ where: { documentId: docId } });
|
|
602
|
+
const doc = await db.document.delete({ where: { id: docId } });
|
|
603
|
+
const roomId = doc.name;
|
|
604
|
+
rtcServer.clearRoom(roomId);
|
|
605
|
+
await broker.clearRoom(roomId);
|
|
606
|
+
roomDocPromises.delete(roomId);
|
|
607
|
+
json(res, {
|
|
608
|
+
message: 'Document deleted',
|
|
609
|
+
id: docId,
|
|
610
|
+
deleted: {
|
|
611
|
+
sessions: sessionsDeleted.count,
|
|
612
|
+
journalBatches: batchesDeleted.count,
|
|
613
|
+
operations: opsDeleted.count,
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
catch (e) {
|
|
618
|
+
json(res, { error: e.message }, 404);
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// ── Text Document Endpoints ──
|
|
624
|
+
// GET /api/text/:id[/sub]
|
|
625
|
+
const textDocMatch = path.match(/^\/api\/text\/([^/]+)(\/.*)?$/);
|
|
626
|
+
if (textDocMatch) {
|
|
627
|
+
const docId = textDocMatch[1];
|
|
628
|
+
const sub = textDocMatch[2] ?? '';
|
|
629
|
+
// GET /api/text/:id
|
|
630
|
+
if ((sub === '' || sub === '/') && req.method === 'GET') {
|
|
631
|
+
const doc = await db.textDocument.findUnique({ where: { id: docId } });
|
|
632
|
+
if (!doc) {
|
|
633
|
+
json(res, { error: 'Not found' }, 404);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
json(res, {
|
|
637
|
+
id: doc.id,
|
|
638
|
+
name: doc.name,
|
|
639
|
+
currentText: doc.currentText,
|
|
640
|
+
version: doc.version,
|
|
641
|
+
});
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
// GET /api/text/:id/journal
|
|
645
|
+
if (sub === '/journal' && req.method === 'GET') {
|
|
646
|
+
const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
|
|
647
|
+
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 100), 1000);
|
|
648
|
+
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
|
|
649
|
+
const [batches, total] = await Promise.all([
|
|
650
|
+
db.textJournalBatch.findMany({
|
|
651
|
+
where: { textDocumentId: docId },
|
|
652
|
+
orderBy: { persistedAt: order },
|
|
653
|
+
take: limit,
|
|
654
|
+
skip: offset,
|
|
655
|
+
}),
|
|
656
|
+
db.textJournalBatch.count({ where: { textDocumentId: docId } }),
|
|
657
|
+
]);
|
|
658
|
+
json(res, { batches, total, limit, offset, order });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// GET /api/text/:id/operations
|
|
662
|
+
if (sub === '/operations' && req.method === 'GET') {
|
|
663
|
+
const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc';
|
|
664
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 1000);
|
|
665
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
666
|
+
const batches = await db.textJournalBatch.findMany({
|
|
667
|
+
where: { textDocumentId: docId },
|
|
668
|
+
orderBy: { lamportTime: order },
|
|
669
|
+
});
|
|
670
|
+
const allOps = [];
|
|
671
|
+
for (const batch of batches) {
|
|
672
|
+
const ops = Array.isArray(batch.operations) ? batch.operations : [];
|
|
673
|
+
for (const op of ops) {
|
|
674
|
+
allOps.push({
|
|
675
|
+
...op,
|
|
676
|
+
client: batch.client,
|
|
677
|
+
batchId: batch.batchId,
|
|
678
|
+
lamportTime: batch.lamportTime,
|
|
679
|
+
persistedAt: batch.persistedAt,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (order === 'desc') {
|
|
684
|
+
allOps.sort((a, b) => (b.lamportTime ?? 0) - (a.lamportTime ?? 0));
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
allOps.sort((a, b) => (a.lamportTime ?? 0) - (b.lamportTime ?? 0));
|
|
688
|
+
}
|
|
689
|
+
const total = allOps.length;
|
|
690
|
+
const paginatedOps = allOps.slice(offset, offset + limit);
|
|
691
|
+
json(res, { operations: paginatedOps, total, limit, offset, order });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
// GET /api/text/:id/sessions
|
|
695
|
+
if (sub === '/sessions' && req.method === 'GET') {
|
|
696
|
+
const sessions = await db.textSession.findMany({
|
|
697
|
+
where: { textDocumentId: docId },
|
|
698
|
+
orderBy: { lastSeenAt: 'desc' },
|
|
699
|
+
});
|
|
700
|
+
json(res, { sessions });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
// POST /api/text/:id/compact
|
|
704
|
+
if (sub === '/compact' && req.method === 'POST') {
|
|
705
|
+
try {
|
|
706
|
+
const beforeBatches = await db.textJournalBatch.findMany({ where: { textDocumentId: docId } });
|
|
707
|
+
const beforeOpsCount = beforeBatches.reduce((sum, b) => {
|
|
708
|
+
const ops = Array.isArray(b.operations) ? b.operations : [];
|
|
709
|
+
return sum + ops.length;
|
|
710
|
+
}, 0);
|
|
711
|
+
await textJournalService.compact(docId);
|
|
712
|
+
const afterBatches = await db.textJournalBatch.findMany({ where: { textDocumentId: docId } });
|
|
713
|
+
const afterOpsCount = afterBatches.reduce((sum, b) => {
|
|
714
|
+
const ops = Array.isArray(b.operations) ? b.operations : [];
|
|
715
|
+
return sum + ops.length;
|
|
716
|
+
}, 0);
|
|
717
|
+
json(res, {
|
|
718
|
+
message: 'Compaction triggered',
|
|
719
|
+
before: { journal: beforeBatches.length, operations: beforeOpsCount },
|
|
720
|
+
after: { journal: afterBatches.length, operations: afterOpsCount },
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
catch (e) {
|
|
724
|
+
json(res, { error: e.message }, 500);
|
|
725
|
+
}
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
// POST /api/text/:id/coalesce
|
|
729
|
+
if (sub === '/coalesce' && req.method === 'POST') {
|
|
730
|
+
try {
|
|
731
|
+
// TODO: Implement text coalescing
|
|
732
|
+
json(res, { error: 'Text coalescing not yet implemented' }, 501);
|
|
733
|
+
}
|
|
734
|
+
catch (e) {
|
|
735
|
+
json(res, { error: e.message }, 500);
|
|
736
|
+
}
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
// DELETE /api/text/:id
|
|
740
|
+
if ((sub === '' || sub === '/') && req.method === 'DELETE') {
|
|
741
|
+
try {
|
|
742
|
+
const sessionsDeleted = await db.textSession.deleteMany({ where: { textDocumentId: docId } });
|
|
743
|
+
const batchesDeleted = await db.textJournalBatch.deleteMany({ where: { textDocumentId: docId } });
|
|
744
|
+
const opsDeleted = await db.textOperation.deleteMany({ where: { textDocumentId: docId } });
|
|
745
|
+
const doc = await db.textDocument.delete({ where: { id: docId } });
|
|
746
|
+
const textDocName = doc.name;
|
|
747
|
+
textRtcServer.clearRoom(textDocName);
|
|
748
|
+
await broker.clearRoom(textDocName);
|
|
749
|
+
textDocPromises.delete(textDocName);
|
|
750
|
+
json(res, {
|
|
751
|
+
message: 'Text document deleted',
|
|
752
|
+
id: docId,
|
|
753
|
+
deleted: {
|
|
754
|
+
sessions: sessionsDeleted.count,
|
|
755
|
+
journalBatches: batchesDeleted.count,
|
|
756
|
+
operations: opsDeleted.count,
|
|
757
|
+
},
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
catch (e) {
|
|
761
|
+
json(res, { error: e.message }, 404);
|
|
762
|
+
}
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
// ── Legacy Document Endpoints (redirect to /api/graph/) ──
|
|
360
767
|
// GET /api/documents/:id[/sub]
|
|
361
768
|
const docMatch = path.match(/^\/api\/documents\/([^/]+)(\/.*)?$/);
|
|
362
769
|
if (docMatch && req.method === 'GET') {
|
|
@@ -405,7 +812,7 @@ async function handleApi(req, res) {
|
|
|
405
812
|
for (const op of ops) {
|
|
406
813
|
allOps.push({
|
|
407
814
|
...op,
|
|
408
|
-
|
|
815
|
+
client: batch.client,
|
|
409
816
|
batchId: batch.batchId,
|
|
410
817
|
lamportTime: batch.lamportTime,
|
|
411
818
|
persistedAt: batch.persistedAt,
|
|
@@ -425,7 +832,7 @@ async function handleApi(req, res) {
|
|
|
425
832
|
return;
|
|
426
833
|
}
|
|
427
834
|
if (sub === '/sessions') {
|
|
428
|
-
// Sessions are derived from JournalBatch
|
|
835
|
+
// Sessions are derived from JournalBatch clients since Session collection isn't used
|
|
429
836
|
const batches = await db.journalBatch.findMany({
|
|
430
837
|
where: { documentId: docId },
|
|
431
838
|
orderBy: { persistedAt: 'desc' },
|
|
@@ -433,9 +840,9 @@ async function handleApi(req, res) {
|
|
|
433
840
|
// Aggregate session info from batches
|
|
434
841
|
const sessionMap = new Map();
|
|
435
842
|
for (const batch of batches) {
|
|
436
|
-
const
|
|
843
|
+
const cid = batch.client;
|
|
437
844
|
const ops = Array.isArray(batch.operations) ? batch.operations : [];
|
|
438
|
-
const existing = sessionMap.get(
|
|
845
|
+
const existing = sessionMap.get(cid);
|
|
439
846
|
if (existing) {
|
|
440
847
|
existing.batchCount++;
|
|
441
848
|
existing.operationCount += ops.length;
|
|
@@ -446,8 +853,8 @@ async function handleApi(req, res) {
|
|
|
446
853
|
existing.lastLamportTime = Math.max(existing.lastLamportTime, batch.lamportTime ?? 0);
|
|
447
854
|
}
|
|
448
855
|
else {
|
|
449
|
-
sessionMap.set(
|
|
450
|
-
|
|
856
|
+
sessionMap.set(cid, {
|
|
857
|
+
client: cid,
|
|
451
858
|
batchCount: 1,
|
|
452
859
|
operationCount: ops.length,
|
|
453
860
|
firstSeen: batch.persistedAt,
|
|
@@ -481,7 +888,7 @@ async function handleApi(req, res) {
|
|
|
481
888
|
if (docPromise) {
|
|
482
889
|
try {
|
|
483
890
|
const docId = await docPromise;
|
|
484
|
-
await
|
|
891
|
+
await graphJournalService.clearDocument(docId);
|
|
485
892
|
// console.log(`[api] cleared document ${docId} for room "${targetRoomId}"`);
|
|
486
893
|
}
|
|
487
894
|
catch (e) {
|
|
@@ -507,7 +914,7 @@ async function handleApi(req, res) {
|
|
|
507
914
|
}, 0);
|
|
508
915
|
console.log(`[compact] Starting compaction for doc ${docId}: ${beforeBatches.length} batches, ${beforeOpsCount} ops`);
|
|
509
916
|
// Run compaction (no watermark = compact everything)
|
|
510
|
-
await
|
|
917
|
+
await graphJournalService.compact(docId);
|
|
511
918
|
// Get after stats
|
|
512
919
|
const afterBatches = await db.journalBatch.findMany({ where: { documentId: docId } });
|
|
513
920
|
const afterOpsCount = afterBatches.reduce((sum, b) => {
|
|
@@ -599,9 +1006,9 @@ async function handleApi(req, res) {
|
|
|
599
1006
|
const now = Date.now();
|
|
600
1007
|
const members = await broker.getMembers(roomId);
|
|
601
1008
|
let removed = 0;
|
|
602
|
-
for (const [
|
|
1009
|
+
for (const [client, member] of members) {
|
|
603
1010
|
if (!member.connected && (now - member.lastSeen) > maxAge) {
|
|
604
|
-
broker.removeMember(roomId,
|
|
1011
|
+
broker.removeMember(roomId, client);
|
|
605
1012
|
removed++;
|
|
606
1013
|
}
|
|
607
1014
|
}
|
|
@@ -612,25 +1019,25 @@ async function handleApi(req, res) {
|
|
|
612
1019
|
const leaveMatch = path.match(/^\/api\/rooms\/([^/]+)\/leave$/);
|
|
613
1020
|
if (leaveMatch && req.method === 'POST') {
|
|
614
1021
|
const roomId = leaveMatch[1];
|
|
615
|
-
// Parse body for
|
|
1022
|
+
// Parse body for client
|
|
616
1023
|
let body = '';
|
|
617
1024
|
for await (const chunk of req) {
|
|
618
1025
|
body += chunk;
|
|
619
1026
|
}
|
|
620
|
-
let
|
|
1027
|
+
let client;
|
|
621
1028
|
try {
|
|
622
1029
|
const data = JSON.parse(body);
|
|
623
|
-
|
|
1030
|
+
client = data.client;
|
|
624
1031
|
}
|
|
625
1032
|
catch {
|
|
626
1033
|
// Ignore parse errors - beacon may send empty body
|
|
627
1034
|
}
|
|
628
|
-
if (
|
|
1035
|
+
if (client) {
|
|
629
1036
|
// Mark as disconnected and remove immediately
|
|
630
1037
|
const members = await broker.getMembers(roomId);
|
|
631
|
-
if (members.has(
|
|
632
|
-
broker.removeMember(roomId,
|
|
633
|
-
json(res, { ok: true, removed: true,
|
|
1038
|
+
if (members.has(client)) {
|
|
1039
|
+
broker.removeMember(roomId, client);
|
|
1040
|
+
json(res, { ok: true, removed: true, client });
|
|
634
1041
|
return;
|
|
635
1042
|
}
|
|
636
1043
|
}
|
|
@@ -658,7 +1065,7 @@ async function handleApi(req, res) {
|
|
|
658
1065
|
const rawSize = JSON.stringify(batches.map(b => b.operations)).length;
|
|
659
1066
|
// Convert batches to CRDTMessage format for RLE encoding
|
|
660
1067
|
const allMessages = batches.map(batch => ({
|
|
661
|
-
|
|
1068
|
+
client: batch.client,
|
|
662
1069
|
lamportTime: batch.lamportTime ?? 0,
|
|
663
1070
|
timestamp: new Date(batch.persistedAt).getTime(),
|
|
664
1071
|
ops: Array.isArray(batch.operations) ? batch.operations : [],
|
|
@@ -670,7 +1077,7 @@ async function handleApi(req, res) {
|
|
|
670
1077
|
const gzipSize = gzipStats.compressedSize;
|
|
671
1078
|
const compressionRatio = rawSize > 0 ? (rawSize - gzipSize) / rawSize : 0;
|
|
672
1079
|
// Calculate batching metrics
|
|
673
|
-
const uniqueSessions = new Set(allMessages.map(m => m.
|
|
1080
|
+
const uniqueSessions = new Set(allMessages.map(m => m.client)).size;
|
|
674
1081
|
const totalOps = allMessages.reduce((sum, m) => sum + (m.ops?.length ?? 1), 0);
|
|
675
1082
|
const avgOpsPerBatch = batches.length > 0 ? totalOps / batches.length : 0;
|
|
676
1083
|
const batchingEfficiency = allMessages.length > 0 ? batches.length / allMessages.length : 0;
|
|
@@ -705,8 +1112,8 @@ async function handleApi(req, res) {
|
|
|
705
1112
|
// Calculate operation type distribution from journal ops
|
|
706
1113
|
const typeDistribution = {};
|
|
707
1114
|
for (const op of flatOps) {
|
|
708
|
-
const
|
|
709
|
-
typeDistribution[
|
|
1115
|
+
const ot = op.ot ?? op.type ?? 'unknown';
|
|
1116
|
+
typeDistribution[ot] = (typeDistribution[ot] ?? 0) + 1;
|
|
710
1117
|
}
|
|
711
1118
|
// Calculate lamport range from batches
|
|
712
1119
|
const lamportTimes = batches.map(b => b.lamportTime ?? 0);
|
|
@@ -714,14 +1121,14 @@ async function handleApi(req, res) {
|
|
|
714
1121
|
? [Math.min(...lamportTimes), Math.max(...lamportTimes)]
|
|
715
1122
|
: [0, 0];
|
|
716
1123
|
// Calculate vector clock complexity from batches
|
|
717
|
-
const
|
|
1124
|
+
const uniqueClientsInClocks = new Set();
|
|
718
1125
|
for (const batch of batches) {
|
|
719
1126
|
const clock = batch.vectorClock;
|
|
720
1127
|
if (clock && typeof clock === 'object') {
|
|
721
|
-
Object.keys(clock).forEach(
|
|
1128
|
+
Object.keys(clock).forEach(client => uniqueClientsInClocks.add(client));
|
|
722
1129
|
}
|
|
723
1130
|
}
|
|
724
|
-
const vectorClockComplexity =
|
|
1131
|
+
const vectorClockComplexity = uniqueClientsInClocks.size;
|
|
725
1132
|
json(res, {
|
|
726
1133
|
storage: {
|
|
727
1134
|
rawSize,
|
|
@@ -776,60 +1183,118 @@ const server = createServer((req, res) => {
|
|
|
776
1183
|
const wss = new WebSocketServer({ server, path: undefined });
|
|
777
1184
|
wss.on('connection', (ws, req) => {
|
|
778
1185
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
|
|
779
|
-
//
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
1186
|
+
// Match either /ws/graph/{roomId} or /ws/text/{docId}
|
|
1187
|
+
const graphMatch = url.pathname.match(/^\/ws\/graph\/([^/]+)$/);
|
|
1188
|
+
const textMatch = url.pathname.match(/^\/ws\/text\/([^/]+)$/);
|
|
1189
|
+
if (!graphMatch && !textMatch) {
|
|
1190
|
+
ws.close(4000, 'Invalid path — expected /ws/graph/{roomId} or /ws/text/{docId}');
|
|
783
1191
|
return;
|
|
784
1192
|
}
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
ws.close(4001, 'Missing sessionId query parameter');
|
|
1193
|
+
const client = url.searchParams.get('client');
|
|
1194
|
+
if (!client) {
|
|
1195
|
+
ws.close(4001, 'Missing client query parameter');
|
|
789
1196
|
return;
|
|
790
1197
|
}
|
|
791
|
-
//
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1198
|
+
// Handle graph document connection
|
|
1199
|
+
if (graphMatch) {
|
|
1200
|
+
const roomId = decodeURIComponent(graphMatch[1]);
|
|
1201
|
+
// Wire up the connection immediately so messages aren't lost.
|
|
1202
|
+
rtcServer.handleConnection(ws, roomId, client);
|
|
1203
|
+
// Create session record in database after ensuring document exists
|
|
1204
|
+
ensureRoomDocument(roomId)
|
|
1205
|
+
.then(async (docId) => {
|
|
1206
|
+
try {
|
|
1207
|
+
const dbSession = await sessionRepo.create({
|
|
1208
|
+
documentId: docId,
|
|
1209
|
+
userId: url.searchParams.get('userId') ?? 'anonymous',
|
|
1210
|
+
clientId: client,
|
|
1211
|
+
});
|
|
1212
|
+
sessionIdMap.set(client, dbSession.id);
|
|
1213
|
+
// console.log(`[session] created graph session ${dbSession.id} for client ${client} in doc ${docId}`);
|
|
1214
|
+
}
|
|
1215
|
+
catch (err) {
|
|
1216
|
+
console.error(`[session] failed to create graph session for ${client}:`, err);
|
|
1217
|
+
}
|
|
1218
|
+
// Send stored state
|
|
1219
|
+
return rtcServer.handleStateTransfer(ws, roomId);
|
|
1220
|
+
})
|
|
1221
|
+
.catch((err) => console.error(`[state-transfer] room="${roomId}":`, err));
|
|
1222
|
+
// Handle disconnect — mark session as disconnected
|
|
1223
|
+
ws.on('close', () => {
|
|
1224
|
+
const dbSessionId = sessionIdMap.get(client);
|
|
1225
|
+
if (dbSessionId) {
|
|
1226
|
+
sessionRepo.disconnect(dbSessionId)
|
|
1227
|
+
.then(() => {
|
|
1228
|
+
// console.log(`[session] disconnected graph session ${dbSessionId}`);
|
|
1229
|
+
sessionIdMap.delete(client);
|
|
1230
|
+
})
|
|
1231
|
+
.catch((err) => console.error(`[session] failed to disconnect session ${dbSessionId}:`, err));
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
// Handle text document connection
|
|
1236
|
+
if (textMatch) {
|
|
1237
|
+
const docId = decodeURIComponent(textMatch[1]);
|
|
1238
|
+
// Wire up the connection immediately so messages aren't lost.
|
|
1239
|
+
textRtcServer.handleConnection(ws, docId, client);
|
|
1240
|
+
// Create session record in database after ensuring text document exists
|
|
1241
|
+
ensureTextDocument(docId)
|
|
1242
|
+
.then(async (textDocId) => {
|
|
1243
|
+
try {
|
|
1244
|
+
const dbSession = await prisma.textSession.create({
|
|
1245
|
+
data: {
|
|
1246
|
+
textDocumentId: textDocId,
|
|
1247
|
+
userId: url.searchParams.get('userId') ?? 'anonymous',
|
|
1248
|
+
clientId: client,
|
|
1249
|
+
clockValue: 0,
|
|
1250
|
+
},
|
|
1251
|
+
});
|
|
1252
|
+
sessionIdMap.set(client, dbSession.id);
|
|
1253
|
+
// console.log(`[session] created text session ${dbSession.id} for client ${client} in doc ${textDocId}`);
|
|
1254
|
+
}
|
|
1255
|
+
catch (err) {
|
|
1256
|
+
console.error(`[session] failed to create text session for ${client}:`, err);
|
|
1257
|
+
}
|
|
1258
|
+
// Send stored state
|
|
1259
|
+
return textRtcServer.handleStateTransfer(ws, docId);
|
|
1260
|
+
})
|
|
1261
|
+
.catch((err) => console.error(`[state-transfer] text-doc="${docId}":`, err));
|
|
1262
|
+
// Handle disconnect — mark session as disconnected
|
|
1263
|
+
ws.on('close', () => {
|
|
1264
|
+
const dbSessionId = sessionIdMap.get(client);
|
|
1265
|
+
if (dbSessionId) {
|
|
1266
|
+
prisma.textSession.update({
|
|
1267
|
+
where: { id: dbSessionId },
|
|
1268
|
+
data: {
|
|
1269
|
+
connected: false,
|
|
1270
|
+
disconnectedAt: new Date(),
|
|
1271
|
+
},
|
|
1272
|
+
})
|
|
1273
|
+
.then(() => {
|
|
1274
|
+
// console.log(`[session] disconnected text session ${dbSessionId}`);
|
|
1275
|
+
sessionIdMap.delete(client);
|
|
1276
|
+
})
|
|
1277
|
+
.catch((err) => console.error(`[session] failed to disconnect text session ${dbSessionId}:`, err));
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
826
1281
|
});
|
|
827
1282
|
server.listen(PORT, () => {
|
|
828
|
-
|
|
1283
|
+
const cyan = '\x1b[36m';
|
|
1284
|
+
const yellow = '\x1b[33m';
|
|
1285
|
+
const green = '\x1b[32m';
|
|
1286
|
+
const bold = '\x1b[1m';
|
|
1287
|
+
const reset = '\x1b[0m';
|
|
1288
|
+
console.log(`\n${green}${bold}✓${reset} ${green}vuer-rtc server ready${reset}`);
|
|
1289
|
+
console.log(` ${bold}HTTP:${reset} ${cyan}http://localhost:${yellow}${PORT}${reset}`);
|
|
1290
|
+
console.log(` ${bold}WebSocket:${reset} ${cyan}ws://localhost:${yellow}${PORT}${cyan}/ws/graph/{roomId}${reset}`);
|
|
1291
|
+
console.log(` ${bold} ${cyan}ws://localhost:${yellow}${PORT}${cyan}/ws/text/{docId}${reset}`);
|
|
1292
|
+
console.log(` ${bold}REST API:${reset} ${cyan}http://localhost:${yellow}${PORT}${cyan}/api${reset}\n`);
|
|
829
1293
|
});
|
|
830
1294
|
// ── Graceful shutdown ──
|
|
831
1295
|
function shutdown() {
|
|
832
|
-
|
|
1296
|
+
graphJournalService.stopCompactionLoop();
|
|
1297
|
+
textJournalService.stopCompactionLoop();
|
|
833
1298
|
stopSessionHeartbeatSync();
|
|
834
1299
|
stopSessionCleanupLoop();
|
|
835
1300
|
server.close(() => {
|