@vuer-ai/vuer-rtc 0.5.0 → 0.5.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/DELETE_COALESCENCE_SOLUTION.md +345 -0
- package/dist/client/coalesceTextOperations.d.ts.map +1 -1
- package/dist/client/coalesceTextOperations.js +41 -2
- package/dist/client/coalesceTextOperations.js.map +1 -1
- package/dist/client/coalescence/textDeletes.d.ts.map +1 -1
- package/dist/client/coalescence/textDeletes.js +7 -0
- package/dist/client/coalescence/textDeletes.js.map +1 -1
- package/dist/client/coalescence/utils.d.ts +29 -0
- package/dist/client/coalescence/utils.d.ts.map +1 -0
- package/dist/client/coalescence/utils.js +52 -0
- package/dist/client/coalescence/utils.js.map +1 -0
- package/dist/crdt/Rope.d.ts.map +1 -1
- package/dist/crdt/Rope.js +22 -1
- package/dist/crdt/Rope.js.map +1 -1
- package/package.json +1 -1
- package/src/client/coalesceTextOperations.ts +40 -2
- package/src/client/coalescence/textDeletes.ts +8 -0
- package/src/client/coalescence/utils.ts +60 -0
- package/src/crdt/Rope.ts +25 -1
- package/tests/client/coalesce-graph-operations.test.ts +321 -0
- package/tests/client/coalesce-text-operations.test.ts +1 -2
- package/tests/client/delete-coalescence-bug.test.ts +249 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests demonstrating the delete coalescence bug
|
|
3
|
+
*
|
|
4
|
+
* BUG: When user types quickly and then deletes, the deletion array contains
|
|
5
|
+
* many single-character deletions instead of being optimized.
|
|
6
|
+
*
|
|
7
|
+
* ROOT CAUSE: Local typing creates separate items in the rope. Insert coalescence
|
|
8
|
+
* merges operations for network transmission but doesn't update the local rope.
|
|
9
|
+
* When deleting, remove() iterates through many items and creates many deletion entries.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from '@jest/globals';
|
|
13
|
+
import { create, insert, remove, apply, toJSON } from '../../src/crdt/Rope.js';
|
|
14
|
+
import type { InsertOp } from '../../src/crdt/Rope.js';
|
|
15
|
+
import { coalesceTextOperations } from '../../src/client/coalesceTextOperations.js';
|
|
16
|
+
import type { TextOperation } from '../../src/client/textTypes.js';
|
|
17
|
+
|
|
18
|
+
describe('Delete Coalescence Bug', () => {
|
|
19
|
+
describe('Local typing creates many items', () => {
|
|
20
|
+
it('should merge consecutive single-char deletions into one deletion', () => {
|
|
21
|
+
const rope = create('alice');
|
|
22
|
+
|
|
23
|
+
// Simulate typing "Hello" one char at a time
|
|
24
|
+
insert(rope, 0, 'H');
|
|
25
|
+
insert(rope, 1, 'e');
|
|
26
|
+
insert(rope, 2, 'l');
|
|
27
|
+
insert(rope, 3, 'l');
|
|
28
|
+
insert(rope, 4, 'o');
|
|
29
|
+
|
|
30
|
+
// Verify text is correct
|
|
31
|
+
expect(rope.toString()).toBe('Hello');
|
|
32
|
+
|
|
33
|
+
// Now delete all 5 characters
|
|
34
|
+
const deleteOp = remove(rope, 0, 5);
|
|
35
|
+
|
|
36
|
+
// FIXED: Now we get 1 deletion with length 5 instead of 5 separate deletions!
|
|
37
|
+
expect(deleteOp.deletions).toHaveLength(1);
|
|
38
|
+
expect(deleteOp.deletions[0].length).toBe(5);
|
|
39
|
+
expect(deleteOp.deletions[0].id).toMatch(/alice:\d+/);
|
|
40
|
+
|
|
41
|
+
// This is efficient - we're sending 1 deletion entry instead of 5
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should show that individual delete operations each have optimized deletions', () => {
|
|
45
|
+
const rope = create('alice');
|
|
46
|
+
|
|
47
|
+
// Type "Hello"
|
|
48
|
+
insert(rope, 0, 'H');
|
|
49
|
+
insert(rope, 1, 'e');
|
|
50
|
+
insert(rope, 2, 'l');
|
|
51
|
+
insert(rope, 3, 'l');
|
|
52
|
+
insert(rope, 4, 'o');
|
|
53
|
+
|
|
54
|
+
// Press backspace 5 times rapidly
|
|
55
|
+
const delete1 = remove(rope, 4, 1); // Delete 'o'
|
|
56
|
+
const delete2 = remove(rope, 3, 1); // Delete 'l'
|
|
57
|
+
const delete3 = remove(rope, 2, 1); // Delete 'l'
|
|
58
|
+
const delete4 = remove(rope, 1, 1); // Delete 'e'
|
|
59
|
+
const delete5 = remove(rope, 0, 1); // Delete 'H'
|
|
60
|
+
|
|
61
|
+
// Each delete creates 1 deletion entry (expected - only deleting 1 char each time)
|
|
62
|
+
expect(delete1.deletions).toHaveLength(1);
|
|
63
|
+
expect(delete2.deletions).toHaveLength(1);
|
|
64
|
+
expect(delete3.deletions).toHaveLength(1);
|
|
65
|
+
expect(delete4.deletions).toHaveLength(1);
|
|
66
|
+
expect(delete5.deletions).toHaveLength(1);
|
|
67
|
+
|
|
68
|
+
// Note: These are 5 separate delete OPERATIONS, each with 1 deletion
|
|
69
|
+
// Operation-level coalescence (in coalesceTextOperations) will merge these
|
|
70
|
+
// 5 operations into 1 operation with 5 deletions, then optimizeDeletions()
|
|
71
|
+
// will merge those 5 deletions into 1 deletion
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('Remote coalesced operations create efficient items', () => {
|
|
76
|
+
it('should show that applying coalesced InsertOp creates 1 item', () => {
|
|
77
|
+
const rope = create('bob');
|
|
78
|
+
|
|
79
|
+
// Simulate receiving a coalesced operation from Alice
|
|
80
|
+
const coalescedOp: InsertOp = {
|
|
81
|
+
id: 'alice:1',
|
|
82
|
+
content: 'Hello',
|
|
83
|
+
parentId: null,
|
|
84
|
+
seq: 1,
|
|
85
|
+
ts: 1000.0,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
apply(rope, coalescedOp);
|
|
89
|
+
|
|
90
|
+
expect(rope.toString()).toBe('Hello');
|
|
91
|
+
|
|
92
|
+
// Now delete all 5 characters
|
|
93
|
+
const deleteOp = remove(rope, 0, 5);
|
|
94
|
+
|
|
95
|
+
// GOOD: Only 1 deletion with length 5 (because it's a single item)
|
|
96
|
+
expect(deleteOp.deletions).toHaveLength(1);
|
|
97
|
+
expect(deleteOp.deletions[0].id).toBe('alice:1');
|
|
98
|
+
expect(deleteOp.deletions[0].length).toBe(5);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('Coalescence works at network layer, deletions optimized at rope layer', () => {
|
|
103
|
+
it('should show that remove() optimizes deletions even though rope has separate items', () => {
|
|
104
|
+
const rope = create('alice');
|
|
105
|
+
const ops: InsertOp[] = [];
|
|
106
|
+
|
|
107
|
+
// Type "Hello" and collect operations
|
|
108
|
+
ops.push(insert(rope, 0, 'H'));
|
|
109
|
+
ops.push(insert(rope, 1, 'e'));
|
|
110
|
+
ops.push(insert(rope, 2, 'l'));
|
|
111
|
+
ops.push(insert(rope, 3, 'l'));
|
|
112
|
+
ops.push(insert(rope, 4, 'o'));
|
|
113
|
+
|
|
114
|
+
// Coalesce operations for network transmission
|
|
115
|
+
const textOps: TextOperation[] = ops.map(op => ({ type: 'insert', op }));
|
|
116
|
+
const coalesced = coalesceTextOperations(textOps, { thresholdMs: 1000 });
|
|
117
|
+
|
|
118
|
+
// Coalescence merged 5 ops into 1 op
|
|
119
|
+
expect(coalesced).toHaveLength(1);
|
|
120
|
+
expect((coalesced[0] as any).op.content).toBe('Hello');
|
|
121
|
+
|
|
122
|
+
// The local rope still has 5 separate items (coalescence doesn't modify the rope)
|
|
123
|
+
// BUT remove() now optimizes the deletions array!
|
|
124
|
+
const deleteOp = remove(rope, 0, 5);
|
|
125
|
+
expect(deleteOp.deletions).toHaveLength(1); // FIXED: 1 deletion instead of 5!
|
|
126
|
+
expect(deleteOp.deletions[0].length).toBe(5);
|
|
127
|
+
|
|
128
|
+
// This is the fix: even though rope has 5 items, remove() merges consecutive
|
|
129
|
+
// deletions from the same agent into a single deletion entry
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('Verified correct behavior after fix', () => {
|
|
134
|
+
it('should merge consecutive deletions from same agent', () => {
|
|
135
|
+
const rope = create('alice');
|
|
136
|
+
|
|
137
|
+
// Type "Hello" - creates alice:0, alice:1, alice:2, alice:3, alice:4
|
|
138
|
+
insert(rope, 0, 'H');
|
|
139
|
+
insert(rope, 1, 'e');
|
|
140
|
+
insert(rope, 2, 'l');
|
|
141
|
+
insert(rope, 3, 'l');
|
|
142
|
+
insert(rope, 4, 'o');
|
|
143
|
+
|
|
144
|
+
// Delete all 5 characters
|
|
145
|
+
const deleteOp = remove(rope, 0, 5);
|
|
146
|
+
|
|
147
|
+
// FIXED: deletions = [{id: alice:0, len:5}] (merged consecutive IDs)
|
|
148
|
+
expect(deleteOp.deletions).toHaveLength(1);
|
|
149
|
+
expect(deleteOp.deletions[0].length).toBe(5);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should handle mixed local and remote items correctly', () => {
|
|
153
|
+
const rope = create('alice');
|
|
154
|
+
|
|
155
|
+
// Alice types "Hi"
|
|
156
|
+
insert(rope, 0, 'H');
|
|
157
|
+
insert(rope, 1, 'i');
|
|
158
|
+
|
|
159
|
+
// Bob types "ey" (received as coalesced op)
|
|
160
|
+
const bobOp: InsertOp = {
|
|
161
|
+
id: 'bob:1',
|
|
162
|
+
content: 'ey',
|
|
163
|
+
parentId: 'alice:1',
|
|
164
|
+
seq: 3,
|
|
165
|
+
ts: 1000.0,
|
|
166
|
+
};
|
|
167
|
+
apply(rope, bobOp);
|
|
168
|
+
|
|
169
|
+
// YATA ordering determines final text (could be "Hiey" or "eyHi" depending on timestamps)
|
|
170
|
+
const text = rope.toString();
|
|
171
|
+
expect(text.length).toBe(4);
|
|
172
|
+
expect(text).toContain('Hi');
|
|
173
|
+
expect(text).toContain('ey');
|
|
174
|
+
|
|
175
|
+
// Alice deletes all 4 characters
|
|
176
|
+
const deleteOp = remove(rope, 0, 4);
|
|
177
|
+
|
|
178
|
+
// FIXED: 2 deletions (can't merge across agents, but alice's chars are merged)
|
|
179
|
+
// - alice:0-1 merged → 1 deletion with length 2
|
|
180
|
+
// - bob:1 → 1 deletion with length 2
|
|
181
|
+
expect(deleteOp.deletions).toHaveLength(2);
|
|
182
|
+
|
|
183
|
+
// Both deletions should be multi-char
|
|
184
|
+
expect(deleteOp.deletions[0].length).toBe(2);
|
|
185
|
+
expect(deleteOp.deletions[1].length).toBe(2);
|
|
186
|
+
|
|
187
|
+
// One from alice, one from bob
|
|
188
|
+
const agents = deleteOp.deletions.map(d => d.id.split(':')[0]);
|
|
189
|
+
expect(agents).toContain('alice');
|
|
190
|
+
expect(agents).toContain('bob');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('Real-world scenario from bug report', () => {
|
|
195
|
+
it('should fix the "kkkkkkk" deletion bug', () => {
|
|
196
|
+
const rope = create('Alice');
|
|
197
|
+
|
|
198
|
+
// User types "kkkkkkk" quickly (7 characters)
|
|
199
|
+
for (let i = 0; i < 7; i++) {
|
|
200
|
+
insert(rope, i, 'k');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
expect(rope.toString()).toBe('kkkkkkk');
|
|
204
|
+
|
|
205
|
+
// User selects all and deletes
|
|
206
|
+
const deleteOp = remove(rope, 0, 7);
|
|
207
|
+
|
|
208
|
+
// FIXED: Creates 1 deletion entry with length 7
|
|
209
|
+
expect(deleteOp.deletions).toHaveLength(1);
|
|
210
|
+
expect(deleteOp.deletions[0].length).toBe(7);
|
|
211
|
+
expect(deleteOp.deletions[0].id).toMatch(/Alice:\d+/);
|
|
212
|
+
|
|
213
|
+
// This is now efficient - 1 deletion entry instead of 7
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should show delete operation coalescence creates efficient operations', () => {
|
|
217
|
+
const rope = create('Alice');
|
|
218
|
+
|
|
219
|
+
// Type "kkkkkkk"
|
|
220
|
+
for (let i = 0; i < 7; i++) {
|
|
221
|
+
insert(rope, i, 'k');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Press backspace 7 times rapidly
|
|
225
|
+
const deleteOps: TextOperation[] = [];
|
|
226
|
+
for (let i = 6; i >= 0; i--) {
|
|
227
|
+
const op = remove(rope, i, 1);
|
|
228
|
+
deleteOps.push({ type: 'delete', op });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Coalesce delete operations
|
|
232
|
+
const coalesced = coalesceTextOperations(deleteOps, { thresholdMs: 1000 });
|
|
233
|
+
|
|
234
|
+
// Operation-level coalescence works: 7 ops → 1 op
|
|
235
|
+
expect(coalesced).toHaveLength(1);
|
|
236
|
+
expect(coalesced[0].type).toBe('delete');
|
|
237
|
+
|
|
238
|
+
// The deletions array should already be efficient from remove() optimization
|
|
239
|
+
// Each individual remove() creates 1 deletion (only deleting 1 char)
|
|
240
|
+
// When coalesceTextOperations merges the 7 ops, it combines the deletions arrays
|
|
241
|
+
const coalescedOp = (coalesced[0] as any).op;
|
|
242
|
+
expect(coalescedOp.deletions).toHaveLength(7);
|
|
243
|
+
|
|
244
|
+
// Note: These 7 deletions are NOT consecutive because we deleted backwards (6→0)
|
|
245
|
+
// So they cannot be merged. If we add optimizeDeletions() to coalesceTextOperations,
|
|
246
|
+
// it would merge them if they were consecutive.
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|