Brief maintainer reference for the seam model and entry-point layout. The full API contract lives in the JSDoc of each module (hovers + published typings); the consumer-facing guide is the root README.md § Extensibility guide. This file points at it, not a restatement.
Persistence is bound to a structural PersistableSource (getState / setState / subscribe) rather than a specific store, so the same middleware persists TanStack Store, zustand, Redux, or a hand-rolled atom. Three seams make every backend × codec cell a one-line composition:
| Seam | Type | Role |
|---|---|---|
| Backend | StateStorage<TRaw = string> |
getItem / setItem / removeItem — sync or Promise-returning, string-wire by default, generic for structured backends. |
| Codec | StorageCodec<S, TRaw = string> |
Pure encode / decode between the persisted envelope (StorageValue) and the backend's wire type. |
| Source | PersistableSource<TState> |
The reactive store to persist — structural, store-agnostic. |
createStorage(backend, codec, options) composes a PersistStorage; persistSource(source, options) wires it to a store. Factory policy: codec factories take the backend as an argument; a backend earns its own factory only when it needs real adaptation (IndexedDB). Everything else composes — no factory-per-combination.
| Entry | Module | Optional peer |
|---|---|---|
@stainless-code/persist |
core/index (persist-core + hydration) |
none (zero-dep core, enforced by a gate test) |
@stainless-code/persist/codecs/seroval |
adapters/codecs/seroval |
seroval |
@stainless-code/persist/codecs/standard-schema |
adapters/codecs/standard-schema |
none |
@stainless-code/persist/backends/idb |
adapters/backends/idb |
idb-keyval |
@stainless-code/persist/backends/async-storage |
adapters/backends/async-storage |
@react-native-async-storage/async-storage |
@stainless-code/persist/backends/mmkv |
adapters/backends/mmkv |
react-native-mmkv |
@stainless-code/persist/backends/secure-store |
adapters/backends/secure-store |
expo-secure-store |
@stainless-code/persist/backends/encrypted |
adapters/backends/encrypted |
none (web global) |
@stainless-code/persist/backends/compressed |
adapters/backends/compressed |
none (web global) |
@stainless-code/persist/backends/node-fs |
adapters/backends/node-fs |
none (Node built-in) |
@stainless-code/persist/transport/crosstab |
adapters/transport/crosstab |
none (web global) |
@stainless-code/persist/sources/tanstack-store |
adapters/sources/tanstack-store |
@tanstack/store (types only) |
@stainless-code/persist/sources/zustand |
adapters/sources/zustand |
zustand |
@stainless-code/persist/sources/jotai |
adapters/sources/jotai |
jotai |
@stainless-code/persist/sources/valtio |
adapters/sources/valtio |
valtio |
@stainless-code/persist/sources/mobx |
adapters/sources/mobx |
mobx |
@stainless-code/persist/sources/pinia |
adapters/sources/pinia |
pinia |
@stainless-code/persist/sources/redux |
adapters/sources/redux |
redux |
@stainless-code/persist/frameworks/react |
adapters/frameworks/react |
react |
@stainless-code/persist/frameworks/preact |
adapters/frameworks/preact |
preact (>=10.19) |
@stainless-code/persist/frameworks/solid |
adapters/frameworks/solid |
solid-js |
@stainless-code/persist/frameworks/angular |
adapters/frameworks/angular |
@angular/core (>=17) |
@stainless-code/persist/frameworks/vue |
adapters/frameworks/vue |
vue |
@stainless-code/persist/frameworks/lit |
adapters/frameworks/lit |
lit (>=3) |
@stainless-code/persist/frameworks/alpine |
adapters/frameworks/alpine |
alpinejs (>=3) |
@stainless-code/persist/frameworks/svelte |
adapters/frameworks/svelte |
svelte (>=5.7 runes) |
@stainless-code/persist/frameworks/svelte-store |
adapters/frameworks/svelte-store |
svelte (>=3 store) |
No barrel — importing a subpath is the dependency opt-in. Each subpath entry owns its optional peer dep when the adapter needs one — the no-peer entries are the core, standard-schema, encrypted, compressed, node-fs, and crosstab subpaths — and the peer stays external in the build (tsdown.config.ts neverBundle) so consumers tree-shake cleanly.
src/ splits at the dependency-direction boundary:
core/— the zero-dep engine (persist-core.ts,hydration.ts) plusindex.ts(the.entry that re-exports both). Nothing incore/imports an adapter.adapters/<seam>/— opt-in entries that own an optional peer and import only fromcore/:codecs/—StorageCodecadapters (seroval, Standard Schema) + Standard SchemaPersistStoragewrapsbackends/—StateStorageadapters + wrappers (idb, async-storage, mmkv, secure-store, encrypted, compressed, node-fs)transport/—CrossTabEventTargetadapters (crosstab — BroadcastChannel bridge)sources/—PersistableSourceadapters (tanstack-store, zustand, jotai, valtio, mobx, pinia, redux). Shape-named, not library-named — same persistable shape → same name → same merge semantics; the subpath carries the library. Alias when importing two same-shape adapters into one module.frameworks/—HydrationSignalframework adapters (react, preact, solid, angular, vue, lit, alpine, svelte, svelte-store)
A per-entry self-check test pins the invariant: every adapter's relative imports resolve into core/ (no cross-adapter coupling). dist/ mirrors src/ (dist/<seam>/<name>.mjs via tsdown's record-form entry keyed by <seam>/<name>) — src folder → tsdown key → dist path → subpath, all 1:1.
persistSource hydrates on create (skip with skipHydration; rehydrate() is awaitable), subscribe-writes on every setState (gated until hydrated; optional trailing throttleMs), and tears down via destroy(). The hydration signal (HydrationSignal from hydration) is observed from outside the store — framework adapters mount it into their external-store mechanism (React / Preact useSyncExternalStore, Solid from, Angular signal + effect, Vue shallowRef + onScopeDispose, Lit ReactiveController, Alpine reactive bag + $hydrated, Svelte runes / stores) without coupling to the store's read path. SSR policy: render hydrated = true on the server; null signal = no persistence = hydrated.
One API. Sync backends (localStorage) settle hydration before first paint; async backends (IndexedDB) ride the same getItem Promise branch (instanceof Promise, not thenable duck-typing — a stored value with then is never a pending read). Gate UI on useHydrated for async backends. Standard Schema: withStandardSchema / withStandardSchemaAsync for sync vs async ~standard; JSON factories are sugar. PersistDecodeRethrowError from decode is rethrown by createStorage (not clearCorrupt).
buster / maxAge / throttleMs / retryWrite ship alongside versioned migrate, cross-tab sync (crossTab + onCrossTabRemove), the hydration signal, and the codec seam — beyond what TanStack Query's persistQueryClient offers. Deliberate maxAge default divergence: prefs shouldn't silently expire, so maxAge is opt-in rather than default-on.
What the core deliberately does not do — the seams exist for these, but no shipped impl:
- No IndexedDB integration in core — seam only (
StateStorage<TRaw>+identityCodec); IDB lives in./backends/idb(idb-keyval peer). No transaction batching, key-range cursors, or IDB-specific error mapping in core. - No multi-key batching — one key per
persistSource; no atomic multi-key write.PersistRegistry.clearAllis best-effortallSettled, not a transaction. - No key-namespacing helper —
nameis a raw string; no built-in prefix convention. - Trailing-only throttle — no leading edge; the first write waits out the window (explicit trade-off vs TanStack Query's leading+trailing).
retryWriteuncapped by design — no max-attempts / backoff; the callback owns termination (can spin forever).setOptionscannot re-wire structural options —registry,crossTab,crossTabEventTargetare set at create time only.- No selective / per-field hydrate —
mergeis the only knob; no field-level hydration hooks. - No built-in cross-tab payload diff — full rehydrate on every matching event; no "only changed fields" optimization.
- No built-in size/quota introspection —
retryWritereacts to failures; the core never probes quota. - No SSR serialize-and-rehydrate — the server renders
hydrated: true; shipping server state to the client for first-paint is the framework adapter's job (useHydratedserver snapshot). - Async detection is native same-realm
Promiseonly — cross-realm / custom-thenable backends aren't supported (deliberate, so a stored value with athenproperty isn't mistaken for a pending read). - Cross-tab
onCrossTabRemoveis the only removal primitive — no symmetric "another tab wrote" callback distinct fromrehydrate(). - No telemetry / observability beyond
onError— no write-success / hydrate-success / retry-attempt events. partializeruns on everysetState— no memoization hook for expensive projections (consumer's responsibility).- No built-in devtools / time-travel —
setOptions+rehydrateare the only runtime knobs.
- Public docs — canonical site is
apps/docs(@stainless-code/persist-docs, Blume), base/persist, live at https://stainless-code.com/persist. Root scripts:docs:dev/docs:api/docs:validate/docs:check/docs:build/docs:audit/docs:preview. CI runs that pipeline; merge of adocs-labeled PR (or release /workflow_dispatch) deploys via FTP (.github/workflows/deploy-docs.yml). Brand assets live underapps/docs/public/; accent overrides inapps/docs/theme.css. Maintainer Tier-B (docs/architecture.md, glossary, plans) stays in this folder. - Public surface — every export from an entry point is the public API and carries JSDoc that reads well in hovers and published typings.
@default/@exampletags survive into the shipped.d.mts(tsdown dts preserves JSDoc). No@internaltags are currently warranted — every export is part of the public surface;stripInternal: trueis set intsconfig.jsonas a forward-looking guard so any future@internal-marked member is dropped from the dts. PersistStorage.raw— stays public (semi-public seam): set bycreateStorageand identity-compared by cross-tab rehydrate; hand-rolledPersistStorageimplementations may omit it. Typedunknowndeliberately (only identity matters; typing itStateStorage<TRaw>would cascade the wire-type generic for no benefit).- Internal aliases — non-exported helper types (e.g. the listener alias) are kept out of public signatures:
PersistApi.onHydrate/onFinishHydrationinline(state: TState) => voidso the shipped dts never leaks an unexported type name into a hover. The internal alias is reserved for the implementation's listener Sets. - API reference —
bun run docs:apiruns TypeDoc (typedoc.json) with the markdown + frontmatter plugins into MDX underapps/docs/content/reference/api(generated, gitignored except hand-authoredindex.mdx+meta.ts).apps/docs/scripts/rewrite-api-links.tscleans prior output and rewrites links/anchors for the docs site routes.treatWarningsAsErrors+validation.invalidLinkgate unresolved{@link}targets; all current{@link}resolve within the core entry (no cross-entry links).
Two runners, split by what they need:
| Runner | Scope | Pattern | Why |
|---|---|---|---|
bun:test |
src/**/*.test.ts |
bun test ./src |
No DOM — fast unit tests for the core, codecs, backends, transport, source + framework adapters, and hydration SSR/snapshot contracts. |
vitest + jsdom + @testing-library/react |
tests-dom/**/*.test.{ts,tsx} |
bun run test:dom |
The React useHydrated rerender + unmount-detach path needs a DOM + a client renderer (useSyncExternalStore reactivity). |
The split is structural — tests-dom/ is a top-level directory outside bun test ./src's scan, so the two runners never pick up the same file. check runs both in parallel; CI runs them as separate jobs (Test, Test (DOM)) gated by the single CI complete job. The bun suite's src/adapters/frameworks/react.test.ts header documents which contracts it pins and which it deliberately leaves to the vitest suite.
- Public docs — https://stainless-code.com/persist (
apps/docs). - Root
README.md— npm/repo landing. glossary.md— ubiquitous language (backend, codec, source, envelope, hydration signal, generation guard, entry).roadmap.md— forward-looking work..agents/skills/docs-governance— docs lifecycle for this folder.