gampleman/elm-crdt/1.0.0

Crdt.Edit

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 error

Errors

= PathNotFound String
| WrongNodeType String

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 increment text, or move a 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. The String names 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 the String describes 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 doc past it. Branch on the variants when you want to tell the two apart — e.g. ignore a PathNotFound (a benign race) but log a WrongNodeType (a real bug). Render either with editErrorToString.

A human-readable description of an EditError, for logging or a message. Branch on the variants themselves if you need to react differently to each.

Simple values

set : Ref r kind a -> a -> Doc doc -> Result EditError (Doc doc)

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.

over : Ref r kind a -> (a -> a) -> Doc doc -> Result EditError (Doc doc)

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).

increment : Ref r Counter Int -> Int -> Doc doc -> Result EditError (Doc doc)

Add delta to a counter ref. Only compiles for a Counter ref, so increment on a register or text is a type error.

switch : Ref r (Variants v) v -> v -> Doc doc -> Result EditError (Doc doc)

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.

Lists

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.

Ref r (ListK mv ek e) (List e)
-> e
-> Doc doc
-> Result EditError (Doc doc)

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).

Ref r (ListK mv ek e) (List e)
-> Int
-> e
-> Doc doc
-> Result EditError (Doc doc)

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 doc
Ref r (ListK mv ek e) (List e)
-> Int
-> Doc doc
-> Result EditError (Doc doc)

Remove the element at visible index i from a list ref.

Ref r (ListK Movable ek e) (List e)
-> Int
-> Int
-> Doc doc
-> Result EditError (Doc doc)

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).

Dictionaries

Ref r (DictK vk v) (Dict String v)
-> String
-> v
-> Doc doc
-> Result EditError (Doc doc)

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).

Ref r (DictK vk v) dictType
-> String
-> Doc doc
-> Result EditError (Doc doc)

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.

Trees

Node ids come from reading the tree (Crdt.Tree.itemId); pass them to these.

Ref r (TreeK ek a) forest
-> Crdt ek a s
-> a
-> Maybe OpId
-> Doc doc
-> Result EditError (Doc doc)

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.

Ref r (TreeK ek a) forest
-> OpId
-> Maybe OpId
-> Doc doc
-> Result EditError (Doc doc)

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.

Ref r (TreeK ek a) forest
-> OpId
-> OpId
-> Doc doc
-> Result EditError (Doc doc)

Move child to sit immediately before sibling (under the sibling's parent).

Ref r (TreeK ek a) forest
-> OpId
-> OpId
-> Doc doc
-> Result EditError (Doc doc)

Move child to sit immediately after sibling (under the sibling's parent).

Ref r (TreeK ek a) forest
-> OpId
-> Doc doc
-> Result EditError (Doc doc)

Remove a node and its subtree from a tree ref.

Rich text

Ref r RichK (List Span)
-> String
-> Doc doc
-> Result EditError (Doc doc)

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.

Ref r RichK (List Span)
-> Int
-> Int
-> String
-> Doc doc
-> Result EditError (Doc doc)

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.

Ref r RichK (List Span)
-> Int
-> Int
-> String
-> Doc doc
-> Result EditError (Doc doc)

Clear mark type_ over the visible range [from, to) of a rich-text ref.

readBlocks : Ref r RichK (List Span) -> Doc doc -> Result EditError (List Block)

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.

Version
-> Ref r RichK (List Span)
-> Doc doc
-> Result EditError (List 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.

Ref r RichK (List Span)
-> Int
-> String
-> Doc doc
-> Result EditError (Doc doc)

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).

Ref r RichK (List Span)
-> Int
-> Int
-> Doc doc
-> Result EditError (Doc doc)

Split at a block-relative caret: charOffset characters into block blockIndex (0 = the leading block). Inserts a block boundary there.

Ref r RichK (List Span)
-> Int
-> Doc doc
-> Result EditError (Doc doc)

Merge block blockIndex into the previous block (tombstones its marker). No-op on block 0.

Ref r RichK (List Span)
-> Int
-> Maybe String
-> Doc doc
-> Result EditError (Doc doc)

Set (Just t) or clear (Nothing) the app-defined type of block blockIndex. type_ is an opaque string the library never interprets.

Ref r RichK (List Span)
-> Int
-> Doc doc
-> Result EditError (Doc doc)

Indent (raise depth by one) block blockIndex.

Ref r RichK (List Span)
-> Int
-> Doc doc
-> Result EditError (Doc doc)

Outdent (lower depth by one, min 0) block blockIndex.

Your own CRDT types

Ref r (OpSetK ck c) a
-> Crdt ck c s
-> c
-> Doc doc
-> Result EditError ( String, Doc doc )

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 _ ->
        ...
Ref r (OpSetK ck c) a
-> String
-> Doc doc
-> Result EditError (Doc doc)

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 doc1

If 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.

Reading a ref at a past version

readAt : Version -> Ref r kind a -> Doc doc -> Result EditError a

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.