Snapshots & Persistence

Most non-trivial applications need to survive a page reload: column widths, filter selections, the cursor position, the active tab. Causl ships two packages that solve the survival problem at different scales without compromising the engine's glitch-freedom guarantees. @causl/persistence persists individual inputs to a storage adapter on every change; @causl/devtools captures and replays whole-graph snapshots through exportSnapshot and importSnapshot. This page walks through both, plus the schema-migration story and the typed error taxonomy every adopter eventually runs into.

What gets persisted, and what doesn't

Both packages enforce the same boundary: only inputs are written to disk. Derived values are deliberately excluded, and this is not a shortcut — it is a correctness property the engine depends on.

Recall that for any derived node, the value at time t is a pure function of its inputs at the same t: derived(t) = f(b₁(t), …, bₙ(t)). If you serialise a derived value to disk and reload it later, the on-disk cache is either redundant (the formula and inputs agree, so recomputing is cheap and correct) or stale (the formula or an upstream input changed in between, so the cache disagrees with what the engine would compute right now). The stale case is §3 glitch-freedom violated by construction. So Causl persists inputs and recomputes everything else on rehydration.

Detailed explanation: the model/controller split. @causl/persistence is scoped tighter than "everything that's an input." It exists to persist editor-controller state — what the cursor is doing, what's selected, the column widths, the active filter — not the user's information model (the spreadsheet's cells, the booking records, the asset list). Controller identifiers live in a separate namespace (controller:gridSelection:wb1) with session-scoped lifetimes, which is what makes round-tripping them through localStorage safe. Authoritative model state belongs above this layer — synced through your server, your CRDT, or your application's own write-ahead log — and the package deliberately refuses to be the place that decision gets made.

1. Wrap an input with persistedInput

The simplest case: a column-width slider whose value should survive a reload. persistedInput creates an InputNode<T> on the graph, hydrates its initial value from storage if anything is there, and writes the value back on every commit that actually changes it.

import { createCausl } from '@causl/core' import { persistedInput, localStorageAdapter } from '@causl/persistence' const graph = createCausl() const columnWidth = persistedInput( graph, 'colWidth', // NodeId 200, // initial value (used only when storage is empty) { key: 'xldatagrid:col-width', storage: localStorageAdapter(), version: 1, }, ) // Use it like any other InputNode. graph.commit('drag-resize', tx => { tx.set(columnWidth, 240) })

The key is what gets used in the storage adapter; the NodeId identifies the node on the graph. They serve different audiences — humans reading localStorage versus the engine's Phase A precondition checks — and conflating them would force a compromise neither audience wins. Treat them as independent.

The return value is just an InputNode

persistedInput is not a wrapper that intercepts reads or writes. It registers a regular InputNode on your graph and attaches a subscribeCommits listener that mirrors changes to disk. Anything you can do with an InputNode from @causl/core — depend on it in a derived, mutate it in a commit, observe it with useCauslNode in React — you can do with this. Persistence is additive.

2. Choose a storage adapter

A StorageAdapter is the minimum surface a backing store has to provide: synchronous get, set, and delete against string keys. The package ships two, and the contract is small enough that writing a third for IndexedDB or chrome.storage takes about a dozen lines.

export interface StorageAdapter { get(key: string): string | null set(key: string, value: string): void delete(key: string): void }

localStorageAdapter()

Wraps the browser's localStorage. Catches and swallows QuotaExceededError and the access errors private-mode browsers throw, so a full disk or a hardened browser never crashes your app — failures surface through the typed error channel described below, not through unhandled exceptions. In an SSR or non-DOM environment (Node without jsdom, a Worker without the API) it falls back to the in-memory adapter, so the same code path is safe to import everywhere.

memoryAdapter(initial?)

A Map-backed adapter. The intended use is tests (deterministic, isolated, no global teardown needed) and SSR, where there is no window.localStorage to persist into anyway. The optional initial argument seeds the map, which is useful for testing the hydration path:

import { memoryAdapter } from '@causl/persistence' const storage = memoryAdapter({ 'xldatagrid:col-width': JSON.stringify({ version: 1, value: 320 }), }) const columnWidth = persistedInput(graph, 'colWidth', 200, { key: 'xldatagrid:col-width', storage, version: 1, }) console.log(graph.read(columnWidth)) // 320 — hydrated from storage

IndexedDB and other async stores

The StorageAdapter contract is synchronous on purpose. Causl's only mutation API is graph.commit, which advances time by exactly one per call; there is no fractional time and no concurrent-mutation API. Hydration therefore has to produce a fully-loaded initial value before the first commit runs, or that commit would observe a half-loaded snapshot — exactly the kind of partially-applied state the engine is built to refuse.

The supported pattern for IndexedDB or chrome.storage is to hydrate a hot cache before constructing the graph, then expose the hot cache through the synchronous adapter interface:

// 1. Hydrate the hot cache asynchronously, before graph construction. const hotCache: Record<string, string> = {} const keys = await db.getAll('prefs') for (const { key, value } of keys) hotCache[key] = value // 2. Wrap it in the sync adapter shape. const storage = memoryAdapter(hotCache) // 3. Build the graph; persistedInput sees a fully-loaded cache. const graph = createCausl() const columnWidth = persistedInput(graph, 'colWidth', 200, { key: 'xldatagrid:col-width', storage, version: 1, }) // 4. Mirror writes back to IndexedDB out-of-band. graph.subscribeCommits(c => { for (const id of c.changedNodes) { const value = hotCache[/* derive key from id */] if (value !== undefined) db.put('prefs', { key, value }) } })

3. Boot-write skip — why cold starts don't round-trip

A naive implementation of "write on change" using graph.subscribe would fire on the initial value too. Cold-starting an app with a hundred persisted inputs would round-trip a hundred identical envelopes back to disk in the first millisecond — a write storm that achieves nothing.

persistedInput avoids this by wiring its write path to graph.subscribeCommits and filtering on commit.changedNodes. The initial hydration is not a commit; the first write only happens when a real graph.commit mutates the input. Cold starts are silent. This is a SPEC §5.2 commitment, not an implementation detail — every adopter has run into the storm at least once, and the filter is what makes the package usable at scale.

4. Schema evolution with migrate

Inputs change shape over time. A column-width input that started as number might become { pixels: number; locked: boolean } in a later version. Without a migration story, that change is a footgun: either you keep both shapes alive forever, or you silently overwrite the old shape on first load and destroy user state.

The on-disk envelope is { version, value }. On hydration, persistedInput compares the stored version against the configured one. If they match, the stored value is used as-is. If they differ, the optional migrate callback runs:

const columnWidth = persistedInput( graph, 'colWidth', { pixels: 200, locked: false }, { key: 'xldatagrid:col-width', storage: localStorageAdapter(), version: 2, migrate(stored, storedVersion) { if (storedVersion === 1) { // v1 was a bare number. return { pixels: stored as number, locked: false } } throw new Error(`unknown version: ${storedVersion}`) }, }, )

If migrate is absent and the version differs, the in-memory value falls back to initial and a migrate-missing error fires through onError. By default (preserveOnError: true) the existing envelope is left on disk, so a later build that ships a real migrate can still recover the value — the package will not silently destroy user data while you figure out the upgrade path. Set preserveOnError: false only when you genuinely want the old drop-on-failure behaviour.

5. The PersistenceError taxonomy

Every failure branch in the load and write paths dispatches a tagged PersistenceError through onError. The default handler is a single console.warn per failure — loud but non-fatal — so behaviour remains audible during rollout. Pass a structured logger (or a no-op, if you really mean it) when you're ready.

The taxonomy is five tags, designed for switch (err.kind) exhaustiveness:

kindWhenCarries cause
parseStored bytes are not JSON, or not a valid envelope.yes
migrate-threwCaller supplied migrate and it threw.yes
migrate-missingStored version differs and no migrate is configured.no
serialiseThe current value couldn't be JSON.stringify-ed (cycles, BigInt, …).yes
quotaStorage rejected the write (full disk, private mode, browser quota).yes
import type { PersistenceError } from '@causl/persistence' function handler(err: PersistenceError) { switch (err.kind) { case 'parse': log.warn('corrupt envelope', { key: err.key, cause: err.cause }) return case 'migrate-threw': log.error('migration threw', err) return case 'migrate-missing': log.error('schema bumped without migrate', { key: err.key, expected: err.expectedVersion, stored: err.storedVersion, }) return case 'serialise': case 'quota': log.warn(err.kind, err) return } }

Detailed explanation: why migrate-threw and migrate-missing are separate tags. Earlier versions of the package encoded these as a single migrate tag with an optional cause?: unknown — present when the supplied migrator threw, absent when no migrator was configured. That is the SPEC §17.4 anti-pattern of "X may or may not have Y", an optional field that is a state machine in disguise. With the split, a switch on err.kind narrows correctly and no consumer body needs a runtime cause !== undefined check to know which mode fired.

6. Snapshots: capturing the whole graph

persistedInput handles a single value at a time, which is the right granularity for UI preferences. The wrong granularity for "let me attach the user's full state to this bug report" or "let me round-trip this session to an integration test." For that, reach for @causl/devtools:

import { exportSnapshot, importSnapshot } from '@causl/devtools' const snap = exportSnapshot(graph, { inputs: [columnWidth, activeFilter, cursorPos], intent: 'before-edit', }) // Later, on a fresh graph with matching inputs registered: importSnapshot(freshGraph, snap, { inputs: new Map([ [columnWidth.id, freshColumnWidth], [activeFilter.id, freshActiveFilter], [cursorPos.id, freshCursorPos], ]), })

The export captures every requested input value at the current GraphTime, packages them into a versioned envelope, and hands you back a plain object you can JSON.stringify. The JSON convenience wrappers exportSnapshotJson and importSnapshotJson do that for you.

The import applies every recognised input in a single commit — observers fire once, derived values recompute once, and the destination graph lands on a byte-identical state to the source (modulo the derivation closures, which must agree on both sides for the equivalence to hold). Inputs whose ids the destination doesn't register are silently skipped, so callers can import only the subset they recognise.

Schema versioning

The envelope carries a schema number. importSnapshot throws if it doesn't recognise the schema — failing loudly is the only acceptable behaviour when the alternative is silently misinterpreting bytes. Bump the schema when the envelope shape changes; older payloads will throw rather than be misread.

When persistence is the wrong tool

@causl/persistence is for editor-controller state on a single client. Things it is not, and won't become:

For full-graph snapshots, @causl/devtools is the right tool for capture and replay — bug reports, integration-test fixtures, time-travel debugging — and the wrong tool for live multi-process synchronisation, where you want @causl/sync and a real transport.

Next steps