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
This commit is contained in:
Zixuan Chen 2025-01-06 11:15:10 +08:00 committed by GitHub
parent ac51ceb2f9
commit 8039e446a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 440 additions and 64 deletions

View file

@ -0,0 +1,5 @@
---
"loro-crdt": minor
---
feat: find id spans between #607

View file

@ -133,8 +133,8 @@ pub struct VersionVectorDiff {
impl From<loro::VersionVectorDiff> 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(),
}
}
}

View file

@ -99,7 +99,6 @@ impl<T: Dag + ?Sized> 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<T: Dag + ?Sized> 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<T: Dag + ?Sized> 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<T: Dag + ?Sized> 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),
);

View file

@ -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")]

View file

@ -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)]

View file

@ -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;

View file

@ -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<Item = IdSpan> + '_ {
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<Item = IdSpan> + '_ {
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,

View file

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

View file

@ -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<JsID>,
to: Vec<JsID>,
) -> JsResult<JsVersionVectorDiff> {
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<LoroDocInner>) -> 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;
}
/**

View file

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

View file

@ -937,6 +937,12 @@ impl LoroDoc {
pub fn get_changed_containers_in(&self, id: ID, len: usize) -> FxHashSet<ContainerID> {
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.

View file

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

View file

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

View file

@ -1,4 +1,4 @@
const LORO_VERSION = "1.2.1";
const LORO_VERSION = "1.2.7";
export interface CodeBlock {
filename: string;