Skip to content

@absoluteui/services/storage

Typed local, session, memory, and IndexedDB stores with TTL, migrations, cross-tab sync, cookies, and encryption at rest.

import { ... } from '@absoluteui/services/storage';

Typed, namespaced key-value stores over localStorage, sessionStorage, IndexedDB, or memory, with the same async API for all of them. Reach for createStore when you persist user preferences, drafts, or cached lookups; it adds expiry, schema versions, validation, and change events, and falls back to memory during SSR or when storage is blocked. Cookies and a promise wrapper for IndexedDB live here too.

How do I keep typed preferences that expire?

Section titled “How do I keep typed preferences that expire?”

Give the store a namespace, a shape check, and a default time to live. Reads never throw: missing, expired, corrupt, or invalid entries read as undefined.

import { createStore } from '@absoluteui/services/storage';
interface GridPrefs { density: 'compact' | 'comfortable'; pageSize: number }
const isGridPrefs = (v: unknown): v is GridPrefs =>
typeof v === 'object' && v !== null && 'density' in v && 'pageSize' in v && typeof v.pageSize === 'number';
let clock = Date.parse('2026-10-05T09:00:00Z');
const prefs = createStore<GridPrefs>({ namespace: 'hr-grid', validate: isGridPrefs, ttlMs: 30 * 86_400_000, now: () => clock });
await prefs.set('employees', { density: 'compact', pageSize: 50 });
await prefs.get('employees'); // => { density: 'compact', pageSize: 50 }
await prefs.set('leave', { density: 'comfortable', pageSize: 25 }, { ttlMs: 60_000 }); // per-entry override
clock += 120_000;
await prefs.get('leave'); // => undefined
await prefs.keys(); // => ['employees']

The injected now is only for the example; omit it in applications. prune() removes expired entries in bulk, for example on start-up.

How do I change the shape of stored data without losing it?

Section titled “How do I change the shape of stored data without losing it?”

Raise version and pass migrate. Older entries are upgraded on first read and written back; without migrate, they are dropped.

import { createStore } from '@absoluteui/services/storage';
const v1 = createStore<{ compact: boolean }>({ namespace: 'hr-layout' });
await v1.clear();
await v1.set('timesheet', { compact: true });
const v2 = createStore<{ density: 'compact' | 'comfortable' }>({
namespace: 'hr-layout',
version: 2,
migrate: (old, fromVersion) => {
const compact = fromVersion === 1 && typeof old === 'object' && old !== null && 'compact' in old && old.compact === true;
return { density: compact ? 'compact' : 'comfortable' };
},
});
await v2.get('timesheet'); // => { density: 'compact' }

How do I react when another tab changes a value?

Section titled “How do I react when another tab changes a value?”

subscribe reports changes from this store instance (source: 'local') and from other tabs ('remote', through storage events or a BroadcastChannel for IndexedDB). Read the new value with get; pass null to hear about every key.

import { createStore, type StoreChange } from '@absoluteui/services/storage';
const settings = createStore<string>({ namespace: 'hr-settings' });
const seen: StoreChange[] = [];
const stop = settings.subscribe('theme', (change) => { seen.push(change); });
await settings.set('theme', 'dark');
seen; // => [{ key: 'theme', kind: 'set', source: 'local' }]
stop();
settings.close();

Wrap a string store with createEncryptedStore and an AES-GCM key. Derive the key from a passphrase with deriveKeyPbkdf2 and keep the salt (it is not secret) next to the data.

import { deriveKeyPbkdf2, randomSalt } from '@absoluteui/services/crypto';
import { createEncryptedStore, createStore } from '@absoluteui/services/storage';
interface Draft { employeeId: number; note: string }
const key = await deriveKeyPbkdf2('passphrase entered by the user', randomSalt());
const raw = createStore<string>({ namespace: 'hr-drafts', backend: 'indexeddb' });
const drafts = createEncryptedStore<Draft>(raw, key);
await drafts.set('review-42', { employeeId: 42, note: 'Promotion case' });
await drafts.get('review-42'); // => { employeeId: 42, note: 'Promotion case' }
(await raw.get('review-42'))?.includes('Promotion'); // => false

This protects data at rest (disk, backups). Script running in the page can use the same key, so it does not protect against XSS.

Use cookies only for small, non-sensitive values such as a locale or consent state. setCookie defaults to Secure, SameSite=Lax, and Path=/, URI-encodes the value, and refuses cookies over MAX_COOKIE_BYTES (SVC_COOKIE_TOO_LARGE).

