Skip to content

fix: apply multi-child MovableList diffs as moves without losing elements - #1138

Merged
zxch3n merged 1 commit into
mainfrom
fix/movable-list-apply-diff-multi-move
Sep 27, 2026
Merged

zxch3n merged 1 commit into
mainfrom
fix/movable-list-apply-diff-multi-move

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 27, 2026

Copy link
Copy Markdown
Member

Summary

Fixes two defects in MovableListHandler::apply_delta (crates/loro-internal/src/handler/movable_list_apply_delta.rs). That function is the movable-list path of LoroDoc::apply_diff, revert_to, and UndoManager undo/redo. Both defects were found while cross-checking the loro.js port (#1131, review round 3, B3 and the mixed-operation random matrix).

  1. applyDiff that moves several children drops one. Swapping two LoroText children with [{delete: 2}, {insert: ["🦜:" + b.id, "🦜:" + a.id]}] produced {l: ["A"]} instead of {l: ["B", "A"]}. Rotations, reversals, and children moved among scalars could also lose elements or put them in the wrong order.
  2. Undo/redo panics. Undo replays the inverse diff through the same handler. Minimal case: [text t2, "s"], two moves that cancel out, Rust diff → apply_diff → undo(). Main hits index - 1 with index == 0: a subtract overflow in debug, and in release/WASM mov(0, usize::MAX) → OutOfBound → .unwrap() panic.

B2 (adjacent delete items dropping a freshly inserted value) was not reproducible in Rust. The public ListDiffItem → DeltaRope conversion merges adjacent deletes. This PR adds Rust and WASM regression tests for that shape anyway, and the new algorithm does not rely on the merge.

Root cause

The old code deferred every delete to the end. It tracked the slots vacated by moved children with ad-hoc index arithmetic (deleted_indices, next_deleted, index_shift) and assumed a moved child's old slot lay behind the current index. Once two or more children moved, or a child was re-inserted before its own delete, the arithmetic went wrong in three ways:

  • it deleted a moved child;
  • it inserted at the wrong index;
  • it underflowed and made an out-of-range mov behind process_replacements(..).unwrap(), which is the undo panic.

Fix

apply_delta now runs in three passes. The invariant is documented in the module docs and in context/movable-list-apply-diff.md.

  1. Plan: record the delete ranges in original indices, and decide which deleted children are re-inserted ("claimed"). The lookup follows container_remap as before.
  2. Delete the unclaimed elements right to left, so original indices stay valid.
  3. Place: walk the delta with a live cursor. Each inserted value goes right after the previous target element. live[cursor..] is always the unplaced claimed children of the current delete gap, followed by the unmoved originals. A retain skips both. A claimed child found before the cursor moves to cursor - 1. Claimed children are located with get_child_index (O(log n)).

Behavior that is kept: container values without a claim create a new child and record the remap. from_move inserts whose child still exists elsewhere are skipped. The O(n) contains_container scan is replaced by the state's child index. The old per-op position updates cost O(#deleted children) each; those are gone.

Validation

  • New crates/loro/tests/movable_list_apply_diff.rs: 9 tests.
    • Targeted: the JS swap, rotations, children mixed with scalars (moves left, moves right past retains, into later gaps), child content edited in the same batch, adjacent delete items, and the minimal undo panic.
    • Seeded random tests:
      • Rust diff → apply_diff → undo → redo (64 seeds × root/nested × 4 rounds).
      • Synthesized deltas with multi-child moves and split deletes, then undo/redo across every step (128 seeds).
      • Two-peer mix of local ops, revert-style apply_diff, sync, and undo/redo: no panic, and the peers converge (48 seeds).
    • Against main, 8 of 9 fail. The adjacent-deletes test already passes there, as explained above.
  • Review-style matrix (160 seeds × root/nested × 4 segments of 8 random ops, Rust diff → apply → undo → redo):
pass apply wrong undo wrong redo wrong error/panic
main 200/320 40 30 1 49
this PR 320/320 0 0 0 0
  • cargo test -p loro -p loro-internal: 1188 passed, 0 failed.
  • pnpm check (clippy --all-features -D warnings) passes.
  • cargo fmt is clean for the changed files. The pre-existing formatting drift in dag.rs, undo.rs, moon_transcode.rs, and shallow_checkout_equal_value.rs is deliberately not included.
  • pnpm -C crates/loro-wasm build-dev: vitest 380 passed, including 2 new applyDiff cases in movable_list.test.ts. tsc, deno, bun, and the CommonJS smoke test pass.

Performance

crates/loro/tests/perf_movable_list_apply_diff.rs is an ignored test (cargo test --release -p loro --test perf_movable_list_apply_diff -- --ignored --nocapture). Setup: native release, a 100k-element list, median of 5 samples, then the median across 3 alternating main/PR rounds.

scenario apply main apply PR undo main undo PR
move child to end 65.5 µs 63.2 µs 98.5 µs 106.3 µs
move child to front 41.3 µs 39.0 µs 95.8 µs 89.7 µs
insert child at tail 36.8 µs 36.4 µs 80.8 µs 79.5 µs
delete 100k scalars 89.4 ms 80.4 ms 343.3 ms 335.2 ms
rotate 1k children 3.75 ms ✗ 3.03 ms 17.0 ms 15.5 ms
reverse 1k children 7.56 ms ✗ 6.39 ms 15.8 ms 20.1 ms
rotate 5k children 39.2 ms ✗ 17.0 ms 109.6 ms 90.5 ms
reverse 5k children 115.4 ms ✗ 102.5 ms 108.8 ms 170.1 ms

✗ means main produces a wrong list: rotate loses 1 child, reverse 1k loses 499, reverse 5k loses 2499. The reverse-5k undo rows are therefore not comparable, because main's undo restores a much smaller, already-broken change. A breakdown on this PR shows its undo is dominated by the checkout-based diff() (103 ms at 8k children), which this PR does not touch. apply_diff of the inverse took 17 ms, about 2× issuing the same moves directly.

Coordination with open PRs

Follow-ups (not in this PR)

  • A container id with an unknown type in a list/movable-list apply_diff insert still panics in Handler::new_unattached (unreachable!()), for example "🦜:cid:0@1:Unknown(9)" from JS. This is pre-existing and shared with ListHandler. It should become an Err validated before any op is applied.
  • Moves are not minimal for "delete all, insert permuted" deltas: delete 3, insert [b, c, a] moves b and c. Deltas from LoroDoc::diff only move elements that really moved, so they are unaffected. An LIS pass could reduce ops for hand-written deltas.
  • loro.js (fix(loro.js): include reattached child containers' state in diff() and revertTo() #1131) still needs the equivalent B2/B3 fix on the TS side.

🤖 Generated with Claude Code

…ents

MovableListHandler::apply_delta deferred every delete and tracked moved
slots with index arithmetic. When a delta moved two or more children
(e.g. `delete 2, insert [b, a]`), the arithmetic deleted a moved child or
placed elements out of order, and could underflow or call `mov` out of
range behind an `unwrap()`. Undo/redo replay inverse diffs through the
same handler, so they panicked with OutOfBound.

apply_delta now plans which deleted children are re-inserted, deletes the
rest right to left, then places each inserted value at a live cursor,
locating moved children with get_child_index. Algorithm, invariant and
pitfalls: context/movable-list-apply-diff.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3209.89 KB
  • Gzipped size: 1063.81 KB
  • Brotli size: 745.00 KB

@zxch3n
zxch3n merged commit cdbc239 into main Sep 27, 2026
1 check passed
zxch3n added a commit that referenced this pull request Sep 28, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zxch3n added a commit that referenced this pull request Sep 29, 2026
#1166)

* test(loro.js): add a Rust differential suite for MovableList

Drive the Rust WASM build and loro.js with the same random multi-peer
edits, crossing engines on every sync, and require equal values, versions,
per-element metadata and events. Profiles that diverge on the current
MovableList model are recorded as known divergences. Design and findings:
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): compare event effects and replicate unordered reverts

Event deltas that differ only in shape are checked by effect; reverts whose
op order Rust leaves to hash order are checked by value and replicated from
Rust; peers that both engines reject are retired; peer IDs are never reused;
undo stays unchained; the minimizer keeps those rules. See
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): join shallow snapshots through Rust and keep replayed undo unchained

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): accept no-op sequence events and replicate reverts whose diff shape differs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): skip shallow editors and start range exports at the shallow root

