@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vuer-ai/vuer-rtc",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "CRDT-based real-time collaborative data structures",
@@ -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
+ });