diff --git a/src/__tests__/__snapshots__/server.caching.test.ts.snap b/src/__tests__/__snapshots__/server.caching.test.ts.snap index eb5e1ee..cd7b8e9 100644 --- a/src/__tests__/__snapshots__/server.caching.test.ts.snap +++ b/src/__tests__/__snapshots__/server.caching.test.ts.snap @@ -1,73 +1,810 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`memo should clear cache on inactivity, async: cache list length 1`] = ` +exports[`memo should clear cache on inactivity and fire "onCacheExpire", cacheLimit with NO cacheErrors, error last: async 1`] = ` [ - 2, - 2, - 4, - 4, - 6, - 6, - 2, - 2, - 4, - 4, - 6, - 6, + { + "all": [ + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", cacheLimit with NO cacheErrors, error last: sync 1`] = ` +[ + { + "all": [ + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", cacheLimit with NO cacheErrors: async 1`] = ` +[ + { + "all": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", cacheLimit with NO cacheErrors: sync 1`] = ` +[ + { + "all": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", cacheLimit with cacheErrors, error last: async 1`] = ` +[ + { + "all": [ + { + "reason": [Error: onCacheRollout-3], + "status": "rejected", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + "remaining": [], + "removed": [ + { + "reason": [Error: onCacheRollout-3], + "status": "rejected", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", cacheLimit with cacheErrors, error last: sync 1`] = ` +[ + { + "all": [ + { + "status": "fulfilled", + "value": [Function], + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": [Function], + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", cacheLimit with cacheErrors: async 1`] = ` +[ + { + "all": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "reason": [Error: onCacheRollout-3], + "status": "rejected", + }, + { + "status": "fulfilled", + "value": "2", + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "reason": [Error: onCacheRollout-3], + "status": "rejected", + }, + { + "status": "fulfilled", + "value": "2", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", cacheLimit with cacheErrors: sync 1`] = ` +[ + { + "all": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": [Function], + }, + { + "status": "fulfilled", + "value": "2", + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": [Function], + }, + { + "status": "fulfilled", + "value": "2", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", default with NO cacheErrors, error last: async 1`] = ` +[ + { + "all": [], + "remaining": [], + "removed": [], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", default with NO cacheErrors, error last: sync 1`] = ` +[ + { + "all": [], + "remaining": [], + "removed": [], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", default with NO cacheErrors: async 1`] = ` +[ + { + "all": [ + { + "status": "fulfilled", + "value": "3", + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": "3", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", default with NO cacheErrors: sync 1`] = ` +[ + { + "all": [ + { + "status": "fulfilled", + "value": "3", + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": "3", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", default with cacheErrors, error last: async 1`] = ` +[ + { + "all": [ + { + "reason": [Error: onCacheRollout-2], + "status": "rejected", + }, + ], + "remaining": [], + "removed": [ + { + "reason": [Error: onCacheRollout-2], + "status": "rejected", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", default with cacheErrors, error last: sync 1`] = ` +[ + { + "all": [ + { + "status": "fulfilled", + "value": [Function], + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": [Function], + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", default with cacheErrors: async 1`] = ` +[ + { + "all": [ + { + "status": "fulfilled", + "value": "3", + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": "3", + }, + ], + }, +] +`; + +exports[`memo should clear cache on inactivity and fire "onCacheExpire", default with cacheErrors: sync 1`] = ` +[ + { + "all": [ + { + "status": "fulfilled", + "value": "3", + }, + ], + "remaining": [], + "removed": [ + { + "status": "fulfilled", + "value": "3", + }, + ], + }, +] +`; + +exports[`memo should fire "onCacheRollout" callback on cache rollout, multiple entries removed, errors cached: async 1`] = ` +[ + { + "remaining": [ + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "reason": [Error: onCacheRollout-1], + "status": "rejected", + }, + ], + "removed": [ + { + "status": "fulfilled", + "value": "undefined", + }, + ], + }, + { + "remaining": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + ], + "removed": [ + { + "reason": [Error: onCacheRollout-1], + "status": "rejected", + }, + ], + }, +] +`; + +exports[`memo should fire "onCacheRollout" callback on cache rollout, multiple entries removed, errors cached: sync 1`] = ` +[ + { + "remaining": [ + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": [Function], + }, + ], + "removed": [ + { + "status": "fulfilled", + "value": "undefined", + }, + ], + }, + { + "remaining": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + ], + "removed": [ + { + "status": "fulfilled", + "value": [Function], + }, + ], + }, +] +`; + +exports[`memo should fire "onCacheRollout" callback on cache rollout, multiple entries removed, errors not cached: async 1`] = ` +[ + { + "remaining": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + ], + "removed": [ + { + "status": "fulfilled", + "value": "undefined", + }, + ], + }, ] `; -exports[`memo should memoize a function, async 1`] = ` +exports[`memo should fire "onCacheRollout" callback on cache rollout, multiple entries removed, errors not cached: sync 1`] = ` [ { + "remaining": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + ], + "removed": [ + { + "status": "fulfilled", + "value": "undefined", + }, + ], + }, +] +`; + +exports[`memo should fire "onCacheRollout" callback on cache rollout, multiple entries removed: async 1`] = ` +[ + { + "remaining": [ + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + "removed": [ + { + "status": "fulfilled", + "value": "undefined", + }, + ], + }, + { + "remaining": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + ], + "removed": [ + { + "status": "fulfilled", + "value": "1", + }, + ], + }, +] +`; + +exports[`memo should fire "onCacheRollout" callback on cache rollout, multiple entries removed: sync 1`] = ` +[ + { + "remaining": [ + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + "removed": [ + { + "status": "fulfilled", + "value": "undefined", + }, + ], + }, + { + "remaining": [ + { + "status": "fulfilled", + "value": "4", + }, + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + ], + "removed": [ + { + "status": "fulfilled", + "value": "1", + }, + ], + }, +] +`; + +exports[`memo should fire "onCacheRollout" callback on cache rollout, no entries removed: async 1`] = `[]`; + +exports[`memo should fire "onCacheRollout" callback on cache rollout, no entries removed: sync 1`] = `[]`; + +exports[`memo should fire "onCacheRollout" callback on cache rollout, one entry removed: async 1`] = ` +[ + { + "remaining": [ + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + "removed": [ + { + "status": "fulfilled", + "value": "undefined", + }, + ], + }, +] +`; + +exports[`memo should fire "onCacheRollout" callback on cache rollout, one entry removed: sync 1`] = ` +[ + { + "remaining": [ + { + "status": "fulfilled", + "value": "3", + }, + { + "status": "fulfilled", + "value": "2", + }, + { + "status": "fulfilled", + "value": "1", + }, + ], + "removed": [ + { + "status": "fulfilled", + "value": "undefined", + }, + ], + }, +] +`; + +exports[`memo should memoize a function, cacheLimit: async 1`] = ` +[ + { + "cacheKeys": [ + "97d170e1550eee4afc0af065b78cda302a97674c", + ], "cacheLength": 4, "errorValue": undefined, "successValue": "[EMPTY]", "type": "memo promise", }, { + "cacheKeys": [ + "97d170e1550eee4afc0af065b78cda302a97674c", + ], "cacheLength": 4, "errorValue": undefined, "successValue": "[EMPTY]", "type": "memo promise", }, { + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + "97d170e1550eee4afc0af065b78cda302a97674c", + ], "cacheLength": 4, "errorValue": undefined, "successValue": "1", "type": "memo promise", }, { + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + "97d170e1550eee4afc0af065b78cda302a97674c", + ], "cacheLength": 4, "errorValue": undefined, "successValue": "1", "type": "memo promise", }, { + "cacheKeys": [ + "2499831338ca5dc8c44f3d063e076799bea9bdff", + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + ], "cacheLength": 4, "errorValue": undefined, "successValue": "2", "type": "memo promise", }, { + "cacheKeys": [ + "2499831338ca5dc8c44f3d063e076799bea9bdff", + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + ], "cacheLength": 4, "errorValue": undefined, "successValue": "2", "type": "memo promise", }, { + "cacheKeys": [ + "f1e31df9806ce94c5bdbbfff9608324930f4d3f1", + "2499831338ca5dc8c44f3d063e076799bea9bdff", + ], "cacheLength": 4, "errorValue": undefined, "successValue": "3", "type": "memo promise", }, { + "cacheKeys": [ + "f1e31df9806ce94c5bdbbfff9608324930f4d3f1", + "2499831338ca5dc8c44f3d063e076799bea9bdff", + ], "cacheLength": 4, "errorValue": undefined, "successValue": "3", "type": "memo promise", }, { + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + "f1e31df9806ce94c5bdbbfff9608324930f4d3f1", + ], "cacheLength": 4, "errorValue": undefined, "successValue": "1", @@ -76,343 +813,676 @@ exports[`memo should memoize a function, async 1`] = ` ] `; -exports[`memo should memoize a function, async bypass memoization when cacheLimit is zero 1`] = ` +exports[`memo should memoize a function, cacheLimit: sync 1`] = ` [ { - "cacheLength": 0, + "cacheKeys": [ + "97d170e1550eee4afc0af065b78cda302a97674c", + ], + "cacheLength": 4, "errorValue": undefined, "successValue": "[EMPTY]", - "type": "memo bypass", + "type": "memo", }, { - "cacheLength": 0, + "cacheKeys": [ + "97d170e1550eee4afc0af065b78cda302a97674c", + ], + "cacheLength": 4, "errorValue": undefined, "successValue": "[EMPTY]", - "type": "memo bypass", + "type": "memo", }, { - "cacheLength": 0, + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + "97d170e1550eee4afc0af065b78cda302a97674c", + ], + "cacheLength": 4, "errorValue": undefined, "successValue": "1", - "type": "memo bypass", + "type": "memo", }, { - "cacheLength": 0, + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + "97d170e1550eee4afc0af065b78cda302a97674c", + ], + "cacheLength": 4, "errorValue": undefined, "successValue": "1", - "type": "memo bypass", + "type": "memo", }, { - "cacheLength": 0, - "errorValue": "2", - "successValue": undefined, - "type": "memo bypass", + "cacheKeys": [ + "2499831338ca5dc8c44f3d063e076799bea9bdff", + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + ], + "cacheLength": 4, + "errorValue": undefined, + "successValue": "2", + "type": "memo", }, { - "cacheLength": 0, - "errorValue": "2", - "successValue": undefined, - "type": "memo bypass", + "cacheKeys": [ + "2499831338ca5dc8c44f3d063e076799bea9bdff", + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + ], + "cacheLength": 4, + "errorValue": undefined, + "successValue": "2", + "type": "memo", + }, + { + "cacheKeys": [ + "f1e31df9806ce94c5bdbbfff9608324930f4d3f1", + "2499831338ca5dc8c44f3d063e076799bea9bdff", + ], + "cacheLength": 4, + "errorValue": undefined, + "successValue": "3", + "type": "memo", + }, + { + "cacheKeys": [ + "f1e31df9806ce94c5bdbbfff9608324930f4d3f1", + "2499831338ca5dc8c44f3d063e076799bea9bdff", + ], + "cacheLength": 4, + "errorValue": undefined, + "successValue": "3", + "type": "memo", + }, + { + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + "f1e31df9806ce94c5bdbbfff9608324930f4d3f1", + ], + "cacheLength": 4, + "errorValue": undefined, + "successValue": "1", + "type": "memo", }, ] `; -exports[`memo should memoize a function, async errors 1`] = ` +exports[`memo should memoize a function, default: async 1`] = ` [ { - "cacheLength": 4, - "errorValue": "[EMPTY]", - "successValue": undefined, + "cacheKeys": [ + "97d170e1550eee4afc0af065b78cda302a97674c", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "[EMPTY]", "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": "[EMPTY]", - "successValue": undefined, + "cacheKeys": [ + "97d170e1550eee4afc0af065b78cda302a97674c", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "[EMPTY]", "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": "4", - "successValue": undefined, + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "1", "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": "4", - "successValue": undefined, + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "1", "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": "5", - "successValue": undefined, + "cacheKeys": [ + "2499831338ca5dc8c44f3d063e076799bea9bdff", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "2", "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": "5", - "successValue": undefined, + "cacheKeys": [ + "2499831338ca5dc8c44f3d063e076799bea9bdff", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "2", "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": "6", - "successValue": undefined, + "cacheKeys": [ + "f1e31df9806ce94c5bdbbfff9608324930f4d3f1", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "3", "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": "6", - "successValue": undefined, + "cacheKeys": [ + "f1e31df9806ce94c5bdbbfff9608324930f4d3f1", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "3", "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": "4", - "successValue": undefined, + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "1", "type": "memo promise", }, ] `; -exports[`memo should memoize a function, async errors NOT cached 1`] = ` +exports[`memo should memoize a function, default: sync 1`] = ` [ { - "cacheLength": 4, - "errorValue": "7", - "successValue": undefined, - "type": "memo promise", + "cacheKeys": [ + "97d170e1550eee4afc0af065b78cda302a97674c", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "[EMPTY]", + "type": "memo", }, { - "cacheLength": 4, - "errorValue": "7", - "successValue": undefined, - "type": "memo promise", + "cacheKeys": [ + "97d170e1550eee4afc0af065b78cda302a97674c", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "[EMPTY]", + "type": "memo", }, { - "cacheLength": 4, + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + ], + "cacheLength": 2, "errorValue": undefined, - "successValue": "8", - "type": "memo promise", + "successValue": "1", + "type": "memo", }, { - "cacheLength": 4, + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + ], + "cacheLength": 2, "errorValue": undefined, - "successValue": "8", - "type": "memo promise", + "successValue": "1", + "type": "memo", }, { - "cacheLength": 4, - "errorValue": "9", - "successValue": undefined, - "type": "memo promise", + "cacheKeys": [ + "2499831338ca5dc8c44f3d063e076799bea9bdff", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "2", + "type": "memo", }, { - "cacheLength": 4, - "errorValue": "9", - "successValue": undefined, - "type": "memo promise", + "cacheKeys": [ + "2499831338ca5dc8c44f3d063e076799bea9bdff", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "2", + "type": "memo", }, { - "cacheLength": 4, - "errorValue": "7", - "successValue": undefined, - "type": "memo promise", + "cacheKeys": [ + "f1e31df9806ce94c5bdbbfff9608324930f4d3f1", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "3", + "type": "memo", + }, + { + "cacheKeys": [ + "f1e31df9806ce94c5bdbbfff9608324930f4d3f1", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "3", + "type": "memo", + }, + { + "cacheKeys": [ + "f629ae44b7b3dcfed444d363e626edf411ec69a8", + ], + "cacheLength": 2, + "errorValue": undefined, + "successValue": "1", + "type": "memo", }, ] `; -exports[`memo should memoize a function, bypass memoization when cacheLimit is zero 1`] = ` +exports[`memo should memoize a function, disable memoization when cacheLimit is zero: async 1`] = ` [ { + "cacheKeys": [], "cacheLength": 0, "errorValue": undefined, "successValue": "[EMPTY]", "type": "memo bypass", }, { + "cacheKeys": [], "cacheLength": 0, "errorValue": undefined, "successValue": "[EMPTY]", "type": "memo bypass", }, { + "cacheKeys": [], "cacheLength": 0, "errorValue": undefined, "successValue": "1", "type": "memo bypass", }, { + "cacheKeys": [], "cacheLength": 0, "errorValue": undefined, "successValue": "1", "type": "memo bypass", }, + { + "cacheKeys": [], + "cacheLength": 0, + "errorValue": "2", + "successValue": undefined, + "type": "memo bypass", + }, + { + "cacheKeys": [], + "cacheLength": 0, + "errorValue": "2", + "successValue": undefined, + "type": "memo bypass", + }, ] `; -exports[`memo should memoize a function, sync 1`] = ` +exports[`memo should memoize a function, disable memoization when cacheLimit is zero: sync 1`] = ` [ { - "cacheLength": 4, + "cacheKeys": [], + "cacheLength": 0, "errorValue": undefined, "successValue": "[EMPTY]", - "type": "memo", + "type": "memo bypass", }, { - "cacheLength": 4, + "cacheKeys": [], + "cacheLength": 0, "errorValue": undefined, "successValue": "[EMPTY]", - "type": "memo", + "type": "memo bypass", }, { - "cacheLength": 4, + "cacheKeys": [], + "cacheLength": 0, "errorValue": undefined, "successValue": "1", - "type": "memo", + "type": "memo bypass", }, { - "cacheLength": 4, + "cacheKeys": [], + "cacheLength": 0, "errorValue": undefined, "successValue": "1", - "type": "memo", + "type": "memo bypass", + }, +] +`; + +exports[`memo should memoize a function, errors NOT cached: async 1`] = ` +[ + { + "cacheKeys": [ + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, + "errorValue": "7", + "successValue": undefined, + "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": undefined, - "successValue": "2", - "type": "memo", + "cacheKeys": [ + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, + "errorValue": "7", + "successValue": undefined, + "type": "memo promise", }, { - "cacheLength": 4, + "cacheKeys": [ + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], + "cacheLength": 6, "errorValue": undefined, - "successValue": "2", - "type": "memo", + "successValue": "8", + "type": "memo promise", }, { - "cacheLength": 4, + "cacheKeys": [ + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], + "cacheLength": 6, "errorValue": undefined, - "successValue": "3", - "type": "memo", + "successValue": "8", + "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": undefined, - "successValue": "3", - "type": "memo", + "cacheKeys": [ + "fd8e473f708ab8fa24350d1ac63e0c31fd5c0bad", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], + "cacheLength": 6, + "errorValue": "9", + "successValue": undefined, + "type": "memo promise", }, { - "cacheLength": 4, + "cacheKeys": [ + "fd8e473f708ab8fa24350d1ac63e0c31fd5c0bad", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], + "cacheLength": 6, + "errorValue": "9", + "successValue": undefined, + "type": "memo promise", + }, + { + "cacheKeys": [ + "7f0204faf934498c81fdd57f586030926ad7184c", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], + "cacheLength": 6, + "errorValue": "7", + "successValue": undefined, + "type": "memo promise", + }, + { + "cacheKeys": [ + "e9310b0c165be166c43d717718981dd6c9379fbe", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], + "cacheLength": 6, "errorValue": undefined, - "successValue": "1", - "type": "memo", + "successValue": "10", + "type": "memo promise", }, ] `; -exports[`memo should memoize a function, sync errors 1`] = ` +exports[`memo should memoize a function, errors NOT cached: sync 1`] = ` [ { + "cacheKeys": [], "cacheLength": 4, - "errorValue": "[EMPTY]", + "errorValue": "7", "successValue": undefined, "type": "memo error", }, { + "cacheKeys": [], "cacheLength": 4, - "errorValue": "[EMPTY]", + "errorValue": "7", "successValue": undefined, "type": "memo error", }, { + "cacheKeys": [ + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], + "cacheLength": 6, + "errorValue": undefined, + "successValue": "8", + "type": "memo", + }, + { + "cacheKeys": [ + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], + "cacheLength": 6, + "errorValue": undefined, + "successValue": "8", + "type": "memo", + }, + { + "cacheKeys": [ + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], "cacheLength": 4, - "errorValue": "4", + "errorValue": "9", "successValue": undefined, "type": "memo error", }, { + "cacheKeys": [ + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], "cacheLength": 4, - "errorValue": "4", + "errorValue": "9", "successValue": undefined, "type": "memo error", }, { + "cacheKeys": [ + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], "cacheLength": 4, - "errorValue": "5", + "errorValue": "7", "successValue": undefined, "type": "memo error", }, { - "cacheLength": 4, - "errorValue": "5", + "cacheKeys": [ + "e9310b0c165be166c43d717718981dd6c9379fbe", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], + "cacheLength": 6, + "errorValue": undefined, + "successValue": "10", + "type": "memo", + }, +] +`; + +exports[`memo should memoize a function, errors cached: async 1`] = ` +[ + { + "cacheKeys": [ + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, + "errorValue": "7", "successValue": undefined, - "type": "memo error", + "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": "6", + "cacheKeys": [ + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, + "errorValue": "7", "successValue": undefined, - "type": "memo error", + "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": "6", + "cacheKeys": [ + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, + "errorValue": undefined, + "successValue": "8", + "type": "memo promise", + }, + { + "cacheKeys": [ + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, + "errorValue": undefined, + "successValue": "8", + "type": "memo promise", + }, + { + "cacheKeys": [ + "fd8e473f708ab8fa24350d1ac63e0c31fd5c0bad", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, + "errorValue": "9", "successValue": undefined, - "type": "memo error", + "type": "memo promise", }, { - "cacheLength": 4, - "errorValue": "4", + "cacheKeys": [ + "fd8e473f708ab8fa24350d1ac63e0c31fd5c0bad", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, + "errorValue": "9", "successValue": undefined, - "type": "memo error", + "type": "memo promise", + }, + { + "cacheKeys": [ + "fd8e473f708ab8fa24350d1ac63e0c31fd5c0bad", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, + "errorValue": "7", + "successValue": undefined, + "type": "memo promise", + }, + { + "cacheKeys": [ + "e9310b0c165be166c43d717718981dd6c9379fbe", + "fd8e473f708ab8fa24350d1ac63e0c31fd5c0bad", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], + "cacheLength": 6, + "errorValue": undefined, + "successValue": "10", + "type": "memo promise", }, ] `; -exports[`memo should memoize a function, sync errors NOT cached 1`] = ` +exports[`memo should memoize a function, errors cached: sync 1`] = ` [ { - "cacheLength": 2, + "cacheKeys": [ + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, "errorValue": "7", "successValue": undefined, "type": "memo error", }, { - "cacheLength": 2, + "cacheKeys": [ + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, "errorValue": "7", "successValue": undefined, "type": "memo error", }, { - "cacheLength": 4, + "cacheKeys": [ + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, "errorValue": undefined, "successValue": "8", "type": "memo", }, { - "cacheLength": 4, + "cacheKeys": [ + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, "errorValue": undefined, "successValue": "8", "type": "memo", }, { - "cacheLength": 2, + "cacheKeys": [ + "fd8e473f708ab8fa24350d1ac63e0c31fd5c0bad", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, "errorValue": "9", "successValue": undefined, "type": "memo error", }, { - "cacheLength": 2, + "cacheKeys": [ + "fd8e473f708ab8fa24350d1ac63e0c31fd5c0bad", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, "errorValue": "9", "successValue": undefined, "type": "memo error", }, { - "cacheLength": 2, + "cacheKeys": [ + "fd8e473f708ab8fa24350d1ac63e0c31fd5c0bad", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + "7f0204faf934498c81fdd57f586030926ad7184c", + ], + "cacheLength": 6, "errorValue": "7", "successValue": undefined, "type": "memo error", }, + { + "cacheKeys": [ + "e9310b0c165be166c43d717718981dd6c9379fbe", + "fd8e473f708ab8fa24350d1ac63e0c31fd5c0bad", + "1fb0856518ee0490ff78e43d1b6dae12ad6ec686", + ], + "cacheLength": 6, + "errorValue": undefined, + "successValue": "10", + "type": "memo", + }, ] `; diff --git a/src/__tests__/__snapshots__/server.helpers.test.ts.snap b/src/__tests__/__snapshots__/server.helpers.test.ts.snap index eb6018d..7675972 100644 --- a/src/__tests__/__snapshots__/server.helpers.test.ts.snap +++ b/src/__tests__/__snapshots__/server.helpers.test.ts.snap @@ -1,22 +1,71 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`generateHash should minimally generate a consistent hash: hash, object and primitive values 1`] = ` -{ - "valueArray": "0e8e2b3096e6aee20e3cd10b7788ce3d72502061", - "valueArrayConfirmSort": true, - "valueArraySort": "3c7765817d3c04f1104093994b6db4371672d3f7", - "valueBoolFalse": "c0338288abd30f72cd65dad83833448f345f78fa", - "valueBoolTrue": "d991ef01679b350a728c665383abb257cfeca53a", - "valueFloat": "ba0abc40d5bc3a257ecf9396e3b2525d7fff76c9", - "valueInt": "17dada6f0f5fe358d94f7b0849d17fc8b4c5ce1f", - "valueNull": "f7fd41b8a32014e6ccd665899be623c6d5c99d43", - "valueObject": "5a7036ff63f04efcc11d6b68eb97a4b9989d1a45", - "valueObjectConfirm": true, - "valueObjectConfirmSort": false, - "valueSet": "86838cc5365d6e9d59fb005d2353dde173712a8a", - "valueSetConfirmSort": true, - "valueSymbol": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f", - "valueSymbolUndefined": true, - "valueUndefined": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f", -} -`; +exports[`generateHash should generate a consistent hash, array of objects, reversed order 1`] = `"06202b82cd89a6fcd7fa23b2f18e971670a1a7e6"`; + +exports[`generateHash should generate a consistent hash, array of objects, same order 1`] = `"06202b82cd89a6fcd7fa23b2f18e971670a1a7e6"`; + +exports[`generateHash should generate a consistent hash, array, reversed order 1`] = `"9ef50cc82ae474279fb8e82896142702bccbb33a"`; + +exports[`generateHash should generate a consistent hash, array, same order 1`] = `"9ef50cc82ae474279fb8e82896142702bccbb33a"`; + +exports[`generateHash should generate a consistent hash, bigint 1`] = `"b78029581c6dfdf18a0d1aac46ab6db99857352a"`; + +exports[`generateHash should generate a consistent hash, boolean 1`] = `"5ffe533b830f08a0326348a9160afafc8ada44db"`; + +exports[`generateHash should generate a consistent hash, circular reference 1`] = `"c1de315e4682f69d3704339f7e564ef74e81d29f"`; + +exports[`generateHash should generate a consistent hash, date 1`] = `"ea45cc0aaf078c41d281bc969b1e21d66aaff9c4"`; + +exports[`generateHash should generate a consistent hash, error 1`] = `"231aa275786b4856cfaf512aad2c3e4c59147f58"`; + +exports[`generateHash should generate a consistent hash, function 1`] = `"adf4394f45bb2dee237a9c9dadc2883a31ae7b18"`; + +exports[`generateHash should generate a consistent hash, map of objects, reversed order 1`] = `"d7534cc9388daa961fe01876d91a001b9d02bf1d"`; + +exports[`generateHash should generate a consistent hash, map of objects, same order 1`] = `"d7534cc9388daa961fe01876d91a001b9d02bf1d"`; + +exports[`generateHash should generate a consistent hash, map with number vs string number 1`] = `"05103e8b4f9b402e4e105ce29df53b8088417ddc"`; + +exports[`generateHash should generate a consistent hash, map, reversed order 1`] = `"c9eeacc4516e41a78667977243637cfb357d90ac"`; + +exports[`generateHash should generate a consistent hash, map, same order 1`] = `"c9eeacc4516e41a78667977243637cfb357d90ac"`; + +exports[`generateHash should generate a consistent hash, null 1`] = `"cc05f953e4621d654a1d43e39e1d6e6c6e7a0fc3"`; + +exports[`generateHash should generate a consistent hash, number, Infinity 1`] = `"8e277d4bfce0a1cb296114da4e9bf35325b6622d"`; + +exports[`generateHash should generate a consistent hash, number, NaN 1`] = `"bd0232df3e7c88859c4ede3dbfe3adbbb453413b"`; + +exports[`generateHash should generate a consistent hash, number, float 1`] = `"4d0db10189f2d1123b8cc3662d6efa722dcda187"`; + +exports[`generateHash should generate a consistent hash, number, int 1`] = `"9f9af029585ba014e07cd3910ca976cf56160616"`; + +exports[`generateHash should generate a consistent hash, number, negative Infinity 1`] = `"4bb1bdf868dc0daa4333ce0792fbb259772fe8c3"`; + +exports[`generateHash should generate a consistent hash, number, negative zero 1`] = `"eb46b7bb25a84f2342ca26dcc3767aa219ba4387"`; + +exports[`generateHash should generate a consistent hash, number, negative zero inside array 1`] = `"5437d2bd40f9815cc024cc0978053e1de30eff8f"`; + +exports[`generateHash should generate a consistent hash, plain object 1`] = `"f6c24541e7d20e6c541a08c6c51f39163d414361"`; + +exports[`generateHash should generate a consistent hash, plain object with nested values, reversed order 1`] = `"db24bc1ae2d7f644dac2582d96a2bafa13a81e80"`; + +exports[`generateHash should generate a consistent hash, plain object with nested values, same order 1`] = `"db24bc1ae2d7f644dac2582d96a2bafa13a81e80"`; + +exports[`generateHash should generate a consistent hash, regex 1`] = `"c93bde1202770e81192f329e00d776453fdb539b"`; + +exports[`generateHash should generate a consistent hash, set of objects, reversed order 1`] = `"99ee65bbcc2b8c6694e201c2e9e6442582f9b973"`; + +exports[`generateHash should generate a consistent hash, set of objects, same order 1`] = `"99ee65bbcc2b8c6694e201c2e9e6442582f9b973"`; + +exports[`generateHash should generate a consistent hash, set, reversed order 1`] = `"eacc79e3a94672558bd2a81d42eeed3664906fae"`; + +exports[`generateHash should generate a consistent hash, set, same order 1`] = `"eacc79e3a94672558bd2a81d42eeed3664906fae"`; + +exports[`generateHash should generate a consistent hash, string 1`] = `"8af0dd24cbbe67fa6c55e2641f1e93b76f888ae5"`; + +exports[`generateHash should generate a consistent hash, symbols, different instances AND with different strings will NOT return the same 1`] = `"b3f594684e6b3954940c8f206a54a79f153a2424"`; + +exports[`generateHash should generate a consistent hash, symbols, different instances BUT with same string will return the same 1`] = `"b3f594684e6b3954940c8f206a54a79f153a2424"`; + +exports[`generateHash should generate a consistent hash, undefined 1`] = `"19dafbceef2fcb380547ee9dc4c690a2f9e08d07"`; diff --git a/src/__tests__/server.caching.test.ts b/src/__tests__/server.caching.test.ts index 374d60b..48da3d5 100644 --- a/src/__tests__/server.caching.test.ts +++ b/src/__tests__/server.caching.test.ts @@ -11,28 +11,64 @@ describe('memo', () => { it.each([ { - description: 'sync', + description: 'default', + options: {}, + params: [[], [], [1], [1], [2], [2], [3], [3], [1]] + }, + { + description: 'cacheLimit', options: { cacheLimit: 2 }, params: [[], [], [1], [1], [2], [2], [3], [3], [1]] }, { - description: 'sync errors', - options: { cacheLimit: 2, cacheErrors: true }, - params: [[, true], [, true], [4, true], [4, true], [5, true], [5, true], [6, true], [6, true], [4, true]] + description: 'errors cached', + options: { cacheLimit: 3, cacheErrors: true }, + params: [[7, true], [7, true], [8], [8], [9, true], [9, true], [7, true], [10]] }, { - description: 'sync errors NOT cached', - options: { cacheLimit: 2, cacheErrors: false }, - params: [[7, true], [7, true], [8], [8], [9, true], [9, true], [7, true]] + description: 'errors NOT cached', + options: { cacheLimit: 3, cacheErrors: false }, + params: [[7, true], [7, true], [8], [8], [9, true], [9, true], [7, true], [10]] }, { - description: 'bypass memoization when cacheLimit is zero', + description: 'disable memoization when cacheLimit is zero', options: { cacheLimit: 0 }, params: [[], [], [1], [1], [2, true], [2, true]] } - ])('should memoize a function, $description', ({ options, params }) => { - const log: { type: string; value: () => string; cache: string[] }[] = []; + ])('should memoize a function, $description', async ({ options, params }) => { + const log: any[] = []; + const logAsync: any[] = []; const debug = (response: any) => log.push(response); + const debugAsync = (response: any) => logAsync.push(response); + const updateLog = async (aLog: any) => { + const updatedLog = []; + + for (const { type, value, cache } of aLog) { + let successValue; + let errorValue; + + try { + successValue = await value(); + } catch (e) { + const error = e as Error; + + errorValue = error.message; + } + + successValue = successValue?.split?.('-')[1]; + errorValue = errorValue?.split?.('-')[1]; + + updatedLog.push({ + type, + successValue, + errorValue, + cacheKeys: cache.filter((_value: any, index: number) => index % 2 === 0).filter(Boolean), + cacheLength: cache.length + }); + } + + return updatedLog; + }; const memoized = memo( (str, isError = false) => { @@ -49,142 +85,274 @@ describe('memo', () => { { debug, ...options } ); + const memoizedAsync = memo( + async (str, isError = false) => { + const arr = ['lorem', 'ipsum', 'dolor', 'sit']; + const randomStr = Math.floor(Math.random() * arr.length); + const genStr = `${arr[randomStr]}-${str || '[EMPTY]'}`; + + if (isError) { + throw new Error(genStr); + } + + return genStr; + }, + { debug: debugAsync, ...options } + ); + for (const param of params) { try { - memoized(...param as [unknown, unknown]); + memoized(...param as [unknown]); } catch {} } - const updatedLog = []; - - for (const { type, value, cache } of log) { - let successValue; - let errorValue; - + for (const param of params) { try { - successValue = value(); - } catch (e) { - const error = e as Error; - - errorValue = error.message; - } - - successValue = successValue?.split?.('-')[1]; - errorValue = errorValue?.split?.('-')[1]; - - updatedLog.push({ type, successValue, errorValue, cacheLength: cache.length }); + await memoizedAsync(...param as [unknown]); + } catch {} } - expect(updatedLog).toMatchSnapshot(); + await expect(updateLog(log)).resolves.toMatchSnapshot('sync'); + await expect(updateLog(logAsync)).resolves.toMatchSnapshot('async'); }); it.each([ { - description: 'async', - options: { cacheLimit: 2 }, - params: [[], [], [1], [1], [2], [2], [3], [3], [1]] + description: 'default with cacheErrors', + options: { expire: 10, cacheErrors: true }, + params: [[1], [1], [2, true], [2, true], [3], [3]], + pause: 70 }, { - description: 'async errors', - options: { cacheLimit: 2, cacheErrors: true }, - params: [[, true], [, true], [4, true], [4, true], [5, true], [5, true], [6, true], [6, true], [4, true]] + description: 'default with cacheErrors, error last', + options: { expire: 10, cacheErrors: true }, + params: [[1], [1], [2, true], [2, true]], + pause: 70 }, { - description: 'async errors NOT cached', - options: { cacheLimit: 2, cacheErrors: false }, - params: [[7, true], [7, true], [8], [8], [9, true], [9, true], [7, true]] + description: 'default with NO cacheErrors', + options: { expire: 10, cacheErrors: false }, + params: [[1], [1], [2, true], [2, true], [3], [3]], + pause: 70 }, { - description: 'async bypass memoization when cacheLimit is zero', - options: { cacheLimit: 0 }, - params: [[], [], [1], [1], [2, true], [2, true]] + description: 'default with NO cacheErrors, error last', + options: { expire: 10, cacheErrors: false }, + params: [[1], [1], [2, true], [2, true]], + pause: 70 + }, + { + description: 'cacheLimit with cacheErrors', + options: { cacheLimit: 3, expire: 10, cacheErrors: true }, + params: [[], [], [1], [1], [2], [2], [3, true], [3, true], [4], [4]], + pause: 70 + }, + { + description: 'cacheLimit with cacheErrors, error last', + options: { cacheLimit: 3, expire: 10, cacheErrors: true }, + params: [[], [], [1], [1], [2], [2], [3, true], [3, true]], + pause: 70 + }, + { + description: 'cacheLimit with NO cacheErrors', + options: { cacheLimit: 3, expire: 10, cacheErrors: false }, + params: [[], [], [1], [1], [2], [2], [3, true], [3, true], [4], [4]], + pause: 70 + }, + { + description: 'cacheLimit with NO cacheErrors, error last', + options: { cacheLimit: 3, expire: 10, cacheErrors: false }, + params: [[], [], [1], [1], [2], [2], [3, true], [3, true]], + pause: 70 } - ])('should memoize a function, $description', async ({ options, params }) => { - const log: { type: string; value: () => Promise; cache: string[] }[] = []; - const debug = (response: any) => log.push(response); + ])('should clear cache on inactivity and fire "onCacheExpire", $description', async ({ options, params, pause }) => { + const log: any[] = []; + const logAsync: any[] = []; + const mockOnCacheExpire = (response: unknown) => { + log.push(response); + }; + const mockOnCacheExpireAsync = async (response: unknown) => { + logAsync.push(response); + }; + const updateLog = async (aLog: any) => { + const sanitizedLog = []; + + for (const { all, removed, remaining } of aLog) { + const sanitized = { + all: [] as any[], + removed: [] as any[], + remaining: [] as any[] + }; + let updatedAll: any[] = []; + let updatedRemaining: any[] = []; + let updatedRemoved: any[] = []; + + updatedAll = await Promise.allSettled(all); + updatedRemoved = await Promise.allSettled(removed); + updatedRemaining = await Promise.allSettled(remaining); + + if (updatedAll) { + sanitized.all.push(...updatedAll); + } + + if (updatedRemoved) { + sanitized.removed.push(...updatedRemoved); + } + if (updatedRemaining) { + sanitized.remaining.push(...updatedRemaining); + } + sanitizedLog.push(sanitized); + } + + return sanitizedLog; + }; + const memoized = memo( - async (str, isError = false) => { - const arr = ['lorem', 'ipsum', 'dolor', 'sit']; - const randomStr = Math.floor(Math.random() * arr.length); - const genStr = `${arr[randomStr]}-${str || '[EMPTY]'}`; + (str: any, isError = false) => { + const genStr = `${str}`; if (isError) { - throw new Error(genStr); + throw new Error(`onCacheRollout-${genStr}`); } return genStr; }, - { debug, ...options } + { onCacheExpire: mockOnCacheExpire, ...options } ); - try { - await Promise.all(params.map(param => memoized(...param as [unknown, unknown]))); - } catch {} + const memoizedAsync = memo( + async (str: any, isError = false) => { + const genStr = `${str}`; - const updatedLog = []; + if (isError) { + throw new Error(`onCacheRollout-${genStr}`); + } - for (const { type, value, cache } of log) { - let successValue; - let errorValue; + return genStr; + }, + { onCacheExpire: mockOnCacheExpireAsync, ...options } + ); + for (const param of params) { try { - successValue = await value(); - } catch (e) { - const error = e as Error; - - errorValue = error.message; - } + await memoized(...param as [unknown]); + } catch {} + } - successValue = successValue?.split?.('-')[1]; - errorValue = errorValue?.split?.('-')[1]; + jest.advanceTimersByTime(pause); - updatedLog.push({ type, successValue, errorValue, cacheLength: cache.length }); + for (const param of params) { + try { + await memoizedAsync(...param as [unknown]); + } catch {} } - expect(updatedLog).toMatchSnapshot(); + jest.advanceTimersByTime(pause); + + await expect(updateLog(log)).resolves.toMatchSnapshot('sync'); + await expect(updateLog(logAsync)).resolves.toMatchSnapshot('async'); }); it.each([ { - description: 'async', - options: { cacheLimit: 3, expire: 10 }, - paramsOne: [[], [], [1], [1], [2], [2]], - paramsTwo: [[3, true], [3, true], [4, true], [4, true], [5, true], [5, true]], - pause: 70 + description: 'no entries removed', + options: { cacheLimit: 3 }, + params: [[], [], [1], [1], [2], [2]] + }, + { + description: 'one entry removed', + options: { cacheLimit: 3 }, + params: [[], [], [1], [1], [2], [2], [3]] + }, + { + description: 'multiple entries removed', + options: { cacheLimit: 3 }, + params: [[], [], [1], [1], [2], [2], [3], [4]] + }, + { + description: 'multiple entries removed, errors not cached', + options: { cacheLimit: 3, cacheErrors: false }, + params: [[], [], [1, true], [1, true], [2], [2], [3], [4]] + }, + { + description: 'multiple entries removed, errors cached', + options: { cacheLimit: 3, cacheErrors: true }, + params: [[], [], [1, true], [1, true], [2], [2], [3], [4]] } - ])('should clear cache on inactivity, $description', async ({ options, paramsOne, paramsTwo, pause }) => { - const log: { type: string; value: () => Promise; cache: string[] }[] = []; - const debug = (response: any) => log.push(response); - const memoized = memo(async (str, isError = false) => { - const arr = ['lorem', 'ipsum', 'dolor', 'sit']; - const randomStr = Math.floor(Math.random() * arr.length); - const genStr = `${arr[randomStr]}-${str || '[EMPTY]'}`; - - if (isError) { - throw new Error(genStr); + ])('should fire "onCacheRollout" callback on cache rollout, $description', async ({ options, params }) => { + const log: any[] = []; + const logAsync: any[] = []; + const mockOnCacheRollout = (response: unknown) => { + log.push(response); + }; + const mockOnCacheRolloutAsync = async (response: unknown) => { + logAsync.push(response); + }; + const updateLog = async (aLog: any) => { + const sanitizedLog = []; + + for (const { removed, remaining } of aLog) { + const sanitized = { + removed: [] as any[], + remaining: [] as any[] + }; + let updatedRemaining: any[] = []; + let updatedRemoved: any[] = []; + + updatedRemoved = await Promise.allSettled(removed); + updatedRemaining = await Promise.allSettled(remaining); + + if (updatedRemoved) { + sanitized.removed.push(...updatedRemoved); + } + if (updatedRemaining) { + sanitized.remaining.push(...updatedRemaining); + } + sanitizedLog.push(sanitized); } - return genStr; - }, { debug, ...options }); + return sanitizedLog; + }; - try { - await Promise.all(paramsOne.map(param => memoized(...param as [unknown]))); - } catch {} + const memoized = memo( + (str: any, isError = false) => { + const genStr = `${str}`; - jest.advanceTimersByTime(pause); + if (isError) { + throw new Error(`onCacheRollout-${genStr}`); + } - try { - await Promise.all(paramsTwo.map(param => memoized(...param as [unknown, unknown]))); - } catch {} + return genStr; + }, + { onCacheRollout: mockOnCacheRollout, ...options } + ); - jest.advanceTimersByTime(pause); + const memoizedAsync = memo( + async (str: any, isError = false) => { + const genStr = `${str}`; - const updatedLog = []; + if (isError) { + throw new Error(`onCacheRollout-${genStr}`); + } + + return genStr; + }, + { onCacheRollout: mockOnCacheRolloutAsync, ...options } + ); + + for (const param of params) { + try { + await memoized(...param as [unknown]); + } catch {} + } - for (const { cache } of log) { - updatedLog.push(cache.filter(Boolean).length); + for (const param of params) { + try { + await memoizedAsync(...param as [unknown]); + } catch {} } - expect(updatedLog).toMatchSnapshot('cache list length'); + await expect(updateLog(log)).resolves.toMatchSnapshot('sync'); + await expect(updateLog(logAsync)).resolves.toMatchSnapshot('async'); }); }); diff --git a/src/__tests__/server.helpers.test.ts b/src/__tests__/server.helpers.test.ts index 8451ee0..a1b631b 100644 --- a/src/__tests__/server.helpers.test.ts +++ b/src/__tests__/server.helpers.test.ts @@ -1,29 +1,295 @@ -import { generateHash, isPromise } from '../server.helpers'; +import { generateHash, hashCode, isPlainObject, isPromise } from '../server.helpers'; describe('generateHash', () => { - it('should minimally generate a consistent hash', () => { - expect({ - valueObject: generateHash({ lorem: 'ipsum', dolor: ['sit', null, undefined, 1, () => 'hello world'] }), - valueObjectConfirm: - generateHash({ lorem: 'ipsum', dolor: ['sit', null, undefined, 1, () => 'hello world'] }) === - generateHash({ lorem: 'ipsum', dolor: ['sit', null, undefined, 1, () => 'hello world'] }), - valueObjectConfirmSort: - generateHash({ lorem: 'ipsum', dolor: ['sit', null, undefined, 1, () => 'hello world'] }) === - generateHash({ dolor: ['sit', null, undefined, 1, () => 'hello world'], lorem: 'ipsum' }), - valueInt: generateHash(200), - valueFloat: generateHash(20.000006), - valueNull: generateHash(null), - valueUndefined: generateHash(undefined), - valueArray: generateHash([1, 2, 3]), - valueArraySort: generateHash([3, 2, 1]), - valueArrayConfirmSort: generateHash([1, 2, 3]) !== generateHash([3, 2, 1]), - valueSet: generateHash(new Set([1, 2, 3])), - valueSetConfirmSort: generateHash(new Set([1, 2, 3])) === generateHash(new Set([3, 2, 1])), - valueSymbol: generateHash(Symbol('lorem ipsum')), - valueSymbolUndefined: generateHash(Symbol('lorem ipsum')) === generateHash(undefined), - valueBoolTrue: generateHash(true), - valueBoolFalse: generateHash(false) - }).toMatchSnapshot('hash, object and primitive values'); + it.each([ + { + description: 'null', + value: null, + comparisonValue: undefined, + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'undefined', + value: undefined, + comparisonValue: null, + expectedComparison: false, + expectedUndefined: true + }, + { + description: 'string', + value: 'lorem ipsum', + comparisonValue: 'ipsum lorem', + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'number, int', + value: 200, + comparisonValue: 200.000006, + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'number, float', + value: 200.000006, + comparisonValue: 200, + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'number, NaN', + value: NaN, + comparisonValue: null, + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'number, Infinity', + value: Infinity, + comparisonValue: null, + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'number, negative Infinity', + value: -Infinity, + comparisonValue: null, + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'number, negative zero', + value: -0, + comparisonValue: 0, + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'number, negative zero inside array', + value: [-0], + comparisonValue: [0], + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'boolean', + value: true, + comparisonValue: false, + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'bigint', + value: BigInt(200), + comparisonValue: BigInt(201), + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'function', + value: () => 'lorem ipsum', + comparisonValue: () => 'ipsum lorem', + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'symbols, different instances BUT with same string will return the same', + value: Symbol('lorem ipsum'), + comparisonValue: Symbol('lorem ipsum'), + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'symbols, different instances AND with different strings will NOT return the same', + value: Symbol('lorem ipsum'), + comparisonValue: Symbol('ipsum lorem'), + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'array, same order', + value: [1, 2, 3], + comparisonValue: [1, 2, 3], + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'array, reversed order', + value: [1, 2, 3], + comparisonValue: [3, 2, 1], + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'array of objects, same order', + value: [{ 1: 'lorem' }, { 2: 'ipsum' }], + comparisonValue: [{ 1: 'lorem' }, { 2: 'ipsum' }], + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'array of objects, reversed order', + value: [{ 1: 'lorem' }, { 2: 'ipsum' }], + comparisonValue: [{ 2: 'ipsum' }, { 1: 'lorem' }], + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'error', + value: new Error('lorem ipsum'), + comparisonValue: new Error('ipsum lorem'), + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'date', + value: new Date('2023-01-01'), + comparisonValue: new Date('2023-01-02'), + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'regex', + value: /lorem/g, + comparisonValue: /ipsum/g, + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'map, same order', + value: new Map([['lorem', 1], ['ipsum', 2]]), + comparisonValue: new Map([['lorem', 1], ['ipsum', 2]]), + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'map, reversed order', + value: new Map([['lorem', 1], ['ipsum', 2]]), + comparisonValue: new Map([['ipsum', 2], ['lorem', 1]]), + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'map with number vs string number', + value: new Map([[1, 'a']]), + comparisonValue: new Map([['1', 'a']]), + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'map of objects, same order', + value: new Map([['lorem', { 1: 'lorem' }], ['ipsum', { 2: 'ipsum' }]]), + comparisonValue: new Map([['lorem', { 1: 'lorem' }], ['ipsum', { 2: 'ipsum' }]]), + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'map of objects, reversed order', + value: new Map([['lorem', { 1: 'lorem' }], ['ipsum', { 2: 'ipsum' }]]), + comparisonValue: new Map([['ipsum', { 2: 'ipsum' }], ['lorem', { 1: 'lorem' }]]), + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'set, same order', + value: new Set([1, 2, 3]), + comparisonValue: new Set([1, 2, 3]), + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'set, reversed order', + value: new Set([1, 2, 3]), + comparisonValue: new Set([3, 2, 1]), + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'set of objects, same order', + value: new Set([{ 1: 'lorem' }, { 2: 'ipsum' }]), + comparisonValue: new Set([{ 1: 'lorem' }, { 2: 'ipsum' }]), + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'set of objects, reversed order', + value: new Set([{ 1: 'lorem' }, { 2: 'ipsum' }]), + comparisonValue: new Set([{ 2: 'ipsum' }, { 1: 'lorem' }]), + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'plain object', + value: { lorem: 'ipsum' }, + comparisonValue: { dolor: 'sit amet' }, + expectedComparison: false, + expectedUndefined: false + }, + { + description: 'plain object with nested values, same order', + value: { lorem: 'ipsum', dolor: ['sit', null, undefined, 1, () => 'sit amet'] }, + comparisonValue: { lorem: 'ipsum', dolor: ['sit', null, undefined, 1, () => 'sit amet'] }, + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'plain object with nested values, reversed order', + value: { lorem: 'ipsum', dolor: ['sit', null, undefined, 1, () => 'sit amet'] }, + comparisonValue: { dolor: ['sit', null, undefined, 1, () => 'sit amet'], lorem: 'ipsum' }, + expectedComparison: true, + expectedUndefined: false + }, + { + description: 'circular reference', + value: (() => { + const obj: any = {}; + + obj.a = { b: obj }; + + return obj; + })(), + comparisonValue: (() => { + const obj: any = {}; + + obj.a = { b: obj }; + + return obj; + })(), + expectedComparison: true, + expectedUndefined: false + } + ])('should generate a consistent hash, $description', ({ value, comparisonValue, expectedComparison, expectedUndefined }) => { + expect(generateHash(value) === generateHash(undefined)).toBe(expectedUndefined); + expect(generateHash(value) === generateHash(comparisonValue)).toBe(expectedComparison); + expect(generateHash(value)).toMatchSnapshot(); + }); +}); + +describe('hashCode', () => { + it.each([ + { + description: 'string', + param: 'lorem ipsum' + }, + { + description: 'number', + param: 200 + }, + { + description: 'undefined', + param: undefined + }, + { + description: 'null', + param: null + }, + { + description: 'JSON string', + param: JSON.stringify({ lorem: 'ipsum' }) + } + ])('should generate a consistent hash code, $description', ({ param }) => { + expect(hashCode(param)).toBe(hashCode(param)); }); }); @@ -31,20 +297,72 @@ describe('isPromise', () => { it.each([ { description: 'Promise.resolve', - func: Promise.resolve(), + param: Promise.resolve(), value: true }, { description: 'async function', - func: async () => {}, + param: async () => {}, value: true }, { description: 'non-promise', - func: () => 'lorem', + param: () => 'lorem', + value: false + } + ])('should determine a promise for $description', ({ param, value }) => { + expect(isPromise(param)).toBe(value); + }); +}); + +describe('isPlainObject', () => { + it.each([ + { + description: 'plain object, empty', + param: {}, + value: true + }, + { + description: 'plain object', + param: { 1: 'lorem', 2: 'ipsum' }, + value: true + }, + { + description: 'create object', + param: Object.create(null), + value: true + }, + { + description: 'array', + param: [], + value: false + }, + { + description: 'null', + param: null, + value: false + }, + { + description: 'undefined', + param: undefined, + value: false + }, + { + description: 'NaN', + param: NaN, + value: false + }, + { + description: 'function', + param: () => 'lorem', + value: false + }, + { + description: 'date', + param: new Date('2023-01-01'), value: false } - ])('should determine a promise for $description', ({ func, value }) => { - expect(isPromise(func)).toBe(value); + ])('should determine a plain object for $description', ({ param, value }) => { + expect(isPlainObject(param)).toBe(value); }); }); diff --git a/src/server.caching.ts b/src/server.caching.ts index 855443c..7b8f89b 100644 --- a/src/server.caching.ts +++ b/src/server.caching.ts @@ -1,42 +1,111 @@ import { generateHash, isPromise } from './server.helpers'; +/** + * Memo cache store. + * + * @template TReturn Array of cache entries. + */ +type MemoCache = Array | { (): never; isError: boolean } | any>; + +/** + * Memo cache handler response parameters. + * + * @template TReturn Return type of the memoized function. + * + * @property {MemoCache} remaining Array of values that were NOT removed from the cache due to cache limit. + * @property {MemoCache} removed Array of values that were removed from the cache due to cache limit. + * @property {MemoCache} all Array of all values in the cache, including removed values. + */ +type MemoCacheHandlerResponse = { + remaining: MemoCache; + removed: MemoCache; + all: MemoCache +}; + +/** + * Memo cache handler callback. + * + * Return values are ignored. Thrown errors are logged but not propagated. + * + * @template TReturn Return type of the memoized function. + * + * @param {MemoCacheHandlerResponse} cache Memo cache handler response. + */ +type OnMemoCacheHandler = (cache: MemoCacheHandlerResponse) => void | Promise; + +/** + * Debug handler callback. + * + * @template TReturn Return type of the memoized function. + * + * @param info - Object containing debugging information. + * @param info.type - Information debugging category. + * @param info.value - Value associated with the debug operation. + * @param {MemoCache} info.cache - MemoCache array + */ +type MemoDebugHandler = (info: { type: string; value: unknown; cache: MemoCache }) => void; + +/** + * Memo configuration options. + * + * @template TReturn Return type of the memoized function. + * + * @property [cacheErrors] Memoize errors, or don't (default: true). For async errors, a promise is cached. + * When the promise errors/rejects/catches, it is removed from the cache. + * @property [cacheLimit] Number of entries to cache before overwriting previous entries (default: 1) + * @property {MemoDebugHandler} [debug] Debug callback function + * @property [expire] Expandable milliseconds until cache expires + * @property {OnMemoCacheHandler} [onCacheExpire] Callback when cache expires. Only fires when the `expire` option is set. + * @property {OnMemoCacheHandler} [onCacheRollout] Callback when cache entries are rolled off due to cache limit. + */ +interface MemoOptions { + cacheErrors?: boolean; + cacheLimit?: number; + debug?: MemoDebugHandler; + expire?: number; + onCacheExpire?: OnMemoCacheHandler; + onCacheRollout?: OnMemoCacheHandler; +} + /** * Simple argument-based memoize with adjustable cache limit, and extendable cache expire. * apidoc-mock: https://github.com/cdcabrera/apidoc-mock.git * * - `Zero-arg caching`: Zero-argument calls are memoized. To disable caching and perform a manual reset on every call, set cacheLimit <= 0. * - `Expiration`: Expiration expands until a pause in use happens. All results, regardless of type, will be expired. - * - `Promises`: Allows for promises and promise-like functions - * - `Errors`: It's on the consumer to catch function errors and await or process a Promise resolve/reject/catch. * - * @param {Function} func - A function or promise/promise-like function to memoize - * @param {object} [options] - Configuration options - * @param {boolean} [options.cacheErrors] - Memoize errors, or don't (default: true) - * @param {number} [options.cacheLimit] - Number of entries to cache before overwriting previous entries (default: 1) - * @param {Function} [options.debug] - Debug callback function (default: Function.prototype) - * @param {number} [options.expire] - Expandable milliseconds until cache expires - * @returns {Function} Memoized function + * @template TArgs Arguments passed to the provided function represented as an array. + * @template TReturn Return type of the provided/memoized function. + * + * @param {(...args: TArgs) => TReturn} func The function or promise/promise-like function to memoize + * @param {MemoOptions} [options={}] Configuration options. + * @returns Memoized function + * + * @throws {Error} If an error occurs during function execution and `cacheErrors` is set to `false`, + * the error will not be cached and will need to be addressed by the caller. It's on the consumer to catch + * function errors and await or process a Promise resolve/reject/catch. */ -const memo = ( +const memo = ( func: (...args: TArgs) => TReturn, { cacheErrors = true, cacheLimit = 1, debug = () => {}, - expire - }: { - cacheErrors?: boolean; - cacheLimit?: number; - debug?: (info: { type: string; value: unknown; cache: unknown[] }) => void; - expire?: number; - } = {} + expire, + onCacheExpire, + onCacheRollout + }: MemoOptions = {} ): (...args: TArgs) => TReturn => { const isCacheErrors = Boolean(cacheErrors); const isFuncPromise = isPromise(func); + const isOnCacheExpirePromise = isPromise(onCacheExpire); + const isOnCacheExpire = typeof onCacheExpire === 'function' || isOnCacheExpirePromise; + const isOnCacheRolloutPromise = isPromise(onCacheRollout); + const isOnCacheRollout = typeof onCacheRollout === 'function' || isOnCacheRolloutPromise; const updatedExpire = Number.parseInt(String(expire), 10) || undefined; const ized = function () { - const cache: any[] = []; + const cache: MemoCache = []; let timeout: NodeJS.Timeout | undefined; return (...args: TArgs): TReturn => { @@ -46,8 +115,33 @@ const memo = ( clearTimeout(timeout); timeout = setTimeout(() => { + if (isOnCacheExpire) { + const allCacheEntries: Array = []; + + cache.forEach((entry, index) => { + if (index % 2 === 0) { + allCacheEntries.push(cache[index + 1] as TReturn); + } + }); + + const cacheEntries = { remaining: [], removed: allCacheEntries, all: allCacheEntries }; + + if (isOnCacheExpirePromise) { + Promise.resolve(onCacheExpire?.(cacheEntries)).catch(console.error); + } else { + try { + onCacheExpire?.(cacheEntries); + } catch (error) { + console.error(error); + } + } + } + cache.length = 0; }, updatedExpire); + + // Allow the process to exit + timeout.unref(); } // Zero cacheLimit, reset and bypass memoization @@ -71,9 +165,10 @@ const memo = ( if (isFuncPromise) { const promiseResolve = Promise .resolve(func.call(null, ...args)) - .catch((error: any) => { + .catch((error: unknown) => { const promiseKeyIndex = cache.indexOf(key); + // Remove the promise if (isCacheErrors === false && promiseKeyIndex >= 0) { cache.splice(promiseKeyIndex, 2); } @@ -81,22 +176,51 @@ const memo = ( return Promise.reject(error); }); + // Cache the promise cache.unshift(key, promiseResolve); } else { try { cache.unshift(key, func.call(null, ...args)); } catch (error) { + // Wrap a sync error in a function then cache it const errorFunc = () => { throw error; }; - (errorFunc as any).isError = true; + errorFunc.isError = true; cache.unshift(key, errorFunc); } } - // Run after cache update to trim + // Run callback and cache trim after cache update. if (isMemo) { + if (isOnCacheRollout && cache.length > cacheLimit * 2) { + const allCacheEntries: Array = []; + + cache.forEach((entry, index) => { + if (index % 2 === 0) { + allCacheEntries.push(cache[index + 1] as TReturn); + } + }); + + const removedCacheEntries = allCacheEntries.slice(cacheLimit); + + if (removedCacheEntries.length > 0) { + const remainingCacheEntries = allCacheEntries.slice(0, cacheLimit); + const cacheEntries = { remaining: remainingCacheEntries, removed: removedCacheEntries, all: allCacheEntries }; + + if (isOnCacheRolloutPromise) { + Promise.resolve(onCacheRollout?.(cacheEntries)).catch(console.error); + } else { + try { + onCacheRollout?.(cacheEntries); + } catch (error) { + console.error(error); + } + } + } + } + cache.length = cacheLimit * 2; } } @@ -132,4 +256,4 @@ const memo = ( return ized(); }; -export { memo }; +export { memo, type MemoCacheHandlerResponse, type MemoDebugHandler, type MemoCache, type OnMemoCacheHandler, type MemoOptions }; diff --git a/src/server.helpers.ts b/src/server.helpers.ts index b7d18f3..4bd7397 100644 --- a/src/server.helpers.ts +++ b/src/server.helpers.ts @@ -1,25 +1,165 @@ -import { createHash } from 'crypto'; +import { createHash, type BinaryToTextEncoding } from 'node:crypto'; /** - * Simple hash from content. + * Check if an object is an object * - * @param {unknown} content - Content to hash - * @returns {string} Hash string + * @param obj - Object, or otherwise, to check + * @returns `true` if an object is an object */ -const generateHash = (content: unknown) => - createHash('sha1') - .update(JSON.stringify({ value: (typeof content === 'function' && content.toString()) || content })) - .digest('hex'); +const isObject = (obj: unknown) => + Object.prototype.toString.call(obj) === '[object Object]'; + +/** + * Is an object a plain object? + * + * @param obj - Object, or otherwise, to check + * @returns `true` if an object is a "plain object" + */ +const isPlainObject = (obj: unknown) => { + if (!isObject(obj)) { + return false; + } + + const proto = Object.getPrototypeOf(obj); + + return proto === null || proto === Object.prototype; +}; /** * Check if "is a Promise", "Promise like". * - * @param {object} obj - Object to check - * @returns {boolean} True if object is a Promise + * @param obj - Object, or otherwise, to check + * @returns `true` if the object is a Promise */ const isPromise = (obj: unknown) => /^\[object (Promise|Async|AsyncFunction)]/.test(Object.prototype.toString.call(obj)); +/** + * Generate a hash from a string + * + * @param str + * @param options - Hash options + * @param options.algorithm - Hash algorithm (default: 'sha1') + * @param options.encoding - Encoding of the hash (default: 'hex') + * @returns Hash string + */ +const hashCode = (str: unknown, { algorithm = 'sha1', encoding = 'hex' }: { algorithm?: string; encoding?: BinaryToTextEncoding } = {}) => + createHash(algorithm) + .update(String(str), 'utf8') + .digest(encoding); + +/** + * Normalize a value for hashing with JSON.stringify + * + * @param value - Value to normalize for hashing, typically for JSON.stringify + */ +const hashNormalizeValue = (value: unknown): unknown => { + const normalizeSort = (a: any, b: any) => (a < b ? -1 : a > b ? 1 : 0); + + if (value === null) { + return { $null: true }; + } + + switch (typeof value) { + case 'undefined': + return { $undefined: true }; + case 'string': + case 'boolean': + return value; + case 'number': + if (Number.isNaN(value)) { + return { $number: 'NaN' }; + } + + if (value === Infinity) { + return { $number: '+Infinity' }; + } + + if (value === -Infinity) { + return { $number: '-Infinity' }; + } + + if (Object.is(value, -0)) { + return { $number: '-0' }; + } + + return value; + case 'bigint': + return { $bigint: value.toString() }; + case 'function': + return { $function: hashCode(value.toString(), { algorithm: 'sha256' }) }; + case 'symbol': + return { $symbol: String(value) }; + } + + if (Array.isArray(value)) { + return value.map(hashNormalizeValue); + } + + if (value instanceof Error) { + return { $error: value.toString() }; + } + + if (value instanceof Date) { + return { $date: value.toISOString() }; + } + + if (value instanceof RegExp) { + return { $regexp: value.toString() }; + } + + if (value instanceof Map) { + const entries = Array.from(value.entries()) + .map(([key, val]) => [hashNormalizeValue(key), hashNormalizeValue(val)]) + .sort(([a], [b]) => normalizeSort(JSON.stringify(a), JSON.stringify(b))); + + return { $map: entries }; + } + + if (value instanceof Set) { + const items = Array.from(value.values()) + .map(val => hashNormalizeValue(val)) + .sort((a, b) => normalizeSort(JSON.stringify(a), JSON.stringify(b))); + + return { $set: items }; + } + + if (isPlainObject(value)) { + const rec = value as Record; + + return Object.fromEntries( + Object.keys(rec) + .sort(normalizeSort) + .map(key => [key, hashNormalizeValue(rec[key])]) + ); + } + + return value; +}; + +/** + * Generate a consistent hash from a value + * + * @param anyValue - Value to hash + * @returns Hash string + */ +const generateHash = (anyValue: unknown): string => { + const normalizeValue = (_key: string, value: unknown) => hashNormalizeValue(value); + let stringify: string; + + try { + stringify = JSON.stringify(anyValue, normalizeValue); + } catch (error) { + stringify = `$error:${Object.prototype.toString.call(anyValue)}:${error}`; + } + + return hashCode(stringify); +}; + export { generateHash, + hashCode, + hashNormalizeValue, + isObject, + isPlainObject, isPromise };