@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
@@ -6,7 +6,7 @@ import { describe, it, expect } from '@jest/globals';
6
6
  import { readFileSync } from 'fs';
7
7
  import { join, dirname } from 'path';
8
8
  import { fileURLToPath } from 'url';
9
- import { isAdditiveOp, mergeValues, EditBufferImpl } from '../../src/client/EditBuffer.js';
9
+ import { isAdditiveOp, mergeValues, EditBufferImpl, coalesceTextOps } from '../../src/client/EditBuffer.js';
10
10
  import type { Operation } from '../../src/operations/OperationTypes.js';
11
11
 
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -114,4 +114,140 @@ describe('EditBuffer', () => {
114
114
  expect(buffer.getOps()).toHaveLength(0);
115
115
  });
116
116
  });
117
+
118
+ describe('coalesceTextOps', () => {
119
+ it('should NOT coalesce text inserts with CRDT metadata', () => {
120
+ const ops: Operation[] = [
121
+ {
122
+ otype: 'text.insert',
123
+ key: 'text-doc',
124
+ path: 'content',
125
+ position: 0,
126
+ value: 'a',
127
+ id: { agent: 'session-1', seq: 1 },
128
+ content: 'a',
129
+ parentId: null,
130
+ seq: 1,
131
+ } as any,
132
+ {
133
+ otype: 'text.insert',
134
+ key: 'text-doc',
135
+ path: 'content',
136
+ position: 1,
137
+ value: 'b',
138
+ id: { agent: 'session-1', seq: 2 },
139
+ content: 'b',
140
+ parentId: { agent: 'session-1', seq: 1 },
141
+ seq: 2,
142
+ } as any,
143
+ ];
144
+
145
+ const result = coalesceTextOps(ops);
146
+
147
+ // Should NOT coalesce - each operation keeps its CRDT metadata
148
+ expect(result).toHaveLength(2);
149
+ expect((result[0] as any).id).toEqual({ agent: 'session-1', seq: 1 });
150
+ expect((result[0] as any).value).toBe('a');
151
+ expect((result[1] as any).id).toEqual({ agent: 'session-1', seq: 2 });
152
+ expect((result[1] as any).value).toBe('b');
153
+ });
154
+
155
+ it('should coalesce position-based text inserts without CRDT metadata', () => {
156
+ const ops: Operation[] = [
157
+ {
158
+ otype: 'text.insert',
159
+ key: 'text-doc',
160
+ path: 'content',
161
+ position: 0,
162
+ value: 'a',
163
+ } as any,
164
+ {
165
+ otype: 'text.insert',
166
+ key: 'text-doc',
167
+ path: 'content',
168
+ position: 1,
169
+ value: 'b',
170
+ } as any,
171
+ ];
172
+
173
+ const result = coalesceTextOps(ops);
174
+
175
+ // Should coalesce position-based operations
176
+ expect(result).toHaveLength(1);
177
+ expect((result[0] as any).position).toBe(0);
178
+ expect((result[0] as any).value).toBe('ab');
179
+ expect((result[0] as any).id).toBeUndefined();
180
+ });
181
+
182
+ it('should NOT coalesce text deletes with CRDT metadata', () => {
183
+ const ops: Operation[] = [
184
+ {
185
+ otype: 'text.delete',
186
+ key: 'text-doc',
187
+ path: 'content',
188
+ position: 0,
189
+ length: 1,
190
+ deletions: [{ id: { agent: 'session-1', seq: 1 }, length: 1 }],
191
+ } as any,
192
+ {
193
+ otype: 'text.delete',
194
+ key: 'text-doc',
195
+ path: 'content',
196
+ position: 0,
197
+ length: 1,
198
+ deletions: [{ id: { agent: 'session-1', seq: 2 }, length: 1 }],
199
+ } as any,
200
+ ];
201
+
202
+ const result = coalesceTextOps(ops);
203
+
204
+ // Should NOT coalesce - each operation keeps its CRDT metadata
205
+ expect(result).toHaveLength(2);
206
+ expect((result[0] as any).deletions).toEqual([{ id: { agent: 'session-1', seq: 1 }, length: 1 }]);
207
+ expect((result[1] as any).deletions).toEqual([{ id: { agent: 'session-1', seq: 2 }, length: 1 }]);
208
+ });
209
+
210
+ it('should preserve CRDT metadata through mixed operations', () => {
211
+ const ops: Operation[] = [
212
+ {
213
+ otype: 'text.insert',
214
+ key: 'text-doc',
215
+ path: 'content',
216
+ position: 0,
217
+ value: 'hello',
218
+ } as any,
219
+ {
220
+ otype: 'text.insert',
221
+ key: 'text-doc',
222
+ path: 'content',
223
+ position: 5,
224
+ value: ' ',
225
+ id: { agent: 'session-1', seq: 1 },
226
+ content: ' ',
227
+ parentId: null,
228
+ seq: 1,
229
+ } as any,
230
+ {
231
+ otype: 'text.insert',
232
+ key: 'text-doc',
233
+ path: 'content',
234
+ position: 6,
235
+ value: 'world',
236
+ } as any,
237
+ ];
238
+
239
+ const result = coalesceTextOps(ops);
240
+
241
+ // First "hello" should be alone (position-based, coalesced)
242
+ // Second " " should be alone (has CRDT metadata)
243
+ // Third "world" should be alone (position-based but not adjacent to first)
244
+ expect(result).toHaveLength(3);
245
+ expect((result[0] as any).value).toBe('hello');
246
+ expect((result[0] as any).id).toBeUndefined();
247
+ expect((result[1] as any).value).toBe(' ');
248
+ expect((result[1] as any).id).toEqual({ agent: 'session-1', seq: 1 });
249
+ expect((result[2] as any).value).toBe('world');
250
+ expect((result[2] as any).id).toBeUndefined();
251
+ });
252
+ });
117
253
  });
