From fa651813dcdb9606f8e8b6ad76e231ecf7bdc5bc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 21:11:08 +0000 Subject: [PATCH] feat(@cacheable/memory): add hooks like cacheable Add CacheableMemoryHooks enum and hookSync invocations to CacheableMemory for get, getMany, set, setMany, delete, deleteMany, and clear operations. BEFORE hooks allow mutation of parameters (key, value, ttl) before the operation executes, matching the cacheable package's hook pattern. https://claude.ai/code/session_016g93vFCCgE3LPqYRYJWzCG --- packages/memory/src/index.ts | 74 +++++++++--- packages/memory/test/index.test.ts | 181 ++++++++++++++++++++++++++++- 2 files changed, 239 insertions(+), 16 deletions(-) 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(); + }); +});