@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.
- package/CLAUDE.md +3 -2
- package/dist/client/EditBuffer.d.ts +4 -4
- package/dist/client/EditBuffer.d.ts.map +1 -1
- package/dist/client/EditBuffer.js +26 -25
- package/dist/client/EditBuffer.js.map +1 -1
- package/dist/client/actions.d.ts +3 -3
- package/dist/client/actions.d.ts.map +1 -1
- package/dist/client/actions.js +71 -70
- package/dist/client/actions.js.map +1 -1
- package/dist/client/coalesceGraphOps.d.ts +4 -4
- package/dist/client/coalesceGraphOps.js +4 -4
- package/dist/client/coalesceTextOperations.d.ts.map +1 -1
- package/dist/client/coalesceTextOperations.js +23 -20
- package/dist/client/coalesceTextOperations.js.map +1 -1
- package/dist/client/coalescence/lwwOperations.js +3 -3
- package/dist/client/coalescence/lwwOperations.js.map +1 -1
- package/dist/client/coalescence/numberOperations.js +2 -2
- package/dist/client/coalescence/numberOperations.js.map +1 -1
- package/dist/client/coalescence/registry.d.ts +3 -3
- package/dist/client/coalescence/registry.d.ts.map +1 -1
- package/dist/client/coalescence/registry.js +11 -11
- package/dist/client/coalescence/registry.js.map +1 -1
- package/dist/client/coalescence/textDeletes.d.ts +8 -7
- package/dist/client/coalescence/textDeletes.d.ts.map +1 -1
- package/dist/client/coalescence/textDeletes.js +11 -11
- package/dist/client/coalescence/textDeletes.js.map +1 -1
- package/dist/client/coalescence/textInserts.d.ts +8 -5
- package/dist/client/coalescence/textInserts.d.ts.map +1 -1
- package/dist/client/coalescence/textInserts.js +32 -12
- package/dist/client/coalescence/textInserts.js.map +1 -1
- package/dist/client/coalescence/utils.d.ts +3 -9
- package/dist/client/coalescence/utils.d.ts.map +1 -1
- package/dist/client/coalescence/utils.js +10 -8
- package/dist/client/coalescence/utils.js.map +1 -1
- package/dist/client/coalescence/vector3Operations.js +2 -2
- package/dist/client/coalescence/vector3Operations.js.map +1 -1
- package/dist/client/createGraph.d.ts +2 -2
- package/dist/client/createGraph.js +4 -4
- package/dist/client/createGraph.js.map +1 -1
- package/dist/client/createTextDocument.d.ts +1 -1
- package/dist/client/createTextDocument.js +3 -3
- package/dist/client/createTextDocument.js.map +1 -1
- package/dist/client/hooks.d.ts +3 -3
- package/dist/client/hooks.d.ts.map +1 -1
- package/dist/client/hooks.js +4 -4
- package/dist/client/hooks.js.map +1 -1
- package/dist/client/textActions.d.ts +2 -2
- package/dist/client/textActions.d.ts.map +1 -1
- package/dist/client/textActions.js +47 -47
- package/dist/client/textActions.js.map +1 -1
- package/dist/client/textTypes.d.ts +8 -8
- package/dist/client/textTypes.d.ts.map +1 -1
- package/dist/client/types.d.ts +4 -4
- package/dist/client/types.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.d.ts +2 -2
- package/dist/crdt/GraphTextCRDT.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.js +6 -6
- package/dist/crdt/GraphTextCRDT.js.map +1 -1
- package/dist/crdt/Rope.d.ts +13 -14
- package/dist/crdt/Rope.d.ts.map +1 -1
- package/dist/crdt/Rope.js +130 -59
- package/dist/crdt/Rope.js.map +1 -1
- package/dist/crdt/index.d.ts +1 -1
- package/dist/crdt/index.d.ts.map +1 -1
- package/dist/crdt/index.js +1 -1
- package/dist/crdt/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/operations/OperationTypes.d.ts +45 -48
- package/dist/operations/OperationTypes.d.ts.map +1 -1
- package/dist/operations/OperationValidator.js +11 -11
- package/dist/operations/OperationValidator.js.map +1 -1
- package/dist/operations/apply/node.js +3 -3
- package/dist/operations/apply/node.js.map +1 -1
- package/dist/operations/apply/text.d.ts.map +1 -1
- package/dist/operations/apply/text.js +35 -32
- package/dist/operations/apply/text.js.map +1 -1
- package/dist/operations/apply/types.d.ts +4 -4
- package/dist/operations/apply/types.d.ts.map +1 -1
- package/dist/operations/apply/types.js +8 -8
- package/dist/operations/apply/types.js.map +1 -1
- package/dist/operations/dispatcher.d.ts.map +1 -1
- package/dist/operations/dispatcher.js +52 -13
- package/dist/operations/dispatcher.js.map +1 -1
- package/dist/serdes.d.ts +1 -1
- package/dist/serdes.d.ts.map +1 -1
- package/dist/state/ConflictResolver.d.ts +9 -9
- package/dist/state/ConflictResolver.d.ts.map +1 -1
- package/dist/state/ConflictResolver.js +20 -20
- package/dist/state/ConflictResolver.js.map +1 -1
- package/dist/state/DType.d.ts +2 -2
- package/dist/state/DType.d.ts.map +1 -1
- package/dist/state/DType.js +14 -14
- package/dist/state/DType.js.map +1 -1
- package/dist/state/VectorClock.d.ts +6 -6
- package/dist/state/VectorClock.d.ts.map +1 -1
- package/dist/state/VectorClock.js +14 -14
- package/dist/state/VectorClock.js.map +1 -1
- package/dist/state/index.d.ts +1 -1
- package/dist/state/index.js +1 -1
- package/examples/01-basic-usage.ts +16 -16
- package/examples/02-concurrent-edits.ts +29 -29
- package/examples/03-scene-building.ts +28 -28
- package/examples/04-conflict-resolution.ts +56 -56
- package/examples/05-coalescence-usage.ts +23 -23
- package/examples/README.md +12 -12
- package/package.json +1 -1
- package/src/client/EditBuffer.ts +28 -27
- package/src/client/TEXT_DOCUMENT_API.md +9 -9
- package/src/client/actions.ts +74 -70
- package/src/client/coalesceGraphOps.ts +4 -4
- package/src/client/coalesceTextOperations.ts +26 -22
- package/src/client/coalescence/lwwOperations.ts +3 -3
- package/src/client/coalescence/numberOperations.ts +2 -2
- package/src/client/coalescence/registry.ts +13 -12
- package/src/client/coalescence/textDeletes.ts +22 -18
- package/src/client/coalescence/textInserts.ts +49 -25
- package/src/client/coalescence/utils.ts +14 -11
- package/src/client/coalescence/vector3Operations.ts +2 -2
- package/src/client/createGraph.ts +4 -4
- package/src/client/createTextDocument.ts +3 -3
- package/src/client/hooks.tsx +5 -5
- package/src/client/textActions.ts +47 -47
- package/src/client/textTypes.ts +8 -8
- package/src/client/types.ts +4 -4
- package/src/crdt/GraphTextCRDT.ts +6 -6
- package/src/crdt/Rope.ts +156 -71
- package/src/crdt/index.ts +2 -0
- package/src/index.ts +2 -0
- package/src/operations/OperationTypes.ts +47 -47
- package/src/operations/OperationValidator.ts +11 -11
- package/src/operations/apply/node.ts +3 -3
- package/src/operations/apply/text.ts +38 -32
- package/src/operations/apply/types.ts +11 -11
- package/src/operations/dispatcher.ts +57 -13
- package/src/serdes.ts +1 -1
- package/src/state/ConflictResolver.ts +23 -23
- package/src/state/DType.ts +16 -16
- package/src/state/VectorClock.ts +14 -14
- package/src/state/index.ts +1 -1
- package/tests/client/actions.test.ts +76 -76
- package/tests/client/coalesce-graph-operations.test.ts +84 -84
- package/tests/client/coalesce-text-operations.test.ts +91 -114
- package/tests/client/compaction.test.ts +18 -18
- package/tests/client/delete-coalescence-bug.test.ts +34 -34
- package/tests/client/edit-buffer.test.ts +27 -30
- package/tests/client/graph-coalescence-phase1.test.ts +66 -66
- package/tests/client/graph-coalescence.test.ts +50 -50
- package/tests/client/journal-benchmark.test.ts +5 -5
- package/tests/crdt/graph-text-crdt.test.ts +60 -64
- package/tests/crdt/rope.test.ts +9 -8
- package/tests/crdt/text-operations.test.ts +28 -28
- package/tests/fixtures/array-ops.jsonl +6 -6
- package/tests/fixtures/boolean-ops.jsonl +6 -6
- package/tests/fixtures/color-ops.jsonl +4 -4
- package/tests/fixtures/edit-buffer.jsonl +3 -3
- package/tests/fixtures/node-ops.jsonl +6 -6
- package/tests/fixtures/number-ops.jsonl +7 -7
- package/tests/fixtures/object-ops.jsonl +4 -4
- package/tests/fixtures/operations.jsonl +7 -7
- package/tests/fixtures/string-ops.jsonl +4 -4
- package/tests/fixtures/undo-redo.jsonl +3 -3
- package/tests/fixtures/vector-ops.jsonl +17 -17
- package/tests/operations/collections.test.ts +4 -4
- package/tests/operations/nodes.test.ts +5 -5
- package/tests/operations/operation-ordering.test.ts +406 -0
- package/tests/operations/primitives.test.ts +4 -4
- package/tests/operations/unified-schema.test.ts +27 -27
- package/tests/operations/vectors.test.ts +4 -4
- package/tests/sync/digest.test.ts +5 -5
package/src/client/textTypes.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
23
|
+
client: string;
|
|
24
24
|
operations: TextOperation[];
|
|
25
25
|
vectorClock: VectorClock;
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
lt: number;
|
|
76
76
|
vectorClock: VectorClock;
|
|
77
|
-
|
|
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
|
-
|
|
84
|
+
client: string;
|
|
85
85
|
initialSnapshot?: TextSnapshot;
|
|
86
86
|
onSend?: (msg: TextMessage) => void;
|
|
87
87
|
onStateChange?: (state: TextDocumentState) => void;
|
package/src/client/types.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface EditBuffer {
|
|
|
34
34
|
export interface Snapshot {
|
|
35
35
|
graph: SceneGraph;
|
|
36
36
|
vectorClock: VectorClock;
|
|
37
|
-
|
|
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
|
-
|
|
58
|
+
lt: number;
|
|
59
59
|
vectorClock: VectorClock;
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
47
|
+
private _client: string;
|
|
48
48
|
|
|
49
|
-
constructor(
|
|
49
|
+
constructor(client: string) {
|
|
50
50
|
this._state = new Map();
|
|
51
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
93
|
+
ot: 'insert';
|
|
94
94
|
id: ItemId;
|
|
95
|
-
|
|
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
|
-
|
|
103
|
-
|
|
101
|
+
ot: 'delete';
|
|
102
|
+
rm: Array<[ItemId, number]>; // [[itemId, length], ...]
|
|
104
103
|
}
|
|
105
104
|
|
|
106
105
|
export interface MoveOp {
|
|
107
|
-
|
|
106
|
+
ot: 'move';
|
|
108
107
|
delete: DeleteOp;
|
|
109
108
|
insert: InsertOp;
|
|
110
109
|
}
|
|
111
110
|
|
|
112
111
|
export interface ReplaceOp {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
318
|
-
if (
|
|
319
|
-
const parsed = parseItemId(
|
|
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,
|
|
328
|
-
if (
|
|
329
|
-
const parsedParent = parseItemId(
|
|
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.
|
|
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
|
|
443
|
+
content,
|
|
439
444
|
isDeleted: false,
|
|
440
|
-
|
|
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 +
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
552
|
+
content,
|
|
547
553
|
isDeleted: false,
|
|
548
|
-
|
|
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 +
|
|
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 {
|
|
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
|
|
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 =
|
|
619
|
-
if (lastDeletion && canMergeDeletions(lastDeletion
|
|
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
|
|
627
|
+
lastDeletion[1] += item.content.length;
|
|
622
628
|
} else {
|
|
623
629
|
// Cannot merge: add new deletion entry
|
|
624
|
-
|
|
630
|
+
rm.push([item.id, item.content.length]);
|
|
625
631
|
}
|
|
626
632
|
|
|
627
633
|
remaining -= toDelete;
|
|
628
634
|
}
|
|
629
635
|
|
|
630
|
-
return {
|
|
636
|
+
return { ot: 'delete', rm };
|
|
631
637
|
}
|
|
632
638
|
|
|
633
639
|
export function applyDelete(rope: TextRope, op: DeleteOp): void {
|
|
634
|
-
if (op.
|
|
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.
|
|
639
|
-
const
|
|
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 +
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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 {
|
|
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
|
-
|
|
868
|
+
ot: 'insert',
|
|
791
869
|
id: item.id,
|
|
792
|
-
|
|
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, {
|
|
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 |
|
|
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 |
|
|
921
|
+
const rawItems: Array<[string, string, string | undefined, number, number]> = items.map(item => [
|
|
845
922
|
item.id,
|
|
846
923
|
item.content,
|
|
847
|
-
item.
|
|
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,
|
|
933
|
+
const items: Item[] = rawItems.map(([id, content, anchor, seq, ts]) => ({
|
|
857
934
|
id,
|
|
858
935
|
content,
|
|
859
936
|
isDeleted: false,
|
|
860
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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