@vuer-ai/vuer-rtc 0.4.2 → 0.5.1
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 +158 -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 +172 -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-graph-operations.test.ts +321 -0
- package/tests/client/coalesce-text-operations.test.ts +326 -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,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end tests for graph operation coalescence
|
|
3
|
+
*
|
|
4
|
+
* Verifies that text.insert and text.delete operations are properly coalesced
|
|
5
|
+
* when using createGraph with coalescingEnabled.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, jest } from '@jest/globals';
|
|
9
|
+
import { createGraph } from '../../src/client/createGraph.js';
|
|
10
|
+
import type { CRDTMessage, Operation } from '../../src/operations/OperationTypes.js';
|
|
11
|
+
|
|
12
|
+
describe('Graph Operation Coalescence (end-to-end)', () => {
|
|
13
|
+
describe('text.insert coalescence', () => {
|
|
14
|
+
it('should coalesce consecutive text.insert operations when coalescingEnabled', async () => {
|
|
15
|
+
const messages: CRDTMessage[] = [];
|
|
16
|
+
const store = createGraph({
|
|
17
|
+
sessionId: 'alice',
|
|
18
|
+
onSend: (msg) => messages.push(msg),
|
|
19
|
+
coalescingEnabled: true,
|
|
20
|
+
coalescingDelayMs: 50, // Short delay for testing
|
|
21
|
+
coalescingThresholdMs: 300, // 300ms threshold
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Create node first (insert as child of root '')
|
|
25
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
26
|
+
|
|
27
|
+
// Initialize a text property
|
|
28
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
29
|
+
|
|
30
|
+
// Simulate typing "Hello" quickly (within 300ms)
|
|
31
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'H' });
|
|
32
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'e' });
|
|
33
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 2, value: 'l' });
|
|
34
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 3, value: 'l' });
|
|
35
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 4, value: 'o' });
|
|
36
|
+
|
|
37
|
+
// Wait for coalescing timer to fire
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
39
|
+
|
|
40
|
+
// Should have sent ONE coalesced message (init + coalesced inserts)
|
|
41
|
+
expect(messages.length).toBe(1);
|
|
42
|
+
|
|
43
|
+
// The message should contain coalesced text.insert operations
|
|
44
|
+
const msg = messages[0];
|
|
45
|
+
const textInserts = msg.ops.filter(op => op.otype === 'text.insert');
|
|
46
|
+
|
|
47
|
+
// All 5 inserts should be coalesced into 1 operation
|
|
48
|
+
expect(textInserts.length).toBeLessThan(5);
|
|
49
|
+
|
|
50
|
+
// The coalesced operation should have content "Hello"
|
|
51
|
+
const firstInsert = textInserts[0];
|
|
52
|
+
expect(firstInsert.content).toBe('Hello');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should NOT coalesce when coalescingEnabled is false', async () => {
|
|
56
|
+
const messages: CRDTMessage[] = [];
|
|
57
|
+
const store = createGraph({
|
|
58
|
+
sessionId: 'alice',
|
|
59
|
+
onSend: (msg) => messages.push(msg),
|
|
60
|
+
coalescingEnabled: false, // Disabled
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
64
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
65
|
+
|
|
66
|
+
// Type "Hi" and commit immediately
|
|
67
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'H' });
|
|
68
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'i' });
|
|
69
|
+
store.commit('typing');
|
|
70
|
+
|
|
71
|
+
// Should send ONE message with init and 2 separate insert operations (not coalesced)
|
|
72
|
+
expect(messages.length).toBe(1);
|
|
73
|
+
const textInserts = messages[0].ops.filter(op => op.otype === 'text.insert');
|
|
74
|
+
|
|
75
|
+
// Should have 2 separate operations
|
|
76
|
+
expect(textInserts.length).toBe(2);
|
|
77
|
+
expect(textInserts[0].content).toBe('H');
|
|
78
|
+
expect(textInserts[1].content).toBe('i');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should respect time threshold and create separate operations for slow typing', async () => {
|
|
82
|
+
const messages: CRDTMessage[] = [];
|
|
83
|
+
const store = createGraph({
|
|
84
|
+
sessionId: 'alice',
|
|
85
|
+
onSend: (msg) => messages.push(msg),
|
|
86
|
+
coalescingEnabled: true,
|
|
87
|
+
coalescingDelayMs: 50,
|
|
88
|
+
coalescingThresholdMs: 100, // 100ms threshold
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
92
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
93
|
+
|
|
94
|
+
// Type "H" and "e" quickly
|
|
95
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'H' });
|
|
96
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'e' });
|
|
97
|
+
|
|
98
|
+
// Wait for first batch to be sent
|
|
99
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
100
|
+
|
|
101
|
+
// Type "l" and "l" quickly (after threshold)
|
|
102
|
+
await new Promise(resolve => setTimeout(resolve, 150)); // Exceed threshold
|
|
103
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 2, value: 'l' });
|
|
104
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 3, value: 'l' });
|
|
105
|
+
|
|
106
|
+
// Wait for second batch
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
108
|
+
|
|
109
|
+
// Should have sent TWO messages: one with "He", one with "ll"
|
|
110
|
+
expect(messages.length).toBe(2);
|
|
111
|
+
|
|
112
|
+
const firstInserts = messages[0].ops.filter(op => op.otype === 'text.insert');
|
|
113
|
+
const secondInserts = messages[1].ops.filter(op => op.otype === 'text.insert');
|
|
114
|
+
|
|
115
|
+
expect(firstInserts.length).toBe(1);
|
|
116
|
+
expect(firstInserts[0].content).toBe('He');
|
|
117
|
+
|
|
118
|
+
expect(secondInserts.length).toBe(1);
|
|
119
|
+
expect(secondInserts[0].content).toBe('ll');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('text.delete coalescence', () => {
|
|
124
|
+
it('should coalesce consecutive text.delete operations', async () => {
|
|
125
|
+
const messages: CRDTMessage[] = [];
|
|
126
|
+
const store = createGraph({
|
|
127
|
+
sessionId: 'alice',
|
|
128
|
+
onSend: (msg) => messages.push(msg),
|
|
129
|
+
coalescingEnabled: true,
|
|
130
|
+
coalescingDelayMs: 50,
|
|
131
|
+
coalescingThresholdMs: 300,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Create node and initialize with some text
|
|
135
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
136
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: 'Hello World' });
|
|
137
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
138
|
+
messages.length = 0; // Clear init message
|
|
139
|
+
|
|
140
|
+
// Delete "World" one character at a time (positions 6-10)
|
|
141
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 6, length: 1 }); // Delete 'W'
|
|
142
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 6, length: 1 }); // Delete 'o'
|
|
143
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 6, length: 1 }); // Delete 'r'
|
|
144
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 6, length: 1 }); // Delete 'l'
|
|
145
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 6, length: 1 }); // Delete 'd'
|
|
146
|
+
|
|
147
|
+
// Wait for coalescing timer
|
|
148
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
149
|
+
|
|
150
|
+
// Should have ONE coalesced delete message
|
|
151
|
+
expect(messages.length).toBe(1);
|
|
152
|
+
|
|
153
|
+
const deleteOps = messages[0].ops.filter(op => op.otype === 'text.delete');
|
|
154
|
+
|
|
155
|
+
// Should be coalesced into fewer operations
|
|
156
|
+
expect(deleteOps.length).toBeLessThan(5);
|
|
157
|
+
|
|
158
|
+
// If coalesced into 1 operation, it should have a deletions array
|
|
159
|
+
if (deleteOps.length === 1) {
|
|
160
|
+
const firstDelete = deleteOps[0] as any;
|
|
161
|
+
expect(firstDelete.deletions).toBeDefined();
|
|
162
|
+
expect(firstDelete.deletions.length).toBeGreaterThanOrEqual(1);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should handle mixed insert and delete operations', async () => {
|
|
167
|
+
const messages: CRDTMessage[] = [];
|
|
168
|
+
const store = createGraph({
|
|
169
|
+
sessionId: 'alice',
|
|
170
|
+
onSend: (msg) => messages.push(msg),
|
|
171
|
+
coalescingEnabled: true,
|
|
172
|
+
coalescingDelayMs: 50,
|
|
173
|
+
coalescingThresholdMs: 300,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
177
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
178
|
+
|
|
179
|
+
// Type "Hello", then delete "lo", then type "p"
|
|
180
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'H' });
|
|
181
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'e' });
|
|
182
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 2, value: 'l' });
|
|
183
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 3, value: 'l' });
|
|
184
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 4, value: 'o' });
|
|
185
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 3, length: 1 }); // Delete 'l'
|
|
186
|
+
store.edit({ otype: 'text.delete', key: 'doc', path: 'content', position: 3, length: 1 }); // Delete 'o'
|
|
187
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 3, value: 'p' });
|
|
188
|
+
|
|
189
|
+
// Wait for coalescing timer
|
|
190
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
191
|
+
|
|
192
|
+
// Should have ONE message with coalesced operations
|
|
193
|
+
expect(messages.length).toBe(1);
|
|
194
|
+
|
|
195
|
+
const msg = messages[0];
|
|
196
|
+
const inserts = msg.ops.filter(op => op.otype === 'text.insert');
|
|
197
|
+
const deletes = msg.ops.filter(op => op.otype === 'text.delete');
|
|
198
|
+
|
|
199
|
+
// Should have some coalesced inserts and deletes
|
|
200
|
+
expect(inserts.length).toBeGreaterThan(0);
|
|
201
|
+
expect(deletes.length).toBeGreaterThan(0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('explicit commit with coalescence', () => {
|
|
206
|
+
it('should coalesce operations when explicitly committing with coalescingEnabled', () => {
|
|
207
|
+
const messages: CRDTMessage[] = [];
|
|
208
|
+
const store = createGraph({
|
|
209
|
+
sessionId: 'alice',
|
|
210
|
+
onSend: (msg) => messages.push(msg),
|
|
211
|
+
coalescingEnabled: true,
|
|
212
|
+
coalescingThresholdMs: 300,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
216
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
217
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'H' });
|
|
218
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'i' });
|
|
219
|
+
|
|
220
|
+
// Explicitly commit (should clear timer and coalesce immediately)
|
|
221
|
+
store.commit('typing');
|
|
222
|
+
|
|
223
|
+
// Should send ONE message immediately with coalesced operations
|
|
224
|
+
expect(messages.length).toBe(1);
|
|
225
|
+
|
|
226
|
+
const textInserts = messages[0].ops.filter(op => op.otype === 'text.insert');
|
|
227
|
+
expect(textInserts.length).toBe(1);
|
|
228
|
+
expect(textInserts[0].content).toBe('Hi');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('multi-document coalescence', () => {
|
|
233
|
+
it('should coalesce operations on different documents independently', async () => {
|
|
234
|
+
const messages: CRDTMessage[] = [];
|
|
235
|
+
const store = createGraph({
|
|
236
|
+
sessionId: 'alice',
|
|
237
|
+
onSend: (msg) => messages.push(msg),
|
|
238
|
+
coalescingEnabled: true,
|
|
239
|
+
coalescingDelayMs: 50,
|
|
240
|
+
coalescingThresholdMs: 300,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Edit doc1
|
|
244
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc1', tag: 'Doc', name: 'Document 1' } });
|
|
245
|
+
store.edit({ otype: 'text.init', key: 'doc1', path: 'content', value: '' });
|
|
246
|
+
store.edit({ otype: 'text.insert', key: 'doc1', path: 'content', position: 0, value: 'A' });
|
|
247
|
+
store.edit({ otype: 'text.insert', key: 'doc1', path: 'content', position: 1, value: 'B' });
|
|
248
|
+
|
|
249
|
+
// Edit doc2
|
|
250
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc2', tag: 'Doc', name: 'Document 2' } });
|
|
251
|
+
store.edit({ otype: 'text.init', key: 'doc2', path: 'content', value: '' });
|
|
252
|
+
store.edit({ otype: 'text.insert', key: 'doc2', path: 'content', position: 0, value: 'X' });
|
|
253
|
+
store.edit({ otype: 'text.insert', key: 'doc2', path: 'content', position: 1, value: 'Y' });
|
|
254
|
+
|
|
255
|
+
// Wait for coalescing
|
|
256
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
257
|
+
|
|
258
|
+
// Should send ONE message with operations from both docs
|
|
259
|
+
expect(messages.length).toBe(1);
|
|
260
|
+
|
|
261
|
+
const msg = messages[0];
|
|
262
|
+
const doc1Inserts = msg.ops.filter(op => op.otype === 'text.insert' && op.key === 'doc1');
|
|
263
|
+
const doc2Inserts = msg.ops.filter(op => op.otype === 'text.insert' && op.key === 'doc2');
|
|
264
|
+
|
|
265
|
+
// Each document's operations should be coalesced independently
|
|
266
|
+
expect(doc1Inserts.length).toBe(1);
|
|
267
|
+
expect(doc1Inserts[0].content).toBe('AB');
|
|
268
|
+
|
|
269
|
+
expect(doc2Inserts.length).toBe(1);
|
|
270
|
+
expect(doc2Inserts[0].content).toBe('XY');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('edge cases', () => {
|
|
275
|
+
it('should handle rapid commits without losing operations', async () => {
|
|
276
|
+
const messages: CRDTMessage[] = [];
|
|
277
|
+
const store = createGraph({
|
|
278
|
+
sessionId: 'alice',
|
|
279
|
+
onSend: (msg) => messages.push(msg),
|
|
280
|
+
coalescingEnabled: true,
|
|
281
|
+
coalescingDelayMs: 100,
|
|
282
|
+
coalescingThresholdMs: 300,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
store.edit({ otype: 'node.insert', key: '', path: 'children', value: { key: 'doc', tag: 'Doc', name: 'Document' } });
|
|
286
|
+
store.edit({ otype: 'text.init', key: 'doc', path: 'content', value: '' });
|
|
287
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 0, value: 'A' });
|
|
288
|
+
store.commit('first');
|
|
289
|
+
|
|
290
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 1, value: 'B' });
|
|
291
|
+
store.commit('second');
|
|
292
|
+
|
|
293
|
+
store.edit({ otype: 'text.insert', key: 'doc', path: 'content', position: 2, value: 'C' });
|
|
294
|
+
store.commit('third');
|
|
295
|
+
|
|
296
|
+
// Should have 3 messages (one per commit)
|
|
297
|
+
expect(messages.length).toBe(3);
|
|
298
|
+
|
|
299
|
+
// Each message should contain the expected operation
|
|
300
|
+
expect(messages[0].ops.some(op => op.otype === 'text.insert' && op.content === 'A')).toBe(true);
|
|
301
|
+
expect(messages[1].ops.some(op => op.otype === 'text.insert' && op.content === 'B')).toBe(true);
|
|
302
|
+
expect(messages[2].ops.some(op => op.otype === 'text.insert' && op.content === 'C')).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should handle empty edits gracefully', async () => {
|
|
306
|
+
const messages: CRDTMessage[] = [];
|
|
307
|
+
const store = createGraph({
|
|
308
|
+
sessionId: 'alice',
|
|
309
|
+
onSend: (msg) => messages.push(msg),
|
|
310
|
+
coalescingEnabled: true,
|
|
311
|
+
coalescingDelayMs: 50,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Wait for timer without any edits
|
|
315
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
316
|
+
|
|
317
|
+
// Should not send any messages
|
|
318
|
+
expect(messages.length).toBe(0);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|