Skip to content

Commit 09d83a0

Browse files
committed
[feat] typescript - projects have a persistent disk cache
1 parent 4685082 commit 09d83a0

File tree

12 files changed

+198
-10
lines changed

12 files changed

+198
-10
lines changed

src/command/preview/cmd.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ export const previewCommand = new Command()
278278
// get project and preview format
279279
const nbContext = notebookContext();
280280
const project = (await projectContext(dirname(file), nbContext)) ||
281-
singleFileProjectContext(file, nbContext);
281+
(await singleFileProjectContext(file, nbContext));
282282
const formats = await (async () => {
283283
const services = renderServices(nbContext);
284284
try {

src/command/preview/preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ export async function previewFormat(
373373
return format;
374374
}
375375
const nbContext = notebookContext();
376-
project = project || singleFileProjectContext(file, nbContext);
376+
project = project || (await singleFileProjectContext(file, nbContext));
377377
formats = formats ||
378378
await withRenderServices(
379379
nbContext,

src/command/publish/cmd.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ async function createPublishOptions(
339339

340340
// check for directory (either website or single-file project)
341341
const project = (await projectContext(path, nbContext)) ||
342-
singleFileProjectContext(path, nbContext);
342+
(await singleFileProjectContext(path, nbContext));
343343
if (Deno.statSync(path).isDirectory) {
344344
if (projectIsWebsite(project)) {
345345
input = project;

src/command/render/render-shared.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export async function render(
9696
validateDocumentRenderFlags(options.flags);
9797

9898
// NB: singleFileProjectContext is currently not fully-featured
99-
context = singleFileProjectContext(path, nbContext, options.flags);
99+
context = await singleFileProjectContext(path, nbContext, options.flags);
100100

101101
// otherwise it's just a file render
102102
const result = await renderFiles(

src/command/serve/cmd.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export const serveCommand = new Command()
6969

7070
const nbContext = notebookContext();
7171
const context = (await projectContext(input, nbContext)) ||
72-
singleFileProjectContext(input, nbContext);
72+
(await singleFileProjectContext(input, nbContext));
7373
const formats = await withRenderServices(
7474
nbContext,
7575
(services: RenderServices) =>

src/command/serve/serve.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export async function serve(options: RunOptions): Promise<ProcessResult> {
4848
const { host, port } = await resolveHostAndPort(options);
4949
const nbContext = notebookContext();
5050
const project = (await projectContext(options.input, nbContext)) ||
51-
singleFileProjectContext(options.input, nbContext);
51+
(await singleFileProjectContext(options.input, nbContext));
5252

5353
const engine = await fileExecutionEngine(options.input, undefined, project);
5454
if (engine?.run) {

src/core/cache/cache-types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* types.ts
3+
*
4+
* Types for ./cache.ts
5+
*
6+
* Copyright (C) 2025 Posit Software, PBC
7+
*/
8+
9+
export type ProjectCacheKey = string[];
10+
11+
export type CacheIndexEntry = {
12+
hash: string;
13+
type: "buffer" | "string";
14+
};
15+
16+
export type ProjectCache = {
17+
clear: () => Promise<void>;
18+
addBuffer: (key: ProjectCacheKey, value: Uint8Array) => Promise<void>;
19+
addString: (key: ProjectCacheKey, value: string) => Promise<void>;
20+
getBuffer: (key: ProjectCacheKey) => Promise<Uint8Array | null>;
21+
getString: (key: ProjectCacheKey) => Promise<string | null>;
22+
get: (key: ProjectCacheKey) => Promise<CacheIndexEntry | null>;
23+
};

src/core/cache/cache.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
};

src/project/project-context.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import { NotebookContext } from "../render/notebook/notebook-types.ts";
101101
import { MappedString } from "../core/mapped-text.ts";
102102
import { timeCall } from "../core/performance/function-times.ts";
103103
import { assertEquals } from "testing/asserts";
104+
import { createProjectCache } from "../core/cache/cache.ts";
104105

105106
export async function projectContext(
106107
path: string,
@@ -305,6 +306,7 @@ export async function projectContext(
305306
return projectFileMetadata(result, file, force);
306307
},
307308
isSingleFile: false,
309+
diskCache: await createProjectCache(join(dir, ".quarto")),
308310
};
309311

310312
// see if the project [kProjectType] wants to filter the project config
@@ -387,6 +389,7 @@ export async function projectContext(
387389
},
388390
notebookContext,
389391
isSingleFile: false,
392+
diskCache: await createProjectCache(join(dir, ".quarto")),
390393
};
391394
const { files, engines } = await projectInputFiles(
392395
result,
@@ -453,6 +456,7 @@ export async function projectContext(
453456
return projectFileMetadata(context, file, force);
454457
},
455458
isSingleFile: false,
459+
diskCache: await createProjectCache(join(dir, ".quarto")),
456460
};
457461
if (Deno.statSync(path).isDirectory) {
458462
const { files, engines } = await projectInputFiles(context);

src/project/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
ProjectConfig as ProjectConfig_Project,
2222
} from "../resources/types/schema-types.ts";
2323
import { ProjectEnvironment } from "./project-environment-types.ts";
24+
import { ProjectCache } from "../core/cache/cache-types.ts";
25+
2426
export {
2527
type NavigationItem as NavItem,
2628
type NavigationItemObject,
@@ -110,6 +112,8 @@ export interface ProjectContext {
110112
environment: () => Promise<ProjectEnvironment>;
111113

112114
isSingleFile: boolean;
115+
116+
diskCache: ProjectCache;
113117
}
114118

115119
export interface ProjectFiles {

0 commit comments

Comments
 (0)