CRDT LF
- CRDT LF
- Features
- Greyhound Markdown
- Getting Started
- Usage
- Sync
- Flutter
- Persistence
- Complete example
- Benchmarks
- Design
- Architecture
- Core data structures
- Project Status
- Acknowledgments
- Apps
- Packages
- Migrations
A Conflict-free Replicated Data Type (CRDT) implementation in Dart. This library provides solutions for:
- Text Editing.
- List Editing.
- Map Editing.
- Set Editing.
- Nested (recursive) data structures.
Supporting:
- Fugue Algorithm for Text Editing to minimize interleaving.
- Observed-Removed (OR) for conflict resolution.
- Movable lists that preserve element identity across concurrent reorderings.
- Nested CRDTs via a flat storage of references (model documents, canvases, treesβ¦).
Beyond each handler's API documentation, see Choosing How to Model Your Data for guidance on which handlers to pick and how to combine them to model your own document.
Features
- β±οΈ Hybrid Logical Clock: Uses HLC for causal ordering of operations
- π Automatic Conflict Resolution: Automatically resolves conflicts in a CRDT
- π¦ Local Availability: Operations are available locally as soon as they are applied
- π Handler deltas: a handler can say what each change did to your copy of it, not only that it changed
- β©οΈ Undo:
CRDTUndoManagertakes back your own edits by writing the opposite operation, and leaves everyone else's work alone
Greyhound Markdown
A real-time collaborative markdown editor built with crdt_lf. Open it on
separate devices, join the same room and edit together β no install needed.
Source: apps/greyhound_markdown.
Getting Started
Add this to your package's pubspec.yaml file:
dependencies:
crdt_lf:
Usage
Basic Usage
import 'package:crdt_lf/crdt_lf.dart';
void main() {
// Create a new document
final doc = CRDTDocument(
peerId: PeerId.parse('45ee6b65-b393-40b7-9755-8b66dc7d0518'),
);
// Create a text handler
final text = CRDTFugueTextHandler(doc, 'text1');
// Insert text
text.insert(0, 'Hello');
// Delete text
text.delete(0, 2); // Deletes "He"
// Get current value
print(text.value); // Prints "llo"
}
Dart Distributed Collaboration Example
Flutter Distributed Collaboration Example
π Try the examples live in your browser β no install needed.
Sync
A sync library is available in the crdt_socket_sync package. And it's used to synchronize the CRDT state between peers. More info in the README of the sync package.
CRDTReplica, from the sync package, opens the document and puts a sync client
on it. Over a relay:
final replica = await CRDTReplica.open(
documentId: 'my-room',
sync: (document) => WebSocketRelayClient(
url: 'ws://localhost:8080',
document: document,
author: document.peerId,
),
);
final text = CRDTFugueTextHandler(replica.document, 'content');
// ...edit
await replica.close();
To keep the document on disk too, see Complete example.
A flutter example is available in the client_example and provide a synced version of the "Flutter Distributed Collaboration" Example.
Flutter
A companion library, crdt_lf_flutter,containing widgets that make it easier to use crdt_lf within Flutter systems.
It provides Flutter reactivity for crdt_lf: widgets rebuild when the CRDT state changes, with selectors, a provider and a collaborative text field. More info in the README of the Flutter package.
Persistence
Storage is not handled in this library. Use crdt_lf_persistence: it holds the storage contract and the API that does the saving for you β open a document, follow it, write down every move it makes, compact it, copy it. You write none of that.
The package holds no store of its own. An adapter re-exports the whole API, so your pubspec.yaml
only names the adapter:
- crdt_lf_hive: adapters and utils for persist data using Hive.
- crdt_lf_drift: adapters and utils for persist data using Drift.
- crdt_lf_sqlite: adapters and utils for persist data using sqlite3.
For a backend none of them covers, implement the storage contract and everything above works on it unchanged.
Setting it up
One document, one storage, three lines. This is what greyhound_markdown does on every launch, with the Hive adapter:
// The backend keeps the identity too, so this device writes under the same
// author on every launch.
final backend = await CRDTHive.open();
final (:document, :persistence) = await backend.openDocument(roomId);
// Build the handlers on the document it hands back: it already holds what
// was on disk.
final text = CRDTFugueTextHandler(document, 'content');
// ...edit. Every change is written down.
await persistence.dispose(); // writes what is still waiting
await backend.close();
openDocument reads the stored PeerId, builds the document, restores it, and
follows it from there. For everything else β how the writes are batched, when a
prune runs, how to compact or copy a document β see the
crdt_lf_persistence README.
Complete example
The two halves together, as
greyhound_markdown
puts them. CRDTReplica, from
crdt_socket_sync, opens the
document from this device and puts a relay client on it, in the order that keeps
the data: restore first, connect second. Offline, or on a relay that has
forgotten the room, the local copy is all there is.
Source: room_host.dart.
// 1. Who else is in the room, and where their carets are. `AwarenessService`
// is the app's own wrapper over the awareness plugin of crdt_socket_sync.
final awareness = AwarenessService(
name: profile.displayName,
color: profile.color,
);
// 2. The document as this device last left it, and the relay client on it.
// The backend keeps the identity too, so the device writes under the same
// author on every launch.
final replica = await CRDTReplica.open(
documentId: roomId,
storage: CRDTHive.open,
sync: (document) => WebSocketRelayClient(
url: roomUrl(kServerUrl, roomId),
document: document,
author: document.peerId,
plugins: [awareness.plugin],
),
);
// 3. The handler, built on the restored document.
final text = CRDTFugueTextHandler(replica.document, kHandlerId);
final undo = CRDTUndoManager(replica.document)..track(text);
Closing is one call too. What the app built goes first; then close stops the
client, writes what is still waiting, and lets the document and the storage go:
undo.dispose();
awareness.dispose();
await replica.close();
The editor on top of it
crdt_lf_flutter turns the handler into a TextEditingController and draws the
remote carets. CrdtTextFieldBuilder owns the controller and keeps it in step
with the document both ways; onSelectionAnchorsChanged reports this user's
caret to awareness, and CrdtTextCursorsOverlay paints everyone else's.
Source: editor_pane.dart.
CrdtTextFieldBuilder(
id: kHandlerId,
// This user's caret, on every selection change.
onSelectionAnchorsChanged: awareness.setLocalCursor,
builder: (context, controller) =>
ValueListenableBuilder<Map<String, PeerState>>(
// Everyone else's, as they move.
valueListenable: awareness.peers,
builder: (context, peers, child) => CrdtTextCursorsOverlay(
id: kHandlerId,
cursors: [
for (final entry in peers.entries)
if (entry.value.base != null)
CrdtTextCursor(
id: entry.key,
label: entry.value.name,
color: entry.value.color,
base: entry.value.base!,
extent: entry.value.extent,
),
],
child: child!,
),
child: TextField(controller: controller, maxLines: null),
),
)
The document is reached through CrdtProvider, so nothing above has to be
handed down. See the
crdt_lf_flutter README
for the widgets, and the
crdt_socket_sync README
for the awareness plugin.
Benchmarks
This package includes a suite of benchmarks to ensure performance and stability. You can find the latest results here.
To run the benchmarks yourself, run from the repository root:
melos run benchmark_dart
Design
Operation based
The synchronization mechanism is operation-based (CmRDT). Each document manages synchronization by propagating only the operations. Locally, each handler (list, text, etc.) applies these operations to resolve its state. It's possible to create snapshots to establish an initial state on which operations are resolved. This is useful to prevent the memory requirements of the system from growing indefinitely.
Operation resolution is handled by each individual handler. This design allows each handler to implement its own operation resolution logic according to its specific requirements. The library includes simple implementations like CRDTList, where interleaving is managed solely through HLC timestamps, as well as more sophisticated systems like OR-Sets and Fugue Text. Each handler provides documentation that describes its approach to operation resolution β see Handlers.
Transaction
Each operation created by an handler is registered in the document. The document manages operations through a transaction system. A transaction is considered an atomic operation, and notifications to subscribers are sent only when the transaction is completed. If not explicitly declared, each operation is registered in an implicit transaction.
An explicit transaction creates an environment where operations are grouped together and applied atomically. At the end of the transaction, contiguous operations can be compacted into fewer operations through compound algorithms to reduce the number of changes created.
// implicit transaction: one operation, subscribers are notified right away
listHandler.insert(0, 'item1');
// explicit transaction: every operation inside the callback is grouped, and
// subscribers are notified once, after the callback returns
doc.runInTransaction(() {
listHandler.insert(0, 'item1');
listHandler.insert(1, 'item2');
});
graph TD
A[Operation Request] --> B{Transaction Active?}
B -->|No| C[Start Implicit Transaction]
B -->|Yes| D[Queue Operation]
C --> E[Queue Operation]
E --> F[Update Handler Cache]
F --> G[Commit Transaction]
D --> F
G --> H[Flush Transaction]
H --> I[Compact Operations]
I --> N[Process Each Operation]
N --> O[Create Change]
O --> P[Apply to Document]
P --> T[Notify Subscribers]
T --> U[Transaction Complete]
π Diagrams render best in the live documentation.
Reactivity
Three signals tell a consumer something moved, each at a different grain. None of them replays history to answer, so polling any of them is cheap.
revision
document.revisionForHandler(handlerId) is a per-handler, monotonically
increasing int. It moves every time the observable state of that handler
may have changed: a local or imported change targets it, a snapshot
carrying its state is imported or merged, or it β or a handler it
references β is deleted.
It never decreases, pruning included, so two equal readings guarantee the
handler's state did not change in between. This is the signal a reactive
binding should watch. Deleting a handler that a container references bumps the
container's revision (its resolved view now reads differently) without
touching its cached raw value β the container still holds the same
HandlerRefs it always did, only one of them no longer resolves to anything.
document
document.updates is a Stream<void>: one coarse "something changed, read
again" signal, with no detail on what changed or where. It fires after every
change this document applies β local or imported, inside a transaction only
once, at the commit β and after every snapshot import or merge.
This is the pair crdt_lf_flutter builds its reactivity on: updates says
when to check, revisionForHandler says what actually moved. A widget
compares the two revisions instead of re-reading and diffing the value on
every tick:
int _lastRevision = -1;
document.updates.listen((_) {
final revision = document.revisionForHandler(handler.id);
if (revision == _lastRevision) {
return; // woken up, but this handler did not change
}
_lastRevision = revision;
rebuild();
});
events
document.events is a Stream<CRDTDocumentEvent>: a typed stream for a
consumer that needs more than updates throws away β DocumentSnapshotUpdated,
DocumentHistoryPruned, and the other CRDTDocumentEvent subclasses say
what happened, not just that something did.
For what changed inside one handler's own value, in that handler's own
coordinates, see Handler deltas below: watch() publishes
one event per change instead of asking you to re-read revisionForHandler
and diff the value yourself.
This is also how crdt_lf_persistence keeps a document on disk: it listens
once on events and writes down exactly what each variant says happened,
without reading any handler.
document.events.listen((event) {
switch (event) {
case DocumentChangesApplied():
storage.appendChanges(event.changes); // batched, then flushed
case DocumentSnapshotUpdated():
storage.writeSnapshot(event.snapshot);
case DocumentHistoryPruned():
storage.recordPrune(event.removed, event.rewritten);
}
});
Handler deltas
A Change and a delta describe the same edit from two different places.
A change is what the network carries: one peer's edit, written in that
peer's terms. A Fugue text insert says "put these elements after element
e7". Every peer receives that same change, and none of them can tell from it
where the edit lands in the text they are looking at β e7 is an identity,
not a position.
A delta is the answer to the other question: how your own copy moved
when that change was folded in, written in your coordinates. "Keep the 5
characters you have, then put world after them." It is derived locally, by
this document, from the state this document holds. It never travels.
graph LR
S["your value<br/>hello"] --> F
C["Change<br/>put elements after e7<br/>the author's terms"] --> F[fold into this document]
F --> D["Delta<br/>retain 5 Β· insert ' world'<br/>your terms"]
F --> V["your value<br/>hello world"]
So a change tells you what someone did; a delta tells you what happened to what you are holding. That is why a delta can place a caret and a change cannot, and why two peers that agree on the state can still see two different sequences of deltas β they folded the same changes in a different order.
watch() publishes one delta event per change, so a consumer keeps its own
projection and never reads handler.value again.
final text = CRDTFugueTextHandler(doc, 'text');
var mine = '';
var seen = -1;
text.watch().listen((update) {
switch (update) {
case HandlerReset():
// The base moved: read it, and learn which events it already holds.
// The value it hands back is yours to keep.
final point = text.readSynced();
mine = point.value;
seen = point.seq;
case HandlerDelta():
if (update.seq <= seen) {
return; // already inside the value the last read handed over
}
// The handler is the only place that knows both its value type and its
// delta type, so it is the one that says how a delta moves a value.
mine = text.applyDelta(mine, update.delta);
seen = update.seq;
}
});
Four vocabularies cover every built-in handler, by shape rather than by
handler: SequenceDelta<T> (retain / insert / delete, plus a move for the
movable list), MapDelta<K, V>, SetDelta<T> and RegisterDelta<T>.
A reset is not an error. It is the honest answer when the base the deltas
described has been replaced β a snapshot arrived, or the handler dropped the
cached state. ResetCause says which. Every subscription opens with
ResetCause.initial, so the reset path runs on the first frame instead of
months later.
Where a delta comes from
A local edit and a remote change reach the same event by two different roads.
graph TD
L[Local edit] --> LF[Fold it in, collecting the delta]
LF --> LH[Hold the delta: the Change does not exist yet]
LH --> LC[Transaction commits]
LC -->|compaction may fuse operations| LP[Compose and publish under the Change]
R[Remote change arrives] --> RW{Is anyone watching?}
RW -->|No| RQ[Queue it: folded at the next read, no delta built]
RW -->|Yes| RF[Fold it now, collecting the delta]
RF --> RP[Publish under its Change]
LP --> E[HandlerDelta event]
RP --> E
Two things follow from that shape.
A local delta is computed early and published late. It is built while the
operation is applied, before the Change that will carry it exists, and
published on commit β after compaction may have fused several operations into
one. One event always covers exactly one change, so the deltas of fused
operations are composed into one.
Watching changes when the work happens, not what work happens.
Unwatched, a remote change waits in the queue and is folded at the next read.
Watched, it is folded on arrival, because a stream cannot wait for a read that
may never come. The work is the same; only the moment differs. While nobody
watches, the whole feature is one null check on the apply path.
Deltas, or the value?
Start with handler.value. It is cache-backed (see State cache
for how), an edit advances the cache instead of replaying, and for most reads
it is already the right answer.
Reach for deltas when the projection you keep costs more to rebuild than the edit costs to apply:
| What you need | Reach for |
|---|---|
| an occasional read, or you re-render everything anyway | handler.value |
| to move something expensive to rebuild β a long text, a large list | deltas |
to know where it changed: a caret, an AnimatedList, a scroll anchor |
deltas |
| to know who changed it, or which peer it came from | deltas (HandlerDelta.author, .local) |
| to skip the echo of your own write | deltas (origin) |
Consuming them is three calls, all shown above: watch() to subscribe,
readSynced() to answer a reset with a value and the point of the stream
it reflects, and applyDelta to move that value by each delta that follows.
The rest is the one rule that keeps the two in step β drop every event whose
seq the last read already covers.
If you write too: your own edit comes back
A write publishes while it is still being applied, so the event reaches your listener before the write has even returned. If you had already moved your own copy by hand, you now move it twice:
items = [...items, 'bread']; // 1. move my own copy
list.insert(0, 'bread'); // 2. write to the CRDT
// // β the event arrives inside this line
// onDelta applies it again: ['bread', 'bread']
With two consumers on one document the same event must be dropped by one and
applied by the other, so no property of the event alone can decide it. What
decides is who caused it, which is what origin carries.
| Your situation | What to use |
|---|---|
| you apply every delta and never touch your copy by hand | nothing |
you move your copy by hand (a controller, an AnimatedList, an optimistic update) |
origin |
| you want to show who edited, or tell the network apart from this peer | local / author |
The first row is worth trying first: write to the handler, move nothing by hand, and let the event do the work. Then there is no echo, and nothing to tag.
Tagging a write
final tag = Object();
doc.runInTransaction(() => list.insert(0, 'bread'), origin: tag);
// in the listener:
if (identical(update.origin, tag)) {
return; // mine, already applied
}
origin takes any object and is compared by identity. It never travels β a
delta is a local observation β so it costs nothing on the wire. importChanges,
binaryImportChanges, applyChange, createChange and import take it too,
which is how a sync manager marks what arrived from the network.
A HandlerReset carries no origin. It asks for a read, and that read is owed
whoever caused it.
In Flutter, crdt_lf_flutter does all of that for you.
CrdtHandlerDeltaBuilder holds the value and rebuilds with it already moved β
the first row of the table, so it needs no tag.
CrdtHandlerDeltaListener hands you each change as a side effect and takes an
origin for the second row. CrdtTextFieldBuilder drives a
TextEditingController from the same stream.
Undo
A change is immutable, it is already in the DAG, and it may already have reached other peers. So an undo never removes one: it writes a new operation with the opposite effect.
final document = CRDTDocument();
final text = CRDTFugueTextHandler(document, 'text');
final undo = CRDTUndoManager(document)..track(text);
text.insert(0, 'Hello');
undo.undo(); // ''
undo.redo(); // 'Hello'
Driving it
track a handler to record it, then read canUndo / canRedo to enable your
buttons and listen to changes to know when to read them again.
final undo = CRDTUndoManager(document)
..track(text)
..track(title); // one manager can hold several handlers
final subscription = undo.changes.listen((_) {
undoButton.enabled = undo.canUndo;
redoButton.enabled = undo.canRedo;
});
undo.undo(); // does nothing when canUndo is false
undo.redo();
undo.stopCapturing(); // the next write starts a new step
undo.untrack(title); // stop recording, keep the steps already taken
undo.clear(); // drop both stacks
await subscription.cancel();
undo.dispose(); // disposing the document does this for you
captureTimeout |
how long a step stays open for the next write to join it (500 ms; Duration.zero turns merging off) |
stackLimit |
how many steps each stack keeps (100) |
trackedOrigins |
which writes to record (every local one by default) |
Leaving other peers alone
An inverse names CRDT identities β element ids, keys, tags β and never a position. That is what makes an undo right under concurrency: it takes back exactly what this peer did, and leaves everyone else's work alone.
// Two peers add the same value, each under a tag of its own.
final undoA = CRDTUndoManager(docA)..track(setA);
setA.add('shared');
setB.add('shared');
// ...they sync, and both read {'shared'}...
undoA.undo();
// A's tag is gone, B's is not, so the value stays in the set.
What is one step
runInTransaction is one step, whatever it holds. Outside a transaction each
write is its own step, and steps written within captureTimeout of each other
(500 ms by default) merge β a burst of typing is one undo, not one per
character. Duration.zero turns the merging off, and stopCapturing() ends a
step by hand, for when the user moves the caret.
Merging compares the origin and the time, and nothing else, so two writes this
close together become one step even when they are on different handlers.
Give them different origins, or call stopCapturing() between them, to keep
them apart.
What is recorded
Only local writes, and only on the handlers you track. By default every local
write counts; pass trackedOrigins to narrow it to the writes you tag (see
Tagging a write):
final undo = CRDTUndoManager(document, trackedOrigins: {myEditor})..track(text);
document.runInTransaction(() => text.insert(0, 'hi'), origin: myEditor);
What it does not undo
- Remote changes. Only what this peer writes through
registerOperationis recorded; an operation handed tocreateChangenever reaches the stack. - Snapshots.
importSnapshotandmergeSnapshotreplace the base the state is replayed from, so both stacks are dropped. - Anything older than a prune.
garbageCollect, andtakeSnapshotunless you passpruneHistory: false, leave the state to be read from the snapshot. A snapshot carries less identity than the changes it replaces β an OR-Set or OR-Map element comes back without its tag β so both stacks are dropped there too. PasspruneHistory: falseto checkpoint and keep the history. - A register back to empty. A register has no operation that clears it, and
it cannot tell a stored
nullfrom one that was never written, so an undo reaches neither. - The contents of a nested handler. Undoing a write that stored a
HandlerRefremoves the reference; the data it pointed at stays. - A deleted handler. A delete is final. Undoing a
removeChildputs the reference back, and it resolves to nothing. The steps of a deleted handler leave both stacks.
An element that comes back is a new element: a CRDT never resurrects what it
removed, so undoing a delete writes the values again under fresh ids. Undoing
the insert that created them still removes them β the handler follows the chain
(see RebuiltIdentities). A deleted run comes back as one block, so text a peer
typed inside it while the delete was in flight ends up in front of the restored
block rather than within it.
undo() and redo() are transactions of their own and throw inside an open
runInTransaction. A handler is recorded by one manager at a time: track
refuses a handler another manager already holds.
Which handlers
CRDTFugueTextHandler, CRDTFugueListHandler, CRDTFugueMovableListHandler,
CRDTMapHandler, CRDTORMapHandler, CRDTORSetHandler,
CRDTRegisterHandler and the reference handlers built on them, and
CRDTAggregateHandler when its definition is an
InvertibleAggregateDefinition.
CRDTTextHandler and CRDTListHandler are indexed by position alone. They have
no element identity to anchor an inverse to, so Handler.invertible is false
for them and track refuses them.
State cache
A handler answers a read from a cached state. Without that cache every read would replay the whole history of the handler, so the cost of an edit would grow with the age of the document. An edit therefore tries to advance the cached state instead of dropping it.
A local edit always can: the operation is folded in the moment it is registered.
A remote change is harder, because it arrives in the order the network delivers
it, while a replay walks the history sorted by (HLC, author). It can still be
folded in when one of two things is true:
- the handler's state is the same whatever the order causally ready
operations are applied in β it declares this with
stateIsOrderIndependent. The Fugue sequence handlers (they address elements by id) and the OR handlers (they pick winners by tag) do; - or the change is the newest one so far, so folding it on top is the same as replaying with it at the end. This holds for every handler, and covers the common case of one peer writing at a time.
Otherwise the cached state is dropped and the next read replays the history. That is always correct β it is the slow path, never a wrong one.
graph TD
A[Insert] --> B{Local or Remote?}
B -->|Local| C[Fold Operation into Cached State]
C --> D[Value Ready]
C -->|Cannot Apply| I[Drop Cached State]
B -->|Remote| E{Handler Affected?}
E -->|No| D
E -->|Yes| F{State Order-Independent?}
F -->|Yes| G[Queue Change]
F -->|No| H{Newest Change So Far?}
H -->|Yes| G
H -->|No| I
G -->|On Read| J[Fold Queued Changes]
I -->|On Read| K[Replay History]
J --> D
K --> D
π Diagrams render best in the live documentation.
Queued changes are folded in on the next read, not on arrival: applying them right away would mean decoding every incoming operation, which the apply path avoids on purpose. A read never sees a stale value β reading is what drains the queue.
Set useIncrementalCacheUpdate = false on a handler to turn every incremental
path off and always replay. A custom handler that resolves conflicts by replay
order must leave stateIsOrderIndependent at its default false, otherwise two
peers that receive the same changes in a different order diverge.
Architecture
The library is built above the hlc_dart package and provide a solution to implement CRDT systems.
CRDTDocument
The main document class that manages the CRDT state and handles synchronization between peers.
Identity
documentId: identifies the document/resource (used for routing, persistence, and ACLs). It does not participate in operation identifiers.peerId: identifies the peer/author generating operations. It is embedded intoOperationIdtogether with the Hybrid Logical Clock.
If not provided, both are generated: peerId and documentId.
Handlers
Handlers are the core components of the library. They manage the state of a specific type of data and provide operations to modify it.
CRDTTextHandler: Handles text editing (concurrent edits ordered by HLC).CRDTFugueTextHandler: Handles text editing with the Fugue algorithm (minimizes interleaving of concurrent edits).CRDTListHandler: Handles list editing (concurrent edits ordered by HLC).CRDTFugueListHandler: Handles ordered list editing with the Fugue algorithm (minimizes interleaving of concurrent edits in the same region).CRDTFugueMovableListHandler: Handles ordered list editing with an explicitmoveoperation that preserves element identity across concurrent reorderings (no duplicates).CRDTMapHandler: Handles map editing (last-writer-wins by HLC).CRDTRegisterHandler: Holds a single value with last-writer-wins (the scalar counterpart of the collections β a flag, a number, a non-collaborative string).CRDTORSetHandler: Handles set editing with the Observed-Removed (OR) algorithm.CRDTORMapHandler: Handles map editing with the Observed-Removed (OR) algorithm.CRDTAggregateHandler: Holds one aggregate β an object with several fields β built from the events written on it (see Aggregates built from events).
Container handlers, used to model nested structures (see Nested Structures):
CRDTMapRefHandler: a map whose values are references to other handlers.CRDTListRefHandler: an ordered (Fugue) list of references to other handlers.CRDTMovableListRefHandler: a movable ordered list of references to other handlers.CRDTORMapRefHandler: a map of references with the Observed-Removed (OR) algorithm.CRDTORSetRefHandler: a set of references with the Observed-Removed (OR) algorithm.
Reading a collection. The value of a list, map or set handler is
unmodifiable, and stays the same object until the next change: two reads are
equal when they are identical. Copy it to edit it (List.of, Map.of,
toSet()). The value of CRDTMapHandler and CRDTListHandler shows the
current state, so a value read before a change shows the change too: copy it to
keep what it holds. The others keep what they held.
final doc = CRDTDocument(
documentId: 'todo-list-123',
peerId: PeerId.parse('45ee6b65-b393-40b7-9755-8b66dc7d0518'),
);
final list = CRDTListHandler<String>(doc, 'my-todos', handlerType: 'todo-list');
list.insert(0, 'Buy apples');
list.insert(1, 'Buy milk');
list.delete(0);
print(list.value); // Prints "[Buy milk]"
Every handler can be found in the handlers folder. Want to add your own, with a kind the four conventional ones don't cover? See Custom handlers.
Custom handlers
Handler<T> is an abstract class, and a handler you write yourself plugs into
the same document, transaction system and sync path as the built-in ones.
It is a base class, so you extend it and cannot implement it, and your
own handler says base, final or sealed in turn:
final class PNCounterHandler extends Handler<int> { β¦ }
Its constructor passes a HandlerSpec to super, which is what names the
kind on the wire and tells the document how to build one β what a peer that
receives a reference needs:
final class PNCounterHandler extends Handler<int> {
PNCounterHandler(super.doc, this.id) : super(spec: PNCounterHandler.spec);
/// The kind this handler is.
static const HandlerSpec<PNCounterHandler> spec = HandlerSpec(
'pn-counter',
PNCounterHandler.new,
formats: HandlerFormats(
operationKinds: {OperationType.kindUpdate},
blobVersions: BlobVersionRange.single(1),
),
);
@override
final String id;
@override
late final OperationDecoders operationDecoders = {
OperationType.kindUpdate: PNCounterOperation.fromBytes,
};
β¦
}
The tag has to be a constant of your own: runtimeType.toString() cannot do
the job, because dart2js renames types in a Flutter web release build.
formats states the operation kinds the handler decodes and the snapshot blob
versions it reads, which is what doc.describeBuildCapabilities() reports to a
sync layer. It has to agree with the handler: the kinds it lists are the keys of
operationDecoders, and the blob versions are snapshotBlobVersion and
minReadableSnapshotBlobVersion. An assertion checks it as the handler
registers, so a declaration cannot drift from what the code actually decodes.
A handler overrides:
idandoperationDecodersβ required: how the handler is addressed, and how its operations are decoded.getSnapshotStateβ required: the state as bytes, seeded back throughlastSnapshot.incrementCachedStateβ to advance the cached state by one operation instead of replaying the whole history on every read.stateIsOrderIndependentβ only when the state is the same whatever order causally ready operations arrive in.compoundβ to collapse consecutive operations inside a transaction into fewer changes.invertibleandinvertβ to make the handler undoable (see Undo).
The four conventional kinds (insert, delete, update, move) are ready
to use as insertType, deleteType, updateType and moveType. A handler
that needs a fifth semantics declares its own:
factory OperationType.custom(
Handler<dynamic> handler, {
required int kind,
required String name,
bool stamped = false,
})
kindis scoped to the handler type, not global β two unrelated handlers can both use kind4for two unrelated meanings, because the envelope always says which handler type it addresses first. Values0-3are the conventional kinds;4up toOperationType.maxKind(127) are free. The ceiling is127because bit 7 of the kind byte is reserved.namelabels the kind intoPayloadand debug output. Onlykindis written on the wire.
When to turn stamped on
A stamp is a unique, totally ordered mark: the OperationId of the change the
operation travels in. Every operation carries one, declared or not: the
document mints it when the operation is registered, and a remote one reads it
off the change. So the flag is not about reaching the stamp β it is about
saying that this kind's conflict resolution reads it, which is something two
peers have to agree on. The built-in handlers use it for two different things:
| Use | Who | What the handler does |
|---|---|---|
| Last-writer-wins tie-break | update on the Fugue sequence handlers, insert/move/update on the movable list |
keeps the greater stamp, so every peer picks the same winner instead of whichever change arrived last |
| Identity tag | add on CRDTORSetHandler, put on CRDTORMapHandler |
stores the stamp as the tag a later remove tombstones; CRDTORSetHandler never compares two of them |
A kind with no conflict to resolve and nothing to tag declares nothing β a
text insert is one, because element ids are already unique. A delete is
another: it beats everything, so there is no rule for a peer to disagree
with. It still reads its own stamp and records it on the tombstone, against
the day something tries to take an element back.
A stamped kind cannot be compounded. Compound never folds operations of
a stamped kind, and asserts in debug if your compound returns one for them.
A compound is a single change and carries a single id, but your handler folded
each constituent under its own; on a compound touching more than one target
the two disagree, and nothing shows it until a later concurrent write lands
between the two ids and wins on one peer while losing on the other. So it is
one or the other: a kind that reads a stamp, or a kind that folds.
stamped is a local declaration, not part of the wire format: bit 7 of
the kind byte carries the writer's declaration, and nothing else. Two builds
that disagree about it are refused on decode, in both directions. Changing the
flag on a kind you already shipped is therefore a breaking change.
A change carrying a kind this build does not recognize for that handler type
throws UnknownOperationKindException instead of being dropped in silence β
which is why a decoder in operationDecoders never returns null.
Making a handler undoable
invert returns the operations that take an operation back, read against the
state as it is before it is applied. The document calls it from
registerOperation, after the stamp is minted and before the operation is
folded in, so operation.stamp is readable and the state has not moved yet.
@override
bool get invertible => true;
@override
List<Operation> invert(Operation operation) {
if (operation is! PNCounterIncrementOperation) {
return const [];
}
return [
PNCounterIncrementOperation.fromHandler(this, by: -operation.by),
];
}
Name identities, never positions: an inverse is written much later, after other peers may have edited the same handler.
The operations you return are fresh and unstamped β the document stamps them when they are registered, and an operation is stamped once. Return an empty list when there is nothing to undo, which includes an operation with no observable effect: undoing what changed nothing must change nothing.
If your handler can rebuild something it removed β a CRDT never resurrects,
so it comes back under a new identity β mix in RebuiltIdentities, record each
step with noteRebuilt, and override prepareInverse to follow the chain. That
is what makes "undo the delete, then undo the insert" end where it started.
A full worked example, a PN-counter with its own increment kind, lives in
test/helpers/pn_counter_handler.dart.
It is test-only; publishing a real PN-counter is tracked by
issue #126. Using one
looks like any built-in handler:
final doc = CRDTDocument(peerId: PeerId.generate());
final votes = PNCounterHandler(doc, 'votes')
..increment(3)
..decrement();
print(votes.value); // 2
Text handlers index by rune
CRDTTextHandler and CRDTFugueTextHandler count positions in runes
(Unicode code points), not UTF-16 code units. insert, delete, update,
length, and the Fugue handler's stablePositionAt/indexOfStablePosition
all agree on this: one element is one rune.
Building a text field on top of this in Flutter? RenderEditable counts
UTF-16 code units, not runes β see RuneOffsets in the crdt_lf_flutter
docs
for the conversion.
Working with Complex Types
When using CRDTListHandler<T> or CRDTMapHandler<T> with complex object types (e.g., your own custom classes) for T, it's crucial to understand how data is managed.
The value of your complex object is directly embedded within the Change's payload. This has two important implications:
-
Serialization: If you plan to persist these
Changes (e.g., usingcrdt_lf_hive) or send them over a network, you must provide a strategy to serialize your custom type to bytes and back. This is done by passing aValueCodec<T>to the handler β itsencode(T) β Uint8Listis stored directly inside the operation payload, anddecode(Uint8List) β Tis used on the receiver. AValueCodeccan wrap any binary format (raw fixed-width fields, protobuf, json bytes, etc.). -
Immutability and Value Semantics: When a
Changeis created, it captures the state of thevalueat that specific moment. If you later mutate the original object, theChangewill still hold the old state. This can lead to unexpected behavior. It is highly recommended to treat your complex objects as immutable. When you need to modify an object, create a new instance with the updated values instead of mutating the existing one. This ensures that eachChangeis a predictable and self-contained snapshot of the operation.
Example with a custom class and a binary ValueCodec<T>:
class MyData {
const MyData(this.name, this.count);
final String name;
final int count;
}
class MyDataCodec implements ValueCodec<MyData> {
const MyDataCodec();
@override
Uint8List encode(MyData value) {
final nameBytes = utf8.encode(value.name);
final out = BytesBuilder(copy: false)
..add(Uint8List(4)..buffer.asByteData().setInt32(0, value.count))
..add(nameBytes);
return out.toBytes();
}
@override
MyData decode(Uint8List bytes) {
final count = ByteData.sublistView(bytes, 0, 4).getInt32(0);
final name = utf8.decode(bytes.sublist(4));
return MyData(name, count);
}
}
// Wire the codec into the handler
final list = CRDTListHandler<MyData>(
doc,
'my-data-list',
handlerType: 'my-data',
valueCodec: const MyDataCodec(),
);
// GOOD: create a new instance for the change
list.insert(0, const MyData('item1', 1));
// BAD: mutating the object after insertion
// This will NOT be reflected in the CRDT history.
// For updates, create a new instance
list.update(0, const MyData('item1', 2));
If you don't pass a valueCodec, the handler falls back to JsonValueCodec<T>, which simply wraps json.encode/json.decode β convenient for types that already implement toJson()/fromJson().
About snapshot data. When you call document.takeSnapshot(), each handler projects its current state into Snapshot.data as a Uint8List produced by the handler's own getSnapshotState(). Built-in handlers reuse the same ValueCodec<T> you pass at construction time to encode each item, so a CRDTListHandler<MyData> with a MyDataCodec snapshots its state with that same codec. Snapshot itself only frames each per-handler blob with a length prefix.
Alternative approach: store raw data inside the handler.
If you don't need a custom binary layout and you're fine with JSON, you can rely on the default JsonValueCodec<T> by declaring the handler with a JSON-friendly type (e.g. Map<String, dynamic>). The same JsonValueCodec is reused both for operation payloads and for snapshot entries.
// 1. Declare the handler with a raw type
final rawList = CRDTListHandler<Map<String, dynamic>>(
doc,
'my-raw-list',
handlerType: 'my-raw',
);
// 2. Serialize before inserting/updating
rawList.insert(0, const MyData('item2', 1).toJson());
// 3. Deserialize when reading the value
final myDataList = rawList.value.map(MyData.fromJson).toList();
print(myDataList.first.name); // Prints "item2"
Nested Structures (Containers and References)
The handlers above store raw values, which keeps the data flat. To model
real-world, deeply nested documents (e.g. a document β chapters β paragraphs β
collaborative text and sortable lists, or a canvas β slides β elements β
coordinates), the library uses a "flat storage & references" approach,
similar to Yjs/Loro: the CRDTDocument is a flat registry of all handlers,
and parents point to children by reference.
Container handlers store HandlerRefs (a child handler's id + type)
instead of raw data:
CRDTMapRefHandlerβ keyed references (setRef(key, handler)/getRef(key)).CRDTListRefHandlerβ ordered references using Fugue (insertRef(index, handler)/getRefAt(index)).CRDTMovableListRefHandlerβ movable ordered references (addsmove(from, to)), ideal for reordering with stable identity (slides, z-index, sortable listsβ¦).CRDTORMapRefHandlerβ keyed references with OR semantics (putRef(key, handler)/getRef(key)): a write concurrent with a remove of the same key wins.CRDTORSetRefHandlerβ an unordered set of references with OR semantics (addRef(handler)/children), read back sorted by handler id.
Each container exposes both views:
- the inherited
valuegetter returns the raw references (Map<String, HandlerRef>/List<HandlerRef>/Set<HandlerRef>); - the
resolvedgetter returns the fully resolved subtree, resolving every reference recursively (with cycle protection). A leaf handler resolves to itsvalueas is (aSet, a custom type, an aggregate state), so the result is JSON-safe only when every leaf value is.
Children are resolved lazily through the document registry, so the state is computed only when read.
A child is named by a spec: every handler class exposes one, and it says
which kind the child is, so the document learns how to rebuild it. A generic
handler's takes the tag β CRDTListHandler.spec<Todo>('todo-list') β and a
non-generic one is a plain constant.
final doc = CRDTDocument();
// Root container. `handler` builds it, or returns the one already open.
final root = doc.handler(CRDTMapRefHandler.spec, 'root');
// A nested, sortable list of chapters.
final chapters = root.child('chapters', CRDTListRefHandler.spec);
// A chapter holding collaborative text.
final chapter = chapters.insertChild(0, CRDTMapRefHandler.spec);
chapter.child('title', CRDTFugueTextHandler.spec).insert(0, 'Intro');
// Read the whole tree, every reference resolved.
print(root.resolved); // {chapters: [{title: Intro}]}
Both calls mint the child's id and attach the reference for you.
child(key, spec) returns the one the key already holds, so calling it twice is
safe β building a handler by hand throws when the id is taken.
insertChild(index, spec) always adds a new one: an insert adds an element, so
there is no key to be idempotent about.
setRef / insertRef / getRefAs still work and are unchanged: child and
insertChild are a front door over them, for the common case where the child
does not exist yet.
The lifecycle of a nested handler β create and attach it, then visualize the tree β looks like this:
graph TD
A[Create Child Handler] --> B[Register Handler in Document]
B --> C[Attach to Parent as Reference]
C --> D[Reference Recorded as a Change]
D -->|When Read| E[Resolve Tree]
E --> F{For Each Reference}
F -->|Cycle Detected| G[Resolve to Null]
F -->|Otherwise| H[Resolve Referenced Handler]
H --> I{Container or Leaf?}
I -->|Container| E
I -->|Leaf| J[Read Handler Value]
G --> K[Build Resolved Tree]
J --> K
π Diagrams render best in the live documentation.
On a remote peer the same resolution step recreates children, as long as that peer knows their kinds β see below. Reading is what brings them in:
importChangesbuilds nothing on its own.
Every node is a standard CRDT, so concurrent edits at any depth merge conflict-free (e.g. one peer adds a chapter while another types into an existing paragraph).
Reconstructing the tree on a remote peer. A peer that only received the
Changes does not know the structure in advance. The document keeps, per handler
kind, how to build one β so it can rebuild the right handler from the kind
carried in each reference and in each operation payload.
Setting a document up takes two calls, one per kind and one per instance:
doc.register(spec)declares one kind, for one this peer never opens itself.doc.handler(spec, id)returns that handler, building it when it is not open.
A handler names its own kind as it is constructed. The constructor is the one
point every creation passes, so CRDTTextHandler(doc, 'id'),
doc.handler(spec, id) and container.child(key, spec) all leave the document
able to rebuild that kind.
One rule, for every kind alike: a document rebuilds the kinds it has opened a
handler of, plus the ones passed to register. A reference to anything else
resolves to null, so a peer that reads a tree it received declares the kinds
that tree is made of, or opens one of each.
register is then for the one case a constructor cannot cover: a kind this peer
never opens β a server that stores and forwards, or a client waiting for a
subtree a peer is about to send.
doc.resolveHandler(ref) resolves a reference, and doc.newHandlerId() mints
an id for a child; the containers call both for you.
Naming a generic handler
A generic handler must be given a tag: its default would carry the type argument
(CRDTRegisterHandler<bool>), and dart2js minifies that away in a Flutter web
--release build. So the tag is required, on the constructor and on spec
alike. Write it once, in the spec you pass around:
final doneKind = CRDTRegisterHandler.spec<bool>('todo.done');
todo.child('done', doneKind).set(true);
doc.register(doneKind); // for a peer that receives one without opening it
A non-generic handler fixes its own tag, so its spec takes no argument:
CRDTFugueTextHandler.spec.
HandlerSpec is the type behind all of this β what a kind is: its tag, how
to build one, and the formats it reads. You write one by hand only for a handler
of your own; see Custom handlers.
Reading the tree is what brings it in: importChanges builds nothing, and a
child materializes when the reference that names it is resolved.
// Peer B declares the kinds the tree is made of, then imports.
final docB = CRDTDocument()
..register(CRDTMapRefHandler.spec)
..register(CRDTListRefHandler.spec)
..register(CRDTFugueTextHandler.spec);
docB.importChanges(docA.exportChanges());
final rootB = docB.handler(CRDTMapRefHandler.spec, 'root');
print(rootB.resolved); // same tree as docA
// doc.roots() returns the entry points (containers not referenced by another).
For state coming from a pruned snapshot (where the changes have been removed
and only the snapshot {id: type} manifest remains), call doc.reconstruct()
to rebuild every reachable handler from the manifest and the references.
A complete, interactive example is available in the Flutter example app under the Document entry (sortable chapters β paragraphs β collaborative text and sortable item lists).
Deleting a nested handler
Removing a reference leaves the child in the document: its changes, and its
state in every snapshot. To delete the child for every peer, remove it with
removeChild (removeChildAt on the lists), or call doc.deleteHandler:
chapters.removeChildAt(0); // the reference, and the chapter
root.removeChild('draft'); // the same on a map
doc.deleteHandler(chapter); // the chapter alone, any reference stays
doc.deleteHandlerRef(ref); // by reference, without building it
- The subtree goes too. Every handler reachable from the deleted one is deleted. A child that another container also references is deleted for that container too. A child added to the container on another peer, at the same time as the delete, is deleted as well.
- A peer that cannot build a child still deletes it.
removeChildanddeleteHandlerRefneed only its id, so a server that registers no kinds can delete it too. The children of that child are deleted by the first peer that can build it. - A delete wins. An edit made at the same time on another peer is ignored.
- A delete is final. The handler is closed: its
watchstream ends, and a write,doc.handleror a constructor on its id throwsHandlerDeletedException. A reference to it resolves tonull:resolvedkeeps anullin a list, so positions stay the onesremoveChildAtcounts, and leaves the key out of a map.child(key, spec)makes a new child in its place. - The data goes at the next snapshot.
takeSnapshotleaves the deleted handlers out, so a prune removes their changes. - The id stays. The document keeps every deleted id for good, so a late edit cannot bring the handler back. Each delete adds its id, plus a few bytes, to every snapshot, and this list never shrinks.
The deletes travel as the operations of an internal handler, with the
reserved id crdt_lf/deleted-handlers. A peer on crdt_lf before 5.2.0 sees an
unknown kind (see describeDataRequirements) and keeps showing the deleted
data.
Choosing How to Model Your Data
There is rarely a single "right" model β it depends on how far down you want conflicts to be resolved. The trade-off is always the same:
- Coarser (flat values) β fewer handlers, less memory/overhead, simpler code, but concurrent edits are resolved at a coarser unit (often last-writer-wins over the whole value).
- Finer (nested handlers) β concurrent edits merge per field / per character, but you pay with more handlers (memory, snapshot size, resolution cost) and more setup.
Rule of thumb: push granularity down only where concurrency actually happens. Model a node as a nested container when peers can edit different parts of it at the same time and you want both edits to survive; model it as a flat value when it is atomic or last-writer-wins is acceptable.
Worked example: a TODO list
Each todo has a text and a done flag. Two reasonable models:
A β Flat list of values
// TodoItem is a plain value: {text, done}, encoded via a ValueCodec/JSON.
final todos = CRDTFugueListHandler<Map<String, dynamic>>(
doc,
'todos',
handlerType: 'todo-list',
);
todos.insert(0, {'text': 'Buy milk', 'done': false});
todos.update(0, {'text': 'Buy milk', 'done': true});
- The list merges conflict-free (ordering, concurrent inserts).
- Each item is atomic: editing it is replacing the whole value, so two peers
changing
textanddoneof the same item concurrently β oneupdatewins, the other is lost (last-writer-wins on the item). - Simple, compact, fast. Good when items are small and rarely co-edited.
B β List of references to per-item sub-documents
// `done` is generic, so its tag is required β written once in its spec.
final doneKind = CRDTRegisterHandler.spec<bool>('todo.done');
final todos = doc.handler(CRDTMovableListRefHandler.spec, 'todos');
final item = todos.insertChild(0, CRDTMapRefHandler.spec);
item.child('text', CRDTFugueTextHandler.spec).insert(0, 'Buy milk');
item.child('done', doneKind).set(false);
- Conflict resolution reaches each field: one peer editing
textwhile another togglesdoneon the same item β both survive. textis itself collaborative (character-level merge);doneis a tiny last-writer-wins scalar (CRDTRegisterHandler<bool>) β the right primitive for a single value, instead of abusing a one-key map.- A movable list keeps each item's identity across concurrent reorders (no duplicates). Costs more handlers and setup.
Aggregates built from events
Model C. A Car has an owner, a license plate set once at registration and
never changed, and its most recent maintenance record. The app changes it
through domain operations: register it, change the owner, log a service.
Write those operations as events, and say once how each event moves the
state:
final class CarAggregate extends AggregateDefinition<Car, CarEvent> {
const CarAggregate();
@override
String get handlerType => 'car';
// The license plate is the aggregate's id: no event ever changes it.
@override
Car initial(String id) => Car(licensePlate: id);
@override
Car reduce(Car state, CarEvent event, EventContext context) =>
switch (event) {
OwnerChanged(:final owner) => state.copyWith(owner: owner),
MaintenanceLogged(:final note) =>
state.copyWith(lastMaintenance: note),
};
@override
ValueCodec<CarEvent> get eventCodec => const CarEventCodec();
@override
ValueCodec<Car> get stateCodec => const CarCodec();
}
final car = doc.handler(
CRDTAggregateHandler.spec(const CarAggregate()),
'AB123CD',
);
car.dispatch(const OwnerChanged('Alice'));
car.dispatch(const MaintenanceLogged('Oil change'));
print(car.value.owner); // Alice
CRDTAggregateHandler replays the events of every peer in the replay order,
(HLC, author), through reduce. Peers that hold the same events hold the same
state. What you get, compared with models A and B:
- One handler per aggregate, with a typed
value, onewatch()stream and oneCRDTUndoManager.track(give the definition aninvertby extendingInvertibleAggregateDefinition). - Atomic writes.
dispatchAllputs its events in one change: a peer receives all of them or none. - Stable identities.
EventContext.idis the same on every peer, so an entity an event creates can keep it, and later events name that entity instead of a position that a concurrent insert may shift.dispatchreturns that id.
The rules of reduce are what keeps peers equal:
- Pure and deterministic. No clock, no randomness, no iteration over an
unordered set. Take the author and the event id from
EventContext. - Identities, never positions. When an event creates or targets part of
the state (an item in a list, say), address it by the id
EventContextgives, never by where it sits: a concurrent insert can shift a position but never an id. - A new state for every event. The handler keeps the state it returns and
hands it out as its
value. - An event on a missing target does nothing. An event addressed to an entity another peer already removed finds nothing, and that is the merged result.
- Throw to refuse an event.
dispatchthen throws the same error and writes nothing. A change whose event throws is skipped whole on every peer, so a batch a concurrent event made invalid leaves no trace.
What it does not do: merge two concurrent edits of the same field. The event
later in the replay order wins, the whole field at once. For text that peers
type together, keep a CRDTFugueTextHandler beside the aggregate. An event that
arrives older than the newest one already applied makes the next read replay
the aggregate's history since the last snapshot, so keep one aggregate small and
take snapshots.
Versions and migrations
Events and the snapshot state are written in the definition's version. When
the bytes of eventCodec or stateCodec change, or an event takes a new
meaning in reduce, raise version and add one AggregateMigration from the
previous version:
final class CarV1ToV2 extends AggregateMigration {
const CarV1ToV2();
@override
int get from => 1;
@override
List<Uint8List> upEvent(Uint8List event) => /* v1 event β v2 events */;
@override
Uint8List upState(Uint8List state) => /* v1 state bytes β v2 */;
}
final class CarAggregate extends AggregateDefinition<Car, CarEvent> {
@override
int get version => 2;
@override
List<AggregateMigration> get migrations => const [CarV1ToV2()];
// ...
}
The history keeps the bytes each peer wrote, so a migration never rewrites it: it runs every time old data is read. Three rules follow:
- Pure and deterministic, like
reduce. - Ids never move.
upEventmay drop an event or split it in several: the first event keeps the id of the one it came from, and each other one gets anEventId.partof its own. upStateagrees withupEvent. The upgraded snapshot has to equal the state the upgraded events replay to, or a peer that starts from a snapshot diverges from one that replays the history. Keep the bytes an old build wrote as test fixtures and check both paths.
Every version travels under its own operation kind and snapshot blob version, so a build that meets a newer version refuses it, and the capability check of a sync layer reports the mismatch before that. Peers have to upgrade before they write in a new version. A build reads every version its migrations lead up from. A snapshot is always written in the current version: once every replica took one that pruned the old events, drop the migrations below it.
A quick decision guide
| Question | Lean towards |
|---|---|
| Peers edit different fields of the same item concurrently? | Nested (per-field) β model B |
| Peers co-edit the same text in real time? | A text handler as a child (model B) |
| Item is atomic / co-editing is rare? | Flat value + LWW β model A |
| Need drag-to-reorder without duplicating on concurrent moves? | CRDTMovableListRefHandler / CRDTFugueMovableListHandler |
| Order matters and peers insert at the same spot? | A Fugue list (less interleaving) |
| An object changed by domain events, as one unit? | CRDTAggregateHandler β model C |
Picking a leaf handler
- Text β
CRDTTextHandlervsCRDTFugueTextHandler: both are character-level collaborative text.CRDTTextorders concurrent edits by HLC (simpler, cheaper, but concurrent insertions at the same position may interleave).CRDTFugueTextminimizes interleaving (concurrent runs stay contiguous, more intuitive merges) at a higher cost. Use Fugue for real collaborative prose;CRDTTextfor short or rarely-co-edited strings β or a plainStringvalue when it is never co-edited. Fugue sequence handlers also expose stable positions (stablePositionAt/indexOfStablePosition): serializable caret/cursor anchors tied to element identity that survive concurrent edits useful for carets and remote cursors. On all three Fugue handlersupdateoverwrites an element in place, so an anchor keeps resolving across it, and two peers updating the same element converge on one of the two values instead of keeping both. - List β
CRDTListHandlervsCRDTFugueListHandlervsCRDTFugueMovableListHandler: HLC-ordered (cheapest) β interleaving-aware β interleaving-aware plus identity-preservingmove. - Scalar β
CRDTRegisterHandler<T>: a single last-writer-wins value (flag, number, non-collaborative string). Use it for a scalar field of a nested node instead of a one-key map. - Map / Set β
CRDTMapHandler(last-writer-wins per key) vsCRDTORMapHandler/CRDTORSetHandler(observed-removed, add-wins semantics for concurrent add/remove). - Containers β
CRDTMapRefHandler/CRDTListRefHandler/CRDTMovableListRefHandler: use these when the values are themselves sub-documents (model B) rather than raw data.
Core data structures
What CRDTDocument and the handlers are built from: the causal graph a
document replays, the unit it replays, and the compact state it can replay
from instead.
Mixing explicit operations in a transaction
To manage operations in a transaction, use the runInTransaction method of the document.
doc.runInTransaction(() {
listHandler.insert(0, 'item1');
listHandler.insert(1, 'item2');
});
// only here doc notifies subscribers about the transaction completion
Within a transaction can also be executed changes and imports. Those actions are applied immediately but notified only at the end of the transaction.
doc.runInTransaction(() {
listHandler.insert(0, 'item1');
listHandler.insert(1, 'item2');
// immediately applied
doc.createChange(listHandler.insert(0, 'item1'));
// immediately applied
doc.importSnapshot(otherDocument.takeSnapshot());
});
// Insertions are compacted, processed and applied to the document.
// Doc notifies subscribers about the transaction completion
DAG
A Directed Acyclic Graph that maintains the causal ordering of operations.
Change
Represents a modification to the CRDT state, including operation ID, dependencies, and timestamp.
Frontiers
A structure that manages the frontiers (latest operations) of the CRDT.
Snapshot
A snapshot of the CRDT state, including the version vector and the data.
document.snapshot reads the one the document holds, as a read-only copy.
Snapshot.data is a Map<String, Uint8List> with one blob per entry. Two
kinds of entry live in it: one handler's own state, keyed by its id, and a
handful of reserved entries, keyed by a crdt_lf/-prefixed name, that
describe the document as a whole rather than one handler.
One handler's state, keyed by its id. takeSnapshot keeps the state of
every handler the document holds data for, open or not, so a prune never
loses what another peer can still read:
- an open handler writes its blob;
- a closed handler of a kind the document knows (see
register) is built for the time of the snapshot and writes its blob; - a handler with no change since the last snapshot keeps the blob it had;
- a handler of a kind the document does not know keeps its blob, and the changes it got since ride inside the reserved unfolded-changes entry (see below). The first peer that knows the kind folds them into a new blob when it takes its own snapshot.
A server or a relay that opens no handler can therefore take snapshots and prune its history.
The deletions registry, under the reserved crdt_lf/deleted-handlers id,
is one such entry: an OR-set of every id deleteHandler / deleteHandlerRef
ever deleted, on this peer or on a peer whose changes this document holds. It
is a handler like any other, so it follows the same four rules above and needs
no special case. A document that never deletes a handler never opens it: a
snapshot without this entry is a document that has never seen a delete.
Reserved whole-document entries, each keyed by its own crdt_lf/-prefixed
name rather than a handler id:
crdt_lf/handler-manifestβ{handlerId: handlerType}for every handler the snapshot names. The history a closed handler is rebuilt from is pruned once it has a blob, so after that the manifest is the only record left that says what type its id is β needed to resolve a reference to it, or to read its changes back for a later snapshot.crdt_lf/capabilitiesβ{handlerType: (operation kinds, blob versions)}this build read or wrote into the snapshot. Without it, a peer that reloads a pruned document has no changes left to infer requirements from, and would report that it needs nothing. SeeCRDTDocument.builtInCapabilities.crdt_lf/unfolded-changesβ the changes of a handler of an unknown kind, one entry per handler, that no blob could take in. A snapshot that carries one needscrdt_lf5.2.0 or later to be read. Carried changes are kept per handler: if a newer build wrote them in a form this one cannot read, only that handler fails to read, and the snapshot passes them on unchanged. Once that handler gets new changes,takeSnapshotthrows aFormatExceptioninstead of losing them.
Binary representation
Every core CRDT type β PeerId, HybridLogicalClock, OperationId,
FugueElementID, VersionVector, Change, Snapshot β exposes a compact,
self-describing binary representation through a toBytes()/toUint8List()
and a matching fromBytes()/fromUint8List(). This is the canonical wire
format used by crdt_lf_hive for persistence and by crdt_socket_sync for
transport β but it is also a public API you can use directly to build your
own storage or sync layer. Each type's own dartdoc, on its toBytes() /
fromBytes(), documents its byte layout and size: see that type's API
reference rather than a table here, which would only drift out of sync.
Operation payloads inside a Change are produced by the handler's
ValueCodec<T>. Each entry of Snapshot.data is produced by the handler's
getSnapshotState() β built-in handlers reuse the same ValueCodec<T> to
encode their items, so the whole pipeline (operation payload β Change β
Snapshot) is fully binary end-to-end. JSON only appears as the default
ValueCodec<T> when the user does not provide a custom one.
Two levels of versioning. Snapshot.schemaVersion (currently 1, added
in crdt_lf 4.0.0) covers only the wrapper: the document id, the version
vector, and the framing of the per-handler entries. Inside an entry, the
layout belongs to the handler that wrote it, and so does its version: every
built-in blob starts with a version: u8 byte of its own, so a handler can
change its layout without moving a byte any other handler reads. The reader
is strict β a version it does not write is refused whole rather than parsed
as far as it happens to work. Write one in your own handler too; there is
nothing to gain from a blob that cannot say what it is.
The two Fugue sequence handlers (CRDTFugueTextHandler,
CRDTFugueListHandler) show what the room is for: their blob groups elements
into runs of consecutive ids from the same peer instead of one entry per
element, and carries the deleted elements inside those runs, marked by a bit,
so a deletion keeps its identity and its place instead of leaving a gap.
Project Status
This library is currently in progress and under active development. While all existing functionality is thoroughly tested, we are continuously working on improvements and new features.
Roadmap
A roadmap is available in the project page. The roadmap provides a high-level overview of the project's goals and the current status of the project.
Contributing
We welcome contributions! Whether you want to:
- Fix bugs
- Add new features
- Improve documentation
- Optimize performance
- Or something else
Feel free to:
- Check out our GitHub repository
- Look at the open issues
- Submit a Pull Request
Acknowledgments
- Fugue Algorithm
- Hybrid Logical Clock
- A comprehensive study of Convergent and Commutative Replicated Data Types
- An O(ND) Difference Algorithm and its Variations (Myers diff algorithm)
- Moving Elements in List CRDTs
- Sqrt Decomposition Data Structure
Apps
- greyhound_markdown β Real-time collaborative markdown editor built on crdt_lf
Packages
Other bricks of the crdt "system" are:
- crdt_socket_sync
- crdt_lf_flutter
- hlc_dart
- crdt_lf_persistence
- crdt_lf_hive
- crdt_lf_drift
- crdt_lf_sqlite
- crdt_socket_sync_dart_frog
- crdt_socket_sync_serverpod
Migrations
Migrating from 4.x to 5.0
The bytes do not change. Changes and snapshot blobs keep the 4.x layout, so a stored document opens as it is and a 4.x peer and a 5.0 peer read the same data. What changes is the Dart API: a handler now always says which kind it is, out loud.
In 4.x a generic handler could leave its kind implicit and fall back on
runtimeType.toString(). That reads CRDTRegisterHandler<bool> on the VM, and
a short renamed symbol in a Flutter web --release build, where dart2js
minifies type names. So two peers on two platforms wrote two different tags for
the same kind, and a reference from one could not be rebuilt by the other β
and dart test -p chrome does not minify, so nothing caught it.
The tag is therefore explicit:
// 4.x β the tag came from the class name
final done = CRDTRegisterHandler<bool>(doc, 'todo.1.done');
// 5.0 β you name the kind
final done = CRDTRegisterHandler<bool>(
doc,
'todo.1.done',
handlerType: 'todo.done',
);
Only the seven generic handlers ask for it: CRDTListHandler,
CRDTMapHandler, CRDTRegisterHandler, CRDTORSetHandler, CRDTORMapHandler,
CRDTFugueListHandler, CRDTFugueMovableListHandler. A non-generic one fixes
its own tag, so CRDTTextHandler(doc, 'text') still compiles.
Pick the tag once and keep it: it travels in every operation envelope and in every reference, so every peer has to spell it the same way. It is not the handler's id β many ids share one tag. See Naming a generic handler.
Registration follows the same idea. A kind is now a HandlerSpec, which every
handler class exposes as spec, and the three calls that name a kind take one:
doc.register(spec), doc.handler(spec, id) and a container's
child(key, spec) / insertChild(index, spec) β see
Nested Structures.
Renamed or removed symbols:
| 4.x | 5.0 | Note |
|---|---|---|
CRDTListHandler<T>(doc, id) |
CRDTListHandler<T>(doc, id, handlerType: 'todo-list') |
And the same for the six other generic handlers. |
registerFactory(type, factory) |
register(HandlerSpec) |
The spec carries the tag, the builder and the formats together, so the tag is not passed beside a factory that also knows it. |
registerDefaultFactories() |
removed | There is no privileged set of kinds any more. Open a handler of the kind, or register(CRDTMapRefHandler.spec). |
registerHandler(handler) |
removed | The constructor registers the handler on the document it is given. |
HandlerFactory |
HandlerBuilder<T> |
Same shape, (doc, id) => handler, with the handler type on the signature. |
a custom handler's MyHandler(super.doc, this.id); |
MyHandler(super.doc, this.id) : super(spec: MyHandler.spec); |
A handler cannot exist without naming its kind. See Custom handlers. |
A 5.0 snapshot carries one extra reserved data entry (crdt_lf/capabilities),
which records what the blobs are written in so a pruned document can still say
what it holds. A 4.x peer ignores it.
Migrating from 3.x to 4.0
A 4.0 peer refuses to read v3 bytes β not from a document's history, not from a snapshot, not from the bytes a Hive/SQLite/Drift adapter saved. 4.0 changes what several of those bytes mean, so reading them under the old assumptions would leave two peers with different content while their version vectors still agreed.
Most of the table below is harmless if you never persisted a document and
never wrote a custom handler. With no stored bytes there is nothing a 4.0
peer can refuse to open, and the operation-layer rows (ORHandlerTag,
OperationDecoders, OperationType, incrementCachedState) only reach code
that implements Handler itself. Peers still have to upgrade together: a 3.x
and a 4.0 client talking live break the same way.
The one row that reaches ordinary code either way is rune indexing. It changes
nothing while the text stays inside the BMP β for ASCII and most Latin text a
rune and a UTF-16 code unit are the same thing. It bites when the text holds
emoji or other non-BMP characters, or when you hand a handler an offset taken
from a Flutter TextField, which counts code units.
Every package that depends on crdt_lf has to move to a version that requires
crdt_lf: ^4.0.0 as well, or a client still resolving 3.x never reaches the
guard.
Renamed or removed symbols:
| 3.x | 4.0 | Note |
|---|---|---|
ORHandlerTag |
OperationId |
The tie-break is the id of the change carrying the operation. A handler no longer writes its own, and it costs no bytes. |
OperationType.typeNameFromKind |
removed | No caller anywhere in the monorepo; OperationType.type already carries the name. |
OperationType.fromPayload |
removed | The payload string is a debug format and never carried a kind. |
a fromBytes(bytes) a handler implemented by hand |
OperationDecoders operationDecoders |
Was raw bytes decoded by whatever a handler wrote. Now a Map<int, Operation Function(Uint8List body)> keyed by the operation's kind byte: the framework looks the kind up itself and raises UnknownOperationKindException on a miss, instead of a handler returning null or hand-rolling the same check. |
FugueTree, FugueNode, FugueNodeTriple, FugueValueNode |
no longer exported | Implementation detail of the two Fugue sequence handlers. FugueElementID is still public. |
update on CRDTFugueTextHandler / CRDTFugueListHandler (delete + insert) |
update keeps the element's identity |
For the old behavior, ask for it: doc.runInTransaction(() { text..delete(index, count)..insert(index, replacement); }); |
incrementCachedState({required operation, required state}) |
adds an optional DeltaSink<Object?>? sink |
It is null unless someone watches the handler's deltas. An override that wants to publish them writes what the operation did to it; see Handler deltas. |
class MyHandler extends Handler<T> |
base/final/sealed class MyHandler extends Handler<T> |
Handler is a base class now. Extending it is unchanged; implementing it is no longer allowed. See Custom handlers. |
Dart >=2.17.0 |
Dart >=3.0.0 |
Class modifiers need it. crdt_socket_sync and crdt_lf_hive move with it. |
| Text handler positions in UTF-16 code units | positions in runes | Affects insert, delete, update, length, stablePositionAt, indexOfStablePosition and myersDiff. See Text handlers index by rune. |