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 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 at GraphTime t 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 whose kind !== 'input', and persistedInput() rejects derived parameters at the type level with the AssertNotDerived guard.

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:

  1. Schema-version mismatch. Only version 1 is supported on the current wire format.
  2. Schema-hash mismatch. When the snapshot's schemaHash does 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 via subscribeCommits and filters on commit.changedNodes.includes(id). A naïve graph.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:

AdapterWhen 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:

KindWhere it fires
parseStored bytes are not valid JSON — corrupt envelope.
migrate-threwCaller supplied migrate; it threw. cause is the thrown value.
migrate-missingStored version differs and no migrate was supplied. No cause — there is no exception to attach.
serialiseJSON.stringify threw on the post-commit value (cycles, BigInt, …).
quotaThe 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: why migrate-threw and migrate-missing are separate tags. An earlier shape encoded the two states as the presence or absence of an optional cause?: unknown on a single migrate tag — a state machine in disguise of an optional field. Consumers had to runtime-check for 'cause' in err to know which mode had fired. The split means switch (err.kind) narrows directly to the right branch and a structured logger can pull err.cause on 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:

Cheat sheet

NeedUse
Save the whole document to a filegraph.snapshot()JSON.stringify
Open a document from a fileJSON.parsegraph.hydrate
Time-travel into a historical tickgraph.snapshotAt(t) (returns retained or evicted)
Persist one UI preferencepersistedInput(graph, id, initial, opts)
Evolve a stored shapeBump version, supply migrate
Handle failures structurallyPass 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.

← Part 5: React integration Part 7: Testing →