@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.
Files changed (90) hide show
  1. package/CLAUDE.md +29 -0
  2. package/COALESCE_FIX_VERIFICATION.md +81 -0
  3. package/REFACTORING_NOTES.md +229 -0
  4. package/dist/client/EditBuffer.d.ts +13 -1
  5. package/dist/client/EditBuffer.d.ts.map +1 -1
  6. package/dist/client/EditBuffer.js +47 -3
  7. package/dist/client/EditBuffer.js.map +1 -1
  8. package/dist/client/actions.d.ts +5 -1
  9. package/dist/client/actions.d.ts.map +1 -1
  10. package/dist/client/actions.js +12 -9
  11. package/dist/client/actions.js.map +1 -1
  12. package/dist/client/coalesceGraphOps.d.ts +34 -0
  13. package/dist/client/coalesceGraphOps.d.ts.map +1 -0
  14. package/dist/client/coalesceGraphOps.js +35 -0
  15. package/dist/client/coalesceGraphOps.js.map +1 -0
  16. package/dist/client/coalesceTextOperations.d.ts +42 -0
  17. package/dist/client/coalesceTextOperations.d.ts.map +1 -0
  18. package/dist/client/coalesceTextOperations.js +158 -0
  19. package/dist/client/coalesceTextOperations.js.map +1 -0
  20. package/dist/client/coalescence/index.d.ts +9 -0
  21. package/dist/client/coalescence/index.d.ts.map +1 -0
  22. package/dist/client/coalescence/index.js +9 -0
  23. package/dist/client/coalescence/index.js.map +1 -0
  24. package/dist/client/coalescence/registry.d.ts +48 -0
  25. package/dist/client/coalescence/registry.d.ts.map +1 -0
  26. package/dist/client/coalescence/registry.js +95 -0
  27. package/dist/client/coalescence/registry.js.map +1 -0
  28. package/dist/client/coalescence/textDeletes.d.ts +38 -0
  29. package/dist/client/coalescence/textDeletes.d.ts.map +1 -0
  30. package/dist/client/coalescence/textDeletes.js +68 -0
  31. package/dist/client/coalescence/textDeletes.js.map +1 -0
  32. package/dist/client/coalescence/textInserts.d.ts +45 -0
  33. package/dist/client/coalescence/textInserts.d.ts.map +1 -0
  34. package/dist/client/coalescence/textInserts.js +96 -0
  35. package/dist/client/coalescence/textInserts.js.map +1 -0
  36. package/dist/client/createGraph.d.ts.map +1 -1
  37. package/dist/client/createGraph.js +9 -2
  38. package/dist/client/createGraph.js.map +1 -1
  39. package/dist/client/createTextDocument.d.ts.map +1 -1
  40. package/dist/client/createTextDocument.js +2 -1
  41. package/dist/client/createTextDocument.js.map +1 -1
  42. package/dist/client/index.d.ts +4 -0
  43. package/dist/client/index.d.ts.map +1 -1
  44. package/dist/client/index.js +4 -0
  45. package/dist/client/index.js.map +1 -1
  46. package/dist/client/textActions.d.ts +1 -1
  47. package/dist/client/textActions.d.ts.map +1 -1
  48. package/dist/client/textActions.js +7 -2
  49. package/dist/client/textActions.js.map +1 -1
  50. package/dist/client/types.d.ts +3 -0
  51. package/dist/client/types.d.ts.map +1 -1
  52. package/dist/crdt/GraphTextCRDT.d.ts +0 -4
  53. package/dist/crdt/GraphTextCRDT.d.ts.map +1 -1
  54. package/dist/crdt/GraphTextCRDT.js +3 -0
  55. package/dist/crdt/GraphTextCRDT.js.map +1 -1
  56. package/dist/crdt/Rope.d.ts +27 -6
  57. package/dist/crdt/Rope.d.ts.map +1 -1
  58. package/dist/crdt/Rope.js +137 -69
  59. package/dist/crdt/Rope.js.map +1 -1
  60. package/dist/operations/OperationTypes.d.ts +10 -26
  61. package/dist/operations/OperationTypes.d.ts.map +1 -1
  62. package/dist/operations/apply/text.d.ts.map +1 -1
  63. package/dist/operations/apply/text.js +8 -16
  64. package/dist/operations/apply/text.js.map +1 -1
  65. package/examples/05-coalescence-usage.ts +189 -0
  66. package/package.json +1 -1
  67. package/src/client/EditBuffer.ts +51 -3
  68. package/src/client/actions.ts +13 -9
  69. package/src/client/coalesceGraphOps.ts +40 -0
  70. package/src/client/coalesceTextOperations.ts +172 -0
  71. package/src/client/coalescence/index.ts +18 -0
  72. package/src/client/coalescence/registry.ts +137 -0
  73. package/src/client/coalescence/textDeletes.ts +94 -0
  74. package/src/client/coalescence/textInserts.ts +128 -0
  75. package/src/client/createGraph.ts +11 -2
  76. package/src/client/createTextDocument.ts +2 -1
  77. package/src/client/index.ts +14 -0
  78. package/src/client/textActions.ts +9 -2
  79. package/src/client/types.ts +4 -1
  80. package/src/crdt/GraphTextCRDT.ts +0 -5
  81. package/src/crdt/Rope.ts +155 -79
  82. package/src/operations/OperationTypes.ts +10 -8
  83. package/src/operations/apply/text.ts +8 -20
  84. package/test-coalescence.ts +201 -0
  85. package/tests/client/actions.test.ts +156 -0
  86. package/tests/client/coalesce-graph-operations.test.ts +321 -0
  87. package/tests/client/coalesce-text-operations.test.ts +326 -0
  88. package/tests/client/edit-buffer.test.ts +137 -1
  89. package/tests/crdt/graph-text-crdt.test.ts +29 -17
  90. 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
+ });