Docs
Advanced
Operations and Change

Operations and Change

In Loro, every basic operation such as setting a key-value on a Map, adding a list item, or inserting/deleting a character in text is considered an individual op. (Don't worry about the cost, in Loro's internal memory representation and export format, consecutive ops are merged into a larger op, such as consecutive text insertions and deletions.)

One or more local consecutive Ops constitute a Change, which additionally includes the following information:

  • Timestamp: An optional timestamp, which can be enabled with setRecordTimestamp(true). If not enabled, there is no extra storage overhead.
  • Dependency IDs: Used to represent the causal order, the Op IDs that the current Change directly depends on.
  • Commit Message: An optional commit message (WIP not yet released); when not enabled, there is no extra storage overhead.

Each time doc.commit() is called, a new Change is generated, which will be merged with the previous local Change as much as possible to reduce the amount of metadata that needs to be stored.

Note: Each time you export, a doc.commit() is implicitly performed by the Loro Doc.

Unlike a Git commit, Loro's Change can be merged; it is neither atomic nor indivisible. This design allows Loro to better accommodate real-time collaboration scenarios (where each keystroke would have its own doc.commit(), which would be hugely costly if not merged) and asynchronous collaboration scenarios (like Git, which combines many modifications to form one).

When a New Change is Formed

Note: You may not need to understand the content of this section, and the content may change in future versions. Unless you want to understand Loro's internal implementation or want to achieve more extreme performance optimization.

By default, each commit-generated Change will merge with the previous local Change. However, there are exceptions in several cases:

  • The current Change has Dependency IDs. When we have merged operations from remote sources, local operations sometimes build on the basis of remote operations already applied, such as deleting a character sequence depending on that sequence having already been inserted into the document. We save this causal order in the Change, and these causal relationships form a DAG. Therefore, the next local Change after importing remote updates will have Dependency IDs that need to be recorded, making it a separate Change.
  • The current Change includes Dependencies from other Changes. This is related to Loro's current internal implementation, which requires Dependency IDs to only point to the end of a Change, so if a Change is depended upon, subsequent Changes from the same Peer cannot merge with it.
  • When setRecordTimestamp(true) is set, if the time interval between successive Changes exceeds the "change merge interval" (default duration 1000s).
  • When successive Changes have different Commit Messages.

Example

import { Change, Loro } from "npm:loro-crdt@0.15.0";
 
const docA = new Loro();
docA.setPeerId("0");
const textA = docA.getText("text");
// This create 3 operations
textA.insert(0, "123");
// This create a new Change
docA.commit();
// This create 2 operations
textA.insert(0, "ab");
// This will NOT create a new Change
docA.commit();
 
{
  const changeMap: Map<`${number}`, Change[]> = docA.getAllChanges();
  console.log(changeMap);
  // Output:
  //
  // Map(1) {
  //   "0" => [
  //     {
  //       lamport: 0,
  //       length: 5,
  //       peer: "0",
  //       counter: 0,
  //       deps: [],
  //       timestamp: 0
  //     }
  //   ]
  // }
}
 
// Create docB from doc
const docB = Loro.fromSnapshot(docA.exportSnapshot());
docB.setPeerId("1");
const textB = docB.getText("text");
// This create 2 operations
textB.insert(0, "cd");
 
// Import the Change from docB to doc
const bytes = docB.exportFrom(); // Exporting has implicit commit
docA.import(bytes);
 
// This create 1 operations
textA.insert(0, "1");
// Because doc import a Change from docB, it will create a new Change for
// new commit to record this causal order
docA.commit();
{
  const changeMap: Map<`${number}`, Change[]> = docA.getAllChanges();
  console.log(changeMap);
  // Output:
  //
  // Map(2) {
  //   "0" => [
  //     {
  //       lamport: 0,
  //       length: 5,
  //       peer: "0",
  //       counter: 0,
  //       deps: [],
  //       timestamp: 0
  //     },
  //     {
  //       lamport: 7,
  //       length: 1,
  //       peer: "0",
  //       counter: 5,
  //       deps: [ { peer: "1", counter: 1 } ],
  //       timestamp: 0
  //     }
  //   ],
  //   "1" => [
  //     {
  //       lamport: 5,
  //       length: 2,
  //       peer: "1",
  //       counter: 0,
  //       deps: [ { peer: "0", counter: 4 } ],
  //       timestamp: 0
  //     }
  //   ]
  // }
}