From 8039e446a88951924185770f70b8268113ca8c99 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 6 Jan 2025 11:15:10 +0800 Subject: [PATCH] feat: find id spans between (#607) * feat: add a method to find id spans between frontiers * feat(wasm): expose frontiers (from&to) in event * test: fix test * chore: changeset * refactor: rename to findIdSpansBetween * test: fix test err * refactor: rename the fields of version vector diff replace `left` and `right` with `retreat` and `forward` * docs: add more details about find_id_spans_between --- .changeset/tame-spies-attack.md | 5 + crates/loro-ffi/src/version.rs | 4 +- crates/loro-internal/src/dag.rs | 9 +- crates/loro-internal/src/lib.rs | 4 +- crates/loro-internal/src/loro.rs | 9 +- crates/loro-internal/src/oplog.rs | 2 +- crates/loro-internal/src/version.rs | 32 +++--- crates/loro-wasm/deno_tests/basic.test.ts | 79 ++++++++----- crates/loro-wasm/src/lib.rs | 131 +++++++++++++++++++++- crates/loro-wasm/tests/basic.test.ts | 128 +++++++++++++++++++++ crates/loro/src/lib.rs | 6 + crates/loro/tests/loro_rust_test.rs | 87 ++++++++++++++ scripts/deno.lock | 6 +- scripts/run-js-doc-tests.ts | 2 +- 14 files changed, 440 insertions(+), 64 deletions(-) create mode 100644 .changeset/tame-spies-attack.md diff --git a/.changeset/tame-spies-attack.md b/.changeset/tame-spies-attack.md new file mode 100644 index 000000000..0b5f3fb12 --- /dev/null +++ b/.changeset/tame-spies-attack.md @@ -0,0 +1,5 @@ +--- +"loro-crdt": minor +--- + +feat: find id spans between #607 diff --git a/crates/loro-ffi/src/version.rs b/crates/loro-ffi/src/version.rs index 96c655dd5..42e9835dc 100644 --- a/crates/loro-ffi/src/version.rs +++ b/crates/loro-ffi/src/version.rs @@ -133,8 +133,8 @@ pub struct VersionVectorDiff { impl From for VersionVectorDiff { fn from(value: loro::VersionVectorDiff) -> Self { Self { - left: value.left.into_iter().collect(), - right: value.right.into_iter().collect(), + left: value.retreat.into_iter().collect(), + right: value.forward.into_iter().collect(), } } } diff --git a/crates/loro-internal/src/dag.rs b/crates/loro-internal/src/dag.rs index 16a5e323b..72c13a526 100644 --- a/crates/loro-internal/src/dag.rs +++ b/crates/loro-internal/src/dag.rs @@ -99,7 +99,6 @@ impl DagUtils for T { fn find_path(&self, from: &Frontiers, to: &Frontiers) -> VersionVectorDiff { let mut ans = VersionVectorDiff::default(); - trace!("find_path from={:?} to={:?}", from, to); if from == to { return ans; } @@ -112,12 +111,12 @@ impl DagUtils for T { let to_span = self.get(to).unwrap(); if from_span.id_start() == to_span.id_start() { if from.counter < to.counter { - ans.right.insert( + ans.forward.insert( from.peer, CounterSpan::new(from.counter + 1, to.counter + 1), ); } else { - ans.left.insert( + ans.retreat.insert( from.peer, CounterSpan::new(to.counter + 1, from.counter + 1), ); @@ -128,7 +127,7 @@ impl DagUtils for T { if from_span.deps().len() == 1 && to_span.contains_id(from_span.deps().as_single().unwrap()) { - ans.left.insert( + ans.retreat.insert( from.peer, CounterSpan::new(to.counter + 1, from.counter + 1), ); @@ -138,7 +137,7 @@ impl DagUtils for T { if to_span.deps().len() == 1 && from_span.contains_id(to_span.deps().as_single().unwrap()) { - ans.right.insert( + ans.forward.insert( from.peer, CounterSpan::new(from.counter + 1, to.counter + 1), ); diff --git a/crates/loro-internal/src/lib.rs b/crates/loro-internal/src/lib.rs index f4e88f3c2..981f6d3ff 100644 --- a/crates/loro-internal/src/lib.rs +++ b/crates/loro-internal/src/lib.rs @@ -84,8 +84,8 @@ pub use encoding::json_schema::json; pub use fractional_index::FractionalIndex; pub use loro_common::{loro_value, to_value}; pub use loro_common::{ - Counter, CounterSpan, IdLp, IdSpan, Lamport, LoroEncodeError, LoroError, LoroResult, - LoroTreeError, PeerID, TreeID, ID, + Counter, CounterSpan, IdLp, IdSpan, IdSpanVector, Lamport, LoroEncodeError, LoroError, + LoroResult, LoroTreeError, PeerID, TreeID, ID, }; pub use loro_common::{LoroBinaryValue, LoroListValue, LoroMapValue, LoroStringValue}; #[cfg(feature = "wasm")] diff --git a/crates/loro-internal/src/loro.rs b/crates/loro-internal/src/loro.rs index 6d9f6766b..cacfac437 100644 --- a/crates/loro-internal/src/loro.rs +++ b/crates/loro-internal/src/loro.rs @@ -30,7 +30,7 @@ use crate::{ IntoContainerId, }, cursor::{AbsolutePosition, CannotFindRelativePosition, Cursor, PosQueryResult}, - dag::Dag, + dag::{Dag, DagUtils}, diff_calc::DiffCalculator, encoding::{ self, decode_snapshot, export_fast_snapshot, export_fast_updates, @@ -49,7 +49,7 @@ use crate::{ txn::Transaction, undo::DiffBatch, utils::subscription::{SubscriberSetWithQueue, Subscription}, - version::{shrink_frontiers, Frontiers, ImVersionVector, VersionRange}, + version::{shrink_frontiers, Frontiers, ImVersionVector, VersionRange, VersionVectorDiff}, ChangeMeta, DocDiff, HandlerTrait, InternalString, ListHandler, LoroError, MapHandler, VersionVector, }; @@ -1635,6 +1635,11 @@ impl LoroDoc { 0 } } + + #[inline] + pub fn find_id_spans_between(&self, from: &Frontiers, to: &Frontiers) -> VersionVectorDiff { + self.oplog().try_lock().unwrap().dag.find_path(from, to) + } } #[derive(Debug, thiserror::Error)] diff --git a/crates/loro-internal/src/oplog.rs b/crates/loro-internal/src/oplog.rs index c6f7015f0..eec5a6c18 100644 --- a/crates/loro-internal/src/oplog.rs +++ b/crates/loro-internal/src/oplog.rs @@ -409,7 +409,7 @@ impl OpLog { let common_ancestors_vv = self.dag.frontiers_to_vv(&common_ancestors).unwrap(); // go from lca to merged_vv - let diff = common_ancestors_vv.diff(&merged_vv).right; + let diff = common_ancestors_vv.diff(&merged_vv).forward; let mut iter = self.dag.iter_causal(common_ancestors, diff); let mut node = iter.next(); let mut cur_cnt = 0; diff --git a/crates/loro-internal/src/version.rs b/crates/loro-internal/src/version.rs index aa7368873..d0e18e6fc 100644 --- a/crates/loro-internal/src/version.rs +++ b/crates/loro-internal/src/version.rs @@ -300,42 +300,46 @@ impl Deref for VersionVector { #[derive(Default, Debug, PartialEq, Eq)] pub struct VersionVectorDiff { - /// need to add these spans to move from right to left - pub left: IdSpanVector, - /// need to add these spans to move from left to right - pub right: IdSpanVector, + /// The spans that the `left` side needs to retreat to reach the `right` side + /// + /// these spans are included in the left, but not in the right + pub retreat: IdSpanVector, + /// The spans that the `left` side needs to forward to reach the `right` side + /// + /// these spans are included in the right, but not in the left + pub forward: IdSpanVector, } impl VersionVectorDiff { #[inline] pub fn merge_left(&mut self, span: IdSpan) { - merge(&mut self.left, span); + merge(&mut self.retreat, span); } #[inline] pub fn merge_right(&mut self, span: IdSpan) { - merge(&mut self.right, span); + merge(&mut self.forward, span); } #[inline] pub fn subtract_start_left(&mut self, span: IdSpan) { - subtract_start(&mut self.left, span); + subtract_start(&mut self.retreat, span); } #[inline] pub fn subtract_start_right(&mut self, span: IdSpan) { - subtract_start(&mut self.right, span); + subtract_start(&mut self.forward, span); } pub fn get_id_spans_left(&self) -> impl Iterator + '_ { - self.left.iter().map(|(peer, span)| IdSpan { + self.retreat.iter().map(|(peer, span)| IdSpan { peer: *peer, counter: *span, }) } pub fn get_id_spans_right(&self) -> impl Iterator + '_ { - self.right.iter().map(|(peer, span)| IdSpan { + self.forward.iter().map(|(peer, span)| IdSpan { peer: *peer, counter: *span, }) @@ -457,7 +461,7 @@ impl VersionVector { if let Some(&rhs_counter) = rhs.get(client_id) { match counter.cmp(&rhs_counter) { Ordering::Less => { - ans.right.insert( + ans.forward.insert( *client_id, CounterSpan { start: counter, @@ -466,7 +470,7 @@ impl VersionVector { ); } Ordering::Greater => { - ans.left.insert( + ans.retreat.insert( *client_id, CounterSpan { start: rhs_counter, @@ -477,7 +481,7 @@ impl VersionVector { Ordering::Equal => {} } } else { - ans.left.insert( + ans.retreat.insert( *client_id, CounterSpan { start: 0, @@ -488,7 +492,7 @@ impl VersionVector { } for (client_id, &rhs_counter) in rhs.iter() { if !self.contains_key(client_id) { - ans.right.insert( + ans.forward.insert( *client_id, CounterSpan { start: 0, diff --git a/crates/loro-wasm/deno_tests/basic.test.ts b/crates/loro-wasm/deno_tests/basic.test.ts index 4e5955648..acada7f7f 100644 --- a/crates/loro-wasm/deno_tests/basic.test.ts +++ b/crates/loro-wasm/deno_tests/basic.test.ts @@ -1,43 +1,60 @@ -import init, { initSync, LoroDoc, LoroMap } from "../web/loro_wasm.js"; +import init, { initSync, LoroDoc, LoroMap, LoroText } from "../web/index.js"; import { expect } from "npm:expect"; await init(); Deno.test("basic", () => { - const doc = new LoroDoc(); - doc.getText("text").insert(0, "Hello, world!"); - expect(doc.getText("text").toString()).toBe("Hello, world!"); + const doc = new LoroDoc(); + doc.getText("text").insert(0, "Hello, world!"); + expect(doc.getText("text").toString()).toBe("Hello, world!"); }); Deno.test("fork when detached", () => { - const doc = new LoroDoc(); - doc.setPeerId("0"); - doc.getText("text").insert(0, "Hello, world!"); - doc.checkout([{ peer: "0", counter: 5 }]); - const newDoc = doc.fork(); - newDoc.setPeerId("1"); - newDoc.getText("text").insert(6, " Alice!"); - // ┌───────────────┐ ┌───────────────┐ - // │ Hello, │◀─┬──│ world! │ - // └───────────────┘ │ └───────────────┘ - // │ - // │ ┌───────────────┐ - // └──│ Alice! │ - // └───────────────┘ - doc.import(newDoc.export({ mode: "update" })); - doc.checkoutToLatest(); - console.log(doc.getText("text").toString()); // "Hello, world! Alice!" + const doc: LoroDoc = new LoroDoc(); + doc.setPeerId("0"); + doc.getText("text").insert(0, "Hello, world!"); + doc.checkout([{ peer: "0", counter: 5 }]); + const newDoc = doc.fork(); + newDoc.setPeerId("1"); + newDoc.getText("text").insert(6, " Alice!"); + // ┌───────────────┐ ┌───────────────┐ + // │ Hello, │◀─┬──│ world! │ + // └───────────────┘ │ └───────────────┘ + // │ + // │ ┌───────────────┐ + // └──│ Alice! │ + // └───────────────┘ + doc.import(newDoc.export({ mode: "update" })); + doc.checkoutToLatest(); + console.log(doc.getText("text").toString()); // "Hello, world! Alice!" }); Deno.test("isDeleted", () => { - const doc = new LoroDoc(); - const list = doc.getList("list"); - expect(list.isDeleted()).toBe(false); - const tree = doc.getTree("root"); - const node = tree.createNode(undefined, undefined); - const containerBefore = node.data.setContainer("container", new LoroMap()); - containerBefore.set("A", "B"); - tree.delete(node.id); - const containerAfter = doc.getContainerById(containerBefore.id) as LoroMap; - expect(containerAfter.isDeleted()).toBe(true); + const doc = new LoroDoc(); + const list = doc.getList("list"); + expect(list.isDeleted()).toBe(false); + const tree = doc.getTree("root"); + const node = tree.createNode(undefined, undefined); + const containerBefore = node.data.setContainer("container", new LoroMap()); + containerBefore.set("A", "B"); + tree.delete(node.id); + const containerAfter = doc.getContainerById(containerBefore.id) as LoroMap; + expect(containerAfter.isDeleted()).toBe(true); +}); + +Deno.test("toJsonWithReplacer", () => { + const doc = new LoroDoc(); + const text = doc.getText("text"); + text.insert(0, "Hello"); + text.mark({ start: 0, end: 2 }, "bold", true); + + // Use delta to represent text + // @ts-ignore: deno is not very clever + const json = doc.toJsonWithReplacer((key, value) => { + if (value instanceof LoroText) { + return value.toDelta(); + } + + return value; + }); }); diff --git a/crates/loro-wasm/src/lib.rs b/crates/loro-wasm/src/lib.rs index e82dce2ef..9e9099fb9 100644 --- a/crates/loro-wasm/src/lib.rs +++ b/crates/loro-wasm/src/lib.rs @@ -22,10 +22,10 @@ use loro_internal::{ id::{Counter, PeerID, TreeID, ID}, json::JsonSchema, loro::{CommitOptions, ExportMode}, - loro_common::check_root_container_name, + loro_common::{check_root_container_name, IdSpanVector}, undo::{UndoItemMeta, UndoOrRedo}, version::Frontiers, - ContainerType, DiffEvent, FxHashMap, HandlerTrait, LoroDoc as LoroDocInner, LoroResult, + ContainerType, DiffEvent, FxHashMap, HandlerTrait, IdSpan, LoroDoc as LoroDocInner, LoroResult, LoroValue, MovableListHandler, Subscription, TreeNodeWithChildren, TreeParentId, UndoManager as InnerUndoManager, VersionVector as InternalVersionVector, }; @@ -210,6 +210,8 @@ extern "C" { pub type JsLoroRootShallowValue; #[wasm_bindgen(typescript_type = "{ peer: PeerID, counter: number, length: number }")] pub type JsIdSpan; + #[wasm_bindgen(typescript_type = "VersionVectorDiff")] + pub type JsVersionVectorDiff; } mod observer { @@ -673,6 +675,90 @@ impl LoroDoc { .map_err(|e| JsValue::from(e.to_string())) } + /// Find the op id spans that between the `from` version and the `to` version. + /// + /// You can combine it with `exportJsonInIdSpan` to get the changes between two versions. + /// + /// You can use it to travel all the changes from `from` to `to`. `from` and `to` are frontiers, + /// and they can be concurrent to each other. You can use it to find all the changes related to an event: + /// + /// @example + /// ```ts + /// import { LoroDoc } from "loro-crdt"; + /// + /// const docA = new LoroDoc(); + /// docA.setPeerId("1"); + /// const docB = new LoroDoc(); + /// + /// docA.getText("text").update("Hello"); + /// docA.commit(); + /// const snapshot = docA.export({ mode: "snapshot" }); + /// let done = false; + /// docB.subscribe(e => { + /// const spans = docB.findIdSpansBetween(e.from, e.to); + /// const changes = docB.exportJsonInIdSpan(spans.forward[0]); + /// console.log(changes); + /// // [{ + /// // id: "0@1", + /// // timestamp: expect.any(Number), + /// // deps: [], + /// // lamport: 0, + /// // msg: undefined, + /// // ops: [{ + /// // container: "cid:root-text:Text", + /// // counter: 0, + /// // content: { + /// // type: "insert", + /// // pos: 0, + /// // text: "Hello" + /// // } + /// // }] + /// // }] + /// }); + /// docB.import(snapshot); + /// ``` + #[wasm_bindgen(js_name = "findIdSpansBetween")] + pub fn find_id_spans_between( + &self, + from: Vec, + to: Vec, + ) -> JsResult { + fn id_span_to_js(v: IdSpan) -> JsValue { + let obj = Object::new(); + js_sys::Reflect::set(&obj, &"peer".into(), &JsValue::from(v.peer.to_string())).unwrap(); + js_sys::Reflect::set(&obj, &"counter".into(), &JsValue::from(v.counter.start)).unwrap(); + js_sys::Reflect::set( + &obj, + &"length".into(), + &JsValue::from(v.counter.end - v.counter.start), + ) + .unwrap(); + obj.into() + } + + fn id_span_vector_to_js(v: IdSpanVector) -> JsValue { + let arr = Array::new(); + for (peer, span) in v.iter() { + let v = id_span_to_js(IdSpan { + peer: *peer, + counter: *span, + }); + arr.push(&v); + } + arr.into() + } + + let from = ids_to_frontiers(from)?; + let to = ids_to_frontiers(to)?; + let diff = self.0.find_id_spans_between(&from, &to); + let obj = Object::new(); + + js_sys::Reflect::set(&obj, &"retreat".into(), &id_span_vector_to_js(diff.retreat)).unwrap(); + js_sys::Reflect::set(&obj, &"forward".into(), &id_span_vector_to_js(diff.forward)).unwrap(); + let v: JsValue = obj.into(); + Ok(v.into()) + } + /// Checkout the `DocState` to a specific version. /// /// > The document becomes detached during a `checkout` operation. @@ -1401,7 +1487,7 @@ impl LoroDoc { /// /// @example /// ```ts - /// import { LoroDoc, LoroText } from "loro-crdt"; + /// import { LoroDoc, LoroText, LoroMap } from "loro-crdt"; /// /// const doc = new LoroDoc(); /// const list = doc.getList("list"); @@ -1841,6 +1927,18 @@ fn diff_event_to_js_value(event: DiffEvent, doc: &Arc) -> JsValue } Reflect::set(&obj, &"events".into(), &events.into()).unwrap(); + Reflect::set( + &obj, + &"from".into(), + &frontiers_to_ids(&event.event_meta.from).into(), + ) + .unwrap(); + Reflect::set( + &obj, + &"to".into(), + &frontiers_to_ids(&event.event_meta.to).into(), + ) + .unwrap(); obj.into() } @@ -2745,7 +2843,7 @@ impl LoroMap { /// /// @example /// ```ts - /// import { LoroDoc } from "loro-crdt"; + /// import { LoroDoc, LoroText } from "loro-crdt"; /// /// const doc = new LoroDoc(); /// doc.setPeerId("1"); @@ -4997,7 +5095,7 @@ interface LoroDoc { * const doc = new LoroDoc(); * const text = doc.getText("text"); * text.insert(0, "Hello"); - * text.mark(0, 2, {bold: true}); + * text.mark({ start: 0, end: 2 }, "bold", true); * * // Use delta to represent text * const json = doc.toJsonWithReplacer((key, value) => { @@ -5089,6 +5187,27 @@ export type Value = | Value[] | undefined; +export type IdSpan = { + peer: PeerID, + counter: number, + length: number, +} + +export type VersionVectorDiff = { + /** + * The spans that the `from` side needs to retreat to reach the `to` side + * + * These spans are included in the `from`, but not in the `to` + */ + retreat: IdSpan[], + /** + * The spans that the `from` side needs to forward to reach the `to` side + * + * These spans are included in the `to`, but not in the `from` + */ + forward: IdSpan[], +} + export type UndoConfig = { mergeInterval?: number, maxUndoSteps?: number, @@ -5453,6 +5572,8 @@ export interface LoroEventBatch { */ currentTarget?: ContainerID; events: LoroEvent[]; + from: Frontiers; + to: Frontiers; } /** diff --git a/crates/loro-wasm/tests/basic.test.ts b/crates/loro-wasm/tests/basic.test.ts index 608036d7e..97cf054be 100644 --- a/crates/loro-wasm/tests/basic.test.ts +++ b/crates/loro-wasm/tests/basic.test.ts @@ -15,6 +15,7 @@ import { Frontiers, encodeFrontiers, decodeFrontiers, + OpId, } from "../bundler/index"; import { ContainerID } from "loro-wasm"; @@ -1023,3 +1024,130 @@ it("export json in id span #602", () => { expect(changes).toStrictEqual([]); } }) + +it("find spans between versions", () => { + const doc = new LoroDoc(); + doc.setPeerId("1"); + + // Make some changes to create version history + doc.getText("text").insert(0, "Hello"); + doc.commit({ message: "a" }); + const f1 = doc.oplogFrontiers(); + + doc.getText("text").insert(5, " World"); + doc.commit({ message: "b" }); + const f2 = doc.oplogFrontiers(); + + // Test finding spans between frontiers (f1 -> f2) + let diff = doc.findIdSpansBetween(f1, f2); + expect(diff.retreat).toHaveLength(0); // No changes needed to go from f2 to f1 + expect(diff.forward).toHaveLength(1); // One change needed to go from f1 to f2 + expect(diff.forward[0]).toEqual({ + peer: "1", + counter: 5, + length: 6, + }); + + // Test empty frontiers + const emptyFrontiers: OpId[] = []; + diff = doc.findIdSpansBetween(emptyFrontiers, f2); + expect(diff.retreat).toHaveLength(0); // No changes needed to go from f2 to empty + expect(diff.forward).toHaveLength(1); // One change needed to go from empty to f2 + expect(diff.forward[0]).toEqual({ + peer: "1", + counter: 0, + length: 11, + }); + + // Test with multiple peers + const doc2 = new LoroDoc(); + doc2.setPeerId("2"); + doc2.getText("text").insert(0, "Hi"); + doc2.commit(); + doc.import(doc2.export({ mode: "snapshot" })); + const f3 = doc.oplogFrontiers(); + + // Test finding spans between f2 and f3 + diff = doc.findIdSpansBetween(f2, f3); + expect(diff.retreat).toHaveLength(0); // No changes needed to go from f3 to f2 + expect(diff.forward).toHaveLength(1); // One change needed to go from f2 to f3 + expect(diff.forward[0]).toEqual({ + peer: "2", + counter: 0, + length: 2, + }); + + // Test spans in both directions between f1 and f3 + diff = doc.findIdSpansBetween(f1, f3); + expect(diff.retreat).toHaveLength(0); // No changes needed to go from f3 to f1 + expect(diff.forward).toHaveLength(2); // Two changes needed to go from f1 to f3 + const forwardSpans = new Map(diff.forward.map(span => [span.peer, span])); + expect(forwardSpans.get("1")).toEqual({ + peer: "1", + counter: 5, + length: 6, + }); + expect(forwardSpans.get("2")).toEqual({ + peer: "2", + counter: 0, + length: 2, + }); + + // Test spans in reverse direction (f3 -> f1) + diff = doc.findIdSpansBetween(f3, f1); + expect(diff.forward).toHaveLength(0); // No changes needed to go from f3 to f1 + expect(diff.retreat).toHaveLength(2); // Two changes needed to go from f1 to f3 + const retreatSpans = new Map(diff.retreat.map(span => [span.peer, span])); + expect(retreatSpans.get("1")).toEqual({ + peer: "1", + counter: 5, + length: 6, + }); + expect(retreatSpans.get("2")).toEqual({ + peer: "2", + counter: 0, + length: 2, + }); +}); + +it("can travel changes from event", async () => { + const docA = new LoroDoc(); + docA.setPeerId("1"); + const docB = new LoroDoc(); + + docA.getText("text").update("Hello"); + docA.commit(); + const snapshot = docA.export({ mode: "snapshot" }); + let done = false; + docB.subscribe(e => { + const spans = docB.findIdSpansBetween(e.from, e.to); + expect(spans.retreat).toHaveLength(0); + expect(spans.forward).toHaveLength(1); + expect(spans.forward[0]).toEqual({ + peer: "1", + counter: 0, + length: 5, + }); + const changes = docB.exportJsonInIdSpan(spans.forward[0]); + expect(changes).toStrictEqual([{ + id: "0@1", + timestamp: expect.any(Number), + deps: [], + lamport: 0, + msg: undefined, + ops: [{ + container: "cid:root-text:Text", + counter: 0, + content: { + type: "insert", + pos: 0, + text: "Hello" + } + }] + }]); + done = true; + }); + docB.import(snapshot); + await Promise.resolve(); + expect(done).toBe(true); +}) diff --git a/crates/loro/src/lib.rs b/crates/loro/src/lib.rs index b2344864a..0f6f52470 100644 --- a/crates/loro/src/lib.rs +++ b/crates/loro/src/lib.rs @@ -937,6 +937,12 @@ impl LoroDoc { pub fn get_changed_containers_in(&self, id: ID, len: usize) -> FxHashSet { self.doc.get_changed_containers_in(id, len) } + + /// Find the operation id spans that between the `from` version and the `to` version. + #[inline] + pub fn find_id_spans_between(&self, from: &Frontiers, to: &Frontiers) -> VersionVectorDiff { + self.doc.find_id_spans_between(from, to) + } } /// It's used to prevent the user from implementing the trait directly. diff --git a/crates/loro/tests/loro_rust_test.rs b/crates/loro/tests/loro_rust_test.rs index 8948ec75d..d4c089a84 100644 --- a/crates/loro/tests/loro_rust_test.rs +++ b/crates/loro/tests/loro_rust_test.rs @@ -2653,3 +2653,90 @@ fn test_export_json_in_id_span_with_complex_operations() -> LoroResult<()> { Ok(()) } + +#[test] +fn test_find_spans_between() -> LoroResult<()> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + + // Make some changes to create version history + doc.get_text("text").insert(0, "Hello")?; + doc.set_next_commit_message("a"); + doc.commit(); + let f1 = doc.state_frontiers(); + + doc.get_text("text").insert(5, " World")?; + doc.set_next_commit_message("b"); + doc.commit(); + let f2 = doc.state_frontiers(); + + // Test finding spans between frontiers (f1 -> f2) + let diff = doc.find_id_spans_between(&f1, &f2); + assert!(diff.retreat.is_empty()); // No changes needed to go from f2 to f1 + assert_eq!(diff.forward.len(), 1); // One change needed to go from f1 to f2 + let span = diff.forward.get(&1).unwrap(); + assert_eq!(span.start, 5); // First change ends at counter 3 + assert_eq!(span.end, 11); // Second change ends at counter 6 + + // Test empty frontiers + let empty_frontiers = Frontiers::default(); + let diff = doc.find_id_spans_between(&empty_frontiers, &f2); + assert!(diff.retreat.is_empty()); // No changes needed to go from f2 to empty + assert_eq!(diff.forward.len(), 1); // One change needed to go from empty to f2 + let span = diff.forward.get(&1).unwrap(); + assert_eq!(span.start, 0); // From beginning + assert_eq!(span.end, 11); // To latest change + + // Test with multiple peers + let doc2 = LoroDoc::new(); + doc2.set_peer_id(2)?; + doc2.get_text("text").insert(0, "Hi")?; + doc2.commit(); + doc.import(&doc2.export_snapshot())?; + let f3 = doc.state_frontiers(); + + // Test finding spans between f2 and f3 + let diff = doc.find_id_spans_between(&f2, &f3); + assert!(diff.retreat.is_empty()); // No changes needed to go from f3 to f2 + assert_eq!(diff.forward.len(), 1); // One change needed to go from f2 to f3 + let span = diff.forward.get(&2).unwrap(); + assert_eq!(span.start, 0); + assert_eq!(span.end, 2); + + // Test spans in both directions between f1 and f3 + let diff = doc.find_id_spans_between(&f1, &f3); + assert!(diff.retreat.is_empty()); // No changes needed to go from f3 to f1 + assert_eq!(diff.forward.len(), 2); // Two changes needed to go from f1 to f3 + for (peer, span) in diff.forward.iter() { + match peer { + 1 => { + assert_eq!(span.start, 5); + assert_eq!(span.end, 11); + } + 2 => { + assert_eq!(span.start, 0); + assert_eq!(span.end, 2); + } + _ => panic!("Unexpected peer ID"), + } + } + + let diff = doc.find_id_spans_between(&f3, &f1); + assert!(diff.forward.is_empty()); // No changes needed to go from f3 to f1 + assert_eq!(diff.retreat.len(), 2); // Two changes needed to go from f1 to f3 + for (peer, span) in diff.retreat.iter() { + match peer { + 1 => { + assert_eq!(span.start, 5); + assert_eq!(span.end, 11); + } + 2 => { + assert_eq!(span.start, 0); + assert_eq!(span.end, 2); + } + _ => panic!("Unexpected peer ID"), + } + } + + Ok(()) +} diff --git a/scripts/deno.lock b/scripts/deno.lock index fc5ab161b..b4bfc47f8 100644 --- a/scripts/deno.lock +++ b/scripts/deno.lock @@ -7,7 +7,8 @@ "jsr:@std/toml@^1.0.1": "1.0.1", "npm:expect@29.7.0": "29.7.0", "npm:loro-crdt@1.0.7": "1.0.7", - "npm:loro-crdt@1.2.1": "1.2.1" + "npm:loro-crdt@1.2.1": "1.2.1", + "npm:loro-crdt@1.2.7": "1.2.7" }, "jsr": { "@std/collections@1.0.5": { @@ -260,6 +261,9 @@ "loro-crdt@1.2.1": { "integrity": "sha512-KP06MrpH2Yh2Tl7VHSCS78/ko575nVS5tEp/0NsIf4Q8XfJ0on+Zj6SVHnKpOq0iwKB7xt/mU2yVroe5jPr4SQ==" }, + "loro-crdt@1.2.7": { + "integrity": "sha512-eqVxUtLr7YBv5QaSW4obs01HIMzzJGfBC6MVnKHzMAEdaRitTP6Dj+BRvLFPqLEJthRtjohKez8wZgwzAUDiTg==" + }, "loro-wasm@1.0.7": { "integrity": "sha512-WFIpGGzc6I7zRMDoRGxa3AHhno7gVnOgwqcrTfmpKWOtktZQ7BvhIV4kYgsdyuIBcMSrQEJTfOY/80xQSjUKTw==" }, diff --git a/scripts/run-js-doc-tests.ts b/scripts/run-js-doc-tests.ts index 46e1a2983..7e5d68803 100644 --- a/scripts/run-js-doc-tests.ts +++ b/scripts/run-js-doc-tests.ts @@ -1,4 +1,4 @@ -const LORO_VERSION = "1.2.1"; +const LORO_VERSION = "1.2.7"; export interface CodeBlock { filename: string;