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..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 @@ -70,8 +69,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 new file mode 100644 index 0000000..c1fec22 --- /dev/null +++ b/src/cache-kit.module.ts @@ -0,0 +1,265 @@ +/** + * @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 { 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"; +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?: Array; + + /** Inline factory that resolves to CacheModuleOptions. */ + useFactory?: (...args: unknown[]) => 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?: ModuleMetadata["imports"]; +} + +// --------------------------------------------------------------------------- +// 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..1927827 --- /dev/null +++ b/src/services/cache.service.ts @@ -0,0 +1,188 @@ +/** + * @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; + } +}