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
74 changes: 59 additions & 15 deletions packages/memory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -303,25 +320,32 @@ export class CacheableMemory extends Hookified {
* @returns {T | undefined} - The value of the key
*/
public get<T>(key: string): T | undefined {
this.hookSync(CacheableMemoryHooks.BEFORE_GET, key);
Comment thread
jaredwray marked this conversation as resolved.
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;
Comment thread
jaredwray marked this conversation as resolved.
}

/**
Expand All @@ -330,11 +354,13 @@ export class CacheableMemory extends Hookified {
* @returns {T[]} - The values of the keys
*/
public getMany<T>(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;
Comment thread
jaredwray marked this conversation as resolved.
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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 */
Expand All @@ -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);
}

/**
Expand All @@ -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);
}
Comment thread
jaredwray marked this conversation as resolved.

this.hookSync(CacheableMemoryHooks.AFTER_SET_MANY, items);
Comment thread
jaredwray marked this conversation as resolved.
Comment thread
jaredwray marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -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);
Comment thread
jaredwray marked this conversation as resolved.
}

/**
Expand All @@ -519,21 +558,26 @@ 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);
Comment thread
jaredwray marked this conversation as resolved.
}

/**
* Clear the cache
* @returns {void}
*/
public clear(): void {
this.hookSync(CacheableMemoryHooks.BEFORE_CLEAR);
this._store = Array.from(
{ length: this._storeHashSize },
() => new Map<string, CacheableStoreItem>(),
);
this._lru = new DoublyLinkedList<string>();
this.hookSync(CacheableMemoryHooks.AFTER_CLEAR);
}

/**
Expand Down
181 changes: 180 additions & 1 deletion packages/memory/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down Expand Up @@ -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");
});
Comment thread
jaredwray marked this conversation as resolved.
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");
});
Comment thread
jaredwray marked this conversation as resolved.

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();
});
});
Loading