Heximon Logo
Capabilities

Key/Value Storage

The abstract Storage DI token and its BatchStorage capability, MemoryStorage with TTL and LRU bounds, and the first-class UpstashStorage, CloudflareKvStorage, RedisStorage, and UnstorageStorage adapters.

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

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.

storage.module.ts
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:

session-cache.ts
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.

Pick exactly one backend per app graph. Because the abstract 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).

storage.module.ts
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:

storage.module.ts
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],
}) {}
Cloudflare KV is eventually consistent (up to ~60 s global propagation) and 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).

storage.module.ts
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.

session.service.ts
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

BackendgetManysetMany (no TTL)setMany (with TTL)
MemoryStorageSingle in-memory loopSingle in-memory loopSingle in-memory loop
RedisStorage (single-node)One MGET commandOne MSET commandParallel per-key SET EX
RedisStorage (cluster)Parallel per-key GETParallel per-key SETParallel per-key SET EX
UpstashStorageOne MGET REST callOne MSET REST callOne 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.

storage.module.ts
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 BlobStore instead 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 Storage to dedup by key.
  • Example: Storage & blob adapters — swappable Storage/BlobStore bindings and signed URL issuance, wired end-to-end.
Copyright © 2026