Commiq Docs
Plugins

Persistence

State persistence and rehydration for commiq stores — versioned envelopes, migration, validation, storage adapters, and SSR-safe defaults.

Persist API

@naikidev/commiq-persist saves store state to localStorage — or any storage adapter — with debounced writes, and restores it on load with versioning, migration and validation.

Installation

pnpm add @naikidev/commiq-persist
npm install @naikidev/commiq-persist
yarn add @naikidev/commiq-persist
bun add @naikidev/commiq-persist

Basic usage

import { createStore } from "@naikidev/commiq";
import { persistStore } from "@naikidev/commiq-persist";

const store = createStore({ count: 0 });

store.addCommandHandler("increment", (ctx) => {
  ctx.setState({ count: ctx.state.count + 1 });
});

const persisted = persistStore(store, { key: "app-state" });

await persisted.hydrated; // store.state now reflects storage

persistStore accepts any object with state, replaceState, suspend, openStream and closeStream — a StoreImpl or your own test double. The returned PersistResult satisfies core's Disposable.

persistStore(store, options)

PersistOptions<S>

OptionTypeDefaultDescription
keystringrequiredStorage key
storageStorageAdapterlocalStorageAdapter()Storage backend
debouncenumber300Debounce writes, in ms
versionnumber0Version stamped into the stored envelope
migrate(persisted: unknown, from: number) => SConvert an older persisted value to the current shape
validate(raw: unknown) => S | nullReject an untrusted persisted value by returning null
merge(persisted: unknown, initial: DeepReadonly<S>) => S | nullmergeOverInitialCombine the persisted value with current state
replacerJsonReplacerJSON.stringify replacer
reviverJsonReviverJSON.parse reviver
serialize(snapshot: PersistedSnapshot) => stringJSON envelopeFull control over the stored string
deserialize(raw: string) => unknownJSON.parseFull control over parsing
clearOnCorruptbooleantrueDelete the key when it cannot be parsed
flushOnHidebooleantrueFlush on pagehide/beforeunload in browsers
syncTabsbooleanfalseApply changes made by other tabs — needs an adapter with subscribe
onError(report: PersistErrorReport) => voidconsole.error outside productionError channel

serialize receives a PersistedSnapshot ({ version, state }), not raw state, and deserialize returns unknown rather than S. A v1 serialize: (state) => … still compiles in loose configurations but will stringify the envelope's wrapper as if it were your state. Update both together.

PersistResult

MemberTypeDescription
hydratedPromise<void>Resolves once the initial read has been applied. Never rejects
flush() => Promise<void>Write any pending debounced value now. Never rejects
clear() => Promise<void>Remove the stored value — call on logout. Needs an adapter with removeItem
destroy() => voidFlush, unsubscribe, stop persisting. Idempotent
persisted.flush();
await persisted.clear();
persisted.destroy();

The hydration guarantee

This is the part worth reading carefully, because v1's docs promised more than the code delivered.

persistStore takes core's store.suspend() gate for the duration of the initial read and releases it once hydration has settled — on success, on failure, and on corrupt data alike.

Any command dispatched after persistStore() returns runs against hydrated state, in order — whether hydration succeeded or failed. Commands queued during an asynchronous hydration are accepted and ordered, then executed once the gate opens. Their CommandHandles resolve normally, and flush() resolves once the gate opens and the queue drains. You do not need to await hydrated before dispatching.

You still need to await hydrated before reading store.state, because the restored value is not visible until then.

A command that was already in flight when persistStore() was called keeps running, so its state change can still be overwritten by hydration. That narrow case is reported through onError with source: "hydrationRace" rather than failing silently. Call persistStore during store setup, before anything dispatches, and the case cannot arise.

Synchronous adapters — localStorageAdapter, sessionStorageAdapter, memoryStorageAdapter — hydrate inline before persistStore returns, so hydrated is already resolved and the gate is taken and released within the same tick.

Error handling

Nothing throws out of persistStore, and neither hydrated, flush() nor clear() ever reject. Every failure is reported to onError:

type PersistErrorReport = {
  error: unknown;
  source: PersistErrorSource;
  key: string;
  raw?: string;
};

The shape mirrors core's StoreErrorReport, so one reporter can serve both channels. Sources: read, write, remove, serialize, deserialize, migrate, validate, merge, apply, hydrationRace, unsupported.

A quota error, an offline adapter or a corrupt key degrades persistence for that operation only — writes keep working afterwards. In v1 a single truncated write left the internal hydrating flag stuck, disabling writes for the rest of the process.

Versioning and migration

Values are stored in an envelope:

{ "$": "commiq/persist", "version": 2, "state": { "items": [] } }

Values written without an envelope — including anything written by v1 of this package — are read as version 0, exported as LEGACY_VERSION. Upgrading does not discard existing user data.

createSerializer(replacer?) and createDeserializer(reviver?) build the default codecs, so a custom serialize can wrap rather than replace the envelope logic.

