redora
v0.3.1
Published
Redis SDK for NestJS with a typed command API and declarative HTTP caching (interceptor, decorator, cache keys, TTL presets), built on ioredis.
Downloads
385
Maintainers
Readme
Redora
A Redis SDK for NestJS — typed Redis commands, automatic serialization, eviction-aware caching, structured logging, and Redis observability. Built on ioredis.
Try it
See Redora wired end-to-end in a NestJS API — cache interceptor, remember / set / evict, TTL, and observability:
The Story
Redora is inspired by Pandora, the fictional world of the Avatar movies — a place where different tribes, each with their own unique skills, live together in one connected ecosystem.
Redora is Pandora for Redis. Different Redis "tribes" live together under one roof:
- Tribe 1 — Redis: typed command API, serialization, connection lifecycle, and a raw ioredis escape hatch.
- Tribe 2 — Cache: HTTP interceptor caching, service-level
remember/set, eviction groups (tags), TTL presets, and skip-empty-payload rules. - Tribe 3 — Redis Logger: pino-backed logger used by the Redis module (and injectable in your own providers).
- Tribe 4 — Observability: health checks from
INFO, diagnosis groups, and telemetry payload builders for Prometheus / OpenTelemetry configuration.
Future tribes — queues, rate limiting, distributed locking, Redis for AI — will continue to join.
Features
- Typed Redis commands for strings, hashes, lists, sets, sorted sets, keys,
PING, andINFO - Automatic JSON serialize / deserialize with generics (
get<T>(),hGetAll<T>(), …) - Duration strings (
'5m','250ms') and numeric TTL withExpiryUnits(EXvsPX) - Declarative HTTP caching:
@Cacheable()+CacheInterceptor - Service cache-aside:
CacheService.remember() - Write-through + tag index:
CacheService.set()(always indexes an eviction group) - Group invalidation:
CacheService.evict() - Meaningful-payload gate: empty
[]/{}/nullare not written optional: trueRedis so Nest can boot when Redis is down- Observability: ping, memory/client/server snapshots, stats diagnosis, telemetry metric mapping
- Full TypeScript declarations (
node≥ 18)
Installation
npm install redoraPeer dependencies (already present in a Nest 11 app): @nestjs/common, @nestjs/core, rxjs, reflect-metadata.
npm install @nestjs/common @nestjs/core rxjs reflect-metadataWiring all modules
Import Redis first. Cache, logger, and observability resolve RedisService from that global module.
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import {
CacheModule,
DEFAULT_CACHE_TTL_SECONDS,
ExpiryUnits,
ObservabilityModule,
RedisLoggerModule,
RedisModule,
} from 'redora';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
RedisLoggerModule.forRoot({ level: 'info' }),
RedisModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
url: config.getOrThrow<string>('REDIS_URL'),
}),
}),
CacheModule.forRoot({
prefix: 'cache',
namespace: 'http',
ttlSeconds: DEFAULT_CACHE_TTL_SECONDS.instant,
expiryUnit: ExpiryUnits.SECONDS,
}),
ObservabilityModule.forRoot({
healthCheck: {
memory: { show: true },
diagnosis: true,
},
telemetry: {
prometheus: { enabled: true },
},
}),
],
})
export class AppModule {}RedisModule, CacheModule, and ObservabilityModule are global. RedisLoggerModule.forRoot() is global unless you set isGlobal: false.
RedisModule.forRoot() already imports the logger. You can still register RedisLoggerModule.forRoot() yourself to set level.
Tribe 1 — Redis
Registering the connection
Static
RedisModule.forRoot({ host: 'localhost', port: 6379 });
RedisModule.forRoot('redis://localhost:6379');
RedisModule.forRoot(6379); // ioredis port shorthandAsync (ConfigService)
RedisModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
url: config.getOrThrow('REDIS_URL'),
password: config.get('REDIS_PASSWORD'),
database: Number(config.get('REDIS_DB') ?? 0),
}),
});Connection options
interface RedisConnectionOptions {
host?: string;
port?: number;
username?: string;
password?: string;
database?: number; // mapped to ioredis `db`
url?: string; // redis:// or rediss://
keyPrefix?: string;
maxRetriesPerRequest?: number | null;
retryStrategy?: (times: number) => number | null;
enableReadyCheck?: boolean;
lazyConnect?: boolean;
enableOfflineQueue?: boolean;
optional?: boolean;
tls?: { rejectUnauthorized?: boolean };
maxRedirects?: number;
}Soft-fail when Redis is down (optional: true)
By default ioredis reconnects and may queue commands (requests hang). optional: true sets lazy connect, fail-fast commands, limited retries:
RedisModule.forRoot({
url: process.env.REDIS_URL,
optional: process.env.NODE_ENV !== 'production',
});Nest still starts. Commands throw until Redis is reachable — wrap best-effort cache calls in try/catch. CacheModule still needs RedisModule in DI; optional only softens the socket, it does not remove Redis from the graph.
RedisService vs CacheService.set
| | RedisService.set | CacheService.set |
|---|---|---|
| Options type | SetOptions | CacheSetOptions |
| Eviction group | not used | required — indexes a tag after write |
| Empty payload | written as-is | skipped (hasMeaningfulData) |
| Typical use | sessions, counters, arbitrary keys | cache tribe write-through |
// Low-level: no tag
await this.redisService.set({
key: 'session:abc',
value: { userId: 1 },
ttlSeconds: 3600,
});
// Cache tribe: always pass evictionGroupName
await this.cacheService.set({
key: this.cacheService.buildKey({ namespace: 'products', key: ['item', id] }),
value: product,
ttlSeconds: DEFAULT_CACHE_TTL_SECONDS.short,
evictionGroupName: `products:item:${id}`,
});TTL on Redis writes
Numeric ttlSeconds defaults to seconds (EX). Override with ExpiryUnits.MILLISECONDS (PX).
Duration strings encode the unit. Do not pass expiryUnit together with a string (throws TimeFormatExceptions).
| Input | Redis |
|---|---|
| ttlSeconds omitted | no expiry |
| ttlSeconds: 60 | EX 60 |
| ttlSeconds: 60, expiryUnit: SECONDS | EX 60 |
| ttlSeconds: 1500, expiryUnit: MILLISECONDS | PX 1500 |
| ttlSeconds: '5m' | EX 300 |
| ttlSeconds: '250ms' | PX 250 |
| ttlSeconds: 0 or negative | throws TimeFormatExceptions |
Supported duration suffixes: ms, s, m, h, d, y (365-day years). Examples: '2s', '15m', '1h', '1d'.
Omit ttlSeconds when the key should persist until deleted.
Command API
All of the following live on RedisService.
Strings, numbers, ping
await redis.set({ key: 'greeting', value: 'hello' });
await redis.set({ key: 'session', value: { userId: 1 }, ttlSeconds: '1h' });
await redis.set({
key: 'flash',
value: 'gone-soon',
ttlSeconds: 1500,
expiryUnit: ExpiryUnits.MILLISECONDS,
});
const greeting = await redis.get('greeting');
const session = await redis.get<{ userId: number }>('session');
await redis.incr('counter');
await redis.incrBy('counter', 10);
await redis.decr('counter');
await redis.decrBy('counter', 5);
await redis.mget<User>('user:1', 'user:2');
await redis.mset({ 'config:a': { on: true }, 'config:b': 42 });
await redis.ping(); // 'PONG'Keys
await redis.del('key1', 'key2');
await redis.unlink('big-key');
await redis.exists('key1');
await redis.expire('key1', 60);
await redis.ttl('key1'); // seconds left; -1 no expiry; -2 missing
await redis.persist('key1');
await redis.rename('old', 'new');
await redis.keys('user:*'); // avoid on large production keyspaces
const [cursor, keys] = await redis.scan(0, { match: 'user:*', count: 100 });Hashes
await redis.hSet('profile:1', 'name', 'Neba');
await redis.hSet('profile:1', { age: 30, city: 'Addis Ababa' });
const name = await redis.hGet<string>('profile:1', 'name');
const profile = await redis.hGetAll<Profile>('profile:1');
await redis.hDel('profile:1', 'age');
await redis.hExists('profile:1', 'name');
await redis.hKeys('profile:1');
await redis.hVals<string>('profile:1');
await redis.hLen('profile:1');Lists
await redis.lPush('queue', job1, job2);
await redis.rPush('log', entry);
const job = await redis.lPop<Job>('queue');
const last = await redis.rPop<Entry>('log');
const page = await redis.lRange<Job>('queue', 0, 9);
const size = await redis.lLen('queue');Sets
await redis.sAdd('tags', 'redis', 'nestjs');
await redis.sRem('tags', 'redis');
const tags = await redis.sMembers<string>('tags');
const isMember = await redis.sIsMember('tags', 'nestjs');
const count = await redis.sCard('tags');Sorted sets (ZMember is { score, member })
await redis.zAdd(
'leaderboard',
{ score: 100, member: 'alice' },
{ score: 90, member: 'bob' },
);
await redis.zRem('leaderboard', 'bob');
const top = await redis.zRange('leaderboard', 0, 9);
const topWithScores = await redis.zRange('leaderboard', 0, 9, true);
const reversed = await redis.zRevRange('leaderboard', 0, 9);
const score = await redis.zScore('leaderboard', 'alice');INFO
const raw = await redis.info(); // entire INFO dump
const memory = await redis.info('memory');Prefer ObservabilityService for parsed health/diagnosis rather than scraping INFO yourself.
Raw ioredis client
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { REDIS_CLIENT } from 'redora';
@Injectable()
export class PubSubService {
constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) {}
publish(channel: string, message: string) {
return this.client.publish(channel, message);
}
}Tribe 3 — Redis Logger
RedisLoggerService is a small pino wrapper. Redis connection events (connect, error, reconnecting, end) go through it.
RedisLoggerModule.forRoot({
isGlobal: true, // default true
level: 'debug', // debug | info | warn | error
});Inject it anywhere:
constructor(private readonly redisLogger: RedisLoggerService) {}
this.redisLogger.log('cache warmed');
this.redisLogger.warn('fallback to database');
this.redisLogger.error(err.message);
this.redisLogger.debug('ttl sync');Token REDIS_LOGGER_OPTIONS is exported if you need the raw options object.
Tribe 2 — Cache
Recommended consumer pattern
| Endpoint | Mechanism | Eviction group |
|---|---|---|
| GET collection (findAll) | @Cacheable + CacheInterceptor | one list group for every list variant (query, page, sort) |
| GET one (findOne) | CacheService.remember in the service | per-id item group |
| POST | evict(list group) only | — |
| PATCH | evict(list group) then set item + item group | item group shared with remember |
| DELETE | evict([list group, item group]) | — |
Do not put @Cacheable on GET by id. That would store cache:…:GET:/products/3 while remember/set use cache:products:item:3. PATCH would update one key and leave the HTTP key stale.
Keep group names in constants so the decorator and evict cannot drift:
export const PRODUCTS_LIST_EVICTION_GROUP = 'products:list';
export function productsItemEvictionGroup(id: number): string {
return `products:item:${id}`;
}What gets written (meaningful data)
Interceptor, remember, and CacheService.set all refuse payloads that are not meaningful:
Not cached (response still returned): null, undefined, '', NaN, [], {}, { items: [] }, [{}], { user: null }, empty Buffer, invalid Date.
Cached: 'hello', 0, false, [1, 2], { id: 1 }, { success: false, count: 0 }, { empty: [], full: [1] }.
remember still returns the loader result when it skips the write (for example []). A thrown NotFoundException is never stored.
CacheModule
forRoot registers defaults. The interceptor, CacheService.set, and CacheService.remember all shallow-merge { ...moduleDefaults, ...callOrDecorator }. Call / decorator fields win. undefined on the call does not wipe a module default (set / remember skip those keys).
There is no module-level evictionGroupName. A shared default tag would mix unrelated keys. The group must be set on @Cacheable, set, and remember. Missing or empty group throws CacheEvictionException.
CacheModule.forRoot({
prefix: 'cache',
namespace: 'http',
ttlSeconds: DEFAULT_CACHE_TTL_SECONDS.short,
expiryUnit: ExpiryUnits.SECONDS,
});| Option | Role |
|---|---|
| ttlSeconds / expiryUnit | Default TTL when @Cacheable(), set, or remember omit them |
| prefix / namespace / ignoreQuery / vary* / hash / maxLength | Default HTTP key pieces for the interceptor |
@Cacheable({
namespace: 'products',
evictionGroupName: PRODUCTS_LIST_EVICTION_GROUP,
})This route uses module TTL and prefix, decorator namespace, and a required list group. @Cacheable({ namespace: 'products', ttlSeconds: '5m', evictionGroupName: 'products:list' }) overrides module TTL for that handler only.
set / remember only consume TTL fields from the module (ttlSeconds, expiryUnit). Key-building fields (prefix, namespace, …) apply to the interceptor, not to a hand-built key string.
HTTP caching — @Cacheable + CacheInterceptor
Always pair them on the same handler.
@Controller('products')
export class ProductController {
@Get()
@UseInterceptors(CacheInterceptor)
@Cacheable({
namespace: 'products',
ttlSeconds: '5m',
evictionGroupName: PRODUCTS_LIST_EVICTION_GROUP,
})
findAll() {
return this.productService.findAll();
}
@Get('featured')
@UseInterceptors(CacheInterceptor)
@Cacheable({
namespace: 'products',
key: 'featured',
ttlSeconds: DEFAULT_CACHE_TTL_SECONDS.medium,
ignoreQuery: ['utm_source', 'page'],
varyByUser: true,
varyHeaders: ['accept-language'],
evictionGroupName: PRODUCTS_LIST_EVICTION_GROUP,
})
findFeatured() {
return this.productService.findFeatured();
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.productService.findOne(id); // remember() inside the service
}
}Hit: handler is not called. Miss: handler runs; concatMap waits for Redis SET + tag index, then emits the same body. Redis errors in that write are swallowed so the HTTP response still succeeds.
Scenarios
GET /productsthenGET /products— second request is a hit (same key).GET /products?sort=namevsGET /products?sort=price— different keys, same list group.evict('products:list')clears both.?a=1&b=2vs?b=2&a=1— same key (query is sorted).- Cache-buster query keys (
_,t,timestamp,cb,nocache, …) are ignored by default. - Empty catalog
[]— 200 with[], no Redis write, next GET hits the database again. - After POST — list group gone; next GET all is a miss and rebuilds keys + tag.
Do not GET tag keys with RedisService.get(). Tags are Redis sets. GET on a set is WRONGTYPE. Use sMembers / ttl / TYPE, or SCAN instead of KEYS in production.
Service caching — remember, set, evict
Omit ttlSeconds to inherit the module default. Pass ttlSeconds on the call to override it. evictionGroupName is always required.
@Injectable()
export class ProductService {
constructor(
@InjectRepository(Product) private readonly products: Repository<Product>,
private readonly cacheService: CacheService,
) {}
findOne(id: number) {
return this.cacheService.remember<Product>({
key: this.itemKey(id),
evictionGroupName: productsItemEvictionGroup(id),
callback: async () => {
const product = await this.products.findOne({ where: { id } });
if (!product) throw new NotFoundException();
return product;
},
});
}
async create(dto: CreateProductDto) {
const product = await this.products.save(this.products.create(dto));
await this.cacheService.evict(PRODUCTS_LIST_EVICTION_GROUP);
return product;
}
async update(id: number, dto: UpdateProductDto) {
const existing = await this.products.findOne({ where: { id } });
if (!existing) throw new NotFoundException();
const product = await this.products.save(
this.products.merge(existing, dto),
);
await this.cacheService.evict(PRODUCTS_LIST_EVICTION_GROUP);
await this.cacheService.set({
key: this.itemKey(id),
value: product,
evictionGroupName: productsItemEvictionGroup(id),
});
return product;
}
async remove(id: number) {
const result = await this.products.delete(id);
if (!result.affected) throw new NotFoundException();
await this.cacheService.evict([
PRODUCTS_LIST_EVICTION_GROUP,
productsItemEvictionGroup(id),
]);
}
private itemKey(id: number) {
return this.cacheService.buildKey({
namespace: 'products',
key: ['item', id],
});
}
}Order on PATCH: evict(list) before set(item). Evicting the item group in the same mutation would delete the key you just wrote.
Scenarios
- GET one miss → loader →
SET+ tag TTL copied from the key. - GET one hit → no loader, no re-index.
- GET one 404 → exception, nothing stored.
- PATCH without a prior GET —
setstill creates the item key and tag; the next GET one is a hit. - PATCH after GET one — same group;
evictionIndexskipsSADDif already a member but syncs tag TTL to the new item TTL. - DELETE — both list keys (via list tag members) and the item key disappear.
evictwhen the tag does not exist — no-op, no throw.- Two list URLs in one tag — one POST clears both HTTP keys.
buildKey
Use cacheService.buildKey() for service keys. HTTP keys come from the interceptor.
this.cacheService.buildKey({ namespace: 'test', key: id });
// cache:test:abc123
this.cacheService.buildKey({
namespace: 'users',
key: 'list',
params: { status: 'active', page: 1 },
vary: { userId: 42 },
});
// cache:users:list?page=1&status=active#userId=42Behaviour: sorted params, default ignore-query on HTTP keys, stable nested serialize, path normalize, sanitize, hash suffix when over maxLength (default 250), optional hash: true.
TTL presets
import { DEFAULT_CACHE_TTL_SECONDS } from 'redora';| Preset | Seconds |
|---|---|
| instant | 5 |
| brief | 15 |
| vshort | 60 |
| short | 300 |
| medium | 600 |
| long | 1800 |
| vlong / hour | 3600 |
| hours2 | 7200 |
| hours6 | 21600 |
| hours12 | 43200 |
| day | 86400 |
| days2 | 172800 |
| days3 | 259200 |
| week | 604800 |
| days14 | 1209600 |
| month | 2592000 |
There is no forever preset. Persist a key by omitting ttlSeconds on RedisService.set. CacheService.set still requires a group name even when you omit TTL (tag is PERSISTed if the member has no expiry).
Cache API reference
set(options) — write if meaningful, then evictionIndex. Returns Redis 'OK' or null.
get<T>(key) — typed value or null.
remember<T>(options) — miss → callback → set if meaningful.
evict(group | groups[]) — SMEMBERS each tag, DEL members, DEL tags.
evictionIndex({ groupName, key }) — SADD if needed, then align tag TTL to the member (EXPIRE extend, or PERSIST if the member has no TTL). Empty groupName throws CacheEvictionException.
delete(key | keys[]) — raw key delete; returns whether any key was removed.
buildKey(options) — deterministic key string.
Inject CACHE_OPTIONS if you need the module defaults in a provider.
Tribe 4 — Observability
ObservabilityModule needs RedisModule. It reads Redis INFO and PING. It does not start a Prometheus scrape endpoint or an OTLP exporter by itself. telemetry() returns a JSON description of backends, filtered INFO fields, and metric rules you can feed into your own instrumentation.
Register
ObservabilityModule.forRoot({
healthCheck: {
memory: { show: true, isDetailed: false },
client: { show: true },
server: { show: false },
diagnosis: true,
},
telemetry: {
enabled: true,
backends: ['prometheus', 'opentelemetry'],
prometheus: {
enabled: true,
endpoint: '/metrics',
namespace: 'redora',
prefix: 'redis_',
defaultLabels: { env: 'prod' },
},
openTelemetry: {
enabled: true,
serviceName: 'orders-api',
serviceVersion: '1.2.0',
exporterEndpoint: 'http://otel-collector:4318',
temporality: 'cumulative',
resourceAttributes: { 'deployment.environment': 'prod' },
},
sources: {
memory: { enabled: true, detailed: false, fields: ['used_memory'] },
diagnosis: { enabled: true, groups: ['keyspace', 'eventloop'] },
},
metrics: [
{
name: 'redis_memory_usage',
source: 'memory',
path: 'used_memory',
format: 'gauge',
unit: 'bytes',
},
],
},
});Per-call options on healthCheck(options) / telemetry(options) override the module defaults for that request.
Health endpoint
@Controller('redis')
export class RedisHealthController {
constructor(private readonly observability: ObservabilityService) {}
@Get('health')
health() {
return this.observability.healthCheck();
}
}healthCheck():
PING— if notPONG, throwsRedisUnhealthyException(HTTP 500).- Optionally attaches
memory,client,server(summary orisDetailedfull INFO parse). - Optionally attaches
diagnosis(parsedINFO statsgrouped: connections, commands, network, replication, expiration, eviction, keyspace, pubsub, …).
await this.observability.healthCheck({
memory: { show: true, isDetailed: true },
client: { show: true },
server: { show: true },
diagnosis: true,
});Direct helpers: memory(detailed?), client(detailed?), server(detailed?), diagnosis().
REDIS_INFO_KEYS (memory, clients, server, stats) is exported if you call redisService.info(REDIS_INFO_KEYS.MEMORY) yourself.
Telemetry endpoint
@Get('telemetry')
telemetry() {
return this.observability.telemetry({
backends: ['opentelemetry'],
openTelemetry: { enabled: true, serviceName: 'api' },
sources: {
memory: { enabled: true, fields: ['used_memory'] },
client: { enabled: false },
diagnosis: { enabled: true, groups: ['keyspace'] },
},
metrics: [
{
name: 'redis_memory_usage',
source: 'memory',
path: 'used_memory',
format: 'histogram',
unit: 'bytes',
labels: { server: 'cache-1' },
},
{
name: 'redis_keyspace_hits',
source: 'diagnosis',
path: 'keyspace.keyspace_hits',
format: 'counter',
},
],
});
}enabled: false→{ enabled: false }and no INFO calls for that invocation’s default path.- Sources default to on unless
enabled: false. fields/groupspick a subset; omit them to return the full snapshot for that source.metrics[].pathis a dotted path into the collected source object.- Prometheus / OpenTelemetry blocks in the JSON are config echoes (endpoint, namespace, labels). Wire
prom-clientor the OTel SDK separately using those values.
Scenarios
- Liveness:
healthCheck()with no extras — ping only. - Ops dashboard: memory + diagnosis.
- Deep incident:
isDetailed: trueon memory/client/server. - Metrics pipeline:
telemetry()withmetricsrules; pushvalueinto your counters. - Unhealthy Redis: ping fails →
RedisUnhealthyException.
Exceptions
| Class | When |
|---|---|
| TimeFormatExceptions | Invalid duration string, expiryUnit mixed with a string TTL, non-positive numeric TTL |
| CacheEvictionException | Empty eviction group name |
| RedisUnhealthyException | PING did not return PONG |
They extend Nest InternalServerErrorException, so default exception filters return HTTP 500 unless you map them.
Public exports (redora)
Modules and services: RedisModule, RedisService, CacheModule, CacheService, CacheInterceptor, Cacheable, RedisLoggerModule, RedisLoggerService, ObservabilityModule, ObservabilityService.
Tokens: REDIS_CLIENT, CACHE_OPTIONS, REDIS_LOGGER_OPTIONS, OBSERVABILITY_OPTIONS, REDIS_INFO_KEYS.
Options / types: RedisConnectionOptions, RedisConnectionAsyncOptions, SetOptions, ZMember, CacheOptions, CacheKeyOptions, CacheSetOptions, CacheRememberOptions, EvictionIndexOptions, ExpiryUnits, DEFAULT_CACHE_TTL_SECONDS, DEFAULT_CACHE_KEY, RedisLoggerOptions, ObservabilityOptions, HealthCheckOptions, TelemetryOptions (and nested telemetry types), MemoryInfo, ClientInfo, ServerInfo, RedisDiagnosis.
Exceptions: TimeFormatExceptions, CacheEvictionException, RedisUnhealthyException.
What's in 0.3.0
- Redis command mixins, serialization, optional connection, logger
- Cache interceptor (
concatMap),remember/set/evict/ tag TTL sync - Meaningful-data skip on interceptor,
remember, andset - Observability health + telemetry JSON
- Duration-string TTLs; numeric TTL defaults to seconds
Roadmap
- Queues (BullMQ), rate limiting, distributed locking, Redis for AI
- Sliding expiration
- Built-in Prometheus / OTLP exporters (today: structured payloads only)
License
MIT
