Explorar el Código

feat: implement Redis module with health check functionality and update environment configurations

Dave hace 4 meses
padre
commit
a410d7f098

+ 2 - 2
.env.app

@@ -2,9 +2,9 @@
 APP_ENV=test
 
 # Prisma Config
-MONGO_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_admin?authSource=admin"
+# MONGO_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_admin?authSource=admin"
 # Dave local
-# MONGO_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_admin?authSource=admin&replicaSet=rs0"
+MONGO_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_admin?authSource=admin&replicaSet=rs0"
 
 # office dev env
 # MONGO_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@localhost:27017/box_admin?authSource=admin"

+ 4 - 4
.env.mgnt

@@ -2,12 +2,12 @@
 APP_ENV=test
 
 # Prisma Config
-MYSQL_URL="mysql://boxdbuser:dwR%3D%29whu2Ze@localhost:3306/box_admin"
-MONGO_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_admin?authSource=admin"
+# MYSQL_URL="mysql://boxdbuser:dwR%3D%29whu2Ze@localhost:3306/box_admin"
+# MONGO_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_admin?authSource=admin"
 
 # dave local
-# MYSQL_URL="mysql://root:123456@localhost:3306/box_admin"
-# MONGO_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_admin?authSource=admin"
+MYSQL_URL="mysql://root:123456@localhost:3306/box_admin"
+MONGO_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_admin?authSource=admin"
 
 # Redis Config
 REDIS_HOST=127.0.0.1

+ 18 - 13
apps/box-mgnt-api/src/app.module.ts

@@ -1,5 +1,5 @@
 import { Module, OnModuleInit } from '@nestjs/common';
-import { ConfigModule } from '@nestjs/config';
+import { ConfigModule, ConfigService } from '@nestjs/config';
 import { DevtoolsModule } from '@nestjs/devtools-integration';
 import dayjs from 'dayjs';
 import timezone from 'dayjs/plugin/timezone.js';
@@ -11,34 +11,39 @@ import { validateEnvironment } from './config/env.validation';
 
 import { LoggerModule } from 'nestjs-pino';
 import { MgntBackendModule } from './mgnt-backend/mgnt-backend.module';
-import pinoConfig from '@box/common/config/pino.config'; // adjust if your export name differs
-import { RedisModule } from './redis/redis.module';
+import pinoConfig from '@box/common/config/pino.config';
 import { CacheSyncModule } from './cache-sync/cache-sync.module';
