diff --git a/.gitignore b/.gitignore
index bae70c7ff..20ff1822e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@
*.ipynb
flamegraph.svg
target
+moon/_build/
+moon_*_fuzz_artifacts*/
dhat-heap.json
.DS_Store
node_modules/
diff --git a/Cargo.lock b/Cargo.lock
index b35fe3c91..a618abd0b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -886,6 +886,42 @@ dependencies = [
"serde",
]
+[[package]]
+name = "futures-core"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-task"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+
+[[package]]
+name = "futures-util"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
[[package]]
name = "fuzz"
version = "0.1.0"
@@ -1506,6 +1542,7 @@ dependencies = [
"rand 0.8.5",
"rustc-hash",
"serde_json",
+ "serial_test",
"tracing",
]
@@ -1836,7 +1873,7 @@ checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a"
[[package]]
name = "loro-wasm"
-version = "1.10.3"
+version = "1.10.4"
dependencies = [
"console_error_panic_hook",
"js-sys",
@@ -2222,6 +2259,12 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -2602,6 +2645,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "scc"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
+dependencies = [
+ "sdd",
+]
+
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -2614,6 +2666,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "sdd"
+version = "3.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
+
[[package]]
name = "semver"
version = "1.0.26"
@@ -2698,6 +2756,32 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serial_test"
+version = "3.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555"
+dependencies = [
+ "futures-executor",
+ "futures-util",
+ "log",
+ "once_cell",
+ "parking_lot",
+ "scc",
+ "serial_test_derive",
+]
+
+[[package]]
+name = "serial_test_derive"
+version = "3.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.106",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -2740,6 +2824,12 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "slab"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
+
[[package]]
name = "smallvec"
version = "1.15.1"
diff --git a/crates/kv-store/tests/moon_sstable_fixture.rs b/crates/kv-store/tests/moon_sstable_fixture.rs
new file mode 100644
index 000000000..b00f6753b
--- /dev/null
+++ b/crates/kv-store/tests/moon_sstable_fixture.rs
@@ -0,0 +1,19 @@
+use bytes::Bytes;
+use loro_kv_store::sstable::SsTable;
+
+#[test]
+fn import_moon_encoded_sstable() {
+ let bytes = Bytes::from_static(include_bytes!("testdata/moon_sstable_simple.bin"));
+ let table = SsTable::import_all(bytes, true).unwrap();
+ let kvs: Vec<(Bytes, Bytes)> = table.iter().collect();
+
+ assert_eq!(
+ kvs,
+ vec![
+ (Bytes::from_static(b"a"), Bytes::from_static(b"1")),
+ (Bytes::from_static(b"ab"), Bytes::from_static(b"2")),
+ (Bytes::from_static(b"z"), Bytes::from_static(b"")),
+ ]
+ );
+}
+
diff --git a/crates/kv-store/tests/testdata/moon_sstable_simple.bin b/crates/kv-store/tests/testdata/moon_sstable_simple.bin
new file mode 100644
index 000000000..da6282ff8
Binary files /dev/null and b/crates/kv-store/tests/testdata/moon_sstable_simple.bin differ
diff --git a/crates/loro-internal/src/state/dead_containers_cache.rs b/crates/loro-internal/src/state/dead_containers_cache.rs
index 598c0d8dc..c40dbf06e 100644
--- a/crates/loro-internal/src/state/dead_containers_cache.rs
+++ b/crates/loro-internal/src/state/dead_containers_cache.rs
@@ -21,8 +21,9 @@ impl DocState {
pub(crate) fn is_deleted(&mut self, idx: ContainerIdx) -> bool {
#[cfg(not(debug_assertions))]
{
- if let Some(is_deleted) = self.dead_containers_cache.cache.get(&idx) {
- return *is_deleted;
+ // Cache stores only deleted containers.
+ if self.dead_containers_cache.cache.contains_key(&idx) {
+ return true;
}
}
@@ -52,8 +53,14 @@ impl DocState {
}
}
- for idx in visited {
- self.dead_containers_cache.cache.insert(idx, is_deleted);
+ if is_deleted {
+ for idx in visited {
+ self.dead_containers_cache.cache.insert(idx, true);
+ }
+ } else {
+ for idx in visited {
+ self.dead_containers_cache.cache.remove(&idx);
+ }
}
is_deleted
diff --git a/crates/loro/Cargo.toml b/crates/loro/Cargo.toml
index dc83c2148..42de337bf 100644
--- a/crates/loro/Cargo.toml
+++ b/crates/loro/Cargo.toml
@@ -26,7 +26,7 @@ tracing = { workspace = true }
rustc-hash = { workspace = true }
[dev-dependencies]
-serde_json = "1.0.87"
+serde_json = { version = "1.0.87", features = ["float_roundtrip"] }
anyhow = "1.0.83"
ctor = "0.2"
dev-utils = { path = "../dev-utils" }
@@ -37,6 +37,7 @@ base64 = "0.22.1"
serial_test = "3"
[features]
+default = ["counter"]
counter = ["loro-internal/counter"]
jsonpath = ["loro-internal/jsonpath"]
logging = ["loro-internal/logging"]
diff --git a/crates/loro/examples/moon_golden_gen.rs b/crates/loro/examples/moon_golden_gen.rs
new file mode 100644
index 000000000..ba9a0f58e
--- /dev/null
+++ b/crates/loro/examples/moon_golden_gen.rs
@@ -0,0 +1,420 @@
+use std::borrow::Cow;
+use std::path::{Path, PathBuf};
+
+use loro::{
+ ExpandType, ExportMode, LoroDoc, LoroValue, StyleConfig, StyleConfigMap, Timestamp, ToJson,
+ TreeParentId, VersionVector,
+};
+use rand::{rngs::StdRng, Rng, SeedableRng};
+
+fn usage() -> ! {
+ eprintln!(
+ r#"moon_golden_gen (loro)
+
+Generate a deterministic random Loro document and export:
+- FastUpdates (binary) + JsonUpdates (JsonSchema)
+- FastSnapshot (binary) + deep JSON (get_deep_value)
+
+Usage:
+ cargo run -p loro --example moon_golden_gen -- \
+ --out-dir
[--seed ] [--ops ] [--commit-every ] [--peers ]
+
+Outputs in :
+ - updates.blob
+ - updates.json
+ - snapshot.blob
+ - snapshot.deep.json
+ - meta.json
+"#
+ );
+ std::process::exit(2);
+}
+
+fn parse_arg_value<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
+ args.windows(2)
+ .find_map(|w| (w[0] == name).then_some(w[1].as_str()))
+}
+
+fn parse_u64(args: &[String], name: &str, default: u64) -> u64 {
+ match parse_arg_value(args, name) {
+ None => default,
+ Some(v) => v.parse().unwrap_or_else(|_| usage()),
+ }
+}
+
+fn parse_usize(args: &[String], name: &str, default: usize) -> usize {
+ match parse_arg_value(args, name) {
+ None => default,
+ Some(v) => v.parse().unwrap_or_else(|_| usage()),
+ }
+}
+
+fn parse_out_dir(args: &[String]) -> PathBuf {
+ parse_arg_value(args, "--out-dir")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| usage())
+}
+
+fn write_json(path: &Path, value: &serde_json::Value) -> anyhow::Result<()> {
+ let s = serde_json::to_string_pretty(value)?;
+ std::fs::write(path, s)?;
+ Ok(())
+}
+
+fn apply_random_ops(
+ doc: &LoroDoc,
+ seed: u64,
+ ops: usize,
+ commit_every: usize,
+ peer_ids: &[u64],
+) -> anyhow::Result<()> {
+ let mut rng = StdRng::seed_from_u64(seed);
+
+ let peer_ids = if peer_ids.is_empty() { &[1] } else { peer_ids };
+
+ let mut styles = StyleConfigMap::new();
+ styles.insert(
+ "bold".into(),
+ StyleConfig {
+ expand: ExpandType::After,
+ },
+ );
+ styles.insert(
+ "link".into(),
+ StyleConfig {
+ expand: ExpandType::Before,
+ },
+ );
+ doc.config_text_style(styles);
+
+ let mut active_peer = peer_ids[0];
+ doc.set_peer_id(active_peer)?;
+ let map = doc.get_map("map");
+ let list = doc.get_list("list");
+ let text = doc.get_text("text");
+ let mlist = doc.get_movable_list("mlist");
+ let tree = doc.get_tree("tree");
+ tree.enable_fractional_index(0);
+
+ // Stable baseline so root containers don't disappear from deep JSON.
+ map.insert("keep", 0)?;
+ list.insert(0, 0)?;
+ text.insert(0, "hi😀")?;
+ mlist.insert(0, 0)?;
+ let keep_node = tree.create(None)?;
+ tree.get_meta(keep_node)?.insert("title", "keep")?;
+
+ // Ensure Text mark/mark_end coverage.
+ if text.len_unicode() >= 2 {
+ text.mark(0..2, "bold", true)?;
+ if text.len_unicode() >= 3 {
+ text.mark(1..3, "link", "https://example.com")?;
+ }
+ text.unmark(0..1, "bold")?;
+ }
+
+ // Ensure nested container coverage (container values in map/list/movable_list).
+ let child_map = map.insert_container("child_map", loro::LoroMap::new())?;
+ child_map.insert("a", 1)?;
+ let child_text = child_map.insert_container("t", loro::LoroText::new())?;
+ child_text.insert(0, "inner😀")?;
+
+ let child_list = map.insert_container("child_list", loro::LoroList::new())?;
+ child_list.insert(0, "x")?;
+ let child_mlist = map.insert_container("child_mlist", loro::LoroMovableList::new())?;
+ child_mlist.insert(0, 10)?;
+ child_mlist.insert(1, 20)?;
+ child_mlist.mov(0, 1)?;
+
+ let child_tree = map.insert_container("child_tree", loro::LoroTree::new())?;
+ child_tree.enable_fractional_index(0);
+ let child_tree_root = child_tree.create(None)?;
+ child_tree.get_meta(child_tree_root)?.insert("m", 1)?;
+
+ let maps = [map.clone(), child_map];
+ let lists = [list.clone(), child_list];
+ let texts = [text.clone(), child_text];
+ let mlists = [mlist.clone(), child_mlist];
+
+ struct TreeCtx {
+ tree: loro::LoroTree,
+ nodes: Vec,
+ }
+ let mut trees = [
+ TreeCtx {
+ tree: tree.clone(),
+ nodes: vec![keep_node],
+ },
+ TreeCtx {
+ tree: child_tree,
+ nodes: vec![child_tree_root],
+ },
+ ];
+
+ let mut map_keys: Vec = Vec::new();
+ let mut child_map_keys: Vec = Vec::new();
+
+ for i in 0..ops {
+ // Switch active peer after each commit boundary (when multiple peers are requested).
+ if commit_every > 0 && i > 0 && i % commit_every == 0 && peer_ids.len() > 1 {
+ active_peer = peer_ids[rng.gen_range(0..peer_ids.len())];
+ doc.set_peer_id(active_peer)?;
+ }
+
+ let op_type = rng.gen_range(0..18);
+ match op_type {
+ 0 => {
+ let key = format!("k{}", rng.gen::());
+ map.insert(&key, rng.gen::())?;
+ map_keys.push(key);
+ }
+ 1 => {
+ let key = format!("k{}", rng.gen::());
+ let value = if rng.gen::() {
+ LoroValue::from(rng.gen::())
+ } else {
+ LoroValue::Null
+ };
+ map.insert(&key, value)?;
+ map_keys.push(key);
+ }
+ 2 => {
+ // Insert more value kinds (string/f64/binary) into either root map or child_map.
+ let (target, keys) = if rng.gen::() {
+ (&maps[0], &mut map_keys)
+ } else {
+ (&maps[1], &mut child_map_keys)
+ };
+ let key = format!("v{}", rng.gen::());
+ match rng.gen_range(0..3) {
+ 0 => target.insert(&key, "str😀")?,
+ 1 => target.insert(&key, rng.gen::() - 0.5)?,
+ _ => target.insert(&key, vec![0u8, 1, 2, rng.gen::()])?,
+ }
+ keys.push(key);
+ }
+ 3 => {
+ // Map delete (guarantee it hits an existing key sometimes).
+ if !map_keys.is_empty() && rng.gen::() {
+ let idx = rng.gen_range(0..map_keys.len());
+ let key = map_keys.swap_remove(idx);
+ map.delete(&key)?;
+ } else if !child_map_keys.is_empty() {
+ let idx = rng.gen_range(0..child_map_keys.len());
+ let key = child_map_keys.swap_remove(idx);
+ maps[1].delete(&key)?;
+ }
+ }
+ 4 => {
+ let target = &lists[rng.gen_range(0..lists.len())];
+ let index = rng.gen_range(0..=target.len());
+ target.insert(index, rng.gen::())?;
+ }
+ 5 => {
+ let target = &lists[rng.gen_range(0..lists.len())];
+ if target.len() > 0 {
+ let index = rng.gen_range(0..target.len());
+ let max_len = (target.len() - index).min(3);
+ let len = rng.gen_range(1..=max_len);
+ target.delete(index, len)?;
+ }
+ }
+ 6 => {
+ let target = &texts[rng.gen_range(0..texts.len())];
+ let index = rng.gen_range(0..=target.len_unicode());
+ let s = match rng.gen_range(0..8) {
+ 0 => "a",
+ 1 => "b",
+ 2 => "Z",
+ 3 => "😀",
+ 4 => "中",
+ 5 => "ab",
+ 6 => "😀!",
+ _ => "!",
+ };
+ target.insert(index, s)?;
+ }
+ 7 => {
+ let target = &texts[rng.gen_range(0..texts.len())];
+ let len_u = target.len_unicode();
+ if len_u > 0 {
+ let index = rng.gen_range(0..len_u);
+ let max_len = (len_u - index).min(3);
+ let len = rng.gen_range(1..=max_len);
+ target.delete(index, len)?;
+ }
+ }
+ 8 => {
+ // Text mark/unmark
+ let target = &texts[rng.gen_range(0..texts.len())];
+ let len_u = target.len_unicode();
+ if len_u >= 2 {
+ let start = rng.gen_range(0..len_u - 1);
+ let end = rng.gen_range(start + 1..=len_u);
+ if rng.gen::() {
+ let key = if rng.gen::() { "bold" } else { "link" };
+ let value: LoroValue = if key == "bold" {
+ LoroValue::from(true)
+ } else {
+ LoroValue::from("https://loro.dev")
+ };
+ let _ = target.mark(start..end, key, value);
+ } else {
+ let key = if rng.gen::() { "bold" } else { "link" };
+ let _ = target.unmark(start..end, key);
+ }
+ }
+ }
+ 9 => {
+ // MovableList insert
+ let target = &mlists[rng.gen_range(0..mlists.len())];
+ let index = rng.gen_range(0..=target.len());
+ target.insert(index, rng.gen::())?;
+ }
+ 10 => {
+ // MovableList delete
+ let target = &mlists[rng.gen_range(0..mlists.len())];
+ if target.len() > 0 {
+ let index = rng.gen_range(0..target.len());
+ let max_len = (target.len() - index).min(3);
+ let len = rng.gen_range(1..=max_len);
+ target.delete(index, len)?;
+ }
+ }
+ 11 => {
+ // MovableList set
+ let target = &mlists[rng.gen_range(0..mlists.len())];
+ if target.len() > 0 {
+ let index = rng.gen_range(0..target.len());
+ target.set(index, rng.gen::())?;
+ }
+ }
+ 12 => {
+ // MovableList move
+ let target = &mlists[rng.gen_range(0..mlists.len())];
+ if target.len() >= 2 {
+ let from = rng.gen_range(0..target.len());
+ let to = rng.gen_range(0..target.len());
+ let _ = target.mov(from, to);
+ }
+ }
+ 13 => {
+ // Tree create
+ let t = &mut trees[rng.gen_range(0..trees.len())];
+ let parent = if t.nodes.is_empty() || rng.gen::() {
+ TreeParentId::Root
+ } else {
+ TreeParentId::from(t.nodes[rng.gen_range(0..t.nodes.len())])
+ };
+ let id = t.tree.create(parent)?;
+ t.nodes.push(id);
+ }
+ 14 => {
+ // Tree move
+ let t = &mut trees[rng.gen_range(0..trees.len())];
+ if t.nodes.len() >= 2 {
+ let target = t.nodes[rng.gen_range(0..t.nodes.len())];
+ let parent = if rng.gen::() {
+ TreeParentId::Root
+ } else {
+ TreeParentId::from(t.nodes[rng.gen_range(0..t.nodes.len())])
+ };
+ let _ = t.tree.mov(target, parent);
+ }
+ }
+ 15 => {
+ // Tree delete (try to keep at least 1 node around)
+ let t = &mut trees[rng.gen_range(0..trees.len())];
+ if t.nodes.len() > 1 {
+ let idx = rng.gen_range(0..t.nodes.len());
+ let id = t.nodes.swap_remove(idx);
+ let _ = t.tree.delete(id);
+ }
+ }
+ 16 => {
+ // Tree meta insert
+ let t = &mut trees[rng.gen_range(0..trees.len())];
+ if !t.nodes.is_empty() {
+ let id = t.nodes[rng.gen_range(0..t.nodes.len())];
+ if let Ok(meta) = t.tree.get_meta(id) {
+ let key = format!("m{}", rng.gen::());
+ let _ = meta.insert(&key, rng.gen::());
+ }
+ }
+ }
+ 17 => {
+ // Insert container values into sequence containers.
+ if rng.gen::() {
+ let target = &lists[rng.gen_range(0..lists.len())];
+ let index = rng.gen_range(0..=target.len());
+ let _ = target.insert_container(index, loro::LoroMap::new());
+ } else {
+ let target = &mlists[rng.gen_range(0..mlists.len())];
+ let index = rng.gen_range(0..=target.len());
+ let _ = target.insert_container(index, loro::LoroText::new());
+ }
+ }
+ _ => unreachable!(),
+ }
+
+ if commit_every > 0 && (i + 1) % commit_every == 0 {
+ let msg = format!("commit-{} seed={} peer={}", i + 1, seed, active_peer);
+ doc.set_next_commit_message(&msg);
+ doc.set_next_commit_timestamp(i as Timestamp);
+ doc.commit();
+ }
+ }
+
+ let msg = format!("final seed={seed} ops={ops}");
+ doc.set_next_commit_message(&msg);
+ doc.set_next_commit_timestamp(ops as Timestamp);
+ doc.commit();
+ Ok(())
+}
+
+fn main() -> anyhow::Result<()> {
+ let args: Vec = std::env::args().collect();
+ if args.iter().any(|a| a == "--help" || a == "-h") {
+ usage();
+ }
+
+ let out_dir = parse_out_dir(&args);
+ let seed = parse_u64(&args, "--seed", 1);
+ let ops = parse_usize(&args, "--ops", 200);
+ let commit_every = parse_usize(&args, "--commit-every", 20);
+ let peers = parse_usize(&args, "--peers", 1);
+
+ std::fs::create_dir_all(&out_dir)?;
+
+ let doc = LoroDoc::new();
+ let peer_ids: Vec = (1..=peers.max(1) as u64).collect();
+ apply_random_ops(&doc, seed, ops, commit_every, &peer_ids)?;
+
+ let start = VersionVector::default();
+ let end = doc.oplog_vv();
+
+ let updates_blob = doc.export(ExportMode::Updates {
+ from: Cow::Borrowed(&start),
+ })?;
+ std::fs::write(out_dir.join("updates.blob"), &updates_blob)?;
+
+ let updates_schema = doc.export_json_updates(&start, &end);
+ let updates_json = serde_json::to_value(&updates_schema)?;
+ write_json(&out_dir.join("updates.json"), &updates_json)?;
+
+ let snapshot_blob = doc.export(ExportMode::Snapshot)?;
+ std::fs::write(out_dir.join("snapshot.blob"), &snapshot_blob)?;
+
+ let deep = doc.get_deep_value().to_json_value();
+ write_json(&out_dir.join("snapshot.deep.json"), &deep)?;
+
+ let meta = serde_json::json!({
+ "seed": seed,
+ "ops": ops,
+ "commit_every": commit_every,
+ "peers": peers,
+ });
+ write_json(&out_dir.join("meta.json"), &meta)?;
+
+ Ok(())
+}
diff --git a/crates/loro/examples/moon_jsonschema_fuzz.rs b/crates/loro/examples/moon_jsonschema_fuzz.rs
new file mode 100644
index 000000000..f53fa8233
--- /dev/null
+++ b/crates/loro/examples/moon_jsonschema_fuzz.rs
@@ -0,0 +1,672 @@
+use std::borrow::Cow;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use loro::{
+ Container, ExpandType, ExportMode, Frontiers, LoroDoc, LoroValue, StyleConfig, StyleConfigMap,
+ Timestamp, ToJson, TreeParentId, ValueOrContainer, VersionVector,
+};
+use rand::{rngs::StdRng, Rng, SeedableRng};
+
+fn configure_styles(doc: &LoroDoc) {
+ let mut styles = StyleConfigMap::new();
+ styles.insert(
+ "bold".into(),
+ StyleConfig {
+ expand: ExpandType::After,
+ },
+ );
+ styles.insert(
+ "link".into(),
+ StyleConfig {
+ expand: ExpandType::Before,
+ },
+ );
+ doc.config_text_style(styles);
+}
+
+fn usage() -> ! {
+ eprintln!(
+ r#"moon_jsonschema_fuzz (loro)
+
+Randomly generate Loro ops in Rust, export JsonSchema updates, then ask MoonBit to
+encode them into a FastUpdates (mode=4) blob. Import the blob back in Rust and
+validate the final state matches.
+
+This is a semantic test for Moon `encode-jsonschema` (JsonSchema -> binary Updates).
+
+Usage:
+ MOON_BIN=~/.moon/bin/moon NODE_BIN=node \
+ cargo run -p loro --example moon_jsonschema_fuzz -- \
+ --iters [--seed ] [--ops ] [--commit-every ] [--peers ] [--out-dir ]
+
+If a mismatch happens, this tool writes a reproducible case into:
+ /case-/
+
+"#
+ );
+ std::process::exit(2);
+}
+
+fn parse_arg_value<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
+ args.windows(2)
+ .find_map(|w| (w[0] == name).then_some(w[1].as_str()))
+}
+
+fn parse_usize(args: &[String], name: &str, default: usize) -> usize {
+ match parse_arg_value(args, name) {
+ None => default,
+ Some(v) => v.parse().unwrap_or_else(|_| usage()),
+ }
+}
+
+fn parse_u64(args: &[String], name: &str, default: u64) -> u64 {
+ match parse_arg_value(args, name) {
+ None => default,
+ Some(v) => v.parse().unwrap_or_else(|_| usage()),
+ }
+}
+
+fn parse_out_dir(args: &[String]) -> PathBuf {
+ parse_arg_value(args, "--out-dir")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from("moon_jsonschema_fuzz_artifacts"))
+}
+
+fn bin_available(bin: &str, args: &[&str]) -> bool {
+ Command::new(bin)
+ .args(args)
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+}
+
+fn repo_root() -> PathBuf {
+ // crates/loro -> crates -> repo root
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .and_then(|p| p.parent())
+ .expect("repo root")
+ .to_path_buf()
+}
+
+fn build_moon_cli_js(moon_bin: &str) -> anyhow::Result {
+ let root = repo_root();
+ let moon_dir = root.join("moon");
+ let status = Command::new(moon_bin)
+ .current_dir(&moon_dir)
+ .args(["build", "--target", "js", "--release", "cmd/loro_codec_cli"])
+ .status()?;
+ anyhow::ensure!(status.success(), "failed to build MoonBit CLI");
+ Ok(moon_dir.join("_build/js/release/build/cmd/loro_codec_cli/loro_codec_cli.js"))
+}
+
+fn run_encode_jsonschema(node_bin: &str, cli_js: &Path, input_json: &str) -> anyhow::Result> {
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ let tmp = std::env::temp_dir().join(format!(
+ "loro-moon-jsonschema-fuzz-{}-{ts}",
+ std::process::id()
+ ));
+ std::fs::create_dir_all(&tmp)?;
+ let in_path = tmp.join("in.json");
+ let out_path = tmp.join("out.blob");
+ std::fs::write(&in_path, input_json.as_bytes())?;
+
+ let status = Command::new(node_bin)
+ .arg(cli_js)
+ .args([
+ "encode-jsonschema",
+ in_path.to_str().unwrap(),
+ out_path.to_str().unwrap(),
+ ])
+ .status()?;
+ anyhow::ensure!(status.success(), "node encode-jsonschema failed");
+
+ let out = std::fs::read(&out_path)?;
+ Ok(out)
+}
+
+fn write_json(path: &Path, value: &serde_json::Value) -> anyhow::Result<()> {
+ let s = serde_json::to_string_pretty(value)?;
+ std::fs::write(path, s)?;
+ Ok(())
+}
+
+fn first_json_diff_path(
+ a: &serde_json::Value,
+ b: &serde_json::Value,
+ path: &str,
+) -> Option {
+ use serde_json::Value;
+ if a == b {
+ return None;
+ }
+ match (a, b) {
+ (Value::Object(ao), Value::Object(bo)) => {
+ for (k, av) in ao {
+ let Some(bv) = bo.get(k) else {
+ return Some(format!("{path}.{k} (missing rhs)"));
+ };
+ if let Some(p) = first_json_diff_path(av, bv, &format!("{path}.{k}")) {
+ return Some(p);
+ }
+ }
+ for k in bo.keys() {
+ if !ao.contains_key(k) {
+ return Some(format!("{path}.{k} (missing lhs)"));
+ }
+ }
+ Some(path.to_string())
+ }
+ (Value::Array(aa), Value::Array(ba)) => {
+ if aa.len() != ba.len() {
+ return Some(format!("{path} (len {} != {})", aa.len(), ba.len()));
+ }
+ for (i, (av, bv)) in aa.iter().zip(ba.iter()).enumerate() {
+ if let Some(p) = first_json_diff_path(av, bv, &format!("{path}[{i}]")) {
+ return Some(p);
+ }
+ }
+ Some(path.to_string())
+ }
+ _ => Some(path.to_string()),
+ }
+}
+
+fn frontiers_sorted_strings(frontiers: &Frontiers) -> Vec {
+ let mut ids: Vec = frontiers.iter().map(|id| id.to_string()).collect();
+ ids.sort();
+ ids
+}
+
+fn richtext_json_child_text(doc: &LoroDoc) -> anyhow::Result {
+ let map = doc.get_map("map");
+ let Some(ValueOrContainer::Container(Container::Map(child_map))) = map.get("child_map") else {
+ anyhow::bail!("missing map.child_map container")
+ };
+ let Some(ValueOrContainer::Container(Container::Text(child_text))) = child_map.get("t") else {
+ anyhow::bail!("missing map.child_map.t container")
+ };
+ Ok(child_text.get_richtext_value().to_json_value())
+}
+
+fn apply_random_ops(
+ doc: &LoroDoc,
+ seed: u64,
+ ops: usize,
+ commit_every: usize,
+ peer_ids: &[u64],
+) -> anyhow::Result> {
+ let mut rng = StdRng::seed_from_u64(seed);
+ let peer_ids = if peer_ids.is_empty() { &[1] } else { peer_ids };
+
+ configure_styles(doc);
+
+ let mut active_peer = peer_ids[0];
+ doc.set_peer_id(active_peer)?;
+
+ let map = doc.get_map("map");
+ let list = doc.get_list("list");
+ let text = doc.get_text("text");
+ let mlist = doc.get_movable_list("mlist");
+ let tree = doc.get_tree("tree");
+ tree.enable_fractional_index(0);
+
+ // Counter (always enabled by default in this repo).
+ let counter = map.insert_container("counter", loro::LoroCounter::new())?;
+
+ // Stable baseline so root containers don't disappear from deep JSON.
+ map.insert("keep", 0)?;
+ list.insert(0, 0)?;
+ text.insert(0, "hi😀")?;
+ mlist.insert(0, 0)?;
+ counter.increment(0.0)?;
+ let keep_node = tree.create(None)?;
+ tree.get_meta(keep_node)?.insert("title", "keep")?;
+
+ // Ensure nested container coverage (container values in map/list/movable_list).
+ let child_map = map.insert_container("child_map", loro::LoroMap::new())?;
+ child_map.insert("a", 1)?;
+ let child_text = child_map.insert_container("t", loro::LoroText::new())?;
+ child_text.insert(0, "inner😀")?;
+
+ let child_list = map.insert_container("child_list", loro::LoroList::new())?;
+ child_list.insert(0, "x")?;
+ let child_mlist = map.insert_container("child_mlist", loro::LoroMovableList::new())?;
+ child_mlist.insert(0, 10)?;
+ child_mlist.insert(1, 20)?;
+ child_mlist.mov(0, 1)?;
+
+ let child_tree = map.insert_container("child_tree", loro::LoroTree::new())?;
+ child_tree.enable_fractional_index(0);
+ let child_tree_root = child_tree.create(None)?;
+ child_tree.get_meta(child_tree_root)?.insert("m", 1)?;
+
+ let counters = [counter];
+ let maps = [map.clone(), child_map];
+ let lists = [list.clone(), child_list];
+ let texts = [text.clone(), child_text];
+ let mlists = [mlist.clone(), child_mlist];
+
+ struct TreeCtx {
+ tree: loro::LoroTree,
+ nodes: Vec,
+ }
+ let mut trees = [
+ TreeCtx {
+ tree: tree.clone(),
+ nodes: vec![keep_node],
+ },
+ TreeCtx {
+ tree: child_tree,
+ nodes: vec![child_tree_root],
+ },
+ ];
+
+ let mut map_keys: Vec = Vec::new();
+ let mut child_map_keys: Vec = Vec::new();
+
+ let mut frontiers: Vec = Vec::new();
+
+ for i in 0..ops {
+ // Switch active peer after each commit boundary (when multiple peers are requested).
+ if commit_every > 0 && i > 0 && i % commit_every == 0 && peer_ids.len() > 1 {
+ active_peer = peer_ids[rng.gen_range(0..peer_ids.len())];
+ doc.set_peer_id(active_peer)?;
+ }
+
+ let op_type = rng.gen_range(0..20);
+ match op_type {
+ 0 => {
+ let key = format!("k{}", rng.gen::());
+ map.insert(&key, rng.gen::())?;
+ map_keys.push(key);
+ }
+ 1 => {
+ let key = format!("k{}", rng.gen::());
+ let value = if rng.gen::() {
+ LoroValue::from(rng.gen::())
+ } else {
+ LoroValue::Null
+ };
+ map.insert(&key, value)?;
+ map_keys.push(key);
+ }
+ 2 => {
+ // Insert more value kinds (string/f64/binary) into either root map or child_map.
+ let (target, keys) = if rng.gen::() {
+ (&maps[0], &mut map_keys)
+ } else {
+ (&maps[1], &mut child_map_keys)
+ };
+ let key = format!("v{}", rng.gen::());
+ match rng.gen_range(0..3) {
+ 0 => target.insert(&key, "str😀")?,
+ 1 => target.insert(&key, rng.gen::() - 0.5)?,
+ _ => target.insert(&key, vec![0u8, 1, 2, rng.gen::()])?,
+ }
+ keys.push(key);
+ }
+ 3 => {
+ // Map delete (guarantee it hits an existing key sometimes).
+ if !map_keys.is_empty() && rng.gen::() {
+ let idx = rng.gen_range(0..map_keys.len());
+ let key = map_keys.swap_remove(idx);
+ map.delete(&key)?;
+ } else if !child_map_keys.is_empty() {
+ let idx = rng.gen_range(0..child_map_keys.len());
+ let key = child_map_keys.swap_remove(idx);
+ maps[1].delete(&key)?;
+ }
+ }
+ 4 => {
+ let target = &lists[rng.gen_range(0..lists.len())];
+ let index = rng.gen_range(0..=target.len());
+ target.insert(index, rng.gen::())?;
+ }
+ 5 => {
+ let target = &lists[rng.gen_range(0..lists.len())];
+ if target.len() > 0 {
+ let index = rng.gen_range(0..target.len());
+ let max_len = (target.len() - index).min(3);
+ let len = rng.gen_range(1..=max_len);
+ target.delete(index, len)?;
+ }
+ }
+ 6 => {
+ let target = &texts[rng.gen_range(0..texts.len())];
+ let index = rng.gen_range(0..=target.len_unicode());
+ target.insert(index, "x😀")?;
+ }
+ 7 => {
+ let target = &texts[rng.gen_range(0..texts.len())];
+ if target.len_unicode() > 0 {
+ let index = rng.gen_range(0..target.len_unicode());
+ let max_len = (target.len_unicode() - index).min(3);
+ let len = rng.gen_range(1..=max_len);
+ target.delete(index, len)?;
+ }
+ }
+ 8 => {
+ // Text mark
+ let target = &texts[rng.gen_range(0..texts.len())];
+ if target.len_unicode() >= 2 {
+ let start = rng.gen_range(0..target.len_unicode());
+ let end = rng.gen_range(start..=target.len_unicode());
+ let _ = target.mark(start..end, "bold", true);
+ }
+ }
+ 9 => {
+ // Text unmark
+ let target = &texts[rng.gen_range(0..texts.len())];
+ if target.len_unicode() >= 1 {
+ let start = rng.gen_range(0..target.len_unicode());
+ let end = rng.gen_range(start..=target.len_unicode());
+ let _ = target.unmark(start..end, "bold");
+ }
+ }
+ 10 => {
+ let target = &mlists[rng.gen_range(0..mlists.len())];
+ let index = rng.gen_range(0..=target.len());
+ target.insert(index, rng.gen::())?;
+ }
+ 11 => {
+ let target = &mlists[rng.gen_range(0..mlists.len())];
+ if target.len() > 0 {
+ let index = rng.gen_range(0..target.len());
+ let max_len = (target.len() - index).min(3);
+ let len = rng.gen_range(1..=max_len);
+ target.delete(index, len)?;
+ }
+ }
+ 12 => {
+ // MovableList set
+ let target = &mlists[rng.gen_range(0..mlists.len())];
+ if target.len() > 0 {
+ let index = rng.gen_range(0..target.len());
+ target.set(index, rng.gen::())?;
+ }
+ }
+ 13 => {
+ // MovableList move
+ let target = &mlists[rng.gen_range(0..mlists.len())];
+ if target.len() >= 2 {
+ let from = rng.gen_range(0..target.len());
+ let to = rng.gen_range(0..target.len());
+ let _ = target.mov(from, to);
+ }
+ }
+ 14 => {
+ // Tree create
+ let t = &mut trees[rng.gen_range(0..trees.len())];
+ let parent = if t.nodes.is_empty() || rng.gen::() {
+ TreeParentId::Root
+ } else {
+ TreeParentId::from(t.nodes[rng.gen_range(0..t.nodes.len())])
+ };
+ let id = t.tree.create(parent)?;
+ t.nodes.push(id);
+ }
+ 15 => {
+ // Tree move
+ let t = &mut trees[rng.gen_range(0..trees.len())];
+ if t.nodes.len() >= 2 {
+ let target = t.nodes[rng.gen_range(0..t.nodes.len())];
+ let parent = if rng.gen::() {
+ TreeParentId::Root
+ } else {
+ TreeParentId::from(t.nodes[rng.gen_range(0..t.nodes.len())])
+ };
+ let _ = t.tree.mov(target, parent);
+ }
+ }
+ 16 => {
+ // Tree delete (try to keep at least 1 node around)
+ let t = &mut trees[rng.gen_range(0..trees.len())];
+ if t.nodes.len() > 1 {
+ let idx = rng.gen_range(0..t.nodes.len());
+ let id = t.nodes.swap_remove(idx);
+ let _ = t.tree.delete(id);
+ }
+ }
+ 17 => {
+ // Tree meta insert
+ let t = &mut trees[rng.gen_range(0..trees.len())];
+ if !t.nodes.is_empty() {
+ let id = t.nodes[rng.gen_range(0..t.nodes.len())];
+ if let Ok(meta) = t.tree.get_meta(id) {
+ let key = format!("m{}", rng.gen::());
+ let _ = meta.insert(&key, rng.gen::());
+ }
+ }
+ }
+ 18 => {
+ // Insert container values into sequence containers.
+ if rng.gen::() {
+ let target = &lists[rng.gen_range(0..lists.len())];
+ let index = rng.gen_range(0..=target.len());
+ let _ = target.insert_container(index, loro::LoroMap::new());
+ } else {
+ let target = &mlists[rng.gen_range(0..mlists.len())];
+ let index = rng.gen_range(0..=target.len());
+ let _ = target.insert_container(index, loro::LoroText::new());
+ }
+ }
+ 19 => {
+ // Counter inc/dec
+ let target = &counters[rng.gen_range(0..counters.len())];
+ let x = (rng.gen::() - 0.5) * 10.0;
+ if rng.gen::() {
+ let _ = target.increment(x);
+ } else {
+ let _ = target.decrement(x);
+ }
+ }
+ _ => unreachable!(),
+ }
+
+ if commit_every > 0 && (i + 1) % commit_every == 0 {
+ let msg = format!("commit-{} seed={} peer={}", i + 1, seed, active_peer);
+ doc.set_next_commit_message(&msg);
+ doc.set_next_commit_timestamp(i as Timestamp);
+ doc.commit();
+ let f = doc.state_frontiers();
+ if frontiers.last().map_or(true, |last| last != &f) {
+ frontiers.push(f);
+ }
+ }
+ }
+
+ let msg = format!("final seed={seed} ops={ops}");
+ doc.set_next_commit_message(&msg);
+ doc.set_next_commit_timestamp(ops as Timestamp);
+ doc.commit();
+ let f = doc.state_frontiers();
+ if frontiers.last().map_or(true, |last| last != &f) {
+ frontiers.push(f);
+ }
+
+ Ok(frontiers)
+}
+
+fn main() -> anyhow::Result<()> {
+ let args: Vec = std::env::args().collect();
+ if args.iter().any(|a| a == "--help" || a == "-h") {
+ usage();
+ }
+
+ let iters = parse_usize(&args, "--iters", 100);
+ if iters == 0 {
+ usage();
+ }
+
+ let ops = parse_usize(&args, "--ops", 200);
+ let commit_every = parse_usize(&args, "--commit-every", 20);
+ let peers_n = parse_usize(&args, "--peers", 3).max(1);
+
+ let seed = parse_u64(
+ &args,
+ "--seed",
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ );
+
+ let out_dir = parse_out_dir(&args);
+ std::fs::create_dir_all(&out_dir)?;
+
+ let moon_bin = std::env::var("MOON_BIN").unwrap_or_else(|_| "moon".to_string());
+ let node_bin = std::env::var("NODE_BIN").unwrap_or_else(|_| "node".to_string());
+ anyhow::ensure!(
+ bin_available(&moon_bin, &["version"]),
+ "moon not available (set MOON_BIN)"
+ );
+ anyhow::ensure!(
+ bin_available(&node_bin, &["--version"]),
+ "node not available (set NODE_BIN)"
+ );
+
+ let cli_js = build_moon_cli_js(&moon_bin)?;
+
+ let peer_ids: Vec = (1..=peers_n as u64).collect();
+
+ for i in 0..iters {
+ let case_seed = seed.wrapping_add(i as u64);
+
+ let doc = LoroDoc::new();
+ let commit_frontiers = apply_random_ops(&doc, case_seed, ops, commit_every, &peer_ids)?;
+
+ let expected_local = doc.get_deep_value().to_json_value();
+ let end = doc.oplog_vv();
+
+ // Choose a deterministic starting point (empty or a commit frontier).
+ let mut rng = StdRng::seed_from_u64(case_seed ^ 0xD1B5_4A32_D192_ED03);
+ let mut starts: Vec