diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 2f2063be..9db7ec01 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -11,6 +11,23 @@ import { import { Hookified } from "hookified"; import { DoublyLinkedList } from "./memory-lru.js"; +export enum CacheableMemoryHooks { + BEFORE_SET = "BEFORE_SET", + AFTER_SET = "AFTER_SET", + BEFORE_SET_MANY = "BEFORE_SET_MANY", + AFTER_SET_MANY = "AFTER_SET_MANY", + BEFORE_GET = "BEFORE_GET", + AFTER_GET = "AFTER_GET", + BEFORE_GET_MANY = "BEFORE_GET_MANY", + AFTER_GET_MANY = "AFTER_GET_MANY", + BEFORE_DELETE = "BEFORE_DELETE", + AFTER_DELETE = "AFTER_DELETE", + BEFORE_DELETE_MANY = "BEFORE_DELETE_MANY", + AFTER_DELETE_MANY = "AFTER_DELETE_MANY", + BEFORE_CLEAR = "BEFORE_CLEAR", + AFTER_CLEAR = "AFTER_CLEAR", +} + export type StoreHashAlgorithmFunction = ( key: string, storeHashSize: number, @@ -303,25 +320,32 @@ export class CacheableMemory extends Hookified { * @returns {T | undefined} - The value of the key */ public get(key: string): T | undefined { + this.hookSync(CacheableMemoryHooks.BEFORE_GET, key); const store = this.getStore(key); const item = store.get(key); if (!item) { + this.hookSync(CacheableMemoryHooks.AFTER_GET, { key, result: undefined }); return undefined; } if (item.expires && Date.now() > item.expires) { store.delete(key); this.lruRemove(key); + this.hookSync(CacheableMemoryHooks.AFTER_GET, { key, result: undefined }); return undefined; } this.lruMoveToFront(key); + let result: T; if (!this._useClone) { - return item.value as T; + result = item.value as T; + } else { + result = this.clone(item.value) as T; } - return this.clone(item.value) as T; + this.hookSync(CacheableMemoryHooks.AFTER_GET, { key, result }); + return result; } /** @@ -330,11 +354,13 @@ export class CacheableMemory extends Hookified { * @returns {T[]} - The values of the keys */ public getMany(keys: string[]): T[] { + this.hookSync(CacheableMemoryHooks.BEFORE_GET_MANY, keys); const result: T[] = []; for (const key of keys) { result.push(this.get(key) as T); } + this.hookSync(CacheableMemoryHooks.AFTER_GET_MANY, { keys, result }); return result; } @@ -389,25 +415,31 @@ export class CacheableMemory extends Hookified { value: any, ttl?: number | string | SetOptions, ): void { - const store = this.getStore(key); + const hookItem = { key, value, ttl }; + this.hookSync(CacheableMemoryHooks.BEFORE_SET, hookItem); + + const store = this.getStore(hookItem.key); // biome-ignore lint/suspicious/noImplicitAnyLet: allowed let expires; - if (ttl !== undefined || this._ttl !== undefined) { - if (typeof ttl === "object") { - if (ttl.expire) { + const effectiveTtl = hookItem.ttl; + if (effectiveTtl !== undefined || this._ttl !== undefined) { + if (typeof effectiveTtl === "object") { + if (effectiveTtl.expire) { expires = - typeof ttl.expire === "number" ? ttl.expire : ttl.expire.getTime(); + typeof effectiveTtl.expire === "number" + ? effectiveTtl.expire + : effectiveTtl.expire.getTime(); } - if (ttl.ttl) { - const finalTtl = shorthandToTime(ttl.ttl); + if (effectiveTtl.ttl) { + const finalTtl = shorthandToTime(effectiveTtl.ttl); /* v8 ignore next -- @preserve */ if (finalTtl !== undefined) { expires = finalTtl; } } } else { - const finalTtl = shorthandToTime(ttl ?? this._ttl); + const finalTtl = shorthandToTime(effectiveTtl ?? this._ttl); /* v8 ignore next -- @preserve */ if (finalTtl !== undefined) { @@ -417,10 +449,10 @@ export class CacheableMemory extends Hookified { } if (this._lruSize > 0) { - if (store.has(key)) { - this.lruMoveToFront(key); + if (store.has(hookItem.key)) { + this.lruMoveToFront(hookItem.key); } else { - this.lruAddToFront(key); + this.lruAddToFront(hookItem.key); if (this._lru.size > this._lruSize) { const oldestKey = this._lru.getOldest(); /* v8 ignore next -- @preserve */ @@ -432,8 +464,10 @@ export class CacheableMemory extends Hookified { } } - const item = { key, value, expires }; - store.set(key, item); + const item = { key: hookItem.key, value: hookItem.value, expires }; + store.set(hookItem.key, item); + + this.hookSync(CacheableMemoryHooks.AFTER_SET, hookItem); } /** @@ -442,9 +476,12 @@ export class CacheableMemory extends Hookified { * @returns {void} */ public setMany(items: CacheableItem[]): void { + this.hookSync(CacheableMemoryHooks.BEFORE_SET_MANY, items); for (const item of items) { this.set(item.key, item.value, item.ttl); } + + this.hookSync(CacheableMemoryHooks.AFTER_SET_MANY, items); } /** @@ -508,9 +545,11 @@ export class CacheableMemory extends Hookified { * @returns {void} */ public delete(key: string): void { + this.hookSync(CacheableMemoryHooks.BEFORE_DELETE, key); const store = this.getStore(key); store.delete(key); this.lruRemove(key); + this.hookSync(CacheableMemoryHooks.AFTER_DELETE, key); } /** @@ -519,9 +558,12 @@ export class CacheableMemory extends Hookified { * @returns {void} */ public deleteMany(keys: string[]): void { + this.hookSync(CacheableMemoryHooks.BEFORE_DELETE_MANY, keys); for (const key of keys) { this.delete(key); } + + this.hookSync(CacheableMemoryHooks.AFTER_DELETE_MANY, keys); } /** @@ -529,11 +571,13 @@ export class CacheableMemory extends Hookified { * @returns {void} */ public clear(): void { + this.hookSync(CacheableMemoryHooks.BEFORE_CLEAR); this._store = Array.from( { length: this._storeHashSize }, () => new Map(), ); this._lru = new DoublyLinkedList(); + this.hookSync(CacheableMemoryHooks.AFTER_CLEAR); } /** diff --git a/packages/memory/test/index.test.ts b/packages/memory/test/index.test.ts index e6b520d3..16dca1f3 100644 --- a/packages/memory/test/index.test.ts +++ b/packages/memory/test/index.test.ts @@ -1,7 +1,7 @@ import { createWrapKey, HashAlgorithm, sleep } from "@cacheable/utils"; import { faker } from "@faker-js/faker"; import { describe, expect, test } from "vitest"; -import { CacheableMemory } from "../src/index.js"; +import { CacheableMemory, CacheableMemoryHooks } from "../src/index.js"; const cacheItemList = [ { key: "key", value: "value" }, @@ -901,3 +901,182 @@ describe("CacheableMemory LRU and TTL integration", () => { expect(cache.get("keep")).toBe("value2"); }); }); + +describe("CacheableMemory Hooks", () => { + test("should handle BEFORE_SET and AFTER_SET hooks", () => { + const cache = new CacheableMemory(); + let beforeSet = false; + let afterSet = false; + cache.onHook(CacheableMemoryHooks.BEFORE_SET, (item) => { + beforeSet = true; + item.value = "new value"; + }); + cache.onHook(CacheableMemoryHooks.AFTER_SET, (item) => { + afterSet = true; + expect(item.value).toEqual("new value"); + }); + cache.set("key", "value"); + expect(beforeSet).toBe(true); + expect(afterSet).toBe(true); + expect(cache.get("key")).toEqual("new value"); + }); + + test("should allow BEFORE_SET hook to modify ttl", () => { + const cache = new CacheableMemory(); + cache.onHook(CacheableMemoryHooks.BEFORE_SET, (item) => { + item.ttl = "1h"; + }); + cache.set("key", "value"); + const raw = cache.getRaw("key"); + expect(raw).toBeDefined(); + expect(raw?.expires).toBeGreaterThan(Date.now()); + }); + + test("should handle BEFORE_SET_MANY and AFTER_SET_MANY hooks", () => { + const cache = new CacheableMemory(); + let beforeSetMany = false; + let afterSetMany = false; + cache.onHook(CacheableMemoryHooks.BEFORE_SET_MANY, (items) => { + beforeSetMany = true; + expect(items).toHaveLength(2); + }); + cache.onHook(CacheableMemoryHooks.AFTER_SET_MANY, (items) => { + afterSetMany = true; + expect(items).toHaveLength(2); + }); + cache.setMany([ + { key: "key1", value: "value1" }, + { key: "key2", value: "value2" }, + ]); + expect(beforeSetMany).toBe(true); + expect(afterSetMany).toBe(true); + }); + + test("should handle BEFORE_GET and AFTER_GET hooks", () => { + const cache = new CacheableMemory(); + let beforeGet = false; + let afterGet = false; + cache.onHook(CacheableMemoryHooks.BEFORE_GET, (key) => { + beforeGet = true; + expect(key).toEqual("key"); + }); + cache.onHook(CacheableMemoryHooks.AFTER_GET, (item) => { + afterGet = true; + expect(item.key).toEqual("key"); + expect(item.result).toEqual("value"); + }); + cache.set("key", "value"); + cache.get("key"); + expect(beforeGet).toBe(true); + expect(afterGet).toBe(true); + }); + + test("should handle AFTER_GET hook with undefined result on cache miss", () => { + const cache = new CacheableMemory(); + let afterGet = false; + cache.onHook(CacheableMemoryHooks.AFTER_GET, (item) => { + afterGet = true; + expect(item.key).toEqual("missing"); + expect(item.result).toBeUndefined(); + }); + cache.get("missing"); + expect(afterGet).toBe(true); + }); + + test("should handle BEFORE_GET_MANY and AFTER_GET_MANY hooks", () => { + const cache = new CacheableMemory(); + let beforeGetMany = false; + let afterGetMany = false; + cache.onHook(CacheableMemoryHooks.BEFORE_GET_MANY, (keys) => { + beforeGetMany = true; + expect(keys).toEqual(["key1", "key2"]); + }); + cache.onHook(CacheableMemoryHooks.AFTER_GET_MANY, (data) => { + afterGetMany = true; + expect(data.keys).toEqual(["key1", "key2"]); + expect(data.result).toEqual(["value1", "value2"]); + }); + cache.set("key1", "value1"); + cache.set("key2", "value2"); + cache.getMany(["key1", "key2"]); + expect(beforeGetMany).toBe(true); + expect(afterGetMany).toBe(true); + }); + + test("should handle BEFORE_DELETE and AFTER_DELETE hooks", () => { + const cache = new CacheableMemory(); + let beforeDelete = false; + let afterDelete = false; + cache.onHook(CacheableMemoryHooks.BEFORE_DELETE, (key) => { + beforeDelete = true; + expect(key).toEqual("key"); + }); + cache.onHook(CacheableMemoryHooks.AFTER_DELETE, (key) => { + afterDelete = true; + expect(key).toEqual("key"); + }); + cache.set("key", "value"); + cache.delete("key"); + expect(beforeDelete).toBe(true); + expect(afterDelete).toBe(true); + expect(cache.get("key")).toBeUndefined(); + }); + + test("should handle BEFORE_DELETE_MANY and AFTER_DELETE_MANY hooks", () => { + const cache = new CacheableMemory(); + let beforeDeleteMany = false; + let afterDeleteMany = false; + cache.onHook(CacheableMemoryHooks.BEFORE_DELETE_MANY, (keys) => { + beforeDeleteMany = true; + expect(keys).toEqual(["key1", "key2"]); + }); + cache.onHook(CacheableMemoryHooks.AFTER_DELETE_MANY, (keys) => { + afterDeleteMany = true; + expect(keys).toEqual(["key1", "key2"]); + }); + cache.set("key1", "value1"); + cache.set("key2", "value2"); + cache.deleteMany(["key1", "key2"]); + expect(beforeDeleteMany).toBe(true); + expect(afterDeleteMany).toBe(true); + }); + + test("should handle BEFORE_CLEAR and AFTER_CLEAR hooks", () => { + const cache = new CacheableMemory(); + let beforeClear = false; + let afterClear = false; + cache.onHook(CacheableMemoryHooks.BEFORE_CLEAR, () => { + beforeClear = true; + }); + cache.onHook(CacheableMemoryHooks.AFTER_CLEAR, () => { + afterClear = true; + }); + cache.set("key", "value"); + cache.clear(); + expect(beforeClear).toBe(true); + expect(afterClear).toBe(true); + expect(cache.size).toBe(0); + }); + + test("should handle BEFORE_SET hook modifying the key", () => { + const cache = new CacheableMemory(); + cache.onHook(CacheableMemoryHooks.BEFORE_SET, (item) => { + item.key = `prefix:${item.key}`; + }); + cache.set("key", "value"); + expect(cache.get("key")).toBeUndefined(); + expect(cache.get("prefix:key")).toEqual("value"); + }); + + test("should handle AFTER_GET with expired item", async () => { + const cache = new CacheableMemory(); + let afterGetResult: unknown; + cache.onHook(CacheableMemoryHooks.AFTER_GET, (item) => { + afterGetResult = item.result; + }); + cache.set("key", "value", 5); + await sleep(20); + cache.get("key"); + expect(afterGetResult).toBeUndefined(); + }); +});