Skip to content
Documentation GitHub
Content

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/

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

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.

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:

LayerCRDT Awareness
DomainNone. Block.content is String.
ApplicationTreats 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.
FrontendFull CRDT awareness — creates, syncs, exports, and imports LoroDoc instances.

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

Every save writes to three locations in a single transaction:

ColumnTypePurpose
blocks.content_loroBLOBSource of truth — full LoroDoc snapshot
blocks.contentTEXTDebug column — materialized text alongside the BLOB
pages.raw_markdownTEXTFTS5 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.

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.

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’s pageName and pageSlug) 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.

  1. TipTap editor — user edits trigger ProseMirror transactions
  2. LoroSyncPlugin — transactions are applied to the in-memory LoroDoc
  3. Debounced save (1.5s after last edit) calls exportSnapshot(doc) which runs doc.export({ mode: "snapshot" }) and converts the Uint8Array to number[] for JSON serialization over Tauri IPC
  4. Tauri command save_block_content (or save_block_content_by_id for grid cells) receives both content_bytes: Vec<u8> and content_text: String
  5. SaveBlockContentUseCase validates non-empty BLOB, checks PagesWrite capability, delegates to repository
  6. SqlitePageRepository::save_block_content_blob_impl in a single transaction:
    • Updates blocks.content, blocks.content_loro, blocks.content_loro_version
    • Regenerates pages.raw_markdown from all blocks for FTS5
    • Updates pages.updated_at
  1. Tauri command get_block_content_snapshot (or _by_id) is called on page/cell mount
  2. GetBlockSnapshotUseCase checks PagesRead capability, delegates to repository
  3. SqlitePageRepository::get_block_content_blob_impl reads blocks.content_loro BLOB:
    • If BLOB is present and non-empty: returns it directly
    • If BLOB is NULL/empty (pre-migration): synthesizes from blocks.content TEXT via text_to_loro_bytes()
    • If no row found: returns None
  4. Frontend loadLoroDoc() converts number[] to Uint8Array, calls LoroDoc.fromSnapshot(). Returns null on empty/invalid input for graceful fallback.
  5. LoroDoc is passed to LoroSync.configure({ doc }), and LoroSyncPlugin initializes the ProseMirror editor state from the LoroDoc contents

loro_utils.rs provides helpers for multi-device sync:

  • get_version_vector(snapshot) — extracts the version vector from a snapshot for tracking sync state
  • export_loro_updates(snapshot, since_version) — exports only operations newer than a given version vector
  • apply_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.

For pages with CSS Grid layouts, each named grid area maps to a block with its own LoroDoc:

  1. GridLayout component renders a CSS Grid container from layout.template
  2. Each BlockCell independently loads its LoroDoc via get_block_content_snapshot_by_id
  3. Each cell has its own TipTap editor instance with its own LoroSync extension
  4. Saves are per-cell via save_block_content_by_id, debounced independently
  5. Empty areas show an EmptyCell placeholder — blocks are created on-demand via create_block_in_area
ScenarioBehavior
Empty BLOB on saveSaveBlockContentUseCase rejects with Validation error before reaching repository
Corrupt BLOB on loadGetBlockSnapshotUseCase surfaces CrdtDecodeError to frontend for user notification
NULL/empty BLOB on loadRepository synthesizes LoroDoc from TEXT column (pre-migration fallback)
Invalid snapshot in loadLoroDoc()Returns null, logs warning; caller creates fresh LoroDoc
Page not found on saveRepository returns NotFound error
Block not found (by-id save)Repository returns NotFound error after checking rows updated = 0
Loro decode failure in loro_utils.rsLoroUtilsError::DecodeSnapshot with original error message
Version vector decode failureLoroUtilsError::DecodeVersionVector — prevents silent sync corruption
Update import failureLoroUtilsError::ImportUpdate — prevents partial merge
  • 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?