Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/adapters/redis-cache-store.adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
*
* Uses ioredis-mock so no real Redis server is required. The mock exposes the
* same API surface as real ioredis, meaning all RedisCacheStore code paths
* (get, set+EX, del, keys+del, flushdb) are exercised against in-memory state.
* (get, set+EX, del, scan+del, flushdb) are exercised against in-memory state.
*
* Tests cover:
* - Full ICacheStore contract: get, set (with and without TTL), delete, clear
* - Key prefix namespacing: keys are stored and retrieved with the prefix
* - clear() with prefix: only prefixed keys are removed
* - clear() with prefix: only prefixed keys are removed via cursor-based SCAN
* - clear() without prefix: full flushdb is called
* - Parse-error resilience: malformed JSON stored in Redis returns null
* - Constructor accepts both a URL string and an existing Redis instance
Expand Down Expand Up @@ -154,9 +154,20 @@ describe("RedisCacheStore", () => {
expect(await redis.get(`${PREFIX}:key`)).toBeNull();
});

// ── clear (with prefix → keys + del) ────────────────────────────────
// ── clear (with prefix → scan + del) ────────────────────────────────

describe("clear() with prefix", () => {
it("uses SCAN (not KEYS) to iterate matching keys", async () => {
// SCAN must be used instead of the blocking KEYS command
const scanSpy = jest.spyOn(redis, "scan");
await redis.set(`${PREFIX}:x`, "1");

await store.clear();

// At least one SCAN call must have been made
expect(scanSpy).toHaveBeenCalled();
});

it("removes only keys matching the prefix pattern", async () => {
// Populate both prefixed and non-prefixed keys
await redis.set(`${PREFIX}:x`, "1");
Expand Down
30 changes: 20 additions & 10 deletions src/adapters/redis-cache-store.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* - 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;
* - clear() only removes keys that belong to this adapter's prefix via
* cursor-based SCAN (non-blocking, safe for large key sets);
* without a prefix it flushes the entire Redis database (FLUSHDB).
*
* Exports:
Expand Down Expand Up @@ -137,15 +138,24 @@ export class RedisCacheStore implements ICacheStore {
/** {@inheritDoc ICacheStore.clear} */
async clear(): Promise<void> {
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);
}
// Use cursor-based SCAN instead of KEYS to avoid blocking Redis while
// iterating large key sets. SCAN is O(1) per call (amortised O(N) total)
// and yields control back to Redis between iterations.
const pattern = `${this.keyPrefix}:*`;
let cursor = "0";

do {
// Each SCAN call returns [nextCursor, matchedKeys].
// COUNT is a hint to Redis — it may return more or fewer per call.
const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
cursor = nextCursor;

// Delete the batch found in this iteration immediately to keep memory
// usage flat regardless of total key count.
if (keys.length > 0) {
await this.redis.del(...keys);
}
} while (cursor !== "0");
} else {
// No prefix — flush every key in the currently selected Redis database
await this.redis.flushdb();
Expand Down
34 changes: 31 additions & 3 deletions src/decorators/cacheable.decorator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
* - Cache hit: original method NOT called on second invocation
* - Cache miss: original method called and result persisted on first call
* - Key template interpolation: "user:{0}" with arg "42" → "user:42"
* - Optional TTL forwarded to the store
* - Optional TTL forwarded to the store (value wrapped in envelope)
* - Null return value is correctly cached (not treated as a permanent miss)
* - Works on async methods
* - Works on sync methods
*/
Expand Down Expand Up @@ -149,8 +150,35 @@ describe("@Cacheable decorator", () => {
}

await new DataService().fetch();
// The explicit 60-second TTL must have been passed to set()
expect(setSpy).toHaveBeenCalledWith("data-key", "data", 60);
// The value is wrapped in an envelope before being passed to set();
// the explicit 60-second TTL must be forwarded unchanged.
expect(setSpy).toHaveBeenCalledWith("data-key", { __v: "data" }, 60);
});

// ── Null return value ──────────────────────────────────────────────────────

it("correctly caches a null return value (does not re-call the method on the next invocation)", async () => {
// Methods that return null are a real edge case: without the internal
// envelope, null from get() looks identical to a cache miss, so the
// original method would be called on every invocation.
const impl = jest.fn().mockResolvedValue(null);

class UserService {
@Cacheable("user:null-case")
async findDeleted() {
return impl();
}
}

const svc = new UserService();
const first = await svc.findDeleted(); // miss — caches null
const second = await svc.findDeleted(); // must be a hit, NOT a miss

// The original method should only have been called once
expect(impl).toHaveBeenCalledTimes(1);
// Both calls must return null
expect(first).toBeNull();
expect(second).toBeNull();
});

// ── Sync method support ───────────────────────────────────────────────────
Expand Down
51 changes: 46 additions & 5 deletions src/decorators/cacheable.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,37 @@
import { CacheServiceRef } from "@utils/cache-service-ref";
import { resolveCacheKey } from "@utils/resolve-cache-key.util";

// ---------------------------------------------------------------------------
// Internal cache envelope
// ---------------------------------------------------------------------------

/**
* Thin wrapper stored under every cache key written by @Cacheable.
*
* Why this is needed:
* ICacheStore.get() returns null for both a cache miss AND a stored null value.
* Without an envelope, a method that legitimately returns null would never
* benefit from caching — every call would look like a miss and re-execute
* the original method.
*
* By wrapping the return value in `{ __v: result }` before writing, and
* unwrapping after reading, the decorator can tell "cached null" (envelope
* present, __v is null) from "cache miss" (get() returned null itself).
*
* This type is intentionally NOT exported — it is an internal implementation
* detail. Consumers should not read @Cacheable keys via CacheService.get()
* directly, as they would receive the raw envelope object.
*/
interface CacheEnvelope {
/** The actual cached return value — may be null or undefined */
readonly __v: unknown;
}
Comment on lines +45 to +48
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CacheEnvelope claims it can preserve undefined, but JSON serialization drops undefined object properties (e.g., { __v: undefined } becomes {}), so methods returning undefined will never actually be cached and will re-execute on every call. Either remove the undefined claim from docs/types, or change the envelope format to include a dedicated marker field so an "envelope with undefined" can round-trip reliably.

Copilot uses AI. Check for mistakes.

/** Type guard — true when `v` is a CacheEnvelope written by @Cacheable */
function isEnvelope(v: unknown): v is CacheEnvelope {
return typeof v === "object" && v !== null && "__v" in v;
}
Comment on lines +45 to +53
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The envelope detection is too permissive: isEnvelope() treats any cached object with a __v property as an envelope. This can break existing caches (and manual cache.set usage) where the cached value is an object containing __v (e.g., Mongoose documents), causing the decorator to return the inner __v field instead of the full object. Consider using an unambiguous marker (e.g., __cachekit: 1 + value) and/or a stricter guard (e.g., require a marker and hasOwnProperty) so non-envelope objects are never misclassified.

Copilot uses AI. Check for mistakes.

/**
* Cache-aside method decorator.
*
Expand All @@ -46,6 +77,12 @@ import { resolveCacheKey } from "@utils/resolve-cache-key.util";
* @Cacheable("config")
* async getConfig(): Promise<Config> { ... }
* ```
*
* @remarks
* Methods that return `null` are correctly cached. The decorator uses an
* internal envelope so a cached `null` is distinguishable from a cache miss.
* Do **not** read keys written by `@Cacheable` via `CacheService.get()` directly
* — you will receive the raw envelope object `{ __v: value }` instead of the value.
*/
Comment on lines +81 to 86
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing { __v: result } changes the on-disk/on-Redis value shape for keys written by @Cacheable, and the new JSDoc explicitly warns that CacheService.get() will now return the raw envelope object for those keys. That’s a breaking change in how @Cacheable interoperates with the library’s primary public API (CacheService). Consider keeping the external value shape stable (e.g., by namespacing decorator keys, or by unwrapping the envelope transparently in CacheService.get()/wrap() when a Cacheable-envelope marker is detected), and document the migration/compatibility story for existing cached entries.

Copilot uses AI. Check for mistakes.
export function Cacheable(key: string, ttlSeconds?: number): MethodDecorator {
return (
Expand All @@ -65,17 +102,21 @@ export function Cacheable(key: string, ttlSeconds?: number): MethodDecorator {
const resolvedKey = resolveCacheKey(key, args);

// ── Cache hit ──────────────────────────────────────────────────────
// Return the stored value immediately without calling the original method
const cached = await cacheService.get<unknown>(resolvedKey);
if (cached !== null) return cached;
// Read the envelope. null from get() means cache miss; a present envelope
// means hit — even when the stored return value was null or undefined.
const envelope = await cacheService.get<CacheEnvelope>(resolvedKey);
if (envelope !== null && isEnvelope(envelope)) {
return envelope.__v;
}

// ── 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);
// Wrap in an envelope before storing so null/undefined returns are
// preserved and distinguishable from a cache miss on the next call.
await cacheService.set<CacheEnvelope>(resolvedKey, { __v: result }, ttlSeconds);

return result;
};
Expand Down
20 changes: 0 additions & 20 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,6 @@ export { CACHE_STORE, CACHE_MODULE_OPTIONS } from "./constants";
// Inject it anywhere via constructor injection.
export { CacheService } from "./services/cache.service";

// ============================================================================
// DTOs (Public Contracts)
// ============================================================================
// DTOs are the public interface for your API
// Consumers depend on these, so they must be stable
export { CreateExampleDto } from "./dto/create-example.dto";
export { UpdateExampleDto } from "./dto/update-example.dto";

// ============================================================================
// GUARDS (For Route Protection)
// ============================================================================
// Export guards so consumers can use them in their apps
export { ExampleGuard } from "./guards/example.guard";

// ============================================================================
// DECORATORS (For Dependency Injection & Metadata)
// ============================================================================
// Export decorators for use in consumer controllers/services
export { ExampleData, ExampleParam } from "./decorators/example.decorator";

// ============================================================================
// DECORATORS
// ============================================================================
Expand Down
Loading