Keyval is a small key/value storage layer with a consistent API across environments and backends:
- In-memory backend (fast, ephemeral)
- Browser storage backend (WebStorage, IndexedDB, Cookie Store)
- File storage backend (Node.js)
- Redis backend (shared server-side storage)
It gives you a simple dictionary API for state—regardless of where that state physically lives.
npm i @webqit/keyvalYou import the implementation you need by subpath:
import { InMemoryKV } from '@webqit/keyval/inmemory';
import { FileKV } from '@webqit/keyval/file';
import { WebStorageKV } from '@webqit/keyval/webstorage';
import { IndexedDBKV } from '@webqit/keyval/indexeddb';
import { CookieStoreKV } from '@webqit/keyval/cookiestore';
import { RedisKV } from '@webqit/keyval/redis';Each implementation follows the same interface.
const kv = new InMemoryKV({
path: ['session', 'session-123'],
});
await kv.set('step', 1);
await kv.set('flags', { beta: true });
console.log(await kv.get('step')); // 1
console.log(await kv.get('flags')); // { beta: true }Keyval is designed around at least four useful concepts.
The first thing you do is map an instance to a storage path:
const kv = new InMemoryKV({
path: ['session', 'session-123'],
});This path defines a storage namespace.
A namespace can represent anything meaningful in your system:
- a user →
['user', userId] - a session →
['session', sessionId] - a request →
['request', requestId] - a tenant →
['tenant', tenantId] - a document →
['document', docId] - a workflow →
['workflow', workflowId] - a cache window →
['cache', cacheKey] - a logical subsystem →
['feature', featureName]
Examples:
// per-user state
new IndexedDBKV({ path: ['user', userId] });
// per-session state
new InMemoryKV({ path: ['session', sessionId] });
// per-tenant configuration
new RedisKV({ path: ['tenant', tenantId] });
// per-document draft
new WebStorageKV({ path: ['draft', docId] });Whatever your path scheme or depth:
- everything written through the instance lives under that namespace
- everything read through the instance is isolated to that namespace
- clearing the instance clears only that namespace
Each Keyval instance is like a simple JavaScript map – a dictionary:
await kv.set('state', 'active');
await kv.set('flags', { beta: true });But Keyval diverges from the Map contract in a few ways:
- Methods are async.
- An async
.count()method is the equivalent ofMap.size. - No
.forEach()method. You use.entries()instead.
And Keyval extends the contract with additional methods like .observe(), .close(), etc.
Keyval ensures a transparent mapping between what you set and what you get. But internally, each key is held as a metadata object containing the actual value and optional user-supplied metadata. This typically looks like:
{
value: any,
...meta
}This makes it possible to support field-level metadata when needed:
kv.set('key1', 22);
kv.set({ key: 'key1', value: 22, expires: Date.now() + 60 * 60 * 1000 });Metadata remains unexposed until explicitly requested:
console.log(await kv.get('key1')); // 22
console.log(await kv.get({ key: 'key1' })); // { value: 22, expires: ... }You’ll see this concept again in the API section.
Across KV types, you get the same API, and same mental model.
Swap the backend implementation — nothing else changes:
import { RedisKV } from '@webqit/keyval/redis';
const kv = new RedisKV({
path: ['user', 'user-123'],
redisUrl: process.env.REDIS_URL,
});
await kv.set('state', { step: 2 });This is the central promise of Keyval: you design your state model once, and choose the storage backend separately.
Once an instance exists, these are the operations you’ll use most often:
await kv.set(key, value);
await kv.get(key);
await kv.has(key);
await kv.delete(key);
await kv.clear();For working with structured state:
await kv.patch({ a: 1, b: 2 });
const all = await kv.json();For reacting to changes:
kv.observe((event) => {
console.log(event.type, event.key);
});Everything else in the documentation builds on these primitives.
When the lifecycle of the thing your instance represents ends, you can clear it in one operation:
await kv.clear();This removes only the data associated with ['session', 'session-123']. Other sessions, users, or subsystems are unaffected.
This pattern is especially natural for:
- session teardown
- logout flows
- request-level caches
- workflow resets
Very often, the data to persist is a JSON object of multiple fields, not just a field value. Sometimes too, you want the whole dictionary returned as plain JSON object.
Keyval’s patch() and json() methods let you do that.
await kv.patch({
profile: { theme: 'dark', locale: 'en' },
flags: { beta: true },
});This patches the dictionary.
If you want to reset the dictionary instead, use the replace flag:
await kv.patch(
{ flags: { beta: false } },
{ replace: true }
);This updates flags and clears out other fields.
const state = await kv.json();
console.log(state);
// {
// profile: { theme: 'dark', locale: 'en' },
// flags: { beta: true }
// }To have each field return their full metadata, pass { meta: true } to .json():
const state = await kv.json({ meta: true });
// {
// profile: { value: { theme: 'dark', locale: 'en' }, expires: ... },
// flags: { value: { beta: true }, expires: ... }
// }State is often shared between parts of a system: UI components, background tasks, request handlers, or sync processes.
Keyval provides a small but expressive observation API so you can react to changes.
const stop = kv.observe('profile', (event) => {
console.log(event.type, event.value);
});
await kv.set('profile', { theme: 'dark' });
// logs: set { theme: 'dark' }
stop();This is ideal when a particular value drives behavior elsewhere in your app.
const stop = kv.observe((event) => {
console.log(event.type, event.key);
});
await kv.set('flags', { beta: true });
// logs: set flags
stop();This is useful for:
- debugging
- synchronization
- derived state
- audit or logging pipelines
Observers can be configured to auto-dispose:
kv.observe('flags', (event) => {
console.log('flags changed once:', event.value);
}, { once: true });They can also be bound to an AbortSignal, which is especially convenient in async workflows:
const controller = new AbortController();
kv.observe('state', handler, { signal: controller.signal });
// later
controller.abort();Many KV types support cross-process observability. This means that you can observe changes to a namespace from multiple processes – e.g. a KV instance in another worker, tab, or even another machine (for RedisKV).
Supporting implementations are: RedisKV, WebStorageKV, CookieStoreKV, IndexedDBKV.
RedisKV
RedisKV supports cross-process observability out of the box using Redis pub/sub. RedisKV instances operate globally in the channel name specified in the options.channel parameter. This is null by default. When not set, only local mutations are observed.
When set, multiple RedisKV instances connected to the same Redis server and channel will observe changes to the same namespace – even if they live on different machines. The observe() method lets you opt-in or out of global events:
kv.observe((e) => {
}, { scope: 0/* only locaal events */ });How it works: TODO
WebStorageKV, CookieStoreKV, IndexedDBKV
These KV types support cross-process observability out of the box using BroadcastChannel. Instances operate globally in the channel name specified in the options.channel parameter. This is null by default. When not set, only local mutations are observed.
When set, multiple instances connected to the same channel will observe changes to the same namespace – even if they live in different tabs or processes (e.g. different tabs, the Service Worker or a Web Worker vs the main browser window). The observe() method lets you opt-in or out of global events:
kv.observe((e) => {
}, { scope: 0/* only locaal events */ });How it works: TODO
Keyval supports expiry at two levels: per-namespace TTL and field-level expiry.
import { InMemoryKV } from '@webqit/keyval/inmemory';
const kv = new InMemoryKV({
path: ['request', 'req-98f3'],
ttl: 5_000, // 5 seconds
});The ttl option accepts:
- numeric time intervals (milliseconds)
This namespace and its fields will automatically expire after 5 seconds.
A value of zero (or a negative value) expires the namespace immediately.
On top of the namespace-level TTL, Keyval supports field-level expiry.
await kv.set({
key: 'challenge',
value: 'abc123',
expires: Date.now() + 60_000, // 1 minute
});The expires field accepts:
Date- ISO date string
- numeric timestamps (milliseconds)
Keyval normalizes these internally so you don’t have to.
This field will expire after 1 minute.
Important: Field-level expiry only takes effect when namespace-level ttl is set – even if 0.
ttldefines the lifetime of the storage namespace.expiresdefines the lifetime of a field within that namespace.
Unless this condition is met, the expires metadata is not treated specially by Keyval.
This rule applies consistently across all KV types, including Redis.
But since Redis does not natively have a per-field expiry behavior, Keyval requires an additional opt-in to field-level expiry for Redis instances: { fieldLevelExpiry: true }.
const kv = new RedisKV({
path: ['user', userId],
ttl: 60_000,
fieldLevelExpiry: true,
});When enabled:
-
Field-level
expiressemantics take effect. -
On every
set()orpatch()mutation, the namespace-level TTL is re-applied/renewed -
If a key has an
expireslater than the namespace-level TTL:- the namespace TTL is extended to ensure that the namespace lives as long as the key – and not expire before key expiry.
- Other keys still expire according to their own
expiresor according to the original namespace-level TTL.
import { InMemoryKV } from '@webqit/keyval/inmemory';
export function createSessionStore(sessionId) {
return new InMemoryKV({
path: ['session', sessionId],
ttl: 30 * 60_000, // 30 minutes
});
}Use this for:
- CSRF tokens
- auth challenges
- flash messages
- request aggregation
import { IndexedDBKV } from '@webqit/keyval/indexeddb';
export function createUserStore(userId) {
return new IndexedDBKV({
path: ['user', userId],
dbName: 'my_app',
});
}Use this for:
- preferences
- drafts
- offline-first data
- user-local caches
import { RedisKV } from '@webqit/keyval/redis';
export function createTenantStore(tenantId) {
return new RedisKV({
path: ['tenant', tenantId],
redisUrl: process.env.REDIS_URL,
ttl: 5 * 60_000,
});
}Use this for:
- shared caches
- rate-limiting state
- coordination data
- feature rollout flags
All Keyval backends share the same conceptual model and API surface, but they differ in:
- where data is physically stored,
- how
pathis flattened into backend-specific keys, - how expiry is enforced,
- what metadata is supported.
This section documents those differences explicitly, so you know exactly what to expect when choosing a backend.
import { InMemoryKV } from '@webqit/keyval/inmemory';
const kv = new InMemoryKV({
path: ['session', sessionId],
});What it is
A process-local, in-memory dictionary backed by JavaScript Maps.
Persistence & sharing
- Data exists only for the lifetime of the process.
- Not shared across processes, workers, or browser tabs.
Path flattening
pathis used to structure the instance internally.- Path flattening or serialization as a concept is not applicable.
Metadata
- Arbitrary field-level metadata is supported:
kv.set({ key, value, ...meta }).
Expiry
- Field-level expiry is supported (when a namespace-level TTL is set).
- Expired keys are removed lazily on next access.
Typical use cases
- request- or session-scoped state
- hot caches
- tests and local tooling
import { FileKV } from '@webqit/keyval/file';
const kv = new FileKV({
path: ['user', userId],
dir: '.webqit_keyval',
});What it is
A persistent key/value dictionary backed by the filesystem.
Path flattening
- The
patharray is flattened using:and mapped to a file name:
<dir>/<path.join(':')>.json
Example structure:
.webqit_keyval/ ← Directory
└── user:user-42.json ← File – a KV instancePersistence & sharing
- Persists in the filesystem.
- Not concurrency-safe across multiple processes unless the filesystem is shared and externally synchronized.
Metadata
- Arbitrary field-level metadata is supported:
kv.set({ key, value, ...meta }).
Expiry
- Field-level expiry is supported (when a namespace-level TTL is set).
- Expired keys are removed lazily on next access.
Typical use cases
- CLI tools
- small Node services
- local persistence without Redis or a database
import { WebStorageKV } from '@webqit/keyval/webstorage';
const kv = new WebStorageKV({
path: ['session', sessionId],
storage: 'local', // or 'session'
});What it is
A Keyval dictionary backed by localStorage or sessionStorage.
Path flattening
- Keys are flattened as:
<path.join(':')>:<key>
Example structure:
session:abc123:flags ← { value, ...meta }
session:abc123:profile ← { value, ...meta }Persistence & sharing
localStorage: persists across reloads, shared across tabs.sessionStorage: scoped to a single tab/session.- Optional
BroadcastChannelpublishing for cross-tab signaling.
Metadata
- Arbitrary field-level metadata is supported:
kv.set({ key, value, ...meta }).
Expiry
- Field-level expiry is supported (when a namespace-level TTL is set).
- Expired keys are removed lazily on next access.
Caveats
- Standard Web Storage size limits apply.
- Underlying storage is synchronous (even though Keyval’s API is async).
import { IndexedDBKV } from '@webqit/keyval/indexeddb';
const kv = new IndexedDBKV({
path: ['user', userId],
dbName: 'my_app',
});What it is
An async Keyval dictionary backed by IndexedDB.
Path flattening
- Each
pathmaps to one object store. - The object store name is:
path.join(':')
Example structure:
my_app ← Database
└── user:user-42 ← Store – a KV instancePersistence & sharing
- Persists in the database.
- Available offline.
- Optional
BroadcastChannelpublishing for multi-tab coordination.
Metadata
- Arbitrary field-level metadata is supported:
kv.set({ key, value, ...meta }).
Expiry
- Field-level expiry is supported (when a namespace-level TTL is set).
- Expired keys are removed lazily on next access.
Typical use cases
- offline-first applications
- larger browser-resident datasets
- async-safe browser persistence
import { CookieStoreKV } from '@webqit/keyval/cookiestore';
const kv = new CookieStoreKV({
path: ['session', sessionId],
cookiePath: '/',
});What it is
A Keyval dictionary backed by the Cookie Store API (cookieStore) or a compatible storage interface.
Path flattening
- Cookie names are flattened as:
<path.join(':')>:<key>
Example structure:
session:abc123:csrf ← { value, ...meta }
user:user-42:profile ← { value, ...meta }Metadata (Constrained)
-
Only metadata supported by the Cookie API is allowed:
expiresmaxAgepathdomainsecuresameSite
Expiry
- Enforced natively by the browser via cookie semantics.
Typical use cases
- cookie-centric auth flows
- interoperability with existing cookie-based systems
- lightweight persistence with strict constraints
import { RedisKV } from '@webqit/keyval/redis';
const kv = new RedisKV({
path: ['user', userId],
redisUrl: process.env.REDIS_URL,
ttl: 60_000,
});What it is
A Keyval dictionary backed by Redis hashes.
Path flattening
- Each instance maps to one Redis hash key:
<namespace>:<path.join(':')>
Default namespace is *.
Example structure:
*:user:user-42 ← Redis hash (instance)
└── profile ← { value, ...meta }Metadata
- Arbitrary field-level metadata is supported:
kv.set({ key, value, ...meta }).
Expiry
- Standard hash-level TTL is enforced natively by Redis.
- Field-level expiry is supported (when a namespace-level TTL is set and
options.fieldLevelExpiryis set). - Expired keys are removed lazily on next access.
Typical use cases
- shared caches
- session storage at scale
- coordination state across server instances
All Keyval instances—regardless of backend—expose the same API.
All methods are async except
observe().
await kv.set(key, value);or
await kv.set({
key,
value,
...meta
});You may include any metadata you want as metadata—except where restricted by the backend (notably CookieStoreKV)–and it will be stored alongside the value.
Example:
await kv.set({
key: 'profile',
value: { theme: 'dark' },
expires: Date.now() + 60_000,
source: 'sync',
revision: 4,
});Backend notes:
- All backends except CookieStoreKV allow arbitrary metadata.
- CookieStoreKV allows only cookie-supported attributes as metadata.
Also, for the Cookie Store API, you do not call:
cookieStore.set({ name, ... })With Keyval, you always use:
kv.set({ key, ... })Keyval maps key to name internally.
The same applies to get():
await kv.get({ key });await kv.get(key);or
await kv.get({ key });- Returns the stored
value. - If the key is expired or missing, returns
undefined.
The object form returns the full field metadata.
await kv.has(key);
await kv.has({ key });Returns true if the key exists and is not expired.
await kv.delete(key);
await kv.delete({ key });Removes the key and its metadata.
await kv.clear();Clears all keys within the namespace.
await kv.patch(object);or
await kv.patch(object, options);{
replace?: boolean;
meta?: boolean;
}Where fields in the input JSON object are field metadata objects, not raw values, set options.meta: true to tell .patch() to treat them as such.
Example:
await kv.patch(
{
profile: {
value: { theme: 'dark' },
expires: Date.now() + 60_000,
source: 'import',
},
},
{ meta: true }
);This allows bulk writes with field-level metadata.
await kv.json(); // returns { key: value }
await kv.json({ meta: true }); // returns { key: { value, ...meta } }Passing { meta: true } returns the full field metadata.
All enumeration methods are async.
await kv.count(); // async equivalent of Map.size
await kv.keys();
await kv.values();
await kv.entries();These methods always reflect the active, non-expired fields.
const stop = kv.observe(key?, handler, options?);- The only synchronous method.
- Returns an unsubscribe function.
Supports:
- observing a specific key
- observing the entire namespace
{ once: true }{ signal: AbortSignal }
Observer callbacks receive an event describing the mutation (type, key, value, etc.).
kv.cleanup(); // auto unbinds all observers
await kv.close(); // releases backend resourcesAll forms of contributions are welcome at this time. For example, syntax and other implementation details are all up for discussion. Also, help is needed with more formal documentation. And here are specific links:
MIT.