diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..897cb9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +package-lock.json +dist \ No newline at end of file diff --git a/README.md b/README.md index c9f5cca..ebb8a1f 100644 --- a/README.md +++ b/README.md @@ -20,26 +20,28 @@ npm install run-cache ## Usage ```js -import { RunCache } from 'run-cache' +import { RunCache } from "run-cache"; // Set a cache value with 60s ttl -RunCache.set('sample_key_1', 'sample_value_1', 60000) +RunCache.set("sample_key_1", "sample_value_1", 60000); // Set a cache value with function definition to fetch the value later -RunCache.setWithSourceFn('sample_key_2', () => { return Promise.resolve('sample_value_2') }) +RunCache.setWithSourceFn("sample_key_2", () => { + return Promise.resolve("sample_value_2"); +}); // Refetch the cache value (This will call the above function and update the cache value) -await RunCache.refetch('sample_key_2') +await RunCache.refetch("sample_key_2"); // Get a value for a given cache key -const value = RunCache.get('sample_key_1') +const value = RunCache.get("sample_key_1"); // Delete a specific cache key -RunCache.delete('sample_key_1'); +RunCache.delete("sample_key_1"); // Delete all cache keys -RunCache.deleteAll() +RunCache.deleteAll(); // Returns a boolean based on existence of the given cache key -const hasCache = RunCache.has('sample_key_1') -``` \ No newline at end of file +const hasCache = RunCache.has("sample_key_1"); +``` diff --git a/index.ts b/index.ts index c72eaec..8bb8edf 100644 --- a/index.ts +++ b/index.ts @@ -1,146 +1 @@ -type SourceFn = () => Promise; - -type CacheState = { - value: string; - sourceFn?: SourceFn; - expiry?: number; -}; - -export class RunCache { - private static cache: Map = new Map(); - - private static getExpiry(ttl: number | undefined): number | undefined { - return ttl ? Date.now() + ttl : undefined; - } - - /** - * Adds a value to the cache with an optional TTL. - * - * @param {string} key - The cache key. - * @param {string} value - The value to cache. - * @param {number} [ttl] - Optional time-to-live in milliseconds. - * @returns {void} - */ - static set(key: string, value: string, ttl?: number): void { - RunCache.cache.set(key, { value, expiry: this.getExpiry(ttl) }); - } - - /** - * Adds a value to the cache using a source function and sets an optional TTL. - * The source function is used to generate the value and is stored for future refetching. - * - * @param {string} key - The cache key. - * @param {SourceFn} sourceFn - The function used to generate the value to be cached. - * @param {number} [ttl] - Optional time-to-live in milliseconds. - * @returns {Promise} A promise that resolves when the value is set. - * @throws Will throw an error if the source function fails. - */ - static async setWithSourceFn( - key: string, - sourceFn: SourceFn, - ttl?: number, - ): Promise { - try { - const value = await sourceFn.call(this); - RunCache.cache.set(key, { - value: JSON.stringify(value), - expiry: this.getExpiry(ttl), - sourceFn: sourceFn, - }); - } catch (e) { - throw e; - } - } - - /** - * Refetches the cached value using the stored source function and updates the cache with the new value. - * - * @param {string} key - The cache key. - * @param {number} [ttl] - Optional time-to-live in milliseconds for the updated value. - * @returns {Promise} A promise that resolves when the value is updated. - * @throws Will throw an error if the source function fails. - */ - static async refetch(key: string, ttl?: number): Promise { - const cached = RunCache.cache.get(key); - - if (!cached) { - return; - } - - if (!cached.sourceFn) { - return; - } - - try { - const value = await cached.sourceFn.call(this); - RunCache.cache.set(key, { - value: JSON.stringify(value), - expiry: this.getExpiry(ttl), - sourceFn: cached.sourceFn, - }); - } catch (e) { - throw e; - } - } - - /** - * Retrieves the cached value associated with the given key if it exists and has not expired. - * - * @param {string} key - The cache key. - * @returns {string | undefined} The cached value, or undefined if not found or expired. - */ - static get(key: string): string | undefined { - const cached = RunCache.cache.get(key); - - if (!cached) { - return undefined; - } - - if (cached.expiry && cached.expiry < Date.now()) { - RunCache.cache.delete(key); - return undefined; - } - - return cached.value; - } - - /** - * Deletes the cached value associated with the given key. - * - * @param {string} key - The cache key. - * @returns {boolean} True if the key was deleted, false otherwise. - */ - static delete(key: string): boolean { - return RunCache.cache.delete(key); - } - - /** - * Clears all cached values. - * - * @returns {void} - */ - static deleteAll(): void { - RunCache.cache.clear(); - } - - /** - * Checks if the cache contains a valid (non-expired) value for the given key. - * - * @param {string} key - The cache key. - * @returns {boolean} True if the cache contains a valid value for the key, false otherwise. - */ - static has(key: string): boolean { - const cached = RunCache.cache.get(key); - - if (!cached) { - return false; - } - - if (cached.expiry && cached.expiry < Date.now()) { - RunCache.cache.delete(key); - return false; - } - - return true; - } -} +export { RunCache } from "./src/run-cache"; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..28777fa --- /dev/null +++ b/jest.config.js @@ -0,0 +1,9 @@ +// jest.config.js +export default { + preset: "ts-jest", + testEnvironment: "node", + transform: { + "^.+\\.tsx?$": "ts-jest", // Transform TypeScript files using ts-jest + }, + extensionsToTreatAsEsm: [".ts"], // Treat .ts files as ES modules +}; diff --git a/package.json b/package.json index 0213559..1d63de6 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,27 @@ { "name": "run-cache", - "version": "1.0.4", + "version": "1.0.5", "description": "`RunCache` is a dependency nil, light-weight in-memory caching library for JavaScript and TypeScript that allows you to cache `string` values with optional time-to-live (TTL) settings. It also supports caching values generated from asynchronous functions and provides methods to refetch them on demand.", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest test.ts" }, - "keywords": ["cache", "runtime", "in-memory", "memory"], + "type": "module", + "keywords": [ + "cache", + "runtime", + "in-memory", + "memory" + ], "author": "helloscoopa", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/helloscoopa/run-cache.git" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.2.5" } } diff --git a/src/run-cache.test.ts b/src/run-cache.test.ts new file mode 100644 index 0000000..85a6ba2 --- /dev/null +++ b/src/run-cache.test.ts @@ -0,0 +1,150 @@ +import { RunCache } from "./run-cache"; + +describe("RunCache", () => { + beforeEach(() => { + RunCache.deleteAll(); + }); + + describe("set()", () => { + it("should throw an error if the cache key or value is empty", () => { + expect(() => RunCache.set("", "value1")).toThrow("Empty key"); + expect(() => RunCache.set("key1", "")).toThrow("Empty value"); + }); + + it("should return true if the cache value set successfully", () => { + expect(RunCache.set("key1", "value1")).toBe(true); + }); + + it("should return true if the cache set with a ttl and ttl is functioning properly", () => { + expect(RunCache.set("key2", "value2", 100)).toBe(true); + expect(RunCache.get("key2")).toBe("value2"); + + // Wait for the TTL to expire + setTimeout(() => { + expect(RunCache.get("key2")).toBeUndefined(); + }, 150); + }); + }); + + describe("get()", () => { + it("should return undefined if the key is empty", () => { + expect(RunCache.get("")).toBeUndefined(); + }); + + it("should return undefined if the key is not found", () => { + expect(RunCache.get("key1")).toBeUndefined(); + }); + + it("should return the value successfully", () => { + RunCache.set("key1", "value1"); + expect(RunCache.get("key1")).toBe("value1"); + }); + }); + + describe("delete()", () => { + it("should return false if the operation failed", () => { + expect(RunCache.delete("nonExistentKey")).toBe(false); + }); + + it("should return true if the value is successfully deleted", () => { + RunCache.set("key1", "value1"); + expect(RunCache.delete("key1")).toBe(true); + expect(RunCache.get("key1")).toBeUndefined(); + }); + }); + + describe("deleteAll()", () => { + it("should clear all values", () => { + RunCache.set("key1", "value1"); + RunCache.set("key2", "value2"); + RunCache.deleteAll(); + expect(RunCache.get("key1")).toBeUndefined(); + expect(RunCache.get("key2")).toBeUndefined(); + }); + }); + + describe("has()", () => { + it("should return true if the key exists", () => { + RunCache.set("key1", "value1"); + expect(RunCache.has("key1")).toBe(true); + }); + + it("should return false if the key exists", () => { + expect(RunCache.has("nonExistentKey")).toBe(false); + }); + + it("should return false after ttl expiry", () => { + RunCache.set("key2", "value2", 50); // Set TTL to 50ms + expect(RunCache.has("key2")).toBe(true); + + // Wait for the TTL to expire + setTimeout(() => { + expect(RunCache.has("key2")).toBe(false); + }, 100); + }); + }); + + describe("setWithSourceFn()", () => { + it("should throw an error when the source function throws an error", async () => { + let sourceFn = async () => { + throw Error("Unexpected Error"); + }; + expect(RunCache.setWithSourceFn("key1", sourceFn)).rejects.toThrow( + "Source function failed", + ); + }); + + it("should be able to set a value with source function successfully", async () => { + const sourceFn = async () => "dynamicValue"; + await RunCache.setWithSourceFn("key2", sourceFn); + expect(RunCache.get("key2")).toBe('"dynamicValue"'); + }); + }); + + describe("refetch()", () => { + it("should throw an error if refetch is called on a key having no source function", async () => { + RunCache.set("key2", "value2"); + await expect(RunCache.refetch("key2")).rejects.toThrow( + "No source function found", + ); + }); + + it("should throw an error when the source function throws an error", async () => { + let breaker = false; + + let sourceFn = async () => { + if (breaker) { + throw Error("Unexpected Error"); + } else { + return "SomeValue"; + } + }; + await RunCache.setWithSourceFn("key3", sourceFn); + + // Make source function to fail + breaker = true; + + expect(RunCache.refetch("key3")).rejects.toThrow( + "Source function failed", + ); + }); + + it("should not refetch if the key does not exist", async () => { + await expect(RunCache.refetch("nonExistentKey")).resolves.toBeFalsy(); + }); + + it("should refetch and update the value from the source function", async () => { + let dynamicValue = "initialValue"; + const sourceFn = async () => dynamicValue; + + await RunCache.setWithSourceFn("key1", sourceFn); + expect(RunCache.get("key1")).toBe('"initialValue"'); + + // Update what's being returned in the source function + dynamicValue = "updatedValue"; + + await RunCache.refetch("key1"); + expect(RunCache.get("key1")).toBe('"updatedValue"'); + }); + }); +}); diff --git a/src/run-cache.ts b/src/run-cache.ts new file mode 100644 index 0000000..7ebcdfa --- /dev/null +++ b/src/run-cache.ts @@ -0,0 +1,165 @@ +type SourceFn = () => Promise; + +type CacheState = { + value: string; + sourceFn?: SourceFn; + expiry?: number; +}; + +class RunCache { + private static cache: Map = new Map(); + + private static getExpiry(ttl: number | undefined): number | undefined { + return ttl ? Date.now() + ttl : undefined; + } + + /** + * Adds a value to the cache with an optional TTL. + * + * @param {string} key - The cache key. + * @param {string} value - The value to cache. + * @param {number} [ttl] - Optional time-to-live in milliseconds. + * @returns {boolean} - The state of the operation + * @throws Will throw an error if the key or value is empty + */ + static set(key: string, value: string, ttl?: number): boolean { + if (!key.length) { + throw Error("Empty key"); + } + + if (!value.length) { + throw Error("Empty value"); + } + + RunCache.cache.set(key, { value, expiry: this.getExpiry(ttl) }); + + return true; + } + + /** + * Adds a value to the cache using a source function and sets an optional TTL. + * The source function is used to generate the value and is stored for future refetching. + * + * @param {string} key - The cache key. + * @param {SourceFn} sourceFn - The function used to generate the value to be cached. + * @param {number} [ttl] - Optional time-to-live in milliseconds. + * @returns {Promise} + * @throws Will throw an error if the source function fails. + */ + static async setWithSourceFn( + key: string, + sourceFn: SourceFn, + ttl?: number, + ): Promise { + try { + const value = await sourceFn.call(this); + RunCache.cache.set(key, { + value: JSON.stringify(value), + expiry: this.getExpiry(ttl), + sourceFn: sourceFn, + }); + } catch (e) { + throw Error("Source function failed"); + } + } + + /** + * Refetches the cached value using the stored source function and updates the cache with the new value. + * + * @param {string} key - The cache key. + * @param {number} [ttl] - Optional time-to-live in milliseconds for the updated value. + * @returns {Promise} A promise that resolves to a boolean representing the execution state of the request. + * @throws Will throw an error if the source function fails. + */ + static async refetch(key: string, ttl?: number): Promise { + const cached = RunCache.cache.get(key); + + if (!cached) { + return false; + } + + if (!cached.sourceFn) { + throw Error("No source function found"); + } + + try { + const value = await cached.sourceFn.call(this); + RunCache.cache.set(key, { + value: JSON.stringify(value), + expiry: this.getExpiry(ttl), + sourceFn: cached.sourceFn, + }); + + return true; + } catch (e) { + throw Error("Source function failed"); + } + } + + /** + * Retrieves the cached value associated with the given key if it exists and has not expired. + * + * @param {string} key - The cache key. + * @returns {string | undefined} The cached value, or undefined if not found or expired. + */ + static get(key: string): string | undefined { + if (!key) { + return undefined; + } + + const cached = RunCache.cache.get(key); + + if (!cached) { + return undefined; + } + + if (cached.expiry && cached.expiry < Date.now()) { + RunCache.cache.delete(key); + return undefined; + } + + return cached.value; + } + + /** + * Deletes the cached value associated with the given key. + * + * @param {string} key - The cache key. + * @returns {boolean} True if the key was deleted, false otherwise. + */ + static delete(key: string): boolean { + return RunCache.cache.delete(key); + } + + /** + * Clears all cached values. + * + * @returns {void} + */ + static deleteAll(): void { + RunCache.cache.clear(); + } + + /** + * Checks if the cache contains a valid (non-expired) value for the given key. + * + * @param {string} key - The cache key. + * @returns {boolean} True if the cache contains a valid value for the key, false otherwise. + */ + static has(key: string): boolean { + const cached = RunCache.cache.get(key); + + if (!cached) { + return false; + } + + if (cached.expiry && cached.expiry < Date.now()) { + RunCache.cache.delete(key); + return false; + } + + return true; + } +} + +export { RunCache }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..066dfe3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "esModuleInterop": true + } +}