Both follow Rust issues listed in context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): measure revert container depths at the revert target

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): run the MovableList differential suite on the shared Rust reference

Load Rust through tests/support/rust-reference.ts like the other differential
suites, so it runs in `pnpm test` (and in CI's test-loro-js) and skips without
a WASM build. Drop the separate workflow and scripts. insert/delete now
converges on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): model MovableList like Rust (positions and LWW elements)

MovableList keeps a Fugue sequence of list items and elements whose
position and value are last-writer-wins registers, as Rust does. Moves of
concurrently deleted elements, deletes after moves, snapshots (list item,
element and last-set IDs, dead items), metadata getters, version
transitions, revertTo/applyDiff (Rust's apply_delta), undo and events now
match Rust. Snapshot-hydrated documents replay history before concurrent
imports, and a few shallow-snapshot incompatibilities are fixed. The
differential suite passes every profile. Design and remaining gaps:
context/loro-js-movable-list.md.

Rebuilt on current main: MovableList joins #1126's per-container snapshot
completion (its snapshot state names Rust IDs), applyDiff/revertTo/undo use
main's port of Rust #1138's apply_delta on the new model, and undo
transforms only over changes the UndoManager did not make.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): skip final syncs with a peer that left the run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): keep pending changes and follow Rust's shallow import rules

