Skip to content
Documentation GitHub
Data Flow

Write Path

How content flows from an editor keystroke to persistent storage and all side effects.



Code: apps/desktop/src-react/lib/loro-sync/LoroSyncExtension.ts

The TipTap editor runs LoroSyncPlugin from loro-prosemirror. Every ProseMirror transaction is intercepted by the plugin and applied to a LoroDoc CRDT instance. This keeps the CRDT document in sync with the visible editor state at all times.

Two plugins are registered:

  • LoroSyncPlugin — bidirectional sync between ProseMirror document state and the LoroDoc. Captures each transaction and applies it as a CRDT operation.
  • LoroUndoPlugin — replaces ProseMirror’s built-in undo with Loro-backed undo/redo. This preserves the full undo history across sessions because the CRDT itself holds the operation log.

The LoroDoc is per-block: each cell in a grid layout has its own independent LoroDoc. Blocks are loaded individually and saved individually — they are never shared across cells.

The React save handler extracts two representations from the LoroDoc:

  • blob — the full CRDT snapshot bytes via doc.export({ mode: 'snapshot' }). This is the source of truth. It contains the complete operation history and can be merged with snapshots from other devices.
  • text — materialized plain text via doc.getText(...). This is a derived projection used only for search indexing. It is never used to reconstruct the editor state.

Code: apps/desktop/src-tauri/src/commands/page.rs

The frontend calls invoke("save_block_content", { slug, blob, text }). Tauri deserializes the arguments and dispatches to the save_block_content command handler.

The command validates the input at the system boundary (non-empty blob, valid slug format), resolves the active workspace and permission guard, then delegates to the use case.

Code: crates/application/src/page/save_block_content.rs

The use case requires the PagesWrite capability, validates that the blob is non-empty, then calls PageRepository::save_block_content_blob(slug, blob, text).

This is the single point of entry for all block content writes from the editor. The use case contains no storage logic — it only enforces pre-conditions and delegates.

Code: crates/infrastructure/sqlite/src/workspace/page/block_content.rs

SqlitePageRepository::save_block_content_blob writes three columns atomically:

TableColumnContent
blockscontent_loroRaw LoroDoc CRDT snapshot bytes (BLOB)
blockscontentMaterialized text (for debug inspection)
pagesraw_markdownFull-page text for FTS5 index triggers

The write is a single SQL transaction. FTS5 index update triggers fire automatically when raw_markdown is updated on the pages table.

Critical invariant: The CRDT blob is always written as received from the frontend. It is never re-serialized from the materialized text. The text is a derived projection, not the source of truth.

Code: apps/desktop/src-tauri/src/side_effects.rs

After the use case returns, WriteEffectCoordinator::on_block_content_saved fires idempotent side effects. Side effects are fire-and-forget — if any fail, the write still succeeds.

task.events.try_send(EmbeddingEvent::EmbedPage { page_id })

EmbeddingManager receives the event via a bounded channel and schedules the page for re-embedding. The manager deduplicates pending events (a HashSet prevents queuing the same page multiple times). When the embedding runs, it reads the current raw_markdown from SQLite, generates a 768-dimensional vector via the ONNX Runtime (snowflake-arctic-embed-m-v2.0), and upserts the result into the page_embeddings table.

RecordEventUseCase writes an EventLogEntry with EntityType::Block and EventType::Updated to the event_log table. This is used for the history timeline visible in the UI.

The sync queue is populated by the WriteEffectCoordinator after every page/block mutation. When SaveBlockContentUseCase succeeds, the coordinator enqueues a sync entry via SyncQueueRepository::enqueue(). The sync engine drains this queue in background cycles and pushes updates to Supabase when cloud sync is configured.

The sync queue, use cases, and engine are implemented in the application layer (crates/application/src/sync/) with SQLite storage in the infrastructure layer (crates/infrastructure/sqlite/src/sync/). The write side-effect coordinator wires the call site in the Tauri save command.


FailureBehavior
Empty blobValidation error returned to frontend before any DB write
SQLite write failureInternal error; no side effects fire
Embedding queue fulltry_send drops the event; next save will re-queue
Event log failureLogged and discarded; write is not rolled back

  • Sync System — Sync queue populated after every block save
  • Embedding System — Re-embedding triggered by on_block_content_saved
  • Search System — FTS5 index updated via SQLite triggers on raw_markdown

Was this page helpful?