@vuer-ai/vuer-rtc-server 0.2.0 → 0.2.2

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 (76) hide show
  1. package/.env +1 -0
  2. package/S3_COMPRESSION_GUIDE.md +233 -0
  3. package/dist/archive/ArchivalService.d.ts +117 -0
  4. package/dist/archive/ArchivalService.d.ts.map +1 -0
  5. package/dist/archive/ArchivalService.js +181 -0
  6. package/dist/archive/ArchivalService.js.map +1 -0
  7. package/dist/broker/InMemoryBroker.d.ts +2 -0
  8. package/dist/broker/InMemoryBroker.d.ts.map +1 -1
  9. package/dist/broker/InMemoryBroker.js +4 -0
  10. package/dist/broker/InMemoryBroker.js.map +1 -1
  11. package/dist/compression/CompressionUtils.d.ts +57 -0
  12. package/dist/compression/CompressionUtils.d.ts.map +1 -0
  13. package/dist/compression/CompressionUtils.js +90 -0
  14. package/dist/compression/CompressionUtils.js.map +1 -0
  15. package/dist/compression/index.d.ts +7 -0
  16. package/dist/compression/index.d.ts.map +1 -0
  17. package/dist/compression/index.js +7 -0
  18. package/dist/compression/index.js.map +1 -0
  19. package/dist/journal/CoalescingService.d.ts +63 -0
  20. package/dist/journal/CoalescingService.d.ts.map +1 -0
  21. package/dist/journal/CoalescingService.js +507 -0
  22. package/dist/journal/CoalescingService.js.map +1 -0
  23. package/dist/journal/JournalRLE.d.ts +81 -0
  24. package/dist/journal/JournalRLE.d.ts.map +1 -0
  25. package/dist/journal/JournalRLE.js +199 -0
  26. package/dist/journal/JournalRLE.js.map +1 -0
  27. package/dist/journal/JournalService.d.ts +7 -3
  28. package/dist/journal/JournalService.d.ts.map +1 -1
  29. package/dist/journal/JournalService.js +152 -12
  30. package/dist/journal/JournalService.js.map +1 -1
  31. package/dist/journal/RLECompression.d.ts +73 -0
  32. package/dist/journal/RLECompression.d.ts.map +1 -0
  33. package/dist/journal/RLECompression.js +152 -0
  34. package/dist/journal/RLECompression.js.map +1 -0
  35. package/dist/journal/rle-demo.d.ts +8 -0
  36. package/dist/journal/rle-demo.d.ts.map +1 -0
  37. package/dist/journal/rle-demo.js +159 -0
  38. package/dist/journal/rle-demo.js.map +1 -0
  39. package/dist/persistence/S3ColdStorage.d.ts +62 -0
  40. package/dist/persistence/S3ColdStorage.d.ts.map +1 -0
  41. package/dist/persistence/S3ColdStorage.js +88 -0
  42. package/dist/persistence/S3ColdStorage.js.map +1 -0
  43. package/dist/persistence/S3ColdStorageIntegration.d.ts +78 -0
  44. package/dist/persistence/S3ColdStorageIntegration.d.ts.map +1 -0
  45. package/dist/persistence/S3ColdStorageIntegration.js +93 -0
  46. package/dist/persistence/S3ColdStorageIntegration.js.map +1 -0
  47. package/dist/serve.d.ts +2 -0
  48. package/dist/serve.d.ts.map +1 -1
  49. package/dist/serve.js +623 -15
  50. package/dist/serve.js.map +1 -1
  51. package/docs/RLE_COMPRESSION.md +397 -0
  52. package/examples/compression-example.ts +259 -0
  53. package/package.json +14 -14
  54. package/src/archive/ArchivalService.ts +250 -0
  55. package/src/broker/InMemoryBroker.ts +5 -0
  56. package/src/compression/CompressionUtils.ts +113 -0
  57. package/src/compression/index.ts +14 -0
  58. package/src/journal/COALESCING.md +267 -0
  59. package/src/journal/CoalescingService.ts +626 -0
  60. package/src/journal/JournalRLE.ts +265 -0
  61. package/src/journal/JournalService.ts +163 -11
  62. package/src/journal/RLECompression.ts +210 -0
  63. package/src/journal/rle-demo.ts +193 -0
  64. package/src/serve.ts +702 -15
  65. package/tests/benchmark/journal-optimization-benchmark.test.ts +482 -0
  66. package/tests/compression/compression.test.ts +343 -0
  67. package/tests/integration/repositories.test.ts +89 -0
  68. package/tests/journal/compaction-load-bug.test.ts +409 -0
  69. package/tests/journal/compaction.test.ts +42 -2
  70. package/tests/journal/journal-rle.test.ts +511 -0
  71. package/tests/journal/lww-ordering-bug.test.ts +248 -0
  72. package/tests/journal/multi-session-coalescing.test.ts +871 -0
  73. package/tests/journal/rle-compression.test.ts +526 -0
  74. package/tests/journal/text-coalescing.test.ts +210 -0
  75. package/tests/unit/s3-compression.test.ts +257 -0
  76. package/PHASE1_SUMMARY.md +0 -94
