feat(wasm): add diff, applyDiff, revertTo

This commit is contained in:
Zixuan Chen 2025-01-07 00:59:07 +08:00
parent 43a14ddaca
commit 875b455fc1
No known key found for this signature in database
7 changed files with 510 additions and 17 deletions

4
Cargo.lock generated
View file

@ -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",

View file

@ -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<Index> 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<Self, Self::Error> {
if value.is_null() || value.is_undefined() {
return Ok(TextMeta::default());
}
let obj = value.dyn_ref::<Object>().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::<Array>().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)
}
}
}

View file

@ -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 = []

View file

@ -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<Diff> {
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<Diff> {
}
"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<TextDiff, JsValue> {
let arr = js
.dyn_ref::<Array>()
.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::<Object>()
.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<ResolvedMapDelta, JsValue> {
let obj = js
.dyn_ref::<Object>()
.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::<Array>().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::<Object>().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<ListDiff, JsValue> {
let arr = js
.dyn_ref::<Array>()
.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::<Object>()
.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::<Array>()
.ok_or_else(|| JsValue::from_str("insert must be an array"))?;
let mut values = array_vec::ArrayVec::<ValueOrHandler, 8>::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::<Object>().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::<Array>()
.unwrap()
.get(0)
.as_string()
.unwrap();
let value = entry.dyn_ref::<Array>().unwrap().get(1);
map.insert(key, js_value_to_loro_value(&value));
}
LoroValue::Map(LoroMapValue::from(map))
} else {
LoroValue::Null
}
}

View file

@ -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<JsID>) -> 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::<js_sys::Array>();
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(())

View file

@ -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,
},
},
}
`;

View file

@ -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()
})