@vuer-ai/vuer-rtc 0.8.1 → 0.8.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/package.json
CHANGED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Operation ordering bug that caused IME divergence
|
|
3
|
+
*
|
|
4
|
+
* This test reproduces the exact scenario from message-log-1772398431392.json
|
|
5
|
+
* where operations arrived with non-monotonic sequence numbers:
|
|
6
|
+
* seq: 40, 50, 59, 62, 46, 56, 64, 60, 63
|
|
7
|
+
*
|
|
8
|
+
* The fix ensures operations are sorted by Lamport timestamp before applying.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from '@jest/globals';
|
|
12
|
+
import { applyMessage, createEmptyGraph } from '../../src/operations/dispatcher.js';
|
|
13
|
+
import type { CRDTMessage } from '../../src/operations/OperationTypes.js';
|
|
14
|
+
|
|
15
|
+
describe('Operation Ordering - IME Divergence Bug', () => {
|
|
16
|
+
it('should apply operations in Lamport order when array has non-monotonic seq', () => {
|
|
17
|
+
// Create initial graph with text node
|
|
18
|
+
let graph = createEmptyGraph();
|
|
19
|
+
|
|
20
|
+
// Initialize root and text node
|
|
21
|
+
const initMsg: CRDTMessage = {
|
|
22
|
+
id: 'msg-init',
|
|
23
|
+
client: 'Misaka-08330-hy6',
|
|
24
|
+
clock: { 'Misaka-08330-hy6': 1 },
|
|
25
|
+
lt: 1,
|
|
26
|
+
ts: 1000.0,
|
|
27
|
+
ops: [
|
|
28
|
+
{
|
|
29
|
+
ot: 'node.insert',
|
|
30
|
+
key: '',
|
|
31
|
+
path: 'children',
|
|
32
|
+
value: { key: 'default-scene', tag: 'Scene', name: 'Scene' },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
ot: 'node.insert',
|
|
36
|
+
key: 'default-scene',
|
|
37
|
+
path: 'children',
|
|
38
|
+
value: { key: 'text-doc', tag: 'Text', name: 'Text' },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
ot: 'text.init',
|
|
42
|
+
key: 'text-doc',
|
|
43
|
+
path: 'content',
|
|
44
|
+
value: '',
|
|
45
|
+
} as any,
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
graph = applyMessage(graph, initMsg);
|
|
49
|
+
|
|
50
|
+
// Simulate operations arriving in non-monotonic seq order
|
|
51
|
+
// This reproduces entry #21 from the divergence log
|
|
52
|
+
const msg: CRDTMessage = {
|
|
53
|
+
id: 'msg-ime',
|
|
54
|
+
client: 'Misaka-08330-hy6',
|
|
55
|
+
clock: { 'Misaka-08330-hy6': 2 },
|
|
56
|
+
lt: 64,
|
|
57
|
+
ts: 1001.0,
|
|
58
|
+
ops: [
|
|
59
|
+
// Op #1: seq:40
|
|
60
|
+
{
|
|
61
|
+
ot: 'text.insert',
|
|
62
|
+
key: 'text-doc',
|
|
63
|
+
path: 'content',
|
|
64
|
+
id: 'Misaka-08330-hy6:7',
|
|
65
|
+
value: [null, 'wo z'],
|
|
66
|
+
seq: 40,
|
|
67
|
+
ts: 1001.0,
|
|
68
|
+
} as any,
|
|
69
|
+
// Op #2: seq:50
|
|
70
|
+
{
|
|
71
|
+
ot: 'text.insert',
|
|
72
|
+
key: 'text-doc',
|
|
73
|
+
path: 'content',
|
|
74
|
+
id: 'Misaka-08330-hy6:24',
|
|
75
|
+
value: ['Misaka-08330-hy6:7', 'ch'],
|
|
76
|
+
seq: 50,
|
|
77
|
+
ts: 1001.1,
|
|
78
|
+
} as any,
|
|
79
|
+
// Op #3: seq:59 - creates ID that will be deleted
|
|
80
|
+
{
|
|
81
|
+
ot: 'text.insert',
|
|
82
|
+
key: 'text-doc',
|
|
83
|
+
path: 'content',
|
|
84
|
+
id: 'Misaka-08330-hy6:37',
|
|
85
|
+
value: ['Misaka-08330-hy6:24', 's'],
|
|
86
|
+
seq: 59,
|
|
87
|
+
ts: 1001.2,
|
|
88
|
+
} as any,
|
|
89
|
+
// Op #4: seq:62
|
|
90
|
+
{
|
|
91
|
+
ot: 'text.insert',
|
|
92
|
+
key: 'text-doc',
|
|
93
|
+
path: 'content',
|
|
94
|
+
id: 'Misaka-08330-hy6:39',
|
|
95
|
+
value: ['Misaka-08330-hy6:37', 'u r'],
|
|
96
|
+
seq: 62,
|
|
97
|
+
ts: 1001.3,
|
|
98
|
+
} as any,
|
|
99
|
+
// Op #5: seq:46 - GOES BACKWARDS!
|
|
100
|
+
{
|
|
101
|
+
ot: 'text.insert',
|
|
102
|
+
key: 'text-doc',
|
|
103
|
+
path: 'content',
|
|
104
|
+
id: 'Misaka-08330-hy6:17',
|
|
105
|
+
value: ['Misaka-08330-hy6:7', ' l'],
|
|
106
|
+
seq: 46,
|
|
107
|
+
ts: 1001.05,
|
|
108
|
+
} as any,
|
|
109
|
+
// Op #6: seq:56
|
|
110
|
+
{
|
|
111
|
+
ot: 'text.insert',
|
|
112
|
+
key: 'text-doc',
|
|
113
|
+
path: 'content',
|
|
114
|
+
id: 'Misaka-08330-hy6:33',
|
|
115
|
+
value: ['Misaka-08330-hy6:24', 'i'],
|
|
116
|
+
seq: 56,
|
|
117
|
+
ts: 1001.15,
|
|
118
|
+
} as any,
|
|
119
|
+
// Op #7: seq:64 - replace that deletes id:37 from op #3
|
|
120
|
+
{
|
|
121
|
+
ot: 'text.replace',
|
|
122
|
+
key: 'text-doc',
|
|
123
|
+
path: 'content',
|
|
124
|
+
position: 0,
|
|
125
|
+
length: 6,
|
|
126
|
+
value: [null, '输入'],
|
|
127
|
+
rm: [['Misaka-08330-hy6:37', 6]],
|
|
128
|
+
id: 'Misaka-08330-hy6:43',
|
|
129
|
+
seq: 64,
|
|
130
|
+
ts: 1001.4,
|
|
131
|
+
} as any,
|
|
132
|
+
// Op #8: seq:60
|
|
133
|
+
{
|
|
134
|
+
ot: 'text.insert',
|
|
135
|
+
key: 'text-doc',
|
|
136
|
+
path: 'content',
|
|
137
|
+
id: 'Misaka-08330-hy6:38',
|
|
138
|
+
value: ['Misaka-08330-hy6:37', 'h'],
|
|
139
|
+
seq: 60,
|
|
140
|
+
ts: 1001.25,
|
|
141
|
+
} as any,
|
|
142
|
+
// Op #9: delete (no seq shown in log, using 65)
|
|
143
|
+
{
|
|
144
|
+
ot: 'text.delete',
|
|
145
|
+
key: 'text-doc',
|
|
146
|
+
path: 'content',
|
|
147
|
+
position: 0,
|
|
148
|
+
length: 1,
|
|
149
|
+
rm: [['Misaka-08330-hy6:36', 1]],
|
|
150
|
+
seq: 65,
|
|
151
|
+
ts: 1001.45,
|
|
152
|
+
} as any,
|
|
153
|
+
// Op #10: seq:63
|
|
154
|
+
{
|
|
155
|
+
ot: 'text.insert',
|
|
156
|
+
key: 'text-doc',
|
|
157
|
+
path: 'content',
|
|
158
|
+
id: 'Misaka-08330-hy6:42',
|
|
159
|
+
value: ['Misaka-08330-hy6:38', 'u'],
|
|
160
|
+
seq: 63,
|
|
161
|
+
ts: 1001.35,
|
|
162
|
+
} as any,
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Apply the message - operations should be sorted before applying
|
|
167
|
+
const result = applyMessage(graph, msg);
|
|
168
|
+
|
|
169
|
+
// The key test: operations should have been applied in seq order
|
|
170
|
+
// (40, 46, 50, 56, 59, 60, 62, 63, 64, 65) NOT array order
|
|
171
|
+
// This should produce consistent text without the CRDT throwing errors
|
|
172
|
+
expect(result.nodes['text-doc']).toBeDefined();
|
|
173
|
+
expect(result.nodes['text-doc'].content).toBeDefined();
|
|
174
|
+
|
|
175
|
+
// The exact text content depends on the CRDT merge logic,
|
|
176
|
+
// but it should not throw errors and should be deterministic
|
|
177
|
+
const text = String(result.nodes['text-doc'].content);
|
|
178
|
+
expect(text).toBeTruthy();
|
|
179
|
+
|
|
180
|
+
// Most importantly: this should NOT throw an error about missing IDs
|
|
181
|
+
// The replace operation (seq:64) that deletes id:37 should be applied
|
|
182
|
+
// AFTER the insert operation (seq:59) that creates id:37
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should sort operations with only seq metadata', () => {
|
|
186
|
+
let graph = createEmptyGraph();
|
|
187
|
+
|
|
188
|
+
// Create nodes first
|
|
189
|
+
const initMsg: CRDTMessage = {
|
|
190
|
+
id: 'msg-init',
|
|
191
|
+
client: 'alice',
|
|
192
|
+
clock: { alice: 0 },
|
|
193
|
+
lt: 0,
|
|
194
|
+
ts: 999.0,
|
|
195
|
+
ops: [
|
|
196
|
+
{ ot: 'node.insert', key: '', path: 'children', value: { key: 'node-1' } },
|
|
197
|
+
{ ot: 'node.insert', key: '', path: 'children', value: { key: 'node-2' } },
|
|
198
|
+
{ ot: 'node.insert', key: '', path: 'children', value: { key: 'node-3' } },
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
graph = applyMessage(graph, initMsg);
|
|
202
|
+
|
|
203
|
+
const msg: CRDTMessage = {
|
|
204
|
+
id: 'msg-1',
|
|
205
|
+
client: 'alice',
|
|
206
|
+
clock: { alice: 1 },
|
|
207
|
+
lt: 50,
|
|
208
|
+
ts: 1000.0,
|
|
209
|
+
ops: [
|
|
210
|
+
{
|
|
211
|
+
ot: 'number.set',
|
|
212
|
+
key: 'node-1',
|
|
213
|
+
path: 'value',
|
|
214
|
+
value: 40,
|
|
215
|
+
seq: 40,
|
|
216
|
+
} as any,
|
|
217
|
+
{
|
|
218
|
+
ot: 'number.set',
|
|
219
|
+
key: 'node-2',
|
|
220
|
+
path: 'value',
|
|
221
|
+
value: 30,
|
|
222
|
+
seq: 30,
|
|
223
|
+
} as any,
|
|
224
|
+
{
|
|
225
|
+
ot: 'number.set',
|
|
226
|
+
key: 'node-3',
|
|
227
|
+
path: 'value',
|
|
228
|
+
value: 50,
|
|
229
|
+
seq: 50,
|
|
230
|
+
} as any,
|
|
231
|
+
],
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Operations should be applied in seq order: 30, 40, 50
|
|
235
|
+
const result = applyMessage(graph, msg);
|
|
236
|
+
expect(result.nodes['node-1'].value).toBe(40);
|
|
237
|
+
expect(result.nodes['node-2'].value).toBe(30);
|
|
238
|
+
expect(result.nodes['node-3'].value).toBe(50);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should handle operations without seq/ts/id gracefully', () => {
|
|
242
|
+
let graph = createEmptyGraph();
|
|
243
|
+
|
|
244
|
+
// Create nodes first
|
|
245
|
+
const initMsg: CRDTMessage = {
|
|
246
|
+
id: 'msg-init',
|
|
247
|
+
client: 'alice',
|
|
248
|
+
clock: { alice: 0 },
|
|
249
|
+
lt: 0,
|
|
250
|
+
ts: 999.0,
|
|
251
|
+
ops: [
|
|
252
|
+
{ ot: 'node.insert', key: '', path: 'children', value: { key: 'node-1' } },
|
|
253
|
+
{ ot: 'node.insert', key: '', path: 'children', value: { key: 'node-2' } },
|
|
254
|
+
],
|
|
255
|
+
};
|
|
256
|
+
graph = applyMessage(graph, initMsg);
|
|
257
|
+
|
|
258
|
+
const msg: CRDTMessage = {
|
|
259
|
+
id: 'msg-1',
|
|
260
|
+
client: 'alice',
|
|
261
|
+
clock: { alice: 1 },
|
|
262
|
+
lt: 10,
|
|
263
|
+
ts: 1000.0,
|
|
264
|
+
ops: [
|
|
265
|
+
{
|
|
266
|
+
ot: 'number.set',
|
|
267
|
+
key: 'node-1',
|
|
268
|
+
path: 'value',
|
|
269
|
+
value: 1,
|
|
270
|
+
} as any,
|
|
271
|
+
{
|
|
272
|
+
ot: 'number.set',
|
|
273
|
+
key: 'node-2',
|
|
274
|
+
path: 'value',
|
|
275
|
+
value: 2,
|
|
276
|
+
} as any,
|
|
277
|
+
],
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Should not throw even without seq/ts/id
|
|
281
|
+
const result = applyMessage(graph, msg);
|
|
282
|
+
expect(result.nodes['node-1'].value).toBe(1);
|
|
283
|
+
expect(result.nodes['node-2'].value).toBe(2);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should use ts as tiebreaker when seq is equal', () => {
|
|
287
|
+
let graph = createEmptyGraph();
|
|
288
|
+
|
|
289
|
+
// Create nodes first
|
|
290
|
+
const initMsg: CRDTMessage = {
|
|
291
|
+
id: 'msg-init',
|
|
292
|
+
client: 'alice',
|
|
293
|
+
clock: { alice: 0 },
|
|
294
|
+
lt: 0,
|
|
295
|
+
ts: 999.0,
|
|
296
|
+
ops: [
|
|
297
|
+
{ ot: 'node.insert', key: '', path: 'children', value: { key: 'node-1' } },
|
|
298
|
+
{ ot: 'node.insert', key: '', path: 'children', value: { key: 'node-2' } },
|
|
299
|
+
{ ot: 'node.insert', key: '', path: 'children', value: { key: 'node-3' } },
|
|
300
|
+
],
|
|
301
|
+
};
|
|
302
|
+
graph = applyMessage(graph, initMsg);
|
|
303
|
+
|
|
304
|
+
const msg: CRDTMessage = {
|
|
305
|
+
id: 'msg-1',
|
|
306
|
+
client: 'alice',
|
|
307
|
+
clock: { alice: 1 },
|
|
308
|
+
lt: 40,
|
|
309
|
+
ts: 1000.0,
|
|
310
|
+
ops: [
|
|
311
|
+
{
|
|
312
|
+
ot: 'number.set',
|
|
313
|
+
key: 'node-1',
|
|
314
|
+
path: 'value',
|
|
315
|
+
value: 1,
|
|
316
|
+
seq: 40,
|
|
317
|
+
ts: 1000.2,
|
|
318
|
+
} as any,
|
|
319
|
+
{
|
|
320
|
+
ot: 'number.set',
|
|
321
|
+
key: 'node-2',
|
|
322
|
+
path: 'value',
|
|
323
|
+
value: 2,
|
|
324
|
+
seq: 40,
|
|
325
|
+
ts: 1000.1,
|
|
326
|
+
} as any,
|
|
327
|
+
{
|
|
328
|
+
ot: 'number.set',
|
|
329
|
+
key: 'node-3',
|
|
330
|
+
path: 'value',
|
|
331
|
+
value: 3,
|
|
332
|
+
seq: 40,
|
|
333
|
+
ts: 1000.3,
|
|
334
|
+
} as any,
|
|
335
|
+
],
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Should sort by ts when seq is equal: 1000.1, 1000.2, 1000.3
|
|
339
|
+
const result = applyMessage(graph, msg);
|
|
340
|
+
expect(result.nodes['node-1'].value).toBe(1);
|
|
341
|
+
expect(result.nodes['node-2'].value).toBe(2);
|
|
342
|
+
expect(result.nodes['node-3'].value).toBe(3);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should use id as final tiebreaker', () => {
|
|
346
|
+
let graph = createEmptyGraph();
|
|
347
|
+
|
|
348
|
+
// Create nodes first
|
|
349
|
+
const initMsg: CRDTMessage = {
|
|
350
|
+
id: 'msg-init',
|
|
351
|
+
client: 'alice',
|
|
352
|
+
clock: { alice: 0 },
|
|
353
|
+
lt: 0,
|
|
354
|
+
ts: 999.0,
|
|
355
|
+
ops: [
|
|
356
|
+
{ ot: 'node.insert', key: '', path: 'children', value: { key: 'node-1' } },
|
|
357
|
+
{ ot: 'node.insert', key: '', path: 'children', value: { key: 'node-2' } },
|
|
358
|
+
{ ot: 'node.insert', key: '', path: 'children', value: { key: 'node-3' } },
|
|
359
|
+
],
|
|
360
|
+
};
|
|
361
|
+
graph = applyMessage(graph, initMsg);
|
|
362
|
+
|
|
363
|
+
const msg: CRDTMessage = {
|
|
364
|
+
id: 'msg-1',
|
|
365
|
+
client: 'alice',
|
|
366
|
+
clock: { alice: 1 },
|
|
367
|
+
lt: 40,
|
|
368
|
+
ts: 1000.0,
|
|
369
|
+
ops: [
|
|
370
|
+
{
|
|
371
|
+
ot: 'number.set',
|
|
372
|
+
key: 'node-1',
|
|
373
|
+
path: 'value',
|
|
374
|
+
value: 1,
|
|
375
|
+
seq: 40,
|
|
376
|
+
ts: 1000.0,
|
|
377
|
+
id: 'charlie:1',
|
|
378
|
+
} as any,
|
|
379
|
+
{
|
|
380
|
+
ot: 'number.set',
|
|
381
|
+
key: 'node-2',
|
|
382
|
+
path: 'value',
|
|
383
|
+
value: 2,
|
|
384
|
+
seq: 40,
|
|
385
|
+
ts: 1000.0,
|
|
386
|
+
id: 'alice:1',
|
|
387
|
+
} as any,
|
|
388
|
+
{
|
|
389
|
+
ot: 'number.set',
|
|
390
|
+
key: 'node-3',
|
|
391
|
+
path: 'value',
|
|
392
|
+
value: 3,
|
|
393
|
+
seq: 40,
|
|
394
|
+
ts: 1000.0,
|
|
395
|
+
id: 'bob:1',
|
|
396
|
+
} as any,
|
|
397
|
+
],
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Should sort by id when seq and ts are equal: alice, bob, charlie
|
|
401
|
+
const result = applyMessage(graph, msg);
|
|
402
|
+
expect(result.nodes['node-1'].value).toBe(1);
|
|
403
|
+
expect(result.nodes['node-2'].value).toBe(2);
|
|
404
|
+
expect(result.nodes['node-3'].value).toBe(3);
|
|
405
|
+
});
|
|
406
|
+
});
|