Decoding deferred snapshot history dropped pending changes. Shallow imports
now check the unknown part of each change like is_before_shallow_root, and an
import that only adds pending changes creates their root containers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): pin diff() of a MovableList child created in the range

main's diff() reports such a child by its ops, including map keys set and
deleted again, as Rust does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(loro.js): resolve concurrent MovableList op indices through a tracker

A causal view per concurrent op made importing concurrent moves O(n^2).
Like Rust's Tracker::checkout, keep a tracker version and a per-position
delta metric that moves incrementally between op versions; see
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(loro.js): describe the merged MovableList fixes in the changeset

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): never walk a container's fields when comparing event values

A MovableList replaced by a plain value made the comparison enumerate the
container and hit its guarded element sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): fuzz MovableList in the multi-peer differential CI run

The Rust-model MovableList passes seeds 0-599 of the #1141 fuzz with all
oracles in lockstep with Rust; main failed 144 of seeds 0-299.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): apply MovableList diffs deletes first, like Rust's apply_delta

Without moved children the delta was walked in order, which gave the ops
other IDs than Rust's and later deletes by ID missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): skip map entries that only delete keys when comparing diff()

A child created in the range whose keys were all set and deleted again is
listed by Rust and omitted by loro.js, also on main and for any parent; see
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(loro.js): record snapshot completion on import and its cost

The performance context still described main's MovableList snapshot special
case and #1163 as open. Measurements are in context/loro-js-performance.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(loro.js): check MovableList transition inserts in one tree walk

canTransition walked the position tree once per insert op, which made
checkout, diff and revertTo O(insert ops * positions). Collect the runs and
check them in one containsIdRuns call, as the List branch does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): fix the suite's changeset and ignore empty diff() deltas

The changeset named a test:differential script that does not exist. Rust's
diff() can list a sequence with an empty delta where loro.js omits it
(context/loro-js-movable-list.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zxch3n added a commit that referenced this pull request Sep 29, 2026
* test(loro.js): add a Rust differential suite for MovableList

Drive the Rust WASM build and loro.js with the same random multi-peer
edits, crossing engines on every sync, and require equal values, versions,
per-element metadata and events. Profiles that diverge on the current
MovableList model are recorded as known divergences. Design and findings:
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): compare event effects and replicate unordered reverts

Event deltas that differ only in shape are checked by effect; reverts whose
op order Rust leaves to hash order are checked by value and replicated from
Rust; peers that both engines reject are retired; peer IDs are never reused;
undo stays unchained; the minimizer keeps those rules. See
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): join shallow snapshots through Rust and keep replayed undo unchained

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): accept no-op sequence events and replicate reverts whose diff shape differs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): skip shallow editors and start range exports at the shallow root

