@vuer-ai/vuer-rtc-server 0.2.3 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +1 -1
- package/README.md +56 -0
- package/dist/archive/ArchivalService.js +1 -1
- package/dist/archive/ArchivalService.js.map +1 -1
- package/dist/broker/InMemoryBroker.d.ts +2 -2
- package/dist/broker/InMemoryBroker.d.ts.map +1 -1
- package/dist/broker/InMemoryBroker.js +4 -4
- package/dist/broker/InMemoryBroker.js.map +1 -1
- package/dist/broker/types.d.ts +3 -3
- package/dist/broker/types.d.ts.map +1 -1
- package/dist/journal/CoalescingService.d.ts.map +1 -1
- package/dist/journal/CoalescingService.js +18 -208
- package/dist/journal/CoalescingService.js.map +1 -1
- package/dist/journal/GraphJournalService.d.ts +127 -0
- package/dist/journal/GraphJournalService.d.ts.map +1 -0
- package/dist/journal/GraphJournalService.js +491 -0
- package/dist/journal/GraphJournalService.js.map +1 -0
- package/dist/journal/JournalRLE.d.ts +2 -2
- package/dist/journal/JournalRLE.js +14 -14
- package/dist/journal/JournalRLE.js.map +1 -1
- package/dist/journal/JournalRepository.js +7 -7
- package/dist/journal/JournalRepository.js.map +1 -1
- package/dist/journal/JournalService.d.ts.map +1 -1
- package/dist/journal/JournalService.js +6 -40
- package/dist/journal/JournalService.js.map +1 -1
- package/dist/journal/RLECompression.d.ts +9 -9
- package/dist/journal/RLECompression.d.ts.map +1 -1
- package/dist/journal/RLECompression.js +22 -22
- package/dist/journal/RLECompression.js.map +1 -1
- package/dist/journal/TextJournalService.d.ts +98 -0
- package/dist/journal/TextJournalService.d.ts.map +1 -0
- package/dist/journal/TextJournalService.js +401 -0
- package/dist/journal/TextJournalService.js.map +1 -0
- package/dist/journal/index.d.ts +3 -1
- package/dist/journal/index.d.ts.map +1 -1
- package/dist/journal/index.js +4 -1
- package/dist/journal/index.js.map +1 -1
- package/dist/journal/rle-demo.js +11 -11
- package/dist/journal/rle-demo.js.map +1 -1
- package/dist/serve.d.ts +29 -11
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +558 -93
- package/dist/serve.js.map +1 -1
- package/dist/transport/RTCServer.d.ts +2 -2
- package/dist/transport/RTCServer.d.ts.map +1 -1
- package/dist/transport/RTCServer.js +22 -22
- package/dist/transport/RTCServer.js.map +1 -1
- package/docs/API.md +642 -0
- package/examples/compression-example.ts +3 -3
- package/package.json +2 -2
- package/prisma/schema.prisma +124 -6
- package/src/archive/ArchivalService.ts +1 -1
- package/src/broker/InMemoryBroker.ts +4 -4
- package/src/broker/types.ts +3 -3
- package/src/journal/CoalescingService.ts +18 -235
- package/src/journal/{JournalService.ts → GraphJournalService.ts} +34 -74
- package/src/journal/JournalRLE.ts +15 -15
- package/src/journal/JournalRepository.ts +7 -7
- package/src/journal/RLECompression.ts +24 -24
- package/src/journal/TextJournalService.ts +483 -0
- package/src/journal/index.ts +10 -2
- package/src/journal/rle-demo.ts +11 -11
- package/src/serve.ts +598 -94
- package/src/transport/RTCServer.ts +23 -23
- package/tests/benchmark/journal-optimization-benchmark.test.ts +14 -14
- package/tests/compression/compression.test.ts +8 -8
- package/tests/demo.ts +88 -88
- package/tests/e2e/convergence.test.ts +9 -9
- package/tests/e2e/helpers/assertions.ts +22 -0
- package/tests/e2e/helpers/createTestServer.ts +4 -4
- package/tests/e2e/latency.test.ts +47 -41
- package/tests/e2e/packet-loss.test.ts +6 -6
- package/tests/e2e/relay.test.ts +9 -9
- package/tests/e2e/sync-perf.test.ts +5 -5
- package/tests/e2e/sync-reconciliation.test.ts +6 -6
- package/tests/e2e/text-sync.test.ts +14 -14
- package/tests/e2e/tombstone-convergence.test.ts +22 -22
- package/tests/fixtures/array-ops.jsonl +6 -6
- package/tests/fixtures/boolean-ops.jsonl +6 -6
- package/tests/fixtures/color-ops.jsonl +4 -4
- package/tests/fixtures/edit-buffer.jsonl +3 -3
- package/tests/fixtures/messages.jsonl +4 -4
- package/tests/fixtures/node-ops.jsonl +6 -6
- package/tests/fixtures/number-ops.jsonl +7 -7
- package/tests/fixtures/object-ops.jsonl +4 -4
- package/tests/fixtures/operations.jsonl +7 -7
- package/tests/fixtures/string-ops.jsonl +4 -4
- package/tests/fixtures/undo-redo.jsonl +3 -3
- package/tests/fixtures/vector-ops.jsonl +9 -9
- package/tests/integration/repositories.test.ts +8 -9
- package/tests/journal/compaction-load-bug.test.ts +31 -31
- package/tests/journal/compaction.test.ts +26 -26
- package/tests/journal/journal-rle.test.ts +38 -38
- package/tests/journal/journal-service.test.ts +13 -13
- package/tests/journal/lww-ordering-bug.test.ts +39 -39
- package/tests/journal/rle-compression.test.ts +71 -71
- package/tests/journal/text-coalescing.test.ts +34 -34
- package/tests/test-data/datatypes.ts +85 -85
- package/tests/test-data/operations-example.ts +62 -62
- package/tests/test-data/scene-example.ts +11 -11
- package/tests/unit/operations.test.ts +7 -7
- package/tests/unit/s3-compression.test.ts +5 -3
- package/tests/unit/vectorClock.test.ts +2 -2
- package/tests/journal/multi-session-coalescing.test.ts +0 -871
|
@@ -21,17 +21,17 @@ import type { CRDTMessage, SceneGraph, Operation, Snapshot } from '@vuer-ai/vuer
|
|
|
21
21
|
/** Build a CRDTMessage with sensible defaults */
|
|
22
22
|
function makeMsg(
|
|
23
23
|
id: string,
|
|
24
|
-
|
|
24
|
+
client: string,
|
|
25
25
|
ops: Operation[],
|
|
26
|
-
|
|
26
|
+
lt: number,
|
|
27
27
|
clock?: Record<string, number>,
|
|
28
28
|
): CRDTMessage {
|
|
29
29
|
return {
|
|
30
30
|
id,
|
|
31
|
-
|
|
32
|
-
clock: clock ?? { [
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
client,
|
|
32
|
+
clock: clock ?? { [client]: lt },
|
|
33
|
+
lt,
|
|
34
|
+
ts: Date.now() / 1000,
|
|
35
35
|
ops,
|
|
36
36
|
};
|
|
37
37
|
}
|
|
@@ -40,7 +40,7 @@ function makeMsg(
|
|
|
40
40
|
function nodeInsertOp(parentKey: string, nodeKey: string, props: Record<string, unknown> = {}): Operation {
|
|
41
41
|
return {
|
|
42
42
|
key: parentKey,
|
|
43
|
-
|
|
43
|
+
ot: 'node.insert',
|
|
44
44
|
path: 'children',
|
|
45
45
|
value: {
|
|
46
46
|
key: nodeKey,
|
|
@@ -55,7 +55,7 @@ function nodeInsertOp(parentKey: string, nodeKey: string, props: Record<string,
|
|
|
55
55
|
function vec3SetOp(nodeKey: string, path: string, value: [number, number, number]): Operation {
|
|
56
56
|
return {
|
|
57
57
|
key: nodeKey,
|
|
58
|
-
|
|
58
|
+
ot: 'vector3.set',
|
|
59
59
|
path,
|
|
60
60
|
value,
|
|
61
61
|
} as Operation;
|
|
@@ -65,7 +65,7 @@ function vec3SetOp(nodeKey: string, path: string, value: [number, number, number
|
|
|
65
65
|
function numSetOp(nodeKey: string, path: string, value: number): Operation {
|
|
66
66
|
return {
|
|
67
67
|
key: nodeKey,
|
|
68
|
-
|
|
68
|
+
ot: 'number.set',
|
|
69
69
|
path,
|
|
70
70
|
value,
|
|
71
71
|
} as Operation;
|
|
@@ -75,7 +75,7 @@ function numSetOp(nodeKey: string, path: string, value: number): Operation {
|
|
|
75
75
|
function numAddOp(nodeKey: string, path: string, value: number): Operation {
|
|
76
76
|
return {
|
|
77
77
|
key: nodeKey,
|
|
78
|
-
|
|
78
|
+
ot: 'number.add',
|
|
79
79
|
path,
|
|
80
80
|
value,
|
|
81
81
|
} as Operation;
|
|
@@ -85,7 +85,7 @@ function numAddOp(nodeKey: string, path: string, value: number): Operation {
|
|
|
85
85
|
function nodeRemoveOp(parentKey: string, nodeKey: string): Operation {
|
|
86
86
|
return {
|
|
87
87
|
key: parentKey,
|
|
88
|
-
|
|
88
|
+
ot: 'node.remove',
|
|
89
89
|
path: 'children',
|
|
90
90
|
value: nodeKey,
|
|
91
91
|
} as Operation;
|
|
@@ -131,7 +131,7 @@ class InMemoryJournalService {
|
|
|
131
131
|
snapshot: {
|
|
132
132
|
graph: createEmptyGraph(),
|
|
133
133
|
vectorClock: {},
|
|
134
|
-
|
|
134
|
+
lt: 0,
|
|
135
135
|
journalIndex: 0,
|
|
136
136
|
},
|
|
137
137
|
journal: [],
|
|
@@ -161,11 +161,11 @@ class InMemoryJournalService {
|
|
|
161
161
|
|
|
162
162
|
// Meta ops
|
|
163
163
|
for (const op of msg.ops) {
|
|
164
|
-
if (op.
|
|
164
|
+
if (op.ot === 'meta.undo') {
|
|
165
165
|
const targetId = (op as any).targetMsgId;
|
|
166
166
|
const target = state.journal.find((e) => e.msg.id === targetId);
|
|
167
|
-
if (target) target.deletedAt = msg.
|
|
168
|
-
} else if (op.
|
|
167
|
+
if (target) target.deletedAt = msg.ts;
|
|
168
|
+
} else if (op.ot === 'meta.redo') {
|
|
169
169
|
const targetId = (op as any).targetMsgId;
|
|
170
170
|
const target = state.journal.find((e) => e.msg.id === targetId);
|
|
171
171
|
if (target) delete target.deletedAt;
|
|
@@ -181,7 +181,7 @@ class InMemoryJournalService {
|
|
|
181
181
|
let graph = state.snapshot.graph;
|
|
182
182
|
for (const entry of state.journal) {
|
|
183
183
|
if (entry.deletedAt) continue;
|
|
184
|
-
const realOps = entry.msg.ops.filter((op) => !op.
|
|
184
|
+
const realOps = entry.msg.ops.filter((op) => !op.ot.startsWith('meta.'));
|
|
185
185
|
if (realOps.length > 0) {
|
|
186
186
|
graph = applyMessage(graph, { ...entry.msg, ops: realOps });
|
|
187
187
|
}
|
|
@@ -208,20 +208,20 @@ class InMemoryJournalService {
|
|
|
208
208
|
|
|
209
209
|
let maxLamport = state.snapshot.journalIndex;
|
|
210
210
|
for (const entry of state.journal) {
|
|
211
|
-
maxLamport = Math.max(maxLamport, entry.msg.
|
|
211
|
+
maxLamport = Math.max(maxLamport, entry.msg.lt);
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
let mergedClock = { ...state.snapshot.vectorClock };
|
|
215
215
|
for (const entry of state.journal) {
|
|
216
|
-
for (const [
|
|
217
|
-
mergedClock[
|
|
216
|
+
for (const [client, time] of Object.entries(entry.msg.clock)) {
|
|
217
|
+
mergedClock[client] = Math.max(mergedClock[client] || 0, time);
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
state.snapshot = {
|
|
222
222
|
graph: newGraph,
|
|
223
223
|
vectorClock: mergedClock,
|
|
224
|
-
|
|
224
|
+
lt: maxLamport,
|
|
225
225
|
journalIndex: maxLamport,
|
|
226
226
|
};
|
|
227
227
|
|
|
@@ -379,7 +379,7 @@ describe('JournalService.compact()', () => {
|
|
|
379
379
|
|
|
380
380
|
const stateAfter = svc.loadDocument(DOC_ID)!;
|
|
381
381
|
expect(stateAfter.journal).toHaveLength(0);
|
|
382
|
-
expect(stateAfter.snapshot.
|
|
382
|
+
expect(stateAfter.snapshot.lt).toBe(snapshotBefore.lt);
|
|
383
383
|
});
|
|
384
384
|
|
|
385
385
|
it('should compact a single entry', () => {
|
|
@@ -390,7 +390,7 @@ describe('JournalService.compact()', () => {
|
|
|
390
390
|
const state = svc.loadDocument(DOC_ID)!;
|
|
391
391
|
expect(state.journal).toHaveLength(0);
|
|
392
392
|
expect(state.snapshot.graph.nodes['scene']).toBeDefined();
|
|
393
|
-
expect(state.snapshot.
|
|
393
|
+
expect(state.snapshot.lt).toBe(1);
|
|
394
394
|
});
|
|
395
395
|
|
|
396
396
|
it('should compact with mixed operation types', () => {
|
|
@@ -429,7 +429,7 @@ describe('JournalService.compact()', () => {
|
|
|
429
429
|
const state = svc.loadDocument(DOC_ID)!;
|
|
430
430
|
expect(state.snapshot.vectorClock['alice']).toBe(1);
|
|
431
431
|
expect(state.snapshot.vectorClock['bob']).toBe(1);
|
|
432
|
-
expect(state.snapshot.
|
|
432
|
+
expect(state.snapshot.lt).toBe(2);
|
|
433
433
|
});
|
|
434
434
|
|
|
435
435
|
it('should handle undo entries during compaction (skip deleted)', () => {
|
|
@@ -441,7 +441,7 @@ describe('JournalService.compact()', () => {
|
|
|
441
441
|
// Undo the position change
|
|
442
442
|
svc.processMessage(
|
|
443
443
|
DOC_ID,
|
|
444
|
-
makeMsg('m4-undo', 'a', [{ key: '_meta',
|
|
444
|
+
makeMsg('m4-undo', 'a', [{ key: '_meta', ot: 'meta.undo', path: '_meta', targetMsgId: 'm3' } as any], 4),
|
|
445
445
|
);
|
|
446
446
|
|
|
447
447
|
const graphBefore = svc.computeGraph(svc.loadDocument(DOC_ID)!);
|
|
@@ -588,12 +588,12 @@ describe('Edge Cases', () => {
|
|
|
588
588
|
// Undo m3
|
|
589
589
|
svc.processMessage(
|
|
590
590
|
DOC_ID,
|
|
591
|
-
makeMsg('m4', 'a', [{ key: '_meta',
|
|
591
|
+
makeMsg('m4', 'a', [{ key: '_meta', ot: 'meta.undo', path: '_meta', targetMsgId: 'm3' } as any], 4),
|
|
592
592
|
);
|
|
593
593
|
// Redo m3
|
|
594
594
|
svc.processMessage(
|
|
595
595
|
DOC_ID,
|
|
596
|
-
makeMsg('m5', 'a', [{ key: '_meta',
|
|
596
|
+
makeMsg('m5', 'a', [{ key: '_meta', ot: 'meta.redo', path: '_meta', targetMsgId: 'm3' } as any], 5),
|
|
597
597
|
);
|
|
598
598
|
|
|
599
599
|
const graphBefore = svc.computeGraph(svc.loadDocument(DOC_ID)!);
|
|
@@ -23,22 +23,22 @@ import {
|
|
|
23
23
|
*/
|
|
24
24
|
function createTestMessage(
|
|
25
25
|
id: string,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
client: string,
|
|
27
|
+
lt: number,
|
|
28
|
+
ts: number,
|
|
29
29
|
ops: any[] = []
|
|
30
30
|
): CRDTMessage {
|
|
31
31
|
return {
|
|
32
32
|
id,
|
|
33
|
-
|
|
34
|
-
clock: { [
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
client,
|
|
34
|
+
clock: { [client]: lt },
|
|
35
|
+
lt,
|
|
36
|
+
ts,
|
|
37
37
|
ops: ops.length > 0 ? ops : [{
|
|
38
38
|
key: 'cube-1',
|
|
39
|
-
|
|
39
|
+
ot: 'vector3.set',
|
|
40
40
|
path: 'position',
|
|
41
|
-
value: [
|
|
41
|
+
value: [lt, 0, 0],
|
|
42
42
|
}],
|
|
43
43
|
};
|
|
44
44
|
}
|
|
@@ -118,8 +118,8 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
118
118
|
|
|
119
119
|
it('should handle many agents with alternating pattern', () => {
|
|
120
120
|
const messages = Array.from({ length: 10 }, (_, i) => {
|
|
121
|
-
const
|
|
122
|
-
return createTestMessage(`msg-${i}`,
|
|
121
|
+
const client = i % 2 === 0 ? 'session-1' : 'session-2';
|
|
122
|
+
return createTestMessage(`msg-${i}`, client, i, 1000 + i);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
const encoded = encodeJournalRLE(messages);
|
|
@@ -136,18 +136,18 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
136
136
|
const messages = [
|
|
137
137
|
{
|
|
138
138
|
id: 'msg-1',
|
|
139
|
-
|
|
139
|
+
client: 'session-1',
|
|
140
140
|
clock: { 'session-1': 5, 'session-2': 3 },
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
lt: 8,
|
|
142
|
+
ts: 1000,
|
|
143
143
|
ops: [],
|
|
144
144
|
},
|
|
145
145
|
{
|
|
146
146
|
id: 'msg-2',
|
|
147
|
-
|
|
147
|
+
client: 'session-1',
|
|
148
148
|
clock: { 'session-1': 6, 'session-2': 3 },
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
lt: 9,
|
|
150
|
+
ts: 1001,
|
|
151
151
|
ops: [],
|
|
152
152
|
},
|
|
153
153
|
] as CRDTMessage[];
|
|
@@ -169,15 +169,15 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
169
169
|
const encoded = encodeJournalRLE(messages);
|
|
170
170
|
const decoded = decodeJournalRLE(encoded);
|
|
171
171
|
|
|
172
|
-
expect(decoded[0].
|
|
173
|
-
expect(decoded[1].
|
|
174
|
-
expect(decoded[2].
|
|
172
|
+
expect(decoded[0].lt).toBe(1);
|
|
173
|
+
expect(decoded[1].lt).toBe(2);
|
|
174
|
+
expect(decoded[2].lt).toBe(3);
|
|
175
175
|
});
|
|
176
176
|
|
|
177
177
|
it('should preserve operation semantics', () => {
|
|
178
178
|
const ops = [
|
|
179
|
-
{ key: 'cube-1',
|
|
180
|
-
{ key: 'cube-2',
|
|
179
|
+
{ key: 'cube-1', ot: 'vector3.set', path: 'position', value: [1, 2, 3] },
|
|
180
|
+
{ key: 'cube-2', ot: 'vector3.add', path: 'position', value: [0.5, 0.5, 0.5] },
|
|
181
181
|
];
|
|
182
182
|
|
|
183
183
|
const messages = [
|
|
@@ -245,8 +245,8 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
245
245
|
it('should have low compression for heterogeneous journal', () => {
|
|
246
246
|
// 100 messages alternating between 10 agents
|
|
247
247
|
const messages = Array.from({ length: 100 }, (_, i) => {
|
|
248
|
-
const
|
|
249
|
-
return createTestMessage(`msg-${i}`,
|
|
248
|
+
const client = `session-${i % 10}`;
|
|
249
|
+
return createTestMessage(`msg-${i}`, client, i, 1000 + i);
|
|
250
250
|
});
|
|
251
251
|
|
|
252
252
|
const encoded = encodeJournalRLE(messages);
|
|
@@ -286,7 +286,7 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
286
286
|
agentId: 'session-1',
|
|
287
287
|
count: 5,
|
|
288
288
|
messages: [
|
|
289
|
-
createTestMessage('msg-1', 'session-2', 1, 1000), // Wrong
|
|
289
|
+
createTestMessage('msg-1', 'session-2', 1, 1000), // Wrong client!
|
|
290
290
|
] as CRDTMessage[],
|
|
291
291
|
},
|
|
292
292
|
],
|
|
@@ -294,7 +294,7 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
294
294
|
};
|
|
295
295
|
|
|
296
296
|
expect(() => decodeJournalRLE(corrupted)).toThrow(
|
|
297
|
-
/
|
|
297
|
+
/client mismatch/
|
|
298
298
|
);
|
|
299
299
|
});
|
|
300
300
|
|
|
@@ -354,7 +354,7 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
354
354
|
const complexOps = [
|
|
355
355
|
{
|
|
356
356
|
key: 'node-1',
|
|
357
|
-
|
|
357
|
+
ot: 'node.insert',
|
|
358
358
|
path: 'children',
|
|
359
359
|
value: {
|
|
360
360
|
key: 'child-1',
|
|
@@ -365,7 +365,7 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
365
365
|
},
|
|
366
366
|
{
|
|
367
367
|
key: '_meta',
|
|
368
|
-
|
|
368
|
+
ot: 'meta.undo',
|
|
369
369
|
path: '_meta',
|
|
370
370
|
targetMsgId: 'msg-1',
|
|
371
371
|
},
|
|
@@ -387,14 +387,14 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
387
387
|
it('should handle multiple operations per message', () => {
|
|
388
388
|
const multiOpMsg: CRDTMessage = {
|
|
389
389
|
id: 'msg-1',
|
|
390
|
-
|
|
390
|
+
client: 'session-1',
|
|
391
391
|
clock: { 'session-1': 1 },
|
|
392
|
-
|
|
393
|
-
|
|
392
|
+
lt: 1,
|
|
393
|
+
ts: 1000,
|
|
394
394
|
ops: [
|
|
395
|
-
{ key: 'node-1',
|
|
396
|
-
{ key: 'node-2',
|
|
397
|
-
{ key: 'node-3',
|
|
395
|
+
{ key: 'node-1', ot: 'vector3.set', path: 'position', value: [1, 2, 3] },
|
|
396
|
+
{ key: 'node-2', ot: 'vector3.set', path: 'position', value: [4, 5, 6] },
|
|
397
|
+
{ key: 'node-3', ot: 'vector3.set', path: 'position', value: [7, 8, 9] },
|
|
398
398
|
],
|
|
399
399
|
};
|
|
400
400
|
|
|
@@ -416,7 +416,7 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
416
416
|
const encoded = encodeJournalRLE(messages);
|
|
417
417
|
const decoded = decodeJournalRLE(encoded);
|
|
418
418
|
|
|
419
|
-
expect(decoded[0].
|
|
419
|
+
expect(decoded[0].ts).toBe(largeTimestamp);
|
|
420
420
|
});
|
|
421
421
|
});
|
|
422
422
|
|
|
@@ -433,7 +433,7 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
433
433
|
'session-a',
|
|
434
434
|
i,
|
|
435
435
|
1000 + i,
|
|
436
|
-
[{ key: 'cube-1',
|
|
436
|
+
[{ key: 'cube-1', ot: 'vector3.add', path: 'position', value: [0.1, 0, 0] }]
|
|
437
437
|
)
|
|
438
438
|
);
|
|
439
439
|
}
|
|
@@ -446,7 +446,7 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
446
446
|
'session-b',
|
|
447
447
|
i,
|
|
448
448
|
1010 + i,
|
|
449
|
-
[{ key: 'cube-2',
|
|
449
|
+
[{ key: 'cube-2', ot: 'vector3.add', path: 'position', value: [0, 0.1, 0] }]
|
|
450
450
|
)
|
|
451
451
|
);
|
|
452
452
|
}
|
|
@@ -459,7 +459,7 @@ describe('JournalRLE - Run-Length Encoding', () => {
|
|
|
459
459
|
'session-a',
|
|
460
460
|
i,
|
|
461
461
|
1020 + i,
|
|
462
|
-
[{ key: 'cube-1',
|
|
462
|
+
[{ key: 'cube-1', ot: 'vector3.add', path: 'position', value: [0.1, 0, 0] }]
|
|
463
463
|
)
|
|
464
464
|
);
|
|
465
465
|
}
|
|
@@ -47,10 +47,10 @@ describe('JournalService', () => {
|
|
|
47
47
|
for (const fixture of fixtures) {
|
|
48
48
|
const msg = fixture.msg;
|
|
49
49
|
expect(msg.id).toBeDefined();
|
|
50
|
-
expect(msg.
|
|
50
|
+
expect(msg.client).toBeDefined();
|
|
51
51
|
expect(msg.clock).toBeDefined();
|
|
52
|
-
expect(msg.
|
|
53
|
-
expect(msg.
|
|
52
|
+
expect(msg.lt).toBeGreaterThanOrEqual(0);
|
|
53
|
+
expect(msg.ts).toBeGreaterThan(0);
|
|
54
54
|
expect(Array.isArray(msg.ops)).toBe(true);
|
|
55
55
|
}
|
|
56
56
|
});
|
|
@@ -60,7 +60,7 @@ describe('JournalService', () => {
|
|
|
60
60
|
expect(undoFixture).toBeDefined();
|
|
61
61
|
|
|
62
62
|
const undoOp = undoFixture!.msg.ops[0];
|
|
63
|
-
expect(undoOp.
|
|
63
|
+
expect(undoOp.ot).toBe('meta.undo');
|
|
64
64
|
expect(undoOp.key).toBe('_meta');
|
|
65
65
|
expect((undoOp as any).targetMsgId).toBe('msg-1');
|
|
66
66
|
});
|
|
@@ -70,7 +70,7 @@ describe('JournalService', () => {
|
|
|
70
70
|
expect(redoFixture).toBeDefined();
|
|
71
71
|
|
|
72
72
|
const redoOp = redoFixture!.msg.ops[0];
|
|
73
|
-
expect(redoOp.
|
|
73
|
+
expect(redoOp.ot).toBe('meta.redo');
|
|
74
74
|
expect(redoOp.key).toBe('_meta');
|
|
75
75
|
expect((redoOp as any).targetMsgId).toBe('msg-1');
|
|
76
76
|
});
|
|
@@ -115,14 +115,14 @@ describe('JournalService', () => {
|
|
|
115
115
|
// First create a node (key=parent, path='children', value.key=new node)
|
|
116
116
|
const nodeMsg: CRDTMessage = {
|
|
117
117
|
id: 'node-msg',
|
|
118
|
-
|
|
118
|
+
client: 'session-1',
|
|
119
119
|
clock: { 'session-1': 0 },
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
lt: 0,
|
|
121
|
+
ts: Date.now() / 1000,
|
|
122
122
|
ops: [
|
|
123
123
|
{
|
|
124
124
|
key: '', // No parent for root node
|
|
125
|
-
|
|
125
|
+
ot: 'node.insert',
|
|
126
126
|
path: 'children',
|
|
127
127
|
value: {
|
|
128
128
|
key: 'cube-1',
|
|
@@ -153,14 +153,14 @@ describe('JournalService', () => {
|
|
|
153
153
|
// Create node (key=parent, path='children', value.key=new node)
|
|
154
154
|
const nodeMsg: CRDTMessage = {
|
|
155
155
|
id: 'node-msg',
|
|
156
|
-
|
|
156
|
+
client: 'session-1',
|
|
157
157
|
clock: { 'session-1': 0 },
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
lt: 0,
|
|
159
|
+
ts: Date.now() / 1000,
|
|
160
160
|
ops: [
|
|
161
161
|
{
|
|
162
162
|
key: '', // No parent for root node
|
|
163
|
-
|
|
163
|
+
ot: 'node.insert',
|
|
164
164
|
path: 'children',
|
|
165
165
|
value: {
|
|
166
166
|
key: 'cube-1',
|
|
@@ -19,14 +19,14 @@ function coalesceBuggy(messages: CRDTMessage[]): CRDTMessage[] {
|
|
|
19
19
|
// Current implementation: Group by session
|
|
20
20
|
const sessionGroups = new Map<string, CRDTMessage[]>();
|
|
21
21
|
for (const msg of messages) {
|
|
22
|
-
if (!sessionGroups.has(msg.
|
|
23
|
-
sessionGroups.set(msg.
|
|
22
|
+
if (!sessionGroups.has(msg.client)) {
|
|
23
|
+
sessionGroups.set(msg.client, []);
|
|
24
24
|
}
|
|
25
|
-
sessionGroups.get(msg.
|
|
25
|
+
sessionGroups.get(msg.client)!.push(msg);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const coalesced: CRDTMessage[] = [];
|
|
29
|
-
for (const [
|
|
29
|
+
for (const [client, sessionMessages] of sessionGroups) {
|
|
30
30
|
// Flatten all ops from this session
|
|
31
31
|
const allOps: Operation[] = [];
|
|
32
32
|
for (const msg of sessionMessages) {
|
|
@@ -56,12 +56,12 @@ function coalesceCorrect(messages: CRDTMessage[]): CRDTMessage[] {
|
|
|
56
56
|
let lastSessionId: string | null = null;
|
|
57
57
|
|
|
58
58
|
for (const msg of messages) {
|
|
59
|
-
if (msg.
|
|
59
|
+
if (msg.client !== lastSessionId && currentRun.length > 0) {
|
|
60
60
|
runs.push(currentRun);
|
|
61
61
|
currentRun = [];
|
|
62
62
|
}
|
|
63
63
|
currentRun.push(msg);
|
|
64
|
-
lastSessionId = msg.
|
|
64
|
+
lastSessionId = msg.client;
|
|
65
65
|
}
|
|
66
66
|
if (currentRun.length > 0) runs.push(currentRun);
|
|
67
67
|
|
|
@@ -97,24 +97,24 @@ describe('LWW Ordering Bug', () => {
|
|
|
97
97
|
let graph = createEmptyGraph();
|
|
98
98
|
graph = applyMessage(graph, {
|
|
99
99
|
id: 'init',
|
|
100
|
-
|
|
100
|
+
client: 'init',
|
|
101
101
|
clock: { init: 1 },
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
ops: [{
|
|
102
|
+
lt:1,
|
|
103
|
+
ts: 0,
|
|
104
|
+
ops: [{ ot: 'node.insert', key: '__root', path: 'children', value: { key: 'cube', tag: 'Mesh', name: 'Cube' } }],
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
// Original messages in interleaved order
|
|
108
108
|
const messages: CRDTMessage[] = [
|
|
109
109
|
{
|
|
110
110
|
id: 'msg1',
|
|
111
|
-
|
|
111
|
+
client: 'alice',
|
|
112
112
|
clock: { alice: 10 },
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
lt:10,
|
|
114
|
+
ts: 1000,
|
|
115
115
|
ops: [
|
|
116
116
|
{
|
|
117
|
-
|
|
117
|
+
ot: 'string.set',
|
|
118
118
|
key: 'cube',
|
|
119
119
|
path: 'color',
|
|
120
120
|
value: 'red',
|
|
@@ -123,13 +123,13 @@ describe('LWW Ordering Bug', () => {
|
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
id: 'msg2',
|
|
126
|
-
|
|
126
|
+
client: 'bob',
|
|
127
127
|
clock: { alice: 10, bob: 15 },
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
lt:15,
|
|
129
|
+
ts: 1500,
|
|
130
130
|
ops: [
|
|
131
131
|
{
|
|
132
|
-
|
|
132
|
+
ot: 'string.set',
|
|
133
133
|
key: 'cube',
|
|
134
134
|
path: 'color',
|
|
135
135
|
value: 'blue',
|
|
@@ -138,13 +138,13 @@ describe('LWW Ordering Bug', () => {
|
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
id: 'msg3',
|
|
141
|
-
|
|
141
|
+
client: 'alice',
|
|
142
142
|
clock: { alice: 20, bob: 15 },
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
lt:20,
|
|
144
|
+
ts: 2000,
|
|
145
145
|
ops: [
|
|
146
146
|
{
|
|
147
|
-
|
|
147
|
+
ot: 'string.set',
|
|
148
148
|
key: 'cube',
|
|
149
149
|
path: 'color',
|
|
150
150
|
value: 'green',
|
|
@@ -164,7 +164,7 @@ describe('LWW Ordering Bug', () => {
|
|
|
164
164
|
const coalescedBuggy = coalesceBuggy(messages);
|
|
165
165
|
console.log('Buggy coalescing:');
|
|
166
166
|
coalescedBuggy.forEach(msg => {
|
|
167
|
-
console.log(` session=${msg.
|
|
167
|
+
console.log(` session=${msg.client}, lamport=${msg.lt}, ops=${msg.ops.length}`);
|
|
168
168
|
msg.ops.forEach(op => console.log(` ${JSON.stringify(op)}`));
|
|
169
169
|
});
|
|
170
170
|
|
|
@@ -180,7 +180,7 @@ describe('LWW Ordering Bug', () => {
|
|
|
180
180
|
const coalescedCorrect = coalesceCorrect(messages);
|
|
181
181
|
console.log('\nCorrect coalescing:');
|
|
182
182
|
coalescedCorrect.forEach(msg => {
|
|
183
|
-
console.log(` session=${msg.
|
|
183
|
+
console.log(` session=${msg.client}, lamport=${msg.lt}, ops=${msg.ops.length}`);
|
|
184
184
|
msg.ops.forEach(op => console.log(` ${JSON.stringify(op)}`));
|
|
185
185
|
});
|
|
186
186
|
|
|
@@ -201,37 +201,37 @@ describe('LWW Ordering Bug', () => {
|
|
|
201
201
|
const messages: CRDTMessage[] = [
|
|
202
202
|
{
|
|
203
203
|
id: 'msg1',
|
|
204
|
-
|
|
204
|
+
client: 'alice',
|
|
205
205
|
clock: { alice: 10 },
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
ops: [{
|
|
206
|
+
lt:10,
|
|
207
|
+
ts: 1000,
|
|
208
|
+
ops: [{ ot: 'number.set', key: 'x', path: 'value', value: 1 }],
|
|
209
209
|
},
|
|
210
210
|
{
|
|
211
211
|
id: 'msg2',
|
|
212
|
-
|
|
212
|
+
client: 'bob',
|
|
213
213
|
clock: { alice: 10, bob: 15 },
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
ops: [{
|
|
214
|
+
lt:15,
|
|
215
|
+
ts: 1500,
|
|
216
|
+
ops: [{ ot: 'number.set', key: 'x', path: 'value', value: 2 }],
|
|
217
217
|
},
|
|
218
218
|
{
|
|
219
219
|
id: 'msg3',
|
|
220
|
-
|
|
220
|
+
client: 'alice',
|
|
221
221
|
clock: { alice: 20, bob: 15 },
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
ops: [{
|
|
222
|
+
lt:20,
|
|
223
|
+
ts: 2000,
|
|
224
|
+
ops: [{ ot: 'number.set', key: 'x', path: 'value', value: 3 }],
|
|
225
225
|
},
|
|
226
226
|
];
|
|
227
227
|
|
|
228
228
|
const coalesced = coalesceBuggy(messages);
|
|
229
229
|
|
|
230
230
|
// Find alice's coalesced message
|
|
231
|
-
const aliceMsg = coalesced.find(m => m.
|
|
231
|
+
const aliceMsg = coalesced.find(m => m.client === 'alice')!;
|
|
232
232
|
|
|
233
233
|
// Bug: ALL of Alice's ops inherit lamportTime=20 from msg3
|
|
234
|
-
expect(aliceMsg.
|
|
234
|
+
expect(aliceMsg.lt).toBe(20);
|
|
235
235
|
expect(aliceMsg.ops).toHaveLength(2); // Both set operations
|
|
236
236
|
|
|
237
237
|
// When these ops are applied, they BOTH get lamportTime=20
|
|
@@ -239,7 +239,7 @@ describe('LWW Ordering Bug', () => {
|
|
|
239
239
|
// instead of its original lamport=10
|
|
240
240
|
|
|
241
241
|
console.log('Alice coalesced message:', {
|
|
242
|
-
|
|
242
|
+
lt:aliceMsg.lt,
|
|
243
243
|
ops: aliceMsg.ops,
|
|
244
244
|
});
|
|
245
245
|
|