crdt — replicated JSON documents
crdt is a full OCaml implementation of JSON CRDTs, wire-compatible with the json-joy ecosystem: any JSON structure becomes a document that independent replicas can edit concurrently and merge deterministically. The snippets below come from two programs under examples/ — one runs two replicas to convergence, the other tours the codec formats — both in the test suite.
Models, builders, operations
A Model is one replica's copy of the document. You never mutate it directly: a Patch_builder records operations, and applying the resulting patch is what changes the model.
A builder per patch
(* Each patch is produced by a builder with its own session id, so
operation ids from different patches can never collide. *)
let editor sid = Builder.create (Clock.create_vector sid)
(* Flush the builder's pending operations into a patch. *)
let flush_exn builder =
match Builder.flush builder with
| Some patch -> patch
| None ->
prerr_endline "error: no pending operations";
exit 1
(* Simulate the network: encode to binary on one side, decode on the
other. The same bytes are what a json-joy peer would receive. *)
let send patch =
match Codec.decode (Codec.encode patch) with
| Ok patch -> patch
| Error msg ->
Printf.eprintf "error: decode failed: %s\n" msg;
exit 1Each builder carries a session id and a clock vector; every operation it emits gets a unique id from that clock, so operations from different replicas can never collide. flush turns the pending operations into a patch — the unit that travels between replicas.
Building structure
(* Add [key] to object [obj] with the constant [v]: one op creates the
constant node, the next links it under the key. *)
let add_field builder ~obj key v =
let value = Builder.next_op_id builder in
Builder.new_con builder v;
Builder.ins_obj builder ~obj ~entries:[ (key, value) ]
(* [Model.view] resolves the node tree into a plain [Value.t]. *)
let show name model =
Printf.printf " %s = %s\n" name (Crdt.Value_codec.encode (Model.view model))Low-level operations compose document structure: new_con makes a constant node, new_obj an object, ins_obj links a value under a key, ins_val sets a register. Model.view resolves the whole node tree into a plain Value.t you can render or compare.
Concurrent edits converge
The point of a CRDT: two replicas edit independently, exchange patches in any order, and end up identical.
A first shared document
(* Each replica is a model with its own session id. *)
let alice = Model.create 1 and bob = Model.create 2 in
(* Replica A creates the document: an object at the root holding two
constant fields. The object's op id addresses it in later edits. *)
let ed = editor 100 in
let obj = Builder.next_op_id ed in
Builder.new_obj ed;
Builder.ins_val ed ~obj:Model.root_id ~value:obj;
add_field ed ~obj "title" (Value.String "Plan the trip");
add_field ed ~obj "status" (Value.String "draft");
let p0 = flush_exn ed in
Model.apply alice p0;
Model.apply bob (send p0);Replica A builds an object at the document root and ships the patch to B; both now hold the same document. An operation's id addresses the node it created, so later edits can target it.
Divergence, then convergence
(* Concurrent edits: each side changes its own copy without seeing
the other's patch yet, so the views temporarily diverge. *)
let ed_a = editor 11 and ed_b = editor 22 in
add_field ed_a ~obj "place" (Value.String "Lisbon");
let pa = flush_exn ed_a in
Model.apply alice pa;
add_field ed_b ~obj "owner" (Value.String "Bob");
let pb = flush_exn ed_b in
Model.apply bob pb;Each side adds a different field without seeing the other's patch, so the views temporarily disagree. Exchanging and applying the patches reconciles them — concurrent writes to distinct keys both survive; concurrent writes to the same register resolve last-writer-wins on logical time, and lists and text use RGA so concurrent inserts interleave deterministically.
(* Exchange the patches; each replica applies the other's edit. *)
Model.apply bob (send pa);
Model.apply alice (send pb);
print_endline "after exchanging patches:";
show "A" alice;
show "B" bob;
match Value.equal (Model.view alice) (Model.view bob) with
| true -> print_endline "replicas converged"
| false ->
prerr_endline "error: replicas diverged";
exit 1$ dune exec examples/crdt_replicas/main.exeThe high-level editing API
(* Model_api edits through typed proxies; [commit] flushes the pending
operations as a patch and applies it to the model in one step. *)
let build () =
let model = Model.create 7001 in
let api = Api.create model in
let obj =
match Api.obj api "" with
| Some obj -> obj (* an obj node now backs the document root *)
| None -> fail "root is not an object"
in
let _ = Api.obj_set obj ~key:"title" ~value:(Value.String "Notes") in
let _ = Api.obj_set obj ~key:"stars" ~value:(Value.Int 5) in
match Api.commit api with
| Some patch -> (model, patch)
| None -> fail "nothing to commit"Model_api wraps the low-level operations in typed proxies — obj returns an object handle, obj_set writes a key — and commit flushes the pending edits as a patch and applies it in one step. It is the comfortable way to build documents; the raw Patch_builder is there when you need precise control over operation ids.
Codecs: the wire and storage formats
Patches and whole documents both encode to several formats: binary for the wire and storage, compact and verbose JSON for debugging and human inspection.
Patch codecs
(* Patches encode to binary (the wire format, json-joy compatible),
compact JSON and verbose JSON. Decoders return a result. *)
let bytes = Patch_binary.encode patch in
Printf.printf "binary patch: %d bytes\n" (Bytes.length bytes);
Printf.printf "compact patch: %s\n" (Patch_compact.encode patch);
let replica = Model.create 7002 in
(match Patch_binary.decode bytes with
| Ok decoded -> Model.apply replica decoded
| Error msg -> fail ("binary patch decode: " ^ msg));
(match Value.equal (Model.view model) (Model.view replica) with
| true -> print_endline "binary round-trip: replica matches"
| false -> fail "binary round-trip diverged");The binary patch is the json-joy-compatible wire format — the exact bytes a browser peer would send or accept. Decoders return a result, so a truncated or corrupt patch is a value to handle, not a crash. Encoding a patch, shipping the bytes, decoding and applying them reproduces the edit on the far replica.
Document snapshots
(* Whole documents snapshot the same way: binary for the wire and
storage, verbose JSON when a human needs to read the node tree. *)
let snapshot = Model_codec.Binary.encode model in
Printf.printf "binary document: %d bytes\n" (Bytes.length snapshot);
(match Model_codec.Binary.decode snapshot with
| Some copy ->
Printf.printf "decoded view: %s\n"
(Crdt.Value_codec.encode (Model.view copy))
| None -> fail "binary document decode failed");
print_endline "verbose document:";
print_endline (Model_codec.Verbose.encode_string ~minify:false model)A whole model snapshots the same way: binary for persistence and transfer, verbose JSON when you need to read the node tree with its ids and timestamps. Compact, sidecar and indexed formats round out the set for different size and random-access trade-offs.
$ dune exec examples/crdt_codecs/main.exeReal-time sync, and in araara
The JSON-Rx RPC protocol carries patches over a live connection for real-time collaboration; hcs provides the WebSocket, crdt provides the convergence, and json-joy compatibility means a TypeScript client and an OCaml server can edit the same document.
crdt answers an araara hard rule: nothing cluster-replicated may be a global mutable variable. hive's cluster mode replicates worker state as crdt documents over swim gossip; collaborative end-user features ride the same machinery.