Skip to content

structured — the documents

crdt/structured is where documents are built, and the first thing to say about it is that it re-implements nothing.

There are three merging structures underneath — a text, a list, a map — and every type here is a way of using them. Convergence, commutativity, idempotence and associativity are proved once, of those, and inherited by everything composed from them. The package's own acceptance gate runs those four laws plus a snapshot round trip over four document types at once, against byte-equal snapshots rather than equal readings, because two replicas can agree on every value and still disagree about which write produced it.

Type What it is
Blocks a document of paragraphs, headings, list items, quotes, code — nested
RichText text carrying bold, links, comments — anything covering a stretch
Sheet a spreadsheet: rows and columns that move, cells addressed by identity
Diagram an isometric diagram: nodes, connectors, zones, layers, free text
Tree a tree whose nodes move — a file tree, an outline, threaded replies
Sequence an ordered collection whose items move — slides, a board, layers
Counter a number several people add to at once
MultiRegister a value two replicas are allowed to disagree about
Set a set of names — labels, participants, what is showing
Ink what is drawn by hand
Blobs the files a document refers to but is not made of
Undo putting back what this replica did
Proposals changes that are not part of the document yet
Register, RecordMap, Cell the pieces the rest compose

What follows is why each is shaped the way it is. In every case the shape is forced by something the obvious version gets wrong.

Records whose fields merge apart

A record stored as one opaque value loses an edit: two replicas that change two different fields collide, and last-writer-wins discards one of them wholesale. RecordMap gives each field its own map key, so only a genuine same-field conflict is one. Register is the other degenerate case — one key, and nothing to merge that the map does not already merge.

Stable identities come from operations. A type that needs one writes a map key whose value nobody reads and takes the identity of the write: unique across replicas, reload-safe, never reused.

Things that move

crdt.List is an RGA. It decides where a new element goes against every other element arriving at the same moment, per element, which is exactly right for text — and it has no operation for moving something already in it. Written with the operations it does have, a move is a delete and an insert: two operations, which a concurrent move of the same item splits, leaving the item in both places or in neither.

So Sequence and Tree carry position as a rank, a string with another always available between any two. A move is one field write, and two replicas moving the same item are two writes to one field, which the map already settles.

A Tree has a second problem: a parent merges on its own, but the shape two legal concurrent moves make between them is a ring. It is resolved when the tree is read, by rules that are a function of the state alone. A node whose parent is not a live node reads as a child of the root, so deleting a folder does not delete a file somebody concurrently moved into it — the file resurfaces, and that direction is deliberate: a tree that loses work to a concurrent delete cannot be trusted with a project.

Three things a register cannot be

A count. Read it, add one, write it back: two replicas holding 7 both write 8 and a vote is lost. The mistake is not in the register — "add one" is not a value, and writing a value cannot say it. Counter keys the map by site, so concurrent additions are concurrent writes to different keys and nothing conflicts at all.

A disagreement. Two people rename the same thing at once and one of the names is gone, with nothing anywhere recording that there was a second — not in the state, not in the operations, not in anything a reader could show. MultiRegister keeps a version vector beside each replica's value and reads the ones nothing dominates. Choosing between them is writing the one chosen, since a write dominates everything its writer could see, so there is no operation for settling and none is needed. A vector rather than a clock, because a Lamport clock gives a total order and the question here is which pairs are unordered.

A set. Keyed by the name, one replica adds a label while another, which has never seen it, takes it away; one write wins by an order that has nothing to do with what either knew. In Set every addition mints a tag and a removal takes away the tags it can see. An addition nobody had seen is untouched — not as a policy but for want of anything to base one on.

Formatting

Written into the sequence — a bold-on character, a bold-off character — two replicas bolding overlapping stretches produce interleaved markers and the text between them reads as bold on one replica and not on the other. Written per character it converges and costs a write per letter, forever, and each of those writes is stored forever.

