@vuer-ai/vuer-rtc 0.7.0 → 0.8.2

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 (172) hide show
  1. package/CLAUDE.md +3 -2
  2. package/dist/client/EditBuffer.d.ts +4 -4
  3. package/dist/client/EditBuffer.d.ts.map +1 -1
  4. package/dist/client/EditBuffer.js +26 -25
  5. package/dist/client/EditBuffer.js.map +1 -1
  6. package/dist/client/actions.d.ts +3 -3
  7. package/dist/client/actions.d.ts.map +1 -1
  8. package/dist/client/actions.js +71 -70
  9. package/dist/client/actions.js.map +1 -1
  10. package/dist/client/coalesceGraphOps.d.ts +4 -4
  11. package/dist/client/coalesceGraphOps.js +4 -4
  12. package/dist/client/coalesceTextOperations.d.ts.map +1 -1
  13. package/dist/client/coalesceTextOperations.js +23 -20
  14. package/dist/client/coalesceTextOperations.js.map +1 -1
  15. package/dist/client/coalescence/lwwOperations.js +3 -3
  16. package/dist/client/coalescence/lwwOperations.js.map +1 -1
  17. package/dist/client/coalescence/numberOperations.js +2 -2
  18. package/dist/client/coalescence/numberOperations.js.map +1 -1
  19. package/dist/client/coalescence/registry.d.ts +3 -3
  20. package/dist/client/coalescence/registry.d.ts.map +1 -1
  21. package/dist/client/coalescence/registry.js +11 -11
  22. package/dist/client/coalescence/registry.js.map +1 -1
  23. package/dist/client/coalescence/textDeletes.d.ts +8 -7
  24. package/dist/client/coalescence/textDeletes.d.ts.map +1 -1
  25. package/dist/client/coalescence/textDeletes.js +11 -11
  26. package/dist/client/coalescence/textDeletes.js.map +1 -1
  27. package/dist/client/coalescence/textInserts.d.ts +8 -5
  28. package/dist/client/coalescence/textInserts.d.ts.map +1 -1
  29. package/dist/client/coalescence/textInserts.js +32 -12
  30. package/dist/client/coalescence/textInserts.js.map +1 -1
  31. package/dist/client/coalescence/utils.d.ts +3 -9
  32. package/dist/client/coalescence/utils.d.ts.map +1 -1
  33. package/dist/client/coalescence/utils.js +10 -8
  34. package/dist/client/coalescence/utils.js.map +1 -1
  35. package/dist/client/coalescence/vector3Operations.js +2 -2
  36. package/dist/client/coalescence/vector3Operations.js.map +1 -1
  37. package/dist/client/createGraph.d.ts +2 -2
  38. package/dist/client/createGraph.js +4 -4
  39. package/dist/client/createGraph.js.map +1 -1
  40. package/dist/client/createTextDocument.d.ts +1 -1
  41. package/dist/client/createTextDocument.js +3 -3
  42. package/dist/client/createTextDocument.js.map +1 -1
  43. package/dist/client/hooks.d.ts +3 -3
  44. package/dist/client/hooks.d.ts.map +1 -1
  45. package/dist/client/hooks.js +4 -4
  46. package/dist/client/hooks.js.map +1 -1
  47. package/dist/client/textActions.d.ts +2 -2
  48. package/dist/client/textActions.d.ts.map +1 -1
  49. package/dist/client/textActions.js +47 -47
  50. package/dist/client/textActions.js.map +1 -1
  51. package/dist/client/textTypes.d.ts +8 -8
  52. package/dist/client/textTypes.d.ts.map +1 -1
  53. package/dist/client/types.d.ts +4 -4
  54. package/dist/client/types.d.ts.map +1 -1
  55. package/dist/crdt/GraphTextCRDT.d.ts +2 -2
  56. package/dist/crdt/GraphTextCRDT.d.ts.map +1 -1
  57. package/dist/crdt/GraphTextCRDT.js +6 -6
  58. package/dist/crdt/GraphTextCRDT.js.map +1 -1
  59. package/dist/crdt/Rope.d.ts +13 -14
  60. package/dist/crdt/Rope.d.ts.map +1 -1
  61. package/dist/crdt/Rope.js +130 -59
  62. package/dist/crdt/Rope.js.map +1 -1
  63. package/dist/crdt/index.d.ts +1 -1
  64. package/dist/crdt/index.d.ts.map +1 -1
  65. package/dist/crdt/index.js +1 -1
  66. package/dist/crdt/index.js.map +1 -1
  67. package/dist/index.d.ts +1 -1
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +1 -1
  70. package/dist/index.js.map +1 -1
  71. package/dist/operations/OperationTypes.d.ts +45 -48
  72. package/dist/operations/OperationTypes.d.ts.map +1 -1
  73. package/dist/operations/OperationValidator.js +11 -11
  74. package/dist/operations/OperationValidator.js.map +1 -1
  75. package/dist/operations/apply/node.js +3 -3
  76. package/dist/operations/apply/node.js.map +1 -1
  77. package/dist/operations/apply/text.d.ts.map +1 -1
  78. package/dist/operations/apply/text.js +35 -32
  79. package/dist/operations/apply/text.js.map +1 -1
  80. package/dist/operations/apply/types.d.ts +4 -4
  81. package/dist/operations/apply/types.d.ts.map +1 -1
  82. package/dist/operations/apply/types.js +8 -8
  83. package/dist/operations/apply/types.js.map +1 -1
  84. package/dist/operations/dispatcher.d.ts.map +1 -1
  85. package/dist/operations/dispatcher.js +52 -13
  86. package/dist/operations/dispatcher.js.map +1 -1
  87. package/dist/serdes.d.ts +1 -1
  88. package/dist/serdes.d.ts.map +1 -1
  89. package/dist/state/ConflictResolver.d.ts +9 -9
  90. package/dist/state/ConflictResolver.d.ts.map +1 -1
  91. package/dist/state/ConflictResolver.js +20 -20
  92. package/dist/state/ConflictResolver.js.map +1 -1
  93. package/dist/state/DType.d.ts +2 -2
  94. package/dist/state/DType.d.ts.map +1 -1
  95. package/dist/state/DType.js +14 -14
  96. package/dist/state/DType.js.map +1 -1
  97. package/dist/state/VectorClock.d.ts +6 -6
  98. package/dist/state/VectorClock.d.ts.map +1 -1
  99. package/dist/state/VectorClock.js +14 -14
  100. package/dist/state/VectorClock.js.map +1 -1
  101. package/dist/state/index.d.ts +1 -1
  102. package/dist/state/index.js +1 -1
  103. package/examples/01-basic-usage.ts +16 -16
  104. package/examples/02-concurrent-edits.ts +29 -29
  105. package/examples/03-scene-building.ts +28 -28
  106. package/examples/04-conflict-resolution.ts +56 -56
  107. package/examples/05-coalescence-usage.ts +23 -23
  108. package/examples/README.md +12 -12
  109. package/package.json +1 -1
  110. package/src/client/EditBuffer.ts +28 -27
  111. package/src/client/TEXT_DOCUMENT_API.md +9 -9
  112. package/src/client/actions.ts +74 -70
  113. package/src/client/coalesceGraphOps.ts +4 -4
  114. package/src/client/coalesceTextOperations.ts +26 -22
  115. package/src/client/coalescence/lwwOperations.ts +3 -3
  116. package/src/client/coalescence/numberOperations.ts +2 -2
  117. package/src/client/coalescence/registry.ts +13 -12
  118. package/src/client/coalescence/textDeletes.ts +22 -18
  119. package/src/client/coalescence/textInserts.ts +49 -25
  120. package/src/client/coalescence/utils.ts +14 -11
  121. package/src/client/coalescence/vector3Operations.ts +2 -2
  122. package/src/client/createGraph.ts +4 -4
  123. package/src/client/createTextDocument.ts +3 -3
  124. package/src/client/hooks.tsx +5 -5
  125. package/src/client/textActions.ts +47 -47
  126. package/src/client/textTypes.ts +8 -8
  127. package/src/client/types.ts +4 -4
  128. package/src/crdt/GraphTextCRDT.ts +6 -6
  129. package/src/crdt/Rope.ts +156 -71
  130. package/src/crdt/index.ts +2 -0
  131. package/src/index.ts +2 -0
  132. package/src/operations/OperationTypes.ts +47 -47
  133. package/src/operations/OperationValidator.ts +11 -11
  134. package/src/operations/apply/node.ts +3 -3
  135. package/src/operations/apply/text.ts +38 -32
  136. package/src/operations/apply/types.ts +11 -11
  137. package/src/operations/dispatcher.ts +57 -13
  138. package/src/serdes.ts +1 -1
  139. package/src/state/ConflictResolver.ts +23 -23
  140. package/src/state/DType.ts +16 -16
  141. package/src/state/VectorClock.ts +14 -14
  142. package/src/state/index.ts +1 -1
  143. package/tests/client/actions.test.ts +76 -76
  144. package/tests/client/coalesce-graph-operations.test.ts +84 -84
  145. package/tests/client/coalesce-text-operations.test.ts +91 -114
  146. package/tests/client/compaction.test.ts +18 -18
  147. package/tests/client/delete-coalescence-bug.test.ts +34 -34
  148. package/tests/client/edit-buffer.test.ts +27 -30
  149. package/tests/client/graph-coalescence-phase1.test.ts +66 -66
  150. package/tests/client/graph-coalescence.test.ts +50 -50
  151. package/tests/client/journal-benchmark.test.ts +5 -5
  152. package/tests/crdt/graph-text-crdt.test.ts +60 -64
  153. package/tests/crdt/rope.test.ts +9 -8
  154. package/tests/crdt/text-operations.test.ts +28 -28
  155. package/tests/fixtures/array-ops.jsonl +6 -6
  156. package/tests/fixtures/boolean-ops.jsonl +6 -6
  157. package/tests/fixtures/color-ops.jsonl +4 -4
  158. package/tests/fixtures/edit-buffer.jsonl +3 -3
  159. package/tests/fixtures/node-ops.jsonl +6 -6
  160. package/tests/fixtures/number-ops.jsonl +7 -7
  161. package/tests/fixtures/object-ops.jsonl +4 -4
  162. package/tests/fixtures/operations.jsonl +7 -7
  163. package/tests/fixtures/string-ops.jsonl +4 -4
  164. package/tests/fixtures/undo-redo.jsonl +3 -3
  165. package/tests/fixtures/vector-ops.jsonl +17 -17
  166. package/tests/operations/collections.test.ts +4 -4
  167. package/tests/operations/nodes.test.ts +5 -5
  168. package/tests/operations/operation-ordering.test.ts +406 -0
  169. package/tests/operations/primitives.test.ts +4 -4
  170. package/tests/operations/unified-schema.test.ts +27 -27
  171. package/tests/operations/vectors.test.ts +4 -4
  172. package/tests/sync/digest.test.ts +5 -5
