reptree 1.0.1 → 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 +93 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +22 -5
- package/dist/index.d.ts +22 -5
- package/dist/index.js +93 -50
- 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;
|
|
@@ -1363,14 +1396,14 @@ var _RepTree = class _RepTree {
|
|
|
1363
1396
|
this.pendingMovesWithMissingParent.set(op.parentId, []);
|
|
1364
1397
|
}
|
|
1365
1398
|
this.pendingMovesWithMissingParent.get(op.parentId).push(op);
|
|
1366
|
-
this.markOpSeen(op
|
|
1399
|
+
this.markOpSeen(op);
|
|
1367
1400
|
return;
|
|
1368
1401
|
}
|
|
1369
1402
|
this.updateMoveClock(op);
|
|
1370
1403
|
const lastOp = this.moveOps.length > 0 ? this.moveOps[this.moveOps.length - 1] : null;
|
|
1371
1404
|
if (lastOp === null || isOpIdGreaterThan(op.id, lastOp.id)) {
|
|
1372
1405
|
this.moveOps.push(op);
|
|
1373
|
-
this.
|
|
1406
|
+
this.reportMoveOpAsApplied(op);
|
|
1374
1407
|
this.tryToMove(op);
|
|
1375
1408
|
} else {
|
|
1376
1409
|
let targetIndex = this.moveOps.length;
|
|
@@ -1384,7 +1417,7 @@ var _RepTree = class _RepTree {
|
|
|
1384
1417
|
}
|
|
1385
1418
|
}
|
|
1386
1419
|
this.moveOps.splice(targetIndex + 1, 0, op);
|
|
1387
|
-
this.
|
|
1420
|
+
this.reportMoveOpAsApplied(op);
|
|
1388
1421
|
this.tryToMove(op);
|
|
1389
1422
|
for (let i = targetIndex + 2; i < this.moveOps.length; i++) {
|
|
1390
1423
|
this.tryToMove(this.moveOps[i]);
|
|
@@ -1392,16 +1425,16 @@ var _RepTree = class _RepTree {
|
|
|
1392
1425
|
}
|
|
1393
1426
|
this.applyPendingMovesForParent(op.targetId);
|
|
1394
1427
|
}
|
|
1395
|
-
setLLWPropertyAndItsOpId(op) {
|
|
1396
|
-
this.propertyOpsByKey.set(
|
|
1428
|
+
setLLWPropertyAndItsOpId(op, previousOp) {
|
|
1429
|
+
this.propertyOpsByKey.set(this.getPropertyKey(op), op);
|
|
1397
1430
|
this.state.setProperty(op.targetId, op.key, op.value);
|
|
1398
|
-
this.
|
|
1399
|
-
this.
|
|
1431
|
+
this.recordRetainedPropertyOpInStateVector(op, previousOp);
|
|
1432
|
+
this.reportPropertyOpAsApplied(op);
|
|
1400
1433
|
}
|
|
1401
1434
|
setTransientPropertyAndItsOpId(op) {
|
|
1402
|
-
this.transientPropertiesAndTheirOpIds.set(
|
|
1435
|
+
this.transientPropertiesAndTheirOpIds.set(this.getPropertyKey(op), op.id);
|
|
1403
1436
|
this.state.setTransientProperty(op.targetId, op.key, op.value);
|
|
1404
|
-
this.
|
|
1437
|
+
this.reportPropertyOpAsApplied(op);
|
|
1405
1438
|
}
|
|
1406
1439
|
applyProperty(op) {
|
|
1407
1440
|
const targetNode = this.state.getNode(op.targetId);
|
|
@@ -1413,30 +1446,31 @@ var _RepTree = class _RepTree {
|
|
|
1413
1446
|
this.pendingPropertiesWithMissingNode.set(op.targetId, []);
|
|
1414
1447
|
}
|
|
1415
1448
|
this.pendingPropertiesWithMissingNode.get(op.targetId).push(op);
|
|
1416
|
-
this.markOpSeen(op
|
|
1449
|
+
this.markOpSeen(op);
|
|
1417
1450
|
return;
|
|
1418
1451
|
}
|
|
1419
1452
|
this.updatePropClock(op);
|
|
1420
1453
|
this.applyLLWProperty(op, targetNode);
|
|
1421
1454
|
}
|
|
1422
1455
|
applyLLWProperty(op, targetNode) {
|
|
1423
|
-
const
|
|
1424
|
-
const
|
|
1456
|
+
const propertyKey = this.getPropertyKey(op);
|
|
1457
|
+
const prevTransientOpId = this.transientPropertiesAndTheirOpIds.get(propertyKey);
|
|
1458
|
+
const previousOp = this.propertyOpsByKey.get(propertyKey);
|
|
1425
1459
|
if (!op.transient) {
|
|
1426
|
-
if (!
|
|
1427
|
-
this.setLLWPropertyAndItsOpId(op);
|
|
1460
|
+
if (!previousOp || isOpIdGreaterThan(op.id, previousOp.id)) {
|
|
1461
|
+
this.setLLWPropertyAndItsOpId(op, previousOp);
|
|
1428
1462
|
} else {
|
|
1429
|
-
this.markOpSeen(op
|
|
1463
|
+
this.markOpSeen(op);
|
|
1430
1464
|
}
|
|
1431
1465
|
if (prevTransientOpId && isOpIdGreaterThan(op.id, prevTransientOpId)) {
|
|
1432
|
-
this.transientPropertiesAndTheirOpIds.delete(
|
|
1466
|
+
this.transientPropertiesAndTheirOpIds.delete(propertyKey);
|
|
1433
1467
|
targetNode.removeTransientProperty(op.key);
|
|
1434
1468
|
}
|
|
1435
1469
|
} else {
|
|
1436
1470
|
if (!prevTransientOpId || isOpIdGreaterThan(op.id, prevTransientOpId)) {
|
|
1437
1471
|
this.setTransientPropertyAndItsOpId(op);
|
|
1438
1472
|
} else {
|
|
1439
|
-
this.markOpSeen(op
|
|
1473
|
+
this.markOpSeen(op);
|
|
1440
1474
|
}
|
|
1441
1475
|
}
|
|
1442
1476
|
}
|
|
@@ -1447,18 +1481,21 @@ var _RepTree = class _RepTree {
|
|
|
1447
1481
|
this.applyProperty(op);
|
|
1448
1482
|
}
|
|
1449
1483
|
}
|
|
1450
|
-
markOpSeen(op
|
|
1484
|
+
markOpSeen(op) {
|
|
1451
1485
|
this.knownOps.add(this.getOpKey(op));
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
}
|
|
1486
|
+
}
|
|
1487
|
+
reportMoveOpAsApplied(op) {
|
|
1488
|
+
this.markOpSeen(op);
|
|
1489
|
+
if (this._stateVectorEnabled) {
|
|
1490
|
+
this.moveStateVector.updateFromOp(op);
|
|
1458
1491
|
}
|
|
1492
|
+
this.reportOpAsApplied(op);
|
|
1493
|
+
}
|
|
1494
|
+
reportPropertyOpAsApplied(op) {
|
|
1495
|
+
this.markOpSeen(op);
|
|
1496
|
+
this.reportOpAsApplied(op);
|
|
1459
1497
|
}
|
|
1460
|
-
reportOpAsApplied(op
|
|
1461
|
-
this.markOpSeen(op, includeInStateVector);
|
|
1498
|
+
reportOpAsApplied(op) {
|
|
1462
1499
|
for (const callback of this.opAppliedCallbacks) {
|
|
1463
1500
|
callback(op);
|
|
1464
1501
|
}
|
|
@@ -1493,7 +1530,7 @@ var _RepTree = class _RepTree {
|
|
|
1493
1530
|
// --- Range-Based State Vector Methods ---
|
|
1494
1531
|
/**
|
|
1495
1532
|
* Returns the current state vectors for move and property streams.
|
|
1496
|
-
* Returns
|
|
1533
|
+
* Returns copies of the internal state vectors.
|
|
1497
1534
|
*/
|
|
1498
1535
|
getStateVectors() {
|
|
1499
1536
|
if (!this._stateVectorEnabled) {
|
|
@@ -1561,16 +1598,22 @@ var _RepTree = class _RepTree {
|
|
|
1561
1598
|
getPropertyOps() {
|
|
1562
1599
|
return Array.from(this.propertyOpsByKey.values());
|
|
1563
1600
|
}
|
|
1564
|
-
|
|
1601
|
+
recordRetainedPropertyOpInStateVector(op, previousOp) {
|
|
1565
1602
|
if (!this._stateVectorEnabled) {
|
|
1566
1603
|
return;
|
|
1567
1604
|
}
|
|
1568
|
-
|
|
1605
|
+
if (previousOp) {
|
|
1606
|
+
this.propStateVector.removeFromOp(previousOp);
|
|
1607
|
+
}
|
|
1608
|
+
this.propStateVector.updateFromOp(op);
|
|
1569
1609
|
}
|
|
1570
1610
|
getOpKey(op) {
|
|
1571
1611
|
const stream = isMoveNodeOp(op) ? "move" : "prop";
|
|
1572
1612
|
return `${stream}:${opIdToString(op.id)}`;
|
|
1573
1613
|
}
|
|
1614
|
+
getPropertyKey(op) {
|
|
1615
|
+
return `${op.key}@${op.targetId}`;
|
|
1616
|
+
}
|
|
1574
1617
|
filterOpsByRanges(ops, ranges) {
|
|
1575
1618
|
const missingOps = [];
|
|
1576
1619
|
for (const op of ops) {
|