Both follow Rust issues listed in context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): measure revert container depths at the revert target

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): run the MovableList differential suite on the shared Rust reference

Load Rust through tests/support/rust-reference.ts like the other differential
suites, so it runs in `pnpm test` (and in CI's test-loro-js) and skips without
a WASM build. Drop the separate workflow and scripts. insert/delete now
converges on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): model MovableList like Rust (positions and LWW elements)

MovableList keeps a Fugue sequence of list items and elements whose
position and value are last-writer-wins registers, as Rust does. Moves of
concurrently deleted elements, deletes after moves, snapshots (list item,
element and last-set IDs, dead items), metadata getters, version
transitions, revertTo/applyDiff (Rust's apply_delta), undo and events now
match Rust. Snapshot-hydrated documents replay history before concurrent
imports, and a few shallow-snapshot incompatibilities are fixed. The
differential suite passes every profile. Design and remaining gaps:
context/loro-js-movable-list.md.

Rebuilt on current main: MovableList joins #1126's per-container snapshot
completion (its snapshot state names Rust IDs), applyDiff/revertTo/undo use
main's port of Rust #1138's apply_delta on the new model, and undo
transforms only over changes the UndoManager did not make.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): skip final syncs with a peer that left the run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): keep pending changes and follow Rust's shallow import rules

Decoding deferred snapshot history dropped pending changes. Shallow imports
now check the unknown part of each change like is_before_shallow_root, and an
import that only adds pending changes creates their root containers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): pin diff() of a MovableList child created in the range

main's diff() reports such a child by its ops, including map keys set and
deleted again, as Rust does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(loro.js): resolve concurrent MovableList op indices through a tracker

A causal view per concurrent op made importing concurrent moves O(n^2).
Like Rust's Tracker::checkout, keep a tracker version and a per-position
delta metric that moves incrementally between op versions; see
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(loro.js): describe the merged MovableList fixes in the changeset

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): make import and importBatch atomic

A failed import no longer leaves history, pending changes, version,
containers, state or events half applied. In-place history changes are
journaled and undone, state is rebuilt only when it had changed, events are
emitted after success.
Details: context/import-batch-atomicity.md ("loro.js").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): never walk a container's fields when comparing event values

A MovableList replaced by a plain value made the comparison enumerate the
container and hit its guarded element sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): fuzz MovableList in the multi-peer differential CI run

The Rust-model MovableList passes seeds 0-599 of the #1141 fuzz with all
oracles in lockstep with Rust; main failed 144 of seeds 0-299.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): apply MovableList diffs deletes first, like Rust's apply_delta

Without moved children the delta was walked in order, which gave the ops
other IDs than Rust's and later deletes by ID missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): skip map entries that only delete keys when comparing diff()

A child created in the range whose keys were all set and deleted again is
listed by Rust and omitted by loro.js, also on main and for any parent; see
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(loro.js): record snapshot completion on import and its cost

The performance context still described main's MovableList snapshot special
case and #1163 as open. Measurements are in context/loro-js-performance.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(loro.js): check MovableList transition inserts in one tree walk

canTransition walked the position tree once per insert op, which made
checkout, diff and revertTo O(insert ops * positions). Collect the runs and
check them in one containsIdRuns call, as the List branch does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): fix the suite's changeset and ignore empty diff() deltas

The changeset named a test:differential script that does not exist. Rust's
diff() can list a sequence with an empty delta where loro.js omits it
(context/loro-js-movable-list.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(loro.js): journal snapshot-sequence writes instead of copying per import

Each import copied the whole snapshot-sequence table for rollback, so its cost
grew with the number of snapshot-hydrated containers. Record each write in the
import journal instead (context/import-batch-atomicity.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zxch3n added a commit that referenced this pull request Sep 29, 2026
…) (#1168)

* test(loro.js): add a Rust differential suite for MovableList

Drive the Rust WASM build and loro.js with the same random multi-peer
edits, crossing engines on every sync, and require equal values, versions,
per-element metadata and events. Profiles that diverge on the current
MovableList model are recorded as known divergences. Design and findings:
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): compare event effects and replicate unordered reverts

Event deltas that differ only in shape are checked by effect; reverts whose
op order Rust leaves to hash order are checked by value and replicated from
Rust; peers that both engines reject are retired; peer IDs are never reused;
undo stays unchained; the minimizer keeps those rules. See
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): join shallow snapshots through Rust and keep replayed undo unchained

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): accept no-op sequence events and replicate reverts whose diff shape differs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): skip shallow editors and start range exports at the shallow root

