From 980b6c7e38236054fa70c6d927ad7191af448d00 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Tue, 16 Jun 2026 03:02:52 +0000 Subject: [PATCH] docs: remove outdated Loro maturity language --- components/api/LanguageSelector.tsx | 77 - deno_scripts/deno.lock | 380 +- deno_scripts/loro.ts | 2 +- deno_scripts/run_code_blocks.ts | 26 +- package.json | 2 +- pages/blog/crdt-richtext.mdx | 8 +- pages/blog/loro-mirror.mdx | 8 +- pages/blog/loro-now-open-source.mdx | 36 +- pages/blog/v1.0.mdx | 46 +- pages/docs/api/js.mdx | 2 +- pages/docs/performance/index.md | 2 +- pages/docs/tutorial/get_started.mdx | 2 +- pnpm-lock.yaml | 24 +- public/blog.xml | 102 +- public/changelog.xml | 24 +- public/llms-full.txt | 33398 +++++++++++++------------- public/sitemap-0.xml | 142 +- 17 files changed, 17124 insertions(+), 17157 deletions(-) delete mode 100644 components/api/LanguageSelector.tsx diff --git a/components/api/LanguageSelector.tsx b/components/api/LanguageSelector.tsx deleted file mode 100644 index 643849f..0000000 --- a/components/api/LanguageSelector.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useRouter } from 'next/router'; - -const languages = [ - { id: 'js', name: 'JavaScript / TypeScript', available: true }, - { id: 'rust', name: 'Rust', available: false }, - { id: 'python', name: 'Python', available: false }, - { id: 'swift', name: 'Swift', available: false }, -]; - -export default function LanguageSelector() { - const router = useRouter(); - const [selectedLang, setSelectedLang] = useState('js'); - - useEffect(() => { - // Get language from URL query or localStorage - const urlLang = router.query.lang as string; - const storedLang = localStorage.getItem('loro-api-lang'); - - if (urlLang && languages.find(l => l.id === urlLang)) { - setSelectedLang(urlLang); - localStorage.setItem('loro-api-lang', urlLang); - } else if (storedLang && languages.find(l => l.id === storedLang)) { - setSelectedLang(storedLang); - } - }, [router.query.lang]); - - const handleLanguageChange = (langId: string) => { - const lang = languages.find(l => l.id === langId); - if (lang && lang.available) { - setSelectedLang(langId); - localStorage.setItem('loro-api-lang', langId); - - // Update URL without navigation - const newUrl = new URL(window.location.href); - newUrl.searchParams.set('lang', langId); - window.history.pushState({}, '', newUrl.toString()); - } - }; - - return ( -
-
- - Select Language/Binding: - -
-
- {languages.map((lang) => ( - - ))} -
- {selectedLang !== 'js' && ( -
-

API documentation for {languages.find(l => l.id === selectedLang)?.name} is coming soon.

-

Currently showing JavaScript/TypeScript API reference.

-
- )} -
- ); -} \ No newline at end of file diff --git a/deno_scripts/deno.lock b/deno_scripts/deno.lock index 471c2bb..b70ca95 100644 --- a/deno_scripts/deno.lock +++ b/deno_scripts/deno.lock @@ -1,60 +1,45 @@ { "version": "5", "specifiers": { - "jsr:@std/fs@*": "1.0.4", - "jsr:@std/path@^1.0.6": "1.0.6", - "npm:expect@*": "29.7.0", + "jsr:@std/fs@*": "1.0.24", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.1.5": "1.1.5", "npm:expect@29.7.0": "29.7.0", - "npm:expect@^30.0.5": "30.0.5", - "npm:loro-crdt@*": "0.15.1", - "npm:loro-crdt@0.15.1": "0.15.1", - "npm:loro-crdt@1.0.0-beta.5": "1.0.0-beta.5", - "npm:loro-crdt@1.5.10": "1.5.10" + "npm:expect@^30.0.5": "30.4.1", + "npm:loro-crdt@1.13.3": "1.13.3" }, "jsr": { - "@std/fs@1.0.4": { - "integrity": "2907d32d8d1d9e540588fd5fe0ec21ee638134bd51df327ad4e443aaef07123c", + "@std/fs@1.0.24": { + "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", "dependencies": [ + "jsr:@std/internal", "jsr:@std/path" ] }, - "@std/path@1.0.6": { - "integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed" + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.5": { + "integrity": "ccea00982ea28c36becaf6e62f855406c76a8c32d462f66f415bbb7d83a271bc", + "dependencies": [ + "jsr:@std/internal" + ] } }, "npm": { - "@babel/code-frame@7.25.9": { - "integrity": "sha512-z88xeGxnzehn2sqZ8UdGQEvYErF1odv2CftxInpSYJt6uHuPe9YjahKZITGs3l5LeI9d2ROG+obuDAoSlqbNfQ==", + "@babel/code-frame@7.29.7": { + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dependencies": [ - "@babel/highlight", - "picocolors" - ] - }, - "@babel/code-frame@7.27.1": { - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dependencies": [ - "@babel/helper-validator-identifier@7.27.1", + "@babel/helper-validator-identifier", "js-tokens", "picocolors" ] }, - "@babel/helper-validator-identifier@7.25.9": { - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==" - }, - "@babel/helper-validator-identifier@7.27.1": { - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==" - }, - "@babel/highlight@7.25.9": { - "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", - "dependencies": [ - "@babel/helper-validator-identifier@7.25.9", - "chalk@2.4.2", - "js-tokens", - "picocolors" - ] + "@babel/helper-validator-identifier@7.29.7": { + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==" }, - "@jest/diff-sequences@30.0.1": { - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==" + "@jest/diff-sequences@30.4.0": { + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==" }, "@jest/expect-utils@29.7.0": { "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", @@ -62,32 +47,32 @@ "jest-get-type" ] }, - "@jest/expect-utils@30.0.5": { - "integrity": "sha512-F3lmTT7CXWYywoVUGTCmom0vXq3HTTkaZyTAzIy+bXSBizB7o5qzlC9VCtq0arOa8GqmNsbg/cE9C6HLn7Szew==", + "@jest/expect-utils@30.4.1": { + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dependencies": [ "@jest/get-type" ] }, - "@jest/get-type@30.0.1": { - "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==" + "@jest/get-type@30.1.0": { + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==" }, - "@jest/pattern@30.0.1": { - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "@jest/pattern@30.4.0": { + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dependencies": [ - "@types/node@22.12.0", + "@types/node", "jest-regex-util" ] }, "@jest/schemas@29.6.3": { "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dependencies": [ - "@sinclair/typebox@0.27.8" + "@sinclair/typebox@0.27.10" ] }, - "@jest/schemas@30.0.5": { - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "@jest/schemas@30.4.1": { + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dependencies": [ - "@sinclair/typebox@0.34.38" + "@sinclair/typebox@0.34.49" ] }, "@jest/types@29.6.3": { @@ -96,28 +81,28 @@ "@jest/schemas@29.6.3", "@types/istanbul-lib-coverage", "@types/istanbul-reports", - "@types/node@22.5.4", + "@types/node", "@types/yargs", - "chalk@4.1.2" + "chalk" ] }, - "@jest/types@30.0.5": { - "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "@jest/types@30.4.1": { + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dependencies": [ "@jest/pattern", - "@jest/schemas@30.0.5", + "@jest/schemas@30.4.1", "@types/istanbul-lib-coverage", "@types/istanbul-reports", - "@types/node@22.12.0", + "@types/node", "@types/yargs", - "chalk@4.1.2" + "chalk" ] }, - "@sinclair/typebox@0.27.8": { - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" + "@sinclair/typebox@0.27.10": { + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==" }, - "@sinclair/typebox@0.34.38": { - "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==" + "@sinclair/typebox@0.34.49": { + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==" }, "@types/istanbul-lib-coverage@2.0.6": { "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==" @@ -134,16 +119,10 @@ "@types/istanbul-lib-report" ] }, - "@types/node@22.12.0": { - "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", - "dependencies": [ - "undici-types@6.20.0" - ] - }, - "@types/node@22.5.4": { - "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "@types/node@25.9.3": { + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dependencies": [ - "undici-types@6.19.8" + "undici-types" ] }, "@types/stack-utils@2.0.3": { @@ -152,22 +131,16 @@ "@types/yargs-parser@21.0.3": { "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" }, - "@types/yargs@17.0.33": { - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "@types/yargs@17.0.35": { + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dependencies": [ "@types/yargs-parser" ] }, - "ansi-styles@3.2.1": { - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": [ - "color-convert@1.9.3" - ] - }, "ansi-styles@4.3.0": { "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dependencies": [ - "color-convert@2.0.1" + "color-convert" ] }, "ansi-styles@5.2.0": { @@ -179,51 +152,31 @@ "fill-range" ] }, - "chalk@2.4.2": { - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": [ - "ansi-styles@3.2.1", - "escape-string-regexp@1.0.5", - "supports-color@5.5.0" - ] - }, "chalk@4.1.2": { "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dependencies": [ "ansi-styles@4.3.0", - "supports-color@7.2.0" + "supports-color" ] }, "ci-info@3.9.0": { "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==" }, - "ci-info@4.3.0": { - "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==" - }, - "color-convert@1.9.3": { - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": [ - "color-name@1.1.3" - ] + "ci-info@4.4.0": { + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==" }, "color-convert@2.0.1": { "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": [ - "color-name@1.1.4" + "color-name" ] }, - "color-name@1.1.3": { - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, "color-name@1.1.4": { "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "diff-sequences@29.6.3": { "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==" }, - "escape-string-regexp@1.0.5": { - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" - }, "escape-string-regexp@2.0.0": { "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==" }, @@ -237,15 +190,15 @@ "jest-util@29.7.0" ] }, - "expect@30.0.5": { - "integrity": "sha512-P0te2pt+hHI5qLJkIR+iMvS+lYUZml8rKKsohVHAGY+uClp9XVbdyYNJOIjSRpHVp8s8YqxJCiHUkSYZGr8rtQ==", + "expect@30.4.1": { + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dependencies": [ - "@jest/expect-utils@30.0.5", + "@jest/expect-utils@30.4.1", "@jest/get-type", - "jest-matcher-utils@30.0.5", - "jest-message-util@30.0.5", + "jest-matcher-utils@30.4.1", + "jest-message-util@30.4.1", "jest-mock", - "jest-util@30.0.5" + "jest-util@30.4.1" ] }, "fill-range@7.1.1": { @@ -257,9 +210,6 @@ "graceful-fs@4.2.11": { "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, - "has-flag@3.0.0": { - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" - }, "has-flag@4.0.0": { "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, @@ -269,19 +219,19 @@ "jest-diff@29.7.0": { "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dependencies": [ - "chalk@4.1.2", + "chalk", "diff-sequences", "jest-get-type", "pretty-format@29.7.0" ] }, - "jest-diff@30.0.5": { - "integrity": "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A==", + "jest-diff@30.4.1": { + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dependencies": [ "@jest/diff-sequences", "@jest/get-type", - "chalk@4.1.2", - "pretty-format@30.0.5" + "chalk", + "pretty-format@30.4.1" ] }, "jest-get-type@29.6.3": { @@ -290,28 +240,28 @@ "jest-matcher-utils@29.7.0": { "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", "dependencies": [ - "chalk@4.1.2", + "chalk", "jest-diff@29.7.0", "jest-get-type", "pretty-format@29.7.0" ] }, - "jest-matcher-utils@30.0.5": { - "integrity": "sha512-uQgGWt7GOrRLP1P7IwNWwK1WAQbq+m//ZY0yXygyfWp0rJlksMSLQAA4wYQC3b6wl3zfnchyTx+k3HZ5aPtCbQ==", + "jest-matcher-utils@30.4.1": { + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dependencies": [ "@jest/get-type", - "chalk@4.1.2", - "jest-diff@30.0.5", - "pretty-format@30.0.5" + "chalk", + "jest-diff@30.4.1", + "pretty-format@30.4.1" ] }, "jest-message-util@29.7.0": { "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "dependencies": [ - "@babel/code-frame@7.25.9", + "@babel/code-frame", "@jest/types@29.6.3", "@types/stack-utils", - "chalk@4.1.2", + "chalk", "graceful-fs", "micromatch", "pretty-format@29.7.0", @@ -319,131 +269,112 @@ "stack-utils" ] }, - "jest-message-util@30.0.5": { - "integrity": "sha512-NAiDOhsK3V7RU0Aa/HnrQo+E4JlbarbmI3q6Pi4KcxicdtjV82gcIUrejOtczChtVQR4kddu1E1EJlW6EN9IyA==", + "jest-message-util@30.4.1": { + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dependencies": [ - "@babel/code-frame@7.27.1", - "@jest/types@30.0.5", + "@babel/code-frame", + "@jest/types@30.4.1", "@types/stack-utils", - "chalk@4.1.2", + "chalk", "graceful-fs", - "micromatch", - "pretty-format@30.0.5", + "jest-util@30.4.1", + "picomatch@4.0.4", + "pretty-format@30.4.1", "slash", "stack-utils" ] }, - "jest-mock@30.0.5": { - "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "jest-mock@30.4.1": { + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", "dependencies": [ - "@jest/types@30.0.5", - "@types/node@22.12.0", - "jest-util@30.0.5" + "@jest/types@30.4.1", + "@types/node", + "jest-util@30.4.1" ] }, - "jest-regex-util@30.0.1": { - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==" + "jest-regex-util@30.4.0": { + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==" }, "jest-util@29.7.0": { "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "dependencies": [ "@jest/types@29.6.3", - "@types/node@22.5.4", - "chalk@4.1.2", + "@types/node", + "chalk", "ci-info@3.9.0", "graceful-fs", - "picomatch@2.3.1" + "picomatch@2.3.2" ] }, - "jest-util@30.0.5": { - "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "jest-util@30.4.1": { + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dependencies": [ - "@jest/types@30.0.5", - "@types/node@22.12.0", - "chalk@4.1.2", - "ci-info@4.3.0", + "@jest/types@30.4.1", + "@types/node", + "chalk", + "ci-info@4.4.0", "graceful-fs", - "picomatch@4.0.3" + "picomatch@4.0.4" ] }, "js-tokens@4.0.0": { "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, - "loro-crdt@0.15.1": { - "integrity": "sha512-2pK3IchNL2ZLl9Q53rgjWLP1Cr0uCDvChnb9o5NgQVamOD4ap5rHeDfIk05PVKn9epgHn2NCSacvFFepUYJGZg==", - "dependencies": [ - "loro-wasm@0.15.1" - ] - }, - "loro-crdt@1.0.0-beta.5": { - "integrity": "sha512-vSs4YDabF4iuwNX15djXQNoV6+sORNzqC6vrurOGN1VFlJ15TlBKkR5Y0WF30UBx1RTuI3tUBtcEm+vqiJKsgA==", - "dependencies": [ - "loro-wasm@1.0.0-beta.5" - ] - }, - "loro-crdt@1.5.10": { - "integrity": "sha512-dBAw3Hh9EJLaS5MqyNMoZRq7r01UxLWUdm3go2v2EtYIrH658IG0vcktHuZqeEwyTMvE5eUtyHhLx3lgUyDFfw==" - }, - "loro-wasm@0.15.1": { - "integrity": "sha512-1ZRJgD3WJzFDBEHnMMiciaszt1iuM7BwJECggjNqCH1wBhGJ6gNk777b1pLQvuqHP1rU8LDcKeWaYwnavep17g==" - }, - "loro-wasm@1.0.0-beta.5": { - "integrity": "sha512-UR8dOyNKmRR6gABr7KfjKCGgEWDR6yR2AuN59Kbz0QkNIOztMYdQnSlSl4x7vKpkK+rz2QmhkNUD3KdzkANVHw==" + "loro-crdt@1.13.3": { + "integrity": "sha512-vh+hYxvsGL9MrsUaquPpawbrL97R4Qb84nc41xTh0zEEz7tJsdioH4mhbsLi0R659LFj3/xII2ML2ScZYJTw9Q==" }, "micromatch@4.0.8": { "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": [ "braces", - "picomatch@2.3.1" + "picomatch@2.3.2" ] }, "picocolors@1.1.1": { "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, - "picomatch@2.3.1": { - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + "picomatch@2.3.2": { + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==" }, - "picomatch@4.0.3": { - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + "picomatch@4.0.4": { + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==" }, "pretty-format@29.7.0": { "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dependencies": [ "@jest/schemas@29.6.3", "ansi-styles@5.2.0", - "react-is" + "react-is@18.3.1" ] }, - "pretty-format@30.0.5": { - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "pretty-format@30.4.1": { + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dependencies": [ - "@jest/schemas@30.0.5", + "@jest/schemas@30.4.1", "ansi-styles@5.2.0", - "react-is" + "react-is-18@npm:react-is@18.3.1", + "react-is-19@npm:react-is@19.2.7" ] }, "react-is@18.3.1": { "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, + "react-is@19.2.7": { + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==" + }, "slash@3.0.0": { "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" }, "stack-utils@2.0.6": { "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "dependencies": [ - "escape-string-regexp@2.0.0" - ] - }, - "supports-color@5.5.0": { - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": [ - "has-flag@3.0.0" + "escape-string-regexp" ] }, "supports-color@7.2.0": { "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dependencies": [ - "has-flag@4.0.0" + "has-flag" ] }, "to-regex-range@5.0.1": { @@ -452,16 +383,10 @@ "is-number" ] }, - "undici-types@6.19.8": { - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" - }, - "undici-types@6.20.0": { - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" + "undici-types@7.24.6": { + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==" } }, - "redirects": { - "https://deno.land/std/fs/mod.ts": "https://deno.land/std@0.224.0/fs/mod.ts" - }, "remote": { "https://deno.land/std@0.139.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", "https://deno.land/std@0.139.0/_util/os.ts": "49b92edea1e82ba295ec946de8ffd956ed123e2948d9bd1d3e901b04e4307617", @@ -481,7 +406,6 @@ "https://deno.land/std@0.220.1/fs/_create_walk_entry.ts": "5d9d2aaec05bcf09a06748b1684224d33eba7a4de24cf4cf5599991ca6b5b412", "https://deno.land/std@0.220.1/fs/_to_path_string.ts": "29bfc9c6c112254961d75cbf6ba814d6de5349767818eb93090cecfa9665591e", "https://deno.land/std@0.220.1/fs/exists.ts": "d2757ef764eaf5c6c5af7228e8447db2de42ab084a2dae540097f905723d83f5", - "https://deno.land/std@0.220.1/fs/expand_glob.ts": "a1ce02b05ed7b96985b0665067c9f1018f3f2ade7ee0fb0d629231050260b158", "https://deno.land/std@0.220.1/fs/walk.ts": "78e1d01a9f75715614bf8d6e58bd77d9fafb1222c41194e607cd3849d7a0e771", "https://deno.land/std@0.220.1/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8", "https://deno.land/std@0.220.1/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", @@ -559,71 +483,7 @@ "https://deno.land/std@0.220.1/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7", "https://deno.land/std@0.220.1/path/windows/resolve.ts": "8dae1dadfed9d46ff46cc337c9525c0c7d959fb400a6308f34595c45bdca1972", "https://deno.land/std@0.220.1/path/windows/to_file_url.ts": "40e560ee4854fe5a3d4d12976cef2f4e8914125c81b11f1108e127934ced502e", - "https://deno.land/std@0.220.1/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c", - "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", - "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", - "https://deno.land/std@0.224.0/fs/_create_walk_entry.ts": "5d9d2aaec05bcf09a06748b1684224d33eba7a4de24cf4cf5599991ca6b5b412", - "https://deno.land/std@0.224.0/fs/_get_file_info_type.ts": "da7bec18a7661dba360a1db475b826b18977582ce6fc9b25f3d4ee0403fe8cbd", - "https://deno.land/std@0.224.0/fs/_is_same_path.ts": "709c95868345fea051c58b9e96af95cff94e6ae98dfcff2b66dee0c212c4221f", - "https://deno.land/std@0.224.0/fs/_is_subdir.ts": "c68b309d46cc8568ed83c000f608a61bbdba0943b7524e7a30f9e450cf67eecd", - "https://deno.land/std@0.224.0/fs/_to_path_string.ts": "29bfc9c6c112254961d75cbf6ba814d6de5349767818eb93090cecfa9665591e", - "https://deno.land/std@0.224.0/fs/copy.ts": "7ab12a16adb65d155d4943c88081ca16ce3b0b5acada64c1ce93800653678039", - "https://deno.land/std@0.224.0/fs/empty_dir.ts": "e400e96e1d2c8c558a5a1712063bd43939e00619c1d1cc29959babc6f1639418", - "https://deno.land/std@0.224.0/fs/ensure_dir.ts": "51a6279016c65d2985f8803c848e2888e206d1b510686a509fa7cc34ce59d29f", - "https://deno.land/std@0.224.0/fs/ensure_file.ts": "67608cf550529f3d4aa1f8b6b36bf817bdc40b14487bf8f60e61cbf68f507cf3", - "https://deno.land/std@0.224.0/fs/ensure_link.ts": "5c98503ebfa9cc05e2f2efaa30e91e60b4dd5b43ebbda82f435c0a5c6e3ffa01", - "https://deno.land/std@0.224.0/fs/ensure_symlink.ts": "cafe904cebacb9a761977d6dbf5e3af938be946a723bb394080b9a52714fafe4", - "https://deno.land/std@0.224.0/fs/eol.ts": "18c4ac009d0318504c285879eb7f47942643f13619e0ff070a0edc59353306bd", - "https://deno.land/std@0.224.0/fs/exists.ts": "3d38cb7dcbca3cf313be343a7b8af18a87bddb4b5ca1bd2314be12d06533b50f", - "https://deno.land/std@0.224.0/fs/expand_glob.ts": "2e428d90acc6676b2aa7b5c78ef48f30641b13f1fe658e7976c9064fb4b05309", - "https://deno.land/std@0.224.0/fs/mod.ts": "c25e6802cbf27f3050f60b26b00c2d8dba1cb7fcdafe34c66006a7473b7b34d4", - "https://deno.land/std@0.224.0/fs/move.ts": "ca205d848908d7f217353bc5c623627b1333490b8b5d3ef4cab600a700c9bd8f", - "https://deno.land/std@0.224.0/fs/walk.ts": "cddf87d2705c0163bff5d7767291f05b0f46ba10b8b28f227c3849cace08d303", - "https://deno.land/std@0.224.0/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8", - "https://deno.land/std@0.224.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", - "https://deno.land/std@0.224.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", - "https://deno.land/std@0.224.0/path/_common/dirname.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", - "https://deno.land/std@0.224.0/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf", - "https://deno.land/std@0.224.0/path/_common/glob_to_reg_exp.ts": "6cac16d5c2dc23af7d66348a7ce430e5de4e70b0eede074bdbcf4903f4374d8d", - "https://deno.land/std@0.224.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", - "https://deno.land/std@0.224.0/path/_common/normalize_string.ts": "33edef773c2a8e242761f731adeb2bd6d683e9c69e4e3d0092985bede74f4ac3", - "https://deno.land/std@0.224.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", - "https://deno.land/std@0.224.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", - "https://deno.land/std@0.224.0/path/basename.ts": "7ee495c2d1ee516ffff48fb9a93267ba928b5a3486b550be73071bc14f8cc63e", - "https://deno.land/std@0.224.0/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36", - "https://deno.land/std@0.224.0/path/dirname.ts": "85bd955bf31d62c9aafdd7ff561c4b5fb587d11a9a5a45e2b01aedffa4238a7c", - "https://deno.land/std@0.224.0/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069", - "https://deno.land/std@0.224.0/path/glob_to_regexp.ts": "7f30f0a21439cadfdae1be1bf370880b415e676097fda584a63ce319053b5972", - "https://deno.land/std@0.224.0/path/is_absolute.ts": "4791afc8bfd0c87f0526eaa616b0d16e7b3ab6a65b62942e50eac68de4ef67d7", - "https://deno.land/std@0.224.0/path/is_glob.ts": "a65f6195d3058c3050ab905705891b412ff942a292bcbaa1a807a74439a14141", - "https://deno.land/std@0.224.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a", - "https://deno.land/std@0.224.0/path/join_globs.ts": "5b3bf248b93247194f94fa6947b612ab9d3abd571ca8386cf7789038545e54a0", - "https://deno.land/std@0.224.0/path/normalize.ts": "4155743ccceeed319b350c1e62e931600272fad8ad00c417b91df093867a8352", - "https://deno.land/std@0.224.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", - "https://deno.land/std@0.224.0/path/posix/basename.ts": "d2fa5fbbb1c5a3ab8b9326458a8d4ceac77580961b3739cd5bfd1d3541a3e5f0", - "https://deno.land/std@0.224.0/path/posix/constants.ts": "93481efb98cdffa4c719c22a0182b994e5a6aed3047e1962f6c2c75b7592bef1", - "https://deno.land/std@0.224.0/path/posix/dirname.ts": "76cd348ffe92345711409f88d4d8561d8645353ac215c8e9c80140069bf42f00", - "https://deno.land/std@0.224.0/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40", - "https://deno.land/std@0.224.0/path/posix/glob_to_regexp.ts": "76f012fcdb22c04b633f536c0b9644d100861bea36e9da56a94b9c589a742e8f", - "https://deno.land/std@0.224.0/path/posix/is_absolute.ts": "cebe561ad0ae294f0ce0365a1879dcfca8abd872821519b4fcc8d8967f888ede", - "https://deno.land/std@0.224.0/path/posix/join.ts": "7fc2cb3716aa1b863e990baf30b101d768db479e70b7313b4866a088db016f63", - "https://deno.land/std@0.224.0/path/posix/join_globs.ts": "a9475b44645feddceb484ee0498e456f4add112e181cb94042cdc6d47d1cdd25", - "https://deno.land/std@0.224.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91", - "https://deno.land/std@0.224.0/path/posix/normalize_glob.ts": "9c87a829b6c0f445d03b3ecadc14492e2864c3ebb966f4cea41e98326e4435c6", - "https://deno.land/std@0.224.0/path/posix/resolve.ts": "08b699cfeee10cb6857ccab38fa4b2ec703b0ea33e8e69964f29d02a2d5257cf", - "https://deno.land/std@0.224.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d", - "https://deno.land/std@0.224.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", - "https://deno.land/std@0.224.0/path/windows/basename.ts": "6bbc57bac9df2cec43288c8c5334919418d784243a00bc10de67d392ab36d660", - "https://deno.land/std@0.224.0/path/windows/constants.ts": "5afaac0a1f67b68b0a380a4ef391bf59feb55856aa8c60dfc01bd3b6abb813f5", - "https://deno.land/std@0.224.0/path/windows/dirname.ts": "33e421be5a5558a1346a48e74c330b8e560be7424ed7684ea03c12c21b627bc9", - "https://deno.land/std@0.224.0/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01", - "https://deno.land/std@0.224.0/path/windows/glob_to_regexp.ts": "e45f1f89bf3fc36f94ab7b3b9d0026729829fabc486c77f414caebef3b7304f8", - "https://deno.land/std@0.224.0/path/windows/is_absolute.ts": "4a8f6853f8598cf91a835f41abed42112cebab09478b072e4beb00ec81f8ca8a", - "https://deno.land/std@0.224.0/path/windows/join.ts": "8d03530ab89195185103b7da9dfc6327af13eabdcd44c7c63e42e27808f50ecf", - "https://deno.land/std@0.224.0/path/windows/join_globs.ts": "a9475b44645feddceb484ee0498e456f4add112e181cb94042cdc6d47d1cdd25", - "https://deno.land/std@0.224.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780", - "https://deno.land/std@0.224.0/path/windows/normalize_glob.ts": "9c87a829b6c0f445d03b3ecadc14492e2864c3ebb966f4cea41e98326e4435c6", - "https://deno.land/std@0.224.0/path/windows/resolve.ts": "8dae1dadfed9d46ff46cc337c9525c0c7d959fb400a6308f34595c45bdca1972" + "https://deno.land/std@0.220.1/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c" }, "workspace": { "dependencies": [ diff --git a/deno_scripts/loro.ts b/deno_scripts/loro.ts index 4efbae9..d98bb75 100644 --- a/deno_scripts/loro.ts +++ b/deno_scripts/loro.ts @@ -1,4 +1,4 @@ -import { LoroDoc } from "npm:loro-crdt@1.5.2"; +import { LoroDoc } from "npm:loro-crdt@1.13.3"; const doc = new LoroDoc(); doc.getCursorPos; diff --git a/deno_scripts/run_code_blocks.ts b/deno_scripts/run_code_blocks.ts index 7699f3d..f689998 100644 --- a/deno_scripts/run_code_blocks.ts +++ b/deno_scripts/run_code_blocks.ts @@ -2,7 +2,12 @@ import { walk } from "jsr:@std/fs"; import { CodeBlock, extractCodeBlocks } from "./extract_code_blocks.ts"; import { resolve } from "https://deno.land/std@0.139.0/path/mod.ts"; -const LORO_VERSION = "1.5.10"; +const LORO_VERSION = "1.13.3"; +const PACKAGE_VERSIONS: Record = { + "loro-crdt": LORO_VERSION, + "loro-mirror": "1.0.0", + "loro-mirror-react": "1.0.0", +}; async function scanMarkdownFiles( dir: string, @@ -33,10 +38,13 @@ async function scanMarkdownFiles( ); } -function replaceImportVersion(input: string, targetVersion: string): string { - const regex = /from "loro-crdt"/g; - const replacement = `from "npm:loro-crdt@${targetVersion}"`; - return input.replace(regex, replacement); +function replaceImportVersions(input: string): string { + let output = input; + for (const [pkg, version] of Object.entries(PACKAGE_VERSIONS)) { + const regex = new RegExp(`from "${pkg}"`, "g"); + output = output.replace(regex, `from "npm:${pkg}@${version}"`); + } + return output; } // Parsing command-line arguments @@ -89,7 +97,7 @@ await scanMarkdownFiles(targetDir, (block) => { name: `${block.filePath}:${block.lineNumber}`, job: async () => { let codeBlock = block.content; - codeBlock = replaceImportVersion(codeBlock, LORO_VERSION); + codeBlock = replaceImportVersions(codeBlock); if (codeBlock.includes("Loro") && !codeBlock.includes("import {")) { codeBlock = IMPORTS + codeBlock; } @@ -142,6 +150,8 @@ await scanMarkdownFiles(targetDir, (block) => { }); }); -for (let i = 0; i < 16; i++) { - runJob(); +await Promise.all(Array.from({ length: 16 }, () => runJob())); +await processLogQueue(); +if (failed > 0) { + Deno.exit(1); } diff --git a/package.json b/package.json index 7ec8276..d233745 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "highlight.js": "^11.9.0", "is-equal": "^1.7.0", "jotai": "^2.6.0", - "loro-crdt": "^1.5.10", + "loro-crdt": "^1.13.3", "lucide-react": "^0.294.0", "next": "^14.2.31", "next-seo": "^5.15.0", diff --git a/pages/blog/crdt-richtext.mdx b/pages/blog/crdt-richtext.mdx index 1bfe437..2e93c67 100644 --- a/pages/blog/crdt-richtext.mdx +++ b/pages/blog/crdt-richtext.mdx @@ -2,7 +2,7 @@ title: crdt-richtext - Rust implementation of Peritext and Fugue date: 2023/04/20 keywords: crdt, richtext, peritext, fugue, loro -description: Presenting a new Rust crate that combines Peritext and Fugue's power with impressive performance, tailored specifically for rich text. This crate's functionality is set to be incorporated into Loro, a general-purpose CRDT library currently under development. +description: Presenting the early Rust crate that combined Peritext and Fugue's power with impressive performance, tailored specifically for rich text. This work later informed Loro's rich text CRDT. tag: richtext # ogImage: /images/blog/joining-vercel/x-card.png --- @@ -18,7 +18,7 @@ import Authors, { Author } from "../../components/authors"; /> -Presenting a new Rust crate that combines [Peritext](https://inkandswitch.com/peritext) and [Fugue](https://arxiv.org/abs/2305.00583)'s power with impressive performance, tailored specifically for rich text. This crate's functionality is set to be incorporated into **[Loro](https://www.loro.dev/)**, a general-purpose CRDT library currently under development. +Presenting an early Rust crate that combines [Peritext](https://inkandswitch.com/peritext) and [Fugue](https://arxiv.org/abs/2305.00583)'s power with impressive performance, tailored specifically for rich text. This work later informed **[Loro](https://www.loro.dev/)**'s rich text CRDT. # What’s Peritext @@ -240,14 +240,14 @@ The benchmark was conducted on a 2020 M1 MacBook Pro 13-inch on 2023-05-11. | [B4x100] Apply real-world editing dataset 100 times (docSize) | 12,667,753 +/- 0 bytes | 26,634,606 +/- 80 bytes | skipped | 17,844,936 +/- 0 bytes | 15,989,245 +/- 0 bytes | skipped | | [B4x100] Apply real-world editing dataset 100 times (parseTime) | 1,252 +/- 14 ms | 170 +/- 15 ms | skipped | 368 +/- 13 ms | 1,335 +/- 238 ms | skipped | -The complete benchmark result and code is available at https://github.com/https://twitter.com/zx_loro/fugue-bench. +The complete benchmark result and code is available at https://github.com/zxch3n/fugue-bench. It is worth noting that: - The benchmark for Automerge is based on `automerge-wasm`, which is not the latest version of Automerge 2.0. - `crdt-richtext` and `fugue` are special-purpose CRDTs that tend to be faster and have a smaller encoding size. - The encoding of `yjs`, `ywasm`, and `loro-wasm` still contains redundancy that can be compressed significantly. For more details, see [the full report](https://loro.dev/docs/performance/docsize). -- loro-wasm and fugue only support plain text for now +- At the time of this 2023 benchmark, `loro-wasm` and `fugue` only supported plain text. # Discussion diff --git a/pages/blog/loro-mirror.mdx b/pages/blog/loro-mirror.mdx index 2e5fa48..b6ff23a 100644 --- a/pages/blog/loro-mirror.mdx +++ b/pages/blog/loro-mirror.mdx @@ -68,7 +68,7 @@ This code is repetitive and easy to get wrong. Mirror centralizes it behind a de ### Basic Example -```ts twoslash +```ts no_run /** * As an example, you can use `useState` from React to manage the state * @@ -141,7 +141,7 @@ const stop = doc.subscribeLocalUpdates((bytes) => { ### React Example -```tsx twoslash +```tsx no_run import React, { useMemo } from "react"; import { LoroDoc } from "loro-crdt"; import { schema } from "loro-mirror"; @@ -214,7 +214,7 @@ export function TodoApp() { Undo/Redo ```tsx -import { UndoManageker } from "loro-crdt"; +import { UndoManager } from "loro-crdt"; // Inside the same component, after creating `doc`: const undo = useMemo(() => new UndoManager(doc), [doc]); @@ -257,7 +257,7 @@ Because Mirror owns the bidirectional mapping between application state and the - Text. Many interfaces render by lines, yet LoroText’s low‑level API is index‑based. Teams typically re‑implement line segmentation and map edits back to lines by hand. With Mirror in the middle, it becomes feasible to surface optional line‑aware events on top of LoroText so the UI receives stable, line‑based diffs without custom conversion—while retaining the underlying CRDT guarantees. - Tree. LoroTree CRDT already ensures correct concurrent moves, but developers still translate tree operations into application‑state patches. Mirror carries first‑class mappings from tree events into your state shape, so consumers can work with natural “insert/move/delete node” updates. -- Ephemeral patches. We'll add [`setStateWithEphemeralPatch`](https://github.com/loro-dev/loro-mirror/issues/35) so Mirror can stream temporary drag or scale interactions through an `EphemeralStore`, letting collaborators see live previews while the persisted history stays clean and deduplicated once the change finalizes. +- Ephemeral patches. [`setStateWithEphemeralPatch`](https://github.com/loro-dev/loro-mirror/issues/35) lets Mirror stream temporary drag or scale interactions through an `EphemeralStore`, so collaborators can see live previews while the persisted history stays clean and deduplicated once the change finalizes. By using loro-mirror to bridge CRDTs and application state consistency, and by expressing schemas declaratively, we can let AI help developers get more done correctly. This makes Loro not only suitable for professional creative tools with real-time collaboration, but also for enabling people to build practical mini-tools for themselves and their communities. diff --git a/pages/blog/loro-now-open-source.mdx b/pages/blog/loro-now-open-source.mdx index cf11cb4..94c8f6b 100644 --- a/pages/blog/loro-now-open-source.mdx +++ b/pages/blog/loro-now-open-source.mdx @@ -221,7 +221,8 @@ yet to be widely used. Our CRDTs library is built on the brilliant concept of OT-like CRDTs from Seph Gentle's [Diamond-types](https://github.com/josephg/diamond-types). Joseph Gentle -is currently writing a paper on it, which is worth looking forward to. Its +and Martin Kleppmann later published the +[Eg-walker paper](https://arxiv.org/abs/2409.14252) on this line of work. Its notable features include reducing the cost of local operations, easier historical data reclamation, and sometimes lower storage and memory overhead. However, it relies on high-performance algorithms to apply remote operations. @@ -272,8 +273,8 @@ challenge was that We have recently overcome this issue. We developed a new rich text CRDT algorithm that can run on OT-like CRDTs and has passed the capabilities listed in the Peritext paper's Criteria for rich text CRDTs, with no new issues -revealed in our current million fuzzing tests. We will write an article in the -future specifically to introduce this algorithm. +revealed in our current million fuzzing tests. We later introduced this work in +the [Loro rich text article](https://loro.dev/blog/loro-richtext). #### Movable Tree @@ -325,38 +326,33 @@ Internally, we've The state represents the current form of the document, akin to Git's HEAD pointer, while the document's history resembles the complete operation history behind Git. Hence, multiple document states can correspond to the same history. -This structure simplifies our code and facilitates future support for version -control. +This structure simplifies our code and provides the foundation for Loro's +version-control primitives. Most of our optimizations thus far have focused on text manipulations, -historically one of the thorniest problems in CRDTs. In the future, we plan -optimizations for a wider range of real-world scenarios. +historically one of the thorniest problems in CRDTs. Later releases expanded +these optimizations across more real-world scenarios. -### The Future +### Update ![Untitled](./loro-now-open-source/Untitled%202.png) -We aim to reach version 1.0 by mid-next year, with much work to complete. +Loro has since reached 1.0 and stabilized its core data format. The project now +ships production-ready JavaScript/WASM and Rust packages, plus maintained +bindings and examples for other platforms. -Given our limited workforce, we will first provide a WASM interface for web -developers to experiment with. Optimizing the WASM size is one of our goals for -this phase. Much of our design work is still ongoing, and we plan to stabilize -it in the next quarter, aiming for a simple yet powerful and flexible API. We -welcome ideas and suggestions in our +Documentation and developer tooling remain active areas of work. We welcome +ideas and suggestions in our [community discussions](https://discord.gg/tUsBSVfqzf). -There's also extensive documentation work to make working with Loro enjoyable. A -potential indicator of success would be GPT generating sufficiently good code -based on our documentation. - Developing tools for developers is a challenging and exciting task. Many developer tools and visualization methods in front-end development are exceptionally good, and we hope to bring such experiences into the world of CRDTs and local-first development. DevTools will reveal CRDTs' hidden states and simplify control, making state maintenance and debugging a breeze. -We also plan to support richer CRDT semantics, including Movable Lists and -global undo/redo operations to support more diverse application scenarios. +Loro now supports richer CRDT semantics, including movable data structures and +undo/redo APIs for more diverse application scenarios. ## Seeking Collaborative Project Opportunities diff --git a/pages/blog/v1.0.mdx b/pages/blog/v1.0.mdx index 5849587..a80fbd0 100644 --- a/pages/blog/v1.0.mdx +++ b/pages/blog/v1.0.mdx @@ -12,7 +12,7 @@ import Authors, { Author } from "../../components/authors"; @@ -73,10 +73,10 @@ operation application, and network exception handling. For Loro's CRDT document, just two rounds of data exchange can transmit the missing operations between two documents to achieve final consistency: -> You can find all the code samples in this blog [here](https://github.com/https://twitter.com/zx_loro/loro-blog-examples) +> You can find all the code samples in this blog [here](https://github.com/zxch3n/loro-blog-examples) ```jsx -import { LoroDoc, VersionVector } from "npm:loro-crdt@1.0.0-beta.3"; +import { LoroDoc, VersionVector } from "npm:loro-crdt@1.13.3"; const docA = new LoroDoc(); const docB = new LoroDoc(); @@ -112,7 +112,7 @@ A minimum of one round of data exchange can ensure consistency ```jsx -import { LoroDoc } from "npm:loro-crdt@1.0.0-beta.2"; +import { LoroDoc } from "npm:loro-crdt@1.13.3"; const docA = new LoroDoc(); const docB = new LoroDoc(); @@ -180,7 +180,7 @@ We also support: Loro also supports nesting between types, so you can model edits on JSON documents through them: -> You can find all the code samples in this blog [here](https://github.com/https://twitter.com/zx_loro/loro-blog-examples) +> You can find all the code samples in this blog [here](https://github.com/zxch3n/loro-blog-examples) ```tsx import { @@ -188,7 +188,7 @@ import { LoroList, LoroMap, LoroText, -} from "npm:loro-crdt@1.0.0-beta.2"; +} from "npm:loro-crdt@1.13.3"; // Create a JSON structure of interface JsonStructure { @@ -223,10 +223,10 @@ Loro supports primitives that allow users to switch between different versions, Based on this operation primitive, applications can build various Git-like capabilities: - You can merge multiple versions without needing to manually resolve conflicts -- You can rebase/squash updates from the current branch to the target branch (WIP) +- Applications can build rebase and squash workflows on top of Loro's version primitives. ```jsx -import { LoroDoc } from "npm:loro-crdt@1.0.0-beta.2"; +import { LoroDoc } from "npm:loro-crdt@1.13.3"; const doc = new LoroDoc(); doc.setPeerId("0"); @@ -257,7 +257,7 @@ console.log(doc.getText("text").toString()); // "Hello, world! Alice!" You can also use `doc.fork()` to create a separate doc at the current version. It is independent of the current doc, and works like a fork: ```tsx -import { LoroDoc } from "npm:loro-crdt@1.0.0-beta.4"; +import { LoroDoc } from "npm:loro-crdt@1.13.3"; const doc = new LoroDoc(); doc.setPeerId("0"); @@ -279,7 +279,7 @@ console.log(doc.getText("text").toString()); // "Hello, world! Alice!" ``` Loro CRDTs still have significant room for optimization in these scenarios. -Currently, the Loro CRDTs library doesn't involve network or disk I/O, which -enhances its ease of use but also constrains its capabilities and potential -optimizations. +At the time of Loro 1.0, the Loro CRDTs library didn't involve network or disk +I/O, which enhanced its ease of use but also constrained its capabilities and +potential optimizations. For example, while we've implemented block-level storage, documents are still imported and exported as whole units. Adding I/O capabilities to selectively load/save blocks would enable significant performance optimizations. @@ -722,9 +722,5 @@ We're excited to see it being applied in various scenarios. If you're interested in using Loro, welcome to join our [Discord community](https://discord.gg/tUsBSVfqzf) for discussions. - +For current updates, follow the [changelog](/changelog) or join the +[Discord community](https://discord.gg/tUsBSVfqzf). diff --git a/pages/docs/api/js.mdx b/pages/docs/api/js.mdx index a7f908a..4800d29 100644 --- a/pages/docs/api/js.mdx +++ b/pages/docs/api/js.mdx @@ -6,7 +6,7 @@ import Method from "./method"; # API Reference -> _Last updated: 2025-08-09 loro-crdt@1.5.10_ +> _Last updated: 2026-06-16 loro-crdt@1.13.3_ ## Overview diff --git a/pages/docs/performance/index.md b/pages/docs/performance/index.md index de4382e..11c4f45 100644 --- a/pages/docs/performance/index.md +++ b/pages/docs/performance/index.md @@ -99,7 +99,7 @@ and Fire" is only 1.6 million characters long (including whitespace). | N = 6000 | yjs | ywasm | loro | loro-old | automerge | automerge-wasm | | :----------------------------------------------------------------------- | ---------------: | --------------: | ---------------: | ---------------: | ---------------: | --------------: | -| Version | 13.6.15 | 0.17.4 | 1.0.0-beta.2 | 0.15.2 | 2.1.10 | 0.9.0 | +| Version | 13.6.15 | 0.17.4 | 1.0.0-beta.2 (historical) | 0.15.2 | 2.1.10 | 0.9.0 | | Bundle size | 84,017 bytes | 938,991 bytes | 2,919,363 bytes | 1,583,094 bytes | 1,696,176 bytes | 1,701,136 bytes | | Bundle size (gzipped) | 25,105 bytes | 284,616 bytes | 894,460 bytes | 592,039 bytes | 591,049 bytes | 594,071 bytes | | [B1.1] Append N characters (time) | 141 ms | 171 ms | 164 ms | 115 ms | 279 ms | 110 ms | diff --git a/pages/docs/tutorial/get_started.mdx b/pages/docs/tutorial/get_started.mdx index d6cdeb2..6bace6b 100644 --- a/pages/docs/tutorial/get_started.mdx +++ b/pages/docs/tutorial/get_started.mdx @@ -119,7 +119,7 @@ example: - - -``` - -## Introduction - -It is well-known that syncing data/building realtime collaborative apps is -challenging, especially when devices can be offline or part of a peer-to-peer -network. Loro simplifies this process for you. - -After you model your app state by Loro, syncing is simple: - -```ts twoslash -import { LoroDoc } from "loro-crdt"; -const docA = new LoroDoc(); -const docB = new LoroDoc(); - -//...operations on docA and docB - -// Assume docA and docB are two Loro documents in two different devices -const bytesA = docA.export({ mode: "update" }); -// send bytes to docB by any method -docB.import(bytesA); -// docB is now updated with all the changes from docA - -const bytesB = docB.export({ mode: "update" }); -// send bytes to docA by any method -docA.import(bytesB); -// docA and docB are now in sync, they have the same state -``` - -Saving your app state is also straightforward: - -```ts twoslash -import { LoroDoc } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -doc.getText("text").insert(0, "Hello world!"); -const bytes = doc.export({ mode: "snapshot" }); -// Bytes can be saved to local storage, database, or sent over the network -``` - -Snapshots and updates include a checksum in their headers, so any corruption from storage or transmission (like bit flips) is detected during import before it can affect your document. - -Loading your app state: - -```ts no_run twoslash -import { LoroDoc } from "loro-crdt"; -const bytes = new Uint8Array(); -// ---cut--- -const newDoc = new LoroDoc(); -newDoc.import(bytes); -``` - -Loro also makes it easy for you to time travel the history and add version -control to your app. [Learn more about time travel](/docs/tutorial/time_travel). - -```ts no_run twoslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const version = doc.frontiers(); -// ---cut--- -doc.checkout(version); // Checkout the doc to the given version -``` - -Loro is compatible with the JSON schema. If you can model your app state with -JSON, you probably can sync your app with Loro. Because we need to adhere to the -JSON schema, using a number as a key in a Map is not permitted, and cyclic links -should be avoided. - -```ts no_run twoslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -// ---cut--- -doc.toJSON(); // Get the JSON representation of the doc -``` - -## Entry Point: LoroDoc - -LoroDoc is the entry point for using Loro. You must create a Doc to use Map, -List, Text, and other types and to complete data synchronization. - -```ts twoslash -import { LoroDoc, LoroText } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -const text: LoroText = doc.getText("text"); -text.insert(0, "Hello world!"); -console.log(doc.toJSON()); // { "text": "Hello world!" } -``` - -## Container - -We refer to CRDT types such as `List`, `Map`, `Tree`, `MovableList`, and `Text` -as `Container`s. - -Here are their basic operations: - -```ts twoslash -import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; -import { expect } from "expect"; -// ---cut--- -const doc = new LoroDoc(); -const list: LoroList = doc.getList("list"); -list.insert(0, "A"); -list.insert(1, "B"); -list.insert(2, "C"); - -const map: LoroMap = doc.getMap("map"); -// map can only has string key -map.set("key", "value"); -expect(doc.toJSON()).toStrictEqual({ - list: ["A", "B", "C"], - map: { key: "value" }, -}); - -// delete 2 element at index 0 -list.delete(0, 2); -expect(doc.toJSON()).toStrictEqual({ - list: ["C"], - map: { key: "value" }, -}); - -// Insert a text container to the list -const text = list.insertContainer(0, new LoroText()); -text.insert(0, "Hello"); -text.insert(0, "Hi! "); - -expect(doc.toJSON()).toStrictEqual({ - list: ["Hi! Hello", "C"], - map: { key: "value" }, -}); - -// Insert a list container to the map -const list2 = map.setContainer("test", new LoroList()); -list2.insert(0, 1); -expect(doc.toJSON()).toStrictEqual({ - list: ["Hi! Hello", "C"], - map: { key: "value", test: [1] }, -}); -``` - -## Save and Load - -Loro is a pure library and does not handle network protocols or storage mechanisms. It is your responsibility to manage the storage and transmission of the binary data exported by Loro. - -To save the document, use `doc.export({mode: "snapshot"})` to get its binary -form. To open it again, use `doc.import(data)` to load this binary data. - -```ts twoslash -import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; -import { expect } from "expect"; -// ---cut--- -const doc = new LoroDoc(); -doc.getText("text").insert(0, "Hello world!"); -const data = doc.export({ mode: "snapshot" }); - -const newDoc = new LoroDoc(); -newDoc.import(data); -expect(newDoc.toJSON()).toStrictEqual({ - text: "Hello world!", -}); -``` +# How to Use Time Travel in Loro -Exporting the entire document on each keypress is inefficient. Instead, use -`doc.export({mode: "update", from: VersionVector})` to obtain binary data for -operations since the last export. +In Loro, you can call `doc.checkout(frontiers)` to jump to the version specified +by the +frontiers([Learn more about frontiers](/docs/advanced/version_deep_dive#frontiers)). -```ts twoslash -import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; -import { expect } from "expect"; -// ---cut--- -const doc = new LoroDoc(); -doc.getText("text").insert(0, "Hello world!"); -const data = doc.export({ mode: "snapshot" }); -let lastSavedVersion = doc.version(); -doc.getText("text").insert(0, "✨"); -const update0 = doc.export({ mode: "update", from: lastSavedVersion }); -lastSavedVersion = doc.version(); -doc.getText("text").insert(0, "😶‍🌫️"); -const update1 = doc.export({ mode: "update", from: lastSavedVersion }); +Note that using `doc.checkout(frontiers)` to jump to a specific version places +the document in a detached state, preventing further edits. To learn more, see +[_Attached/Detached Status_](/docs/advanced/doc_state_and_oplog#attacheddetached-status). +To continue editing, reattach the document to the latest version using +`doc.attach()`. This design is temporary and will be phased out once we have a +more refined version control API in place. -{ - /** - * You can import the snapshot and the updates to get the latest version of the document. - */ +## Read-only Time Travel - // import the snapshot - const newDoc = new LoroDoc(); - newDoc.import(data); - expect(newDoc.toJSON()).toStrictEqual({ - text: "Hello world!", - }); +Below we demonstrate how to implement simple, read-only time-travel. You could, +for example, combine this with a slider in a UI to allow users to view the document +over time. - // import update0 - newDoc.import(update0); - expect(newDoc.toJSON()).toStrictEqual({ - text: "✨Hello world!", - }); +### Enable Timestamps - // import update1 - newDoc.import(update1); - expect(newDoc.toJSON()).toStrictEqual({ - text: "😶‍🌫️✨Hello world!", - }); -} +Before this example will work, it is important that the edits made to the document +have had [timestamp storage](/docs/advanced/timestamp) enabled: -{ - /** - * You may also import them in a batch - */ - const newDoc = new LoroDoc(); - newDoc.importUpdateBatch([update1, update0, data]); - expect(newDoc.toJSON()).toStrictEqual({ - text: "😶‍🌫️✨Hello world!", - }); -} +```ts no_run +doc.setRecordTimestamp(true); ``` -If updates accumulate, exporting a new snapshot can quicken import times and -decrease the overall size of the exported data. +This makes sure that all changes to the document will have a timestamp added to it. +We will use this timestamp to sort changes so that the ordering will match user +intuition. -You can store the binary data exported from Loro wherever you prefer. +### Implementing Time Travel -## Sync +The first step is to load our document. Here we assume that you have a snapshot from your database +or API. -Two documents with concurrent edits can be synchronized by just two message -exchanges. +```ts no_run +// Get the snapshot for your doc from your database / API +let snapshot = fetchSnapshot(); -Below is an example of synchronization between two documents: +// Import into a new document +const doc = new LoroDoc(); +doc.import(snapshot); +``` -```ts twoslash -import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; -import { expect } from "expect"; -// ---cut--- -const docA = new LoroDoc(); -const docB = new LoroDoc(); -const listA: LoroList = docA.getList("list"); -listA.insert(0, "A"); -listA.insert(1, "B"); -listA.insert(2, "C"); -// B import the ops from A -const data: Uint8Array = docA.export({ mode: "update" }); -// The data can be sent to B through the network -docB.import(data); -expect(docB.toJSON()).toStrictEqual({ - list: ["A", "B", "C"], -}); +Next we must collect and sort the timestamps for every change in the document. We want uesrs to be +able to drag a slider to select a timestamp out of this list. -const listB: LoroList = docB.getList("list"); -listB.delete(1, 1); +```ts no_run +// Collect all changes from the document +const changes = doc.getAllChanges(); -// `doc.export({mode: "update", from: version})` can encode all the ops from the version to the latest version -// `version` is the version vector of another document -const missingOps = docB.export({ - mode: "update", - from: docA.oplogVersion(), -}); -docA.import(missingOps); +// Get the timestamps for all changes +const timestamps = Array.from( + new Set( + [...changes.values()] + .flat() // Flatten changes from all peers into one list + .map((x) => x.timestamp) // Get the timestamp from each peer + .filter((x) => !!x), + ), +); -expect(docA.toJSON()).toStrictEqual({ - list: ["A", "C"], -}); -expect(docA.toJSON()).toStrictEqual(docB.toJSON()); +// Sort the timestamps +timestamps.sort((a, b) => a - b); ``` -## Event +Next we need to make a helper function that will return a list of +[Frontiers](/docs/advanced/version_deep_dive#frontiers) for any timestamp. -You can subscribe to the event from `Container`s. +For each peer that has edited a document, there is a list of changes by that peer. Each change has a +`counter`, and a `length`. That `counter` is like an always incrementing version number for the +changes made by that peer. -`LoroText` and `LoroList` can receive updates in -[Quill Delta](https://quilljs.com/docs/delta/) format. +A change's `counter` is the starting point of the change, and the `length` indicates how much the +change incremented the counter before the end of the change. -The events will be emitted after a transaction is committed. A transaction is -committed when: +The frontiers are the list of counters that we want to checkout from each peer. Since we are going +for a timeline view, we want to get the highest counter that we know happned before our timestamp +for each peer. -- `doc.commit()` is called. -- `doc.export(mode)` is called. -- `doc.import(data)` is called. -- `doc.checkout(version)` is called. +Here we make a helper function to do that. -Below is an example of rich text event: +```ts +const getFrontiersForTimestamp = ( + changes: Map, + ts: number, +): { peer: string; counter: number }[] => { + const frontiers = [] as { peer: string; counter: number }[]; -```ts twoslash -import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; -import { expect } from "expect"; -// ---cut--- -// The code is from https://github.com/loro-dev/loro-examples-deno -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello world!"); -doc.commit(); -let ran = false; -text.subscribe((e) => { - for (const event of e.events) { - if (event.diff.type === "text") { - expect(event.diff.diff).toStrictEqual([ - { - retain: 5, - attributes: { bold: true }, - }, - ]); - ran = true; + // Record the highest counter for each peer where it's change is not later than + // our target timestamp. + changes.forEach((changes, peer) => { + let counter = -1; + for (const change of changes) { + if (change.timestamp <= ts) { + counter = Math.max(counter, change.counter + change.length - 1); + } } - } -}); -text.mark({ start: 0, end: 5 }, "bold", true); -doc.commit(); -await new Promise((r) => setTimeout(r, 1)); -expect(ran).toBeTruthy(); + if (counter > -1) { + frontiers.push({ counter, peer }); + } + }); + return frontiers; +}; ``` -The types of events are defined as follows: - -```ts twoslash -import { Path, Diff, Frontiers, ContainerID } from "loro-crdt"; +Finally, all we can get the index from our slider, get the timestamp from our list, and then +checkout the calculated frontiers. -export interface LoroEventBatch { - /** - * How the event is triggered. - * - * - `local`: The event is triggered by a local transaction. - * - `import`: The event is triggered by an import operation. - * - `checkout`: The event is triggered by a checkout operation. - */ - by: "local" | "import" | "checkout"; - origin?: string; - /** - * The container ID of the current event receiver. - * It's undefined if the subscriber is on the root document. - */ - currentTarget?: ContainerID; - events: LoroEvent[]; - from: Frontiers; - to: Frontiers; -} +```ts no_run +let sliderIdx = 3; +const timestamp = timestamps[sliderIdx - 1]; +const frontiers = getFrontiersForTimestamp(changes, timestamp); -/** - * The concrete event of Loro. - */ -export interface LoroEvent { - /** - * The container ID of the event's target. - */ - target: ContainerID; - diff: Diff; - /** - * The absolute path of the event's emitter, which can be an index of a list container or a key of a map container. - */ - path: Path; -} +doc.checkout(frontiers); ``` +## Time Travel With Editing -# FILE: pages/docs/index.mdx +Below is a more complete example demonstrating Time Travel functionality with a node editor. -## Introduction to Loro + -It is well-known that syncing data/building realtime collaborative apps is -challenging, especially when devices can be offline or part of a peer-to-peer -network. Loro simplifies this process for you. -We want to provide better DevTools to make building -[local-first apps](https://www.inkandswitch.com/local-first/) easy and -enjoyable. +# FILE: pages/docs/tutorial/composition.mdx -Loro uses [Conflict-free Replicated Data Types (CRDTs)](/docs/concepts/crdt) to -resolve parallel edits. By utilizing Loro's data types, your applications can be -made collaborative and keep the editing history with low overhead. +--- +keywords: "crdts, json, data model, document state, semantics" +description: "Everyone can effectively model the states and the updates of documents that conform to the JSON schema." +--- -After you model your app state by Loro, syncing is simple: +# Composing CRDTs -```ts twoslash -import { LoroDoc } from "loro-crdt"; -const docA = new LoroDoc(); -const docB = new LoroDoc(); -docA.getText("text").insert(0, "Hello world!"); -docB.getText("text").insert(0, "Hi!"); -// Assume docA and docB are two Loro documents in two different devices -const bytesA = docA.export({ mode: "update" }); -// send bytes to docB by any method -docB.import(bytesA); -// docB is now updated with all the changes from docA +In Loro, you can build complex data structures using basic CRDTs such as List, MovableList, Map and Tree. These containers can include sub-containers, which in turn can contain more sub-containers, allowing for the composition of intricate data structures. -const bytesB = docB.export({ mode: "update" }); -// send bytes to docA by any method -docA.import(bytesB); -// docA and docB are now in sync, they have the same state -``` +It's important to note that documents in Loro must adhere to a tree structure. This means that while a parent can have multiple children, each child is restricted to only one parent. Therefore, the document forms a tree rather than a graph (like a DAG). -Saving your app state is also straightforward: +By leveraging these fundamental CRDTs, you can effectively model the states and the updates of documents that conform to the JSON schema. ```ts twoslash -import { LoroDoc } from "loro-crdt"; +import { LoroDoc, LoroList, LoroText } from "loro-crdt"; +import { expect } from "expect"; // ---cut--- const doc = new LoroDoc(); -doc.getText("text").insert(0, "Hello world!"); -const bytes = doc.export({ mode: "snapshot" }); -// Bytes can be saved to local storage, database, or sent over the network -``` - -Loading your app state: +const map = doc.getMap("map"); +let callTimes = 0; +// Events from a child are propagated to all ancestor nodes. +map.subscribe((event) => { + console.log(event); + callTimes++; +}); -```ts no_run twoslash -import { LoroDoc } from "loro-crdt"; -const bytes = new Uint8Array([1, 2, 3]); -// ---cut--- -const newDoc = new LoroDoc(); -newDoc.import(bytes); -``` +// Create a sub container for map +// { map: { list: [] } } +const list = map.setContainer("list", new LoroList()); +list.push(0); +list.push(1); -Loro also makes it easy for you to time travel the history and add version -control to your app. [Learn more about time travel](/docs/tutorial/time_travel). +// Create a sub container for list +// { map: { list: [0, 1, LoroText] } } +const text = list.insertContainer(2, new LoroText()); +expect(doc.toJSON()).toStrictEqual({ map: { list: [0, 1, ""] } }); +{ + // Commit will trigger the event, because list is a sub container of map + doc.commit(); + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(callTimes).toBe(1); +} -```ts no_run twoslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const version = doc.frontiers(); -// ---cut--- -doc.checkout(version); // Checkout the doc to the given version +text.insert(0, "Hello, "); +text.insert(7, "World!"); +expect(doc.toJSON()).toStrictEqual({ map: { list: [0, 1, "Hello, World!"] } }); +{ + // Commit will trigger the event, because text is a descendant of map + doc.commit(); + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(callTimes).toBe(2); +} ``` -Loro is compatible with the JSON schema. If you can model your app state with -JSON, you probably can sync your app with Loro. Because we need to adhere to the -JSON schema, using a number as a key in a Map is not permitted, and cyclic links -should be avoided. +`setContainer` creates a regular child container. This is fine for fixed +schemas, or when each creation should produce a distinct child object. If +multiple peers may lazily create the same child under the same Map key, use a +mergeable child container instead: -```ts no_run twoslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -// ---cut--- -doc.toJSON(); // Get the JSON representation of the doc +```ts no_run +const sharedList = map.ensureMergeableList("list"); ``` -import { Cards } from "nextra/components"; - - - <>![Getting started](/images/GettingStarted.png) - - +# FILE: pages/docs/tutorial/text.mdx -## Is Loro Right for You? +--- +keywords: "text crdt, richtext, richtext editor" +description: "how to use loro richtext crdt and show all APIs of loro text crdt." +--- -### ✅ Use Loro when you need: +# Text -- Real-time collaboration on documents -- Automatic conflict resolution for concurrent edits -- Offline editing with later synchronization -- Complete edit history and time travel -- P2P synchronization capabilities +Loro supports both plain text and rich text. When rich text features (like mark +and unmark) are not used, the text container operates as plain text without any +rich text overhead, making it efficient for simple text operations. -### ⚠️ Consider alternatives when: +LoroText offers excellent performance, particularly when handling large strings. +It significantly outperforms native JavaScript string operations due to its +internal B-tree structure. All basic operations like insert and delete have +O(log N) time complexity, making it highly efficient even when working with +documents containing several million characters. -- Your application requires strong consistency -- Your data isn't JSON-like (e.g., large binary/media streaming) -- Simple client–server sync is sufficient (e.g., basic WebSockets) -- Your application is sensitive to bundle size (Loro WASM binary ~970KB gzipped) +> To learn how rich text CRDT in Loro works under the hood, please refer to our +> blog: [Introduction to Loro's Rich Text CRDT](/blog/loro-richtext). -[Learn more about when not to use CRDTs →](/docs/concepts/when_not_crdt) +LoroText also supports stable cursor and selection positions. Using the Cursor API (`text.getCursor`, `doc.getCursorPos`, `Side`), you can represent caret/selection endpoints that remain valid across concurrent edits—similar to Yjs's `RelativePosition`. See the [Cursor](/docs/tutorial/cursor) guide for details. -## Differences from other CRDT libraries +## Editor Bindings -The table below summarizes Loro's features, which may not be present in other -CRDT libraries. +Loro provides official bindings for popular editors to make it easier to integrate Loro's CRDT capabilities: -| Features/Important design decisions | Loro | Diamond-types | Yjs | Automerge | -| :-------------------------------------------------------------------------- | :--- | :------------ | :---------- | :---------- | -| [Event Graph Walker](https://loro.dev/docs/advanced/replayable_event_graph) | ✅ | ✅ Inventor | ❌ | ❌ | -| Rich Text CRDT | ✅ | ❌ | ❌ | ✅ | -| [Movable Tree](https://ieeexplore.ieee.org/document/9563274) | ✅ | ❌ | ❌ | ❌ Inventor | -| [Movable List](https://loro.dev/docs/tutorial/list) | ✅ | ❌ | ❌ | ❌ Inventor | -| Time Travel | ✅ | ✅ | ✅[1] | ✅ | -| [Fugue](https://arxiv.org/abs/2305.00583) / Maximal non-interleaving | ✅ | ✅ | ❌ | ❌ | -| JSON Types | ✅ | ❓ | ✅ | ✅ | -| [Mergeable Containers](/blog/mergeable-containers) | ✅ | ❌ | ❌ | ❌ | -| Merging Elements in Memory by Run Length Encoding | ✅ | ✅ | ✅ Inventor | ❌ | -| Byzantine-fault-tolerance | ❌ | ❌ | ❌ | ✅ | -| Version Control | ✅ | ❌ | ❌ | ✅ | +### ProseMirror Binding -- [1] Unlike others, Yjs requires users to store a version vector and a delete - set, enabling time travel back to a specific point. -- [Fugue](https://arxiv.org/abs/2305.00583) is a text/list CRDTs that can - minimize the chance of the interleaving anomalies. +The [loro-prosemirror](https://github.com/loro-dev/loro-prosemirror) package provides seamless integration between Loro and ProseMirror, a powerful rich text editor framework. It includes: +- Document state synchronization with rich text support +- Cursor awareness and synchronization +- Undo/Redo support in collaborative editing -# FILE: pages/docs/concepts/cursor_stable_positions.mdx +The ProseMirror binding can also be used with [Tiptap](https://tiptap.dev/), a popular rich text editor built on top of ProseMirror. This means you can easily add collaborative editing capabilities to your Tiptap-based applications. ---- -keywords: "cursor, stable position, collaborative editing, CRDT, concurrent edits, selection, annotation, caret" -description: "Understanding cursor and stable position systems in Loro for maintaining accurate positions across concurrent edits" ---- +```ts no_run +import { + CursorAwareness, + LoroCursorPlugin, + LoroSyncPlugin, + LoroUndoPlugin, + undo, + redo, +} from "loro-prosemirror"; +import { LoroDoc } from "loro-crdt"; +import { EditorView } from "prosemirror-view"; +import { EditorState } from "prosemirror-state"; +import { keymap } from "prosemirror-keymap"; -# Cursor and Stable Positions +const doc = new LoroDoc(); +const awareness = new CursorAwareness(doc.peerIdStr); +const plugins = [ + ...pmPlugins, + LoroSyncPlugin({ doc }), + LoroUndoPlugin({ doc }), + keymap({ + "Mod-z": undo, + "Mod-y": redo, + "Mod-Shift-z": redo, + }), + LoroCursorPlugin(awareness, {}), +]; +const editor = new EditorView(editorDom, { + state: EditorState.create({ doc, plugins }), +}); +``` -## Quick Reference +### CodeMirror Binding -**Cursors** maintain stable positions across concurrent edits by anchoring to operation IDs instead of indices. Essential for collaborative editing features like collaborative cursors and persistent annotations. +The [loro-codemirror](https://github.com/loro-dev/loro-codemirror) package provides integration between Loro and CodeMirror 6, a versatile code editor. It supports: -## How It Works +- Document state synchronization +- Cursor awareness +- Undo/Redo functionality -Cursors anchor to operation IDs and ContainerIDs, not indices: +```ts no_run +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { LoroExtensions } from "loro-codemirror"; +import { Awareness, LoroDoc, UndoManager } from "loro-crdt"; -``` -Text: H e l l o W o r l d -Op IDs: 1 2 3 4 5 6 7 8 9 A B -Cursor: References ID 5 (after 'o') +const doc = new LoroDoc(); +const awareness = new Awareness(doc.peerIdStr); +const undoManager = new UndoManager(doc, {}); -After concurrent insert at start: -Text: N e w H e l l o W o r l d -Op IDs: C D E F 1 2 3 4 5 6 7 8 9 A B -Cursor: Still references ID 5 - position automatically adjusted +new EditorView({ + state: EditorState.create({ + extensions: [ + // ... other extensions + LoroExtensions( + doc, + { + awareness: awareness, + user: { name: "Bob", colorClassName: "user1" }, + }, + undoManager, + ), + ], + }), + parent: document.querySelector("#editor")!, +}); ``` -## Side Parameter - -- **`Side.Before` (-1)**: Stay before the target -- **`Side.Middle` (0)**: On the target (default) -- **`Side.After` (1)**: Stay after the target - -```ts twoslash -import { LoroDoc } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "ABC"); +## LoroText vs String -const cursor = text.getCursor(1, -1); // Before 'B' -text.insert(1, "X"); // Insert at cursor -// Result: "AXBC", cursor still before 'B' -const pos = doc.getCursorPos(cursor!); -console.log(pos); // { offset: 2, side: Side.Before } -``` +It's important to understand that LoroText is very different from using a regular string type. So the following code has different merge results: -## Common Use Cases +Using `LoroText`: -### Text Selections ```ts twoslash import { LoroDoc } from "loro-crdt"; // ---cut--- const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello World"); - -// Selection range -const start = text.getCursor(0, -1); // Anchor -const end = text.getCursor(5, 1); // Head +doc.setPeerId("0"); +doc.getText("text").insert(0, "Hello"); +const doc2 = new LoroDoc(); +doc2.setPeerId("1"); +doc2.getText("text").insert(0, "World"); +doc.import(doc2.export({ mode: "update" })); +console.log(doc.getText("text").toString()); // "HelloWorld" ``` -### List Positions +Using `String`: + ```ts twoslash import { LoroDoc } from "loro-crdt"; // ---cut--- const doc = new LoroDoc(); -const list = doc.getList("items"); -list.insert(0, ["a", "b", "c"]); - -const cursor = list.getCursor(0, 1); // After "a" -list.insert(0, "new"); // Cursor adjusts automatically +doc.setPeerId("0"); +doc.getMap("map").set("text", "Hello"); +const doc2 = new LoroDoc(); +doc2.setPeerId("1"); +doc2.getMap("map").set("text", "World"); +doc.import(doc2.export({ mode: "update" })); +console.log(doc.getMap("map").get("text")); // "World" ``` -## Related Documentation - -- [Text Container](../tutorial/text) - Text editing with cursors -- [Cursor Tutorial](../tutorial/cursor) - Building collaborative features -- [Events](../tutorial/event) - Cursor change events +### Merge Semantics +Unlike LoroMap which uses Last-Write-Wins (LWW) semantics, LoroText is designed to preserve edits. Here's how they differ: -# FILE: pages/docs/concepts/when_not_crdt.mdx +When user A and user B make concurrent edits to the same text: ---- -keywords: "crdt, limitations, constraints, business logic, invariants" -description: "When CRDTs are not the right tool: hard invariants, exclusivity, ordering, and validation" ---- +- LoroText will merge both users' edits in sequence, preserving both changes +- LoroMap will use LWW semantics, keeping only one user's changes -# When Not to Use CRDTs +### When to Use String in LoroMap -CRDTs shine for collaborative editing and offline-friendly applications. -But they are not a universal replacement for coordination, transactions, -or authorization. Use this guide to recognize when CRDTs are a poor fit and what to use instead. +There are specific scenarios where using a string in LoroMap (with LWW semantics) might be more appropriate than using LoroText: -## Quick Reference +- **URLs**: When dealing with hyperlinks, automatic merging could result in invalid URLs. In this case, it's better to use LoroMap's LWW semantics to ensure the URL remains valid. +- **Hash String**: When handling hash string, LWW semantics are more appropriate to maintain data accuracy and consistency. -CRDTs merge; they do not reject. This clashes with requirements that demand global agreement at the time of the action: -- **Hard invariants**: Must never be violated (e.g., balance ≥ 0) -- **Exclusive ownership**: Only one winner per slot/resource +## Rich Text Config -## Why CRDTs fall short here +To use rich text in Loro, you need to specify the expanding behaviors for each +format first. When we insert new text at the format boundaries, they define +whether the inserted text should inherit the format. -- Merging is monotonic: remote edits cannot be “rolled back” on arrival -- No locks or transactions across replicas -- Check-then-set across peers needs coordination, not just convergence +There are four kinds of expansion behaviors: -## Common problem scenarios +- `after`(default): when inserting text right after the given range, the mark + will be expanded to include the inserted text +- `before`: when inserting text right before the given range, the mark will be + expanded to include the inserted text +- `none`: the mark will not be expanded to include the inserted text at the + boundaries +- `both`: when inserting text either right before or right after the given + range, the mark will be expanded to include the inserted text -### 1) Exclusive resource management +### Example -Multiple users book the same room/time concurrently. +```ts twoslash +import { LoroDoc, Delta } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +doc.configTextStyle({ + bold: { expand: "after" }, + link: { expand: "before" }, +}); +const text = doc.getText("text"); +text.insert(0, "Hello World!"); +text.mark({ start: 0, end: 5 }, "bold", true); +expect(text.toDelta()).toStrictEqual([ + { + insert: "Hello", + attributes: { + bold: true, + }, + }, + { + insert: " World!", + }, +] as Delta[]); -```ts no_run -// With a CRDT-only model both intents exist after merge -// The app must pick a winner out-of-band -book("A101@2pm", { by: "A" }) -book("A101@2pm", { by: "B" }) -// After sync: both entries are present → invariant violated without coordination +// " Test" will inherit the bold style because `bold` is configured to expand forward +text.insert(5, " Test"); +expect(text.toDelta()).toStrictEqual([ + { + insert: "Hello Test", + attributes: { + bold: true, + }, + }, + { + insert: " World!", + }, +] as Delta[]); ``` -Use instead: -- Transactional authority (central server or database) -- Distributed consensus (Raft/Paxos) for ownership -- Hybrid: CRDT for UI drafts, authoritative booking via server +## Methods -### 2) Financial transactions +### `insert(pos: number, s: string)` -Two withdrawals race on a $100 account. +Insert text at the given pos. -```ts no_run -// Each client applies its own withdrawal locally -withdraw(60) // A -withdraw(60) // B -// After merge, naive addition overdrafts without an authority -``` +### `delete(pos: number, len: number)` -Use instead: -- ACID transactions on an authoritative store -- Command validation with a single writer per account -- Event-sourced ledger with server-side checks +Delete text at the given range. -### Other cases to avoid CRDT-as-the-only-source +### `slice(start: number, end: number): string` -- Uniqueness constraints (usernames, slugs, one primary per group) -- Authorization decisions that must be enforced at write time -- Global invariants (referential integrity, cross-document totals) +Get a string slice. -## When CRDTs work well +### `sliceDelta(start: number, end: number): Delta[]` -CRDTs excel when merged results are meaningful and temporary inconsistency is acceptable: +Get a Quill-style Delta slice for the given UTF-16 range. Use +`sliceDeltaUtf8` to slice by UTF-8 byte offsets instead. -- ✅ Collaborative text, lists, maps, rich presence -- ✅ Comments, reactions, drafts, annotations -- ✅ Offline edits where convergence is enough +### `sliceDeltaUtf8(start: number, end: number): Delta[]` +Get a Quill-style Delta slice for the given UTF-8 byte range. +### `toString(): string` -# FILE: pages/docs/concepts/import_status.mdx +Get the plain text value. -# Import Status +### `charAt(pos: number): char` -## Quick Reference +Get the character at the given position. -**Import status** tells you what operations were applied and what's pending due to missing dependencies. Essential for handling out-of-order updates in distributed systems. +### `splice(pos: number, len: number, text: string): string` -## Status Structure +Delete and return the string at the given range and insert a string at the same +position. -```ts twoslash -interface ImportStatus { - success: PeerVersionRange; // What was applied - pending?: PeerVersionRange; // What needs dependencies -} +### `length: number` -interface PeerVersionRange { - [peerId: `${number}`]: { - start: number; // Inclusive - end: number; // Exclusive (e.g., 0-50 = ops 0-49) - }; -} -``` +Get the length of text +### `getCursor(pos: number, side?: Side): Cursor | undefined` -## Pending Operations +Create a stable position handle for the given logical index. This is equivalent in purpose to Yjs's `RelativePosition` and is designed to stay valid across concurrent inserts/deletes. -Operations become pending when they depend on missing operations (causal dependencies). +- A cursor can represent a caret or one end of a selection. Store two cursors (anchor/head) to represent a selection. +- Use `doc.getCursorPos(cursor)` to resolve the current absolute offset and side. It may also return an updated cursor you should persist to minimize future replays. +- Offsets for Text are UTF-16 indices for the WASM binding. +- `side` controls whether the cursor sits to the left (-1), center (0), or right (1) of the target ID at boundaries. It affects how the cursor behaves when text is inserted at its position. -### Common Scenario: Out-of-Order Delivery +See the dedicated guide: [Cursor](/docs/tutorial/cursor). + +Example (caret): ```ts twoslash import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; // ---cut--- const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "123"); -// Peer A creates ops 0-4, then 5-10 -const peerA = new LoroDoc(); -peerA.getText("text").insert(0, "Hello"); -const update1 = peerA.export({ mode: "update" }); - -peerA.getText("text").insert(5, " World"); -const update2 = peerA.export({ mode: "update", from: peerA.version() }); - -// If update2 arrives first: -const status = doc.import(update2); -console.log(status.pending); // Ops 10-19 pending, need 0-9 - -// Import missing dependencies: -doc.import(update1); // Both updates now applied -``` - -## Handling Pending Operations +// Create a stable cursor at the beginning +const cur = text.getCursor(0, 0); +{ + const pos = doc.getCursorPos(cur!); + expect(pos.offset).toBe(0); +} -```ts twoslash -import { LoroDoc, VersionVector } from "loro-crdt"; -// ---cut--- -async function handleImport( - doc: LoroDoc, - update: Uint8Array, - fetchMissing: (from: VersionVector) => Promise -) { - const status = doc.import(update); - - if (status.pending) { - const missing = await fetchMissing(doc.version()); - doc.import(missing); - } +// Insert before the cursor. The cursor now moves forward +text.insert(0, "1"); +{ + const pos = doc.getCursorPos(cur!); + expect(pos.offset).toBe(1); } ``` +Example (selection via two cursors): -## Best Practices - -### Always Check Status - -```ts twoslash no_run +```ts twoslash import { LoroDoc } from "loro-crdt"; -const update = new Uint8Array(); // ---cut--- const doc = new LoroDoc(); -const status = doc.import(update); -if (status.pending) { - console.warn("Operations pending:", status.pending); - // Fetch missing updates -} +const text = doc.getText("text"); +text.update("Hello World"); + +// Anchor at index 0 (left side), head at index 5 (right side) +const anchor = text.getCursor(0, -1)!; +const head = text.getCursor(5, 1)!; + +// Resolve to absolute positions when needed +const a = doc.getCursorPos(anchor); +const h = doc.getCursorPos(head); +// selection = [a.offset, h.offset) ``` -### Use Batch Import +Persisting and restoring a cursor: + ```ts twoslash -import { LoroDoc } from "loro-crdt"; +import { Cursor, LoroDoc } from "loro-crdt"; // ---cut--- -const updates: Uint8Array[] = [ - //... -]; const doc = new LoroDoc(); -const batchStatus = doc.importBatch(updates); -// Single status check for all updates -``` - -## Related Documentation +const text = doc.getText("text"); +text.update("Hi"); +const cur = text.getCursor(1, 0)!; -- [Version Vector](./version_vector) - Understanding version vectors -- [Synchronization Guide](../tutorial/sync) - Complete sync patterns -- [PeerID Management](./peerid_management) - How peer IDs affect import +// Serialize and share +const bytes = cur.encode(); +// Later on another peer +const restored = Cursor.decode(bytes); +const pos = doc.getCursorPos(restored); +``` -# FILE: pages/docs/concepts/frontiers.mdx +### `toJSON(): string` ---- -keywords: "frontiers, version, compact representation, DAG, CRDT" -description: "Understanding Frontiers in Loro - a compact way to represent document versions" ---- +Returns the plain text content as a string. This method: +- Returns only the text content without any formatting marks +- Is not affected by any rich text attributes (bold, italic, links, etc.) +- Is equivalent to `toString()` for text containers -# Frontiers +If you need rich text information including formatting, use `toDelta()` instead. -Frontiers are a compact way to represent document versions in Loro by identifying the "frontier" operations - the most recent operations that aren't followed by any other operations. +### `toDelta(): Delta[]` -## What are Frontiers? +Get the rich text value with all formatting information. It's in +[Quill's Delta format](https://quilljs.com/docs/delta/). -Think of Frontiers as **bookmarks in your document's history**. Instead of listing every change made by every collaborator (like Version Vectors do), Frontiers only point to the boundary operations that implicitly include all their ancestors through causal relationships. +Unlike `toJSON()` which returns plain text, `toDelta()` preserves all rich text attributes like bold, italic, links, and custom marks. -## Basic Usage +Example comparing `toJSON()` vs `toDelta()`: ```ts twoslash import { LoroDoc } from "loro-crdt"; - +// ---cut--- const doc = new LoroDoc(); -const text = doc.getText("content"); -text.insert(0, "Hello World"); - -// Get current frontiers (usually 1-2 elements) -const frontiers = doc.frontiers(); -console.log(frontiers); // [{ peer: "...", counter: 0 }] +doc.configTextStyle({ bold: { expand: "after" } }); +const text = doc.getText("text"); +text.insert(0, "Hello World!"); +text.mark({ start: 0, end: 5 }, "bold", true); -// Use frontiers for checkpoints -const checkpoint = doc.frontiers(); -text.insert(5, " Beautiful"); +// toJSON returns plain string without marks +console.log(text.toJSON()); // "Hello World!" -// Restore to checkpoint -doc.checkout(checkpoint); -console.log(text.toString()); // Back to "Hello World" +// toDelta returns rich text with formatting +console.log(text.toDelta()); +// [ +// { insert: "Hello", attributes: { bold: true } }, +// { insert: " World!" } +// ] ``` -## When to Use Frontiers - -Frontiers are ideal for: - -1. **Creating checkpoints** - Mark specific points in document history -2. **Time travel** - Navigate to exact operation points efficiently -3. **Storage optimization** - Remain compact even with many collaborators (usually 1-2 elements vs N entries in Version Vector) -4. **Recording milestones** - Save important document states - -## Quick Comparison - -| Aspect | Frontiers | Version Vectors | -|--------|-----------|----------------| -| **Size** | 1-2 elements typically | Grows with peer count | -| **Use Case** | Checkpoints, time travel | Synchronization, diffing | -| **Storage** | Very compact | Larger with many peers | -| **Unknown ops** | Cannot determine included ops | Can determine all included ops | +### Slice a Delta snippet -## Practical Example +Use `sliceDelta(start, end)` when you only need a portion of the Delta (for example, to copy a styled snippet). It uses UTF-16 indices just like other text APIs; use `sliceDeltaUtf8` if you need to slice by UTF-8 byte offsets instead. ```ts twoslash import { LoroDoc } from "loro-crdt"; - +import { expect } from "expect"; +// ---cut--- const doc = new LoroDoc(); -const text = doc.getText("content"); -const checkpoints = new Map(); - -// Save checkpoint with frontiers -text.insert(0, "Draft version"); -checkpoints.set("draft", doc.frontiers()); +doc.configTextStyle({ + bold: { expand: "after" }, + comment: { expand: "none" }, +}); +const text = doc.getText("text"); -// Make changes -text.delete(0, 5); -text.insert(0, "Final"); -checkpoints.set("final", doc.frontiers()); +text.insert(0, "Hello World!"); +text.mark({ start: 0, end: 5 }, "bold", true); +text.mark({ start: 6, end: 11 }, "comment", "greeting"); -// Restore to any checkpoint -doc.checkout(checkpoints.get("draft")); -console.log(text.toString()); // "Draft version" +const snippet = text.sliceDelta(1, 8); +expect(snippet).toStrictEqual([ + { insert: "ello", attributes: { bold: true } }, + { insert: " " }, + { insert: "Wo", attributes: { comment: "greeting" } }, +]); ``` -## Important Limitation +### `mark(range: {start: number, end: number}, key: string, value: any): void` -Frontiers have a key limitation when dealing with unknown operations: -- When you have a Frontier pointing to operations you don't know about, you cannot determine the complete set of operation IDs included in that version -- Version Vectors don't have this limitation - they explicitly list all peers and their operation counts, so you always know exactly which operations are included +Mark the given range with a key-value pair. -**Example**: -```ts twoslash -import { LoroDoc } from "loro-crdt"; +### `unmark(range: {start: number, end: number}, key: string): void` -// If you receive a frontier [{ peer: "unknown-peer", counter: 42 }] -// Without having the operations from "unknown-peer": -// - Frontiers: Cannot tell which specific operations are included -// - Version Vector: Would show { "unknown-peer": 43 }, clearly indicating ops 0-42 +Remove key-value pairs in the given range with the given key. -const doc = new LoroDoc(); -const frontiers = doc.frontiers(); -// Frontiers are compact but require operation history for full information -``` +### `update(text: string)` -This limitation is why Frontiers are best for scenarios where you have access to the operation history (like checkpoints in a local document), while Version Vectors are preferred for synchronization between peers who may not share complete history. +Update the current text based on the provided text. -## Conversion with Version Vectors +### `applyDelta(delta: Delta[]): void` -Loro allows seamless conversion between representations: +Change the state of this text by delta. + +If a delta item is `insert`, it should include all the attributes of the +inserted text. Loro's rich text CRDT may make the inserted text inherit some +styles when you use the `insert` method directly. However, when you use +`applyDelta` if some attributes are inherited from CRDT but not included in the +delta, they will be removed. + +Another special property of `applyDelta` is if you format an attribute for +ranges out of the text length, Loro will insert new lines to fill the gap first. +It's useful when you build the binding between Loro and rich text editors like +Quill, which might assume there is always a newline at the end of the text +implicitly. ```ts twoslash import { LoroDoc } from "loro-crdt"; - +import { expect } from "expect"; +// ---cut--- const doc = new LoroDoc(); -const frontiers = doc.frontiers(); -const vv = doc.frontiersToVV(frontiers); // Convert to Version Vector -const backToFrontiers = doc.vvToFrontiers(vv); // Convert back -``` - -## Learn More +const text = doc.getText("text"); +doc.configTextStyle({ bold: { expand: "after" } }); -For detailed technical explanation of how Frontiers work with Loro's DAG structure and causal ordering, see [Version Deep Dive](/docs/advanced/version_deep_dive). +text.insert(0, "Hello World!"); +text.mark({ start: 0, end: 5 }, "bold", true); +const delta = text.toDelta(); +const text2 = doc.getText("text2"); +text2.applyDelta(delta); +expect(text2.toDelta()).toStrictEqual(delta); +``` -# FILE: pages/docs/concepts/event_graph_walker.mdx +### `subscribe(f: (event: Listener)): number` ---- -keywords: "crdt, event graph walker, eg-walker, synchronization, collaboration, algorithm, innovation" -description: "Comprehensive guide to Event Graph Walker (Eg-Walker), a revolutionary CRDT algorithm that enables simpler metadata and better performance in Loro" ---- +This method returns a number that can be used to remove the subscription. -# Event Graph Walker (Eg-Walker) +The text event is in `Delta[]` format. It can be used to bind the rich +text editor. It has the same type as the arg of `applyDelta`, so the following +example works: -Event Graph Walker (Eg-Walker) is a revolutionary CRDT algorithm that fundamentally changes how collaborative editing systems handle concurrent operations. Instead of storing complex CRDT metadata, Eg-Walker enables the use of simple indices by efficiently replaying relevant history when needed. +```ts no_run twoslash +import { LoroDoc, TextDiff } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +(async () => { + const doc1 = new LoroDoc(); + doc1.configTextStyle({ + link: { expand: "none" }, + bold: { expand: "after" }, + }); + const text1 = doc1.getText("text"); + const doc2 = new LoroDoc(); + doc2.configTextStyle({ + link: { expand: "none" }, + bold: { expand: "after" }, + }); + const text2 = doc2.getText("text"); + text1.subscribe((e) => { + for (const event of e.events) { + const d = event.diff as TextDiff; + text2.applyDelta(d.diff); + } + }); + text1.insert(0, "foo"); + text1.mark({ start: 0, end: 3 }, "link", true); + doc1.commit(); + await new Promise((r) => setTimeout(r, 1)); + expect(text2.toDelta()).toStrictEqual(text1.toDelta()); + text1.insert(3, "baz"); + doc1.commit(); + await new Promise((r) => setTimeout(r, 1)); + expect(text2.toDelta()).toStrictEqual([ + { insert: "foo", attributes: { link: true } }, + { insert: "baz" }, + ]); + expect(text2.toDelta()).toStrictEqual(text1.toDelta()); + text1.mark({ start: 2, end: 5 }, "bold", true); + doc1.commit(); + await new Promise((r) => setTimeout(r, 1)); + expect(text2.toDelta()).toStrictEqual(text1.toDelta()); +})(); +``` -> **Important Note**: Loro is not a strict implementation of Event Graph Walker. Rather, Loro is heavily inspired by Eg-Walker's design philosophy and incorporates its key insights to achieve similar properties - particularly the ability to use simple indices instead of complex CRDT metadata, and the efficient replay mechanism for handling concurrent operations. -## The Problem: Complex CRDT Metadata +# FILE: pages/docs/tutorial/tips.mdx -Traditional CRDTs require extensive metadata to maintain consistency across distributed systems: +# Tips and Tricks -- **RGA algorithm**: Requires operation ID and Lamport timestamp of the left neighbor -- **Yjs/Fugue**: Needs operation IDs of both left and right neighbors -- **Storage overhead**: Each operation carries significant metadata for position tracking -- **Tombstone accumulation**: Deleted content must be retained indefinitely +##### `LoroDoc` will be initialized with a new random PeerID each time -This metadata complexity leads to: -- Increased storage requirements -- Slower performance as documents grow -- Complex garbage collection challenges -- Higher network bandwidth usage +
+What if I need to set the initial state? -## The Innovation: Simple Indices with Smart Replay +If your document requires an initial state, you should not edit the document to achieve this state right +after creating it with new LoroDoc(). This approach can cause problems - each time someone opens the document, +new operations with different PeerIDs would be added just to set up the initial state. -Eg-Walker introduces a paradigm shift: **record simple indices, replay when needed**. +The better approach is to initialize your document by loading the same Snapshot. This ensures all users start +from an identical baseline without generating unnecessary operations. -import { ReactPlayer } from "../../../components/video"; +
- +--- -### Core Concept +##### Be careful when using `doc.setPeerId(newId)` -Instead of storing complex position descriptors, Eg-Walker: -1. Records operations with **simple indices** at the time of execution -2. Maintains a directed acyclic graph (DAG) of the edit history -3. **Replays relevant portions** of history when merging concurrent changes -4. Reconstructs the exact CRDT state only when needed +When using `setPeerId`, you must avoid having two parallel peers use the same PeerId. This can cause serious consistency problems in your application. -## How It Works +If you plan to reuse a PeerId across sessions, make sure that you persist the document's local state together with that PeerId and load it before applying any remote updates. Otherwise, the same `peerId + counter` combination could end up referring to two different operations, producing divergence that cannot be resolved automatically. -### The Event Graph +
+Why -In collaborative editing, parallel edits naturally form a directed acyclic graph (DAG), similar to Git's history: +It's because Loro determines whether an operation has already been included by checking its operation ID. Since operation IDs are composed of `PeerId + Counter`, duplicate PeerIds can easily lead to duplicate operation IDs. During synchronization, Loro might incorrectly assume certain operations have already been processed, resulting in document inconsistency across peers. -``` - A --- B --- C (User 1) - / \ -Root Merge - \ / - D --- E --- F (User 2) -``` +
-Each node represents an operation, and edges represent causal dependencies. +
+How to reuse PeerIds safely -### The Algorithm +Be careful when reusing PeerIds (this optimization is often unnecessary). You should not assign a fixed PeerId to a user, as one user might use multiple devices. Similarly, you shouldn't assign a fixed PeerId to a device, because even on a single browser, multiple tabs might open the same document simultaneously. -#### 1. **Local Operations: Direct and Fast** +If you must reuse PeerIds, you need to carefully manage your local PeerId cache with proper locking mechanisms. This would allow only one tab to "take" a specific PeerId, while other tabs use random IDs. The PeerId should be returned to the cache when no longer in use. -When a user makes a local edit: -- Record the operation with its **current index position** -- No complex metadata calculation needed -- Immediate application to the document +In addition to locking, avoid having multiple browser tabs (or apps on different processes) use the same PeerId concurrently. Coordinating access in this way ensures that a reused PeerId never emits two different operations with the same operation ID. -Example: -```javascript no_run -// Traditional CRDT (e.g., RGA) -insert({ - char: 'a', - leftId: 'op-123', - timestamp: 1234567890, - siteId: 'user-1' -}) +
-// With Eg-Walker -insert({ - char: 'a', - index: 5 // Simple index! -}) -``` +--- -#### 2. **Merging Remote Operations: Smart Replay** +##### Root containers don't need operations to be initialized -When receiving remote operations: +Root Containers are created implicitly in Loro. This means that when you call `doc.getText("text")`, no new operations appear in the LoroDoc history, and there are no operations that need to be synchronized with other peers. -1. **Find the Lowest Common Ancestor (LCA)** between local and remote versions -2. **Replay operations** from the LCA to both versions -3. **Construct temporary CRDT state** to calculate effects -4. **Apply the merged result** +This behavior contrasts with non-root containers. For example, when you execute `doc.getMap("meta").setContainer("text", new LoroText())`, it generates an operation to insert the LoroText container into the map. -The key insight: You only replay the **divergent portion** of history, not the entire document. +--- -#### 3. **Index Transformation** +##### When initializing child containers of LoroMap in parallel, use mergeable containers when you want one shared child. -Consider this scenario: -- You highlight text from index 10 to 20 -- Concurrently, someone inserts 5 characters at index 3 -- Your highlight should shift to indices 15 to 25 +
+Why this happens -Eg-Walker handles this by: -1. Identifying concurrent operations through the event graph -2. Replaying operations in causal order -3. Transforming indices based on the replay sequence +This happens because parallel creation of regular child containers results in different container IDs, preventing automatic merging of their contents. When a container holds substantial data or serves as the primary storage for document content, overwriting it can lead to unintended hiding or loss of critical information. -## Performance Benefits +```ts twoslash +import { LoroDoc, LoroText } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const map = doc.getMap("map"); -### 1. **Reduced Storage** +// Parallel initialization of child containers +const docB = doc.fork(); +const textA = doc.getMap("map").setContainer("text", new LoroText()); +textA.insert(0, "A"); +const textB = docB.getMap("map").setContainer("text", new LoroText()); +textB.insert(0, "B"); -| Aspect | Traditional CRDT | Eg-Walker | -|--------|-----------------|-----------| -| Tombstone storage | Permanent | Can be garbage collected | -| Document growth | Linear with all operations | Linear with active operations | +doc.import(docB.export({ mode: "update" })); +// Result: Either { "meta": { "text": "A" } } or { "meta": { "text": "B" } } +``` -### 2. **Faster Local Updates** +
-- **No metadata computation**: Direct index-based operations -- **No tombstone traversal**: Clean document state -- **Predictable performance**: O(1) or O(logN) for most local operations +
+Best practices for container initialization -### 3. **Efficient Synchronization** +1. If peers may lazily create the same child under the same Map key, use a mergeable child container: -- **Minimal replay**: Only divergent history portions -- **Bounded computation**: Proportional to concurrent edits, not document size -- **Smart caching**: Reuse computed states when possible + ```ts no_run + const entries = doc.getMap("days").ensureMergeableList("2026-06-08"); + entries.insert(0, "meeting notes"); + ``` -## Implementation in Loro + The same pattern is available through `ensureMergeableText`, `ensureMergeableMap`, `ensureMergeableMovableList`, `ensureMergeableTree`, and `ensureMergeableCounter`. -While Loro is not a pure Eg-Walker implementation, it draws heavily from Eg-Walker's innovations to achieve similar benefits. Loro implements Fugue (a modern CRDT algorithm) with Eg-Walker-inspired optimizations: +2. If the child structure is fixed and known ahead of time, initialize all child containers during the map container's initialization. -- **Fugue's correctness guarantees** for text editing -- **Eg-Walker-inspired efficiency** in storage and computation -- **Simple index-based operations** at the API level -- **Smart replay mechanisms** for merging concurrent changes +3. If the child naturally has a unique global name, a root container is also safe. You can use `doc.getMap("user." + userId)` instead of `doc.getMap("user").getOrCreateContainer(userId, new LoroMap())`. -This hybrid approach provides: -```javascript no_run -// Simple API with powerful internals -const doc = new Loro(); -const text = doc.getText("content"); +4. Keep using `setContainer` or `insertContainer` when each creation should produce a distinct child object, or when you are modeling replacement rather than shared initialization. +
-// Just use indices - Eg-Walker handles the complexity -text.insert(5, "Hello"); -text.delete(2, 3); +--- -// Efficient merging happens automatically -doc.import(remoteUpdate); -``` +##### Use `diff` + `applyDiff` to share squash-like change sets -## Garbage Collection Revolution +For PR-style workflows, compute a diff from the base version to the new version, then apply it back. The diff is compact: operations that cancel out (e.g., insert then delete) are compressed away. -One of Eg-Walker's most significant advantages is **safe garbage collection**: +```ts twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const baseDoc = new LoroDoc(); +baseDoc.getText("text").insert(0, "hello world"); -### Traditional CRDTs -- Must keep all tombstones forever -- Document size grows unbounded -- Complex protocols for coordinated cleanup +const newDoc = baseDoc.fork(); +const text = newDoc.getText("text"); +text.insert(0, "abc"); +text.delete(0, 4); // cancels most of the insert -### With Eg-Walker -- Operations synchronized across all endpoints can be **safely removed** -- No new operations will be concurrent with fully-synchronized ones -- Document size remains proportional to **active content** +const diff = newDoc.diff(baseDoc.frontiers(), newDoc.frontiers()); +baseDoc.applyDiff(diff); -## Research Foundation +console.log(diff); +// [ +// [ +// "cid:root-text:Text", +// { type: "text", diff: [ { delete: 1 } ] } +// ] +// ] +console.log(baseDoc.toJSON()); // { text: "ello world" } +``` -Eg-Walker is based on rigorous academic research: +--- -> [Collaborative Text Editing with Eg-walker: Better, Faster, Smaller](https://arxiv.org/abs/2409.14252) -> By: Joseph Gentle, Martin Kleppmann +##### Use redaction to safely share document history -The algorithm has been proven to: -- Maintain strong eventual consistency -- Preserve all CRDT correctness properties -- Significantly reduce computational and storage overhead +There are times when users might accidentally paste sensitive information (like API keys, passwords, or personal data) into a collaborative document. When this happens, you need a way to remove just that sensitive content from the document history without compromising the rest of the document's integrity. +
+How to safely redact sensitive content -# FILE: pages/docs/concepts/attached_detached.mdx +Loro provides a `redactJsonUpdates` function that allows you to selectively redact operations within specific version ranges. ---- -keywords: "attached, detached, state, container, document, version control" -description: "Understanding attached and detached states in Loro - two different but related concepts" ---- +For example, if a user accidentally pastes a password or API key into a document: -# Attached vs Detached States +```ts no_run twoslash +import { LoroDoc, redactJsonUpdates } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId("1"); -## Quick Reference +// Create some content to be redacted +const text = doc.getText("text"); +text.insert(0, "Sensitive information"); +doc.commit(); -Loro uses "attached/detached" in two distinct contexts: +const map = doc.getMap("map"); +map.set("password", "secret123"); +map.set("public", "public information"); +doc.commit(); -1. **Document States** - Version synchronization (latest vs historical) -2. **Container States** - Document membership (belongs to doc vs standalone) +// Export JSON updates +const jsonUpdates = doc.exportJsonUpdates(); -⚠️ These are independent concepts - a container can be attached to a detached document. +// Define version range to redact (redact the text content) +const versionRange = { + "1": [0, 21], // Redact the "Sensitive information" +}; -## Document States +// Apply redaction +const redactedJson = redactJsonUpdates(jsonUpdates, versionRange); -### Attached (Default) -- Synchronized with latest OpLog version -- Normal editing mode -- All operations applied immediately +// Create a new document with redacted content +const redactedDoc = new LoroDoc(); +redactedDoc.importJsonUpdates(redactedJson); -```ts twoslash -import { LoroDoc } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -console.log(doc.isDetached()); // false - normal state -``` +// The text content is now redacted with replacement characters +console.log(redactedDoc.getText("text").toString()); +// Outputs: "���������������������" -### Detached (Time Travel) -- Viewing historical version -- Editing disabled by default -- OpLog has newer operations not shown +// You can also redact specific map entries +const versionRange2 = { + "1": [21, 22], // Redact the "secret123" password +}; -```ts twoslash -import { LoroDoc } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -doc.getText("text").insert(0, "v1"); -const v1 = doc.frontiers(); -doc.getText("text").insert(2, " -> v2"); +const redactedJson2 = redactJsonUpdates(jsonUpdates, versionRange2); +const redactedDoc2 = new LoroDoc(); +redactedDoc2.importJsonUpdates(redactedJson2); -// Time travel to v1 -doc.checkout(v1); -console.log(doc.isDetached()); // true -console.log(doc.getText("text").toString()); // "v1" +console.log(redactedDoc2.getMap("map").get("password")); // null +console.log(redactedDoc2.getMap("map").get("public")); // "public information" ``` +This approach is safer than manually editing document content because: -## Container States +1. It maintains document structure and CRDT consistency +2. It keeps key metadata like operation IDs and dependencies intact +3. It allows concurrent editing to continue working after redaction +4. It selectively redacts only specific operations, not the entire document -### Detached (Standalone) -- Created with constructors (`new LoroMap()`) -- Not part of any document -- No valid ContainerID -- Used as templates +The redaction process follows these rules: -```ts twoslash -import { LoroMap } from "loro-crdt"; -// ---cut--- -const map = new LoroMap(); -console.log(map.isAttached()); // false -``` +- Preserves delete, tree move, and list move operations +- Replaces text insertion content with Unicode replacement characters '�' +- Substitutes list and map insert values with null +- Maintains structure of child containers +- Replaces text mark values with null +- Preserves map keys and text annotation keys -### Attached (Document Member) -- Part of document hierarchy -- Has ContainerID -- Changes tracked in document +**Important**: Your application needs to ensure that all peers receive the redacted version, otherwise the original document with sensitive information will still exist on other peers. -```ts twoslash -import { LoroDoc, LoroText } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -const map = doc.getMap("data"); // Attached +
-// Adding detached container returns attached version -const detached = new LoroText(); -const attached = map.setContainer("text", detached); -console.log(detached.isAttached()); // false - original unchanged -console.log(attached.isAttached()); // true - new attached copy -``` +--- -## Key Differences +##### Use shallow snapshots to completely remove old history -| Aspect | Document Attached/Detached | Container Attached/Detached | -|--------|---------------------------|-----------------------------| -| **Purpose** | Version control | Document membership | -| **When** | Time travel, branching | Adding to document | -| **Independence** | N/A | Independent from doc state | -| **Editing** | Restricted when detached | Always allowed | +When you need to completely remove ALL history older than a certain version point, shallow snapshots provide the solution. -## Common Use Cases +
+How to remove old history with shallow snapshots + +Shallow snapshots create a new document that preserves the current state but completely eliminates all history before a specified point, similar to Git's shallow clone functionality. -### Time Travel ```ts twoslash import { LoroDoc } from "loro-crdt"; // ---cut--- const doc = new LoroDoc(); -const v1 = doc.frontiers(); -// ... more edits ... -doc.checkout(v1); // View old version -doc.attach(); // Return to latest -``` - -### Container Templates -```ts twoslash -import { LoroDoc, LoroMap } from "loro-crdt"; -// ---cut--- -const template = new LoroMap(); -template.set("type", "task"); +doc.setPeerId("1"); -const doc = new LoroDoc(); -const list = doc.getList("tasks"); -// Reuse template multiple times -list.insertContainer(0, template); -list.insertContainer(1, template); -``` +// Old history - will be completely removed +const text = doc.getText("text"); +text.insert(0, "This document has a long history with many edits"); +doc.commit(); +text.insert(0, "Including some potentially sensitive information. "); +doc.commit(); -## Related Documentation +// More recent history - will be preserved +text.delete(11, 55); // Remove the middle part +text.insert(11, "with sanitized history"); +doc.commit(); -- [OpLog and DocState](./oplog_docstate) - Understanding version states -- [Containers](./container) - Container types and usage -- [Time Travel](../tutorial/time_travel) - Using checkout and branching +// Create a sanitized version that removes ALL history before current point +const sanitizedSnapshot = doc.export({ + mode: "shallow-snapshot", + frontiers: doc.oplogFrontiers(), +}); +// Create a new document from the sanitized snapshot +const sanitizedDoc = new LoroDoc(); +sanitizedDoc.import(sanitizedSnapshot); -# FILE: pages/docs/concepts/crdt.mdx +// The document has the final state +console.log(sanitizedDoc.getText("text").toString()); +// Outputs: "Including with sanitized history" -import Image from "next/image"; +// But ALL history before the snapshot point is completely removed +console.log(sanitizedDoc.isShallow()); // true +console.log(sanitizedDoc.shallowSinceFrontiers()); // Shows the starting point +``` -# What are CRDTs +This approach is useful for: -CRDT (conflict-free replicated data type) is a data structure that can be -replicated across multiple computers in a network, where replicas can be updated -independently and in parallel, without the need for coordination between -replicas, and with a guarantee that no conflicts will occur. +1. Completely removing all old history that might contain various sensitive information +2. Significantly reducing document size by eliminating unnecessary history +3. Creating clean document instances after certain milestones +4. Ensuring old operations cannot be recovered or examined -CRDT is often used in collaborative software, such as scenarios where multiple -users need to work together to edit/read a shared document, database, or state. -It can be used in database software, text editing software, chat software, etc. +Compared to redaction: -# What problems does CRDT solve? +- Shallow snapshots completely remove all operations before a version point +- Redaction selectively replaces just specific content with placeholders -For example, a scenario where multiple users edit the same document online at -the same time. +**Important**: While both methods maintain future synchronization consistency, your application must distribute the sanitized document to all peers. Otherwise, the original document with sensitive information will still exist on other clients. -This scenario requires that each user sees the same content, even after -concurrent edits by different users (e.g. two users changing the title at the -same time), which is known as **consistency**. (To be precise, CRDT satisfies -the eventual consistency, see below for more details) +**When to use each approach**: -Users can use CRDT even when they are offline. They can be back on sync with -others the network is restored. It also supports collaboratively editing with -other users via P2P. It is known as **partitioning fault tolerance**. This -allows CRDT to support **decentralized** applications very well: synchronization -can be done even without a centralized server. +- Use **redaction** when you need to sanitize specific operations (like an accidental password paste) while preserving older history +- Use **shallow snapshots** when you want to completely eliminate all history before a certain point -# The Emergence of CRDTs +
-The formal concept of Conflict-free Replicated Data Types (CRDTs) was first -introduced in Marc Shapiro's 2011 paper, -[Conflict-Free Replicated Data Types](https://inria.hal.science/hal-00932836/file/CRDTs_SSS-2011.pdf). -However, it can be argued that the groundwork for CRDTs was laid earlier, in the -2006 study [Woot](https://doi.org/10.1145%2F1180875.1180916): An Algorithm for -Collaborative Real-time Editing. The primary motivation behind developing CRDTs -was to address the challenges associated with designing conflict resolution -mechanisms for eventual consistency. Prior to the introduction of CRDTs, -literature on the subject offered limited guidance, and ad hoc solutions were -often prone to errors. Consequently, Shapiro's paper presented a simple, -theoretically sound approach to achieving eventual consistency through the use -of CRDTs. +--- -(PS: Marc Shapiro actually wrote a paper -[Designing a commutative replicated data type](https://hal.inria.fr/inria-00177693v2/document) -in 2007. In 2011, he reworded commutative into conflict-free, expanding -the definition of commutative to include state-based CRDT) +##### You can store mappings between LoroDoc's peerIds and user IDs in the document itself -According to [CAP theorem](https://en.wikipedia.org/wiki/CAP_theorem), it is -impossible for a distributed computing system to perfectly satisfy the following -three points at the same time. +Use `doc.subscribeFirstCommitFromPeer(listener)` to associate peer information with user identities when a peer first interacts with the document. -- _Consistency_: each read receives the result of the most recent write or - reports an error; it behaves as if it is accessing the same piece of data -- _Availability_: every request gets a non-error response - but there is no - guarantee that the data fetched is up-to-date -- _Partition tolerance_: the ability of a distributed system to continue - functioning properly even when communication between its different components - is lost or delayed, resulting in a partition or network failure. +
+How to track peer-to-user mappings -If the system cannot achieve data consistency within the time limit, it means -that partitioning has occurred and a choice must be made between C and A for the -current operation, so "perfect consistency" is in conflict with "perfect -availability". +This functionality is essential for building user-centric features in collaborative applications. You often need bidirectional mapping between user IDs and peer IDs: -CRDTs do not provide "perfect consistency", but -Strong Eventual Consistency (SEC). This -means that site A may not immediately reflect the state changes from site B, but -when A and B synchronize their messages they both regain consistency and do not -need to resolve potential conflicts (CRDT mathematically prevents conflicts from -occurring). _Strong Eventual Consistency_ does not conflict with _Availability_ -and _Partition Tolerance_. CRDTs provide a good CAP tradeoff. +- **Finding all edits by a user**: When you need to retrieve all document edits made by a specific user ID, you must first find all peer IDs associated with that user +- **Showing edit attribution**: When displaying which user edited a piece of text, you need to map from the peer ID (stored in the operation) back to the user ID for display -![CPA](./crdt-images/a4858e2a50bc1a2d79722060156e89b0cac5815cf25e8c67e409aa0926280cef.png) -_CRDT satisfies A + P + Eventual Consistency; a good tradeoff under CAP_ +This hook provides an ideal point to associate peer information (such as author identity) with the document. The listener is triggered on the first commit from each peer, allowing you to store user metadata within the document itself. -(PS: In 2012, Eric Brewer, author of the CAP theorem, wrote an article -[CAP Twelve Years Later: How the "Rules" Have Changed](https://www.infoq.com/articles/cap-twelve-years-later-how-the-rules-have-changed/), -explaining that the description of the "two out of three CAP features" is -actually misleading, and that the CAP actually prohibits perfect availability -and consistency in a very small part of the design space, i.e., in the presence -of partitions; in fact, the design of the tradeoff between C and A is very -flexible. A good example is CRDT.) +```ts twoslash +import { LoroDoc } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +doc.setPeerId(0); +doc.subscribeFirstCommitFromPeer((e) => { + doc.getMap("users").set(e.peer, "user-" + e.peer); +}); +doc.getList("list").insert(0, 100); +doc.commit(); +await Promise.resolve(); +expect(doc.getMap("users").get("0")).toBe("user-0"); +``` -# A simple CRDT case +This approach allows you to: -We can use a few simple examples to get a general idea of how CRDTs achieve -**Strong Eventual Consistency**. +1. Automatically track which peers have contributed to the document +2. Store user metadata (names, emails, etc.) alongside the document +3. Build features like author attribution, presence indicators, or edit history -> **Grow-only Counter** +The mapping is stored within the document, so it automatically synchronizes across all peers and persists with the document's state. -How can we count the number of times something happens in a distributed system -without locking? +
-G-Counter +--- -- Let each copy increments only its own counter => no locking synchronization & - no conflicts -- Each copy keeps the count values of all other copies at the same time -- Number of occurrences = sum of count values of all copies -- Since each copy only updates its own count and does not conflict with other - counters, this type satisfies consistency after message synchronization +##### You can use https://loro.dev/llms-full.txt to prompt your AI -> **Grow-only Set** +When working with AI assistants or language models on Loro-related tasks, you can use these URLs to provide comprehensive context about Loro's capabilities and API: -G-Set +- `https://loro.dev/llms-full.txt` - All the documentation in one file +- `https://loro.dev/llms.txt` - An overview of Loro website -- The elements in a Grow-only Set can only be increased and not decreased -- To merge two such states, you only need to do a merge set -- This type satisfies consistency after message synchronization because there - are no conflicting operations since the elements only grow and do not - decrease. -Both of these methods are CRDTs, and they both satisfy the following properties +# FILE: pages/docs/tutorial/event.mdx -- They can both be updated independently and concurrently, without coordination - (locking) between replicas -- There is no possibility of conflict between multiple updates -- Final consistency can always be guaranteed +# Event Handling in Loro -# Introduction to the Principle +Loro implements an event system to track changes in the document. This section +explains when events are emitted and how transactions work in Loro. -There are two types of CRDTs: Op-based CRDTs and State-based CRDTs. This article -focuses on the concept of Op-based CRDTs. +## Event Emission Points -Op-based CRDTs operate on the principle that if two users perform identical -sequences of operations, the final state of the document should also be -identical. To achieve this, each user saves all the operations performed on the -data (Operations) and synchronizes these Operations with other users to ensure a -consistent final state. A critical challenge in this approach is ensuring the -order of Operations remains consistent, especially when parallel modification -operations occur. To address this, Op-based CRDTs require that all possible -parallel Operations be commutative, satisfying the final consistency -requirement. +Events in Loro are emitted whenever the internal document state changes. This +mechanism allows application-level derived states to automatically synchronize +with changes in the document state. -# Comparison of CRDT and OT +1. **Local Operations**: For local operations (like insertions or deletions on + text), the operations are first placed in a pending state within an internal + transaction. -Both CRDT and [Operation Transformation(OT)][ot] can be used in online -collaborative applications, with the following differences +2. **Transaction Commit**: When a transaction is committed, all pending + operations collectively emit their corresponding events. This transaction + commit occurs in two scenarios: + - When `LoroDoc.commit()` is explicitly called + - Automatically before an import or export operation -| OT | CRDT | -| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------ | -| OT relies on a centralized server for collaboration; [it is extremely difficult to make it work in a distributed environment](https://digitalfreepen.com/2018/01/04/operational-transform-hard.html) | CRDT algorithm can be used to synchronize data through a P2P approach synchronization | -| The earliest paper on OT was presented in 1989 | The earliest paper on CRDT appeared in 2006 | -| The OT algorithm is designed with higher complexity to ensure consistency | The CRDT algorithm is designed to be simpler to ensure consistency | -| It is easier to design OT to preserve user intent | It is more difficult to design a CRDT algorithm that preserves user intent | -| OT does not affect document size | CRDT documents are larger than the original document data | +Starting from `loro-crdt@1.8.0`, events are emitted synchronously during the commit cycle. If you are using an older version (`<=1.7.x`), you will still need to await a microtask for the callbacks to fire. -[a highly-available move operation for replicated trees]: - https://martin.kleppmann.com/papers/move-op.pdf -[moving elements in list crdts]: - https://martin.kleppmann.com/papers/list-move-papoc20.pdf -[interleaving anomalies in collaborative text editors]: - https://martin.kleppmann.com/papers/interleaving-papoc19.pdf -[conflict-free replicated data types]: https://readpaper.com/paper/1516319412 -[5000x faster crdts: an adventure in optimization]: - https://josephg.com/blog/crdts-go-brrr/ -[json crdt]: https://arxiv.org/abs/1608.03960 -[yata]: - https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types -[yjs]: https://github.com/yjs/yjs -[loro]: https://loro.dev -[automerge]: https://github.com/automerge/automerge -[half grid]: https://zh.wikipedia.org/wiki/%E5%8D%8A%E6%A0%BC -[ot]: https://en.wikipedia.org/wiki/Operational_transformation -[crdts and the quest for distributed consistency]: - https://www.infoq.com/presentations/crdt-distributed-consistency/ +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +doc.subscribe((event) => { + console.log("Event:", event); +}); +const text = doc.getText("text"); +text.insert(0, "Hello"); +doc.commit(); +// Event has already been emitted synchronously +``` -# FILE: pages/docs/concepts/container.mdx +3. **Import**: When importing changes from a remote source using the `import()` + method, respective events are emitted. This allows the local document to + react to changes made by other peers. -# Container +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +declare const remoteChanges: Uint8Array; +// ---cut--- +const doc = new LoroDoc(); +doc.subscribe((event) => { + console.log("Event:", event); +}); -Containers are the fundamental building blocks in Loro for organizing and structuring collaborative data. They provide typed data structures that automatically merge when concurrent edits occur. +doc.import(remoteChanges); // This immediately triggers events (v1.8+) +``` -## Container Types +4. **Version Checkout**: When you switch document state to a different version + using `doc.checkout(frontiers)`, Loro emits an event to reflect this change. + As of v1.8, checkout events fire synchronously alongside the state change. -Loro provides several container types, each optimized for different use cases: +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const frontiers = doc.frontiers(); +doc.checkout(frontiers); // This triggers events immediately (v1.8+) +``` -- **LoroMap**: Key-value pairs with Last-Write-Wins semantics -- **LoroList**: Ordered sequences that merge concurrent insertions -- **LoroText**: Text with character-level merging and rich text support -- **LoroTree**: Hierarchical tree structures with move operations -- **LoroMovableList**: Lists with reordering capabilities -- **LoroCounter**: Numerical values with increment/decrement operations +## Transaction Behavior -## Container States: Attached vs Detached +Transactions in Loro primarily serve to bundle related operations and emit their +events together as a cohesive unit. This is useful in several scenarios: -Containers in Loro exist in two distinct states that affect their behavior and identity. +1. **Related Local Operations**: When performing multiple local operations that + are logically connected, you may want them to: + - Share the same commit message + - Have the same timestamp + - Move together during undo/redo operations -### Detached Containers +2. **Event Handling**: Applications often benefit from receiving related changes + as a single batch rather than individual updates. Transactions facilitate + this by: + - Allowing you to set an origin identifier during commit + - Including this origin value in the emitted events + - Enabling better event filtering and processing based on the origin -A container is **detached** when created directly using constructors: + -```ts twoslash -import { LoroMap, LoroText, LoroList } from "loro-crdt"; -// ---cut--- -// These containers are all detached -const map = new LoroMap(); -const text = new LoroText(); -const list = new LoroList(); -``` +## Triggering a Commit -Characteristics of detached containers: -- Not yet part of any document -- Have a default placeholder ContainerID -- Can be used as templates or temporary data structures -- Will get a proper ContainerID when inserted into a document +There are several ways to trigger a commit: -### Attached Containers +1. **Explicit Commit**: Directly calling the `commit()` method on the Loro + document. -A container becomes **attached** when it's part of a document hierarchy: + ```ts twoslash + import { LoroDoc } from "loro-crdt"; + // ---cut--- + const doc = new LoroDoc(); + const text = doc.getText("myText"); + text.insert(0, "Hello, Loro!"); + doc.commit(); // This commits pending operations and emits events + ``` -```ts twoslash -import { LoroDoc, LoroMap, LoroText } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); +2. **Before Import/Export**: A commit is automatically triggered before + executing an import operation. -// Root containers are immediately attached -const rootMap = doc.getMap("myMap"); -const rootText = doc.getText("myText"); + ```ts twoslash + import { LoroDoc } from "loro-crdt"; + // ---cut--- + const doc1 = new LoroDoc(); + doc1.setPeerId(1); + const doc2 = new LoroDoc(); + doc2.setPeerId(2); -// Child containers: the returned value is attached -const detachedChild = new LoroText(); -const attachedChild = rootMap.setContainer("child", detachedChild); -// Note: detachedChild remains detached -// attachedChild is the attached version with proper ContainerID -``` + // Some ops on doc1 and doc2 + doc1.getText("text").insert(0, "Alice"); + doc2.getText("text").insert(0, "Hello, Loro!"); + console.log(doc1.version().toJSON()); // Map(0) {} + console.log(doc2.version().toJSON()); // Map(0) {} + const updates = doc1.export({ mode: "snapshot" }); + doc2.import(updates); // This first commits any pending operations in doc2 + console.log(doc2.version().toJSON()); // Map(2) { "1" => 5, "2" => 12 } + console.log(doc1.version().toJSON()); // Map(2) { "1" => 5 } + ``` -Characteristics of attached containers: -- Belong to a specific document -- Have a proper ContainerID that uniquely identifies them -- Changes are tracked in the document's history -- Can be synchronized across peers +## Transactions in Loro -## Container IDs +It's important to note that Loro's concept of a transaction differs from +traditional database transactions: -Every attached container has a unique ContainerID that identifies it within the distributed system. The ID generation depends on the container type: +- Loro transactions do not have ACID properties. +- They primarily serve as event wrappers. +- There is no rollback mechanism if an operation fails. -- **Root containers**: ID derived from their name (e.g., "myMap" in `doc.getMap("myMap")`) -- **Child containers**: ID based on the operation that created them (OpID) -This deterministic ID generation ensures that: -- The same container can be identified across all peers -- Container IDs are not random but contextually determined -- A detached container cannot have its final ID until insertion +# FILE: pages/docs/tutorial/version.mdx -## Working with Containers +# Version -### Creating Root Containers +In centralized environments, we can use linear version numbers to represent a version, such as incrementing a number each time or using timestamps. However, CRDTs can be used in decentralized environments, and their version representation is different. -Root containers are created through the document API and are immediately attached: +In Loro, you can express a document's version through a [Version Vector](/docs/concepts/version_vector) or [Frontiers](/docs/concepts/frontiers). ```ts twoslash import { LoroDoc } from "loro-crdt"; // ---cut--- const doc = new LoroDoc(); - -// These methods create or get root containers -const map = doc.getMap("settings"); -const text = doc.getText("content"); -const list = doc.getList("items"); -const tree = doc.getTree("hierarchy"); +doc.version(); // State Version vector +doc.oplogVersion(); // OpLog Version vector +doc.frontiers(); // State Frontiers +doc.oplogFrontiers(); // OpLog Frontiers ``` -### Nesting Containers +In most cases, you might only need the Version Vector, which can be used for data synchronization and version comparison. -Containers can be nested to create complex data structures: +## Learn More -```ts twoslash -import { LoroDoc, LoroMap, LoroList, LoroText } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -const rootMap = doc.getMap("root"); +- [Version Vector](/docs/concepts/version_vector) - Complete peer state tracking for synchronization +- [Frontiers](/docs/concepts/frontiers) - Compact version representation for checkpoints +- [Version Deep Dive](/docs/advanced/version_deep_dive) - Technical details about DAG, causality, and version implementations -// Method 1: Using setContainer (returns attached container) -const childText = rootMap.setContainer("description", new LoroText()); -// Method 2: Using insertContainer for lists -const list = doc.getList("items"); -const childMap = list.insertContainer(0, new LoroMap()); -``` +# FILE: pages/docs/tutorial/get_started.mdx -## Container Overwrites +--- +keywords: "loro-crdt, build collaboration software, local-first, operation transform, crdts, ot" +description: "How to use Loro to build real-time or asynchronous collaboration software." +--- -When initializing child containers in parallel, overwrites can occur instead of -automatic merging. For example: +# Getting Started -```ts twoslash -import { LoroDoc, LoroText } from "loro-crdt"; -// ---cut--- -const a: string = "hello"; -const doc = new LoroDoc(); -const map = doc.getMap("map"); +You can use Loro in your application by using: -// Parallel initialization of child containers -const docB = doc.fork(); -const textA = doc.getMap("map").setContainer("text", new LoroText()); -textA.insert(0, "A"); -const textB = docB.getMap("map").setContainer("text", new LoroText()); -textB.insert(0, "B"); +- [`loro-crdt`](https://www.npmjs.com/package/loro-crdt) NPM package +- [`loro`](https://crates.io/crates/loro) Rust crate +- [`loro-swift`](https://github.com/loro-dev/loro-swift) Swift package +- [`loro-py`](https://github.com/loro-dev/loro-py) Python package +- [`loro-cs`](https://github.com/sensslen/loro-cs) Community-maintained C# package +- You can also find a list of examples in + [Loro examples in Deno](https://github.com/loro-dev/loro-examples-deno). +- [`loro-go`](https://github.com/aholstenson/loro-go) Community-maintained Go package +- [`loro-go`](https://github.com/Deln0r/loro-go) Community-maintained pure-Go package (no cgo) -doc.import(docB.export({ mode: "update" })); -// Result: Either { "meta": { "text": "A" } } or { "meta": { "text": "B" } } +You can use [Loro Inspector](/docs/advanced/inspector) to debug and visualize the state and history of Loro documents. + +The following guide will use `loro-crdt` js package as the example. + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/edit/loro-basic-test?file=test%2Floro-sync.test.ts) + +## Install + +```bash +npm install loro-crdt + +# Or +pnpm install loro-crdt + +# Or +yarn add loro-crdt ``` -This behavior poses a significant risk of data loss if the editing history is -not preserved. Even when the complete history is available and allows for data -recovery, the recovery process can be complex. +If you're using `Vite`, you should add the following to your vite.config.ts: - +```ts no-run +import wasm from "vite-plugin-wasm"; +import topLevelAwait from "vite-plugin-top-level-await"; -When a container holds substantial data or serves as the primary storage for -document content, overwriting it can lead to the unintended hiding/loss of -critical information. For this reason, it is essential to implement careful and -systematic container initialization practices to prevent such issues. +export default defineConfig({ + plugins: [...otherConfigures, wasm(), topLevelAwait()], +}); +``` -### Mergeable Containers +
+⚠️ DOMContentLoaded Timing Issue with Vite -The overwrite happens because `setContainer` creates a regular child container. -Its Container ID includes the operation that created it, so two peers that -create `map["text"]` concurrently create two different Text containers. +When using Loro with Vite, be aware of module loading timing issues with DOM events: -When a child container should be identified by its logical position in a Map, -use a mergeable child container instead: +**Problem:** The following code will cause nothing to load on the screen: ```ts no_run -const text = doc.getMap("map").ensureMergeableText("text"); -text.insert(0, "A"); +import { LoroDoc } from "loro-crdt"; + +document.addEventListener("DOMContentLoaded", () => { + const doc = new LoroDoc(); + // Your code here... +}); ``` -Peers that call `ensureMergeableText("text")` on the same parent Map address the -same logical Text container. The same pattern is available for Map, List, -MovableList, Tree, and Counter children through the `ensureMergeable*` methods. +**Reason:** This occurs because Vite loads ES modules asynchronously, and the WASM module initialization within `loro-crdt` also happens asynchronously. When you import at the top level but execute code inside `DOMContentLoaded`, the WASM module may not be fully initialized when the event fires, causing the application to fail silently. -### Best Practices +**Solutions:** -1. For dynamic Map keys where peers may lazily create the same child, use - `ensureMergeableText`, `ensureMergeableMap`, `ensureMergeableList`, and the - other `ensureMergeable*` methods. +1. **Remove the event listener** (recommended for most cases): -2. When the structure is fixed and known ahead of time: - - If possible, initialize all child containers during the map container's - initialization + ```ts no_run twoslash + import { LoroDoc } from "loro-crdt"; -3. Use regular `setContainer` / `insertContainer` when each creation should - produce a distinct child object, or when you are modeling replacement rather - than shared initialization. + const doc = new LoroDoc(); + // Your code here... + ``` -4. A unique root container name can still be a good fit when the child naturally - belongs at the root, for example `doc.getMap("user." + userId)`. +2. **Use dynamic import inside the event listener**: + ```ts no_run twoslash + document.addEventListener("DOMContentLoaded", async () => { + const { LoroDoc } = await import("loro-crdt"); + const doc = new LoroDoc(); + // Your code here... + }); + ``` +The dynamic import ensures the module and its WASM dependencies are fully loaded before use. -## Related Concepts +
-- [Container ID](/docs/advanced/cid): Deep dive into how Container IDs work -- [Choosing CRDT Types](/docs/concepts/choose_crdt_type): Guide for selecting the right container type -- [Composition](/docs/tutorial/composition): How to compose containers into complex structures +If you're using `Next.js`, you should add the following to your next.config.js: +```js no-run +module.exports = { + webpack: function (config) { + config.experiments = { + layers: true, + asyncWebAssembly: true, + }; + return config; + }, +}; +``` -# FILE: pages/docs/concepts/shallow_snapshots.mdx +You can also use Loro directly in the browser via ESM imports. Here's a minimal +example: -# Shallow Snapshots +```html + + + + + + ESM Module Example + -## Quick Reference + +
+ + + +``` + +## Introduction +It is well-known that syncing data/building realtime collaborative apps is +challenging, especially when devices can be offline or part of a peer-to-peer +network. Loro simplifies this process for you. -## Basic Usage +After you model your app state by Loro, syncing is simple: ```ts twoslash import { LoroDoc } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -// ... extensive editing history ... +const docA = new LoroDoc(); +const docB = new LoroDoc(); -// Regular snapshot - full history -const full = doc.export({ mode: "snapshot" }); +//...operations on docA and docB -// Shallow snapshot - trimmed history -const shallow = doc.export({ - mode: "shallow-snapshot", - frontiers: doc.frontiers(), -}); +// Assume docA and docB are two Loro documents in two different devices +const bytesA = docA.export({ mode: "update" }); +// send bytes to docB by any method +docB.import(bytesA); +// docB is now updated with all the changes from docA -// Typically 70-90% smaller -console.log(`Size reduction: ${100 - (shallow.length / full.length * 100)}%`); +const bytesB = docB.export({ mode: "update" }); +// send bytes to docA by any method +docA.import(bytesB); +// docA and docB are now in sync, they have the same state ``` -## Content Redaction +Saving your app state is also straightforward: ```ts twoslash import { LoroDoc } from "loro-crdt"; // ---cut--- const doc = new LoroDoc(); -const text = doc.getText("content"); - -// Sensitive data added -text.insert(0, "SSN: 123-45-6789. "); -text.insert(18, "Public info."); - -// Redact sensitive part -text.delete(0, 18); - -// Create clean snapshot -const redacted = doc.export({ - mode: "shallow-snapshot", - frontiers: doc.frontiers(), -}); -// Sensitive data permanently removed from history +doc.getText("text").insert(0, "Hello world!"); +const bytes = doc.export({ mode: "snapshot" }); +// Bytes can be saved to local storage, database, or sent over the network ``` -## Synchronization Limitations - -⚠️ **Important**: Peers can only sync if they have versions after the shallow snapshot point. - +Snapshots and updates include a checksum in their headers, so any corruption from storage or transmission (like bit flips) is detected during import before it can affect your document. -## Common Patterns +Loading your app state: -### Archive and Trim -```ts twoslash +```ts no_run twoslash import { LoroDoc } from "loro-crdt"; +const bytes = new Uint8Array(); // ---cut--- -async function archiveAndTrim(doc: LoroDoc) { - // 1. Archive full history - const full = doc.export({ mode: "snapshot" }); - await saveToArchive(full); - - // 2. Create shallow for active use - const shallow = doc.export({ - mode: "shallow-snapshot", - frontiers: doc.frontiers(), - }); - - return shallow; -} - -async function saveToArchive(data: Uint8Array) { - // Save to cold storage -} +const newDoc = new LoroDoc(); +newDoc.import(bytes); ``` -### Privacy-Aware Design -```ts twoslash +Loro also makes it easy for you to time travel the history and add version +control to your app. [Learn more about time travel](/docs/tutorial/time_travel). + +```ts no_run twoslash import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const version = doc.frontiers(); // ---cut--- -class PrivacyDoc { - constructor(private doc: LoroDoc) {} - - async redactSensitive() { - // Delete sensitive content - this.doc.getText("private").delete(0, this.doc.getText("private").length); - - // Create clean snapshot - return this.doc.export({ - mode: "shallow-snapshot", - frontiers: this.doc.frontiers(), - }); - } -} +doc.checkout(version); // Checkout the doc to the given version ``` -## Best Practices +Loro is compatible with the JSON schema. If you can model your app state with +JSON, you probably can sync your app with Loro. Because we need to adhere to the +JSON schema, using a number as a key in a Map is not permitted, and cyclic links +should be avoided. + +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +// ---cut--- +doc.toJSON(); // Get the JSON representation of the doc +``` -- **Coordinate before trimming**: Ensure all peers synchronized -- **Archive before deletion**: Keep full history backup if needed +## Entry Point: LoroDoc -## Related Documentation +LoroDoc is the entry point for using Loro. You must create a Doc to use Map, +List, Text, and other types and to complete data synchronization. -- [Encoding](../tutorial/encoding) - Different export options -- [Version Tutorial](../tutorial/version) - Managing document versions -- [Event Graph Walker](./event_graph_walker) - Algorithm powering shallow snapshots +```ts twoslash +import { LoroDoc, LoroText } from "loro-crdt"; +// ---cut--- +const doc = new LoroDoc(); +const text: LoroText = doc.getText("text"); +text.insert(0, "Hello world!"); +console.log(doc.toJSON()); // { "text": "Hello world!" } +``` +## Container -# FILE: pages/docs/concepts/oplog_docstate.mdx +We refer to CRDT types such as `List`, `Map`, `Tree`, `MovableList`, and `Text` +as `Container`s. ---- -keywords: "crdt, oplog, snapshot, doc state, checkout, version, architecture, memory, performance" -description: "Understanding Loro's architectural separation of OpLog and DocState for efficient CRDT operations and version control" ---- +Here are their basic operations: -# OpLog and DocState +```ts twoslash +import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; +import { expect } from "expect"; +// ---cut--- +const doc = new LoroDoc(); +const list: LoroList = doc.getList("list"); +list.insert(0, "A"); +list.insert(1, "B"); +list.insert(2, "C"); -## Quick Reference +const map: LoroMap = doc.getMap("map"); +// map can only has string key +map.set("key", "value"); +expect(doc.toJSON()).toStrictEqual({ + list: ["A", "B", "C"], + map: { key: "value" }, +}); -- **OpLog** = Sequence of events/operations that compose the document history -- **DocState** = Current materialized state of the document +// delete 2 element at index 0 +list.delete(0, 2); +expect(doc.toJSON()).toStrictEqual({ + list: ["C"], + map: { key: "value" }, +}); -This separation enables time travel, efficient sync, and flexible storage strategies. +// Insert a text container to the list +const text = list.insertContainer(0, new LoroText()); +text.insert(0, "Hello"); +text.insert(0, "Hi! "); -## Key Concepts +expect(doc.toJSON()).toStrictEqual({ + list: ["Hi! Hello", "C"], + map: { key: "value" }, +}); -**OpLog (Operation Log)** -- Append-only sequence of operations -- Causal relationships and metadata +// Insert a list container to the map +const list2 = map.setContainer("test", new LoroList()); +list2.insert(0, 1); +expect(doc.toJSON()).toStrictEqual({ + list: ["Hi! Hello", "C"], + map: { key: "value", test: [1] }, +}); +``` -**DocState (Document State)** -- Current materialized view of the document -- Actual data structures and values -- What your app reads and displays +## Save and Load -## Benefits +Loro is a pure library and does not handle network protocols or storage mechanisms. It is your responsibility to manage the storage and transmission of the binary data exported by Loro. -- **Memory efficiency**: Load OpLog without state (relay servers) -- **Time travel**: Navigate history without losing the log -- **Fast startup**: Load state via snapshots, fetch history later -- **Flexible storage**: Store separately for optimization +To save the document, use `doc.export({mode: "snapshot"})` to get its binary +form. To open it again, use `doc.import(data)` to load this binary data. ```ts twoslash -import { LoroDoc } from "loro-crdt"; +import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; +import { expect } from "expect"; // ---cut--- const doc = new LoroDoc(); +doc.getText("text").insert(0, "Hello world!"); +const data = doc.export({ mode: "snapshot" }); -// Edit updates both -doc.getText("text").insert(0, "Hello"); -console.log(doc.oplogVersion()); // Latest known version -console.log(doc.version()); // The version of the current state of the document -// If the document is attached, they are the same. +const newDoc = new LoroDoc(); +newDoc.import(data); +expect(newDoc.toJSON()).toStrictEqual({ + text: "Hello world!", +}); ``` -## Time Travel & Detachment +Exporting the entire document on each keypress is inefficient. Instead, use +`doc.export({mode: "update", from: VersionVector})` to obtain binary data for +operations since the last export. ```ts twoslash -import { LoroDoc } from "loro-crdt"; +import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; +import { expect } from "expect"; // ---cut--- const doc = new LoroDoc(); -doc.getText("text").insert(0, "v1"); -const v1 = doc.frontiers(); +doc.getText("text").insert(0, "Hello world!"); +const data = doc.export({ mode: "snapshot" }); +let lastSavedVersion = doc.version(); +doc.getText("text").insert(0, "✨"); +const update0 = doc.export({ mode: "update", from: lastSavedVersion }); +lastSavedVersion = doc.version(); +doc.getText("text").insert(0, "😶‍🌫️"); +const update1 = doc.export({ mode: "update", from: lastSavedVersion }); -doc.getText("text").insert(2, " -> v2"); +{ + /** + * You can import the snapshot and the updates to get the latest version of the document. + */ -// Checkout old version - DocState diverges from OpLog -doc.checkout(v1); -console.log(doc.version()); // v1 state -console.log(doc.oplogVersion()); // Still has v2 -console.log(doc.isDetached()); // true + // import the snapshot + const newDoc = new LoroDoc(); + newDoc.import(data); + expect(newDoc.toJSON()).toStrictEqual({ + text: "Hello world!", + }); -// Return to latest -doc.attach(); + // import update0 + newDoc.import(update0); + expect(newDoc.toJSON()).toStrictEqual({ + text: "✨Hello world!", + }); + + // import update1 + newDoc.import(update1); + expect(newDoc.toJSON()).toStrictEqual({ + text: "😶‍🌫️✨Hello world!", + }); +} + +{ + /** + * You may also import them in a batch + */ + const newDoc = new LoroDoc(); + newDoc.importUpdateBatch([update1, update0, data]); + expect(newDoc.toJSON()).toStrictEqual({ + text: "😶‍🌫️✨Hello world!", + }); +} ``` -**Detached state**: DocState shows old version while OpLog has all operations. Editing disabled by default. +If updates accumulate, exporting a new snapshot can quicken import times and +decrease the overall size of the exported data. +You can store the binary data exported from Loro wherever you prefer. -## Export Strategies +## Sync + +Two documents with concurrent edits can be synchronized by just two message +exchanges. + +Below is an example of synchronization between two documents: ```ts twoslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); +import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; +import { expect } from "expect"; // ---cut--- -// Update: OpLog only (sync) -const update = doc.export({ mode: "update" }); +const docA = new LoroDoc(); +const docB = new LoroDoc(); +const listA: LoroList = docA.getList("list"); +listA.insert(0, "A"); +listA.insert(1, "B"); +listA.insert(2, "C"); +// B import the ops from A +const data: Uint8Array = docA.export({ mode: "update" }); +// The data can be sent to B through the network +docB.import(data); +expect(docB.toJSON()).toStrictEqual({ + list: ["A", "B", "C"], +}); -// Snapshot: OpLog + DocState (persistence) -const snapshot = doc.export({ mode: "snapshot" }); +const listB: LoroList = docB.getList("list"); +listB.delete(1, 1); -// Shallow: Minimal OpLog + DocState (fast startup) -const shallow = doc.export({ mode: "shallow-snapshot", frontiers: doc.frontiers() }); +// `doc.export({mode: "update", from: version})` can encode all the ops from the version to the latest version +// `version` is the version vector of another document +const missingOps = docB.export({ + mode: "update", + from: docA.oplogVersion(), +}); +docA.import(missingOps); + +expect(docA.toJSON()).toStrictEqual({ + list: ["A", "C"], +}); +expect(docA.toJSON()).toStrictEqual(docB.toJSON()); ``` -## Common Patterns +## Event + +You can subscribe to the event from `Container`s. + +`LoroText` and `LoroList` can receive updates in +[Quill Delta](https://quilljs.com/docs/delta/) format. + +The events will be emitted after a transaction is committed. A transaction is +committed when: + +- `doc.commit()` is called. +- `doc.export(mode)` is called. +- `doc.import(data)` is called. +- `doc.checkout(version)` is called. + +Below is an example of rich text event: -### Relay Server (OpLog Only) ```ts twoslash -import { LoroDoc } from "loro-crdt"; +import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt"; +import { expect } from "expect"; // ---cut--- -class RelayServer { - private doc: LoroDoc; - constructor() { - this.doc = new LoroDoc(); - this.doc.detach(); // Never materialize state - } - - handleUpdate(update: Uint8Array) { - this.doc.import(update); +// The code is from https://github.com/loro-dev/loro-examples-deno +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "Hello world!"); +doc.commit(); +let ran = false; +text.subscribe((e) => { + for (const event of e.events) { + if (event.diff.type === "text") { + expect(event.diff.diff).toStrictEqual([ + { + retain: 5, + attributes: { bold: true }, + }, + ]); + ran = true; + } } -} +}); +text.mark({ start: 0, end: 5 }, "bold", true); +doc.commit(); +await new Promise((r) => setTimeout(r, 1)); +expect(ran).toBeTruthy(); ``` -## Best Practices - -- **Export modes**: Updates for sync, snapshots for persistence, shallow for startup -- **Optimize by use case**: - - Editors: Both in memory - - Relays: OpLog only +The types of events are defined as follows: -## Related Documentation +```ts twoslash +import { Path, Diff, Frontiers, ContainerID } from "loro-crdt"; -- [Attached/Detached States](./attached_detached) - Document and container states -- [Shallow Snapshots](./shallow_snapshots) - History trimming -- [Time Travel](../tutorial/time_travel) - Using checkout +export interface LoroEventBatch { + /** + * How the event is triggered. + * + * - `local`: The event is triggered by a local transaction. + * - `import`: The event is triggered by an import operation. + * - `checkout`: The event is triggered by a checkout operation. + */ + by: "local" | "import" | "checkout"; + origin?: string; + /** + * The container ID of the current event receiver. + * It's undefined if the subscriber is on the root document. + */ + currentTarget?: ContainerID; + events: LoroEvent[]; + from: Frontiers; + to: Frontiers; +} +/** + * The concrete event of Loro. + */ +export interface LoroEvent { + /** + * The container ID of the event's target. + */ + target: ContainerID; + diff: Diff; + /** + * The absolute path of the event's emitter, which can be an index of a list container or a key of a map container. + */ + path: Path; +} +``` -# FILE: pages/docs/concepts/operations_changes.mdx -# Operations and Changes +# FILE: pages/docs/index.mdx -## Quick Reference +## Introduction to Loro -**Operations** are atomic edits. **Changes** are logical groups of operations with metadata. Understanding these helps optimize sync and performance. +It is well-known that syncing data/building realtime collaborative apps is +challenging, especially when devices can be offline or part of a peer-to-peer +network. Loro simplifies this process for you. -## Key Concepts +We want to provide better DevTools to make building +[local-first apps](https://www.inkandswitch.com/local-first/) easy and +enjoyable. -### Operations -- Atomic units of change (insert a single Unicode character, delete a single character, insert an new entry to a map, etc.) -- Automatically merged internally for efficiency -- Each has unique ID: `(peerId, counter)` +Loro uses [Conflict-free Replicated Data Types (CRDTs)](/docs/concepts/crdt) to +resolve parallel edits. By utilizing Loro's data types, your applications can be +made collaborative and keep the editing history with low overhead. -### Changes -- Groups of consecutive operations -- Include metadata (timestamp, dependencies, peer ID) -- Created by `commit()` or auto-commit +After you model your app state by Loro, syncing is simple: ```ts twoslash import { LoroDoc } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -const text = doc.getText("text"); +const docA = new LoroDoc(); +const docB = new LoroDoc(); +docA.getText("text").insert(0, "Hello world!"); +docB.getText("text").insert(0, "Hi!"); +// Assume docA and docB are two Loro documents in two different devices +const bytesA = docA.export({ mode: "update" }); +// send bytes to docB by any method +docB.import(bytesA); +// docB is now updated with all the changes from docA -text.insert(0, "Hello"); // Operation -text.insert(5, " World"); // Operation -doc.commit(); // Groups into one Change +const bytesB = docB.export({ mode: "update" }); +// send bytes to docA by any method +docA.import(bytesB); +// docA and docB are now in sync, they have the same state ``` -## Automatic Merging - -Consecutive operations from same peer merge into one Change: +Saving your app state is also straightforward: ```ts twoslash import { LoroDoc } from "loro-crdt"; // ---cut--- const doc = new LoroDoc(); -const text = doc.getText("text"); +doc.getText("text").insert(0, "Hello world!"); +const bytes = doc.export({ mode: "snapshot" }); +// Bytes can be saved to local storage, database, or sent over the network +``` -text.insert(0, "abc"); -doc.commit(); // Change #1 +Loading your app state: -text.insert(3, "def"); -doc.commit(); // Merges with #1 (same peer, consecutive) +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +const bytes = new Uint8Array([1, 2, 3]); +// ---cut--- +const newDoc = new LoroDoc(); +newDoc.import(bytes); ``` -## When New Changes Are Created - -1. **Cross-peer dependencies**: After importing remote operations -2. **Time separation**: When timestamps enabled and > the merge interval (default 1000s) between commits -3. **Different commit messages**: +Loro also makes it easy for you to time travel the history and add version +control to your app. [Learn more about time travel](/docs/tutorial/time_travel). -```ts twoslash +```ts no_run twoslash import { LoroDoc } from "loro-crdt"; -// ---cut--- const doc = new LoroDoc(); -doc.getText("text").insert(0, "v1"); -doc.commit(); // Change #1 +const version = doc.frontiers(); +// ---cut--- +doc.checkout(version); // Checkout the doc to the given version +``` -// Import from another peer -const doc2 = new LoroDoc(); -doc2.getText("text").insert(0, "v1"); -const remote = doc2.export({ mode: "update" }); -doc.import(remote); +Loro is compatible with the JSON schema. If you can model your app state with +JSON, you probably can sync your app with Loro. Because we need to adhere to the +JSON schema, using a number as a key in a Map is not permitted, and cyclic links +should be avoided. -// Next commit creates new Change (dependency on remote) -doc.getText("text").insert(0, "v2"); -doc.commit(); // Change #2 +```ts no_run twoslash +import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +// ---cut--- +doc.toJSON(); // Get the JSON representation of the doc ``` +import { Cards } from "nextra/components"; -## Impact on Sync & Storage + + + <>![Getting started](/images/GettingStarted.png) + + -- **History**: Changes track logical units of work -- **Sync**: Dependencies ensure causal ordering -- **Storage**: Auto-merging reduces metadata overhead +## Is Loro Right for You? -## Related Documentation +### ✅ Use Loro when you need: -- [Transaction Model](./transaction_model) - Grouping operations -- [Version Vector](./version_vector) - How Changes form versions -- [Synchronization](../tutorial/sync) - Using Changes for sync +- Real-time collaboration on documents +- Automatic conflict resolution for concurrent edits +- Offline editing with later synchronization +- Complete edit history and time travel +- P2P synchronization capabilities +### ⚠️ Consider alternatives when: -# FILE: pages/docs/concepts/peerid_management.mdx +- Your application requires strong consistency +- Your data isn't JSON-like (e.g., large binary/media streaming) +- Simple client–server sync is sufficient (e.g., basic WebSockets) +- Your application is sensitive to bundle size (Loro WASM binary ~970KB gzipped) -# PeerID Management +[Learn more about when not to use CRDTs →](/docs/concepts/when_not_crdt) -## Quick Reference +## Differences from other CRDT libraries -**Peer IDs** are unique identifiers for each editing session in Loro's distributed system. They ensure operation uniqueness without coordination between peers. +The table below summarizes Loro's features, which may not be present in other +CRDT libraries. -## Key Concepts +| Features/Important design decisions | Loro | Diamond-types | Yjs | Automerge | +| :-------------------------------------------------------------------------- | :--- | :------------ | :---------- | :---------- | +| [Event Graph Walker](https://loro.dev/docs/advanced/replayable_event_graph) | ✅ | ✅ Inventor | ❌ | ❌ | +| Rich Text CRDT | ✅ | ❌ | ❌ | ✅ | +| [Movable Tree](https://ieeexplore.ieee.org/document/9563274) | ✅ | ❌ | ❌ | ❌ Inventor | +| [Movable List](https://loro.dev/docs/tutorial/list) | ✅ | ❌ | ❌ | ❌ Inventor | +| Time Travel | ✅ | ✅ | ✅[1] | ✅ | +| [Fugue](https://arxiv.org/abs/2305.00583) / Maximal non-interleaving | ✅ | ✅ | ❌ | ❌ | +| JSON Types | ✅ | ❓ | ✅ | ✅ | +| [Mergeable Containers](/blog/mergeable-containers) | ✅ | ❌ | ❌ | ❌ | +| Merging Elements in Memory by Run Length Encoding | ✅ | ✅ | ✅ Inventor | ❌ | +| Byzantine-fault-tolerance | ❌ | ❌ | ❌ | ✅ | +| Version Control | ✅ | ❌ | ❌ | ✅ | -- **Peer ID**: A 64-bit unique identifier for each client/session -- **Operation ID**: Combination of `(peerId, counter)` that uniquely identifies each operation -- **Counter**: Monotonically increasing number starting at 0 for each peer +- [1] Unlike others, Yjs requires users to store a version vector and a delete + set, enabling time travel back to a specific point. +- [Fugue](https://arxiv.org/abs/2305.00583) is a text/list CRDTs that can + minimize the chance of the interleaving anomalies. -```ts -interface OpId { - peerId: `${number}`; // Unique peer identifier. It's string because 64bit integers are not supported in JS. - counter: number; // Monotonically increasing counter -} -``` -## Peer ID Assignment +# FILE: pages/changelog/v1.8.0.mdx -### Automatic (Default) +--- +version: "v1.8.0" +title: "Release Loro v1.8.0" +date: 2025/09/22 +--- -```ts twoslash -import { LoroDoc } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); // Gets a random peer ID -// Safe, no coordination needed -``` +Events now fire **synchronously** -**Note**: New peer ID generated for each `LoroDoc` instance, even when loading same document. +In the JavaScript package `loro-crdt@1.8.0`, we changed event emission from **“after a microtask”** to **synchronous** dispatch. This makes event handling simpler and less error‑prone. -### Manual +### Why we changed it + +Historically, the JS binding emitted events **after a microtask**. The reason was to avoid Rust borrow/aliasing issues: if an event triggered inside `doc.commit()` re‑entered the same `doc`, Rust could panic because `commit()` holds a borrow and reusing the object would violate borrowing rules. Deferring events to a microtask avoided that re‑entrancy. (Background: Loro’s JS API exposes subscriptions via `doc.subscribe(...)`; the docs note that events used to arrive after a microtask. ([Loro][1])) + +However, this deferral made app logic fragile. There was a window between the mutation and the event callback. Example: the app receives the event from CRDT “delete the 3rd character of `Hi!`”, but during the microtask gap the app state changed `Hi!` → `Hi`. When the deferred event arrives, applying a “delete index 2” delta can be wrong. In practice, users had to maintain an awkward invariant: **don’t mutate the app state during that microtask** if you consume delta updates. + +### What’s new in 1.8.0 + +Events are now **dispatched synchronously**—that is, your listeners run **before** the top‑level JS call (e.g., `doc.commit()`) returns. To keep this safe with Rust’s borrowing: + +- We keep a **global queue** of “pending (listener, event)” pairs. +- JS wrappers decorate APIs that can borrow the Rust doc (e.g., `commit`, import/export, checkout). +- The wrapper calls into WASM/Rust, **returns from the borrow**, then **flushes the queue** and clears it. +- Because callbacks run **outside** the borrowed region, listeners can freely call `doc` APIs without triggering borrow violations. + +### What this means for your app -```ts twoslash -import { LoroDoc } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -doc.setPeerId("123123123"); // You can only set 64 bit integers as peer IDs -``` +- **Simpler state updates.** No more microtask gap; event ordering matches your mutation order. +- **Fewer footguns with deltas.** You no longer need to uphold “don’t touch the doc during the microtask” when applying delta‑style updates. +- **Listeners can still safely use `doc`.** -⚠️ **Warning**: Manual assignment requires careful conflict avoidance. -If you intentionally reuse a Peer ID, persist the document's local data (snapshot, updates, or durable cache) alongside that ID and load it before fetching or applying any remote updates. This ensures the `(peerId, counter)` pairs continue to reference the same operations. Skipping this step risks generating a new operation that reuses an existing operation ID, which leads to inconsistent replicas. +# FILE: pages/changelog/v1.9.0.mdx -## Counter System +--- +version: "v1.9.0" +title: "Release Loro v1.9.0" +date: 2025/11/10 +--- -Each peer maintains a monotonic counter starting at 0: +## Highlights -```ts twoslash -import { LoroDoc } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -doc.setPeerId("1"); -const text = doc.getText("text"); +- JSONPath queries are now RFC 9535 compliant across both Rust and WASM bindings. A new parser, evaluator, and conformance test suite (PR #848, commit `21d13218`) unlock richer filters like `in` and `contains` while delivering clearer error messages. Thanks @zolero! +- Source maps become first-class in the WASM toolchain. `loro-wasm-tools` now embeds symbols during bundling (#836) and the new `loro-wasm-map` package publishes maps next to the runtime bundle (#844), drastically simplifying debugging in browsers, Vite, and other build systems. -text.insert(0, "H"); // Operation ("1", 0) -text.insert(1, "i!"); // Operation ("1", 1) and Operation ("1", 2) are created -console.log(doc.version()); // { "1": 2 } -``` +## Breaking change -**Properties**: Monotonic, continuous, per-peer, persistent. +- **Removed legacy v0.x encoding path (#849, `10a405b4`)**. Legacy serializers, compatibility fuzz tests, and decoding shims are gone. All runtimes now rely on the v1.* encoding format. Documents created with v0.x must be migrated via an intermediate ≤1.8.x release before installing 1.9.0. -## Common Pitfalls +## New features & improvements -❌ **Never**: -- Use user IDs as peer IDs, because an user can have multiple devices -- Use fixed IDs -- Reuse IDs without proper management -- Allow multiple browser tabs or processes to operate on the same reused Peer ID in parallel—coordinate access with locks so that only one session emits operations for that ID at a time +- **Spec-compliant JSONPath (#848, `21d13218`)**. Ships a Pest-based parser, richer AST, the `in`/`contains` filter operators, better existence checks, and shared tests/benchmarks to keep Rust and WASM perfectly aligned. +- **WASM debugging experience (#836, `6f987022`; #844, `53f55331`)**. Adds `loro-wasm-tools` for embedding sourcemaps, automates sourcemap publishing in CI, and hosts the maps in a dedicated workspace package so source-level debugging works without manual steps. +- **Better bundler support (#852 `3af6a857`, #851 `366f0161`)**. Patches the JS entry points so esbuild and rsbuild can import the WASM bundle with zero loader overrides. +- **Bun runtime improvements (#829 `b8c070fd`, #828 `cf123453`, #827 `8b622619`, #834 `865bccba`)**. Streams the WASM binary during bundling, pre-seeds the externref table, integrates Bun into CI, and removes earlier hacks that hid loader bugs. +- **JavaScript performance (#820 `c2c535c1`)**. Event conversion now runs exactly once per callback invocation, trimming overhead during heavy real-time editing sessions. +- **Core dependency refresh (`e8f79de8`, `b2f5e107`)**. Upgrades `generic-btree` and the columnar storage crate to pick up the latest bug fixes and perf wins. -## Related Documentation +## Bug fixes & stability -- [Version Vector](./version_vector) - How peer IDs form version vectors -- [Import Status](./import_status) - Handling synchronization with peer IDs -- [Shallow Snapshots](./shallow_snapshots) - Consolidating peer history +- Fixed tree undo operations that moved nodes between siblings (#821 `76a8728e`). +- Resolved container ID bookkeeping when exporting shallow snapshots (#823 `b72a759a`) and ensured pending containers now return `None` instead of leaking state (#840 `35d9064d`). +- Prevented `LoroMap` entries from turning into `null` after `applyDiff` (#825 `3afc4d52`). +- Ensured undo manager callbacks fire without tripping Rust aliasing violations (#831 `a39daf85`). +- Guarded against panics when cursors point to deleted entries (#835 `e97e6056`) and when fetching unknown cursors in JS integration tests. +- Cleaned up WASM loader glue so we no longer rely on an extra patch (#834 `865bccba`) and added multiple Bun regression tests (#828 `cf123453`). +- Tightened CI release scripts (`74b78514`, `2383cdc1`, `4740a04c`, `3373e046`, `24f93249`) to keep `loro-wasm` artifacts and sourcemaps publishing in sync. -# FILE: pages/docs/concepts/choose_crdt_type.mdx +# FILE: pages/changelog/v1.2.0.mdx --- -keywords: "crdt, crdts, application, data model, concurrent, conflict" -description: "Loro supports many CRDT types. You need to choose the correct type to model the data based on the algorithm semantics." +version: "v1.2.0" +title: "Release Loro v1.2.0" +date: 2024/12/10 --- -# How to Choose the Right CRDT Types +## New -Choosing the right CRDT type means understanding their potential behavior in concurrent editing situations and judging whether such behavior is acceptable for your application. +- Add `isDeleted()` method to all container types (Text, Map, List, Tree, etc.) -For text, you can choose to represent it directly as a Value on a Map (where the Value can be a string type), or you can choose to use a Text CRDT. For the former, each operation completely overwrites the previous one, so if A and B make concurrent modifications, only one of their edits will remain in the end. For the latter, the CRDT will retain all concurrent insertions by both people, and concurrent deletions are combined to complete the deletion. For most text box edits, you might prefer the latter. But for something like editing a link, you might want to use the former. +### LoroDoc -For Lists, concurrently removing the same element and inserting a single element creates a new element, differentiating from the semantics of Set on a Map (we may consider providing a list set method in the future). For representing coordinates, it's better to use a Map rather than a List. If you represent coordinates as [x, y], and the A client updates the y coordinate by deleting the y element and reinserting a new y_a, and the B client also deletes y and inserts y_b, then after merging, the array will become [x, y_a, y_b], which does not conform to the user's schema. Using a Map can prevent this problem. +- `changeCount()`: Get the number of changes in the oplog. +- `opCount()`: Get the number of ops in the oplog. -For nested data, also decide how the child container should get its identity: +### VersionVector -- Use a Map value, such as a string or plain object, when the field should have - Last-Write-Wins replacement semantics. -- Use a regular child container with `setContainer` or `insertContainer` when - each creation should produce a distinct child object. -- Use a mergeable child container with `ensureMergeableText`, - `ensureMergeableMap`, `ensureMergeableList`, and the other - `ensureMergeable*` methods when multiple peers may lazily initialize the same - child under the same Map key and should end up editing one shared child. +- `setEnd(id: ID)`: Set the exclusive ending point. target id will NOT be included by self. +- `setLast(id: ID)`: Set the inclusive ending point. target id will be included. +- `remove(peer: PeerID)`: Remove the specific peer id. +- `length()`: Get the number of peers in the VersionVector. -This distinction matters for dynamic keys, schema migrations, date-keyed lists, -and per-entity subdocuments. In those cases, the child identity usually should -come from the logical position `(parent Map, key, type)` rather than from the -operation that happened to create it first. +## Change + +- Return `ImportStatus` in the `importUpdateBatch` method. +- Fractional index is enabled by default now. -# FILE: pages/docs/concepts/version_vector.mdx +## Fix + +- fix: getOrCreateContainer should not throw if value is null [#576](https://github.com/loro-dev/loro/pull/576) +- fix: dead loop when importing updates [#570](https://github.com/loro-dev/loro/pull/570) + + +# FILE: pages/changelog/v1.4.7.mdx --- -keywords: "version vector, logical clock, distributed systems, CRDT, synchronization" -description: "Understanding Version Vectors in Loro - complete peer state tracking for synchronization" +version: "v1.4.7" +title: "Release Loro v1.4.7" +date: 2025/04/01 --- -# Version Vector +## New -Version Vectors are a fundamental concept in distributed systems that track the complete state of all peers by recording how many operations each peer has contributed. +- You can get the version of Loro by `LORO_VERSION` +- `setNextCommitOrigin(origin: string)`: Set the origin of the next commit. +- `setNextCommitTimestamp(timestamp: number)`: Set the timestamp of the next commit. +- `setNextCommitOptions(options: CommitOption)`: Set the options of the next commit. +- `clearNextCommitOptions()`: Clear the options of the next commit. +- `configDefaultTextStyle(style: TextStyle)`: Configures the default text style for the document. +- `getUncommittedOpsAsJson()`: Get the pending operations from the current transaction in JSON format -## What is a Version Vector? +## Fix -A Version Vector is a map from peer IDs to operation counters, explicitly listing every peer and their operation count. It provides a complete picture of which operations are included in a version. +- fix: memory leak issue [#647](https://github.com/loro-dev/loro/pull/647) +- fix: mark err on detached LoroText [#659](https://github.com/loro-dev/loro/pull/659) +- fix: detached loro text issues [#665](https://github.com/loro-dev/loro/pull/665) +- fix: entity index when the tree is empty [#670](https://github.com/loro-dev/loro/pull/670) -**Example**: `{ "peer-A": 5, "peer-B": 3 }` means this version includes operations 0-4 from peer A and operations 0-2 from peer B. -## Key Characteristics +# FILE: pages/changelog/v1.5.0.mdx -- **Complete information**: Explicitly lists all peers and their operation counts -- **Grows with peer count**: Size increases as more peers join -- **No dependency on history**: Can determine included operations without accessing the operation log -- **Enables version comparison**: Can easily check if one version includes another +--- +version: "v1.5.0" +title: "Release Loro v1.5.0" +date: 2025/04/04 +--- -## Basic Usage +## New -```ts twoslash -import { LoroDoc } from "loro-crdt"; +### 1. New Hooks -const doc = new LoroDoc(); -doc.setPeerId("1"); -const text = doc.getText("content"); -text.insert(0, "Hello"); +`doc.subscribePreCommit(listener)` - Modify commit options before processing: -// Get version vector -const vv = doc.version(); -console.log(vv.toJSON()); // Map { "1" => 1 } -``` +This hook is particularly useful because doc.commit() is often invoked implicitly in various methods such as doc.import, doc.export, doc.checkout, and doc.exportJsonUpdates. Without this hook, users attempting to add custom messages to each commit might miss these implicit commit triggers. -## When to Use Version Vectors +```ts +const doc = new LoroDoc(); +doc.setPeerId(0); +doc.subscribePreCommit((e) => { + e.modifier.setMessage("test").setTimestamp(Date.now()); +}); +doc.getList("list").insert(0, 100); +doc.commit(); +expect(doc.getChangeAt({ peer: "0", counter: 0 }).message).toBe("test"); +``` -Version Vectors are ideal for: +Advanced Example - Creating a Merkle DAG: -1. **Synchronization protocols** - Determine what updates to send between peers -2. **Network communication** - Self-contained version information -3. **Distributed systems** - Track state across multiple nodes +```ts no_run +const doc = new LoroDoc(); +doc.setPeerId(0); +doc.subscribePreCommit((e) => { + const changes = doc.exportJsonInIdSpan(e.changeMeta); + expect(changes).toHaveLength(1); + const hash = crypto.createHash("sha256"); + const change = { + ...changes[0], + deps: changes[0].deps.map((d) => { + const depChange = doc.getChangeAt(idStrToId(d)); + return depChange.message; + }), + }; + hash.update(JSON.stringify(change)); + const sha256Hash = hash.digest("hex"); + e.modifier.setMessage(sha256Hash); +}); -## Comparison with Frontiers +doc.getList("list").insert(0, 100); +doc.commit(); -| Aspect | Version Vectors | Frontiers | -|--------|----------------|-----------| -| **Size** | O(number of peers) | O(1-2) typically | -| **Information** | Complete peer states | Boundary operations only | -| **Use Case** | Synchronization | Checkpoints | -| **History Required** | No | Yes, for full information | +expect(doc.getChangeAt({ peer: "0", counter: 0 }).message).toBe( + "2af99cf93869173984bcf6b1ce5412610b0413d027a5511a8f720a02a4432853", +); +``` -## Conversion with Frontiers +`doc.subscribeFirstCommitFromPeer(listener)` - Triggers on first peer interaction: -```ts twoslash -import { LoroDoc } from "loro-crdt"; +This hook provides an ideal point to associate peer information (such as author identity) with the document. +```ts const doc = new LoroDoc(); -const vv = doc.version(); -const frontiers = doc.vvToFrontiers(vv); // Convert to Frontiers -const backToVV = doc.frontiersToVV(frontiers); // Convert back +doc.setPeerId(0); +doc.subscribeFirstCommitFromPeer((e) => { + doc.getMap("users").set(e.peer, "user-" + e.peer); +}); +doc.getList("list").insert(0, 100); +doc.commit(); +expect(doc.getMap("users").get("0")).toBe("user-0"); ``` -## Related Documentation +### 2. EphemeralStore -- [Frontiers](./frontiers) - Compact version representation -- [Version Tutorial](../tutorial/version) - Working with versions -- [Version Deep Dive](../advanced/version_deep_dive) - Technical details +EphemeralStore is a better alternative to Awareness for ephemeral states: +Awareness is commonly used as a state-based CRDT for handling ephemeral states in real-time collaboration scenarios, such as cursor positions and application component highlights. As application complexity grows, Awareness may be set in multiple places, from cursor positions to user presence. However, the current version of Awareness doesn't support partial state updates, which means even minor mouse movements require synchronizing the entire Awareness state. -# FILE: pages/docs/concepts/transaction_model.mdx +```ts no_run +awareness.setLocalState({ + ...awareness.getLocalState(), + x: 167, +}); +``` +Since Awareness is primarily used in real-time collaboration scenarios where consistency requirements are relatively low, we can make it more flexible. We've introduced EphemeralStore as an alternative to Awareness. Think of it as a simple key-value store that uses timestamp-based last-write-wins for conflict resolution. You can choose the appropriate granularity for your key-value pairs based on your application's needs, and only modified key-value pairs are synchronized. -# Transaction Model -## Quick Reference +```ts +import { + EphemeralStore, + EphemeralListener, + EphemeralStoreEvent, +} from "loro-crdt"; -**Loro transactions are NOT database ACID transactions.** They are operation bundling mechanisms for event emission. +const store = new EphemeralStore(); +// Set ephemeral data +store.set("user-alice", { + anchor: 10, + focus: 20, + user: "Alice" +}); -## Key Concepts +// Encode only the data for `loro-prosemirror` +const encoded = store.encode("user-alice") +const newStore = new EphemeralStore(); +newStore.subscribe((e: EphemeralStoreEvent) => { + // Listen to changes from `local`, `remote`, or `timeout` events +}); -- **Purpose**: Bundle related operations and control event emission -- **No rollback**: Failed operations don't undo previous ones -- **Event batching**: Single event for all operations in transaction -- **History grouping**: Operations stay together for undo/redo +newStore.apply(encoded); +console.log(newStore.get("user-alice")) +// { +// anchor: 10, +// focus: 20, +// user: "Alice" +// } +``` -## Basic Usage +## Fix -```ts twoslash -import { LoroDoc } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); +- Fixed text styling at end "\n" character +- Added JSON support for current transaction operations +- For environments that support multi-threading such as Rust and Swift, LoroDoc can now be directly and safely + shared and accessed in parallel across multiple threads without triggering the previous WouldBlock panic. -// Without transaction - multiple events -const text = doc.getText("text"); -text.insert(0, "Hello"); -doc.commit(); // Event emitted -text.insert(5, " World"); -doc.commit(); // Another event -``` -## How Transactions Work +# FILE: pages/changelog/v1.0.0-beta.mdx -### Pending Operations -Local operations are **pending** by default and won't emit events until committed: +--- +version: "v1.0.0" +title: "Release Loro v1.0.0" +date: 2024/10/21 +# breakingChange: false +# category: ["Encoding", "Tree"] +--- -```ts twoslash -import { LoroDoc } from "loro-crdt"; -// ---cut--- -const doc = new LoroDoc(); -const text = doc.getText("text"); +We are very excited to announce the release of Loro v1.0, a major milestone. -// Operation is pending, no event emitted yet -text.insert(0, "Hello"); +It has a stable encoding format, faster document import and export speed, better version control capabilities, and a shallow snapshot. For more information, please check [the blog](https://loro.dev/blog/v1.0). -// Explicit commit triggers event emission -doc.commit(); -``` +The following are the specific API changes: -### Implicit Commits -Certain operations trigger implicit commits automatically: +## New -```ts twoslash no_run -import { LoroDoc } from "loro-crdt"; -const update = new Uint8Array(); -// ---cut--- -const doc = new LoroDoc(); -const text = doc.getText("text"); +### LoroDoc -text.insert(0, "Hello"); // Pending operation +- `getChange(id: ID)`: get `ChangeMeta` by `ID`. +- `setDetachedEditing(flag: boolean)`: Enables editing in detached mode, which is disabled by default. +- `isDetachedEditingEnabled()`: Whether the editing is enabled in detached mode. +- `setNextCommitMessage(msg: string)`: Set the commit message of the next commit. +- `shallowSinceVV()`: The doc only contains the history since this version. +- `shallowSinceFrontiers()`: The doc only contains the history since this version. +- `export(mode: ExportMode)`: Export the document based on the specified ExportMode. see more details [here](/docs/tutorial/encoding). +- `getDeepValueWithID()`: Get deep value of the document with container id. +- `subscribeLocalUpdates(callback:(bytes: Uint8Array) => void)`: Subscribe to updates from local edits. +- `getPathToContainer(id: ContainerID)`: Get the path from the root to the container. +- `JSONPath(jsonPath: string)`: Evaluate JSONPath against a LoroDoc. +- `forkAt(frontiers: Frontiers): LoroDoc`: Creates a new LoroDoc at a specified version (Frontiers) +- `getPendingTxnLength():number`: Get the number of operations in the pending transaction. +- `travelChangeAncestors(ids: ID[], callback: (meta: ChangeMeta)->bool)`: Iterate over all changes including the input id in order, and stop iterating if the callback returns false. -// These operations trigger implicit commit: -doc.import(update); // Implicit commit before import -// or -doc.checkout(doc.frontiers()); // Implicit commit before checkout -// or -doc.export({ mode: "update" }); // Implicit commit before export -``` +### LoroText -### Transaction Guarantees +- `updateByLine(text: string)`: Update the current text based on the provided text line by line like git. -- All operations share the same timestamp (if enabled) -- Operations grouped in a single Change -- One event emitted after transaction completes -- Operations committed together at transaction end -- Before committing, the local edits are not synced to other peers +### LoroList -## Related Documentation +- `toArray(): ValueOrContainer[]`: Get elements of the list. If the value is a child container, the corresponding `Container` will be returned. +- `clear()`: Delete all elements in the list. -- [Operations and Changes](./operations_changes) - How transactions create Changes -- [Event System](../tutorial/event) - Understanding event emission -- [Undo/Redo](../advanced/undo) - Using transactions for undo boundaries +### LoroMovableList +- `toArray(): ValueOrContainer[]`: Get elements of the list. If the value is a child container, the corresponding `Container` will be returned. +- `clear()`: Delete all elements in the list. -# FILE: pages/docs/api/js.mdx +### LoroMap -import styles from "./api-reference.module.css"; -import Indent from "./indent"; -import Method from "./method"; +- `clear()`: Delete all key-value pairs in the map. -
+### LoroTree -# API Reference +- `enableFractionalIndex(jitter: number)`: Set whether to generate fractional index for Tree Position. +- `disableFractionalIndex()`: Disable the fractional index generation for Tree Position when + you don't need the Tree's siblings to be sorted. The fractional index will be always default. +- `isFractionalIndexEnabled()`: Whether the tree enables the fractional index generation. +- `isNodeDeleted(id: TreeID)`: Return `undefined` if the node is not exist, otherwise return `true` if the node is deleted. +- `getNodes(prop: getNodesProp): LoroTreeNode[]`: Get the flat array of the forest. If `with_deleted` is true, the deleted nodes will be included. -> _Last updated: 2025-08-09 loro-crdt@1.5.10_ +### UndoManager -## Overview +- `clear()`: Clear the Undo and Redo stack of `UndoManager` -Loro is a powerful Conflict-free Replicated Data Type (CRDT) library that enables real-time collaboration. If CRDTs are new to you, start with [What are CRDTs](/docs/concepts/crdt) for a gentle intro. This API reference provides comprehensive documentation for all classes, methods, and types available in the JavaScript/TypeScript binding. +## Changes -Note: Under the hood, Loro combines a Fugue-based CRDT core with Eg-walker-inspired techniques that use simple index operations and replay only the divergent history when merging. This yields fast local edits, efficient merges, and low overhead without permanent tombstones. See the primer [Event Graph Walker (Eg-walker)](/docs/advanced/event_graph_walker) and performance notes in the v1.0 blog (import/export speedups, shallow snapshots): https://loro.dev/blog/v1.0 +### LoroDoc -## Pitfalls & Best Practices +- Move `setFractionalIndexJitter()` to `LoroTree`, you can set whether to enable or disable it for each `Tree Container`. +- `import()`, `importWith()` and `importJsonUpdates` will return `ImportStatus` for indicating which ops have been successfully applied and which ops are pending. +- New Subscription for event. +- In Loro 1.0, `doc.version()` `doc.frontiers()` `doc.oplogVersion()` and `doc.oplogFrontiers()` even if ops has not been committed, it indicates the latest version of all operations. +- rename `Loro` to `LoroDoc`. -**Peer ID Management** +### LoroTree -- **Never share PeerIDs** between concurrent sessions (tabs/devices) - causes document divergence -- Use random PeerIDs (default) unless you have strict single-ownership locking -- Don't assign fixed PeerIDs to users or devices +- `contains(id: TreeID)`: Return true even if the node exists in the internal state and has been deleted. +- `nodes()`: deleted nodes will be included now, you can use `isDeleted()` to filter. +- `toJSON()`: Now use the hierarchical approach to express the tree structure. -**UTF-16 Text Encoding** +## Deprecation -- All text operations use UTF-16 indices by default in JS API -- Slicing in the middle of multi-unit codepoints corrupts them -- Use `insertUtf8()`/`deleteUtf8()` for UTF-8 systems +### LoroDoc -**Container Creation** +- `exportFrom(version)` and `exportSnapshot()` are deprecated, use `export(mode: ExportMode)` instead. -- Concurrent child container creation inside the same LoroMap at same key causes overwrites -- Initialize all child containers for a LoroMap upfront when possible -- Operations on the root containers will not override each other -- Events emit synchronously during commit/import/checkout in JS API (v1.8+). Stay on `<=1.7.x`? Await a microtask before reading batched events. -- Import/export/checkout trigger automatic commits -- Loro transactions are NOT ACID - no rollback/isolation +# FILE: pages/changelog/v1.4.0.mdx -**Version Control** +--- +version: "v1.4.0" +title: "Release Loro v1.4.0" +date: 2025/02/13 +--- -- After `checkout()`, document enters read-only "detached" mode, unless `setDetachedEditing(true)` is called -- [Frontiers](/docs/concepts/frontiers) can't determine complete operation sets without history +## New -**Data Structure Choice** +- add `unsubscribe()` for Subscription. -- Use strings in Map for URLs/IDs (LWW), LoroText for collaborative editing +## Fix -## Common Tasks & Examples +- fix: getting values by path in LoroTree [#643](https://github.com/loro-dev/loro/pull/643) +- fix: should be able to call subscription after diffing [#637] +- fix: update long text may fail [#633](https://github.com/loro-dev/loro/pull/633) +- fix: map.keys() may return keys from deleted entries [#618](https://github.com/loro-dev/loro/pull/618) -**Getting Started** -- **Create a document**: [`new LoroDoc()`](#LoroDoc.constructor) - Initialize a new collaborative document -- **Add containers**: [`getText`](#LoroDoc.getText), [`getList`](#LoroDoc.getList), [`getMap`](#LoroDoc.getMap), [`getTree`](#LoroDoc.getTree) -- **Listen to changes**: [`subscribe`](#LoroDoc.subscribe) - React to document modifications -- **Export/Import state**: [`export`](#LoroDoc.export) and [`import`](#LoroDoc.import) - Save and load documents +# FILE: pages/changelog/inspector-v0.1.0.mdx -**Real-time Collaboration** +--- +version: "v0.1.0" +title: "Release Loro Inspector v0.1.0" +date: 2025/04/30 +--- -- **Sync between peers**: [`export`](#LoroDoc.export) with `mode: "update"` + [`import`](#LoroDoc.import)/[`importBatch`](#LoroDoc.importBatch) - Exchange incremental updates -- **Stream updates**: [`subscribeLocalUpdates`](#LoroDoc.subscribeLocalUpdates) - Send changes over WebSocket/WebRTC -- **Set unique peer ID**: [`setPeerId`](#LoroDoc.setPeerId) - Ensure each client has a unique identifier -- **Handle conflicts**: Automatic - All Loro data types are CRDTs that merge concurrent edits +Try it here: [Loro Inspector](https://inspector.loro.dev/) -**Rich Text Editing** +Now you can directly browse the current state and complete edit history of your Loro +documents in the browser. You can also use this tool to time travel to any version +in the history of your Loro document. -- **Create rich text**: [`getText`](#LoroDoc.getText) - Initialize a collaborative text container -- **Edit text**: [`insert`](#LoroText.insert), [`delete`](#LoroText.delete), [`applyDelta`](#LoroText.applyDelta) -- **Apply formatting**: [`mark`](#LoroText.mark) - Add bold, italic, links, custom styles -- **Copy styled snippets**: [`sliceDelta`](#LoroText.sliceDelta) - Get a Delta for a range (UTF-16; use `sliceDeltaUtf8` for byte offsets) -- **Track cursor positions**: [`getCursor`](#LoroText.getCursor) + [`getCursorPos`](#LoroDoc.getCursorPos) - Stable positions across edits -- **Configure styles**: [`configTextStyle`](#LoroDoc.configTextStyle) - Define expand behavior for marks +import { ReactPlayer } from "../../components/video"; -**Data Structures** + -- **Ordered lists**: [`getList`](#LoroDoc.getList) - Arrays with [`push`](#LoroList.push), [`insert`](#LoroList.insert), [`delete`](#LoroList.delete) -- **Key-value maps**: [`getMap`](#LoroDoc.getMap) - Objects with [`set`](#LoroMap.set), [`get`](#LoroMap.get), [`delete`](#LoroMap.delete) -- **Hierarchical trees**: [`getTree`](#LoroDoc.getTree) - File systems, nested comments with [`createNode`](#LoroTree.createNode), [`move`](#LoroTree.move) -- **Reorderable lists**: [`getMovableList`](#LoroDoc.getMovableList) - Drag-and-drop with [`move`](#LoroMovableList.move), [`set`](#LoroMovableList.set) -- **Counters**: [`getCounter`](#LoroDoc.getCounter) - Distributed counters with [`increment`](#LoroCounter.increment) -**Ephemeral State & Presence** +# FILE: pages/changelog/v1.6.0.mdx -- **User presence**: [`EphemeralStore`](#ephemeralstore) - Share cursor positions, selections, user status (not persisted) -- **Cursor syncing**: Use [`EphemeralStore.set`](#EphemeralStore.set) with cursor data from [`getCursor`](#LoroText.getCursor) -- **Live indicators**: Track who's online, typing indicators, mouse positions -- **Important**: EphemeralStore is a separate CRDT without history - perfect for temporary state that shouldn't persist +--- +version: "v1.6.0" +title: "Release Loro v1.6.0" +date: 2025/08/29 +--- -**Version Control & History** +Snapshot import speed is now 2x. -- **Undo/redo**: [`UndoManager`](#undomanager) - Local undo of user's own edits -- **Time travel**: [`checkout`](#LoroDoc.checkout) to any [`frontiers`](#LoroDoc.frontiers) - Debug or review history -- **Version tracking**: [`version`](#LoroDoc.version), [`frontiers`](#LoroDoc.frontiers), [`versionVector`](#LoroDoc.versionVector) -- **Fork documents**: [`fork`](#LoroDoc.fork) or [`forkAt`](#LoroDoc.forkAt) - Create branches for experimentation -- **Merge branches**: [`import`](#LoroDoc.import) - Combine changes from forked documents +This is implemented by skipping the scan when importing a snapshot, which avoids running the decompression during the import process (but we need to ensure the parent-child link is still accessible in Arena). It also skips the checksum check in the import_all method on MemKV because we already check the checksum in the header of the snapshot/update. -**Performance & Storage** +## v1.0.0 vs v1.6.0 -- **Incremental updates**: [`export`](#LoroDoc.export) from specific [`version`](#LoroDoc.version) - Send only changes -- **Compact history**: [`export`](#LoroDoc.export) with `mode: "snapshot"` - Full state with compressed history -- **Shallow snapshots**: [`export`](#LoroDoc.export) with `mode: "shallow-snapshot"` - State without partial history (see [Shallow Snapshots](/docs/concepts/shallow_snapshots)) +You can find the benchmark [here](https://github.com/loro-dev/latch-bench/tree/cmp-loro-160). -## Basic Usage +| name | task | time | +| -------------------------- | ------------------------------- | ---------------------- | +| Shallow Snapshot on v1.0.0 | Import | 150.667µs +- 1.823µs | +| | Import+GetAllValues | 163.957µs +- 1.841µs | +| | Import+GetAllValues+Edit | 173.971µs +- 2.03µs | +| | Import+GetAllValues+Edit+Export | 488.848µs +- 3.621µs | +| Shallow Snapshot on v1.6.0 | Import | 82.82µs +- 507ns | +| | Import+GetAllValues | 90.376µs +- 393ns | +| | Import+GetAllValues+Edit | 103.358µs +- 1.916µs | +| | Import+GetAllValues+Edit+Export | 419.463µs +- 2.316µs | +| Snapshot on v1.0.0 | Import | 466.425µs +- 3.879µs | +| | Import+GetAllValues | 487.06µs +- 3.523µs | +| | Import+GetAllValues+Edit | 541.477µs +- 9.067µs | +| | Import+GetAllValues+Edit+Export | 2.98382ms +- 80.537µs | +| Snapshot on v1.6.0 | Import | 201.934µs +- 854ns | +| | Import+GetAllValues | 370.108µs +- 4.049µs | +| | Import+GetAllValues+Edit | 386.497µs +- 3.509µs | +| | Import+GetAllValues+Edit+Export | 2.362296ms +- 28.258µs | -```typescript twoslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello World"); +# FILE: pages/changelog/v1.3.0.mdx -// Subscribe to changes -const unsubscribe = doc.subscribe((event) => { - console.log("Document changed:", event); -}); +--- +version: "v1.3.0" +title: "Release Loro v1.3.0" +date: 2025/01/09 +--- -// Export updates for synchronization -const updates = doc.export({ mode: "update" }); -``` +## New -## LoroDoc +- UndoManager's `onPush` now can access the change event. +- add getShallowValue for each container. -The `LoroDoc` class manages containers, sync, versions, and events. +### LoroDoc -**Constructor** +- `toJsonWithReplacer(replacer: (k, v)=>Value)`: Convert the document to a JSON value with a custom replacer function. +- `revertTo(frontiers: Frontiers)`: Revert the document to the given frontiers. +- `findIdSpansBetween(from: Frontiers, to: Frontiers)`: Find the op id spans that between the `from` version and the `to` version. +- `exportJsonInIdSpan(idSpan: IdSpan)`: Export the readable [`Change`]s in the given [`IdSpan`]. -```typescript no_run -new LoroDoc(); -``` +## Fix -Creates a new Loro document with a randomly generated peer ID. +- fix: prevent merging remote changes based on local `changeMergeInterval` config [#643](https://github.com/loro-dev/loro/pull/643) +- fix: should commit before travel_change_ancestors [#599](https://github.com/loro-dev/loro/pull/599) +- fix: panic when detach then attach [#592](https://github.com/loro-dev/loro/pull/592) +- fix: move child in current parent [#589](https://github.com/loro-dev/loro/pull/589) +- fix: panic when returned non-boolean value from text.iter(f) [#578](https://github.com/loro-dev/loro/pull/578) -**Example:** -```ts twoslash -import { LoroDoc } from "loro-crdt"; +# FILE: pages/changelog/v1.1.0.mdx -const doc = new LoroDoc(); -``` +--- +version: "v1.1.0" +title: "Release Loro v1.1.0" +date: 2024/11/09 +--- -**Static Methods** +## New - -```typescript no_run -static fromSnapshot(snapshot: Uint8Array): LoroDoc -``` - - -Creates a new LoroDoc from a snapshot. This is useful for loading a document from a previously exported snapshot. +### LoroDoc -**Parameters:** +- `forkAt(frontiers: Frontiers)`: Fork the document at the given frontiers. +- `getChangedContainersIn(id: ID, len: number)`: Gets container IDs modified in the given ID range. +- `` -- `snapshot` - Binary snapshot data +### LoroText -**Returns:** A new LoroDoc instance +- `getEditorOf(pos: number)`: Get the editor of the text at the given position. +- `push(s: string)`: Push a string to the end of the text. -**Example:** -```ts no_run twoslash -import { LoroDoc } from "loro-crdt"; +### LoroMap -// Assume we have a snapshot from a previous export -const prevDoc = new LoroDoc(); -prevDoc.getText("text").insert(0, "Hello"); -const snapshot: Uint8Array = prevDoc.export({ mode: "snapshot" }); +- `getLastEditor(key: string)`: Get the peer id of the last editor on the given entry -const doc = LoroDoc.fromSnapshot(snapshot); -``` +### LoroList - +- `getIdAt(pos: number)`: Get the ID of the list item at the given position. +- `pushContainer(child: Container)`: Push a container to the end of the list. -**Properties** +### LoroMovableList - -```typescript no_run -readonly peerId: bigint -``` - - -Gets the peer ID of the current writer as a bigint. +- `getCreatorAt(pos: number)`: Get the creator of the list item at the given position. +- `getLastMoverAt(pos: number)`: Get the last mover of the list item at the given position. +- `getLastEditorAt(pos: number)`: Get the last editor of the list item at the given position. +- `pushContainer(child: Container)`: Push a container to the end of the list. -**See Also:** [PeerID Management](/docs/concepts/peerid_management) +### LoroTree -**Example:** +- `toJSON()`: Get JSON format of the LoroTreeNode. -```ts twoslash -import { LoroDoc } from "loro-crdt"; +## Fix -const doc = new LoroDoc(); -const id = doc.peerId; -``` +- fix get correct encode blob info [#545](https://github.com/loro-dev/loro/pull/545) +- fix: avoid creating non-root containers that doesn't exist by get_container api [#541](https://github.com/loro-dev/loro/pull/541) +- fix: define the fork behavior when the doc is detached [#537](https://github.com/loro-dev/loro/pull/537) - - -```typescript no_run -readonly peerIdStr: `${number}` -``` - - -Gets the peer ID as a decimal string. +# FILE: pages/blog/v1.0.mdx -**Example:** +--- +title: "Loro 1.0" +date: 2024/10/23 +keywords: "loro, crdt, event graph walker" +description: "Announcing Loro 1.0: Introducing a stable encoding schema, 10-100x faster document import, advanced version control, and more for efficient real-time collaboration and local-first software development." +image: https://i.ibb.co/T1x1bSf/IMG-8191.jpg +--- -```ts twoslash -import { LoroDoc } from "loro-crdt"; +# Loro 1.0 -const doc = new LoroDoc(); -const idStr = doc.peerIdStr; -``` +import Authors, { Author } from "../../components/authors"; - + + -### Configuration Methods +import Image from "next/image"; - -```typescript no_run -setPeerId(peer: number | bigint | `${number}`): void -``` - - -Sets the peer ID for this document. It must be a number, a BigInt, or a decimal string that fits into an unsigned 64-bit integer. -See [PeerID Management](/docs/concepts/peerid_management) for why uniqueness matters in distributed systems. +Loro is a [Conflict-free Replicated Data Type (CRDT)](https://crdt.tech/) +library that developers can use to implement real-time collaboration and version +control in their applications. You can use Loro to create +[local-first software](https://www.inkandswitch.com/local-first/). Loro 1.0 has +a stable data format, excellent performance, and rich features. You can use it +in Rust, JS (via WASM), and Swift. -**Parameters:** +
+What is CRDT? What is it used for? -- `peer` - Peer ID as number, bigint, or decimal string +Distributed states are now ubiquitous in multi-user collaborative applications +and applications that need multi-device synchronization. You need to ensure +consistency across devices. CRDTs provide a decentralized way to automatically +solve this problem. -**Example:** +> CRDTs automatically resolve conflicts and ensure the consistency of the data. +> Some CRDT algorithms provide extra properties for merge results, which should align with user expectations as much as possible. -```ts twoslash -import { LoroDoc } from "loro-crdt"; +CRDT provides a decentralized way to solve this problem. The decentralization here not only means that it can synchronize through P2P methods, but it also means: -const doc = new LoroDoc(); -doc.setPeerId("42"); -``` +- It allows applications to naturally support offline editing +- It allows users to store and implement two-way synchronization of data in + multiple different locations +- It makes it easier for the backend to implement horizontal scaling +- It can easily support end-to-end encryption -**⚠️ Critical Pitfall:** Never let two parallel peers (e.g., multiple tabs/devices) share the same PeerID — it creates duplicate op IDs and causes document divergence. Common mistakes: +CRDTs were once considered unable to be used in serious and complex scenarios, +such as rich text, but optimizations in recent years have greatly expanded its +application scenarios, making it a practical and easy-to-use technology. -- Don't assign a fixed PeerId to a user (users have multiple devices) -- Don't assign a fixed PeerId to a device (multiple tabs can open the same document) -- If you must reuse PeerIDs, enforce single ownership with strict locking mechanisms -- Best practice: Use random IDs (default behavior) unless you have a strong reason not to +Based on CRDT, we can create applications that +allow users to fully control data ownership. These applications can be like +Git-managed repositories, not relying on specific software service providers. +Users can switch between GitHub, GitLab, self-hosted Git servers, and the data +is always available locally. This is the vision of +[local-first software](https://www.inkandswitch.com/local-first/). -See [PeerID reuse](/docs/tutorial/tips) for safe reuse patterns. + -**Parameters:** +CRDTs often also provides a simpler and easier-to-use sync method, +because for Op-based CRDTs like Loro, as long as the sets of CRDT operations +received by two peers are consistent, the CRDT document states of these two +peers are consistent. You don't have to worry about idempotency, the order of +operation application, and network exception handling. For Loro's CRDT document, +just two rounds of data exchange can transmit the missing operations between two +documents to achieve final consistency: -- `auto_record` - Whether to automatically record timestamps +> You can find all the code samples in this blog [here](https://github.com/zxch3n/loro-blog-examples) -**⚠️ Important:** This setting doesn't persist in exported Updates or Snapshots. You must reapply this configuration each time you initialize a document. +```jsx +import { LoroDoc, VersionVector } from "npm:loro-crdt@1.13.3"; -**Example:** +const docA = new LoroDoc(); +const docB = new LoroDoc(); +docA.setPeerId(0); +docA.setPeerId(1); -```ts twoslash -import { LoroDoc } from "loro-crdt"; +docA.getText("text").insert(0, "Hello!"); +docB.getText("text").insert(0, "Hi!"); +const versionA: Uint8Array = docA.version().encode(); +const versionB: Uint8Array = docB.version().encode(); -const doc = new LoroDoc(); -doc.setRecordTimestamp(true); -``` +// Exchange versionA and versionB Info +const bytesA: Uint8Array = docA.export({ + mode: "update", + from: VersionVector.decode(versionB), +}); +const bytesB: Uint8Array = docB.export({ + mode: "update", + from: VersionVector.decode(versionA), +}); - +// Exchange bytesA and bytesB +docB.import(bytesA); +docA.import(bytesB); - -```typescript no_run -setChangeMergeInterval(interval: number): void +console.log(docA.getText("text").toString()); // Hello!Hi! +console.log(docB.getText("text").toString()); // Hello!Hi! ``` - - -Sets the interval in milliseconds for merging continuous local changes into a single change record. In Loro, multiple low-level operations are grouped into higher-level Changes for readability and syncing. See [Operations and Changes](/docs/concepts/operations_changes). -**Parameters:** +
+ +A minimum of one round of data exchange can ensure consistency + -- `interval` - Merge interval in milliseconds +```jsx +import { LoroDoc } from "npm:loro-crdt@1.13.3"; -**Example:** +const docA = new LoroDoc(); +const docB = new LoroDoc(); +docA.setPeerId(0); +docA.setPeerId(1); +docA.getText("text").insert(0, "Hello!"); +docB.getText("text").insert(0, "Hi!"); -```ts twoslash -import { LoroDoc } from "loro-crdt"; +// Exchange versionA and versionB Info +const bytesA: Uint8Array = docA.export({ + mode: "update", +}); +const bytesB: Uint8Array = docB.export({ + mode: "update", +}); -const doc = new LoroDoc(); -doc.setChangeMergeInterval(1000); // Merge changes within 1 second +// Exchange bytesA and bytesB +docB.import(bytesA); +docA.import(bytesB); + +console.log(docA.getText("text").toString()); // Hello!Hi! +console.log(docB.getText("text").toString()); // Hello!Hi! ``` - +If a nested child container may be created lazily by multiple peers under the +same Map key, use [Mergeable Containers](/blog/mergeable-containers) instead of +regular `setContainer`. - -```typescript no_run -configTextStyle(styles: StyleConfig): void -``` - - -Configures the behavior of text styles (marks) in rich text containers. Marks can expand when edits happen at their edges (before/after/both/none). For a primer on rich text and marks in Loro, see [Text](/docs/tutorial/text). +
+
-**Parameters:** +## Features of Loro 1.0 -- `styles` - Configuration object mapping style names to their config +### High-performance CRDTs -**StyleConfig Type:** +High-performance, general-purpose CRDTs can significantly reduce data synchronization +complexity and are crucial for local-first development. -```typescript no_run -type StyleConfig = Record< - string, - { - expand?: "after" | "before" | "both" | "none"; - } ->; -``` +However, large CRDT documents may face challenges with loading speed and memory consumption, +especially when dealing with those with extensive editing histories. +Loro 1.0 addresses this challenge through a new storage format, achieving a 10x improvement in +loading speed. In [benchmarks using Loro with real-world editing data](#document-import-speed-benchmarks), +we've reduced the loading time for a document with millions of operations from 16ms to 1ms. When utilizing the +shallow snapshot format (discussed later), the time can be further reduced to 0.37ms. +As a result, Loro will not become a bottleneck for applications dealing with such large documents. +It expands the potential use cases for CRDTs, making them viable for a wider range of applications. -**Example:** +### Rich CRDT types -```ts twoslash -import { LoroDoc } from "loro-crdt"; +Loro now supports +[rich text CRDT](https://loro.dev/blog/loro-richtext), +which enhances the merge result of rich text (text with formatting and styling) to better align with user expectations. +Our text/list CRDT is based on the [Fugue](https://arxiv.org/abs/2305.00583) algorithm. +It prevents interleaving issues when merging concurrent edits. For example, +it can avoid unintended merges like "1H2i3" when "123" and "Hi" are inserted concurrently. -const doc = new LoroDoc(); -doc.configTextStyle({ - bold: { expand: "after" }, - italic: { expand: "none" }, - link: { expand: "none" }, -}); -``` +We also support: -
+- Movable List: Supports set, insert, delete, and move operations. The algorithm ensures that after + merging concurrent moves, each element occupies only one position. +- Map: Similar to a JavaScript object. +- [Movable Tree](https://loro.dev/blog/movable-tree): Used to model file directories, outliners, and + other hierarchical structures that may need moving. It ensures no cyclic dependencies exist in the + tree after merging concurrent move operations. - -```typescript no_run -configDefaultTextStyle(style?: { expand: "after" | "before" | "both" | "none" }): void -``` - - -Configures the default text style for the document when using LoroText. If undefined is provided, the default style is reset. +Loro also supports nesting between types, so you can model edits on JSON documents through them: -**Parameters:** +> You can find all the code samples in this blog [here](https://github.com/zxch3n/loro-blog-examples) -- `style` - Default style configuration (optional) +```tsx +import { + LoroDoc, + LoroList, + LoroMap, + LoroText, +} from "npm:loro-crdt@1.13.3"; -**Example:** +// Create a JSON structure of +interface JsonStructure { + users: LoroList< + LoroMap<{ + name: string; + age: number; + }> + >; + notes: LoroList; +} -```ts twoslash -import { LoroDoc } from "loro-crdt"; +const doc = new LoroDoc(); +const users = doc.getList("users"); +const user = users.insertContainer(0, new LoroMap()); +user.set("name", "Alice"); +user.set("age", 20); +const notes = doc.getList("notes"); +const firstNote = notes.insertContainer(0, new LoroText()); +firstNote.insert(0, "Hello, world!"); -const doc = new LoroDoc(); -doc.configDefaultTextStyle({ expand: "after" }); +// { users: [ { age: 20, name: "Alice" } ], notes: [ "Hello, world!" ] } +console.log(doc.toJSON()); ``` - +### Version control -### Container Access Methods +Like Git, Loro saves a complete directed acyclic graph (DAG) of edit history. In Loro, the DAG is used to represent the dependencies between edits, similar to how Git represents commit history. -**📝 Note:** Creating root containers (e.g., `doc.getText("...")`) does not record operations; nested container creation (e.g., `map.setContainer(...)`) does. +Loro supports primitives that allow users to switch between different versions, fork new branches, edit on new branches, and merge branches. -**⚠️ Pitfall:** Avoid concurrent creation of regular child containers with the same key in LoroMaps. Instead of: +Based on this operation primitive, applications can build various Git-like capabilities: -```ts no_run -// Dangerous - concurrent peers can create different child container IDs -doc.getMap("user").getOrCreateContainer(userId, new LoroMap()); -``` +- You can merge multiple versions without needing to manually resolve conflicts +- Applications can build rebase and squash workflows on top of Loro's version primitives. -Use a mergeable child container when the child should be identified by its logical position: +```jsx +import { LoroDoc } from "npm:loro-crdt@1.13.3"; -```ts no_run -doc.getMap("user").ensureMergeableMap(userId); +const doc = new LoroDoc(); +doc.setPeerId("0"); +doc.getText("text").insert(0, "Hello, world!"); +doc.checkout([{ peer: "0", counter: 1 }]); +console.log(doc.getText("text").toString()); // "He" +doc.checkout([{ peer: "0", counter: 5 }]); +console.log(doc.getText("text").toString()); // "Hello," +doc.checkoutToLatest(); +console.log(doc.getText("text").toString()); // "Hello, world!" + +// Simulate a concurrent edit +doc.checkout([{ peer: "0", counter: 5 }]); +doc.setDetachedEditing(true); +doc.setPeerId("1"); +doc.getText("text").insert(6, " Alice!"); +// ┌───────────────┐ ┌───────────────┐ +// │ Hello, │◀─┬──│ world! │ +// └───────────────┘ │ └───────────────┘ +// │ +// │ ┌───────────────┐ +// └──│ Alice! │ +// └───────────────┘ +doc.checkoutToLatest(); +console.log(doc.getText("text").toString()); // "Hello, world! Alice!" ``` -Alternatively, use a unique root container if that better fits your model: +You can also use `doc.fork()` to create a separate doc at the current version. It is independent of the current doc, and works like a fork: -```ts no_run -doc.getMap("user." + userId); -``` +```tsx +import { LoroDoc } from "npm:loro-crdt@1.13.3"; - -```typescript no_run -getText(name: string): LoroText +const doc = new LoroDoc(); +doc.setPeerId("0"); +doc.getText("text").insert(0, "Hello, world!"); +doc.checkout([{ peer: "0", counter: 5 }]); +const newDoc = doc.fork(); +newDoc.setPeerId("1"); +newDoc.getText("text").insert(6, " Alice!"); +// ┌───────────────┐ ┌───────────────┐ +// │ Hello, │◀─┬──│ world! │ +// └───────────────┘ │ └───────────────┘ +// │ +// │ ┌───────────────┐ +// └──│ Alice! │ +// └───────────────┘ +doc.import(newDoc.export({ mode: "update" })); +doc.checkoutToLatest(); +console.log(doc.getText("text").toString()); // "Hello, world! Alice!" ``` - - -Gets or creates a text container with the given name. New to LoroText and marks? See [Text](/docs/tutorial/text). + -```ts twoslash -import { LoroDoc } from "loro-crdt"; +### Leveraging the potential of the [Eg-walker](https://arxiv.org/abs/2409.14252) -const doc = new LoroDoc(); -const text = doc.getText("content"); -text.insert(0, "Hello"); -``` +import { ReactPlayer } from "../../components/video"; +import Caption from "../../components/caption"; - + - -```typescript no_run -getList(name: string): LoroList -``` - - +[Event Graph Walker (Eg-walker)](/docs/advanced/event_graph_walker) is a pioneering collaboration algorithm that combines the strengths of +Operational Transformation (OT) and CRDT, two widely used algorithms for real-time collaboration. + +While OT is centralized and CRDT is decentralized, OT traditionally had an advantage +in terms of lower document overhead. CRDTs initially had higher overhead, but recent +optimizations have significantly reduced this gap, making CRDTs increasingly competitive. +Eg-walker leverages the best aspects of both approaches. + +Not only have we use the idea of Eg-walker for Text and List CRDTs in Loro, but +Loro's overall architecture has also been greatly inspired by Eg-walker. As a +result, Loro closely resembles Eg-walker in terms of algorithmic properties. + + -**Returns:** A `LoroList` instance +[The Eg-walker paper](https://arxiv.org/abs/2409.14252) was released in +September 2023. Prior to its official publication, Joseph Gentle shared an +initial version of the algorithm in the Diamond-Type repository. Excited by +the design, I implemented a similar algorithm in Loro two years ago. A brief +introduction to this algorithm can be found +[here](https://loro.dev/docs/advanced/event_graph_walker). -**Example:** +The properties of Eg-walker includes: -```ts twoslash -import { LoroDoc } from "loro-crdt"; +- It itself conforms to the definition of CRDT, so it has the strong eventual + consistency property of CRDT, thus can be used in distributed environments +- Fast local operation speed: compared to previous CRDTs, it processes + operations extremely fast because it doesn't need to generate corresponding + Operations based on CRDT data structures +- Fast merging of remote operations: The complexity of OT merging remote + operations is O(n^2), while Eg-walker, like mainstream CRDTs, is O(nlogn), + only reaching O(n^2) in extremely rare worst-case scenarios. This means that + when the number of concurrent operations reaches 10,000, OT will start to show + noticeable lag to users, while CRDTs can handle it easily. And in most + real-world scenario benchmarks, it's faster than other CRDTs. +- Lower memory usage: Because it doesn't need to persistently store CRDT + structures in memory, its memory usage is lower than general CRDTs +- Faster import speed: CRDT documents often take a long time to load because + they need to parse the corresponding CRDT structures or operations to build the CRDT + data structures. Without these structures, they cannot continue subsequent + editing, resulting in long import times. Eg-walker, like OT algorithms, only + needs the current document state and does not need to build these additional + structures to allow users to start editing the document directly, thus + achieving much faster import speed -const doc = new LoroDoc(); -const list = doc.getList("items"); -list.push("Item 1"); -``` + -- `name` - The container name +In the past quarter, we have made significant architectural adjustments to allow +Loro to further leverage the advantages of the Eg-walker algorithm. Here are our +achievements -**Returns:** A `LoroMap` instance +#### Shallow Snapshot -**Example:** +By default, Loro stores the complete editing history of the document like Git, +because +[the Eg-walker algorithm needs to load edits that are parallel to them and to the least common ancestor when merging remote edits](https://loro.dev/docs/advanced/event_graph_walker). +Shallow Snapshot is like Git's Shallow Clone, which can remove old historical +operations that users don't need, greatly reducing document size and improving +document import and export speed. This allows you to cold store document history +that is too old and mainly use shallow doc for collaboration. Here's +an example usage: -```ts twoslash -import { LoroDoc } from "loro-crdt"; +```jsx +import { LoroDoc } from "npm:loro-crdt@1.13.3"; const doc = new LoroDoc(); -const map = doc.getMap("settings"); -map.set("theme", "dark"); -``` - - +for (let i = 0; i < 10_000; i++) { + doc.getText("text").insert(0, "Hello, world!"); +} +const snapshotBytes = doc.export({ mode: "snapshot" }); +const shallowSnapshotBytes = doc.export({ + mode: "shallow-snapshot", + frontiers: doc.frontiers(), +}); - -```typescript no_run -getTree(name: string): LoroTree +console.log(snapshotBytes.length); // 5421 +console.log(shallowSnapshotBytes.length); // 869 ``` - - -Gets or creates a tree container with the given name. Learn about hierarchical editing and moves in [Tree](/docs/tutorial/tree). -**Parameters:** +For details on the implementation principle, see +[Shallow Snapshot](/docs/advanced/shallow_snapshot). -- `name` - The container name +#### Optimized Document Format -**Returns:** A `LoroTree` instance +Loro version 1.0 has achieved a 10x to 100x improvement in document import speed +compared to version 0.16, which already has a fast import speed. +It makes it possible to load a large text document with several million operations +in under a frame time. -**Example:** +This is because we introduced a new snapshot format. +When a LoroDoc is initialized through this snapshot format, we don't +parse the corresponding document state and historical information until the user +actually needs that information. -```ts threeslash -import { LoroDoc } from "loro-crdt"; + -**Parameters:** +In Loro 1.0's snapshot format, without compression algorithms, its document size +is twice that of the old version (and other mainstream CRDTs). This additional +size mainly comes from encoding historical operations + document state in the +1.0 snapshot format, without reusing stored data between the two, while in the +old version we used the order of historical operations to encode the current +state of the document (the old version's encoding learned from +[Automerge encoding's Value Column](https://automerge.org/automerge-binary-format-spec/#_value_column)). -- `name` - The container name +Trading twice the document size for ten times the import speed is worthwhile +because import speed affects the performance of many aspects, and the import +speed of CRDT documents +[is often noticeable to users on large documents](https://loro.dev/docs/performance) +(> 16ms). It also leaves possibilities for more optimizations in the future. -**Returns:** A `LoroCounter` instance + -**Parameters:** +Inspired by the design of Key-Value Databases, we have also divided the storage +of document state and history into blocks, with each block roughly 4KB in size, +so that when users really need a piece of history, we only need to decompress +and read this 4KB of content, without parsing the entire document. This has led +to a qualitative improvement in import speed, and because the serialization +format can better compress history and state, memory usage is also lower than +before. -- `name` - The container name +The lazy loading optimization takes advantage of Eg-walker's property that "it +doesn't need to keep the complete CRDT data structure in memory at all times, +and only needs to access historical operations when parallel edits occur". -**Returns:** A `LoroMovableList` instance +
+How we implemented lazy loading -**Example:** +In Loro 1.0, we implemented a simple [LSM (Log-structured merge-tree)](https://en.wikipedia.org/wiki/Log-structured_merge-tree) engine internally. LSM is a data structure often used to +implement Key-Value Databases, and Loro 1.0 is heavily inspired by its design. +At the time of Loro 1.0, Loro's storage implementation used get, set, and range operations of +Key-Value Database as primitives. For example, Loro stores history as a series of +ChangeBlocks, with each ChangeBlock serialized to about 4KB. Each ChangeBlock +uses its first Op Id as the Key, and the serialized binary data of the +ChangeBlock as the Value, stored in the internal LSM engine. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +In our simple LSM Engine implementation, each Block is compressed during +serialization, and decompression only occurs when the corresponding Value is +actually retrieved. This allows the import speed of the new data format to be up +to a hundred times faster than before, with even lower memory usage. So in Loro +1.0: -const doc = new LoroDoc(); -const movableList = doc.getMovableList("tasks"); -movableList.push("Task 1"); -movableList.push("Task 2"); -movableList.push("Task 3"); -movableList.move(0, 2); // Move first item to third position -``` +1. Data integrity is checked during import +2. Loro internally stores history (History/OpLog) and state (DocState) in + blocks, loading the corresponding blocks as needed +3. The Eg-walker algorithm that Loro is based on allows documents to start + editing without complete CRDTs meta information, thus easily working with + lazy loading behavior - +Why is lazy loading valuable? Because in many use cases, we don't need to fully +load the document's history and state: - -```typescript no_run -getContainerById(id: ContainerID): Container | undefined -``` - - -Gets a container by its unique ID. Container IDs (CID) uniquely reference containers across updates; see [Container ID](/docs/advanced/cid) and [Container](/docs/concepts/container). +- For example, when we receive a set of remote updates, but the Loro document + data is still in the database, and we want to know the latest state of the + document, we need to load the LoroDoc snapshot from the database, then import + the remote update set, and then get the latest document state. At this point, + most of the historical information won't be accessed. +- Sometimes in data synchronization scenarios, peer A needs to send historical + data that peer B doesn't have. It needs to import the snapshot and then + extract the historical information that B doesn't have. In this case, the + document's state doesn't need to be parsed, and the unused part of the history + doesn't need to be parsed either. +- Users don't need document history when initializing a document; only parsing + the State is necessary at this point -**Parameters:** + ![When merging remote operations, only the modified containers and some of the related historical operations need to be visited](./v1/merge-edit.png) -- `id` - The container ID + When merging remote operations, only the modified containers and some of the + related historical operations need to be visited -**Returns:** The container instance or undefined if not found +What happens during import and export in the new version? Let's take a common +scenario as an example: -**Example:** +In real-time collaboration sessions or local storage, we recommend developers +first store the operations from users, and then periodically perform compaction. +This compaction involves importing the old snapshot and all scattered updates +into the same LoroDoc, then exporting uniformly through the Snapshot format. In +the new version, this will involve the following: -```ts threeslash -import { LoroDoc } from "loro-crdt"; +- First, the old version of the snapshot is imported +- The received updates may contain parallel edits, so a part of the related + parallel edit history from the old version needs to be loaded to construct the + CRDT and complete the diff calculation + - Loro internally loads and parses the data of the corresponding block to get + the corresponding history; at this point, complete document parsing does not + occur +- After the diff calculation is complete, it needs to be applied to the + corresponding States + - Loro will internally load and parse the corresponding state, and at this + point, complete document parsing does not occur either +- Export + - Unaffected history blocks or state blocks are exported as they are + - Affected blocks will be serialized to overwrite the original block, then + exported + - During export, we use a method similar to SSTable internally for the final + export -const doc = new LoroDoc(); -const text = doc.getText("text"); -const textId = text.id; -const sameText = doc.getContainerById(textId); -``` +The only data that needs to be parsed in this entire process are: - +- Meta information for each stored block +- Blocks that need to be read will be decompressed +- History Blocks / state Blocks that will be affected by Updates -### Import/Export Methods + -- `mode` - Export configuration (optional) +
-**ExportMode Options:** +#### Benchmarks -```typescript no_run -type ExportMode = - | { mode: "snapshot" } - | { mode: "update"; from?: VersionVector } - | { mode: "shallow-snapshot"; frontiers: Frontiers } - | { mode: "updates-in-range"; spans: { id: OpId; len: number }[] }; -``` +> All benchmarks results below were performed on a MacBook Pro M1 2020 -**Returns:** Encoded binary data +Below is a historical comparison of Snapshot import and export speeds between +Loro versions 1.0.0-beta.1 and 0.16.12. The benchmark is based on document editing history +from the real world. Thanks to [latch.bio](http://latch.bio) for sharing the +document data. The benchmark code is available [here](https://github.com/loro-dev/latch-bench). +The document contains 1,659,541 operations. -**⚠️ Important Notes:** +> In Loro, a Snapshot stores the document history along with its current state. +> The Shallow Snapshot format, similar to Git's Shallow Clone, can exclude +> history. In the benchmark below, the Shallow Snapshot has a depth=1 (only the +> most recent operation history is retained, other historical operations are +> removed) -- **Shallow snapshots**: Cannot import updates from before the shallow start point. Peers can only sync if they have versions after this point. -- **Auto-commit**: The document automatically commits pending operations before export. -- **Performance**: Export new snapshots periodically to reduce import times for new peers. +| task | Old Snapshot Format on 0.16.12 | New Snapshot Format | Shallow Snapshot Format | +| ------------------------------- | ------------------------------ | -------------------------- | ----------------------- | +| Import | 17.3ms +- 0.0298ms | 1.15ms +- 0.0101ms (15x) | 375µs +- 8.47µs (47x) | +| Import+GetAllValues | 17.4ms +- 0.0437ms | 1.19ms +- 0.0122ms (14.5x) | 375µs +- 1.60µs (46x) | +| Import+GetAllValues+Edit | 17.5ms +- 0.0263ms | 1.21ms +- 0.0120ms (14.5x) | 375µs +- 1.40µs (46.5x) | +| Import+GetAllValues+Edit+Export | 32.4ms +- 0.0560ms | 5.46ms +- 0.0772ms (6x) | 844µs +- 5.12µs (38.5x) | -**Examples:** +Here are the key points of this benchmark: -```typescript no_run -import { LoroDoc, VersionVector } from "loro-crdt"; +- The Shallow Snapshot has a depth of 1, meaning it only contains the document + state and a single historical operation, which is why it's significantly + faster +- _GetAllValue_ refers to calling `doc.get_deep_value()` (in JS, it's + `doc.toJSON()` ). It loads the complete state of the document and + obtains the corresponding JSON-like structure. This represents the time spent + on CRDT parsing before a user loads a document. +- _Edit_ refers to making a local modification. As you can see, it has little + impact on the time taken because Loro doesn't need to load the complete CRDT + data structure for local operations. +- _Export_ refers to exporting the complete document data again. We expect to + further reduce the time spent here in the future, as we can continue to reuse + the encoding of unmodified Blocks from the import. -const doc = new LoroDoc(); -// ... make some changes to the document ... +The following shows the performance on a document after applying the editing +history from the +[Automerge Paper](https://github.com/automerge/automerge-perf/tree/master/edit-by-index) +**100 times**. You can reproduce the results [here](https://github.com/zxch3n/automerge-paper-bench). +The document contains: -// Export full snapshot -const snapshot = doc.export({ mode: "snapshot" }); +- 18,231,500 single-character insertion operations +- 7,746,300 single-character deletion operations +- 25,977,800 operations totally +- 10,485,200 characters in the final document -// Export updates from a specific version -const lastSyncVersion = doc.version(); // Get current version -// ... make more changes ... -const updates = doc.export({ - mode: "update", - from: lastSyncVersion, -}); +| Snapshot Type | Size (bytes) | +| ---------------- | ------------ | +| Old snapshot | 27,347,374 | +| New snapshot | 23,433,380 | +| Shallow Snapshot | 4,388,215 | -// Export shallow snapshot at current version -const shallowSnapshot = doc.export({ - mode: "shallow-snapshot", - frontiers: doc.frontiers(), -}); -``` +- The New snapshot data is smaller because it performs additional simple + compression on each Block during encoding internally -
+| task | Old Snapshot | New Snapshot | Shallow Snapshot | +| -------------------------- | ---------------- | ---------------------- | ---------------------- | +| Parse | 538ms +- 3.23ms | 17.9ms +- 48.5µs (30x) | 14.4ms +- 114µs (37x) | +| Parse+ToString | 568ms +- 1.78ms | 20.2ms +- 57.2µs (28x) | 16.8ms +- 81.4µs (34x) | +| Parse+ToString+Edit | 561ms +- 940µs | 119ms +- 180µs (4.5x) | 113ms +- 185µs (5x) | +| Parse+ToString+Edit+Export | 1460ms +- 22.9ms | 251ms +- 1.60ms (6x) | 206ms +- 360µs (7x) | - -```typescript no_run -import(data: Uint8Array): ImportStatus -``` - - -Imports updates or snapshots into the document. Returns an `ImportStatus` describing which peer ranges were applied or are pending. See [Sync](/docs/tutorial/sync) and [Import Status](/docs/concepts/import_status) for how Loro handles out-of-order and partial updates. +## Next Steps for Loro -**Parameters:** +### Loro Version Controller -- `data` - Binary data or another LoroDoc to import from + -**⚠️ Important:** LoroDoc will automatically commits pending operations before import. If the doc is in detached mode, the imported operations are recorded into OpLog but not applied to DocState until you call `attach()`, see [Attached vs Detached States](/docs/concepts/attached_detached) adn [OpLog and DocState](/docs/concepts/oplog_docstate). +Importing Loro Git Repo into Loro Version Controller -**Example:** +Loro's performance on a single document is now sufficient to cover the real-time +collaboration and version management needs of most documents. So our next step +will be to explore real-time collaboration and version control across a +collection of documents. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +We believe that CRDTs can create a Git for Everyone and Everything: -const doc = new LoroDoc(); -// Receive updates from another peer (e.g., via network) -const otherDoc = new LoroDoc(); -otherDoc.getText("text").insert(0, "Hello"); -const updates: Uint8Array = otherDoc.export({ mode: "update" }); +- It's for Everyone because by leveraging the power of CRDTs, we can make version + control much easier to reason about and use for the average person. +- It's (nearly) for Everything because Loro provides a rich set of data + synchronization types. We're no longer limited to synchronizing plain text + data, but can solve semantic automatic merging of JSON-like schema, which can meet + most needs of creative tools and collaborative tools. -// Import binary updates -const status = doc.import(updates); -console.log(status.success); -``` +We've created a demo of the Loro version controller, which is based on our +sub-document implementation (implemented in the application layer) with Version information. +It can import the entire React repository (about 20,000 commits, thousands of +collaborators), and it supports real-time collaboration on such +repositories. However, how to better manage versions and seamlessly integrate with Git still needs to be explored. - + - -```typescript no_run -importBatch(data: Uint8Array[]): ImportStatus -``` - - -Efficiently imports multiple updates in a single batch operation. See [Batch Import](/docs/advanced/import_batch) for performance considerations and usage. +Loro CRDTs still have significant room for optimization in these scenarios. +At the time of Loro 1.0, the Loro CRDTs library didn't involve network or disk +I/O, which enhanced its ease of use but also constrained its capabilities and +potential optimizations. +For example, while we've implemented block-level storage, documents are still +imported and exported as whole units. Adding I/O capabilities to selectively +load/save blocks would enable significant performance optimizations. -**Parameters:** +## Conclusion -- `data` - Array of binary updates +Loro 1.0 features great performance improvements, rich CRDT types, and advanced +version control features. Our optimized document format has yielded promising +results on the import speed and the memory usage. -**Example:** +Now that Loro CRDTs are stable, we are able to develop a better ecosystem. +We're excited to see it being applied in various scenarios. +If you're interested in using Loro, welcome to join our +[Discord community](https://discord.gg/tUsBSVfqzf) for discussions. -```typescript no_run -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -declare const update1: Uint8Array; -declare const update2: Uint8Array; -declare const update3: Uint8Array; +For current updates, follow the [changelog](/changelog) or join the +[Discord community](https://discord.gg/tUsBSVfqzf). -// Usage example: -const updates = [update1, update2, update3]; -const status = doc.importBatch(updates); -``` - +# FILE: pages/blog/mergeable-containers.mdx - -```typescript no_run -exportJsonUpdates(start?: VersionVector, end?: VersionVector, withPeerCompression?: boolean): JsonSchema -``` - - -Exports updates in JSON format for debugging or alternative storage. See [Export Mode](/docs/tutorial/encoding) for format details and trade-offs. +--- +title: "Mergeable Containers: Fixing Concurrent Child Creation" +date: 2026/06/09 +description: "Mergeable Containers let Loro peers concurrently create the same child container under a Map key and still merge into one shared child, by deriving identity from the logical parent/key/type instead of the creation OpID." +image: "/images/blog-mergeable-containers.png" +--- -**Parameters:** +# Mergeable Containers: Fixing Concurrent Child Creation -- `start` - Starting version (optional) -- `end` - Ending version (optional) +import Authors, { Author } from "../../components/authors"; -**Returns:** JSON representation of updates + + + -**Example:** +![Mergeable Containers overview](/images/blog-mergeable-containers.png) -```ts threeslash -import { LoroDoc } from "loro-crdt"; +Two users are offline. Both add content to the same empty note. They come back online, sync finishes, and one user's edits seem to disappear. -const doc = new LoroDoc(); -const jsonUpdates = doc.exportJsonUpdates(); -console.log(JSON.stringify(jsonUpdates, null, 2)); -``` +There is no error, and the data is not actually gone from history. But `note.get("body")` can only return one Text container. The other container was created concurrently and still exists in history, but it is no longer visible in the current document state. From the application's point of view, this looks like data loss. - +This is a classic problem in JSON-like CRDTs. Users have run into versions of it in the Loro, Yjs, and Automerge communities. The [Appendix](#appendix-runnable-reproductions) has short scripts that reproduce it in all three. - -```typescript no_run -importJsonUpdates(json: string | JsonSchema): void -``` - - -Imports updates from JSON format. Useful for debugging, migration, or custom storage layers; see [Export Mode](/docs/tutorial/encoding). +Loro now solves this with Mergeable Containers. They make a child container's identity come from its logical position in the `Map`, not from the ID of the operation that happened to create it. -**Parameters:** +Special thanks to [Alexis Williams](https://github.com/typedrat) from [Synapdeck](https://synapdeck.com/) for the substantial implementation work and design discussion behind this feature. -- `json` - JSON string or object containing updates +From the user's point of view, the API change is small. Instead of creating an on-demand child container like this: -**Example:** +```ts no_run +// Peer A +doc.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "A"); -```ts threeslash -import { LoroDoc } from "loro-crdt"; +// Peer B, offline +doc.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "B"); -const doc = new LoroDoc(); -const otherDoc = new LoroDoc(); -otherDoc.getText("text").insert(0, "Hello"); -const jsonStr = otherDoc.exportJsonUpdates(); -doc.importJsonUpdates(jsonStr); +// after sync: only one List is visible at "2026-06-08" ``` - - -## Versioning +you can use a mergeable child: -Work with the history DAG using frontiers (heads) and version vectors. Switch, branch, and merge versions safely without manual conflict resolution. See [Versioning Deep Dive](/docs/advanced/version_deep_dive) and [Attached vs Detached States](/docs/concepts/attached_detached). +```ts no_run +// Peer A +doc.getMap("days").ensureMergeableList("2026-06-08").insert(0, "A"); -### Version Control Methods +// Peer B, offline +doc.getMap("days").ensureMergeableList("2026-06-08").insert(0, "B"); - -```typescript no_run -checkout(frontiers: Frontiers): void +// after sync: both peers edit the same List ``` - - -Checks out the document to a specific version, making it read-only at that point in history. This is the core of time travel; see [Time Travel](/docs/tutorial/time_travel) and [Version](/docs/tutorial/version). - -**Parameters:** -- `frontiers` - Array of OpIds representing the target version +As a rule of thumb, use `ensureMergeable*` when a child container should be identified by its logical position: -**Example:** +```ts no_run +map.ensureMergeableText(key); +map.ensureMergeableMap(key); +map.ensureMergeableList(key); +map.ensureMergeableMovableList(key); +map.ensureMergeableTree(key); +map.ensureMergeableCounter(key); +``` -```ts threeslash -import { LoroDoc } from "loro-crdt"; +Use them for fields that should behave like one shared child container for everyone: one shared Text, one shared List, one shared Map, and so on. It should not matter which peer creates that child first. The rest of this post walks through why the problem exists and how the new encoding works. -const doc = new LoroDoc(); -const frontiers = doc.frontiers(); -// Make some changes... -doc.checkout(frontiers); // Go back to previous version -``` +## Why This Happens - +CRDTs are usually good at cases like "multiple users editing the same text at the same time" or "multiple users inserting into the same list concurrently." This issue happens one layer earlier: before the peers can edit the same List, Text, or Map, they first need to agree on which child container that key refers to. - -**⚠️ Important:** In Loro 1.0, `version()`/`frontiers()` include pending (uncommitted) local operations. +Before Mergeable Containers, the recommended workaround was to initialize all required child containers as soon as the parent `LoroMap` was created. For example, if every note always needs a `body` text, creating that `body` together with the note avoids the first-creation race. -**📝 Note:** After `checkout()`, the document enters "detached" mode and becomes read-only by default. Use `attach()` or `checkoutToLatest()` to return to editing mode. See [Version Deep Dive](/docs/advanced/version_deep_dive) and [Attached vs Detached States](/docs/concepts/attached_detached). +That workaround is useful, but it has limits. Some applications cannot know every child container ahead of time. A schema migration may add a new child container to existing documents. A calendar-like document may create child containers by date. A dynamic index may create one child container per user-defined key. In these cases, on-demand creation is natural, and concurrent first creation is hard to avoid. - +The root cause is the way regular child Container IDs are represented. A normal child Container ID includes the `OpID` that created it. Concurrent first creation therefore creates different Container IDs, and the Map conflict-resolution rule decides which one is visible. - -```typescript no_run -checkoutToLatest(): void -``` - - -Returns the document to the latest version after a checkout. Related concepts: [Frontiers](/docs/concepts/frontiers) and [Version Vector](/docs/concepts/version_vector). +The issue is not that List insertion cannot merge. Once both peers are editing the same List, List edits merge normally. The issue is that the two peers created two different Lists at the same Map key. -**Example:** +## Why Root Containers Are Naturally Mergeable -```ts threeslash -import { LoroDoc } from "loro-crdt"; +In Loro and Yjs, top-level Root Containers are usually accessed by name: -const doc = new LoroDoc(); -doc.checkoutToLatest(); +```ts no_run +doc.getMap("state"); +doc.getText("content"); ``` - +Here, `"state"` or `"content"` is already a stable identity. It does not depend on which peer created it or which operation created it. As long as multiple peers access the same root name, they naturally refer to the same logical Container. - -```typescript no_run -attach(): void -``` - - -Attaches the document to track latest changes after being detached. See [Attached vs Detached States](/docs/concepts/attached_detached) for how Loro separates current state from history. +> Automerge has a different object identity model, so this root-container comparison is specifically about Loro and Yjs. The broader issue is still similar: when composite values are created concurrently at the same key, the system needs a rule for which object identity becomes visible. -**Example:** +Regular child Containers are different. Their identity is tied to the operation that created them, so two concurrent "first creations" become two different objects. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +Mergeable Containers bring the useful part of Root Container identity to selected child Containers: the child identity comes from a deterministic name, not from the creation operation. -const doc = new LoroDoc(); -doc.attach(); -``` +## API: Explicitly Ensuring a Mergeable Child - +This feature does not change the existing `setContainer` / `insertContainer` behavior. It adds explicit `ensureMergeable*` APIs for the mergeable case. In Rust, the same methods use snake case: - -```typescript no_run -detach(): void +```rust +map.ensure_mergeable_text("body")?; +map.ensure_mergeable_map("profile")?; ``` - - -Detaches the document from tracking latest changes, freezing it at current version. See [Attached vs Detached States](/docs/concepts/attached_detached). - -**Example:** -```ts threeslash -import { LoroDoc } from "loro-crdt"; +The word `ensure` is intentional. It returns the child and, if needed, writes the marker that makes it visible at that key. Calling the same method again for the same type is idempotent. -const doc = new LoroDoc(); -doc.detach(); -``` +If the key already holds a regular scalar value or a regular child Container, the API returns an error instead of silently overwriting it. - +One subtle case is type changes. If one peer asks for a mergeable Text at `"field"` while another peer asks for a mergeable Map at the same key, Loro still needs one visible value at that key. The Map's normal conflict rule decides which type is visible. The non-visible mergeable child's state is still preserved under its deterministic ID, so switching back to that type can resurface it later. - -```typescript no_run -fork(): LoroDoc -``` - - -Creates a new document that is a fork of the current one with a new peer ID. Forking is useful for branching workflows; see [Version](/docs/tutorial/version). +## Core Design: Deterministic CID + Map Slot Marker -**Returns:** A new LoroDoc instance +Mergeable Containers have two separate layers of representation: -**Example:** +1. The child Container ID derived from the parent Container ID, key, and type. This decides whether peers address the same CRDT object. +2. The parent Map slot. This decides whether that object is currently visible at a key, and which mergeable child type is active there. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +Keeping these two layers separate makes the behavior easier to reason about. -const doc = new LoroDoc(); -const forkedDoc = doc.fork(); -``` +## 1. CID: A Synthetic Root Container ID - +A Mergeable Container uses a synthetic `ContainerID::Root` under an internal namespace. User-created root names cannot use this prefix, so ordinary roots cannot collide with mergeable CIDs: - -```typescript no_run -forkAt(frontiers: Frontiers): LoroDoc +```text +🤝: ``` - - -Creates a fork at a specific version in history. Learn more about versions, DAG history, and heads in [Version Deep Dive](/docs/advanced/version_deep_dive). - -**Parameters:** -- `frontiers` - The version to fork from +The payload is derived from the parent Map and the key. The Container type stays in `ContainerID::Root.container_type`, just like ordinary Root Containers. This lets all peers derive the same child ID without using the creation `OpID`. -**Returns:** A new LoroDoc instance +The current encoding keeps nested mergeable Map IDs linear in the logical path length. This change was made before release to avoid recursive CID growth for deeply nested mergeable maps. -**Example:** +
+More details: the flattened CID encoding -```ts threeslash -import { LoroDoc } from "loro-crdt"; +After [PR #1002](https://github.com/loro-dev/loro/pull/1002), the payload no longer recursively embeds the full parent CID. Instead, it uses a flattened path: -const doc = new LoroDoc(); -const frontiers = doc.frontiers(); -const forkedDoc = doc.forkAt(frontiers); +```text +payload = base-parent ">" key-1 ">" key-2 ... ``` - +The `base-parent` is the nearest non-mergeable Map ancestor: -## Events & Transactions +```text +$ +@: +``` -React to changes and group local operations into transactions. Starting in v1.8, events are delivered synchronously; older releases require awaiting a microtask. See [Event Handling](/docs/tutorial/event) and [Transaction Model](/docs/concepts/transaction_model). +For example: -### Subscription Methods +```text +Root map "state", key "note-1", child map: +🤝:$state>note-1 type = Map - -```typescript no_run -subscribe(listener: (event: LoroEventBatch) => void): () => void +Nested key "body" under that mergeable map, child text: +🤝:$state>note-1>body type = Text ``` - - -Subscribes to all document changes. See [Event Handling](/docs/tutorial/event) for the event model and best practices. -**Parameters:** +Parsing the second CID gives: -- `listener` - Callback function that receives change events +```text +parent = Root("🤝:$state>note-1", Map) +key = "body" +type = Text +``` -**Returns:** Unsubscribe function +
-**Event Structure:** +## 2. Map Slot: A Binary Marker Controls Visibility -```typescript no_run -interface LoroEventBatch { - by: "local" | "import" | "checkout"; - origin?: string; - currentTarget?: ContainerID; - events: LoroEvent[]; - from: Frontiers; - to: Frontiers; -} -``` +A deterministic CID alone is not enough because Loro has multiple Container types. If one peer calls `ensureMergeableText("field")` while another peer concurrently calls `ensureMergeableMap("field")`, both deterministic child CIDs can exist. The parent Map still needs to decide which type is currently visible at `"field"`. That decision needs to be deterministic and reversible: switching the visible type should not destroy the state of the other mergeable child. -**Example:** +So Loro stores a small activation marker in the parent Map slot. Its meaning is: -```ts threeslash -import { LoroDoc } from "loro-crdt"; +```text +At this key of this parent Map, activate a mergeable child of this type. +``` -const doc = new LoroDoc(); -const unsubscribe = doc.subscribe((event) => { - console.log("Change type:", event.by); - event.events.forEach((e) => { - console.log("Container changed:", e.target); - console.log("Diff:", e.diff); - }); -}); +When a new Loro client reads the slot, it uses the current `parent id + key + kind` to derive the deterministic mergeable CID, then presents it through the public API as a normal Container: -// Later: unsubscribe(); +```ts no_run +const body = map.get("body"); +// body is a LoroText, not the internal binary marker ``` -**⚠️ Important:** Events are emitted synchronously as of v1.8. If you are pinned to `<=1.7.x`, await a microtask before reading the batch. +When the key is deleted, only the marker is removed. The mergeable child state is not immediately destroyed, because the parent slot controls visibility rather than the child's stored history. Calling this again: ```ts no_run -doc.commit(); -// Events have already been delivered in v1.8+ -// await Promise.resolve(); // Only needed on <=1.7.x +map.ensureMergeableText("body"); ``` -**📝 Note:** Multiple operations before a commit are batched into a single event. See [Event Handling](/docs/tutorial/event). +resurfaces the same deterministic Text Container. -
+The marker is also bound to its exact parent, key, and type. That keeps it from accidentally activating a mergeable child if the same binary value is copied somewhere else. - -```typescript no_run -subscribeLocalUpdates(f: (bytes: Uint8Array) => void): () => void +
+More details: the binary marker format + +The marker is a compact binary value: + +```text +MAGIC[4] + KIND[1] + DIGEST[3] ``` - - -Subscribes only to local changes, useful for syncing with remote peers. This is typically wired to your transport layer; see [Sync](/docs/tutorial/sync). -**Parameters:** +`DIGEST` is the low 24 bits of CRC32 over `(parent_id, key, kind)`. So the marker is not a magic value that can be copied anywhere. -- `f` - Callback that receives binary updates +If a user copies the marker binary from one key to another key, or from one parent Map to another, new Loro clients will not recognize it as a valid mergeable child marker. It remains an ordinary binary value. -**Returns:** Unsubscribe function +This matters because `LoroValue::Binary` is still valid user data. Without binding the marker to parent, key, and type, copying a binary value could accidentally activate a mergeable Container somewhere else. -**Example:** +### Why Not Use a Reserved Keyword? -```ts no_run threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -declare const websocket: { send: (data: Uint8Array) => void }; +One possible approach would be to store a special string or JSON object: -// Usage example: -const unsubscribe = doc.subscribeLocalUpdates((updates) => { - // Send updates to remote peers - websocket.send(updates); -}); +```json +{ "__loro_mergeable_container__": "Text" } ``` - +or: - -```typescript no_run -subscribeFirstCommitFromPeer(f: (e: { peer: PeerID }) => void): () => void +```text +"__loro_mergeable_text__" ``` - - -Subscribes to the first commit from each peer, useful for tracking peer metadata. -**Parameters:** +But that would take over part of the user data space. `LoroMap` is a general-purpose Map, and users may legitimately store such strings or objects. Reserved keywords would make ordinary user values suddenly have special meaning. -- `f` - Callback that receives peer information +They are also hard to bind safely to parent, key, and type. If a string marker is copied somewhere else, it still looks like a marker. Avoiding accidental activation would require extra validation fields, which would make the format longer and more fragile. -**Returns:** Unsubscribe function +A binary marker fits this role better: it is low-level structural metadata, not business data. Older clients that do not understand Mergeable Containers see it as an ordinary binary value, rather than misinterpreting it as a child Container reference. -**Example:** +### Why Not Store the Full ContainerID in the Slot? -```ts threeslash -import { LoroDoc } from "loro-crdt"; +Another possible design would be to store the full deterministic ContainerID directly in the parent Map slot. -const doc = new LoroDoc(); -doc.subscribeFirstCommitFromPeer(({ peer }) => { - // Store peer metadata - doc.getMap("peers").set(peer, { - joinedAt: Date.now(), - name: `User ${peer}`, - }); -}); -``` +The problem is that older clients may interpret it as a regular child Container edge. That would give them the wrong view of the document structure. - +Mergeable Containers need more than "a pointer to a Container." The design also needs to preserve these rules: -### Transaction Methods +- The same `(parent, key, type)` deterministically produces the same CID. +- Deleting the key hides the child, but does not delete the child state. +- Conflicts between different mergeable child types still use the Map's normal LWW rule. +- The marker must only activate at the correct parent/key/type. +- Older clients must not mistake it for a normal child Container edge. - -```typescript no_run -commit(options?: { origin?: string, message?: string, timestamp?: number }): void -``` - - -Commits pending changes as a single transaction. A transaction groups operations into a Change; see [Operations and Changes](/docs/concepts/operations_changes). +The marker is better understood as an activation marker. New clients derive the actual child CID from the surrounding context. -**⚠️ Critical Distinction:** Loro transactions are NOT ACID database transactions: +
-- No rollback capability -- No isolation guarantees -- Purpose: Bundle local operations for event batching and history grouping -- Many operations (import/export/checkout) trigger implicit commits +## What This Solves for Users -See [Transaction Model](/docs/concepts/transaction_model). +Mergeable Containers are especially useful when eager initialization is not practical. -**Parameters:** +For example, suppose an application stores one child List per date: -- `options` - Optional commit configuration - - `message` - Commit message (persisted in the document like a git commit message, visible to all peers after sync) - - `origin` - Origin identifier (local only - used for marking local events, remote peers won't see this) - - `timestamp` - Unix timestamp in seconds (see [Storing Timestamps](/docs/advanced/timestamp)) +```ts no_run +const days = doc.getMap("days"); +const entries = days.ensureMergeableList("2026-06-08"); +entries.insert(0, "meeting notes"); +``` -**Important distinction:** +Or suppose a schema migration lazily adds a new child Map to existing records: -- `message` is persisted in the document's history and will be synchronized to all peers, similar to git commit messages -- `origin` is only used locally for filtering events (e.g., excluding certain origins from undo) and is NOT synchronized to remote peers +```ts no_run +const record = doc.getMap("records").ensureMergeableMap(recordId); +const metadata = record.ensureMergeableMap("metadata_v2"); +metadata.set("migrated", true); +``` -**Example:** +In both cases, the child container identity no longer depends on which peer created it first. It depends on the logical position in the document structure. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +This makes Mergeable Containers especially useful for: -const doc = new LoroDoc(); -doc.commit({ - message: "Updated document title", // Persisted & synced to all peers - origin: "user-action", // Local only, for event filtering - timestamp: Math.floor(Date.now() / 1000), -}); -``` +- date-keyed child lists or maps +- schema migrations that add new child containers lazily +- dynamic per-user or per-entity subdocuments +- revision counters +- settings maps whose keys are discovered over time - +## Cost and Compatibility -### Query Methods +Mergeable Containers have some metadata cost. Their CIDs carry logical path information, so deeper paths and longer keys produce larger IDs. [PR #1002](https://github.com/loro-dev/loro/pull/1002) changed the encoding so nested mergeable Map IDs grow linearly instead of recursively, but very deep mergeable Map chains are still better to avoid. - -```typescript no_run -toJSON(): Value -``` - - -Converts the entire document to a JSON-compatible value. If you prefer a structure where sub-containers are referenced by ID (for privacy or streaming), use getShallowValue(); see [Shallow Snapshots](/docs/concepts/shallow_snapshots). +The compatibility story is intentionally conservative: -**Returns:** JSON representation of the document +- Existing `setContainer` / `insertContainer` behavior is unchanged. +- Existing documents can be read normally by new versions. +- Mergeable Containers are introduced through new APIs, without changing existing method signatures. +- Older clients that do not understand this feature see the parent slot marker as an ordinary binary value, not as a fake child Container edge. They can preserve and sync the data, but they will not display the mergeable child with the new semantics. +- User-created root names that start with the internal `🤝:` prefix are rejected by Loro's root-name validator, so they cannot collide with mergeable CIDs. -**Example:** +## Summary -```ts threeslash -import { LoroDoc } from "loro-crdt"; +Mergeable Containers are for child Containers whose identity should come from their logical position, not from whichever peer created them first. -const doc = new LoroDoc(); -const json = doc.toJSON(); -console.log(JSON.stringify(json, null, 2)); -``` +Use `ensureMergeable*` when: - +- the key is dynamic or lazily created +- different peers may initialize the same child while offline +- the child should behave like one shared Text, List, Map, Tree, or Counter +- deleting the key should hide the child without treating its internal history as immediately destroyed - -```typescript no_run -getShallowValue(): Record -``` - - -Gets a shallow representation where sub-containers are represented by their IDs. This is helpful when you want to share structure without history; see [Shallow Snapshots](/docs/concepts/shallow_snapshots). +Keep using `setContainer` / `insertContainer` when: -**Returns:** Shallow JSON value +- each creation should produce a distinct child object +- the parent slot should point to exactly the Container created by that operation +- you are modeling replacement rather than shared initialization -**Example:** +The short version: if two peers creating the same child at the same Map key should mean "we both found the same child," use a Mergeable Container. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +References: -const doc = new LoroDoc(); -const shallow = doc.getShallowValue(); -// Sub-containers appear as: "cid:..." -``` +- Loro background: [issue #759](https://github.com/loro-dev/loro/issues/759) +- Loro implementation: [PR #991](https://github.com/loro-dev/loro/pull/991), [PR #1002](https://github.com/loro-dev/loro/pull/1002) +- Related Yjs discussions: [complex diagram page](https://discuss.yjs.dev/t/how-would-you-model-a-complex-diagram-page/2114), [losing data](https://discuss.yjs.dev/t/why-am-i-losing-data/2734), [nested `Y.Map`](https://discuss.yjs.dev/t/create-y-map-is-empty/1701) +- Related Automerge discussions: [#528: failing merge for text values](https://github.com/automerge/automerge/issues/528) is the closest match; [#526: conflict resolution for replaced arrays and objects](https://github.com/automerge/automerge/issues/526) is useful background on object identity and conflict handling; the historical [automerge-classic #4](https://github.com/automerge/automerge-classic/issues/4) also covers concurrently created objects under the same key. - +## Appendix: Runnable Reproductions - -```typescript no_run -getDeepValueWithID(): any +The snippets below are self-contained and run directly on Node (tested on Node 24; any Node 18+ with ESM works). Install the three libraries once: + +```bash +npm install loro-crdt@^1.13 yjs @automerge/automerge ``` - - -Gets the deep value of the document with container IDs preserved. This is useful when you need to traverse the document structure while maintaining references to container IDs. -**Returns:** Document value with container IDs +Save each block as a `.mjs` file and run it with `node file.mjs`. They all model the same scenario from this post: two offline peers concurrently create a child container under the same `Map` key, then sync. The Loro example also shows the `ensureMergeable*` fix. -**Example:** +### Loro — the bug, and the fix -```ts threeslash -import { LoroDoc } from "loro-crdt"; +```js no_run +// loro.mjs — node loro.mjs +// In plain Node import from "loro-crdt/nodejs"; the bare "loro-crdt" entry +// targets a bundler. With Vite/webpack, import from "loro-crdt" instead. +import { LoroDoc, LoroList } from "loro-crdt/nodejs"; -const doc = new LoroDoc(); -const deepValue = doc.getDeepValueWithID(); -``` +function sync(a, b) { + const va = a.export({ mode: "update" }); + const vb = b.export({ mode: "update" }); + a.import(vb); + b.import(va); +} - +// 1. The bug: concurrent setContainer at the same key +{ + const a = new LoroDoc(); + const b = new LoroDoc(); + a.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "A"); + b.getMap("days").setContainer("2026-06-08", new LoroList()).insert(0, "B"); + sync(a, b); + console.log( + "setContainer ->", + JSON.stringify(a.getMap("days").get("2026-06-08").toArray()), + ); + // -> ["A"] or ["B"], never both: only one peer's List survives. + // (which one wins depends on the randomly-assigned peer IDs) +} - -```typescript no_run -version(): VersionVector +// 2. The fix: concurrent ensureMergeableList at the same key +{ + const a = new LoroDoc(); + const b = new LoroDoc(); + a.getMap("days").ensureMergeableList("2026-06-08").insert(0, "A"); + b.getMap("days").ensureMergeableList("2026-06-08").insert(0, "B"); + sync(a, b); + console.log( + "ensureMergeable ->", + JSON.stringify(a.getMap("days").get("2026-06-08").toArray()), + ); + // -> both entries, e.g. ["A","B"] (order may vary): both peers share one List. +} ``` - - -Gets the current version vector of the document. Version vectors track how much data from each peer you’ve seen; see [Version Vector](/docs/concepts/version_vector). -**Returns:** Map from PeerID to counter +### Yjs — the same problem -**Example:** +```js no_run +// yjs.mjs — node yjs.mjs +import * as Y from "yjs"; -```ts threeslash -import { LoroDoc } from "loro-crdt"; +const a = new Y.Doc(); +const b = new Y.Doc(); -const doc = new LoroDoc(); -const vv = doc.version(); -console.log(vv.toJSON()); -``` +// Peer A and Peer B each create a Y.Array at the same key, offline. +{ + const l = new Y.Array(); + a.getMap("days").set("2026-06-08", l); + l.insert(0, ["A"]); +} +{ + const l = new Y.Array(); + b.getMap("days").set("2026-06-08", l); + l.insert(0, ["B"]); +} - +// Sync both ways. +Y.applyUpdate(a, Y.encodeStateAsUpdate(b)); +Y.applyUpdate(b, Y.encodeStateAsUpdate(a)); - -```typescript no_run -frontiers(): Frontiers +console.log( + "yjs ->", + JSON.stringify(a.getMap("days").get("2026-06-08").toArray()), +); +// -> ["A"] or ["B"], never both: one peer's child Y.Array wins, the other is dropped. ``` - - -Gets the current frontiers (heads) of the document. Frontiers are a compact representation of a version; see [Frontiers](/docs/concepts/frontiers) for when to use them instead of version vectors. -**📝 Note:** Frontiers are a compact version representation. +### Automerge — the same problem -**⚠️ Limitation:** When you have a Frontier pointing to operations you don't know about, you cannot determine the complete set of operation IDs included in that version. Version Vectors don't have this limitation but are more verbose. See [Frontiers](/docs/concepts/frontiers) for trade-offs. +```js no_run +// automerge.mjs — node automerge.mjs +import * as A from "@automerge/automerge"; -**Returns:** Array of OpIds +let base = A.from({ days: {} }); +let a = A.clone(base); +let b = A.clone(base); -**Example:** +// Peer A and Peer B each create a list at the same key, offline. +a = A.change(a, (d) => { + d.days["2026-06-08"] = ["A"]; +}); +b = A.change(b, (d) => { + d.days["2026-06-08"] = ["B"]; +}); -```ts threeslash -import { LoroDoc } from "loro-crdt"; +let merged = A.merge(A.clone(a), b); +console.log("automerge visible ->", JSON.stringify(merged.days["2026-06-08"])); +// -> ["A"] or ["B"], never both: one list wins. +console.log( + "automerge conflicts ->", + JSON.stringify(A.getConflicts(merged.days, "2026-06-08")), +); +// -> both lists keyed by op id: the losing list is retained but hidden, +// reachable only via getConflicts(). -const doc = new LoroDoc(); -const frontiers = doc.frontiers(); -// Can be used for checkouts or shallow snapshots +// Control: when the child is created ONCE up front, concurrent edits merge. +let shared = A.from({ days: { "2026-06-08": [] } }); +let c = A.clone(shared), + d = A.clone(shared); +c = A.change(c, (x) => { + x.days["2026-06-08"].push("A"); +}); +d = A.change(d, (x) => { + x.days["2026-06-08"].push("B"); +}); +let ok = A.merge(A.clone(c), d); +console.log("automerge pre-created ->", JSON.stringify(ok.days["2026-06-08"])); +// -> ["A","B"] (order may vary): both survive — this is the eager-init workaround. ``` - +Note one difference worth calling out: in Automerge the losing child is retained and can be recovered through `getConflicts()`, while Yjs overwrites the map key and drops the losing child outright. Either way, from the application's point of view it looks like data loss — which is exactly what Mergeable Containers avoid. - -```typescript no_run -diff(from: Frontiers, to: Frontiers, for_json?: boolean): [ContainerID, Diff | JsonDiff][] -``` - - -Calculates differences between two versions. Understanding how Loro computes diffs benefits from the history DAG model; see [Version Deep Dive](/docs/advanced/version_deep_dive). -**Parameters:** +# FILE: pages/blog/crdt-richtext.mdx -- `from` - Starting frontiers -- `to` - Ending frontiers -- `for_json` - If true, returns JsonDiff format (default: true) +--- +title: crdt-richtext - Rust implementation of Peritext and Fugue +date: 2023/04/20 +keywords: crdt, richtext, peritext, fugue, loro +description: Presenting the early Rust crate that combined Peritext and Fugue's power with impressive performance, tailored specifically for rich text. This work later informed Loro's rich text CRDT. +tag: richtext +# ogImage: /images/blog/joining-vercel/x-card.png +--- -**Returns:** Array of container IDs and their diffs +# [crdt-richtext](https://github.com/loro-dev/crdt-richtext): Rust implementation of Peritext and Fugue -**Example:** +import Authors, { Author } from "../../components/authors"; -```ts threeslash -import { LoroDoc } from "loro-crdt"; + + + -const doc = new LoroDoc(); -const fromFrontiers = doc.frontiers(); -// Make changes... -const toFrontiers = doc.frontiers(); +Presenting an early Rust crate that combines [Peritext](https://inkandswitch.com/peritext) and [Fugue](https://arxiv.org/abs/2305.00583)'s power with impressive performance, tailored specifically for rich text. This work later informed **[Loro](https://www.loro.dev/)**'s rich text CRDT. -const diffs = doc.diff(fromFrontiers, toFrontiers); -diffs.forEach(([containerId, diff]) => { - console.log(`Container ${containerId} changed:`, diff); -}); -``` +# What’s Peritext - +[Peritext: A CRDT for Rich-Text Collaboration](https://inkandswitch.com/peritext) -### Pre-Commit Hook +Peritext is a novel rich-text CRDT (Conflict-free Replicated Data Type) algorithm. It is capable of merging concurrent edits in rich text format while [preserving users' intent as much as possible](https://www.inkandswitch.com/peritext/#preserving-the-authors-intent). Its primary focus is on merging the formats and annotations of rich text content, such as bold, italic, and comments. - -```typescript no_run -subscribePreCommit(f: (e: { changeMeta: Change, origin: string, modifier: ChangeModifier }) => void): () => void -``` - - -Subscribe to the pre-commit event. You can modify the message and timestamp of the next change. This hook runs right before a Change is recorded; see [Transaction Model](/docs/concepts/transaction_model) and [Operations and Changes](/docs/concepts/operations_changes). +> 💡 The specific definition of user intent in the context of concurrent rich text editing can't be clearly explained in a few words. it's best understood through specific examples. -Pitfall: `commit()` can be triggered implicitly by `import`, `export`, and `checkout`. Use this hook to attach metadata even for those implicit commits. +Peritext is designed to solve a couple of significant challenges: -```typescript no_run -import { LoroDoc } from "loro-crdt"; +Firstly, it addresses the anticipated problems arising from conflicting style edits. For instance, consider a text example, "The quick fox jumped." If User A highlights "The quick" in bold and User B highlights "quick fox jumped," the ideal merge should result in the entire sentence, "The quick fox jumped," being bold. However, existing algorithms might not meet this expectation, resulting in either "The quick fox" or "The" and "jumped" being bold instead. -const doc = new LoroDoc(); -const unsubscribe = doc.subscribePreCommit(({ modifier }) => { - modifier - .setMessage("Tagged by pre-commit") - .setTimestamp(Math.floor(Date.now() / 1000)); -}); -doc.getText("text").insert(0, "Hello"); -doc.commit(); -unsubscribe(); -``` +| Original Text | The quick fox jumped | +| -------------------------------------------- | ---------------------------- | +| Concurrent Edit from A | **The quick** fox jumped | +| Concurrent Edit from B | The **quick fox jumped** | +| Expected Merged Result | **The quick fox jumped** | +| Bad case from merging Markdown text directly | **The** quick **fox jumped** | +| Bad case from Yjs | **The quick** fox jumped | - +Additionally, Peritext manages conflicts between style and text edits. In the same example, if User A highlights "The quick" in bold, but User B changes the text to "The fast fox jumped," the ideal merge should result in "The fast" being bold. -### Cursor Utilities +| Original Text | The quick fox jumped | +| ---------------------- | ------------------------ | +| Concurrent Edit from A | **The quick** fox jumped | +| Concurrent Edit from B | The fast fox jumped | +| Expected Merged Result | **The fast** fox jumped | - -```typescript no_run -getCursorPos(cursor: Cursor): { update?: Cursor, offset: number, side: Side } -``` - - -Resolve a stable `Cursor` to an absolute position. Cursors remain valid across concurrent edits; see [Cursor and Stable Positions](/docs/concepts/cursor_stable_positions) and [Cursor tutorial](/docs/tutorial/cursor). The side controls affinity when the cursor sits at an insertion boundary. +What’s more, Peritext takes into account different expectations for expanding styles. For example, if you type after a bold text, you would typically want the new text to continue being bold. However, if you're typing after a hyperlink or a comment, you likely wouldn't want the new input to become part of the hyperlink or comment. -```typescript no_run -import { LoroDoc } from "loro-crdt"; +# What’s Fugue -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "abc"); +Fugue is a new CRDT text algorithm, presented in _[The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing](https://arxiv.org/abs/2305.00583)_ by [Matthew Weidner](https://arxiv.org/search/cs?searchtype=author&query=Weidner%2C+M) et al., nicely solves **the interleaving problem**. -// Get cursor at position 1 -const c0 = text.getCursor(1); -const pos = doc.getCursorPos(c0!); -console.log(pos.offset); // 1 -``` +## The interleaving problem - +The interleaving problem was proposed in the paper _[Interleaving anomalies in collaborative text editors](https://martin.kleppmann.com/2019/03/25/papoc-interleaving-anomalies.html)_ by Martin Kleppmann et al. -### Pending Operations +An example of interleaving: - -```typescript no_run -getUncommittedOpsAsJson(): JsonSchema | undefined -``` - - -Get pending operations from the current transaction in JSON format. Useful for debugging what will be included in the next Change; see [Transaction Model](/docs/concepts/transaction_model). +- A type "Hello " from left to right/right to left +- B type "Hi " from left to right/right to left +- The expected result: "Hello Hi " or "Hi Hello " +- The interleaving result may look like: "HHeil lo" + - This happens when typing from right to left in RGA. -```typescript no_run -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); +![An example of an interleaving anomaly when using [fractional indexing](https://madebyevan.com/algos/crdt-fractional-indexing/) CRDT on text content. +Source: **Martin Kleppmann, Victor B. F. Gomes, Dominic P. Mulligan, and Alastair R. Beresford. 2019. Interleaving anomalies in collaborative text editors. [https://doi.org/10.1145/3301419.3323972](https://doi.org/10.1145/3301419.3323972)](./images/richtext0.png) -text.insert(0, "Hello"); -const pending = doc.getUncommittedOpsAsJson(); -doc.commit(); -const none = doc.getUncommittedOpsAsJson(); // undefined after commit -``` +An example of an interleaving anomaly when using [fractional indexing](https://madebyevan.com/algos/crdt-fractional-indexing/) CRDT on text content. +Source: \*\*Martin Kleppmann, Victor B. F. Gomes, Dominic P. Mulligan, and Alastair R. Beresford. 2019. Interleaving anomalies in collaborative text editors. [https://doi.org/10.1145/3301419.3323972](https://doi.org/10.1145/3301419.3323972) - +The [Fugue paper](https://arxiv.org/abs/2305.00583) summarizes the current state of the interleaving problems in the table. -### Change Graph & History +![Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. *ArXiv*. /abs/2305.00583](./images/richtext1.png) -These APIs traverse the history DAG of changes (ancestors/descendants, spans). If this sounds unfamiliar, start with Loro's [Versioning Deep Dive](/docs/advanced/version_deep_dive) and the [Event Graph Walker](/docs/concepts/event_graph_walker). +Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. _ArXiv_. /abs/2305.00583 - -```typescript no_run -travelChangeAncestors(ids: OpId[], f: (change: Change) => boolean): void -``` - - -Visit ancestors of the given changes in causal order. +The interleaving problem sometimes are unsolvable when there are more than 2 sites. See [Fugue](https://arxiv.org/abs/2305.00583) paper Appendix B, Proof of Theorem 5 for detailed explanation. -```typescript no_run -import { LoroDoc } from "loro-crdt"; +![The case where the interleaving problem is unsolvable +Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. *ArXiv*. /abs/2305.00583](./images/richtext2.png) -const doc = new LoroDoc(); -doc.getText("text").insert(0, "Hello"); -doc.commit(); -const head = doc.frontiers(); -doc.travelChangeAncestors(head, (change) => { - console.log(change.peer, change.counter); - return true; // continue -}); -``` +The case where the interleaving problem is unsolvable +Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. _ArXiv_. /abs/2305.00583 - +However, we can still minimize the chance of interleaving. Fugue introduces the concept of **maximal non-interleaving** and solves it with an elegant algorithm that is easy to optimize. The definition of _maximal non-interleaving_ makes a lot of sense to me and leaves little room for ambiguity. I won't reiterate the definition here. But the basic idea is first to solve forward interleaving by leftOrigin. If there is still ambiguity, then solve the backward interleaving by rightOrigin. (The leftOrigin and rightOrigin refer to the ids of the original neighbors when the character is inserted, just like Yjs) - - ```typescript no_run findIdSpansBetween(from: Frontiers, to: Frontiers): - VersionVectorDiff ``` - -Find the op id spans that lie between two versions. +# CRDT-Richtext - -```typescript no_run -exportJsonInIdSpan(idSpan: { peer: PeerID, counter: number, length: number }): JsonChange[] -``` - - -```typescript no_run -import { LoroDoc } from "loro-crdt"; -const a = new LoroDoc(); -const b = new LoroDoc(); +Based on the algorithms of Peritext and Fugue, we made `crdt-richtext`, a lib written in Rust that provides a wasm interface. It’s available on [crates.io](http://crates.io) and npm now. -// Usage example: -a.getText("text").update("Hello"); -a.commit(); -const snapshot = a.export({ mode: "snapshot" }); -let printed: any; -b.subscribe((e) => { -const spans = b.findIdSpansBetween(e.from, e.to); -const changes = b.exportJsonInIdSpan(spans.forward[0]); -printed = changes; -}); -b.import(snapshot); +## Example -```` - +```tsx +import { RichText } from "crdt-richtext-wasm"; - -```typescript no_run -getChangedContainersIn(id: OpId, len: number): ContainerID[] -```` +const text = new RichText(BigInt(1)); +text.insert(0, "你好,世界!"); +text.insert(2, "呀"); +expect(text.toString()).toBe("你好呀,世界!"); +text.annotate(0, 3, "bold", AnnotateType.BoldLike); +const spans = text.getAnnSpans(); +expect(spans.length).toBe(2); +expect(spans[0].text).toBe("你好呀"); +expect(spans[0].annotations.size).toBe(1); +expect(spans[0].annotations.has("bold")).toBeTruthy(); +expect(spans[1].text.length).toBe(4); - - -Get container IDs modified in the given ID range. +const b = new RichText(BigInt(2)); +b.import(text.export(new Uint8Array())); +expect(b.toString()).toBe("你好呀,世界!"); +``` -```typescript no_run -import { LoroDoc } from "loro-crdt"; +## Data structure -const doc = new LoroDoc(); -doc.getList("list").insert(0, 1); -doc.commit(); -const head = doc.frontiers()[0]; -const containers = doc.getChangedContainersIn(head, 1); -``` +We heavily use B-Trees to optimize our algorithm. We made a library called [generic-btree](https://github.com/loro-dev/generic-btree), which is written in safe Rust code, which provides a flexible foundation for our optimization efforts. - +[https://github.com/loro-dev/generic-btree](https://github.com/loro-dev/generic-btree) -### Revert & Apply Diff +![The cached content inside B-Tree](./images/richtext3.png) - -```typescript no_run -revertTo(frontiers: Frontiers): void -``` - - -Revert the document to a given version by generating inverse operations. +The cached content inside B-Tree -```ts threeslash -import { LoroDoc } from "loro-crdt"; +There are several common tasks we need to address in Text CRDT, including: -const doc = new LoroDoc(); -doc.setPeerId("1"); -const t = doc.getText("text"); -t.update("Hello"); -doc.commit(); -doc.revertTo([{ peer: "1", counter: 1 }]); -``` +- Finding, inserting, or deleting content at a given index: + - We use a BTree to look up and update the content + - The time complexity is O(logN), where N is the length of the content +- Finding content with a given op ID: + - We use a combination of HashMap and BTree + - The time complexity if O(logN), where N is the number of operations +- Compressing content in memory: + - To reduce the amount of memory used by storing every operation in raw format, we compress the content using the RLE tricks from Yjs and DiamondTypes. + - The insight behind this compression is that neighboring inserts and deletions tend to be continuous, so we can merge them and store less metadata. + - Commonly, every leaf node in the diagram contains a dozen of characters +- Converting index between UTF-16 and UTF-8: + - In JS, the default encoding of a string is utf16, but in Rust, the default one is utf8. Although the WASM interface can help us convert the encoding of the string, we still need to convert the _index_ of the operation. + - To solve this, `crdt-richtext` also store the UTF-16 length of the content in B-Tree. So we can query the B-Tree with either the utf8 index or the utf16 index. +- Storing the boundary of style/format/comments: + - We use the same B-Tree to store the boundary, with each subtree corresponding to a span of text or tombstones. For each node in the tree, we store which annotations start before it, start after it, end before it, or end after it. + ```rust + #[derive(Debug, PartialEq, Eq, Default, Clone)] + pub struct ElemAnchorSet { + start_before: FxHashSet, + end_before: FxHashSet, + start_after: FxHashSet, + end_after: FxHashSet, + } + ``` + - This is basically the same optimization as Peritext, except we do it on the tree. - +## Encoding - -```typescript no_run -applyDiff(diff: [ContainerID, Diff | JsonDiff][]): void -``` - - -Apply a batch of diffs to the document. +We use columnar encoding, which was first adopted to CRDTs by Martin Kelppmann [in automerge](https://github.com/automerge/automerge-classic/pull/253). To make it easier in Rust, we created the lib [Serde Columnar: Ergonomic columnar storage encoding crate](https://www.notion.so/Serde-Columnar-Ergonomic-columnar-storage-encoding-crate-7b0c86d6f8d24e4da45a1e2ebd86741c?pvs=21). -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc1 = new LoroDoc(); -const doc2 = new LoroDoc(); +## Heavily tested by libFuzzer -// Usage example: -doc1.getText("text").insert(0, "Hello"); -const diff = doc1.diff([], doc1.frontiers()); -doc2.applyDiff(diff); -``` +Test-Driven Development (TDD) provides an amazing development experience. If possible, I always write unit tests for a standalone module before moving forward. However, for algorithms like CRDTs, it is infeasible to list all possible cases manually but is easy to generate test cases automatically. This is where fuzzing tests come into play. -**Workflow example (squash-like diffs):** For PR-style reviews, combine `diff` and `applyDiff` to send a compact change set between a base version and a new version. Operations that cancel out (insert + delete) are compressed away. +Some fuzzers can track coverage information and generate mutations on the input data to maximize code coverage. LibFuzzer can also identify memory leaks and UAF problems. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +`[cargo-fuzz`](https://www.notion.so/crdt-richtext-Rust-implementation-of-Peritext-and-Fugue-c49ef2a411c0404196170ac8daf066c0?pvs=21) provides a user-friendly API for writing fuzzing tests, and it supports two fuzzers: libFuzzer and AFL. It makes the unstructured libFuzzer feel structured. So we’re able to write fuzzing tests in this way -const baseDoc = new LoroDoc(); -const baseText = baseDoc.getText("text"); -baseText.insert(0, "hello world"); +```rust +use arbitrary::Arbitrary; -// Fork to make isolated edits -const newDoc = baseDoc.fork(); -const newText = newDoc.getText("text"); -newText.insert(0, "abc"); -newText.delete(0, 4); +#[derive(Arbitrary, Clone, Debug, Copy)] +pub enum Action { + Insert { + actor: u8, + pos: u8, + content: u16, + }, + Delete { + actor: u8, + pos: u8, + len: u8, + }, + Annotate { + actor: u8, + pos: u8, + len: u8, + annotation: AnnotationType, + }, + Sync(u8, u8), +} -const diff = newDoc.diff(baseDoc.frontiers(), newDoc.frontiers()); -console.log(diff); -// [ -// [ -// "cid:root-text:Text", -// { type: "text", diff: [ { delete: 1 } ] } -// ] -// ] +pub fn fuzzing(actions: Vec) { + // run tests based on actions + ... +} -baseDoc.applyDiff(diff); -console.log(baseDoc.toJSON()); -// { text: "ello world" } +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|actions: [Action; 100]| { fuzzing(actions.to_vec()) }); ``` - +![We will run millions of Fuzzing Tests after making big changes. The fuzzer can help us extract the most useful thousands of tests to be included into the corpus. The minor changes can be verified by running the corpus.](./images/richtext4.png) -### Detached Editing +We will run millions of Fuzzing Tests after making big changes. The fuzzer can help us extract the most useful thousands of tests to be included into the corpus. The minor changes can be verified by running the corpus. - -```typescript no_run -setDetachedEditing(enable: boolean): void -``` - - -Enables or disables detached editing mode. Detached editing lets you stage edits separate from the latest head; see [Attached vs Detached States](/docs/concepts/attached_detached). +We use fuzzing tests in Loro's CRDTs too. This test suite is like our safety net when we're making big tweaks to the code. It's great at spotting all our little slip-ups. -**Parameters:** +# Performance -- `enable` - Whether to enable detached editing +## Benchmark -**Example:** +- Benchmark setup + ### **B4: Real-world editing dataset** + Replay a real-world editing dataset. This dataset contains the character-by-character editing trace of a large-ish text document, the LaTeX source of this paper: [https://arxiv.org/abs/1608.03960(opens in a new tab)](https://arxiv.org/abs/1608.03960) + Source: [https://github.com/automerge/automerge-perf/tree/master/edit-by-index(opens in a new tab)](https://github.com/automerge/automerge-perf/tree/master/edit-by-index) + - 182,315 single-character insertion operations + - 77,463 single-character deletion operations + - 259,778 operations totally + - 104,852 characters in the final document + We simulate one client replaying all changes and storing each update. We measure the time to replay the changes and the size of all update messages (`updateSize`), the size of the encoded document after the task is performed (`docSize`), the time to encode the document (`encodeTime`), the time to parse the encoded document (`parseTime`), and the memory used to hold the decoded document in memory (`memUsed`). + ### **[B4 x 100] Real-world editing dataset 100 times** + Replay the [B4] dataset one hundred times. The final document has a size of over 10 million characters. As comparison, the book "Game of Thrones: A Song of Ice and Fire" is only 1.6 million characters long (including whitespace). + - 18,231,500 single-character insertion operations + - 7,746,300 single-character deletion operations + - 25,977,800 operations totally + - 10,485,200 characters in the final document -```ts threeslash -import { LoroDoc } from "loro-crdt"; +The benchmark was conducted on a 2020 M1 MacBook Pro 13-inch on 2023-05-11. -const doc = new LoroDoc(); -doc.setDetachedEditing(true); -``` +| N=6000 | crdt-richtext-wasm | loro-wasm | automerge-wasm | tree-fugue | yjs | ywasm | +| ---------------------------------------------------------------- | ---------------------- | ----------------------- | ------------------- | --------------------------- | ---------------------------- | ------------------- | +| [B4] Apply real-world editing dataset (time) | 176 +/- 10 ms | 141 +/- 15 ms | 821 +/- 7 ms | 721 +/- 15 ms | 1,114 +/- 33 ms | 23,419 +/- 102 ms | +| [B4] Apply real-world editing dataset (memUsed) | skipped | skipped | skipped | 2,373,909 +/- 13725 bytes | 3,480,708 +/- 168887 bytes | skipped | +| [B4] Apply real-world editing dataset (encodeTime) | 8 +/- 1 ms | 8 +/- 1 ms | 115 +/- 2 ms | 12 +/- 0 ms | 12 +/- 1 ms | 6 +/- 1 ms | +| [B4] Apply real-world editing dataset (docSize) | 127,639 +/- 0 bytes | 255,603 +/- 8 bytes | 129,093 +/- 0 bytes | 167,873 +/- 0 bytes | 159,929 +/- 0 bytes | 159,929 +/- 0 bytes | +| [B4] Apply real-world editing dataset (parseTime) | 11 +/- 0 ms | 2 +/- 0 ms | 620 +/- 5 ms | 8 +/- 0 ms | 43 +/- 3 ms | 40 +/- 3 ms | +| [B4x100] Apply real-world editing dataset 100 times (time) | 15,324 +/- 3188 ms | 12,436 +/- 444 ms | skipped | 91,902 +/- 863 ms | 112,563 +/- 3861 ms | skipped | +| [B4x100] Apply real-world editing dataset 100 times (memUsed) | skipped | skipped | skipped | 224076566 +/- 2812359 bytes | 318807378 +/- 15737245 bytes | skipped | +| [B4x100] Apply real-world editing dataset 100 times (encodeTime) | 769 +/- 37 ms | 780 +/- 32 ms | skipped | 943 +/- 52 ms | 297 +/- 16 ms | skipped | +| [B4x100] Apply real-world editing dataset 100 times (docSize) | 12,667,753 +/- 0 bytes | 26,634,606 +/- 80 bytes | skipped | 17,844,936 +/- 0 bytes | 15,989,245 +/- 0 bytes | skipped | +| [B4x100] Apply real-world editing dataset 100 times (parseTime) | 1,252 +/- 14 ms | 170 +/- 15 ms | skipped | 368 +/- 13 ms | 1,335 +/- 238 ms | skipped | - +The complete benchmark result and code is available at https://github.com/zxch3n/fugue-bench. - -```typescript no_run -isDetachedEditingEnabled(): boolean -``` - - -Checks if detached editing mode is enabled. +It is worth noting that: -**Returns:** True if detached editing is enabled +- The benchmark for Automerge is based on `automerge-wasm`, which is not the latest version of Automerge 2.0. +- `crdt-richtext` and `fugue` are special-purpose CRDTs that tend to be faster and have a smaller encoding size. +- The encoding of `yjs`, `ywasm`, and `loro-wasm` still contains redundancy that can be compressed significantly. For more details, see [the full report](https://loro.dev/docs/performance/docsize). +- At the time of this 2023 benchmark, `loro-wasm` and `fugue` only supported plain text. -**Example:** +# Discussion -```ts threeslash -import { LoroDoc } from "loro-crdt"; +[CRDT-richtext: Rust implementation of Peritext and Fugue | Hacker News](https://news.ycombinator.com/item?id=35988046) -const doc = new LoroDoc(); -const enabled = doc.isDetachedEditingEnabled(); -``` - +# FILE: pages/blog/loro-richtext.mdx - -```typescript no_run -isDetached(): boolean -``` - - -Checks if the document is currently detached. +--- +title: "Introduction to Loro's Rich Text CRDT" +date: 2024/01/22 +keywords: "loro, crdt, rich text, peritext, event graph walker, eg-walker, fugue" +description: + This article presents the rich text CRDT algorithm implemented in Loro, + complying with Peritext's criteria for seamless rich text collaboration. + Furthermore, it can be built on top of any List CRDT algorithms and turn them + into rich text CRDTs. +image: https://i.ibb.co/rsX5vR6/cover-long.png +--- -**Returns:** True if document is detached +# Introduction to Loro's Rich Text CRDT -**Example:** +import Authors, { Author } from "../../components/authors"; -```ts threeslash -import { LoroDoc } from "loro-crdt"; + + + -const doc = new LoroDoc(); -console.log(doc.isDetached()); -``` +![](./loro-richtext/cover_long.png) - +import Caption from "../../components/caption"; +import Demo from "@components/richtextDemo"; -### Commit Options Helpers +This article presents the rich text CRDT algorithm implemented in Loro, +complying with [Peritext]'s criteria for seamless rich text collaboration. +Furthermore, it can be built on top of any List CRDT algorithms and turn them +into rich text CRDTs. - -```typescript no_run -setNextCommitMessage(msg: string): void -``` - - -Sets the message for the next commit. +
+ + + Above is an online demo of Loro's rich text CRDT, built with Quill. After the + replay, you can simulate real-time collaboration and concurrent editing while + offline. You can also drag on the history view to replay the editing history. + -**Parameters:** +If CRDTs are new to you, our article [What are CRDTs](/docs/concepts/crdt) +provides a brief introduction. -- `msg` - Commit message +## Background -**Example:** +Loro is based on the +[Event Graph Walker (Eg-walker)](/docs/advanced/replayable_event_graph) algorithm +proposed by Joseph Gentle, but this algorithm cannot integrate the original +version of Peritext. This motivates us to create a new rich text algorithm. It +is independent of the specific List CRDTs, thus working nicely with Eg-walker, and is +developed on top of them to establish a rich text CRDT. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +Before diving into the algorithm of Loro's rich text CRDT, I'd like to briefly +introduce Eg-walker and Peritext, and why Peritext cannot be used on Eg-walker. -const doc = new LoroDoc(); -doc.setNextCommitMessage("User action"); -``` +
+Recap on List CRDTs - +### Recap on List CRDTs - -```typescript no_run -setNextCommitOrigin(origin: string): void -``` - - -Sets the origin for the next commit. +Unlike OT, most List-oriented CRDTs assign a unique ID to each item or +character, often corresponding to the operation ID of its insertion. With unique +IDs for each character, we can reliably reference a character or position +through its ID. -**Parameters:** +![](./loro-richtext/list_crdt_ids.png) -- `origin` - Origin identifier +The unique ID eliminates concerns about consistent position descriptions during +synchronization. For instance, deletions are straightforward by specifying the +deleted character's ID, and insertions are described using the IDs of adjacent +characters. In cases of concurrent insertions at the same location, List CRDT +algorithms resolve the consistency issues. -**Example:** +![](./loro-richtext/list_crdt_insert.png) -```ts threeslash -import { LoroDoc } from "loro-crdt"; +![](./loro-richtext/list_crdt_delete.png) -const doc = new LoroDoc(); -doc.setNextCommitOrigin("ui"); -``` +However, a notable limitation of List CRDTs is the use of 'tombstones'. Upon +deletion of a character, it is not fully removed but replaced with a tombstone, +maintaining the ID's position. Depending on the algorithm, this tombstone may be +removed once all participating nodes acknowledge the deletion. However, it can +be challenging to determine if all peers have received the corresponding +deletion operation. This information often means additional overhead for many +CRDTs. Thus, the simplest solution is not to perform any tombstone collection. - +![](./loro-richtext/list_crdt_tombstone.png) - -```typescript no_run -setNextCommitTimestamp(timestamp: number): void -``` - - -Sets the timestamp for the next commit. +
-**Parameters:** +### Brief Introduction to Event Graph Walker -- `timestamp` - Unix timestamp in seconds +Eg-walker is a novel CRDT algorithm introduced in: -**Example:** +> [Collaborative Text Editing with Eg-walker: Better, Faster, Smaller](https://arxiv.org/abs/2409.14252) +> By: Joseph Gentle, Martin Kleppmann -```ts threeslash -import { LoroDoc } from "loro-crdt"; +Eg-walker is a novel CRDT algorithm that combines the strengths of both OT and CRDTs. +It has the distributed nature of CRDT that enables P2P collaboration and data +ownership. Moreover, it achieves minimal overhead in scenarios devoid of +concurrent edits, similar to OT. -const doc = new LoroDoc(); -doc.setNextCommitTimestamp(Math.floor(Date.now() / 1000)); -``` +import { ReactPlayer } from "../../components/video"; - + - -```typescript no_run -setNextCommitOptions(options: { origin?: string, timestamp?: number, message?: string }): void -``` - - -Sets multiple options for the next commit. +Whether in real-time collaboration or multi-end synchronization, a directed +acyclic graph (DAG) forms over the history of these parallel edits, similar to +Git's history. The Eg-walker algorithm records the history of user edits on the DAG. -**Parameters:** +Unlike conventional CRDTs, Eg-walker can record just the original description of +operations, not the metadata of CRDTs. For instance, in text editing scenarios, +the [RGA algorithm] needs the op ID and [Lamport timestamp][Lamport] of the +character to the left to determine the insertion point. [Yjs]/Fugue, however, +requires the op ID of both the left and right characters at insertion. In +contrast, Eg-walker simplifies this by only recording the index at the time of +insertion. Loro, which uses [Fugue] upon Eg-walker, inherits these advantages. -- `options` - Commit options object +An index is not a stable position descriptor, as the index of an operation can +be affected by other operations. For example, if you highlight content from +`index=x` to `index=y`, and concurrently someone inserts n characters at +`index=n` where `n +What is Fugue - +Fugue is a new CRDT text algorithm, presented in +_[The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing](https://arxiv.org/abs/2305.00583)_ +by +[Matthew Weidner](https://arxiv.org/search/cs?searchtype=author&query=Weidner%2C+M) +et al., nicely solves **the interleaving problem**. - -```typescript no_run -clearNextCommitOptions(): void -``` - - -Clears all pending commit options. +The interleaving problem was proposed in the paper +_[Interleaving anomalies in collaborative text editors](https://martin.kleppmann.com/2019/03/25/papoc-interleaving-anomalies.html)_ +by Martin Kleppmann et al. -**Example:** +An example of interleaving: -```ts threeslash -import { LoroDoc } from "loro-crdt"; +- A type "Hello " from left to right/right to left +- B type "Hi " from left to right/right to left +- The expected result: "Hello Hi " or "Hi Hello " +- The interleaving result may look like: "HHeil lo" + - This happens when typing from right to left in RGA. -const doc = new LoroDoc(); -doc.clearNextCommitOptions(); -``` +![An example of an interleaving anomaly when using [fractional indexing](https://madebyevan.com/algos/crdt-fractional-indexing/) CRDT on text content. +Source: **Martin Kleppmann, Victor B. F. Gomes, Dominic P. Mulligan, and Alastair R. Beresford. 2019. Interleaving anomalies in collaborative text editors. [https://doi.org/10.1145/3301419.3323972](https://doi.org/10.1145/3301419.3323972)](./images/richtext0.png) - +An example of an interleaving anomaly when using +[fractional indexing](https://madebyevan.com/algos/crdt-fractional-indexing/) +CRDT on text content. Source: \*\*Martin Kleppmann, Victor B. F. Gomes, Dominic +P. Mulligan, and Alastair R. Beresford. 2019. Interleaving anomalies in +collaborative text editors. +[https://doi.org/10.1145/3301419.3323972](https://doi.org/10.1145/3301419.3323972) -### Version & Frontier Utilities +The [Fugue paper](https://arxiv.org/abs/2305.00583) summarizes the current state +of the interleaving problems in the table. - -```typescript no_run -frontiersToVV(frontiers: Frontiers): VersionVector -``` - - -Converts frontiers to a version vector. +![Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. *ArXiv*. /abs/2305.00583](./images/richtext1.png) -**Parameters:** +Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: +Minimizing Interleaving in Collaborative Text Editing. _ArXiv_. /abs/2305.00583 -- `frontiers` - Frontiers to convert +The interleaving problem sometimes are unsolvable when there are more than 2 +sites. See [Fugue](https://arxiv.org/abs/2305.00583) paper Appendix B, Proof of +Theorem 5 for detailed explanation. -**Returns:** Version vector representation +![The case where the interleaving problem is unsolvable +Source: Weidner, M., Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing Interleaving in Collaborative Text Editing. *ArXiv*. /abs/2305.00583](./images/richtext2.png) -**Example:** +The case where the interleaving problem is unsolvable Source: Weidner, M., +Gentle, J., & Kleppmann, M. (2023). The Art of the Fugue: Minimizing +Interleaving in Collaborative Text Editing. _ArXiv_. /abs/2305.00583 -```ts threeslash -import { LoroDoc } from "loro-crdt"; +However, we can still minimize the chance of interleaving. Fugue introduces the +concept of **maximal non-interleaving** and solves it with an elegant algorithm +that is easy to optimize. The definition of _maximal non-interleaving_ makes a +lot of sense to me and leaves little room for ambiguity. I won't reiterate the +definition here. But the basic idea is first to solve forward interleaving by +leftOrigin. If there is still ambiguity, then solve the backward interleaving by +rightOrigin. (The leftOrigin and rightOrigin refer to the ids of the original +neighbors when the character is inserted, just like Yjs) -const doc = new LoroDoc(); -const frontiers = doc.frontiers(); -const vv = doc.frontiersToVV(frontiers); -``` + - +### Brief Introduction to Peritext - -```typescript no_run -vvToFrontiers(vv: VersionVector): Frontiers -``` - - -Converts a version vector to frontiers. +[Peritext] was proposed by _Geoffrey Litt et al._ It's the first paper to +discuss rich text CRDTs. It can merge concurrent edits in rich text format while +[preserving users' intent as much as possible](https://www.inkandswitch.com/peritext/#preserving-the-authors-intent). +Its primary focus is merging the formats and annotations of rich text content, +such as bold, italic, and comments. It was implemented in [Automerge] and +[crdt-richtext]. -**Parameters:** +> 💡 The specific definition of user intent in the context of concurrent rich +> text editing can't be clearly explained in a few words. It's best understood +> through particular examples. -- `vv` - Version vector to convert +Peritext is designed to solve a couple of significant challenges: -**Returns:** Frontiers representation +Firstly, it addresses the anticipated problems arising from conflicting style +edits. For instance, consider a text example, "The quick fox jumped." If User A +highlights "The quick" in bold and User B highlights "quick fox jumped," the +ideal merge should result in the entire sentence, "The quick fox jumped," being +bold. However, existing algorithms might not meet this expectation, resulting in +either "The quick fox" or "The" and "jumped" being bold instead. -**Example:** +| Original Text | The quick fox jumped | +| -------------------------------------------- | ---------------------------- | +| Concurrent Edit from A | **The quick** fox jumped | +| Concurrent Edit from B | The **quick fox jumped** | +| Expected Merged Result | **The quick fox jumped** | +| Bad case from merging Markdown text directly | **The** quick **fox jumped** | +| Bad case from Yjs | **The quick** fox jumped | -```ts threeslash -import { LoroDoc } from "loro-crdt"; +Additionally, Peritext manages conflicts between style and text edits. In the +same example, if User A highlights "The quick" in bold, but User B changes the +text to "The fast fox jumped," the ideal merge should result in "The fast" being +bold. -const doc = new LoroDoc(); -const vv = doc.version(); -const frontiers = doc.vvToFrontiers(vv); -``` +| Original Text | The quick fox jumped | +| ---------------------- | ------------------------ | +| Concurrent Edit from A | **The quick** fox jumped | +| Concurrent Edit from B | The fast fox jumped | +| Expected Merged Result | **The fast** fox jumped | - +What’s more, Peritext takes into account different expectations for expanding +styles. For example, if you type after a bold text, you would typically want the +new text to continue being bold. However, if you're typing after a hyperlink or +a comment, you likely wouldn't want the new input to become part of the +hyperlink or comment. - -```typescript no_run -oplogVersion(): VersionVector -``` - - -Gets the oplog version vector. +
+![Link style should not expand](./loro-richtext/Peritext.png) +
+ +Illustration of Peritext's internal state. It uses the IDs of the character's ops to record the style ranges. In the example, the bold mark has the range of `{ start: { type: "before", opId: "9@B" }, end: { type: "before", opId: "10@B" }}` + -**Returns:** Oplog version vector +### Why Original Peritext Can't Be Directly Used with Eg-walker -**Example:** +On the one hand, Peritext's algorithm expresses style ranges +[through character OpIDs](https://www.inkandswitch.com/peritext/#generating-inline-formatting-operations). +Without replaying history, CRDTs based on Eg-walker cannot determine the specific +positions corresponding to these OpIDs. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +On the other hand, it's not feasible to model Peritext on Eg-walker through replaying. +This is because Eg-walker's "local backtracking suffices" relies on the algorithm +satisfying "the same operation will produce the same effect, regardless of the +current state," which Peritext does not adhere to. For example, when inserting +the character "x" at position `p`, whether "x" is bold depends on "whether `p` +is surrounded by bold" and +"[whether the tombstones at `p` contain boundaries of bold and other styles](https://arc.net/l/quote/ifxpaand)." -const doc = new LoroDoc(); -const vv = doc.oplogVersion(); -``` +## Loro's Rich Text CRDT -
+### Algorithm - -```typescript no_run -oplogFrontiers(): Frontiers -``` - - -Gets the oplog frontiers. +Loro implements rich text using special control characters called 'style +anchors'. Each matching pair of start anchor and end anchor contains the +following information: -**Returns:** Oplog frontiers +- The op ID of the style operation +- The style's key-value pair +- The style's [Lamport timestamp][Lamport] +- Style expansion behavior: Determines whether newly inserted text before or + after the style boundaries should inherit the style. -**Example:** +The method to determine a character's style is as follows: -```ts threeslash -import { LoroDoc } from "loro-crdt"; +- Find all style anchor pairs that include the character, where each pair is + created by the same style operation +- Aggregate pairs according to the key. There may be multiple style pairs with + the same key but different values. In such cases, the value with the greatest + Lamport timestamp is chosen (if Lamport timestamps are equal, then use the + peer ID to break the tie) -const doc = new LoroDoc(); -const frontiers = doc.oplogFrontiers(); -``` +Contrary to +[Yjs's method of using control characters](https://www.inkandswitch.com/peritext/#adding-control-characters-to-plain-text) +for rich text, our algorithm pairs start and end anchors when they originate +from the same style operation. This approach accurately handles the following +scenarios: - +![overlap_bold](./loro-richtext/overlap_bold.png) - -```typescript no_run -cmpWithFrontiers(frontiers: Frontiers): -1 | 0 | 1 -``` - - -Compares current document state with given frontiers. +These special control characters are not exposed to the user; each control +character is effectively of zero length from the user's perspective. Our data +structure supports various methods of measuring text length for indexing text +content. Besides Unicode, UTF-16, and UTF-8, we also measure our rich text +length in `Entity length`. It treats each style anchor as an entity with a +length of 1 and measures plain text in Unicode length. -**Parameters:** +![len](./loro-richtext/len.png) -- `frontiers` - Frontiers to compare with +| Concept | Definition | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Style Anchors | Control characters used in Loro denote style boundaries' start and end. They are differentiated into start and end anchors, representing a style's beginning and end. | +| Rich Text Element | A rich text element is either a span of text or a style anchor. A list of rich text elements represents the internal state of Loro's rich text. | +| Unicode Index | A method of indexing text positions in rich text. In this method, the length of the text is measured in Unicode char length, and the length of style anchors is considered 0. | +| Entity Index | A method of indexing text positions in rich text. In this method, the length of the text is measured in Unicode char length, and the length of a style anchor is considered 1. | -**Returns:** -1 if behind, 0 if equal, 1 if ahead +#### Local Behavior -**Example:** +Multiple valid insertion points can exist when users insert text at a specific +Unicode index. It occurs due to style anchors, which are zero-length elements +from the user's perspective. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +![Two Different Kinds of Indexes](./loro-richtext/two_index.png) -const doc = new LoroDoc(); -const frontiers = doc.frontiers(); -const cmp = doc.cmpWithFrontiers(frontiers); -``` +For example, in the case of `Hello world`, when a user inserts content at +Unicode `index=5`, they face the choice of inserting to the left or right of +``. If the user sets the expand behavior of bold to expand forward, the new +character will be inserted to the left of ``, making the inserted text bold +as well. - +When users delete text, Loro uses an additional mapping layer to avoid deleting +style anchors within the text range. - -```typescript no_run -cmpFrontiers(a: Frontiers, b: Frontiers): -1 | 0 | 1 | undefined -``` - - -Compares two frontiers. +To model the deletion of a style, a new style anchor pair with a null value is +added. -**Parameters:** +We can implement the following optimizations to remove redundant style anchors: -- `a` - First frontiers -- `b` - Second frontiers +- The style anchors that include no text can be removed. +- When styles completely negate each other, like a span of bold is canceled by a + span of unbold, we can remove their style anchors. -**Returns:** -1 if a < b, 0 if equal, 1 if a > b, undefined if incomparable +All these behaviors happen locally, and the algorithm is independent of the +specific List CRDT. -**Example:** +##### Behavior When Inserting Text at Style Boundaries -```ts threeslash -import { LoroDoc } from "loro-crdt"; +Most modern rich text editors (Google Doc, Microsoft Word, Notion) behave as +follows: when new text is entered right after bold text, the new text should +inherit the bold style; when entered after a hyperlink, the new content should +not inherit the hyperlink style. Different styles have varying preferences for +text insertion positions, leading to potential conflicts. This is reflected in +the degree of freedom we have when inserting new text. -const doc = new LoroDoc(); -const f1 = doc.frontiers(); -const f2 = doc.frontiers(); -const cmp = doc.cmpFrontiers(f1, f2); -``` +Users interact with rich text based on text-based indexes, like the Unicode +index. Since style anchors have a Unicode Length of 0, a Unicode index with n +style anchors presents n + 1 potential insertion positions. - +We select the insertion position based on the following rules: -### JSONPath & Path Queries +1. Insertions occur before a start anchor of a style that should not expand + backward. +2. Insertions occur before style anchors that signify the end of bold-like marks + (expand = "after" or expand = "both"). +3. Insertions occur after style anchors that signify the end of link-like marks + (expand = "none" or expand = "before"). -Use simple path strings and JSONPath to fetch nested values and containers. Paths are formed from root container names and keys (e.g., map/key or list/0). For container IDs, see [Container ID](/docs/advanced/cid). +Rule 1 should be prioritized over rules 2 and 3 to prevent +[the accidental creation of a new style](https://github.com/inkandswitch/peritext/issues/32). - -```typescript no_run -getByPath(path: string): Value | Container | undefined -``` - - -Gets a value or container by its path. +The current method first scans forward to find the last position satisfying +rules 1 and 2. -**Parameters:** +Then, it scans backward to find the first position satisfying rule 3. -- `path` - Path string (e.g., "map/key") +#### Merging Remote Updates -**Returns:** Value or container at the path, or undefined +Loro treats style anchors as a special element and handles them using the same +List CRDT for resolving concurrent conflicts. The logic related to rich text is +independent of the particular List CRDT. Therefore, this algorithm can rely on +any List CRDT algorithm for merging remote operations. Loro utilizes the [Fugue] +List CRDT algorithm. -**Example:** +When new style anchors are inserted by remote updates, new styles are added; if +old style anchors are deleted, the corresponding old styles are removed. -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const map = doc.getMap("map"); -map.set("key", 1); +#### Strong Eventual Consistency -// Usage example: -const value = doc.getByPath("map/key"); -``` +The internal state of this algorithm consists of a list of elements, each either +a text segment or a style anchor. The rich text document is derived from this +internal state. - +The internal state achieves strong eventual consistency through the upstream +List CRDT. - -```typescript no_run -getPathToContainer(id: ContainerID): (string | number)[] | undefined -``` - - -Gets the path to a container by its ID. +Identical internal states result in identical rich text documents. Hence, the +same set of updates will produce the same rich text documents, evidencing the +strong eventual consistency of this algorithm. -**Parameters:** +### Criteria in Peritext -- `id` - Container ID +[The Peritext paper](https://www.inkandswitch.com/peritext/static/cscw-publication.pdf) +specifies the intent-preserving merge behavior for rich text inline format. +Loro's rich text algorithm successfully passes all test cases outlined therein. -**Returns:** Array representing the path, or undefined +#### 1. Concurrent Formatting and Insertion -**Example:** +| Name | Text | +| :-------------- | :------------------ | +| Origin | Hello World | +| Concurrent A | **Hello World** | +| Concurrent B | Hello New World | +| Expected Result | **Hello New World** | -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const map = doc.getMap("map"); +Loro easily supports this case by treating style anchors as special elements +alongside text. -const path = doc.getPathToContainer(map.id); -``` +#### 2. Overlapping Formatting - +| Name | Text | +| :-------------- | :-------------- | +| Origin | Hello World | +| Concurrent A | **Hello** World | +| Concurrent B | Hel**lo World** | +| Expected Result | **Hello World** | - -```typescript no_run -JSONPath(jsonpath: string): any[] -``` - - -Queries the document using JSONPath syntax. +This case has been analyzed earlier. Since our style anchors contain style op ID +information, we know there are two bold segments: one from 0 to 5 and another +from 3 to 11, allowing us to merge them. -**Parameters:** +| Name | Text | +| :-------------- | :------------------ | +| Origin | Hello World | +| Concurrent A | **Hello** World | +| Concurrent B | Hel*lo World* | +| Expected Result | **Hel*lo*** _World_ | -- `jsonpath` - JSONPath query string +Multiple style types are easily supported. -**Returns:** Array of matching values +| Name | Text | Note | +| :-------------- | :--------------------------------------------------- | :------------------ | +| Origin | Hello World | | +| Concurrent A | **Hello World**
Then
**Hello** World | Bold, then unbold | +| Concurrent B | Hello Wor**ld** | | +| Expected Result | **Hello** Wor**ld**
Or
**Hello** World | Both are acceptable | -**Example:** +Like Peritext, we model unbolding by adding a new style with the key `bold` and +the value `null`. The final value of each style key on each character is +determined by the style with the greatest [Lamport] timestamp that includes the +character. Thus, it easily supports this case. -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const map = doc.getMap("map"); -map.set("key", 1); +#### 3. Text Insertion at Span Boundaries -// Usage example: -const results = doc.JSONPath("$.map"); -``` +Insertion right after a bold style should result in the newly inserted text also +being bold. -
+
+ ![bold_expand](./loro-richtext/bold_expand.png) +
-### Shallow Doc Utilities +However, insertion right after a link style should result in the newly inserted +text not having the hyperlink style. -These helpers relate to shallow snapshots and redaction. If you need a refresher on what “shallow” means, see [Shallow Snapshots](/docs/concepts/shallow_snapshots). +
+ ![Link style should not expand](./loro-richtext/link_expand.png) +
- -```typescript no_run -shallowSinceVV(): VersionVector -``` - - -Gets the version vector since which the document is shallow. +#### 4. Styles that Support Overlapping -**Returns:** Version vector +
![](./loro-richtext/overlap_mark.png)
-**Example:** +The problem of overlapping styles is related to how we represent them. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +We represent the rich text using +[Quill's Delta](https://quilljs.com/docs/delta/) format. -const doc = new LoroDoc(); -const vv = doc.shallowSinceVV(); +```ts no_run +[ + { insert: "Gandalf", attributes: { bold: true } }, + { insert: " the " }, + { insert: "Grey", attributes: { color: "#cccccc" } }, +]; ``` -
+An example of Quill's Delta format - -```typescript no_run -shallowSinceFrontiers(): Frontiers -``` - - -Gets the frontiers since which the document is shallow. +However, it cannot handle cases with multiple values assigned to the same key. +So, it's a headache to handle the styles that support overlapping. -**Returns:** Frontiers +![](./loro-richtext/overlap_comments.png) -**Example:** +For example, in the above case, the text "fox" is commented on by both Alice and +Bob. We can't represent it with Quill's Delta format directly. So the possible +workaround includes: -```ts threeslash -import { LoroDoc } from "loro-crdt"; +**Turn the attribute value into a list** -const doc = new LoroDoc(); -const frontiers = doc.shallowSinceFrontiers(); +```ts no-run +[ + { insert: "The ", attributes: { comment: [{ ...commentA }] } }, + { + insert: "fox", + attributes: { comment: [{ ...commentA }, { ...commentB }] }, + }, + { insert: " jumped", attributes: { comment: [{ ...commentB }] } }, +]; ``` - +**Use op ID that creates the op as the key of the attribute** - -```typescript no_run -isShallow(): boolean +```ts no-run +[ + { insert: "The ", attributes: { "id:0@A": { key: "comment", ...commentA } } }, + { + insert: "fox", + attributes: { + "id:0@A": { key: "comment", ...commentA }, + "id:0@B": { key: "comment", ...commentB }, + }, + }, + { + insert: " jumped", + attributes: { "id:0@B": { key: "comment", ...commentA } }, + }, +]; ``` - - -Checks if the document is shallow. - -**Returns:** True if document is shallow -**Example:** +But both require special behaviors for both CRDT lib and for application code, +which are painful to work with. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +Finally, we found that the optimal approach to represent an overlappable style +was to use `:` as a prefix and allow users to assign a unique suffix to +create a distinct style key. This method simplifies the CRDTs library code, as +it doesn't require handling special cases. It effectively addresses scenarios +where multiple comments overlap and is also user-friendly for application +coding. -const doc = new LoroDoc(); -const shallow = doc.isShallow(); +```ts no-run +[ + { insert: "The ", attributes: { "comment:alice": "Hi" } }, + { + insert: "fox", + attributes: { "comment:alice": "Hi", "comment:bob": "Jump" }, + }, + { insert: " jumped", attributes: { "comment:bob": "Jump" } }, +]; ``` - +Following is the example code in Loro: - -```typescript no_run -setHideEmptyRootContainers(hide: boolean): void +```ts +const doc = new Loro(); +doc.configTextStyle({ + comment: { expand: "none" }, +}); +const text = doc.getText("text"); +text.insert(0, "The fox jumped."); +text.mark({ start: 0, end: 7 }, "comment:alice", "Hi"); +text.mark({ start: 4, end: 14 }, "comment:bob", "Jump"); +expect(text.toDelta()).toStrictEqual([ + { + insert: "The ", + attributes: { "comment:alice": "Hi" }, + }, + { + insert: "fox", + attributes: { + "comment:alice": "Hi", + "comment:bob": "Jump", + }, + }, + { + insert: " jumped", + attributes: { "comment:bob": "Jump" }, + }, + { + insert: ".", + }, +]); ``` - - -Controls whether empty root containers are hidden in JSON output. - -**Parameters:** -- `hide` - Whether to hide empty root containers +## Implementation of Loro's Rich Text Algorithm -**Example:** +The following is an overview of Loro's implementation as of January, 2024. -```ts threeslash -import { LoroDoc } from "loro-crdt"; +### Architecture of Loro -const doc = new LoroDoc(); -doc.setHideEmptyRootContainers(true); -// Now empty roots are hidden in toJSON() -``` +In line with the properties of Event Graph Walker, Loro uses `OpLog` and +`DocState` as the internal state. - +`OpLog` is dedicated to recording history, while `DocState` only records the +current document state and does not include historical operation information. +When applying updates from remote sources, Loro uses the relevant operations +from `OpLog` and computes the diff through a `DiffCalculator`. This diff is then +applied to `DocState`. This architecture also makes time travel easier to +implement. - -```typescript no_run -deleteRootContainer(cid: ContainerID): void -``` - - -Deletes a root container. +For more details, see the documentation on +[DocState and OpLog](https://loro.dev/docs/advanced/doc_state_and_oplog). -**Parameters:** +![](./loro-richtext/apply_updates.png) -- `cid` - Container ID to delete +### Implementation of Loro's Rich Text CRDT -**Example:** +For rich text, Loro reuses the same `DiffCalculator` as Loro List, based on the +[Fugue] algorithm. As a result, the primary logic related to rich text is +concentrated in `DocState`. This includes expressing styles, inserting new +characters, and representing multiple index formats. -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const map = doc.getMap("map"); +In the representation of rich text state, we distinguish between the data +structure `ContentTree`, which expresses the text (including style anchors), and +`StyleRangeMap`, which expresses styles. -// Usage example: -doc.deleteRootContainer(map.id); -``` +![](./loro-richtext/text_state_arch.png) - +Both structures are built on B+Trees. - -```typescript no_run -hasContainer(id: ContainerID): boolean -``` - - -Checks if a container exists in the document. +`ContentTree` is responsible for efficient text finding, insertion, and +deletion. It can index specific insertion/deletion positions using +Unicode/UTF-8/UTF-16/Entity index. It does not store what specific style each +text segment should have. -**Parameters:** +We built the following B+Tree structure based on our +[generic-btree library](https://github.com/loro-dev/generic-btree) to express +text in memory: -- `id` - Container ID to check +- Each internal node in the B+Tree stores the Unicode char length, UTF-16 + length, UTF-8 length, and Entity length of its subtree. The Entity length + considers the length of style anchors as 1, otherwise 0. +- The leaf nodes of the B+Tree are text or style anchors. -**Returns:** True if container exists +`StyleRangeMap` is responsible for efficient updating/querying of style ranges. -**Example:** +In the `StyleRangeMap` B+Tree expressing styles: -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const map = doc.getMap("map"); +- Each internal node stores the `entity length` of its subtree. +- Each leaf node stores the collection of style information for the + corresponding range and its `entity length. -const exists = doc.hasContainer(map.id); -``` +Separating the text `ContentTree` and style `StyleRangeMap` into two structures +aims for better performance optimization. On rich text, style information is +often not abundant and tends to have good continuity, such as several paragraphs +having the same format, which can be expressed with a single leaf node. However, +our structure for storing text is unsuitable for leaf nodes with large content, +as conversion time between different encoding formats would become excessively +long. - +When a user inserts a new character at `Unicode index` = i, the following +occurs: -### JSON Serialization with Replacer +- Find the position at `Unicode index` = i in `ContentTree`. +- Check if there are any adjacent style anchors at this position. If not, + directly insert. +- If there are, decide whether to insert to the left or right of the + corresponding style anchor based on its type and properties. If there are + multiple such style anchors, insert them according to the previous section on + ["Behavior When Inserting Text at Style Boundaries"](#behavior-when-inserting-text-at-style-boundaries). - -```typescript no_run -toJsonWithReplacer(replacer: (key: string | number, value: Value | Container) => Value | Container | undefined): Value -``` - - -Customize JSON serialization of containers and values. +### Testing -**Parameters:** +We have written tests for the criteria proposed by Peritext and passed all of +them. -- `replacer` - Function to transform values during serialization +To ensure the correctness of our CRDTs, we have added numerous fuzzing tests to +simulate different collaborative behaviors, synchronization behaviors, and +time-travel behaviors. These tests check for the strong eventual consistency and +the correctness of internal invariants. We run these fuzzing tests continuously +for several days after every critical modification to avoid oversights. -**Returns:** Customized JSON value +## How to Use -**Example:** +Before using the Loro's rich text module, it is necessary to define the +configuration for rich text styles, specifying the expand behavior for different +keys and whether overlap is allowed. -```ts threeslash -import { LoroDoc, LoroText } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello"); +Here is an example of using Loro's rich text in JavaScript: -// Usage example: -const json = doc.toJsonWithReplacer((key, value) => { - if (value instanceof LoroText) { - return value.toDelta(); - } - return value; +```typescript +const doc = new Loro(); +doc.configTextStyle({ + bold: { + expand: "after", + }, + comment: { + expand: "none", + overlap: true, + }, + link: { + expand: "none", + }, }); -``` - - -### Stats & Introspection +const text = doc.getText("text"); +text.insert(0, "Hello world!"); +text.mark({ start: 0, end: 5 }, "bold", true); +expect(text.toDelta()).toStrictEqual([ + { + insert: "Hello", + attributes: { bold: true }, + }, + { + insert: " world!", + }, +] as Delta[]); - -```typescript no_run -debugHistory(): void +text.insert(5, "!"); +expect(text.toDelta()).toStrictEqual([ + { + insert: "Hello!", + attributes: { bold: true }, + }, + { + insert: " world!", + }, +] as Delta[]); ``` - - -Prints debug information about the document history. - -**Example:** - -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -doc.debugHistory(); -``` +## Summary - +This article presents Loro's rich text algorithm design and implementation. Its +correctness is readily demonstrable. It can be built upon any existing List CRDT +algorithm. It allows Loro to support rich text collaboration using +[Eg-walker](#brief-introduction-to-replayable-event-graph) and [Fugue], combining the +strengths of multiple CRDT algorithms. - -```typescript no_run -changeCount(): number -``` - - -Gets the total number of changes in the document. +We are continuously refining its design and actively seeking design partners. We +are open to all forms of feedback and constructive criticism. Should you have +any proposals for collaboration, please reach out to zx@loro.dev -**Returns:** Number of changes +[Peritext]: https://www.inkandswitch.com/peritext/ +[Fugue]: https://arxiv.org/abs/2305.00583 +[Lamport]: https://en.wikipedia.org/wiki/Lamport_timestamp +[RGA algorithm]: https://www.sciencedirect.com/science/article/abs/pii/S0743731510002716 +[Yjs]: https://github.com/yjs/yjs +[Automerge]: https://github.com/automerge/automerge +[crdt-richtext]: https://github.com/loro-dev/crdt-richtext -**Example:** -```ts threeslash -import { LoroDoc } from "loro-crdt"; +# FILE: pages/blog/loro-now-open-source.mdx -const doc = new LoroDoc(); -const changes = doc.changeCount(); -``` +--- +title: "Loro: Reimagine State Management with CRDTs" +date: 2023/11/13 +description: + "Loro, our high-performance CRDTs library, is now open source. In this + article, we share our vision for the local-first software development + paradigm, why we're excited about it, and the current status of Loro." +--- - +# Loro: Reimagine State Management with CRDTs - -```typescript no_run -opCount(): number -``` - - -Gets the total number of operations in the document. +import Caption from "../../components/caption"; +import GitHub from "../../components/github"; +import Authors, { Author } from "../../components/authors"; -**Returns:** Number of operations + + + + -**Example:** +
+
+ Loro, our high-performance CRDTs library, is now open source +
+ +
.
+
-```ts threeslash -import { LoroDoc } from "loro-crdt"; +In this article, we share our vision for the local-first software development +paradigm, explain why we're excited about it, and discuss the current status of +Loro. -const doc = new LoroDoc(); -const ops = doc.opCount(); -``` +With better DevTools, documentation, and a friendly ecosystem, everyone can +easily build local-first software. -
+![Loro's 'time machine' example](./loro-now-open-source/colab_and_travel.gif) - -```typescript no_run -getAllChanges(): Map -``` - - -Gets all changes grouped by peer ID. + + You can build collaborative apps with time travel features easily using Loro. + [Play the example online](https://loro-react-flow-example.vercel.app/). + -**Returns:** Map of peer ID to changes +## Envisioning the Local-First Development Paradigm -**Example:** +Distributed states are commonly found in numerous scenarios, such as multiplayer +games, multi-device document synchronization, and edge networks. These scenarios +require synchronization to achieve consistency, usually entailing elaborate +design and coding. For instance, considerations for network issues or concurrent +write operations are necessary. However, for a wide range of applications CRDTs +can simplify the code significantly: -```ts threeslash -import { LoroDoc } from "loro-crdt"; +- CRDTs can automatically merge concurrent writes without conflicts. +- Fewer abstractions. There's no need to design specific backend database + schemas, manually execute expected conflict merges, or implement interfaces to + memory and memory to persistent structure conversions. +- Offline supports are right out of the box -const doc = new LoroDoc(); -const changes = doc.getAllChanges(); -``` +
+What are CRDTs - +### What are Conflict-Free Replicated Data Types (CRDTs)? - -```typescript no_run -getChangeAt(id: OpId): Change -``` - - -Gets a specific change by operation ID. +CRDTs are data structures used in distributed systems that allow updates to be +merged across multiple replicas without conflicts. In this context, "replicas" +refer to different independent data instances within the system, such as the +same collaborative document on various user devices. -**Parameters:** +CRDTs enable users to operate independently on their replicas, like editing a +document, without needing real-time communication with other replicas. The CRDTs +merge these operations, ensuring all replicas achieve "strong eventual +consistency". As long as all nodes receive the same set of updates, regardless +of the order, their data states will eventually be consistent. -- `id` - Operation ID +> For more details, visit +> [What are CRDTs](https://www.loro.dev/docs/concepts/crdt) -**Returns:** Change object +
-**Example:** +
+When you can't use CRDTs +### When you can't use CRDTs -```ts threeslash -import { LoroDoc } from "loro-crdt"; +CRDTs only guarantee _Strong Eventual Consistency_. You have to make sure it's +suitable for your application. -const doc = new LoroDoc(); -doc.getText("text").insert(0, "hello"); -doc.commit(); -const changes = doc.getAllChanges(); -const change = changes.get(doc.peerIdStr)?.[0]; -``` +"Strong Eventual Consistency": As long as all nodes receive the same set of +updates, their data states will ultimately become consistent regardless of their +sequence. - +Strong eventual consistency may not be acceptable in scenarios requiring +immediate consistency or transactional integrity, such as financial +transactions, exclusive resource access, or allocation. - -```typescript no_run -getChangeAtLamport(peer_id: string, lamport: number): Change | undefined -``` - - -Gets a change by peer ID and Lamport timestamp. +
-**Parameters:** +Since the data resides locally, client applications can directly access and +manipulate local data, offering both speed and availability. Additionally, due +to CRDTs' nature, synchronization / real-time collaboration can be achieved +without relying on centralized servers (similar to Git, allowing migration to +other platforms without data loss). With performance improvements, CRDTs +increasingly replace traditional real-time collaboration solutions in various +contexts. -- `peer_id` - Peer ID -- `lamport` - Lamport timestamp +This represents a new paradigm. Local-first not only empowers users with control +over their data, but also makes developers' lives easier. -**Returns:** Change object or undefined +![Local-first](./loro-now-open-source/Untitled.png) -**Example:** + + The annual growth rate of the *"local-first"* star count in GitHub has reached + 40%+. + -```ts threeslash -import { LoroDoc } from "loro-crdt"; +### Integrating CRDTs with UI State Management -const doc = new LoroDoc(); -doc.getText("text").insert(0, "hello"); -const change = doc.getChangeAtLamport(doc.peerIdStr, 1); -``` +![Loro's rich text collaboration example](./loro-now-open-source/richtext.gif) -
+Loro's rich text collaboration example - -```typescript no_run -getOpsInChange(id: OpId): any[] -``` - - -Gets all operations in a specific change. +Since CRDTs enable conflict-free automatic merging, the challenge of managing +distributed states shifts to "how to express operations and states on CRDTs". -**Parameters:** +Front-end state management libraries typically require developers to define how +to retrieve State and specify Actions, as illustrated by this example from Vue's +state management tool, Pinia: -- `id` - Operation ID +```ts no-run +export const useCartStore = defineStore({ + id: "cart", + state: () => ({ + rawItems: [] as string[], + }), + getters: { + items: (state): Array<{ name: string; amount: number }> => + state.rawItems.reduce( + (items, item) => { + const existingItem = items.find((it) => it.name === item); -**Returns:** Array of operations + if (!existingItem) { + items.push({ name: item, amount: 1 }); + } else { + existingItem.amount++; + } -**Example:** + return items; + }, + [] as Array<{ name: string; amount: number }>, + ), + }, + actions: { + addItem(name: string) { + this.rawItems.push(name); + }, -```typescript no_run -import { LoroDoc } from "loro-crdt"; + removeItem(name: string) { + const i = this.rawItems.lastIndexOf(name); + if (i > -1) this.rawItems.splice(i, 1); + }, -const doc = new LoroDoc(); -doc.getText("text").insert(0, "hello"); -const changes = doc.getAllChanges(); -const ops = doc.getOpsInChange(changes[0].id); -``` + async purchaseItems() { + const user = useUserStore(); + if (!user.name) return; - + console.log("Purchasing", this.items); + const n = this.items.length; + this.rawItems = []; - -```typescript no_run -getPendingTxnLength(): number + return n; + }, + }, +}); ``` - - -Gets the number of pending operations in the current transaction. -**Returns:** Number of pending operations +This paradigm and CRDTs are easily compatible: The state in the state management +libraries corresponds to CRDT types, and Action corresponds to a set of CRDT +operations. -**Example:** +Thus, implementing UI state management through CRDTs does not require users to +change their habits. It also has many advanced features: -```ts threeslash -import { LoroDoc } from "loro-crdt"; +- Make states automatically synchronizable / support real-time collaboration. +- Like Git, maintain a complete distributed editing history. +- It can store an extensively large editing history with a low memory footprint + and a compact encoding size. Below is an example. -const doc = new LoroDoc(); -doc.getText("text").insert(0, "x"); -console.log(doc.getPendingTxnLength()); -doc.commit(); -``` +With this, you can effortlessly implement products with real-time / async +collaboration and time machine features. - +![Tracing a document with 360,000 operations using Loro](./loro-now-open-source/Untitled.gif) -### Import/Export Utilities + +
+ Time travel a document with 360,000+ operations using Loro. To load the + whole history and playback, it only takes 8.4MB in memory. And the entire + history only takes 361KB in storage. The editing trace is from{" "} +
+ +
.
+ - -```typescript no_run -decodeImportBlobMeta(blob: Uint8Array, check_checksum: boolean): ImportBlobMetadata -``` - - -Decodes metadata from an import blob. +## Introduction to Loro -**Parameters:** +Loro is our CRDTs library, now open-sourced under a permissive license. We +believe a cooperative and friendly open-source community is key to creating +outstanding developer experiences. -- `blob` - Binary data to decode -- `check_checksum` - Whether to verify checksum +We aim to make Loro simple to use, extensible, and maintain high performance. +The following is the latest status of Loro. -**Returns:** Import blob metadata +### CRDTs -**Example:** +We have explored extensively, supporting a range of CRDT algorithms that have +yet to be widely used. -```ts threeslash -import { LoroDoc, decodeImportBlobMeta } from "loro-crdt"; +#### OT-like CRDTs -const doc = new LoroDoc(); -const updates = doc.export({ mode: "update" }); -const meta = decodeImportBlobMeta(updates, true); -``` +> Update: This algorithm is now called Event Graph Walker (Eg-Walker) - +Our CRDTs library is built on the brilliant concept of OT-like CRDTs from Seph +Gentle's [Diamond-types](https://github.com/josephg/diamond-types). Joseph Gentle +and Martin Kleppmann later published the +[Eg-walker paper](https://arxiv.org/abs/2409.14252) on this line of work. Its +notable features include reducing the cost of local operations, easier +historical data reclamation, and sometimes lower storage and memory overhead. +However, it relies on high-performance algorithms to apply remote operations. +This design has great potential and we are excited about its future. - -```typescript no_run -redactJsonUpdates(json: string | JsonSchema, version_range: any): JsonSchema -``` - - -Redacts JSON updates within a specified version range. +
+Brief Introduction to OT-like CRDT algorithms -Use this to safely remove accidentally leaked sensitive content from history while preserving structure. See [Tips: Redaction](/docs/tutorial/tips). +To briefly introduce the concept of OT-like CRDTs, this part is complex and +requires some prior knowledge. I might not encapsulate it well. -**Parameters:** +The general idea of OT-like CRDTs is that they do not retain the CRDTs' data +structure (e.g., originLeft originRight information). When merging remote +operations, they return to the lowest common ancestor in the directed acyclic +graph history of local and remote, and from there, reapply each operation. This +process reconstructs the CRDTs structure, resolving conflicts arising from +parallel editing. Its advantage is that, since it doesn't need to retain these +CRDTs Meta information, local operations are virtually cost-free, like OT, where +only the index at which insertions and deletions occur needs to be saved. The +trade-off is a longer time for merging remote operations, but this issue can be +significantly mitigated with well-designed data structures and algorithms. +Moreover, since most parallel edits last only a short time, the lowest common +ancestor is not far, making the merging process quick. -- `json` - JSON updates to redact -- `version_range` - Version range for redaction +The image below shows an example of merging versions 2@1 and 1@2 using this +algorithm on a DAG. The algorithm needs to revert to the lowest common ancestor +version 0@1 and apply all subsequent operations from there (a total of four +operations). For a better understanding of this image, refer to +[https://www.loro.dev/docs/advanced/version_deep_dive](https://www.loro.dev/docs/advanced/version_deep_dive) -**Returns:** Redacted JSON schema +![Untitled](./loro-now-open-source/Untitled%201.png) -**Example:** +
-```ts threeslash -import { LoroDoc, redactJsonUpdates } from "loro-crdt"; +#### Rich Text CRDTs -const doc = new LoroDoc(); -const json = doc.exportJsonUpdates(); -const redacted = redactJsonUpdates(json, { [doc.peerIdStr]: [0, 999999] }); -``` +In May of this year, we open-sourced the +[crdt-richtext](https://github.com/loro-dev/crdt-richtext) project, integrating +the algorithms of [the rich text CRDT](https://loro.dev/blog/loro-richtext) and the +sequence CRDT [Fugue by Matthew Weidner](https://arxiv.org/abs/2305.00583). A +brief introduction to these two algorithms can be found in +[our blog at the time](https://www.notion.so/crdt-richtext-Rust-implementation-of-Peritext-and-Fugue-c49ef2a411c0404196170ac8daf066c0?pvs=21). -
+Based on our experience from previous projects, we have integrated a rich text +CRDT and Fugue into our framework in the current Loro. However, the biggest +challenge was that +[Peritext did not integrate well with OT-like CRDTs](https://github.com/inkandswitch/peritext/issues/31). +We have recently overcome this issue. We developed a new rich text CRDT +algorithm that can run on OT-like CRDTs and has passed the capabilities listed +in the Peritext paper's Criteria for rich text CRDTs, with no new issues +revealed in our current million fuzzing tests. We later introduced this work in +the [Loro rich text article](https://loro.dev/blog/loro-richtext). ---- +#### Movable Tree -## Container Types +We have also supported a movable tree CRDT. Synchronizing tree movements is +often complex due to the potential for circular references. Addressing this +issue in the distributed environment is even more challenging. -Common CRDT containers for modeling JSON-like structures. See [Choosing CRDT Types](/docs/concepts/choose_crdt_type) and [Composing CRDTs](/docs/tutorial/composition) for when to use each and how to nest them. +We implemented Martin Kleppmann's paper, +[_A Highly-Available Move Operation for Replicated Trees_](https://ieeexplore.ieee.org/document/9563274/). +The idea of this algorithm is to sort all move operations, ensuring the ordering +is consistent across the replicas. Then, each operation is applied sequentially. +If an operation would cause a circular reference, it has no effect. -### LoroText +We found it to be elegant in design and also performant. The time complexity of +local operations is O(k) (k being the average tree depth, as circular reference +detection is required). For applying remote operations, which entails inserting +new operations into the sorted list, we must undo operations that are subsequent +in the ordering, apply the remote operation, and then redo the undone +operations, with a cost of O(km) (m being the number of operations to undo). -A rich text container supporting collaborative text editing with formatting. Supports overlapping marks (bold, italic, links) and -stable cursors. The merge semantics avoid interleaving artifacts under concurrency (Fugue + Eg-walker ideas); you use simple index -APIs and Loro handles index transformation. -See [Text](/docs/tutorial/text), [Eg-walker](/docs/advanced/event_graph_walker), and the rich text blog: https://loro.dev/blog/loro-richtext +![Untitled](./loro-now-open-source/Untitled%201.gif) -**⚠️ Critical: UTF-16 String Encoding** +Visualization of applying a remote op -LoroText uses **UTF-16** encoding, matching JavaScript's native string encoding: +Our tests show that local operations involving ten thousand random movements +among a thousand nodes take less than 10ms (tested on an M2 MAX chip). Moreover, +the cost of merging remote operations in this algorithm is similar to applying +remote operations in OT-like CRDTs, making it adoptable. We've also experimented +with [log-spaced snapshots](https://madebyevan.com/algos/log-spaced-snapshots/) +and immutable data structure approaches in our +[movable-tree project](https://github.com/loro-dev/movable-tree), concluding +that the undo + redo method is the fastest and the most memory-efficient. -- All standard methods (`insert()`, `delete()`, `mark()`, `slice()`, `charAt()`) use UTF-16 code unit indices -- `length` returns UTF-16 code units (same as JavaScript `string.length`) -- Use `insertUtf8()` and `deleteUtf8()` for UTF-8 byte-based operations when integrating with UTF-8 systems +### Data Structures -**⚠️ Common Pitfalls:** +Designing and experimenting with data structures is routine in Loro's +development process. -1. **Index Misalignment**: UTF-16 indices differ from visual character count -2. **Performance**: Cursor queries on deleted positions require history traversal - in that case, it will return a refreshed Cursor object that does not point to the deleted text +We previously open-sourced +[generic-btree](https://github.com/loro-dev/generic-btree) and have redesigned +its structure for a more compact memory layout and cache-friendliness. Besides +its remarkable performance, its flexibility enables us to support various +information types required for Text, like utf16/Unicode code points/utf8, with +minimal code. We also extensively reuse it to fulfill various requirements, +highlighting Rust's impressive type expression capabilities. -**Example with emoji:** +Internally, we've +[separated the document's state from its history](https://www.loro.dev/docs/advanced/doc_state_and_oplog). +The state represents the current form of the document, akin to Git's HEAD +pointer, while the document's history resembles the complete operation history +behind Git. Hence, multiple document states can correspond to the same history. +This structure simplifies our code and provides the foundation for Loro's +version-control primitives. -```typescript no_run -const text = doc.getText("text"); -text.insert(0, "Hello 😀 World"); -console.log(text.length); // 13 (emoji counts as 2) -console.log(text.toString()[6]); // ⚠️ Invalid - splits the emoji -text.delete(6, 2); // ✅ Correct - deletes entire emoji -text.delete(6, 1); // ❌ Wrong - corrupts the emoji +Most of our optimizations thus far have focused on text manipulations, +historically one of the thorniest problems in CRDTs. Later releases expanded +these optimizations across more real-world scenarios. -// Safe iteration -text.iter((char) => { - console.log(char); // Each character handled correctly - return true; -}); -``` +### Update -**📝 Text vs String in Maps:** +![Untitled](./loro-now-open-source/Untitled%202.png) -- Use `LoroText` for collaborative text editing where all concurrent edits must be preserved -- Use regular strings in `LoroMap` for atomic values (URLs, IDs, hashes) where Last-Write-Wins is preferred -- Example: URLs should be strings in maps, not LoroText. Otherwise, the automatically merged result may be an invalid URL +Loro has since reached 1.0 and stabilized its core data format. The project now +ships production-ready JavaScript/WASM and Rust packages, plus maintained +bindings and examples for other platforms. - - ```typescript no_run insert(index: number, text: string): void ``` - +Documentation and developer tooling remain active areas of work. We welcome +ideas and suggestions in our +[community discussions](https://discord.gg/tUsBSVfqzf). - -Inserts text at the specified position using UTF-16 code unit indices (same as JavaScript string indices). +Developing tools for developers is a challenging and exciting task. Many +developer tools and visualization methods in front-end development are +exceptionally good, and we hope to bring such experiences into the world of +CRDTs and local-first development. DevTools will reveal CRDTs' hidden states and +simplify control, making state maintenance and debugging a breeze. -**Parameters:** +Loro now supports richer CRDT semantics, including movable data structures and +undo/redo APIs for more diverse application scenarios. -- `index` - UTF-16 code unit position to insert at (0-based, same as JavaScript string index) -- `text` - Text to insert +## Seeking Collaborative Project Opportunities -**Example:** +Our design and optimization efforts need feedback from real-world applications. +If you are excited about a local-first future and think Loro can help you, +please contact us directly at [zx@loro.dev](mailto:zx@loro.dev). We're open to +collaboration and ready to help. -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -// Usage example: -text.insert(0, "Hello "); -text.insert(6, "World"); -``` +# FILE: pages/blog/movable-tree.mdx - +--- +title: "Movable tree CRDTs and Loro's implementation" +date: 2024/07/18 +description: This article introduces the implementation difficulties and challenges of Movable Tree CRDTs when collaboration, and how Loro implements it and sorts child nodes. The algorithm has high performance and can be used in production. +image: https://i.ibb.co/nMrgzZJ/DALL-E-2024-01-31-21-29-16-Create-a-black-and-white-illustration-with-a-black-background-that-matche.png +--- - - ```typescript no_run delete(index: number, len: number): void ``` - +# Movable tree CRDTs and Loro's implementation - -Deletes text from the specified position using UTF-16 code units. +import Caption from "../../components/caption"; +import Authors, { Author } from "../../components/authors"; -**Parameters:** + + + -- `index` - Starting UTF-16 code unit position (same as JavaScript string index) -- `len` - Number of UTF-16 code units to delete +![](./movable-tree/movable-tree-cover.png) -**Example:** +This article introduces the implementation difficulties and challenges of Movable Tree CRDTs when collaboration, and how Loro implements it and sorts child nodes. The algorithm has high performance and can be used in production. -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello 😀 World"); -text.delete(6, 2); // Delete emoji (2 UTF-16 units) -text.delete(5, 1); // Delete space before World -``` +## Background - +In distributed systems and collaborative software, managing hierarchical relationships is difficult and complex. Challenges arise in resolving conflicts and meeting user expectations when working with the data structure that models movement by combining deletion and insertion. For instance, if a node is concurrently moved to different parents in replicas, it may lead to the unintended creation of duplicate nodes with the same content. Because the node is deleted twice and created under two parents. - -```typescript no_run -mark(range: { start: number, end: number }, key: string, value: Value): void -``` - +Currently, many software solutions offer different levels of support and functionality for managing hierarchical data structures in distributed environments. The key variation among these solutions lies in their approaches to handling potential conflicts. - -Applies formatting to a text range. Marks can be configured to expand/stop at edges via configTextStyle(); see [Text](/docs/tutorial/text) for mark behavior. +### Conflicts in Movable Trees -**Parameters:** +A movable tree has 3 primary operations: creation, deletion, and movement. Consider a scenario where two peers independently execute various operations on their respective replicas of the same movable tree. Synchronizing these operations can lead to potential conflicts, such as: -- `range` - The range to format -- `key` - Style attribute name -- `value` - Style value +- The same node was deleted and moved +- The same node was moved under different nodes +- Different nodes were moved, resulting in a cycle +- The ancestor node is deleted while the descendant node is moved -**Example:** +#### Deletion and Movement of the Same Node -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello World"); -doc.configTextStyle({ bold: { expand: "after" } }); -text.mark({ start: 0, end: 5 }, "bold", true); -``` +![Deletion and Movement of the Same Node](./movable-tree/move-delete-dark.png) - +This situation is relatively easy to resolve. It can be addressed by applying one of the operations while ignoring the other based on the timestamp in the distributed system or the application's specific requirements. Either approach yields an acceptable outcome. - -```typescript no_run -unmark(range: { start: number, end: number }, key: string): void -``` - - -Removes formatting from a text range. For how conflicting edits on marks resolve, see [Text](/docs/tutorial/text). +#### Moving the Same Node Under Different Parents + +![Moving the Same Node Under Different Parents](./movable-tree/move-same-node-dark.png) -**Parameters:** +Merging concurrent movement operations of the same node is slightly more complex. Different approaches can be adopted depending on the application: -- `range` - The range to unformat -- `key` - Style attribute to remove +- Delete the node and create copies of nodes under different parent nodes. Subsequent operations then treat these nodes independently. This approach is acceptable when node uniqueness is not critical. +- Allow the node have two edges pointing to different parents. However, this approach breaks the fundamental tree structure and is generally not considered acceptable. +- Sort all operations, then apply them one by one. The order can be determined by timestamps in a distributed system. Providing the system maintains a consistent operation sequence, it ensures uniform results across all peers. -**Example:** +#### Movement of Different Nodes Resulting in a Cycle -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello World"); -text.mark({ start: 0, end: 5 }, "bold", true); -text.unmark({ start: 0, end: 5 }, "bold"); -``` +![cycle](./movable-tree/cycle-dark.png) - +Concurrent movement operations that cause cycles make the conflict resolution of movable trees complex. Matthew Weidner listed several solutions to resolve cycles in his [blog](https://mattweidner.com/2023/09/26/crdt-survey-2.html#forests-and-trees). - -```typescript no_run -toDelta(): Delta[] -``` - - -Converts text to Delta format (Quill-compatible). +> 1. Error. Some desktop file sync apps do this in practice ([Martin Kleppmann et al. (2022)](https://doi.org/10.1109/TPDS.2021.3118603) give an example). +> 2. Render the cycle nodes (and their descendants) in a special “time-out” zone. They will stay there until some user manually fixes the cycle. +> 3. Use a server to process move ops. When the server receives an op, if it would create a cycle in the server’s own state, the server rejects it and tells users to do likewise. This is [what Figma does](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/#syncing-trees-of-objects). Users can still process move ops optimistically, but they are tentative until confirmed by the server. (Optimistic updates can cause temporary cycles for users; in that case, Figma uses strategy (2): it hides the cycle nodes.) +> 4. Similar, but use a [topological sort](https://mattweidner.com/2023/09/26/crdt-survey-2.html#topological-sort) (below) instead of a server’s receipt order. When processing ops in the sort order, if an op would create a cycle, skip it [(Martin Kleppmann et al. 2022)](https://doi.org/10.1109/TPDS.2021.3118603). +> 5. For forests: Within each cycle, let `B.parent = A` be the edge whose `set` operation has the largest LWW timestamp. At render time, “hide” that edge, instead rendering `B.parent = "none"`, but don’t change the actual CRDT state. This hides one of the concurrent edges that created the cycle. +> • To prevent future surprises, users’ apps should follow the rule: before performing any operation that would create or destroy a cycle involving a hidden edge, first “affirm” that hidden edge, by performing an op that sets `B.parent = "none"`. +> 6. For trees: Similar, except instead of rendering `B.parent = "none"`, render the previous parent for `B` - as if the bad operation never happened. More generally, you might have to backtrack several operations. Both [Hall et al. (2018)](http://dx.doi.org/10.1145/3209280.3229110) and [Nair et al. (2022)](https://arxiv.org/abs/2103.04828) describe strategies along these lines. -**Returns:** Array of Delta operations +#### Ancestor Node Deletion and Descendant Node Movement -**Example:** +![Ancestor Node Deletion and Descendant Node Movement](./movable-tree/move_chlid_delete_parent_dark.png) -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello World"); -text.mark({ start: 0, end: 5 }, "bold", true); -const delta = text.toDelta(); -// [{ insert: "Hello", attributes: { bold: true } }, { insert: " World" }] -``` +The most easily overlooked scenario is moving descendant nodes when deleting an ancestor node. If all descendant nodes of the ancestor are deleted directly, users may easily misunderstand that their data has been lost. - +### How Popular Applications Handle Conflicts - -```typescript no_run -sliceDelta(start: number, end: number): Delta[] -``` - - -Returns a Quill-style Delta for a subsection of the text, using UTF-16 indices. Useful for copying a styled span. Use `sliceDeltaUtf8` if you need UTF-8 byte offsets instead. +Dropbox is a file data synchronization software. Initially, Dropbox treated file movement as a two-step process: deletion from the original location followed by creation at a new location. However, this method risked data loss, especially if a power outage or system crash occurred between the delete and create operations. -**Parameters:** +Today, when multiple people move the same file concurrently and attempt to save their changes, Dropbox detects a conflict. In this scenario, it typically saves one version of the original file and creates a new ["conflicted copy"](https://help.dropbox.com/organize/conflicted-copy) for the changes made by one of the users. -- `start` - Start UTF-16 code unit index (inclusive) -- `end` - End UTF-16 code unit index (exclusive) +![Solution for conflicts when moving files with Dropbox](./movable-tree/dropbox_move.gif) -**Example:** + + The image shows the conflict that occurs when A is moved to the B folder and B + is moved to the A folder concurrently. + -```ts threeslash -import { LoroDoc } from "loro-crdt"; -import { expect } from "expect"; -const doc = new LoroDoc(); -doc.configTextStyle({ - bold: { expand: "after" }, - comment: { expand: "none" }, -}); -const text = doc.getText("text"); +Figma is a real-time collaborative prototyping tool. They consider tree structures as the most complex part of the collaborative system, as detailed in their [blog post about multiplayer technology](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/#syncing-trees-of-objects). To maintain consistency, each element in Figma has a "parent" attribute. The centralized server plays a crucial role in ensuring the integrity of these structures. It monitors updates from various users and checks if any operation would result in a cycle. If a potential cycle is detected, the server rejects the operation. -text.insert(0, "Hello World!"); -text.mark({ start: 0, end: 5 }, "bold", true); -text.mark({ start: 6, end: 11 }, "comment", "greeting"); +However, due to network delays and similar issues, there can be instances where updates from users temporarily create a cycle before the server has the chance to reject them. Figma acknowledges that this situation is uncommon. Their [solution](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/#syncing-trees-of-objects) is straightforward yet effective: they temporarily preserve this state and hide the elements involved in the cycle. This approach lasts until the server formally rejects the operation, ensuring both the stability of the system and a seamless user experience. -const snippet = text.sliceDelta(1, 8); -expect(snippet).toStrictEqual([ - { insert: "ello", attributes: { bold: true } }, - { insert: " " }, - { insert: "Wo", attributes: { comment: "greeting" } }, -]); -``` +
+ ![An animation that demonstrates how Figma resolves + conflicts.](./movable-tree/figma-tree.gif) +
-
+ + An animation that demonstrates how + [Figma](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/#syncing-trees-of-objects) + resolves conflicts. + - -```typescript no_run -sliceDeltaUtf8(start: number, end: number): Delta[] -``` - - -Returns a Quill-style Delta for a subsection of the text using **UTF-8 byte offsets**. Choose this when your offsets come from UTF-8 encoded buffers. +## Movable Tree CRDTs -**Parameters:** +The applications mentioned above use movable trees and resolve conflicts based on centralized solutions. Another alternative approach to collaborative tree structures is using Conflict-free Replicated Data Types (CRDTs). While initial CRDT-based algorithms were challenging to implement and incurred significant storage overhead as noted in prior research, such as [Abstract unordered and +ordered trees CRDT](https://arxiv.org/pdf/1201.1784.pdf) or [File system on CRDT](https://arxiv.org/pdf/1207.5990.pdf), but continual optimization and improvement have made several CRDT-based tree synchronization algorithms suitable for certain production environments. This article highlights two innovative CRDT-based approaches for movable trees. The first is presented by Martin Kleppmann et al. in their work **_[A highly-available move operation for replicated trees](https://martin.kleppmann.com/2021/10/07/crdt-tree-move-operation.html)_** and the second by Evan Wallace in his **_[CRDT: Mutable Tree Hierarchy](https://madebyevan.com/algos/crdt-mutable-tree-hierarchy/)_**. -- `start` - Start byte offset (inclusive) -- `end` - End byte offset (exclusive) +### A highly-available move operation for replicated trees -**Example:** +This paper unifies the three operations used in trees (creating, deleting, and moving nodes) into a move operation. The move operation is defined as a four-tuple `Move t p m c`, where `t` is the operation's unique and ordered timestamp such as [`Lamport timestamp`](https://en.wikipedia.org/wiki/Lamport_timestamp), `p` is the parent node ID, `m` is the metadata associated with the node, and `c` is the child node ID. -```ts threeslash -import { LoroDoc } from "loro-crdt"; -import { expect } from "expect"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hi 👋"); +If all nodes of the tree do not contain `c`, this is a **creation** operation that creates a child node `c` under parent node `p`. Otherwise, it is a **move** operation that moves `c` from its original parent to the new parent `p`. Additionally, node deletion is elegantly handled by introducing a designated `TRASH` node; moving a node to `TRASH` implies its deletion, with all descendants of `TRASH` considered deleted. But they remain in memory to prevent concurrent editing from moving them to other nodes. In order to handle the previously mentioned situation of deleting ancestor nodes and moving descendant nodes concurrently. -const enc = new TextEncoder(); -const start = enc.encode("Hi ").length; // 3 bytes -const end = enc.encode("Hi 👋").length; // 7 bytes +In the three potential conflicts mentioned earlier, since deletion is also defined as a move operation, **deleting and moving the same node** is transformed into two move operations, leaving only two remaining problems: -const delta = text.sliceDeltaUtf8(start, end); -expect(delta).toStrictEqual([{ insert: "👋" }]); -``` +- **Moving the same node under different parents** +- **Moving different nodes, creating a cycle** - +Logical timestamps are added so that all operations can be linearly ordered, thus the first conflict can be avoided as they can be expressed as two operations in sequence rather than concurrently for the same node. Therefore, in modeling a Tree using only move operations, the only exceptional case in concurrent editing would be creating a cycle, and operations causing a cycle are termed **unsafe operations**. - -```typescript no_run -applyDelta(delta: Delta[]): void -``` - - -Applies Delta operations to the text. +This algorithm sorts all move operations according to their timestamps. It can then sequentially apply each operation. Before applying, the algorithm detects cycles to determine whether an operation is safe. If the operation creates a cycle, we ignore the unsafe operation to ensure the correct structure of the tree. -**Parameters:** -- `delta` - Array of Delta operations +Based on the above approach, the consistency problem of movable trees becomes the following two questions: -**Example:** +1. How to introduce global order to operations +2. How to apply a remote operation that should be inserted in the middle of an existing sorted sequence of operations -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.applyDelta([ - { insert: "Hello", attributes: { bold: true } }, - { insert: " World" }, -]); -``` +#### Globally Ordered Logical Timestamps - +[Lamport Timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp) can determine the causal order of events in a distributed system. Here's how they work: each peer starts with a counter initialized to `0`. When a local event occurs, the counter is increased by `1`, and this value becomes the event's Lamport Timestamp. When peer `A` sends a message to peer `B`, `A` attaches its Lamport Timestamp to the message. Upon receiving the message, peer `B` compares its current logical clock value with the timestamp in the message and updates its logical clock to the larger value. - -```typescript no_run -update(text: string, options?: { timeoutMs?: number; useRefinedDiff?: boolean }): void -``` - - -Updates the current text to the target text using Myers' diff algorithm. +To globally sort events, we first look at the Lamport Timestamps: smaller numbers mean earlier events. If two events have the same timestamp, we use the unique ID of the peer serves as a tiebreaker. -**Parameters:** -- `text` - New text content -- `options` - Update options - - `timeoutMs` - Optional timeout for the diff computation - - `useRefinedDiff` - Use refined diff for better quality on long texts +#### Apply a Remote Operation -**Example:** +An op's safety depends on the tree's state when applied, avoiding cycles. Insertion requires evaluating the state formed by all preceding ops. For remote updates, we may need to: -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); +1. Undo recent ops +2. Insert the new op +3. Reapply undone ops -text.insert(0, "Hello"); -text.update("Hello World", { timeoutMs: 100 }); -``` +This ensures proper integration of new ops into the existing sequence. - +##### Undo Recent Ops - -```typescript no_run -updateByLine(text: string, options?: { timeoutMs?: number; useRefinedDiff?: boolean }): void -``` - - -Line-based update that's faster for large texts (less precise than `update`). +Since we've modeled all operations on the tree as move operations, undoing a move operation involves either moving the node back to its old parent or undoing the operation that created this node. To enable quick undoing, we cache and record the **old parent** of the node before applying each move operation. -```typescript no_run -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); +##### Apply the Remote Op -text.insert(0, "Line A\nLine C"); -text.updateByLine("Line A\nLine B\nLine C"); -``` +Upon encountering an unsafe operation, disregarding its effects prevents the creation of a cycle. Nevertheless, it's essential to record the operation, as the safety of an operation is determined **dynamically**. For instance, if we receive and sort an update that deletes another node causing the cycle prior to this operation, the operation that was initially unsafe becomes safe. Additionally, we need to mark this unsafe operation as ineffective, since during undo operations, it's necessary to query the **old parent** node, which is the target parent of the last effective operation in the sequence targeting this node. - +##### Reapply Undone Ops - -```typescript no_run -getCursor(pos: number, side?: Side): Cursor | undefined +Cycles only occur when receiving updates from other peers, so the undo-do-redo process is also needed at this time. When receiving a new op: + +```jsx +function apply(newOp) + // Compare the ID of the new operation with existing operations + if largerThanExistingOpId(newOp.id, oplog) + // If the new operation's ID is greater, apply it directly + oplog.applyOp(newOp) + else + // If the new operation's ID is not the greatest, undo operations until it can be applied + undoneOps = oplog.undoUtilCanBeApplied(newOp) + oplog.applyOp(newOp) + // After applying the new operation, redo the undone operations to maintain sequence order + oplog.redoOps(undoneOps) ``` - - -Gets a stable cursor position that survives edits. -**Parameters:** +- If the new operation depends on an op that has not been encountered locally, indicating that some inter-version updates are still missing, it is necessary to temporarily cache the new op and wait to apply it until the missing updates are received. +- Compare the new operation with all existing operations. If the `opId` of the new operation is greater than that of all existing operations, it can be directly applied. If the new operation is safe, record the parent node of the target node as the old parent node, then apply the move operation to change the current state. If it is not safe, mark this operation as ineffective and ignore the operation's impact. +- If the new opId is sorted in the middle of the existing sequence, it is necessary to pop the operations that are sorted later from the sequence one by one, and undo the impact of this operation, which means moving back to the child of the old parent node, until the new operation can be applied. After applying the new operation, reapply the undone nodes in sequence order, ensuring that all operations are applied in order. -- `pos` - Position in the text -- `side` - Cursor affinity (-1, 0, or 1) +The following animated GIF demonstrates the process executed by `Peer1`: -**Returns:** Cursor object or undefined +1. Received `Peer0` creating node `A` with the `root` node as its parent. +2. Received `Peer0` creating node `B` with `A` as its parent. +3. Created node `C` with `A` as its parent and synchronized it with `Peer0`. +4. Moved `C` to have `B` as its parent. +5. Received `Peer0`'s moving `B` to have `C` as its parent. -**Example:** +
+ ![](./movable-tree/undo-do-redo.gif) +
-```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello World"); +The queue at the top right of the animation represents the order of local operations and newly received updates. The interpretation of each element in each `Block` is as follows: -const cursor = text.getCursor(5); -// Cursor remains valid even after edits -``` +
+ ![](./movable-tree/explain.png) +
-
+A particular part of this process to note is the two operations with `lamport timestamps` of `0:3` and `1:3`. Initially, the `1:3` operation moving `C` to `B` was created and applied locally, followed by receiving `Peer0`'s `0:3` operation moving `B` to `C`. In `lamport timestamp` order, `0:3` is less than `1:3` but greater than `1:2` (with peer as the tiebreaker when counters are equal). To apply the new op, the `1:3` operation is undone first, moving `C` back to its old parent `A`, then `0:3` moving `B` to `C` is applied. After that, `1:3` is redone, attempting to move `C` to `B` again (the old parent remains `A`, omitted in the animation). However, a cycle is detected during this attempt, preventing the operation from taking effect, and the state of the tree remains unchanged. This completes an `undo-do-redo` process. - -```typescript no_run -toString(): string -``` - - -Converts to plain text string. +### CRDT: Mutable Tree Hierarchy -**Returns:** Plain text content +Evan Wallace has developed an innovative algorithm that enables each node to track all its historical parent nodes, attaching a counter to each recorded parent. The count value of a new parent node is 1 higher than that of all the node's historical parents, indicating the update sequence of the node's parents. The parent with the highest count is considered the current parent node. -**Example:** +During synchronization, this parent node information is also synced. If a cycle occurs, a heuristic algorithm reattaches the nodes causing the cycle back to the nearest historical parent node that won't cause a cycle and is connected to the root node, thus updating the parent node record. This process is repeated until all nodes causing cycles are reattached to the tree, achieving all replica synchronization of the tree structure. The demo in [Evan's blog](https://madebyevan.com/algos/crdt-mutable-tree-hierarchy/) clearly illustrates this process. -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello World"); -const plainText = text.toString(); -``` +As Evan summarized at the end of the article, this algorithm does not require the expensive `undo-do-redo` process. However, each time a remote move is received, the algorithm needs to determine if all nodes are connected to the root node and reattach the nodes causing cycles back to the tree, which can perform poorly when there are too many nodes. - +I established a [benchmark](https://github.com/Leeeon233/movable-tree-crdt) to compare the performance of the movable tree algorithms. - -```typescript no_run -charAt(pos: number): string -``` - - -Gets the character at a specific UTF-16 code unit position. +## Movable Tree CRDTs implementation in Loro -**Parameters:** +Loro implements the algorithm proposed by Martin Kleppmann et al., **_[A highly-available move operation for replicated trees](https://martin.kleppmann.com/2021/10/07/crdt-tree-move-operation.html)_**. On one hand, this algorithm has high performance in most real world scenarios. On the other hand, the core `undo-do-redo` process of the algorithm is highly similar to how Eg-walker (Event Graph Walker) applies remote updates in Loro. Introduction about **Eg-walker** can be found in our previous [blog](https://www.loro.dev/blog/loro-richtext#brief-introduction-to-replayable-event-graph). -- `pos` - UTF-16 code unit position +Movable tree has been introduced in detail, but there is still another problem of tree structure that has not been solved. For movable tree, in some real use cases, we still need the capability to sort child nodes. This is necessary for outline notes or layer management in graphic design softwares. Users need to adjust node order and sync it to other collaborators or devices. -**Returns:** Character at position +We integrated the `Fractional Index` algorithm into Loro and combined it with the movable tree, making the child nodes of the movable tree sortable. -**Example:** +There are many introductions to `Fractional Index` on the web, You can read more about `Fractional Index` in the [Figma blog](https://www.figma.com/blog/realtime-editing-of-ordered-sequences) or [Evan blog](https://madebyevan.com/algos/crdt-fractional-indexing/). In simple terms, `Fractional Index` assigns a sortable value to each object, and if a new insertion occurs between two objects, the `Fractional Index` of the new object will be between the left and right values. What we want to speak about more here is how to deal with potential conflicts brought by `Fractional Index` in CRDTs systems. -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello"); -const char = text.charAt(1); // "e" -``` +### Potential Conflicts in Child Node Sorting - +As our applications are in a distributive condition, when multiple peers insert new nodes in the same position, the same `Fractional Index` would be assigned to these differing content but same position nodes. When updates from the remote are applied to local, conflicts arise as the same `Fractional Index` is encountered. - -```typescript no_run -slice(start: number, end: number): string -``` - - -Extracts a section of the text using UTF-16 code unit positions. +In Loro, we retain these identical `Fractional Index` and use `PeerID` (unique ID of every Peer) as the tie-breaker for the relative order judgment of the same `Fractional Index`. -**Parameters:** +![](./movable-tree/FI-and-PeerID-dark.png) -- `start` - Start UTF-16 code unit index -- `end` - End UTF-16 code unit index (exclusive) +Although this solved the sorting problem among the same `Fractional Index` nodes from different peers, it impacted the generation of new `Fractional Index` as we cannot generate a new `Fractional Index` between two same ones. We use two methods to solve this problem: -**Returns:** Sliced text +1. The first method, as stated in Evan's blog, we could add a certain amount of jitter to each generated `Fractional Index`, (for the ease of explanation, all examples below take decimal fraction as the `Fractional Index`) for example, when generating a new `Fractional Index` between 0 and 1, it should have been 0.5, but through random jitters, it could be `0.52712`, `0.58312`, `0.52834`, etc., thus significantly reducing the chance of same `Fractional Index` appearing. +2. If the situation arises where the same `Fractional Index` is present on both sides, we can handle this problem by resetting these `Fractional Index`. For example, if we need to insert a new node between `0.7@A` and `0.7@B` (which indicates `Fractional Index` @ `PeerID`), instead of generating a new `Fractional Index` between 0.7 and 0.7, we could assign two new `Fractional Index` respectively for the new node and the `0.7@B` node between 0.7 and 1, which could be understood as an extra move operations. -**Example:** +![](./movable-tree/same-FI-dark.png) -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello 😀 World"); +### Implementation and Encoding Size -const slice1 = text.slice(0, 5); // "Hello" -const slice2 = text.slice(6, 8); // "😀" (emoji spans 6-8) -const slice3 = text.slice(9, 14); // "World" -``` +Introducing `Fractional Index` brings the advantage of node sequence. What about encoding size? - +Loro uses [drifting-in-space](https://github.com/drifting-in-space/fractional_index) `Fractional Index` implementation based on `Vec`, which is base 256. In other words, you need to continuously insert 128 values forward or backward from the default value to increase the byte size of the `Fractional Index` by 1. The worst storage overhead case, such as inserting new values alternately each time. For example, the initial sequence is `ab`, insert `c` between `a` and `b`, then insert `d` between `c` and `b`, then `e` between `c` and `d`, like: - -```typescript no_run -splice(pos: number, len: number, s: string): string +```js no_run +ab // [128] [129, 128] +acb // [128] [129, 127, 128] [129, 128] +acdb // [128] [129, 127, 128] [129, 127, 129, 128] [129, 128] +acedb // [128] [129, 127, 128] [129, 127, 129, 127, 128] [129, 127, 129, 128] [129, 128] ``` - - -Replaces text at a position with new content. +a new operation would cause an additional byte to be needed. But such a situation is very rare. -**Parameters:** +Considering that potential conflicts wouldn't appear frequently in most applications, Loro simply extended the implementation, the original implementation produced new `Fractional Index` in `Vec` by only increasing or decreasing 1 in certain index to achieve relative sorting. The simple jitter solution was added, by appending random bytes in length of jitter value to `Fractional Index`. To enable jitter in js, you can use `doc.setFractionalIndexJitter(number)` with a positive value. But this will increase the encoding size slightly, but each `Fractional Index` only adds `jitter` bytes. If you want to generate `Fractional Index` at the same position with 99% probability without conflict, the relationship between `jitter` settings and the maximum number of concurrent edits `n` will be: -- `pos` - Start position -- `len` - Length to delete -- `s` - String to insert + + + + + + + + + + + + + + + + + + + + + +
+ jitter + + max num of concurrent edits +
+ 1 + + 3 +
+ 2 + + 37 +
+ 3 + + 582 +
-**Returns:** Deleted text +When there are numerous `Fractional Indexes`, there will be many common prefixes after being sorted, when Loro encodes these `Fractional Indexes`, prefix optimization would be implemented. Each `Fractional Index` only saves the amount of same prefix bits and remaining bytes with the previous one, which further downsizes the overall encoding size. -**Example:** +### Related work -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); -text.insert(0, "Hello World"); +Other than using Fractional Index, there are other movable list CRDT that can make sibling nodes of the tree in order. One of these algorithms is Martin Kleppmann's [Moving Elements in List CRDTs](https://martin.kleppmann.com/2020/04/27/papoc-list-move.html), which has been used in Loro's [Movable List](https://www.loro.dev/docs/tutorial/list). -// Usage example: -const deleted = text.splice(6, 5, "Loro"); // returns "World" -``` -
+In comparison, the implementation of `Fractional Index` solution is simpler, and no stable position representation is provided for child nodes when modeling nodes in a tree, otherwise, the overall tree structure would be too complex. However, the `Fractional Index` has the problem of [interleaving](https://vlcn.io/blog/fractional-indexing#interleaving), but this is acceptable when some only need relative order and do not require strict sequential semantics, such as figma layer items, multi-level bookmarks, etc. - -```typescript no_run -push(s: string): void -``` - - -Appends text to the end of the document. +## Benchmark -**Parameters:** +We conducted performance benchmarks on the Movable Tree implementation by Loro, including scenarios of random node movement, switching to historical versions, and performance under extreme conditions with significantly deep tree structures. The results indicate that it is capable of supporting real-time collaboration and enabling seamless historical version checkouts. -- `s` - String to append +| Task | Time | Setup | +| :---------------------------------------- | :----- | :------------------------------------------------ | +| Move 10000 times randomly | 28 ms | Create 1000 nodes first | +| Switch to different versions 1000 times | 153 ms | Create 1000 nodes and move 1000 times first | +| Switch to different versions 1000 times in a tree with depth of 300 | 701 ms | The new node is a child node of the previous node | -**Example:** + + Test environment: M2 Max CPU, you can find the bench code + [here](https://github.com/loro-dev/loro/blob/main/crates/loro-internal/benches/tree.rs). + -```ts threeslash -import { LoroDoc } from "loro-crdt"; -const doc = new LoroDoc(); -const text = doc.getText("text"); +## Usage -text.push("Hello"); -text.push(" World"); +```tsx +import { Loro, LoroTree, LoroTreeNode, LoroMap } from "loro-crdt"; + +let doc = new Loro(); +let tree: LoroTree = doc.getTree("tree"); +let root: LoroTreeNode = tree.createNode(); +// By default, append to the end of the parent node's children list +let node = root.createNode(); +// Specify the child's position +let node2 = root.createNode(0); +// Move `node2` to be the last child of `node` +node2.move(node); +// Move `node` to be the first child of `node2` +node.move(node2, 0); +// Move the node to become the root node +node.move(); +// Move the node to be positioned after another node +node.moveAfter(node2); +// Move the node to be positioned before another node +node.moveBefore(node2); +// Retrieve the index of the node within its parent's children +let index = node.index(); +// Get the `Fractional Index` of the node +let fractionalIndex = node.fractionalIndex(); +// Access the associated data map container +let nodeData: LoroMap = node.data; ``` - +### Demo - -```typescript no_run -iter(callback: (char: string) => boolean): void -``` - - -Iterates over each character in the text. +We developed a simulated Todo app with data synchronization among multiple peers using Loro, including the use of `Movable Tree` to represent subtask relationships, `Map` to represent various attributes of tasks, and `Text` to represent task titles, etc. In addition to basic creation, moving, modification, and deletion, we also implemented version switching based on Loro. You can drag the scrollbar to switch between all the historical versions that have been operated on. -**Parameters:** +