@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.
Files changed (104) hide show
  1. package/.env +1 -1
  2. package/README.md +56 -0
  3. package/dist/archive/ArchivalService.js +1 -1
  4. package/dist/archive/ArchivalService.js.map +1 -1
  5. package/dist/broker/InMemoryBroker.d.ts +2 -2
  6. package/dist/broker/InMemoryBroker.d.ts.map +1 -1
  7. package/dist/broker/InMemoryBroker.js +4 -4
  8. package/dist/broker/InMemoryBroker.js.map +1 -1
  9. package/dist/broker/types.d.ts +3 -3
  10. package/dist/broker/types.d.ts.map +1 -1
  11. package/dist/journal/CoalescingService.d.ts.map +1 -1
  12. package/dist/journal/CoalescingService.js +18 -208
  13. package/dist/journal/CoalescingService.js.map +1 -1
  14. package/dist/journal/GraphJournalService.d.ts +127 -0
  15. package/dist/journal/GraphJournalService.d.ts.map +1 -0
  16. package/dist/journal/GraphJournalService.js +491 -0
  17. package/dist/journal/GraphJournalService.js.map +1 -0
  18. package/dist/journal/JournalRLE.d.ts +2 -2
  19. package/dist/journal/JournalRLE.js +14 -14
  20. package/dist/journal/JournalRLE.js.map +1 -1
  21. package/dist/journal/JournalRepository.js +7 -7
  22. package/dist/journal/JournalRepository.js.map +1 -1
  23. package/dist/journal/JournalService.d.ts.map +1 -1
  24. package/dist/journal/JournalService.js +6 -40
  25. package/dist/journal/JournalService.js.map +1 -1
  26. package/dist/journal/RLECompression.d.ts +9 -9
  27. package/dist/journal/RLECompression.d.ts.map +1 -1
  28. package/dist/journal/RLECompression.js +22 -22
  29. package/dist/journal/RLECompression.js.map +1 -1
  30. package/dist/journal/TextJournalService.d.ts +98 -0
  31. package/dist/journal/TextJournalService.d.ts.map +1 -0
  32. package/dist/journal/TextJournalService.js +401 -0
  33. package/dist/journal/TextJournalService.js.map +1 -0
  34. package/dist/journal/index.d.ts +3 -1
  35. package/dist/journal/index.d.ts.map +1 -1
  36. package/dist/journal/index.js +4 -1
  37. package/dist/journal/index.js.map +1 -1
  38. package/dist/journal/rle-demo.js +11 -11
  39. package/dist/journal/rle-demo.js.map +1 -1
  40. package/dist/serve.d.ts +29 -11
  41. package/dist/serve.d.ts.map +1 -1
  42. package/dist/serve.js +558 -93
  43. package/dist/serve.js.map +1 -1
  44. package/dist/transport/RTCServer.d.ts +2 -2
  45. package/dist/transport/RTCServer.d.ts.map +1 -1
  46. package/dist/transport/RTCServer.js +22 -22
  47. package/dist/transport/RTCServer.js.map +1 -1
  48. package/docs/API.md +642 -0
  49. package/examples/compression-example.ts +3 -3
  50. package/package.json +2 -2
  51. package/prisma/schema.prisma +124 -6
  52. package/src/archive/ArchivalService.ts +1 -1
  53. package/src/broker/InMemoryBroker.ts +4 -4
  54. package/src/broker/types.ts +3 -3
  55. package/src/journal/CoalescingService.ts +18 -235
  56. package/src/journal/{JournalService.ts → GraphJournalService.ts} +34 -74
  57. package/src/journal/JournalRLE.ts +15 -15
  58. package/src/journal/JournalRepository.ts +7 -7
  59. package/src/journal/RLECompression.ts +24 -24
  60. package/src/journal/TextJournalService.ts +483 -0
  61. package/src/journal/index.ts +10 -2
  62. package/src/journal/rle-demo.ts +11 -11
  63. package/src/serve.ts +598 -94
  64. package/src/transport/RTCServer.ts +23 -23
  65. package/tests/benchmark/journal-optimization-benchmark.test.ts +14 -14
  66. package/tests/compression/compression.test.ts +8 -8
  67. package/tests/demo.ts +88 -88
  68. package/tests/e2e/convergence.test.ts +9 -9
  69. package/tests/e2e/helpers/assertions.ts +22 -0
  70. package/tests/e2e/helpers/createTestServer.ts +4 -4
  71. package/tests/e2e/latency.test.ts +47 -41
  72. package/tests/e2e/packet-loss.test.ts +6 -6
  73. package/tests/e2e/relay.test.ts +9 -9
  74. package/tests/e2e/sync-perf.test.ts +5 -5
  75. package/tests/e2e/sync-reconciliation.test.ts +6 -6
  76. package/tests/e2e/text-sync.test.ts +14 -14
  77. package/tests/e2e/tombstone-convergence.test.ts +22 -22
  78. package/tests/fixtures/array-ops.jsonl +6 -6
  79. package/tests/fixtures/boolean-ops.jsonl +6 -6
  80. package/tests/fixtures/color-ops.jsonl +4 -4
  81. package/tests/fixtures/edit-buffer.jsonl +3 -3
  82. package/tests/fixtures/messages.jsonl +4 -4
  83. package/tests/fixtures/node-ops.jsonl +6 -6
  84. package/tests/fixtures/number-ops.jsonl +7 -7
  85. package/tests/fixtures/object-ops.jsonl +4 -4
  86. package/tests/fixtures/operations.jsonl +7 -7
  87. package/tests/fixtures/string-ops.jsonl +4 -4
  88. package/tests/fixtures/undo-redo.jsonl +3 -3
  89. package/tests/fixtures/vector-ops.jsonl +9 -9
  90. package/tests/integration/repositories.test.ts +8 -9
  91. package/tests/journal/compaction-load-bug.test.ts +31 -31
  92. package/tests/journal/compaction.test.ts +26 -26
  93. package/tests/journal/journal-rle.test.ts +38 -38
  94. package/tests/journal/journal-service.test.ts +13 -13
  95. package/tests/journal/lww-ordering-bug.test.ts +39 -39
  96. package/tests/journal/rle-compression.test.ts +71 -71
  97. package/tests/journal/text-coalescing.test.ts +34 -34
  98. package/tests/test-data/datatypes.ts +85 -85
  99. package/tests/test-data/operations-example.ts +62 -62
  100. package/tests/test-data/scene-example.ts +11 -11
  101. package/tests/unit/operations.test.ts +7 -7
  102. package/tests/unit/s3-compression.test.ts +5 -3
  103. package/tests/unit/vectorClock.test.ts +2 -2
  104. 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