A RichText mark is one operation naming two boundaries, and the formatting is worked out when the text is read. A boundary is a character and a side of it, which is exactly the bold-continues / link-does-not distinction: bold ends at the boundary before the next character and grows as you type; a link ends at the boundary after its last character and does not.

A document of blocks

A RichText per block converges and does not scale, for a reason that has nothing to do with merging: a part cannot be taken out of a composite, and a version carries one entry per part. A thousand-block document is then a thousand entries exchanged on every sync, and the version of an empty document that once had a thousand blocks is the same size as the version of a full one.

Blocks is one text, one marks map and one blocks map — three parts however many blocks there are. Measured in its own tests, and asserted there so the claim fails if it stops being true:

a thousand blocks version vector parts
a part apiece 18894 bytes 1000
Blocks 35 bytes 3

A block begins at a marker character rather than at a boundary between two of them, and that is the whole of why it works. Two people can edit the same seam at the same moment and mean different things — one is finishing a paragraph, the other is starting the next — and both are the same offset. A boundary faces one way or the other and not both, because there is one place in the sequence to insert at; a character has two sides. So each intention is its own insertion at its own place, and the sequence already knows how to keep two of those apart.

Nesting is a depth and not a parent. The order of the blocks is already decided, by the text, and a parent pointer is a second statement about the same arrangement — one that can contradict the first, leaving a block reading before the block it hangs under, with no answer that is not an arbitration. Every sequence of depths is a document somebody can read.

Handwriting, and files

A stroke held as one value has its whole path rewritten by every point, so the person watching sees the line redrawn rather than extended. Ink appends points to a stream of their own, each saying which stroke it belongs to — one stream rather than one part per stroke, for the reason Blocks gives.

A file as one map value is one operation the size of the file: it cannot be sent as it is read, resumed if the connection drops, or recognised as one a peer already has. Blobs cuts it into chunks stored under the hash of their own bytes, so the same chunk written by two replicas is the same key and the same value — nothing to merge, and nothing stored twice.

Three things that are not state

Undo is not a stack of states. Restoring one throws away what everybody else has done since, and travels to them as an instruction to do the same. An undo here is a new edit, made now, whose effect is that the old one did not happen, and it reaches a peer as an ordinary edit with no code needed in front of it. Edits go through the manager, because inverting one afterwards is impossible from what the document keeps: a reported change carries the text that was inserted, never the text that was removed.

History costs nothing, because it is already kept. A document that merges without a server carries the identity of every operation and of every deletion, so "was this character there, and was it visible" is a question a version vector already answers. TextAt, LenAt and ChangesSince ask it. No log is added. A map keeps one record per key, so a map-backed type can say when its current value was written and not what preceded it — which the documentation says rather than hides.

A proposal is a replica that has not synced. Two copies of a text can be compared, and what a comparison produces is a difference; applying a difference mints new characters, so every comment and cursor anchored to the ones it replaced is left pointing at nothing — a review that accepted a wording change would take the comments off the paragraph around it. Proposals records the document's own operations instead, made against the document's own identities. Accepting is Apply, which merges with whatever happened meanwhile because that is what a replica returning from offline does; turning one down costs nothing, because the operations were never applied.

Start here

docs := structured.NewBlocks(1)

title, _, _ := docs.Insert(structured.DocStart, "heading")
docs.InsertText(title, 0, "On rivers")

body, _, _ := docs.Insert(title, "paragraph")
docs.InsertText(body, 0, "They run downhill.")
docs.Mark(body, 0, body, 4, "bold", nil, structured.ExpandEnd)

for _, block := range docs.List() {
    fmt.Printf("%s: %s\n", block.Type, block.Text)
}
// heading: On rivers
// paragraph: They run downhill.

Every type here is a crdt.Composite underneath, so it is one snapshot, one version and one thing to authorise — and collab carries any of them between people without knowing which it is.