@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
package/src/crdt/Rope.ts CHANGED
@@ -17,22 +17,24 @@ import {
17
17
  // Types
18
18
  // ============================================
19
19
 
20
- export interface ItemId {
21
- agent: string;
22
- seq: number;
23
- }
20
+ /**
21
+ * ItemId - String format for efficient comparison
22
+ * Format: "agent:seq" (e.g., "agent-1:42")
23
+ */
24
+ export type ItemId = string;
24
25
 
25
26
  export interface Item {
26
27
  id: ItemId;
27
28
  content: string;
28
29
  isDeleted: boolean;
29
30
  parentId: ItemId | null;
30
- seq: number;
31
+ seq: number; // Lamport timestamp for total ordering
32
+ ts: number; // Wall-clock time (seconds) for tie-breaking
31
33
  }
32
34
 
33
35
  interface SpanEntry {
34
- startSeq: number;
35
- endSeq: number;
36
+ startSeq: number; // Sequence number from ItemId
37
+ endSeq: number; // startSeq + content.length
36
38
  item: Item;
37
39
  }
38
40
 
@@ -88,6 +90,7 @@ export interface InsertOp {
88
90
  content: string;
89
91
  parentId: ItemId | null;
90
92
  seq: number;
93
+ ts: number; // Wall-clock time (seconds) for tie-breaking
91
94
  }
92
95
 
93
96
  export interface DeleteOp {
@@ -108,16 +111,39 @@ export interface ReplaceOp {
108
111
  // Utilities
109
112
  // ============================================
110
113
 
114
+ /**
115
+ * Compare two ItemIds for equality
116
+ */
111
117
  export function itemIdEquals(a: ItemId | null, b: ItemId | null): boolean {
112
- if (a === null && b === null) return true;
113
- if (a === null || b === null) return false;
114
- return a.agent === b.agent && a.seq === b.seq;
118
+ return a === b;
115
119
  }
116
120
 
121
+ /**
122
+ * Compare two ItemIds for ordering (primarily for agent ordering)
123
+ * For tie-breaking by ts, use seq + ts + id comparison
124
+ */
117
125
  export function itemIdCompare(a: ItemId, b: ItemId): number {
118
- if (a.agent < b.agent) return -1;
119
- if (a.agent > b.agent) return 1;
120
- return a.seq - b.seq;
126
+ if (a < b) return -1;
127
+ if (a > b) return 1;
128
+ return 0;
129
+ }
130
+
131
+ /**
132
+ * Parse an ItemId string into its components
133
+ */
134
+ export function parseItemId(id: ItemId): { agent: string; seq: number } {
135
+ const colonIdx = id.lastIndexOf(':');
136
+ return {
137
+ agent: id.slice(0, colonIdx),
138
+ seq: parseInt(id.slice(colonIdx + 1), 10),
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Create an ItemId from agent and seq
144
+ */
145
+ export function createItemId(agent: string, seq: number): ItemId {
146
+ return `${agent}:${seq}`;
121
147
  }
122
148
 
123
149
  // ============================================
@@ -137,13 +163,14 @@ function aiFind(entries: SpanEntry[], seq: number): number {
137
163
  }
138
164
 
139
165
  function aiAdd(index: Map<string, SpanEntry[]>, item: Item): void {
166
+ const parsed = parseItemId(item.id);
140
167
  const entry: SpanEntry = {
141
- startSeq: item.id.seq,
142
- endSeq: item.id.seq + item.content.length,
168
+ startSeq: parsed.seq,
169
+ endSeq: parsed.seq + item.content.length,
143
170
  item,
144
171
  };
145
- let arr = index.get(item.id.agent);
146
- if (!arr) { arr = []; index.set(item.id.agent, arr); }
172
+ let arr = index.get(parsed.agent);
173
+ if (!arr) { arr = []; index.set(parsed.agent, arr); }
147
174
  let lo = 0, hi = arr.length;
148
175
  while (lo < hi) {
149
176
  const mid = (lo + hi) >>> 1;
@@ -154,9 +181,10 @@ function aiAdd(index: Map<string, SpanEntry[]>, item: Item): void {
154
181
  }
155
182
 
156
183
  function aiRemove(index: Map<string, SpanEntry[]>, item: Item): void {
157
- const arr = index.get(item.id.agent);
184
+ const parsed = parseItemId(item.id);
185
+ const arr = index.get(parsed.agent);
158
186
  if (!arr) return;
159
- const ei = aiFind(arr, item.id.seq);
187
+ const ei = aiFind(arr, parsed.seq);
160
188
  if (ei !== -1 && arr[ei].item === item) {
161
189
  arr.splice(ei, 1);
162
190
  }
@@ -165,9 +193,10 @@ function aiRemove(index: Map<string, SpanEntry[]>, item: Item): void {
165
193
  function aiBuild(items: Item[]): Map<string, SpanEntry[]> {
166
194
  const index = new Map<string, SpanEntry[]>();
167
195
  for (const item of items) {
168
- let arr = index.get(item.id.agent);
169
- if (!arr) { arr = []; index.set(item.id.agent, arr); }
170
- arr.push({ startSeq: item.id.seq, endSeq: item.id.seq + item.content.length, item });
196
+ const parsed = parseItemId(item.id);
197
+ let arr = index.get(parsed.agent);
198
+ if (!arr) { arr = []; index.set(parsed.agent, arr); }
199
+ arr.push({ startSeq: parsed.seq, endSeq: parsed.seq + item.content.length, item });
171
200
  }
172
201
  for (const arr of index.values()) {
173
202
  if (arr.length > 1) arr.sort((a, b) => a.startSeq - b.startSeq);
@@ -201,12 +230,14 @@ export function getItems(rope: TextRope): Item[] {
201
230
 
202
231
  export function findItemById(rope: TextRope, id: ItemId | null): number {
203
232
  if (id === null) return -1;
204
- const entries = rope._agentIndex.get(id.agent);
233
+ const parsed = parseItemId(id);
234
+ const entries = rope._agentIndex.get(parsed.agent);
205
235
  if (!entries) return -1;
206
- const ei = aiFind(entries, id.seq);
236
+ const ei = aiFind(entries, parsed.seq);
207
237
  if (ei === -1) return -1;
208
238
  const item = entries[ei].item;
209
- if (item.id.seq !== id.seq) return -1;
239
+ const itemParsed = parseItemId(item.id);
240
+ if (itemParsed.seq !== parsed.seq) return -1;
210
241
  return computeOrdinal(rope._tree, item);
211
242
  }
212
243
 
@@ -235,21 +266,24 @@ export function getStats(rope: TextRope): {
235
266
 
236
267
  function splitItem(rope: TextRope, ordinal: number, offset: number): Item {
237
268
  const item = getAt(rope._tree, ordinal) as Item;
269
+ const itemParsed = parseItemId(item.id);
238
270
 
239
271
  const before: Item = {
240
- id: { agent: item.id.agent, seq: item.id.seq },
272
+ id: createItemId(itemParsed.agent, itemParsed.seq),
241
273
  content: item.content.slice(0, offset),
242
274
  isDeleted: item.isDeleted,
243
- parentId: item.parentId ? { agent: item.parentId.agent, seq: item.parentId.seq } : null,
275
+ parentId: item.parentId,
244
276
  seq: item.seq,
277
+ ts: item.ts,
245
278
  };
246
279
 
247
280
  const after: Item = {
248
- id: { agent: item.id.agent, seq: item.id.seq + offset },
281
+ id: createItemId(itemParsed.agent, itemParsed.seq + offset),
249
282
  content: item.content.slice(offset),
250
283
  isDeleted: item.isDeleted,
251
- parentId: { agent: item.id.agent, seq: item.id.seq + offset - 1 },
284
+ parentId: createItemId(itemParsed.agent, itemParsed.seq + offset - 1),
252
285
  seq: item.seq,
286
+ ts: item.ts,
253
287
  };
254
288
 
255
289
  splitAtOrdinal(rope._tree, ordinal, before, after);
@@ -257,8 +291,8 @@ function splitItem(rope: TextRope, ordinal: number, offset: number): Item {
257
291
  aiAdd(rope._agentIndex, before);
258
292
  aiAdd(rope._agentIndex, after);
259
293
 
260
- if (item.id.agent === rope.agentId) {
261
- const maxSeqInItem = item.id.seq + item.content.length - 1;
294
+ if (itemParsed.agent === rope.agentId) {
295
+ const maxSeqInItem = itemParsed.seq + item.content.length - 1;
262
296
  if (maxSeqInItem >= rope.clock) rope.clock = maxSeqInItem + 1;
263
297
  }
264
298
 
@@ -266,29 +300,33 @@ function splitItem(rope: TextRope, ordinal: number, offset: number): Item {
266
300
  }
267
301
 
268
302
  function containsItemId(rope: TextRope, id: ItemId): boolean {
269
- const entries = rope._agentIndex.get(id.agent);
303
+ const parsed = parseItemId(id);
304
+ const entries = rope._agentIndex.get(parsed.agent);
270
305
  if (!entries) return false;
271
- return aiFind(entries, id.seq) !== -1;
306
+ return aiFind(entries, parsed.seq) !== -1;
272
307
  }
273
308
 
274
309
  function findParentOrdinal(rope: TextRope, parentId: ItemId | null): number {
275
310
  if (parentId === null) return -1;
276
- const entries = rope._agentIndex.get(parentId.agent);
311
+ const parsed = parseItemId(parentId);
312
+ const entries = rope._agentIndex.get(parsed.agent);
277
313
  if (!entries) return -1;
278
- const ei = aiFind(entries, parentId.seq);
314
+ const ei = aiFind(entries, parsed.seq);
279
315
  if (ei === -1) return -1;
280
316
  return computeOrdinal(rope._tree, entries[ei].item);
281
317
  }
282
318
 
283
319
  function splitForParent(rope: TextRope, parentId: ItemId | null): number {
284
320
  if (parentId === null) return -1;
285
- const entries = rope._agentIndex.get(parentId.agent);
321
+ const parsedParent = parseItemId(parentId);
322
+ const entries = rope._agentIndex.get(parsedParent.agent);
286
323
  if (!entries) return -1;
287
- const ei = aiFind(entries, parentId.seq);
324
+ const ei = aiFind(entries, parsedParent.seq);
288
325
  if (ei === -1) return -1;
289
326
  const item = entries[ei].item;
290
327
  const ordinal = computeOrdinal(rope._tree, item);
291
- const splitOffset = parentId.seq - item.id.seq + 1;
328
+ const itemParsed = parseItemId(item.id);
329
+ const splitOffset = parsedParent.seq - itemParsed.seq + 1;
292
330
  if (splitOffset < item.content.length) {
293
331
  splitItem(rope, ordinal, splitOffset);
294
332
  }
@@ -309,7 +347,8 @@ function findInsertPosition(rope: TextRope, position: number): ItemId | null {
309
347
 
310
348
  const item = result.item as Item;
311
349
  const charOffset = Math.min(result.charOffset, item.content.length - 1);
312
- return { agent: item.id.agent, seq: item.id.seq + charOffset };
350
+ const itemParsed = parseItemId(item.id);
351
+ return createItemId(itemParsed.agent, itemParsed.seq + charOffset);
313
352
  }
314
353
 
315
354
  function findInsertPositionWithSplit(rope: TextRope, position: number): {
@@ -331,7 +370,8 @@ function findInsertPositionWithSplit(rope: TextRope, position: number): {
331
370
 
332
371
  const item = result.item as Item;
333
372
  const charOffset = Math.min(result.charOffset, item.content.length - 1);
334
- const parentId: ItemId = { agent: item.id.agent, seq: item.id.seq + charOffset };
373
+ const itemParsed = parseItemId(item.id);
374
+ const parentId: ItemId = createItemId(itemParsed.agent, itemParsed.seq + charOffset);
335
375
  const needsSplit = charOffset + 1 < item.content.length;
336
376
 
337
377
  return { parentId, parentOrdinal: result.ordinal, needsSplit, splitOffset: charOffset + 1 };
@@ -348,17 +388,32 @@ function integrate(rope: TextRope, newItem: Item, parentOrdinal: number): void {
348
388
  if (insertOrdinal < total) {
349
389
  for (const existing of iterateFrom(rope._tree, insertOrdinal)) {
350
390
  const existingItem = existing as Item;
391
+ // First, compare by Lamport timestamp (seq) - higher seq means happened later
351
392
  if (newItem.seq > existingItem.seq) break;
352
393
 
353
394
  const existingParentOrdinal = findParentOrdinal(rope, existingItem.parentId);
354
- if (
355
- existingParentOrdinal < parentOrdinal ||
356
- (existingParentOrdinal === parentOrdinal &&
357
- newItem.seq === existingItem.seq &&
358
- itemIdCompare(newItem.id, existingItem.id) < 0)
359
- ) {
395
+
396
+ // If existing item's parent is before our parent in the document, insert here
397
+ if (existingParentOrdinal < parentOrdinal) {
360
398
  break;
361
399
  }
400
+
401
+ // If same parent and same Lamport seq, use wall-clock time (ts) for tie-breaking
402
+ if (existingParentOrdinal === parentOrdinal && newItem.seq === existingItem.seq) {
403
+ // Use ts for tie-breaking (earlier ts wins - inserts first)
404
+ if (newItem.ts < existingItem.ts) {
405
+ break;
406
+ }
407
+ // If ts also equal (rare), fall back to ID comparison for determinism
408
+ if (newItem.ts === existingItem.ts && itemIdCompare(newItem.id, existingItem.id) < 0) {
409
+ break;
410
+ }
411
+ }
412
+
413
+ // If same parent but different seq, the YATA algorithm says:
414
+ // Insert before items with lower seq (that happened earlier)
415
+ // This is already handled by the first check above
416
+
362
417
  insertOrdinal++;
363
418
  }
364
419
  }
@@ -371,17 +426,19 @@ function _applyLocal(rope: TextRope, op: InsertOp, parentOrdinal: number): void
371
426
  if (op.seq > rope.maxSeq) rope.maxSeq = op.seq;
372
427
 
373
428
  const newItem: Item = {
374
- id: { agent: op.id.agent, seq: op.id.seq },
429
+ id: op.id,
375
430
  content: op.content,
376
431
  isDeleted: false,
377
- parentId: op.parentId ? { agent: op.parentId.agent, seq: op.parentId.seq } : null,
432
+ parentId: op.parentId,
378
433
  seq: op.seq,
434
+ ts: op.ts,
379
435
  };
380
436
 
381
437
  integrate(rope, newItem, parentOrdinal);
382
438
 
383
- if (op.id.agent === rope.agentId) {
384
- const maxSeqInItem = op.id.seq + op.content.length - 1;
439
+ const idParsed = parseItemId(op.id);
440
+ if (idParsed.agent === rope.agentId) {
441
+ const maxSeqInItem = idParsed.seq + op.content.length - 1;
385
442
  if (maxSeqInItem >= rope.clock) rope.clock = maxSeqInItem + 1;
386
443
  }
387
444
  }
@@ -415,10 +472,11 @@ export function insert(rope: TextRope, position: number, content: string, agentI
415
472
  const parentId = findInsertPosition(rope, position);
416
473
 
417
474
  const op: InsertOp = {
418
- id: { agent: rope.agentId, seq: rope.clock },
475
+ id: createItemId(rope.agentId, rope.clock),
419
476
  content,
420
477
  parentId,
421
478
  seq: rope.maxSeq + 1,
479
+ ts: Date.now() / 1000,
422
480
  };
423
481
 
424
482
  const parentOrdinal = splitForParent(rope, op.parentId);
@@ -436,10 +494,11 @@ export function insertWithSplit(rope: TextRope, position: number, content: strin
436
494
  }
437
495
 
438
496
  const op: InsertOp = {
439
- id: { agent: rope.agentId, seq: rope.clock },
497
+ id: createItemId(rope.agentId, rope.clock),
440
498
  content,
441
499
  parentId,
442
500
  seq: rope.maxSeq + 1,
501
+ ts: Date.now() / 1000,
443
502
  };
444
503
 
445
504
  _applyLocal(rope, op, parentOrdinal);
@@ -453,17 +512,19 @@ export function apply(rope: TextRope, op: InsertOp): void {
453
512
  const parentOrdinal = splitForParent(rope, op.parentId);
454
513
 
455
514
  const newItem: Item = {
456
- id: { agent: op.id.agent, seq: op.id.seq },
515
+ id: op.id,
457
516
  content: op.content,
458
517
  isDeleted: false,
459
- parentId: op.parentId ? { agent: op.parentId.agent, seq: op.parentId.seq } : null,
518
+ parentId: op.parentId,
460
519
  seq: op.seq,
520
+ ts: op.ts,
461
521
  };
462
522
 
463
523
  integrate(rope, newItem, parentOrdinal);
464
524
 
465
- if (op.id.agent === rope.agentId) {
466
- const maxSeqInItem = op.id.seq + op.content.length - 1;
525
+ const idParsed = parseItemId(op.id);
526
+ if (idParsed.agent === rope.agentId) {
527
+ const maxSeqInItem = idParsed.seq + op.content.length - 1;
467
528
  if (maxSeqInItem >= rope.clock) rope.clock = maxSeqInItem + 1;
468
529
  }
469
530
  }
@@ -504,7 +565,7 @@ export function remove(rope: TextRope, position: number, length: number): Delete
504
565
  // Delete the item
505
566
  item.isDeleted = true;
506
567
  recalcCountsFor(rope._tree, item);
507
- deletions.push({ id: { ...item.id }, length: item.content.length });
568
+ deletions.push({ id: item.id, length: item.content.length });
508
569
  remaining -= toDelete;
509
570
  }
510
571
 
@@ -517,9 +578,10 @@ export function applyDelete(rope: TextRope, op: DeleteOp): void {
517
578
  // Group and merge deletion ranges by agent
518
579
  const byAgent = new Map<string, { start: number; end: number }[]>();
519
580
  for (const del of op.deletions) {
520
- let ranges = byAgent.get(del.id.agent);
521
- if (!ranges) { ranges = []; byAgent.set(del.id.agent, ranges); }
522
- ranges.push({ start: del.id.seq, end: del.id.seq + del.length });
581
+ const parsed = parseItemId(del.id);
582
+ let ranges = byAgent.get(parsed.agent);
583
+ if (!ranges) { ranges = []; byAgent.set(parsed.agent, ranges); }
584
+ ranges.push({ start: parsed.seq, end: parsed.seq + del.length });
523
585
  }
524
586
 
525
587
  for (const [, ranges] of byAgent) {
@@ -564,7 +626,8 @@ function applyDeleteRange(rope: TextRope, agent: string, start: number, end: num
564
626
  }
565
627
 
566
628
  const item = entries[ei].item;
567
- const itemStart = item.id.seq;
629
+ const itemParsed = parseItemId(item.id);
630
+ const itemStart = itemParsed.seq;
568
631
  const itemEnd = itemStart + item.content.length;
569
632
  const overlapStart = Math.max(itemStart, start);
570
633
  const overlapEnd = Math.min(itemEnd, end);
@@ -608,9 +671,9 @@ export function replace(
608
671
  ): ReplaceOp {
609
672
  if (agentId !== undefined) switchAgent(rope, agentId);
610
673
  const deleteOp = deleteLength > 0 ? remove(rope, position, deleteLength) : { deletions: [] };
611
- const insertOp = insertText.length > 0
674
+ const insertOp: InsertOp = insertText.length > 0
612
675
  ? insertWithSplit(rope, position, insertText)
613
- : { id: { agent: rope.agentId, seq: rope.clock }, content: '', parentId: null, seq: rope.maxSeq };
676
+ : { id: createItemId(rope.agentId, rope.clock), content: '', parentId: null, seq: rope.maxSeq, ts: Date.now() / 1000 };
614
677
  return { delete: deleteOp, insert: insertOp };
615
678
  }
616
679
 
@@ -621,7 +684,9 @@ export function applyReplace(rope: TextRope, op: ReplaceOp): void {
621
684
  }
622
685
  }
623
686
 
624
- export function move(rope: TextRope, fromPosition: number, length: number, toPosition: number): MoveOp {
687
+ export function move(rope: TextRope, fromPosition: number, length: number, toPosition: number, agentId?: string): MoveOp {
688
+ if (agentId !== undefined) switchAgent(rope, agentId);
689
+
625
690
  // Extract content before deleting
626
691
  let content = '';
627
692
  let remaining = length;
@@ -668,6 +733,7 @@ export function merge(rope: TextRope, other: TextRope): void {
668
733
  content: item.content,
669
734
  parentId: item.parentId,
670
735
  seq: item.seq,
736
+ ts: item.ts,
671
737
  });
672
738
  if (item.isDeleted) {
673
739
  applyDelete(rope, { deletions: [{ id: item.id, length: item.content.length }] });
@@ -688,9 +754,16 @@ export function compact(rope: TextRope): TextRope {
688
754
  const item = treeItem as Item;
689
755
  if (item.isDeleted) continue;
690
756
  const newItem: Item = { ...item };
691
- if (prevItem && prevItem.id.agent === newItem.id.agent &&
692
- prevItem.id.seq + prevItem.content.length === newItem.id.seq) {
693
- prevItem.content += newItem.content;
757
+ if (prevItem) {
758
+ const prevParsed = parseItemId(prevItem.id);
759
+ const newParsed = parseItemId(newItem.id);
760
+ if (prevParsed.agent === newParsed.agent &&
761
+ prevParsed.seq + prevItem.content.length === newParsed.seq) {
762
+ prevItem.content += newItem.content;
763
+ } else {
764
+ visibleItems.push(newItem);
765
+ prevItem = newItem;
766
+ }
694
767
  } else {
695
768
  visibleItems.push(newItem);
696
769
  prevItem = newItem;
@@ -704,29 +777,30 @@ export function compact(rope: TextRope): TextRope {
704
777
  // Serialization
705
778
  // ============================================
706
779
 
707
- export type RawTextRope = [string, number, number, Array<[string, number, string, string | null, number | null]>];
780
+ export type RawTextRope = [string, number, number, Array<[string, string, string | null, number, number]>];
708
781
 
709
782
  export function toRaw(rope: TextRope): RawTextRope {
710
783
  const compacted = compact(rope);
711
784
  const items = flattenItems(compacted._tree) as Item[];
712
- const rawItems: Array<[string, number, string, string | null, number | null]> = items.map(item => [
713
- item.id.agent,
714
- item.id.seq,
785
+ const rawItems: Array<[string, string, string | null, number, number]> = items.map(item => [
786
+ item.id,
715
787
  item.content,
716
- item.parentId?.agent ?? null,
717
- item.parentId?.seq ?? null,
788
+ item.parentId,
789
+ item.seq,
790
+ item.ts,
718
791
  ]);
719
792
  return [rope.agentId, rope.clock, rope.maxSeq, rawItems];
720
793
  }
721
794
 
722
795
  export function fromRaw(raw: RawTextRope, newAgentId?: string): TextRope {
723
796
  const [agentId, clock, maxSeq, rawItems] = raw;
724
- const items: Item[] = rawItems.map(([agent, seq, content, parentAgent, parentSeq]) => ({
725
- id: { agent, seq },
797
+ const items: Item[] = rawItems.map(([id, content, parentId, seq, ts]) => ({
798
+ id,
726
799
  content,
727
800
  isDeleted: false,
728
- parentId: parentAgent !== null ? { agent: parentAgent, seq: parentSeq! } : null,
729
- seq: maxSeq,
801
+ parentId,
802
+ seq,
803
+ ts,
730
804
  }));
731
805
  return new TextRope(bulkLoad(items), aiBuild(items), newAgentId ?? agentId, newAgentId ? 0 : clock, maxSeq);
732
806
  }
@@ -780,11 +854,13 @@ function extractItemsFromSerializedTree(node: unknown): Item[] {
780
854
  const items = n.items as unknown[];
781
855
  if (!Array.isArray(items)) return [];
782
856
  return items.map((item: any) => ({
783
- id: { agent: item.id.agent, seq: item.id.seq },
857
+ id: typeof item.id === 'string' ? item.id : createItemId(item.id.agent, item.id.seq),
784
858
  content: item.content,
785
859
  isDeleted: item.isDeleted ?? false,
786
- parentId: item.parentId ? { agent: item.parentId.agent, seq: item.parentId.seq } : null,
860
+ parentId: item.parentId === null ? null :
861
+ (typeof item.parentId === 'string' ? item.parentId : createItemId(item.parentId.agent, item.parentId.seq)),
787
862
  seq: item.seq,
863
+ ts: item.ts ?? Date.now() / 1000,
788
864
  }));
789
865
  } else if (n.type === 'internal') {
790
866
  // Internal node: recursively collect from children
@@ -109,7 +109,7 @@ export interface TextInitOp extends BaseOp {
109
109
  /**
110
110
  * Insert text - supports two formats:
111
111
  * 1. Position-based (for local operations): { position, value }
112
- * 2. CRDT metadata (for network sync): { id, content, parentId, seq }
112
+ * 2. CRDT metadata (for network sync): { id, content, parentId, seq, ts }
113
113
  */
114
114
  export interface TextInsertOp extends BaseOp {
115
115
  key: string;
@@ -119,10 +119,11 @@ export interface TextInsertOp extends BaseOp {
119
119
  position?: number;
120
120
  value?: string;
121
121
  // CRDT metadata format (network sync)
122
- id?: { agent: string; seq: number };
122
+ id?: string; // ItemId string format: "agent:seq"
123
123
  content?: string;
124
- parentId?: { agent: string; seq: number } | null;
124
+ parentId?: string | null; // ItemId string format
125
125
  seq?: number; // Lamport timestamp
126
+ ts?: number; // Wall-clock time (seconds) for tie-breaking
126
127
  }
127
128
 
128
129
  /**
@@ -138,13 +139,13 @@ export interface TextDeleteOp extends BaseOp {
138
139
  position?: number;
139
140
  length?: number;
140
141
  // CRDT metadata format (network sync)
141
- deletions?: Array<{ id: { agent: string; seq: number }; length: number }>;
142
+ deletions?: Array<{ id: string; length: number }>; // ItemId string format
142
143
  }
143
144
 
144
145
  /**
145
146
  * Replace text - atomic delete + insert (for select-and-type)
146
147
  * Position-based: { position, length, value }
147
- * CRDT metadata: { deletions, id, content, parentId, seq }
148
+ * CRDT metadata: { deletions, id, content, parentId, seq, ts }
148
149
  */
149
150
  export interface TextReplaceOp extends BaseOp {
150
151
  key: string;
@@ -155,11 +156,12 @@ export interface TextReplaceOp extends BaseOp {
155
156
  length?: number; // chars to delete
156
157
  value?: string; // chars to insert
157
158
  // CRDT metadata format (network sync)
158
- deletions?: Array<{ id: { agent: string; seq: number }; length: number }>;
159
- id?: { agent: string; seq: number };
159
+ deletions?: Array<{ id: string; length: number }>; // ItemId string format
160
+ id?: string; // ItemId string format
160
161
  content?: string;
161
- parentId?: { agent: string; seq: number } | null;
162
+ parentId?: string | null; // ItemId string format
162
163
  seq?: number;
164
+ ts?: number; // Wall-clock time (seconds) for tie-breaking
163
165
  }
164
166
 
165
167
  // ============================================
@@ -98,13 +98,9 @@ export function TextInit(
98
98
  op: TextInitOp,
99
99
  meta: OpMeta
100
100
  ): void {
101
- let node = draft.nodes[op.key];
101
+ const node = draft.nodes[op.key];
102
102
  if (!node) return;
103
103
 
104
- // Clone node to trigger React re-render
105
- node = { ...node, children: node.children ? [...node.children] : [] };
106
- draft.nodes[op.key] = node;
107
-
108
104
  const rope = getOrCreateRope(node, op.path, meta.sessionId);
109
105
 
110
106
  if (op.value && op.value.length > 0) {
@@ -122,13 +118,9 @@ export function TextInsert(
122
118
  op: TextInsertOp,
123
119
  meta: OpMeta
124
120
  ): void {
125
- let node = draft.nodes[op.key];
121
+ const node = draft.nodes[op.key];
126
122
  if (!node) return;
127
123
 
128
- // Clone node to trigger React re-render
129
- node = { ...node, children: node.children ? [...node.children] : [] };
130
- draft.nodes[op.key] = node;
131
-
132
124
  const rope = getOrCreateRope(node, op.path, meta.sessionId);
133
125
 
134
126
  if (op.id !== undefined && op.content !== undefined && op.seq !== undefined) {
@@ -138,6 +130,7 @@ export function TextInsert(
138
130
  content: op.content,
139
131
  parentId: op.parentId ?? null,
140
132
  seq: op.seq,
133
+ ts: op.ts ?? Date.now() / 1000,
141
134
  });
142
135
  } else if (op.position !== undefined && op.value !== undefined) {
143
136
  // Position-based format (local edit) → convert to CRDT metadata in-place
@@ -147,6 +140,7 @@ export function TextInsert(
147
140
  op.content = crdtOp.content;
148
141
  op.parentId = crdtOp.parentId;
149
142
  op.seq = crdtOp.seq;
143
+ op.ts = crdtOp.ts;
150
144
  }
151
145
 
152
146
  // node[path] is the mutated TextRope (React will call .toString())
@@ -160,16 +154,12 @@ export function TextDelete(
160
154
  op: TextDeleteOp,
161
155
  meta: OpMeta
162
156
  ): void {
163
- let node = draft.nodes[op.key];
157
+ const node = draft.nodes[op.key];
164
158
  if (!node) return;
165
159
 
166
160
  const rope = getRope(node, op.path);
167
161
  if (!rope) return;
168
162
 
169
- // Clone node to trigger React re-render
170
- node = { ...node, children: node.children ? [...node.children] : [] };
171
- draft.nodes[op.key] = node;
172
-
173
163
  if (op.deletions !== undefined) {
174
164
  // CRDT metadata format (replay from journal)
175
165
  applyDelete(rope, { deletions: op.deletions });
@@ -190,13 +180,9 @@ export function TextReplace(
190
180
  op: TextReplaceOp,
191
181
  meta: OpMeta
192
182
  ): void {
193
- let node = draft.nodes[op.key];
183
+ const node = draft.nodes[op.key];
194
184
  if (!node) return;
195
185
 
196
- // Clone node to trigger React re-render
197
- node = { ...node, children: node.children ? [...node.children] : [] };
198
- draft.nodes[op.key] = node;
199
-
200
186
  const rope = getOrCreateRope(node, op.path, meta.sessionId);
201
187
 
202
188
  if (op.deletions !== undefined && op.id !== undefined && op.seq !== undefined) {
@@ -208,6 +194,7 @@ export function TextReplace(
208
194
  content: op.content ?? '',
209
195
  parentId: op.parentId ?? null,
210
196
  seq: op.seq,
197
+ ts: op.ts ?? Date.now() / 1000,
211
198
  },
212
199
  });
213
200
  } else if (op.position !== undefined && op.length !== undefined && op.value !== undefined) {
@@ -218,6 +205,7 @@ export function TextReplace(
218
205
  op.content = crdtOp.insert.content;
219
206
  op.parentId = crdtOp.insert.parentId;
220
207
  op.seq = crdtOp.insert.seq;
208
+ op.ts = crdtOp.insert.ts;
221
209
  }
222
210
 
223
211
  // node[path] is the mutated TextRope