Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/loro-js-local-op-parity.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions loro-js/src/runtime/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4026,8 +4026,8 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
}

_mapDelete(container: LoroMap, key: string): void {
const current = container._entries.get(key);
if (current === undefined || current.deleted) return;
// Rust records a delete even when the key is absent: it can still win
// against a concurrent set.
this.#appendAndApply(container, { type: "map-delete", key }, 1);
}

Expand Down Expand Up @@ -4180,7 +4180,6 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
}

_counterIncrement(container: LoroCounter, value: number): void {
if (value === 0) return;
this.#appendAndApply(
container,
{ type: "future", property: 0, value: { type: "double", value } },
Expand Down Expand Up @@ -10223,7 +10222,8 @@ function restoreBlueprint(container: Container, blueprint: ContainerBlueprint):
} else if (container instanceof LoroText) {
container.applyDelta(blueprint.value as never);
} else if (container instanceof LoroCounter) {
if ((blueprint.value as number) !== 0) container.increment(blueprint.value as number);
// Rust's CounterHandler::attach increments by the detached value, even 0.
container.increment(blueprint.value as number);
} else if (container instanceof LoroList) {
for (const value of blueprint.value as unknown[]) {
if (isContainer(value)) container.pushContainer(value);
Expand Down
42 changes: 31 additions & 11 deletions loro-js/tests/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1726,22 +1726,42 @@ describe("loro-wasm-compatible runtime", () => {
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", () => {
Expand Down
Loading