@@ -11,7 +11,7 @@ import type { TextRope, InsertOp, DeleteOp, ReplaceOp } from '../crdt/Rope.js';
11
11
  import type { VectorClock } from '../state/VectorClock.js';
12
12
 
13
13
  /**
14
- * Text operation types - no wrapper needed since ops have otype discriminator
14
+ * Text operation types - no wrapper needed since ops have ot discriminator
15
15
  */
16
16
  export type TextOperation = InsertOp | DeleteOp | ReplaceOp;
17
17
 
@@ -20,11 +20,11 @@ export type TextOperation = InsertOp | DeleteOp | ReplaceOp;
20
20
  */
21
21
  export interface TextMessage {
22
22
  msgId: string;
23
- sessionId: string;
23
+ client: string;
24
24
  operations: TextOperation[];
25
25
  vectorClock: VectorClock;
26
- lamportTime: number;
27
- timestamp: number;
26
+ lt: number;
27
+ ts: number;
28
28
  description?: string;
29
29
  }
30
30
 
@@ -51,7 +51,7 @@ export interface TextEditBuffer {
51
51
  export interface TextSnapshot {
52
52
  rope: TextRope;
53
53
  vectorClock: VectorClock;
54
- lamportTime: number; // Max lamport time baked in
54
+ lt: number; // Max lamport time baked in
55
55
  journalIndex: number; // How many entries are baked in
56
56
  }
57
57
 
@@ -72,16 +72,16 @@ export interface TextDocumentState {
72
72
  snapshot: TextSnapshot;
73
73
 
74
74
  // Clocks
75
- lamportTime: number;
75
+ lt: number;
76
76
  vectorClock: VectorClock;
77
- sessionId: string;
77
+ client: string;
78
78
  }
79
79
 
80
80
  /**
81
81
  * Options for creating a text document store
82
82
  */
83
83
  export interface CreateTextDocumentOptions {
84
- sessionId: string;
84
+ client: string;
85
85
  initialSnapshot?: TextSnapshot;
86
86
  onSend?: (msg: TextMessage) => void;
87
87
  onStateChange?: (state: TextDocumentState) => void;
@@ -34,7 +34,7 @@ export interface EditBuffer {
34
34
  export interface Snapshot {
35
35
  graph: SceneGraph;
36
36
  vectorClock: VectorClock;
37
- lamportTime: number; // Max lamport time baked in
37
+ lt: number; // Max lamport time baked in
38
38
  journalIndex: number; // How many entries are baked in
39
39
  }
40
40
 
@@ -55,16 +55,16 @@ export interface ClientState {
55
55
  snapshot: Snapshot;
56
56
 
57
57
  // Clocks
58
- lamportTime: number;
58
+ lt: number;
59
59
  vectorClock: VectorClock;
60
- sessionId: string;
60
+ client: string;
61
61
  }
62
62
 
63
63
  /**
64
64
  * Options for creating a graph store
65
65
  */
66
66
  export interface CreateGraphOptions {
67
- sessionId: string;
67
+ client: string; // Client/session ID (renamed from sessionId for consistency with wire format)
68
68
  initialSnapshot?: Snapshot;
69
69
  onSend?: (msg: CRDTMessage) => void;
70
70
  onStateChange?: (state: ClientState) => void;
@@ -44,11 +44,11 @@ import {
44
44
  */
45
45
  export class GraphTextCRDT {
46
46
  private _state: Map<string, Map<string, TextRope>>;
47
- private _sessionId: string;
47
+ private _client: string;
48
48
 
49
- constructor(sessionId: string) {
49
+ constructor(client: string) {
50
50
  this._state = new Map();
51
- this._sessionId = sessionId;
51
+ this._client = client;
52
52
  }
53
53
 
54
54
  /**
@@ -63,7 +63,7 @@ export class GraphTextCRDT {
63
63
 
64
64
  let rope = nodeState.get(path);
65
65
  if (!rope) {
66
- rope = create(this._sessionId);
66
+ rope = create(this._client);
67
67
  nodeState.set(path, rope);
68
68
  }
69
69
 
@@ -99,7 +99,7 @@ export class GraphTextCRDT {
99
99
  text: string
100
100
  ): InsertOp {
101
101
  const rope = this.getOrCreate(nodeKey, path);
102
- return insertWithSplit(rope, position, text, this._sessionId);
102
+ return insertWithSplit(rope, position, text, this._client);
103
103
  }
104
104
 
105
105
  /**
@@ -143,7 +143,7 @@ export class GraphTextCRDT {
143
143
  text: string
144
144
  ): ReplaceOp {
145
145
  const rope = this.getOrCreate(nodeKey, path);
146
- return replace(rope, position, length, text, this._sessionId);
146
+ return replace(rope, position, length, text, this._client);
147
147
  }
148
148
 
149
149
  /**
package/src/crdt/Rope.ts CHANGED
@@ -27,7 +27,7 @@ export interface Item {
27
27
  id: ItemId;
28
28
  content: string;
29
29
  isDeleted: boolean;
30
- parentId: ItemId | null;
30
+ anchor?: ItemId; // Changed from 'parentId: ItemId | null' - omit when null
31
31
  seq: number; // Lamport timestamp for total ordering
32
32
  ts: number; // Wall-clock time (seconds) for tie-breaking
33
33
  }
@@ -90,29 +90,31 @@ export class TextRope {
90
90
  }
91
91
 
92
92
  export interface InsertOp {
93
- otype: 'insert';
93
+ ot: 'insert';
94
94
  id: ItemId;
95
- content: string;
96
- parentId: ItemId | null;
95
+ value: [ItemId | null, string]; // [anchor, content]
97
96
  seq: number;
98
97
  ts: number; // Wall-clock time (seconds) for tie-breaking
99
98
  }
100
99
 
101
100
  export interface DeleteOp {
102
- otype: 'delete';
103
- deletions: Array<{ id: ItemId; length: number }>;
101
+ ot: 'delete';
102
+ rm: Array<[ItemId, number]>; // [[itemId, length], ...]
104
103
  }
105
104
 
106
105
  export interface MoveOp {
107
- otype: 'move';
106
+ ot: 'move';
108
107
  delete: DeleteOp;
109
108
  insert: InsertOp;
110
109
  }
111
110
 
112
111
  export interface ReplaceOp {
113
- otype: 'replace';
114
- delete: DeleteOp;
115
- insert: InsertOp;
112
+ ot: 'replace';
113
+ rm: Array<[ItemId, number]>;
114
+ id: ItemId;
115
+ value: [ItemId | null, string]; // [anchor, content]
116
+ seq: number;
117
+ ts: number;
116
118
  }
117
119
 
118
120
  // ============================================
@@ -280,7 +282,7 @@ function splitItem(rope: TextRope, ordinal: number, offset: number): Item {
280
282
  id: createItemId(itemParsed.agent, itemParsed.seq),
281
283
  content: item.content.slice(0, offset),
282
284
  isDeleted: item.isDeleted,
283
- parentId: item.parentId,
285
+ ...(item.anchor !== undefined && { anchor: item.anchor }),
284
286
  seq: item.seq,
285
287
  ts: item.ts,
286
288
  };
@@ -289,7 +291,7 @@ function splitItem(rope: TextRope, ordinal: number, offset: number): Item {
289
291
  id: createItemId(itemParsed.agent, itemParsed.seq + offset),
290
292
  content: item.content.slice(offset),
291
293
  isDeleted: item.isDeleted,
292
- parentId: createItemId(itemParsed.agent, itemParsed.seq + offset - 1),
294
+ anchor: createItemId(itemParsed.agent, itemParsed.seq + offset - 1),
293
295
  seq: item.seq,
294
296
  ts: item.ts,
295
297
  };
@@ -314,9 +316,9 @@ function containsItemId(rope: TextRope, id: ItemId): boolean {
314
316
  return aiFind(entries, parsed.seq) !== -1;
315
317
  }
316
318
 
317
- function findParentOrdinal(rope: TextRope, parentId: ItemId | null): number {
318
- if (parentId === null) return -1;
319
- const parsed = parseItemId(parentId);
319
+ function findParentOrdinal(rope: TextRope, anchor: ItemId | undefined): number {
320
+ if (anchor === undefined) return -1;
321
+ const parsed = parseItemId(anchor);
320
322
  const entries = rope._agentIndex.get(parsed.agent);
321
323
  if (!entries) return -1;
322
324
  const ei = aiFind(entries, parsed.seq);
@@ -324,9 +326,9 @@ function findParentOrdinal(rope: TextRope, parentId: ItemId | null): number {
324
326
  return computeOrdinal(rope._tree, entries[ei].item);
325
327
  }
326
328
 
327
- function splitForParent(rope: TextRope, parentId: ItemId | null): number {
328
- if (parentId === null) return -1;
329
- const parsedParent = parseItemId(parentId);
329
+ function splitForParent(rope: TextRope, anchor: ItemId | undefined): number {
330
+ if (anchor === undefined) return -1;
331
+ const parsedParent = parseItemId(anchor);
330
332
  const entries = rope._agentIndex.get(parsedParent.agent);
331
333
  if (!entries) return -1;
332
334
  const ei = aiFind(entries, parsedParent.seq);
@@ -399,7 +401,7 @@ function integrate(rope: TextRope, newItem: Item, parentOrdinal: number): void {
399
401
  // First, compare by Lamport timestamp (seq) - higher seq means happened later
400
402
  if (newItem.seq > existingItem.seq) break;
401
403
 
402
- const existingParentOrdinal = findParentOrdinal(rope, existingItem.parentId);
404
+ const existingParentOrdinal = findParentOrdinal(rope, existingItem.anchor);
403
405
 
404
406
  // If existing item's parent is before our parent in the document, insert here
405
407
  if (existingParentOrdinal < parentOrdinal) {
@@ -433,11 +435,14 @@ function integrate(rope: TextRope, newItem: Item, parentOrdinal: number): void {
433
435
  function _applyLocal(rope: TextRope, op: InsertOp, parentOrdinal: number): void {
434
436
  if (op.seq > rope.maxSeq) rope.maxSeq = op.seq;
435
437
 
438
+ const anchor = op.value[0];
439
+ const content = op.value[1];
440
+
436
441
  const newItem: Item = {
437
442
  id: op.id,
438
- content: op.content,
443
+ content,
439
444
  isDeleted: false,
440
- parentId: op.parentId,
445
+ ...(anchor !== null && { anchor }),
441
446
  seq: op.seq,
442
447
  ts: op.ts,
443
448
  };
@@ -446,7 +451,7 @@ function _applyLocal(rope: TextRope, op: InsertOp, parentOrdinal: number): void
446
451
 
447
452
  const idParsed = parseItemId(op.id);
448
453
  if (idParsed.agent === rope.agentId) {
449
- const maxSeqInItem = idParsed.seq + op.content.length - 1;
454
+ const maxSeqInItem = idParsed.seq + content.length - 1;
450
455
  if (maxSeqInItem >= rope.clock) rope.clock = maxSeqInItem + 1;
451
456
  }
452
457
  }
@@ -480,15 +485,14 @@ export function insert(rope: TextRope, position: number, content: string, agentI
480
485
  const parentId = findInsertPosition(rope, position);
481
486
 
482
487
  const op: InsertOp = {
483
- otype: 'insert',
488
+ ot: 'insert',
484
489
  id: createItemId(rope.agentId, rope.clock),
485
- content,
486
- parentId,
490
+ value: [parentId, content],
487
491
  seq: rope.maxSeq + 1,
488
492
  ts: Date.now() / 1000,
489
493
  };
490
494
 
491
- const parentOrdinal = splitForParent(rope, op.parentId);
495
+ const parentOrdinal = splitForParent(rope, op.value[0] ?? undefined);
492
496
  _applyLocal(rope, op, parentOrdinal);
493
497
  return op;
494
498
  }
@@ -516,10 +520,9 @@ export function insertWithSplit(rope: TextRope, position: number, content: strin
516
520
  }
517
521
 
518
522
  const op: InsertOp = {
519
- otype: 'insert',
523
+ ot: 'insert',
520
524
  id: createItemId(rope.agentId, rope.clock),
521
- content,
522
- parentId,
525
+ value: [parentId, content],
523
526
  seq: rope.maxSeq + 1,
524
527
  ts: Date.now() / 1000,
525
528
  };
@@ -539,13 +542,16 @@ export function apply(rope: TextRope, op: InsertOp): void {
539
542
  if (op.seq > rope.maxSeq) rope.maxSeq = op.seq;
540
543
  if (containsItemId(rope, op.id)) return;
541
544
 
542
- const parentOrdinal = splitForParent(rope, op.parentId);
545
+ const anchor = op.value[0];
546
+ const content = op.value[1];
547
+
548
+ const parentOrdinal = splitForParent(rope, anchor ?? undefined);
543
549
 
544
550
  const newItem: Item = {
545
551
  id: op.id,
546
- content: op.content,
552
+ content,
547
553
  isDeleted: false,
548
- parentId: op.parentId,
554
+ ...(anchor !== null && { anchor }),
549
555
  seq: op.seq,
550
556
  ts: op.ts,
551
557
  };
@@ -554,7 +560,7 @@ export function apply(rope: TextRope, op: InsertOp): void {
554
560
 
555
561
  const idParsed = parseItemId(op.id);
556
562
  if (idParsed.agent === rope.agentId) {
557
- const maxSeqInItem = idParsed.seq + op.content.length - 1;
563
+ const maxSeqInItem = idParsed.seq + content.length - 1;
558
564
  if (maxSeqInItem >= rope.clock) rope.clock = maxSeqInItem + 1;
559
565
  }
560
566
  }
@@ -578,13 +584,13 @@ function canMergeDeletions(lastId: ItemId, lastLength: number, currentId: ItemId
578
584
  }
579
585
 
580
586
  export function remove(rope: TextRope, position: number, length: number): DeleteOp {
581
- if (length === 0) return { otype: 'delete', deletions: [] };
587
+ if (length === 0) return { ot: 'delete', rm: [] };
582
588
 
583
589
  // Invalidate insert tracking when deleting
584
590
  rope.lastInsertId = null;
585
591
  rope.lastInsertPos = 0;
586
592
 
587
- const deletions: Array<{ id: ItemId; length: number }> = [];
593
+ const rm: Array<[ItemId, number]> = [];
588
594
  let remaining = length;
589
595
 
590
596
  while (remaining > 0) {
@@ -615,31 +621,33 @@ export function remove(rope: TextRope, position: number, length: number): Delete
615
621
  recalcCountsFor(rope._tree, item);
616
622
 
617
623
  // Try to merge with previous deletion if consecutive
618
- const lastDeletion = deletions[deletions.length - 1];
619
- if (lastDeletion && canMergeDeletions(lastDeletion.id, lastDeletion.length, item.id)) {
624
+ const lastDeletion = rm[rm.length - 1];
625
+ if (lastDeletion && canMergeDeletions(lastDeletion[0], lastDeletion[1], item.id)) {
620
626
  // Merge: extend the previous deletion
621
- lastDeletion.length += item.content.length;
627
+ lastDeletion[1] += item.content.length;
622
628
  } else {
623
629
  // Cannot merge: add new deletion entry
624
- deletions.push({ id: item.id, length: item.content.length });
630
+ rm.push([item.id, item.content.length]);
625
631
  }
626
632
 
627
633
  remaining -= toDelete;
628
634
  }
629
635
 
630
- return { otype: 'delete', deletions };
636
+ return { ot: 'delete', rm };
631
637
  }
632
638
 
633
639
  export function applyDelete(rope: TextRope, op: DeleteOp): void {
634
- if (op.deletions.length === 0) return;
640
+ if (op.rm.length === 0) return;
635
641
 
636
642
  // Group and merge deletion ranges by agent
637
643
  const byAgent = new Map<string, { start: number; end: number }[]>();
638
- for (const del of op.deletions) {
639
- const parsed = parseItemId(del.id);
644
+ for (const del of op.rm) {
645
+ const itemId = del[0];
646
+ const len = del[1];
647
+ const parsed = parseItemId(itemId);
640
648
  let ranges = byAgent.get(parsed.agent);
641
649
  if (!ranges) { ranges = []; byAgent.set(parsed.agent, ranges); }
642
- ranges.push({ start: parsed.seq, end: parsed.seq + del.length });
650
+ ranges.push({ start: parsed.seq, end: parsed.seq + len });
643
651
  }
644
652
 
645
653
  for (const [, ranges] of byAgent) {
@@ -728,17 +736,87 @@ export function replace(
728
736
  agentId?: string
729
737
  ): ReplaceOp {
730
738
  if (agentId !== undefined) switchAgent(rope, agentId);
731
- const deleteOp: DeleteOp = deleteLength > 0 ? remove(rope, position, deleteLength) : { otype: 'delete', deletions: [] };
732
- const insertOp: InsertOp = insertText.length > 0
733
- ? insertWithSplit(rope, position, insertText)
734
- : { otype: 'insert', id: createItemId(rope.agentId, rope.clock), content: '', parentId: null, seq: rope.maxSeq, ts: Date.now() / 1000 };
735
- return { otype: 'replace', delete: deleteOp, insert: insertOp };
739
+
740
+ // Capture parent ID for insert BEFORE delete (delete mutates rope and loses parent info)
741
+ let insertParentId: ItemId | null = null;
742
+ let insertParentOrdinal: number = -1;
743
+ let needsSplit = false;
744
+ let splitOffset = 0;
745
+
746
+ if (insertText.length > 0) {
747
+ // Check if we're continuing from the last insert position (continuous typing)
748
+ if (position === rope.lastInsertPos && rope.lastInsertId !== null) {
749
+ insertParentId = rope.lastInsertId;
750
+ insertParentOrdinal = splitForParent(rope, insertParentId);
751
+ } else {
752
+ // Cursor moved or first insert - capture parent info before delete
753
+ const result = findInsertPositionWithSplit(rope, position);
754
+ insertParentId = result.parentId;
755
+ insertParentOrdinal = result.parentOrdinal;
756
+ needsSplit = result.needsSplit;
757
+ splitOffset = result.splitOffset;
758
+ }
759
+ }
760
+
761
+ // Delete operation invalidates insert tracking
762
+ const deleteOp: DeleteOp = deleteLength > 0 ? remove(rope, position, deleteLength) : { ot: 'delete', rm: [] };
763
+
764
+ // Create flattened replace operation
765
+ const replaceOp: ReplaceOp = {
766
+ ot: 'replace',
767
+ rm: deleteOp.rm,
768
+ id: createItemId(rope.agentId, rope.clock),
769
+ value: [insertParentId, insertText],
770
+ seq: rope.maxSeq + 1,
771
+ ts: Date.now() / 1000,
772
+ };
773
+
774
+ // Apply insert if there's text to insert
775
+ if (insertText.length > 0) {
776
+ // Split item if needed (parent ordinal may have changed after delete)
777
+ if (needsSplit && insertParentOrdinal >= 0) {
778
+ splitItem(rope, insertParentOrdinal, splitOffset);
779
+ }
780
+
781
+ const insertOp: InsertOp = {
782
+ ot: 'insert',
783
+ id: replaceOp.id,
784
+ value: replaceOp.value,
785
+ seq: replaceOp.seq,
786
+ ts: replaceOp.ts,
787
+ };
788
+
789
+ _applyLocal(rope, insertOp, insertParentOrdinal);
790
+
791
+ // Update last insert tracking
792
+ const opIdParsed = parseItemId(insertOp.id);
793
+ const lastCharSeq = opIdParsed.seq + insertText.length - 1;
794
+ rope.lastInsertId = createItemId(rope.agentId, lastCharSeq);
795
+ rope.lastInsertPos = position + insertText.length;
796
+ } else {
797
+ rope.lastInsertId = null;
798
+ rope.lastInsertPos = 0;
799
+ }
800
+
801
+ return replaceOp;
736
802
  }
737
803
 
738
804
  export function applyReplace(rope: TextRope, op: ReplaceOp): void {
739
- applyDelete(rope, op.delete);
740
- if (op.insert.content.length > 0) {
741
- apply(rope, op.insert);
805
+ // Apply delete side
806
+ const deleteOp: DeleteOp = { ot: 'delete', rm: op.rm };
807
+ applyDelete(rope, deleteOp);
808
+
809
+ // Apply insert side
810
+ const content = op.value[1];
811
+ if (content.length > 0) {
812
+ const insertOp: InsertOp = {
813
+ ot: 'insert',
814
+ id: op.id,
815
+ value: op.value,
816
+ seq: op.seq,
817
+ ts: op.ts,
818
+ };
819
+ apply(rope, insertOp);
742
820
  }
743
821
  }
744
822
 
@@ -771,7 +849,7 @@ export function move(rope: TextRope, fromPosition: number, length: number, toPos
771
849
  if (toPosition > fromPosition) adjustedTo = toPosition - length;
772
850
  const insertOp = insertWithSplit(rope, adjustedTo, content);
773
851
 
774
- return { otype: 'move', delete: deleteOp, insert: insertOp };
852
+ return { ot: 'move', delete: deleteOp, insert: insertOp };
775
853
  }
776
854
 
777
855
  export function applyMove(rope: TextRope, op: MoveOp): void {
@@ -787,15 +865,14 @@ export function merge(rope: TextRope, other: TextRope): void {
787
865
  for (const treeItem of iterateAll(other._tree)) {
788
866
  const item = treeItem as Item;
789
867
  apply(rope, {
790
- otype: 'insert',
868
+ ot: 'insert',
791
869
  id: item.id,
792
- content: item.content,
793
- parentId: item.parentId,
870
+ value: [item.anchor ?? null, item.content],
794
871
  seq: item.seq,
795
872
  ts: item.ts,
796
873
  });
797
874
  if (item.isDeleted) {
798
- applyDelete(rope, { otype: 'delete', deletions: [{ id: item.id, length: item.content.length }] });
875
+ applyDelete(rope, { ot: 'delete', rm: [[item.id, item.content.length]] });
799
876
  }
800
877
  }
801
878
  }
@@ -836,15 +913,15 @@ export function compact(rope: TextRope): TextRope {
836
913
  // Serialization
837
914
  // ============================================
838
915
 
839
- export type RawTextRope = [string, number, number, Array<[string, string, string | null, number, number]>];
916
+ export type RawTextRope = [string, number, number, Array<[string, string, string | undefined, number, number]>];
840
917
 
841
918
  export function toRaw(rope: TextRope): RawTextRope {
842
919
  const compacted = compact(rope);
843
920
  const items = flattenItems(compacted._tree) as Item[];
844
- const rawItems: Array<[string, string, string | null, number, number]> = items.map(item => [
921
+ const rawItems: Array<[string, string, string | undefined, number, number]> = items.map(item => [
845
922
  item.id,
846
923
  item.content,
847
- item.parentId,
924
+ item.anchor,
848
925
  item.seq,
849
926
  item.ts,
850
927
  ]);
@@ -853,11 +930,11 @@ export function toRaw(rope: TextRope): RawTextRope {
853
930
 
854
931
  export function fromRaw(raw: RawTextRope, newAgentId?: string): TextRope {
855
932
  const [agentId, clock, maxSeq, rawItems] = raw;
856
- const items: Item[] = rawItems.map(([id, content, parentId, seq, ts]) => ({
933
+ const items: Item[] = rawItems.map(([id, content, anchor, seq, ts]) => ({
857
934
  id,
858
935
  content,
859
936
  isDeleted: false,
860
- parentId,
937
+ ...(anchor !== undefined && { anchor }),
861
938
  seq,
862
939
  ts,
863
940
  }));
@@ -912,15 +989,23 @@ function extractItemsFromSerializedTree(node: unknown): Item[] {
912
989
  // Leaf node: extract items array
913
990
  const items = n.items as unknown[];
914
991
  if (!Array.isArray(items)) return [];
915
- return items.map((item: any) => ({
916
- id: typeof item.id === 'string' ? item.id : createItemId(item.id.agent, item.id.seq),
917
- content: item.content,
918
- isDeleted: item.isDeleted ?? false,
919
- parentId: item.parentId === null ? null :
920
- (typeof item.parentId === 'string' ? item.parentId : createItemId(item.parentId.agent, item.parentId.seq)),
921
- seq: item.seq,
922
- ts: item.ts ?? Date.now() / 1000,
923
- }));
992
+ return items.map((item: any) => {
993
+ const result: Item = {
994
+ id: typeof item.id === 'string' ? item.id : createItemId(item.id.agent, item.id.seq),
995
+ content: item.content,
996
+ isDeleted: item.isDeleted ?? false,
997
+ seq: item.seq,
998
+ ts: item.ts ?? Date.now() / 1000,
999
+ };
1000
+
1001
+ // Handle both old 'parentId' and new 'anchor' field names
1002
+ const anchorValue = item.anchor ?? item.parentId;
1003
+ if (anchorValue !== undefined && anchorValue !== null) {
1004
+ result.anchor = typeof anchorValue === 'string' ? anchorValue : createItemId(anchorValue.agent, anchorValue.seq);
1005
+ }
1006
+
1007
+ return result;
1008
+ });
924
1009
  } else if (n.type === 'internal') {
925
1010
  // Internal node: recursively collect from children
926
1011
  const children = n.children as unknown[];
package/src/crdt/index.ts CHANGED
@@ -28,6 +28,8 @@ export {
28
28
  findItemById,
29
29
  itemIdEquals,
30
30
  itemIdCompare,
31
+ parseItemId,
32
+ createItemId,
31
33
  } from './Rope.js';
32
34
 
33
35
  export type {
package/src/index.ts CHANGED
@@ -39,6 +39,8 @@ export {
39
39
  findItemById,
40
40
  itemIdEquals,
41
41
  itemIdCompare,
42
+ parseItemId,
43
+ createItemId,
42
44
  } from './crdt/index.js';
43
45
 
44
46
  export type {