Repository navigation
Releases
v0.9.1 — fix precompiled NIF install
Fixes the v0.9.0 install path:
- Rustler is no longer
optional: true— required for the force_build fallback when the checksum file is absent or the target isn't prebuilt. - macOS artifact naming corrected (
.so.tar.gzconvention matching rustler_precompiled expectations). - Checksum file will be committed once the release workflow completes.
Consumers should pin to tag: "v0.9.1". Source build works immediately; prebuilt download activates once checksums are committed in a follow-up.
v0.9.0 — production polish
[0.9.0] — 2026-05-15
Production polish release. Strictly additive — zero breaking changes.
NIF surface grows from 115 → ~130 functions. Test suite from
133 → 158 :nif-tagged tests.
Added — Precompiled NIF artifacts (rustler_precompiled)
Foundation for zero-Rust-toolchain installs. Four targets ship in the
release asset matrix:
| Target triple | Platform |
|---|---|
aarch64-apple-darwin |
Apple Silicon Mac |
x86_64-apple-darwin |
Intel Mac |
aarch64-unknown-linux-gnu |
ARM Linux (Graviton, Pi) |
x86_64-unknown-linux-gnu |
AMD64 Linux |
Set FORCE_LORO_EX_BUILD=1 to fall back to a source build.
Added — Time travel API (6 NIFs)
LoroEx.checkout/2— detach and move state to a given frontier.LoroEx.attach/1— re-attach a detached doc to latest.LoroEx.detach/1— detach without checking out.LoroEx.detached?/1— predicate for detached state.LoroEx.checkout_to_latest/1— shorthand for checkout + attach.LoroEx.set_detached_editing/2— enable/disable editing while
detached.
Added — JSON-path queries (2 NIFs)
LoroEx.get_by_str_path/2— resolve a/-separated path to a
JSON value.LoroEx.jsonpath/2— evaluate a JSONPath expression against the
doc state.
Added — LoroCounter container (3 NIFs + :counter kind)
Closes the container-coverage gap to 100% of Loro 1.12's types.
LoroEx.counter_increment/3— increment (or decrement) by a float.LoroEx.counter_get/2— read current value.LoroEx.counter_decrement/3— convenience decrement wrapper.:counteradded to the container kind enum.
Added — Subscription improvements (2 NIFs + internal dispatcher)
LoroEx.subscribe_pre_commit/2— fire callback before a
transaction commits (validation / side-effects).LoroEx.subscribe_peer_id_change/2— notify when the doc's peer
id changes (fork, set_peer_id).- Internal: subscription dispatcher rewritten from per-event
spawn to a dedicated background thread with mpsc channel. Removes
the throughput ceiling noted in 0.2.0.
Added — Property-based convergence tests (4 properties)
Powered by stream_data. Tagged :property (excluded from default
mix test; included via --include property).
- Concurrent text edits converge after bidirectional sync.
- Concurrent map mutations converge.
- Concurrent list operations converge.
- Concurrent tree moves produce no cycles and converge.
Internal
- 25 new tests; suite grows from 133 → 158
:nif-tagged tests. mix test --include nif --include propertyexercises the full
surface.
v0.8.0 — projection pipeline, MovableList, tree queries, revert
[0.8.0] — 2026-05-14
The biggest single release since 0.5.0. Closes the largest functional gaps
in the binding: MovableList is now wrappable end-to-end (it was 0%
covered before), the tree container exposes proper structural queries
instead of forcing a full JSON decode, and the projection-pipeline
integration that prompted the release ships as containers_touched_since/2.
Public NIF surface goes from 73 → 115 functions (≈24% → ≈45% of the
Loro 1.12 facade). Strictly additive — zero breaking changes for callers
of 0.7.x.
Added — projection pipeline
LoroEx.containers_touched_since/2— list the distinct
ContainerIDstrings that received at least one op between a stored
version vector and the doc's current state. The Layer-1 pre-filter
for projection / cache rebuilders: cost is O(ops_since), not
O(doc_size). For a single paragraph edit on a 500-block doc, this
typically returns 1–3 CIDs in microseconds. Implementation goes
throughexport_json_updates_without_peer_compression(the
compressed variant rewrites peer ids in CID strings, which would
break round-tripping).
Added — MovableList full surface
Previously the :movable_list kind could be named as a child container
but the resulting handle was inert. 18 new NIFs cover the full
operational surface:
- Mirror of
list_*:movable_list_get_json/2,
movable_list_length/2,movable_list_get_json_at/3,
movable_list_push/3,movable_list_insert/4,
movable_list_delete/4,movable_list_insert_container/4,
movable_list_get_child_cid/3,
movable_list_get_or_create_container/4,
movable_list_get_cursor/4. MovableList-only:movable_list_set/4(set-by-index),
movable_list_move/4(the headline feature — preserves identity
across moves so concurrent moves of the same element converge),
movable_list_pop/2,movable_list_clear/2,
movable_list_set_container/4,movable_list_get_creator_at/3,
movable_list_get_last_mover_at/3,
movable_list_get_last_editor_at/3.
A regression test exercises convergence: Alice moves index 0→2, Bob
concurrently moves index 2→0; after sync both peers see the same
list.
Added — Tree structural queries
8 new query NIFs so consumers walking a tree don't have to decode
tree_get_nodes/2's full JSON dump for every structural question:
tree_parent/3— projected as{:ok, parent_id}|:root|
:deleted|:unexist.tree_children/3,tree_children_num/3,tree_roots/2.tree_contains/3— answers "has this id existed in this tree",
not "is currently live"; pair with the next entry to disambiguate.tree_is_node_deleted/3— confirms cascade-on-parent-delete.tree_fractional_index/3— for stable relative ordering.tree_get_value_with_meta/2— JSON tree with each node's meta map
inlined.
Added — revert_to/2
LoroEx.revert_to/2— rewind the doc to a previous frontier by
emitting new ops that invert the changes since that point. NOT
a checkout: the inverse ops sync to peers like any other edit, fire
subscription callbacks, and integrate withUndoManager. The
forward-compatible-undo distinction is documented prominently in
the@docbecause it is unintuitive.
Added — LoroDoc admin & introspection (Tier A)
17 small additions filling gaps in the LoroDoc surface:
- Identity:
peer_id/1,set_peer_id/2. - State predicates:
shallow?/1,has_container/2,
pending_txn_len/1. - Sizing & analysis:
len_ops/1,len_changes/1,analyze/1
(returns per-container size / depth / ops-num / dropped /
last-edit-time as JSON). - Path & deep value:
get_path_to_container/2(returns the
root-to-target path, eliminating the need for consumer apps to
maintain their own CID-path index),get_deep_value_with_id/1
(full state preserving CIDs). - Sync helpers:
import_batch/2(atomic batch import — single
mutex / single commit),from_snapshot/1(one-shot constructor),
decode_import_blob_meta/2(peek at a blob without importing —
useful for auth, quota, corruption checks),fork_at/2(fork at
a specific frontier). - Memory hygiene:
free_history_cache/1,free_diff_calculator/1,
compact_change_store/1.
API decisions documented in this release
shallow?/1is the public Elixir-idiomatic name; the Native stub
keepsis_shallow(matching Loro's Rust function) with a Credo
override.movable_list_move/4uses the full Elixir verb rather than Loro's
Rust abbreviationmov.- Tree parent variants project as the atoms
:root,:deleted,
:unexist, with{:ok, id}for the value case. - All Rust struct returns (
DocAnalysis,ImportBlobMetadata,
container path, deep value with id) are JSON strings, matching the
existingget_map_json/tree_get_nodespattern.
Internal
- Adds
:root,:deleted,:unexistatoms for tree-parent variants. - New helper
get_movable_list_handle/2next to the existing
get_list_handle/get_tree_handle. - 42 new tests; suite grows from 91 → 133
:nif-tagged tests.
v0.7.0 — LoroEx.fork/1
[0.7.0] — 2026-05-12
Added
LoroEx.fork/1— return an independent clone of a document.
The clone observes the parent's full op history at the moment of
the call but lives in a separate Rust resource; mutations and
exports on the fork do not affect the parent, and vice versa. Loro
assigns the clone a new random peer id. O(n) in op-log size but
considerably cheaper thanexport_snapshot/1because no
serialization to bytes happens. Primary use case: move an
expensiveexport_snapshot/1orexport_shallow_snapshot/2call
off a doc-owning GenServer's mailbox by forking synchronously
(fast) and exporting the fork inside aTask.
v0.6.0 — nested-container ergonomics + structured map/list values
[0.6.0] — 2026-05-10
Both changes in this release are strictly additive — zero breaking
changes for callers of the 0.5.x API. Inputs that previously errored
now succeed; all previously-working calls keep their behavior.
Added
LoroEx.map_get_or_create_container/4and
LoroEx.list_get_or_create_container/4— idempotent,
race-free "ensure a child container exists" operations. The
check-and-insert happens under a single doc-mutex lock, so there
is no window where concurrent callers can both see a missing CID
and both insert. Directly retires the
map_get_child_cid || map_insert_containerdance and the
hydrate-safety footgun in super_loop's
Doc.Server.ensure_root_children_cid/1. Behavior:- Existing container of the same kind → return its CID unchanged.
- Existing container of a different kind →
{:error, {:invalid_container_kind, _}}. - Scalar at the key/index →
{:error, {:invalid_value, _}}
(never clobbers data). - Absent → insert and return new CID.
list_get_or_create_containeradditionally treats
index == lengthas "append at end" and
index > lengthas{:error, {:out_of_bound, _}}.
LoroEx.map_get_child_cid/3andLoroEx.list_get_child_cid/3
— return the serializedContainerIDof a child container, or
nilfor scalar/absent/out-of-bounds cases. The return shape
matchesmap_insert_container/4/list_insert_container/4
(a bare CID string, no{:ok, _}wrapping). Enables path-based
descent into nested container structures (e.g.
root.children[0].children) without going through
get_map_json/list_get_json, which call Loro'sget_deep_value
and strip container IDs. Two primary uses:- Nested block writes: agent-facing block-edit tools can now
resolve a path like[N, M, ...]to the CID of an arbitrary
descendant and pass it to any map/list/text function. - Hydrate-safe path caches: after
apply_update, consumers that
keep apath → cidcache can re-derive it instead of
re-creating containers (which would clobber content).
- Nested block writes: agent-facing block-edit tools can now
LoroEx.map_keys/2andLoroEx.map_size/2— cheap map
introspection. O(n) and O(1) respectively at the CRDT layer,
avoiding the full deep-JSON-encode cost ofget_map_json/2 |> Jason.decode!() |> Map.keys()when the caller only needs keys or
a count.LoroEx.list_length/2andLoroEx.list_get_json_at/3—
symmetric withmap_size/2/map_get_json/3.list_lengthis
O(1);list_get_json_atreturns one element as JSON (the literal
string"null"for out-of-bounds, matchingmap_get_json/3's
missing-key behavior). For nested container elements returns the
deep value; uselist_get_child_cid/3to recover the CID.LoroEx.list_insert/4— insert a JSON value at a specific
position (shifts tail). Closes the API symmetry gap vs
list_push/3(end) andlist_insert_container/4(container at
position). Same value rules aslist_push/3.
Changed
LoroEx.map_set/4andLoroEx.list_push/3now accept JSON-encoded
objects and arrays as values, not just scalars. The
parse_scalar_json/json_to_loro_valuehelpers now route
structured values through Loro's built-in
LoroValue::from(serde_json::Value)impl, producing
LoroValue::MapandLoroValue::Listvariants. The helpers keep
their names for source-compat; their@docstrings are updated.
Structured values are stored as frozen values inside the parent
— for CRDT-level merging on nested fields, use
map_insert_container/4/list_insert_container/4to materialize
a nested container instead. The same relaxation applies to
text_mark/6(which also goes throughparse_scalar_json), so
structured mark values (e.g. complex attribute objects) now
round-trip throughtext_to_delta/2.- Tests reflecting the old scalar-only behavior were updated:
themap mutation,list mutation, andbad inputs to new NIFs
describes previously asserted{:error, {:invalid_value, _}}for
objects/arrays. They now verify successful round-trips.
v0.5.2 — cargo fmt so CI is green
[0.5.2] — 2026-05-01
Changed
cargo fmtrun acrossnative/loro_nif/src/lib.rs. Pure
formatting; no behavior change. The 0.5.1 CI workflow caught
that the file had never been fmt'd, and thecargo fmt --check
job failed on the 0.5.1 commit. This patch resolves that so CI
is green onmain.
v0.5.1 — declare :jason runtime dep; add CI
[0.5.1] — 2026-05-01
Fixed
jasonis now a declared runtime dependency. v0.5.0 called
Jason.encode!/1andJason.decode!/1fromtext_to_delta/2,
text_apply_delta/3,text_get_richtext_value/2, and every
LoroEx.Presencefunction, but never declared:jasonin
mix.exs. Applications that didn't already have Jason
transitively via another dep would crash at runtime with
UndefinedFunctionError. Caught by dialyzer once it had a full
PLT; fixed by adding{:jason, "~> 1.4"}as a runtime dep.
Added
- CI workflow at
.github/workflows/ci.yml. Runs on every push
and pull request tomain:mix format --check-formatted,
mix credo --strict,mix dialyzer,cargo fmt --check,
cargo clippy -D warnings,mix test --include nif,mix docs
(fails on any warning). Seven parallel jobs with per-job caching
ofdeps,_build, and the Rust target directory.
Changed
mix formatrun across the tree — minor style fixes in
lib/loro_ex.ex,mix.exs, andtest/loro_ex_test.exs. No
behavior change.
v0.5.0 — Tier-1 collaboration APIs
[0.5.0] — 2026-05-01
Added
All the "Tier 1" features — the minimum surface for a collaborative editor
product — are now exposed.
Rich text (Peritext).
LoroEx.text_mark/6,text_unmark/5— apply and remove formatting marks
(bold, italic, link, color, …) over Unicode ranges.LoroEx.text_to_delta/2,text_apply_delta/3— Quill-compatible deltas
for interop with TipTap, ProseMirror, and CodeMirror rich-text editors.LoroEx.text_get_richtext_value/2— decoded rich-text segments with
per-run attributes.LoroEx.text_len/3— length in:unicode | :utf8 | :utf16units.LoroEx.text_convert_pos/5— convert an index between unit systems.
Useful at the browser/server boundary (UTF-16 ↔ Unicode).
Cursors (stable positions).
LoroEx.text_get_cursor/4,list_get_cursor/4— produce an opaque
binary that tracks a position across concurrent edits.LoroEx.cursor_resolve/2— resolve a cursor to its current
{pos, side}. Returns{:error, {:history_cleared | :container_deleted | :id_not_found, _}}when the anchor is gone.- New atoms:
:left | :middle | :right,:unicode | :utf8 | :utf16.
Undo / redo.
- New module
LoroEx.UndoManagerwithnew/1,undo/1,redo/1,
can_undo/1,can_redo/1,record_new_checkpoint/1,
set_max_undo_steps/2,set_merge_interval/2. Local-only per Loro's
semantics: only undoes operations from the bound peer.
Ephemeral / awareness (presence).
- New module
LoroEx.Presencewrapping Loro'sEphemeralStore. new/1,set/3,get/2,delete/2,keys/1,get_all_states/1,
encode/2,encode_all/1,apply/2,remove_outdated/1,
subscribe/2. Subscription callback delivers
{:loro_ephemeral, ref, bytes}— bytes are ready to feed into
apply/2on a peer store.
Structured diff subscriptions.
LoroEx.subscribe_container/3— subscribepidto diffs on one
container (root name or nested ContainerID).LoroEx.subscribe_root/2— subscribe to diffs across the whole doc.- Both deliver
{:loro_diff, ref, events_json_binary}with a structured
diff payload (text ops, map updates, list ops, tree actions).
Nested containers.
LoroEx.map_insert_container/4,list_insert_container/4— create a
new:text | :map | :list | :movable_listnested inside a parent
container. Returns the new container's serialized id.LoroEx.tree_get_meta/3— return the id of a tree node's metadata
map. Pass it tomap_set/4/map_get_json/3to store per-node data
(title, icon, props).- All container-taking NIFs accept serialized
ContainerIDstrings in
addition to root names.
Changed
- Container-id resolution is unified: functions try
ContainerID::try_from
first and fall back to root-name lookup. No API break.
New error reasons
:invalid_cursor, :invalid_delta, :invalid_container_kind,
:history_cleared, :id_not_found, :ephemeral_apply_failed.
Tested
Sixteen new :nif tests across seven describe blocks: text marks,
length helpers, cursors, undo manager, presence, structured diff
subscriptions, nested containers, plus one end-to-end cross-feature
test (edit + mark + cursor survives concurrent insert + undo + presence)
that exercises every Tier-1 surface together.
39 :nif tests total, all passing.
Documentation
- Every public function in
LoroEx,LoroEx.UndoManager, and
LoroEx.Presencenow has a@docblock with Purpose / Use cases /
Example / Errors / See also sections. - New topic guides under
docs/guides/:
Getting started,
Rich text,
Sync & persistence,
Presence & cursors,
Tree & blocks,
Undo. mix.exswires all guides into ExDoc with sidebar groups.
mix docsnow builds without warnings.
Build
loropinned exactly at=1.12.0inCargo.toml(was"1.10"
caret range; the lockfile was already resolving to 1.12.0).rust-versionbumped to 1.91 inCargo.tomlto match the Rustler
0.37 MSRV stated in the README andrust-toolchain.toml.
Behavior notes (not a break in practice)
container_idstrings starting with"cid:"are now parsed as
serializedContainerIDs rather than treated as root-container
names. If you were using a literal"cid:…"string as a root name
in v0.4.0 (extremely unusual), rename it. Any other root name
("body","settings", etc.) is unaffected.
Known limitations
- Subscriptions must be unsubscribed explicitly. Due to an
Arc cycle between the subscription handle and its delivery
closure, dropping a subscription reference without calling
unsubscribe/1leaks the subscription (and its captured doc ref)
until BEAM exits. Always pair everysubscribe/*with an
unsubscribe/1at shutdown. Fix planned for 0.5.1 via a global
subscription registry. text_len/3with an unknown unit atom returns
{:error, {:invalid_value, _}}instead of raising
FunctionClauseError(added in this release). Previously unknown
units crashed the caller.