diff --git a/.changeset/loro-js-local-op-parity.md b/.changeset/loro-js-local-op-parity.md new file mode 100644 index 000000000..8cb0bf580 --- /dev/null +++ b/.changeset/loro-js-local-op-parity.md @@ -0,0 +1,8 @@ +--- +"loro.js": patch +--- + +`loro.js` now records the same local ops as `loro-crdt` for these API calls, so a document edited through either package gets the same op IDs and the same concurrent outcomes: + +- `LoroMap.delete(key)` records a delete even when the key is absent. Like in Rust, that delete can win against a concurrent `set` of the same key. +- `LoroCounter.increment(0)` records an increment, and attaching a detached counter records an increment by its value, even 0. diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index 81937856f..14afcb931 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -4026,8 +4026,8 @@ export class LoroDoc = Record = Record { expect(() => root.ensureMergeableMap("regular")).toThrow(/non-mergeable value/u); }); - test("does not record semantic no-op edits", () => { + // loro-crdt skips setting a map key to its current value, but records a + // delete of an absent key and a zero counter increment. Matching it keeps op + // IDs and concurrent outcomes the same for the same API calls. + test("records the same local ops as loro-crdt for no-op-looking edits", () => { const doc = new LoroDoc(); const map = doc.getMap("map"); map.set("value", { nested: [1, true, null] }); - const movable = doc.getMovableList("movable"); - movable.push("same"); - doc.commit(); - const opCount = doc.opCount(); - - map.set("value", { nested: [1, true, null] }); - map.delete("missing"); - movable.set(0, "same"); - doc.getCounter("counter").increment(0); doc.commit(); + const count = (edit: () => void): number => { + const before = doc.opCount(); + edit(); + doc.commit(); + return doc.opCount() - before; + }; - expect(doc.opCount()).toBe(opCount); + expect(count(() => map.set("value", { nested: [1, true, null] }))).toBe(0); + expect(count(() => map.delete("missing"))).toBe(1); + expect(count(() => doc.getCounter("counter").increment(0))).toBe(1); + // Attaching a detached counter increments it by its value, even 0. + expect(count(() => map.setContainer("child", new LoroCounter()))).toBe(2); + }); + + test("lets a delete of an absent map key win against a concurrent set", () => { + const a = new LoroDoc(); + a.setPeerId(1); + const b = new LoroDoc(); + b.setPeerId(2); + a.getMap("map").set("key", 1); + a.commit(); + b.getMap("map").delete("key"); + b.commit(); + a.import(b.export({ mode: "update" })); + b.import(a.export({ mode: "update" })); + // Same lamport; the larger peer's delete wins, as in loro-crdt. + expect(a.toJSON()).toEqual({ map: {} }); + expect(b.toJSON()).toEqual({ map: {} }); }); test("resurfaces preserved state and switches mergeable kinds", () => {