Tree CRDT Engine¶
CRDT for hierarchical trees with a native, conflict-free Move operation. Based on Kleppmann et al.'s 2021 move-tree algorithm.
When to use¶
- Notion / Outliner-style block trees.
- File-system / folder hierarchies.
- Mind maps, hierarchical TODO lists.
- Any UI where moving a node from one parent to another must survive concurrent edits without creating cycles.
Why not JSON CRDT?¶
In a JSON CRDT, moving a node from one parent to another is "delete then re-create" — you lose history continuity, and two peers moving the same node concurrently to different parents can leave the document in a nonsense state.
Tree CRDT makes Move a first-class atomic operation, with cycle
detection and a timestamp-ordered move log that absorbs late-arriving ops
correctly.
Types¶
TDoc = TreeDocument:
| Field | Description |
|---|---|
Nodes : Dictionary<id, TreeNode> |
All known nodes, alive or tombstoned (ParentId == TrashId). |
MoveLog : IReadOnlyList<AppliedMove> |
Every move applied so far, sorted by (Timestamp asc, PeerId asc). |
TreeNode = (Id, ParentId, Position, Payload).
| Special parent id | Meaning |
|---|---|
TreeConstants.RootId ("__root__") |
Top-level nodes attach here. |
TreeConstants.TrashId ("__trash__") |
Tombstone parent — deletion modelled as a move. |
TOp = TreeOpBatch wrapping one or more MoveOp:
new MoveOp(
NodeId: "block-42",
NewParentId: "block-7",
NewPosition: "m", // fractional index — see below
NewPayload: payload, // JsonElement opaque to the engine
Timestamp: 100,
PeerId: "peer1");
Move is the only operation — Insert, Delete, Reorder, and Reparent
are all expressed as moves:
| You want to… | Build |
|---|---|
| Create a new node | MoveOp(newId, parentId, position, payload, …) |
| Move a node | MoveOp(existingId, newParentId, newPosition, payload, …) |
| Delete a node | MoveOp(id, TreeConstants.TrashId, position, payload, …) |
| Reorder siblings | MoveOp(id, sameParentId, newPosition, payload, …) |
Ordering siblings — fractional indexing¶
Position is a lexicographically-comparable string. v1 ships
FractionalIndex.Between(left, right)
in OpStream.Server.Engine.Common:
using OpStream.Server.Engine.Common;
var first = FractionalIndex.Between(null, null); // some mid-range key
var before = FractionalIndex.Between(null, first); // sorts before `first`
var after = FractionalIndex.Between(first, null); // sorts after `first`
var middle = FractionalIndex.Between(before, first);// sorts between them
If the boundaries are degenerate (no alphabet-valid intermediate exists,
e.g. Between("a", "a!")), the call throws ArgumentException. The
caller should react — typically by re-balancing neighbouring positions.
Why fractional indexing for now?
A full sequence CRDT (RGA / LSEQ) is the long-term plan and would eliminate the rare "no key fits between" failure mode. The Position field's wire shape is already a string, so a future migration is non-breaking.
Worked example¶
var engine = new TreeCrdtEngine();
var batch = new TreeOpBatch(new TreeOp[]
{
new MoveOp("A", TreeConstants.RootId, "m", JsonNull, 1, "p"),
new MoveOp("B", "A", "m", JsonNull, 2, "p"),
new MoveOp("C", "A", FractionalIndex.Between("m", null),
JsonNull, 3, "p"),
});
var state = engine.Apply(new TreeDocument(), batch);
// state.Nodes contains A under __root__, and B+C under A.
Cycle prevention¶
MoveOp is rejected if applying it would make a node its own ancestor.
The check covers:
- Direct cycle (
Move(A, A)orMove(A, B)whereB's ancestry includesA). - Orphan-pointer cycles during undo / redo replay: if another node
declares
ParentId = AwhileAis transiently absent from the map, re-insertingAis still refused.
The offending move stays in the log as a no-op so all replicas converge to the same state.
Typed wrapper¶
TreeCrdtEngine<TPayload> (also in OpStream.Server.Engine.Tree) lets
your code work with a domain payload instead of JsonElement:
public record BlockContent(string Kind, string Text);
var typed = new TreeCrdtEngine<BlockContent>();
var move = typed.BuildMove(
nodeId: "blk-1",
newParentId: TreeConstants.RootId,
newPosition: "m",
payload: new BlockContent("paragraph", "Hello"),
timestamp: 100,
peerId: "peer1");
Internally the wrapper delegates to the untyped core, so the wire format stays uniform across clients.
Registration¶
Undo / redo¶
Compatible with UndoRedoEngine. The move-log absorbs
stale timestamps natively, so the engine inherits the identity default
RestampToWin — no override needed.
Limitations¶
- Position generation is fractional indexing (see warning above).
- The move log grows with the number of applied ops; snapshot the document periodically to truncate the replay cost (Snapshots).