@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.
- package/.env +1 -0
- package/S3_COMPRESSION_GUIDE.md +233 -0
- package/dist/archive/ArchivalService.d.ts +117 -0
- package/dist/archive/ArchivalService.d.ts.map +1 -0
- package/dist/archive/ArchivalService.js +181 -0
- package/dist/archive/ArchivalService.js.map +1 -0
- package/dist/broker/InMemoryBroker.d.ts +2 -0
- package/dist/broker/InMemoryBroker.d.ts.map +1 -1
- package/dist/broker/InMemoryBroker.js +4 -0
- package/dist/broker/InMemoryBroker.js.map +1 -1
- package/dist/compression/CompressionUtils.d.ts +57 -0
- package/dist/compression/CompressionUtils.d.ts.map +1 -0
- package/dist/compression/CompressionUtils.js +90 -0
- package/dist/compression/CompressionUtils.js.map +1 -0
- package/dist/compression/index.d.ts +7 -0
- package/dist/compression/index.d.ts.map +1 -0
- package/dist/compression/index.js +7 -0
- package/dist/compression/index.js.map +1 -0
- package/dist/journal/CoalescingService.d.ts +63 -0
- package/dist/journal/CoalescingService.d.ts.map +1 -0
- package/dist/journal/CoalescingService.js +507 -0
- package/dist/journal/CoalescingService.js.map +1 -0
- package/dist/journal/JournalRLE.d.ts +81 -0
- package/dist/journal/JournalRLE.d.ts.map +1 -0
- package/dist/journal/JournalRLE.js +199 -0
- package/dist/journal/JournalRLE.js.map +1 -0
- package/dist/journal/JournalService.d.ts +7 -3
- package/dist/journal/JournalService.d.ts.map +1 -1
- package/dist/journal/JournalService.js +152 -12
- package/dist/journal/JournalService.js.map +1 -1
- package/dist/journal/RLECompression.d.ts +73 -0
- package/dist/journal/RLECompression.d.ts.map +1 -0
- package/dist/journal/RLECompression.js +152 -0
- package/dist/journal/RLECompression.js.map +1 -0
- package/dist/journal/rle-demo.d.ts +8 -0
- package/dist/journal/rle-demo.d.ts.map +1 -0
- package/dist/journal/rle-demo.js +159 -0
- package/dist/journal/rle-demo.js.map +1 -0
- package/dist/persistence/S3ColdStorage.d.ts +62 -0
- package/dist/persistence/S3ColdStorage.d.ts.map +1 -0
- package/dist/persistence/S3ColdStorage.js +88 -0
- package/dist/persistence/S3ColdStorage.js.map +1 -0
- package/dist/persistence/S3ColdStorageIntegration.d.ts +78 -0
- package/dist/persistence/S3ColdStorageIntegration.d.ts.map +1 -0
- package/dist/persistence/S3ColdStorageIntegration.js +93 -0
- package/dist/persistence/S3ColdStorageIntegration.js.map +1 -0
- package/dist/serve.d.ts +2 -0
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +623 -15
- package/dist/serve.js.map +1 -1
- package/docs/RLE_COMPRESSION.md +397 -0
- package/examples/compression-example.ts +259 -0
- package/package.json +14 -14
- package/src/archive/ArchivalService.ts +250 -0
- package/src/broker/InMemoryBroker.ts +5 -0
- package/src/compression/CompressionUtils.ts +113 -0
- package/src/compression/index.ts +14 -0
- package/src/journal/COALESCING.md +267 -0
- package/src/journal/CoalescingService.ts +626 -0
- package/src/journal/JournalRLE.ts +265 -0
- package/src/journal/JournalService.ts +163 -11
- package/src/journal/RLECompression.ts +210 -0
- package/src/journal/rle-demo.ts +193 -0
- package/src/serve.ts +702 -15
- package/tests/benchmark/journal-optimization-benchmark.test.ts +482 -0
- package/tests/compression/compression.test.ts +343 -0
- package/tests/integration/repositories.test.ts +89 -0
- package/tests/journal/compaction-load-bug.test.ts +409 -0
- package/tests/journal/compaction.test.ts +42 -2
- package/tests/journal/journal-rle.test.ts +511 -0
- package/tests/journal/lww-ordering-bug.test.ts +248 -0
- package/tests/journal/multi-session-coalescing.test.ts +871 -0
- package/tests/journal/rle-compression.test.ts +526 -0
- package/tests/journal/text-coalescing.test.ts +210 -0
- package/tests/unit/s3-compression.test.ts +257 -0
- 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
|
+
});
|