import { getCookie, removeCookie, setCookie } from '@absoluteui/services/storage';
setCookie('hr-locale', 'en-GB', { maxAge: 365 * 86_400 });
getCookie('hr-locale'); // e.g. 'en-GB'
removeCookie('hr-locale');

JavaScript cannot read or set HttpOnly cookies; set session cookies from the server.

  • Bad keys, namespaces, or TTLs throw at once (SVC_STORAGE_INVALID_KEY, SVC_STORAGE_INVALID_NAMESPACE, SVC_STORAGE_INVALID_TTL); a value that fails validate throws SVC_STORAGE_INVALID; a full disk rejects with SVC_STORAGE_QUOTA.
  • In private browsing or during SSR the store silently uses memory: check persistent after ready if losing data matters.
  • Never keep auth tokens in web storage; any script in the page can read them. Use HttpOnly cookies set by the server.
import { createStore } from '@absoluteui/services/storage';
const store = createStore<number>({ namespace: 'hr-counters', validate: (v): v is number => typeof v === 'number' });
createStore<number>({ namespace: 'hr:counters' }); // => throws SVC_STORAGE_INVALID_NAMESPACE
await store.set('', 1); // => throws SVC_STORAGE_INVALID_KEY
await store.set('visits', Number.NaN, { ttlMs: -1 }); // => throws SVC_STORAGE_INVALID_TTL

getCookie

One cookie’s decoded value, or undefined.

function getCookie(name: string): string | undefined;

Example:

const consent = getCookie('consent');

getCookies

All readable (non-HttpOnly) cookies as a null-prototype record; {} without a document (SSR). The first occurrence of a duplicate name wins (the most specific path, per browsers’ ordering).

function getCookies(): Record<string, string>;

Example:

getCookies().theme

removeCookie

Deletes a cookie. path and domain must match the values it was set with.

function removeCookie(name: string, options?: Pick<CookieOptions, 'path' | 'domain' | 'secure' | 'sameSite' | 'partitioned'>): boolean;

Example:

removeCookie('theme');

setCookie

Writes a cookie (value is URI-encoded). Defaults: Path=/, Secure, SameSite=Lax. __Host- and __Secure- prefixes are validated. HttpOnly cannot be set from JavaScript. Returns false without a document (SSR).

function setCookie(name: string, value: string, options?: CookieOptions): boolean;

Example:

setCookie('theme', 'dark', { maxAge: 60 * 60 * 24 * 365 });

MAX_COOKIE_BYTES

Browsers cap one cookie (name + value) at about 4096 bytes.

const MAX_COOKIE_BYTES = 4096;

CookieOptions

Options for {@link setCookie}.

interface CookieOptions {
readonly maxAge?: number;
readonly expires?: Date;
readonly path?: string;
readonly domain?: string;
readonly secure?: boolean;
readonly sameSite?: 'Strict' | 'Lax' | 'None';
readonly partitioned?: boolean;
}

createEncryptedStore

Wraps a string store so values are encrypted with AES-GCM (fresh random 96-bit IV per write; AAD binds each ciphertext to namespace and key, so entries cannot be swapped between keys). TTL, versions and change events come from the inner store (its migrate sees ciphertext, so version the plaintext yourself if needed).

Protects data at rest only (disk, backups, other OS users). It does NOT protect against XSS: script running in the page can use the same key. Never store auth tokens in web storage; use HttpOnly cookies.

function createEncryptedStore<T>(store: Store<string>, key: CryptoKey, options?: EncryptedStoreOptions<T>): Store<T>;

Example:

import { deriveKeyPbkdf2, randomSalt } from '@absoluteui/services/crypto';
type Draft = { text: string };
const key = await deriveKeyPbkdf2('user passphrase', randomSalt());
const drafts = createEncryptedStore<Draft>(createStore<string>({ namespace: 'drafts', backend: 'indexeddb' }), key);
await drafts.set('d1', { text: 'Hi' });

EncryptedStoreOptions

Options for {@link createEncryptedStore}.

interface EncryptedStoreOptions<T> {
readonly validate?: (value: unknown) => value is T;
readonly onError?: (error: HelperError, key: string) => void;
}

idbError

Maps a DOMException from IndexedDB to a HelperError (quota -> SVC_STORAGE_QUOTA) without leaking platform messages.

function idbError(e: unknown): HelperError;

Example:

declare const req: IDBRequest<number>;
declare const reject: (error: Error) => void;
req.onerror = () => reject(idbError(req.error));

idbRequest

Wraps an IDBRequest in a promise.

function idbRequest<T>(request: IDBRequest<T>): Promise<T>;

