CRDT & Notes Sync
Notes and journal entries use Yjs CRDTs so concurrent edits across devices merge cleanly.
Source of Truth
The Y.Doc is canonical. Markdown is a derived, lossy export — useful for .md interop but not authoritative.
Where Y.Docs Live
The main process owns Y.Doc instances, persists them to disk via y-leveldb (<vault>/leveldb/), and exposes them to the renderer through an IPC provider.
renderer ──Yjs IPC provider──▶ main (Y.Doc) ──y-leveldb──▶ disk
│
└──network sync──▶ /sync/crdt/updatesWhy the Main Process Owns Y.Docs
- Single writer per document avoids merge complexity across renderer windows.
- Persistence via y-leveldb is a Node-side concern.
- Main can broadcast updates to multiple renderer windows (when split view exists).
Open Doc Lifecycle
Main keeps a Y.Doc open while an editor window is attached to it. Sync pulls may also open a Y.Doc without a window so remote updates can be applied, but those sync-only docs are closed again after the pull if they are still inactive.
Inactive docs are capped with least-recently-used eviction. The eviction path only targets docs with zero attached windows, so active editor docs are never evicted. The provider metrics expose the open doc count, encoded size, and per-doc windowCount so memory growth can be observed without inspecting private provider state.
IPC Loop Prevention
Three pieces of metadata prevent feedback loops:
sourceWindowIdon every IPC update.- Y.Doc origin parameter distinguishes local typing, IPC re-application, and network apply.
- Update buffering in
CrdtUpdateQueueorders updates pernoteId.
Hybrid Sync Model
Notes flow through both sync paths:
- Snapshot — periodic full encrypted state, via the
SyncItemHandlerpipeline. Used for new devices, big diffs, and recovery. - Incremental — small Yjs binary updates via
/sync/crdt/updates. Used for live collaboration during a session.
Snapshots are pushed pre-batch so other devices receive correct state before the sync notification reaches them.
Sign-Out / Sign-In Ordering
A sign out → sign in cycle has a sharp ordering rule:
engine.start() # pull from server FIRST
└─ seedExistingCrdtDocs() # fire-and-forget; only fills truly orphaned notesReversing this order causes split-brain: stale markdown seeds Y.Docs with new client IDs, server pull then sees non-trivial state vectors and skips bootstrap, and the device diverges.
CrdtUpdateQueue
A separate queue from SyncQueueManager:
- Handles binary
Uint8Arrayupdates - Respects sequence ordering per
noteId - Buffers updates when the network is paused
BlockNote Compatibility
BlockNote uses Yjs natively. The renderer's BlockNote editor binds to the renderer-side Y.Doc proxy provided by the IPC provider; edits flow through main and back to disk.
Markdown cannot represent arbitrary nested BlockNote paragraphs. Note markdown export and CRDT writeback preserve those unsupported child blocks with hidden nesting markers, then restore them when a note reloads. Inbox note reload also reads BlockNote's saved data-nesting-level HTML metadata so captured note indentation round-trips through the editor. Marker parsing trims imported markdown with a linear scan so malformed or very large note bodies cannot trigger regex backtracking during reload.
Files Worth Knowing
apps/desktop/src/main/sync/
├─ crdt-update-queue.ts
└─ engine.ts # ordering: pull → seed → per-batch push
apps/desktop/src/renderer/src/sync/
├─ yjs-ipc-provider.ts # renderer-side Y.Doc proxy
└─ use-yjs-collaboration.ts # editor hook