@@ -96,10 +96,11 @@ describe('GraphTextCRDT', () => {
96
96
  it('should return InsertOp with CRDT metadata', () => {
97
97
  const op = crdt.insertLocal('node-1', 'content', 0, 'test');
98
98
  expect(op.id).toBeDefined();
99
- expect(op.id.agent).toBe('session-1');
100
- expect(op.id.seq).toBeDefined();
99
+ expect(typeof op.id).toBe('string');
100
+ expect(op.id).toMatch(/^session-1:/); // String format: "agent:seq"
101
101
  expect(op.content).toBe('test');
102
102
  expect(op.seq).toBeDefined();
103
+ expect(op.ts).toBeDefined();
103
104
  });
104
105
 
105
106
  it('should handle multiple sequential inserts', () => {
@@ -112,7 +113,7 @@ describe('GraphTextCRDT', () => {
112
113
  it('should auto-create rope if not initialized', () => {
113
114
  const op = crdt.insertLocal('node-2', 'title', 0, 'New Title');
114
115
  expect(crdt.getText('node-2', 'title')).toBe('New Title');
115
- expect(op.id.agent).toBe('session-1');
116
+ expect(op.id).toMatch(/^session-1:/);
116
117
  });
117
118
  });
118
119
 
@@ -216,10 +217,11 @@ describe('GraphTextCRDT', () => {
216
217
 
217
218
  it('should apply remote insert operation', () => {
218
219
  const op: InsertOp = {
219
- id: { agent: 'session-2', seq: 0 },
220
+ id: 'session-2:0',
220
221
  content: 'Hello',
221
222
  parentId: null,
222
223
  seq: 1,
224
+ ts: Date.now() / 1000,
223
225
  };
224
226
  crdt.applyInsert('node-1', 'content', op);
225
227
  expect(crdt.getText('node-1', 'content')).toBe('Hello');
@@ -227,18 +229,20 @@ describe('GraphTextCRDT', () => {
227
229
 
228
230
  it('should apply remote insert with parent', () => {
229
231
  const op1: InsertOp = {
230
- id: { agent: 'session-1', seq: 0 },
232
+ id: 'session-1:0',
231
233
  content: 'Hello',
232
234
  parentId: null,
233
235
  seq: 1,
236
+ ts: Date.now() / 1000,
234
237
  };
235
238
  crdt.applyInsert('node-1', 'content', op1);
236
239
 
237
240
  const op2: InsertOp = {
238
- id: { agent: 'session-2', seq: 0 },
241
+ id: 'session-2:0',
239
242
  content: ' World',
240
- parentId: { agent: 'session-1', seq: 4 },
243
+ parentId: 'session-1:4',
241
244
  seq: 2,
245
+ ts: Date.now() / 1000,
242
246
  };
243
247
  crdt.applyInsert('node-1', 'content', op2);
244
248
  expect(crdt.getText('node-1', 'content')).toBe('Hello World');
@@ -246,10 +250,11 @@ describe('GraphTextCRDT', () => {
246
250
 
247
251
  it('should auto-create rope if not initialized', () => {
248
252
  const op: InsertOp = {
249
- id: { agent: 'session-2', seq: 0 },
253
+ id: 'session-2:0',
250
254
  content: 'test',
251
255
  parentId: null,
252
256
  seq: 1,
257
+ ts: Date.now() / 1000,
253
258
  };
254
259
  crdt.applyInsert('node-2', 'title', op);
255
260
  expect(crdt.getText('node-2', 'title')).toBe('test');
@@ -259,10 +264,11 @@ describe('GraphTextCRDT', () => {
259
264
  const op1 = crdt.insertLocal('node-1', 'content', 0, 'A');
260
265
 
261
266
  const op2: InsertOp = {
262
- id: { agent: 'session-2', seq: 0 },
267
+ id: 'session-2:0',
263
268
  content: 'B',
264
269
  parentId: null,
265
270
  seq: 1,
271
+ ts: Date.now() / 1000,
266
272
  };
267
273
  crdt.applyInsert('node-1', 'content', op2);
268
274
 
@@ -301,7 +307,7 @@ describe('GraphTextCRDT', () => {
301
307
 
302
308
  it('should auto-create rope if not initialized', () => {
303
309
  const op: DeleteOp = {
304
- deletions: [{ id: { agent: 'session-1', seq: 0 }, length: 5 }],
310
+ deletions: [{ id: 'session-1:0', length: 5 }],
305
311
  };
306
312
  crdt.applyDelete('node-2', 'title', op);
307
313
  expect(crdt.getText('node-2', 'title')).toBe('');
@@ -311,7 +317,7 @@ describe('GraphTextCRDT', () => {
311
317
  const delOp = crdt.deleteLocal('node-1', 'content', 0, 5);
312
318
 
313
319
  const remoteOp: DeleteOp = {
314
- deletions: [{ id: { agent: 'session-1', seq: 5 }, length: 6 }],
320
+ deletions: [{ id: 'session-1:5', length: 6 }],
315
321
  };
316
322
  crdt.applyDelete('node-1', 'content', remoteOp);
317
323
 
@@ -336,10 +342,11 @@ describe('GraphTextCRDT', () => {
336
342
  deletions: [{ id: items[0].id, length: 5 }],
337
343
  },
338
344
  insert: {
339
- id: { agent: 'session-2', seq: 0 },
345
+ id: 'session-2:0',
340
346
  content: 'Hi',
341
347
  parentId: null,
342
348
  seq: 2,
349
+ ts: Date.now() / 1000,
343
350
  },
344
351
  };
345
352
  crdt.applyReplace('node-1', 'content', op);
@@ -355,10 +362,11 @@ describe('GraphTextCRDT', () => {
355
362
  deletions: [{ id: items[0].id, length: 6 }],
356
363
  },
357
364
  insert: {
358
- id: { agent: 'session-2', seq: 0 },
365
+ id: 'session-2:0',
359
366
  content: '',
360
367
  parentId: null,
361
368
  seq: 2,
369
+ ts: Date.now() / 1000,
362
370
  },
363
371
  };
364
372
  crdt.applyReplace('node-1', 'content', op);
@@ -369,10 +377,11 @@ describe('GraphTextCRDT', () => {
369
377
  const op: ReplaceOp = {
370
378
  delete: { deletions: [] },
371
379
  insert: {
372
- id: { agent: 'session-2', seq: 0 },
380
+ id: 'session-2:0',
373
381
  content: 'new',
374
382
  parentId: null,
375
383
  seq: 1,
384
+ ts: Date.now() / 1000,
376
385
  },
377
386
  };
378
387
  crdt.applyReplace('node-2', 'title', op);
@@ -562,10 +571,11 @@ describe('GraphTextCRDT', () => {
562
571
 
563
572
  // Initialize both from the same initial state
564
573
  const initOp: InsertOp = {
565
- id: { agent: 'init', seq: 0 },
574
+ id: 'init:0',
566
575
  content: 'Hello',
567
576
  parentId: null,
568
577
  seq: 1,
578
+ ts: Date.now() / 1000,
569
579
  };
570
580
  crdt1.applyInsert('doc-1', 'content', initOp);
571
581
  crdt2.applyInsert('doc-1', 'content', initOp);
@@ -590,10 +600,11 @@ describe('GraphTextCRDT', () => {
590
600
 
591
601
  // Initialize from same initial state
592
602
  const initOp: InsertOp = {
593
- id: { agent: 'init', seq: 0 },
603
+ id: 'init:0',
594
604
  content: 'ABCDEF',
595
605
  parentId: null,
596
606
  seq: 1,
607
+ ts: Date.now() / 1000,
597
608
  };
598
609
  crdt1.applyInsert('doc-1', 'content', initOp);
599
610
  crdt2.applyInsert('doc-1', 'content', initOp);
@@ -615,10 +626,11 @@ describe('GraphTextCRDT', () => {
615
626
 
616
627
  // Initialize from same initial state
617
628
  const initOp: InsertOp = {
618
- id: { agent: 'init', seq: 0 },
629
+ id: 'init:0',
619
630
  content: 'test',
620
631
  parentId: null,
621
632
  seq: 1,
633
+ ts: Date.now() / 1000,
622
634
  };
623
635
  crdt1.applyInsert('doc-1', 'content', initOp);
624
636
  crdt2.applyInsert('doc-1', 'content', initOp);
@@ -24,6 +24,7 @@ import {
24
24
  getStats,
25
25
  itemIdEquals,
26
26
  itemIdCompare,
27
+ parseItemId,
27
28
  toRaw,
28
29
  fromRaw,
29
30
  hydrateRope,
@@ -276,28 +277,28 @@ describe('TextRope', () => {
276
277
 
277
278
  describe('ItemId Utilities', () => {
278
279
  it('should compare equal ItemIds', () => {
279
- const id1: ItemId = { agent: 'a', seq: 1 };
280
- const id2: ItemId = { agent: 'a', seq: 1 };
280
+ const id1: ItemId = 'a:1';
281
+ const id2: ItemId = 'a:1';
281
282
  expect(itemIdEquals(id1, id2)).toBe(true);
282
283
  });
283
284
 
284
285
  it('should compare different ItemIds', () => {
285
- const id1: ItemId = { agent: 'a', seq: 1 };
286
- const id2: ItemId = { agent: 'a', seq: 2 };
286
+ const id1: ItemId = 'a:1';
287
+ const id2: ItemId = 'a:2';
287
288
  expect(itemIdEquals(id1, id2)).toBe(false);
288
289
  });
289
290
 
290
291
  it('should handle null ItemIds', () => {
291
- const id1: ItemId = { agent: 'a', seq: 1 };
292
+ const id1: ItemId = 'a:1';
292
293
  expect(itemIdEquals(null, null)).toBe(true);
293
294
  expect(itemIdEquals(id1, null)).toBe(false);
294
295
  expect(itemIdEquals(null, id1)).toBe(false);
295
296
  });
296
297
 
297
298
  it('should compare ItemIds for ordering', () => {
298
- const id1: ItemId = { agent: 'a', seq: 1 };
299
- const id2: ItemId = { agent: 'b', seq: 1 };
300
- const id3: ItemId = { agent: 'a', seq: 2 };
299
+ const id1: ItemId = 'a:1';
300
+ const id2: ItemId = 'b:1';
301
+ const id3: ItemId = 'a:2';
301
302
 
302
303
  expect(itemIdCompare(id1, id2)).toBeLessThan(0);
303
304
  expect(itemIdCompare(id2, id1)).toBeGreaterThan(0);
@@ -384,7 +385,7 @@ describe('TextRope', () => {
384
385
 
385
386
  it('should return -1 for non-existent ID', () => {
386
387
  const rope = create('agent-1');
387
- const index = findItemById(rope, { agent: 'unknown', seq: 999 });
388
+ const index = findItemById(rope, 'unknown:999');
388
389
  expect(index).toBe(-1);
389
390
  });
390
391
  });
@@ -1090,7 +1091,7 @@ describe('TextRope', () => {
1090
1091
  apply(rope1, ins2);
1091
1092
  apply(rope2, ins1);
1092
1093
  // Also need to sync the delete
1093
- const delOp = { deletions: [{ id: { agent: 'agent-1', seq: 2 }, length: 2 }] };
1094
+ const delOp = { deletions: [{ id: 'agent-1:2', length: 2 }] };
1094
1095
  applyDelete(rope2, delOp);
1095
1096
 
1096
1097
  expect(getText(rope1)).toBe(getText(rope2));
@@ -1305,7 +1306,8 @@ describe('TextRope', () => {
1305
1306
 
1306
1307
  // New inserts should have seq > maxSeq
1307
1308
  const newOp = insert(restored, 4, ' more');
1308
- expect(newOp.id.seq).toBeGreaterThan(expectedMaxSeq);
1309
+ const parsed = parseItemId(newOp.id);
1310
+ expect(parsed.seq).toBeGreaterThan(expectedMaxSeq);
1309
1311
  });
1310
1312
  });
1311
1313
  });