-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/compt 57 cacheable cacheevict decorators #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c28c8d1
529604d
56a19a1
36f12ec
d9e22e9
e15cb01
5886a67
1379e4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Product> { ... } | ||
| * ``` | ||
| * | ||
| * @example Dynamic key with argument interpolation | ||
| * ```typescript | ||
| * @CacheEvict("user:{0}") | ||
| * async updateUser(id: string, dto: UpdateUserDto): Promise<User> { ... } | ||
| * ``` | ||
| */ | ||
| 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<unknown> { | ||
| // 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; | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Product[]> { ... } | ||
| * ``` | ||
| * | ||
| * @example Dynamic key with argument interpolation | ||
| * ```typescript | ||
| * @Cacheable("user:{0}", 60) | ||
| * async findUserById(id: string): Promise<User> { ... } | ||
| * ``` | ||
| * | ||
| * @example Without explicit TTL (inherits module default) | ||
| * ```typescript | ||
| * @Cacheable("config") | ||
| * async getConfig(): Promise<Config> { ... } | ||
| * ``` | ||
| */ | ||
| 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<unknown> { | ||
| // 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<unknown>(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; | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
Comment on lines
+55
to
+61
|
||
|
|
||
| // ============================================================================ | ||
| // TYPES & INTERFACES (For TypeScript Typing) | ||
| // ============================================================================ | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+46
to
+52
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 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; | |
| * Called by CacheModule.onModuleInit(). | |
| * | |
| * Re-initialisation is allowed only when the same instance is provided again | |
| * (for example, during an idempotent bootstrap path). A different instance is | |
| * rejected because decorators resolve this module-scoped reference globally, | |
| * and silently replacing it would make them use the wrong CacheService. | |
| * | |
| * @param service - The resolved CacheService from NestJS DI | |
| * @throws {Error} If a different CacheService instance was already registered | |
| */ | |
| set(service: CacheService): void { | |
| if (_instance === null) { | |
| _instance = service; | |
| return; | |
| } | |
| if (_instance !== service) { | |
| throw new Error( | |
| "[CacheKit] CacheServiceRef has already been initialised with a different instance. " + | |
| "Multiple CacheModule.register() or CacheModule.registerAsync() imports in the same " + | |
| "process are not supported by the global decorator cache reference.", | |
| ); | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) => { | ||
|
Check warning on line 34 in src/utils/resolve-cache-key.util.ts
|
||
| const value = args[Number(indexStr)]; | ||
|
|
||
| // If the argument exists, coerce it to a string; otherwise leave empty | ||
| return value !== undefined && value !== null ? String(value) : ""; | ||
|
Check warning on line 38 in src/utils/resolve-cache-key.util.ts
|
||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cacheablewill attempt to cache whatever the wrapped method returns. If the method returnsundefined(common forvoid/command-style methods),JSON.stringify(undefined)yieldsundefined, which is not valid JSON and will cause reads to always miss (or potentially fail depending on the store implementation). Consider skippingcacheService.set()when the result isundefined, or coercing/handling it explicitly.