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 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;
@@ -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, true);
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.reportOpAsApplied(op);
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.reportOpAsApplied(op);
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(`${op.key}@${op.targetId}`, op);
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.reportOpAsApplied(op, false);
1399
- this.refreshPropStateVector();
1431
+ this.recordRetainedPropertyOpInStateVector(op, previousOp);
1432
+ this.reportPropertyOpAsApplied(op);
1400
1433
  }
1401
1434
  setTransientPropertyAndItsOpId(op) {
1402
- this.transientPropertiesAndTheirOpIds.set(`${op.key}@${op.targetId}`, op.id);
1435
+ this.transientPropertiesAndTheirOpIds.set(this.getPropertyKey(op), op.id);
1403
1436
  this.state.setTransientProperty(op.targetId, op.key, op.value);
1404
- this.reportOpAsApplied(op, false);
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, false);
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 prevTransientOpId = this.transientPropertiesAndTheirOpIds.get(`${op.key}@${op.targetId}`);
1424
- 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);
1425
1459
  if (!op.transient) {
1426
- if (!prevOpId || isOpIdGreaterThan(op.id, prevOpId)) {
1427
- this.setLLWPropertyAndItsOpId(op);
1460
+ if (!previousOp || isOpIdGreaterThan(op.id, previousOp.id)) {
1461
+ this.setLLWPropertyAndItsOpId(op, previousOp);
1428
1462
  } else {
1429
- this.markOpSeen(op, false);
1463
+ this.markOpSeen(op);
1430
1464
  }
1431
1465
  if (prevTransientOpId && isOpIdGreaterThan(op.id, prevTransientOpId)) {
1432
- this.transientPropertiesAndTheirOpIds.delete(`${op.key}@${op.targetId}`);
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, false);
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, includeInStateVector) {
1484
+ markOpSeen(op) {
1451
1485
  this.knownOps.add(this.getOpKey(op));
1452
- if (includeInStateVector && this._stateVectorEnabled) {
1453
- if (isMoveNodeOp(op)) {
1454
- this.moveStateVector.updateFromOp(op);
1455
- } else if (isAnyPropertyOp(op)) {
1456
- this.propStateVector.updateFromOp(op);
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, includeInStateVector = true) {
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 readonly references to the internal state vectors.
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
- refreshPropStateVector() {
1601
+ recordRetainedPropertyOpInStateVector(op, previousOp) {
1565
1602
  if (!this._stateVectorEnabled) {
1566
1603
  return;
1567
1604
  }
1568
- this.propStateVector = StateVector.fromOperations(this.getPropertyOps());
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) {