Environment
loro-crdt 1.13.6 (JS; reproduces identically with the nodejs and bundler builds)
- Node.js v22.13.1, also reproduced inside Cloudflare
workerd
Summary
Importing a shallow snapshot into a document that already has any operation concurrent to the snapshot's root silently buffers the whole import as pending — toJSON() stays empty. The reported missing dependency is the snapshot's own root boundary op, i.e. content that is contained in the very snapshot being imported; nothing short of re-receiving the same history as a full mode: "update" export unblocks it. The same snapshot bytes import fine into a pristine (no local ops) document.
Surprisingly, the behavior depends on the shape of the frontier the snapshot is rooted at:
| Importer state |
Shallow root: single-head frontier |
Shallow root: multi-head frontier |
| pristine doc (no ops) |
✅ applies |
✅ applies |
| doc with one concurrent commit |
❌ stuck pending forever |
✅ applies |
If the single-head case is intended (the docs say updates concurrent to the shallow start version cannot be imported), the multi-head case succeeding looks inconsistent — and either way the failure mode is a silent permanent pending rather than an error, which is very hard to detect.
Minimal reproduction
// node --input-type=module repro.mjs (loro-crdt 1.13.6)
import { LoroDoc } from "loro-crdt";
const pend = (st) => (st.pending === null ? "null" : JSON.stringify([...st.pending.entries()]));
const freshWithCommit = (peer) => {
// e.g. every new client of a sync system does its own "schema init" commit
const d = new LoroDoc();
d.setPeerId(peer);
d.getMap("meta").set("schemaVersion", 1);
d.commit();
return d;
};
// ── Case 1: SINGLE-head root — client has SEEN server history, edits on top ──
const server1 = new LoroDoc();
server1.setPeerId(1);
server1.getMap("meta").set("schemaVersion", 1);
server1.commit();
const client1 = new LoroDoc();
client1.setPeerId(2);
client1.import(server1.export({ mode: "update" })); // client knows server history
client1.getTree("nodes").createNode();
client1.commit();
server1.import(client1.export({ mode: "update" }));
console.log("frontiers:", JSON.stringify(server1.frontiers())); // ONE head: [{peer:"2",counter:0}]
const shallowSingle = server1.export({ mode: "shallow-snapshot", frontiers: server1.frontiers() });
const f1 = freshWithCommit(3);
const st1 = f1.import(shallowSingle);
console.log("single-head → nodes:", f1.getTree("nodes").getNodes().length, "| pending:", pend(st1));
// single-head → nodes: 0 | pending: [["2",{"start":0,"end":1}]] ← stuck; the "missing" op
// is the snapshot's own head (peer 2), which is inside the snapshot being imported
const pristine = new LoroDoc();
pristine.setPeerId(4);
console.log("pristine → nodes:", pristine.import(shallowSingle).pending === null
? pristine.getTree("nodes").getNodes().length : "pending");
// pristine → nodes: 1 ← same bytes import fine
// ── Case 2: MULTI-head root — client history is CONCURRENT to the server commit ──
const server2 = new LoroDoc();
server2.setPeerId(5);
server2.getMap("meta").set("schemaVersion", 1);
server2.commit();
const client2 = new LoroDoc();
client2.setPeerId(6);
client2.getMap("meta").set("schemaVersion", 1); // concurrent, never saw server
client2.commit();
client2.getTree("nodes").createNode();
client2.commit();
server2.import(client2.export({ mode: "update" }));
console.log("frontiers:", JSON.stringify(server2.frontiers())); // TWO heads
const shallowMulti = server2.export({ mode: "shallow-snapshot", frontiers: server2.frontiers() });
const f2 = freshWithCommit(7);
const st2 = f2.import(shallowMulti);
console.log("multi-head → nodes:", f2.getTree("nodes").getNodes().length, "| pending:", pend(st2));
// multi-head → nodes: 1 | pending: null ← works
Observed output (Node v22.13.1, loro-crdt 1.13.6):
frontiers: [{"peer":"2","counter":0}]
single-head → nodes: 0 | pending: [["2",{"start":0,"end":1}]]
pristine → nodes: 1
frontiers: [{"peer":"6","counter":1},{"peer":"5","counter":0}]
multi-head → nodes: 1 | pending: null
Real-world impact
A sync server that answers a new client's handshake with export({ mode: "shallow-snapshot", frontiers: doc.frontiers() }) silently delivers an empty document to any client that has already made a single local commit (e.g. schema-version initialization) whenever the workspace frontier has one head — which is the common case in a live workspace (the last writer's op causally dominates the history). Data is intact on the server; the client just never applies the payload and nothing reports an error.
Our workaround: before sending a shallow snapshot, the server probes it by importing into a scratch doc that mimics a fresh client (one local commit) and falls back to export({ mode: "update" }) when the probe import returns non-null pending.
Questions
- Is the single-head behavior intended? If yes, could
import() surface it distinctly (error or a dedicated status) instead of a silent permanent pending, and could the docs state that a shallow snapshot rooted at a single-head frontier is only importable into pristine docs?
- Why does a multi-head root import cleanly into a doc with concurrent ops? If that is the intended merge path, could the single-head case behave the same?
Environment
loro-crdt1.13.6 (JS; reproduces identically with thenodejsandbundlerbuilds)workerdSummary
Importing a shallow snapshot into a document that already has any operation concurrent to the snapshot's root silently buffers the whole import as
pending—toJSON()stays empty. The reported missing dependency is the snapshot's own root boundary op, i.e. content that is contained in the very snapshot being imported; nothing short of re-receiving the same history as a fullmode: "update"export unblocks it. The same snapshot bytes import fine into a pristine (no local ops) document.Surprisingly, the behavior depends on the shape of the frontier the snapshot is rooted at:
pendingforeverIf the single-head case is intended (the docs say updates concurrent to the shallow start version cannot be imported), the multi-head case succeeding looks inconsistent — and either way the failure mode is a silent permanent
pendingrather than an error, which is very hard to detect.Minimal reproduction
Observed output (Node v22.13.1, loro-crdt 1.13.6):
Real-world impact
A sync server that answers a new client's handshake with
export({ mode: "shallow-snapshot", frontiers: doc.frontiers() })silently delivers an empty document to any client that has already made a single local commit (e.g. schema-version initialization) whenever the workspace frontier has one head — which is the common case in a live workspace (the last writer's op causally dominates the history). Data is intact on the server; the client just never applies the payload and nothing reports an error.Our workaround: before sending a shallow snapshot, the server probes it by importing into a scratch doc that mimics a fresh client (one local commit) and falls back to
export({ mode: "update" })when the probe import returns non-nullpending.Questions
import()surface it distinctly (error or a dedicated status) instead of a silent permanentpending, and could the docs state that a shallow snapshot rooted at a single-head frontier is only importable into pristine docs?