Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fuzzy-loros-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"loro-js": patch
---

Keep semantic no-op detection type-safe so empty arrays, objects, maps, binary
values, and containers cannot be mistaken for one another. Match Rust by
rejecting `isNodeDeleted()` queries for tree nodes that do not exist and by
preserving deleted-tree roots, descendants, positions, and last-move metadata
with the same observable semantics.
31 changes: 31 additions & 0 deletions .github/workflows/loro-js.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Test loro-js

on:
push:
branches: ["main"]
pull_request:
types: [opened, synchronize, reopened, ready_for_review]

permissions:
contents: read

jobs:
check:
name: Format, lint, typecheck, test, and build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check loro-js
run: pnpm --filter loro-js check
2 changes: 2 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,5 @@ jobs:
# run: cargo clippy --all-features -- -Dwarnings
- name: Run rust tests
run: GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} deno task test-all
- name: Run Rust and TypeScript interop fuzz smoke
run: pnpm test-loro-js-interop
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Loro is a Rust CRDT workspace with JS/WASM packaging and a MoonBit codec.
[skills/loro/SKILL.md](skills/loro/SKILL.md).
- Pure TypeScript runtime indexes, complexity contracts, benchmarks, and remaining gaps:
[context/loro-js-performance.md](context/loro-js-performance.md).
- Rust/WASM, native Rust, and pure TypeScript differential fuzzing:
[context/loro-js-interop-fuzz.md](context/loro-js-interop-fuzz.md).
- Context backlog: [context/CONTEXT-GAPS.md](context/CONTEXT-GAPS.md).

## Commands
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions context/loro-js-interop-fuzz.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Rust / TypeScript interoperability fuzzing

Verified against code 2026-07-19.

The executable harness lives under `loro-js/fuzz/`. Its shared scenario schema
is defined in `scenario.mjs`, and `runner.mjs` is the semantic oracle for both
the Rust/WASM package and the pure TypeScript runtime.

For every command, `runDifferentialScenario` crosses transport blobs between
the two implementations and compares canonical state, history metadata, and
events. `runMalformedImportChecks` mutates Rust- and TypeScript-produced current
update/snapshot blobs and requires matching acceptance or rejection; rejected
imports must not partially change the target. Raw encoded bytes are not compared
because current Loro encodings are wire-compatible but non-canonical.

`crates/fuzz/src/bin/loro_js_interop_driver.rs` consumes the same JSON schema.
When `LORO_INTEROP_NATIVE_DRIVER` is set, the Node runner checks JS-produced
blobs in native Rust and native-Rust-produced blobs in JS for every permanent
corpus scenario.

The stable generator avoids semantic no-op and invalid operations whose oplog
policy differs independently of visible CRDT behavior. It also omits rich-text
mark/unmark operations and does not compare events when a snapshot is imported
into a non-empty document. The strict corpus records these known gaps. In
particular, `fuzz/corpus/strict/richtext-anchor.json` demonstrates the remaining
anchor/entity-position mismatch after a marked range receives a later insert.

Event normalization is payload-only. Subscription callbacks enqueue raw event
batches, which are normalized after the public command returns. Do not query the
live document from a callback: the WASM adapter flushes queued callbacks at the
outer JS method boundary while the pure TypeScript runtime emits during the
internal commit, so callback-time state can differ even when event payloads and
the resulting document state agree.

Map event recording is operation-aware rather than only comparing the values at
the start and end of a batch. If a key visibly changes and is then restored in
the same local or imported change, Rust reports the final value in `updated`, so
the TypeScript runtime must retain that key. An incoming same-value winner that
never changes the visible value remains an event no-op. The regression scenario
is `fuzz/corpus/local-map-delete-restore-event.json`.

Run the complete green smoke from the repository root with:

```sh
pnpm test-loro-js-interop
```

See `loro-js/fuzz/README.md` for profiles, replay controls, and corpus promotion.
10 changes: 8 additions & 2 deletions crates/examples/examples/b4_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ fn report() {
let d = apply(&actions, 1);
d.export(ExportMode::Snapshot).unwrap()
});
println!(" export(+apply,nocache):{:>8.2?} (includes a fresh apply)", t_export_nc);
println!(
" export(+apply,nocache):{:>8.2?} (includes a fresh apply)",
t_export_nc
);

// ---- Snapshot import ----
let mem_before = get_mem_usage();
Expand All @@ -89,7 +92,10 @@ fn report() {
let v = d.get_deep_value();
std::hint::black_box(v);
});
println!(" import + toJSON: {:>10.2?} (forces full state materialization)", t_import_val);
println!(
" import + toJSON: {:>10.2?} (forces full state materialization)",
t_import_val
);
std::hint::black_box(&imported);

// ---- B4 x100 ----
Expand Down
5 changes: 4 additions & 1 deletion crates/examples/examples/mergeable_notes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ fn print_report(r: &Report) {
println!(" import snapshot: {:?}", r.import_time);
println!(" resident after import: {}", r.imported_resident);
println!(" get_deep_value: {:?}", r.deep_value_time);
println!(" resident after deep_value: {}", r.after_deep_value_resident);
println!(
" resident after deep_value: {}",
r.after_deep_value_resident
);
}

fn main() {
Expand Down
1 change: 1 addition & 0 deletions crates/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ rayon = "1.10.0"
bytes = "1"
ensure-cov = { workspace = true }
pretty_assertions = "1.4.0"
serde = { workspace = true, features = ["derive"] }
loro-alpha-4 = { git = "https://github.com/loro-dev/loro.git", tag = "loro-crdt@1.0.0-alpha.4", package = "loro" }

[dev-dependencies]
Expand Down
Loading
Loading