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/persistenceis 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 throughlocalStoragesafe. 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:
| kind | When | Carries cause |
|---|---|---|
parse | Stored bytes are not JSON, or not a valid envelope. | yes |
migrate-threw | Caller supplied migrate and it threw. | yes |
migrate-missing | Stored version differs and no migrate is configured. | no |
serialise | The current value couldn't be JSON.stringify-ed (cycles, BigInt, …). | yes |
quota | Storage 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
migratetag with an optionalcause?: 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, aswitchonerr.kindnarrows correctly and no consumer body needs a runtimecause !== undefinedcheck 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:
- Multi-user synchronisation. If two tabs in two browsers need to agree on a value, that's a sync problem and belongs above this layer — a server, a CRDT, your own write-ahead log. Two
localStorageinstances will not converge. - Server-authoritative model persistence. The user's information model (the spreadsheet cells, the booking records) lives in a different identifier namespace and has different lifetime guarantees. Persisting it through this package would conflate the namespaces and confuse both audiences.
- Persisting derived values. Refused at the type level — passing a
DerivedNodetopersistedInputfails to typecheck. See SPEC §13 for the boundary argument.
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
- API reference for
@causl/persistence - API reference for
@causl/devtools - The Async Resources page for the
@causl/syncstory when persistence isn't enough.