persistStore(store, {
  key: "cart",
  version: 2,
  migrate: (persisted, from) => (from === 1 ? upgradeV1(persisted) : emptyCart()),
  validate: (raw) => cartSchema.safeParse(raw).data ?? null,
});

When the stored version differs from version and no migrate is given, hydration is skipped and reported. The store keeps its initial state rather than adopting an unknown shape.

validate runs on every hydration, including same-version reads. Returning null rejects the value and reports validate.

Merging

By default the persisted value is shallow-merged over the initial state (mergeOverInitial), so state keys added in a later release keep their defaults instead of arriving as undefined. Non-object states — arrays, primitives — are replaced wholesale.

import { mergeOverInitial } from "@naikidev/commiq-persist";

Supply your own merge for anything deeper. Returning null rejects the value.

Storage adapters

import {
  indexedDbAdapter,
  localStorageAdapter,
  memoryStorageAdapter,
  noopStorageAdapter,
  sessionStorageAdapter,
  webStorageAdapter,
} from "@naikidev/commiq-persist";

persistStore(store, { key: "big", storage: indexedDbAdapter() });
AdapterNotes
localStorageAdapter()Default. Falls back to noopStorageAdapter() when unavailable
sessionStorageAdapter()Per-tab storage
webStorageAdapter(area)Wrap any Storage; adds cross-tab subscribe
memoryStorageAdapter()In-process, useful in tests
noopStorageAdapter()Discards writes, always reads null
indexedDbAdapter(options)Asynchronous. Accepts databaseName, storeName, factory

indexedDbAdapter takes IndexedDbAdapterOptions; passing factory lets you substitute a fake IDBFactory in tests without a DOM.

Custom adapters

type StorageAdapter = {
  getItem(key: string): string | null | Promise<string | null>;
  setItem(key: string, value: string): void | Promise<void>;
  removeItem?(key: string): void | Promise<void>;
  subscribe?(key: string, onChange: (value: string | null) => void): Unsubscribe;
};

getItem and setItem are required. removeItem is needed for clear(), and subscribe for syncTabs — requesting syncTabs without it reports unsupported rather than silently doing nothing.

persistStore(store, {
  key: "app-state",
  storage: {
    getItem: async (key) => {
      const res = await fetch(`/api/state/${key}`);
      return res.ok ? await res.text() : null;
    },
    setItem: async (key, value) => {
      await fetch(`/api/state/${key}`, { method: "PUT", body: value });
    },
  },
});

Server rendering

The default storage is resolved lazily and degrades to a no-op when localStorage is missing or throws — Next.js and Remix server rendering, Safari private mode, hardened iframes.

In v1 the default was a bare localStorage reference captured at call time, so persistStore threw ReferenceError during server rendering. The documented call crashed the render rather than degrading.

On the server nothing is read or written, hydrated resolves immediately, and the suspension gate is taken and released within the same tick, so command processing is never delayed. The browser hydrates on mount.

For deterministic server-side tests, pass an adapter explicitly:

persistStore(store, { key: "app-state", storage: memoryStorageAdapter() });

Cross-tab sync

persistStore(store, { key: "prefs", syncTabs: true });

Changes written by other tabs are applied through replaceState, running the same migrate/validate/merge pipeline as the initial read. The store's own writes are not echoed back, and an external removal leaves state untouched rather than resetting it.

Rehydration loop prevention

While hydration or a cross-tab apply is in progress, writes are suppressed, so the hydration → stateChanged → write cycle cannot form. No configuration needed.

This matters more in v2 than it did in v1: core now publishes stateChanged on every ctx.setState() rather than once per command, so persist observes more events. Writes are still debounced by debounce (default 300ms), so the extra events collapse into the same single write.

JSON round-trip limitations

The default codec is JSON.stringify/JSON.parse, which silently changes some values:

ValueAfter reload
DateISO string — .getTime() throws
Map, Set{}
undefined propertykey removed
NaN, Infinitynull
BigIntthrows on write

Opt into the bundled tagging codec to round-trip them:

import { richReplacer, richReviver } from "@naikidev/commiq-persist";

persistStore(store, { key: "state", replacer: richReplacer, reviver: richReviver });

Date, Map, Set, NaN, ±Infinity and BigInt then survive a reload. Properties whose value is undefined still come back absent — JSON.parse cannot restore them — and mergeOverInitial restores their defaults. Class instances and functions are never persisted; keep persisted state to plain data.

Keeping persisted state plain also keeps devtools honest — its default snapshot mode captures Map, Set and class instances by reference. See Devtools Core.

Exported types

PersistOptions, PersistResult, PersistedSnapshot, PersistableStore, StorageAdapter, PersistErrorReport, PersistErrorReporter, PersistErrorSource, MigrateFn, ValidateFn, MergeFn, JsonReplacer, JsonReviver, and the Idb* adapter interfaces are all exported for annotation and test doubles:

import type { PersistOptions, PersistResult } from "@naikidev/commiq-persist";

function persistCart(store: CartStore): PersistResult {
  const options: PersistOptions<CartState> = { key: "cart", version: 2 };
  return persistStore(store, options);
}

On this page