reptree 1.0.0 → 1.0.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/README.md +95 -80
- package/dist/index.cjs +110 -55
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -6
- package/dist/index.d.ts +30 -6
- package/dist/index.js +110 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,116 +1,131 @@
|
|
|
1
|
-
# RepTree
|
|
1
|
+
# RepTree
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
RepTree is a small TypeScript library for replicated trees with properties.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Use it when your application state is naturally a tree and multiple peers may edit it: folders, documents with nested blocks, scene graphs, project structures, object graphs, or any UI model where nodes can move and carry properties.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
RepTree is built on CRDTs, so peers can exchange operations in any order and converge on the same state without a central merge coordinator.
|
|
8
8
|
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
If you have a tree structure in your app where each node can be moved independently by multiple users, you need a solution that resolves conflicts when the same node is moved in different ways. Otherwise your tree can diverge or form loops. This includes folder structures (people creating and moving folders), 2D/3D scenes with objects being moved and parented, and Notion‑like documents where blocks with text and other properties are edited by users.
|
|
12
|
-
|
|
13
|
-
You probably also want properties on each node and to have them sync correctly between peers without conflicts. RepTree syncs properties too.
|
|
14
|
-
|
|
15
|
-
## Getting started
|
|
9
|
+
## Install
|
|
16
10
|
|
|
17
11
|
```bash
|
|
18
12
|
npm install reptree
|
|
19
13
|
```
|
|
20
14
|
|
|
21
|
-
|
|
15
|
+
## What RepTree Gives You
|
|
16
|
+
|
|
17
|
+
- Tree nodes that can be created, moved, deleted, and observed.
|
|
18
|
+
- JSON-serializable properties on every node.
|
|
19
|
+
- Last-writer-wins property conflict resolution.
|
|
20
|
+
- Move conflict resolution based on the move operation tree CRDT.
|
|
21
|
+
- Operation-based sync for persistence or peer replication.
|
|
22
|
+
- State vectors for efficient delta sync.
|
|
23
|
+
- Optional typed/reactive node bindings.
|
|
24
|
+
|
|
25
|
+
## Basic Usage
|
|
26
|
+
|
|
22
27
|
```ts
|
|
23
28
|
import { RepTree } from "reptree";
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
30
|
+
const tree = new RepTree("peer-a");
|
|
31
|
+
const root = tree.createRoot();
|
|
32
|
+
root.name = "Project";
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
const docs = root.newNamedChild("Docs", {
|
|
35
|
+
type: "folder",
|
|
36
|
+
icon: "folder",
|
|
37
|
+
});
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
const readme = docs.newNamedChild("README.md", {
|
|
40
|
+
type: "file",
|
|
41
|
+
size: 2048,
|
|
42
|
+
tags: ["docs", "intro"],
|
|
43
|
+
});
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
alice.setProperty("age", 32);
|
|
39
|
-
alice.setProperty("meta", { department: "QA", skills: ["cypress", "playwright"], flags: { lead: false } });
|
|
45
|
+
const assets = root.newNamedChild("Assets", { type: "folder" });
|
|
46
|
+
readme.moveTo(assets);
|
|
40
47
|
|
|
41
|
-
//
|
|
42
|
-
|
|
48
|
+
console.log(readme.parent?.name); // "Assets"
|
|
49
|
+
console.log(readme.getProperty("size")); // 2048
|
|
50
|
+
```
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
const bob = qa.newChild().bind<{ name: string; age: number }>();
|
|
46
|
-
bob.name = "Bob";
|
|
47
|
-
bob.age = 33;
|
|
52
|
+
## Sync
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
RepTree syncs by exchanging operations. Store operations, send them over the network, and merge operations received from other peers.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { RepTree } from "reptree";
|
|
58
|
+
|
|
59
|
+
const alice = new RepTree("alice");
|
|
60
|
+
const aliceRoot = alice.createRoot();
|
|
61
|
+
aliceRoot.newNamedChild("Roadmap", { status: "draft" });
|
|
62
|
+
|
|
63
|
+
const bob = new RepTree("bob");
|
|
64
|
+
bob.merge(alice.getAllOps());
|
|
65
|
+
|
|
66
|
+
bob.root!.newNamedChild("Notes", { status: "open" });
|
|
67
|
+
alice.merge(bob.getAllOps());
|
|
55
68
|
```
|
|
56
69
|
|
|
57
|
-
|
|
70
|
+
For larger trees, use state vectors to send only operations the other peer is missing:
|
|
58
71
|
|
|
59
|
-
```
|
|
60
|
-
|
|
72
|
+
```ts
|
|
73
|
+
const aliceVectors = alice.getStateVectors();
|
|
61
74
|
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
if (aliceVectors) {
|
|
76
|
+
const opsForAlice = bob.getMissingOps(aliceVectors);
|
|
77
|
+
alice.merge(opsForAlice);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
66
80
|
|
|
67
|
-
|
|
68
|
-
const docsFolder = root.newNamedChild("Docs");
|
|
69
|
-
docsFolder.setProperties({
|
|
70
|
-
type: "folder",
|
|
71
|
-
icon: "folder-icon",
|
|
72
|
-
});
|
|
81
|
+
Property operations are compacted by `(nodeId, key)`: RepTree keeps the latest winning property operation for each property and uses the property state vector to describe that retained, sendable set. Move operations keep their history so the move CRDT can resolve structural conflicts.
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
imagesFolder.setProperties({
|
|
76
|
-
type: "folder",
|
|
77
|
-
icon: "image-icon",
|
|
78
|
-
});
|
|
83
|
+
## Typed Nodes
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
const readmeFile = docsFolder.newNamedChild("README.md");
|
|
82
|
-
readmeFile.setProperties({
|
|
83
|
-
type: "file",
|
|
84
|
-
size: 2048,
|
|
85
|
-
lastModified: "2023-10-15T14:22:10Z",
|
|
86
|
-
s3Path: "s3://my-bucket/docs/README.md",
|
|
87
|
-
});
|
|
85
|
+
You can bind a node to a live typed object. Reads and writes go through RepTree.
|
|
88
86
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
87
|
+
```ts
|
|
88
|
+
type Task = {
|
|
89
|
+
title: string;
|
|
90
|
+
done: boolean;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const task = root.newNamedChild("Task").bind<Task>();
|
|
94
|
+
task.title = "Write README";
|
|
95
|
+
task.done = false;
|
|
96
|
+
|
|
97
|
+
task.$observe(() => {
|
|
98
|
+
console.log(task.title, task.done);
|
|
95
99
|
});
|
|
100
|
+
```
|
|
96
101
|
|
|
97
|
-
|
|
98
|
-
logoFile.moveTo(docsFolder);
|
|
102
|
+
Zod-style schemas are supported for runtime validation:
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
```ts
|
|
105
|
+
import { z } from "zod";
|
|
102
106
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
const TaskSchema = z.object({
|
|
108
|
+
title: z.string(),
|
|
109
|
+
done: z.boolean(),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const checkedTask = root.newNamedChild("Checked task").bind(TaskSchema);
|
|
113
|
+
checkedTask.title = "Publish package";
|
|
114
|
+
checkedTask.done = true;
|
|
107
115
|
```
|
|
108
116
|
|
|
109
|
-
##
|
|
117
|
+
## Operation Model
|
|
118
|
+
|
|
119
|
+
RepTree uses two conflict-free replicated data types:
|
|
120
|
+
|
|
121
|
+
- A move tree CRDT for the tree structure, based on Kleppmann's [move operation paper](https://martin.kleppmann.com/papers/move-op.pdf).
|
|
122
|
+
- A last-writer-wins (LWW) CRDT for node properties.
|
|
123
|
+
|
|
124
|
+
Both streams have independent counters and state vectors. This keeps property-heavy workloads compact while preserving the move history needed for deterministic tree convergence.
|
|
125
|
+
|
|
126
|
+
## Origin
|
|
110
127
|
|
|
111
|
-
RepTree
|
|
112
|
-
- A move tree CRDT for the tree structure (https://martin.kleppmann.com/papers/move-op.pdf).
|
|
113
|
-
- A last-writer-wins (LWW) CRDT for properties.
|
|
128
|
+
RepTree was created for [Sila](https://github.com/silaorg/sila), an open-source alternative to ChatGPT.
|
|
114
129
|
|
|
115
130
|
## License
|
|
116
131
|
|
package/dist/index.cjs
CHANGED
|
@@ -793,12 +793,63 @@ var StateVector = class _StateVector {
|
|
|
793
793
|
updateFromOp(op) {
|
|
794
794
|
this.update(op.id.peerId, op.id.counter);
|
|
795
795
|
}
|
|
796
|
+
/**
|
|
797
|
+
* Removes a single operation ID from the state vector.
|
|
798
|
+
* Assumes ranges are sorted and non-overlapping.
|
|
799
|
+
*
|
|
800
|
+
* @param peerId The peer ID of the operation
|
|
801
|
+
* @param counter The counter value of the operation
|
|
802
|
+
* @returns true if a counter was removed, false if it was absent
|
|
803
|
+
*/
|
|
804
|
+
remove(peerId, counter) {
|
|
805
|
+
const ranges = this.ranges[peerId];
|
|
806
|
+
if (!ranges) {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
810
|
+
const range = ranges[i];
|
|
811
|
+
const [start, end] = range;
|
|
812
|
+
if (counter < start) {
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
if (counter > end) {
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (start === end) {
|
|
819
|
+
ranges.splice(i, 1);
|
|
820
|
+
} else if (counter === start) {
|
|
821
|
+
range[0] = start + 1;
|
|
822
|
+
} else if (counter === end) {
|
|
823
|
+
range[1] = end - 1;
|
|
824
|
+
} else {
|
|
825
|
+
ranges.splice(i, 1, [start, counter - 1], [counter + 1, end]);
|
|
826
|
+
}
|
|
827
|
+
if (ranges.length === 0) {
|
|
828
|
+
delete this.ranges[peerId];
|
|
829
|
+
}
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Removes the operation ID from the state vector.
|
|
836
|
+
*
|
|
837
|
+
* @param op The operation to remove
|
|
838
|
+
* @returns true if the operation ID was removed, false if it was absent
|
|
839
|
+
*/
|
|
840
|
+
removeFromOp(op) {
|
|
841
|
+
return this.remove(op.id.peerId, op.id.counter);
|
|
842
|
+
}
|
|
796
843
|
/**
|
|
797
844
|
* Returns the current state vector.
|
|
798
|
-
* Returns a
|
|
845
|
+
* Returns a deep copy so callers cannot mutate internal state.
|
|
799
846
|
*/
|
|
800
847
|
getState() {
|
|
801
|
-
|
|
848
|
+
const state = {};
|
|
849
|
+
for (const [peerId, peerRanges] of Object.entries(this.ranges)) {
|
|
850
|
+
state[peerId] = peerRanges.map((range) => [...range]);
|
|
851
|
+
}
|
|
852
|
+
return state;
|
|
802
853
|
}
|
|
803
854
|
/**
|
|
804
855
|
* Calculates which operation ranges we have that the other state vector is missing
|
|
@@ -809,9 +860,8 @@ var StateVector = class _StateVector {
|
|
|
809
860
|
*/
|
|
810
861
|
diff(other) {
|
|
811
862
|
const missingRanges = [];
|
|
812
|
-
const theirState = other.getState();
|
|
813
863
|
for (const [peerId, ourRanges] of Object.entries(this.ranges)) {
|
|
814
|
-
const theirRanges =
|
|
864
|
+
const theirRanges = other.ranges[peerId] || [];
|
|
815
865
|
const missing = subtractRanges(ourRanges, theirRanges);
|
|
816
866
|
for (const [start, end] of missing) {
|
|
817
867
|
if (start <= end) {
|
|
@@ -1181,23 +1231,6 @@ var _RepTree = class _RepTree {
|
|
|
1181
1231
|
this.applyOperation(op);
|
|
1182
1232
|
}
|
|
1183
1233
|
}
|
|
1184
|
-
/** Applies operations in an optimized way, sorting move ops by OpId to avoid undo-do-redo cycles */
|
|
1185
|
-
applyOpsOptimizedForLotsOfMoves(ops) {
|
|
1186
|
-
const newMoveOps = ops.filter((op) => isMoveNodeOp(op) && !this.knownOps.has(this.getOpKey(op)));
|
|
1187
|
-
if (newMoveOps.length > 0) {
|
|
1188
|
-
const allMoveOps = [...this.moveOps, ...newMoveOps];
|
|
1189
|
-
allMoveOps.sort((a, b) => compareOpId(a.id, b.id));
|
|
1190
|
-
for (let i = 0, len = allMoveOps.length; i < len; i++) {
|
|
1191
|
-
const op = allMoveOps[i];
|
|
1192
|
-
this.applyMove(op);
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
const propertyOps = ops.filter((op) => isAnyPropertyOp(op) && !this.knownOps.has(this.getOpKey(op)));
|
|
1196
|
-
for (let i = 0, len = propertyOps.length; i < len; i++) {
|
|
1197
|
-
const op = propertyOps[i];
|
|
1198
|
-
this.applyProperty(op);
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
1234
|
compareStructure(other) {
|
|
1202
1235
|
if (this.root?.id !== other.root?.id) {
|
|
1203
1236
|
return false;
|
|
@@ -1220,16 +1253,29 @@ var _RepTree = class _RepTree {
|
|
|
1220
1253
|
}
|
|
1221
1254
|
return true;
|
|
1222
1255
|
}
|
|
1223
|
-
/** Checks
|
|
1256
|
+
/** Checks whether moving `targetId` under `parentId` would create a cycle. */
|
|
1257
|
+
wouldMoveCreateCycle(move) {
|
|
1258
|
+
if (move.targetId === move.parentId) return true;
|
|
1259
|
+
if (move.parentId === null) return false;
|
|
1260
|
+
return this.hasAncestor(move.parentId, move.targetId);
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Checks if the given `ancestorId` is an ancestor of `childId` in the tree.
|
|
1264
|
+
*
|
|
1265
|
+
* @deprecated Use `wouldMoveCreateCycle` for move validation.
|
|
1266
|
+
*/
|
|
1224
1267
|
isAncestor(childId, ancestorId) {
|
|
1225
|
-
|
|
1268
|
+
return this.hasAncestor(childId, ancestorId);
|
|
1269
|
+
}
|
|
1270
|
+
hasAncestor(nodeId, ancestorId) {
|
|
1271
|
+
let targetId = nodeId;
|
|
1226
1272
|
let node;
|
|
1227
1273
|
const visitedNodes = /* @__PURE__ */ new Set();
|
|
1228
1274
|
while (node = this.state.getNode(targetId)) {
|
|
1229
1275
|
if (node.parentId === ancestorId) return true;
|
|
1230
1276
|
if (!node.parentId) return false;
|
|
1231
1277
|
if (visitedNodes.has(targetId)) {
|
|
1232
|
-
console.error(`
|
|
1278
|
+
console.error(`hasAncestor: cycle detected in the tree structure.`);
|
|
1233
1279
|
return false;
|
|
1234
1280
|
}
|
|
1235
1281
|
visitedNodes.add(targetId);
|
|
@@ -1350,14 +1396,14 @@ var _RepTree = class _RepTree {
|
|
|
1350
1396
|
this.pendingMovesWithMissingParent.set(op.parentId, []);
|
|
1351
1397
|
}
|
|
1352
1398
|
this.pendingMovesWithMissingParent.get(op.parentId).push(op);
|
|
1353
|
-
this.markOpSeen(op
|
|
1399
|
+
this.markOpSeen(op);
|
|
1354
1400
|
return;
|
|
1355
1401
|
}
|
|
1356
1402
|
this.updateMoveClock(op);
|
|
1357
1403
|
const lastOp = this.moveOps.length > 0 ? this.moveOps[this.moveOps.length - 1] : null;
|
|
1358
1404
|
if (lastOp === null || isOpIdGreaterThan(op.id, lastOp.id)) {
|
|
1359
1405
|
this.moveOps.push(op);
|
|
1360
|
-
this.
|
|
1406
|
+
this.reportMoveOpAsApplied(op);
|
|
1361
1407
|
this.tryToMove(op);
|
|
1362
1408
|
} else {
|
|
1363
1409
|
let targetIndex = this.moveOps.length;
|
|
@@ -1371,7 +1417,7 @@ var _RepTree = class _RepTree {
|
|
|
1371
1417
|
}
|
|
1372
1418
|
}
|
|
1373
1419
|
this.moveOps.splice(targetIndex + 1, 0, op);
|
|
1374
|
-
this.
|
|
1420
|
+
this.reportMoveOpAsApplied(op);
|
|
1375
1421
|
this.tryToMove(op);
|
|
1376
1422
|
for (let i = targetIndex + 2; i < this.moveOps.length; i++) {
|
|
1377
1423
|
this.tryToMove(this.moveOps[i]);
|
|
@@ -1379,16 +1425,16 @@ var _RepTree = class _RepTree {
|
|
|
1379
1425
|
}
|
|
1380
1426
|
this.applyPendingMovesForParent(op.targetId);
|
|
1381
1427
|
}
|
|
1382
|
-
setLLWPropertyAndItsOpId(op) {
|
|
1383
|
-
this.propertyOpsByKey.set(
|
|
1428
|
+
setLLWPropertyAndItsOpId(op, previousOp) {
|
|
1429
|
+
this.propertyOpsByKey.set(this.getPropertyKey(op), op);
|
|
1384
1430
|
this.state.setProperty(op.targetId, op.key, op.value);
|
|
1385
|
-
this.
|
|
1386
|
-
this.
|
|
1431
|
+
this.recordRetainedPropertyOpInStateVector(op, previousOp);
|
|
1432
|
+
this.reportPropertyOpAsApplied(op);
|
|
1387
1433
|
}
|
|
1388
1434
|
setTransientPropertyAndItsOpId(op) {
|
|
1389
|
-
this.transientPropertiesAndTheirOpIds.set(
|
|
1435
|
+
this.transientPropertiesAndTheirOpIds.set(this.getPropertyKey(op), op.id);
|
|
1390
1436
|
this.state.setTransientProperty(op.targetId, op.key, op.value);
|
|
1391
|
-
this.
|
|
1437
|
+
this.reportPropertyOpAsApplied(op);
|
|
1392
1438
|
}
|
|
1393
1439
|
applyProperty(op) {
|
|
1394
1440
|
const targetNode = this.state.getNode(op.targetId);
|
|
@@ -1400,30 +1446,31 @@ var _RepTree = class _RepTree {
|
|
|
1400
1446
|
this.pendingPropertiesWithMissingNode.set(op.targetId, []);
|
|
1401
1447
|
}
|
|
1402
1448
|
this.pendingPropertiesWithMissingNode.get(op.targetId).push(op);
|
|
1403
|
-
this.markOpSeen(op
|
|
1449
|
+
this.markOpSeen(op);
|
|
1404
1450
|
return;
|
|
1405
1451
|
}
|
|
1406
1452
|
this.updatePropClock(op);
|
|
1407
1453
|
this.applyLLWProperty(op, targetNode);
|
|
1408
1454
|
}
|
|
1409
1455
|
applyLLWProperty(op, targetNode) {
|
|
1410
|
-
const
|
|
1411
|
-
const
|
|
1456
|
+
const propertyKey = this.getPropertyKey(op);
|
|
1457
|
+
const prevTransientOpId = this.transientPropertiesAndTheirOpIds.get(propertyKey);
|
|
1458
|
+
const previousOp = this.propertyOpsByKey.get(propertyKey);
|
|
1412
1459
|
if (!op.transient) {
|
|
1413
|
-
if (!
|
|
1414
|
-
this.setLLWPropertyAndItsOpId(op);
|
|
1460
|
+
if (!previousOp || isOpIdGreaterThan(op.id, previousOp.id)) {
|
|
1461
|
+
this.setLLWPropertyAndItsOpId(op, previousOp);
|
|
1415
1462
|
} else {
|
|
1416
|
-
this.markOpSeen(op
|
|
1463
|
+
this.markOpSeen(op);
|
|
1417
1464
|
}
|
|
1418
1465
|
if (prevTransientOpId && isOpIdGreaterThan(op.id, prevTransientOpId)) {
|
|
1419
|
-
this.transientPropertiesAndTheirOpIds.delete(
|
|
1466
|
+
this.transientPropertiesAndTheirOpIds.delete(propertyKey);
|
|
1420
1467
|
targetNode.removeTransientProperty(op.key);
|
|
1421
1468
|
}
|
|
1422
1469
|
} else {
|
|
1423
1470
|
if (!prevTransientOpId || isOpIdGreaterThan(op.id, prevTransientOpId)) {
|
|
1424
1471
|
this.setTransientPropertyAndItsOpId(op);
|
|
1425
1472
|
} else {
|
|
1426
|
-
this.markOpSeen(op
|
|
1473
|
+
this.markOpSeen(op);
|
|
1427
1474
|
}
|
|
1428
1475
|
}
|
|
1429
1476
|
}
|
|
@@ -1434,18 +1481,21 @@ var _RepTree = class _RepTree {
|
|
|
1434
1481
|
this.applyProperty(op);
|
|
1435
1482
|
}
|
|
1436
1483
|
}
|
|
1437
|
-
markOpSeen(op
|
|
1484
|
+
markOpSeen(op) {
|
|
1438
1485
|
this.knownOps.add(this.getOpKey(op));
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
}
|
|
1486
|
+
}
|
|
1487
|
+
reportMoveOpAsApplied(op) {
|
|
1488
|
+
this.markOpSeen(op);
|
|
1489
|
+
if (this._stateVectorEnabled) {
|
|
1490
|
+
this.moveStateVector.updateFromOp(op);
|
|
1445
1491
|
}
|
|
1492
|
+
this.reportOpAsApplied(op);
|
|
1493
|
+
}
|
|
1494
|
+
reportPropertyOpAsApplied(op) {
|
|
1495
|
+
this.markOpSeen(op);
|
|
1496
|
+
this.reportOpAsApplied(op);
|
|
1446
1497
|
}
|
|
1447
|
-
reportOpAsApplied(op
|
|
1448
|
-
this.markOpSeen(op, includeInStateVector);
|
|
1498
|
+
reportOpAsApplied(op) {
|
|
1449
1499
|
for (const callback of this.opAppliedCallbacks) {
|
|
1450
1500
|
callback(op);
|
|
1451
1501
|
}
|
|
@@ -1455,8 +1505,7 @@ var _RepTree = class _RepTree {
|
|
|
1455
1505
|
if (targetNode) {
|
|
1456
1506
|
this.parentIdBeforeMove.set(op.id, targetNode.parentId);
|
|
1457
1507
|
}
|
|
1458
|
-
if (
|
|
1459
|
-
if (op.parentId && this.isAncestor(op.parentId, op.targetId)) return;
|
|
1508
|
+
if (this.wouldMoveCreateCycle(op)) return;
|
|
1460
1509
|
this.state.moveNode(op.targetId, op.parentId);
|
|
1461
1510
|
if (!targetNode) {
|
|
1462
1511
|
const pendingProperties = this.pendingPropertiesWithMissingNode.get(op.targetId) || [];
|
|
@@ -1481,7 +1530,7 @@ var _RepTree = class _RepTree {
|
|
|
1481
1530
|
// --- Range-Based State Vector Methods ---
|
|
1482
1531
|
/**
|
|
1483
1532
|
* Returns the current state vectors for move and property streams.
|
|
1484
|
-
* Returns
|
|
1533
|
+
* Returns copies of the internal state vectors.
|
|
1485
1534
|
*/
|
|
1486
1535
|
getStateVectors() {
|
|
1487
1536
|
if (!this._stateVectorEnabled) {
|
|
@@ -1549,16 +1598,22 @@ var _RepTree = class _RepTree {
|
|
|
1549
1598
|
getPropertyOps() {
|
|
1550
1599
|
return Array.from(this.propertyOpsByKey.values());
|
|
1551
1600
|
}
|
|
1552
|
-
|
|
1601
|
+
recordRetainedPropertyOpInStateVector(op, previousOp) {
|
|
1553
1602
|
if (!this._stateVectorEnabled) {
|
|
1554
1603
|
return;
|
|
1555
1604
|
}
|
|
1556
|
-
|
|
1605
|
+
if (previousOp) {
|
|
1606
|
+
this.propStateVector.removeFromOp(previousOp);
|
|
1607
|
+
}
|
|
1608
|
+
this.propStateVector.updateFromOp(op);
|
|
1557
1609
|
}
|
|
1558
1610
|
getOpKey(op) {
|
|
1559
1611
|
const stream = isMoveNodeOp(op) ? "move" : "prop";
|
|
1560
1612
|
return `${stream}:${opIdToString(op.id)}`;
|
|
1561
1613
|
}
|
|
1614
|
+
getPropertyKey(op) {
|
|
1615
|
+
return `${op.key}@${op.targetId}`;
|
|
1616
|
+
}
|
|
1562
1617
|
filterOpsByRanges(ops, ranges) {
|
|
1563
1618
|
const missingOps = [];
|
|
1564
1619
|
for (const op of ops) {
|