@stainless-code/persist
Version:
Hydration-aware persistence middleware for reactive stores (storage × codec seams, TanStack Store adapters, React hydration hook)
395 lines • 19.6 kB
text/typescript
//#region src/persist-core.d.ts
/**
* Minimal string-keyed storage interface (matches `localStorage` /
* `sessionStorage` at the default `TRaw = string`). Generic over the wire
* type so structured-clone backends (IndexedDB) can carry objects without a
* string round-trip — mirrors TanStack Query's
* `AsyncStorage<TStorageValue = string>`. `TRaw` must not be a thenable: the
* sync-vs-Promise branch on `getItem` couldn't tell a raw value apart from a
* pending read. Async detection uses `instanceof Promise` (native, same
* realm) rather than thenable duck-typing — deliberately, so a stored value
* whose state happens to carry a `then` property is never mistaken for a
* pending read; async backends must return native same-realm promises.
*/
interface StateStorage<TRaw = string> {
getItem: (name: string) => TRaw | null | Promise<TRaw | null>;
setItem: (name: string, value: TRaw) => void | Promise<void>;
removeItem: (name: string) => void | Promise<void>;
}
/** Wrapper persisted under each key: the state plus its schema version. */
interface StorageValue<S> {
state: S;
version?: number;
/**
* Write time, stamped on every write. Basis for `maxAge` expiry on hydrate;
* a payload without one (e.g. written by a different persist
* implementation) counts as expired when `maxAge` is configured.
*/
timestamp?: number;
/**
* Cache-buster stamp, written only when the `buster` option is configured.
* A mismatch discards the payload on hydrate.
*/
buster?: string;
}
/**
* Keyed store of `StorageValue`s — the encoded storage layer `persistSource`
* reads and writes. Build one with `createStorage` (backend × codec), use a
* shipped factory (`createJSONStorage` / `createSerovalStorage`), or
* hand-roll the three methods for exotic backends.
*/
interface PersistStorage<S> {
getItem: (name: string) => StorageValue<S> | null | Promise<StorageValue<S> | null>;
setItem: (name: string, value: StorageValue<S>) => void | Promise<void>;
removeItem: (name: string) => void | Promise<void>;
/**
* The raw backing storage object (set by `createStorage`). Cross-tab
* rehydrate identity-compares `event.storageArea` against it; when absent
* (hand-rolled implementations), cross-tab falls back to key-only matching.
* Typed `unknown` deliberately — only identity matters, and typing it
* `StateStorage<TRaw>` would cascade the wire-type generic into
* `PersistOptions`/`PersistApi` for no benefit.
*/
raw?: unknown;
}
/**
* Pure (de)serialization of a `StorageValue` to/from the backend's wire type
* (`TRaw`, default `string`) — the codec half of the two-axis split (backend
* `StateStorage` × codec). A custom codec (superjson / devalue / compression
* / encryption) plugs into {@link createStorage} here instead of
* reimplementing the full `PersistStorage` plumbing. For structured-clone
* backends, {@link identityCodec} skips (de)serialization entirely.
*/
interface StorageCodec<S, TRaw = string> {
encode: (value: StorageValue<S>) => TRaw;
decode: (raw: TRaw) => StorageValue<S>;
}
/** Standard `JSON.parse` reviver / `JSON.stringify` replacer pass-throughs. */
interface JsonStorageOptions {
reviver?: (key: string, value: unknown) => unknown;
replacer?: (key: string, value: unknown) => unknown;
}
interface CreateStorageOptions {
/**
* Remove the storage key when `codec.decode` throws (corrupt-payload
* self-heal). When off, a corrupt payload hydrates to nothing and stays in
* storage.
* @default false
*/
clearCorruptOnFailure?: boolean;
}
/**
* Minimal shape of a browser `storage` event — structural so a non-DOM
* `crossTabEventTarget` (tests, custom runtimes like a `BroadcastChannel`
* bridge) can dispatch fakes without the DOM `StorageEvent` global. The real
* browser `StorageEvent` satisfies this shape.
*/
interface CrossTabStorageEvent {
key: string | null;
newValue: string | null;
storageArea: unknown;
}
/**
* Event-target seam for cross-tab sync. Defaults to `window` when
* `crossTab: true` and one exists; inject a fake to drive simulated
* `storage` events in non-DOM environments (tests), or a custom bridge
* (e.g. `BroadcastChannel`) as an alternate transport.
*/
interface CrossTabEventTarget {
addEventListener: (type: "storage", listener: (event: CrossTabStorageEvent) => void) => void;
removeEventListener: (type: "storage", listener: (event: CrossTabStorageEvent) => void) => void;
}
interface PersistOptions<TState, TPersistedState = TState> {
/** Storage key. Must be unique per persisted store. */
name: string;
/**
* Storage layer to read/write. When no backend is available at all (SSR,
* tests), `persistSource` returns a no-op `PersistApi`. For `Set`/`Map`/
* `Date` round-trips pass `createSerovalStorage` (from `./persist-seroval`).
* @default JSON-encoded `localStorage` (`createJSONStorage`)
*/
storage?: PersistStorage<TPersistedState>;
/**
* Project `TState` to the slice that should be persisted.
* @default identity — the full state is persisted
*/
partialize?: (state: TState) => TPersistedState;
/**
* Called at the start of every (re)hydrate with the pre-hydration state.
* Optionally returns a callback invoked when that hydrate settles —
* `(state, undefined)` on success, `(undefined, error)` on failure.
*/
onRehydrateStorage?: (state: TState) => ((state?: TState, error?: unknown) => void) | void;
/**
* Schema version stamped on every write. A stored payload with a different
* version goes through {@link migrate} on hydrate.
* @default 0
*/
version?: number;
/**
* Transform persisted state from an older version to the current one.
* Without it, a version mismatch discards the payload (reported to
* `onError`, phase `"migrate"`). For "old values are simply wrong, don't
* migrate them" see {@link buster}.
* @param persistedState The stored payload's state, as read from storage.
* @param version The STORED payload's version (not the configured one).
*/
migrate?: (persistedState: unknown, version: number) => TPersistedState | Promise<TPersistedState>;
/**
* Combine persisted state with current state on hydrate.
* @default shallow spread of persisted over current (`persistAtom` replaces
* instead — a spread would corrupt primitive atom values)
*/
merge?: (persistedState: unknown, currentState: TState) => TState;
/**
* Skip the initial hydrate read (caller invokes `rehydrate()` manually).
* @default false
*/
skipHydration?: boolean;
/**
* When true, remove the storage key instead of writing (e.g. the slice equals
* the default). Evaluated against the **partialized** slice, not the full
* state — so a non-persisted field changing alone never writes a key whose
* persisted slice still equals the default. When combined with
* {@link crossTab}, also wire {@link onCrossTabRemove}.
* @default undefined (never skip — every eligible write persists)
*/
skipPersist?: (state: TPersistedState) => boolean;
/**
* Storage/migrate error sink. When provided, write/hydrate/migrate errors are
* routed here instead of `console.*`. The `console.error` / `console.warn`
* fallback is dev-only (`process.env.NODE_ENV !== "production"`, a
* bundler-replaceable check so prod tree-shakes it): prod without a sink is
* silent by design — wire `onError` for production observability. Errors
* never propagate into the caller's `setState` regardless.
*/
onError?: (error: unknown, context: {
name: string;
phase: "write" | "hydrate" | "migrate" | "crossTab";
}) => void;
/**
* Optional clear-callback registry. When provided, the store's
* `clearStorage` is registered there (and unregistered on `destroy()`) so
* one `registry.clearAll()` — e.g. at logout — wipes every persisted key.
* When omitted, the store never registers; there is no ambient registry.
*/
registry?: PersistRegistry;
/**
* Opt-in cross-tab sync. When `true`, listens for `storage` events on
* `crossTabEventTarget` (defaulting to `window`) and calls `rehydrate()`
* when one matches this store's key + storage area — a change in tab A
* rehydrates tab B.
*
* No echo loops: the browser never fires `storage` in the originating tab,
* and overlapping rehydrates dedupe via the internal race guard. The
* listener attaches regardless of `skipHydration`, is removed by
* `destroy()`, and silently no-ops without a `window`. Key-removal events
* are owned by {@link onCrossTabRemove} — wire it whenever
* {@link skipPersist} is also configured.
* @default false
*/
crossTab?: boolean;
/**
* Override the `storage`-event source for `crossTab`. Inject a fake in
* tests / non-DOM runtimes, or a custom bridge (e.g. `BroadcastChannel`)
* when `storage` events aren't the right transport.
* @default `window`, when one exists
*/
crossTabEventTarget?: CrossTabEventTarget;
/**
* Removal semantics for `crossTab`: invoked when another tab REMOVES this
* store's key (`storage` event with `newValue: null` — e.g. that tab's
* `skipPersist` reset-to-default). A removal can't be expressed through
* `rehydrate()` — "no stored state" keeps the current state, correct for
* initial hydrate but stale here — so reset to your initial state in this
* callback. Without it, removal events fall back to `rehydrate()` and the
* local tab keeps its state (tabs diverge on reset-to-default). Pair with
* {@link skipPersist} whenever {@link crossTab} is on. Throws are contained
* and reported to `onError` (phase `"crossTab"`).
*/
onCrossTabRemove?: () => void;
/**
* Max age in ms. On hydrate, a payload older than this (by its `timestamp`
* stamp — a payload without one counts as expired) is treated as absent
* and the key removed, so the store keeps its current/initial state.
* Expiry runs BEFORE version/migrate — expired data is never migrated.
* @default undefined (no expiry)
*/
maxAge?: number;
/**
* Cache-buster string, stamped on every write when configured. On hydrate,
* a stored payload whose `buster` differs is discarded and the key removed
* — same treatment as {@link maxAge} expiry. Prefer `buster` over
* `version` + {@link migrate} when semantics changed so completely that old
* values are wrong and migrating them is pointless.
* @default undefined (no busting)
*/
buster?: string;
/**
* Trailing throttle window (ms) for subscribe-writes. The first eligible
* `setState` after a flush schedules a timer; further calls within the
* window coalesce; when the timer fires, ONE write happens with the state
* read at flush time (last write wins). Trailing-only — the first call
* waits out the window instead of writing immediately (TanStack Query's
* persister throttle is leading+trailing; ours trades first-write latency
* for a simpler single-timer model). Not throttled: {@link skipPersist}
* removals (a reset-to-default must drop the key immediately — a pending
* write is cancelled, the removal supersedes it) and the one-shot
* post-migrate write-back. `destroy()` flushes a pending write immediately
* (initiated at teardown; an async backend's `setItem` is fired, not
* awaited) so no coalesced state is silently dropped.
* @default undefined (no throttling — every setState writes)
*/
throttleMs?: number;
/**
* Shrink-or-give-up write retry. When a `setItem` throws (sync quota) or
* rejects (async backend), the callback receives the slice that failed,
* the error, and `errorCount` (1 on the first invocation, incrementing per
* retry). Return a smaller state to re-attempt — the storage envelope is
* rebuilt fresh per attempt (new `timestamp`, current `version`/`buster`)
* — or `undefined` to give up, which reports the LAST error to `onError`
* (phase `"write"`) exactly once. This callback IS the termination policy:
* the loop is uncapped, so a callback that always returns a state spins
* forever. Applies to both `setItem` paths (subscribe-writes and the
* post-migrate write-back); `removeItem` paths are excluded and
* {@link skipPersist} is NOT re-evaluated on retry states. A newer
* `setState` write or `destroy()` silently abandons an in-flight retry
* loop — a stale shrunk state never clobbers fresher state. Without the
* option, the first write error is reported and no retry happens.
*
* @example
* ```ts
* // Drop the heaviest field progressively; errorCount is the
* // aggressiveness dial. Terminate with `undefined`.
* retryWrite: ({ state, errorCount }) => {
* if (errorCount === 1) return { ...state, history: state.history.slice(-20) };
* if (errorCount === 2) return { ...state, history: [] };
* return; // give up — last error goes to onError
* },
* ```
* @default undefined (no retry — first write error is reported)
*/
retryWrite?: (context: {
/** The slice that just failed to write. */state: TPersistedState;
error: unknown; /** 1 on the first invocation, incrementing per retry. */
errorCount: number;
}) => TPersistedState | undefined | Promise<TPersistedState | undefined>;
}
/** Lifecycle handle returned by `persistSource` / `persistStore` / `persistAtom`. */
interface PersistApi<TState, TPersistedState = TState> {
/**
* Merge new options (explicit `undefined` entries are ignored). Structural
* options wired at create time — `registry`, `crossTab`,
* `crossTabEventTarget` — are NOT re-wired here: passing them after creation
* updates `getOptions()` but attaches no listener / registration.
*/
setOptions: (options: Partial<PersistOptions<TState, TPersistedState>>) => void;
/** Remove this store's key from storage (state stays in memory). */
clearStorage: () => void | Promise<void>;
/**
* Re-run hydration from storage. Awaitable — resolves after the merge
* landed and finish-hydration listeners ran. Overlapping calls race-guard:
* the latest call wins, stale ones resolve without effect.
*/
rehydrate: () => Promise<void> | void;
/** `false` while a (re)hydrate is in flight; subscribe-writes are gated on it. */
hasHydrated: () => boolean;
/** Listen for hydration START (per (re)hydrate). Returns an unsubscribe fn. */
onHydrate: (fn: (state: TState) => void) => () => void;
/** Listen for hydration END — success or failure. Returns an unsubscribe fn. */
onFinishHydration: (fn: (state: TState) => void) => () => void;
getOptions: () => PersistOptions<TState, TPersistedState>;
/**
* Full teardown: detach the source subscription, remove the cross-tab
* listener, unregister from the clear registry, FLUSH any pending
* throttled write (one immediate attempt, initiated at teardown — an async
* backend's `setItem` is fired, not awaited; see
* {@link PersistOptions.throttleMs}), and cancel any in-flight hydrate and
* {@link PersistOptions.retryWrite} loop. Required for non-singleton
* persisted stores (created per mount/scope) to avoid leaks; module
* singletons never need it.
*/
destroy: () => void;
}
/**
* Minimal reactive source `persistSource` can attach to. `Store` and writable
* `Atom` from `@tanstack/store` satisfy this via the `persistStore` /
* `persistAtom` adapters; pass a custom implementation to persist anything else.
*/
interface PersistableSource<TState> {
getState: () => TState;
setState: (updater: (prev: TState) => TState) => void;
subscribe: (listener: () => void) => {
unsubscribe: () => void;
};
}
/**
* Clear-callback registry for persisted stores. The core has no ambient
* module state and never auto-registers: a store joins a registry only via
* its `registry` option. The application owns the instance and decides when
* `clearAll()` runs (typically session end / logout).
*/
interface PersistRegistry {
/** Register a clear callback; returns an unregister fn. */
register: (clearStorage: () => void | Promise<void>) => () => void;
/**
* Wipe every registered store's key. Each registered clear is attempted
* even if one throws — the first rejection is re-thrown after all run.
*/
clearAll: () => Promise<void>;
}
/**
* Build a `PersistRegistry`. `clearAll` uses `allSettled` +
* rethrow-first-rejection semantics: one throwing backend can't skip the
* remaining clears, and the caller still sees the first failure.
*/
declare function createPersistRegistry(): PersistRegistry;
/**
* JSON codec — no `Set` / `Map` / `Date` round-trip. Accepts the standard
* `JSON.parse` reviver / `JSON.stringify` replacer.
*/
declare const jsonCodec: <S>(options?: JsonStorageOptions) => StorageCodec<S>;
/**
* Identity codec for structured-clone backends (`TRaw = StorageValue<S>`):
* the backend stores the envelope natively — IndexedDB's structured-clone
* algorithm round-trips `Set` / `Map` / `Date` without any serialization, so
* string codecs (JSON / seroval) become pure overhead there. Never use with
* string-only backends (`localStorage`).
*/
declare const identityCodec: <S>() => StorageCodec<S, StorageValue<S>>;
/**
* The one shared `PersistStorage` plumbing: getStorage try-guard (returns
* `undefined` when the backend is unavailable), sync-vs-Promise branching on
* `getItem`, and unified corrupt-payload handling (decode throw → `null` +
* optional key removal via `clearCorruptOnFailure`). Pass any
* {@link StorageCodec} to swap the serialization format without
* reimplementing this layer.
*
* @example
* ```ts
* // custom codec (superjson, devalue, compression, encryption, …)
* const storage = createStorage<Prefs>(
* () => localStorage,
* { encode: superjson.stringify, decode: superjson.parse },
* { clearCorruptOnFailure: true },
* );
* ```
*/
declare function createStorage<S, TRaw = string>(getStorage: () => StateStorage<TRaw>, codec: StorageCodec<S, TRaw>, options?: CreateStorageOptions): PersistStorage<S> | undefined;
/**
* Build a JSON-encoded `PersistStorage` (no `Set`/`Map`/`Date` round-trip).
* The backend is the argument — `() => localStorage`, `() => sessionStorage`,
* or any custom `StateStorage`. For corrupt-payload self-heal
* (`clearCorruptOnFailure`), use
* `createStorage(getStorage, jsonCodec(options), { clearCorruptOnFailure })`
* directly — this factory's options are codec-only (reviver/replacer).
*/
declare function createJSONStorage<S>(getStorage: () => StateStorage, options?: JsonStorageOptions): PersistStorage<S> | undefined;
/**
* Attach persist to any `PersistableSource`: hydrate from storage on create,
* subscribe-write on every `setState`. Returns the lifecycle `PersistApi`.
* No-op `PersistApi` (always hydrated) when storage is unavailable (SSR / tests).
*/
declare function persistSource<TState, TPersistedState = TState>(source: PersistableSource<TState>, baseOptions: PersistOptions<TState, TPersistedState>): PersistApi<TState, TPersistedState>;
//#endregion
export { jsonCodec as _, PersistApi as a, PersistStorage as c, StorageCodec as d, StorageValue as f, identityCodec as g, createStorage as h, JsonStorageOptions as i, PersistableSource as l, createPersistRegistry as m, CrossTabEventTarget as n, PersistOptions as o, createJSONStorage as p, CrossTabStorageEvent as r, PersistRegistry as s, CreateStorageOptions as t, StateStorage as u, persistSource as v };