diff --git a/.changeset/loro-js-snapshot-checkout.md b/.changeset/loro-js-snapshot-checkout.md new file mode 100644 index 000000000..a0d6554d6 --- /dev/null +++ b/.changeset/loro-js-snapshot-checkout.md @@ -0,0 +1,5 @@ +--- +"loro.js": patch +--- + +Fix checkout and `diff` on a `loro.js` document imported from a snapshot. Checking out an earlier version could return the latest value of a child container that had not been read yet, restore nothing for elements deleted before the snapshot, skip a delete when moving forward again, or throw `duplicate sequence id`. A transition now rebuilds only the Text and List containers it touches from their own history, once. Rust-created rich text whose history `loro.js` cannot replay keeps its snapshot state instead, so its latest state and exports no longer change after a checkout; its earlier versions still cannot restore text deleted before the snapshot. Checkout events for containers that had not been read are now correct, and a snapshot exported after history was loaded no longer drops containers that had not been read. A character deleted concurrently by two peers is no longer deleted twice during checkout, and a checkout or `diff` that throws leaves the document unchanged. diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 060459793..061a08a23 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -3,14 +3,29 @@ name: Test All on: push: branches: ["main"] + # Any base branch, so stacked PRs are tested too. pull_request: - branches: ["main", "dev", "feat-gc"] types: [opened, synchronize, reopened, ready_for_review] env: CARGO_TERM_COLOR: always jobs: + loro-js: + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - name: Setup Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: 22.x + - run: pnpm install --frozen-lockfile + - name: Check loro.js + run: pnpm -C loro-js check build: runs-on: macos-15 if: github.event.pull_request.draft == false diff --git a/context/loro-js-performance.md b/context/loro-js-performance.md index 896482166..1ea6a0e95 100644 --- a/context/loro-js-performance.md +++ b/context/loro-js-performance.md @@ -1,6 +1,6 @@ # loro-js Performance Architecture -Verified against code 2026-07-22. +Verified against code 2026-09-28. The pure TypeScript runtime lives in `loro-js/src/runtime`. Its performance target is the asymptotic behavior of the Rust runtime, while accepting a larger @@ -127,6 +127,116 @@ JavaScript constant factor. staging document before installing them. Import subscribers retain eager state hydration because their import event must describe every changed container. +- Text, List, and MovableList state hydrated from a snapshot (eager, lazy, or a + shallow root) has no tombstones, deletion index, or style, value, and move + history for the snapshot's operations. `LoroDoc.#snapshotSequences` records + each such container with its snapshot version. Before `checkout`, + `checkoutToLatest`, `diff`, or a detached snapshot export + (`#encodeLatestState`) transitions, `#prepareSnapshotTransition` looks only + at the containers the transition + touches: it hydrates lazily encoded ones (an untouched lazy container still + holds its latest state, which is its state at the current version) and, when + the transition crosses a snapshot operation, rebuilds that one container from + its own operations (`#completeSnapshotSequence`, from its shallow root entry + in a shallow document). Operations applied after hydration are indexed like + any others, and Map, Tree, and Counter state needs no rebuild. The per-container + record index is built once per history revision. Consecutive text inserts that + continue each other replay as one span (`coalescedTextInsert`). Unrelated + containers are never replayed and the lazy SSTable is kept, so snapshot export + copies every untouched entry and rewrites only the touched ones. A delete + transition is still refused unless the deletion index recorded that delete, + for example one imported while detached. +- loro.js does not count Rust rich-text style anchors in Text operation + positions, so a replay of Rust-created styled text can differ from its + snapshot state (for example, an insert right after a mark's end anchor). The + completion therefore compares the replay of a Text or List with the snapshot + state (visible ids, plus the delta when styled). When they differ, the replay + is discarded and the container becomes `unreplayable`: it keeps its snapshot + state, encoded once, and is never given a replay. A transition that touches + it runs without its operations and then moves it separately + (`#planSnapshotStates`): when the installed state already has every forward + operation (tracked as `applied`), a Text is toggled by id and style version, + O(delta) as for a state without history; otherwise (for example an update + imported while detached) it is rebuilt from the snapshot state plus the later + operations the target includes (`#rebuildFromSnapshotState`, O(container + size + its operations)). Events come from the transition's recording, or from + whole-container values when style operations are crossed, since their ranges + come from positions. +- What that guarantees: the latest state, imports, and exports equal the + snapshot state plus the later operations, as loro.js applies them. A later + operation concurrent with a delete that the snapshot already applied can + still land at another position than in Rust, as on main (loro-dev/loro#1163). + An older version is approximate: the snapshot state cannot restore text + deleted before it. In the round-3 review's random Rust histories, older + versions and `revertTo` differed from Rust more often than on main (437 vs + 317 checked versions, 287 vs 151 reverts), while main corrupted the latest + state after a checkout round trip in 57 of 90 seeds and the PR in none. + The anchor model (loro-dev/loro#1137) makes such text replayable and removes + both costs. Two more gaps are also on main: after an update imported while + detached, a shallow export on the live document can change its latest state + (plain text too; loro-dev/loro#1136 fixes the plain-text case), and a shallow + export that throws midway leaves the live document at the root or in + between, since `#encodeShallowSnapshot` rebuilds it without a restore. +- A MovableList is not a snapshot sequence at all (`#markSnapshotSequence`); + it behaves as on main. Its snapshot state names each element by its Rust + position id (`#hydrateContainerState` takes `listItemIds` in order, and after + a move they include invisible positions), while a replay names elements by + their insert ids, and Rust encodes element ids as (peer, lamport), so no + comparison with a replay is meaningful. The hydrated state also has no move + or value history (`_moveHistoryComplete`, `_valueHistoryComplete`), so + `#canTransitionRecords` refuses to cross its snapshot moves, sets, and + deletes, and the transition replays to the target; later transitions are + incremental, and a later move or set by element id resolves. Replaying the + list to the current version and then retreating instead (as round 3 did) + carried loro.js's MovableList non-convergence at the latest version into + older versions. Completing only lists whose snapshot names elements by insert + ids was also tried: it makes a snapshot document transition like a + full-history one, and loro.js's incremental MovableList transitions have + their own bugs with concurrent moves and sets (a full-history document on + main shows them too), so random Rust histories got 167 mismatching versions + against main's 91; leaving every MovableList on main's path gives 56. Until + the MovableList model work (loro-dev/loro#1132 and follow-ups) hydrates + element ids, a MovableList whose loro.js replay differs from Rust can change + its latest state at the first such transition, also as on main. Once the + list is replayed, later transitions are incremental and can still show a + wrong older version where main, which replays on every such checkout, is + right (round-4 review, seed 22: after checking out v11, v12, then v13, the + list shows `[11]` where Rust, main, and a full-history document show `[9]`; + the latest state and `revertTo` are right). This is loro.js's MovableList + transition gap, left to loro-dev/loro#1132. +- A full `#rebuildFromHistory` (the non-incremental fallback, shallow export, + `forkAt`) first checks the snapshot-hydrated Text containers that have or + had styles, including lazily encoded ones, and then rebuilds unreplayable + containers the same way. "Had" matters: when every marked character was + deleted, the snapshot state has no style, but the mark's anchors still shift + later Rust positions, so the replay differs (`#hasStyleHistory`: a mark + operation in the history, indexed as `#markedTexts`, or styles in the shallow + root state). Plain text is not checked, so it pays no extra replay. Only the root state of a shallow export uses the replay, since the + snapshot state is later than the root. `forkAt` keeps a snapshot state only + in a fork whose version includes that state's version; an older fork has + none of the operations needed to undo later ones in that state, so it keeps + the replay of its own history, as on main, and stays consistent with its own + operations. `tests/snapshot-checkout.test.ts` checks random checkouts, + detaches, and imports on Rust rich-text histories (`rich-text-history.json`) + against a document that only imports, plus forks, Rust MovableList moves + (`movable-moves.json`), and a Rust text whose marked characters were deleted + (`deleted-mark.json`). +- Transitions deduplicate sequence elements by id (`SequenceElementSet`): a + packed Text span returns a new wrapper per lookup, so two concurrent deletes + of one character used to delete it twice. A completion that throws + reinstalls the snapshot state and leaves the container hydrated. A checkout + that throws restores its previous version and state (`#transitionTo`, which + also prepares inside its `try`), and `diff` restores the current state with a + full rebuild when it or its move back throws. +- First checkout after importing a 262,144-operation single-peer Text snapshot + takes about 57 ms (medians of 5 alternating runs on a loaded Apple M5 Pro), + versus about 148 ms for the earlier whole-document replay; 65,536 operations + take 25 versus 43 ms, and a subscriber adds nothing measurable (earlier 192 + ms at 262,144). The replay of that one container dominates. Later checkouts + stay around 0.1–0.4 ms. A doc with 32,768 child Maps that retreats one of them + needs no replay: 57–61 ms versus 152 ms, with the same 234 MiB peak RSS as + before the fix (earlier 284 MiB). The coalesced inserts also make importing + the B4 trace as one update about 30% faster (about 195 versus 275 ms). When an element's deleted flag, tree parent/position, or map visibility changes, mutate it through its owning index helper. Direct mutation leaves subtree or diff --git a/loro-js/src/runtime/containers.ts b/loro-js/src/runtime/containers.ts index 9b3979b91..a543a0a00 100644 --- a/loro-js/src/runtime/containers.ts +++ b/loro-js/src/runtime/containers.ts @@ -74,6 +74,34 @@ export interface SequenceMoveMeta { export type CausalVersion = ReadonlyMap; +/** + * Detached internal state of a List, MovableList, or Text, returned by + * `_swapState`. The document swaps a snapshot-hydrated state out while it + * rebuilds the container from history, and can install it again later. + */ +export interface SequenceContainerState { + readonly sequence: SequenceIndex | SequenceIndex; +} + +interface ListState extends SequenceContainerState { + readonly sequence: SequenceIndex; + readonly detachedCounter: number; +} + +interface MovableListState extends ListState { + readonly valueHistoryComplete: boolean; + readonly moveHistoryComplete: boolean; +} + +interface TextState extends SequenceContainerState { + readonly sequence: SequenceIndex; + readonly detachedCounter: number; + readonly detachedStyleCounter: number; + readonly attributeHistoryComplete: boolean; + readonly styleIndex: TextStyleIndex; + readonly styleVersion: CausalVersion | undefined; +} + interface ParentLink { readonly container: LoroContainer; readonly binding?: @@ -363,7 +391,7 @@ export class LoroMap< } export class LoroList extends LoroContainer { - readonly _sequence = new SequenceIndex(); + _sequence = new SequenceIndex(); _detachedCounter = 0; get _elements(): SequenceElement[] { @@ -579,6 +607,21 @@ export class LoroList extends LoroContainer { this._detachedCounter = 0; } + /** Installs `state`, or an empty state, and returns the replaced state. */ + _swapState(state?: SequenceContainerState): SequenceContainerState { + const previous: ListState = { + sequence: this._sequence, + detachedCounter: this._detachedCounter, + }; + const next = state as ListState | undefined; + this._sequence = next?.sequence ?? new SequenceIndex(); + this._detachedCounter = next?.detachedCounter ?? 0; + this._sequence.forEachPhysicalRaw((element) => { + if (element.value instanceof LoroContainer) this._bindChildren([element]); + }); + return previous; + } + _bindChildren(elements: readonly SequenceElement[]): void { for (const element of elements) { if (element.value instanceof LoroContainer) { @@ -1362,17 +1405,11 @@ export interface TextStyleMeta { } export class LoroText extends LoroContainer { - readonly _sequence = new SequenceIndex( - (element) => ({ - utf16: element.value.length, - utf8: utf8CodePointLength(element.value), - }), - (element) => countLineBreaks(element.value), - ); + _sequence = createTextSequence(); _detachedCounter = 0; _detachedStyleCounter = 0; _attributeHistoryComplete = true; - readonly _styleIndex = new TextStyleIndex(); + _styleIndex = new TextStyleIndex(); _styleVersion: CausalVersion | undefined; readonly #attributeValuesCache = new WeakMap< ReadonlyMap, @@ -1995,6 +2032,36 @@ export class LoroText extends LoroContainer { this._styleIndex.reset(); this._styleVersion = undefined; } + + /** Installs `state`, or an empty state, and returns the replaced state. */ + _swapState(state?: SequenceContainerState): SequenceContainerState { + const previous: TextState = { + sequence: this._sequence, + detachedCounter: this._detachedCounter, + detachedStyleCounter: this._detachedStyleCounter, + attributeHistoryComplete: this._attributeHistoryComplete, + styleIndex: this._styleIndex, + styleVersion: this._styleVersion, + }; + const next = state as TextState | undefined; + this._sequence = next?.sequence ?? createTextSequence(); + this._detachedCounter = next?.detachedCounter ?? 0; + this._detachedStyleCounter = next?.detachedStyleCounter ?? 0; + this._attributeHistoryComplete = next?.attributeHistoryComplete ?? true; + this._styleIndex = next?.styleIndex ?? new TextStyleIndex(); + this._styleVersion = next?.styleVersion; + return previous; + } +} + +function createTextSequence(): SequenceIndex { + return new SequenceIndex( + (element) => ({ + utf16: element.value.length, + utf8: utf8CodePointLength(element.value), + }), + (element) => countLineBreaks(element.value), + ); } export class LoroMovableList extends LoroList { @@ -2159,6 +2226,18 @@ export class LoroMovableList extends LoroList { this._valueHistoryComplete = true; this._moveHistoryComplete = true; } + + override _swapState(state?: SequenceContainerState): SequenceContainerState { + const previous: MovableListState = { + ...(super._swapState(state) as ListState), + valueHistoryComplete: this._valueHistoryComplete, + moveHistoryComplete: this._moveHistoryComplete, + }; + const next = state as MovableListState | undefined; + this._valueHistoryComplete = next?.valueHistoryComplete ?? true; + this._moveHistoryComplete = next?.moveHistoryComplete ?? true; + return previous; + } } export class LoroCounter extends LoroContainer { diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index 57f9a8a4d..ee3eed668 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -9,7 +9,11 @@ import { type DecodedOperationContent, } from "../codec/change-block-codec"; import type { ChangeLoroValue, ChangeValue } from "../codec/change-value"; -import { containerTypeFromRawByte, containerTypeToRawByte } from "../codec/container-id"; +import { + containerTypeFromRawByte, + containerTypeToRawByte, + decodeContainerId, +} from "../codec/container-id"; import { decodeDocument, decodeFastSnapshotBody, @@ -24,6 +28,7 @@ import { import { decodeChangeBlockKey, encodeChangeBlockKey } from "../codec/id"; import { decodeSstable, encodeSstable, type SstableEntry } from "../codec/sstable"; import { + decodeContainerStateWrapper, decodeLazyStateSnapshotStore, decodeStateSnapshotStore, encodeStateSnapshotStore, @@ -171,6 +176,8 @@ interface DeferredSnapshotHistory { interface DeferredSnapshotState { readonly store: Extract; + // The version of the encoded state. + readonly version: VersionVector; } interface IndexedHistoryOperation { @@ -192,6 +199,37 @@ interface EventRecording { type MovableMoveTransitionMode = "anchors" | "replay"; +type SnapshotSequenceState = + // The snapshot state of `version` is installed. It has no history for the + // operations of `version`; operations applied since then are indexed. + | { readonly kind: "hydrated"; readonly version: VersionVector } + // loro.js cannot replay this container's history to its snapshot state + // (#completeSnapshotSequence). `state` is that state at `version`; every + // transition rebuilds the container from it (#rebuildFromSnapshotState). + // `applied` covers the operations whose elements, deletions, and styles + // the installed state has; a transition that only crosses those toggles + // them by id (#planSnapshotStates). + | { + readonly kind: "unreplayable"; + readonly state: ContainerStateSnapshot; + readonly version: VersionVector; + readonly applied: VersionVector; + }; + +interface SnapshotStatePlan { + // Toggled by id, with events in the transition's recording. + readonly recorded: Set; + // Toggled by id, with whole-container events. + readonly toggled: Set; + // Rebuilt from the snapshot state, with whole-container events. + readonly rebuilt: Set; +} + +interface ContainerHistoryIndex { + readonly revision: number; + readonly records: Map; +} + interface PendingChange extends EventRecording { readonly id: CodecId; readonly dependencies: CodecId[]; @@ -252,10 +290,22 @@ export class LoroDoc = Record>(); #movableMovePeers = new Map>(); #containersWithOperations = new Set(); + // Text containers with a mark operation in their history + // (#checkSnapshotSequences). + #markedTexts = new Set(); #containerKeys = new WeakMap(); #pendingHistory = new Map(); #deferredSnapshotHistory: DeferredSnapshotHistory | undefined; #deferredSnapshotState: DeferredSnapshotState | undefined; + // Text, List, and MovableList containers whose state came from a snapshot. + // It has no tombstones or style, value, and move history, so a version + // transition first rebuilds each touched one from its own history; see + // #prepareSnapshotTransition and context/loro-js-performance.md. + #snapshotSequences = new Map(); + // Incremented whenever retained history changes; invalidates + // #containerHistoryIndex. + #historyRevision = 0; + #containerHistoryIndex: ContainerHistoryIndex | undefined; #hydratedSnapshotContainers = new Set(); #hydratingSnapshotContainers = new Set(); #snapshotContainerDepths = new Map(); @@ -286,6 +336,9 @@ export class LoroDoc = Record | undefined; #textStyles = new Map([ ["bold", "after"], ["italic", "after"], @@ -741,12 +794,16 @@ export class LoroDoc = Record = Record = Record = Record = Record 0) { const recording = this.#hasEventSubscribers() @@ -919,6 +977,7 @@ export class LoroDoc = Record ({ ...id })); this.#shallowRootStore = rootStore; + this.#shallowRootEntries = undefined; } for (const decoded of ordered) { @@ -936,14 +995,19 @@ export class LoroDoc = Record = Record = Record = Record(); let calculated = new Map(); let mapKeysAtFrom = new Map>(); @@ -1107,6 +1215,13 @@ export class LoroDoc = Record = Record = Record = Record(); fork.#recordTimestamp = this.#recordTimestamp; fork.#changeMergeInterval = this.#changeMergeInterval; @@ -1698,8 +1847,20 @@ export class LoroDoc = Record ({ ...id })); fork.#shallowRootStore = this.#shallowRootStore; + fork.#shallowRootEntries = this.#shallowRootEntries; fork.#integrateHistory(this.#recordsAtVersion(version), undefined); fork.#rebuildFromHistory(); + // A container that loro.js cannot replay keeps its snapshot state in a + // fork that includes that state's version. An older fork has none of the + // operations after its version, so it cannot undo them in that state; it + // keeps the replay of its own history, like main. + for (const [key, entry] of this.#snapshotSequences) { + if (entry.kind !== "unreplayable") continue; + const order = version.compare(entry.version); + if (order !== 0 && order !== 1) continue; + fork.#snapshotSequences.set(key, entry); + fork.#rebuildFromSnapshotState(key, version); + } return fork; } @@ -1721,49 +1882,17 @@ export class LoroDoc = Record(); - let preparedDiffs = new Map(); - this.#checkoutVersion = targetVersion; - this.#detached = true; - const changedRecords = [...forwardRecords, ...retreatRecords]; - const movableMoveMode = movableMoveTransitionMode( + const { beforeValues, preparedDiffs } = this.#transitionTo( + currentVersion, + targetVersion, retreatRecords, forwardRecords, - this.#movableMovePeers, + changed, + () => { + this.#checkoutVersion = targetVersion; + this.#detached = true; + }, ); - if (this.#canTransitionRecords(changedRecords, movableMoveMode)) { - const recording = this.#hasEventSubscribers() - ? { beforeValues: new Map(), eventStates: new Map() } - : undefined; - this.#applyVersionTransition( - retreatRecords, - forwardRecords, - targetVersion, - recording, - movableMoveMode, - ); - if (recording !== undefined) { - beforeValues = recording.beforeValues; - preparedDiffs = this.#recordedEventDiffs(recording); - } - } else if ( - retreatRecords.length === 0 && - !hasMaterializedSequenceInsertions(forwardRecords, this.#containers) - ) { - const recording = this.#hasEventSubscribers() - ? { beforeValues: new Map(), eventStates: new Map() } - : undefined; - this.#applyRecords(forwardRecords, recording); - if (recording !== undefined) { - beforeValues = recording.beforeValues; - preparedDiffs = this.#recordedEventDiffs(recording); - } - } else { - if (this.#hasEventSubscribers()) { - beforeValues = this.#captureContainerEventValues(changed); - } - this.#rebuildFromHistory(targetVersion); - } this.#emit( "checkout", undefined, @@ -1795,61 +1924,118 @@ export class LoroDoc = Record { + this.#checkoutVersion = undefined; + this.#detached = false; + }; + if (!wasDetached) { + attach(); + return; + } + const { beforeValues, preparedDiffs } = this.#transitionTo( + currentVersion, + latestVersion, + retreatRecords, + forwardRecords, + changed, + attach, + ); + this.#emit( + "checkout", + undefined, + before, + this.#frontiersCodec(), + changed, + beforeValues, + preparedDiffs, + ); + if (this.#detachedEditing) this.#renewPeerId(); + } + + /** + * Moves the state from `current` to `target` for a checkout and returns the + * values and diffs of its event. `setVersion` installs the new checkout + * version once the containers are prepared. If the transition throws, the + * checkout version and the state at `current` are restored before the error + * is rethrown, so a failed checkout leaves no partial update. + */ + #transitionTo( + current: VersionVector, + target: VersionVector, + retreatRecords: readonly HistoryRecord[], + forwardRecords: readonly HistoryRecord[], + changed: ReadonlySet, + setVersion: () => void, + ): { beforeValues: Map; preparedDiffs: Map } { + const subscribed = this.#hasEventSubscribers(); + const previousCheckoutVersion = this.#checkoutVersion; + const previousDetached = this.#detached; + let versionSet = false; + let wholeBefore = new Map(); let beforeValues = new Map(); let preparedDiffs = new Map(); - this.#checkoutVersion = undefined; - this.#detached = false; - if (wasDetached) { - const changedRecords = [...forwardRecords, ...retreatRecords]; - const movableMoveMode = movableMoveTransitionMode( + try { + // Preparing leaves every container at `current` even when it throws + // (#completeSnapshotSequence), so only a later failure needs a restore. + const rebuilds = this.#prepareSnapshotTransition( + [...forwardRecords, ...retreatRecords], + current, + ); + const retreat = this.#withoutContainers(retreatRecords, rebuilds); + const forward = this.#withoutContainers(forwardRecords, rebuilds); + const plan = this.#planSnapshotStates( retreatRecords, forwardRecords, + rebuilds, + subscribed, + ); + if (subscribed) wholeBefore = this.#snapshotStateValues(plan); + setVersion(); + versionSet = true; + const movableMoveMode = movableMoveTransitionMode( + retreat, + forward, this.#movableMovePeers, ); - if (this.#canTransitionRecords(changedRecords, movableMoveMode)) { - const recording = this.#hasEventSubscribers() - ? { beforeValues: new Map(), eventStates: new Map() } - : undefined; + const recording: EventRecording | undefined = subscribed + ? { beforeValues: new Map(), eventStates: new Map() } + : undefined; + if (this.#canTransitionRecords([...forward, ...retreat], movableMoveMode, target)) { this.#applyVersionTransition( - retreatRecords, - forwardRecords, - latestVersion, + retreat, + forward, + target, recording, movableMoveMode, ); - if (recording !== undefined) { - beforeValues = recording.beforeValues; - preparedDiffs = this.#recordedEventDiffs(recording); - } } else if ( - retreatRecords.length === 0 && - !hasMaterializedSequenceInsertions(forwardRecords, this.#containers) + retreat.length === 0 && + !hasMaterializedSequenceInsertions(forward, this.#containers) ) { - const recording = this.#hasEventSubscribers() - ? { beforeValues: new Map(), eventStates: new Map() } - : undefined; - this.#applyRecords(forwardRecords, recording); - if (recording !== undefined) { - beforeValues = recording.beforeValues; - preparedDiffs = this.#recordedEventDiffs(recording); - } + this.#applyRecords(forward, recording); } else { - if (this.#hasEventSubscribers()) { - beforeValues = this.#captureContainerEventValues(changed); - } - this.#rebuildFromHistory(); + if (subscribed) beforeValues = this.#captureContainerEventValues(changed); + this.#rebuildFromHistory(target, current); + return { beforeValues, preparedDiffs }; } - this.#emit( - "checkout", - undefined, - before, - this.#frontiersCodec(), - changed, - beforeValues, - preparedDiffs, - ); - if (this.#detachedEditing) this.#renewPeerId(); + this.#applySnapshotStates(plan, retreatRecords, forwardRecords, target, recording); + if (recording !== undefined) { + beforeValues = recording.beforeValues; + preparedDiffs = this.#recordedEventDiffs(recording); + } + } catch (error) { + if (versionSet) { + this.#checkoutVersion = previousCheckoutVersion; + this.#detached = previousDetached; + this.#rebuildFromHistory(current, current); + } + throw error; } + for (const [key, value] of wholeBefore) { + beforeValues.set(key, value); + preparedDiffs.delete(key); + } + return { beforeValues, preparedDiffs }; } #renewPeerId(): void { @@ -2679,6 +2865,7 @@ export class LoroDoc = Record = Record = Record (entry.applied.get(changeId.peer) ?? 0) + ) { + entry.applied.set(changeId.peer, operation.counter + operation.length); + } + } const operationId = { peer: changeId.peer, counter: operation.counter }; const lamport = changeLamport + (operation.counter - changeId.counter); const writer: LastWriter = { peer: changeId.peer, lamport }; @@ -3650,10 +3861,37 @@ export class LoroDoc = Record + left === right || this.#containerKey(left) === this.#containerKey(right), + )); + } const finishEvent = recording === undefined ? undefined @@ -3661,36 +3899,42 @@ export class LoroDoc = Record(); if (movableMoveMode === "replay") { for (const { change } of records) { @@ -3824,14 +4068,48 @@ export class LoroDoc = Record = Record>(); + const sequences = new Map(); const bulkSequenceRemovals = new Map(); const bulkSequenceRestorations = new Map(); const textAttributeRuns = new Map>(); const textStyleContainers = new Set(); - const movableValues = new Map>(); + const movableValues = new Map(); const mapKeys = new Map>(); const treeSubjects = new Map>(); const counters = new Map(); - const sequenceElements = (container: LoroList | LoroText): Set => { + const sequenceElements = (container: LoroList | LoroText): SequenceElementSet => { let elements = sequences.get(container); if (elements === undefined) { - elements = new Set(); + elements = new SequenceElementSet(); sequences.set(container, elements); } return elements; @@ -3954,12 +4232,13 @@ export class LoroDoc = Record = Record = Record 0; + } + + /** + * Readies the containers that `records` touch for a transition from + * `current`. A lazily encoded container that no transition has touched still + * holds its latest state, which is also its state at `current`, so it is + * hydrated now. A snapshot-hydrated sequence is rebuilt from its own history + * when the transition crosses a snapshot operation + * (#completeSnapshotSequence). Unrelated containers are not touched. + * Returns the touched containers that loro.js cannot replay: the caller + * transitions without their operations and moves them separately + * (#planSnapshotStates). + */ + #prepareSnapshotTransition( + records: readonly HistoryRecord[], + current: VersionVector, + ): Set { + const rebuilds = new Set(); + const lazy = this.#deferredSnapshotState; + if (lazy === undefined && this.#snapshotSequences.size === 0) return rebuilds; + // The first counter per peer of the operations on each touched container. + const touched = new Map< + string, + { readonly id: CodecContainerId; readonly firstCounters: Map } + >(); + for (const { change } of records) { + for (const operation of change.operations) { + const key = this.#containerKey(operation.container); + let container = touched.get(key); + if (container === undefined) { + container = { id: operation.container, firstCounters: new Map() }; + touched.set(key, container); + } + const first = container.firstCounters.get(change.id.peer); + if (first === undefined || operation.counter < first) { + container.firstCounters.set(change.id.peer, operation.counter); + } + } + } + for (const [key, { id, firstCounters }] of touched) { + if (lazy !== undefined) { + if ( + this.#containers.has(key) || + getLazyStateSnapshotContainer(lazy.store, id) !== undefined + ) { + this.#getOrCreateContainer(id); + } + this.#dirtySnapshotContainers.add(key); + } + const entry = this.#snapshotSequences.get(key); + if (entry === undefined) continue; + const container = this.#containers.get(key) as LoroList | LoroText; + if (entry.kind === "hydrated") { + // Operations applied after hydration are indexed like any others; only + // crossing a snapshot operation needs its history. + const { version } = entry; + if ( + [...firstCounters].every( + ([peer, counter]) => counter >= (version.get(peer) ?? 0), + ) || + !this.#completeSnapshotSequence(container, key, current) + ) { + continue; + } + } + rebuilds.add(key); + } + return rebuilds; + } + + /** + * Rebuilds a snapshot-hydrated sequence container from its own operations up + * to `version`, starting from its shallow root state in a shallow document, + * and returns whether the replay differed from the snapshot state. loro.js + * does not count Rust rich-text style anchors in operation positions, so a + * replay of Rust rich text can differ. Such a container keeps its snapshot + * state and becomes `unreplayable` (see #rebuildFromSnapshotState and + * context/loro-js-performance.md). + */ + #completeSnapshotSequence( + container: LoroList | LoroText, + key: string, + version: VersionVector, + ): boolean { + const snapshotRuns = sequenceIdRuns(container); + const snapshot = container._swapState(); + let same: boolean; + // Any throw reinstalls the snapshot state and leaves the entry hydrated. + try { + const root = + this.#shallowRootStore === undefined + ? undefined + : this.#shallowRootEntryIndex().get(key); + if (root !== undefined) this.#hydrateContainerState(container, root.wrapper.state); + for (const record of this.#containerHistoryRecords(key)) { + const { change } = record; + const length = changeLength(change); + const start = Math.max( + 0, + (this.#shallowRootVersion.get(change.id.peer) ?? 0) - change.id.counter, + ); + const end = Math.min( + length, + (version.get(change.id.peer) ?? 0) - change.id.counter, + ); + if (start >= end) continue; + this.#applyChange( + start === 0 && end === length ? change : sliceChange(change, start, end), + record.keys, + undefined, + { key, container }, + ); + } + same = sameIdRuns(snapshotRuns, sequenceIdRuns(container)); + if (same) { + // Values need a full read; compare them only when the ids already agree. + const replayedValues = sequenceValues(container); + const replayed = container._swapState(snapshot); + const snapshotValues = sequenceValues(container); + container._swapState(replayed); + same = sameSequenceValues(snapshotValues, replayedValues); + } + if (!same) container._swapState(snapshot); + } catch (error) { + container._swapState(snapshot); + throw error; + } + if (same) { + this.#snapshotSequences.delete(key); + return false; + } + this.#snapshotSequences.set(key, { + kind: "unreplayable", + state: this.#containerState(container), + version: version.clone(), + applied: version.clone(), + }); + return true; + } + + /** + * Rebuilds an unreplayable container at `version` from its snapshot state, + * never from a replay. The operations of that state that `version` excludes + * are undone where the state still has their elements: inserted elements are + * hidden and styles are filtered by version; the state has no history to + * restore what it deleted. Operations after it that `version` includes are + * then applied. So the latest state is always the snapshot state plus the + * later operations, while older versions are approximate. + */ + #rebuildFromSnapshotState(key: string, version: VersionVector): void { + const entry = this.#snapshotSequences.get(key); + const container = this.#containers.get(key); + if ( + entry?.kind !== "unreplayable" || + !(container instanceof LoroList || container instanceof LoroText) + ) { return; } - this.#applyRecords( - version === undefined ? this.#sortedHistory() : this.#recordsAtVersion(version), + container._reset(); + this.#hydrateContainerState(container, entry.state); + const records = this.#containerHistoryRecords(key); + for (const { change } of records) { + const peer = change.id.peer; + const snapshotEnd = entry.version.get(peer) ?? 0; + const versionEnd = version.get(peer) ?? 0; + if (versionEnd >= snapshotEnd) continue; + for (const operation of change.operations) { + const content = operation.content; + if ( + (content.type !== "text-insert" && + content.type !== "list-insert" && + content.type !== "movable-list-insert") || + this.#containerKey(operation.container) !== key + ) { + continue; + } + const end = Math.min(operation.counter + operation.length, snapshotEnd); + for ( + let counter = Math.max(operation.counter, versionEnd); + counter < end; + counter++ + ) { + const element = container._sequence.findById({ peer, counter }); + if (element !== undefined && !element.deleted) { + container._sequence.setDeleted(element as never, true); + } + } + } + } + for (const record of records) { + const { change } = record; + const length = changeLength(change); + const start = Math.max( + 0, + (entry.version.get(change.id.peer) ?? 0) - change.id.counter, + ); + const end = Math.min( + length, + (version.get(change.id.peer) ?? 0) - change.id.counter, + ); + if (start >= end) continue; + this.#applyChange( + start === 0 && end === length ? change : sliceChange(change, start, end), + record.keys, + undefined, + { key, container }, + ); + } + if (container instanceof LoroText) { + container._setStyleVersion( + version.compare(this.#historyVersion()) === 0 ? undefined : versionMap(version), + ); + } + const applied = entry.version.clone(); + for (const { peer, counter } of version._codecEntriesUnsorted()) { + if (counter > (applied.get(peer) ?? 0)) applied.set(peer, counter); + } + this.#snapshotSequences.set(key, { ...entry, applied }); + this.#dirtySnapshotContainers.add(key); + } + + /** + * Plans how #applySnapshotStates moves the unreplayable containers in `keys` + * across a transition, given its full records. When the installed state has + * every forward operation (`applied`), a Text is toggled by id, as a + * transition of state without history is. Otherwise the container is + * rebuilt from its snapshot state. With `record`, a toggled container without + * style operations reports its events through the transition's recording; + * style ranges come from positions that loro.js cannot map, so the others + * need whole-container event values. + */ + #planSnapshotStates( + retreat: readonly HistoryRecord[], + forward: readonly HistoryRecord[], + keys: ReadonlySet, + record: boolean, + ): SnapshotStatePlan { + const plan: SnapshotStatePlan = { + recorded: new Set(), + toggled: new Set(), + rebuilt: new Set(), + }; + if (keys.size === 0) return plan; + const styled = new Set(); + const unapplied = new Set(); + for (const [records, isForward] of [ + [retreat, false], + [forward, true], + ] as const) { + for (const { change } of records) { + for (const operation of change.operations) { + const key = this.#containerKey(operation.container); + if (!keys.has(key)) continue; + if (operation.content.type === "text-mark") styled.add(key); + const entry = this.#snapshotSequences.get(key); + if ( + isForward && + entry?.kind === "unreplayable" && + operation.counter + operation.length > + (entry.applied.get(change.id.peer) ?? 0) + ) { + unapplied.add(key); + } + } + } + } + for (const key of keys) { + if (unapplied.has(key) || !(this.#containers.get(key) instanceof LoroText)) { + plan.rebuilt.add(key); + } else if (record && !styled.has(key)) { + plan.recorded.add(key); + } else { + plan.toggled.add(key); + } + } + return plan; + } + + /** Moves unreplayable containers to `target` as `plan` says. */ + #applySnapshotStates( + plan: SnapshotStatePlan, + retreat: readonly HistoryRecord[], + forward: readonly HistoryRecord[], + target: VersionVector, + recording?: EventRecording, + ): void { + for (const key of plan.rebuilt) this.#rebuildFromSnapshotState(key, target); + for (const [group, groupRecording] of [ + [plan.recorded, recording], + [plan.toggled, undefined], + ] as const) { + if (group.size === 0) continue; + this.#applyVersionTransition( + this.#onlyContainers(retreat, group), + this.#onlyContainers(forward, group), + target, + groupRecording, + ); + } + } + + /** #applySnapshotStates for a transition without events. */ + #moveSnapshotStates( + retreat: readonly HistoryRecord[], + forward: readonly HistoryRecord[], + target: VersionVector, + keys: ReadonlySet, + ): void { + this.#applySnapshotStates( + this.#planSnapshotStates(retreat, forward, keys, false), + retreat, + forward, + target, ); } + /** Whole-container event values of the planned containers without recording. */ + #snapshotStateValues(plan: SnapshotStatePlan): Map { + const values = new Map(); + for (const key of [...plan.toggled, ...plan.rebuilt]) { + const container = this.#containers.get(key); + if (container !== undefined) values.set(key, containerEventValue(container)); + } + return values; + } + + /** `records` with only the operations on `containers`. */ + #onlyContainers( + records: readonly HistoryRecord[], + containers: ReadonlySet, + ): HistoryRecord[] { + return records.flatMap((record) => { + const operations = record.change.operations.filter((operation) => + containers.has(this.#containerKey(operation.container)), + ); + if (operations.length === record.change.operations.length) return [record]; + return operations.length === 0 + ? [] + : [{ ...record, change: { ...record.change, operations } }]; + }); + } + + /** `records` without the operations on `containers`. */ + #withoutContainers( + records: readonly HistoryRecord[], + containers: ReadonlySet, + ): HistoryRecord[] { + if (containers.size === 0) return records as HistoryRecord[]; + return records.flatMap((record) => { + const operations = record.change.operations.filter( + (operation) => !containers.has(this.#containerKey(operation.container)), + ); + if (operations.length === record.change.operations.length) return [record]; + return operations.length === 0 + ? [] + : [{ ...record, change: { ...record.change, operations } }]; + }); + } + + /** + * Retained history records that touch the container, in replay order. The + * index is built on first use after each history change. + */ + #containerHistoryRecords(key: string): readonly HistoryRecord[] { + const records = this.#sortedHistory(); + let index = this.#containerHistoryIndex; + if (index === undefined || index.revision !== this.#historyRevision) { + const byContainer = new Map(); + for (const record of records) { + for (const operation of record.change.operations) { + const containerKey = this.#containerKey(operation.container); + let containerRecords = byContainer.get(containerKey); + if (containerRecords === undefined) { + containerRecords = []; + byContainer.set(containerKey, containerRecords); + } + if (containerRecords.at(-1) !== record) containerRecords.push(record); + } + } + index = { revision: this.#historyRevision, records: byContainer }; + this.#containerHistoryIndex = index; + } + return index.records.get(key) ?? []; + } + + #shallowRootEntryIndex(): Map { + return (this.#shallowRootEntries ??= stateStoreEntriesByKey( + this.#shallowRootStore ?? { kind: "absent" }, + )); + } + #setHistoryRecord(key: string, record: HistoryRecord, appended: HistoryRecord): void { const previous = this.#history.get(key); this.#history.set(key, record); @@ -4627,6 +5382,7 @@ export class LoroDoc = Record = Record = Record = Record = Record = Record = Record = Record { + return new Map( + store.kind === "sstable" + ? store.containers.map((entry) => [formatContainerId(entry.id), entry] as const) + : [], + ); +} + +/** + * Operations in a run of text inserts on one container, each continuing the + * previous one at the next counter and position, have the same Fugue placement + * as one multi-character insert; Loro's Rust runtime stores such a run as one + * operation. Applying the run as one span avoids a sequence-index update per + * character when replaying history. + */ +function coalescedTextInsert( + operations: readonly DecodedOperation[], + index: number, + sameContainer: (left: CodecContainerId, right: CodecContainerId) => boolean, +): { readonly operation: DecodedOperation; readonly next: number } { + const first = operations[index]!; + if (first.content.type !== "text-insert") return { operation: first, next: index + 1 }; + let next = index + 1; + let counter = first.counter + first.length; + let position = first.content.position + first.length; + const values = [first.content.value]; + for (; next < operations.length; next += 1) { + const operation = operations[next]!; + if ( + operation.content.type !== "text-insert" || + operation.counter !== counter || + operation.content.position !== position || + !sameContainer(first.container, operation.container) + ) { + break; + } + values.push(operation.content.value); + counter += operation.length; + position += operation.length; + } + if (next === index + 1) return { operation: first, next }; + return { + operation: { + container: first.container, + counter: first.counter, + length: counter - first.counter, + content: { + type: "text-insert", + position: first.content.position, + value: values.join(""), + }, + }, + next, + }; +} + +/** Visible element ids of a sequence, as maximal runs. */ +function sequenceIdRuns(container: LoroList | LoroText): SequenceIdRun[] { + const sequence = container._sequence; + const runs: SequenceIdRun[] = []; + for (const run of sequence.visibleIdRuns(0, sequence.visibleLength)) { + const last = runs.at(-1); + if ( + last !== undefined && + last.start.peer === run.start.peer && + last.start.counter + last.length === run.start.counter + ) { + runs[runs.length - 1] = { start: last.start, length: last.length + run.length }; + } else { + runs.push(run); + } + } + return runs; +} + +function sameIdRuns( + left: readonly SequenceIdRun[], + right: readonly SequenceIdRun[], +): boolean { + return ( + left.length === right.length && + left.every( + (run, index) => + run.length === right[index]!.length && idsEqual(run.start, right[index]!.start), + ) + ); +} + +/** + * The values that equal ids do not fix: the Text delta when it has styles + * (other element values are fixed by their ids). + */ +function sequenceValues(container: LoroList | LoroText): readonly unknown[] | undefined { + return container instanceof LoroText && !container._styleIndex.isEmpty + ? container.toDelta() + : undefined; +} + +function sameSequenceValues( + left: readonly unknown[] | undefined, + right: readonly unknown[] | undefined, +): boolean { + if (left === undefined || right === undefined) { + // Same ids mean the same characters; an unstyled Text only matches a delta + // without attributes. + const values = left ?? right; + return ( + values === undefined || + values.every((item) => (item as { attributes?: unknown }).attributes === undefined) + ); + } + return eventValuesEqual(left, right); +} + +/** + * Sequence elements deduplicated by id. A packed Text span returns a new + * wrapper object on every lookup, so object identity does not identify its + * elements. + */ +class SequenceElementSet { + readonly #elements = new Map(); + + add(element: SequenceElement): void { + const key = idKey(element.id); + if (!this.#elements.has(key)) this.#elements.set(key, element); + } + + [Symbol.iterator](): IterableIterator { + return this.#elements.values(); + } +} + +function versionMap(version: VersionVector): Map { + return new Map( + version._codecEntriesUnsorted().map(({ peer, counter }) => [peer, counter] as const), + ); +} + function mergeStateSnapshotStores( root: StateSnapshotStore, overlay: StateSnapshotStore, + rootEntries = stateStoreEntriesByKey(root), ): StateSnapshotStore { if (root.kind !== "sstable") return overlay; if (overlay.kind !== "sstable") { return { kind: "sstable", frontiers: undefined, containers: root.containers }; } - const containers = new Map( - root.containers.map((entry) => [formatContainerId(entry.id), entry]), - ); + const containers = new Map(rootEntries); for (const entry of overlay.containers) { containers.set(formatContainerId(entry.id), entry); } diff --git a/loro-js/tests/fixtures/rust/deleted-mark.json b/loro-js/tests/fixtures/rust/deleted-mark.json new file mode 100644 index 000000000..aafb3d640 --- /dev/null +++ b/loro-js/tests/fixtures/rust/deleted-mark.json @@ -0,0 +1,25 @@ +{ + "snapshot": "bG9ybwAAAAAAAAAAAAAAAGP3b2MAA8AAAABMT1JPAAAOAA4BEAEBAAAAAAAAAAEBAAAAAAAFAQAAAQALAgQBAgACBAEEAAQKBGJvbGQBdAJtbAAjAQQFCgADAgAIDQACAQwHAwQIDQUMAAUJCw4IAQUGAQUCAwELAQMCAQACAQICAQQXBWhlbGxvhAIAAQFYBwMDAAMBAwIAAAoAAgBmcgEBGgACAHZ2AQEcAAB8AIQAAwCiHcyaAQAAAAUAAAAMAAAAAAAAAAABAAAAAAACAHZ2lijVh50AAACdAAAATE9STwACAQAEaGxYbwEBAAAAAAAAAAMEAgwABwsACgIFCAUCDAAIAQIEAQEEBAABBGJvbGQBAwABAYQABACEAm1sBAEAAwMCAwQDAAEBAAAAAAAAAAQDAggAAwADAQIABAMCBgAEARYEAgIGAAICAQACARQCAAAAADYAAgAfFYGlAQAAAAUAAAADAIIBdAAEAIQCbWxz8/lmgQAAAAAAAAA=", + "textDone": [ + { + "peer": "1", + "counter": 9 + } + ], + "beforeMove": [ + { + "peer": "1", + "counter": 12 + } + ], + "text": [ + { + "insert": "hlXo" + } + ], + "list": [ + 1, + 2, + 0 + ] +} diff --git a/loro-js/tests/fixtures/rust/movable-moves.json b/loro-js/tests/fixtures/rust/movable-moves.json new file mode 100644 index 000000000..08d69c7d7 --- /dev/null +++ b/loro-js/tests/fixtures/rust/movable-moves.json @@ -0,0 +1,48 @@ +{ + "moved": "bG9ybwAAAAAAAAAAAAAAAPGc/WcAA4sAAABMT1JPAAAEAAQBEAEBAAAAAAAAAAEBAAAAAAAFAQAAAQAGAQQBBAAAAwJtbAARAQQCBAADAwAEAwMLDgMDAwEACwcDAwADAQMCAAAAAAIAZnIBAQYAAgB2dgEBCAAARwBPAAMAly6NmQEAAAAFAAAADAAAAAAAAAAAAQAAAAAAAgB2dpYo1YdoAAAAXQAAAExPUk8ABAEAAwMCAwQDAAEBAAAAAAAAAAQDAggAAwADAQIABAMCBgACBgICBgACAgEAAgEAAgAAAAABAIJRuMQBAAAABQAAAAQAhAJtbAAEAIQCbWz04+pgQAAAAAAAAAA=", + "inserted": "bG9ybwAAAAAAAAAAAAAAAHaS41QAA5IAAABMT1JPAAAFAAUBEAEBAAAAAAAAAAEBAAAAAAAFAQAAAQAGAQQBBAAAAwJtbAAUAQQCBgAEBQAEAwQFCw4LBAEDBAEADwcDAwADAQMCAAAABwEDCQACAGZyAQEIAAIAdnYBAQoAAE4AVgADAHXqMQIBAAAABQAAAAwAAAAAAAAAAAEAAAAAAAIAdnaWKNWHbwAAAGIAAABMT1JPAAQBAAQDEgMCAwQDAAEBAAAAAAAAAAQDAgoAAwAEAQIABQMCCAAFAwgFBAICCAACAgEAAgEAAgAAAAABAKEP+HwBAAAABQAAAAQAhAJtbAAEAIQCbWz04+pgRQAAAAAAAAA=", + "update": "bG9ybwAAAAAAAAAAAAAAAIkuZRsABEwAAgQCARsCAgAAAAAAAAABAAAAAAAAAAEBAQEBAQYAAAAFAQAAAQAGAQQBBAAAAwJtbAAPAQQCBAACBAADAw4PAgQBAAcCAQABAQMH", + "versions": [ + [ + { + "peer": "1", + "counter": 2 + } + ], + [ + { + "peer": "1", + "counter": 3 + } + ], + [ + { + "peer": "1", + "counter": 4 + } + ] + ], + "values": [ + [ + 0, + 1, + 2 + ], + [ + 1, + 2, + 0 + ], + [ + 9, + 1, + 2, + 0 + ] + ], + "merged": [ + 0, + 7, + 2 + ] +} diff --git a/loro-js/tests/fixtures/rust/rich-text-history.json b/loro-js/tests/fixtures/rust/rich-text-history.json new file mode 100644 index 000000000..85ca0f370 --- /dev/null +++ b/loro-js/tests/fixtures/rust/rich-text-history.json @@ -0,0 +1 @@ +[{"seed":1,"snapshot":"bG9ybwAAAAAAAAAAAAAAAAbY0PgAAx4BAABMT1JPAAAhACEBEAEBAAAAAAAAAAEBAAAAAAAFAQAAAQAGAQQBAgAIFQRrZWVwBGJvbGQDcHJlBGxpbmsBdABNAQQCOAAcBgAEAi0DBAMMAwUBBA4NDAsDDAgMEQwZCgMEHhwXBQwABQwADAAFCQwABgUFCQwACAUDDAAECQQFDRoBAQIIAQQCDAEDAwEQAQMCCAAFBwAQCAYEAQIGAEACw6mGAQADAAFhhAEBAwGCAgIDAgLDqYIFAgMAAsOpAmJjAVqAAQMDAgNhw6kCYmMCw6kBYYIGAgMCA2FiYwFaAAIAZnIBAUAAAgB2dgEBQgAA2gDiAAMANljwRAEAAAAFAAAADAAAAAAAAAAAAQAAAAAAAgB2dpYo1Yf7AAAAsQAAAExPUk8AAgEAD2FhYmNhw6lhYmNiw6laYwEBAAAAAAAAAAMEAiwAGAUCEg4EAiEUCQchBh4IKQYIBBQSIxgpBQIsABgGAAEBBAQBAwQADQEEAwABAAQGAAEDBAAEBGtlZXADcHJlBGxpbmsEYm9sZAYDAAMAhgMBAwCCAwIDBIADAQMEggMBAwSCAwMDAoQAAAEAJoeC5AEAAAAFAAAAAwCCAXQAAwCCAXQKb74AlgAAAAAAAAA=","updates":["bG9ybwAAAAAAAAAAAAAAAOcOZhYABEYAAiECARsCAgAAAAAAAAABAAAAAAAAAAEBAQEBAUAAAAAFAQAAAQAGAQQBAgAAAgF0AA4BBAIBAAIBBAIBBQIBAgADAlIw","bG9ybwAAAAAAAAAAAAAAAFivywgABEoCAyMDAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEQEEAgQAAwMEAgMDBQkDAwIBCwEDAgEAAgEGAgECAwJSMQ==","bG9ybwAAAAAAAAAAAAAAABREjfYABDwFAiYCAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQADgEEAgEAAgEEAgEFAgECAAMCUjI=","bG9ybwAAAAAAAAAAAAAAALTqOhwABEoHAygDAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEQEEAgQAAwMEAgMDBQkDAwIBCwEDAgEAAgEQAgECAwJSMw=="],"versions":[[{"peer":"1","counter":0}],[{"peer":"1","counter":2}],[{"peer":"1","counter":3}],[{"peer":"1","counter":5}],[{"peer":"1","counter":7}],[{"peer":"1","counter":7}],[{"peer":"1","counter":8}],[{"peer":"1","counter":9}],[{"peer":"1","counter":11}],[{"peer":"1","counter":12}],[{"peer":"1","counter":14}],[{"peer":"1","counter":15}],[{"peer":"1","counter":16}],[{"peer":"1","counter":18}],[{"peer":"1","counter":19}],[{"peer":"1","counter":20}],[{"peer":"1","counter":22}],[{"peer":"1","counter":22}],[{"peer":"1","counter":23}],[{"peer":"1","counter":24}],[{"peer":"1","counter":26}],[{"peer":"1","counter":28}],[{"peer":"1","counter":29}],[{"peer":"1","counter":31}],[{"peer":"1","counter":32}]]},{"seed":3,"snapshot":"bG9ybwAAAAAAAAAAAAAAADTDOdkAAzkBAABMT1JPAAAmACYBEAEBAAAAAAAAAAEBAAAAAAAFAQAAAQAGAQQBAgAIFQRib2xkBGtlZXAEbGluawNwcmUBdABYAQQCRgAkBgAPBAMAAgEEBgkEACsMBQMUEQIFDAkCAwIBEA8WFRoHETAFJBkFDAAMAAUMAAUMAAwABAkGBSEMAAUJDAAMAAwADAAFDAAFCQgBAiYBAQMcARABAwIIAAUHABIOHQQBAgYAUAJiY4QCAAMAhAEAAwEBYYYCAQMCAVqAAwIDAYYDAQMAAWECw6kCw6mACgIDAgTDqWJjhA8AAwKAEQIDAYIBAwMBgggDAwIBWoAFAgMCAsOpAAIAZnIBAUoAAgB2dgEBTAAA9QD9AAMALvi64gEAAAAFAAAADAAAAAAAAAAAAQAAAAAAAgB2dpYo1YcWAQAA1AAAAExPUk8AAgEADMOpYcOpYmNaw6nDqQEBAAAAAAAAAAMEAjwAHzsYHhEOCxkCHBAKFxACDxQEKxUQAwcOKgglBhADKQMCPAAeBAATAgEAAgECAQACAwQCFwADAAIAAQIBAAQAAwgABARrZWVwBGxpbmsEYm9sZANwcmULAwADAIYDAQMCgAMCAwSEAwEDBIADAAMEhgMDAwKCAwEDBIADAwMEggMCAwCEAwEDAoADAgMChAAAAQA2hHkLAQAAAAUAAAADAIIBdAADAIIBdApvvgC5AAAAAAAAAA==","updates":["bG9ybwAAAAAAAAAAAAAAAFqDurAABEYAAiYCARsCAgAAAAAAAAABAAAAAAAAAAEBAQEBAUoAAAAFAQAAAQAGAQQBAgAAAgF0AA4BBAIBAAIBAgIBBQIBAgADAlIw","bG9ybwAAAAAAAAAAAAAAAIUDx2kABEkCAygDAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEAEEAgQAAgQCAwMFCQMDAgELAQMCAQACAQYCAQIDAlIx","bG9ybwAAAAAAAAAAAAAAAMJMm/UABDwFAisCAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQADgEEAgEAAgECAgEFAgECAAMCUjI=","bG9ybwAAAAAAAAAAAAAAAHyNlCwABEkHAy0DAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEAEEAgQAAgQCAwMFCQMDAgELAQMCAQACARACAQIDAlIz"],"versions":[[{"peer":"1","counter":1}],[{"peer":"1","counter":3}],[{"peer":"1","counter":5}],[{"peer":"1","counter":6}],[{"peer":"1","counter":8}],[{"peer":"1","counter":9}],[{"peer":"1","counter":11}],[{"peer":"1","counter":13}],[{"peer":"1","counter":15}],[{"peer":"1","counter":16}],[{"peer":"1","counter":17}],[{"peer":"1","counter":18}],[{"peer":"1","counter":20}],[{"peer":"1","counter":21}],[{"peer":"1","counter":23}],[{"peer":"1","counter":24}],[{"peer":"1","counter":24}],[{"peer":"1","counter":26}],[{"peer":"1","counter":28}],[{"peer":"1","counter":30}],[{"peer":"1","counter":32}],[{"peer":"1","counter":33}],[{"peer":"1","counter":35}],[{"peer":"1","counter":36}],[{"peer":"1","counter":37}]]},{"seed":4,"snapshot":"bG9ybwAAAAAAAAAAAAAAADoZNtwAA0gBAABMT1JPAAQiTRhgQIINAQAAkgAqACoBEAEBAAEAAwkA8EsFAQAAAQAGAQQBAgAIFQRib2xkBGtlZXADcHJlBGxpbmsBdABjAQQCTgAmCABFBgUABAMEAxIAEQoJABwBBQsABwACAQIBIB8aGRoZGggJDQkpHwUMAAUJDAACABAFBQBwBAUECQQFIQsABAIAIAUJGgBxAQIEAQECIgQA9AsQAQMCCAAFBwAaFwgEAQIGAFgCYmOEAgADAggA8CWEBAADAYYCAQMBAWGEAQADAIYEAQMCAsOpAVoBYQJiY4IBAgMAgAcDAwGGBgEDAYIGAgMBNADwGQCEBQADAgFhAsOphBgAAwAAAgBmcgEBUgACAHZ2AQFUAAAIARABAwAAAAAAnbK3GQEAAAAFAAAADAAAAAAAAAAAAQAAAAABAgB2dkbrZ9wlAQAA4wAAAExPUk8AAgEACsOpYmJjw6lhYVoBAQAAAAAAAAADBAJCACMdMggDERAcQQUOJg0KFiEeBAMfLw4eCgMrBwseBQoaEjMJDQJCACEGAAkCAwIAAgQBBQYFBAQAAQEIAAEBBgADAgEEAgEDCAAEA3ByZQRrZWVwBGxpbmsEYm9sZA0DAAMAggMBAwKGAwIDAoADAwMAhAMDAwSEAwEDAoYDAwMEhAMDAwCEAwMDAoQDAQMEhgMAAwKCAwMDBIQDAwMAhAAAAQAYHPK4AQAAAAUAAAADAIIBdAADAIIBdApvvgDIAAAAAAAAAA==","updates":["bG9ybwAAAAAAAAAAAAAAAP+t+VQABEYAAioCARsCAgAAAAAAAAABAAAAAAAAAAEBAQEBAVIAAAAFAQAAAQAGAQQBAgAAAgF0AA4BBAIBAAIBBAIBBQIBAgADAlIw","bG9ybwAAAAAAAAAAAAAAAIVRjWQABEoCAywDAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEQEEAgQAAwMEAgMDBQkDAwIBCwEDAgEAAgEGAgECAwJSMQ==","bG9ybwAAAAAAAAAAAAAAAJRhVtoABDwFAi8CAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQADgEEAgEAAgEEAgEFAgECAAMCUjI=","bG9ybwAAAAAAAAAAAAAAAE8f+0oABEoHAzEDAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEQEEAgQAAwMEAgMDBQkDAwIBCwEDAgEAAgEQAgECAwJSMw=="],"versions":[[{"peer":"1","counter":1}],[{"peer":"1","counter":3}],[{"peer":"1","counter":5}],[{"peer":"1","counter":6}],[{"peer":"1","counter":8}],[{"peer":"1","counter":10}],[{"peer":"1","counter":12}],[{"peer":"1","counter":13}],[{"peer":"1","counter":15}],[{"peer":"1","counter":17}],[{"peer":"1","counter":18}],[{"peer":"1","counter":19}],[{"peer":"1","counter":21}],[{"peer":"1","counter":22}],[{"peer":"1","counter":24}],[{"peer":"1","counter":26}],[{"peer":"1","counter":28}],[{"peer":"1","counter":30}],[{"peer":"1","counter":32}],[{"peer":"1","counter":34}],[{"peer":"1","counter":36}],[{"peer":"1","counter":37}],[{"peer":"1","counter":38}],[{"peer":"1","counter":39}],[{"peer":"1","counter":41}]]},{"seed":5,"snapshot":"bG9ybwAAAAAAAAAAAAAAAKjmQ3UAAzgBAABMT1JPAAAlACUBEAEBAAAAAAAAAAEBAAAAAAAFAQAAAQAGAQQBAgAIFQRsaW5rBGtlZXADcHJlBGJvbGQBdABaAQQCRgAmBwACAQQEAQYAAwwBBAMBAQQAJQIAAQIBCgIBCQoACQwABgMNHBskAwUJBAUJDAAFDAAICR0FDAAFDAAMAAUJDAAFDAAGBQcMAAwACAECMAEBAhIBEgEDAgwABwsCBgcGCA4EAQIKAEsDWsOpAVoBWoACAAMBAsOpgAEAAwABYYYBAQMAAsOpgAEAAwGCBAIDAALDqYQBAwMCAmJjhgEBAwIBYQLDqQLDqYICAgMBhAIDAwEAAgBmcgEBSAACAHZ2AQFKAAD0APwAAwAfEJWFAQAAAAUAAAAMAAAAAAAAAAABAAAAAAACAHZ2lijVhxUBAADAAAAATE9STwACAQALw6nDqWFiw6ljw6kBAQAAAAAAAAADBAIyABoxHgoDAQQSBgQFBxADBQMQFQMcFQcJDwIHAgIyABoGAAMCAwQCFQECAAMEAwQBAAIDBgAHAgECAQQEa2VlcANwcmUEbGluawRib2xkCQMAAwCGAwEDAIIDAgMCgAMAAwSGAwEDAoIDAwMChAMDAwSEAwIDAIADAgMCgAAAAQBnZ3irAQAAAAUAAAADAIIBdAADAIIBdApvvgClAAAAAAAAAA==","updates":["bG9ybwAAAAAAAAAAAAAAAIvNrsoABEYAAiUCARsCAgAAAAAAAAABAAAAAAAAAAEBAQEBAUgAAAAFAQAAAQAGAQQBAgAAAgF0AA4BBAIBAAIBBAIBBQIBAgADAlIw","bG9ybwAAAAAAAAAAAAAAAD/w5ecABEoCAycDAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEQEEAgQAAwMEAgMDBQkDAwIBCwEDAgEAAgEGAgECAwJSMQ==","bG9ybwAAAAAAAAAAAAAAAJFFvpUABDwFAioCAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQADgEEAgEAAgEEAgEFAgECAAMCUjI=","bG9ybwAAAAAAAAAAAAAAAEu0xFMABEoHAywDAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEQEEAgQAAwMEAgMDBQkDAwIBCwEDAgEAAgEQAgECAwJSMw=="],"versions":[[{"peer":"1","counter":0}],[{"peer":"1","counter":1}],[{"peer":"1","counter":2}],[{"peer":"1","counter":3}],[{"peer":"1","counter":4}],[{"peer":"1","counter":6}],[{"peer":"1","counter":7}],[{"peer":"1","counter":9}],[{"peer":"1","counter":11}],[{"peer":"1","counter":13}],[{"peer":"1","counter":14}],[{"peer":"1","counter":16}],[{"peer":"1","counter":17}],[{"peer":"1","counter":19}],[{"peer":"1","counter":21}],[{"peer":"1","counter":22}],[{"peer":"1","counter":23}],[{"peer":"1","counter":25}],[{"peer":"1","counter":27}],[{"peer":"1","counter":29}],[{"peer":"1","counter":30}],[{"peer":"1","counter":31}],[{"peer":"1","counter":32}],[{"peer":"1","counter":34}],[{"peer":"1","counter":36}]]},{"seed":7,"snapshot":"bG9ybwAAAAAAAAAAAAAAADJHFZcAAxgBAABMT1JPAAAhACEBEAEBAAAAAAAAAAEBAAAAAAAFAQAAAQAGAQQBAgAGEANwcmUEYm9sZARrZWVwAXQATgEEAjwAHwYADQYCAQUGAgcEACMMCwAQAAcFAQoJABoDFQgFEAwcCAUECQQFCwwADAAMAAYFEwkMAAkMAAkMAAkGBQsDAQIaAQECGgEBAhIBAwIMAAcLAAgFDgsKBAECCgA8AWEDWsOpAsOpAWEBYQFhggEAAwGEBQEDAoYDAgMCAsOpAmJjAVqGBwIDAYYOAgMCggEAAwIBWgFaAmJjAAIAZnIBAUAAAgB2dgEBQgAA1ADcAAMA11yBQQEAAAAFAAAADAAAAAAAAAAAAQAAAAAAAgB2dpYo1Yf1AAAAoQAAAExPUk8AAgEAC1rDqcOpWlpiY2JjAQEAAAAAAAAAAwQCKAAVJy4MGwoRDwwIChgRCRQRFAglAxwTAigAFCEAAgABAAIBAAIAAwQBAgMGBQYAAwRrZWVwBGJvbGQDcHJlBgMAAwSGAwADAoYDAQMEhAMCAwKCAwADBIYDAgMEggAAAQCxynr9AQAAAAUAAAADAIIBdAADAIIBdApvvgCGAAAAAAAAAA==","updates":["bG9ybwAAAAAAAAAAAAAAANxyj0IABEYAAiECARsCAgAAAAAAAAABAAAAAAAAAAEBAQEBAUAAAAAFAQAAAQAGAQQBAgAAAgF0AA4BBAIBAAIBAgIBBQIBAgADAlIw","bG9ybwAAAAAAAAAAAAAAAFctYiQABEkCAyMDAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEAEEAgQAAgQCAwMFCQMDAgELAQMCAQACAQYCAQIDAlIx","bG9ybwAAAAAAAAAAAAAAADxgvIEABDwFAiYCAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQADgEEAgEAAgECAgEFAgECAAMCUjI=","bG9ybwAAAAAAAAAAAAAAAFs1HskABEkHAygDAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEAEEAgQAAgQCAwMFCQMDAgELAQMCAQACARACAQIDAlIz"],"versions":[[{"peer":"1","counter":0}],[{"peer":"1","counter":1}],[{"peer":"1","counter":2}],[{"peer":"1","counter":3}],[{"peer":"1","counter":4}],[{"peer":"1","counter":6}],[{"peer":"1","counter":7}],[{"peer":"1","counter":8}],[{"peer":"1","counter":10}],[{"peer":"1","counter":12}],[{"peer":"1","counter":14}],[{"peer":"1","counter":15}],[{"peer":"1","counter":17}],[{"peer":"1","counter":18}],[{"peer":"1","counter":19}],[{"peer":"1","counter":21}],[{"peer":"1","counter":21}],[{"peer":"1","counter":22}],[{"peer":"1","counter":24}],[{"peer":"1","counter":25}],[{"peer":"1","counter":27}],[{"peer":"1","counter":28}],[{"peer":"1","counter":29}],[{"peer":"1","counter":30}],[{"peer":"1","counter":32}]]},{"seed":11,"snapshot":"bG9ybwAAAAAAAAAAAAAAAE9TIvkAAy0BAABMT1JPAAAjACMBEAEBAAAAAAAAAAEBAAAAAAAFAQAAAQAGAQQBAgAGEQRsaW5rBGJvbGQEa2VlcAF0AFkBBAJAACAIAAUGAwIEAAQBBAAlAggACQYDAQIBBAMEAwAIBg0UEyMDBQkGBQQJBwUJDAAEBQcJBQwABAkZBQwADAAFDAAFDAAMAA4EAQECFgEBAhIBAQIOARQBAwIOAAkDAAQEBgUIEQIEAQIMAEMBWgJiYwLDqQLDqQLDqYABAAMCAsOpAsOpAmJjhAEBAwECw6mGBQIDAoQFAQMAAmJjgAEAAwACw6mGBgIDAYAEAAMBAAIAZnIBAUQAAgB2dgEBRgAA6QDxAAMAKQhtgAEAAAAFAAAADAAAAAAAAAAAAQAAAAAAAgB2dpYo1YcKAQAApwAAAExPUk8AAgEACsOpw6liY8OpYmMBAQAAAAAAAAADBAIoABUnOA0QDRAHCg0VKC0CDAMmAw0DCQMCKAAVBQACAwYCCwMAAgEAAQQCAQMIAAEEAwRsaW5rBGtlZXAEYm9sZAcDAAMAgAMBAwSGAwEDAoYDAgMAhAMAAwKAAwADBIADAgMChAAAAQAcdZYLAQAAAAUAAAADAIIBdAADAIIBdApvvgCMAAAAAAAAAA==","updates":["bG9ybwAAAAAAAAAAAAAAAGhqCowABEYAAiMCARsCAgAAAAAAAAABAAAAAAAAAAEBAQEBAUQAAAAFAQAAAQAGAQQBAgAAAgF0AA4BBAIBAAIBAAIBBQIBAgADAlIw","bG9ybwAAAAAAAAAAAAAAAHg54UIABEoCAyUDAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEQEEAgQAAwMAAgMDBQkDAwIBCwEDAgEAAgEGAgECAwJSMQ==","bG9ybwAAAAAAAAAAAAAAALIDhVMABDwFAigCAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQADgEEAgEAAgEAAgEFAgECAAMCUjI=","bG9ybwAAAAAAAAAAAAAAAM5EkwYABEoHAyoDAREBAgAAAAAAAAAAAQEAAAAAAAUBAAABAAYBBAECAAACAXQAEQEEAgQAAwMAAgMDBQkDAwIBCwEDAgEAAgEQAgECAwJSMw=="],"versions":[[{"peer":"1","counter":0}],[{"peer":"1","counter":1}],[{"peer":"1","counter":3}],[{"peer":"1","counter":4}],[{"peer":"1","counter":5}],[{"peer":"1","counter":6}],[{"peer":"1","counter":7}],[{"peer":"1","counter":8}],[{"peer":"1","counter":9}],[{"peer":"1","counter":11}],[{"peer":"1","counter":12}],[{"peer":"1","counter":13}],[{"peer":"1","counter":14}],[{"peer":"1","counter":16}],[{"peer":"1","counter":18}],[{"peer":"1","counter":20}],[{"peer":"1","counter":21}],[{"peer":"1","counter":23}],[{"peer":"1","counter":25}],[{"peer":"1","counter":27}],[{"peer":"1","counter":27}],[{"peer":"1","counter":29}],[{"peer":"1","counter":30}],[{"peer":"1","counter":32}],[{"peer":"1","counter":34}]]}] diff --git a/loro-js/tests/fixtures/rust/styled-text.blob b/loro-js/tests/fixtures/rust/styled-text.blob new file mode 100644 index 000000000..67412c643 Binary files /dev/null and b/loro-js/tests/fixtures/rust/styled-text.blob differ diff --git a/loro-js/tests/fixtures/rust/styled-text.expected.json b/loro-js/tests/fixtures/rust/styled-text.expected.json new file mode 100644 index 000000000..750e98f8c --- /dev/null +++ b/loro-js/tests/fixtures/rust/styled-text.expected.json @@ -0,0 +1,154 @@ +[ + { + "frontiers": [ + { + "peer": "1", + "counter": 2 + } + ], + "both": [ + { + "insert": "a", + "attributes": { + "keep": "yes" + } + } + ], + "after": [] + }, + { + "frontiers": [ + { + "peer": "1", + "counter": 3 + } + ], + "both": [], + "after": [] + }, + { + "frontiers": [ + { + "peer": "1", + "counter": 4 + } + ], + "both": [ + { + "insert": "b", + "attributes": { + "keep": "yes" + } + } + ], + "after": [] + }, + { + "frontiers": [ + { + "peer": "1", + "counter": 9 + } + ], + "both": [ + { + "insert": "b", + "attributes": { + "keep": "yes" + } + } + ], + "after": [ + { + "insert": "hello" + } + ] + }, + { + "frontiers": [ + { + "peer": "1", + "counter": 11 + } + ], + "both": [ + { + "insert": "b", + "attributes": { + "keep": "yes" + } + } + ], + "after": [ + { + "insert": "h" + }, + { + "insert": "el", + "attributes": { + "bold": true + } + }, + { + "insert": "lo" + } + ] + }, + { + "frontiers": [ + { + "peer": "1", + "counter": 12 + } + ], + "both": [ + { + "insert": "b", + "attributes": { + "keep": "yes" + } + } + ], + "after": [ + { + "insert": "h" + }, + { + "insert": "el", + "attributes": { + "bold": true + } + }, + { + "insert": "lXo" + } + ] + }, + { + "frontiers": [ + { + "peer": "1", + "counter": 13 + } + ], + "both": [ + { + "insert": "b", + "attributes": { + "keep": "yes" + } + } + ], + "after": [ + { + "insert": "el", + "attributes": { + "bold": true + } + }, + { + "insert": "lXo" + } + ] + } +] diff --git a/loro-js/tests/snapshot-checkout.test.ts b/loro-js/tests/snapshot-checkout.test.ts new file mode 100644 index 000000000..4d2619b14 --- /dev/null +++ b/loro-js/tests/snapshot-checkout.test.ts @@ -0,0 +1,775 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, test, vi } from "vitest"; + +import { + LoroDoc, + LoroList, + LoroMap, + LoroText, + type Delta, + type Diff, + type Frontiers, + type LoroEventBatch, +} from "../src/index"; +import { SequenceIndex } from "../src/runtime/sequence-index"; + +/** + * A snapshot import keeps non-root containers encoded until they are first read. + * Checkout must not hydrate such a container with its latest state after moving + * the document to another version. + */ +function buildHistory(): { doc: LoroDoc; versions: Frontiers[] } { + const doc = new LoroDoc(); + doc.setPeerId(1); + const root = doc.getMap("root"); + const map = root.setContainer("map", new LoroMap()); + const list = root.setContainer("list", new LoroList()); + const text = root.setContainer("text", new LoroText()); + map.set("a", 1); + list.push(1); + text.insert(0, "abc"); + doc.commit(); + const versions = [doc.frontiers()]; + map.set("a", 2); + map.set("b", 3); + list.push(2); + text.insert(0, "X"); + doc.commit(); + versions.push(doc.frontiers()); + map.delete("b"); + list.delete(0, 1); + text.delete(1, 2); + doc.commit(); + versions.push(doc.frontiers()); + return { doc, versions }; +} + +const valuesAt = (doc: LoroDoc, versions: readonly Frontiers[]): unknown[] => + versions.map((version) => { + doc.checkout(version); + return doc.toJSON(); + }); + +describe("checkout after a lazy snapshot import", () => { + test("matches the full-history document at every version", () => { + const { doc, versions } = buildHistory(); + const expected = valuesAt(doc, versions); + doc.checkoutToLatest(); + const bytes = doc.export({ mode: "snapshot" }); + + // Checking out before anything is read exercises both the version transition + // and the full-replay paths on unhydrated children. + for (const order of [versions, [...versions].reverse()]) { + const loaded = new LoroDoc(); + loaded.import(bytes); + const expectedInOrder = order.map((version) => expected[versions.indexOf(version)]); + expect(valuesAt(loaded, order)).toEqual(expectedInOrder); + loaded.checkoutToLatest(); + expect(loaded.toJSON()).toEqual(doc.toJSON()); + } + }); + + test("reads a child first touched after checkout at its historical state", () => { + const { doc, versions } = buildHistory(); + const loaded = new LoroDoc(); + loaded.import(doc.export({ mode: "snapshot" })); + loaded.checkout(versions[0]!); + const root = loaded.getMap("root"); + expect((root.get("map") as LoroMap).toJSON()).toEqual({ a: 1 }); + expect((root.get("list") as LoroList).toJSON()).toEqual([1]); + expect((root.get("text") as LoroText).toString()).toBe("abc"); + }); + + test("keeps exported snapshots consistent after checkout round trips", () => { + const { doc, versions } = buildHistory(); + const loaded = new LoroDoc(); + loaded.import(doc.export({ mode: "snapshot" })); + loaded.checkout(versions[0]!); + loaded.checkoutToLatest(); + const again = new LoroDoc(); + again.import(loaded.export({ mode: "snapshot" })); + expect(again.toJSON()).toEqual(doc.toJSON()); + again.checkout(versions[1]!); + doc.checkout(versions[1]!); + expect(again.toJSON()).toEqual(doc.toJSON()); + }); + + test("checks out a shallow snapshot through its retained history", () => { + const { doc, versions } = buildHistory(); + const loaded = new LoroDoc(); + loaded.import(doc.export({ mode: "shallow-snapshot", frontiers: versions[0]! })); + const expected = valuesAt(doc, versions); + expect(valuesAt(loaded, versions)).toEqual(expected); + }); +}); + +/** + * `styled-text.blob` is a Rust snapshot (peer 1, `bold` expands after, `keep` + * expands both ways). Versions, one commit each: + * 0. `both`: insert "a", mark it `keep`. + * 1. `both`: delete "a", leaving an empty both-expand pair. + * 2. `both`: insert "b" at 0; Rust places it inside the pair, so it is kept. + * 3. `after`: insert "hello". + * 4. `after`: mark 1..3 `bold`. + * 5. `after`: insert "X" at 4, after the end anchor of the mark. + * 6. `after`: delete 0..1. + * The expected file holds the Rust `toDelta()` of both texts at each version. + * + * loro.js does not count style anchors in Text positions, so replaying either + * text differs from its snapshot state. Both keep the snapshot state; an older + * version is approximate when it needs text deleted before the snapshot (it + * is exact at versions 1, 2, and 6), but the latest state never changes. + */ +interface StyledTextVersion { + readonly frontiers: Frontiers; + readonly both: Delta[]; + readonly after: Delta[]; +} + +const styledText = (): { bytes: Uint8Array; versions: StyledTextVersion[] } => ({ + bytes: new Uint8Array( + readFileSync(new URL("./fixtures/rust/styled-text.blob", import.meta.url)), + ), + versions: JSON.parse( + readFileSync( + new URL("./fixtures/rust/styled-text.expected.json", import.meta.url), + "utf8", + ), + ) as StyledTextVersion[], +}); + +const EXACT_VERSIONS = new Set([1, 2, 6]); + +const mirrorOf = (doc: LoroDoc): LoroDoc => { + const mirror = new LoroDoc(); + mirror.configTextStyle({ keep: { expand: "both" }, bold: { expand: "after" } }); + mirror.getText("both").applyDelta(doc.getText("both").toDelta()); + mirror.getText("after").applyDelta(doc.getText("after").toDelta()); + doc.subscribe((batch: LoroEventBatch) => { + mirror.applyDiff(batch.events.map(({ target, diff }) => [target, diff])); + }); + return mirror; +}; + +describe("checkout after a Rust rich-text snapshot import", () => { + test("keeps snapshot styles and text that a history replay cannot reproduce", () => { + const { bytes, versions } = styledText(); + const latest = versions.at(-1)!; + for (const order of [ + [0, 1, 2, 3, 4, 5, 6], + [6, 5, 4, 3, 2, 1, 0], + [1, 6, 2, 5, 0, 3, 6, 4], + ]) { + const doc = new LoroDoc(); + doc.import(bytes); + const mirror = mirrorOf(doc); + const exact: { index: number; both: unknown; after: unknown }[] = []; + const check = (index: number): void => { + const both = doc.getText("both").toDelta(); + const after = doc.getText("after").toDelta(); + if (EXACT_VERSIONS.has(index)) exact.push({ index, both, after }); + expect(mirror.getText("both").toDelta()).toEqual(both); + expect(mirror.getText("after").toDelta()).toEqual(after); + }; + for (const index of order) { + doc.checkout(versions[index]!.frontiers); + check(index); + } + doc.checkoutToLatest(); + check(versions.length - 1); + expect(exact).toEqual( + exact.map(({ index }) => ({ + index, + both: versions[index]!.both, + after: versions[index]!.after, + })), + ); + + const again = new LoroDoc(); + again.import(doc.export({ mode: "snapshot" })); + expect(again.getText("both").toDelta()).toEqual(latest.both); + expect(again.getText("after").toDelta()).toEqual(latest.after); + } + }); + + test("returns to the snapshot state after edits and imports elsewhere", () => { + const { bytes, versions } = styledText(); + const latest = versions.at(-1)!; + const doc = new LoroDoc(); + doc.import(bytes); + const remote = new LoroDoc(); + remote.import(bytes); + remote.setPeerId(2); + remote.getMap("other").set("remote", 1); + remote.commit(); + + doc.checkout(versions[4]!.frontiers); + doc.checkoutToLatest(); + doc.getMap("other").set("local", 1); + doc.commit(); + doc.checkout(versions[2]!.frontiers); + doc.import(remote.export({ mode: "update", from: doc.oplogVersion() })); + doc.checkoutToLatest(); + expect(doc.getText("both").toDelta()).toEqual(latest.both); + expect(doc.getText("after").toDelta()).toEqual(latest.after); + expect(doc.getMap("other").toJSON()).toEqual({ local: 1, remote: 1 }); + + doc.getText("after").insert(0, "A"); + doc.commit(); + expect(doc.getText("after").toString()).toBe("AellXo"); + doc.checkout(versions[6]!.frontiers); + expect(doc.getText("after").toDelta()).toEqual(latest.after); + }); + + test("exports the latest state from every detached checkout", () => { + const { bytes, versions } = styledText(); + const latest = versions.at(-1)!; + const doc = new LoroDoc(); + doc.import(bytes); + for (const version of [...versions].reverse()) { + doc.checkout(version.frontiers); + const again = new LoroDoc(); + again.import(doc.export({ mode: "snapshot" })); + expect(again.getText("both").toDelta()).toEqual(latest.both); + expect(again.getText("after").toDelta()).toEqual(latest.after); + } + doc.checkoutToLatest(); + expect(doc.getText("both").toDelta()).toEqual(latest.both); + expect(doc.getText("after").toDelta()).toEqual(latest.after); + }); + + test("keeps the latest state exact through detached imports and local edits", () => { + const { bytes, versions } = styledText(); + const latest = versions.at(-1)!; + const remote = new LoroDoc(); + remote.import(bytes); + remote.setPeerId(2); + remote.getText("after").insert(0, "A"); + remote.commit(); + const update = remote.export({ mode: "update", from: new LoroDoc().oplogVersion() }); + const withA: Delta[] = [{ insert: "A" }, ...latest.after]; + for (const mode of ["import", "importBatch", "attached"] as const) { + const doc = new LoroDoc(); + doc.import(bytes); + const mirror = mirrorOf(doc); + doc.checkout(versions[4]!.frontiers); + doc.checkoutToLatest(); + if (mode !== "attached") doc.detach(); + if (mode === "importBatch") doc.importBatch([update]); + else doc.import(update); + doc.attach(); + expect(doc.getText("after").toDelta()).toEqual(withA); + expect(mirror.getText("after").toDelta()).toEqual(withA); + const again = new LoroDoc(); + again.import(doc.export({ mode: "snapshot" })); + expect(again.getText("after").toDelta()).toEqual(withA); + } + + const doc = new LoroDoc(); + doc.import(bytes); + doc.checkout(versions[4]!.frontiers); + doc.checkoutToLatest(); + doc.getText("after").insert(0, "A"); + doc.commit(); + doc.checkout(versions[4]!.frontiers); + doc.checkout(versions[6]!.frontiers); + expect(doc.getText("after").toDelta()).toEqual(latest.after); + doc.checkoutToLatest(); + expect(doc.getText("after").toDelta()).toEqual(withA); + const fork = doc.fork(); + expect(fork.getText("after").toDelta()).toEqual(withA); + expect(doc.forkAt(versions[6]!.frontiers).getText("after").toDelta()).toEqual( + latest.after, + ); + }); + + test("keeps the snapshot state through a shallow export", () => { + const { bytes, versions } = styledText(); + const latest = versions.at(-1)!; + for (const read of [false, true]) { + const doc = new LoroDoc(); + doc.import(bytes); + if (read) doc.toJSON(); + const shallow = new LoroDoc(); + shallow.import( + doc.export({ mode: "shallow-snapshot", frontiers: versions[3]!.frontiers }), + ); + for (const replica of [doc, shallow]) { + expect(replica.getText("both").toDelta()).toEqual(latest.both); + expect(replica.getText("after").toDelta()).toEqual(latest.after); + } + } + }); + + test("diffs a Rust snapshot like a full-history document", () => { + const { bytes, versions } = styledText(); + const doc = new LoroDoc(); + doc.import(bytes); + const diff = doc.diff(versions[6]!.frontiers, versions[1]!.frontiers, false); + expect(doc.getText("both").toDelta()).toEqual(versions[6]!.both); + const mirror = new LoroDoc(); + mirror.configTextStyle({ keep: { expand: "both" }, bold: { expand: "after" } }); + mirror.getText("both").applyDelta(versions[6]!.both); + mirror.getText("after").applyDelta(versions[6]!.after); + mirror.applyDiff(diff); + expect(mirror.getText("both").toDelta()).toEqual(versions[1]!.both); + expect(mirror.getText("after").toDelta()).toEqual(versions[1]!.after); + }); + + test("forks an older version from the fork's own history", () => { + const { bytes, versions } = styledText(); + const deltas = (doc: LoroDoc): unknown => [ + doc.getText("both").toDelta(), + doc.getText("after").toDelta(), + ]; + const doc = new LoroDoc(); + doc.import(bytes); + doc.checkout(versions[4]!.frontiers); + doc.checkoutToLatest(); + for (const index of [0, 3, 4, 5]) { + const frontiers = versions[index]!.frontiers; + doc.checkout(frontiers); + const checkedOut = doc.fork(); + doc.checkoutToLatest(); + for (const fork of [doc.forkAt(frontiers), checkedOut]) { + // The fork has no operation after its version, so its state is the + // replay of its own operations: exports, imports, and local edits agree + // with a document that only replays them. + const replay = new LoroDoc(); + replay.configTextStyle({ keep: { expand: "both" }, bold: { expand: "after" } }); + replay.import(fork.export({ mode: "update" })); + expect(deltas(fork)).toEqual(deltas(replay)); + fork.import(bytes); + replay.import(bytes); + expect(deltas(fork)).toEqual(deltas(replay)); + fork.getText("after").insert(0, "Q"); + fork.commit(); + replay.import(fork.export({ mode: "update", from: replay.oplogVersion() })); + expect(deltas(fork)).toEqual(deltas(replay)); + } + } + // Before the insert next to the mark, the replay matches Rust. + expect(doc.forkAt(versions[4]!.frontiers).getText("after").toDelta()).toEqual( + versions[4]!.after, + ); + }); + + test("keeps the snapshot state when checking a replay throws", () => { + const { bytes, versions } = styledText(); + const latest = versions.at(-1)!; + const failOnce = ( + target: object, + method: "visibleIdRuns" | "_swapState", + call: number, + ): (() => void) => { + const original = (target as Record unknown>)[ + method + ]!; + let calls = 0; + const spy = vi + .spyOn(target as Record unknown>, method) + .mockImplementation(function (this: unknown, ...args: unknown[]) { + calls += 1; + if (calls === call) throw new Error("injected"); + return original.apply(this, args); + }); + return () => spy.mockRestore(); + }; + const failures: unknown[] = []; + for (const operation of ["checkout", "diff"] as const) { + for (const method of ["visibleIdRuns", "_swapState"] as const) { + for (let call = 1; call <= 4; call += 1) { + const doc = new LoroDoc(); + doc.import(bytes); + const text = doc.getText("after"); + const restore = failOnce( + method === "visibleIdRuns" ? SequenceIndex.prototype : text, + method, + call, + ); + try { + if (operation === "checkout") doc.checkout(versions[4]!.frontiers); + else doc.diff(latest.frontiers, versions[4]!.frontiers, false); + } catch (error) { + failures.push({ + message: (error as Error).message, + detached: doc.isDetached(), + after: text.toDelta(), + }); + } finally { + restore(); + } + // A retry and the export still see the snapshot state. + doc.checkout(versions[4]!.frontiers); + doc.checkoutToLatest(); + expect(text.toDelta()).toEqual(latest.after); + const again = new LoroDoc(); + again.import(doc.export({ mode: "snapshot" })); + expect(again.getText("after").toDelta()).toEqual(latest.after); + } + } + } + expect(failures.length).toBeGreaterThan(0); + expect(failures).toEqual( + failures.map(() => ({ message: "injected", detached: false, after: latest.after })), + ); + }); +}); + +/** + * `rich-text-history.json`: for each seed, a Rust snapshot of 25 random Text + * inserts, deletes, and marks (bold/link/keep/pre expand after/none/both/ + * before), four Rust updates from another peer that insert at the start, and + * the snapshot's versions. + */ +interface RichTextHistory { + readonly seed: number; + readonly snapshot: string; + readonly updates: readonly string[]; + readonly versions: readonly Frontiers[]; +} + +const decodeBase64 = (text: string): Uint8Array => + new Uint8Array(Buffer.from(text, "base64")); + +const canonicalDelta = (delta: readonly Delta[]): unknown => + delta.map((item) => + "attributes" in item && item.attributes !== undefined + ? { + ...item, + attributes: Object.fromEntries( + Object.entries(item.attributes).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ), + } + : item, + ); + +describe("latest state of Rust rich text across checkouts", () => { + const histories = JSON.parse( + readFileSync( + new URL("./fixtures/rust/rich-text-history.json", import.meta.url), + "utf8", + ), + ) as RichTextHistory[]; + for (const history of histories) { + test(`checkouts and imports keep the imported latest state (seed ${history.seed})`, () => { + const snapshot = decodeBase64(history.snapshot); + const updates = history.updates.map(decodeBase64); + const reference = new LoroDoc(); + reference.import(snapshot); + for (const update of updates) reference.import(update); + const expected = canonicalDelta(reference.getText("t").toDelta()); + + let state = history.seed; + const random = (): number => { + state = (state * 1_103_515_245 + 12_345) % 2_147_483_648; + return state / 2_147_483_648; + }; + for (const subscribed of [false, true]) { + const doc = new LoroDoc(); + doc.import(snapshot); + const mirror = new LoroDoc(); + mirror.configTextStyle({ + bold: { expand: "after" }, + link: { expand: "none" }, + keep: { expand: "both" }, + pre: { expand: "before" }, + }); + mirror.getText("t").applyDelta(doc.getText("t").toDelta()); + if (subscribed) { + doc.subscribe((batch: LoroEventBatch) => { + mirror.applyDiff(batch.events.map(({ target, diff }) => [target, diff])); + }); + } + const versions = [...history.versions]; + const mirrorMismatches: number[] = []; + let next = 0; + for (let step = 0; step < 16; step += 1) { + const choice = random(); + if (choice < 0.5) { + doc.checkout(versions[Math.floor(random() * versions.length)]!); + } else if (choice < 0.65) { + doc.checkoutToLatest(); + } else if (next < updates.length) { + if (random() < 0.5) doc.detach(); + if (choice < 0.8) doc.import(updates[next++]!); + else doc.importBatch([updates[next++]!]); + versions.push(doc.oplogFrontiers()); + } + if ( + subscribed && + JSON.stringify(canonicalDelta(mirror.getText("t").toDelta())) !== + JSON.stringify(canonicalDelta(doc.getText("t").toDelta())) + ) { + mirrorMismatches.push(step); + } + } + expect(mirrorMismatches).toEqual([]); + while (next < updates.length) doc.import(updates[next++]!); + doc.attach(); + expect(canonicalDelta(doc.getText("t").toDelta())).toEqual(expected); + const again = new LoroDoc(); + again.import(doc.export({ mode: "snapshot" })); + expect(canonicalDelta(again.getText("t").toDelta())).toEqual(expected); + } + }); + } +}); + +/** + * `movable-moves.json` is a Rust history of MovableList `ml` (peer 1): insert + * 0, 1, 2 (version 0); move(0, 2) (version 1, snapshot `moved`); insert(0, 9) + * (version 2, snapshot `inserted`). `values` are Rust's values at each version. + * `update` is peer 2's move(2, 0) and set(1, 7) on `moved`, and `merged` is + * Rust's value after it. + */ +interface MovableMoves { + readonly moved: string; + readonly inserted: string; + readonly update: string; + readonly versions: readonly Frontiers[]; + readonly values: readonly (readonly number[])[]; + readonly merged: readonly number[]; +} + +const applyListDiff = (values: readonly unknown[], diff: Diff): unknown[] => { + if (diff.type !== "list") throw new Error(`unexpected ${diff.type} diff`); + const result: unknown[] = []; + let index = 0; + for (const item of diff.diff) { + if ("retain" in item) { + result.push(...values.slice(index, index + item.retain)); + index += item.retain; + } else if ("delete" in item) { + index += item.delete; + } else { + result.push(...item.insert); + } + } + return [...result, ...values.slice(index)]; +}; + +describe("checkout after a Rust MovableList snapshot with a move", () => { + const fixture = JSON.parse( + readFileSync(new URL("./fixtures/rust/movable-moves.json", import.meta.url), "utf8"), + ) as MovableMoves; + const { versions, values } = fixture; + const load = (snapshot = fixture.moved): LoroDoc => { + const doc = new LoroDoc(); + doc.import(decodeBase64(snapshot)); + return doc; + }; + + // The hydrated state names a moved element by its Rust position id, so the + // list takes the replay of its history, as on main. + test("checks out, diffs, forks, and reverts to the version before the move", () => { + const doc = load(); + const list = doc.getMovableList("ml"); + let mirror: unknown[] = list.toJSON(); + doc.subscribe((batch: LoroEventBatch) => { + for (const event of batch.events) { + if (event.target === list.id) mirror = applyListDiff(mirror, event.diff); + } + }); + for (const [frontiers, value] of [ + [versions[0]!, values[0]!], + [versions[1]!, values[1]!], + [versions[0]!, values[0]!], + ] as const) { + doc.checkout(frontiers); + expect(list.toJSON()).toEqual(value); + expect(mirror).toEqual(value); + } + doc.checkoutToLatest(); + expect(list.toJSON()).toEqual(values[1]); + expect(mirror).toEqual(values[1]); + + const diffed = load(); + const diff = diffed + .diff(versions[1]!, versions[0]!, false) + .find(([id]) => id === diffed.getMovableList("ml").id); + expect(applyListDiff(values[1]!, diff![1])).toEqual(values[0]); + expect(diffed.getMovableList("ml").toJSON()).toEqual(values[1]); + + expect(load().forkAt(versions[0]!).getMovableList("ml").toJSON()).toEqual(values[0]); + + const reverted = load(); + reverted.revertTo(versions[0]!); + reverted.commit(); + expect(reverted.getMovableList("ml").toJSON()).toEqual(values[0]); + const again = new LoroDoc(); + again.import(reverted.export({ mode: "snapshot" })); + expect(again.getMovableList("ml").toJSON()).toEqual(values[0]); + }); + + test("applies a later move by element after a checkout round trip", () => { + const doc = load(); + doc.checkout(versions[0]!); + doc.checkoutToLatest(); + doc.import(decodeBase64(fixture.update)); + expect(doc.getMovableList("ml").toJSON()).toEqual(fixture.merged); + }); + + test("moves by delta once the first checkout replayed it", () => { + const doc = load(); + const list = doc.getMovableList("ml"); + doc.checkout(versions[0]!); + doc.checkoutToLatest(); + const reset = vi.spyOn(list, "_reset"); + for (let round = 0; round < 3; round += 1) { + doc.checkout(versions[0]!); + expect(list.toJSON()).toEqual(values[0]); + doc.checkoutToLatest(); + expect(list.toJSON()).toEqual(values[1]); + } + expect(reset).not.toHaveBeenCalled(); + }); + + test("forks before the move from the fork's own history", () => { + for (const roundTrip of [false, true]) { + const doc = load(fixture.inserted); + if (roundTrip) { + doc.checkout(versions[0]!); + doc.checkoutToLatest(); + } + const fork = doc.forkAt(versions[0]!); + expect(fork.getMovableList("ml").toJSON()).toEqual(values[0]); + fork.import(decodeBase64(fixture.inserted)); + expect(fork.getMovableList("ml").toJSON()).toEqual(values[2]); + expect(fork.oplogFrontiers()).toEqual(doc.oplogFrontiers()); + } + }); +}); + +/** + * `deleted-mark.json` is a Rust snapshot (peer 1, `bold` expands after). Text + * `t` gets "hello", bold on 1..3, and "X" at 4, then loses 1..3, so no style is + * left: "hlXo" (`textDone`). MovableList `ml` then gets 0, 1, 2 (`beforeMove`) + * and one move, so a checkout across the move replays the whole document. + */ +interface DeletedMark { + readonly snapshot: string; + readonly textDone: Frontiers; + readonly beforeMove: Frontiers; + readonly text: Delta[]; + readonly list: readonly number[]; +} + +describe("full rebuilds of a Rust text whose marked characters were deleted", () => { + const fixture = JSON.parse( + readFileSync(new URL("./fixtures/rust/deleted-mark.json", import.meta.url), "utf8"), + ) as DeletedMark; + const load = (bytes = decodeBase64(fixture.snapshot)): LoroDoc => { + const doc = new LoroDoc(); + doc.configTextStyle({ bold: { expand: "after" } }); + doc.import(bytes); + return doc; + }; + + // The mark's anchors still shift the Rust insert of "X", which loro.js does + // not count, so a replay gives "hloX"; the snapshot state must stay. + test("keeps the snapshot text through checkout, shallow export, and forkAt", () => { + for (const frontiers of [fixture.beforeMove, fixture.textDone]) { + const doc = load(); + doc.checkout(frontiers); + doc.attach(); + expect(doc.getText("t").toDelta()).toEqual(fixture.text); + expect( + load(doc.export({ mode: "snapshot" })) + .getText("t") + .toDelta(), + ).toEqual(fixture.text); + + const exported = load(); + const shallow = exported.export({ mode: "shallow-snapshot", frontiers }); + expect(exported.getText("t").toDelta()).toEqual(fixture.text); + expect(load(shallow).getText("t").toDelta()).toEqual(fixture.text); + } + const doc = load(); + const fork = doc.forkAt(doc.frontiers()); + expect(fork.getText("t").toDelta()).toEqual(fixture.text); + expect( + load(fork.export({ mode: "snapshot" })) + .getText("t") + .toDelta(), + ).toEqual(fixture.text); + expect(fork.getMovableList("ml").toJSON()).toEqual(fixture.list); + }); +}); + +describe("checkout events after a lazy snapshot import", () => { + test("reports an unread nested container change as its delta", () => { + const build = (): { doc: LoroDoc; before: Frontiers } => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const parent = doc.getMap("root").setContainer("parent", new LoroMap()); + const child = parent.setContainer("child", new LoroText()); + child.insert(0, "abc"); + doc.commit(); + const before = doc.frontiers(); + child.insert(0, "X"); + doc.commit(); + return { doc, before }; + }; + const { doc, before } = build(); + const bytes = doc.export({ mode: "snapshot" }); + const diffs = (read: boolean): unknown[] => { + const loaded = new LoroDoc(); + loaded.import(bytes); + if (read) loaded.toJSON(); + const events: unknown[] = []; + loaded.subscribe((batch) => { + for (const event of batch.events) events.push(event.diff); + }); + loaded.checkout(before); + expect(loaded.toJSON()).toEqual({ root: { parent: { child: "abc" } } }); + return events; + }; + expect(diffs(false)).toEqual([{ type: "text", diff: [{ delete: 1 }] }]); + expect(diffs(true)).toEqual(diffs(false)); + }); + + test("rebuilds only the unread nested sequence that a checkout crosses", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const text = doc.getMap("root").setContainer("text", new LoroText()); + text.insert(0, "abc"); + doc.getMap("other").set("k", 1); + doc.commit(); + const before = doc.frontiers(); + text.delete(1, 1); + doc.commit(); + + const loaded = new LoroDoc(); + loaded.import(doc.export({ mode: "snapshot" })); + const resets = [loaded.getMap("root"), loaded.getMap("other")].map((container) => + vi.spyOn(container, "_reset"), + ); + loaded.checkout(before); + expect(loaded.toJSON()).toEqual({ root: { text: "abc" }, other: { k: 1 } }); + loaded.checkoutToLatest(); + expect(loaded.toJSON()).toEqual({ root: { text: "ac" }, other: { k: 1 } }); + expect(resets.every((reset) => reset.mock.calls.length === 0)).toBe(true); + }); + + test("exports unread nested containers after history is loaded", () => { + const { doc, versions } = buildHistory(); + for (const load of [ + (loaded: LoroDoc) => loaded.getAllChanges(), + (loaded: LoroDoc) => { + loaded.checkout(versions[0]!); + loaded.checkoutToLatest(); + }, + ]) { + const loaded = new LoroDoc(); + loaded.import(doc.export({ mode: "snapshot" })); + load(loaded); + const again = new LoroDoc(); + again.import(loaded.export({ mode: "snapshot" })); + expect(again.toJSON()).toEqual(doc.toJSON()); + } + }); +}); diff --git a/loro-js/tests/version-transition.test.ts b/loro-js/tests/version-transition.test.ts index 2a254f518..fe6650a36 100644 --- a/loro-js/tests/version-transition.test.ts +++ b/loro-js/tests/version-transition.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import { LoroDoc } from "../src/runtime/document"; +import { SequenceIndex } from "../src/runtime/sequence-index"; import type { LoroEventBatch } from "../src/runtime/types"; describe("indexed version transitions", () => { @@ -823,4 +824,136 @@ describe("indexed version transitions", () => { left.checkout(leftFrontiers); expect(left.getText("text").length).toBe(0); }); + + // Packed Text spans return a new element wrapper on each lookup, so two + // concurrent deletes of one character must still delete it once. + for (const inserts of [["ab"], ["a", "b"]]) { + test(`deletes a character deleted by two peers once (${inserts.length} inserts)`, () => { + const source = new LoroDoc(); + source.setPeerId(1); + let position = 0; + for (const text of inserts) { + source.getText("t").insert(position, text); + position += text.length; + } + source.commit(); + const base = source.frontiers(); + const initial = source.export({ mode: "update" }); + const updates = [2, 3].map((peer) => { + const replica = new LoroDoc(); + replica.import(initial); + replica.setPeerId(peer); + replica.getText("t").delete(1, 1); + if (peer === 3) replica.getText("t").insert(1, "X"); + replica.commit(); + return replica.export({ mode: "update", from: source.oplogVersion() }); + }); + + const doc = new LoroDoc(); + doc.import(initial); + doc.importBatch(updates); + const mirror = new LoroDoc(); + mirror.getText("t").insert(0, "aX"); + let batches = 0; + doc.subscribe((batch: LoroEventBatch) => { + batches += 1; + mirror.applyDiff(batch.events.map(({ target, diff }) => [target, diff])); + }); + doc.checkout(base); + expect(doc.getText("t").toString()).toBe("ab"); + doc.attach(); + expect(doc.getText("t").toString()).toBe("aX"); + expect(mirror.getText("t").toString()).toBe("aX"); + expect(batches).toBe(2); + const again = new LoroDoc(); + again.import(doc.export({ mode: "snapshot" })); + expect(again.getText("t").toString()).toBe("aX"); + }); + } + + test("leaves the state and version unchanged when a checkout throws", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const text = doc.getText("t"); + text.insert(0, "abc"); + doc.getMap("m").set("k", 1); + doc.commit(); + const base = doc.frontiers(); + text.delete(0, 1); + doc.getMap("m").set("k", 2); + doc.commit(); + const latest = doc.frontiers(); + + const failure = vi + .spyOn(text._sequence, "setIdRunsVisible") + .mockImplementation(() => { + throw new Error("transition failed"); + }); + expect(() => doc.checkout(base)).toThrow("transition failed"); + failure.mockRestore(); + expect(doc.isDetached()).toBe(false); + expect(doc.frontiers()).toEqual(latest); + expect(doc.toJSON()).toEqual({ t: "bc", m: { k: 2 } }); + doc.checkout(base); + expect(doc.toJSON()).toEqual({ t: "abc", m: { k: 1 } }); + doc.attach(); + expect(doc.toJSON()).toEqual({ t: "bc", m: { k: 2 } }); + }); + + test("leaves the state unchanged when a diff or its move back throws", () => { + const doc = new LoroDoc(); + doc.setPeerId(1); + const text = doc.getText("t"); + const list = doc.getList("l"); + text.insert(0, "abc"); + list.push(1); + list.push(2); + doc.commit(); + const base = doc.frontiers(); + text.delete(0, 1); + list.delete(0, 1); + list.push(3); + doc.commit(); + const latest = doc.frontiers(); + const value = doc.toJSON(); + + // The diff shows elements with setIdRunsVisible and hides them again with + // setIdRunsDeleted and setDeleted, so this covers both directions. + const failures: string[] = []; + for (const method of [ + "setIdRunsVisible", + "setIdRunsDeleted", + "setDeleted", + ] as const) { + const original = SequenceIndex.prototype[method] as (...args: unknown[]) => unknown; + for (let call = 1; call <= 3; call += 1) { + let calls = 0; + const failure = vi + .spyOn(SequenceIndex.prototype, method) + .mockImplementation(function (this: unknown, ...args: unknown[]) { + calls += 1; + if (calls === call) throw new Error(`${method} failed`); + return original.apply(this, args); + } as never); + try { + doc.diff(latest, base, false); + } catch (error) { + failures.push((error as Error).message); + } finally { + failure.mockRestore(); + } + expect(doc.frontiers()).toEqual(latest); + expect(doc.toJSON()).toEqual(value); + } + } + expect(new Set(failures)).toEqual( + new Set([ + "setIdRunsVisible failed", + "setIdRunsDeleted failed", + "setDeleted failed", + ]), + ); + doc.checkout(base); + expect(doc.toJSON()).toEqual({ t: "abc", l: [1, 2] }); + }); });