From 6b72d4ca00b05e4bb54a1cb301372832124c5cfb Mon Sep 17 00:00:00 2001 From: Engin Aydogan Date: Thu, 19 Jun 2025 20:30:20 +0300 Subject: [PATCH 1/6] feat: persist promises more faithfully Previously resolved promise values were being stored. Once they are server from cache, they were no longer promises, but instead values. So, they could be awaited, but if you were to use .then() on returned value, it would have failed because the resolved value is returned instead of an actual promise. This patch adds more thorough serialization/deserialization logic and ability to restore resolved or rejected promises from cache, the actual promises. --- badges/coverage.svg | 2 +- dist/index.browser.js | 2 +- dist/index.node.js | 2 +- package.json | 3 +- src/cache-store.ts | 2 + src/file-cache-store.ts | 14 ++++- src/mem-cache-store.ts | 6 ++ src/utils.ts | 54 +++++++++++++++++ tests/cacheProxy.test.ts | 77 ++++++++++++++++++++--- tests/utils.test.ts | 128 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 274 insertions(+), 16 deletions(-) create mode 100644 src/utils.ts create mode 100644 tests/utils.test.ts diff --git a/badges/coverage.svg b/badges/coverage.svg index 2c39816..81b52d7 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 88.63%Coverage88.63% \ No newline at end of file +Coverage: 96.21%Coverage96.21% \ No newline at end of file diff --git a/dist/index.browser.js b/dist/index.browser.js index 6f95758..f866ba8 100644 --- a/dist/index.browser.js +++ b/dist/index.browser.js @@ -1 +1 @@ -var P=class{},u=Symbol("NotFound"),O=1e3,M=60*O,N=60*M,l=24*N,F=7*l;function k(s,t,n,r,m,i=""){if(r[i])return r[i];if(typeof s=="function"){let a=i+"/"+s.name,c=(...e)=>{let o=a+": "+JSON.stringify(e.map(f=>f===void 0?"undefined":f===null?"null":f)),x=t.get(o,n.defaultExpire??1*l);if(x!==u)return n.onHit?.(o,e),x;let y=s(...e);return t.set(o,y),n.onMiss?.(o,e),y};return r[i]=c,c}let w=new Proxy(s,{get(a,c){let e=i+"/"+c.toString(),o=a[c];if(typeof o=="function"){if(m[e])return m[e];let x=o.bind(a),{defaultExpire:y=1*l,pathExpire:f={},onHit:S=()=>{},onMiss:v=()=>{}}=n,h=f[e]??y,g=function(...d){let p=e+": "+JSON.stringify(d),T=t.get(p,h);if(T!==u)return S(p,d),T;let E=x(...d);return t.set(p,E),v(p,d),E};return m[e]=g,g}return o instanceof Object?k(a[c],t,n,r,m,e):o}});return r[i]=w,w}var b=class{constructor(t={}){this.cache=t}get(t,n){let r=this.cache[t];return r?Date.now()-r.timestamp>n?u:r.value:u}set(t,n){this.cache[t]={timestamp:Date.now(),value:n}}save(){}clear(){this.cache={}}};function W(s,t={}){let{cacheStore:n=new b}=t;return k(s,n,t,{},{})}export{P as CacheStore,l as DAY,N as HOUR,M as MINUTE,b as MemCacheStore,u as NotFound,O as SECOND,F as WEEK,W as amemo,k as createProxy}; +var P=class{},u=Symbol("NotFound"),O=1e3,M=60*O,N=60*M,l=24*N,F=7*l;function k(s,e,n,t,m,i=""){if(t[i])return t[i];if(typeof s=="function"){let a=i+"/"+s.name,c=(...r)=>{let o=a+": "+JSON.stringify(r.map(f=>f===void 0?"undefined":f===null?"null":f)),x=e.get(o,n.defaultExpire??1*l);if(x!==u)return n.onHit?.(o,r),x;let y=s(...r);return e.set(o,y),n.onMiss?.(o,r),y};return t[i]=c,c}let w=new Proxy(s,{get(a,c){let r=i+"/"+c.toString(),o=a[c];if(typeof o=="function"){if(m[r])return m[r];let x=o.bind(a),{defaultExpire:y=1*l,pathExpire:f={},onHit:E=()=>{},onMiss:S=()=>{}}=n,h=f[r]??y,g=function(...d){let p=r+": "+JSON.stringify(d),T=e.get(p,h);if(T!==u)return E(p,d),T;let v=x(...d);return e.set(p,v),S(p,d),v};return m[r]=g,g}return o instanceof Object?k(a[c],e,n,t,m,r):o}});return t[i]=w,w}var b=class{constructor(e={}){this.cache=e}get(e,n){let t=this.cache[e];return t?Date.now()-t.timestamp>n?u:t.promise?t.rejected?Promise.reject(t.value):Promise.resolve(t.value):t.value:u}set(e,n){this.cache[e]={timestamp:Date.now(),value:n}}save(){}clear(){this.cache={}}};function W(s,e={}){let{cacheStore:n=new b}=e;return k(s,n,e,{},{})}export{P as CacheStore,l as DAY,N as HOUR,M as MINUTE,b as MemCacheStore,u as NotFound,O as SECOND,F as WEEK,W as amemo,k as createProxy}; diff --git a/dist/index.node.js b/dist/index.node.js index b51ac33..cddb3c2 100644 --- a/dist/index.node.js +++ b/dist/index.node.js @@ -1 +1 @@ -var F=class{},u=Symbol("NotFound"),M=1e3,C=60*M,j=60*C,b=24*j,D=7*b;function S(s,n,e,t,m,c=""){if(t[c])return t[c];if(typeof s=="function"){let l=c+"/"+s.name,a=(...r)=>{let i=l+": "+JSON.stringify(r.map(p=>p===void 0?"undefined":p===null?"null":p)),y=n.get(i,e.defaultExpire??1*b);if(y!==u)return e.onHit?.(i,r),y;let h=s(...r);return n.set(i,h),e.onMiss?.(i,r),h};return t[c]=a,a}let k=new Proxy(s,{get(l,a){let r=c+"/"+a.toString(),i=l[a];if(typeof i=="function"){if(m[r])return m[r];let y=i.bind(l),{defaultExpire:h=1*b,pathExpire:p={},onHit:E=()=>{},onMiss:P=()=>{}}=e,N=p[r]??h,v=function(...x){let d=r+": "+JSON.stringify(x),g=n.get(d,N);if(g!==u)return E(d,x),g;let w=y(...x);return n.set(d,w),P(d,x),w};return m[r]=v,v}return i instanceof Object?S(l[a],n,e,t,m,r):i}});return t[c]=k,k}var f=class{constructor(n={}){this.cache=n}get(n,e){let t=this.cache[n];return t?Date.now()-t.timestamp>e?u:t.value:u}set(n,e){this.cache[n]={timestamp:Date.now(),value:e}}save(){}clear(){this.cache={}}};import*as o from"fs";import*as T from"path";var O=class extends f{constructor(e={}){super();this.opts=e;this.cacheFile=e.path??".amemo.json",this.autoSave=e.autoSave??!0;try{if(!o.existsSync(this.cacheFile))return;let t=o.readFileSync(this.cacheFile,"utf8");super.cache=JSON.parse(t)}catch{}}set(e,t){super.set(e,t),this.autoSave&&this.save()}async save(){for(let[e,t]of Object.entries(this.cache))t.value instanceof Promise&&(this.cache[e].value=await t.value);o.mkdirSync(T.dirname(this.cacheFile),{recursive:!0}),o.writeFileSync(this.cacheFile,JSON.stringify(this.cache))}clear(){super.clear(),o.unlinkSync(this.cacheFile)}};function Q(s,n={}){let{cacheStore:e=new f}=n;return S(s,e,n,{},{})}export{F as CacheStore,b as DAY,O as FileCacheStore,j as HOUR,C as MINUTE,f as MemCacheStore,u as NotFound,M as SECOND,D as WEEK,Q as amemo,S as createProxy}; +var _=class{},f=Symbol("NotFound"),N=1e3,R=60*N,C=60*R,g=24*C,D=7*g;function S(n,e,t,r,c,a=""){if(r[a])return r[a];if(typeof n=="function"){let y=a+"/"+n.name,u=(...o)=>{let s=y+": "+JSON.stringify(o.map(m=>m===void 0?"undefined":m===null?"null":m)),h=e.get(s,t.defaultExpire??1*g);if(h!==f)return t.onHit?.(s,o),h;let d=n(...o);return e.set(s,d),t.onMiss?.(s,o),d};return r[a]=u,u}let b=new Proxy(n,{get(y,u){let o=a+"/"+u.toString(),s=y[u];if(typeof s=="function"){if(c[o])return c[o];let h=s.bind(y),{defaultExpire:d=1*g,pathExpire:m={},onHit:T=()=>{},onMiss:M=()=>{}}=t,j=m[o]??d,k=function(...x){let l=o+": "+JSON.stringify(x),w=e.get(l,j);if(w!==f)return T(l,x),w;let E=h(...x);return e.set(l,E),M(l,x),E};return c[o]=k,k}return s instanceof Object?S(y[u],e,t,r,c,o):s}});return r[a]=b,b}var p=class{constructor(e={}){this.cache=e}get(e,t){let r=this.cache[e];return r?Date.now()-r.timestamp>t?f:r.promise?r.rejected?Promise.reject(r.value):Promise.resolve(r.value):r.value:f}set(e,t){this.cache[e]={timestamp:Date.now(),value:t}}save(){}clear(){this.cache={}}};import*as i from"fs";import*as O from"path";function F(n,e){if(e instanceof Date)return{__type:"Date",value:e.toISOString()};if(e instanceof RegExp)return{__type:"RegExp",value:e.toString()};if(e instanceof Set)return{__type:"Set",value:Array.from(e)};if(e instanceof Map)return{__type:"Map",value:Array.from(e.entries())};if(typeof e=="bigint")return{__type:"BigInt",value:e.toString()};if(e===void 0)return{__type:"undefined"};if(e instanceof Promise)throw new Error("Cannot serialize a Promise directly. Please await it first.");return e instanceof Error?{__type:"Error",message:e.message,stack:e.stack}:e}function P(n,e){if(e&&typeof e=="object"&&e.__type)switch(e.__type){case"Date":return new Date(e.value);case"RegExp":{let t=e.value.match(/^\/(.*)\/([a-z]*)$/);if(t)return new RegExp(t[1],t[2]);throw new Error("Invalid RegExp format")}case"Set":return new Set(e.value);case"Map":return new Map(e.value);case"BigInt":return BigInt(e.value);case"undefined":return;case"Error":{let t=new Error(e.message);return t.stack=e.stack,t}}return e}var v=class extends p{constructor(t={}){super();this.opts=t;this.cacheFile=t.path??".amemo.json",this.autoSave=t.autoSave??!0;try{if(!i.existsSync(this.cacheFile))return;let r=i.readFileSync(this.cacheFile,"utf8");super.cache=JSON.parse(r,P)}catch{}}set(t,r){super.set(t,r),this.autoSave&&this.save()}async save(){for(let[t,r]of Object.entries(this.cache))if(r.value instanceof Promise){this.cache[t].promise=!0;try{this.cache[t].value=await r.value}catch(c){this.cache[t].value=c,this.cache[t].rejected=!0}}i.mkdirSync(O.dirname(this.cacheFile),{recursive:!0}),i.writeFileSync(this.cacheFile,JSON.stringify(this.cache,F))}clear(){super.clear(),i.unlinkSync(this.cacheFile)}};function X(n,e={}){let{cacheStore:t=new p}=e;return S(n,t,e,{},{})}export{_ as CacheStore,g as DAY,v as FileCacheStore,C as HOUR,R as MINUTE,p as MemCacheStore,f as NotFound,N as SECOND,D as WEEK,X as amemo,S as createProxy}; diff --git a/package.json b/package.json index a84dbfa..229809d 100644 --- a/package.json +++ b/package.json @@ -58,5 +58,6 @@ "standard-version": "^9.5.0", "ts-node": "^10.9.2", "typescript": "^5.4.4" - } + }, + "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" } diff --git a/src/cache-store.ts b/src/cache-store.ts index 6efc139..f532234 100644 --- a/src/cache-store.ts +++ b/src/cache-store.ts @@ -8,6 +8,8 @@ export abstract class CacheStore { export type Entry = { timestamp: number; value: unknown; + promise?: boolean; + rejected?: boolean; }; export const NotFound = Symbol("NotFound"); diff --git a/src/file-cache-store.ts b/src/file-cache-store.ts index 80aa787..4cb3816 100644 --- a/src/file-cache-store.ts +++ b/src/file-cache-store.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import { MemCacheStore } from "./mem-cache-store"; +import { replacer, reviver } from "./utils"; export type FileCacheStoreOpts = { path?: string; @@ -20,7 +21,7 @@ export class FileCacheStore extends MemCacheStore { return; } const data = fs.readFileSync(this.cacheFile, "utf8"); - super.cache = JSON.parse(data); + super.cache = JSON.parse(data, reviver); } catch (e) { // ignore } @@ -36,12 +37,19 @@ export class FileCacheStore extends MemCacheStore { async save() { for (const [key, entry] of Object.entries(this.cache)) { if (entry.value instanceof Promise) { - this.cache[key].value = await entry.value; + this.cache[key].promise = true; + try { + this.cache[key].value = await entry.value; + } catch (e) { + // Error object is not serializable, so we just remove it + this.cache[key].value = e; + this.cache[key].rejected = true; + } } } fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true }); - fs.writeFileSync(this.cacheFile, JSON.stringify(this.cache)); + fs.writeFileSync(this.cacheFile, JSON.stringify(this.cache, replacer)); } clear() { diff --git a/src/mem-cache-store.ts b/src/mem-cache-store.ts index 6b538e5..111f930 100644 --- a/src/mem-cache-store.ts +++ b/src/mem-cache-store.ts @@ -11,6 +11,12 @@ export class MemCacheStore implements CacheStore { if (Date.now() - entry.timestamp > expire) { return NotFound; } + if (entry.promise) { + if (entry.rejected) { + return Promise.reject(entry.value); + } + return Promise.resolve(entry.value); + } return entry.value; } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..d394b5b --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,54 @@ +export function replacer(key: string, value: any) { + if (value instanceof Date) + return { __type: "Date", value: value.toISOString() }; + if (value instanceof RegExp) + return { __type: "RegExp", value: value.toString() }; + if (value instanceof Set) return { __type: "Set", value: Array.from(value) }; + if (value instanceof Map) + return { __type: "Map", value: Array.from(value.entries()) }; + if (typeof value === "bigint") + return { __type: "BigInt", value: value.toString() }; + if (value === undefined) return { __type: "undefined" }; + if (value instanceof Promise) { + // it would have been better if we could serialize the promise + // replacer is synchronous, so we cannot await the promise here + // it is handled in file-cache-store.ts and mem-cache-store.ts + throw new Error( + "Cannot serialize a Promise directly. Please await it first.", + ); + } + if (value instanceof Error) { + return { __type: "Error", message: value.message, stack: value.stack }; + } + return value; +} + +export function reviver(key: string, value: any) { + if (value && typeof value === "object" && value.__type) { + switch (value.__type) { + case "Date": + return new Date(value.value); + case "RegExp": { + const match = value.value.match(/^\/(.*)\/([a-z]*)$/); + if (match) { + return new RegExp(match[1], match[2]); + } + throw new Error("Invalid RegExp format"); + } + case "Set": + return new Set(value.value); + case "Map": + return new Map(value.value); + case "BigInt": + return BigInt(value.value); + case "undefined": + return undefined; + case "Error": { + const error = new Error(value.message); + error.stack = value.stack; + return error; + } + } + } + return value; +} diff --git a/tests/cacheProxy.test.ts b/tests/cacheProxy.test.ts index e091177..6950a7f 100644 --- a/tests/cacheProxy.test.ts +++ b/tests/cacheProxy.test.ts @@ -11,11 +11,17 @@ jest.useFakeTimers(); class NestedNestedTest { public barCalls = 0; + public throwCalls = 0; // eslint-disable-next-line @typescript-eslint/no-unused-vars public bar(_ = "bar") { return this.barCalls++; } + public async throws() { + this.throwCalls++; + throw new Error("This method should not be cached"); + } + public readonly shouldNotBeCached = "shouldNotBeCached"; } @@ -184,32 +190,85 @@ describe("amemo", () => { }); it("must persist promises", async () => { - let mockFile = ""; + const mockFile: Record = {}; mockFs.existsSync.mockReturnValue(false); - mockFs.readFileSync.mockReturnValue(""); + mockFs.readFileSync.mockImplementation( + (file) => mockFile[file as string] as any, + ); mockFs.writeFileSync.mockImplementation( - (file, data) => (mockFile = data as string), + (file, data) => (mockFile[file as string] = data as string), ); + const t = new Test(); + let cacheStore = new FileCacheStore(); const c = amemo(t, { - cacheStore: new FileCacheStore(), + cacheStore, }); expect(c.nested.foo()).toBeInstanceOf(Promise); expect(await c.nested.foo()).toBe(0); expect(await c.nested.foo()).toBe(0); - const parsed = JSON.parse(mockFile); + const parsed = JSON.parse(mockFile[".amemo.json"]); expect(parsed["/nested/foo: []"]["value"]).toBe(0); mockFs.existsSync.mockReturnValue(true); - mockFs.readFileSync.mockReturnValue(mockFile); + mockFs.readFileSync.mockReturnValue(mockFile[".amemo.json"]); const t2 = new Test(); + cacheStore = new FileCacheStore(); const c2 = amemo(t2, { - cacheStore: new FileCacheStore(), + cacheStore, }); - expect(c2.nested.foo()).not.toBeInstanceOf(Promise); - expect(c2.nested.foo()).toBe(0); + const p = c2.nested.foo(); + expect(p).toBeInstanceOf(Promise); + expect(p.then).toBeDefined(); expect(await c2.nested.foo()).toBe(0); }); + it("must persist rejected promises", async () => { + const mockFile: Record = {}; + mockFs.existsSync.mockImplementation((file) => { + return mockFile[file as string] !== undefined; + }); + + mockFs.readFileSync.mockImplementation( + (file) => mockFile[file as string] as any, + ); + mockFs.writeFileSync.mockImplementation( + (file, data) => (mockFile[file as string] = data as string), + ); + const t = new Test(); + let cacheStore = new FileCacheStore(); + const c = amemo(t, { + cacheStore, + }); + + try { + await c.nested.nested.throws(); + expect(true).toBe(false); + } catch (e) { + expect(e.message).toBe("This method should not be cached"); + expect(c.nested.nested.throwCalls).toBe(1); + expect(t.nested.nested.throwCalls).toBe(1); + } + + try { + await c.nested.nested.throws(); + expect(true).toBe(false); // should not reach here + } catch (e) { + expect(e.message).toBe("This method should not be cached"); + expect(c.nested.nested.throwCalls).toBe(1); + } + cacheStore = new FileCacheStore(); + const c2 = amemo(new Test(), { + cacheStore, + }); + try { + await c2.nested.nested.throws(); + expect(true).toBe(false); // should not reach here + } catch (e) { + expect(e.message).toBe("This method should not be cached"); + expect(c2.nested.nested.throwCalls).toBe(0); + } + }); + it("must support in memory cache", async () => { const t = new Test(); const cacheStore = new MemCacheStore(); diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000..5fcf66c --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,128 @@ +import { replacer, reviver } from "../src/utils"; + +describe("utils.ts", () => { + describe("replacer", () => { + it("should serialize a Date object", () => { + const date = new Date("2023-01-01T00:00:00Z"); + expect(replacer("", date)).toEqual({ + __type: "Date", + value: date.toISOString(), + }); + }); + + it("should serialize a RegExp object", () => { + const regex = /test/i; + expect(replacer("", regex)).toEqual({ + __type: "RegExp", + value: regex.toString(), + }); + }); + + it("should serialize a Set object", () => { + const set = new Set([1, 2, 3]); + expect(replacer("", set)).toEqual({ + __type: "Set", + value: [1, 2, 3], + }); + }); + + it("should serialize a Map object", () => { + const map = new Map([ + ["key1", "value1"], + ["key2", "value2"], + ]); + expect(replacer("", map)).toEqual({ + __type: "Map", + value: Array.from(map.entries()), + }); + }); + + it("should serialize a BigInt value", () => { + const bigIntValue = BigInt(12345678901234567890n); + expect(replacer("", bigIntValue)).toEqual({ + __type: "BigInt", + value: bigIntValue.toString(), + }); + }); + + it("should serialize undefined", () => { + expect(replacer("", undefined)).toEqual({ __type: "undefined" }); + }); + + it("should throw an error for a Promise", () => { + const promise = Promise.resolve(); + expect(() => replacer("", promise)).toThrow( + "Cannot serialize a Promise directly. Please await it first.", + ); + }); + + it("should serialize an Error object", () => { + const error = new Error("Test error"); + expect(replacer("", error)).toEqual({ + __type: "Error", + message: error.message, + stack: error.stack, + }); + }); + + it("should return the value as-is for unsupported types", () => { + const value = { key: "value" }; + expect(replacer("", value)).toEqual(value); + }); + }); + + describe("reviver", () => { + it("should deserialize a Date object", () => { + const date = new Date("2023-01-01T00:00:00Z"); + expect( + reviver("", { __type: "Date", value: date.toISOString() }), + ).toEqual(date); + }); + + it("should deserialize a RegExp object", () => { + const regex = /test/i; + expect( + reviver("", { __type: "RegExp", value: regex.toString() }), + ).toEqual(regex); + }); + + it("should deserialize a Set object", () => { + const set = new Set([1, 2, 3]); + expect(reviver("", { __type: "Set", value: [1, 2, 3] })).toEqual(set); + }); + + it("should deserialize a Map object", () => { + const map = new Map([ + ["key1", "value1"], + ["key2", "value2"], + ]); + expect( + reviver("", { __type: "Map", value: Array.from(map.entries()) }), + ).toEqual(map); + }); + + it("should deserialize a BigInt value", () => { + const bigIntValue = BigInt(12345678901234567890n); + expect( + reviver("", { __type: "BigInt", value: bigIntValue.toString() }), + ).toEqual(bigIntValue); + }); + + it("should deserialize undefined", () => { + expect(reviver("", { __type: "undefined" })).toBeUndefined(); + }); + + it("should deserialize an Error object", () => { + const error = { __type: "Error", message: "Test error", stack: "stack" }; + const deserializedError = reviver("", error); + expect(deserializedError).toBeInstanceOf(Error); + expect(deserializedError.message).toBe(error.message); + expect(deserializedError.stack).toBe(error.stack); + }); + + it("should return the value as-is for unsupported types", () => { + const value = { key: "value" }; + expect(reviver("", value)).toEqual(value); + }); + }); +}); From c7b56f3b5afd243170cb2ec6a7c6c53dec16d7a7 Mon Sep 17 00:00:00 2001 From: Engin Aydogan Date: Thu, 19 Jun 2025 20:44:56 +0300 Subject: [PATCH 2/6] chore: umake lint happy and some cleanup --- README.md | 2 + dist/index.d.ts | 6 +++ dist/index.node.js | 2 +- src/utils.ts | 4 +- tests/cacheProxy.test.ts | 82 +++++++++++++++++++++++++--------------- 5 files changed, 64 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7c9ec65..79fab76 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ It could be used to save time and resources by caching the results of expensive An in memory cache is also provided for non-persistent caching for environments where fs is not available. +It should work both in Node.js and browser environments, but FileCacheStore is only available in Node.js. In browser environments, you can use MemCacheStorage or implement your own CacheStore interface. When MemCacheStorage is used, the cache will not be persistent and will be lost when the page is reloaded. + ## Usage ```typescript diff --git a/dist/index.d.ts b/dist/index.d.ts index 3da258c..17f14cd 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -8,6 +8,8 @@ declare module "cache-store" { export type Entry = { timestamp: number; value: unknown; + promise?: boolean; + rejected?: boolean; }; export const NotFound: unique symbol; export const SECOND = 1000; @@ -49,6 +51,10 @@ declare module "amemo.browser" { export * from "mem-cache-store"; export * from "cache-proxy"; } +declare module "utils" { + export function replacer(key: string, value: any): any; + export function reviver(key: string, value: any): any; +} declare module "file-cache-store" { import { MemCacheStore } from "mem-cache-store"; export type FileCacheStoreOpts = { diff --git a/dist/index.node.js b/dist/index.node.js index cddb3c2..41ccc0d 100644 --- a/dist/index.node.js +++ b/dist/index.node.js @@ -1 +1 @@ -var _=class{},f=Symbol("NotFound"),N=1e3,R=60*N,C=60*R,g=24*C,D=7*g;function S(n,e,t,r,c,a=""){if(r[a])return r[a];if(typeof n=="function"){let y=a+"/"+n.name,u=(...o)=>{let s=y+": "+JSON.stringify(o.map(m=>m===void 0?"undefined":m===null?"null":m)),h=e.get(s,t.defaultExpire??1*g);if(h!==f)return t.onHit?.(s,o),h;let d=n(...o);return e.set(s,d),t.onMiss?.(s,o),d};return r[a]=u,u}let b=new Proxy(n,{get(y,u){let o=a+"/"+u.toString(),s=y[u];if(typeof s=="function"){if(c[o])return c[o];let h=s.bind(y),{defaultExpire:d=1*g,pathExpire:m={},onHit:T=()=>{},onMiss:M=()=>{}}=t,j=m[o]??d,k=function(...x){let l=o+": "+JSON.stringify(x),w=e.get(l,j);if(w!==f)return T(l,x),w;let E=h(...x);return e.set(l,E),M(l,x),E};return c[o]=k,k}return s instanceof Object?S(y[u],e,t,r,c,o):s}});return r[a]=b,b}var p=class{constructor(e={}){this.cache=e}get(e,t){let r=this.cache[e];return r?Date.now()-r.timestamp>t?f:r.promise?r.rejected?Promise.reject(r.value):Promise.resolve(r.value):r.value:f}set(e,t){this.cache[e]={timestamp:Date.now(),value:t}}save(){}clear(){this.cache={}}};import*as i from"fs";import*as O from"path";function F(n,e){if(e instanceof Date)return{__type:"Date",value:e.toISOString()};if(e instanceof RegExp)return{__type:"RegExp",value:e.toString()};if(e instanceof Set)return{__type:"Set",value:Array.from(e)};if(e instanceof Map)return{__type:"Map",value:Array.from(e.entries())};if(typeof e=="bigint")return{__type:"BigInt",value:e.toString()};if(e===void 0)return{__type:"undefined"};if(e instanceof Promise)throw new Error("Cannot serialize a Promise directly. Please await it first.");return e instanceof Error?{__type:"Error",message:e.message,stack:e.stack}:e}function P(n,e){if(e&&typeof e=="object"&&e.__type)switch(e.__type){case"Date":return new Date(e.value);case"RegExp":{let t=e.value.match(/^\/(.*)\/([a-z]*)$/);if(t)return new RegExp(t[1],t[2]);throw new Error("Invalid RegExp format")}case"Set":return new Set(e.value);case"Map":return new Map(e.value);case"BigInt":return BigInt(e.value);case"undefined":return;case"Error":{let t=new Error(e.message);return t.stack=e.stack,t}}return e}var v=class extends p{constructor(t={}){super();this.opts=t;this.cacheFile=t.path??".amemo.json",this.autoSave=t.autoSave??!0;try{if(!i.existsSync(this.cacheFile))return;let r=i.readFileSync(this.cacheFile,"utf8");super.cache=JSON.parse(r,P)}catch{}}set(t,r){super.set(t,r),this.autoSave&&this.save()}async save(){for(let[t,r]of Object.entries(this.cache))if(r.value instanceof Promise){this.cache[t].promise=!0;try{this.cache[t].value=await r.value}catch(c){this.cache[t].value=c,this.cache[t].rejected=!0}}i.mkdirSync(O.dirname(this.cacheFile),{recursive:!0}),i.writeFileSync(this.cacheFile,JSON.stringify(this.cache,F))}clear(){super.clear(),i.unlinkSync(this.cacheFile)}};function X(n,e={}){let{cacheStore:t=new p}=e;return S(n,t,e,{},{})}export{_ as CacheStore,g as DAY,v as FileCacheStore,C as HOUR,R as MINUTE,p as MemCacheStore,f as NotFound,N as SECOND,D as WEEK,X as amemo,S as createProxy}; +var _=class{},f=Symbol("NotFound"),N=1e3,R=60*N,C=60*R,g=24*C,D=7*g;function S(n,e,t,r,c,a=""){if(r[a])return r[a];if(typeof n=="function"){let y=a+"/"+n.name,u=(...o)=>{let s=y+": "+JSON.stringify(o.map(m=>m===void 0?"undefined":m===null?"null":m)),h=e.get(s,t.defaultExpire??1*g);if(h!==f)return t.onHit?.(s,o),h;let d=n(...o);return e.set(s,d),t.onMiss?.(s,o),d};return r[a]=u,u}let k=new Proxy(n,{get(y,u){let o=a+"/"+u.toString(),s=y[u];if(typeof s=="function"){if(c[o])return c[o];let h=s.bind(y),{defaultExpire:d=1*g,pathExpire:m={},onHit:T=()=>{},onMiss:M=()=>{}}=t,j=m[o]??d,w=function(...x){let l=o+": "+JSON.stringify(x),b=e.get(l,j);if(b!==f)return T(l,x),b;let E=h(...x);return e.set(l,E),M(l,x),E};return c[o]=w,w}return s instanceof Object?S(y[u],e,t,r,c,o):s}});return r[a]=k,k}var p=class{constructor(e={}){this.cache=e}get(e,t){let r=this.cache[e];return r?Date.now()-r.timestamp>t?f:r.promise?r.rejected?Promise.reject(r.value):Promise.resolve(r.value):r.value:f}set(e,t){this.cache[e]={timestamp:Date.now(),value:t}}save(){}clear(){this.cache={}}};import*as i from"fs";import*as O from"path";function F(n,e){if(e instanceof Date)return{__type:"Date",value:e.toISOString()};if(e instanceof RegExp)return{__type:"RegExp",value:e.toString()};if(e instanceof Set)return{__type:"Set",value:Array.from(e)};if(e instanceof Map)return{__type:"Map",value:Array.from(e.entries())};if(typeof e=="bigint")return{__type:"BigInt",value:e.toString()};if(e===void 0)return{__type:"undefined"};if(e instanceof Promise)throw new Error("Cannot serialize a Promise directly. Please await it first.");return e instanceof Error?{__type:"Error",message:e.message,stack:e.stack}:e}function P(n,e){if(e&&typeof e=="object"&&e.__type)switch(e.__type){case"Date":return new Date(e.value);case"RegExp":{let t=e.value.match(/^\/(.*)\/([a-z]*)$/);if(t)return new RegExp(t[1],t[2]);throw new Error("Invalid RegExp format")}case"Set":return new Set(e.value);case"Map":return new Map(e.value);case"BigInt":return BigInt(e.value);case"undefined":return;case"Error":{let t=new Error(e.message);return t.stack=e.stack,t}}return e}var v=class extends p{constructor(t={}){super();this.opts=t;this.cacheFile=t.path??".amemo.json",this.autoSave=t.autoSave??!0;try{if(!i.existsSync(this.cacheFile))return;let r=i.readFileSync(this.cacheFile,"utf8");super.cache=JSON.parse(r,P)}catch{}}set(t,r){super.set(t,r),this.autoSave&&this.save()}async save(){for(let[t,r]of Object.entries(this.cache))if(r.value instanceof Promise){this.cache[t].promise=!0;try{this.cache[t].value=await r.value}catch(c){this.cache[t].value=c,this.cache[t].rejected=!0}}i.mkdirSync(O.dirname(this.cacheFile),{recursive:!0}),i.writeFileSync(this.cacheFile,JSON.stringify(this.cache,F))}clear(){super.clear(),i.unlinkSync(this.cacheFile)}};function X(n,e={}){let{cacheStore:t=new p}=e;return S(n,t,e,{},{})}export{_ as CacheStore,g as DAY,v as FileCacheStore,C as HOUR,R as MINUTE,p as MemCacheStore,f as NotFound,N as SECOND,D as WEEK,X as amemo,S as createProxy}; diff --git a/src/utils.ts b/src/utils.ts index d394b5b..f773643 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ -export function replacer(key: string, value: any) { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function replacer(key: string, value: unknown) { if (value instanceof Date) return { __type: "Date", value: value.toISOString() }; if (value instanceof RegExp) @@ -23,6 +24,7 @@ export function replacer(key: string, value: any) { return value; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function reviver(key: string, value: any) { if (value && typeof value === "object" && value.__type) { switch (value.__type) { diff --git a/tests/cacheProxy.test.ts b/tests/cacheProxy.test.ts index 6950a7f..b426633 100644 --- a/tests/cacheProxy.test.ts +++ b/tests/cacheProxy.test.ts @@ -9,6 +9,47 @@ const mockFs = fs as jest.Mocked; jest.useFakeTimers(); +class MockFileSystem { + private files: Record = {}; + + setup() { + mockFs.existsSync.mockImplementation((file) => { + return this.files[file as string] !== undefined; + }); + + mockFs.readFileSync.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (file) => this.files[file as string] as any, + ); + + mockFs.writeFileSync.mockImplementation( + (file, data) => (this.files[file as string] = data as string), + ); + } + + setFile(path: string, content: string) { + this.files[path] = content; + } + + getFile(path: string): string | undefined { + return this.files[path]; + } + + clear() { + this.files = {}; + } + + setupEmpty() { + mockFs.existsSync.mockReturnValue(false); + mockFs.readFileSync.mockReturnValue(""); + } + + setupWithCache(cacheContent: string) { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(cacheContent); + } +} + class NestedNestedTest { public barCalls = 0; public throwCalls = 0; @@ -44,10 +85,12 @@ class Test { } describe("amemo", () => { + let mockFileSystem: MockFileSystem; + beforeEach(() => { jest.clearAllMocks(); - mockFs.existsSync.mockReturnValue(false); - mockFs.readFileSync.mockReturnValue(""); + mockFileSystem = new MockFileSystem(); + mockFileSystem.setupEmpty(); }); it("must cache calls", async () => { let hit = 0; @@ -138,12 +181,9 @@ describe("amemo", () => { expect(c.nested.nested.bar()).toBe(1); }); - jest.mock("fs"); - it("must persist the cache", () => { const t = new Test(); - mockFs.existsSync.mockReturnValue(true); - mockFs.readFileSync.mockReturnValue( + mockFileSystem.setupWithCache( '{"/main: []": {"value": 1, "expire": 100}}', ); const c = amemo(t, { @@ -155,8 +195,7 @@ describe("amemo", () => { it("must handle broken cache", () => { const t = new Test(); - mockFs.existsSync.mockReturnValue(true); - mockFs.readFileSync.mockReturnValue("garbage"); + mockFileSystem.setupWithCache("garbage"); const c = amemo(t); expect(c.main()).toBe(0); expect(c.main()).toBe(0); @@ -190,14 +229,7 @@ describe("amemo", () => { }); it("must persist promises", async () => { - const mockFile: Record = {}; - mockFs.existsSync.mockReturnValue(false); - mockFs.readFileSync.mockImplementation( - (file) => mockFile[file as string] as any, - ); - mockFs.writeFileSync.mockImplementation( - (file, data) => (mockFile[file as string] = data as string), - ); + mockFileSystem.setup(); const t = new Test(); let cacheStore = new FileCacheStore(); @@ -207,10 +239,10 @@ describe("amemo", () => { expect(c.nested.foo()).toBeInstanceOf(Promise); expect(await c.nested.foo()).toBe(0); expect(await c.nested.foo()).toBe(0); - const parsed = JSON.parse(mockFile[".amemo.json"]); + const parsed = JSON.parse(mockFileSystem.getFile(".amemo.json")!); expect(parsed["/nested/foo: []"]["value"]).toBe(0); - mockFs.existsSync.mockReturnValue(true); - mockFs.readFileSync.mockReturnValue(mockFile[".amemo.json"]); + + mockFileSystem.setupWithCache(mockFileSystem.getFile(".amemo.json")!); const t2 = new Test(); cacheStore = new FileCacheStore(); const c2 = amemo(t2, { @@ -223,17 +255,8 @@ describe("amemo", () => { }); it("must persist rejected promises", async () => { - const mockFile: Record = {}; - mockFs.existsSync.mockImplementation((file) => { - return mockFile[file as string] !== undefined; - }); + mockFileSystem.setup(); - mockFs.readFileSync.mockImplementation( - (file) => mockFile[file as string] as any, - ); - mockFs.writeFileSync.mockImplementation( - (file, data) => (mockFile[file as string] = data as string), - ); const t = new Test(); let cacheStore = new FileCacheStore(); const c = amemo(t, { @@ -451,7 +474,6 @@ describe("amemo", () => { const c = amemo(nullableFoo); expect(c(undefined)).toBe("undefined-0"); expect(c(undefined)).toBe("undefined-0"); - console.log(mockFs.writeFileSync.mock.calls); expect(c(null)).toBe("null-1"); expect(c(null)).toBe("null-1"); expect(c("test")).toBe("test-2"); From 1b09c12992f2afa3f837d282088cbd468caa3041 Mon Sep 17 00:00:00 2001 From: Engin Aydogan Date: Thu, 19 Jun 2025 20:45:29 +0300 Subject: [PATCH 3/6] chore: bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 229809d..125e914 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amemo", - "version": "3.0.0", + "version": "3.1.0", "description": "amemo is an experimental drop-in typesafe memoization library", "repository": "https://github.com/enda-automation/amemo", "main": "dist/index.node.js", From c19a1b31d6af08f40e33e9552919234ef8ebc41b Mon Sep 17 00:00:00 2001 From: Engin Aydogan Date: Thu, 19 Jun 2025 20:46:16 +0300 Subject: [PATCH 4/6] chore: make lint happy --- tests/cacheProxy.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/cacheProxy.test.ts b/tests/cacheProxy.test.ts index b426633..44da838 100644 --- a/tests/cacheProxy.test.ts +++ b/tests/cacheProxy.test.ts @@ -183,9 +183,7 @@ describe("amemo", () => { it("must persist the cache", () => { const t = new Test(); - mockFileSystem.setupWithCache( - '{"/main: []": {"value": 1, "expire": 100}}', - ); + mockFileSystem.setupWithCache('{"/main: []": {"value": 1, "expire": 100}}'); const c = amemo(t, { cacheStore: new FileCacheStore(), }); @@ -241,7 +239,7 @@ describe("amemo", () => { expect(await c.nested.foo()).toBe(0); const parsed = JSON.parse(mockFileSystem.getFile(".amemo.json")!); expect(parsed["/nested/foo: []"]["value"]).toBe(0); - + mockFileSystem.setupWithCache(mockFileSystem.getFile(".amemo.json")!); const t2 = new Test(); cacheStore = new FileCacheStore(); From 5b405e23a4111f312de15e37036c66e13093a055 Mon Sep 17 00:00:00 2001 From: Engin Aydogan Date: Thu, 19 Jun 2025 21:00:47 +0300 Subject: [PATCH 5/6] chore: readme --- README.md | 53 ++++++++++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 79fab76..bb32330 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,16 @@ # amemo -**amemo** is an experimental drop-in, type safe, persistent (or not), zero-dependency memoization library. +**amemo** is an experimental drop-in, type-safe, persistent (or not), zero-dependency memoization library. -It could be used to save time and resources by caching the results of expensive function calls, such as paid or rate limited API calls. +It can be used to save time and resources by caching the results of expensive function calls, paid or rate-limited API calls. -An in memory cache is also provided for non-persistent caching for environments where fs is not available. +An in-memory cache is also provided for non-persistent caching in environments where the file system is not available. -It should work both in Node.js and browser environments, but FileCacheStore is only available in Node.js. In browser environments, you can use MemCacheStorage or implement your own CacheStore interface. When MemCacheStorage is used, the cache will not be persistent and will be lost when the page is reloaded. +It works in both Node.js and browser environments, but FileCacheStore is only available in Node.js. In browser environments, you can use MemCacheStore or implement your own CacheStore interface. When MemCacheStore is used, the cache will not be persistent and will be lost when the page is reloaded. + +> [!WARNING] +> If the function being cached has side effects (i.e., it modifies an input object), these side effects won't run when the function result is served from cache. ## Usage @@ -18,13 +21,13 @@ import {amemo} from 'amemo'; const complexType = new ComplexType(); const memoizedType = amemo(complexType); // drop-in replacement memoizedType.nested.method({a: 1, b: 2}); // This will be memoized -memoizedType.nested.method({a: 1, b: 2}); // Free real estate -memoizedType.nested.method({a: 1}); // Not **memoized** +memoizedType.nested.method({a: 1, b: 2}); // Cache hit - no execution +memoizedType.nested.method({a: 1}); // Different arguments - not cached ``` ## API -Options to configure, if you choose to do so. +Configuration options, if you choose to customize the behavior: ```typescript export type CacheProxyOpts = { @@ -51,12 +54,12 @@ export type CacheProxyOpts = { ```typescript export type FileCacheStoreOpts = { // Location of the cache file - // Path will be recursively created if it doesn't exist + // Directory will be created recursively if it doesn't exist // Default: './.amemo.json' path?: string; - // If True the cache will be written to disk on every cache miss. - // If False the cache will be written manually by calling the save method. + // If true, the cache will be written to disk on every cache miss + // If false, the cache must be saved manually by calling the save() method // Default: true autoSave?: boolean; }; @@ -64,19 +67,19 @@ export type FileCacheStoreOpts = { ## Performance -By default the library aims to be extremely easy to use and requires no configuration. It can be used as a drop in replacement for easy gains. +By default, the library aims to be extremely easy to use and requires no configuration. It can be used as a drop-in replacement for easy performance gains. -And it must be just fine for most use cases. However, if you are looking for more performance, you can configure the cache store to use a more performant cache store. +It should be sufficient for most use cases, given that cached operations inherently take long time, the caching mechanism cost should be negligible. However, if you need more performance, you can configure the cache store to use a more performant implementation. ### FileCacheStore #### Constructor -Reads and parses the cache file synchronously (once). +Reads and parses the cache file synchronously (once during initialization). #### set -Writes to the cache file synchronously when autoSave is true. Otherwise, save() method must be called by user to actually commit the cache to the disk. If not, cache store will act like an in-memory cache. +Writes to the cache file synchronously when autoSave is true. Otherwise, the save() method must be called manually to commit the cache to disk. If not called, the cache store will act like an in-memory cache. #### autoSave @@ -85,32 +88,32 @@ import {amemo, FileCacheStore} from 'amemo'; const cacheStore = new FileCacheStore({autoSave: false}); const complexType = new ComplexType(); -const memoizedType = amemo(complexType, {cacheStore}); // drop-in replacement +const memoizedType = amemo(complexType, {cacheStore}); memoizedType.nested.method({a: 1, b: 2}); // This will be memoized -memoizedType.nested.method({a: 1, b: 2}); // Free real estate -memoizedType.nested.method({a: 1}); // Not **memoized** +memoizedType.nested.method({a: 1, b: 2}); // Cache hit - no execution +memoizedType.nested.method({a: 1}); // Different arguments - not cached -// Save the cache to the disk -cacheStore.save(); // <-- Commit the cache to the disk, otherwise store will act like a in-memory cache +// Manually save the cache to disk +cacheStore.save(); // Commit the cache to disk, otherwise it acts like in-memory cache ``` #### MemCacheStore -You can also use an in-memory for non persistent caching. +You can also use an in-memory store for non-persistent caching: ```typescript -import {amemo, MemCacheStorage} from 'amemo'; +import {amemo, MemCacheStore} from 'amemo'; -const cacheStore = new MemCacheStorage(); +const cacheStore = new MemCacheStore(); const complexType = new ComplexType(); -const memoizedType = amemo(complexType, {cacheStore}); // drop-in replacement +const memoizedType = amemo(complexType, {cacheStore}); memoizedType.nested.method({a: 1, b: 2}); // This will be memoized -memoizedType.nested.method({a: 1, b: 2}); // Free real estate +memoizedType.nested.method({a: 1, b: 2}); // Cache hit - no execution ``` ### Alternative implementations -Alternative implementation, say a browser compatible interface like LocalStorage or IndexedDB , can be implemented by implementing the CacheStore interface. +Alternative implementations, such as browser-compatible interfaces like LocalStorage or IndexedDB, can be created by implementing the CacheStore interface: ```typescript export interface CacheStore { From c5555e4ed539986acf0d543b33c5b2cf98d76ae5 Mon Sep 17 00:00:00 2001 From: Engin Aydogan Date: Thu, 19 Jun 2025 21:06:49 +0300 Subject: [PATCH 6/6] docs: added a few more info --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index bb32330..2a3c637 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ It can be used to save time and resources by caching the results of expensive function calls, paid or rate-limited API calls. +It is designed to work with deeply nested objecst such as API SDKs, sync or async methods. + An in-memory cache is also provided for non-persistent caching in environments where the file system is not available. It works in both Node.js and browser environments, but FileCacheStore is only available in Node.js. In browser environments, you can use MemCacheStore or implement your own CacheStore interface. When MemCacheStore is used, the cache will not be persistent and will be lost when the page is reloaded. @@ -73,6 +75,13 @@ It should be sufficient for most use cases, given that cached operations inheren ### FileCacheStore +> [!WARNING] +> FileCacheStore tries to resolve all cached results that are promises. +> +> TODO: Add a timeout option +> +> If that proves to be a problem, turning auto save off might help. + #### Constructor Reads and parses the cache file synchronously (once during initialization).