From c28c8d18887347630ebdcacbf54f0d7f40defbdc Mon Sep 17 00:00:00 2001 From: yasser Date: Tue, 31 Mar 2026 10:08:38 +0100 Subject: [PATCH 1/6] feat(COMPT-55): add ICacheStore port and Redis/InMemory adapters --- jest.config.ts | 2 + package-lock.json | 92 ++++++++++- package.json | 5 +- src/adapters/in-memory-cache-store.adapter.ts | 120 ++++++++++++++ src/adapters/redis-cache-store.adapter.ts | 156 ++++++++++++++++++ src/index.ts | 20 +++ src/ports/cache-store.port.ts | 58 +++++++ tsconfig.json | 4 +- 8 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 src/adapters/in-memory-cache-store.adapter.ts create mode 100644 src/adapters/redis-cache-store.adapter.ts create mode 100644 src/ports/cache-store.port.ts diff --git a/jest.config.ts b/jest.config.ts index 1d7bc2e..958fd0b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -46,6 +46,8 @@ const config: Config = { "^@filters/(.*)$": "/src/filters/$1", "^@middleware/(.*)$": "/src/middleware/$1", "^@utils/(.*)$": "/src/utils/$1", + "^@ports/(.*)$": "/src/ports/$1", + "^@adapters/(.*)$": "/src/adapters/$1", }, }; diff --git a/package-lock.json b/package-lock.json index ff750d7..cc2bc3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "dependencies": { "class-transformer": "^0.5.1", - "class-validator": "^0.14.1" + "class-validator": "^0.14.1", + "ioredis": "^5.10.1" }, "devDependencies": { "@changesets/cli": "^2.27.7", @@ -1362,6 +1363,12 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3909,6 +3916,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4140,7 +4156,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4222,6 +4237,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5904,6 +5928,30 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -7348,6 +7396,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7670,7 +7730,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/multer": { @@ -8592,6 +8651,27 @@ "node": ">= 6" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -9238,6 +9318,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 13f6181..fd7b393 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ }, "dependencies": { "class-transformer": "^0.5.1", - "class-validator": "^0.14.1" + "class-validator": "^0.14.1", + "ioredis": "^5.10.1" }, "devDependencies": { "@changesets/cli": "^2.27.7", @@ -79,4 +80,4 @@ "typescript": "^5.7.3", "typescript-eslint": "^8.50.1" } -} \ No newline at end of file +} diff --git a/src/adapters/in-memory-cache-store.adapter.ts b/src/adapters/in-memory-cache-store.adapter.ts new file mode 100644 index 0000000..e78d36c --- /dev/null +++ b/src/adapters/in-memory-cache-store.adapter.ts @@ -0,0 +1,120 @@ +/** + * @file in-memory-cache-store.adapter.ts + * + * In-memory implementation of ICacheStore backed by a plain JavaScript Map. + * + * Behaviour: + * - Values are JSON-serialized on write and JSON-parsed on read, matching + * the Redis adapter exactly so both can be swapped transparently. + * - TTL is enforced lazily: an expired entry is evicted the first time it + * is read, rather than via a background sweep timer. + * - A parse failure (malformed JSON) returns null instead of throwing. + * - No external dependencies — suitable for unit tests, local development, + * or lightweight production usage that does not require persistence. + * + * Exports: + * - CacheEntry → internal shape of stored entries (exported for tests) + * - InMemoryCacheStore → the concrete in-memory adapter class + */ + +import type { ICacheStore } from "@ports/cache-store.port"; + +// --------------------------------------------------------------------------- +// Internal data shape +// --------------------------------------------------------------------------- + +/** + * Shape of each entry held inside the backing Map. + * Exported so that unit tests can inspect the internal store if needed. + */ +export interface CacheEntry { + /** JSON-serialized representation of the cached value */ + value: string; + + /** + * Absolute Unix timestamp (ms) at which this entry expires. + * null means the entry never expires. + */ + expiresAt: number | null; +} + +// --------------------------------------------------------------------------- +// Adapter +// --------------------------------------------------------------------------- + +/** + * In-memory adapter for the ICacheStore port. + * + * Usage: + * ```typescript + * const store = new InMemoryCacheStore(); + * await store.set("session:abc", { userId: 1 }, 60); // expires in 60 s + * const session = await store.get("session:abc"); + * ``` + */ +export class InMemoryCacheStore implements ICacheStore { + /** + * The backing store. + * Maps every cache key to its serialized value and optional expiry timestamp. + */ + private readonly store = new Map(); + + // --------------------------------------------------------------------------- + // ICacheStore implementation + // --------------------------------------------------------------------------- + + /** {@inheritDoc ICacheStore.get} */ + async get(key: string): Promise { + // Look up the entry — undefined means the key was never set or was deleted + const entry = this.store.get(key); + + // Key does not exist in the store + if (entry === undefined) return null; + + // Lazy TTL expiry: check whether the entry has passed its deadline. + // Date.now() returns the current time in milliseconds. + if (entry.expiresAt !== null && Date.now() > entry.expiresAt) { + // Remove the stale entry and treat the lookup as a cache miss + this.store.delete(key); + return null; + } + + // Deserialize the stored JSON string back to the caller's expected type. + // Return null on malformed JSON instead of propagating a SyntaxError. + try { + return JSON.parse(entry.value) as T; + } catch { + // Parse failure — treat as a cache miss + return null; + } + } + + /** {@inheritDoc ICacheStore.set} */ + async set(key: string, value: T, ttlSeconds?: number): Promise { + // Compute the absolute expiry timestamp from the relative TTL. + // Multiply seconds by 1 000 to convert to milliseconds for Date.now() comparison. + // null signals "no expiry" so the entry lives until deleted or clear() is called. + const expiresAt = + ttlSeconds !== undefined && ttlSeconds > 0 + ? Date.now() + ttlSeconds * 1_000 + : null; + + // Serialize the value to a JSON string before storing to match Redis adapter behaviour + this.store.set(key, { + value: JSON.stringify(value), + expiresAt, + }); + } + + /** {@inheritDoc ICacheStore.delete} */ + async delete(key: string): Promise { + // Map.delete is a no-op when the key does not exist — no guard required + this.store.delete(key); + } + + /** {@inheritDoc ICacheStore.clear} */ + async clear(): Promise { + // Remove every entry from the backing Map in O(1) + this.store.clear(); + } +} diff --git a/src/adapters/redis-cache-store.adapter.ts b/src/adapters/redis-cache-store.adapter.ts new file mode 100644 index 0000000..f884015 --- /dev/null +++ b/src/adapters/redis-cache-store.adapter.ts @@ -0,0 +1,156 @@ +/** + * @file redis-cache-store.adapter.ts + * + * Redis-backed implementation of ICacheStore, built on top of the ioredis client. + * + * Behaviour: + * - Values are JSON-serialized on write and JSON-parsed on read. + * - A parse failure (malformed JSON) returns null instead of throwing. + * - An optional key prefix namespaces every key so multiple adapters can + * share the same Redis database without colliding. + * - clear() only removes keys that belong to this adapter's prefix; + * without a prefix it flushes the entire Redis database (FLUSHDB). + * + * Exports: + * - RedisCacheStoreOptions → configuration shape for the constructor + * - RedisCacheStore → the concrete Redis adapter class + */ + +import Redis from "ioredis"; + +import type { ICacheStore } from "@ports/cache-store.port"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/** + * Constructor options for RedisCacheStore. + */ +export interface RedisCacheStoreOptions { + /** + * An already-constructed ioredis client, OR a Redis connection URL string. + * Passing an existing client lets the caller manage the connection lifecycle. + * Passing a URL string creates a new internal client automatically. + * + * @example "redis://localhost:6379" + */ + client: Redis | string; + + /** + * Optional prefix prepended to every key as ":". + * Useful for isolating cache namespaces on a shared Redis instance. + * + * @example "myapp:cache" + */ + keyPrefix?: string; +} + +// --------------------------------------------------------------------------- +// Adapter +// --------------------------------------------------------------------------- + +/** + * Redis adapter for the ICacheStore port. + * + * Usage: + * ```typescript + * const store = new RedisCacheStore({ client: "redis://localhost:6379", keyPrefix: "app" }); + * await store.set("user:1", { name: "Alice" }, 300); // TTL 5 min + * const user = await store.get("user:1"); + * ``` + */ +export class RedisCacheStore implements ICacheStore { + /** Underlying ioredis client used for all Redis commands */ + private readonly redis: Redis; + + /** Key prefix applied to every cache key (may be an empty string) */ + private readonly keyPrefix: string; + + constructor(options: RedisCacheStoreOptions) { + // Accept either an existing ioredis client or a plain connection URL string. + // When a URL is provided we create a new dedicated client instance. + this.redis = + typeof options.client === "string" ? new Redis(options.client) : options.client; + + // Fall back to an empty string so buildKey() can skip the prefix logic. + this.keyPrefix = options.keyPrefix ?? ""; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Prepend the adapter's namespace prefix to a key. + * Returns the key unchanged when no prefix was configured. + * + * @param key - Raw cache key + * @returns Full Redis key with optional prefix + */ + private buildKey(key: string): string { + // Only add the colon separator when a prefix is set + return this.keyPrefix ? `${this.keyPrefix}:${key}` : key; + } + + // --------------------------------------------------------------------------- + // ICacheStore implementation + // --------------------------------------------------------------------------- + + /** {@inheritDoc ICacheStore.get} */ + async get(key: string): Promise { + // Fetch the raw serialized string from Redis (returns null if key is missing) + const raw = await this.redis.get(this.buildKey(key)); + + // Key does not exist in Redis — return null immediately + if (raw === null) return null; + + // Deserialize the JSON string back to the caller's expected type. + // If the stored value is somehow malformed, return null instead of crashing. + try { + return JSON.parse(raw) as T; + } catch { + // Parse failure — treat as a cache miss + return null; + } + } + + /** {@inheritDoc ICacheStore.set} */ + async set(key: string, value: T, ttlSeconds?: number): Promise { + // Serialize the value to a JSON string before handing it to Redis + const serialized = JSON.stringify(value); + const fullKey = this.buildKey(key); + + if (ttlSeconds !== undefined && ttlSeconds > 0) { + // EX flag sets the expiry in seconds alongside the value in a single command + await this.redis.set(fullKey, serialized, "EX", ttlSeconds); + } else { + // No TTL requested — key persists until explicitly deleted or clear() is called + await this.redis.set(fullKey, serialized); + } + } + + /** {@inheritDoc ICacheStore.delete} */ + async delete(key: string): Promise { + // DEL is a no-op in Redis when the key does not exist, so no guard is needed + await this.redis.del(this.buildKey(key)); + } + + /** {@inheritDoc ICacheStore.clear} */ + async clear(): Promise { + if (this.keyPrefix) { + // Prefix mode: collect only the keys that belong to this adapter's namespace. + // NOTE: KEYS is O(N) and blocks Redis — acceptable for dev / low-traffic scenarios. + // Consider SCAN-based iteration for high-traffic production deployments. + const keys = await this.redis.keys(`${this.keyPrefix}:*`); + + // Only call DEL when there is at least one matching key + if (keys.length > 0) { + await this.redis.del(...keys); + } + } else { + // No prefix — flush every key in the currently selected Redis database + await this.redis.flushdb(); + } + } +} diff --git a/src/index.ts b/src/index.ts index 3026198..ad890ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,26 @@ export { ExampleData, ExampleParam } from "./decorators/example.decorator"; // Export types and interfaces for TypeScript consumers // export type { YourCustomType } from './types'; +// ============================================================================ +// PORTS (Abstractions / Interfaces) +// ============================================================================ +// Export the ICacheStore interface so consumers can type their own adapters +// or declare injection tokens without depending on a concrete implementation. +export type { ICacheStore } from "./ports/cache-store.port"; + +// ============================================================================ +// ADAPTERS (Concrete Cache Store Implementations) +// ============================================================================ +// Both adapters implement ICacheStore — consumers choose the one that fits their stack. + +// Redis-backed adapter — requires the "ioredis" peer dependency. +export { RedisCacheStore } from "./adapters/redis-cache-store.adapter"; +export type { RedisCacheStoreOptions } from "./adapters/redis-cache-store.adapter"; + +// In-memory adapter — zero external dependencies; ideal for tests and local dev. +export { InMemoryCacheStore } from "./adapters/in-memory-cache-store.adapter"; +export type { CacheEntry } from "./adapters/in-memory-cache-store.adapter"; + // ============================================================================ // ❌ NEVER EXPORT (Internal Implementation) // ============================================================================ diff --git a/src/ports/cache-store.port.ts b/src/ports/cache-store.port.ts new file mode 100644 index 0000000..34e2e33 --- /dev/null +++ b/src/ports/cache-store.port.ts @@ -0,0 +1,58 @@ +/** + * @file cache-store.port.ts + * + * Defines the ICacheStore port — the single contract every cache adapter must implement. + * By depending only on this interface (not on Redis, Map, or any concrete client), + * the rest of the codebase stays decoupled from storage details. + * + * Exports: + * - ICacheStore → generic cache interface (get / set / delete / clear) + */ + +/** + * Generic, Promise-based cache store interface. + * + * All four operations are async so that both in-memory and network-backed + * (e.g. Redis) adapters can satisfy the same contract without blocking. + * + * Concrete implementations live in src/adapters/: + * - RedisCacheStore — backed by ioredis + * - InMemoryCacheStore — backed by a plain Map + Date.now() TTL + */ +export interface ICacheStore { + /** + * Retrieve and deserialize a cached value. + * + * Returns null when: + * - the key does not exist + * - the entry has expired (TTL elapsed) + * - the stored value cannot be parsed (malformed JSON) + * + * @param key - Unique cache key + * @returns The deserialized value, or null + */ + get(key: string): Promise; + + /** + * Serialize and store a value under the given key. + * + * @param key - Unique cache key + * @param value - Any JSON-serializable value + * @param ttlSeconds - Optional time-to-live in seconds; omit or pass 0 for no expiry + */ + set(key: string, value: T, ttlSeconds?: number): Promise; + + /** + * Remove a single entry from the cache. + * Silently succeeds if the key does not exist. + * + * @param key - Cache key to remove + */ + delete(key: string): Promise; + + /** + * Evict every entry from the cache. + * After this call the store is empty (equivalent to a full flush). + */ + clear(): Promise; +} diff --git a/tsconfig.json b/tsconfig.json index e92d316..2010ba7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,7 +30,9 @@ "@config/*": ["src/config/*"], "@filters/*": ["src/filters/*"], "@middleware/*": ["src/middleware/*"], - "@utils/*": ["src/utils/*"] + "@utils/*": ["src/utils/*"], + "@ports/*": ["src/ports/*"], + "@adapters/*": ["src/adapters/*"] } }, "include": ["src/**/*.ts", "test/**/*.ts"], From 529604deb6cd234b33c608952a42ffeb66052798 Mon Sep 17 00:00:00 2001 From: yasser Date: Wed, 1 Apr 2026 09:13:13 +0100 Subject: [PATCH 2/6] feat(COMPT-56): add CacheModule, CacheService, and DI tokens --- src/cache-kit.module.ts | 262 ++++++++++++++++++++++++++++++++++ src/constants.ts | 38 +++++ src/index.ts | 18 ++- src/services/cache.service.ts | 189 ++++++++++++++++++++++++ 4 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 src/cache-kit.module.ts create mode 100644 src/constants.ts create mode 100644 src/services/cache.service.ts diff --git a/src/cache-kit.module.ts b/src/cache-kit.module.ts new file mode 100644 index 0000000..f49472c --- /dev/null +++ b/src/cache-kit.module.ts @@ -0,0 +1,262 @@ +/** + * @file cache-kit.module.ts + * + * CacheModule — the top-level NestJS dynamic module for CacheKit. + * + * Responsibilities: + * - Accept configuration (store type, default TTL, provider-specific options) + * via either a synchronous `register()` or an asynchronous `registerAsync()` call. + * - Instantiate the correct ICacheStore adapter (RedisCacheStore or InMemoryCacheStore) + * based on the `store` option and register it under the CACHE_STORE DI token. + * - Register CacheService and export it so consuming modules can inject it. + * + * Exports: + * - CacheModuleOptions → synchronous configuration shape + * - CacheModuleAsyncOptions → asynchronous configuration shape (useFactory / useClass / useExisting) + * - CacheModule → the NestJS dynamic module class + */ + +import { DynamicModule, Module, Provider, Type } from "@nestjs/common"; + +import { InMemoryCacheStore } from "@adapters/in-memory-cache-store.adapter"; +import { RedisCacheStore } from "@adapters/redis-cache-store.adapter"; +import type { RedisCacheStoreOptions } from "@adapters/redis-cache-store.adapter"; +import type { ICacheStore } from "@ports/cache-store.port"; + +import { CACHE_MODULE_OPTIONS, CACHE_STORE } from "./constants"; +import { CacheService } from "./services/cache.service"; + +// --------------------------------------------------------------------------- +// Configuration interfaces +// --------------------------------------------------------------------------- + +/** + * Synchronous configuration options for CacheModule.register(). + */ +export interface CacheModuleOptions { + /** + * Which backing store to use. + * - "redis" → RedisCacheStore (requires the `redis` field) + * - "memory" → InMemoryCacheStore (no extra config needed) + */ + store: "redis" | "memory"; + + /** + * Default time-to-live in seconds applied to every CacheService.set() call + * that does not supply its own TTL. + * Omit or set to 0 for no default expiry. + */ + ttl?: number; + + /** + * Redis adapter configuration — required when store is "redis". + * Ignored when store is "memory". + */ + redis?: RedisCacheStoreOptions; +} + +/** + * Factory function type used by registerAsync's useFactory. + * May return the options synchronously or as a Promise. + */ +export type CacheModuleOptionsFactory = () => + | Promise + | CacheModuleOptions; + +/** + * Asynchronous configuration options for CacheModule.registerAsync(). + * Supports three patterns: + * - useFactory — inline factory function (most common) + * - useClass — instantiate a config class per module + * - useExisting — reuse an already-provided config class + */ +export interface CacheModuleAsyncOptions { + /** Providers whose tokens are passed as arguments to useFactory. */ + inject?: any[]; + + /** Inline factory that resolves to CacheModuleOptions. */ + useFactory?: (...args: any[]) => Promise | CacheModuleOptions; + + /** + * Class that the module will instantiate to obtain the options. + * The class must implement CacheModuleOptionsFactory. + */ + useClass?: Type<{ createCacheOptions(): Promise | CacheModuleOptions }>; + + /** + * Re-use an already-provided token (class or value) as the options factory. + * The resolved instance must implement CacheModuleOptionsFactory. + */ + useExisting?: Type<{ createCacheOptions(): Promise | CacheModuleOptions }>; + + /** Additional NestJS modules to import into the async provider scope. */ + imports?: any[]; +} + +// --------------------------------------------------------------------------- +// Internal factory helpers +// --------------------------------------------------------------------------- + +/** + * Build the ICacheStore provider from a resolved CacheModuleOptions object. + * This is the single place where we decide which adapter to create. + * + * @param options - Fully resolved module options + * @returns The adapter instance typed as ICacheStore + */ +function createStoreFromOptions(options: CacheModuleOptions): ICacheStore { + if (options.store === "redis") { + // Redis store requires connection details — throw early with a clear message + // rather than letting ioredis surface a confusing low-level error. + if (!options.redis) { + throw new Error( + '[CacheModule] store is "redis" but no redis options were provided. ' + + 'Pass a `redis` field to CacheModule.register() or CacheModule.registerAsync().', + ); + } + // Delegate all Redis connection and key-prefix logic to the adapter + return new RedisCacheStore(options.redis); + } + + // Default: in-memory store — zero dependencies, no extra options needed + return new InMemoryCacheStore(); +} + +/** + * Build the CACHE_MODULE_OPTIONS and CACHE_STORE providers for the + * registerAsync path, handling all three async patterns. + * + * @param options - Async configuration options + * @returns Array of NestJS providers ready to be registered + */ +function createAsyncProviders(options: CacheModuleAsyncOptions): Provider[] { + // ── useFactory ───────────────────────────────────────────────────────── + if (options.useFactory) { + return [ + { + // Resolve the options object asynchronously via the factory + provide: CACHE_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject ?? [], + }, + { + // Once options are resolved, build the correct store adapter + provide: CACHE_STORE, + useFactory: (resolvedOptions: CacheModuleOptions): ICacheStore => + createStoreFromOptions(resolvedOptions), + inject: [CACHE_MODULE_OPTIONS], + }, + ]; + } + + // ── useClass / useExisting ────────────────────────────────────────────── + const factoryClass = (options.useClass ?? options.useExisting)!; + + const factoryProvider: Provider = options.useClass + ? // useClass: let NestJS instantiate a new instance of this class + { provide: factoryClass, useClass: factoryClass } + : // useExisting: reuse a token already registered elsewhere in the module tree + { provide: factoryClass, useExisting: options.useExisting }; + + return [ + factoryProvider, + { + // Call createCacheOptions() on the factory instance to get the options + provide: CACHE_MODULE_OPTIONS, + useFactory: (factory: { createCacheOptions(): Promise | CacheModuleOptions }) => + factory.createCacheOptions(), + inject: [factoryClass], + }, + { + // Build the store adapter from the resolved options + provide: CACHE_STORE, + useFactory: (resolvedOptions: CacheModuleOptions): ICacheStore => + createStoreFromOptions(resolvedOptions), + inject: [CACHE_MODULE_OPTIONS], + }, + ]; +} + +// --------------------------------------------------------------------------- +// Module +// --------------------------------------------------------------------------- + +/** + * CacheModule — dynamic NestJS module providing CacheService to the host app. + * + * @example Synchronous registration + * ```typescript + * CacheModule.register({ store: 'memory', ttl: 60 }) + * CacheModule.register({ store: 'redis', ttl: 300, redis: { client: 'redis://localhost:6379' } }) + * ``` + * + * @example Async registration with ConfigService + * ```typescript + * CacheModule.registerAsync({ + * imports: [ConfigModule], + * inject: [ConfigService], + * useFactory: (cfg: ConfigService) => ({ + * store: cfg.get('CACHE_STORE'), + * ttl: cfg.get('CACHE_TTL'), + * redis: { client: cfg.get('REDIS_URL') }, + * }), + * }) + * ``` + */ +@Module({}) +export class CacheModule { + /** + * Register the module with synchronous, inline configuration. + * + * @param options - Cache configuration (store type, default TTL, redis options) + * @returns Configured DynamicModule + */ + static register(options: CacheModuleOptions): DynamicModule { + const providers: Provider[] = [ + // Expose the raw options object for injection (e.g. CacheService reads ttl from here) + { + provide: CACHE_MODULE_OPTIONS, + useValue: options, + }, + // Build and register the correct adapter under the CACHE_STORE token + { + provide: CACHE_STORE, + useValue: createStoreFromOptions(options), + }, + // The main service consumers will inject + CacheService, + ]; + + return { + module: CacheModule, + providers, + // Export CacheService so the importing module's children can use it + exports: [CacheService, CACHE_STORE], + }; + } + + /** + * Register the module with asynchronous configuration — useful when options + * must come from ConfigService, environment variables resolved at runtime, etc. + * + * Supports useFactory, useClass, and useExisting patterns. + * + * @param options - Async configuration options + * @returns Configured DynamicModule + */ + static registerAsync(options: CacheModuleAsyncOptions): DynamicModule { + // Build CACHE_MODULE_OPTIONS + CACHE_STORE providers depending on async pattern used + const asyncProviders = createAsyncProviders(options); + + return { + module: CacheModule, + // Import any modules required by the factory (e.g. ConfigModule) + imports: options.imports ?? [], + providers: [ + ...asyncProviders, + CacheService, + ], + exports: [CacheService, CACHE_STORE], + }; + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..4372bac --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,38 @@ +/** + * @file constants.ts + * + * NestJS dependency-injection tokens used throughout the CacheKit module. + * + * Exporting tokens from this file lets both the module wiring and any + * consumer code reference the same string without risk of typos. + * + * Exports: + * - CACHE_STORE → token for the ICacheStore adapter provider + * - CACHE_MODULE_OPTIONS → token for the CacheModuleOptions configuration provider + */ + +/** + * DI token for the active ICacheStore adapter. + * + * The module registers whichever adapter was selected (Redis or InMemory) + * under this token so CacheService can inject it without knowing the concrete type. + * + * @example + * ```typescript + * @Inject(CACHE_STORE) private readonly store: ICacheStore + * ``` + */ +export const CACHE_STORE = "CACHE_STORE" as const; + +/** + * DI token for the CacheModuleOptions configuration object. + * + * CacheService uses this to read the default TTL when the caller does not + * supply a per-call TTL. + * + * @example + * ```typescript + * @Inject(CACHE_MODULE_OPTIONS) private readonly options: CacheModuleOptions + * ``` + */ +export const CACHE_MODULE_OPTIONS = "CACHE_MODULE_OPTIONS" as const; diff --git a/src/index.ts b/src/index.ts index ad890ee..3ff9c23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,14 +10,24 @@ import "reflect-metadata"; // ============================================================================ // MODULE // ============================================================================ -export { ExampleKitModule } from "./example-kit.module"; -export type { ExampleKitOptions, ExampleKitAsyncOptions } from "./example-kit.module"; +// CacheModule — the main dynamic module consumers import into their AppModule. +// Supports both synchronous (register) and asynchronous (registerAsync) setup. +export { CacheModule } from "./cache-kit.module"; +export type { CacheModuleOptions, CacheModuleAsyncOptions } from "./cache-kit.module"; + +// ============================================================================ +// DI TOKENS +// ============================================================================ +// Exported so consumers can inject the raw ICacheStore directly if needed, +// or reference CACHE_STORE in their own provider definitions. +export { CACHE_STORE, CACHE_MODULE_OPTIONS } from "./constants"; // ============================================================================ // SERVICES (Main API) // ============================================================================ -// Export services that consumers will interact with -export { ExampleService } from "./services/example.service"; +// CacheService is the primary interface consumers interact with. +// Inject it anywhere via constructor injection. +export { CacheService } from "./services/cache.service"; // ============================================================================ // DTOs (Public Contracts) diff --git a/src/services/cache.service.ts b/src/services/cache.service.ts new file mode 100644 index 0000000..3157e27 --- /dev/null +++ b/src/services/cache.service.ts @@ -0,0 +1,189 @@ +/** + * @file cache.service.ts + * + * CacheService — the primary API that consumers inject into their NestJS services. + * + * Wraps the active ICacheStore adapter and adds: + * - Default TTL fall-through from module options + * - `has()` — existence check without deserialization overhead + * - `wrap()` — cache-aside pattern: return cached value or compute, store, and return it + * + * Exports: + * - CacheService → injectable NestJS service + */ + +import { Inject, Injectable } from "@nestjs/common"; + +import type { ICacheStore } from "@ports/cache-store.port"; + +import type { CacheModuleOptions } from "../cache-kit.module"; +import { CACHE_MODULE_OPTIONS, CACHE_STORE } from "../constants"; + +/** + * Injectable caching service. + * + * Inject this in your own services: + * ```typescript + * constructor(private readonly cache: CacheService) {} + * ``` + */ +@Injectable() +export class CacheService { + constructor( + /** The active store adapter (Redis or InMemory) registered under CACHE_STORE */ + @Inject(CACHE_STORE) + private readonly store: ICacheStore, + + /** Module-level options — used to read the default TTL */ + @Inject(CACHE_MODULE_OPTIONS) + private readonly options: CacheModuleOptions, + ) {} + + // --------------------------------------------------------------------------- + // Core operations + // --------------------------------------------------------------------------- + + /** + * Retrieve a value from the cache. + * + * Returns null when the key is missing, the entry is expired, + * or the stored value cannot be parsed. + * + * @param key - Cache key + * @returns The cached value, or null + * + * @example + * ```typescript + * const user = await this.cache.get('user:1'); + * ``` + */ + async get(key: string): Promise { + // Delegate entirely to the adapter — no extra logic here + return this.store.get(key); + } + + /** + * Store a value in the cache. + * + * The TTL resolution order is: + * 1. `ttlSeconds` argument (explicit per-call TTL) + * 2. `options.ttl` supplied to CacheModule.register() (module default) + * 3. No expiry (value lives until explicitly deleted or clear() is called) + * + * @param key - Cache key + * @param value - Any JSON-serializable value + * @param ttlSeconds - Optional per-call TTL; overrides the module default + * + * @example + * ```typescript + * await this.cache.set('user:1', user, 300); // 5-minute TTL + * ``` + */ + async set(key: string, value: T, ttlSeconds?: number): Promise { + // Use the per-call TTL when provided; fall back to the module-level default + const effectiveTtl = ttlSeconds ?? this.options.ttl; + return this.store.set(key, value, effectiveTtl); + } + + /** + * Remove a single entry from the cache. + * Silently succeeds if the key does not exist. + * + * @param key - Cache key to remove + * + * @example + * ```typescript + * await this.cache.delete('user:1'); + * ``` + */ + async delete(key: string): Promise { + return this.store.delete(key); + } + + /** + * Evict every entry from the cache. + * + * @example + * ```typescript + * await this.cache.clear(); + * ``` + */ + async clear(): Promise { + return this.store.clear(); + } + + // --------------------------------------------------------------------------- + // Convenience helpers + // --------------------------------------------------------------------------- + + /** + * Check whether a non-expired entry exists for the given key. + * + * Internally performs a full get() — the value is fetched and parsed but + * then discarded. For frequent hot-path checks consider caching the boolean + * result if the underlying store does not have a native EXISTS command. + * + * @param key - Cache key to check + * @returns true if the key exists and has not expired, false otherwise + * + * @example + * ```typescript + * if (await this.cache.has('rate-limit:user:1')) { ... } + * ``` + */ + async has(key: string): Promise { + // A null result from get() means "does not exist or is expired" + const value = await this.store.get(key); + return value !== null; + } + + /** + * Cache-aside helper: return the cached value if it exists, + * otherwise call `fn`, persist its result, and return it. + * + * This is the recommended way to lazily populate the cache: + * ``` + * cached? ──yes──▶ return cached value + * │ + * no + * │ + * call fn() ──▶ store result ──▶ return result + * ``` + * + * TTL resolution is the same as set(): + * 1. `ttlSeconds` argument + * 2. Module-level default (`options.ttl`) + * 3. No expiry + * + * @param key - Cache key + * @param fn - Async factory that produces the value on a cache miss + * @param ttlSeconds - Optional per-call TTL; overrides the module default + * @returns The cached or freshly computed value + * + * @example + * ```typescript + * const user = await this.cache.wrap( + * `user:${id}`, + * () => this.userRepository.findById(id), + * 60, + * ); + * ``` + */ + async wrap(key: string, fn: () => Promise, ttlSeconds?: number): Promise { + // Step 1: Try the cache first + const cached = await this.store.get(key); + + // Cache hit — return the stored value without calling fn() + if (cached !== null) return cached; + + // Cache miss — compute the fresh value by executing the factory function + const fresh = await fn(); + + // Persist the result so the next caller hits the cache + // Use the per-call TTL when provided; fall back to the module-level default + const effectiveTtl = ttlSeconds ?? this.options.ttl; + await this.store.set(key, fresh, effectiveTtl); + + return fresh; + } +} From 56a19a1fb47ccdb71d1385bc72536f36fe65ead9 Mon Sep 17 00:00:00 2001 From: yasser Date: Wed, 1 Apr 2026 09:48:54 +0100 Subject: [PATCH 3/6] style: fix Prettier formatting across all files --- src/adapters/in-memory-cache-store.adapter.ts | 4 +--- src/adapters/redis-cache-store.adapter.ts | 3 +-- src/cache-kit.module.ts | 16 ++++++---------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/adapters/in-memory-cache-store.adapter.ts b/src/adapters/in-memory-cache-store.adapter.ts index e78d36c..77cc5d9 100644 --- a/src/adapters/in-memory-cache-store.adapter.ts +++ b/src/adapters/in-memory-cache-store.adapter.ts @@ -95,9 +95,7 @@ export class InMemoryCacheStore implements ICacheStore { // Multiply seconds by 1 000 to convert to milliseconds for Date.now() comparison. // null signals "no expiry" so the entry lives until deleted or clear() is called. const expiresAt = - ttlSeconds !== undefined && ttlSeconds > 0 - ? Date.now() + ttlSeconds * 1_000 - : null; + ttlSeconds !== undefined && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1_000 : null; // Serialize the value to a JSON string before storing to match Redis adapter behaviour this.store.set(key, { diff --git a/src/adapters/redis-cache-store.adapter.ts b/src/adapters/redis-cache-store.adapter.ts index f884015..712071c 100644 --- a/src/adapters/redis-cache-store.adapter.ts +++ b/src/adapters/redis-cache-store.adapter.ts @@ -70,8 +70,7 @@ export class RedisCacheStore implements ICacheStore { constructor(options: RedisCacheStoreOptions) { // Accept either an existing ioredis client or a plain connection URL string. // When a URL is provided we create a new dedicated client instance. - this.redis = - typeof options.client === "string" ? new Redis(options.client) : options.client; + this.redis = typeof options.client === "string" ? new Redis(options.client) : options.client; // Fall back to an empty string so buildKey() can skip the prefix logic. this.keyPrefix = options.keyPrefix ?? ""; diff --git a/src/cache-kit.module.ts b/src/cache-kit.module.ts index f49472c..804f371 100644 --- a/src/cache-kit.module.ts +++ b/src/cache-kit.module.ts @@ -59,9 +59,7 @@ export interface CacheModuleOptions { * Factory function type used by registerAsync's useFactory. * May return the options synchronously or as a Promise. */ -export type CacheModuleOptionsFactory = () => - | Promise - | CacheModuleOptions; +export type CacheModuleOptionsFactory = () => Promise | CacheModuleOptions; /** * Asynchronous configuration options for CacheModule.registerAsync(). @@ -111,7 +109,7 @@ function createStoreFromOptions(options: CacheModuleOptions): ICacheStore { if (!options.redis) { throw new Error( '[CacheModule] store is "redis" but no redis options were provided. ' + - 'Pass a `redis` field to CacheModule.register() or CacheModule.registerAsync().', + "Pass a `redis` field to CacheModule.register() or CacheModule.registerAsync().", ); } // Delegate all Redis connection and key-prefix logic to the adapter @@ -163,8 +161,9 @@ function createAsyncProviders(options: CacheModuleAsyncOptions): Provider[] { { // Call createCacheOptions() on the factory instance to get the options provide: CACHE_MODULE_OPTIONS, - useFactory: (factory: { createCacheOptions(): Promise | CacheModuleOptions }) => - factory.createCacheOptions(), + useFactory: (factory: { + createCacheOptions(): Promise | CacheModuleOptions; + }) => factory.createCacheOptions(), inject: [factoryClass], }, { @@ -252,10 +251,7 @@ export class CacheModule { module: CacheModule, // Import any modules required by the factory (e.g. ConfigModule) imports: options.imports ?? [], - providers: [ - ...asyncProviders, - CacheService, - ], + providers: [...asyncProviders, CacheService], exports: [CacheService, CACHE_STORE], }; } From d9e22e9c70abfc1d65610b577dc529c4f9229f99 Mon Sep 17 00:00:00 2001 From: yasser Date: Wed, 1 Apr 2026 09:55:44 +0100 Subject: [PATCH 4/6] style: fix Prettier formatting after develop merge --- src/adapters/in-memory-cache-store.adapter.ts | 4 +--- src/adapters/redis-cache-store.adapter.ts | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/adapters/in-memory-cache-store.adapter.ts b/src/adapters/in-memory-cache-store.adapter.ts index e78d36c..77cc5d9 100644 --- a/src/adapters/in-memory-cache-store.adapter.ts +++ b/src/adapters/in-memory-cache-store.adapter.ts @@ -95,9 +95,7 @@ export class InMemoryCacheStore implements ICacheStore { // Multiply seconds by 1 000 to convert to milliseconds for Date.now() comparison. // null signals "no expiry" so the entry lives until deleted or clear() is called. const expiresAt = - ttlSeconds !== undefined && ttlSeconds > 0 - ? Date.now() + ttlSeconds * 1_000 - : null; + ttlSeconds !== undefined && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1_000 : null; // Serialize the value to a JSON string before storing to match Redis adapter behaviour this.store.set(key, { diff --git a/src/adapters/redis-cache-store.adapter.ts b/src/adapters/redis-cache-store.adapter.ts index f884015..712071c 100644 --- a/src/adapters/redis-cache-store.adapter.ts +++ b/src/adapters/redis-cache-store.adapter.ts @@ -70,8 +70,7 @@ export class RedisCacheStore implements ICacheStore { constructor(options: RedisCacheStoreOptions) { // Accept either an existing ioredis client or a plain connection URL string. // When a URL is provided we create a new dedicated client instance. - this.redis = - typeof options.client === "string" ? new Redis(options.client) : options.client; + this.redis = typeof options.client === "string" ? new Redis(options.client) : options.client; // Fall back to an empty string so buildKey() can skip the prefix logic. this.keyPrefix = options.keyPrefix ?? ""; From e15cb01bf4a3ac63541700ce865e9b398199917a Mon Sep 17 00:00:00 2001 From: yasser Date: Wed, 1 Apr 2026 10:08:26 +0100 Subject: [PATCH 5/6] fix(lint): fix import order and replace any types with proper NestJS types --- src/adapters/redis-cache-store.adapter.ts | 3 +-- src/cache-kit.module.ts | 17 ++++++++++++----- src/services/cache.service.ts | 1 - 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/adapters/redis-cache-store.adapter.ts b/src/adapters/redis-cache-store.adapter.ts index 712071c..36fb2c6 100644 --- a/src/adapters/redis-cache-store.adapter.ts +++ b/src/adapters/redis-cache-store.adapter.ts @@ -16,9 +16,8 @@ * - RedisCacheStore → the concrete Redis adapter class */ -import Redis from "ioredis"; - import type { ICacheStore } from "@ports/cache-store.port"; +import Redis from "ioredis"; // --------------------------------------------------------------------------- // Configuration diff --git a/src/cache-kit.module.ts b/src/cache-kit.module.ts index 804f371..c1fec22 100644 --- a/src/cache-kit.module.ts +++ b/src/cache-kit.module.ts @@ -16,11 +16,18 @@ * - CacheModule → the NestJS dynamic module class */ -import { DynamicModule, Module, Provider, Type } from "@nestjs/common"; - import { InMemoryCacheStore } from "@adapters/in-memory-cache-store.adapter"; import { RedisCacheStore } from "@adapters/redis-cache-store.adapter"; import type { RedisCacheStoreOptions } from "@adapters/redis-cache-store.adapter"; +import { + DynamicModule, + type InjectionToken, + Module, + type ModuleMetadata, + type OptionalFactoryDependency, + Provider, + Type, +} from "@nestjs/common"; import type { ICacheStore } from "@ports/cache-store.port"; import { CACHE_MODULE_OPTIONS, CACHE_STORE } from "./constants"; @@ -70,10 +77,10 @@ export type CacheModuleOptionsFactory = () => Promise | Cach */ export interface CacheModuleAsyncOptions { /** Providers whose tokens are passed as arguments to useFactory. */ - inject?: any[]; + inject?: Array; /** Inline factory that resolves to CacheModuleOptions. */ - useFactory?: (...args: any[]) => Promise | CacheModuleOptions; + useFactory?: (...args: unknown[]) => Promise | CacheModuleOptions; /** * Class that the module will instantiate to obtain the options. @@ -88,7 +95,7 @@ export interface CacheModuleAsyncOptions { useExisting?: Type<{ createCacheOptions(): Promise | CacheModuleOptions }>; /** Additional NestJS modules to import into the async provider scope. */ - imports?: any[]; + imports?: ModuleMetadata["imports"]; } // --------------------------------------------------------------------------- diff --git a/src/services/cache.service.ts b/src/services/cache.service.ts index 3157e27..1927827 100644 --- a/src/services/cache.service.ts +++ b/src/services/cache.service.ts @@ -13,7 +13,6 @@ */ import { Inject, Injectable } from "@nestjs/common"; - import type { ICacheStore } from "@ports/cache-store.port"; import type { CacheModuleOptions } from "../cache-kit.module"; From 5886a6722ab0b35c3fab5134af42a5c94b4ae98d Mon Sep 17 00:00:00 2001 From: yasser Date: Thu, 2 Apr 2026 09:33:38 +0100 Subject: [PATCH 6/6] feat(COMPT-57): add @Cacheable and @CacheEvict decorators with key interpolation --- src/cache-kit.module.ts | 26 +++++++- src/decorators/cache-evict.decorator.ts | 76 ++++++++++++++++++++++ src/decorators/cacheable.decorator.ts | 85 +++++++++++++++++++++++++ src/index.ts | 11 ++++ src/utils/cache-service-ref.ts | 72 +++++++++++++++++++++ src/utils/resolve-cache-key.util.ts | 40 ++++++++++++ tsconfig.json | 3 +- 7 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 src/decorators/cache-evict.decorator.ts create mode 100644 src/decorators/cacheable.decorator.ts create mode 100644 src/utils/cache-service-ref.ts create mode 100644 src/utils/resolve-cache-key.util.ts 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"],