From 875b455fc1fd713c850d4254ff014006dfc22a5b Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Tue, 7 Jan 2025 00:59:07 +0800 Subject: [PATCH] feat(wasm): add diff, applyDiff, revertTo --- Cargo.lock | 4 + crates/loro-internal/src/value.rs | 36 +++- crates/loro-wasm/Cargo.toml | 4 + crates/loro-wasm/src/convert.rs | 183 +++++++++++++++- crates/loro-wasm/src/lib.rs | 30 ++- .../tests/__snapshots__/basic.test.ts.snap | 203 ++++++++++++++++++ crates/loro-wasm/tests/basic.test.ts | 67 ++++++ 7 files changed, 510 insertions(+), 17 deletions(-) create mode 100644 crates/loro-wasm/tests/__snapshots__/basic.test.ts.snap diff --git a/Cargo.lock b/Cargo.lock index 7bafdd09..9e237d50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1480,14 +1480,18 @@ checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a" name = "loro-wasm" version = "0.1.0" dependencies = [ + "arrayvec", "console_error_panic_hook", "getrandom", "js-sys", + "loro-common 1.2.7", + "loro-delta 1.2.7", "loro-internal 1.2.7", "loro-rle 1.2.7", "serde", "serde-wasm-bindgen", "serde_json", + "smallvec", "tracing", "tracing-wasm", "wasm-bindgen", diff --git a/crates/loro-internal/src/value.rs b/crates/loro-internal/src/value.rs index 6222e15f..a2909ff2 100644 --- a/crates/loro-internal/src/value.rs +++ b/crates/loro-internal/src/value.rs @@ -5,12 +5,9 @@ use crate::{ handler::ValueOrHandler, utils::string_slice::StringSlice, }; - -use crate::state::TreeParentId; -use fractional_index::FractionalIndex; use generic_btree::rle::HasLength; +use loro_common::ContainerType; pub use loro_common::LoroValue; -use loro_common::{ContainerType, TreeID}; // TODO: rename this trait pub trait ToJson { @@ -532,8 +529,8 @@ pub mod wasm { use fractional_index::FractionalIndex; use generic_btree::rle::HasLength; use js_sys::{Array, Object}; - use loro_common::TreeID; - use wasm_bindgen::{JsValue, __rt::IntoJsResult}; + use loro_common::{LoroValue, TreeID}; + use wasm_bindgen::{JsCast, JsValue, __rt::IntoJsResult}; impl From for JsValue { fn from(value: Index) -> Self { @@ -950,4 +947,31 @@ pub mod wasm { obj.into_js_result().unwrap() } } + + impl TryFrom<&JsValue> for TextMeta { + type Error = JsValue; + + fn try_from(value: &JsValue) -> Result { + if value.is_null() || value.is_undefined() { + return Ok(TextMeta::default()); + } + + let obj = value.dyn_ref::().ok_or("Expected an object")?; + let mut meta = TextMeta::default(); + + let entries = Object::entries(obj); + for i in 0..entries.length() { + let entry = entries.get(i); + let entry_arr = entry.dyn_ref::().ok_or("Expected an array")?; + let key = entry_arr + .get(0) + .as_string() + .ok_or("Expected a string key")?; + let value = entry_arr.get(1); + meta.0.insert(key, LoroValue::from(value)); + } + + Ok(meta) + } + } } diff --git a/crates/loro-wasm/Cargo.toml b/crates/loro-wasm/Cargo.toml index b4729c3b..a6f3cd89 100644 --- a/crates/loro-wasm/Cargo.toml +++ b/crates/loro-wasm/Cargo.toml @@ -15,6 +15,8 @@ loro-internal = { path = "../loro-internal", features = [ "counter", "jsonpath", ] } +loro-common = { path = "../loro-common" } +loro-delta = { path = "../delta" } wasm-bindgen = "=0.2.92" serde-wasm-bindgen = { version = "^0.6.5" } wasm-bindgen-derive = "0.2.1" @@ -25,6 +27,8 @@ rle = { path = "../rle", package = "loro-rle" } tracing-wasm = "0.2.1" tracing = { version = "0.1", features = ["release_max_level_warn"] } serde_json = "1" +smallvec = "1.11.2" +arrayvec = "0.7.4" [features] default = [] diff --git a/crates/loro-wasm/src/convert.rs b/crates/loro-wasm/src/convert.rs index e98f8251..78199743 100644 --- a/crates/loro-wasm/src/convert.rs +++ b/crates/loro-wasm/src/convert.rs @@ -1,14 +1,15 @@ use std::sync::Arc; use js_sys::{Array, Map, Object, Reflect, Uint8Array}; -use loro_internal::container::ContainerID; -use loro_internal::delta::ResolvedMapDelta; +use loro_common::{IdLp, LoroListValue, LoroMapValue, LoroValue}; +use loro_delta::{array_vec, DeltaRopeBuilder}; +use loro_internal::delta::{ResolvedMapDelta, ResolvedMapValue}; use loro_internal::encoding::{ImportBlobMetadata, ImportStatus}; -use loro_internal::event::Diff; +use loro_internal::event::{Diff, ListDeltaMeta, ListDiff, TextDiff, TextMeta}; use loro_internal::handler::{Handler, ValueOrHandler}; -use loro_internal::undo::DiffBatch; use loro_internal::version::VersionRange; -use loro_internal::{Counter, CounterSpan, FxHashMap, IdSpan, ListDiffItem, LoroDoc, LoroValue}; +use loro_internal::StringSlice; +use loro_internal::{Counter, CounterSpan, FxHashMap, IdSpan, ListDiffItem, LoroDoc}; use wasm_bindgen::{JsCast, JsValue}; use crate::{ @@ -212,12 +213,12 @@ pub(crate) fn js_diff_to_inner_diff(js: JsValue) -> JsResult { match diff_type.as_str() { "text" => { let diff = js_sys::Reflect::get(&obj, &"diff".into())?; - let text_diff = loro_internal::wasm::js_value_to_text_diff(diff)?; + let text_diff = js_value_to_text_diff(&diff)?; Ok(Diff::Text(text_diff)) } "map" => { let updated = js_sys::Reflect::get(&obj, &"updated".into())?; - let map_diff = js_to_map_delta(updated)?; + let map_diff = js_to_map_delta(&updated)?; Ok(Diff::Map(map_diff)) } "counter" => { @@ -232,7 +233,7 @@ pub(crate) fn js_diff_to_inner_diff(js: JsValue) -> JsResult { } "list" => { let diff = js_sys::Reflect::get(&obj, &"diff".into())?; - let list_diff = loro_internal::wasm::js_value_to_tree_diff(diff)?; + let list_diff = js_value_to_list_diff(&diff)?; Ok(Diff::List(list_diff)) } _ => Err(format!("Unknown diff type: {}", diff_type).into()), @@ -464,3 +465,169 @@ fn id_span_vector_to_js_value(v: VersionRange) -> JsValue { } map.into() } + +pub(crate) fn js_value_to_text_diff(js: &JsValue) -> Result { + let arr = js + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("Expected an array"))?; + let mut builder = DeltaRopeBuilder::new(); + + for i in 0..arr.length() { + let item = arr.get(i); + let obj = item + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("Expected an object"))?; + + if let Some(retain) = Reflect::get(&obj, &JsValue::from_str("retain"))?.as_f64() { + let len = retain as usize; + let js_meta = Reflect::get(&obj, &JsValue::from_str("attributes"))?; + let meta = TextMeta::try_from(&js_meta).unwrap_or_default(); + builder = builder.retain(len, meta); + } else if let Some(insert) = Reflect::get(&obj, &JsValue::from_str("insert"))?.as_string() { + let js_meta = Reflect::get(&obj, &JsValue::from_str("attributes"))?; + let meta = TextMeta::try_from(&js_meta).unwrap_or_default(); + builder = builder.insert(StringSlice::from(insert), meta); + } else if let Some(delete) = Reflect::get(&obj, &JsValue::from_str("delete"))?.as_f64() { + let len = delete as usize; + builder = builder.delete(len); + } else { + return Err(JsValue::from_str("Invalid delta item")); + } + } + + Ok(builder.build()) +} + +pub(crate) fn js_to_map_delta(js: &JsValue) -> Result { + let obj = js + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("Expected an object"))?; + let mut delta = ResolvedMapDelta::new(); + + let entries = Object::entries(&obj); + for i in 0..entries.length() { + let entry = entries.get(i); + let entry_arr = entry.dyn_ref::().unwrap(); + let key = entry_arr.get(0).as_string().unwrap(); + let value = entry_arr.get(1); + + if value.is_object() && !value.is_null() { + let obj = value.dyn_ref::().unwrap(); + if let Ok(kind) = Reflect::get(&obj, &JsValue::from_str("kind")) { + if kind.is_function() { + let container = js_to_container(value.clone().unchecked_into())?; + delta = delta.with_entry( + key.into(), + ResolvedMapValue { + idlp: IdLp::new(0, 0), + value: Some(ValueOrHandler::Handler(container.to_handler())), + }, + ); + continue; + } + } + } + delta = delta.with_entry( + key.into(), + ResolvedMapValue { + idlp: IdLp::new(0, 0), + value: Some(ValueOrHandler::Value(js_value_to_loro_value(&value))), + }, + ); + } + + Ok(delta) +} + +pub(crate) fn js_value_to_list_diff(js: &JsValue) -> Result { + let arr = js + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("Expected an array"))?; + let mut builder = DeltaRopeBuilder::new(); + + for i in 0..arr.length() { + let item = arr.get(i); + let obj = item + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("Expected an object"))?; + + if let Some(retain) = Reflect::get(&obj, &JsValue::from_str("retain"))?.as_f64() { + let len = retain as usize; + builder = builder.retain(len, ListDeltaMeta::default()); + } else if let Some(delete) = Reflect::get(&obj, &JsValue::from_str("delete"))?.as_f64() { + let len = delete as usize; + builder = builder.delete(len); + } else if let Ok(insert) = Reflect::get(&obj, &JsValue::from_str("insert")) { + let insert_arr = insert + .dyn_ref::() + .ok_or_else(|| JsValue::from_str("insert must be an array"))?; + let mut values = array_vec::ArrayVec::::new(); + + for j in 0..insert_arr.length() { + let value = insert_arr.get(j); + if value.is_object() && !value.is_null() { + let obj = value.dyn_ref::().unwrap(); + if let Ok(kind) = Reflect::get(&obj, &JsValue::from_str("kind")) { + if kind.is_function() { + let container = js_to_container(value.clone().unchecked_into())?; + values + .push(ValueOrHandler::Handler(container.to_handler())) + .unwrap(); + continue; + } + } + } + values + .push(ValueOrHandler::Value(js_value_to_loro_value(&value))) + .unwrap(); + } + + builder = builder.insert(values, ListDeltaMeta::default()); + } else { + return Err(JsValue::from_str("Invalid delta item")); + } + } + + Ok(builder.build()) +} + +pub(crate) fn js_value_to_loro_value(js: &JsValue) -> LoroValue { + if js.is_null() { + LoroValue::Null + } else if let Some(b) = js.as_bool() { + LoroValue::Bool(b) + } else if let Some(n) = js.as_f64() { + if n.fract() == 0.0 && n >= -(2i64.pow(53) as f64) && n <= 2i64.pow(53) as f64 { + LoroValue::I64(n as i64) + } else { + LoroValue::Double(n) + } + } else if let Some(s) = js.as_string() { + LoroValue::String(s.into()) + } else if js.is_array() { + let arr = Array::from(js); + let mut vec = Vec::with_capacity(arr.length() as usize); + for i in 0..arr.length() { + vec.push(js_value_to_loro_value(&arr.get(i))); + } + LoroValue::List(LoroListValue::from(vec)) + } else if js.is_object() { + let obj = Object::from(JsValue::from(js)); + let mut map = FxHashMap::default(); + let entries = Object::entries(&obj); + for i in 0..entries.length() { + let entry = entries.get(i); + let key = entry + .dyn_ref::() + .unwrap() + .get(0) + .as_string() + .unwrap(); + let value = entry.dyn_ref::().unwrap().get(1); + map.insert(key, js_value_to_loro_value(&value)); + } + LoroValue::Map(LoroMapValue::from(map)) + } else { + LoroValue::Null + } +} diff --git a/crates/loro-wasm/src/lib.rs b/crates/loro-wasm/src/lib.rs index 649be643..295b8023 100644 --- a/crates/loro-wasm/src/lib.rs +++ b/crates/loro-wasm/src/lib.rs @@ -5,7 +5,8 @@ // #![warn(missing_docs)] use convert::{ - import_status_to_js_value, js_to_id_span, js_to_version_vector, resolved_diff_to_js, + import_status_to_js_value, js_diff_to_inner_diff, js_to_id_span, js_to_version_vector, + resolved_diff_to_js, }; use js_sys::{Array, Object, Promise, Reflect, Uint8Array}; use loro_internal::{ @@ -1874,7 +1875,21 @@ impl LoroDoc { .collect()) } - /// Revert the document to the given frontiers + /// Revert the document to the given frontiers. + /// + /// The doc will not become detached when using this method. Instead, it will generate a series + /// of operations to revert the document to the given version. + /// + /// @example + /// ```ts + /// const doc = new LoroDoc(); + /// doc.setPeerId("1"); + /// const text = doc.getText("text"); + /// text.insert(0, "Hello"); + /// doc.commit(); + /// doc.revertTo([{ peer: "1", counter: 1 }]); + /// expect(doc.getText("text").toString()).toBe("He"); + /// ``` #[wasm_bindgen(js_name = "revertTo")] pub fn revert_to(&self, frontiers: Vec) -> JsResult<()> { let frontiers = ids_to_frontiers(frontiers)?; @@ -1882,6 +1897,8 @@ impl LoroDoc { Ok(()) } + /// Apply a diff batch to the document + #[wasm_bindgen(js_name = "applyDiff")] pub fn apply_diff(&self, diff: JsDiffBatch) -> JsResult<()> { let diff: JsValue = diff.into(); let obj: js_sys::Object = diff.into(); @@ -1890,7 +1907,14 @@ impl LoroDoc { let entry = entry.unchecked_into::(); let k = entry.get(0); let v = entry.get(1); - diff.insert(k.as_string().unwrap(), js_diff_to_inner_diff(v)); + diff.insert( + k.as_string() + .ok_or("Expected string key")? + .as_str() + .try_into() + .map_err(|_| "Failed to convert key")?, + js_diff_to_inner_diff(v)?, + ); } self.0.apply_diff(DiffBatch(diff))?; Ok(()) diff --git a/crates/loro-wasm/tests/__snapshots__/basic.test.ts.snap b/crates/loro-wasm/tests/__snapshots__/basic.test.ts.snap new file mode 100644 index 00000000..474c9022 --- /dev/null +++ b/crates/loro-wasm/tests/__snapshots__/basic.test.ts.snap @@ -0,0 +1,203 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`can diff two versions 1`] = ` +{ + "cid:root-list:List": { + "diff": [ + { + "insert": [ + "item1", + ], + }, + ], + "type": "list", + }, + "cid:root-map:Map": { + "type": "map", + "updated": { + "key1": "value1", + "key2": 42, + }, + }, + "cid:root-text:Text": { + "diff": [ + { + "attributes": { + "bold": true, + }, + "insert": "Hello", + }, + ], + "type": "text", + }, + "cid:root-tree:Tree": { + "diff": [ + { + "action": "create", + "fractionalIndex": "80", + "index": 0, + "parent": undefined, + "target": "12@1", + }, + { + "action": "create", + "fractionalIndex": "80", + "index": 0, + "parent": "12@1", + "target": "13@1", + }, + ], + "type": "tree", + }, +} +`; + +exports[`can diff two versions 2`] = ` +{ + "list": [ + "item1", + ], + "map": { + "key1": "value1", + "key2": 42, + }, + "text": "Hello", + "tree": [ + { + "children": [ + { + "children": [], + "fractional_index": "80", + "id": "1@2", + "index": 0, + "meta": {}, + "parent": "0@2", + }, + ], + "fractional_index": "80", + "id": "0@2", + "index": 0, + "meta": {}, + "parent": null, + }, + ], +} +`; + +exports[`the diff will deduplication 1`] = ` +{ + "cid:root-hi:Text": { + "diff": [ + { + "insert": "Hello", + }, + ], + "type": "text", + }, + "cid:root-map:Map": { + "type": "map", + "updated": { + "0": null, + "1": null, + "10": null, + "11": null, + "12": null, + "13": null, + "14": null, + "15": null, + "16": null, + "17": null, + "18": null, + "19": null, + "2": null, + "20": null, + "21": null, + "22": null, + "23": null, + "24": null, + "25": null, + "26": null, + "27": null, + "28": null, + "29": null, + "3": null, + "30": null, + "31": null, + "32": null, + "33": null, + "34": null, + "35": null, + "36": null, + "37": null, + "38": null, + "39": null, + "4": null, + "40": null, + "41": null, + "42": null, + "43": null, + "44": null, + "45": null, + "46": null, + "47": null, + "48": null, + "49": null, + "5": null, + "50": null, + "51": null, + "52": null, + "53": null, + "54": null, + "55": null, + "56": null, + "57": null, + "58": null, + "59": null, + "6": null, + "60": null, + "61": null, + "62": null, + "63": null, + "64": null, + "65": null, + "66": null, + "67": null, + "68": null, + "69": null, + "7": null, + "70": null, + "71": null, + "72": null, + "73": null, + "74": null, + "75": null, + "76": null, + "77": null, + "78": null, + "79": null, + "8": null, + "80": null, + "81": null, + "82": null, + "83": null, + "84": null, + "85": null, + "86": null, + "87": null, + "88": null, + "89": null, + "9": null, + "90": null, + "91": null, + "92": null, + "93": null, + "94": null, + "95": null, + "96": null, + "97": null, + "98": null, + "99": null, + }, + }, +} +`; diff --git a/crates/loro-wasm/tests/basic.test.ts b/crates/loro-wasm/tests/basic.test.ts index 97cf054b..283ba16c 100644 --- a/crates/loro-wasm/tests/basic.test.ts +++ b/crates/loro-wasm/tests/basic.test.ts @@ -1151,3 +1151,70 @@ it("can travel changes from event", async () => { await Promise.resolve(); expect(done).toBe(true); }) + +it("can revert to frontiers", () => { + const doc = new LoroDoc(); + doc.setPeerId("1"); + doc.getText("text").update("Hello"); + doc.commit(); + doc.revertTo([{ peer: "1", counter: 1 }]); + expect(doc.getText("text").toString()).toBe("He"); +}) + +it("can diff two versions", () => { + const doc = new LoroDoc(); + doc.setPeerId("1"); + // Text edits with formatting + const text = doc.getText("text"); + text.update("Hello"); + text.mark({ start: 0, end: 5 }, "bold", true); + doc.commit(); + + // Map edits + const map = doc.getMap("map"); + map.set("key1", "value1"); + map.set("key2", 42); + doc.commit(); + + // List edits + const list = doc.getList("list"); + list.insert(0, "item1"); + list.insert(1, "item2"); + list.delete(1, 1); + doc.commit(); + + // Tree edits + const tree = doc.getTree("tree"); + const a = tree.createNode(); + a.createNode(); + doc.commit(); + + const diff = doc.diff([], doc.frontiers()); + expect(diff).toMatchSnapshot() + + const doc2 = new LoroDoc(); + doc2.setPeerId("2"); + doc2.applyDiff(diff); + expect(doc2.toJSON()).toMatchSnapshot() + expect(doc2.getText("text").toDelta()).toStrictEqual(doc.getText("text").toDelta()) +}) + +it('the diff will deduplication', () => { + const doc = new LoroDoc(); + const list = doc.getList("list"); + const map = doc.getMap("map"); + doc.getText("hi").insert(0, "Hello"); + for (let i = 0; i < 100; i += 1) { + list.push(1) + map.set(i.toString(), i); + doc.setNextCommitMessage("hi " + i); + doc.commit(); + } + + list.clear(); + map.clear(); + doc.commit(); + + const diff = doc.diff([], doc.frontiers()); + expect(diff).toMatchSnapshot() +})