// libs/db/src/redis/redis.service.ts import { Inject, Injectable, Optional } from '@nestjs/common'; import type { Redis } from 'ioredis'; import { REDIS_CLIENT } from './redis.constants'; @Injectable() export class RedisService { constructor( @Optional() @Inject(REDIS_CLIENT) private readonly client?: Redis, ) {} private ensureClient(): Redis { if (!this.client) { throw new Error( 'Redis client is not available. Did you import RedisModule.forRootAsync?', ); } return this.client; } async get(key: string): Promise { const client = this.ensureClient(); return client.get(key); } async set( key: string, value: string, ttlSeconds?: number, ): Promise<'OK' | null> { const client = this.ensureClient(); if (ttlSeconds && ttlSeconds > 0) { return client.set(key, value, 'EX', ttlSeconds); } return client.set(key, value); } async del(...keys: string[]): Promise { const client = this.ensureClient(); if (!keys.length) return 0; return client.del(...keys); } async exists(key: string): Promise { const client = this.ensureClient(); const result = await client.exists(key); return result === 1; } async sadd(key: string, ...members: string[]): Promise { const client = this.ensureClient(); if (!members.length) return 0; return client.sadd(key, ...members); } async srandmember(key: string): Promise { const client = this.ensureClient(); return client.srandmember(key); } async scard(key: string): Promise { const client = this.ensureClient(); return client.scard(key); } async expire(key: string, ttlSeconds: number): Promise { const client = this.ensureClient(); const result = await client.expire(key, ttlSeconds); return result === 1; } async incr(key: string): Promise { const client = this.ensureClient(); return client.incr(key); } async ping(): Promise { const client = this.ensureClient(); return client.ping(); } // Helper for JSON values async setJson( key: string, value: T, ttlSeconds?: number, ): Promise<'OK' | null> { const json = JSON.stringify(value, (_, v) => typeof v === 'bigint' ? v.toString() : v, ); return this.set(key, json, ttlSeconds); } async getJson(key: string): Promise { const raw = await this.get(key); if (!raw) return null; try { return JSON.parse(raw) as T; } catch { return null; } } // 🔎 New helper: list keys by pattern (for cache-sync) async keys(pattern: string): Promise { const client = this.ensureClient(); return client.keys(pattern); } // 🔥 New helper: delete all keys matching a pattern async deleteByPattern(pattern: string): Promise { const keys = await this.keys(pattern); if (!keys.length) return 0; return this.del(...keys); } // ───────────────────────────────────────────── // List operations // ───────────────────────────────────────────── /** * Push items to a Redis LIST (right push). * Atomically deletes the old key first, then pushes all items. */ async rpushList(key: string, items: string[]): Promise { const client = this.ensureClient(); const pipeline = client.pipeline(); // Delete old key first pipeline.del(key); // Push all items if any exist if (items.length > 0) { pipeline.rpush(key, ...items); } await pipeline.exec(); } /** * Build a Redis ZSET with members and scores. * Atomically deletes the old key first, then adds all members. */ async zadd( key: string, members: Array<{ score: number; member: string }>, ): Promise { const client = this.ensureClient(); const pipeline = client.pipeline(); // Delete old key first pipeline.del(key); // Add members if any exist if (members.length > 0) { for (const { score, member } of members) { pipeline.zadd(key, score, member); } } await pipeline.exec(); } /** * Get a range of elements from a Redis LIST by index. * Returns elements from start to stop (inclusive, 0-based). * Use 0 to -1 to get all elements. */ async lrange(key: string, start: number, stop: number): Promise { const client = this.ensureClient(); return client.lrange(key, start, stop); } /** * Get a range of elements from a Redis ZSET in reverse score order. * Returns elements from offset to offset+limit-1. * Useful for pagination: zrevrange(key, (page-1)*pageSize, page*pageSize-1) */ async zrevrange(key: string, start: number, stop: number): Promise { const client = this.ensureClient(); return client.zrevrange(key, start, stop); } // ───────────────────────────────────────────── // Pipelines & atomic swap helpers // ───────────────────────────────────────────── async pipelineSetJson( entries: Array<{ key: string; value: unknown; ttlSeconds?: number }>, ): Promise { const client = this.ensureClient(); if (!entries.length) return; const pipeline = client.pipeline(); for (const { key, value, ttlSeconds } of entries) { const json = JSON.stringify(value, (_, v) => typeof v === 'bigint' ? v.toString() : v, ); if (ttlSeconds && ttlSeconds > 0) { pipeline.set(key, json, 'EX', ttlSeconds); } else { pipeline.set(key, json); } } await pipeline.exec(); } /** * Write to temporary keys and atomically swap them into place with RENAME. * This avoids readers seeing a partially-updated state. */ async atomicSwapJson( entries: Array<{ key: string; value: unknown; ttlSeconds?: number }>, ): Promise { const client = this.ensureClient(); if (!entries.length) return; const tempEntries = entries.map((e) => ({ ...e, tempKey: `${e.key}:tmp:${Date.now()}:${Math.random().toString(36).slice(2)}`, })); // 1) Write all temp keys const writePipeline = client.pipeline(); for (const { tempKey, value, ttlSeconds } of tempEntries as Array<{ tempKey: string; value: unknown; ttlSeconds?: number; }>) { const json = JSON.stringify(value, (_, v) => typeof v === 'bigint' ? v.toString() : v, ); if (ttlSeconds && ttlSeconds > 0) { writePipeline.set(tempKey, json, 'EX', ttlSeconds); } else { writePipeline.set(tempKey, json); } } await writePipeline.exec(); // 2) Atomically swap temp -> target via rename const renamePipeline = client.pipeline(); for (const { key, tempKey } of tempEntries as Array<{ key: string; tempKey: string; }>) { renamePipeline.rename(tempKey, key); } await renamePipeline.exec(); } }