+import { RedisModule } from '@box/db/redis/redis.module';
 
 @Module({
   imports: [
-    // Global config, load from .env.mgnt.dev then .env with validation
+    // Global config, load from .env.mgnt then .env with validation
     ConfigModule.forRoot({
       isGlobal: true,
       envFilePath: ['.env.mgnt', '.env'],
       validate: validateEnvironment,
     }),
     ConfigModule.forFeature(appConfigFactory),
-    RedisModule.forRoot(),
-    CacheSyncModule,
 
-    CommonModule,
+    RedisModule.forRootAsync({
+      imports: [ConfigModule],
+      inject: [ConfigService],
+      useFactory: (config: ConfigService) => ({
+        host: config.get<string>('REDIS_HOST')!,
+        port: config.get<number>('REDIS_PORT')!,
+        password: config.get<string>('REDIS_PASSWORD') || undefined,
+        db: config.get<number>('REDIS_DB') ?? 0,
+        keyPrefix: config.get<string>('REDIS_KEY_PREFIX') ?? 'box:',
+        tls: config.get<boolean>('REDIS_TLS') ?? false,
+      }),
+    }),
 
-    // Shared utilities (includes PrismaModule, UtilsService)
+    CacheSyncModule,
+    CommonModule,
     SharedModule,
-
-    // Pino logger
     LoggerModule.forRoot(pinoConfig),
-
-    // Your actual management backend
     MgntBackendModule,
 
-    // 4) Devtools
     DevtoolsModule.register({
       http: process.env.NODE_ENV === 'development',
     }),

+ 1 - 0
apps/box-mgnt-api/src/cache-sync/cache-sync-debug.controller.ts

@@ -1,3 +1,4 @@
+// apps/box-mgnt-api/src/cache-sync/cache-sync-debug.controller.ts
 import {
   Body,
   Controller,

+ 3 - 6
apps/box-mgnt-api/src/cache-sync/cache-sync.module.ts

@@ -1,17 +1,14 @@
+// apps/box-mgnt-api/src/cache-sync/cache-sync.module.ts
 import { Module } from '@nestjs/common';
-// ⬇️ Adjust this to your actual DB module that provides PrismaService
-import { SharedModule } from '@box/db/shared.module'; // TODO: change to your real module
-import { RedisModule } from '../redis/redis.module';
 import { CacheSyncService } from './cache-sync.service';
 import { CacheSyncDebugController } from './cache-sync-debug.controller';
 
 @Module({
   imports: [
-    SharedModule, // from libs/db/src/shared.module.ts (most likely)
-    RedisModule.forRoot(),
+    // RedisModule is now global, no need to import
   ],
-  providers: [CacheSyncService],
   controllers: [CacheSyncDebugController],
+  providers: [CacheSyncService],
   exports: [CacheSyncService],
 })
 export class CacheSyncModule {}

+ 27 - 21
apps/box-mgnt-api/src/cache-sync/cache-sync.service.ts

@@ -1,9 +1,9 @@
+// apps/box-mgnt-api/src/cache-sync/cache-sync.service.ts
 import { Injectable, Logger } from '@nestjs/common';
-import { Prisma as MysqlPrisma, CacheSyncAction } from '@prisma/mysql/client'; // adjust if your generated types are in a different package
-import { RedisService } from '../redis/redis.service';
-// ⬇️ Adjust this import to your actual db lib (e.g., '@box/db')
-import { MysqlPrismaService } from '@box/db/prisma/mysql-prisma.service'; // TODO: change to your real path
-import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service'; // TODO: change to your real path
+import { Prisma as MysqlPrisma, CacheSyncAction } from '@prisma/mysql/client';
+import { MysqlPrismaService } from '@box/db/prisma/mysql-prisma.service';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import { RedisService } from '@box/db/redis/redis.service';
 
 import {
   CacheEntityType,
@@ -17,8 +17,11 @@ export class CacheSyncService {
   private readonly logger = new Logger(CacheSyncService.name);
 
   constructor(
-    private readonly mysqlPrisma: MysqlPrismaService, // for CacheSyncAction (MySQL)
-    private readonly mongoPrisma: MongoPrismaService, // for Channel, Category, Ads, VideoMedia (Mongo)
+    // MySQL: durable queue of actions
+    private readonly mysqlPrisma: MysqlPrismaService,
+    // MongoDB: actual content sources (channels, categories, ads, videos)
+    private readonly mongoPrisma: MongoPrismaService,
+    // Redis: cache store consumed by box-app-api
     private readonly redis: RedisService,
   ) {}
 
@@ -60,7 +63,9 @@ export class CacheSyncService {
     );
   }
 
+  // ─────────────────────────────────────────────
   // Convenience helpers — used by mgnt services or debug controller
+  // ─────────────────────────────────────────────
 
   async scheduleChannelRefreshAll(): Promise<void> {
     await this.scheduleAction({
@@ -96,10 +101,11 @@ export class CacheSyncService {
     }
   }
 
-  /**
-   * Minimal processing loop (single batch).
-   * Later you can move this into a @Cron job.
-   */
+  // ─────────────────────────────────────────────
+  // Minimal processing loop (single batch).
+  // Later you can move this into a @Cron job.
+  // ─────────────────────────────────────────────
+
   async processPendingOnce(limit = 20): Promise<void> {
     const now = this.nowBigInt();
 
@@ -124,9 +130,12 @@ export class CacheSyncService {
     for (const action of actions) {
       try {
         await this.handleSingleAction(action);
-      } catch (err: any) {
+      } catch (err: unknown) {
+        const message =
+          err instanceof Error ? err.message : String(err ?? 'Unknown error');
+
         this.logger.error(
-          `Error processing CacheSyncAction id=${action.id}: ${err?.message ?? err}`,
+          `Error processing CacheSyncAction id=${action.id}: ${message}`,
         );
 
         const attempts = action.attempts + 1;
@@ -141,7 +150,7 @@ export class CacheSyncService {
                 ? CacheStatus.GAVE_UP
                 : CacheStatus.PENDING,
             attempts,
-            lastError: err?.message ?? String(err),
+            lastError: message,
             nextAttemptAt: this.nowBigInt() + BigInt(backoffMs),
             updatedAt: this.nowBigInt(),
           },
@@ -154,7 +163,6 @@ export class CacheSyncService {
    * Main dispatcher: decide what to do for each action.
    */
   private async handleSingleAction(action: CacheSyncAction): Promise<void> {
-    // 1) Dispatch based on entityType + operation
     switch (action.entityType as CacheEntityType) {
       case CacheEntityType.CHANNEL:
         await this.handleChannelAction(action);
@@ -183,7 +191,6 @@ export class CacheSyncService {
         break;
     }
 
-    // 2) If we get here without throwing, mark SUCCESS
     await this.mysqlPrisma.cacheSyncAction.update({
       where: { id: action.id },
       data: {
@@ -216,21 +223,21 @@ export class CacheSyncService {
   }
 
   private async rebuildChannelsAll(): Promise<void> {
-    // TODO: adjust to your actual Mongo Prisma model name & fields
     const channels = await this.mongoPrisma.channel.findMany({
       where: {
         // e.g. only active / not deleted; adjust if needed
         // isDeleted: false,
       },
       orderBy: {
-        // adjust to your schema; example:
+        // adjust to your schema
         // sortOrder: 'asc',
         // createdAt: 'asc',
         id: 'asc',
       },
     });
 
-    // Store full list; app-api can consume as needed
+    // NOTE:
+    // Actual Redis key will be "box:channels:all" if REDIS_KEY_PREFIX="box:".
     await this.redis.setJson('channels:all', channels);
 
     this.logger.log(`Rebuilt channels:all with ${channels.length} item(s).`);
@@ -253,7 +260,6 @@ export class CacheSyncService {
   }
 
   private async rebuildCategoriesAll(): Promise<void> {
-    // TODO: adjust to your actual Mongo Prisma model name & fields
     const categories = await this.mongoPrisma.category.findMany({
       where: {
         // e.g. only active / not deleted; adjust if needed
@@ -279,7 +285,7 @@ export class CacheSyncService {
   // ─────────────────────────────────────────────
 
   private async handleAdAction(action: CacheSyncAction): Promise<void> {
-    // TODO: implement real ad-by-id refresh using this.mongoPrisma.ads & Redis
+    // TODO: implement real ad-by-id refresh using this.mongoPrisma.ad & Redis
     this.logger.debug(
       `handleAdAction placeholder for id=${action.entityId}, operation=${action.operation}`,
     );

+ 1 - 1
apps/box-mgnt-api/src/cache-sync/cache-sync.types.ts

@@ -1,5 +1,5 @@
+// apps/box-mgnt-api/src/cache-sync/cache-sync.types.ts
 // Keep enums string-based so they map nicely to DB values
-
 export enum CacheEntityType {
   AD = 'AD',
   AD_POOL = 'AD_POOL',

+ 27 - 0
apps/box-mgnt-api/src/config/env.validation.ts

@@ -8,6 +8,8 @@ import {
   IsOptional,
   validateSync,
   IsEnum,
+  IsNumber,
+  IsBoolean,
 } from 'class-validator';
 
 enum NodeEnv {
@@ -45,6 +47,31 @@ class EnvironmentVariables {
   @Max(65535)
   @IsOptional()
   PORT: number = 3000;
+
+  // ===== Redis Config =====
+
+  @IsString()
+  REDIS_HOST!: string;
+
+  @IsNumber()
+  REDIS_PORT!: number;
+
+  @IsOptional()
+  @IsString()
+  REDIS_PASSWORD?: string; // empty string is allowed
+
+  @IsOptional()
+  @IsNumber()
+  REDIS_DB?: number;
+
+  @IsOptional()
+  @IsString()
+  REDIS_KEY_PREFIX?: string;
+
+  // If you later add TLS:
+  @IsOptional()
+  @IsBoolean()
+  REDIS_TLS?: boolean;
 }
 
 export function validateEnvironment(config: Record<string, unknown>) {

+ 2 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/feature.module.ts

@@ -10,6 +10,7 @@ import { CategoryModule } from './category/category.module';
 import { ChannelModule } from './channel/channel.module';
 import { TagModule } from './tag/tag.module';
 import { VideoMediaModule } from './video-media/video-media.module';
+import { HealthModule } from './health/health.module';
 
 @Module({
   imports: [
@@ -23,6 +24,7 @@ import { VideoMediaModule } from './video-media/video-media.module';
     MgntHttpServiceModule,
     SyncVideomediaModule,
     VideoMediaModule,
+    HealthModule,
   ],
 })
 export class FeatureModule {}

+ 15 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/health/health.controller.ts

@@ -0,0 +1,15 @@
+// apps/box-mgnt-api/src/mgnt-backend/feature/health/health.controller.ts
+import { Controller, Get } from '@nestjs/common';
+import { HealthService, HealthResponse } from './health.service';
+
+@Controller('health')
+export class HealthController {
+  constructor(private readonly healthService: HealthService) {}
+
+  @Get('redis')
+  async checkRedis(): Promise<HealthResponse> {
+    // Response example:
+    // { "redis": { "status": "up", "message": "PONG" } }
+    return this.healthService.checkRedis();
+  }
+}

+ 14 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/health/health.module.ts

@@ -0,0 +1,14 @@
+// apps/box-mgnt-api/src/mgnt-backend/feature/health/health.module.ts
+import { Module } from '@nestjs/common';
+import { RedisModule } from '@box/db/redis/redis.module';
+import { HealthController } from './health.controller';
+import { HealthService } from './health.service';
+
+@Module({
+  imports: [
+    RedisModule, // 👈 get RedisService from the shared Redis module
+  ],
+  controllers: [HealthController],
+  providers: [HealthService],
+})
+export class HealthModule {}

+ 43 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/health/health.service.ts

@@ -0,0 +1,43 @@
+// apps/box-mgnt-api/src/mgnt-backend/feature/health/health.service.ts
+import { Injectable } from '@nestjs/common';
+import { RedisService } from '@box/db/redis/redis.service';
+
+interface RedisHealthStatus {
+  status: 'up' | 'down';
+  message: string;
+}
+
+export interface HealthResponse {
+  redis: RedisHealthStatus;
+}
+
+@Injectable()
+export class HealthService {
+  constructor(private readonly redisService: RedisService) {}
+
+  async checkRedis(): Promise<HealthResponse> {
+    try {
+      const pong = await this.redisService.ping();
+      const isUp = pong === 'PONG';
+
+      return {
+        redis: {
+          status: isUp ? 'up' : 'down',
+          message: pong,
+        },
+      };
+    } catch (error: unknown) {
+      const message =
+        error instanceof Error
+          ? error.message
+          : String(error ?? 'Unknown error');
+
+      return {
+        redis: {
+          status: 'down',
+          message,
+        },
+      };
+    }
+  }
+}

+ 2 - 0
apps/box-mgnt-api/src/mgnt-backend/mgnt-backend.module.ts

@@ -18,6 +18,7 @@ import { CategoryModule } from './feature/category/category.module';
 import { ChannelModule } from './feature/channel/channel.module';
 import { TagModule } from './feature/tag/tag.module';
 import { VideoMediaModule } from './feature/video-media/video-media.module';
+import { HealthModule } from './feature/health/health.module';
 
 @Module({
   imports: [
@@ -42,6 +43,7 @@ import { VideoMediaModule } from './feature/video-media/video-media.module';
           TagModule,
           SyncVideomediaModule,
           VideoMediaModule,
+          HealthModule,
         ],
       },
     ]),

+ 0 - 1
apps/box-mgnt-api/src/redis/redis.constants.ts

@@ -1 +0,0 @@
-export const REDIS_CLIENT = Symbol('REDIS_CLIENT');

+ 0 - 53
apps/box-mgnt-api/src/redis/redis.module.ts

@@ -1,53 +0,0 @@
-import { DynamicModule, Global, Module } from '@nestjs/common';
-import { ConfigService } from '@nestjs/config';
-import Redis from 'ioredis';
-import { REDIS_CLIENT } from './redis.constants';
-import { RedisService } from './redis.service';
-
-@Global()
-@Module({})
-export class RedisModule {
-  static forRoot(): DynamicModule {
-    return {
-      module: RedisModule,
-      providers: [
-        {
-          provide: REDIS_CLIENT,
-          inject: [ConfigService],
-          useFactory: (configService: ConfigService) => {
-            const host = configService.get<string>('REDIS_HOST', '127.0.0.1');
-            const port = configService.get<number>('REDIS_PORT', 6379);
-            const password = configService.get<string | undefined>(
-              'REDIS_PASSWORD',
-            );
-            const db = configService.get<number>('REDIS_DB', 0);
-            const keyPrefix = configService.get<string>(
-              'REDIS_KEY_PREFIX',
-              'box:',
-            );
-
-            const redis = new Redis({
-              host,
-              port,
-              password: password || undefined,
-              db,
-              keyPrefix,
-              lazyConnect: true,
-            });
-
-            // Optional logging / error handling
-            redis.on('error', (err) => {
-              // Avoid throwing here, just log; Nest will keep running
-              // You can hook this into your logger later
-              console.error('[Redis] error:', err?.message ?? err);
-            });
-
-            return redis;
-          },
-        },
-        RedisService,
-      ],
-      exports: [REDIS_CLIENT, RedisService],
-    };
-  }
-}

+ 0 - 76
apps/box-mgnt-api/src/redis/redis.service.ts

@@ -1,76 +0,0 @@
-import { Inject, Injectable } from '@nestjs/common';
-import type Redis from 'ioredis';
-import { REDIS_CLIENT } from './redis.constants';
-
-@Injectable()
-export class RedisService {
-  constructor(
-    @Inject(REDIS_CLIENT)
-    private readonly client: Redis,
-  ) {}
-
-  async ping(): Promise<string> {
-    return this.client.ping();
-  }
-
-  async get<T = unknown>(key: string): Promise<T | null> {
-    const raw = await this.client.get(key);
-    if (!raw) {
-      return null;
-    }
-
-    try {
-      return JSON.parse(raw) as T;
-    } catch {
-      // fallback for plain string
-      return raw as unknown as T;
-    }
-  }
-
-  async setJson(
-    key: string,
-    value: unknown,
-    ttlSeconds?: number,
-  ): Promise<'OK' | null> {
-    const payload = JSON.stringify(value);
-
-    if (ttlSeconds && ttlSeconds > 0) {
-      return this.client.set(key, payload, 'EX', ttlSeconds);
-    }
-
-    return this.client.set(key, payload);
-  }
-
-  async del(key: string | string[]): Promise<number> {
-    if (Array.isArray(key)) {
-      if (!key.length) {
-        return 0;
-      }
-      return this.client.del(...key);
-    }
-
-    return this.client.del(key);
-  }
-
-  // For lists/pools (ads, videos)
-  async setList(
-    key: string,
-    values: (string | number)[],
-    ttlSeconds?: number,
-  ): Promise<void> {
-    // Clear existing
-    await this.client.del(key);
-
-    if (values.length > 0) {
-      await this.client.rpush(key, ...values.map((v) => v.toString()));
-    }
-
-    if (ttlSeconds && ttlSeconds > 0) {
-      await this.client.expire(key, ttlSeconds);
-    }
-  }
-
-  async getList(key: string): Promise<string[]> {
-    return this.client.lrange(key, 0, -1);
-  }
-}

+ 4 - 0
libs/db/src/redis/redis.constants.ts

@@ -0,0 +1,4 @@
+// libs/db/src/redis/redis.constants.ts
+
+export const REDIS_CLIENT = 'REDIS_CLIENT';
+export const REDIS_OPTIONS = 'REDIS_OPTIONS';

+ 21 - 0
libs/db/src/redis/redis.interfaces.ts

@@ -0,0 +1,21 @@
+// libs/db/src/redis/redis.interfaces.ts
+
+export interface RedisModuleOptions {
+  host: string;
+  port: number;
+  password?: string;
+  db?: number;
+  keyPrefix?: string;
+  tls?: boolean;
+}
+
+export interface RedisModuleAsyncOptions {
+  imports?: any[];
+  inject?: any[];
+  /**
+   * Typically you will read from ConfigService here.
+   */
+  useFactory: (
+    ...args: any[]
+  ) => Promise<RedisModuleOptions> | RedisModuleOptions;
+}

+ 50 - 0
libs/db/src/redis/redis.module.ts

@@ -0,0 +1,50 @@
+// libs/db/src/redis/redis.module.ts
+import { DynamicModule, Module, Provider } from '@nestjs/common';
+import Redis from 'ioredis';
+import { REDIS_CLIENT, REDIS_OPTIONS } from './redis.constants';
+import {
+  RedisModuleAsyncOptions,
+  RedisModuleOptions,
+} from './redis.interfaces';
+import { RedisService } from './redis.service';
+
+@Module({})
+export class RedisModule {
+  static forRootAsync(options: RedisModuleAsyncOptions): DynamicModule {
+    const optionsProvider: Provider = {
+      provide: REDIS_OPTIONS,
+      useFactory: options.useFactory,
+      inject: options.inject ?? [],
+    };
+
+    const clientProvider: Provider = {
+      provide: REDIS_CLIENT,
+      useFactory: async (opts: RedisModuleOptions) => {
+        const client = new Redis({
+          host: opts.host,
+          port: opts.port,
+          password: opts.password || undefined,
+          db: opts.db ?? 0,
+          keyPrefix: opts.keyPrefix,
+          tls: opts.tls ? {} : undefined,
+        });
+
+        client.on('error', (err) => {
+          // eslint-disable-next-line no-console
+          console.error('[Redis] Error:', err?.message ?? err);
+        });
+
+        return client;
+      },
+      inject: [REDIS_OPTIONS],
+    };
+
+    return {
+      module: RedisModule,
+      global: true, // Make it globally available
+      imports: options.imports ?? [],
+      providers: [optionsProvider, clientProvider, RedisService],
+      exports: [clientProvider, RedisService], // 👈 important
+    };
+  }
+}

+ 100 - 0
libs/db/src/redis/redis.service.ts

@@ -0,0 +1,100 @@
+// 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<string | null> {
+    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<number> {
+    const client = this.ensureClient();
+    if (!keys.length) return 0;
+    return client.del(...keys);
+  }
+
+  async exists(key: string): Promise<boolean> {
+    const client = this.ensureClient();
+    const result = await client.exists(key);
+    return result === 1;
+  }
+
+  async expire(key: string, ttlSeconds: number): Promise<boolean> {
+    const client = this.ensureClient();
+    const result = await client.expire(key, ttlSeconds);
+    return result === 1;
+  }
+
+  async incr(key: string): Promise<number> {
+    const client = this.ensureClient();
+    return client.incr(key);
+  }
+
+  async ping(): Promise<string> {
+    const client = this.ensureClient();
+    return client.ping();
+  }
+
+  // Helper for JSON values
+  async setJson<T>(
+    key: string,
+    value: T,
+    ttlSeconds?: number,
+  ): Promise<'OK' | null> {
+    const json = JSON.stringify(value);
+    return this.set(key, json, ttlSeconds);
+  }
+
+  async getJson<T>(key: string): Promise<T | null> {
+    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<string[]> {
+    const client = this.ensureClient();
+    return client.keys(pattern);
+  }
+
+  // 🔥 New helper: delete all keys matching a pattern
+  async deleteByPattern(pattern: string): Promise<number> {
+    const keys = await this.keys(pattern);
+    if (!keys.length) return 0;
+    return this.del(...keys);
+  }
+}