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
5 changes: 5 additions & 0 deletions .changeset/loro-js-detached-snapshot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"loro.js": patch
---

`export({ mode: "snapshot" })` on a detached `loro.js` document now encodes the latest state, version, and frontiers, as Rust and `loro-crdt` do. This includes a document imported lazily from a snapshot that received updates while detached. Before, the export wrote the checked-out state and version next to the full history, so an importer saw an old state as the latest version and a later checkout failed. The exporting document keeps its checkout.
54 changes: 49 additions & 5 deletions loro-js/src/runtime/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5247,7 +5247,13 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
if (this.isShallow()) {
return this.#encodeShallowSnapshot(this.shallowSinceFrontiers());
}
// A snapshot always carries the latest state, as in Rust. A detached
// document's state (lazy or not) may be at an older version or may miss
// updates imported while detached.
const latestVersion = this.#historyVersion();
const detachedState = this.version().compare(latestVersion) !== 0;
if (
!detachedState &&
this.#deferredSnapshotHistory !== undefined &&
this.#deferredSnapshotState !== undefined
) {
Expand All @@ -5272,11 +5278,13 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
historyEntries.push(
{
key: VERSION_KEY,
value: encodePostcardVersionVector(this.version().codecEntries()),
value: encodePostcardVersionVector(latestVersion.codecEntries()),
},
{
key: FRONTIERS_KEY,
value: encodePostcardFrontiers(this.#frontiersCodec()),
value: encodePostcardFrontiers(
[...this.#historyFrontiers.values()].sort(compareIds),
),
},
);
const body = encodeFastSnapshotBody({
Expand All @@ -5298,20 +5306,56 @@ export class LoroDoc<T extends Record<string, Container> = Record<string, Contai
}));
historyEntries.push({
key: VERSION_KEY,
value: encodePostcardVersionVector(this.version().codecEntries()),
value: encodePostcardVersionVector(latestVersion.codecEntries()),
});
historyEntries.push({
key: FRONTIERS_KEY,
value: encodePostcardFrontiers(this.#frontiersCodec()),
value: encodePostcardFrontiers(
[...this.#historyFrontiers.values()].sort(compareIds),
),
});
const state = encodeStateSnapshotStore(
detachedState ? this.#latestStateStore() : this.#buildStateStore(),
{ compression: "auto" },
);
const body = encodeFastSnapshotBody({
oplog: encodeSstable(historyEntries, { compression: "auto" }),
state: encodeStateSnapshotStore(this.#buildStateStore(), { compression: "auto" }),
state,
shallowRootState: new Uint8Array(),
});
return encodeDocument(EncodeMode.FastSnapshot, body);
}

/**
* The latest state of a detached document, without changing its checkout.
* Like `diff`, it transitions to the latest version and back when the
* version delta allows it (O(delta)). Otherwise, including for lazily
* imported state that `#buildStateStore` cannot enumerate, it replays the
* history once on an isolated staging document.
*/
#latestStateStore(): StateSnapshotStore {
const current = this.version();
const latest = this.#historyVersion();
if (this.#deferredSnapshotState === undefined) {
const forward = this.#recordsInVersionRange(current, latest);
const retreat = this.#recordsInVersionRange(latest, current);
const mode = movableMoveTransitionMode(retreat, forward, this.#movableMovePeers);
if (this.#canTransitionRecords([...retreat, ...forward], mode)) {
let restored = false;
try {
this.#applyVersionTransition(retreat, forward, latest, undefined, mode);
const store = this.#buildStateStore();
this.#applyVersionTransition(forward, retreat, current, undefined, mode);
restored = true;
return store;
} finally {
if (!restored) this.#rebuildFromHistory(current);
}
}
}
return this.forkAt(this.oplogFrontiers()).#buildStateStore();
}

#encodeDeferredSnapshotState(): Uint8Array {
const deferred = this.#deferredSnapshotState;
if (deferred === undefined) {
Expand Down
88 changes: 88 additions & 0 deletions loro-js/tests/detached-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, test } from "vitest";

import { LoroDoc, LoroText } from "../src/index";

function buildHistory() {
const doc = new LoroDoc();
doc.setPeerId(1);
const map = doc.getMap("map");
map.set("a", 1);
map.setContainer("text", new LoroText()).insert(0, "hello");
doc.getList("list").push(1);
doc.commit();
const first = doc.frontiers();
map.set("a", 2);
(map.get("text") as LoroText).insert(5, " world");
(map.get("text") as LoroText).delete(0, 1);
doc.getList("list").push(2);
doc.commit();
return { doc, first, latest: doc.frontiers(), latestValue: doc.toJSON() };
}

describe("snapshot export from a detached document", () => {
test("encodes the latest state and keeps the checkout", () => {
const { doc, first, latest, latestValue } = buildHistory();
doc.checkout(first);
const firstValue = doc.toJSON();
const text = doc.getMap("map").get("text") as LoroText;

const bytes = doc.export({ mode: "snapshot" });
expect(doc.isDetached()).toBe(true);
expect(doc.frontiers()).toEqual(first);
expect(doc.toJSON()).toEqual(firstValue);
expect(text.toString()).toBe("hello");
doc.checkoutToLatest();
expect(doc.toJSON()).toEqual(latestValue);
doc.checkout(first);

const loaded = new LoroDoc();
loaded.import(bytes);
expect(loaded.toJSON()).toEqual(latestValue);
expect(loaded.frontiers()).toEqual(latest);
expect(loaded.oplogFrontiers()).toEqual(latest);
// Retreating snapshot-imported state over a delete is covered by the
// snapshot-checkout tests; here, check that history reaches the latest.
loaded.checkout(latest);
expect(loaded.toJSON()).toEqual(latestValue);
});

test("includes updates imported while detached into a lazily imported document", () => {
const source = new LoroDoc();
source.setPeerId(1);
source.getMap("m").set("x", 1);
source.commit();
const version = source.oplogVersion();
const target = new LoroDoc();
target.import(source.export({ mode: "snapshot" }));
target.detach();
source.getMap("m").set("x", 2);
source.commit();
target.import(source.export({ mode: "update", from: version }));

const loaded = new LoroDoc();
loaded.import(target.export({ mode: "snapshot" }));
expect(loaded.toJSON()).toEqual({ m: { x: 2 } });
expect(loaded.oplogFrontiers()).toEqual(source.frontiers());
loaded.checkout(source.frontiers());
expect(loaded.toJSON()).toEqual({ m: { x: 2 } });
// The exporting document keeps its detached state.
expect(target.isDetached()).toBe(true);
expect(target.toJSON()).toEqual({ m: { x: 1 } });
});

test("includes updates imported while detached", () => {
const { doc, first } = buildHistory();
const other = new LoroDoc();
other.setPeerId(2);
other.import(doc.export({ mode: "update" }));
other.getMap("map").set("b", 3);
other.commit();

doc.checkout(first);
doc.import(other.export({ mode: "update", from: doc.oplogVersion() }));
const loaded = new LoroDoc();
loaded.import(doc.export({ mode: "snapshot" }));
expect(loaded.toJSON()).toEqual(other.toJSON());
expect(loaded.oplogFrontiers()).toEqual(other.oplogFrontiers());
});
});
Loading