Skip to content

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.

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>;
}
MethodPurpose
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.

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| SESSION

The 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.

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(&current);
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.

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.

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 warmingParentSessionIntent::WarmChild spawns svc.backend.load(&id) to load a child’s document from storage before creating a Hot session.
  • Auto-savestart_autosave_loop(interval) periodically flushes dirty sessions via doc.export_snapshot() and backend.save().
  • Session closeclose_session() always persists the final state.
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)

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 + emit

Doc::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.

  • 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