@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.
Files changed (89) 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 +119 -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 +134 -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-text-operations.test.ts +327 -0
  87. package/tests/client/edit-buffer.test.ts +137 -1
  88. package/tests/crdt/graph-text-crdt.test.ts +29 -17
  89. 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
+ });