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"],