@vuer-ai/vuer-rtc 0.4.2 → 0.5.0
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/CLAUDE.md +29 -0
- package/COALESCE_FIX_VERIFICATION.md +81 -0
- package/REFACTORING_NOTES.md +229 -0
- package/dist/client/EditBuffer.d.ts +13 -1
- package/dist/client/EditBuffer.d.ts.map +1 -1
- package/dist/client/EditBuffer.js +47 -3
- package/dist/client/EditBuffer.js.map +1 -1
- package/dist/client/actions.d.ts +5 -1
- package/dist/client/actions.d.ts.map +1 -1
- package/dist/client/actions.js +12 -9
- package/dist/client/actions.js.map +1 -1
- package/dist/client/coalesceGraphOps.d.ts +34 -0
- package/dist/client/coalesceGraphOps.d.ts.map +1 -0
- package/dist/client/coalesceGraphOps.js +35 -0
- package/dist/client/coalesceGraphOps.js.map +1 -0
- package/dist/client/coalesceTextOperations.d.ts +42 -0
- package/dist/client/coalesceTextOperations.d.ts.map +1 -0
- package/dist/client/coalesceTextOperations.js +119 -0
- package/dist/client/coalesceTextOperations.js.map +1 -0
- package/dist/client/coalescence/index.d.ts +9 -0
- package/dist/client/coalescence/index.d.ts.map +1 -0
- package/dist/client/coalescence/index.js +9 -0
- package/dist/client/coalescence/index.js.map +1 -0
- package/dist/client/coalescence/registry.d.ts +48 -0
- package/dist/client/coalescence/registry.d.ts.map +1 -0
- package/dist/client/coalescence/registry.js +95 -0
- package/dist/client/coalescence/registry.js.map +1 -0
- package/dist/client/coalescence/textDeletes.d.ts +38 -0
- package/dist/client/coalescence/textDeletes.d.ts.map +1 -0
- package/dist/client/coalescence/textDeletes.js +68 -0
- package/dist/client/coalescence/textDeletes.js.map +1 -0
- package/dist/client/coalescence/textInserts.d.ts +45 -0
- package/dist/client/coalescence/textInserts.d.ts.map +1 -0
- package/dist/client/coalescence/textInserts.js +96 -0
- package/dist/client/coalescence/textInserts.js.map +1 -0
- package/dist/client/createGraph.d.ts.map +1 -1
- package/dist/client/createGraph.js +9 -2
- package/dist/client/createGraph.js.map +1 -1
- package/dist/client/createTextDocument.d.ts.map +1 -1
- package/dist/client/createTextDocument.js +2 -1
- package/dist/client/createTextDocument.js.map +1 -1
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/textActions.d.ts +1 -1
- package/dist/client/textActions.d.ts.map +1 -1
- package/dist/client/textActions.js +7 -2
- package/dist/client/textActions.js.map +1 -1
- package/dist/client/types.d.ts +3 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.d.ts +0 -4
- package/dist/crdt/GraphTextCRDT.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.js +3 -0
- package/dist/crdt/GraphTextCRDT.js.map +1 -1
- package/dist/crdt/Rope.d.ts +27 -6
- package/dist/crdt/Rope.d.ts.map +1 -1
- package/dist/crdt/Rope.js +137 -69
- package/dist/crdt/Rope.js.map +1 -1
- package/dist/operations/OperationTypes.d.ts +10 -26
- package/dist/operations/OperationTypes.d.ts.map +1 -1
- package/dist/operations/apply/text.d.ts.map +1 -1
- package/dist/operations/apply/text.js +8 -16
- package/dist/operations/apply/text.js.map +1 -1
- package/examples/05-coalescence-usage.ts +189 -0
- package/package.json +1 -1
- package/src/client/EditBuffer.ts +51 -3
- package/src/client/actions.ts +13 -9
- package/src/client/coalesceGraphOps.ts +40 -0
- package/src/client/coalesceTextOperations.ts +134 -0
- package/src/client/coalescence/index.ts +18 -0
- package/src/client/coalescence/registry.ts +137 -0
- package/src/client/coalescence/textDeletes.ts +94 -0
- package/src/client/coalescence/textInserts.ts +128 -0
- package/src/client/createGraph.ts +11 -2
- package/src/client/createTextDocument.ts +2 -1
- package/src/client/index.ts +14 -0
- package/src/client/textActions.ts +9 -2
- package/src/client/types.ts +4 -1
- package/src/crdt/GraphTextCRDT.ts +0 -5
- package/src/crdt/Rope.ts +155 -79
- package/src/operations/OperationTypes.ts +10 -8
- package/src/operations/apply/text.ts +8 -20
- package/test-coalescence.ts +201 -0
- package/tests/client/actions.test.ts +156 -0
- package/tests/client/coalesce-text-operations.test.ts +327 -0
- package/tests/client/edit-buffer.test.ts +137 -1
- package/tests/crdt/graph-text-crdt.test.ts +29 -17
- package/tests/crdt/rope.test.ts +13 -11
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { coalesceTextOperations } from './src/client/coalesceTextOperations.js';
|
|
2
|
+
import type { InsertOp } from './src/crdt/Rope.js';
|
|
3
|
+
import type { TextOperation } from './src/client/textTypes.js';
|
|
4
|
+
|
|
5
|
+
// Test: Typing "Hello" quickly should create ONE operation
|
|
6
|
+
function testHelloCoalescence() {
|
|
7
|
+
console.log('\n=== Test: Typing "Hello" ===\n');
|
|
8
|
+
|
|
9
|
+
const ops: TextOperation[] = [];
|
|
10
|
+
|
|
11
|
+
// Simulate typing "Hello" - each character creates a sequential operation
|
|
12
|
+
// Character 'H' at position 0
|
|
13
|
+
ops.push({
|
|
14
|
+
type: 'insert',
|
|
15
|
+
op: {
|
|
16
|
+
id: 'alice:1',
|
|
17
|
+
content: 'H',
|
|
18
|
+
parentId: null,
|
|
19
|
+
seq: 1,
|
|
20
|
+
ts: 1000.0,
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Character 'e' at position 1 (parent is 'H')
|
|
25
|
+
ops.push({
|
|
26
|
+
type: 'insert',
|
|
27
|
+
op: {
|
|
28
|
+
id: 'alice:2',
|
|
29
|
+
content: 'e',
|
|
30
|
+
parentId: 'alice:1',
|
|
31
|
+
seq: 2,
|
|
32
|
+
ts: 1000.1, // 100ms later
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Character 'l' at position 2 (parent is 'e')
|
|
37
|
+
ops.push({
|
|
38
|
+
type: 'insert',
|
|
39
|
+
op: {
|
|
40
|
+
id: 'alice:3',
|
|
41
|
+
content: 'l',
|
|
42
|
+
parentId: 'alice:2',
|
|
43
|
+
seq: 3,
|
|
44
|
+
ts: 1000.2, // 200ms later
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Character 'l' at position 3 (parent is first 'l')
|
|
49
|
+
ops.push({
|
|
50
|
+
type: 'insert',
|
|
51
|
+
op: {
|
|
52
|
+
id: 'alice:4',
|
|
53
|
+
content: 'l',
|
|
54
|
+
parentId: 'alice:3',
|
|
55
|
+
seq: 4,
|
|
56
|
+
ts: 1000.25, // 250ms later
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Character 'o' at position 4 (parent is second 'l')
|
|
61
|
+
ops.push({
|
|
62
|
+
type: 'insert',
|
|
63
|
+
op: {
|
|
64
|
+
id: 'alice:5',
|
|
65
|
+
content: 'o',
|
|
66
|
+
parentId: 'alice:4',
|
|
67
|
+
seq: 5,
|
|
68
|
+
ts: 1000.29, // 290ms later
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
console.log('Input operations:');
|
|
73
|
+
ops.forEach((op, i) => {
|
|
74
|
+
const insertOp = op.op as InsertOp;
|
|
75
|
+
console.log(` ${i + 1}. id=${insertOp.id}, content="${insertOp.content}", parentId=${insertOp.parentId}, ts=${insertOp.ts}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Coalesce with 300ms threshold
|
|
79
|
+
const coalesced = coalesceTextOperations(ops, { thresholdMs: 300 });
|
|
80
|
+
|
|
81
|
+
console.log('\nCoalesced operations:');
|
|
82
|
+
coalesced.forEach((op, i) => {
|
|
83
|
+
const insertOp = op.op as InsertOp;
|
|
84
|
+
console.log(` ${i + 1}. id=${insertOp.id}, content="${insertOp.content}", parentId=${insertOp.parentId}, _lastCharId=${(insertOp as any)._lastCharId}`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Verify results
|
|
88
|
+
const success = coalesced.length === 1 && coalesced[0].op.content === 'Hello';
|
|
89
|
+
console.log('\n' + (success ? '✅ SUCCESS' : '❌ FAILURE') + ': Expected 1 operation with content="Hello"');
|
|
90
|
+
console.log(` Got ${coalesced.length} operation(s) with content="${coalesced[0].op.content}"`);
|
|
91
|
+
|
|
92
|
+
if (success && (coalesced[0].op as any)._lastCharId) {
|
|
93
|
+
console.log(` _lastCharId correctly set to: ${(coalesced[0].op as any)._lastCharId}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return success;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Test: Typing "World" with longer delay should NOT coalesce
|
|
100
|
+
function testWorldNoCoalescence() {
|
|
101
|
+
console.log('\n=== Test: Typing "World" with delays > 300ms ===\n');
|
|
102
|
+
|
|
103
|
+
const ops: TextOperation[] = [];
|
|
104
|
+
|
|
105
|
+
ops.push({
|
|
106
|
+
type: 'insert',
|
|
107
|
+
op: {
|
|
108
|
+
id: 'alice:10',
|
|
109
|
+
content: 'W',
|
|
110
|
+
parentId: null,
|
|
111
|
+
seq: 10,
|
|
112
|
+
ts: 2000.0,
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 400ms delay - should NOT coalesce
|
|
117
|
+
ops.push({
|
|
118
|
+
type: 'insert',
|
|
119
|
+
op: {
|
|
120
|
+
id: 'alice:11',
|
|
121
|
+
content: 'o',
|
|
122
|
+
parentId: 'alice:10',
|
|
123
|
+
seq: 11,
|
|
124
|
+
ts: 2000.4,
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const coalesced = coalesceTextOperations(ops, { thresholdMs: 300 });
|
|
129
|
+
|
|
130
|
+
console.log('Input operations: 2 (400ms apart)');
|
|
131
|
+
console.log('Coalesced operations:', coalesced.length);
|
|
132
|
+
|
|
133
|
+
const success = coalesced.length === 2;
|
|
134
|
+
console.log('\n' + (success ? '✅ SUCCESS' : '❌ FAILURE') + ': Expected 2 separate operations (delay too long)');
|
|
135
|
+
|
|
136
|
+
return success;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Test: Broken YATA chain should NOT coalesce
|
|
140
|
+
function testBrokenChain() {
|
|
141
|
+
console.log('\n=== Test: Broken YATA chain (non-sequential parents) ===\n');
|
|
142
|
+
|
|
143
|
+
const ops: TextOperation[] = [];
|
|
144
|
+
|
|
145
|
+
ops.push({
|
|
146
|
+
type: 'insert',
|
|
147
|
+
op: {
|
|
148
|
+
id: 'alice:20',
|
|
149
|
+
content: 'A',
|
|
150
|
+
parentId: null,
|
|
151
|
+
seq: 20,
|
|
152
|
+
ts: 3000.0,
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
ops.push({
|
|
157
|
+
type: 'insert',
|
|
158
|
+
op: {
|
|
159
|
+
id: 'alice:21',
|
|
160
|
+
content: 'B',
|
|
161
|
+
parentId: 'alice:20',
|
|
162
|
+
seq: 21,
|
|
163
|
+
ts: 3000.1,
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// This operation's parent is NOT alice:21, breaking the chain
|
|
168
|
+
ops.push({
|
|
169
|
+
type: 'insert',
|
|
170
|
+
op: {
|
|
171
|
+
id: 'alice:22',
|
|
172
|
+
content: 'C',
|
|
173
|
+
parentId: 'alice:15', // Different parent!
|
|
174
|
+
seq: 22,
|
|
175
|
+
ts: 3000.2,
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const coalesced = coalesceTextOperations(ops, { thresholdMs: 300 });
|
|
180
|
+
|
|
181
|
+
console.log('Input operations: 3 (third has wrong parent)');
|
|
182
|
+
console.log('Coalesced operations:', coalesced.length);
|
|
183
|
+
|
|
184
|
+
const success = coalesced.length === 2; // First two should merge, third separate
|
|
185
|
+
console.log('\n' + (success ? '✅ SUCCESS' : '❌ FAILURE') + ': Expected 2 operations (AB merged, C separate)');
|
|
186
|
+
|
|
187
|
+
return success;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Run all tests
|
|
191
|
+
const results = [
|
|
192
|
+
testHelloCoalescence(),
|
|
193
|
+
testWorldNoCoalescence(),
|
|
194
|
+
testBrokenChain(),
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
console.log('\n=== SUMMARY ===');
|
|
198
|
+
console.log(`Passed: ${results.filter(r => r).length}/${results.length}`);
|
|
199
|
+
console.log(results.every(r => r) ? '✅ ALL TESTS PASSED' : '❌ SOME TESTS FAILED');
|
|
200
|
+
|
|
201
|
+
process.exit(results.every(r => r) ? 0 : 1);
|
|
@@ -795,4 +795,160 @@ describe('Client Actions', () => {
|
|
|
795
795
|
expect(getText(alice.graph.nodes['doc']?.content)).toBe('Hello');
|
|
796
796
|
});
|
|
797
797
|
});
|
|
798
|
+
|
|
799
|
+
describe('coalescence metadata preservation', () => {
|
|
800
|
+
it('should preserve CRDT metadata in journal after commit', () => {
|
|
801
|
+
// Create a text document
|
|
802
|
+
let s = createInitialState('test-session');
|
|
803
|
+
s = onEdit(s, {
|
|
804
|
+
key: '',
|
|
805
|
+
otype: 'node.insert',
|
|
806
|
+
path: 'children',
|
|
807
|
+
value: { key: 'doc', tag: 'Text' },
|
|
808
|
+
});
|
|
809
|
+
const { state: s1 } = commitEdits(s);
|
|
810
|
+
s = s1;
|
|
811
|
+
|
|
812
|
+
// Initialize text
|
|
813
|
+
s = onEdit(s, {
|
|
814
|
+
key: 'doc',
|
|
815
|
+
otype: 'text.init',
|
|
816
|
+
path: 'content',
|
|
817
|
+
value: '',
|
|
818
|
+
} as any);
|
|
819
|
+
const { state: s2 } = commitEdits(s);
|
|
820
|
+
s = s2;
|
|
821
|
+
|
|
822
|
+
// Type multiple characters rapidly (simulating coalescence scenario)
|
|
823
|
+
s = onEdit(s, {
|
|
824
|
+
key: 'doc',
|
|
825
|
+
otype: 'text.insert',
|
|
826
|
+
path: 'content',
|
|
827
|
+
position: 0,
|
|
828
|
+
value: 'a',
|
|
829
|
+
} as any);
|
|
830
|
+
|
|
831
|
+
s = onEdit(s, {
|
|
832
|
+
key: 'doc',
|
|
833
|
+
otype: 'text.insert',
|
|
834
|
+
path: 'content',
|
|
835
|
+
position: 1,
|
|
836
|
+
value: 'b',
|
|
837
|
+
} as any);
|
|
838
|
+
|
|
839
|
+
s = onEdit(s, {
|
|
840
|
+
key: 'doc',
|
|
841
|
+
otype: 'text.insert',
|
|
842
|
+
path: 'content',
|
|
843
|
+
position: 2,
|
|
844
|
+
value: 'c',
|
|
845
|
+
} as any);
|
|
846
|
+
|
|
847
|
+
// Commit - this triggers coalescence
|
|
848
|
+
const { state: s3, msg } = commitEdits(s);
|
|
849
|
+
s = s3;
|
|
850
|
+
|
|
851
|
+
// Verify message was created
|
|
852
|
+
expect(msg).not.toBeNull();
|
|
853
|
+
expect(msg!.ops).toHaveLength(3); // Each op should be separate (not coalesced due to CRDT metadata)
|
|
854
|
+
|
|
855
|
+
// Verify all operations in the message have CRDT metadata
|
|
856
|
+
for (const op of msg!.ops) {
|
|
857
|
+
if (op.otype === 'text.insert') {
|
|
858
|
+
expect((op as any).id).toBeDefined();
|
|
859
|
+
expect(typeof (op as any).id).toBe('string');
|
|
860
|
+
expect((op as any).id).toMatch(/:/); // String format: "agent:seq"
|
|
861
|
+
expect((op as any).content).toBeDefined();
|
|
862
|
+
expect((op as any).seq).toBeDefined();
|
|
863
|
+
expect((op as any).ts).toBeDefined();
|
|
864
|
+
// parentId can be null for first operation
|
|
865
|
+
expect((op as any).parentId !== undefined).toBe(true);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Verify journal has the operations with metadata
|
|
870
|
+
const journalEntry = s.journal[s.journal.length - 1];
|
|
871
|
+
expect(journalEntry.msg.ops).toHaveLength(3);
|
|
872
|
+
|
|
873
|
+
for (const op of journalEntry.msg.ops) {
|
|
874
|
+
if (op.otype === 'text.insert') {
|
|
875
|
+
expect((op as any).id).toBeDefined();
|
|
876
|
+
expect((op as any).content).toBeDefined();
|
|
877
|
+
expect((op as any).seq).toBeDefined();
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('should preserve metadata when receiving own message back from server', () => {
|
|
883
|
+
// This tests the full round-trip: send → server ACK → journal update
|
|
884
|
+
let s = createInitialState('test-session');
|
|
885
|
+
|
|
886
|
+
// Create text document
|
|
887
|
+
s = onEdit(s, {
|
|
888
|
+
key: '',
|
|
889
|
+
otype: 'node.insert',
|
|
890
|
+
path: 'children',
|
|
891
|
+
value: { key: 'doc', tag: 'Text' },
|
|
892
|
+
});
|
|
893
|
+
const { state: s1 } = commitEdits(s);
|
|
894
|
+
s = s1;
|
|
895
|
+
|
|
896
|
+
s = onEdit(s, {
|
|
897
|
+
key: 'doc',
|
|
898
|
+
otype: 'text.init',
|
|
899
|
+
path: 'content',
|
|
900
|
+
value: '',
|
|
901
|
+
} as any);
|
|
902
|
+
const { state: s2 } = commitEdits(s);
|
|
903
|
+
s = s2;
|
|
904
|
+
|
|
905
|
+
// Type two characters
|
|
906
|
+
s = onEdit(s, {
|
|
907
|
+
key: 'doc',
|
|
908
|
+
otype: 'text.insert',
|
|
909
|
+
path: 'content',
|
|
910
|
+
position: 0,
|
|
911
|
+
value: 'x',
|
|
912
|
+
} as any);
|
|
913
|
+
|
|
914
|
+
s = onEdit(s, {
|
|
915
|
+
key: 'doc',
|
|
916
|
+
otype: 'text.insert',
|
|
917
|
+
path: 'content',
|
|
918
|
+
position: 1,
|
|
919
|
+
value: 'y',
|
|
920
|
+
} as any);
|
|
921
|
+
|
|
922
|
+
// Commit
|
|
923
|
+
const { state: s3, msg } = commitEdits(s);
|
|
924
|
+
s = s3;
|
|
925
|
+
|
|
926
|
+
// Verify the message has metadata before it's sent
|
|
927
|
+
expect(msg).not.toBeNull();
|
|
928
|
+
const firstOp = msg!.ops[0];
|
|
929
|
+
if (firstOp.otype === 'text.insert') {
|
|
930
|
+
expect((firstOp as any).id).toBeDefined();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Simulate server ACK by receiving the same message back
|
|
934
|
+
// (in real system, server would broadcast this to all clients including sender)
|
|
935
|
+
const journalBefore = s.journal.length;
|
|
936
|
+
s = onRemoteMessage(s, msg!);
|
|
937
|
+
|
|
938
|
+
// Journal shouldn't grow (duplicate message)
|
|
939
|
+
expect(s.journal.length).toBe(journalBefore);
|
|
940
|
+
|
|
941
|
+
// But verify the existing journal entry still has metadata
|
|
942
|
+
const journalEntry = s.journal.find(e => e.msg.id === msg!.id);
|
|
943
|
+
expect(journalEntry).toBeDefined();
|
|
944
|
+
|
|
945
|
+
for (const op of journalEntry!.msg.ops) {
|
|
946
|
+
if (op.otype === 'text.insert') {
|
|
947
|
+
expect((op as any).id).toBeDefined();
|
|
948
|
+
expect((op as any).content).toBeDefined();
|
|
949
|
+
expect((op as any).seq).toBeDefined();
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
});
|
|
798
954
|
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for coalesceTextOperations - verifying the bug fix for multi-character coalescence
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from '@jest/globals';
|
|
6
|
+
import { coalesceTextOperations } from '../../src/client/coalesceTextOperations.js';
|
|
7
|
+
import type { TextOperation } from '../../src/client/textTypes.js';
|
|
8
|
+
import type { InsertOp } from '../../src/crdt/Rope.js';
|
|
9
|
+
|
|
10
|
+
describe('coalesceTextOperations', () => {
|
|
11
|
+
describe('multi-character coalescence (bug fix verification)', () => {
|
|
12
|
+
it('should coalesce ALL consecutive characters within threshold, not just pairs', () => {
|
|
13
|
+
// Simulate typing "Hello" quickly (within 300ms)
|
|
14
|
+
const ops: TextOperation[] = [
|
|
15
|
+
{
|
|
16
|
+
type: 'insert',
|
|
17
|
+
op: {
|
|
18
|
+
id: 'alice:1',
|
|
19
|
+
content: 'H',
|
|
20
|
+
parentId: null,
|
|
21
|
+
seq: 1,
|
|
22
|
+
ts: 1000.0,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
type: 'insert',
|
|
27
|
+
op: {
|
|
28
|
+
id: 'alice:2',
|
|
29
|
+
content: 'e',
|
|
30
|
+
parentId: 'alice:1', // Forms chain with previous
|
|
31
|
+
seq: 2,
|
|
32
|
+
ts: 1000.05, // 50ms later
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: 'insert',
|
|
37
|
+
op: {
|
|
38
|
+
id: 'alice:3',
|
|
39
|
+
content: 'l',
|
|
40
|
+
parentId: 'alice:2', // Forms chain with previous
|
|
41
|
+
seq: 3,
|
|
42
|
+
ts: 1000.1, // 100ms from start
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'insert',
|
|
47
|
+
op: {
|
|
48
|
+
id: 'alice:4',
|
|
49
|
+
content: 'l',
|
|
50
|
+
parentId: 'alice:3', // Forms chain with previous
|
|
51
|
+
seq: 4,
|
|
52
|
+
ts: 1000.15, // 150ms from start
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: 'insert',
|
|
57
|
+
op: {
|
|
58
|
+
id: 'alice:5',
|
|
59
|
+
content: 'o',
|
|
60
|
+
parentId: 'alice:4', // Forms chain with previous
|
|
61
|
+
seq: 5,
|
|
62
|
+
ts: 1000.2, // 200ms from start
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const result = coalesceTextOperations(ops, { thresholdMs: 300 });
|
|
68
|
+
|
|
69
|
+
// Should coalesce into ONE operation with content="Hello"
|
|
70
|
+
expect(result).toHaveLength(1);
|
|
71
|
+
expect(result[0].type).toBe('insert');
|
|
72
|
+
expect((result[0] as any).op.content).toBe('Hello');
|
|
73
|
+
expect((result[0] as any).op.id).toBe('alice:1'); // Keep first ID
|
|
74
|
+
expect((result[0] as any).op.parentId).toBe(null); // Keep first parentId
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should respect time threshold and create separate operations for slow typing', () => {
|
|
78
|
+
// Simulate typing "He" quickly, then pause, then "llo" quickly
|
|
79
|
+
const ops: TextOperation[] = [
|
|
80
|
+
{
|
|
81
|
+
type: 'insert',
|
|
82
|
+
op: {
|
|
83
|
+
id: 'alice:1',
|
|
84
|
+
content: 'H',
|
|
85
|
+
parentId: null,
|
|
86
|
+
seq: 1,
|
|
87
|
+
ts: 1000.0,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: 'insert',
|
|
92
|
+
op: {
|
|
93
|
+
id: 'alice:2',
|
|
94
|
+
content: 'e',
|
|
95
|
+
parentId: 'alice:1',
|
|
96
|
+
seq: 2,
|
|
97
|
+
ts: 1000.05, // 50ms later - within threshold
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: 'insert',
|
|
102
|
+
op: {
|
|
103
|
+
id: 'alice:3',
|
|
104
|
+
content: 'l',
|
|
105
|
+
parentId: 'alice:2',
|
|
106
|
+
seq: 3,
|
|
107
|
+
ts: 1000.5, // 450ms later - EXCEEDS threshold
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: 'insert',
|
|
112
|
+
op: {
|
|
113
|
+
id: 'alice:4',
|
|
114
|
+
content: 'l',
|
|
115
|
+
parentId: 'alice:3',
|
|
116
|
+
seq: 4,
|
|
117
|
+
ts: 1000.55, // 50ms after 'l'
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
type: 'insert',
|
|
122
|
+
op: {
|
|
123
|
+
id: 'alice:5',
|
|
124
|
+
content: 'o',
|
|
125
|
+
parentId: 'alice:4',
|
|
126
|
+
seq: 5,
|
|
127
|
+
ts: 1000.6, // 50ms after second 'l'
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const result = coalesceTextOperations(ops, { thresholdMs: 300 });
|
|
133
|
+
|
|
134
|
+
// Should create TWO operations: "He" and "llo"
|
|
135
|
+
expect(result).toHaveLength(2);
|
|
136
|
+
expect((result[0] as any).op.content).toBe('He');
|
|
137
|
+
expect((result[0] as any).op.id).toBe('alice:1');
|
|
138
|
+
expect((result[1] as any).op.content).toBe('llo');
|
|
139
|
+
expect((result[1] as any).op.id).toBe('alice:3');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should handle single character operations (no coalescence)', () => {
|
|
143
|
+
const ops: TextOperation[] = [
|
|
144
|
+
{
|
|
145
|
+
type: 'insert',
|
|
146
|
+
op: {
|
|
147
|
+
id: 'alice:1',
|
|
148
|
+
content: 'H',
|
|
149
|
+
parentId: null,
|
|
150
|
+
seq: 1,
|
|
151
|
+
ts: 1000.0,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
type: 'insert',
|
|
156
|
+
op: {
|
|
157
|
+
id: 'alice:2',
|
|
158
|
+
content: 'e',
|
|
159
|
+
parentId: 'alice:1',
|
|
160
|
+
seq: 2,
|
|
161
|
+
ts: 1001.5, // 1500ms later - exceeds threshold
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
type: 'insert',
|
|
166
|
+
op: {
|
|
167
|
+
id: 'alice:3',
|
|
168
|
+
content: 'l',
|
|
169
|
+
parentId: 'alice:2',
|
|
170
|
+
seq: 3,
|
|
171
|
+
ts: 1003.0, // 1500ms later - exceeds threshold
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
const result = coalesceTextOperations(ops, { thresholdMs: 300 });
|
|
177
|
+
|
|
178
|
+
// Should create THREE separate operations (no coalescence)
|
|
179
|
+
expect(result).toHaveLength(3);
|
|
180
|
+
expect((result[0] as any).op.content).toBe('H');
|
|
181
|
+
expect((result[1] as any).op.content).toBe('e');
|
|
182
|
+
expect((result[2] as any).op.content).toBe('l');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should handle non-sequential IDs (different agents)', () => {
|
|
186
|
+
const ops: TextOperation[] = [
|
|
187
|
+
{
|
|
188
|
+
type: 'insert',
|
|
189
|
+
op: {
|
|
190
|
+
id: 'alice:1',
|
|
191
|
+
content: 'H',
|
|
192
|
+
parentId: null,
|
|
193
|
+
seq: 1,
|
|
194
|
+
ts: 1000.0,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
type: 'insert',
|
|
199
|
+
op: {
|
|
200
|
+
id: 'bob:1', // Different agent
|
|
201
|
+
content: 'e',
|
|
202
|
+
parentId: 'alice:1',
|
|
203
|
+
seq: 2,
|
|
204
|
+
ts: 1000.05,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
const result = coalesceTextOperations(ops, { thresholdMs: 300 });
|
|
210
|
+
|
|
211
|
+
// Should NOT coalesce (different agents)
|
|
212
|
+
expect(result).toHaveLength(2);
|
|
213
|
+
expect((result[0] as any).op.content).toBe('H');
|
|
214
|
+
expect((result[1] as any).op.content).toBe('e');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should handle delete operations (flush pending inserts)', () => {
|
|
218
|
+
const ops: TextOperation[] = [
|
|
219
|
+
{
|
|
220
|
+
type: 'insert',
|
|
221
|
+
op: {
|
|
222
|
+
id: 'alice:1',
|
|
223
|
+
content: 'H',
|
|
224
|
+
parentId: null,
|
|
225
|
+
seq: 1,
|
|
226
|
+
ts: 1000.0,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
type: 'insert',
|
|
231
|
+
op: {
|
|
232
|
+
id: 'alice:2',
|
|
233
|
+
content: 'e',
|
|
234
|
+
parentId: 'alice:1',
|
|
235
|
+
seq: 2,
|
|
236
|
+
ts: 1000.05,
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
type: 'delete',
|
|
241
|
+
op: {
|
|
242
|
+
id: 'alice:3',
|
|
243
|
+
length: 1,
|
|
244
|
+
seq: 3,
|
|
245
|
+
ts: 1000.1,
|
|
246
|
+
},
|
|
247
|
+
} as any,
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
const result = coalesceTextOperations(ops, { thresholdMs: 300 });
|
|
251
|
+
|
|
252
|
+
// Should flush "He" and then add the delete
|
|
253
|
+
expect(result).toHaveLength(2);
|
|
254
|
+
expect(result[0].type).toBe('insert');
|
|
255
|
+
expect((result[0] as any).op.content).toBe('He');
|
|
256
|
+
expect(result[1].type).toBe('delete');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should verify _lastCharId metadata is tracked correctly', () => {
|
|
260
|
+
const ops: TextOperation[] = [
|
|
261
|
+
{
|
|
262
|
+
type: 'insert',
|
|
263
|
+
op: {
|
|
264
|
+
id: 'alice:1',
|
|
265
|
+
content: 'a',
|
|
266
|
+
parentId: null,
|
|
267
|
+
seq: 1,
|
|
268
|
+
ts: 1000.0,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
type: 'insert',
|
|
273
|
+
op: {
|
|
274
|
+
id: 'alice:2',
|
|
275
|
+
content: 'b',
|
|
276
|
+
parentId: 'alice:1',
|
|
277
|
+
seq: 2,
|
|
278
|
+
ts: 1000.05,
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
type: 'insert',
|
|
283
|
+
op: {
|
|
284
|
+
id: 'alice:3',
|
|
285
|
+
content: 'c',
|
|
286
|
+
parentId: 'alice:2', // This should match _lastCharId after first merge
|
|
287
|
+
seq: 3,
|
|
288
|
+
ts: 1000.1,
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
const result = coalesceTextOperations(ops, { thresholdMs: 300 });
|
|
294
|
+
|
|
295
|
+
// Should coalesce all three
|
|
296
|
+
expect(result).toHaveLength(1);
|
|
297
|
+
expect((result[0] as any).op.content).toBe('abc');
|
|
298
|
+
expect((result[0] as any).op._lastCharId).toBe('alice:3');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('edge cases', () => {
|
|
303
|
+
it('should handle empty operations array', () => {
|
|
304
|
+
const result = coalesceTextOperations([]);
|
|
305
|
+
expect(result).toHaveLength(0);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should handle single operation', () => {
|
|
309
|
+
const ops: TextOperation[] = [
|
|
310
|
+
{
|
|
311
|
+
type: 'insert',
|
|
312
|
+
op: {
|
|
313
|
+
id: 'alice:1',
|
|
314
|
+
content: 'H',
|
|
315
|
+
parentId: null,
|
|
316
|
+
seq: 1,
|
|
317
|
+
ts: 1000.0,
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
const result = coalesceTextOperations(ops);
|
|
323
|
+
expect(result).toHaveLength(1);
|
|
324
|
+
expect((result[0] as any).op.content).toBe('H');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|