class_cache.js

/**
 * File-system backed cache helper with TTL-based invalidation.
 *
 * @class
 */
class Cache {
  /**
   * @param {object} app - Application container.
   */
  constructor (app) {
    this.app = app
  }

  /**
   * Get root cache directory for this plugin.
   *
   * @returns {string} Absolute cache root directory.
   */
  getRootDir = () => {
    return `${this.app.getPluginDataDir('bajo')}/cache`
  }

  /**
   * Prepare cache paths for a namespaced key and TTL.
   *
   * @param {string} name - Cache key in namespaced path format.
   * @param {number|string} [ttlDur=0] - TTL duration (milliseconds or parseable duration).
   * @returns {{dir: string, file: string, cacheDir: string}|undefined} Prepared paths or undefined when not cacheable.
   */
  prep = (name, ttlDur = 0) => {
    const { breakNsPath } = this.app.bajo
    const { aneka, fs } = this.app.lib
    const { ns, subNs, path } = breakNsPath(name)
    ttlDur = aneka.parseDuration(ttlDur)
    if (ttlDur === 0 || !subNs) return
    const cacheDir = `${this.getRootDir()}/${ns}/${subNs}`
    const dir = `${cacheDir}/${ttlDur}`
    fs.ensureDirSync(dir)
    const file = `${dir}/${path}`
    return { dir, file, cacheDir }
  }

  /**
   * Load cached content when available and not expired.
   *
   * @param {string} name - Cache key in namespaced path format.
   * @param {number|string} [ttlDur=0] - TTL duration (milliseconds or parseable duration).
   * @returns {Promise<*>} Cached value, or undefined if missing/expired.
   */
  load = async (name, ttlDur = 0) => {
    const { fs } = this.app.lib
    const { dir, file } = this.prep(name, ttlDur) ?? {}
    if (!file) return
    if (!fs.existsSync(file)) return
    const { mtimeMs } = await fs.stat(dir)
    if (Date.now() - mtimeMs > ttlDur) {
      await fs.remove(dir)
      return
    }
    let content = fs.readFileSync(file, 'utf8')
    try {
      if (['{', '['].includes(content[0]) && ['}', ']'].includes(content[content.length - 1])) content = JSON.parse(content)
    } catch (err) {}
    return content
  }

  /**
   * Save a value into cache for the given key and TTL directory.
   *
   * @param {string} name - Cache key in namespaced path format.
   * @param {*} item - Value to persist.
   * @param {number|string} [ttlDur=0] - TTL duration (milliseconds or parseable duration).
   * @returns {Promise<void>} Resolves when the value is written.
   */
  save = async (name, item, ttlDur = 0) => {
    const { fs } = this.app.lib
    const { cloneDeep, isArray, isPlainObject } = this.app.lib._
    const { dir, file } = this.prep(name, ttlDur) ?? {}
    if (!file || !item) return
    fs.ensureDirSync(dir)
    let content = cloneDeep(item)
    if (isArray(item) || isPlainObject(item)) content = JSON.stringify(content)
    fs.writeFileSync(file, content, 'utf8')
  }

  /**
   * Return cached content, and store the fallback value if cache is empty.
   *
   * @param {string} name - Cache key in namespaced path format.
   * @param {*} item - Fallback value to save when cache miss happens.
   * @param {number|string} [ttlDur=0] - TTL duration (milliseconds or parseable duration).
   * @returns {Promise<*>} Cached content.
   */
  sync = async (name, item, ttlDur = 0) => {
    const content = await this.loadCache(name, ttlDur)
    if (!content) await this.saveCache(name, item, ttlDur)
    return content
  }

  /**
   * Remove a specific cache namespace or all first-level namespaces.
   *
   * @param {string} name - Namespace name or "*" for all.
   * @returns {void}
   */
  _purgeItem = (name) => {
    if (!this.app.bajo) return
    const { fs, fastGlob } = this.app.lib
    try {
      if (name === '*') {
        const dirs = fastGlob.globSync(`${this.getRootDir()}/*`, { onlyDirectories: true })
        for (const dir of dirs) {
          fs.removeSync(dir)
        }
      } else fs.removeSync(`${this.getRootDir()}/${name}`)
    } catch (err) {}
  }

  /**
   * Purge cache by namespace or remove expired TTL directories.
   *
   * @param {string} [name] - Optional namespace to remove directly.
   * @returns {void}
   */
  purge = (name) => {
    if (!this.app.bajo) return
    if (name) return this._purgeItem(name)
    const { fastGlob, fs } = this.app.lib
    const dirs = fastGlob.globSync(`${this.getRootDir()}/*/*/*`, { onlyDirectories: true })
    for (const dir of dirs) {
      try {
        const ttlDur = Number(dir.split('/').pop())
        const { mtimeMs } = fs.statSync(dir)
        if (Date.now() - mtimeMs > ttlDur) fs.removeSync(dir)
      } catch (err) {}
    }
  }

  /**
   * Dispose internal reference.
   */
  dispose = async () => {
    this.app = null
  }
}

export default Cache