Type-safe, middleware-composable storage layer for localStorage, sessionStorage, and IndexedDB.
- Atomic — Each storage key is an independent
Atomwith its own driver, middleware, and lifecycle - Unified async API —
get()/set()/del()/has()/update()all return Promises - Driver abstraction — Swap localStorage, sessionStorage, IndexedDB, or custom backends
- Runtime degradation — Define a driver chain; if one fails, the next is tried automatically
- Middleware pipeline — Onion model with TTL, validation, encryption, compression, caching, debounce, locking, and more
- Scope management — Group atoms by scope for batch cleanup (like
AbortControllerfor storage) - Opt-in sync —
sync()for same-tab peers,tabSync()across tabs; atoms are independent by default - Type-safe — Full TypeScript inference, strict mode
- Framework-agnostic — Zero runtime dependencies, pure ESM, tree-shakeable
pnpm add atorageimport { atom, withDriver } from 'atorage';
import { localStorageDriver } from 'atorage/drivers';
const token = atom<string>('auth-token', withDriver(localStorageDriver()));
await token.set('abc123');
console.log(await token.get()); // 'abc123'
console.log(await token.has()); // true
await token.del();- Core Concepts
- Drivers (Storage Backends)
- Middleware
- ttl — Time-to-Live
- validate — Runtime Validation
- versioned — Version Migration
- debounce — Write Debouncing
- compress — Compression
- encrypt — Encryption
- lock — Single-Tab Async Lock
- crossTabLock — Cross-Tab Lock
- sync — Same-Tab Sync
- tabSync — Cross-Tab Sync
- logger — Logging
- Middleware Ordering Guide
- Custom Middleware
- Scopes
- Batch Operations
- Utilities
- Debug Tools
- Test Utilities
- Event System
- Lifecycle
- Architecture
- Export Structure
- Design Decisions & Trade-offs
- Non-Goals
An Atom is the core abstraction — an independent storage unit bound to a unique key, configurable with drivers, middleware, and scopes.
interface Atom<T> extends EventTarget {
readonly key: string;
get(): Promise<T | undefined>;
set(value: T): Promise<void>;
del(): Promise<void>;
has(): Promise<boolean>;
update(updater: (prev: T | undefined) => T | Promise<T>): Promise<T>;
getMeta(): Promise<Record<string, unknown> | undefined>;
peek(): T | undefined;
dispose(): void;
}| Method | Description |
|---|---|
get() |
Always read from driver through the middleware chain |
set(value) |
Write to driver through the middleware chain. Dispatches change event on success |
del() |
Delete value and associated metadata. Dispatches delete event on success |
has() |
Check if value exists. Runs through the full middleware chain — TTL-expired keys return false |
update(fn) |
Atomic read-modify-write. Internal serial queue ensures concurrency safety. Supports async updaters |
getMeta() |
Read persisted metadata (TTL expiry, version number, etc.). Read-only, for debugging |
peek() |
Sync last-known value for this instance. No I/O, no middleware. undefined if never observed or deleted |
dispose() |
Destroy the atom, clean up all subscriptions and resources. Subsequent operations throw AtomDisposedError |
Modifiers are pure functions that configure an atom's drivers, scopes, and middleware:
import { atom, withDriver, withScope, withMiddleware, withPreMiddleware } from 'atorage';
const a = atom<string>(
'key',
withDriver(driver), // Specify storage backend (arrays = degradation chain)
withScope(scope1, scope2), // Specify scopes, determines key prefix
withMiddleware(ttl(1000), sync()), // Middleware chain (appended)
withPreMiddleware(validate(fn)), // Middleware chain (prepended before base)
);Share configuration across a group of atoms:
import { defineAtom, withDriver, withMiddleware } from 'atorage';
import { localStorageDriver } from 'atorage/drivers';
import { encrypt } from 'atorage/middleware';
const secureAtom = defineAtom(() => [
withDriver(localStorageDriver()),
withMiddleware(encrypt({ encrypt: myEncrypt, decrypt: myDecrypt })),
]);
const token = secureAtom<string>('token');
const refresh = secureAtom<string>(
'refresh',
withPreMiddleware(validate(schema)), // Runs before encrypt
withMiddleware(logger()), // Runs after encrypt
);
// token chain: encrypt → driver
// refresh chain: validate → encrypt → logger → driverThe factory function receives the key as its argument, enabling per-key differentiation:
const scopedAtom = defineAtom((key) => [
withDriver(key.startsWith('temp:') ? memoryDriver() : localStorageDriver()),
]);import {
memoryDriver,
localStorageDriver,
sessionStorageDriver,
indexedDBDriver,
} from 'atorage/drivers';| Driver | Storage | Serialization | batch | Best For |
|---|---|---|---|---|
memoryDriver() |
In-memory Map | None | ✓ | Testing, SSR, ephemeral data |
localStorageDriver() |
localStorage | JSON | — | Small persistent data |
sessionStorageDriver() |
sessionStorage | JSON | — | Tab-scoped session data |
indexedDBDriver(opts?) |
IndexedDB | Structured clone | ✓ | Large data, Blob/File |
indexedDBDriver options:
interface IndexedDBDriverOptions {
dbName?: string; // Default: 'atorage'
storeName?: string; // Default: 'kv'
}dbName is the IndexedDB database name; storeName is an object store (KV partition) inside it. Multiple storeNames can share one dbName — if a store is missing, the driver bumps the DB version and creates an empty KV store. Driver instances on the same database share one connection; dispose() releases this instance's hold and closes the connection when the last holder is idle. Schema versions are managed internally; there is no user-facing version option.
Driver interface — All drivers accept unknown values and handle serialization internally:
- localStorage / sessionStorage: Auto
JSON.stringify/JSON.parse - IndexedDB: Native structured clone, can store
File,Blob,ArrayBufferdirectly - memory: Stores references as-is
Pass a driver array to form a degradation chain:
const data = atom(
'user-prefs',
withDriver([localStorageDriver(), indexedDBDriver(), memoryDriver()]),
);Degradation behavior:
- Initialization: Each driver's
available()filters unsupported drivers (e.g., no localStorage in Node.js) - Write: Tries each driver in order; the first successful one stores data, and stale copies in other drivers are cleaned up
- Read: Tries each in order; returns the first non-
undefinedresult - Delete: All drivers execute delete
Core guarantee: a key exists in exactly one driver at steady state. When a higher-priority driver recovers, new writes naturally flow back to it.
Implement the Driver interface:
import type { Driver } from 'atorage';
function redisDriver(url: string): Driver {
const client = createClient({ url });
const connected = client.connect();
return {
name: 'redis',
async get(key) {
await connected;
return client.get(key);
},
async set(key, value) {
await connected;
await client.set(key, JSON.stringify(value));
},
async del(key) {
await connected;
await client.del(key);
},
async has(key) {
await connected;
return (await client.exists(key)) > 0;
},
async keys(prefix) {
await connected;
return client.keys(prefix ? `${prefix}*` : '*');
},
async dispose() {
await client.quit();
},
};
}Optional methods: available() (environment detection) and batch(ops) (batch operations).
Middleware uses the onion model — modify ctx.value before next() to affect downstream, after next() to affect the upstream return value.
import { withMiddleware } from 'atorage';
import {
ttl,
validate,
versioned,
debounce,
lock,
logger,
sync,
tabSync,
compress,
encrypt,
crossTabLock,
} from 'atorage/middleware';const session = atom(
'session',
withDriver(d),
withMiddleware(
ttl(30 * 60 * 1000), // 30 minutes
),
);
// Enable active deletion of expired data from driver
const temp = atom('temp', withDriver(d), withMiddleware(ttl(5000, { deleteOnExpire: true })));- Write: Records
exp(expiry timestamp) in meta - Read: Returns
undefinedwhen expired;deleteOnExpire: trueactively cleans driver data has(): Expired keys returnfalse- Default lazy expiry (no active deletion) — industry standard (Redis, Guava), reduces I/O overhead
import { validate, ValidationError } from 'atorage/middleware';
const age = atom<number>(
'age',
withDriver(d),
withMiddleware(validate((v) => typeof v === 'number' && v >= 0 && v <= 150)),
);
await age.set(-1); // throws ValidationError
try {
await age.set(-1);
} catch (err) {
if (err instanceof ValidationError) {
// handle invalid write
}
}- Write: Throws
ValidationErroron validation failure, preventing the write - Read: Returns
undefinedon validation failure, notifies viaerrorevent ValidationErroris exported fromatorage/middleware(notatorage) — import it only when using thevalidate()middleware
const prefs = atom(
'prefs',
withDriver(d),
withMiddleware(
versioned({
current: 3,
migrate: {
0: (data) => ({ ...data, theme: 'light' }),
1: (data) => ({ ...data, locale: 'en' }),
2: (data) => ({ ...data, notifications: true }),
},
}),
),
);- Write: Tags
meta.ver = current - Read: Detects old versions, runs migrate functions sequentially, then writes back
- Legacy data without
veris treated as v0 - Downgrades not supported (throws on higher version data)
- Writeback runs through the full middleware chain, ensuring encrypt/compress etc. process correctly
const myDebounce = debounce(500);
const draft = atom('draft', withDriver(d), withMiddleware(myDebounce));
// Multiple set() within 500ms only trigger one driver write
await draft.set('v1');
await draft.set('v2');
await draft.set('v3');
// Only 'v3' is written to driver
// get() during debounce returns the latest pending value (in memory)
const val = await draft.get(); // 'v3'
// Manually flush to driver immediately
await myDebounce.flush();- Ideal for drag positions, input autosave, and other high-frequency write scenarios
changeevent fires after actual persistence, not at call timedel()cancels pending writesawait set()resolves after registering the timer, not after actual persistence. Persistence failures propagate via atom'serrorevent- When combined with
versioned(), version migration writebacks are also delayed. Callflush()afterget()on critical paths
const large = atom(
'large',
withDriver(d),
withMiddleware(
compress({
compress: (data) => myCompress(data),
decompress: (data) => myDecompress(data),
}),
),
);- Write:
JSON.stringify→compress→ store as string - Read:
decompress→JSON.parse - Marks compressed data with
meta.cmp = 1
const secret = atom(
'secret',
withDriver(d),
withMiddleware(
encrypt({
encrypt: (data) => myEncrypt(data),
decrypt: (data) => myDecrypt(data),
}),
),
);- Same pattern as compress, marks with
meta.enc = 1 - Encryption implementation is entirely user-provided; AES-GCM or other authenticated algorithms recommended
const counter = atom('counter', withDriver(d), withMiddleware(lock()));
// All operations on this atom instance are serializedconst shared = atom('shared', withDriver(d), withMiddleware(crossTabLock()));
// Cross-tab mutual exclusion via Web Locks API (set/del operations only)Atoms are pure storage handles and do not auto-sync other instances with the same key. Opt in explicitly:
import { sync } from 'atorage/middleware';
const a = atom('token', withDriver(d), withMiddleware(sync()));
const b = atom('token', withDriver(d), withMiddleware(sync()));
await a.set('x'); // b receives change via refresh- Matches by key only (does not compare drivers/backends)
- Each peer refreshes against its own drivers — keep backend config aligned at the call site
refresh: likeget(read-through, emit events; may writeback/delete via middleware flags). Not a userset/del.sync/tabSynconly broadcast after non-writebackset/del(writeback sets carryisWriteback)
const settings = atom('settings', withDriver(d), withMiddleware(tabSync()));
// set()/del() broadcasts to other tabs via BroadcastChannel- Parallel to
sync: signal only; peers land viarefresh() - Broadcasts only key and operation type, never the value — receivers re-read from driver
- Ordering relative to encrypt is irrelevant — no plaintext leak risk
- Custom channel name:
tabSync('my-channel')
const item = atom('item', withDriver(d), withMiddleware(logger()));
// [atorage] set item (0.12ms)
// Custom log function
const item2 = atom(
'item2',
withDriver(d),
withMiddleware(logger({ log: (msg) => myLogger.info(msg) })),
);Middleware ordering is the user's responsibility. Recommended order:
validate → ttl → versioned → compress → encrypt → sync / tabSync → logger
get() always reads through the full pipeline. For a synchronous in-memory view of the last successful observation, use atom.peek() (not middleware).
Middleware can be a plain function or an object with lifecycle hooks:
import type { MiddlewareFunction, MiddlewareWithHooks, MiddlewareContext } from 'atorage';
// Plain function
const myMiddleware: MiddlewareFunction = async (ctx, next) => {
// Before next(): intercept/modify request
console.log(`${ctx.operation} ${ctx.key}`);
await next();
// After next(): process/modify response
};
// Object with hooks
function myStatefulMiddleware(): MiddlewareWithHooks {
let cache: unknown = undefined;
return {
handle: async (ctx, next) => {
// middleware logic
await next();
},
onInit(init) {
// { key, atomId, refresh } — stable refresh for pools/subscriptions
},
onDispose() {
cache = undefined;
},
};
}MiddlewareContext (per-operation):
| Property/Method | Description |
|---|---|
key / atomId |
Storage key and instance id |
operation |
'get' / 'set' / 'del' / 'has' / 'refresh' (refresh is not a public Atom API; coordinators start it via onInit.refresh, or ctx.refresh() in a handle) |
value / meta |
Current value and metadata (meta reserved: exp, ver, enc, cmp) |
refresh() |
Start a refresh pipeline (same handle as onInit.refresh; prefer onInit for long-lived peers) |
requestWriteback() |
After get/refresh: request a writeback set with isWriteback (transforms run; sync/tabSync skip) |
requestDelete() |
After get/refresh/has: request silent driver delete (not the del pipeline) |
isWriteback |
Set only: automatic writeback (e.g. migration) — coordinators must not rebroadcast |
reportError(error) |
Report non-fatal error (flushed as atom error events) |
A Scope is a registry that does two things: contributes a key prefix and deletes registered atoms on clear().
import { createScope, withScope, atom, withDriver } from 'atorage';
const userScope = createScope('user:123');
const prefs = atom('prefs', withDriver(d), withScope(userScope));
// Actual storage key: "user:123:prefs"
const history = atom('history', withDriver(d), withScope(userScope));
// Logout: clear all user data at once
const { errors } = await userScope.clear();
// Both prefs and history execute del() (through full middleware chain)
if (errors.length > 0) {
// Some atoms failed to delete; each error corresponds to a failed del()
console.warn('Partial clear failure:', errors);
}Multi-level scopes are concatenated by argument order:
const auth = createScope('auth');
const session = createScope('session');
const token = atom('token', withScope(auth, session), withDriver(d));
// Actual storage key: "auth:session:token"Scopes are flat and independent — no hierarchy.
import { batch } from 'atorage';
await batch(async () => {
await counter.set(1);
await counter.set(2);
await counter.set(3);
});
// Only ONE change event fires for counter, value = 3Semantics:
- No transactional atomicity — if one operation fails, subsequent operations continue
- Event coalescing — all
change/deleteevents during batch are deferred and dispatched after completion - Multiple sets on same atom — only the last change event is dispatched
- Nested batch — merges into the outer batch; events defer until the outermost batch completes
import { snapshot, restore, clearByPrefix } from 'atorage/utils';const data = await snapshot({ driver: d, prefix: 'myapp:' });
// → { 'myapp:key1': value1, 'myapp:key2': value2, ... }Operates on raw driver data, bypasses middleware.
await restore(data, { driver: d });Writes snapshot data to the driver.
const count = await clearByPrefix('user:oldId:', { driver: d });
// Returns the number of deleted keysUseful for cleaning up orphaned data (e.g., user switching scenarios).
import { inspect, raw } from 'atorage/debug';const info = await inspect(driver, 'my-key');
// → { exists: true, raw: { $v: 'hello', $m: { exp: 1749600000 } }, value: 'hello', meta: { exp: 1749600000 } }Bypasses middleware and the { $v, $m } wrapper, operates directly on the driver:
await raw.get(driver, 'key'); // Read raw stored value
await raw.set(driver, 'key', value); // Write directly
await raw.del(driver, 'key'); // Delete directlyFor debugging, data migration, and interop with external systems.
import { testDriver } from 'atorage/test';Run the standard conformance test suite against your custom driver:
import { testDriver } from 'atorage/test';
testDriver('myDriver', () => createMyDriver());Covers: get missing key, set/get round-trip, has, del, keys, keys(prefix), overwrite, and more.
Atom extends EventTarget, supporting standard event listeners:
const token = atom<string>('token', withDriver(d));
token.addEventListener('change', (e) => {
const { value } = (e as CustomEvent).detail;
console.log('Value changed to:', value);
});
token.addEventListener('delete', () => {
console.log('Value deleted');
});
token.addEventListener('error', (e) => {
const { error } = (e as CustomEvent).detail;
console.error('Operation error:', error);
});
// AbortController support
const ac = new AbortController();
token.addEventListener('change', handler, { signal: ac.signal });
ac.abort(); // removes listener| Event | Trigger | detail |
|---|---|---|
change |
Local set, or refresh (e.g. via sync / tabSync) reads a value |
{ value: T | undefined } |
delete |
Local del, or refresh finds no value, or scope clear |
— |
error |
Middleware or driver threw / reported an error | { error: Error } |
No implicit same-tab sync: Atoms do not know about other instances. Use sync() / tabSync() explicitly; they trigger the events above via peer refresh().
After dispose(), the atom enters a destroyed state:
const token = atom<string>('token', withDriver(d));
await token.set('abc');
token.dispose();
await token.get(); // throws AtomDisposedError- Subsequent
get()/set()/del()/has()/update()throwAtomDisposedError - Currently executing async operations complete normally; queued operations are rejected
- Removes scope registration and calls middleware
onDispose() - Does not release the driver — drivers may be shared across atoms; their lifecycle is user-managed
┌─────────────────────────────────────────────────┐
│ Atom API │ atom(), defineAtom(), batch()
├─────────────────────────────────────────────────┤
│ Middleware Pipeline (Onion Model) │ validate, ttl, encrypt, ...
├─────────────────────────────────────────────────┤
│ Scope Management │ createScope(), key prefixing
├─────────────────────────────────────────────────┤
│ Coordination (opt-in middleware pools) │ sync, tabSync, lock
├─────────────────────────────────────────────────┤
│ Driver (Persistence) │ localStorage, indexedDB, memory
└─────────────────────────────────────────────────┘
Value and meta are merged into a single structure, stored with a single I/O operation:
{ $v: actualValue, $m: { exp: 1749600000, ver: 3, ... } }
$v— actual value$m— metadata (omitted when empty)- Core auto wraps/unwraps; middleware and user API see clean separated views
| Import Path | Contents |
|---|---|
atorage |
Core API: atom, defineAtom, createScope, batch, modifiers, types, AtomDisposedError, StorageError |
atorage/drivers |
memoryDriver, localStorageDriver, sessionStorageDriver, indexedDBDriver |
atorage/middleware |
All preset middleware (including sync, tabSync, validate, ttl, encrypt, …) |
atorage/utils |
snapshot, restore, clearByPrefix |
atorage/debug |
raw, inspect — debug tools |
atorage/test |
testDriver — driver conformance testing |
All operations return Promises, even when the underlying driver (e.g., localStorage) is synchronous. This ensures API consistency so that switching drivers never requires call-site changes. The trade-off is minimal await overhead in simple scenarios.
Correctness first. Every get() reads from the driver through middleware. Use peek() for a synchronous last-known value on the atom instance after a successful observation.
The { $v, $m } envelope format guarantees atomic consistency between value and metadata — you can never have a value written but its metadata lost. Single I/O operation, no dual-write issues.
Expired data is not actively deleted; it's masked on read. This is industry standard (Redis, Guava), reducing I/O overhead on every get. Use { deleteOnExpire: true } or periodic clearByPrefix for active cleanup.
Cross-tab sync only broadcasts key + operation type; receivers re-read from the driver. Reasons:
- BroadcastChannel's structured clone has performance overhead for large objects
- Encrypted/compressed values are meaningless in another tab without re-running the middleware chain
Consistent with Express / Koa middleware models. The library does not implicitly reorder, as different scenarios may require different execution orders.
del() deletes the value and metadata without returning the old value. If you need the old value, read it first via get() or use update() for an atomic read-then-delete pattern.
has() must traverse the full middleware chain (TTL needs to check expiry), so it cannot simply check key existence at the driver level. This adds overhead for large values but ensures semantic correctness.
Drivers may be shared across multiple atoms. Disposing one atom should not destroy a shared resource. Driver lifecycle is user-managed.
All functionality including the IndexedDB driver is self-implemented, without external dependencies like idb-keyval. Core logic is ~100 lines, maintaining the library's lightweight positioning.
Exceptions from middleware or drivers propagate through two channels:
- throw — callers can
try-catch - error event — dispatched via EventTarget for global listeners
When all drivers in the degradation chain fail, a StorageError is thrown. Partial driver failures (e.g., primary driver throws but fallback succeeds) are reported via the atom's error event without interrupting the operation.
Core errors (AtomDisposedError, StorageError) are exported from atorage. Middleware-specific errors (e.g., ValidationError from validate()) are exported from atorage/middleware.
scope.clear() returns a ClearResult containing any errors from individual atom deletions, rather than silently swallowing failures. Each failed atom also dispatches its own error event independently.
The following are explicitly out of scope:
- State management — no computed / derived atoms; that's the job of Jotai / Zustand
- Framework bindings — no built-in React / Vue hooks; integrate via
EventTarget - Quota management / LRU — atoms are independent; cross-atom eviction decisions don't fit the atomic model. Storage full → driver degradation handles it
- External change detection — does not watch DevTools or third-party writes to storage (impossible to unify across all drivers)
- DevTools browser extension — not in current version; planned for future iterations
MIT