Example:

declare const store: IDBObjectStore;
const n = await idbRequest(store.count());

openIndexedDb

Opens (and creates or upgrades) an IndexedDB database with a promise API. Handles blocked, versionchange (closes so other tabs can upgrade) and transaction completion correctly. Rejects with SVC_IDB_UNAVAILABLE when IndexedDB is absent (SSR, some private modes).

function openIndexedDb(name: string, options?: OpenIndexedDbOptions): Promise<IndexedDbConnection>;

Example:

const conn = await openIndexedDb('app', {
version: 2,
upgrade(db, old) { if (old < 1) db.createObjectStore('drafts'); if (old < 2) db.createObjectStore('files'); },
});
await conn.put('drafts', { text: 'Hi' }, 'd1');

IndexedDbConnection

Promise-based IndexedDB connection returned by {@link openIndexedDb}.

interface IndexedDbConnection {
readonly db: IDBDatabase;
get<T = unknown>(store: string, key: IDBValidKey): Promise<T | undefined>;
put(store: string, value: unknown, key?: IDBValidKey): Promise<IDBValidKey>;
delete(store: string, key: IDBValidKey | IDBKeyRange): Promise<void>;
clear(store: string): Promise<void>;
keys(store: string, query?: IDBKeyRange): Promise<IDBValidKey[]>;
getAll<T = unknown>(store: string, query?: IDBKeyRange): Promise<T[]>;
count(store: string, query?: IDBKeyRange): Promise<number>;
transaction<R>(stores: string | readonly string[], mode: IDBTransactionMode, work: (tx: IDBTransaction) => R): Promise<R>;
close(): void;
readonly closed: boolean;
}

OpenIndexedDbOptions

Options for {@link openIndexedDb}.

interface OpenIndexedDbOptions {
readonly version?: number;
readonly upgrade?: (db: IDBDatabase, oldVersion: number, newVersion: number, transaction: IDBTransaction) => void;
readonly onBlocked?: () => void;
readonly onVersionChange?: () => void;
readonly timeoutMs?: number;
}

createStore

Creates a typed, namespaced store with TTL, versioned migrations, validation and cross-tab change events. All methods are async regardless of backend. Falls back to memory (with persistent === false) when the requested backend is unavailable, including during SSR. Do not keep auth tokens in web storage (XSS-readable).

function createStore<T>(options: CreateStoreOptions<T>): Store<T>;

Example:

const prefs = createStore<{ density: 'compact' | 'comfortable' }>({ namespace: 'prefs', version: 2,
migrate: (old) => ({ density: (old as { compact?: boolean }).compact ? 'compact' : 'comfortable' }) });
await prefs.set('grid', { density: 'compact' });
const stop = prefs.subscribe('grid', () => { console.info('Grid preferences changed'); });

CreateStoreOptions

Options for {@link createStore}.

interface CreateStoreOptions<T> {
readonly backend?: StorageBackend;
readonly namespace: string;
readonly version?: number;
readonly migrate?: (oldValue: unknown, fromVersion: number) => T;
readonly ttlMs?: number;
readonly serializer?: StoreSerializer<T>;
readonly validate?: (value: unknown) => value is T;
readonly onError?: (error: HelperError, key: string) => void;
readonly now?: () => number;
}

Store

Async key-value store returned by {@link createStore}.

interface Store<T> {
readonly namespace: string;
readonly backend: StorageBackend;
readonly persistent: boolean;
readonly ready: Promise<void>;
get(key: string): Promise<T | undefined>;
set(key: string, value: T, options?: StoreSetOptions): Promise<void>;
remove(key: string): Promise<void>;
has(key: string): Promise<boolean>;
keys(): Promise<string[]>;
entries(): Promise<[string, T][]>;
clear(): Promise<void>;
prune(): Promise<number>;
subscribe(key: string | null, callback: (change: StoreChange) => void): () => void;
close(): void;
}

StoreChange

A change notification from {@link Store.subscribe}. Read the new value with get.

interface StoreChange {
readonly key: string | null;
readonly kind: 'set' | 'remove' | 'clear';
readonly source: 'local' | 'remote';
}

StoreSerializer

Converts values to and from strings.

interface StoreSerializer<T> {
readonly serialize: (value: T) => string;
readonly deserialize: (text: string) => unknown;
}

StoreSetOptions

Per-write options.

interface StoreSetOptions {
readonly ttlMs?: number;
}

StorageBackend

Where a store keeps its data.

type StorageBackend = 'local' | 'session' | 'memory' | 'indexeddb';