@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/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
- * 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
  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 { JournalService } from './journal/index.js';
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 journalService = new JournalService(prisma);
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
- journalService.setMemberClockProvider(async (docId) => {
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
- journalService.startCompactionLoop();
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 [sessionId, member] of members) {
91
+ for (const [client, member] of members) {
61
92
  if (!member.connected && (now - member.lastSeen) > SESSION_TTL_MS) {
62
- broker.removeMember(roomId, sessionId);
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 journalService.createDocument(roomId, 'system');
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 journalService.processMessage(docId, msg);
204
+ return graphJournalService.processMessage(docId, msg);
152
205
  },
153
206
  async getStateForClient(roomId) {
154
207
  const docId = await ensureRoomDocument(roomId);
155
- return journalService.getStateForClient(docId);
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, { documents, journalBatches, operations, sessions });
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
- sessionId: batch.sessionId,
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 sessionIds since Session collection isn't used
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 sid = batch.sessionId;
843
+ const cid = batch.client;
437
844
  const ops = Array.isArray(batch.operations) ? batch.operations : [];
438
- const existing = sessionMap.get(sid);
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(sid, {
450
- sessionId: sid,
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 journalService.clearDocument(docId);
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 journalService.compact(docId);
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 [sessionId, member] of members) {
1009
+ for (const [client, member] of members) {
603
1010
  if (!member.connected && (now - member.lastSeen) > maxAge) {
604
- broker.removeMember(roomId, sessionId);
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 sessionId
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 sessionId;
1027
+ let client;
621
1028
  try {
622
1029
  const data = JSON.parse(body);
623
- sessionId = data.sessionId;
1030
+ client = data.client;
624
1031
  }
625
1032
  catch {
626
1033
  // Ignore parse errors - beacon may send empty body
627
1034
  }
628
- if (sessionId) {
1035
+ if (client) {
629
1036
  // Mark as disconnected and remove immediately
630
1037
  const members = await broker.getMembers(roomId);
631
- if (members.has(sessionId)) {
632
- broker.removeMember(roomId, sessionId);
633
- json(res, { ok: true, removed: true, sessionId });
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
- sessionId: batch.sessionId,
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.sessionId)).size;
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 otype = op.otype ?? op.type ?? 'unknown';
709
- typeDistribution[otype] = (typeDistribution[otype] ?? 0) + 1;
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 uniqueSessionsInClocks = new Set();
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(sessionId => uniqueSessionsInClocks.add(sessionId));
1128
+ Object.keys(clock).forEach(client => uniqueClientsInClocks.add(client));
722
1129
  }
723
1130
  }
724
- const vectorClockComplexity = uniqueSessionsInClocks.size;
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
- // Expect /ws/{roomId}
780
- const match = url.pathname.match(/^\/ws\/([^/]+)$/);
781
- if (!match) {
782
- ws.close(4000, 'Invalid path — expected /ws/{roomId}');
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 roomId = decodeURIComponent(match[1]);
786
- const sessionId = url.searchParams.get('sessionId');
787
- if (!sessionId) {
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
- // Wire up the connection immediately so messages aren't lost.
792
- // The journalAdapter handles room creation lazily inside processMessage.
793
- rtcServer.handleConnection(ws, roomId, sessionId);
794
- // Create session record in database after ensuring document exists
795
- ensureRoomDocument(roomId)
796
- .then(async (docId) => {
797
- // Create session in database
798
- try {
799
- const dbSession = await sessionRepo.create({
800
- documentId: docId,
801
- userId: url.searchParams.get('userId') ?? 'anonymous',
802
- clientId: sessionId,
803
- });
804
- sessionIdMap.set(sessionId, dbSession.id);
805
- // console.log(`[session] created session ${dbSession.id} for client ${sessionId} in doc ${docId}`);
806
- }
807
- catch (err) {
808
- console.error(`[session] failed to create session for ${sessionId}:`, err);
809
- }
810
- // Send stored state
811
- return rtcServer.handleStateTransfer(ws, roomId);
812
- })
813
- .catch((err) => console.error(`[state-transfer] room="${roomId}":`, err));
814
- // Handle disconnect mark session as disconnected
815
- ws.on('close', () => {
816
- const dbSessionId = sessionIdMap.get(sessionId);
817
- if (dbSessionId) {
818
- sessionRepo.disconnect(dbSessionId)
819
- .then(() => {
820
- // console.log(`[session] disconnected session ${dbSessionId} for client ${sessionId}`);
821
- sessionIdMap.delete(sessionId);
822
- })
823
- .catch((err) => console.error(`[session] failed to disconnect session ${dbSessionId}:`, err));
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
- // console.log(`vuer-rtc server listening on http://localhost:${PORT}`);
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
- journalService.stopCompactionLoop();
1296
+ graphJournalService.stopCompactionLoop();
1297
+ textJournalService.stopCompactionLoop();
833
1298
  stopSessionHeartbeatSync();
834
1299
  stopSessionCleanupLoop();
835
1300
  server.close(() => {