Both follow Rust issues listed in context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): measure revert container depths at the revert target

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): run the MovableList differential suite on the shared Rust reference

Load Rust through tests/support/rust-reference.ts like the other differential
suites, so it runs in `pnpm test` (and in CI's test-loro-js) and skips without
a WASM build. Drop the separate workflow and scripts. insert/delete now
converges on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): model MovableList like Rust (positions and LWW elements)

MovableList keeps a Fugue sequence of list items and elements whose
position and value are last-writer-wins registers, as Rust does. Moves of
concurrently deleted elements, deletes after moves, snapshots (list item,
element and last-set IDs, dead items), metadata getters, version
transitions, revertTo/applyDiff (Rust's apply_delta), undo and events now
match Rust. Snapshot-hydrated documents replay history before concurrent
imports, and a few shallow-snapshot incompatibilities are fixed. The
differential suite passes every profile. Design and remaining gaps:
context/loro-js-movable-list.md.

Rebuilt on current main: MovableList joins #1126's per-container snapshot
completion (its snapshot state names Rust IDs), applyDiff/revertTo/undo use
main's port of Rust #1138's apply_delta on the new model, and undo
transforms only over changes the UndoManager did not make.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): skip final syncs with a peer that left the run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): keep pending changes and follow Rust's shallow import rules

Decoding deferred snapshot history dropped pending changes. Shallow imports
now check the unknown part of each change like is_before_shallow_root, and an
import that only adds pending changes creates their root containers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): pin diff() of a MovableList child created in the range

main's diff() reports such a child by its ops, including map keys set and
deleted again, as Rust does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(loro.js): resolve concurrent MovableList op indices through a tracker

A causal view per concurrent op made importing concurrent moves O(n^2).
Like Rust's Tracker::checkout, keep a tracker version and a per-position
delta metric that moves incrementally between op versions; see
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(loro.js): describe the merged MovableList fixes in the changeset

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): make import and importBatch atomic

A failed import no longer leaves history, pending changes, version,
containers, state or events half applied. In-place history changes are
journaled and undone, state is rebuilt only when it had changed, events are
emitted after success.
Details: context/import-batch-atomicity.md ("loro.js").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): reject MovableList moves and sets of elements outside their history

Port of #1125; see context/loro-js-movable-list.md (Validation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): never walk a container's fields when comparing event values

A MovableList replaced by a plain value made the comparison enumerate the
container and hit its guarded element sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): fuzz MovableList in the multi-peer differential CI run

The Rust-model MovableList passes seeds 0-599 of the #1141 fuzz with all
oracles in lockstep with Rust; main failed 144 of seeds 0-299.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): apply MovableList diffs deletes first, like Rust's apply_delta

Without moved children the delta was walked in order, which gave the ops
other IDs than Rust's and later deletes by ID missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): skip map entries that only delete keys when comparing diff()

A child created in the range whose keys were all set and deleted again is
listed by Rust and omitted by loro.js, also on main and for any parent; see
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(loro.js): record snapshot completion on import and its cost

The performance context still described main's MovableList snapshot special
case and #1163 as open. Measurements are in context/loro-js-performance.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(loro.js): check MovableList transition inserts in one tree walk

canTransition walked the position tree once per insert op, which made
checkout, diff and revertTo O(insert ops * positions). Collect the runs and
check them in one containsIdRuns call, as the List branch does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): fix the suite's changeset and ignore empty diff() deltas

The changeset named a test:differential script that does not exist. Rust's
diff() can list a sequence with an empty delta where loro.js omits it
(context/loro-js-movable-list.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(loro.js): journal snapshot-sequence writes instead of copying per import

Each import copied the whole snapshot-sequence table for rollback, so its cost
grew with the number of snapshot-hydrated containers. Record each write in the
import journal instead (context/import-batch-atomicity.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant