diff --git a/apps/server/src/core/lib/cache.ts b/apps/server/src/core/lib/cache.ts deleted file mode 100644 index b786f66..0000000 --- a/apps/server/src/core/lib/cache.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const CACHE_TTL = 60 * 60; //1 hour - -export const userCacheKey = (userId: string): string => { - return `user:${userId}`; -}; - -export const accountCacheKey = (userId: string): string => { - return `account:${userId}`; -}; - -export const jwtPayloadCacheKey = (userId: string): string => { - return `jwtPayload:${userId}`; -}; - -export const companyCacheKey = (companyId: string): string => { - return `company:${companyId}`; -}; diff --git a/apps/server/src/modules/account/repositories/index.ts b/apps/server/src/modules/account/repositories/index.ts index 2ba47a0..bbb484d 100644 --- a/apps/server/src/modules/account/repositories/index.ts +++ b/apps/server/src/modules/account/repositories/index.ts @@ -1,8 +1,7 @@ import { db, eq, sql } from "@fixr/db/connection"; import { clients, companies, employees, users } from "@fixr/db/schema"; import { accountSchema } from "@fixr/schemas/account"; -import { redis } from "../../../config/redis"; -import { accountCacheKey, CACHE_TTL } from "../../../core/lib/cache"; +import { Cached } from "../../../shared/infra/cache"; /** @description Account data access layer */ export class AccountRepository { @@ -12,14 +11,8 @@ export class AccountRepository { * @param id - The user ID * @returns The parsed account data */ + @Cached({ ttl: 3600, key: "account" }) static async queryAccountById(id: string) { - const cacheKey = accountCacheKey(id); - const cached = await redis.get(cacheKey); - - if (cached) { - return accountSchema.parse(JSON.parse(cached)); - } - const [account] = await db .select({ id: users.id, @@ -29,19 +22,19 @@ export class AccountRepository { cpf: sql`COALESCE(${employees.cpf}, ${clients.cpf})`, phone: sql`COALESCE(${employees.phone}, ${clients.phone})`, profileType: sql`CASE - WHEN ${employees.id} IS NOT NULL THEN 'employee' - WHEN ${clients.id} IS NOT NULL THEN 'client' - ELSE 'unknown' - END`, + WHEN ${employees.id} IS NOT NULL THEN 'employee' + WHEN ${clients.id} IS NOT NULL THEN 'client' + ELSE 'unknown' + END`, company: sql`CASE - WHEN ${employees.id} IS NOT NULL THEN JSON_OBJECT( - 'id', ${companies.id}, - 'name', ${companies.name}, - 'subdomain', ${companies.subdomain}, - 'role', ${employees.role} - ) - ELSE NULL - END`, + WHEN ${employees.id} IS NOT NULL THEN JSON_OBJECT( + 'id', ${companies.id}, + 'name', ${companies.name}, + 'subdomain', ${companies.subdomain}, + 'role', ${employees.role} + ) + ELSE NULL + END`, createdAt: users.createdAt, }) .from(users) @@ -51,8 +44,6 @@ export class AccountRepository { .where(eq(users.id, id)) .limit(1); - await redis.set(cacheKey, JSON.stringify(account), "EX", CACHE_TTL); - return accountSchema.parse(account); } } diff --git a/apps/server/src/modules/auth/repositories/index.ts b/apps/server/src/modules/auth/repositories/index.ts index 0ca51de..0d4ae8d 100644 --- a/apps/server/src/modules/auth/repositories/index.ts +++ b/apps/server/src/modules/auth/repositories/index.ts @@ -8,12 +8,7 @@ import { import type { TokenPayload } from "google-auth-library"; import type { z } from "zod"; -import { redis } from "../../../config/redis"; -import { - CACHE_TTL, - jwtPayloadCacheKey, - userCacheKey, -} from "../../../core/lib/cache"; +import { Cached, InvalidateCache } from "../../../shared/infra/cache"; /** @description User data access layer */ export class AuthRepository { @@ -23,14 +18,8 @@ export class AuthRepository { * @param id - The user ID * @returns The parsed user data */ + @Cached({ ttl: 3600, key: "user" }) static async queryUserById(id: string) { - const cacheKey = userCacheKey(id); - const cached = await redis.get(cacheKey); - - if (cached) { - return userSchema.parse(JSON.parse(cached)); - } - const [user] = await db .select({ id: users.id, @@ -52,8 +41,6 @@ export class AuthRepository { .where(eq(users.id, id)) .limit(1); - await redis.set(cacheKey, JSON.stringify(user), "EX", CACHE_TTL); - return userSchema.parse(user); } @@ -63,6 +50,7 @@ export class AuthRepository { * @param email - The user email * @returns The parsed user data or null if not found */ + @Cached({ ttl: 3600, key: "user:email" }) static async queryUserByEmail( email: string ): Promise | null> { @@ -100,14 +88,8 @@ export class AuthRepository { * @param userId - The user ID * @returns The parsed JWT payload */ + @Cached({ ttl: 3600, key: "jwt" }) static async queryJWTPayloadByUserId(userId: string) { - const cacheKey = jwtPayloadCacheKey(userId); - const cached = await redis.get(cacheKey); - - if (cached) { - return jwtPayload.parse(JSON.parse(cached)); - } - const [payload] = await db .select({ id: users.id, @@ -138,8 +120,6 @@ export class AuthRepository { .leftJoin(companies, eq(employees.companyId, companies.id)) .where(eq(users.id, userId)); - await redis.set(cacheKey, JSON.stringify(payload), "EX", CACHE_TTL); - return jwtPayload.parse(payload); } @@ -166,15 +146,13 @@ export class AuthRepository { * * @param userId - The user ID */ + @InvalidateCache({ patterns: ["user:*", "jwt:*", "account:*"] }) static async setUserVerified(userId: string) { const verifyUser = db .update(users) .set({ verified: true }) .where(eq(users.id, userId)); - const cacheKey = userCacheKey(userId); - await redis.del(cacheKey); - return await verifyUser; } @@ -183,12 +161,10 @@ export class AuthRepository { * * @param userId - The user ID */ + @InvalidateCache({ patterns: ["user:*", "jwt:*", "account:*"] }) static async deleteUser(userId: string) { const delUser = db.delete(users).where(eq(users.id, userId)); - const cacheKey = userCacheKey(userId); - await redis.del(cacheKey); - return await delUser; } @@ -198,6 +174,7 @@ export class AuthRepository { * @param userId - The user ID * @param data - Google token payload data */ + @InvalidateCache({ patterns: ["user:*", "jwt:*", "account:*"] }) static async updateUserWithGoogleData({ userId, data, @@ -205,14 +182,6 @@ export class AuthRepository { userId: string; data: Partial; }) { - const invalidate = { - user: userCacheKey(userId), - jwt: jwtPayloadCacheKey(userId), - }; - - // Use Promise.all for parallel cache invalidation instead of sequential loop - await Promise.all(Object.values(invalidate).map((key) => redis.del(key))); - return await db .update(users) .set({ googleId: data.sub, avatarUrl: data.picture }) diff --git a/apps/server/src/modules/categories/repositories/index.ts b/apps/server/src/modules/categories/repositories/index.ts index 702446d..c884045 100644 --- a/apps/server/src/modules/categories/repositories/index.ts +++ b/apps/server/src/modules/categories/repositories/index.ts @@ -1,5 +1,6 @@ import { asc, db, eq, like } from "@fixr/db/connection"; import { modelCategories } from "@fixr/db/schema"; +import { Cached } from "../../../shared/infra/cache"; /** @description Data access layer for device categories */ export class CategoriesRepository { @@ -8,6 +9,7 @@ export class CategoriesRepository { * * @param query - Optional name filter */ + @Cached({ ttl: 3600, key: "categories:all" }) static async queryAllCategories(query?: string) { const base = db.select().from(modelCategories).$dynamic(); if (query) { @@ -22,6 +24,7 @@ export class CategoriesRepository { * @param slug - The category slug * @returns The category record or null */ + @Cached({ ttl: 3600, key: "categories:slug" }) static async queryCategoryBySlug(slug: string) { const [category] = await db .select() diff --git a/apps/server/src/modules/companies/repositories/index.ts b/apps/server/src/modules/companies/repositories/index.ts index fffd670..bee82fb 100644 --- a/apps/server/src/modules/companies/repositories/index.ts +++ b/apps/server/src/modules/companies/repositories/index.ts @@ -7,9 +7,8 @@ import { } from "@fixr/db/schema"; import type { createCompanySchema } from "@fixr/schemas/companies"; import type { z } from "zod"; -import { redis } from "../../../config/redis"; -import { CACHE_TTL, companyCacheKey } from "../../../core/lib/cache"; import { hashPassword } from "../../../core/lib/hash-password"; +import { Cached, InvalidateCache } from "../../../shared/infra/cache"; /** @description Companies data access layer */ export class CompaniesRepository { @@ -19,18 +18,8 @@ export class CompaniesRepository { * @param id - The company ID * @returns The parsed company data */ + @Cached({ ttl: 3600, key: "company" }) static async queryCompanyById(id: string) { - const cacheKey = companyCacheKey(id); - const cached = await redis.get(cacheKey); - - if (cached) { - const parsed = JSON.parse(cached); - if (!parsed) { - return undefined; - } - return companySelectSchema.parse(parsed); - } - const [company] = await db .select() .from(companies) @@ -38,12 +27,9 @@ export class CompaniesRepository { .limit(1); if (!company) { - await redis.set(cacheKey, JSON.stringify(null), "EX", CACHE_TTL); return undefined; } - await redis.set(cacheKey, JSON.stringify(company), "EX", CACHE_TTL); - return companySelectSchema.parse(company); } @@ -53,18 +39,8 @@ export class CompaniesRepository { * @param subdomain - The company subdomain * @returns The parsed company data */ + @Cached({ ttl: 3600, key: "company:subdomain" }) static async queryCompanyBySubdomain(subdomain: string) { - const cacheKey = companyCacheKey(subdomain); - const cached = await redis.get(cacheKey); - - if (cached) { - const parsed = JSON.parse(cached); - if (!parsed) { - return undefined; - } - return companySelectSchema.parse(parsed); - } - const [company] = await db .select() .from(companies) @@ -72,12 +48,9 @@ export class CompaniesRepository { .limit(1); if (!company) { - await redis.set(cacheKey, JSON.stringify(null), "EX", CACHE_TTL); return undefined; } - await redis.set(cacheKey, JSON.stringify(company), "EX", CACHE_TTL); - return companySelectSchema.parse(company); } @@ -108,6 +81,7 @@ export class CompaniesRepository { /** * @description Create a company, user, and employee (admin) in sequence */ + @InvalidateCache({ patterns: ["company:*"] }) static async createOrgWithAdmin(data: z.infer) { const [orgId] = await db .insert(companies) diff --git a/apps/server/src/modules/credentials/repositories/index.ts b/apps/server/src/modules/credentials/repositories/index.ts index 6d2cde4..6db2cef 100644 --- a/apps/server/src/modules/credentials/repositories/index.ts +++ b/apps/server/src/modules/credentials/repositories/index.ts @@ -1,7 +1,6 @@ import { db, eq } from "@fixr/db/connection"; import { users } from "@fixr/db/schema"; -import { redis } from "../../../config/redis"; -import { userCacheKey } from "../../../core/lib/cache"; +import { InvalidateCache } from "../../../shared/infra/cache"; /** @description Credentials data access layer */ export class CredentialsRepository { @@ -11,15 +10,13 @@ export class CredentialsRepository { * @param userId - The user ID * @param passwordHash - The new bcrypt hash */ + @InvalidateCache({ patterns: ["user:*", "jwt:*", "account:*"] }) static async updateUserPassword(userId: string, passwordHash: string) { const updatePass = db .update(users) .set({ passwordHash }) .where(eq(users.id, userId)); - const cacheKey = userCacheKey(userId); - await redis.del(cacheKey); - return await updatePass; } } diff --git a/apps/server/src/modules/employees/repositories/index.ts b/apps/server/src/modules/employees/repositories/index.ts index 1d42345..1e51bc4 100644 --- a/apps/server/src/modules/employees/repositories/index.ts +++ b/apps/server/src/modules/employees/repositories/index.ts @@ -4,6 +4,7 @@ import { employees, users } from "@fixr/db/schema"; import type { createEmployeeSchema } from "@fixr/schemas/employees"; import type { z } from "zod"; import { hashPassword } from "../../../core/lib/hash-password"; +import { Cached, InvalidateCache } from "../../../shared/infra/cache"; /** @description Employees data access layer */ export class EmployeesRepository { @@ -13,6 +14,7 @@ export class EmployeesRepository { * @param cpf - The employee CPF * @returns The employee data or undefined */ + @Cached({ ttl: 3600, key: "employees:cpf" }) static async getEmployeeByCpf(cpf: string) { const [data] = await db .select() @@ -27,6 +29,7 @@ export class EmployeesRepository { * @param data - The employee registration data * @param companyId - The company ID */ + @InvalidateCache({ patterns: ["employees:*"] }) static async createEmployeeAndAccount({ data, companyId, diff --git a/apps/server/src/modules/makers/repositories/index.ts b/apps/server/src/modules/makers/repositories/index.ts index 5b7d57d..9629ebf 100644 --- a/apps/server/src/modules/makers/repositories/index.ts +++ b/apps/server/src/modules/makers/repositories/index.ts @@ -1,5 +1,6 @@ import { and, asc, db, desc, eq, like, type SQL } from "@fixr/db/connection"; import { modelMakers } from "@fixr/db/schema"; +import { Cached } from "../../../shared/infra/cache"; /** @description Column selection for paginated makers list */ export const makersListSelect = { @@ -51,6 +52,7 @@ export class MakersRepository { * @param slug - The maker slug * @returns The maker record or null */ + @Cached({ ttl: 3600, key: "makers:slug" }) static async queryMakerBySlug(slug: string) { const [maker] = await db .select() diff --git a/apps/server/src/modules/models/repositories/index.ts b/apps/server/src/modules/models/repositories/index.ts index a5ee04c..4aac331 100644 --- a/apps/server/src/modules/models/repositories/index.ts +++ b/apps/server/src/modules/models/repositories/index.ts @@ -22,6 +22,7 @@ import { r2Bucket, r2Client, } from "../../../config/r2"; +import { Cached, InvalidateCache } from "../../../shared/infra/cache"; const FTS_OPERATOR_REGEX = /[+\-*~()<>@]/; const WHITESPACE_REGEX = /\s+/; @@ -155,6 +156,7 @@ export class ModelsRepository { * @param companyId - Optional company ID for scoping * @returns The model record with relations or null */ + @Cached({ ttl: 3600, key: "models:slug" }) static async queryModelBySlug(slug: string, companyId?: string) { const conditions: SQL[] = [eq(models.slug, slug)]; if (companyId) { @@ -241,6 +243,7 @@ export class ModelsRepository { * @param modelId - The model ID * @returns Array of model images */ + @Cached({ ttl: 3600, key: "models:images" }) static async queryModelImages(modelId: string) { return await db .select() @@ -312,6 +315,7 @@ export class ModelsRepository { * @param id - The model ID * @returns The model record with relations or null */ + @Cached({ ttl: 3600, key: "models:detail" }) static async queryModelById(id: string) { const [model] = await db .select({ @@ -425,6 +429,7 @@ export class ModelsRepository { * * @param data - The model data to insert */ + @InvalidateCache({ patterns: ["models:*"] }) static async insertModel(data: typeof models.$inferInsert) { await db.insert(models).values(data); } @@ -435,6 +440,7 @@ export class ModelsRepository { * @param data - The model image data * @returns The created model image */ + @InvalidateCache({ patterns: ["models:*"] }) static async insertModelImage(data: typeof modelImages.$inferInsert) { const id = data.id as string; await db.insert(modelImages).values(data); @@ -451,6 +457,7 @@ export class ModelsRepository { * * @param imageId - The image ID */ + @InvalidateCache({ patterns: ["models:*"] }) static async deleteModelImageRecord(imageId: string) { await db.delete(modelImages).where(eq(modelImages.id, imageId)); } @@ -490,6 +497,7 @@ export class ModelsRepository { * @param id - The model ID * @param data - The fields to update */ + @InvalidateCache({ patterns: ["models:*"] }) static async updateModel( id: string, data: Partial @@ -502,6 +510,7 @@ export class ModelsRepository { * * @param id - The model ID */ + @InvalidateCache({ patterns: ["models:*"] }) static async deleteModel(id: string) { const keys = await ModelsRepository.queryR2KeysByModel(id); await Promise.all(keys.map((k) => ModelsRepository.deleteR2Object(k))); diff --git a/apps/server/src/modules/service-orders/repositories/index.ts b/apps/server/src/modules/service-orders/repositories/index.ts index c9333fe..582cea0 100644 --- a/apps/server/src/modules/service-orders/repositories/index.ts +++ b/apps/server/src/modules/service-orders/repositories/index.ts @@ -23,6 +23,7 @@ import type { getServiceOrdersQuerySchema, } from "@fixr/schemas/service-orders"; import type { z } from "zod"; +import { Cached, InvalidateCache } from "../../../shared/infra/cache"; function endOfDay(date: Date) { const end = new Date(date); @@ -74,6 +75,7 @@ export const serviceOrdersListJoins = [ ]; export class ServiceOrdersRepository { + @Cached({ ttl: 3600, key: "service-orders:employee" }) static async queryEmployeeByUserId(userId: string) { const [employee] = await db .select() @@ -83,6 +85,7 @@ export class ServiceOrdersRepository { return employee ?? null; } + @Cached({ ttl: 3600, key: "service-orders:client" }) static async queryClientById(clientId: string) { const [client] = await db .select() @@ -92,6 +95,7 @@ export class ServiceOrdersRepository { return client ?? null; } + @Cached({ ttl: 3600, key: "service-orders:device-maker" }) static async queryDeviceMakerById(deviceMakerId: string) { const [maker] = await db .select() @@ -101,6 +105,7 @@ export class ServiceOrdersRepository { return maker ?? null; } + @Cached({ ttl: 3600, key: "service-orders:device-category" }) static async queryDeviceCategoryById(deviceCategoryId: string) { const [category] = await db .select() @@ -154,6 +159,7 @@ export class ServiceOrdersRepository { return and(...conditions); } + @InvalidateCache({ patterns: ["service-orders:*"] }) static async createWithPhotos({ companyId, employeeId, diff --git a/apps/server/src/shared/infra/cache/decorators/cached.ts b/apps/server/src/shared/infra/cache/decorators/cached.ts new file mode 100644 index 0000000..42ccc0d --- /dev/null +++ b/apps/server/src/shared/infra/cache/decorators/cached.ts @@ -0,0 +1,51 @@ +import { CacheService } from "../service/cache-service"; + +/** + * Caches the return value of a static method in Redis. + * + * The cache key is derived from the provided key prefix and the serialized + * arguments of the method call, so different arguments produce distinct keys. + * + * On cache hit the parsed value is returned and a hit is logged. + * On cache miss the original method executes, its result is stored with the + * configured TTL, and a miss is logged. + * If Redis is unavailable the method executes without caching (fail-open). + * + * @param options.ttl - Time-to-live in seconds + * @param options.key - Cache key prefix (e.g. 'user', 'models:detail') + */ +export function Cached(options: { ttl: number; key: string }) { + return ( + _target: unknown, + _propertyKey: string, + descriptor: PropertyDescriptor + ): PropertyDescriptor => { + const originalMethod = descriptor.value as (...args: unknown[]) => unknown; + + descriptor.value = async function ( + this: unknown, + ...args: unknown[] + ): Promise { + const cacheKey = + args.length > 0 + ? `${options.key}:${JSON.stringify(args)}` + : options.key; + + const cached = await CacheService.get(cacheKey); + + if (cached !== null) { + console.log(`[Cache] HIT for "${cacheKey}"`); + return cached; + } + + console.log(`[Cache] MISS for "${cacheKey}"`); + const result = await originalMethod.apply(this, args); + + await CacheService.set(cacheKey, result, options.ttl); + + return result; + }; + + return descriptor; + }; +} diff --git a/apps/server/src/shared/infra/cache/decorators/index.ts b/apps/server/src/shared/infra/cache/decorators/index.ts new file mode 100644 index 0000000..5dfde2e --- /dev/null +++ b/apps/server/src/shared/infra/cache/decorators/index.ts @@ -0,0 +1,2 @@ +export { Cached } from "./cached"; +export { InvalidateCache } from "./invalidate-cache"; diff --git a/apps/server/src/shared/infra/cache/decorators/invalidate-cache.ts b/apps/server/src/shared/infra/cache/decorators/invalidate-cache.ts new file mode 100644 index 0000000..8b81c48 --- /dev/null +++ b/apps/server/src/shared/infra/cache/decorators/invalidate-cache.ts @@ -0,0 +1,41 @@ +import { CacheService } from "../service/cache-service"; + +/** + * Invalidates Redis cache entries after the decorated method executes + * successfully. + * + * Accepts glob-style key patterns (e.g. 'user:*', 'models:*') and uses Redis + * SCAN to find matching keys before deleting them in bulk. This supports loose + * invalidation: a pattern like 'user:*' will match 'user:abc', 'user:["xyz"]', + * etc. + * + * If Redis is unavailable a warning is logged and execution continues + * (fail-open). + * + * @param options.patterns - Array of key patterns to invalidate after the method runs + */ +export function InvalidateCache(options: { patterns: string[] }) { + return ( + _target: unknown, + _propertyKey: string, + descriptor: PropertyDescriptor + ): PropertyDescriptor => { + const originalMethod = descriptor.value as (...args: unknown[]) => unknown; + + descriptor.value = async function ( + this: unknown, + ...args: unknown[] + ): Promise { + const result = await originalMethod.apply(this, args); + + console.log( + `[Cache] Invalidating patterns: ${options.patterns.join(", ")}` + ); + await CacheService.invalidatePatterns(options.patterns); + + return result; + }; + + return descriptor; + }; +} diff --git a/apps/server/src/shared/infra/cache/index.ts b/apps/server/src/shared/infra/cache/index.ts new file mode 100644 index 0000000..d8e2301 --- /dev/null +++ b/apps/server/src/shared/infra/cache/index.ts @@ -0,0 +1,2 @@ +export { Cached, InvalidateCache } from "./decorators"; +export { CacheService } from "./service/cache-service"; diff --git a/apps/server/src/shared/infra/cache/service/cache-service.ts b/apps/server/src/shared/infra/cache/service/cache-service.ts new file mode 100644 index 0000000..d9c2c07 --- /dev/null +++ b/apps/server/src/shared/infra/cache/service/cache-service.ts @@ -0,0 +1,110 @@ +import { redis } from "../../../../config/redis"; + +/** + * Service that wraps ioredis with fail-open behavior. + * + * If Redis is unavailable, reads return null and writes are silently skipped, + * allowing the application to continue without caching. + */ +export class CacheService { + /** + * Retrieve a cached value by key. + * + * @param key - The cache key + * @returns The parsed value, or null if not found or Redis is unavailable + */ + static async get(key: string): Promise { + try { + const raw = await redis.get(key); + if (raw === null) { + return null; + } + return JSON.parse(raw) as T; + } catch (error) { + console.warn(`[CacheService] GET failed for key "${key}":`, error); + return null; + } + } + + /** + * Store a value in the cache with a TTL. + * + * @param key - The cache key + * @param value - The value to serialize and store + * @param ttl - Time-to-live in seconds + */ + static async set(key: string, value: unknown, ttl: number): Promise { + try { + await redis.set(key, JSON.stringify(value), "EX", ttl); + } catch (error) { + console.warn(`[CacheService] SET failed for key "${key}":`, error); + } + } + + /** + * Delete a single cache key. + * + * @param key - The cache key to delete + */ + static async del(key: string): Promise { + try { + await redis.del(key); + } catch (error) { + console.warn(`[CacheService] DEL failed for key "${key}":`, error); + } + } + + /** + * Find all keys matching a glob pattern using Redis SCAN. + * + * Uses cursor-based iteration to avoid blocking Redis. + * + * @param pattern - The glob pattern to match (e.g. 'user:*') + * @returns An array of matching key names + */ + static async scan(pattern: string): Promise { + const keys: string[] = []; + let cursor = "0"; + + try { + do { + const result = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100); + cursor = result[0]; + keys.push(...result[1]); + } while (cursor !== "0"); + } catch (error) { + console.warn( + `[CacheService] SCAN failed for pattern "${pattern}":`, + error + ); + } + + return keys; + } + + /** + * Delete all keys matching one or more glob patterns. + * + * Each pattern is scanned and matching keys are deleted in bulk. + * + * @param patterns - Array of glob patterns to invalidate + */ + static async invalidatePatterns(patterns: string[]): Promise { + try { + const keysToDelete = new Set(); + + for (const pattern of patterns) { + const matched = await CacheService.scan(pattern); + for (const key of matched) { + keysToDelete.add(key); + } + } + + if (keysToDelete.size > 0) { + await redis.del([...keysToDelete]); + } + } catch (error) { + console.warn("[CacheService] invalidatePatterns failed:", error); + } + } +} diff --git a/packages/typescript-config/api.json b/packages/typescript-config/api.json index de27c79..2a7d59f 100644 --- a/packages/typescript-config/api.json +++ b/packages/typescript-config/api.json @@ -5,6 +5,7 @@ "jsx": "react-jsx", "module": "ESNext", "moduleResolution": "Bundler", + "experimentalDecorators": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true,