|
| 1 | +/* |
| 2 | + * cache.ts |
| 3 | + * |
| 4 | + * A persistent cache for projects |
| 5 | + * |
| 6 | + * Copyright (C) 2025 Posit Software, PBC |
| 7 | + */ |
| 8 | + |
| 9 | +import { assert } from "testing/asserts"; |
| 10 | +import { ensureDirSync } from "../../deno_ral/fs.ts"; |
| 11 | +import { join } from "../../deno_ral/path.ts"; |
| 12 | +import { md5HashBytes } from "../hash.ts"; |
| 13 | +import { satisfies } from "semver/mod.ts"; |
| 14 | +import { quartoConfig } from "../quarto.ts"; |
| 15 | +import { CacheIndexEntry, ProjectCache } from "./cache-types.ts"; |
| 16 | + |
| 17 | +export { type ProjectCache } from "./cache-types.ts"; |
| 18 | + |
| 19 | +const currentCacheVersion = "1"; |
| 20 | +const requiredQuartoVersions: Record<string, string> = { |
| 21 | + "1": ">1.7.0", |
| 22 | +}; |
| 23 | + |
| 24 | +class ProjectCacheImpl { |
| 25 | + location: string; |
| 26 | + index: Deno.Kv | null; |
| 27 | + |
| 28 | + constructor(_location: string) { |
| 29 | + this.location = _location; |
| 30 | + this.index = null; |
| 31 | + } |
| 32 | + |
| 33 | + async clear(): Promise<void> { |
| 34 | + assert(this.index); |
| 35 | + const entries = this.index.list({ prefix: [] }); |
| 36 | + for await (const entry of entries) { |
| 37 | + const diskValue = entry.value; |
| 38 | + if (entry.key[0] !== "version") { |
| 39 | + Deno.removeSync( |
| 40 | + join(this.location, "project-cache", diskValue as string), |
| 41 | + ); |
| 42 | + } |
| 43 | + await this.index.delete(entry.key); |
| 44 | + } |
| 45 | + } |
| 46 | + |
| 47 | + async createOnDisk(): Promise<void> { |
| 48 | + assert(this.index); |
| 49 | + this.index.set(["version"], currentCacheVersion); |
| 50 | + } |
| 51 | + |
| 52 | + async init(): Promise<void> { |
| 53 | + const indexPath = join(this.location, "project-cache"); |
| 54 | + ensureDirSync(indexPath); |
| 55 | + this.index = await Deno.openKv(indexPath); |
| 56 | + const version = await this.index.get(["version"]); |
| 57 | + if (version.value === null) { |
| 58 | + await this.createOnDisk(); |
| 59 | + } |
| 60 | + assert(typeof version.value === "number"); |
| 61 | + const requiredVersion = requiredQuartoVersions[version.value as number]; |
| 62 | + if ( |
| 63 | + !requiredVersion || !satisfies(requiredVersion, quartoConfig.version()) |
| 64 | + ) { |
| 65 | + console.warn("Unknown project cache version."); |
| 66 | + console.warn( |
| 67 | + "Project cache was likely created by a newer version of Quarto.", |
| 68 | + ); |
| 69 | + console.warn("Quarto will clear the project cache."); |
| 70 | + await this.clear(); |
| 71 | + await this.createOnDisk(); |
| 72 | + } |
| 73 | + } |
| 74 | + |
| 75 | + async addBuffer(key: string[], value: Uint8Array): Promise<void> { |
| 76 | + assert(this.index); |
| 77 | + const hash = await md5HashBytes(value); |
| 78 | + Deno.writeFileSync(join(this.location, "project-cache", hash), value); |
| 79 | + const result = await this.index.set(key, { |
| 80 | + hash, |
| 81 | + type: "buffer", |
| 82 | + }); |
| 83 | + assert(result.ok); |
| 84 | + } |
| 85 | + |
| 86 | + async addString(key: string[], value: string): Promise<void> { |
| 87 | + assert(this.index); |
| 88 | + const buffer = new TextEncoder().encode(value); |
| 89 | + const hash = await md5HashBytes(buffer); |
| 90 | + Deno.writeTextFileSync(join(this.location, "project-cache", hash), value); |
| 91 | + const result = await this.index.set(key, { |
| 92 | + hash, |
| 93 | + type: "string", |
| 94 | + }); |
| 95 | + assert(result.ok); |
| 96 | + } |
| 97 | + |
| 98 | + async get(key: string[]): Promise<CacheIndexEntry | null> { |
| 99 | + assert(this.index); |
| 100 | + const kvResult = await this.index.get(key); |
| 101 | + if (kvResult.value === null) { |
| 102 | + return null; |
| 103 | + } |
| 104 | + return kvResult.value as CacheIndexEntry; |
| 105 | + } |
| 106 | + |
| 107 | + async _getAs<T>( |
| 108 | + key: string[], |
| 109 | + getter: (hash: string) => T, |
| 110 | + ): Promise<T | null> { |
| 111 | + assert(this.index); |
| 112 | + const result = await this.get(key); |
| 113 | + if (result === null) { |
| 114 | + return null; |
| 115 | + } |
| 116 | + try { |
| 117 | + return getter(result.hash); |
| 118 | + } catch (e) { |
| 119 | + if (!(e instanceof Deno.errors.NotFound)) { |
| 120 | + throw e; |
| 121 | + } |
| 122 | + console.warn( |
| 123 | + `Cache file ${result.hash} not found -- clearing entry in index`, |
| 124 | + ); |
| 125 | + await this.index.delete(key); |
| 126 | + return null; |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + async getBuffer(key: string[]): Promise<Uint8Array | null> { |
| 131 | + return this._getAs(key, (hash) => |
| 132 | + Deno.readFileSync( |
| 133 | + join(this.location, "project-cache", hash), |
| 134 | + )); |
| 135 | + } |
| 136 | + |
| 137 | + async getString(key: string[]): Promise<string | null> { |
| 138 | + return this._getAs(key, (hash) => |
| 139 | + Deno.readTextFileSync( |
| 140 | + join(this.location, "project-cache", hash), |
| 141 | + )); |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +export const createProjectCache = async ( |
| 146 | + location: string, |
| 147 | +): Promise<ProjectCache> => { |
| 148 | + const result = new ProjectCacheImpl(location); |
| 149 | + await result.init(); |
| 150 | + return result as ProjectCache; |
| 151 | +}; |
0 commit comments