Documentation / Tutorial / Part 6
Part 6: Saving & Loading
A graph that forgets its inputs every time the tab closes is half a product. This page covers the two persistence
surfaces causl ships: whole-graph snapshot and restore via @causl/core, and per-input persistence with
schema-evolution support via @causl/persistence. Both write inputs only — derived nodes are pure
functions of inputs at the same GraphTime, so they recompute on rehydration rather than being
shipped to disk.
Why two surfaces?
Persisting state in a reactive engine is not one problem, it is two:
- Whole-graph transfer. SSR handoff, devtools time-travel, "open this document" — you want one envelope, one call to write, one call to read.
- Per-input preferences. Theme, sidebar width, the last filter the user picked. Each input is independent, lives across deployments, and may need a migration when its shape changes.
Whole-graph persistence is a graph.snapshot() / graph.hydrate() pair. Per-input
persistence is the persistedInput() wrapper from @causl/persistence. They are
complementary — most real apps use both.
Detailed explanation: why only inputs. A derived value atGraphTimet is by definition f(b1(t), …, bn(t)) — pure in its inputs at the same tick. Writing a derived to disk would store a cache that goes stale the instant any upstream input or formula changes; on rehydration the cache and the recomputed value would diverge, the §3 glitch-freedom violation the engine was built to refuse. Both persistence surfaces therefore refuse derived inputs structurally:snapshot()walks the registered entries and skips anything whosekind !== 'input', andpersistedInput()rejects derived parameters at the type level with theAssertNotDerivedguard.
1. Whole-graph snapshot and restore
Every graph exposes a snapshot() method that returns a serialisable envelope:
import { createCausl, input, derived } from '@causl/core';
const graph = createCausl();
const count = input(graph, 0, { id: 'count' });
const doubled = derived(graph, g => g.read(count) * 2, { id: 'doubled' });
graph.commit(tx => tx.set(count, 5));
const envelope = graph.snapshot();
// {
// schema: 1,
// time: 1,
// inputs: { count: 5 },
// schemaHash: '…'
// }
localStorage.setItem('myapp.graph', JSON.stringify(envelope));
Note that doubled is not in the envelope. On the receiving side you rebuild the same graph and
hand the envelope to hydrate():
import { createCausl, input, derived } from '@causl/core';
const graph = createCausl();
const count = input(graph, 0, { id: 'count' });
const doubled = derived(graph, g => g.read(count) * 2, { id: 'doubled' });
const raw = localStorage.getItem('myapp.graph');
if (raw) {
graph.hydrate(JSON.parse(raw));
}
graph.read(doubled); // 10 — recomputed from the restored input.
What hydrate actually does
hydrate is not a parallel pipeline. It routes the snapshot's input map through the same Phase A–H
commit pipeline that graph.commit drives, advancing now by exactly one tick, recomputing
affected derivations in topological order, publishing a single Commit with
intent: 'hydrate' and originatedAt: snap.time, and firing subscribers as usual. The §5
"one mutation pipeline" contract holds.
Schema gates
hydrate throws HydrationSchemaError before the commit pipeline runs in two cases:
- Schema-version mismatch. Only version
1is supported on the current wire format. -
Schema-hash mismatch. When the snapshot's
schemaHashdoes not match the digest of the live graph's registered node id-set. This is the structural rejection that prevents server/client id-set drift from silently tearing engine state. Inputs in the snapshot whose id is not registered locally are silently dropped; inputs registered locally but absent from the snapshot keep their current value.
Because both arms throw before entering the pipeline, a rejected hydrate never appears in commitLog
and never fires subscribers. You can wrap the call in try / catch and fall back to defaults without
worrying about partial state.
2. Per-input persistence with persistedInput
Snapshot/hydrate is the right tool for whole-document transfer. For independent UI preferences — theme, sidebar
width, the currency selector — it is overkill. @causl/persistence registers an input whose value is
hydrated from storage on construction and written back to storage on each commit that actually changes the
value.
import { createCausl } from '@causl/core';
import { persistedInput, localStorageAdapter } from '@causl/persistence';
const graph = createCausl();
const storage = localStorageAdapter();
const theme = persistedInput<'light' | 'dark'>(graph, 'ui.theme', 'dark', {
key: 'myapp.ui.theme',
storage,
version: 1,
});
graph.commit(tx => tx.set(theme, 'light'));
// localStorage now holds: { "version": 1, "value": "light" }
On the next page load the wrapper reads myapp.ui.theme from localStorage, parses the
envelope, and constructs the input with 'light' instead of the 'dark' default — without
a round-trip write on cold start.
Detailed explanation: the boot-write skip. The write path subscribes viasubscribeCommitsand filters oncommit.changedNodes.includes(id). A naïvegraph.subscribe(node, …)would fire on the initial value too, round-tripping the same envelope back to disk every cold start. The filter means cold start is read-only.
Storage adapters
The adapter contract is intentionally tiny — three synchronous methods:
interface StorageAdapter {
get(key: string): string | null;
set(key: string, value: string): void;
delete(key: string): void;
}
Sync-only is a deliberate constraint: hydration must finish before the first commit runs, or that
commit would observe a half-loaded snapshot. The engine has no fractional time and no concurrent-mutation API.
Async stores (chrome.storage, IndexedDB) wrap a sync hot cache that implements this
interface.
Two adapters ship in the box:
| Adapter | When to use it |
|---|---|
localStorageAdapter() | Browser preference persistence. Returns memoryAdapter when localStorage is undefined (SSR safety). |
memoryAdapter(initial?) | Tests, SSR, anything that just needs an in-process map. |
Writing your own adapter is a 20-line job for any sync-capable KV store. The disciplined narrow surface is also
a leak-fence: persistedInput never sees the rest of your Graph — it gets only
input, subscribeCommits, and read (the PersistenceGraph
capability narrow).
The envelope
Every value written to storage is wrapped:
{ "version": 1, "value": <your value> }
The envelope is what makes schema evolution possible — it pins the on-disk shape to a version number so the next deploy can recognise older payloads instead of misreading them.
3. Schema evolution with migrate
Shapes change. Your theme started as a string, then became { name: 'dark', accent: 'cyan' }. Users
already have the old envelope on disk. The migrate option converts older payloads to the current
shape on load:
type ThemeV1 = 'light' | 'dark';
type ThemeV2 = { name: 'light' | 'dark'; accent: string };
const theme = persistedInput<ThemeV2>(
graph,
'ui.theme',
{ name: 'dark', accent: 'cyan' },
{
key: 'myapp.ui.theme',
storage,
version: 2,
migrate(stored, storedVersion) {
if (storedVersion === 1) {
return { name: stored as ThemeV1, accent: 'cyan' };
}
throw new Error(`unknown theme version ${storedVersion}`);
},
},
);
The contract is exact: migrate(storedValue, storedVersion) is called only when
storedVersion !== options.version, and only when an envelope is actually present. Its return value
replaces initial for that boot.
What if there is no migrate?
migrate is optional. When the stored version differs and no migrate is supplied, the
wrapper falls back to initial in memory but preserves the existing on-disk envelope
(the preserveOnError: true default). The version skew is reported through onError as a
migrate-missing failure. The deliberate non-destructive default lets you ship a build that introduces
a new version without a migrate handler, notice the warning in the field, and ship a fix —
without having silently overwritten user data in the meantime. Set preserveOnError: false only when
you genuinely prefer drop-on-failure semantics.
4. PersistenceError — the typed discriminated union
Every error branch in the load and write paths dispatches a typed PersistenceError through
onError. The default sink is a single console.warn per failure so rollouts stay audible
without crashing the host.
type PersistenceError =
| { kind: 'parse'; key: string; cause: unknown }
| { kind: 'migrate-threw'; key: string; expectedVersion: number; storedVersion: number; cause: unknown }
| { kind: 'migrate-missing'; key: string; expectedVersion: number; storedVersion: number }
| { kind: 'serialise'; key: string; cause: unknown }
| { kind: 'quota'; key: string; cause: unknown };
The five tags map one-to-one to the five places persistence can break:
| Kind | Where it fires |
|---|---|
parse | Stored bytes are not valid JSON — corrupt envelope. |
migrate-threw | Caller supplied migrate; it threw. cause is the thrown value. |
migrate-missing | Stored version differs and no migrate was supplied. No cause — there is no exception to attach. |
serialise | JSON.stringify threw on the post-commit value (cycles, BigInt, …). |
quota | The adapter's set threw — quota exceeded, private mode, disk full. |
Exhaustive handling
Because the union is tagged, switch (err.kind) narrows correctly and the type checker enforces
exhaustiveness:
import type { PersistenceError } from '@causl/persistence';
function reportPersistence(err: PersistenceError): void {
switch (err.kind) {
case 'parse':
logger.warn('Corrupt envelope', { key: err.key, cause: err.cause });
return;
case 'migrate-threw':
logger.error('Migration threw', {
key: err.key,
from: err.storedVersion,
to: err.expectedVersion,
cause: err.cause,
});
return;
case 'migrate-missing':
logger.warn('No migrate handler for stored version', {
key: err.key,
from: err.storedVersion,
to: err.expectedVersion,
});
return;
case 'serialise':
logger.error('Value not JSON-safe', { key: err.key, cause: err.cause });
return;
case 'quota':
ui.toast('Storage full — preferences not saved.');
return;
default: {
const _exhaustive: never = err;
throw new Error(`unhandled PersistenceError: ${JSON.stringify(_exhaustive)}`);
}
}
}
persistedInput(graph, 'ui.theme', 'dark', {
key: 'myapp.ui.theme',
storage,
version: 2,
migrate,
onError: reportPersistence,
});
Detailed explanation: whymigrate-threwandmigrate-missingare separate tags. An earlier shape encoded the two states as the presence or absence of an optionalcause?: unknownon a singlemigratetag — a state machine in disguise of an optional field. Consumers had to runtime-check for'cause' in errto know which mode had fired. The split meansswitch (err.kind)narrows directly to the right branch and a structured logger can pullerr.causeon the threw branch without an extra guard. It is also a worked example of the SPEC §17.4 tagged-union discipline: never encode a state machine as an optional field, always use a discriminant.
5. Combining the two surfaces
A typical app uses both. The document model — the user's mental world of cells, formulas, bookings — goes
through snapshot/hydrate on open and save. UI preferences — theme, sidebar width, last
filter — go through persistedInput so they survive across documents and deploys without polluting
the document envelope.
import { createCausl, input, derived } from '@causl/core';
import { persistedInput, localStorageAdapter } from '@causl/persistence';
const graph = createCausl();
const storage = localStorageAdapter();
// Document inputs — included in snapshot()
const title = input(graph, 'Untitled', { id: 'doc.title' });
const cells = input(graph, {}, { id: 'doc.cells' });
// UI preference inputs — independent storage keys
const theme = persistedInput(graph, 'ui.theme', 'dark', { key: 'app.theme', storage, version: 1 });
const sidebar = persistedInput(graph, 'ui.sidebar', 280, { key: 'app.sidebar', storage, version: 1 });
function saveDocument() {
return JSON.stringify(graph.snapshot());
}
function openDocument(json: string) {
graph.hydrate(JSON.parse(json));
}
The two paths do not interfere. persistedInput's write path keys off
commit.changedNodes; the snapshot taken with graph.snapshot() includes both document
and UI inputs (everything serialisable), but on hydrate the document file's snapshot only carries the document
ids it knew about — the UI inputs retain whatever value persistedInput just loaded.
Where this is not the right tool
Two honest limits worth naming up front:
- Large binary blobs. Both surfaces JSON-serialise. If you need to persist megabytes of typed array data, route it through an external blob store and persist the URL in the graph.
-
Multi-tab sync. Neither surface watches the adapter for external writes. Two tabs that both
use
persistedInputagainstlocalStoragewill not see each other's changes until a reload. For multi-tab live sync, layer@causl/syncover a broadcast channel.
Cheat sheet
| Need | Use |
|---|---|
| Save the whole document to a file | graph.snapshot() → JSON.stringify |
| Open a document from a file | JSON.parse → graph.hydrate |
| Time-travel into a historical tick | graph.snapshotAt(t) (returns retained or evicted) |
| Persist one UI preference | persistedInput(graph, id, initial, opts) |
| Evolve a stored shape | Bump version, supply migrate |
| Handle failures structurally | Pass onError, switch (err.kind) |
In the next part of the tutorial we tie everything together with the async resource lifecycle from
@causl/sync — including how to compose persistedInput with remote sources without
tripping the glitch-freedom invariants.