- * Also serves REST API for inspecting the MongoDB database:
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
- * GET /api/documents
10
- * GET /api/documents/:id
11
- * GET /api/documents/:id/journal
12
- * GET /api/documents/:id/operations
13
- * GET /api/documents/:id/sessions
14
- * GET /api/rooms/:roomId/state
15
- * DELETE /api/rooms/:roomId
16
- * POST /api/documents/:id/compact — trigger journal compaction
17
- * POST /api/documents/:id/coalesce — operation coalescing (placeholder)
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 { JournalService } from './journal/index.js';
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 journalService = new JournalService(prisma);
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
- journalService.setMemberClockProvider(async (docId: string) => {
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
- journalService.startCompactionLoop();
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 [sessionId, member] of members) {
98
+ for (const [client, member] of members) {
68
99
  if (!member.connected && (now - member.lastSeen) > SESSION_TTL_MS) {
69
- broker.removeMember(roomId, sessionId);
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 journalService.createDocument(roomId, 'system');
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 journalService.processMessage(docId, msg);
224
+ return graphJournalService.processMessage(docId, msg);
172
225
  },
173
226
  async getStateForClient(roomId: string) {
174
227
  const docId = await ensureRoomDocument(roomId);
175
- return journalService.getStateForClient(docId);
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, { documents, journalBatches, operations, sessions });
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
- sessionId: batch.sessionId,
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 sessionIds since Session collection isn't used
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
- sessionId: string;
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 sid = batch.sessionId;
937
+ const cid = batch.client;
499
938
  const ops = Array.isArray(batch.operations) ? batch.operations : [];
500
- const existing = sessionMap.get(sid);
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(sid, {
509
- sessionId: sid,
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 journalService.clearDocument(docId);
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 journalService.compact(docId);
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 [sessionId, member] of members) {
1117
+ for (const [client, member] of members) {
679
1118
  if (!member.connected && (now - member.lastSeen) > maxAge) {
680
- broker.removeMember(roomId, sessionId);
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 sessionId
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 sessionId: string | undefined;
1139
+ let client: string | undefined;
701
1140
  try {
702
1141
  const data = JSON.parse(body);
703
- sessionId = data.sessionId;
1142
+ client = data.client;
704
1143
  } catch {
705
1144
  // Ignore parse errors - beacon may send empty body
706
1145
  }
707
1146
 
708
- if (sessionId) {
1147
+ if (client) {
709
1148
  // Mark as disconnected and remove immediately
710
1149
  const members = await broker.getMembers(roomId);
711
- if (members.has(sessionId)) {
712
- broker.removeMember(roomId, sessionId);
713
- json(res, { ok: true, removed: true, sessionId });
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
- sessionId: batch.sessionId,
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.sessionId)).size;
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 otype = op.otype ?? op.type ?? 'unknown';
803
- typeDistribution[otype] = (typeDistribution[otype] ?? 0) + 1;
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 uniqueSessionsInClocks = new Set<string>();
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(sessionId => uniqueSessionsInClocks.add(sessionId));
1256
+ Object.keys(clock).forEach(client => uniqueClientsInClocks.add(client));
818
1257
  }
819
1258
  }
820
- const vectorClockComplexity = uniqueSessionsInClocks.size;
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
- // Expect /ws/{roomId}
885
- const match = url.pathname.match(/^\/ws\/([^/]+)$/);
886
- if (!match) {
887
- ws.close(4000, 'Invalid path — expected /ws/{roomId}');
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 roomId = decodeURIComponent(match[1]);
892
- const sessionId = url.searchParams.get('sessionId');
893
- if (!sessionId) {
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
- // Wire up the connection immediately so messages aren't lost.
899
- // The journalAdapter handles room creation lazily inside processMessage.
900
- rtcServer.handleConnection(ws, roomId, sessionId);
1338
+ // Handle graph document connection
1339
+ if (graphMatch) {
1340
+ const roomId = decodeURIComponent(graphMatch[1]);
901
1341
 
902
- // Create session record in database after ensuring document exists
903
- ensureRoomDocument(roomId)
904
- .then(async (docId) => {
905
- // Create session in database
906
- try {
907
- const dbSession = await sessionRepo.create({
908
- documentId: docId,
909
- userId: url.searchParams.get('userId') ?? 'anonymous',
910
- clientId: sessionId,
911
- });
912
- sessionIdMap.set(sessionId, dbSession.id);
913
- // console.log(`[session] created session ${dbSession.id} for client ${sessionId} in doc ${docId}`);
914
- } catch (err) {
915
- console.error(`[session] failed to create session for ${sessionId}:`, err);
916
- }
917
-
918
- // Send stored state
919
- return rtcServer.handleStateTransfer(ws, roomId);
920
- })
921
- .catch((err) => console.error(`[state-transfer] room="${roomId}":`, err));
922
-
923
- // Handle disconnect mark session as disconnected
924
- ws.on('close', () => {
925
- const dbSessionId = sessionIdMap.get(sessionId);
926
- if (dbSessionId) {
927
- sessionRepo.disconnect(dbSessionId)
928
- .then(() => {
929
- // console.log(`[session] disconnected session ${dbSessionId} for client ${sessionId}`);
930
- sessionIdMap.delete(sessionId);
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
- .catch((err) => console.error(`[session] failed to disconnect session ${dbSessionId}:`, err));
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
- // console.log(`vuer-rtc server listening on http://localhost:${PORT}`);
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
- journalService.stopCompactionLoop();
1447
+ graphJournalService.stopCompactionLoop();
1448
+ textJournalService.stopCompactionLoop();
945
1449
  stopSessionHeartbeatSync();
946
1450
  stopSessionCleanupLoop();
947
1451
  server.close(() => {