Skip to content

JSON CRDT Engine

LWW (last-writer-wins) CRDT for free-form JSON documents indexed by dotted paths. Best when your document is a tree of named keys whose shape is mostly stable.

When to use

  • Configuration trees, feature flags, free-form metadata.
  • Document headers (title, tags, owner) attached to a richer body.
  • Anything modeled as { "user.name": "Alice", "user.age": 33, ... }.

For hierarchical block-style data with reordering, use Tree CRDT. For flat name-value pairs, the lighter Form OT is usually a better fit.

Types

TDoc = Json_DocumentDictionary<path, CrdtRegister> where each register stores (Value, Timestamp, PeerId).

TOp = JsonOpBatch — a bundle of JsonOp polymorphic variants:

Op Effect
SetPropertyOp(path, value, ts, peerId) LWW assign at path.
DeletePropertyOp(path, ts, peerId) LWW tombstone at path (JSON null register).

Paths are opaque strings — the engine uses them as flat keys. A convention like "root.user.name" is fine, but the engine doesn't parse hierarchy. For real hierarchy with Move, use Tree CRDT.

Worked example

var batch = new JsonOpBatch(new[]
{
    new SetPropertyOp("user.name",  JsonString("Alice"),  100, "peer1"),
    new SetPropertyOp("user.age",   JsonNumber(33),       100, "peer1"),
});

var newState = engine.Apply(state, batch);

When two peers write to the same path concurrently, the LWW resolution runs per-op: higher timestamp wins, ties broken by ordinal PeerId compare. This is deterministic across replicas regardless of delivery order.

Registration

services.AddOpStream()
    .AddEngine<Json_Document, JsonOpBatch, JsonCrdtEngine>("json");

Undo / redo and RestampToWin

JsonCrdtEngine overrides RestampToWin(op, currentState) so that a cached inverse always beats any concurrent LWW winner that landed since record-time. This is what makes UndoRedoEngine<Json_Document, JsonOpBatch> produce visible undoes even under heavy concurrency.

When not to use

  • Lists you need to reorder. Paths are stable, but moving an item between paths is "delete + re-create" — you lose history continuity and concurrency semantics get awkward.
  • Block-tree shapes (Notion / outliners). Reach for Tree CRDT instead.
  • Spreadsheet shapes. Use Table CRDT.