Skip to content

Persist Redis Design

Low-level design for @stateloom/persist-redis — the Redis-backed storage adapter for @stateloom/persist. Covers the BYO client pattern, key prefixing strategy, TTL support, and integration with the StorageAdapter interface.

Overview

The persist-redis package provides an async StorageAdapter implementation backed by Redis. It follows a BYO (Bring Your Own) client pattern — the consumer provides a Redis client instance that satisfies a minimal duck-typed interface. This keeps the package free of Redis runtime dependencies while supporting ioredis, redis (node-redis), @upstash/redis, and compatible clients.

Architecture

The redisStorage() factory accepts a configuration object and returns a StorageAdapter with three async methods: getItem, setItem, and removeItem. All state (client reference, prefix, TTL) is captured in the closure.

RedisClient Interface

The package defines a minimal duck-typed interface that all major Redis clients satisfy:

typescript
interface RedisClient {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ...args: unknown[]): Promise<unknown>;
  del(key: string): Promise<unknown>;
}

The set method uses rest args (...args: unknown[]) to support the 'EX', ttl arguments without importing Redis-specific types. The return types use Promise<unknown> because different clients return different types ('OK', numbers, etc.) — the adapter ignores return values.

Implementation Details

redisStorage Factory

typescript
export function redisStorage(options: RedisStorageOptions): StorageAdapter {
  const { client, ttl } = options;
  const prefix = options.prefix ?? DEFAULT_PREFIX; // 'stateloom:'

  function prefixKey(key: string): string {
    return prefix + key;
  }

  return {
    async getItem(key: string): Promise<string | null> {
      return client.get(prefixKey(key));
    },
    async setItem(key: string, value: string): Promise<void> {
      if (ttl !== undefined) {
        await client.set(prefixKey(key), value, 'EX', ttl);
      } else {
        await client.set(prefixKey(key), value);
      }
    },
    async removeItem(key: string): Promise<void> {
      await client.del(prefixKey(key));
    },
  };
}

Key Prefixing Strategy

All keys are prefixed before being passed to Redis:

The default prefix is 'stateloom:'. Consumers can override it via options.prefix. Key prefixing serves two purposes:

  1. Namespace isolation: Prevents collisions with other applications or services sharing the same Redis instance
  2. Key discovery: Enables KEYS stateloom:* or SCAN for administrative tools and debugging

TTL Support

When ttl is provided (in seconds), keys are stored with a Redis EX (expire) argument:

typescript
if (ttl !== undefined) {
  await client.set(prefixKey(key), value, 'EX', ttl);
} else {
  await client.set(prefixKey(key), value);
}

The TTL is set on every setItem call, not just the first. This means each write refreshes the expiry, which matches the expected behavior for persist middleware that periodically saves state. Without TTL, keys persist indefinitely in Redis.

Async Operation Model

All three methods return Promises, making this an async storage adapter. The @stateloom/persist middleware handles async adapters via its hydration path — state is loaded asynchronously on store initialization and saved asynchronously on state changes. The adapter does not manage concurrency; concurrent writes follow Redis's built-in last-write-wins semantics.

Design Decisions

Why BYO Client Instead of Bundled Redis Dependency

Bundling a specific Redis client would force version conflicts on consumers who already use Redis in their application. The BYO pattern eliminates runtime dependencies entirely — @stateloom/persist-redis has zero production dependencies. The duck-typed RedisClient interface ensures compatibility with any client that provides get, set, and del methods.

Why Duck Typing Instead of Importing an Interface

Importing a RedisClient type from ioredis or redis would create a peer dependency on that specific client. Duck typing via a structural interface means the package works with any compatible client without compile-time or runtime coupling. TypeScript's structural type system enforces compatibility at the call site.

Why EX Instead of PX for TTL

Redis EX (seconds) is more intuitive for server-side TTL configuration than PX (milliseconds). Most server caching use cases think in seconds or minutes, not milliseconds. The persist middleware's save frequency is typically on the order of seconds, making second-precision TTL appropriate.

Why prefix Defaults to 'stateloom:' Instead of Empty String

An empty default prefix would mix StateLoom keys with all other keys in the Redis instance, making it impossible to identify or clean up StateLoom-specific data. The colon-separated prefix (stateloom:) follows Redis key naming conventions and enables glob-based key scanning.

Why Structural Match Instead of Import from @stateloom/persist

The StorageAdapter interface is redeclared locally rather than imported from @stateloom/persist. This keeps @stateloom/persist-redis as a sibling package with no compile-time dependency on persist, consistent with the architecture rule that packages at the same layer do not import from each other.

Performance Considerations

ConcernStrategyCost
Network round-tripOne Redis command per operation (get, set, del)1 RTT per call
Key prefixingString concatenation (prefix + key)O(n) where n = key length
TTL refreshSet on every setItem — no conditional check needed at Redis levelO(1) Redis operation
SerializationHandled by @stateloom/persist (JSON.stringify/parse) — adapter receives stringsZero serialization overhead in adapter
Connection managementDelegated to consumer's Redis clientZero overhead in adapter

Cross-References