Edit a document through the typed refs your schema hands you. You describe the
document and get its refs in the Crdt module; you change it here.
Every edit takes a Ref (naming the spot), whatever the operation needs, and the Doc,
and returns Result EditError (Doc doc) (contribute also hands back the contribution's
key, and the read… functions here return the value read). The ref's kind makes edits
safe: the compiler rejects a nonsensical operation — increment on text, move on a
non-movable list, set with the wrong value type — so most mistakes are compile errors,
not runtime surprises. What's left, an EditError, is a target that didn't resolve
against the current value; you can usually Result.withDefault doc past it.
import Crdt
import Crdt.Edit as Edit
doc1 =
Edit.set board.title "Trip plan" doc
|> Result.withDefault doc
doc2 =
Edit.increment board.votes 1 doc1
|> Result.withDefault doc1
-- Edit.increment board.title => a compile errorA human-readable description of an EditError, for logging or a message. Branch on
the variants themselves if you need to react differently to each.
Set the value at a ref: a present spot is overwritten, emitting minimal ops (a text spot still merges character-wise).
Through a sum-type payload ref, the write lands only while that variant is active. If
the variant was active at some point and isn't now, the payload key still exists and the
write is a harmless no-op on the read. But for a variant that has never been active the
key is absent entirely and there is nothing to write under, so you get
Err (PathNotFound …) — use switch to make the variant active first. Call sites that
don't care can Result.withDefault doc either way.
Modify the value at a ref: read it, apply f, write it back. No-op if the spot
doesn't currently resolve (e.g. an inactive variant payload).
Add delta to a counter ref. Only compiles for a Counter ref, so
increment on a register or text is a type error.
Change which variant a custom-type value is, seeding the new variant's payload
from the value you pass. Only compiles for a Variants ref (one built by Crdt.custom).
The distinction to keep straight: switch moves between variants; a payload Ref (one
the custom builder handed you, reached via Crdt.at) edits inside the variant that's
currently active. Given the Status type from Crdt.custom:
-- switch the whole status from whatever it is to `Archived "old project"`:
doc |> Edit.switch statusRef (Archived "old project")
-- vs. edit the reason text *within* an already-Archived status (a no-op if the
-- status is currently Active or Snooze):
doc |> Edit.set (statusRef |> Crdt.at status.archivedReason) "new reason"So reach for switch when the case changes (Active → Archived), and a payload ref
when the case stays the same but its contents change. Which variant is active merges
last-write-wins.
append adds to the end, insert at any index (0 prepends), remove deletes by index;
all work on any list. move (reorder) is reserved for a movableList.
Append a value to the end of a list ref. The element schema is recovered from the list
ref itself (no schema argument needed). Compiles for any list (ListK _ ek e).
Insert a value at visible index i of a list ref (0 prepends; an i at or past
the end is the same as append), seeded through the element schema. Compiles for any list
(ListK _ ek e) — you don't need a movableList just to insert somewhere other than the
end (e.g. a "newest first" feed that prepends). Concurrent inserts at the same position
converge deterministically and keep each contiguous run intact (Fugue).
-- prepend a new item (newest-first list):
Edit.insert todosRef 0 todo docRemove the element at visible index i from a list ref.
Move the element at visible index from to index to. Compiles only for a
Movable list (ListK Movable …) — calling it on a plain list is a type error.
The moved item keeps its identity (nested edits and cursors follow it).
Set (create or overwrite) a dict key to a value. The value schema is recovered from the
dict ref itself (no schema argument needed). Compiles for a dict ref (DictK vk v).
Remove a dict key (LWW tombstone).
Removing a key this replica has not observed does nothing: a delete races only against
writes it has actually seen, so a concurrent setKey of an unseen key wins.
Node ids come from reading the tree (Crdt.Tree.itemId); pass them to these.
Add a new node (seeded from value) as the last child of parent (Nothing =
a new root) in a tree ref.
Takes the node bundle (the same one you gave Crdt.tree) for the payload schema. Unlike
append/setKey — whose element schema the library recovers from the container ref — a
tree seeds an empty forest, so the node schema can't be recovered and is passed here.
Re-parent child to be the last child of parent (Nothing = a root).
Cycle-forming moves are skipped (the node stays put), so this always converges.
Move child to sit immediately before sibling (under the sibling's parent).
Move child to sit immediately after sibling (under the sibling's parent).
Remove a node and its subtree from a tree ref.
Replace the character content of a rich-text ref (a minimal text diff, like
set on plain text). Marks are preserved and follow the surviving characters. Only
compiles for a RichK ref.
Apply a formatting mark of kind type_ (e.g. "bold", "link") with value
over the visible character range [from, to) of a rich-text ref. Use
RichText.Flag for boolean marks (bold/italic/…) and RichText.Value href for value
marks (link/color). Only compiles for a RichK ref.
Clear mark type_ over the visible range [from, to) of a rich-text ref.
Read a rich-text ref as blocks (type + depth + spans + marker id), rather
than the flat List Span the schema decodes to. Used to resolve a block index to its
marker OpId for the block edits. See Crdt.RichText.Block.
Read a rich-text ref as blocks as of a past Crdt.Doc.Version — the time-travel
counterpart of readBlocks, mirroring Crdt.Doc.readAt. A UI previewing history uses this
to feed the editor the blocks the document had at that version; the live document is
unchanged.
Replace the text of block blockIndex (a minimal diff scoped to that block's
characters), leaving marks and other blocks untouched. This is what an editor should
call per keystroke so typed text lands inside the right block (unlike setRich, which
diffs the whole document and can misplace text across a block boundary).
Split at a block-relative caret: charOffset characters into block blockIndex
(0 = the leading block). Inserts a block boundary there.
Merge block blockIndex into the previous block (tombstones its marker). No-op on
block 0.
Set (Just t) or clear (Nothing) the app-defined type of block blockIndex.
type_ is an opaque string the library never interprets.
Indent (raise depth by one) block blockIndex.
Outdent (lower depth by one, min 0) block blockIndex.
Add a contribution to a user-defined op-set CRDT (Crdt.opSet). Only compiles for
an OpSetK ref. Pass the same contribution schema you gave opSet and a contribution
value; it is written under a fresh op-id, so concurrent contributions from any replicas
all survive and the op-set's fold sees them all. Returns the contribution's key (its
op-id string), which you can keep to retract exactly that contribution later.
case Edit.contribute board.highScore Crdt.int 42 doc of
Ok ( key, doc1 ) ->
-- keep `key` if you'll want to `retract` this contribution later
...
Err _ ->
...Take back one specific contribution you previously added to an op-set, so it no longer counts toward the folded value.
An op-set is a bag of individual contributions, each written under its own id (see
Crdt.opSet / contribute). retract removes one by that id — the key string that
contribute handed back — not by its value. So you must keep the key from the
contribute you want to be able to undo; retracting isn't "remove a 5 from the set",
it's "remove the contribution I made at that moment". Retracting a key you don't hold
(or one already retracted) is a harmless no-op.
Under the hood it's a last-write-wins presence flip on that contribution — it converges
like any edit and can itself be re-added, and it's what turns a grow-only op-set into one
you can shrink. After retraction the op-set's fold runs over the remaining
contributions only.
-- a shopping cart as an add-wins set of item ids:
cart =
Crdt.opSet { contribution = Crdt.string, fold = dedupe }
-- add an item, remembering the key so it can be removed later:
( appleKey, doc1 ) =
Edit.contribute cartRef Crdt.string "apple" doc
|> Result.withDefault ( "", doc )
-- …the shopper removes it again:
doc2 =
Edit.retract cartRef appleKey doc1
|> Result.withDefault doc1If you want removal keyed by value rather than by contribution id, keep your own
Dict value key alongside the document and look the key up when removing.
Read the value a single ref pointed at as of a past Crdt.Doc.Version — the
ref-scoped counterpart of Crdt.Doc.readAt (which reads the whole document). Use it to
inspect one field's history without decoding the entire document — for example to find
where a past edit landed, or to preview a single field on a timeline.
Err if the ref doesn't resolve at that version (its container didn't exist yet) or the
stored data doesn't match the schema — the same conditions as an ordinary edit.
Why an edit couldn't be applied — the error type the editing functions return.
The compile-time checks already rule out the kind mistakes (you can't
incrementtext, ormovea non-movable list — those don't compile). What's left are the two runtime ways a target can fail to resolve against the document's current value, both of which you can branch on:PathNotFound where_— nothing lives at the spot the ref points to right now. Most often a list index or dictionary key that isn't there (perhaps a peer removed it), or a tree node id that has since been deleted. TheStringnames the spot.WrongNodeType detail— something is there, but it isn't the kind of value the edit expects (for instance a block edit aimed at a field that turned out not to be rich text). This points at corrupt or mismatched data rather than a normal race, and theStringdescribes what was expected.In practice most call sites treat either as "the edit didn't apply, keep the document as it was" and
Result.withDefault docpast it. Branch on the variants when you want to tell the two apart — e.g. ignore aPathNotFound(a benign race) but log aWrongNodeType(a real bug). Render either witheditErrorToString.