JsonCrdt
A CRDT-backed JSON document: a recursive, convergent, arbitrary-depth JSON value that merges correctly under concurrent edits from multiple replicas.
The root is always a JSON object keyed by String. Values at each key can be nested objects, arrays, or scalars:
JsonNode = JsonObject(ORMap<String, JsonNode>)
| JsonArray(Rga<JsonNode>)
| JsonLeaf(MVRegister<JsonValue>)Conflict resolution. Merge is structural and recursive:
Key presence — add-wins: a concurrent
putof the same key survives aremove.Nested values — recursed via JsonNode.piece: objects merge their maps, arrays merge their op-logs, leaves merge their multi-value registers.
Concurrent scalar writes — the JsonNode.Leaf's MVRegister retains all concurrent values; the caller resolves by calling
setagain once they read the multi-value state.Cross-type conflicts (e.g. one replica replaces an object with a scalar concurrently with the other replica adding a key to the object) — the richer structural type wins:
Object > Array > Leaf. This is a data-loss decision, not a data-preservation one. The losing node's entire subtree is silently discarded. The scalar equivalent (JsonNode.Leaf vs JsonNode.Leaf) surfaces both values via MVRegister, but a Leaf-vs-Object cross-type conflict does not. This is a deliberate v1 simplification; a future version may model cross-type conflicts as a multi-valued register at the type level.
Every mutator returns the change rather than a new document: set and remove hand back a Patch holding just the key they touched, which is what belongs on the wire. piece absorbs one — and is also how a caller who wants the resulting whole document gets one: doc.piece(doc.set(key, node)).
Known limitations (v1):
Nested writes are still O(subtree) — a write inside an existing JsonNode.Object or JsonNode.Array is expressed by rebuilding the enclosing node and setting it at the top, so the frame is one key whose value is the whole rebuilt subtree. A path-addressed mutator would make it O(depth); that is #2469.
Move / subtree-reattachment — not supported.
Nested Rga GC — arrays embedded inside a JSON document do not participate in the Rga.compact / us.tractat.kuilt.quilter.Quilter GC path. Tombstones inside array elements accumulate without bound until an explicit compact is triggered by the caller.
Conflict-free re-typing — concurrent changes of a key's type are resolved by the precedence rule above, not by surfacing a conflict.
Serialization. Use JsonCrdt.serializer to obtain a KSerializer. The replica id is not included in the wire format — it is a local identity. After deserializing, call withReplica to restore the local replica id before performing mutations.
Caution — mutate after withReplica. The deserialized document defaults to ReplicaId(""), which collides with RgaId.HEAD's sentinel replica and may corrupt Dot uniqueness if used to mint new operations. Always call withReplica before invoking set or remove on a deserialized document.
See also
the node algebra this document is built over.
the scalar type for JsonNode.Leaf registers.
Samples
val a = ReplicaId("A")
val b = ReplicaId("B")
fun text(writer: ReplicaId, value: String) =
JsonNode.Leaf(MVRegister.empty<JsonValue>().set(writer, JsonValue.Str(value)))
// Two peers have converged on a document with a title and a long body.
var alpha = JsonCrdt.empty(a)
.piece { it.set("title", text(a, "Draft")) }
.piece { it.set("body", text(a, "a very long document body")) }
var bravo = alpha.withReplica(b)
// B retitles the document and puts only that key on the wire. The body does not travel —
// that is the whole saving, and it holds however large the rest of the document gets.
val retitle = bravo.set("title", text(b, "Final"))
check(retitle.delta.keys == setOf("title"))
check(retitle.delta["body"] == null)
alpha = alpha.piece(retitle)
bravo = bravo.piece(retitle)
check(alpha == bravo)
// Both scalar writes are retained, because neither observed the other: the leaf is a
// multi-value register the caller resolves by writing again once it has read both.
val title = alpha["title"] as JsonNode.Leaf
check(title.register.values == setOf(JsonValue.Str("Draft"), JsonValue.Str("Final")))
// A concurrent write beats a concurrent remove: B's write mints a tag A's remove never saw.
val concurrent = alpha.withReplica(b).set("title", text(b, "Revived"))
check("title" in alpha.piece(alpha.remove("title")).piece(concurrent).keys)
// A remove ships the retired tags and nothing else — its delta holds no key at all.
val drop = alpha.remove("body")
check(drop.delta.keys.isEmpty())
alpha = alpha.piece(drop)
bravo = bravo.piece(drop)
check("body" !in alpha.keys)
check("body" !in bravo.keys)Properties
Functions
Unions the Rga.causalDots of every JsonNode.Array reachable from the root, recursing through JsonNode.Object values. This feeds the causal-stability GC barrier in us.tractat.kuilt.quilter.Quilter: without this override, embedded Rga tombstones in nested arrays would never be considered for compaction because the delivered frontier would always be empty.
The elementwise max of the Quilted.causalFloors of every node reachable from the root — the floor counterpart of the causalDots union above, over the same reachable set.
Remove key — and return the change: the tags currently on it, retired, and nothing else. Absorbing that patch drops the key; the retired tags stay witnessed, so the removal propagates on merge. To hold the resulting document locally, doc.piece(doc.remove(key)).
Returns a copy of this document configured to issue mutations on behalf of replica. Call this after deserialization to restore the local replica id.