Persisting ops
Ops now flow from every edit, but they vanish on reload. This lesson makes the document durable: the full CRDT snapshot goes to the docs store, each op is queued in the outbox, and reopening a note rebuilds it with NoteDoc.from_snapshot.
What we’re building
Section titled “What we’re building”Two write paths and one read path, all through the typed db.ts wrapper from the IndexedDB module:
- On every edit — persist the new snapshot to
docs(the authoritative state) and append the returned ops tooutbox(the not-yet-synced queue), and refresh thenoteslist projection. - On open — read the note’s record from
docsand reconstruct the live document withNoteDoc.from_snapshot(actorId, snapshot).
The editor keeps rendering from title() and text() exactly as before; it never knows persistence happened.
flowchart TD Edit["edit → Op[]"] --> Snap["doc.snapshot()"] Edit --> Ops["each op"] Snap -->|"putDoc"| Docs[("docs store<br/>{id, actorId, snapshot}")] Ops -->|"enqueueOp"| Outbox[("outbox store<br/>{seq, noteId, op}")] Docs -->|"getDoc + from_snapshot"| Reload["reopen note"]We store two representations on purpose, because they answer two different questions. The snapshot answers “what does this note say right now?” — it’s what we load to show the note instantly, without replaying history. The outbox answers “what have I changed that the server hasn’t seen yet?” — it’s the exact op stream we will push in the sync module.
Could we keep only the op log and replay it? Yes, and a pure event-sourced design would. But replaying every op since creation to open a note gets slower with every edit, and the whole point of local-first is that opening a note is instant and offline. The snapshot is a cache of the folded-up state; the outbox is the tail we still owe the network. Keeping both is the pragmatic middle: fast reads and a precise sync record.
from_snapshot is what makes the snapshot trustworthy. Because it reconstructs a NoteDoc with the same actor id and internal counters, edits made after a reload produce ops that slot correctly into the same causal history — a restored note is indistinguishable from one that was never closed.
Pros & cons
Section titled “Pros & cons”Persisting a snapshot on every edit, vs. persisting only the op log.
- Pros: Opening a note is one
getDocand onefrom_snapshot— constant time, no replay. Survives reload and browser restart. - Cons: The snapshot carries CRDT metadata (tombstones from deleted text never shrink without compaction — the honest caveat from the CRDT module), so the stored blob grows with edit history.
A separate outbox queue, vs. deriving un-synced ops from the snapshot at push time.
- Pros: The outbox is an explicit, ordered, append-only record of exactly what to send; draining it is trivial and idempotent-friendly.
- Cons: It’s a second store to keep consistent with
docs— both writes should happen together, or a crash between them could drop an op from the queue while keeping it in state.
Set it up
Section titled “Set it up”1. apps/web/src/db.ts — the accessors we rely on
Section titled “1. apps/web/src/db.ts — the accessors we rely on”These come from the IndexedDB Foundation → module; the shapes must match the contract exactly. The outbox key seq autoincrements, so enqueueOp supplies no key.
import { openDB, type DBSchema } from 'idb';import type { Op } from './store';
interface NotesDB extends DBSchema { notes: { key: string; value: { id: string; title: string; updatedAt: number } }; docs: { key: string; value: { id: string; actorId: string; snapshot: unknown } }; outbox: { key: number; value: { seq: number; noteId: string; op: Op }; autoIncrement: true; }; meta: { key: string; value: { key: string; value: unknown } };}
const dbp = openDB<NotesDB>('offlinenotes', 1, { upgrade(db) { db.createObjectStore('notes', { keyPath: 'id' }); db.createObjectStore('docs', { keyPath: 'id' }); db.createObjectStore('outbox', { keyPath: 'seq', autoIncrement: true }); db.createObjectStore('meta', { keyPath: 'key' }); },});
export async function putDoc(rec: { id: string; actorId: string; snapshot: unknown }) { return (await dbp).put('docs', rec);}export async function getDoc(id: string) { return (await dbp).get('docs', id);}export async function putNote(rec: { id: string; title: string; updatedAt: number }) { return (await dbp).put('notes', rec);}export async function enqueueOp(noteId: string, op: Op) { // seq is autoIncremented by IndexedDB; omit it from the value. return (await dbp).add('outbox', { noteId, op } as never);}2. crates/crdt/src/lib.rs — snapshot and from_snapshot
Section titled “2. crates/crdt/src/lib.rs — snapshot and from_snapshot”The CRDT module never gave NoteDoc a way to serialize or rebuild itself — add it now, since this is the first lesson that needs it. docs stores whatever snapshot() returns and hands it back to from_snapshot unchanged, so the wire shape only has to make sense to Rust. Reconstructing the Lamport counter as the max timestamp seen in either the title or the body means the very next local edit after a reload still outranks everything already in the document.
#[derive(Serialize, Deserialize, Clone, Debug)]struct Snapshot { title: Lww<String>, body: Rga,}
#[wasm_bindgen]impl NoteDoc { /// Serialize the whole document — title register and body — for storage. pub fn snapshot(&self) -> JsValue { let snap = Snapshot { title: self.title.clone(), body: self.body.clone() }; serde_wasm_bindgen::to_value(&snap).unwrap() }
/// Rebuild a document from a stored snapshot, with the Lamport clock /// seeded past every timestamp it contains. pub fn from_snapshot(actor_id: String, snapshot: JsValue) -> Result<NoteDoc, JsValue> { let snap: Snapshot = serde_wasm_bindgen::from_value(snapshot)?; let counter = snap.title.ts_counter().max(snap.body.max_counter()); Ok(NoteDoc { actor_id, counter, title: snap.title, body: snap.body }) }}Two tiny accessors feed from_snapshot’s counter math — add them next to Lww::merge and Rga::tombstone respectively:
impl<T: Clone> Lww<T> { fn ts_counter(&self) -> u32 { self.ts.0 }}impl Rga { fn max_counter(&self) -> u32 { self.elems.iter().map(|e| e.id.0).max().unwrap_or(0) }}Rebuild the WASM package so snapshot/from_snapshot land in pkg/:
cd crates/crdtwasm-pack build --target web3. apps/web/src/store.ts — persist on change
Section titled “3. apps/web/src/store.ts — persist on change”Extend the store so the mutators save before they return. A single helper folds the three writes together: snapshot to docs, ops to outbox, projection to notes. Wrapping edit and persist in one method keeps docs and outbox in step.
import init, { NoteDoc } from '../../../crates/crdt/pkg/crdt.js';import { getDoc, putDoc, putNote, enqueueOp } from './db';
async function persist(id: string, actorId: string, doc: NoteDoc, ops: Op[]) { // snapshot() serialises the whole CRDT via serde-wasm-bindgen. await putDoc({ id, actorId, snapshot: doc.snapshot() }); for (const op of ops) await enqueueOp(id, op); await putNote({ id, title: doc.title(), updatedAt: Date.now() });}4. apps/web/src/store.ts — open from snapshot
Section titled “4. apps/web/src/store.ts — open from snapshot”openNote reads docs; if a record exists it rebuilds with from_snapshot, otherwise it starts a fresh NoteDoc. The edit methods now await persist(...) before firing onChange.
export async function openNote(id: string, onChange: () => void): Promise<OpenNote> { await ensureWasm(); const actorId = await getActorId();
const stored = await getDoc(id); const doc = stored ? NoteDoc.from_snapshot(stored.actorId, stored.snapshot) : new NoteDoc(actorId); const docActor = stored?.actorId ?? actorId;
const applied = async (ops: Op[]) => { await persist(id, docActor, doc, ops); onChange(); return ops; };
return { id, setTitle: (t) => applied(toOps(doc.set_title(t))) as unknown as Op[], insertText: (i, s) => applied(toOps(doc.insert_text(i, s))) as unknown as Op[], deleteText: (i, n) => applied(toOps(doc.delete_text(i, n))) as unknown as Op[], title: () => doc.title(), text: () => doc.text(), };}The edit methods are async now (they await IndexedDB). Have the editor
await note.insertText(...)so a keystroke isn’t lost if two edits race; IndexedDB writes are queued, but your in-memorydocmust stay the ordering authority.
Verify
Section titled “Verify”Build the WASM package and the app, then prove durability by hand. In the DevTools console:
const note = await window.__store.openNote('n1', () => {});await note.insertText(0, 'buy milk');await note.setTitle('Groceries');// Reload the page (Cmd-R), then:const again = await window.__store.openNote('n1', () => {});console.log(again.title(), '/', again.text());// Groceries / buy milkInspect the stores in DevTools → Application → IndexedDB → offlinenotes:
docs → { id: "n1", actorId: "…", snapshot: {…} }outbox → { seq: 1, noteId: "n1", op: {…} }, { seq: 2, … }, …notes → { id: "n1", title: "Groceries", updatedAt: 173… }Then run the production build to confirm everything type-checks and bundles:
pnpm --filter web build# ✓ built in <time>Check your understanding:
- Why store both a snapshot in
docsand the ops inoutbox, instead of just one of them? - What does
NoteDoc.from_snapshot(actorId, snapshot)reconstruct that a freshnew NoteDoc(actorId)would not have? - Why should the snapshot write and the outbox appends happen together for a single edit?
- What grows without bound in the stored snapshot, and which later technique (named in the CRDT module) would bound it?
A note is now durable: every edit saves its snapshot to docs, queues its ops in outbox, and refreshes the notes projection; reopening rebuilds the exact document with from_snapshot. The outbox is a precise record of what we still owe the network — but nothing reads it yet, and the app still needs a live server to work. Next, Service Worker (Offline) → makes the app shell itself load with no network, so OfflineNotes truly runs offline.