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:
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
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:
- Namespace isolation: Prevents collisions with other applications or services sharing the same Redis instance
- Key discovery: Enables
KEYS stateloom:*orSCANfor administrative tools and debugging
TTL Support
When ttl is provided (in seconds), keys are stored with a Redis EX (expire) argument:
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
| Concern | Strategy | Cost |
|---|---|---|
| Network round-trip | One Redis command per operation (get, set, del) | 1 RTT per call |
| Key prefixing | String concatenation (prefix + key) | O(n) where n = key length |
| TTL refresh | Set on every setItem — no conditional check needed at Redis level | O(1) Redis operation |
| Serialization | Handled by @stateloom/persist (JSON.stringify/parse) — adapter receives strings | Zero serialization overhead in adapter |
| Connection management | Delegated to consumer's Redis client | Zero overhead in adapter |
Cross-References
- Persist Design — the parent persist middleware that consumes this adapter
- Middleware Overview — where persist-redis fits in the middleware taxonomy
- Architecture Overview — layer structure and dependency graph
- API Reference:
@stateloom/persist-redis— consumer-facing documentation