diff --git a/src/cache-kit.module.ts b/src/cache-kit.module.ts index c1fec22..ec1935f 100644 --- a/src/cache-kit.module.ts +++ b/src/cache-kit.module.ts @@ -24,6 +24,8 @@ import { type InjectionToken, Module, type ModuleMetadata, + OnModuleInit, + Optional, type OptionalFactoryDependency, Provider, Type, @@ -32,6 +34,7 @@ import type { ICacheStore } from "@ports/cache-store.port"; import { CACHE_MODULE_OPTIONS, CACHE_STORE } from "./constants"; import { CacheService } from "./services/cache.service"; +import { CacheServiceRef } from "./utils/cache-service-ref"; // --------------------------------------------------------------------------- // Configuration interfaces @@ -210,7 +213,28 @@ function createAsyncProviders(options: CacheModuleAsyncOptions): Provider[] { * ``` */ @Module({}) -export class CacheModule { +export class CacheModule implements OnModuleInit { + constructor( + /** + * Injected by NestJS from the providers registered in register() / registerAsync(). + * @Optional() guards against the rare case where the module class is instantiated + * without CacheService being available (e.g. partial test setups). + */ + @Optional() private readonly cacheService?: CacheService, + ) {} + + /** + * Runs after all providers in this module have been resolved. + * Stores the CacheService reference so @Cacheable and @CacheEvict can access + * it at method-call time without going through constructor injection. + */ + onModuleInit(): void { + // Only populate the ref when CacheService is available + if (this.cacheService) { + CacheServiceRef.set(this.cacheService); + } + } + /** * Register the module with synchronous, inline configuration. * diff --git a/src/decorators/cache-evict.decorator.ts b/src/decorators/cache-evict.decorator.ts new file mode 100644 index 0000000..dc2057c --- /dev/null +++ b/src/decorators/cache-evict.decorator.ts @@ -0,0 +1,76 @@ +/** + * @file cache-evict.decorator.ts + * + * @CacheEvict method decorator — removes a cache entry after the method executes. + * + * When applied to a method, it: + * 1. Calls the original method and awaits its result. + * 2. Resolves the cache key by interpolating method arguments into the template. + * 3. Deletes the matching cache entry so the next read fetches fresh data. + * + * The eviction happens AFTER the method succeeds — if the method throws, the + * cache entry is left intact. + * + * Works with both sync and async methods. The wrapped method always returns a + * Promise because the cache delete operation is asynchronous. + * + * CacheService is resolved from the singleton CacheServiceRef which is populated + * by CacheModule.onModuleInit() — no extra injection is required in consumer classes. + * + * Exports: + * - CacheEvict → method decorator factory + */ + +import { CacheServiceRef } from "@utils/cache-service-ref"; +import { resolveCacheKey } from "@utils/resolve-cache-key.util"; + +/** + * Cache eviction method decorator. + * + * @param key - Cache key template to delete after the method executes. + * Use `{0}`, `{1}`, … to interpolate method arguments, + * e.g. `"user:{0}"`. + * + * @example Static key eviction + * ```typescript + * @CacheEvict("all-products") + * async createProduct(dto: CreateProductDto): Promise { ... } + * ``` + * + * @example Dynamic key with argument interpolation + * ```typescript + * @CacheEvict("user:{0}") + * async updateUser(id: string, dto: UpdateUserDto): Promise { ... } + * ``` + */ +export function CacheEvict(key: string): MethodDecorator { + return ( + _target: object, + _propertyKey: string | symbol, + descriptor: PropertyDescriptor, + ): PropertyDescriptor => { + // Capture the original method before replacing it + const originalMethod = descriptor.value as (...args: unknown[]) => unknown; + + // Replace the method with a cache-evicting wrapper + descriptor.value = async function (this: unknown, ...args: unknown[]): Promise { + // Resolve the CacheService from the module-level singleton + const cacheService = CacheServiceRef.get(); + + // ── Execute the original method first ────────────────────────────── + // Wrap in Promise.resolve() to support both sync and async methods. + // If the method throws, the error propagates and eviction is skipped + // so we don't invalidate a cache entry for a failed operation. + const result = await Promise.resolve(originalMethod.apply(this, args)); + + // ── Evict cache entry after successful execution ─────────────────── + // Interpolate {n} placeholders using the actual call arguments + const resolvedKey = resolveCacheKey(key, args); + await cacheService.delete(resolvedKey); + + return result; + }; + + return descriptor; + }; +} diff --git a/src/decorators/cacheable.decorator.ts b/src/decorators/cacheable.decorator.ts new file mode 100644 index 0000000..16a7839 --- /dev/null +++ b/src/decorators/cacheable.decorator.ts @@ -0,0 +1,85 @@ +/** + * @file cacheable.decorator.ts + * + * @Cacheable method decorator — implements the cache-aside pattern automatically. + * + * When applied to a method, it: + * 1. Resolves the cache key by interpolating method arguments into the template. + * 2. Returns the cached value immediately if it exists (cache hit). + * 3. On a cache miss, calls the original method, stores the result, and returns it. + * + * Works with both sync and async methods. The wrapped method always returns a Promise + * because cache read/write operations are inherently asynchronous. + * + * CacheService is resolved from the singleton CacheServiceRef which is populated + * by CacheModule.onModuleInit() — no extra injection is required in consumer classes. + * + * Exports: + * - Cacheable → method decorator factory + */ + +import { CacheServiceRef } from "@utils/cache-service-ref"; +import { resolveCacheKey } from "@utils/resolve-cache-key.util"; + +/** + * Cache-aside method decorator. + * + * @param key - Cache key template. Use `{0}`, `{1}`, … to interpolate + * method arguments, e.g. `"user:{0}"`. + * @param ttlSeconds - Optional TTL in seconds. Falls back to the module-level + * default TTL configured in CacheModule.register(). + * + * @example Static key + * ```typescript + * @Cacheable("all-products", 300) + * async findAllProducts(): Promise { ... } + * ``` + * + * @example Dynamic key with argument interpolation + * ```typescript + * @Cacheable("user:{0}", 60) + * async findUserById(id: string): Promise { ... } + * ``` + * + * @example Without explicit TTL (inherits module default) + * ```typescript + * @Cacheable("config") + * async getConfig(): Promise { ... } + * ``` + */ +export function Cacheable(key: string, ttlSeconds?: number): MethodDecorator { + return ( + _target: object, + _propertyKey: string | symbol, + descriptor: PropertyDescriptor, + ): PropertyDescriptor => { + // Capture the original method before replacing it + const originalMethod = descriptor.value as (...args: unknown[]) => unknown; + + // Replace the method with a cache-aware wrapper + descriptor.value = async function (this: unknown, ...args: unknown[]): Promise { + // Resolve the CacheService from the module-level singleton + const cacheService = CacheServiceRef.get(); + + // Interpolate any {n} placeholders in the key template with the actual args + const resolvedKey = resolveCacheKey(key, args); + + // ── Cache hit ────────────────────────────────────────────────────── + // Return the stored value immediately without calling the original method + const cached = await cacheService.get(resolvedKey); + if (cached !== null) return cached; + + // ── Cache miss ───────────────────────────────────────────────────── + // Call the original method; wrap in Promise.resolve() to handle both + // sync methods (returns a plain value) and async methods (returns a Promise) + const result = await Promise.resolve(originalMethod.apply(this, args)); + + // Persist the result under the resolved key for future calls + await cacheService.set(resolvedKey, result, ttlSeconds); + + return result; + }; + + return descriptor; + }; +} diff --git a/src/index.ts b/src/index.ts index 3ff9c23..4755cd6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,17 @@ export { ExampleGuard } from "./guards/example.guard"; // Export decorators for use in consumer controllers/services export { ExampleData, ExampleParam } from "./decorators/example.decorator"; +// ============================================================================ +// DECORATORS +// ============================================================================ +// Method decorators for automatic caching and cache invalidation. +// Apply these to service methods — no manual CacheService injection needed. + +// Cache-aside decorator: returns cached value or calls the method and stores the result +export { Cacheable } from "./decorators/cacheable.decorator"; +// Cache eviction decorator: deletes the cache entry after the method executes +export { CacheEvict } from "./decorators/cache-evict.decorator"; + // ============================================================================ // TYPES & INTERFACES (For TypeScript Typing) // ============================================================================ diff --git a/src/utils/cache-service-ref.ts b/src/utils/cache-service-ref.ts new file mode 100644 index 0000000..13ce14f --- /dev/null +++ b/src/utils/cache-service-ref.ts @@ -0,0 +1,72 @@ +/** + * @file cache-service-ref.ts + * + * Module-scoped singleton reference to the active CacheService instance. + * + * Why this exists: + * Method decorators (@Cacheable, @CacheEvict) are applied at class-definition + * time, long before NestJS has assembled the DI container. They cannot receive + * CacheService via constructor injection. Instead, CacheModule stores a reference + * here during `onModuleInit()`, and the decorators read it at call time. + * + * Lifecycle: + * 1. App bootstraps → NestJS initialises CacheModule. + * 2. CacheModule.onModuleInit() calls CacheServiceRef.set(cacheService). + * 3. First method call with @Cacheable / @CacheEvict → CacheServiceRef.get() succeeds. + * + * Exports: + * - CacheServiceRef → { set, get } singleton accessor + */ + +import type { CacheService } from "@services/cache.service"; + +// --------------------------------------------------------------------------- +// Internal holder — private to this module +// --------------------------------------------------------------------------- + +/** + * The single shared CacheService instance. + * null until CacheModule.onModuleInit() runs. + */ +let _instance: CacheService | null = null; + +// --------------------------------------------------------------------------- +// Public accessor +// --------------------------------------------------------------------------- + +/** + * Singleton accessor for the active CacheService. + * + * Populated by CacheModule during application bootstrap. + * Used internally by @Cacheable and @CacheEvict at method-call time. + */ +export const CacheServiceRef = { + /** + * Store the CacheService instance. + * Called once by CacheModule.onModuleInit(). + * + * @param service - The resolved CacheService from NestJS DI + */ + set(service: CacheService): void { + // Overwrite any prior value — safe for hot-reload scenarios + _instance = service; + }, + + /** + * Retrieve the stored CacheService. + * Throws a descriptive error if called before the module has initialised. + * + * @returns The active CacheService instance + * @throws {Error} If CacheModule was not imported in the application + */ + get(): CacheService { + if (_instance === null) { + throw new Error( + "[CacheKit] CacheService is not initialised. " + + "Make sure CacheModule.register() or CacheModule.registerAsync() " + + "is imported in your root AppModule before using @Cacheable or @CacheEvict.", + ); + } + return _instance; + }, +}; diff --git a/src/utils/resolve-cache-key.util.ts b/src/utils/resolve-cache-key.util.ts new file mode 100644 index 0000000..6b0f641 --- /dev/null +++ b/src/utils/resolve-cache-key.util.ts @@ -0,0 +1,40 @@ +/** + * @file resolve-cache-key.util.ts + * + * Utility for resolving cache key templates at runtime. + * + * Templates support positional argument interpolation using the `{n}` syntax, + * where `n` is the zero-based index of the method argument. + * + * Exports: + * - resolveCacheKey → replaces `{0}`, `{1}`, … in a template with actual argument values + */ + +/** + * Resolve a cache key template by substituting `{n}` placeholders with the + * corresponding method argument values. + * + * Rules: + * - `{0}` is replaced with `String(args[0])` + * - `{1}` is replaced with `String(args[1])`, and so on + * - Placeholders that reference a missing argument are replaced with an empty string + * + * @param template - Key template, e.g. `"user:{0}"` or `"post:{0}:comment:{1}"` + * @param args - The method arguments passed at call time + * @returns Fully resolved cache key string + * + * @example + * resolveCacheKey("user:{0}", ["42"]) // → "user:42" + * resolveCacheKey("post:{0}:comments", [7]) // → "post:7:comments" + * resolveCacheKey("static-key", []) // → "static-key" + */ +export function resolveCacheKey(template: string, args: unknown[]): string { + // Replace every {n} token with the stringified value of args[n]. + // The regex matches literal braces wrapping one or more digits. + return template.replace(/\{(\d+)\}/g, (_match, indexStr: string) => { + const value = args[Number(indexStr)]; + + // If the argument exists, coerce it to a string; otherwise leave empty + return value !== undefined && value !== null ? String(value) : ""; + }); +} diff --git a/tsconfig.json b/tsconfig.json index 2010ba7..a7200f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,7 +32,8 @@ "@middleware/*": ["src/middleware/*"], "@utils/*": ["src/utils/*"], "@ports/*": ["src/ports/*"], - "@adapters/*": ["src/adapters/*"] + "@adapters/*": ["src/adapters/*"], + "@utils/*": ["src/utils/*"] } }, "include": ["src/**/*.ts", "test/**/*.ts"],