lib_helper.js

import Print from '../class/print.js'
import Log from '../class/log.js'
import os from 'os'
import fs from 'fs-extra'
import lodash from 'lodash'
import semver from 'semver'
import aneka from 'aneka/index.js'
import outmatch from 'outmatch'
import fastGlob from 'fast-glob'
import { sprintf } from 'sprintf-js'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
import customParseFormat from 'dayjs/plugin/customParseFormat.js'
import localizedFormat from 'dayjs/plugin/localizedFormat.js'
import weekOfYear from 'dayjs/plugin/weekOfYear.js'
const { resolvePath } = aneka
const {
  isFunction, isPlainObject, cloneDeep, merge, forOwn, groupBy, find, reduce, map,
  trim, keys, intersection, each, camelCase, get, orderBy, pick, values, set,
  without, uniq, isEmpty
} = lodash

/**
 * Internal helpers called by Bajo and other classes. It should remains
 * hidden and not to be imported by any program.
 *
 * @module Helper
 */

const omitted = ['spawn', 'cwd', 'name', 'alias', 'applet', 'a', 'plugins']

const defConfig = {
  env: 'dev',
  runtime: {
    noWarning: false
  },
  log: {
    timeTaken: false,
    dateFormat: 'YYYY-MM-DDTHH:mm:ss.SSS',
    useUtc: false,
    pretty: false,
    applet: false,
    traceHook: false,
    save: false,
    rotation: {
      cycle: 'none', // none, daily, weekly, monthly
      compressOld: true,
      byPlugin: false,
      retain: 5
    }
  },
  dump: {
    depth: 2,
    compact: false,
    colors: true,
    breakLength: 80,
    caller: true,
    frame: {
      titleAllignment: 'center',
      padding: 1,
      margin: 1,
      borderStyle: 'round'
    }
  },
  lang: Intl.DateTimeFormat().resolvedOptions().lang ?? 'en-US',
  intl: {
    supported: ['en-US', 'id'],
    fallback: 'en-US',
    lookupOrder: [],
    format: {
      emptyValue: '',
      datetime: { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' },
      date: { dateStyle: 'medium', timeZone: 'UTC' },
      time: { timeStyle: 'short', timeZone: 'UTC' },
      float: { maximumFractionDigits: 2 },
      double: { maximumFractionDigits: 5 },
      smallint: {},
      integer: {}
    },
    unitSys: {
      'en-US': 'imperial',
      id: 'metric'
    }
  },
  exitHandler: true,
  cache: {
    purge: [],
    purgeIntvDur: '5m'
  }
}

const defMain = `async function factory (pkgName) {
  const me = this

  return class Main extends this.app.baseClass.Base {
    constructor () {
      super(pkgName, me.app)
      this.config = {}
    }
  }
}

export default factory
`

function outmatchNs (source, pattern) {
  const { breakNsPath } = this.bajo
  const [src, subSrc] = source.split(':')
  if (!subSrc) return pattern === src
  try {
    const { fullNs, path } = breakNsPath(pattern)
    const isMatch = outmatch(path)
    return src === fullNs && isMatch(subSrc)
  } catch (err) {
    return false
  }
}

function parseObject (obj, options = {}) {
  const me = this
  const { ns = 'bajo', lang } = options
  options.translator = {
    lang,
    prefix: 't:',
    handler: val => {
      const [text, ...args] = val.split('|')
      args.push({ lang })
      return me[ns].t(text, ...args)
    }
  }
  return aneka.parseObject(obj, options)
}

dayjs.extend(utc)
dayjs.extend(customParseFormat)
dayjs.extend(localizedFormat)
dayjs.extend(weekOfYear)

/**
 * @typedef {Object} TLib
 * @memberof App
 * @property {Object} _ Access to {@link https://lodash.com|lodash}.
 * @property {Object} fs Access to {@link https://github.com/jprichardson/node-fs-extra|fs-extra}.
 * @property {Object} fastGlob Access to {@link https://github.com/mrmlnc/fast-glob|fast-glob}.
 * @property {Object} sprintf Access to {@link https://github.com/alexei/sprintf.js|sprintf}.
 * @property {Object} aneka Access to {@link https://github.com/ardhi/aneka|aneka}.
 * @property {Object} outmatch Access to {@link https://github.com/axtgr/outmatch|outmatch}.
 * @property {Object} dayjs Access to {@link https://day.js.org|dayjs} with utc & customParseFormat plugin already applied.
 * @property {Object} freeze
 * @property {Object} findDeep
 * @see App
 */
export const lib = {
  _: lodash,
  fs,
  fastGlob,
  sprintf,
  outmatch,
  dayjs,
  aneka,
  freeze,
  findDeep,
  outmatchNs,
  parseObject
}

/**
 * Building bajo base config. Mostly dealing with directory setups:
 * - determine base directory
 * - check whether data directory is valid. If not exist, create one inside app dir
 * - ensure data config directory is there
 * - ensure tmp dir is there
 * - read the list of plugins from ```.plugins``` file
 *
 * @async
 * @method
 */
export async function buildBaseConfig () {
  // dirs
  const { defaultsDeep, textToArray, currentLoc, resolvePath } = this.app.lib.aneka
  this.config = defaultsDeep({}, this.app.argv._, this.app.envVars._)
  set(this, 'dir.base', this.app.dir)
  const path = currentLoc(import.meta).dir + '/..'
  set(this, 'dir.pkg', resolvePath(path))
  if (get(this, 'config.dir.data')) set(this, 'dir.data', this.config.dir.data)
  if (!get(this, 'dir.data')) set(this, 'dir.data', `${this.dir.base}/data`)
  this.dir.data = resolvePath(this.dir.data)
  fs.ensureDirSync(`${this.dir.data}/config`)
  if (!this.dir.tmp) {
    this.dir.tmp = `${resolvePath(os.tmpdir())}/${this.ns}`
    fs.ensureDirSync(this.dir.tmp)
  }
  this.pkg = await this.getPkgInfo()
  let pluginPkgs = this.app.pluginPkgs
  if (isEmpty(pluginPkgs)) {
    // collect list of plugins
    const mainPkg = await this.getPkgInfo(this.app.dir)
    pluginPkgs = get(mainPkg, 'bajo.plugins', [])
    if (isEmpty(pluginPkgs)) {
      const pluginsFile = `${this.dir.data}/config/.plugins`
      if (fs.existsSync(pluginsFile)) {
        pluginPkgs = textToArray(fs.readFileSync(pluginsFile, 'utf8'))
      }
    }
  }
  this.app.pluginPkgs = without(uniq(pluginPkgs), this.app.mainNs)
  this.app.pluginPkgs.push(this.app.mainNs)
}

/**
 * Building all plugins:
 * - load from app's pluginPkgs
 * - iterate through the list and build related plugins
 * - making sure main plugin is there. If not, create from template
 * - attach these plugins to the app instance
 *
 * @async
 */
export async function buildPlugins () {
  const { resolvePath } = this.app.lib.aneka
  this.log.trace('buildPluginsStart')
  for (const pkg of this.app.pluginPkgs) {
    const ns = camelCase(pkg)
    let dir
    if (ns === 'main') {
      dir = `${this.dir.base}/${this.app.mainNs}`
      fs.ensureDirSync(dir)
      if (!fs.existsSync(`${dir}/index.js`)) {
        fs.writeFileSync(`${dir}/index.js`, defMain, 'utf8')
      }
    } else dir = this.getModuleDir(pkg)
    const factory = `${dir}/index.js`
    if (!fs.existsSync(factory)) throw this.error('pluginPackageNotFound%s', pkg)
    const { default: builder } = await import(resolvePath(factory, true))
    const ClassDef = await builder.call(this, pkg)
    const plugin = new ClassDef()
    if (!(plugin instanceof this.app.baseClass.Base)) throw this.error('pluginPackageInvalid%s', pkg)
    plugin.pkg = plugin.getPkgInfo(ns === 'main' ? this.dir.base : dir)
    plugin.alias = ns === 'main' ? this.app.mainNs : get(plugin.pkg, 'bajo.alias', (pkg.slice(0, 5) === 'bajo-' ? pkg.slice(5) : ns).toLowerCase())
    plugin.dependencies = get(plugin.pkg, 'bajo.dependencies', [])
    this.app.addPlugin(plugin, ClassDef)
    this.log.trace('- ' + pkg)
  }
  this.log.debug('buildPluginsComplete')
}

/**
 * Collect all config handlers, including the one provided by plugins
 *
 * @async
 */
export async function collectConfigHandlers () {
  for (const pkg of this.app.pluginPkgs) {
    let dir
    try {
      dir = this.getModuleDir(pkg)
    } catch (err) {}
    if (!dir) continue
    const file = `${dir}/extend/bajo/config-handlers.js`
    let mod = await this.importModule(file)
    if (!mod) continue
    if (isFunction(mod)) mod = await mod.call(this.app[camelCase(pkg)])
    if (isPlainObject(mod)) mod = [mod]
    mod.forEach(m => set(m, 'ns', camelCase(pkg)))
    this.app.configHandlers = this.app.configHandlers.concat(mod)
  }
}

/**
 * Bajo extra config:
 * - reading config file
 * - merge config with arguments & environments values
 * - Set environment (```dev``` or ```prod```)
 *
 * @async
 */
export async function buildExtConfig () {
  // config merging
  const { defaultsDeep, includes } = this.app.lib.aneka
  const { parseObject } = this.app.lib
  const { isEmpty, get, isString, without, omit } = this.app.lib._

  let resp = get(this, `app.options.config.${this.ns}`, {})
  if (isEmpty(resp)) resp = await this.readAllConfigs(`${this.dir.data}/config/${this.ns}`)
  resp = omit(pick(resp, ['log', 'exitHandler', 'env', 'runtime']), omitted)
  const envs = this.app.envs
  this.config = defaultsDeep({}, this.config, resp, defConfig)
  // language
  this.config.lang = (this.config.lang ?? '').split('.')[0]
  this.app.loadIntl(this.ns)
  this.print = new Print(this)
  // environment
  if (values(envs).includes(this.config.env)) this.config.env = this.app.lib.aneka.getKeyByValue(envs, this.config.env)
  if (!keys(envs).includes(this.config.env)) throw this.error('unknownEnv%s%s', this.config.env, this.join(keys(envs), { lastSeparator: this.t('or') }))
  process.env.NODE_ENV = envs[this.config.env]
  if (!this.config.log.level) this.config.log.level = this.config.env === 'dev' ? 'debug' : 'info'
  // misc
  const obj = this.app.applet ? this.config : pick(this.config, keys(defConfig))
  this.config = parseObject(obj, { parseValue: true })
  const exts = this.app.getConfigFormats()
  if (this.app.applet) {
    if (!this.app.pluginPkgs.includes('bajo-cli')) throw this.error('appletNeedsBajoCli')
    if (!this.config.log.applet) this.config.log.level = 'silent'
    this.config.exitHandler = false
  }
  if (this.config.runtime.noWarning) process.removeAllListeners('warning')
  if (isString(this.config.cache.purge)) this.config.cache.purge = [this.config.cache.purge]
  this.config.cache.purge = without(this.config.cache.purge, '', null, undefined)
  if (this.config.cache.purge.length > 0) {
    if (includes(['all', '*'], this.config.cache.purge)) this.app.cache.purge('*')
    else {
      for (const name of this.config.cache.purge) {
        this.app.cache.purge(name)
      }
    }
  }
  this.app.log = new Log(this.app)
  this.log.trace('dataDir%s', this.dir.data)
  this.log.debug('configHandlers%s', this.join(exts))
}

/**
 * Setup plugins boot orders by reading plugin's ```.bootorder``` file if provided.
 *
 * @async
 */
export async function bootOrder () {
  const { freeze } = this.app.lib
  const { isNumber } = this.app.lib._
  this.log.debug('setupBootOrder')
  let counter = 1000
  const orders = []
  for (const pkg of this.app.pluginPkgs) {
    const item = { pkg }
    const ns = camelCase(pkg)
    const order = get(this.app[ns], 'pkg.bajo.bootorder')
    if (isNumber(order)) item.val = order
    else {
      item.val = counter
      counter++
    }
    orders.push(item)
  }
  this.app.pluginPkgs = map(orderBy(orders, ['val']), 'pkg')
  this.log.debug('runInEnv%s', this.t(this.app.envs[this.config.env]))
  // misc
  freeze(this.config)
}

/**
 * Build configurations
 *
 * @async
 */
export async function buildConfigs () {
  this.bajo.log.debug('readConfigs')
  for (const ns of this.getAllNs()) {
    await this[ns].loadConfig()
    this[ns].print = new Print(this[ns])
    this.loadIntl(ns)
  }
}

/**
 * Ensure for names and aliases to be unique and no clashes with other plugins
 *
 * @async
 */
export async function checkNameAliases () {
  this.bajo.log.debug('checkAliasNameClash')
  const refs = []
  for (const pkg of this.bajo.app.pluginPkgs) {
    const plugin = this.bajo.app[camelCase(pkg)]
    const { ns, alias } = plugin
    let item = find(refs, { ns })
    if (item) throw this.bajo.error('pluginNameClash%s%s%s%s', ns, pkg, item.ns, item.pkg, { code: 'BAJO_NAME_CLASH' })
    item = find(refs, { alias })
    if (item) throw this.bajo.error('pluginNameClash%s%s%s%s', alias, pkg, item.alias, item.pkg, { code: 'BAJO_ALIAS_CLASH' })
    refs.push({ ns, alias, pkg })
  }
}

/**
 * Ensure dependencies are met
 *
 * @async
 */
export async function checkDependencies () {
  const { join } = this.bajo
  this.bajo.log.debug('checkDeps')
  for (const pkg of this.bajo.app.pluginPkgs) {
    const plugin = this.bajo.app[camelCase(pkg)]
    const { ns, dependencies } = plugin
    this.bajo.log.trace('- %s', ns)
    const odep = reduce(dependencies, (o, k) => {
      const item = map(k.split('@'), m => trim(m))
      if (k[0] === '@') o['@' + item[1]] = item[2]
      else o[item[0]] = item[1]
      return o
    }, {})
    const deps = keys(odep)
    if (deps.length > 0) {
      if (intersection(this.bajo.app.pluginPkgs, deps).length !== deps.length) {
        throw this.bajo.error('dependencyUnfulfilled%s%s', pkg, join(deps), { code: 'BAJO_DEPENDENCY' })
      }
      each(deps, d => {
        if (!odep[d]) return
        const ver = get(this.bajo.app[camelCase(d)], 'pkg.version')
        if (!ver) return
        if (!semver.satisfies(ver, odep[d])) {
          throw this.bajo.error('semverCheckFailed%s%s', pkg, `${d}@${odep[d]}`, { code: 'BAJO_DEPENDENCY_SEMVER' })
        }
      })
    }
  }
}

/**
 * Collect and build hooks and push them to the bajo's hook system
 *
 * @async
 */
export async function collectHooks () {
  const { eachPlugins, runHook, isLogInRange, importModule } = this.bajo
  const { isArray, isPlainObject } = this.lib._
  const me = this // "this" is "app"
  me.bajo.log.trace('collecting%s', this.t('hooks'))
  await eachPlugins(async function ({ dir, file }) {
    let mod = await importModule(file, { asHandler: true })
    if (!mod) return undefined
    if (file.includes('hook.js')) mod = await mod.handler.call(this)
    if (isArray(mod)) {
      for (const m of mod) {
        if (!isPlainObject(m)) continue
        if (!m.name) throw me.bajo.error('missing%s%s', 'name', file)
        if (isArray(m.name)) {
          for (const name of m.name) {
            me.bajo.hooks.push(merge({}, m, { name, src: this.ns }))
          }
        } else {
          m.src = this.ns
          me.bajo.hooks.push(m)
        }
      }
    } else {
      const _file = file.replace(dir + '/hook/', '').replace('.js', '')
      let [names, path] = _file.split('@')
      names = names.split('$').map(n => trim(n))
      for (let name of names) {
        name = name.split('.').map(n => camelCase(n)).join('.')
        const m = merge({}, mod, { name: `${name}:${camelCase(path)}`, src: this.ns })
        me.bajo.hooks.push(m)
      }
    }
  }, { glob: ['hook/*.js', 'hook.js'], prefix: me.bajo.ns })
  // for log trace purpose only
  if (isLogInRange('trace')) {
    const items = groupBy(me.bajo.hooks, item => item.name)
    forOwn(items, (v, k) => {
      const [name, path] = k.split(':')
      me.bajo.log.trace('- %s:%s (%d)', name, path, v.length)
    })
  }

  await runHook('bajo:afterCollectHooks', this.bajo.hooks)
  me.bajo.log.debug('collected%s%d', this.t('hooks'), me.bajo.hooks.length)
}

/**
 * Finally, run all plugins
 *
 * @async
 * @see module:Hook~bajo:beforeAllInit
 * @see module:Hook~{ns}:beforeInit
 * @see module:Hook~{ns}:afterInit
 * @see module:Hook~bajo:afterAllInit
 * @see module:Hook~bajo:beforeAllStart
 * @see module:Hook~{ns}:beforeStart
 * @see module:Hook~{ns}:afterStart
 * @see module:Hook~bajo:afterAllStart
 */
export async function run () {
  const me = this
  const { runHook, eachPlugins, join } = me.bajo
  const { freeze } = me.lib
  const methods = ['init']
  if (!me.applet) methods.push('start')
  for (const method of methods) {
    await runHook(`bajo:${camelCase(`before all ${method}`)}`)
    await eachPlugins(async function () {
      const { ns } = this
      await runHook(`${ns}:${camelCase(`before ${method}`)}`)
      await me[ns][method]()
      await runHook(`${ns}:${camelCase(`after ${method}`)}`)
      if (method === 'start') freeze(me[ns].config)
    })
    await runHook(`bajo:${camelCase(`after all ${method}`)}`)
  }
  if (me.bajo.config.log.level === 'trace') {
    let text = join(map(me.bajo.app.pluginPkgs, b => camelCase(b)))
    text += ` (${me.bajo.app.pluginPkgs.length})`
    me.bajo.log.trace('loadedPlugins%s', text)
  } else me.bajo.log.debug('loadedPlugins%s', me.bajo.app.pluginPkgs.length)
}

/**
 * Iterate through all plugins loaded and do:
 *
 * 1. {@link module:Helper/Base.buildConfigs|build configs}
 * 2. {@link module:Helper/Base.checkNameAliases|ensure names & aliases uniqueness}
 * 3. {@link module:Helper/Base.checkDependencies|ensure dependencies are met}
 * 4. {@link module:Helper/Base.collectHooks|collect hooks}
 * 5. {@link module:Helper/Base.run|run plugins}
 *
 * @async
 */
export async function bootPlugins () {
  await buildConfigs.call(this.app)
  await checkNameAliases.call(this.app)
  await checkDependencies.call(this.app)
  await collectHooks.call(this.app)
  await run.call(this.app)
}

/**
 * Attach plugins exit handlers and make sure the app shutdowns gracefully
 *
 * @async
 */
export async function exitHandler () {
  if (!this.config.exitHandler) return

  async function exit (signal) {
    const { eachPlugins } = this
    if (signal) this.log.warn('signalReceived%s', signal)
    const me = this
    await eachPlugins(async function ({ ns }) {
      try {
        await this.exit()
      } catch (err) {}
      me.log.trace('exited%s', this.ns)
    })
    this.log.debug('appShutdown')
    process.exit(0)
  }

  process.on('SIGINT', async () => {
    await exit.call(this, 'SIGINT')
  })

  process.on('SIGTERM', async () => {
    await exit.call(this, 'SIGTERM')
  })

  process.on('beforeExit', async () => {
    await exit.call(this)
  })

  process.on('uncaughtException', (error, origin) => {
    setTimeout(() => {
      console.error(error)
      // process.exit(1)
    }, 50)
  })

  process.on('unhandledRejection', (reason, promise) => {
    const stackFile = reason.stack.split('\n')[1]
    let file
    const info = stackFile.match(/\((.*)\)/) // file is in (<file>)
    if (info) file = info[1]
    else if (stackFile.startsWith('    at ')) file = stackFile.slice(7) // file is stackFile itself
    if (!file) return
    const parts = file.split(':')
    const column = parseInt(parts[parts.length - 1])
    const line = parseInt(parts[parts.length - 2])
    parts.pop()
    parts.pop()
    file = parts.join(':')
    this.log.error({ file, line, column }, '%s', reason.message)
  })

  process.on('warning', warning => {
    this.log.error('%s', warning.message)
  })
}

/**
 * If app is in ```applet``` mode, this little helper should take care plugin's applet boot process
 *
 * @async
 * @fires {ns}:beforeAppletRun
 * @fires {ns}:afterAppletRun
 */
export async function runAsApplet () {
  const { isString, map, find } = this.app.lib._
  await this.eachPlugins(async function ({ file }) {
    const { ns, alias } = this
    this.app.applets.push({ ns, file, alias })
  }, { glob: 'applet.js', prefix: 'bajoCli' })

  this.log.debug('appletModeActivated')
  this.print.info('appRunningAsApplet')
  if (this.app.applets.length === 0) this.print.fatal('noAppletLoaded')
  let name = this.app.applet
  if (!isString(name)) {
    const select = await this.importPkg('bajoCli:@inquirer/select')
    name = await select({
      message: this.t('Please select:'),
      choices: map(this.app.applets, t => ({ value: t.ns }))
    })
  }
  const [ns, path] = name.split(':')
  const applet = find(this.app.applets, a => (a.ns === ns || a.alias === ns))
  if (!applet) this.print.fatal('notFound%s%s', this.app.t('applet'), name)

  await this.runHook(`${applet.ns}:beforeAppletRun`, ...this.app.args)
  await this.app.bajoCli.runApplet(applet, path, ...this.app.args)
  await this.runHook(`${applet.ns}:afterAppletRun`, ...this.app.args)
}

/**
 * Import file/module from any loaded plugins.
 *
 * E.g. your plugin structure looks like this:
 * ```
 * |- src
 * |  |- lib
 * |  |  |- my-module.js
 * |- index.js
 * |- package.json
 * ```
 *
 * And this is how to import ```my-module.js```:
 * ```javascript
 * const { importModule } = this.app.bajo
 * const myModule = await importModule('myPlugin:/src/lib/my-module.js')
 * ```
 *
 * @method
 * @async
 * @param {TNsPathPairs} file File to import.
 * @param {Object} [options={}] Options.
 * @param {boolean} [options.asDefaultImport=true] If ```true``` (default), return default imported module.
 * @param {boolean} [options.asHandler] If ```true```, return as a {@link HandlerType|handler}.
 * @param {boolean} [options.noCache] If ```true```, always import as a fresh copy.
 * @returns {any}
 * @see Bajo#importModule
 */
export async function importModule (file, { asDefaultImport = true, asHandler, noCache } = {}) {
  const load = async (file, asDefaultImport, noCache = false) => {
    file = resolvePath(file, true)
    if (noCache) file += `?_=${Date.now()}`
    const imported = await import(file)
    if (asDefaultImport) return imported.default
    return imported
  }

  if (this) file = this.app.getPluginFile(file)
  if (!fs.existsSync(file)) return
  let mod = await load(file, asDefaultImport, noCache)
  if (!asHandler) return mod
  if (isFunction(mod)) mod = { level: 999, handler: mod }
  if (!isPlainObject(mod)) {
    if (this) throw this.error('fileNotModuleHandler%s', file)
    throw new Error(`File '${file}' is NOT a handler module`)
  }
  return mod
}

/**
 * Freeze object.
 *
 * @method
 * @param {Object} obj Object to freeze.
 * @param {boolean} [shallow=false] If ```false``` (default), deep freeze object.
 */
export function freeze (obj, options = {}) {
  const { shallow = false, clone = false } = options
  if (shallow) Object.freeze(obj)
  else deepFreeze(obj, clone)
}

// take from https://github.com/3imed-jaberi/deepfreeze/blob/master/index.js
export function deepFreeze (object, clone = false) {
  function _deepFreeze (_object) {
    if (Object.isFrozen(_object)) return _object
    if (_object instanceof Map) {
      _object.set = _object.clear = _object.delete = function () {
        throw new Error('Map is read-only')
      }
      return _object
    }
    if (_object instanceof Set) {
      _object.add = _object.clear = _object.delete = function () {
        throw new Error('Set is read-only')
      }
      return _object
    }

    Object.freeze(_object)
    Object.getOwnPropertyNames(_object).forEach(function (key) {
      if (Object.hasOwn(_object, key) && _object[key] !== null &&
        (typeof _object[key] === 'object' || typeof _object[key] === 'function') &&
        !Object.isFrozen(_object[key])
      ) _deepFreeze(_object[key])
    })

    return _object
  }

  if (!Object.isFrozen(object)) Object.defineProperty(object, 'isDeepFrozen', { value: () => true })
  object = clone ? cloneDeep(object) : object
  return _deepFreeze(object)
}

/**
 * Find item deep in paths.
 *
 * @method
 * @param {string} item Item to find.
 * @param {Array} paths Array of path to look for.
 * @returns {string}
 */
export function findDeep (item, paths) {
  let dir
  for (const p of paths) {
    const d = `${p}/${item}`
    if (fs.existsSync(d)) {
      dir = d
      break
    }
  }
  return dir
}

/**
 * Supported data types.
 *
 * @typedef {Object} TDataType
 * @memberof Bajo
 * @type {Array}
 * @property {string} 0 string
 * @property {string} 1 float
 * @property {string} 2 double
 * @property {string} 3 integer
 * @property {string} 4 smallint
 * @property {string} 5 date
 * @property {string} 6 time
 * @property {string} 7 datetime
 * @property {string} 8 array
 * @property {string} 9 object
 * @property {string} 10 auto
 */

/**
 * General format types,
 *
 * @typedef {Object} TFormatType
 * @memberof Bajo
 * @type {Array}
 * @property {string} 0 speed
 * @property {string} 1 distance
 * @property {string} 2 area
 * @property {string} 3 degree
 */
export const types = ['speed', 'distance', 'area', 'degree']

export const formats = {
  metric: {
    speedFn: (val) => val,
    speedUnit: 'kmh',
    distanceFn: (val) => val,
    distanceUnit: 'km',
    areaFn: (val) => val,
    areaUnit: 'km²',
    degreeFn: (val) => val,
    degreeUnit: '°',
    degreeUnitSep: ''
  },
  imperial: {
    speedFn: (val) => val / 1.609,
    speedUnit: 'mih',
    distanceFn: (val) => val / 1.609,
    distanceUnit: 'mi',
    areaFn: (val) => val / 2.59,
    areaUnit: 'mi²',
    degreeFn: (val) => val,
    degreeUnit: '°',
    degreeUnitSep: ''
  },
  nautical: {
    speedFn: (val) => val / 1.852,
    speedUnit: 'knot',
    distanceFn: (val) => val / 1.852,
    distanceUnit: 'nm',
    areaFn: (val) => val / 2.92,
    areaUnit: 'nm²',
    degreeFn: (val) => val,
    degreeUnit: '°',
    degreeUnitSep: ''
  }
}