Loro CRDT System
Status: Shipped Crates: crates/infrastructure/sqlite/src/loro_utils.rs,
crates/infrastructure/sqlite/src/workspace/page/block_content.rs Frontend: apps/desktop/src-react/lib/loro-sync/
Overview
Section titled “Overview”Loro CRDT is the storage format for all block content in Inklings. Each block’s content is stored as a serialized
LoroDoc binary snapshot (BLOB) in the blocks.content_loro column. This BLOB is the source of truth for content
— the text columns (blocks.content and pages.raw_markdown) are derived projections used only for FTS5 search
indexing and debugging. The CRDT binary is never re-serialized from text; it passes through the entire stack untouched.
This architecture provides:
- Operation-level history — every character insertion and deletion is recorded in the LoroDoc
- Cross-session undo/redo — undo history survives app restarts because it lives in the CRDT graph, not in editor memory
- Multi-device sync — devices exchange incremental Loro updates; merge is automatic via the Fugue algorithm
- Per-cell isolation — in grid layouts, each cell has its own independent LoroDoc with separate undo/redo and save lifecycle
Architecture
Section titled “Architecture”Data Flow
Section titled “Data Flow”Key Invariant: Binary Passthrough
Section titled “Key Invariant: Binary Passthrough”The frontend’s LoroDoc snapshot is the authoritative artifact. The backend stores it unchanged — it never deserializes the BLOB, never re-creates a LoroDoc from text, and never inspects the CRDT contents. Text is sent alongside the binary as a derived projection for search indexing. This invariant ensures the full operation graph (version vectors, author IDs, edit history) survives the round-trip.
Violating this invariant (e.g., calling text_to_loro_bytes() on the text parameter instead of storing the binary)
silently destroys CRDT history. Content appears correct as visible text, but undo/redo resets, sync produces
full-document conflicts, and version history collapses to a single operation. See the solution doc for the full
post-mortem.
Layer Boundaries
Section titled “Layer Boundaries”The loro Rust crate is an infrastructure-only dependency. The domain layer’s Block.content field remains a plain
String. CRDT serialization concerns are confined to:
| Layer | CRDT Awareness |
|---|---|
| Domain | None. Block.content is String. |
| Application | Treats BLOB as opaque &[u8]. Use cases validate non-empty, then pass through. |
| Infrastructure (SQLite) | Uses loro_utils.rs for fallback synthesis and format versioning. Stores/retrieves BLOBs. |
| Framework (Tauri) | Thin adapter — receives Vec<u8> from frontend, passes to use case unchanged. |
| Frontend | Full CRDT awareness — creates, syncs, exports, and imports LoroDoc instances. |
Key Design Decisions
Section titled “Key Design Decisions”1. LoroDoc per Block, Not per Page
Section titled “1. LoroDoc per Block, Not per Page”Each block has its own independent LoroDoc. This is essential for grid layouts where a single page may contain multiple editor cells. Per-block isolation ensures:
- Undo in one cell does not affect another cell
- Each cell saves independently with its own debounce timer
- Loading a page does not require deserializing all cells’ CRDT history at once
- Sync operates at block granularity
2. Dual Content Columns
Section titled “2. Dual Content Columns”Every save writes to three locations in a single transaction:
| Column | Type | Purpose |
|---|---|---|
blocks.content_loro | BLOB | Source of truth — full LoroDoc snapshot |
blocks.content | TEXT | Debug column — materialized text alongside the BLOB |
pages.raw_markdown | TEXT | FTS5 search index — regenerated from ALL blocks on each save |
The raw_markdown regeneration iterates all blocks for the page, not just the saved block, ensuring the FTS index
reflects the complete page content.
3. Format Versioning
Section titled “3. Format Versioning”Each BLOB is stored with a content_loro_version integer column (currently 1). This enables migrations if the
Loro crate version or container structure changes. The version constant lives in loro_utils.rs as
LORO_FORMAT_VERSION.
4. Fallback for Pre-Migration Content
Section titled “4. Fallback for Pre-Migration Content”When loading a block with a NULL or empty content_loro BLOB (pre-migration data), the repository synthesizes a fresh
LoroDoc from the blocks.content TEXT column via text_to_loro_bytes(). This is the only acceptable use of
text_to_loro_bytes() at read time — it creates a new CRDT graph with a single insert operation, which is correct for
legacy content that has no prior CRDT history.
5. TipTap Integration via loro-prosemirror
Section titled “5. TipTap Integration via loro-prosemirror”The LoroSync TipTap extension registers two ProseMirror plugins:
LoroSyncPlugin— bidirectional synchronization between ProseMirror document state and the LoroDoc. All ProseMirror schema attributes (including WikiLink’spageNameandpageSlug) are synced automatically.LoroUndoPlugin— replaces TipTap’s built-in undo/redo with Loro-backed undo/redo. This means undo history persists across sessions because it is stored in the LoroDoc, not in ephemeral ProseMirror plugin state.
The extension overrides Mod-z, Mod-y, and Mod-Shift-z keyboard shortcuts to route through Loro’s undo/redo
functions.
Key Code Paths
Section titled “Key Code Paths”Save Path (Editor to SQLite)
Section titled “Save Path (Editor to SQLite)”- TipTap editor — user edits trigger ProseMirror transactions
- LoroSyncPlugin — transactions are applied to the in-memory LoroDoc
- Debounced save (1.5s after last edit) calls
exportSnapshot(doc)which runsdoc.export({ mode: "snapshot" })and converts theUint8Arraytonumber[]for JSON serialization over Tauri IPC - Tauri command
save_block_content(orsave_block_content_by_idfor grid cells) receives bothcontent_bytes: Vec<u8>andcontent_text: String SaveBlockContentUseCasevalidates non-empty BLOB, checksPagesWritecapability, delegates to repositorySqlitePageRepository::save_block_content_blob_implin a single transaction:- Updates
blocks.content,blocks.content_loro,blocks.content_loro_version - Regenerates
pages.raw_markdownfrom all blocks for FTS5 - Updates
pages.updated_at
- Updates
Load Path (SQLite to Editor)
Section titled “Load Path (SQLite to Editor)”- Tauri command
get_block_content_snapshot(or_by_id) is called on page/cell mount GetBlockSnapshotUseCasechecksPagesReadcapability, delegates to repositorySqlitePageRepository::get_block_content_blob_implreadsblocks.content_loroBLOB:- If BLOB is present and non-empty: returns it directly
- If BLOB is NULL/empty (pre-migration): synthesizes from
blocks.contentTEXT viatext_to_loro_bytes() - If no row found: returns
None
- Frontend
loadLoroDoc()convertsnumber[]toUint8Array, callsLoroDoc.fromSnapshot(). Returnsnullon empty/invalid input for graceful fallback. - LoroDoc is passed to
LoroSync.configure({ doc }), andLoroSyncPlugininitializes the ProseMirror editor state from the LoroDoc contents
Sync Path (Incremental Updates)
Section titled “Sync Path (Incremental Updates)”loro_utils.rs provides helpers for multi-device sync:
get_version_vector(snapshot)— extracts the version vector from a snapshot for tracking sync stateexport_loro_updates(snapshot, since_version)— exports only operations newer than a given version vectorapply_loro_updates(snapshot, updates)— merges incremental updates into a snapshot, producing a new snapshot
These are used by the sync infrastructure (crates/infrastructure/sqlite/src/sync/block_storage.rs) to exchange
incremental CRDT updates between devices rather than full snapshots.
Grid Layout Path (Per-Cell CRDT)
Section titled “Grid Layout Path (Per-Cell CRDT)”For pages with CSS Grid layouts, each named grid area maps to a block with its own LoroDoc:
GridLayoutcomponent renders a CSS Grid container fromlayout.template- Each
BlockCellindependently loads its LoroDoc viaget_block_content_snapshot_by_id - Each cell has its own TipTap editor instance with its own
LoroSyncextension - Saves are per-cell via
save_block_content_by_id, debounced independently - Empty areas show an
EmptyCellplaceholder — blocks are created on-demand viacreate_block_in_area
Error Handling
Section titled “Error Handling”| Scenario | Behavior |
|---|---|
| Empty BLOB on save | SaveBlockContentUseCase rejects with Validation error before reaching repository |
| Corrupt BLOB on load | GetBlockSnapshotUseCase surfaces CrdtDecodeError to frontend for user notification |
| NULL/empty BLOB on load | Repository synthesizes LoroDoc from TEXT column (pre-migration fallback) |
Invalid snapshot in loadLoroDoc() | Returns null, logs warning; caller creates fresh LoroDoc |
| Page not found on save | Repository returns NotFound error |
| Block not found (by-id save) | Repository returns NotFound error after checking rows updated = 0 |
Loro decode failure in loro_utils.rs | LoroUtilsError::DecodeSnapshot with original error message |
| Version vector decode failure | LoroUtilsError::DecodeVersionVector — prevents silent sync corruption |
| Update import failure | LoroUtilsError::ImportUpdate — prevents partial merge |
Related
Section titled “Related”- ADR-006: Foundational decision to adopt Loro
- CRDT passthrough: Binary passthrough invariant post-mortem
- Per-cell CRDT: Grid layout isolation architecture
- Block content system: Broader block content architecture (content types, FTS)
- Sync system: Uses Loro incremental updates for multi-device sync
- Loro documentation: https://loro.dev/docs
Was this page helpful?
Thanks for your feedback!