form-graph

Storage

The store persists INTENT — the user's raw choices, scoped addresses included — not resolved state. Reload, and the same resolver over the same intent reproduces the same form, including memory for branches that aren't currently active.

persistedStorage

import { persistedStorage } from 'form-graph';

const storage = persistedStorage('my-app:quote');                  // localStorage
const tabScoped = persistedStorage('my-app:draft', { session: true }); // sessionStorage
const slower = persistedStorage('my-app:big', { delayMs: 1000 });  // debounce (default 300ms)

const store = form.createStore({ storage });
  • Writes are debounced; pagehide and tab-hide flush pending writes.
  • SSR-safe: with no window it returns undefined — and createStore accepts storage: undefined, so one line works on both sides.
  • Load/save failures (quota, privacy mode, bad JSON) degrade to defaults; they never throw into the form.
  • dispose() flushes and detaches the listeners.

The adapter contract

Anything with these two methods is a storage backend — a server draft API, an in-memory test double:

interface StorageAdapter {
  load(): Record<string, unknown> | undefined;   // address -> raw value
  save(intent: Record<string, unknown>): void;
}

// wrap any adapter with debouncing:
import { debouncedStorage } from 'form-graph';
const adapter = debouncedStorage(myServerAdapter, 500);

What the record looks like

{
  "region": "eu-west",
  "instanceType@gpu": "gpu.a100",     // scoped: remembered per preset
  "instanceType@compute": "c2.xlarge",
  "vcpus@gpu": 32
}

Addresses are key@scope (scope parts joined with /, separators escaped). Treat them as opaque keys — [ and ] are reserved for future per-item addressing.

Graph-level scope — it NESTS

A field opts into scoped memory with scope on its definition. When a whole SECTION of the form should bucket the same way, declare it once at construction — and scope composes down the mount tree: a parent's segments prefix everything a mounted child (or branch member) declares, so scope really does apply to everything below it.

const family = defineGraph<FamilyExt>({ scope: (ext) => ext.ecosystem })
  .field('steps', STEPS)               // -> steps@SDXL, steps@Flux, ...
  .field('cfg', ({ model }) => ({ ...CFG, scope: [model.id] })) // APPENDS: cfg@SDXL/123
  .field('seed', { ...SEED, scope: rootScope() })   // ESCAPE HATCH: bare, global
  .use(defineGraph({ scope: () => 'textFields' })   // nests: fields at @SDXL/textFields
    .field('prompt', PROMPT));
  • The fn runs at resolve time with the graph's ext (a mounted child sees the parent's ctx merged in), so the bucket follows the live discriminant.
  • Fields INHERIT the accumulated path; a field-level plain scope appends to it ([] appends nothing).
  • rootScope(...parts) is the escape hatch — absolute from the root regardless of ancestry; rootScope() means the bare key (global memory).
  • Returning undefined from the graph fn contributes nothing — fields stay at the inherited path.

Reading a record from outside

External readers (a "restore last session?" banner, migration scripts) go through the intent readers instead of parsing addresses by hand:

import { readIntentValue, readIntentBuckets } from 'form-graph';

readIntentValue(stored, 'instanceType', 'gpu');  // scoped bucket, bare-key fallback
readIntentBuckets(stored, 'instanceType');       // { gpu: ..., compute: ... }

Seeding a record from outside

The persisted format is one flat object of address → raw value, so a migration from another storage system can build a record and write it under the store's key before the first mount. scopedAddress is the write-side counterpart of the readers — it applies the same escaping the store uses, so never glue key + '@' + part by hand:

import { scopedAddress } from 'form-graph';

const record = {
  prompt: old.prompt,                                   // bare key: global memory
  [scopedAddress('steps', 'SDXL')]: old.sdxlSteps,      // 'steps@SDXL'
};
localStorage.setItem('my-form', JSON.stringify(record));

Values are copied raw: the boundary input schemas validate them on first resolve, so a stale or malformed value degrades to the field's default instead of corrupting the store.