Persistence
Persistence in the modality library is handled by Doc<S>, a thin wrapper around Loro’s LoroDoc. Only the modality’s Synced state is persisted — ephemeral state, AI state, export state, and session-level runtime data all stay local and are never written to the CRDT document.
Doc<S> — Pure Persistence
Section titled “Doc<S> — Pure Persistence”pub struct Doc<S: Synced> { doc: LoroDoc,}
impl<S: Synced> Doc<S> { fn new(synced: &S) -> Self; fn from_loro_doc(doc: LoroDoc) -> Self; fn synced(&self) -> S; fn commit(&self, synced: &S); fn import(&self, bytes: &[u8]) -> Result<S, ()>; fn export_updates(&self) -> Vec<u8>; fn export_snapshot(&self) -> Vec<u8>;}Methods
Section titled “Methods”| Method | Purpose |
|---|---|
new(synced) | Create a new LoroDoc and write the initial synced state into it. |
from_loro_doc(doc) | Wrap an existing LoroDoc (e.g., loaded from storage). |
synced() | Read the current S from the LoroDoc. |
commit(synced) | Write synced into the LoroDoc, creating a new Loro version. |
import(bytes) | Import remote CRDT updates. Returns the merged synced state. |
export_updates() | Export incremental updates since last export. For sync. |
export_snapshot() | Export the full document. For persistence/backup. |
Doc does not dispatch, emit, or know about Session. It is purely a read/write interface to the LoroDoc.
What Gets Persisted
Section titled “What Gets Persisted”graph LR subgraph Persisted SYNCED["M::Synced"] end subgraph Not Persisted EPH["M::Ephemeral"] AI["AiState"] EXP["ExportState"] CACHE["CompileCache"] SINKS["Sinks"] VER["version"] end
SYNCED --> LORO["LoroDoc"] EPH -.->|local only| SESSION["Session"] AI -.->|local only| SESSION EXP -.->|local only| SESSIONThe Synced trait (implemented by WhiteboardSynced, WorksheetSynced, LessonPlanSynced) defines the CRDT-compatible state shape. It requires PartialEq for change detection and derives from Loro mapping traits (LoroMapWrite, LoroMapRead).
Ephemeral state (M::Ephemeral) defaults via Default::default() on session open. It is rebuilt from user interaction (tool selection, cursor position, selection) and from compiled output.
commit_if_changed
Section titled “commit_if_changed”Inside the dispatch lifecycle, step 4 calls commit_if_changed():
// Inside Session::dispatch():self.recompile();let synced_changed = self.commit_if_changed();self.version += 1;self.emit();if synced_changed { self.fire_on_change(); }The implementation uses PartialEq on the synced state:
fn commit_if_changed(&mut self) -> bool { let current = self.modality.state().synced; let last = self.doc.synced(); if current != last { self.doc.commit(¤t); true } else { false }}This avoids writing to the LoroDoc when nothing changed — important because Loro commits create new versions and trigger sync. If only ephemeral state changed (e.g., tool selection, cursor move), commit_if_changed() returns false and fire_on_change() is skipped.
fire_on_change
Section titled “fire_on_change”Only fires when synced state actually changed (i.e., commit_if_changed() returned true). This triggers CRDT sync outbound: the on_change_callbacks on Session are called, which can export updates and send them to remote peers.
Note that snapshots emit on every dispatch regardless of whether synced state changed. Snapshots reflect the full visible state including ephemeral changes; fire_on_change is specifically for persistence and sync.
SessionServices and StoreBackend
Section titled “SessionServices and StoreBackend”Session-level persistence I/O goes through SessionServices:
pub struct SessionServices { pub backend: Arc<dyn StoreBackend>, // Future: sync_client}StoreBackend is the trait for loading and saving documents:
pub trait StoreBackend: Send + Sync { fn load(&self, id: &str) -> Result<Vec<u8>, StoreError>; fn save(&self, id: &str, bytes: &[u8]) -> Result<(), StoreError>;}This is used for:
- Session open — loading the LoroDoc bytes from storage.
- Child warming —
ParentSessionIntent::WarmChildspawnssvc.backend.load(&id)to load a child’s document from storage before creating a Hot session. - Auto-save —
start_autosave_loop(interval)periodically flushes dirty sessions viadoc.export_snapshot()andbackend.save(). - Session close —
close_session()always persists the final state.
Persistence Flow
Section titled “Persistence Flow”sequenceDiagram participant Open as Session Open participant Backend as StoreBackend participant Doc as Doc<S> participant Dispatch as dispatch() participant AutoSave as Auto-Save Loop participant Close as Session Close
Open->>Backend: load(id) Backend-->>Open: bytes Open->>Doc: from_loro_doc(bytes)
loop Every dispatch Dispatch->>Doc: commit_if_changed() Note over Doc: Only writes if synced != last end
loop Every 30s AutoSave->>Doc: export_snapshot() AutoSave->>Backend: save(id, bytes) end
Close->>Doc: export_snapshot() Close->>Backend: save(id, bytes)CRDT Sync (Import)
Section titled “CRDT Sync (Import)”When remote updates arrive:
let merged_synced = doc.import(&remote_bytes)?;// Update modality state with merged synced*modality.state_mut().synced = merged_synced;// Trigger recompile + emitDoc::import() merges remote CRDT updates into the local LoroDoc and returns the merged synced state. The modality’s synced state is then updated, and the standard recompile/emit path runs.
Related Pages
Section titled “Related Pages”- Session Overview — where Doc and SessionServices live on the Session struct
- Dispatch Lifecycle — commit_if_changed() in the lifecycle flow
- Children — child warming uses StoreBackend
- Snapshots — emit() runs after commit, reflects both synced and ephemeral state