Docs
Advanced Topics
Operations and Change

Operations and Change

In Loro, every basic operation such as setting a key-value pair 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 includes the following information:

  • ID: ID of the Change is essentially the first op's ID
  • 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 depends on a Change from a different peer. This occurs when local operations build upon recently applied remote operations. For example, deleting a character sequence that was just inserted by a remote peer. These causal relationships form a DAG (Directed Acyclic Graph). After importing remote updates, the next local Change will have new dependency IDs, necessitating a separate Change.
  • When setRecordTimestamp(true) is set, if the time interval between successive Changes exceeds the "change merge interval" (default duration 1000s).
  • When the current Change has a different commit message from the previous Change by the same peer.

Example

import { Change, LoroDoc } from "npm:loro-crdt@1.0.0-beta.5";
 
const docA = new LoroDoc();
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 = LoroDoc.fromSnapshot(docA.export({ mode: "snapshot" }));
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.export({ mode: "update" }); // 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
  //     }
  //   ]
  // }
}