cache.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. import fs from "node:fs/promises"
  2. import path from "path"
  3. import { createHash } from "crypto"
  4. import { createReadStream } from "fs"
  5. import stableStringify from "safe-stable-stringify"
  6. import {
  7. slugifyString,
  8. checkFilesExist,
  9. getValueAtPath,
  10. removeCwd,
  11. getDeepestPropertiesForKey,
  12. } from "./util.js"
  13. export function hashObject(obj) {
  14. const str = stableStringify(obj)
  15. if (!hashCache.has(str)) {
  16. const hashString = createHash("md5")
  17. .update(str)
  18. .digest("hex")
  19. hashCache.set(str, hashString)
  20. return hashString
  21. } else {
  22. const cachedHashString = hashCache.get(str)
  23. return cachedHashString
  24. }
  25. }
  26. const hashCache = new Map()
  27. export async function getFileHash(filePath, algorithm = "md5") {
  28. return new Promise((resolve, reject) => {
  29. if (!hashCache.has(filePath)) {
  30. const hash = createHash(algorithm)
  31. const stream = createReadStream(filePath)
  32. stream.on("error", reject)
  33. stream.on("data", chunk => hash.update(chunk))
  34. stream.on("end", () => {
  35. const hashString = hash.digest("hex")
  36. hashCache.set(filePath, hashString)
  37. resolve(hashString)
  38. })
  39. } else {
  40. const cachedHashString = hashCache.get(filePath)
  41. resolve(cachedHashString)
  42. }
  43. })
  44. }
  45. async function getFileHashes(pathDeps) {
  46. return Promise.all(
  47. Object.keys(pathDeps).map(async filePath => {
  48. const hash = await getFileHash(filePath)
  49. if (hash !== pathDeps[filePath]) {
  50. return Promise.reject({ filePath, hash })
  51. }
  52. return Promise.resolve(pathDeps[filePath])
  53. }),
  54. )
  55. }
  56. function getStatePropsHash(state, props) {
  57. const stateValues = props.reduce((depmap, dep) => {
  58. const value = getValueAtPath(state, dep)
  59. return { ...depmap, [dep]: value }
  60. }, {})
  61. return hashObject(stateValues)
  62. }
  63. export async function checkCache(cacheKey, currentState, opts) {
  64. const name = slugifyString(cacheKey)
  65. const existingCacheObject = await readCache(opts.cacheDir, name)
  66. if (existingCacheObject.exists) {
  67. const fileChecks = opts.ignoreExisting ? {} : await checkFilesExist(existingCacheObject.content.taskResult.paths, opts.outDir)
  68. if (!fileChecks.absent?.length) {
  69. const stateHash = getStatePropsHash(
  70. currentState,
  71. existingCacheObject.content.deps.state.props,
  72. )
  73. if (stateHash === existingCacheObject.content.deps.state.hash) {
  74. try {
  75. await getFileHashes(existingCacheObject.content.deps.paths)
  76. return { hit: true, taskResult: existingCacheObject.content.taskResult, filePath: existingCacheObject.filePath }
  77. } catch (e) {
  78. const updates = {
  79. deps: {
  80. paths: [e],
  81. },
  82. }
  83. return { hit: false, reason: `File hash mismatch: ${e.filePath}`, updates, filePath: existingCacheObject.filePath }
  84. }
  85. }
  86. const updates = {
  87. deps: {
  88. state: {
  89. ...existingCacheObject.content.deps.state,
  90. hash: stateHash,
  91. },
  92. },
  93. }
  94. const stateValuesList = existingCacheObject.content.deps.state.values
  95. if (stateValuesList) {
  96. const mismatchedStateProps = existingCacheObject.content.deps.state.props.filter((stateProp, index) => {
  97. const currentValue = getValueAtPath(currentState, stateProp)
  98. const cachedValue = stateValuesList[index]
  99. return currentValue !== cachedValue
  100. })
  101. return { hit: false, reason: `State hash mismatch: ${mismatchedStateProps.join(", ")}`, updates, filePath: existingCacheObject.filePath }
  102. }
  103. return { hit: false, reason: "State hash mismatch (no values were found in cache)", updates, filePath: existingCacheObject.filePath }
  104. }
  105. if (opts.clean) {
  106. const outFiles = existingCacheObject.content.taskResult.paths
  107. await Promise.all(
  108. outFiles.map(
  109. async outFile =>
  110. await fs.rm(path.join(opts.outDir, outFile), { force: true }),
  111. ),
  112. )
  113. }
  114. return { hit: false, reason: `Missing output file(s): ${fileChecks.absent.join(", ")}`, filePath: existingCacheObject.filePath }
  115. }
  116. return { hit: false, reason: `Missing cache file: ${existingCacheObject.filePath}`, filePath: existingCacheObject.filePath }
  117. }
  118. export async function updateCache(
  119. cacheDir,
  120. cacheKey,
  121. pathDeps,
  122. stateDeps,
  123. taskResult,
  124. updates,
  125. includeStateValues,
  126. ) {
  127. await fs.mkdir(cacheDir, { recursive: true })
  128. const name = slugifyString(cacheKey)
  129. const accessedState = getDeepestPropertiesForKey([...stateDeps], "path")
  130. const deps = {
  131. paths: [...new Set(removeCwd(pathDeps))],
  132. state: accessedState.reduce(
  133. (as, { path, value }) => ({ ...as, [path]: value }),
  134. {},
  135. ),
  136. }
  137. const statePropsList = Object.keys(deps.state)
  138. const stateValuesList = Object.values(deps.state)
  139. const updatesStateHash = updates?.deps?.state?.props || []
  140. const stateDepsHash =
  141. JSON.stringify(statePropsList) === JSON.stringify(updatesStateHash)
  142. ? updates?.deps?.state?.hash
  143. : hashObject(deps.state)
  144. const updatesPathsCache =
  145. updates?.deps?.paths?.reduce(
  146. (pc, { filePath, hash }) => ({
  147. ...pc,
  148. [filePath]: hash,
  149. }),
  150. {},
  151. ) || {}
  152. const pathsCache = (await Promise.all(
  153. deps.paths.map(async filePath => {
  154. const hash = updatesPathsCache[filePath]
  155. ? updatesPathsCache[filePath]
  156. : await getFileHash(filePath)
  157. return {
  158. hash,
  159. filePath,
  160. }
  161. }),
  162. )).reduce((pc, { filePath, hash }) => ({ ...pc, [filePath]: hash }), {})
  163. const cacheObject = {
  164. deps: {
  165. state: {
  166. hash: stateDepsHash,
  167. props: Object.keys(deps.state),
  168. },
  169. paths: pathsCache,
  170. },
  171. taskResult,
  172. }
  173. if (includeStateValues) {
  174. cacheObject.deps.state.values = stateValuesList
  175. }
  176. return await writeCache(cacheDir, name, cacheObject)
  177. }
  178. async function writeCache(cacheDir, name, cache) {
  179. if (!cacheDir) {
  180. return false
  181. }
  182. return fs.writeFile(
  183. path.join(cacheDir, `${name}.json`),
  184. JSON.stringify(cache),
  185. "utf8",
  186. )
  187. }
  188. async function readCache(cacheDir, name) {
  189. const filePath = path.join(cacheDir, `${name}.json`)
  190. if (!cacheDir) {
  191. return { exists: false, filePath }
  192. }
  193. try {
  194. const content = await fs.readFile(
  195. filePath,
  196. "utf8",
  197. )
  198. return { exists: true, content: JSON.parse(content), filePath }
  199. } catch (e) {
  200. return { exists: false, filePath, reason: e }
  201. }
  202. }