Persistence policy
What gets stored in a persisted draft, how schema changes invalidate old payloads, and what persistence is — and isn't — designed for.
Sparse payloads
The persisted payload contains only opted-in paths:
// Schema: { email: string, phone: string, cvv: string }
// register('email', { persist: true })
// register('phone', { persist: true })
// register('cvv') ← no opt-in
// Persisted payload, written under key attaform:signup:${fingerprint}
{
v: 4, // attaform-internal envelope version
data: { form: { email: '…', phone: '…' } } // no `cvv`
}
The v field on the envelope is internal to attaform — it tracks the
on-disk format and is bumped only when attaform itself changes the
serialised shape. Consumers don't (and now can't) set it. Drafts
saved against a stale envelope version are dropped with a one-time
dev-warn on read.
The envelope also round-trips the form's blankPaths set when
populated, so a numeric field cleared by the user stays visually
empty after reload (storage holds the slim default; the
displayed-empty state survives).
On hydration, opted-in fields restore from storage; non-opted fields
come from schema defaults. The opt-in set can change between mounts
— a previously-persisted path that's no longer opted in stays in
storage until the next write (which won't include it) or an explicit
form.clearPersistedDraft(path).
Including errors
Default include: 'form' persists just the values. Server-side
validation errors on reload are usually stale and confusing.
For multi-step wizards where reconstructing errors is expensive,
include: 'form+errors' persists and re-hydrates errors.
Errors on non-opted-in paths are dropped from the persisted envelope — a persisted error without a persisted value would dangle on rehydration.
Auto-invalidation on schema change
Storage keys carry the schema's structural fingerprint:
attaform:signup:7c3a0b ← key on disk
└────┘
fingerprint of the current schema
When the schema changes shape — adding / removing / renaming a
field, changing a leaf type, restructuring nested objects — the
fingerprint changes. New writes go to a new key
(attaform:signup:9d2b1f); the old key
(attaform:signup:7c3a0b) becomes unreachable.
On the next mount, the orphan-cleanup pass enumerates keys under
attaform:signup (via FormStorage.listKeys), keeps the
current-fingerprint entry, and removes the rest. No manual version
bump, no possibility of forgetting it, no draft drops when only
refinement logic changed (refinements collapse to opaque sentinels
in the fingerprint).
The same orphan pass also wipes pre-fingerprint legacy entries written by older library versions, so upgrades clean up cleanly on the next mount.
Malformed-shape entries (corrupted JSON, attaform-internal envelope-version mismatch, anything that doesn't match the expected payload contract) are wiped on read. "Truly absent" entries (the key was never set) are a no-op — the wipe only fires when there's actually something to clean.
If you need to force-invalidate a draft without changing the schema
(e.g. shipping an unrelated field-validation tweak that you want
users to retest from scratch), call form.clearPersistedDraft() at
mount or wrap the schema in a thin no-op layer that perturbs the
fingerprint. The library deliberately doesn't expose a
"force-version" knob — most consumers don't need it, and the schema
fingerprint already captures every legitimate "shape changed"
signal.
What persistence is NOT for
- Sensitive data. Don't persist passwords, payment cards, SSNs, tokens, or anything else listed in the sensitive-name heuristic unless your storage adapter encrypts AND the encryption key isn't itself client-side derivable. The library throws at mount on obvious cases; the heuristic isn't exhaustive.
- Authoritative state. Persistence is for draft UX, not for source-of-truth data. The server still owns the canonical record.
- Cross-form coordination. Each form persists independently. Multiple forms can share a key (and so a FormStore + a persistence entry), but they're still one form to the persistence layer.
- Schema migrations. Schema changes auto-invalidate old payloads
via the fingerprint (the old key becomes unreachable and is swept
on the next mount). If you need to rename a field without losing
state, read the raw entry yourself before the schema change ships
and massage it into the new shape before calling
reset(). The library deliberately doesn't ship a renaming-aware migration helper — schemas are the contract; renames are a write-once transformation the consumer owns.
See also
- Persistence walkthrough — the basics
- Persistence backends — picking and configuring storage
- Persistence edge cases — imperative APIs, gotchas