adapt-authoring-adaptframework/lib/BuildCache.js

import fs from 'node:fs/promises'
import path from 'upath'
import { log } from './utils/log.js'

/** Build output entries that aren't cached (rebuilt per-build from course data) */
const SKIP_ENTRIES = new Set(['course'])

/**
 * Filesystem-level cache of grunt build output, keyed by (pluginHash, theme, menu).
 * One instance per cache root; methods are stateless beyond the root path.
 */
class BuildCache {
  /**
   * @param {String} cacheRoot Root cache directory
   */
  constructor (cacheRoot) {
    this.cacheRoot = cacheRoot
  }

  /**
   * @returns {String} The cache directory path for the given combo
   */
  getPath (pluginHash, theme, menu) {
    return path.join(this.cacheRoot, `${pluginHash}_${theme}_${menu}`)
  }

  /**
   * @returns {Promise<Boolean>} Whether a cached build exists for the given combo
   */
  async has (pluginHash, theme, menu) {
    try {
      await fs.access(this.getPath(pluginHash, theme, menu))
      return true
    } catch {
      return false
    }
  }

  /**
   * Copies the build output (minus per-course content) into the cache for the given combo.
   * Uses a temp dir + atomic rename for parallel safety.
   * @param {String} buildOutputDir The build output directory
   */
  async populate (buildOutputDir, pluginHash, theme, menu) {
    const cacheDir = this.getPath(pluginHash, theme, menu)
    await fs.mkdir(this.cacheRoot, { recursive: true })

    const tmpDir = `${cacheDir}_tmp_${Date.now()}`
    try {
      await fs.mkdir(tmpDir, { recursive: true })
      const entries = await fs.readdir(buildOutputDir)
      for (const entry of entries) {
        if (SKIP_ENTRIES.has(entry)) continue
        await copyEntry(path.join(buildOutputDir, entry), path.join(tmpDir, entry))
      }
      await safeRename(tmpDir, cacheDir)
      log('info', 'CACHE', `populated cache for ${pluginHash} (theme=${theme}, menu=${menu})`)
    } catch (e) {
      await fs.rm(tmpDir, { recursive: true, force: true })
      throw e
    }
  }

  /**
   * Copies cached artifacts to a build directory.
   * @param {String} destDir Destination build directory
   */
  async restore (pluginHash, theme, menu, destDir) {
    await fs.mkdir(destDir, { recursive: true })
    await fs.cp(this.getPath(pluginHash, theme, menu), destDir, { recursive: true })
    log('info', 'CACHE', `restored from cache for ${pluginHash} (theme=${theme}, menu=${menu})`)
  }

  /**
   * Removes the entire cache root.
   */
  async invalidate () {
    await fs.rm(this.cacheRoot, { recursive: true, force: true })
    log('info', 'CACHE', 'invalidated prebuilt cache')
  }
}

async function copyEntry (src, dest) {
  const stat = await fs.stat(src)
  if (stat.isDirectory()) {
    await fs.cp(src, dest, { recursive: true })
  } else {
    await fs.mkdir(path.dirname(dest), { recursive: true })
    await fs.copyFile(src, dest)
  }
}

async function safeRename (src, dest) {
  try {
    await fs.rename(src, dest)
  } catch (e) {
    if (e.code === 'ENOTEMPTY' || e.code === 'EEXIST') {
      await fs.rm(src, { recursive: true, force: true })
    } else {
      throw e
    }
  }
}

export default BuildCache