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 CHANGED
@@ -1,116 +1,131 @@
1
- # RepTree - replicated trees with properties
1
+ # RepTree
2
2
 
3
- A JavaScript tree data structure for storing and syncing app state. It can be used both to represent and persist the state in the frontend and backend.
3
+ RepTree is a small TypeScript library for replicated trees with properties.
4
4
 
5
- RepTree uses [CRDTs](https://crdt.tech/) for seamless replication between users.
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
- > RepTree was created for the [Sila](https://github.com/silaorg/sila) project, an open-source alternative to ChatGPT.
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
- ## What it solves
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
- ### Example 1
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
- // Create a tree with a root
26
- const tree = new RepTree("company-org-1");
27
- const company = tree.createRoot();
30
+ const tree = new RepTree("peer-a");
31
+ const root = tree.createRoot();
32
+ root.name = "Project";
28
33
 
29
- // Create nodes in the root of our new tree
30
- const devs = company.newNamedChild("developers");
31
- const qa = company.newNamedChild("qa");
34
+ const docs = root.newNamedChild("Docs", {
35
+ type: "folder",
36
+ icon: "folder",
37
+ });
32
38
 
33
- // Create a node in another node
34
- const alice = qa.newChild();
39
+ const readme = docs.newNamedChild("README.md", {
40
+ type: "file",
41
+ size: 2048,
42
+ tags: ["docs", "intro"],
43
+ });
35
44
 
36
- // Set properties (supports any JSON-serializable values)
37
- alice.setProperty("name", "Alice");
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
- // Move the node inside a different node
42
- alice.moveTo(devs);
48
+ console.log(readme.parent?.name); // "Assets"
49
+ console.log(readme.getProperty("size")); // 2048
50
+ ```
43
51
 
44
- // Bind a node to a type to set its properties like regular fields
45
- const bob = qa.newChild().bind<{ name: string; age: number }>();
46
- bob.name = "Bob";
47
- bob.age = 33;
52
+ ## Sync
48
53
 
49
- // Use a Zod type for runtime type checks
50
- import { z } from "zod";
51
- const Person = z.object({ name: z.string(), age: z.number().int().min(0) });
52
- const casey = devs.newNamedChild("Casey").bind(Person);
53
- casey.name = "Casey";
54
- casey.age = 34;
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
- ### Example 2
70
+ For larger trees, use state vectors to send only operations the other peer is missing:
58
71
 
59
- ```typescript
60
- import { RepTree } from "reptree";
72
+ ```ts
73
+ const aliceVectors = alice.getStateVectors();
61
74
 
62
- // Create a new tree
63
- const tree = new RepTree("peer1");
64
- const root = tree.createRoot();
65
- root.name = "Project";
75
+ if (aliceVectors) {
76
+ const opsForAlice = bob.getMissingOps(aliceVectors);
77
+ alice.merge(opsForAlice);
78
+ }
79
+ ```
66
80
 
67
- // Create a folder structure with properties
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
- const imagesFolder = root.newNamedChild("Images");
75
- imagesFolder.setProperties({
76
- type: "folder",
77
- icon: "image-icon",
78
- });
83
+ ## Typed Nodes
79
84
 
80
- // Add files to folders
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
- const logoFile = imagesFolder.newNamedChild("logo.png");
90
- logoFile.setProperties({
91
- type: "file",
92
- size: 15360,
93
- meta: { dimensions: "512x512", format: "png" },
94
- s3Path: "s3://my-bucket/images/logo.png",
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
- // Move a file to a different folder
98
- logoFile.moveTo(docsFolder);
102
+ Zod-style schemas are supported for runtime validation:
99
103
 
100
- // Get children of a folder
101
- const docsFolderContents = docsFolder.children;
104
+ ```ts
105
+ import { z } from "zod";
102
106
 
103
- // Syncing between trees
104
- const otherTree = new RepTree("peer2");
105
- const ops = tree.getAllOps();
106
- otherTree.merge(ops);
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
- ## CRDTs
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 uses two conflict-free replicated data types (CRDTs):
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 readonly reference to the internal state.
845
+ * Returns a deep copy so callers cannot mutate internal state.
799
846
  */
800
847
  getState() {
801
- return this.ranges;
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 = theirState[peerId] || [];
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 if the given `ancestorId` is an ancestor of `childId` in the tree */
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
- let targetId = childId;
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(`isAncestor: cycle detected in the tree structure.`);
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, true);
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.reportOpAsApplied(op);
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.reportOpAsApplied(op);
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(`${op.key}@${op.targetId}`, op);
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.reportOpAsApplied(op, false);
1386
- this.refreshPropStateVector();
1431
+ this.recordRetainedPropertyOpInStateVector(op, previousOp);
1432
+ this.reportPropertyOpAsApplied(op);
1387
1433
  }
1388
1434
  setTransientPropertyAndItsOpId(op) {
1389
- this.transientPropertiesAndTheirOpIds.set(`${op.key}@${op.targetId}`, op.id);
1435
+ this.transientPropertiesAndTheirOpIds.set(this.getPropertyKey(op), op.id);
1390
1436
  this.state.setTransientProperty(op.targetId, op.key, op.value);
1391
- this.reportOpAsApplied(op, false);
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, false);
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 prevTransientOpId = this.transientPropertiesAndTheirOpIds.get(`${op.key}@${op.targetId}`);
1411
- const prevOpId = this.propertyOpsByKey.get(`${op.key}@${op.targetId}`)?.id;
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 (!prevOpId || isOpIdGreaterThan(op.id, prevOpId)) {
1414
- this.setLLWPropertyAndItsOpId(op);
1460
+ if (!previousOp || isOpIdGreaterThan(op.id, previousOp.id)) {
1461
+ this.setLLWPropertyAndItsOpId(op, previousOp);
1415
1462
  } else {
1416
- this.markOpSeen(op, false);
1463
+ this.markOpSeen(op);
1417
1464
  }
1418
1465
  if (prevTransientOpId && isOpIdGreaterThan(op.id, prevTransientOpId)) {
1419
- this.transientPropertiesAndTheirOpIds.delete(`${op.key}@${op.targetId}`);
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, false);
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, includeInStateVector) {
1484
+ markOpSeen(op) {
1438
1485
  this.knownOps.add(this.getOpKey(op));
1439
- if (includeInStateVector && this._stateVectorEnabled) {
1440
- if (isMoveNodeOp(op)) {
1441
- this.moveStateVector.updateFromOp(op);
1442
- } else if (isAnyPropertyOp(op)) {
1443
- this.propStateVector.updateFromOp(op);
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, includeInStateVector = true) {
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 (op.targetId === op.parentId) return;
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 readonly references to the internal state vectors.
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
- refreshPropStateVector() {
1601
+ recordRetainedPropertyOpInStateVector(op, previousOp) {
1553
1602
  if (!this._stateVectorEnabled) {
1554
1603
  return;
1555
1604
  }
1556
- this.propStateVector = StateVector.fromOperations(this.getPropertyOps());
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) {