loro-mirror
Version:
Type-safe state management synchronized with Loro CRDT via a declarative schema and bidirectional mirroring.
726 lines (619 loc) • 23.1 kB
text/typescript
/* eslint-disable unicorn/consistent-function-scoping */
import { describe, it, expect } from "vitest";
import { LoroDoc, LoroText, LoroList, LoroMap, LoroCounter } from "loro-crdt";
import { applyEventBatchToState } from "./loroEventApply";
const commitAndAssert = (doc: LoroDoc, getState: () => unknown) => {
doc.commit();
expect(getState()).toEqual(doc.toJSON());
};
describe("applyEventBatchToState (inline)", () => {
it("syncs map primitives", () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const m = doc.getMap("m");
m.set("a", 1);
commitAndAssert(doc, () => state);
m.set("b", 2);
commitAndAssert(doc, () => state);
m.delete("a");
commitAndAssert(doc, () => state);
unsub();
});
it("syncs list operations", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const l = doc.getList("l");
l.insert(0, "x");
await commitAndAssert(doc, () => state);
l.insert(1, "y");
await commitAndAssert(doc, () => state);
l.delete(0, 1);
await commitAndAssert(doc, () => state);
unsub();
});
it("syncs text updates", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const t = doc.getText("t");
t.update("Hello");
await commitAndAssert(doc, () => state);
t.update("Hello World");
await commitAndAssert(doc, () => state);
unsub();
});
it("syncs nested container in map (text)", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const m = doc.getMap("m");
const inner = new LoroText();
inner.update("Hi");
m.setContainer("inner", inner);
await commitAndAssert(doc, () => state);
inner.update("Hello");
await commitAndAssert(doc, () => state);
unsub();
});
it("preserves null values and supports deletes", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const m = doc.getMap("m");
// Setting null should persist as a normal value
m.set("a", null);
await commitAndAssert(doc, () => state);
// Setting a value then deleting it removes the key
m.set("a", 42);
await commitAndAssert(doc, () => state);
m.delete("a");
await commitAndAssert(doc, () => state);
unsub();
});
it("syncs nested container in list (text)", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const list = doc.getList("list");
const text = list.insertContainer(0, new LoroText());
text.update("Item 0");
await commitAndAssert(doc, () => state);
list.insert(1, "plain");
await commitAndAssert(doc, () => state);
text.update("Item 0 updated");
await commitAndAssert(doc, () => state);
unsub();
});
it("syncs movable list moves", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const ml = doc.getMovableList("ml");
ml.push("a");
ml.push("b");
ml.push("c");
await commitAndAssert(doc, () => state);
ml.move(0, 2); // [b, c, a]
await commitAndAssert(doc, () => state);
unsub();
});
it("handles deep nested paths (map -> list -> map)", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const rootMap = doc.getMap("root");
const list = rootMap.setContainer("list", new LoroList());
const innerMap = list.insertContainer(0, new LoroMap());
innerMap.set("k", 1);
await commitAndAssert(doc, () => state);
innerMap.set("k", 2);
await commitAndAssert(doc, () => state);
unsub();
});
it("batches multiple container diffs in one commit", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const m = doc.getMap("m");
const t = doc.getText("t");
const l = doc.getList("l");
// Multiple operations before a single commit should arrive as one batch
m.set("a", 1);
t.update("hello");
l.push("x");
doc.commit();
await Promise.resolve();
expect(state).toEqual(doc.toJSON());
unsub();
});
it("syncs counter increments", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const c = doc.getCounter("count");
c.increment(5);
await commitAndAssert(doc, () => state);
c.decrement(2);
await commitAndAssert(doc, () => state);
unsub();
});
it("map.clear and list.clear propagate", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const m = doc.getMap("m");
m.set("a", 1);
m.set("b", 2);
const l = doc.getList("l");
l.push(1);
l.push(2);
await commitAndAssert(doc, () => state);
m.clear();
l.clear();
await commitAndAssert(doc, () => state);
unsub();
});
it("movable list set/replace and delete in same commit", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const ml = doc.getMovableList("ml");
ml.push("x");
ml.push("y");
ml.push("z");
await commitAndAssert(doc, () => state);
// Replace middle with a container and delete last in one commit
const t = new LoroText();
t.update("middle");
ml.setContainer(1, t);
ml.delete(2, 1);
await commitAndAssert(doc, () => state);
unsub();
});
it("nested counter inside map and list", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const root = doc.getMap("root");
const cnt = root.setContainer("cnt", new LoroCounter());
const lst = root.setContainer("lst", new LoroList());
const cnt2 = lst.insertContainer(0, new LoroCounter());
cnt.increment(3);
cnt2.increment(7);
await commitAndAssert(doc, () => state);
cnt.decrement(1);
cnt2.decrement(2);
await commitAndAssert(doc, () => state);
unsub();
});
it("list: multiple container inserts then edits in one batch", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const l = doc.getList("l");
const t0 = l.insertContainer(0, new LoroText());
const t1 = l.insertContainer(1, new LoroText());
t0.update("A");
t1.update("B");
await commitAndAssert(doc, () => state);
t0.update("AA");
t1.update("BB");
await commitAndAssert(doc, () => state);
unsub();
});
it("text complex edits: splice, delete, applyDelta, mark/unmark", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
const t = doc.getText("t");
t.insert(0, "Hello");
t.splice(5, 0, " World");
await commitAndAssert(doc, () => state);
t.delete(0, 1); // remove 'H'
await commitAndAssert(doc, () => state);
t.applyDelta([{ retain: 0 }, { insert: "Start: " }]);
await commitAndAssert(doc, () => state);
// Mark/unmark shouldn't change string content
doc.configTextStyle({ bold: { expand: "after" } });
t.mark({ start: 0, end: 3 }, "bold", true);
t.unmark({ start: 0, end: 3 }, "bold");
await commitAndAssert(doc, () => state);
unsub();
});
it("import updates apply via event (by: import)", async () => {
const a = new LoroDoc();
const b = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = b.subscribe((batch) => {
state = applyEventBatchToState(state, batch, (id) =>
b.getContainerById(id),
);
});
// author edits on a
const m = a.getMap("m");
const l = a.getList("l");
const t = a.getText("t");
m.set("x", 1);
l.push("a");
l.push("b");
t.update("hello");
a.commit();
const updates = a.export({ mode: "update" });
b.import(updates);
await Promise.resolve();
expect(state).toEqual(b.toJSON());
unsub();
});
it("setting a map key to same value is a no-op (no divergence)", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b);
});
const m = doc.getMap("m");
m.set("k", 1);
await commitAndAssert(doc, () => state);
// Setting the same value should not emit changes; state should remain in sync
m.set("k", 1);
doc.commit();
await Promise.resolve();
expect(state).toEqual(doc.toJSON());
unsub();
});
it("map: container replaced with primitive in same commit", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b);
});
const m = doc.getMap("m");
const txt = new LoroText();
txt.update("A");
m.setContainer("k", txt);
// Replace with primitive before commit
m.set("k", "B");
await commitAndAssert(doc, () => state);
unsub();
});
it("map: primitive replaced with container in same commit", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b);
});
const m = doc.getMap("m");
m.set("k", "B");
const txt = new LoroText();
txt.update("C");
m.setContainer("k", txt);
await commitAndAssert(doc, () => state);
unsub();
});
it("list: insert container then delete it in same commit (no residual)", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
state = applyEventBatchToState(state, b);
});
const l = doc.getList("l");
const t = new LoroText();
t.update("x");
l.insertContainer(0, t);
l.delete(0, 1);
await commitAndAssert(doc, () => state);
unsub();
});
it("random fuzz maintains mirrored state", async () => {
const doc = new LoroDoc();
let state: Record<string, unknown> = {};
const unsub = doc.subscribe((b) => {
// console.log("state", JSON.stringify(state, null, 2));
// console.log("batch", JSON.stringify(b, null, 2));
state = applyEventBatchToState(state, b, (id) =>
doc.getContainerById(id),
);
});
// Seeded PRNG for reproducibility
/* eslint-disable unicorn/consistent-function-scoping */
function mulberry32(seed: number) {
return function () {
let t = (seed += 0x6d2b79f5) | 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rnd = mulberry32(0xdecafbad);
const rand = (n: number) => Math.floor(rnd() * n);
const chance = (p: number) => rnd() < p;
const randStr = () =>
Array.from({ length: rand(6) + 1 }, () =>
String.fromCharCode(97 + rand(26)),
).join("");
// Root containers
const maps = [doc.getMap("m0"), doc.getMap("m1")];
const lists = [doc.getList("l0"), doc.getList("l1")];
const mlist = doc.getMovableList("ml0");
const texts = [doc.getText("t0"), doc.getText("t1")];
const counters = [doc.getCounter("c0"), doc.getCounter("c1")];
// Track nested containers for later mutations
const nestedTexts: LoroText[] = [];
const nestedLists: LoroList[] = [];
const nestedMaps: LoroMap[] = [];
const nestedCounters: LoroCounter[] = [];
const mapSetPrimitive = () => {
const m = maps[rand(maps.length)];
const key = `k${rand(6)}`;
const valueTypes = [
() => rand(100),
() => randStr(),
() => chance(0.5),
() => null,
];
const v = valueTypes[rand(valueTypes.length)]();
m.set(key, v as any);
};
const mapDelete = () => {
const m = maps[rand(maps.length)];
if (m.isDeleted()) {
return;
}
const keys = m.keys();
if (keys.length === 0) return;
const k = keys[rand(keys.length)];
m.delete(k);
};
const mapSetContainer = () => {
const m = maps[rand(maps.length)];
if (m.isDeleted()) {
return;
}
const key = `c${rand(6)}`;
const which = rand(4);
if (which === 0) {
const t = new LoroText();
m.setContainer(key, t);
if (chance(0.8)) t.update(randStr());
nestedTexts.push(t);
} else if (which === 1) {
const l = new LoroList();
m.setContainer(key, l);
if (chance(0.8)) l.push(randStr());
nestedLists.push(l);
} else if (which === 2) {
const mm = new LoroMap();
m.setContainer(key, mm);
if (chance(0.8)) mm.set("x", rand(10));
nestedMaps.push(mm);
} else {
const c = new LoroCounter();
m.setContainer(key, c);
if (chance(0.8)) c.increment(rand(5) + 1);
nestedCounters.push(c);
}
};
const listOp = () => {
const isMovable = chance(0.3);
const list = isMovable ? mlist : lists[rand(lists.length)];
if (list.isDeleted()) {
return;
}
const len = list.length;
const doWhat = rand(isMovable ? 4 : 3);
if (doWhat === 0) {
// insert primitive
const idx = rand(len + 1);
list.insert(idx, chance(0.5) ? randStr() : rand(100));
} else if (doWhat === 1) {
// insert container
const idx = rand(len + 1);
const pick = rand(3);
if (pick === 0) {
const t = list.insertContainer(idx, new LoroText());
if (chance(0.8)) t.update(randStr());
nestedTexts.push(t);
} else if (pick === 1) {
const l2 = list.insertContainer(idx, new LoroList());
if (chance(0.8)) l2.push(randStr());
nestedLists.push(l2);
} else {
const m2 = list.insertContainer(idx, new LoroMap());
if (chance(0.8)) m2.set("z", rand(10));
nestedMaps.push(m2);
}
} else if (doWhat === 2) {
// delete
if (len > 0) {
const idx = rand(len);
list.delete(idx, 1);
}
} else {
// move (movable only)
if (len > 1 && "move" in (list as any)) {
const from = rand(len);
let to = rand(len);
if (to === from) to = (to + 1) % len;
(list as any).move(from, to);
}
}
};
const textOp = () => {
const t = chance(0.5)
? texts[rand(texts.length)]
: nestedTexts[rand(nestedTexts.length)] || texts[0];
if (t.isDeleted()) {
return;
}
const s = t.toString();
const kind = rand(3);
if (kind === 0) {
const pos = rand(s.length + 1);
t.insert(pos, randStr());
} else if (kind === 1) {
if (s.length > 0) {
const pos = rand(s.length);
const del = Math.min(s.length - pos, 1 + rand(3));
t.delete(pos, del);
}
} else {
t.update(randStr());
}
};
const counterOp = () => {
const c = chance(0.5)
? counters[rand(counters.length)]
: nestedCounters[rand(nestedCounters.length)] || counters[0];
if (doc.getPathToContainer(c.id) == null) {
return;
}
const delta = (rand(7) + 1) * (chance(0.5) ? 1 : -1);
if (delta >= 0) c.increment(delta);
else c.decrement(-delta);
};
const nestedMapOp = () => {
if (nestedMaps.length === 0) return;
const mm = nestedMaps[rand(nestedMaps.length)];
if (mm.isDeleted()) {
return;
}
if (chance(0.5)) mm.set(`n${rand(5)}`, rand(10));
else {
const ks = mm.keys();
if (ks.length) mm.delete(ks[rand(ks.length)]);
}
};
const nestedListOp = () => {
if (nestedLists.length === 0) return;
const l = nestedLists[rand(nestedLists.length)];
if (l.isDeleted()) {
return;
}
const len = l.length;
if (chance(0.5)) l.insert(rand(len + 1), randStr());
else if (len > 0) l.delete(rand(len), 1);
};
// Perform random ops in random-sized commits
const commits = 1000;
for (let c = 0; c < commits; c++) {
const ops = 1 + rand(5);
for (let i = 0; i < ops; i++) {
const pick = rand(7);
switch (pick) {
case 0:
mapSetPrimitive();
break;
case 1:
mapDelete();
break;
case 2:
mapSetContainer();
break;
case 3:
listOp();
break;
case 4:
textOp();
break;
case 5:
counterOp();
break;
default:
// mutate nested content occasionally
if (chance(0.5)) nestedMapOp();
else nestedListOp();
}
}
// Commit this batch and validate
doc.commit();
await Promise.resolve();
// console.log(JSON.stringify({ state, doc: doc.toJSON(), updates: doc.exportJsonUpdates() }, null, 2));
expect(normalize(state)).toEqual(normalize(doc.toJSON()));
}
unsub();
});
});
function normalize(i: Record<string, unknown>): Record<string, unknown> {
const s = JSON.parse(JSON.stringify(i));
for (const [k, v] of Object.entries(s)) {
if (Array.isArray(v)) {
if (v.length === 0) {
delete s[k];
}
} else if (typeof v === "object" && v !== null) {
if (Object.keys(v).length === 0) {
delete s[k];
}
} else if (typeof v === "number") {
if (v === 0) {
delete s[k];
}
} else if (typeof v === "string") {
if (v === "") {
delete s[k];
}
}
}
return s;
}