State Persistence — Reference
@tagna/udiot v1.0.0-rc.1
import {
StatePersister,
createPersister,
createMigration,
createStorageAdapter,
detectBestStorage,
LocalStorageAdapter,
SessionStorageAdapter,
IndexedDBAdapter,
MemoryStorageAdapter,
compressionHelpers,
encryptionHelpers
} from '@tagna/udiot';
import { Persist } from '@tagna/udiot';
StorageAdapter Interface
All storage adapters implement this async interface. You can also implement it yourself for any storage backend.
interface StorageAdapter {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
clear(): Promise<void>;
getAllKeys(): Promise<string[]>;
}
Built-In Adapters
LocalStorageAdapter
new LocalStorageAdapter(prefix?: string) // Default prefix: 'udiot_'
Wraps window.localStorage. All keys are automatically prefixed to avoid collisions with other libraries. Throws at construction time in environments without localStorage (e.g., Node.js).
SessionStorageAdapter
new SessionStorageAdapter(prefix?: string) // Default prefix: 'udiot_'
Wraps window.sessionStorage. State is cleared when the browser tab is closed. Same prefix behavior as LocalStorageAdapter.
IndexedDBAdapter
new IndexedDBAdapter(
dbName?: string, // Default: 'udiot_db'
storeName?: string, // Default: 'state'
version?: number // Default: 1
)
Uses the browser's IndexedDB API. Fully async. Suitable for large state objects or offline-first applications. Call .close() to release the database connection when no longer needed.
MemoryStorageAdapter
new MemoryStorageAdapter()
In-memory Map. Data does not survive page refresh. Use in Node.js environments, server-side rendering, or unit tests.
const adapter = new MemoryStorageAdapter();
await adapter.setItem('key', 'value');
console.log(await adapter.getItem('key')); // 'value'
console.log(adapter.size()); // 1
createStorageAdapter(type, options?)
function createStorageAdapter(
type: 'localStorage' | 'sessionStorage' | 'indexedDB' | 'memory',
options?: {
prefix?: string;
dbName?: string;
storeName?: string;
version?: number;
}
): StorageAdapter
Factory that constructs the appropriate adapter by name.
const storage = createStorageAdapter('localStorage', { prefix: 'myapp_' });
const idb = createStorageAdapter('indexedDB', { dbName: 'myapp', storeName: 'store' });
detectBestStorage()
function detectBestStorage(): 'localStorage' | 'sessionStorage' | 'memory'
Probes the environment and returns the best available storage type by writing and removing a test key. Useful for universal (SSR + browser) applications.
const bestType = detectBestStorage();
const storage = createStorageAdapter(bestType);
StatePersister<TState>
The engine that serializes state to and from storage.
class StatePersister<TState = any> {
constructor(options: PersistenceOptions<TState>)
load(): Promise<TState | null>
save(state: TState): void // debounced
saveImmediate(state: TState): Promise<void>
remove(): Promise<void>
exists(): Promise<boolean>
getMetadata(): Promise<{ version: number; timestamp: number } | null>
flush(): Promise<void>
}
PersistenceOptions<TState>
interface PersistenceOptions<TState = any> {
storage: StorageAdapter; // Required
key: string; // Required. Storage key.
debounce?: number; // Save delay in ms. Default: 1000.
version?: number; // Schema version. Default: 1.
migrate?: (
state: any,
fromVersion: number,
toVersion: number
) => TState; // Run when stored version differs.
whitelist?: (keyof TState)[]; // Only persist these keys.
blacklist?: (keyof TState)[]; // Exclude these keys from persistence.
serialize?: (state: TState) => any; // Transform before JSON.stringify.
deserialize?: (data: any) => TState; // Transform after JSON.parse.
onError?: (error: Error) => void; // Error handler.
onSave?: (state: TState) => void; // Called after each successful save.
onLoad?: (state: TState) => void; // Called after successful load.
}
The persisted format on disk is:
interface PersistedState<TState> {
state: TState;
version: number;
timestamp: number; // Unix milliseconds
}
load()
Reads from storage, runs migration if the stored version differs, runs deserialize, and calls onLoad. Returns null if nothing is stored or if an error occurs.
save(state)
Debounced. Writes the newest state debounce milliseconds after the last call; each call resets the timer.
saveImmediate(state)
Runs the whitelist/blacklist filter, runs serialize, wraps in PersistedState, and writes JSON to storage. Resolves once written. Writes never overlap: a write already in progress finishes first, and only the newest pending state is written after it.
flush()
Writes any pending (debounced) state now and resolves when nothing is being written. Use it in tests and shutdown hooks.
remove()
Deletes the stored state and drops pending saves, so a debounced save cannot write it back.
createPersister(options)
function createPersister<TState>(
options: PersistenceOptions<TState>
): StatePersister<TState>
Convenience wrapper around new StatePersister(options).
createMigration(migrations)
function createMigration<TState>(
migrations: Record<number, (state: any) => any>
): (state: any, fromVersion: number, toVersion: number) => TState
Creates a sequential migration function. Each key is the target version number. Migrations are applied in ascending order from fromVersion + 1 to toVersion.
const migrate = createMigration({
2: (state) => ({
// v1 had 'count: number', v2 renames it to 'value' and adds 'lastUpdated'
value: state.count,
lastUpdated: new Date().toISOString()
}),
3: (state) => ({
// v3 adds a 'history' array
...state,
history: []
})
});
Helpers
compressionHelpers
const compressionHelpers = {
compress: (data: any) => string, // JSON.stringify → base64
decompress: (compressed: string) => any // base64 → JSON.parse
};
Use as serialize/deserialize hooks to reduce storage size:
createPersister({
storage,
key: 'app',
serialize: compressionHelpers.compress,
deserialize: compressionHelpers.decompress
});
Note: the current implementation uses btoa/atob. Replace with a library such as pako for real compression.
encryptionHelpers
const encryptionHelpers = {
encrypt: async (data: any, key: string) => Promise<string>,
decrypt: async (encrypted: string, key: string) => Promise<any>
};
Placeholder implementation using btoa. Replace with the Web Crypto API for production use.
@Persist Decorator
import { Persist } from '@tagna/udiot';
A class decorator that automatically wires StatePersister to a Store subclass. Applied to the class definition. The store must extend Store<TState>.
function Persist<TState = any>(options?: PersistDecoratorOptions<TState>): ClassDecorator
PersistDecoratorOptions<TState>
interface PersistDecoratorOptions<TState = any> {
key?: string; // Default: class name
storage?: 'localStorage' | 'sessionStorage' | 'indexedDB' | 'memory' | StorageAdapter;
debounce?: number;
version?: number;
migrate?: (state: any, fromVersion: number, toVersion: number) => TState;
whitelist?: (keyof TState)[];
blacklist?: (keyof TState)[];
serialize?: (state: TState) => any;
deserialize?: (data: any) => TState;
autoLoad?: boolean; // Load on construction. Default: true.
autoSave?: boolean; // Subscribe to changes. Default: true.
}
Example: CounterStore with Migration
A store whose state shape changes from v1 (just count) to v2 (renamed to value, adds lastUpdated).
import { Store, type StoreAction as Action } from '@tagna/udiot';
import { ReactiveStore } from '@tagna/udiot';
import { Persist, createMigration } from '@tagna/udiot';
// v2 shape
interface CounterState {
value: number; // was 'count' in v1
lastUpdated: string;
}
const migrateCounter = createMigration<CounterState>({
2: (oldState: { count: number }) => ({
value: oldState.count,
lastUpdated: new Date().toISOString()
})
});
@ReactiveStore({ name: 'CounterStore' })
@Persist<CounterState>({
storage: 'localStorage',
key: 'counter-state',
version: 2,
migrate: migrateCounter,
whitelist: ['value', 'lastUpdated']
})
class CounterStore extends Store<CounterState> {
constructor() {
super({ value: 0, lastUpdated: new Date().toISOString() });
}
protected reduce(state: CounterState, action: Action): CounterState {
switch (action.type) {
case 'INCREMENT':
return { value: state.value + 1, lastUpdated: new Date().toISOString() };
case 'DECREMENT':
return { value: state.value - 1, lastUpdated: new Date().toISOString() };
case 'RESET':
return { value: 0, lastUpdated: new Date().toISOString() };
default:
return state;
}
}
}
const store = new CounterStore();
// State is automatically loaded from localStorage on construction.
// Each dispatch triggers a debounced save.
await store.dispatch({ type: 'INCREMENT' });
// { value: 1, lastUpdated: '...' } is saved to localStorage['counter-state']
If a user's browser still holds v1 data { count: 5 }, migrateCounter is applied automatically when load() is called, yielding { value: 5, lastUpdated: '...' } before the store's subscribers see it.