@@ -0,0 +1,871 @@
1
+ /**
2
+ * Multi-Session Coalescing Test
3
+ *
4
+ * Tests the critical bug fix: Operations from DIFFERENT sessions must never
5
+ * be merged into a single message during coalescing. This preserves CRDT
6
+ * metadata (sessionId, clock, lamportTime) which is essential for correct
7
+ * conflict resolution.
8
+ *
9
+ * The Bug (Fixed):
10
+ * - Coalescing was merging operations from alice and bob into one message
11
+ * - This broke CRDT semantics because the merged message had wrong metadata
12
+ * - Last-Write-Wins (LWW) and other conflict resolution relied on this metadata
13
+ *
14
+ * The Fix:
15
+ * - Operations are now coalesced separately per session
16
+ * - Each session gets its own message with correct metadata
17
+ * - Operations within a session are still coalesced (text inserts, set ops, etc)
18
+ *
19
+ * ⚠️ MongoDB Replica Set Required
20
+ * These tests require MongoDB to be running with replica set enabled because
21
+ * Prisma uses transactions for atomic operations. To run these tests:
22
+ *
23
+ * 1. Start MongoDB with replica set:
24
+ * docker compose -f docker/docker-compose.yml up -d mongo
25
+ *
26
+ * 2. Verify replica set is initialized:
27
+ * docker exec vuer-rtc-mongo mongosh --eval "rs.status().set"
28
+ * (should output: rs0)
29
+ *
30
+ * 3. Ensure .env has replica set configuration:
31
+ * DATABASE_URL="mongodb://localhost:27017/vuer?replicaSet=rs0&directConnection=true"
32
+ *
33
+ * 4. Run tests:
34
+ * npm test -- multi-session-coalescing.test.ts
35
+ */
36
+
37
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
38
+ import type { CRDTMessage, Operation } from '@vuer-ai/vuer-rtc';
39
+ import { JournalService } from '../../src/journal/JournalService.js';
40
+ import { PrismaClient } from '@prisma/client';
41
+
42
+ describe('Multi-Session Coalescing', () => {
43
+ let service: JournalService;
44
+ let prisma: PrismaClient;
45
+ let documentId: string;
46
+
47
+ beforeEach(async () => {
48
+ prisma = new PrismaClient();
49
+ await prisma.$connect();
50
+
51
+ // Clean up test data
52
+ await prisma.journalBatch.deleteMany({});
53
+ await prisma.document.deleteMany({});
54
+
55
+ service = new JournalService(prisma);
56
+ documentId = await service.createDocument('test-doc', 'test-user');
57
+ });
58
+
59
+ afterEach(async () => {
60
+ await prisma.$disconnect();
61
+ });
62
+
63
+ it('should keep operations from different sessions in separate messages', async () => {
64
+ // Create text node
65
+ const initMsg: CRDTMessage = {
66
+ id: 'msg-init',
67
+ sessionId: 'system',
68
+ clock: { system: 1 },
69
+ lamportTime: 1,
70
+ timestamp: Date.now() / 1000,
71
+ ops: [
72
+ {
73
+ key: 'default-scene',
74
+ otype: 'node.insert',
75
+ path: 'children',
76
+ value: {
77
+ key: 'text-doc',
78
+ tag: 'Text',
79
+ name: 'Text Doc',
80
+ },
81
+ } as Operation,
82
+ {
83
+ key: 'text-doc',
84
+ otype: 'text.init',
85
+ path: 'content',
86
+ value: '',
87
+ } as Operation,
88
+ ],
89
+ };
90
+
91
+ await service.processMessage(documentId, initMsg);
92
+
93
+ // Alice types "hello" (5 separate messages)
94
+ for (let i = 0; i < 5; i++) {
95
+ const char = 'hello'[i];
96
+ const msg: CRDTMessage = {
97
+ id: `msg-alice-${i}`,
98
+ sessionId: 'alice',
99
+ clock: { alice: 1 + i, system: 1 },
100
+ lamportTime: 2 + i,
101
+ timestamp: Date.now() / 1000 + i * 0.1,
102
+ ops: [
103
+ {
104
+ key: 'text-doc',
105
+ otype: 'text.insert',
106
+ path: 'content',
107
+ position: i,
108
+ value: char,
109
+ } as Operation,
110
+ ],
111
+ };
112
+ await service.processMessage(documentId, msg);
113
+ }
114
+
115
+ // Bob types "world" (5 separate messages)
116
+ for (let i = 0; i < 5; i++) {
117
+ const char = 'world'[i];
118
+ const msg: CRDTMessage = {
119
+ id: `msg-bob-${i}`,
120
+ sessionId: 'bob',
121
+ clock: { bob: 1 + i, alice: 5, system: 1 },
122
+ lamportTime: 7 + i,
123
+ timestamp: Date.now() / 1000 + 0.5 + i * 0.1,
124
+ ops: [
125
+ {
126
+ key: 'text-doc',
127
+ otype: 'text.insert',
128
+ path: 'content',
129
+ position: 5 + i,
130
+ value: char,
131
+ } as Operation,
132
+ ],
133
+ };
134
+ await service.processMessage(documentId, msg);
135
+ }
136
+
137
+ // Before compaction: verify we have 11 batches (1 init + 5 alice + 5 bob)
138
+ const batchesBefore = await prisma.journalBatch.findMany({
139
+ where: { documentId },
140
+ orderBy: { lamportTime: 'asc' },
141
+ });
142
+ expect(batchesBefore.length).toBe(11);
143
+
144
+ // Compact
145
+ await service.compact(documentId);
146
+
147
+ // After compaction: should have 3 batches (1 system + 1 alice + 1 bob)
148
+ const batchesAfter = await prisma.journalBatch.findMany({
149
+ where: { documentId },
150
+ orderBy: { lamportTime: 'asc' },
151
+ });
152
+
153
+ expect(batchesAfter.length).toBe(3);
154
+
155
+ // Verify sessions are preserved
156
+ const sessionIds = batchesAfter.map(b => b.sessionId).sort();
157
+ expect(sessionIds).toEqual(['alice', 'bob', 'system']);
158
+
159
+ // Verify alice's batch
160
+ const aliceBatch = batchesAfter.find(b => b.sessionId === 'alice');
161
+ expect(aliceBatch).toBeDefined();
162
+ expect(aliceBatch!.sessionId).toBe('alice');
163
+ const aliceOps = aliceBatch!.operations as any[];
164
+ expect(aliceOps.length).toBe(1); // Should be coalesced into one text.insert
165
+ expect(aliceOps[0].otype).toBe('text.insert');
166
+ expect(aliceOps[0].value).toBe('hello');
167
+ expect(aliceOps[0].position).toBe(0);
168
+
169
+ // Verify bob's batch
170
+ const bobBatch = batchesAfter.find(b => b.sessionId === 'bob');
171
+ expect(bobBatch).toBeDefined();
172
+ expect(bobBatch!.sessionId).toBe('bob');
173
+ const bobOps = bobBatch!.operations as any[];
174
+ expect(bobOps.length).toBe(1); // Should be coalesced into one text.insert
175
+ expect(bobOps[0].otype).toBe('text.insert');
176
+ expect(bobOps[0].value).toBe('world');
177
+ expect(bobOps[0].position).toBe(5);
178
+
179
+ // Verify vector clocks are preserved
180
+ expect(aliceBatch!.vectorClock).toBeDefined();
181
+ expect(bobBatch!.vectorClock).toBeDefined();
182
+ });
183
+
184
+ it('should preserve metadata for each session', async () => {
185
+ // Create a node
186
+ const initMsg: CRDTMessage = {
187
+ id: 'msg-init',
188
+ sessionId: 'system',
189
+ clock: { system: 1 },
190
+ lamportTime: 1,
191
+ timestamp: Date.now() / 1000,
192
+ ops: [
193
+ {
194
+ key: 'default-scene',
195
+ otype: 'node.insert',
196
+ path: 'children',
197
+ value: {
198
+ key: 'cube',
199
+ tag: 'Mesh',
200
+ name: 'Cube',
201
+ },
202
+ } as Operation,
203
+ ],
204
+ };
205
+ await service.processMessage(documentId, initMsg);
206
+
207
+ const baseTime = Date.now() / 1000;
208
+
209
+ // Alice modifies position multiple times
210
+ const aliceOps = [
211
+ { position: [1, 0, 0], time: baseTime + 0.1 },
212
+ { position: [2, 0, 0], time: baseTime + 0.2 },
213
+ { position: [3, 0, 0], time: baseTime + 0.3 },
214
+ ];
215
+
216
+ for (let i = 0; i < aliceOps.length; i++) {
217
+ const msg: CRDTMessage = {
218
+ id: `msg-alice-${i}`,
219
+ sessionId: 'alice',
220
+ clock: { alice: 2 + i, system: 1 },
221
+ lamportTime: 2 + i,
222
+ timestamp: aliceOps[i].time,
223
+ ops: [
224
+ {
225
+ key: 'cube',
226
+ otype: 'vector3.set',
227
+ path: 'position',
228
+ value: aliceOps[i].position as [number, number, number],
229
+ } as Operation,
230
+ ],
231
+ };
232
+ await service.processMessage(documentId, msg);
233
+ }
234
+
235
+ // Bob modifies rotation multiple times
236
+ const bobOps = [
237
+ { rotation: [0, 1, 0], time: baseTime + 1.0 },
238
+ { rotation: [0, 2, 0], time: baseTime + 1.1 },
239
+ { rotation: [0, 3, 0], time: baseTime + 1.2 },
240
+ ];
241
+
242
+ for (let i = 0; i < bobOps.length; i++) {
243
+ const msg: CRDTMessage = {
244
+ id: `msg-bob-${i}`,
245
+ sessionId: 'bob',
246
+ clock: { bob: 1 + i, alice: 4, system: 1 },
247
+ lamportTime: 5 + i,
248
+ timestamp: bobOps[i].time,
249
+ ops: [
250
+ {
251
+ key: 'cube',
252
+ otype: 'vector3.set',
253
+ path: 'rotation',
254
+ value: bobOps[i].rotation as [number, number, number],
255
+ } as Operation,
256
+ ],
257
+ };
258
+ await service.processMessage(documentId, msg);
259
+ }
260
+
261
+ // Compact
262
+ await service.compact(documentId);
263
+
264
+ // After compaction: verify metadata is preserved per session
265
+ const batchesAfter = await prisma.journalBatch.findMany({
266
+ where: { documentId },
267
+ orderBy: { lamportTime: 'asc' },
268
+ });
269
+
270
+ // Should have 3 batches: system, alice, bob
271
+ expect(batchesAfter.length).toBe(3);
272
+
273
+ // Check alice's batch metadata
274
+ const aliceBatch = batchesAfter.find(b => b.sessionId === 'alice');
275
+ expect(aliceBatch).toBeDefined();
276
+ expect(aliceBatch!.sessionId).toBe('alice');
277
+ expect(aliceBatch!.lamportTime).toBe(4); // Last alice lamportTime
278
+ expect((aliceBatch!.vectorClock as any).alice).toBeDefined();
279
+ // Should have only the last position (LWW coalescing)
280
+ const aliceOpsAfter = aliceBatch!.operations as any[];
281
+ expect(aliceOpsAfter.length).toBe(1);
282
+ expect(aliceOpsAfter[0].value).toEqual([3, 0, 0]);
283
+
284
+ // Check bob's batch metadata
285
+ const bobBatch = batchesAfter.find(b => b.sessionId === 'bob');
286
+ expect(bobBatch).toBeDefined();
287
+ expect(bobBatch!.sessionId).toBe('bob');
288
+ expect(bobBatch!.lamportTime).toBe(7); // Last bob lamportTime
289
+ expect((bobBatch!.vectorClock as any).bob).toBeDefined();
290
+ // Should have only the last rotation (LWW coalescing)
291
+ const bobOpsAfter = bobBatch!.operations as any[];
292
+ expect(bobOpsAfter.length).toBe(1);
293
+ expect(bobOpsAfter[0].value).toEqual([0, 3, 0]);
294
+ });
295
+
296
+ it('should coalesce text operations within same session but not across sessions', async () => {
297
+ // Create text node
298
+ const initMsg: CRDTMessage = {
299
+ id: 'msg-init',
300
+ sessionId: 'system',
301
+ clock: { system: 1 },
302
+ lamportTime: 1,
303
+ timestamp: Date.now() / 1000,
304
+ ops: [
305
+ {
306
+ key: 'default-scene',
307
+ otype: 'node.insert',
308
+ path: 'children',
309
+ value: {
310
+ key: 'text-doc',
311
+ tag: 'Text',
312
+ name: 'Text Doc',
313
+ },
314
+ } as Operation,
315
+ {
316
+ key: 'text-doc',
317
+ otype: 'text.init',
318
+ path: 'content',
319
+ value: '',
320
+ } as Operation,
321
+ ],
322
+ };
323
+ await service.processMessage(documentId, initMsg);
324
+
325
+ const baseTime = Date.now() / 1000;
326
+
327
+ // Alice types "abc" at position 0
328
+ for (let i = 0; i < 3; i++) {
329
+ const msg: CRDTMessage = {
330
+ id: `msg-alice-${i}`,
331
+ sessionId: 'alice',
332
+ clock: { alice: 1 + i, system: 1 },
333
+ lamportTime: 2 + i,
334
+ timestamp: baseTime + i * 0.1,
335
+ ops: [
336
+ {
337
+ key: 'text-doc',
338
+ otype: 'text.insert',
339
+ path: 'content',
340
+ position: i,
341
+ value: 'abc'[i],
342
+ } as Operation,
343
+ ],
344
+ };
345
+ await service.processMessage(documentId, msg);
346
+ }
347
+
348
+ // Bob types "xyz" at position 3
349
+ for (let i = 0; i < 3; i++) {
350
+ const msg: CRDTMessage = {
351
+ id: `msg-bob-${i}`,
352
+ sessionId: 'bob',
353
+ clock: { bob: 1 + i, alice: 3, system: 1 },
354
+ lamportTime: 5 + i,
355
+ timestamp: baseTime + 1.0 + i * 0.1,
356
+ ops: [
357
+ {
358
+ key: 'text-doc',
359
+ otype: 'text.insert',
360
+ path: 'content',
361
+ position: 3 + i,
362
+ value: 'xyz'[i],
363
+ } as Operation,
364
+ ],
365
+ };
366
+ await service.processMessage(documentId, msg);
367
+ }
368
+
369
+ // Alice types "123" at position 3 (interleaved with bob)
370
+ for (let i = 0; i < 3; i++) {
371
+ const msg: CRDTMessage = {
372
+ id: `msg-alice-second-${i}`,
373
+ sessionId: 'alice',
374
+ clock: { alice: 4 + i, bob: 3, system: 1 },
375
+ lamportTime: 8 + i,
376
+ timestamp: baseTime + 2.0 + i * 0.1,
377
+ ops: [
378
+ {
379
+ key: 'text-doc',
380
+ otype: 'text.insert',
381
+ path: 'content',
382
+ position: 3 + i,
383
+ value: '123'[i],
384
+ } as Operation,
385
+ ],
386
+ };
387
+ await service.processMessage(documentId, msg);
388
+ }
389
+
390
+ // Before compaction: 10 batches (1 init + 3 alice + 3 bob + 3 alice)
391
+ const batchesBefore = await prisma.journalBatch.findMany({
392
+ where: { documentId },
393
+ });
394
+ expect(batchesBefore.length).toBe(10);
395
+
396
+ // Compact
397
+ await service.compact(documentId);
398
+
399
+ // After compaction: 4 batches (1 system + 2 alice runs + 1 bob)
400
+ // The new implementation preserves interleaved order, so Alice's two
401
+ // runs (before and after Bob) are kept separate to maintain causality
402
+ const batchesAfter = await prisma.journalBatch.findMany({
403
+ where: { documentId },
404
+ orderBy: { lamportTime: 'asc' },
405
+ });
406
+ expect(batchesAfter.length).toBe(4);
407
+
408
+ // Alice's first run: "abc" at position 0
409
+ const aliceBatch1 = batchesAfter[1]; // After system init
410
+ expect(aliceBatch1.sessionId).toBe('alice');
411
+ const aliceOps1 = aliceBatch1.operations as any[];
412
+ expect(aliceOps1.length).toBe(1);
413
+ expect(aliceOps1[0].value).toBe('abc');
414
+ expect(aliceOps1[0].position).toBe(0);
415
+
416
+ // Bob's operations: "xyz" at position 3
417
+ const bobBatch = batchesAfter[2];
418
+ expect(bobBatch.sessionId).toBe('bob');
419
+ const bobOps = bobBatch.operations as any[];
420
+ expect(bobOps.length).toBe(1);
421
+ expect(bobOps[0].value).toBe('xyz');
422
+ expect(bobOps[0].position).toBe(3);
423
+
424
+ // Alice's second run: "123" at position 3
425
+ const aliceBatch2 = batchesAfter[3];
426
+ expect(aliceBatch2.sessionId).toBe('alice');
427
+ const aliceOps2 = aliceBatch2.operations as any[];
428
+ expect(aliceOps2.length).toBe(1);
429
+ expect(aliceOps2[0].value).toBe('123');
430
+ expect(aliceOps2[0].position).toBe(3);
431
+ });
432
+
433
+ it('should handle multi-session set operations with correct LWW metadata', async () => {
434
+ // Create a node
435
+ const initMsg: CRDTMessage = {
436
+ id: 'msg-init',
437
+ sessionId: 'system',
438
+ clock: { system: 1 },
439
+ lamportTime: 1,
440
+ timestamp: Date.now() / 1000,
441
+ ops: [
442
+ {
443
+ key: 'default-scene',
444
+ otype: 'node.insert',
445
+ path: 'children',
446
+ value: {
447
+ key: 'counter',
448
+ tag: 'Mesh',
449
+ name: 'Counter',
450
+ },
451
+ } as Operation,
452
+ ],
453
+ };
454
+ await service.processMessage(documentId, initMsg);
455
+
456
+ const baseTime = Date.now() / 1000;
457
+
458
+ // Alice sets count rapidly (within coalescing threshold)
459
+ const aliceValues = [10, 20, 30];
460
+ for (let i = 0; i < aliceValues.length; i++) {
461
+ const msg: CRDTMessage = {
462
+ id: `msg-alice-${i}`,
463
+ sessionId: 'alice',
464
+ clock: { alice: 1 + i, system: 1 },
465
+ lamportTime: 2 + i,
466
+ timestamp: baseTime + i * 0.1, // 100ms apart (within 5s threshold)
467
+ ops: [
468
+ {
469
+ key: 'counter',
470
+ otype: 'number.set',
471
+ path: 'count',
472
+ value: aliceValues[i],
473
+ } as Operation,
474
+ ],
475
+ };
476
+ await service.processMessage(documentId, msg);
477
+ }
478
+
479
+ // Bob sets count rapidly (within coalescing threshold)
480
+ const bobValues = [100, 200, 300];
481
+ for (let i = 0; i < bobValues.length; i++) {
482
+ const msg: CRDTMessage = {
483
+ id: `msg-bob-${i}`,
484
+ sessionId: 'bob',
485
+ clock: { bob: 1 + i, alice: 3, system: 1 },
486
+ lamportTime: 5 + i,
487
+ timestamp: baseTime + 1.0 + i * 0.1, // 100ms apart (within 5s threshold)
488
+ ops: [
489
+ {
490
+ key: 'counter',
491
+ otype: 'number.set',
492
+ path: 'count',
493
+ value: bobValues[i],
494
+ } as Operation,
495
+ ],
496
+ };
497
+ await service.processMessage(documentId, msg);
498
+ }
499
+
500
+ // Compact
501
+ await service.compact(documentId);
502
+
503
+ // After compaction: verify each session keeps only its latest set
504
+ const batchesAfter = await prisma.journalBatch.findMany({
505
+ where: { documentId },
506
+ orderBy: { lamportTime: 'asc' },
507
+ });
508
+
509
+ // Should have 3 batches: system, alice, bob
510
+ expect(batchesAfter.length).toBe(3);
511
+
512
+ // Alice's batch should have only the last set (30)
513
+ const aliceBatch = batchesAfter.find(b => b.sessionId === 'alice');
514
+ expect(aliceBatch).toBeDefined();
515
+ const aliceOps = aliceBatch!.operations as any[];
516
+ expect(aliceOps.length).toBe(1);
517
+ expect(aliceOps[0].otype).toBe('number.set');
518
+ expect(aliceOps[0].value).toBe(30);
519
+
520
+ // Bob's batch should have only the last set (300)
521
+ const bobBatch = batchesAfter.find(b => b.sessionId === 'bob');
522
+ expect(bobBatch).toBeDefined();
523
+ const bobOps = bobBatch!.operations as any[];
524
+ expect(bobOps.length).toBe(1);
525
+ expect(bobOps[0].otype).toBe('number.set');
526
+ expect(bobOps[0].value).toBe(300);
527
+
528
+ // CRITICAL: LWW resolution should use timestamps/lamportTime from metadata
529
+ // Bob's lamportTime (7) > Alice's lamportTime (4), so bob should win
530
+ expect(bobBatch!.lamportTime).toBeDefined();
531
+ expect(aliceBatch!.lamportTime).toBeDefined();
532
+ expect(bobBatch!.lamportTime!).toBeGreaterThan(aliceBatch!.lamportTime!);
533
+ });
534
+
535
+ it('should handle complex multi-session scenario with mixed operation types', async () => {
536
+ // Create scene with multiple nodes
537
+ const initMsg: CRDTMessage = {
538
+ id: 'msg-init',
539
+ sessionId: 'system',
540
+ clock: { system: 1 },
541
+ lamportTime: 1,
542
+ timestamp: Date.now() / 1000,
543
+ ops: [
544
+ {
545
+ key: 'default-scene',
546
+ otype: 'node.insert',
547
+ path: 'children',
548
+ value: {
549
+ key: 'cube',
550
+ tag: 'Mesh',
551
+ name: 'Cube',
552
+ },
553
+ } as Operation,
554
+ {
555
+ key: 'default-scene',
556
+ otype: 'node.insert',
557
+ path: 'children',
558
+ value: {
559
+ key: 'text-doc',
560
+ tag: 'Text',
561
+ name: 'Text Doc',
562
+ },
563
+ } as Operation,
564
+ {
565
+ key: 'text-doc',
566
+ otype: 'text.init',
567
+ path: 'content',
568
+ value: '',
569
+ } as Operation,
570
+ ],
571
+ };
572
+ await service.processMessage(documentId, initMsg);
573
+
574
+ const baseTime = Date.now() / 1000;
575
+
576
+ // Alice: modifies cube position (set ops) + types text
577
+ const aliceMessages = [
578
+ // Position updates
579
+ {
580
+ ops: [{ key: 'cube', otype: 'vector3.set', path: 'position', value: [1, 0, 0] }],
581
+ time: baseTime + 0.1,
582
+ },
583
+ {
584
+ ops: [{ key: 'cube', otype: 'vector3.set', path: 'position', value: [2, 0, 0] }],
585
+ time: baseTime + 0.2,
586
+ },
587
+ // Text inserts
588
+ {
589
+ ops: [{ key: 'text-doc', otype: 'text.insert', path: 'content', position: 0, value: 'a' }],
590
+ time: baseTime + 0.3,
591
+ },
592
+ {
593
+ ops: [{ key: 'text-doc', otype: 'text.insert', path: 'content', position: 1, value: 'b' }],
594
+ time: baseTime + 0.4,
595
+ },
596
+ ];
597
+
598
+ for (let i = 0; i < aliceMessages.length; i++) {
599
+ const msg: CRDTMessage = {
600
+ id: `msg-alice-${i}`,
601
+ sessionId: 'alice',
602
+ clock: { alice: 2 + i, system: 1 },
603
+ lamportTime: 2 + i,
604
+ timestamp: aliceMessages[i].time,
605
+ ops: aliceMessages[i].ops as Operation[],
606
+ };
607
+ await service.processMessage(documentId, msg);
608
+ }
609
+
610
+ // Bob: modifies cube rotation (set ops) + types text
611
+ const bobMessages = [
612
+ // Rotation updates
613
+ {
614
+ ops: [{ key: 'cube', otype: 'vector3.set', path: 'rotation', value: [0, 1, 0] }],
615
+ time: baseTime + 1.0,
616
+ },
617
+ {
618
+ ops: [{ key: 'cube', otype: 'vector3.set', path: 'rotation', value: [0, 2, 0] }],
619
+ time: baseTime + 1.1,
620
+ },
621
+ // Text inserts
622
+ {
623
+ ops: [{ key: 'text-doc', otype: 'text.insert', path: 'content', position: 2, value: 'x' }],
624
+ time: baseTime + 1.2,
625
+ },
626
+ {
627
+ ops: [{ key: 'text-doc', otype: 'text.insert', path: 'content', position: 3, value: 'y' }],
628
+ time: baseTime + 1.3,
629
+ },
630
+ ];
631
+
632
+ for (let i = 0; i < bobMessages.length; i++) {
633
+ const msg: CRDTMessage = {
634
+ id: `msg-bob-${i}`,
635
+ sessionId: 'bob',
636
+ clock: { bob: 1 + i, alice: 5, system: 1 },
637
+ lamportTime: 6 + i,
638
+ timestamp: bobMessages[i].time,
639
+ ops: bobMessages[i].ops as Operation[],
640
+ };
641
+ await service.processMessage(documentId, msg);
642
+ }
643
+
644
+ // Before compaction: 9 batches (1 init + 4 alice + 4 bob)
645
+ const batchesBefore = await prisma.journalBatch.findMany({
646
+ where: { documentId },
647
+ });
648
+ expect(batchesBefore.length).toBe(9);
649
+
650
+ // Compact
651
+ await service.compact(documentId);
652
+
653
+ // After compaction: 3 batches (1 system + 1 alice + 1 bob)
654
+ const batchesAfter = await prisma.journalBatch.findMany({
655
+ where: { documentId },
656
+ orderBy: { lamportTime: 'asc' },
657
+ });
658
+ expect(batchesAfter.length).toBe(3);
659
+
660
+ // Verify alice's batch
661
+ const aliceBatch = batchesAfter.find(b => b.sessionId === 'alice');
662
+ expect(aliceBatch).toBeDefined();
663
+ const aliceOps = aliceBatch!.operations as any[];
664
+ // Should have 2 ops: 1 position.set (last one) + 1 text.insert (coalesced "ab")
665
+ expect(aliceOps.length).toBe(2);
666
+
667
+ const alicePosOp = aliceOps.find((op: any) => op.path === 'position');
668
+ expect(alicePosOp).toBeDefined();
669
+ expect(alicePosOp.value).toEqual([2, 0, 0]);
670
+
671
+ const aliceTextOp = aliceOps.find((op: any) => op.otype === 'text.insert');
672
+ expect(aliceTextOp).toBeDefined();
673
+ expect(aliceTextOp.value).toBe('ab');
674
+ expect(aliceTextOp.position).toBe(0);
675
+
676
+ // Verify bob's batch
677
+ const bobBatch = batchesAfter.find(b => b.sessionId === 'bob');
678
+ expect(bobBatch).toBeDefined();
679
+ const bobOps = bobBatch!.operations as any[];
680
+ // Should have 2 ops: 1 rotation.set (last one) + 1 text.insert (coalesced "xy")
681
+ expect(bobOps.length).toBe(2);
682
+
683
+ const bobRotOp = bobOps.find((op: any) => op.path === 'rotation');
684
+ expect(bobRotOp).toBeDefined();
685
+ expect(bobRotOp.value).toEqual([0, 2, 0]);
686
+
687
+ const bobTextOp = bobOps.find((op: any) => op.otype === 'text.insert');
688
+ expect(bobTextOp).toBeDefined();
689
+ expect(bobTextOp.value).toBe('xy');
690
+ expect(bobTextOp.position).toBe(2);
691
+
692
+ // CRITICAL: Verify sessions are completely separate
693
+ expect(aliceBatch!.sessionId).not.toBe(bobBatch!.sessionId);
694
+ expect(aliceBatch!.lamportTime).not.toBe(bobBatch!.lamportTime);
695
+ expect(aliceBatch!.vectorClock).not.toEqual(bobBatch!.vectorClock);
696
+ });
697
+
698
+ it('should not merge operations even if they are identical across sessions', async () => {
699
+ // This tests that even if alice and bob perform the EXACT same operation,
700
+ // they should still be kept in separate messages with separate metadata.
701
+
702
+ // Create node
703
+ const initMsg: CRDTMessage = {
704
+ id: 'msg-init',
705
+ sessionId: 'system',
706
+ clock: { system: 1 },
707
+ lamportTime: 1,
708
+ timestamp: Date.now() / 1000,
709
+ ops: [
710
+ {
711
+ key: 'default-scene',
712
+ otype: 'node.insert',
713
+ path: 'children',
714
+ value: {
715
+ key: 'cube',
716
+ tag: 'Mesh',
717
+ name: 'Cube',
718
+ },
719
+ } as Operation,
720
+ ],
721
+ };
722
+ await service.processMessage(documentId, initMsg);
723
+
724
+ const baseTime = Date.now() / 1000;
725
+
726
+ // Alice sets position to [1, 1, 1]
727
+ const aliceMsg: CRDTMessage = {
728
+ id: 'msg-alice',
729
+ sessionId: 'alice',
730
+ clock: { alice: 1, system: 1 },
731
+ lamportTime: 2,
732
+ timestamp: baseTime + 0.1,
733
+ ops: [
734
+ {
735
+ key: 'cube',
736
+ otype: 'vector3.set',
737
+ path: 'position',
738
+ value: [1, 1, 1],
739
+ } as Operation,
740
+ ],
741
+ };
742
+ await service.processMessage(documentId, aliceMsg);
743
+
744
+ // Bob ALSO sets position to [1, 1, 1] (identical operation)
745
+ const bobMsg: CRDTMessage = {
746
+ id: 'msg-bob',
747
+ sessionId: 'bob',
748
+ clock: { bob: 1, alice: 1, system: 1 },
749
+ lamportTime: 3,
750
+ timestamp: baseTime + 0.2,
751
+ ops: [
752
+ {
753
+ key: 'cube',
754
+ otype: 'vector3.set',
755
+ path: 'position',
756
+ value: [1, 1, 1], // SAME VALUE
757
+ } as Operation,
758
+ ],
759
+ };
760
+ await service.processMessage(documentId, bobMsg);
761
+
762
+ // Compact
763
+ await service.compact(documentId);
764
+
765
+ // After compaction: should STILL have 3 separate batches
766
+ const batchesAfter = await prisma.journalBatch.findMany({
767
+ where: { documentId },
768
+ orderBy: { lamportTime: 'asc' },
769
+ });
770
+ expect(batchesAfter.length).toBe(3);
771
+
772
+ // Verify they are kept separate
773
+ const aliceBatch = batchesAfter.find(b => b.sessionId === 'alice');
774
+ const bobBatch = batchesAfter.find(b => b.sessionId === 'bob');
775
+
776
+ expect(aliceBatch).toBeDefined();
777
+ expect(bobBatch).toBeDefined();
778
+
779
+ // Both should have the same operation value
780
+ const aliceOps = aliceBatch!.operations as any[];
781
+ const bobOps = bobBatch!.operations as any[];
782
+ expect(aliceOps[0].value).toEqual([1, 1, 1]);
783
+ expect(bobOps[0].value).toEqual([1, 1, 1]);
784
+
785
+ // But they should have different metadata
786
+ expect(aliceBatch!.sessionId).toBe('alice');
787
+ expect(bobBatch!.sessionId).toBe('bob');
788
+ expect(aliceBatch!.lamportTime).toBe(2);
789
+ expect(bobBatch!.lamportTime).toBe(3);
790
+ });
791
+
792
+ it('should handle single session coalescing (baseline)', async () => {
793
+ // This is the baseline test: operations from a SINGLE session
794
+ // should be coalesced as before.
795
+
796
+ // Create text node
797
+ const initMsg: CRDTMessage = {
798
+ id: 'msg-init',
799
+ sessionId: 'system',
800
+ clock: { system: 1 },
801
+ lamportTime: 1,
802
+ timestamp: Date.now() / 1000,
803
+ ops: [
804
+ {
805
+ key: 'default-scene',
806
+ otype: 'node.insert',
807
+ path: 'children',
808
+ value: {
809
+ key: 'text-doc',
810
+ tag: 'Text',
811
+ name: 'Text Doc',
812
+ },
813
+ } as Operation,
814
+ {
815
+ key: 'text-doc',
816
+ otype: 'text.init',
817
+ path: 'content',
818
+ value: '',
819
+ } as Operation,
820
+ ],
821
+ };
822
+ await service.processMessage(documentId, initMsg);
823
+
824
+ // Alice types "hello world" (11 chars)
825
+ const text = 'hello world';
826
+ for (let i = 0; i < text.length; i++) {
827
+ const msg: CRDTMessage = {
828
+ id: `msg-alice-${i}`,
829
+ sessionId: 'alice',
830
+ clock: { alice: 1 + i, system: 1 },
831
+ lamportTime: 2 + i,
832
+ timestamp: Date.now() / 1000 + i * 0.05,
833
+ ops: [
834
+ {
835
+ key: 'text-doc',
836
+ otype: 'text.insert',
837
+ path: 'content',
838
+ position: i,
839
+ value: text[i],
840
+ } as Operation,
841
+ ],
842
+ };
843
+ await service.processMessage(documentId, msg);
844
+ }
845
+
846
+ // Before compaction: 12 batches (1 init + 11 alice)
847
+ const batchesBefore = await prisma.journalBatch.findMany({
848
+ where: { documentId },
849
+ });
850
+ expect(batchesBefore.length).toBe(12);
851
+
852
+ // Compact
853
+ await service.compact(documentId);
854
+
855
+ // After compaction: 2 batches (1 system + 1 alice)
856
+ const batchesAfter = await prisma.journalBatch.findMany({
857
+ where: { documentId },
858
+ orderBy: { lamportTime: 'asc' },
859
+ });
860
+ expect(batchesAfter.length).toBe(2);
861
+
862
+ // Alice's batch should have a single coalesced text.insert
863
+ const aliceBatch = batchesAfter.find(b => b.sessionId === 'alice');
864
+ expect(aliceBatch).toBeDefined();
865
+ const aliceOps = aliceBatch!.operations as any[];
866
+ expect(aliceOps.length).toBe(1);
867
+ expect(aliceOps[0].otype).toBe('text.insert');
868
+ expect(aliceOps[0].value).toBe('hello world');
869
+ expect(aliceOps[0].position).toBe(0);
870
+ });
871
+ });