Key/Value Storage
Sometimes you just need a simple place to stash a session, cache a computed value, or check an
idempotency key. Heximon gives you an abstract Storage DI port for exactly that — a namespaced
key/value contract you inject, with the owning module deciding whether it's backed by an in-memory
Map under tests or Redis in production.
There's no SQL, no joins, no schema here — that's the point. A key/value store is a fast, swappable place to put data you address by name. When you need relationships, transactions, or migrations, reach for Drizzle instead.
Install the port
MemoryStorage and the main entry are dependency-free. Only the /unstorage subpath pulls in the
optional unstorage peer, plus any driver you import from it.
pnpm add @heximon/kv
npm install @heximon/kv
bun add @heximon/kv
Use the Storage port
Storage is a seven-method key/value contract over opaque string keys. Every method is async, so a
networked backend can stand in for the in-memory one without changing a single call site.
abstract class Storage<TValue extends StorageValue = StorageValue> {
abstract has(key: string): Promise<boolean>;
abstract get<TItem extends TValue = TValue>(key: string): Promise<TItem | null>;
// `options.ttl` (a TimeSpan) is a best-effort native-TTL hint (Redis EX, CF KV expirationTtl, …).
abstract set<TItem extends TValue = TValue>(
key: string,
value: TItem,
options?: StorageSetOptions,
): Promise<void>;
// Write only when the key holds no live value; `true` = the write happened.
abstract setIfAbsent<TItem extends TValue = TValue>(
key: string,
value: TItem,
options?: StorageSetOptions,
): Promise<boolean>;
abstract delete(key: string): Promise<void>;
abstract getKeys(base?: string): Promise<string[]>;
abstract clear(base?: string): Promise<void>;
}
A missing key resolves to null — never undefined, never a throw — so a read is always a single
branch.
setIfAbsent is the conditional write, and how atomic it is depends on the backend: Redis and
Upstash execute it as one atomic SET … NX command, and MemoryStorage checks and writes in a
single synchronous step, while the unstorage bridge and Cloudflare KV fall back to a documented
check-then-set (two calls — concurrent callers can race). Reach for an atomic backend whenever the
returned boolean gates a side effect, like claiming an idempotency key. Keys are opaque strings: pick any namespace convention you like, and pass a base prefix to
scope getKeys and clear to one namespace. One store can host several logical tiers (cache,
sessions, flags) and clear them independently.
import { MemoryStorage } from "@heximon/kv/memory";
const storage = new MemoryStorage();
await storage.set("cache:user:1", { name: "Ada", roles: ["admin"] });
await storage.set("session:1", "token");
await storage.getKeys("cache:"); // ["cache:user:1"] — scoped to the cache: namespace
await storage.clear("cache:"); // removes only the cache: namespace
await storage.get("missing"); // null
TTL is a TimeSpan, not a bare number of seconds — a driver converts it to whatever unit its native
expiry takes:
import { TimeSpan } from "@heximon/primitives";
await storage.set("session:1", "token", { ttl: new TimeSpan(1, "h") });
Bind a backend in a module
Application code injects the abstract Storage and never names a backend. The module picks one:
MemoryStorage for dev and tests (zero infrastructure), a networked driver in production. Swapping
environments changes only this binding.
import { Module } from "@heximon/runtime";
import { Storage } from "@heximon/kv";
import { MemoryStorage } from "@heximon/kv/memory";
import { UnstorageStorage } from "@heximon/kv/unstorage";
import redisDriver from "unstorage/drivers/redis";
export class StorageModule extends Module({
providers: [
// dev / tests — the in-memory backend:
{ provide: Storage, useClass: MemoryStorage },
// production — wrap any unstorage driver behind the same Storage token:
// { provide: Storage, useFactory: () => UnstorageStorage.fromDriver(redisDriver({})) },
],
exports: [Storage], // visible to any module that imports StorageModule
}) {}
A consumer declares the dependency the usual way — a constructor parameter typed Storage, which the
compiler resolves to whatever the module bound:
import { Storage } from "@heximon/kv";
export class SessionCache {
constructor(private readonly storage: Storage) {}
public async remember(id: string, value: object): Promise<void> {
await this.storage.set(`session:${id}`, value);
}
}
UnstorageStorage bridges the whole unstorage driver ecosystem —
memory, fs, Redis, Cloudflare KV, Upstash, S3 — behind the same Storage contract, so application code
stays on the one port no matter which driver is underneath.
Storage class is the DI token, the
graph must resolve it to a single concrete subclass — binding two (one useClass, one useFactory)
makes the resolution ambiguous and the build fails. Use one binding per app, or compose a per-environment
module that contributes exactly one.MemoryStorage stores values by reference (no copy, no serialization), while the networked drivers
serialize — so they round-trip JSON-compatible values only. Functions and symbols are out of contract
for both.MemoryStorage — TTL and LRU bounds
MemoryStorage is the zero-dependency, Map-backed backend for dev and tests. TTL is honored with
lazy expiry (millisecond precision — an expired entry is dropped by the next operation that touches
it), and it's unbounded by default, because a general-purpose KV must not silently drop data like
idempotency markers or sessions.
export interface MemoryStorageOptions {
readonly maxEntries?: number; // LRU eviction past this entry count
readonly maxBytes?: number; // LRU eviction past this approximate serialized size
}
Opt into LRU bounds when you're using it as a cache tier rather than a source of truth — reads and
writes mark entries most-recently-used, and a write past a cap evicts least-recently-used keys first.
maxBytes is the out-of-memory guard for caching large values; maxEntries alone doesn't bound
memory if entries vary wildly in size.
import { MemoryStorage } from "@heximon/kv/memory";
const cache = new MemoryStorage({ maxEntries: 10_000, maxBytes: 50 * 1024 * 1024 });
First-class networked adapters
Beyond the unstorage bridge, three backends ship as first-class adapters on their own subpaths —
typed, with the right lifecycle for each. The two edge backends (Upstash, Cloudflare KV) are
connectionless (no onShutdown); the Node Redis backend holds a real connection (it close()s on
shutdown). All extends Storage, so they bind under the same token. A fourth first-class backend —
the strongly-consistent, Durable-Object-backed KvRoomStorage with a genuinely atomic setIfAbsent —
ships from @heximon/durable/kv
(the DO satisfier lives with the DO machinery; this package stays dependency-thin).
Upstash Redis — @heximon/kv/upstash (edge-safe)
UpstashStorage wraps the fetch-based @upstash/redis client, so it
runs on Cloudflare Workers, Vercel Edge, Deno, and Node with no TCP socket. This is also the Vercel KV
story — Vercel retired its standalone KV and migrated stores to Upstash, so Redis.fromEnv() reads
the KV_REST_API_URL / KV_REST_API_TOKEN it injects with no extra glue.
Three construction paths are supported: inject a pre-configured Redis instance, or use the
convenience statics UpstashStorage.fromEnv() (reads UPSTASH_REDIS_REST_URL /
UPSTASH_REDIS_REST_TOKEN, with Vercel KV fallbacks) or UpstashStorage.fromConfig({ url, token })
(explicit credentials).
import { Module } from "@heximon/runtime";
import { Storage } from "@heximon/kv";
import { UpstashStorage } from "@heximon/kv/upstash";
export class StorageModule extends Module({
providers: [
// From env vars (Vercel KV / any Upstash project):
{ provide: Storage, useFactory: () => UpstashStorage.fromEnv() },
// Or explicit credentials:
// { provide: Storage, useFactory: () => UpstashStorage.fromConfig({ url: "...", token: "..." }) },
],
exports: [Storage],
}) {}
set(key, value, { ttl }) maps to Redis SET … EX (1-second precision), and setIfAbsent to a
single atomic SET … NX [EX] command. getKeys / clear use a
SCAN cursor loop and need a read-write token — they are O(n) admin/teardown operations, not hot-path
calls. UpstashStorage also implements the optional BatchStorage capability interface (native
MGET/MSET; TTL path uses a single pipeline of SET EX commands).
Cloudflare Workers KV — @heximon/kv/cloudflare (edge-safe)
CloudflareKvStorage wraps a KVNamespace binding, resolved via the static Platform.binding(...) —
Platform is a static helper, not a DI token, so the factory takes no parameters:
import { Module, Platform } from "@heximon/runtime";
import { Storage } from "@heximon/kv";
import { CloudflareKvStorage } from "@heximon/kv/cloudflare";
export class StorageModule extends Module({
providers: [
{
provide: Storage,
useFactory: (): Storage => new CloudflareKvStorage(Platform.binding("MY_KV")),
},
],
exports: [Storage],
}) {}
expirationTtl has a
60-second floor — a sub-minute ttl is clamped to 60 with a console.warn. It is therefore the wrong
store for distributed idempotency or cross-region dedup, where a stale read across regions means a
retried request slips past a dedup check that should have caught it — use Durable Objects or D1 for
those instead. setIfAbsent is a check-then-set fallback here (KV has no compare-and-swap), so it
must never gate a cross-isolate side effect.Redis — @heximon/kv/redis (Node-only)
RedisStorage wraps a node-redis TCP client (the redis
package) — the durable Node-side backend for self-hosted Redis, Redis Cloud, and AWS ElastiCache
(point the URL at the managed endpoint). It opens a raw TCP socket, so it is Node-only — edge
runtimes forbid raw sockets, so use @heximon/kv/upstash there instead.
It owns the connection:
connect() on init, close() on shutdown. Use the fromUrl config-form, or inject a pre-built client
for full control (auth, TLS, reconnect, Sentinel).
import { Module } from "@heximon/runtime";
import { Storage } from "@heximon/kv";
import { RedisStorage } from "@heximon/kv/redis";
export class StorageModule extends Module({
providers: [
{
provide: Storage,
useFactory: () => RedisStorage.fromUrl(process.env.REDIS_URL ?? "redis://localhost:6379"),
},
],
exports: [Storage],
}) {}
set(key, value, { ttl }) maps to SET … EX, and setIfAbsent to a single atomic SET … NX [EX]
command — safe across processes and isolates. getKeys / clear use a non-blocking SCAN loop
(never KEYS); clear removes with UNLINK (never FLUSHDB), so it is safe on a shared instance.
Both are O(n) admin/teardown operations. Single-node, Sentinel, and Redis Cluster clients are all
accepted — see Redis Cluster support below. RedisStorage also implements
the optional BatchStorage capability interface (native MGET/MSET on single-node; parallel
fallback on clusters).
Batch operations
Some applications need to read or write many keys at once — warming a cache, seeding a batch of
session tokens, or applying a bulk import. Storage covers single-key ops; when you need native
multi-key commands, inject the BatchStorage capability token instead.
import { BatchStorage } from "@heximon/kv";
BatchStorage extends Storage (so it satisfies any Storage injection too) and adds two methods:
abstract class BatchStorage<TValue> extends Storage<TValue> {
// Result aligned 1:1 with keys; missing key → null.
abstract getMany<TItem>(keys: string[]): Promise<(TItem | null)[]>;
// ttl applied uniformly to all entries when supplied.
abstract setMany<TItem>(
entries: ReadonlyArray<{ readonly key: string; readonly value: TItem }>,
options?: StorageSetOptions,
): Promise<void>;
}
Backends that implement it natively (MemoryStorage, RedisStorage, UpstashStorage) extend
BatchStorage rather than Storage directly, so they are valid satisfiers for both tokens. Drivers
that cannot batch efficiently (CloudflareKvStorage, UnstorageStorage) stay extends Storage — you
simply cannot bind or inject them as a BatchStorage.
import { BatchStorage } from "@heximon/kv";
import { TimeSpan } from "@heximon/primitives";
export class SessionService {
constructor(private readonly store: BatchStorage) {}
public async warmSessions(sessions: { id: string; token: string }[]): Promise<void> {
await this.store.setMany(
sessions.map((session) => ({ key: `session:${session.id}`, value: session.token })),
{ ttl: new TimeSpan(1, "h") },
);
}
public async readSessions(ids: string[]): Promise<(string | null)[]> {
return this.store.getMany<string>(ids.map((id) => `session:${id}`));
}
}
Batch behaviour per backend
| Backend | getMany | setMany (no TTL) | setMany (with TTL) |
|---|---|---|---|
MemoryStorage | Single in-memory loop | Single in-memory loop | Single in-memory loop |
RedisStorage (single-node) | One MGET command | One MSET command | Parallel per-key SET EX |
RedisStorage (cluster) | Parallel per-key GET | Parallel per-key SET | Parallel per-key SET EX |
UpstashStorage | One MGET REST call | One MSET REST call | One pipeline of SET EX |
Redis Cluster does not support MGET/MSET across hash-slot boundaries, so cluster clients fall back
to parallel single-key operations automatically — same semantics, more round-trips.
Redis Cluster support
RedisStorage accepts both a single-node/Sentinel RedisClientType and a RedisClusterType from
node-redis. For a cluster, construct a createCluster(...) client and pass it directly to the
constructor — fromUrl creates a single-node client only.
import { Module } from "@heximon/runtime";
import { Storage } from "@heximon/kv";
import { RedisStorage } from "@heximon/kv/redis";
import { createCluster } from "redis";
export class StorageModule extends Module({
providers: [
{
provide: Storage,
useFactory: () =>
new RedisStorage(
createCluster({
rootNodes: [{ url: "redis://node1:6379" }, { url: "redis://node2:6379" }],
}),
),
},
],
exports: [Storage],
}) {}
getKeys and clear on a cluster iterate over each master node independently (a cluster has no global
SCAN cursor) and union the results — O(n) admin-only ops, not hot-path calls.
See also
- Blob Storage — reach for
BlobStoreinstead when the payload is files or streamed bytes rather than JSON-shaped values. - Drizzle ORM — when you outgrow key/value and need relational queries, transactions, and migrations.
- Idempotency — the CQRS/queue/event idempotency interceptors that inject
Storageto dedup by key. - Example: Storage & blob adapters
— swappable
Storage/BlobStorebindings and signed URL issuance, wired end-to-end.
Caching
Multi-tier caching — TieredCache over the Storage port, the Cache facade with single-flight and tag invalidation, QueryCacheInterceptor for CQRS query results, CacheMiddleware for HTTP responses, CacheEnvelope TTL, CdnPurgeAdapter.
Blob Storage
The abstract BlobStore DI token for streamed bodies and byte ranges, the memory/filesystem/R2/S3 drivers, and the SignedUrlBlobStore capability for direct-to-storage signed upload/download URLs.