Source: BuildCache.js

/*
 * Copyright (C) 2018 Nick Downing <nick@ndcode.org>
 * SPDX-License-Identifier: MIT
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to
 * deal in the Software without restriction, including without limitation the
 * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
 * sell copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 * IN THE SOFTWARE.
 */

let fs = require('fs')
let util = require('util')

let fs_stat = util.promisify(fs.stat)

/**
 * Constructs a cache object. The cache object is intended to store objects of
 * arbitrary JavaScript type, which are built from on-disk source files of some
 * kind. The cache tracks the source files of each object, and makes sure the
 * objects are rebuilt as required if the source files change on disk.
 *
 * @constructor
 * @param {boolean} diag - Should diagnostic messages be printed to the
 *   console.
 */
let BuildCache = function(diag) {
  if (!this instanceof BuildCache)
    throw new Error('BuildCache is a constructor')
  this.map = new Map()
  this.diag = diag || false
}

/**
 * Abstract method which is expected to build and return an object, given its
 * key. Called from "get()" when the object does not exist or is out of date.
 *
 * If this method throws an exception, the key will be deleted from the cache
 * and the exception re-thrown to the caller of "get()". If there are multiple
 * callers to "get()" blocking and waiting for the build, they all receive the
 * same exception object. So one has to be careful the exception is shareable.
 *
 * @method
 * @param {string} key - Usually the path to the main source file on disk.
 * @param {object} result - A dictionary to receive information about the built
 *   object, you can optionally set "result.deps" to a list of dependency files
 *   whose modification would invalidate the just-built and cached object.
 */
BuildCache.prototype.build = async function(key, result) {
  throw new Error('not implemented')
}

/**
 * Retrieves the object stored in the cache under "key". If "key" already
 * exists in the cache, then it will be checked for up-to-dateness. If present
 * and up-to-date then its object is returned directly. Otherwise the abstract
 * "build()" method is called to attempt to build the object, and either an
 * exception is thrown or the built object is stored and return to the caller.
 *
 * Other callers requsting the same object while the original build progresses
 * will be blocked, and all will wait for the build to complete. In this time,
 * no new up-to-date check will be initiated. But as soon as the build is
 * completed and the cache updated, further up-to-date checks become possible.
 *
 * An interesting alternate usage is provided for objects whose contents only
 * matter if they have been rebuilt since last time. For example, suppose we
 * want to periodically read a configuration file, and then possibly restart
 * some long-running process if the configuration has changed. Then it is not
 * necessary to store the result of configuration parsing in the cache, since
 * it is only needed momentarily (while we're actually restarting the process).
 * In such case, pass "once = true" and an "undefined" return means no change.
 *
 * @method
 * @param {string} key - Usually the path to the main source file on disk.
 * @param {boolean} once - If "true", it means the returned object will only be
 *   used once. See above for a more comprehensive discussion of this feature.
 */
BuildCache.prototype.get = async function(key, once) {
  let result = this.map.get(key)
  if (result === undefined) {
    if (this.diag)
      console.log(`building ${key}`)
    result = {deps: [key], time: Date.now()}
    result.done = this.build(key, result)
    this.map.set(key, result)
    try {
      await result.done
    }
    catch (err) {
      delete result.done
      this.map.delete(key)
      throw err
    }
    delete result.done
  }
  else if (result.done === undefined) {
    if (this.diag)
      console.log(`checking ${key}`)
    result.done = (
      async () => {
        for (let i = 0; i < result.deps.length; ++i) {
          let stats
          try {
            stats = await fs_stat(result.deps[i])
          }
          catch (err) {
            if (!(err instanceof Error) || err.code !== 'ENOENT')
              throw err
            //stats = undefined
          }
          if (stats === undefined || stats.mtimeMs > result.time) {
            if (this.diag)
              console.log(`rebuilding ${key} reason ${result.deps[i]}`)
            result.deps = [key]
            result.time = Date.now()
            await this.build(key, result)
            break
          }
        }
      }
    )()
    try {
      await result.done
    }
    catch (err) {
      delete result.done
      this.map.delete(key)
      throw err
    }
    delete result.done
  }
  else
    await result.done
  let value = result.value
  if (once)
    result.value = undefined
  return value
}

/**
 * Call this periodically to allow the cache to clean itself of stale objects.
 * It can be called as often as convenient, but since the cache can be large,
 * the frequency of calls to "kick()" should be kept low. For example, if
 * unreferenced objects should be kept for one day, then call "kick()" once
 * per hour, and the actual lifetime will be at least 24 and up to 25 hours.
 *
 * The cache cleaning is not yet implemented, but the dummy "kick()" function
 * is provided so that you can start to put the cleaning infrastructure in your
 * code already. The constructor arguments might change later for this feature.
 *
 * @method
 */
BuildCache.prototype.kick = function() {
  // not yet implemented
}

module.exports = BuildCache

Documentation generated by JSDoc 3.6.3 on Wed Feb 05 2020 00:11:43 GMT+1100 (Australian Eastern Daylight Time)