@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.
- package/CLAUDE.md +29 -0
- package/COALESCE_FIX_VERIFICATION.md +81 -0
- package/REFACTORING_NOTES.md +229 -0
- package/dist/client/EditBuffer.d.ts +13 -1
- package/dist/client/EditBuffer.d.ts.map +1 -1
- package/dist/client/EditBuffer.js +47 -3
- package/dist/client/EditBuffer.js.map +1 -1
- package/dist/client/actions.d.ts +5 -1
- package/dist/client/actions.d.ts.map +1 -1
- package/dist/client/actions.js +12 -9
- package/dist/client/actions.js.map +1 -1
- package/dist/client/coalesceGraphOps.d.ts +34 -0
- package/dist/client/coalesceGraphOps.d.ts.map +1 -0
- package/dist/client/coalesceGraphOps.js +35 -0
- package/dist/client/coalesceGraphOps.js.map +1 -0
- package/dist/client/coalesceTextOperations.d.ts +42 -0
- package/dist/client/coalesceTextOperations.d.ts.map +1 -0
- package/dist/client/coalesceTextOperations.js +119 -0
- package/dist/client/coalesceTextOperations.js.map +1 -0
- package/dist/client/coalescence/index.d.ts +9 -0
- package/dist/client/coalescence/index.d.ts.map +1 -0
- package/dist/client/coalescence/index.js +9 -0
- package/dist/client/coalescence/index.js.map +1 -0
- package/dist/client/coalescence/registry.d.ts +48 -0
- package/dist/client/coalescence/registry.d.ts.map +1 -0
- package/dist/client/coalescence/registry.js +95 -0
- package/dist/client/coalescence/registry.js.map +1 -0
- package/dist/client/coalescence/textDeletes.d.ts +38 -0
- package/dist/client/coalescence/textDeletes.d.ts.map +1 -0
- package/dist/client/coalescence/textDeletes.js +68 -0
- package/dist/client/coalescence/textDeletes.js.map +1 -0
- package/dist/client/coalescence/textInserts.d.ts +45 -0
- package/dist/client/coalescence/textInserts.d.ts.map +1 -0
- package/dist/client/coalescence/textInserts.js +96 -0
- package/dist/client/coalescence/textInserts.js.map +1 -0
- package/dist/client/createGraph.d.ts.map +1 -1
- package/dist/client/createGraph.js +9 -2
- package/dist/client/createGraph.js.map +1 -1
- package/dist/client/createTextDocument.d.ts.map +1 -1
- package/dist/client/createTextDocument.js +2 -1
- package/dist/client/createTextDocument.js.map +1 -1
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/textActions.d.ts +1 -1
- package/dist/client/textActions.d.ts.map +1 -1
- package/dist/client/textActions.js +7 -2
- package/dist/client/textActions.js.map +1 -1
- package/dist/client/types.d.ts +3 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.d.ts +0 -4
- package/dist/crdt/GraphTextCRDT.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.js +3 -0
- package/dist/crdt/GraphTextCRDT.js.map +1 -1
- package/dist/crdt/Rope.d.ts +27 -6
- package/dist/crdt/Rope.d.ts.map +1 -1
- package/dist/crdt/Rope.js +137 -69
- package/dist/crdt/Rope.js.map +1 -1
- package/dist/operations/OperationTypes.d.ts +10 -26
- package/dist/operations/OperationTypes.d.ts.map +1 -1
- package/dist/operations/apply/text.d.ts.map +1 -1
- package/dist/operations/apply/text.js +8 -16
- package/dist/operations/apply/text.js.map +1 -1
- package/examples/05-coalescence-usage.ts +189 -0
- package/package.json +1 -1
- package/src/client/EditBuffer.ts +51 -3
- package/src/client/actions.ts +13 -9
- package/src/client/coalesceGraphOps.ts +40 -0
- package/src/client/coalesceTextOperations.ts +134 -0
- package/src/client/coalescence/index.ts +18 -0
- package/src/client/coalescence/registry.ts +137 -0
- package/src/client/coalescence/textDeletes.ts +94 -0
- package/src/client/coalescence/textInserts.ts +128 -0
- package/src/client/createGraph.ts +11 -2
- package/src/client/createTextDocument.ts +2 -1
- package/src/client/index.ts +14 -0
- package/src/client/textActions.ts +9 -2
- package/src/client/types.ts +4 -1
- package/src/crdt/GraphTextCRDT.ts +0 -5
- package/src/crdt/Rope.ts +155 -79
- package/src/operations/OperationTypes.ts +10 -8
- package/src/operations/apply/text.ts +8 -20
- package/test-coalescence.ts +201 -0
- package/tests/client/actions.test.ts +156 -0
- package/tests/client/coalesce-text-operations.test.ts +327 -0
- package/tests/client/edit-buffer.test.ts +137 -1
- package/tests/crdt/graph-text-crdt.test.ts +29 -17
- 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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
119
|
-
if (a
|
|
120
|
-
return
|
|
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:
|
|
142
|
-
endSeq:
|
|
168
|
+
startSeq: parsed.seq,
|
|
169
|
+
endSeq: parsed.seq + item.content.length,
|
|
143
170
|
item,
|
|
144
171
|
};
|
|
145
|
-
let arr = index.get(
|
|
146
|
-
if (!arr) { arr = []; index.set(
|
|
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
|
|
184
|
+
const parsed = parseItemId(item.id);
|
|
185
|
+
const arr = index.get(parsed.agent);
|
|
158
186
|
if (!arr) return;
|
|
159
|
-
const ei = aiFind(arr,
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
arr
|
|
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
|
|
233
|
+
const parsed = parseItemId(id);
|
|
234
|
+
const entries = rope._agentIndex.get(parsed.agent);
|
|
205
235
|
if (!entries) return -1;
|
|
206
|
-
const ei = aiFind(entries,
|
|
236
|
+
const ei = aiFind(entries, parsed.seq);
|
|
207
237
|
if (ei === -1) return -1;
|
|
208
238
|
const item = entries[ei].item;
|
|
209
|
-
|
|
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:
|
|
272
|
+
id: createItemId(itemParsed.agent, itemParsed.seq),
|
|
241
273
|
content: item.content.slice(0, offset),
|
|
242
274
|
isDeleted: item.isDeleted,
|
|
243
|
-
parentId: item.parentId
|
|
275
|
+
parentId: item.parentId,
|
|
244
276
|
seq: item.seq,
|
|
277
|
+
ts: item.ts,
|
|
245
278
|
};
|
|
246
279
|
|
|
247
280
|
const after: Item = {
|
|
248
|
-
id:
|
|
281
|
+
id: createItemId(itemParsed.agent, itemParsed.seq + offset),
|
|
249
282
|
content: item.content.slice(offset),
|
|
250
283
|
isDeleted: item.isDeleted,
|
|
251
|
-
parentId:
|
|
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 (
|
|
261
|
-
const maxSeqInItem =
|
|
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
|
|
303
|
+
const parsed = parseItemId(id);
|
|
304
|
+
const entries = rope._agentIndex.get(parsed.agent);
|
|
270
305
|
if (!entries) return false;
|
|
271
|
-
return aiFind(entries,
|
|
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
|
|
311
|
+
const parsed = parseItemId(parentId);
|
|
312
|
+
const entries = rope._agentIndex.get(parsed.agent);
|
|
277
313
|
if (!entries) return -1;
|
|
278
|
-
const ei = aiFind(entries,
|
|
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
|
|
321
|
+
const parsedParent = parseItemId(parentId);
|
|
322
|
+
const entries = rope._agentIndex.get(parsedParent.agent);
|
|
286
323
|
if (!entries) return -1;
|
|
287
|
-
const ei = aiFind(entries,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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:
|
|
429
|
+
id: op.id,
|
|
375
430
|
content: op.content,
|
|
376
431
|
isDeleted: false,
|
|
377
|
-
parentId: op.parentId
|
|
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
|
-
|
|
384
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
515
|
+
id: op.id,
|
|
457
516
|
content: op.content,
|
|
458
517
|
isDeleted: false,
|
|
459
|
-
parentId: op.parentId
|
|
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
|
-
|
|
466
|
-
|
|
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:
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
ranges
|
|
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
|
|
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:
|
|
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
|
|
692
|
-
|
|
693
|
-
|
|
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,
|
|
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,
|
|
713
|
-
item.id
|
|
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
|
|
717
|
-
item.
|
|
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(([
|
|
725
|
-
id
|
|
797
|
+
const items: Item[] = rawItems.map(([id, content, parentId, seq, ts]) => ({
|
|
798
|
+
id,
|
|
726
799
|
content,
|
|
727
800
|
isDeleted: false,
|
|
728
|
-
parentId
|
|
729
|
-
seq
|
|
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:
|
|
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
|
|
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?:
|
|
122
|
+
id?: string; // ItemId string format: "agent:seq"
|
|
123
123
|
content?: string;
|
|
124
|
-
parentId?:
|
|
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:
|
|
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:
|
|
159
|
-
id?:
|
|
159
|
+
deletions?: Array<{ id: string; length: number }>; // ItemId string format
|
|
160
|
+
id?: string; // ItemId string format
|
|
160
161
|
content?: string;
|
|
161
|
-
parentId?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|