Write Path
How content flows from an editor keystroke to persistent storage and all side effects.
Overview
Section titled “Overview”Step-by-Step Details
Section titled “Step-by-Step Details”1. Editor Keystroke to LoroDoc
Section titled “1. Editor Keystroke to LoroDoc”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 theLoroDoc. 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.
2. User Saves
Section titled “2. User Saves”The React save handler extracts two representations from the LoroDoc:
blob— the full CRDT snapshot bytes viadoc.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 viadoc.getText(...). This is a derived projection used only for search indexing. It is never used to reconstruct the editor state.
3. Tauri IPC
Section titled “3. Tauri IPC”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.
4. SaveBlockContentUseCase
Section titled “4. SaveBlockContentUseCase”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.
5. SQLite Write
Section titled “5. SQLite Write”Code: crates/infrastructure/sqlite/src/workspace/page/block_content.rs
SqlitePageRepository::save_block_content_blob writes three columns atomically:
| Table | Column | Content |
|---|---|---|
blocks | content_loro | Raw LoroDoc CRDT snapshot bytes (BLOB) |
blocks | content | Materialized text (for debug inspection) |
pages | raw_markdown | Full-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.
6. WriteEffectCoordinator Side Effects
Section titled “6. WriteEffectCoordinator Side Effects”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.
Embedding Pipeline
Section titled “Embedding Pipeline”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.
Event Log
Section titled “Event Log”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.
7. Sync Queue
Section titled “7. Sync Queue”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.
Error Handling
Section titled “Error Handling”| Failure | Behavior |
|---|---|
| Empty blob | Validation error returned to frontend before any DB write |
| SQLite write failure | Internal error; no side effects fire |
| Embedding queue full | try_send drops the event; next save will re-queue |
| Event log failure | Logged and discarded; write is not rolled back |
Related
Section titled “Related”- 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?
Thanks for your feedback!