@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,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for RLE Compression
|
|
3
|
+
*
|
|
4
|
+
* Tests:
|
|
5
|
+
* 1. RLE encode/decode roundtrip with various message patterns
|
|
6
|
+
* 2. CRDT semantics preservation (operations remain unchanged)
|
|
7
|
+
* 3. Compression ratio calculation
|
|
8
|
+
* 4. Edge cases (empty, single message, alternating sessions)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from '@jest/globals';
|
|
12
|
+
import type { CRDTMessage, NumberSetOp } from '@vuer-ai/vuer-rtc';
|
|
13
|
+
import {
|
|
14
|
+
encodeRLE,
|
|
15
|
+
decodeRLE,
|
|
16
|
+
encodeRLEWithMetadata,
|
|
17
|
+
decodeRLEWithMetadata,
|
|
18
|
+
calculateCompressionRatio,
|
|
19
|
+
} from '../../src/journal/RLECompression';
|
|
20
|
+
|
|
21
|
+
describe('RLE Compression', () => {
|
|
22
|
+
// Helper to create test messages
|
|
23
|
+
function createMessage(
|
|
24
|
+
id: string,
|
|
25
|
+
sessionId: string,
|
|
26
|
+
lamportTime: number,
|
|
27
|
+
opsCount: number = 1
|
|
28
|
+
): CRDTMessage {
|
|
29
|
+
const ops: NumberSetOp[] = Array.from({ length: opsCount }, (_, i) => ({
|
|
30
|
+
otype: 'number.set',
|
|
31
|
+
key: `key-${lamportTime}`,
|
|
32
|
+
path: `properties/prop-${i}`,
|
|
33
|
+
value: Math.random() * 100,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
id,
|
|
38
|
+
sessionId,
|
|
39
|
+
clock: { [sessionId]: lamportTime },
|
|
40
|
+
lamportTime,
|
|
41
|
+
timestamp: Date.now() / 1000,
|
|
42
|
+
ops,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('encodeRLE', () => {
|
|
47
|
+
it('should encode empty list', () => {
|
|
48
|
+
const result = encodeRLE([]);
|
|
49
|
+
expect(result).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should encode single message', () => {
|
|
53
|
+
const msg = createMessage('msg-1', 'session-a', 1, 2);
|
|
54
|
+
const result = encodeRLE([msg]);
|
|
55
|
+
|
|
56
|
+
expect(result).toHaveLength(1);
|
|
57
|
+
expect(result[0]).toMatchObject({
|
|
58
|
+
sessionId: 'session-a',
|
|
59
|
+
count: 1,
|
|
60
|
+
lamportTime: 1,
|
|
61
|
+
endLamportTime: 1,
|
|
62
|
+
ops: msg.ops,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should encode consecutive messages from same session', () => {
|
|
67
|
+
const msgs = [
|
|
68
|
+
createMessage('msg-1', 'session-a', 1, 1),
|
|
69
|
+
createMessage('msg-2', 'session-a', 2, 1),
|
|
70
|
+
createMessage('msg-3', 'session-a', 3, 1),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const result = encodeRLE(msgs);
|
|
74
|
+
|
|
75
|
+
expect(result).toHaveLength(1);
|
|
76
|
+
expect(result[0]).toMatchObject({
|
|
77
|
+
sessionId: 'session-a',
|
|
78
|
+
count: 3,
|
|
79
|
+
lamportTime: 1,
|
|
80
|
+
endLamportTime: 3,
|
|
81
|
+
});
|
|
82
|
+
// All ops should be flattened
|
|
83
|
+
expect(result[0].ops).toHaveLength(3);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should encode alternating sessions as separate runs', () => {
|
|
87
|
+
const msgs = [
|
|
88
|
+
createMessage('msg-1', 'session-a', 1, 1),
|
|
89
|
+
createMessage('msg-2', 'session-b', 2, 1),
|
|
90
|
+
createMessage('msg-3', 'session-a', 3, 1),
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const result = encodeRLE(msgs);
|
|
94
|
+
|
|
95
|
+
expect(result).toHaveLength(3);
|
|
96
|
+
expect(result[0].sessionId).toBe('session-a');
|
|
97
|
+
expect(result[0].count).toBe(1);
|
|
98
|
+
expect(result[1].sessionId).toBe('session-b');
|
|
99
|
+
expect(result[1].count).toBe(1);
|
|
100
|
+
expect(result[2].sessionId).toBe('session-a');
|
|
101
|
+
expect(result[2].count).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should encode mixed multi-operation messages', () => {
|
|
105
|
+
const msgs = [
|
|
106
|
+
createMessage('msg-1', 'session-a', 1, 3), // 3 ops
|
|
107
|
+
createMessage('msg-2', 'session-a', 2, 2), // 2 ops
|
|
108
|
+
createMessage('msg-3', 'session-a', 3, 1), // 1 op
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const result = encodeRLE(msgs);
|
|
112
|
+
|
|
113
|
+
expect(result).toHaveLength(1);
|
|
114
|
+
expect(result[0].count).toBe(3);
|
|
115
|
+
expect(result[0].ops).toHaveLength(6); // 3 + 2 + 1
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should preserve operation details', () => {
|
|
119
|
+
const msg = createMessage('msg-1', 'session-a', 1, 2);
|
|
120
|
+
const result = encodeRLE([msg]);
|
|
121
|
+
|
|
122
|
+
expect(result[0].ops).toEqual(msg.ops);
|
|
123
|
+
expect(result[0].ops[0].key).toBe(msg.ops[0].key);
|
|
124
|
+
expect(result[0].ops[1].otype).toBe(msg.ops[1].otype);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('decodeRLE', () => {
|
|
129
|
+
it('should decode single run to single message', () => {
|
|
130
|
+
const encoded = [
|
|
131
|
+
{
|
|
132
|
+
sessionId: 'session-a',
|
|
133
|
+
count: 1,
|
|
134
|
+
lamportTime: 1,
|
|
135
|
+
endLamportTime: 1,
|
|
136
|
+
ops: [{ otype: 'number.set', key: 'k1', path: 'p1', value: 10 }],
|
|
137
|
+
timestamp: 1000,
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
const opCounts = [1];
|
|
141
|
+
|
|
142
|
+
const result = decodeRLE(encoded, opCounts);
|
|
143
|
+
|
|
144
|
+
expect(result).toHaveLength(1);
|
|
145
|
+
expect(result[0].sessionId).toBe('session-a');
|
|
146
|
+
expect(result[0].lamportTime).toBe(1);
|
|
147
|
+
expect(result[0].ops).toHaveLength(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should decode run with multiple messages', () => {
|
|
151
|
+
const encoded = [
|
|
152
|
+
{
|
|
153
|
+
sessionId: 'session-a',
|
|
154
|
+
count: 3,
|
|
155
|
+
lamportTime: 1,
|
|
156
|
+
endLamportTime: 3,
|
|
157
|
+
ops: [
|
|
158
|
+
{ otype: 'number.set', key: 'k1', path: 'p1', value: 1 },
|
|
159
|
+
{ otype: 'number.set', key: 'k2', path: 'p2', value: 2 },
|
|
160
|
+
{ otype: 'number.set', key: 'k3', path: 'p3', value: 3 },
|
|
161
|
+
],
|
|
162
|
+
timestamp: 1000,
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
const opCounts = [1, 1, 1];
|
|
166
|
+
|
|
167
|
+
const result = decodeRLE(encoded, opCounts);
|
|
168
|
+
|
|
169
|
+
expect(result).toHaveLength(3);
|
|
170
|
+
expect(result[0].lamportTime).toBe(1);
|
|
171
|
+
expect(result[1].lamportTime).toBe(2);
|
|
172
|
+
expect(result[2].lamportTime).toBe(3);
|
|
173
|
+
// Each should have one op
|
|
174
|
+
result.forEach((msg) => {
|
|
175
|
+
expect(msg.ops).toHaveLength(1);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should handle multi-op messages during decode', () => {
|
|
180
|
+
const encoded = [
|
|
181
|
+
{
|
|
182
|
+
sessionId: 'session-a',
|
|
183
|
+
count: 2,
|
|
184
|
+
lamportTime: 1,
|
|
185
|
+
endLamportTime: 2,
|
|
186
|
+
ops: [
|
|
187
|
+
{ otype: 'number.set', key: 'k1a', path: 'p1a', value: 10 },
|
|
188
|
+
{ otype: 'number.set', key: 'k1b', path: 'p1b', value: 20 },
|
|
189
|
+
{ otype: 'number.set', key: 'k2a', path: 'p2a', value: 30 },
|
|
190
|
+
],
|
|
191
|
+
timestamp: 1000,
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
const opCounts = [2, 1]; // First msg has 2 ops, second has 1
|
|
195
|
+
|
|
196
|
+
const result = decodeRLE(encoded, opCounts);
|
|
197
|
+
|
|
198
|
+
expect(result).toHaveLength(2);
|
|
199
|
+
expect(result[0].ops).toHaveLength(2);
|
|
200
|
+
expect(result[1].ops).toHaveLength(1);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('Roundtrip: encode → decode', () => {
|
|
205
|
+
it('should preserve single message through roundtrip', () => {
|
|
206
|
+
const original = [createMessage('msg-1', 'session-a', 1, 1)];
|
|
207
|
+
const opCounts = original.map((m) => m.ops.length);
|
|
208
|
+
|
|
209
|
+
const encoded = encodeRLE(original);
|
|
210
|
+
const decoded = decodeRLE(encoded, opCounts);
|
|
211
|
+
|
|
212
|
+
// Check counts
|
|
213
|
+
expect(decoded).toHaveLength(original.length);
|
|
214
|
+
expect(decoded[0].sessionId).toBe(original[0].sessionId);
|
|
215
|
+
expect(decoded[0].lamportTime).toBe(original[0].lamportTime);
|
|
216
|
+
expect(decoded[0].ops).toEqual(original[0].ops);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should preserve multiple consecutive messages from same session', () => {
|
|
220
|
+
const original = [
|
|
221
|
+
createMessage('msg-1', 'session-a', 1, 1),
|
|
222
|
+
createMessage('msg-2', 'session-a', 2, 1),
|
|
223
|
+
createMessage('msg-3', 'session-a', 3, 1),
|
|
224
|
+
];
|
|
225
|
+
const opCounts = original.map((m) => m.ops.length);
|
|
226
|
+
|
|
227
|
+
const encoded = encodeRLE(original);
|
|
228
|
+
const decoded = decodeRLE(encoded, opCounts);
|
|
229
|
+
|
|
230
|
+
expect(decoded).toHaveLength(original.length);
|
|
231
|
+
for (let i = 0; i < original.length; i++) {
|
|
232
|
+
expect(decoded[i].sessionId).toBe(original[i].sessionId);
|
|
233
|
+
expect(decoded[i].lamportTime).toBe(original[i].lamportTime);
|
|
234
|
+
expect(decoded[i].ops).toEqual(original[i].ops);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should preserve alternating sessions through roundtrip', () => {
|
|
239
|
+
const original = [
|
|
240
|
+
createMessage('msg-1', 'session-a', 1, 1),
|
|
241
|
+
createMessage('msg-2', 'session-b', 2, 1),
|
|
242
|
+
createMessage('msg-3', 'session-c', 3, 1),
|
|
243
|
+
];
|
|
244
|
+
const opCounts = original.map((m) => m.ops.length);
|
|
245
|
+
|
|
246
|
+
const encoded = encodeRLE(original);
|
|
247
|
+
const decoded = decodeRLE(encoded, opCounts);
|
|
248
|
+
|
|
249
|
+
expect(decoded).toHaveLength(original.length);
|
|
250
|
+
for (let i = 0; i < original.length; i++) {
|
|
251
|
+
expect(decoded[i].sessionId).toBe(original[i].sessionId);
|
|
252
|
+
expect(decoded[i].lamportTime).toBe(original[i].lamportTime);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should preserve complex multi-operation messages', () => {
|
|
257
|
+
const original = [
|
|
258
|
+
createMessage('msg-1', 'session-a', 1, 3),
|
|
259
|
+
createMessage('msg-2', 'session-a', 2, 2),
|
|
260
|
+
createMessage('msg-3', 'session-b', 3, 4),
|
|
261
|
+
createMessage('msg-4', 'session-b', 4, 1),
|
|
262
|
+
];
|
|
263
|
+
const opCounts = original.map((m) => m.ops.length);
|
|
264
|
+
|
|
265
|
+
const encoded = encodeRLE(original);
|
|
266
|
+
const decoded = decodeRLE(encoded, opCounts);
|
|
267
|
+
|
|
268
|
+
expect(decoded).toHaveLength(original.length);
|
|
269
|
+
for (let i = 0; i < original.length; i++) {
|
|
270
|
+
expect(decoded[i].ops).toHaveLength(original[i].ops.length);
|
|
271
|
+
expect(decoded[i].sessionId).toBe(original[i].sessionId);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('encodeRLEWithMetadata & decodeRLEWithMetadata', () => {
|
|
277
|
+
it('should preserve all message metadata through roundtrip', () => {
|
|
278
|
+
const original = [
|
|
279
|
+
createMessage('msg-1', 'session-a', 1, 2),
|
|
280
|
+
createMessage('msg-2', 'session-a', 2, 1),
|
|
281
|
+
createMessage('msg-3', 'session-b', 3, 3),
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
const encoded = encodeRLEWithMetadata(original);
|
|
285
|
+
const decoded = decodeRLEWithMetadata(encoded);
|
|
286
|
+
|
|
287
|
+
expect(decoded).toHaveLength(original.length);
|
|
288
|
+
for (let i = 0; i < original.length; i++) {
|
|
289
|
+
expect(decoded[i].id).toBe(original[i].id);
|
|
290
|
+
expect(decoded[i].sessionId).toBe(original[i].sessionId);
|
|
291
|
+
expect(decoded[i].clock).toEqual(original[i].clock);
|
|
292
|
+
expect(decoded[i].lamportTime).toBe(original[i].lamportTime);
|
|
293
|
+
expect(decoded[i].ops).toEqual(original[i].ops);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should recover message IDs correctly', () => {
|
|
298
|
+
const original = [
|
|
299
|
+
createMessage('unique-id-1', 'session-a', 1, 1),
|
|
300
|
+
createMessage('unique-id-2', 'session-a', 2, 1),
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
const encoded = encodeRLEWithMetadata(original);
|
|
304
|
+
const decoded = decodeRLEWithMetadata(encoded);
|
|
305
|
+
|
|
306
|
+
expect(decoded[0].id).toBe('unique-id-1');
|
|
307
|
+
expect(decoded[1].id).toBe('unique-id-2');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should preserve vector clocks', () => {
|
|
311
|
+
const msg1: CRDTMessage = {
|
|
312
|
+
id: 'msg-1',
|
|
313
|
+
sessionId: 'session-a',
|
|
314
|
+
clock: { 'session-a': 5, 'session-b': 3 },
|
|
315
|
+
lamportTime: 1,
|
|
316
|
+
timestamp: 1000,
|
|
317
|
+
ops: [{ otype: 'number.set', key: 'k', path: 'p', value: 10 }],
|
|
318
|
+
};
|
|
319
|
+
const msg2: CRDTMessage = {
|
|
320
|
+
id: 'msg-2',
|
|
321
|
+
sessionId: 'session-a',
|
|
322
|
+
clock: { 'session-a': 6, 'session-b': 4 },
|
|
323
|
+
lamportTime: 2,
|
|
324
|
+
timestamp: 1001,
|
|
325
|
+
ops: [{ otype: 'number.set', key: 'k', path: 'p', value: 20 }],
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const encoded = encodeRLEWithMetadata([msg1, msg2]);
|
|
329
|
+
const decoded = decodeRLEWithMetadata(encoded);
|
|
330
|
+
|
|
331
|
+
expect(decoded[0].clock).toEqual({ 'session-a': 5, 'session-b': 3 });
|
|
332
|
+
expect(decoded[1].clock).toEqual({ 'session-a': 6, 'session-b': 4 });
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('CRDT Semantics Preservation', () => {
|
|
337
|
+
it('should not lose any operations', () => {
|
|
338
|
+
const original = [
|
|
339
|
+
createMessage('msg-1', 'session-a', 1, 5),
|
|
340
|
+
createMessage('msg-2', 'session-a', 2, 3),
|
|
341
|
+
createMessage('msg-3', 'session-b', 3, 7),
|
|
342
|
+
];
|
|
343
|
+
|
|
344
|
+
const encoded = encodeRLEWithMetadata(original);
|
|
345
|
+
const decoded = decodeRLEWithMetadata(encoded);
|
|
346
|
+
|
|
347
|
+
let totalOriginalOps = 0;
|
|
348
|
+
let totalDecodedOps = 0;
|
|
349
|
+
|
|
350
|
+
for (const msg of original) {
|
|
351
|
+
totalOriginalOps += msg.ops.length;
|
|
352
|
+
}
|
|
353
|
+
for (const msg of decoded) {
|
|
354
|
+
totalDecodedOps += msg.ops.length;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
expect(totalDecodedOps).toBe(totalOriginalOps);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should maintain operation order within messages', () => {
|
|
361
|
+
const ops: NumberSetOp[] = [
|
|
362
|
+
{ otype: 'number.set', key: 'k1', path: 'p1', value: 1 },
|
|
363
|
+
{ otype: 'number.set', key: 'k2', path: 'p2', value: 2 },
|
|
364
|
+
{ otype: 'number.set', key: 'k3', path: 'p3', value: 3 },
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
const msg: CRDTMessage = {
|
|
368
|
+
id: 'msg-1',
|
|
369
|
+
sessionId: 'session-a',
|
|
370
|
+
clock: { 'session-a': 1 },
|
|
371
|
+
lamportTime: 1,
|
|
372
|
+
timestamp: 1000,
|
|
373
|
+
ops,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const encoded = encodeRLEWithMetadata([msg]);
|
|
377
|
+
const decoded = decodeRLEWithMetadata(encoded);
|
|
378
|
+
|
|
379
|
+
expect(decoded[0].ops).toEqual(ops);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should maintain lamport timestamps', () => {
|
|
383
|
+
const original = [
|
|
384
|
+
createMessage('msg-1', 'session-a', 100, 1),
|
|
385
|
+
createMessage('msg-2', 'session-a', 101, 1),
|
|
386
|
+
createMessage('msg-3', 'session-b', 102, 1),
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
const encoded = encodeRLEWithMetadata(original);
|
|
390
|
+
const decoded = decodeRLEWithMetadata(encoded);
|
|
391
|
+
|
|
392
|
+
for (let i = 0; i < original.length; i++) {
|
|
393
|
+
expect(decoded[i].lamportTime).toBe(original[i].lamportTime);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe('Compression Ratio', () => {
|
|
399
|
+
it('should calculate compression for small set', () => {
|
|
400
|
+
const msgs = [
|
|
401
|
+
createMessage('msg-1', 'session-a', 1, 1),
|
|
402
|
+
createMessage('msg-2', 'session-a', 2, 1),
|
|
403
|
+
];
|
|
404
|
+
const encoded = encodeRLE(msgs);
|
|
405
|
+
|
|
406
|
+
const { original, encoded: encodedSize, ratio } = calculateCompressionRatio(
|
|
407
|
+
msgs,
|
|
408
|
+
encoded
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
expect(original).toBeGreaterThan(0);
|
|
412
|
+
expect(encodedSize).toBeGreaterThan(0);
|
|
413
|
+
expect(ratio).toBeGreaterThanOrEqual(-1);
|
|
414
|
+
expect(ratio).toBeLessThanOrEqual(1);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should show positive compression for repetitive data', () => {
|
|
418
|
+
// Create many messages from same session
|
|
419
|
+
const msgs = Array.from({ length: 50 }, (_, i) =>
|
|
420
|
+
createMessage(`msg-${i}`, 'session-a', i + 1, 1)
|
|
421
|
+
);
|
|
422
|
+
const encoded = encodeRLE(msgs);
|
|
423
|
+
|
|
424
|
+
const { ratio } = calculateCompressionRatio(msgs, encoded);
|
|
425
|
+
|
|
426
|
+
// With many runs of same session, should get good compression
|
|
427
|
+
expect(ratio).toBeGreaterThan(0);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('should show little compression for highly alternating sessions', () => {
|
|
431
|
+
// Create messages alternating between sessions
|
|
432
|
+
const msgs = Array.from({ length: 20 }, (_, i) =>
|
|
433
|
+
createMessage(`msg-${i}`, i % 2 === 0 ? 'session-a' : 'session-b', i + 1, 1)
|
|
434
|
+
);
|
|
435
|
+
const encoded = encodeRLE(msgs);
|
|
436
|
+
|
|
437
|
+
const { original, encoded: encodedSize } = calculateCompressionRatio(msgs, encoded);
|
|
438
|
+
|
|
439
|
+
// Alternating sessions means many runs, less compression
|
|
440
|
+
// But should still not be larger
|
|
441
|
+
expect(encodedSize).toBeLessThanOrEqual(original * 1.1);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
describe('Edge Cases', () => {
|
|
446
|
+
it('should handle very large lamport times', () => {
|
|
447
|
+
const msg: CRDTMessage = {
|
|
448
|
+
id: 'msg-huge',
|
|
449
|
+
sessionId: 'session-a',
|
|
450
|
+
clock: { 'session-a': 999999999 },
|
|
451
|
+
lamportTime: 999999999,
|
|
452
|
+
timestamp: 1000,
|
|
453
|
+
ops: [{ otype: 'number.set', key: 'k', path: 'p', value: 10 }],
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const encoded = encodeRLEWithMetadata([msg]);
|
|
457
|
+
const decoded = decodeRLEWithMetadata(encoded);
|
|
458
|
+
|
|
459
|
+
expect(decoded[0].lamportTime).toBe(999999999);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should handle empty operations array', () => {
|
|
463
|
+
const msg: CRDTMessage = {
|
|
464
|
+
id: 'msg-1',
|
|
465
|
+
sessionId: 'session-a',
|
|
466
|
+
clock: { 'session-a': 1 },
|
|
467
|
+
lamportTime: 1,
|
|
468
|
+
timestamp: 1000,
|
|
469
|
+
ops: [],
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const encoded = encodeRLEWithMetadata([msg]);
|
|
473
|
+
const decoded = decodeRLEWithMetadata(encoded);
|
|
474
|
+
|
|
475
|
+
expect(decoded[0].ops).toEqual([]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should handle very long operation lists', () => {
|
|
479
|
+
const ops: NumberSetOp[] = Array.from({ length: 1000 }, (_, i) => ({
|
|
480
|
+
otype: 'number.set',
|
|
481
|
+
key: `k-${i}`,
|
|
482
|
+
path: `p-${i}`,
|
|
483
|
+
value: i,
|
|
484
|
+
}));
|
|
485
|
+
|
|
486
|
+
const msg: CRDTMessage = {
|
|
487
|
+
id: 'msg-huge-ops',
|
|
488
|
+
sessionId: 'session-a',
|
|
489
|
+
clock: { 'session-a': 1 },
|
|
490
|
+
lamportTime: 1,
|
|
491
|
+
timestamp: 1000,
|
|
492
|
+
ops,
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const encoded = encodeRLEWithMetadata([msg]);
|
|
496
|
+
const decoded = decodeRLEWithMetadata(encoded);
|
|
497
|
+
|
|
498
|
+
expect(decoded[0].ops).toHaveLength(1000);
|
|
499
|
+
expect(decoded[0].ops).toEqual(ops);
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
describe('CRDT Semantics Preservation - Operation Details', () => {
|
|
504
|
+
it('should maintain operation order and properties', () => {
|
|
505
|
+
const ops: NumberSetOp[] = [
|
|
506
|
+
{ otype: 'number.set', key: 'k1', path: 'p1', value: 1 },
|
|
507
|
+
{ otype: 'number.set', key: 'k2', path: 'p2', value: 2 },
|
|
508
|
+
{ otype: 'number.set', key: 'k3', path: 'p3', value: 3 },
|
|
509
|
+
];
|
|
510
|
+
|
|
511
|
+
const msg: CRDTMessage = {
|
|
512
|
+
id: 'msg-1',
|
|
513
|
+
sessionId: 'session-a',
|
|
514
|
+
clock: { 'session-a': 1 },
|
|
515
|
+
lamportTime: 1,
|
|
516
|
+
timestamp: 1000,
|
|
517
|
+
ops,
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const encoded = encodeRLEWithMetadata([msg]);
|
|
521
|
+
const decoded = decodeRLEWithMetadata(encoded);
|
|
522
|
+
|
|
523
|
+
expect(decoded[0].ops).toEqual(ops);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
});
|