cache-verdict 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +311 -2
- package/bin/cache-verdict.mjs +383 -0
- package/examples/npm-build-cache/README.md +95 -0
- package/examples/npm-build-cache/build.mjs +25 -0
- package/examples/npm-build-cache/package.json +10 -0
- package/examples/npm-build-cache/src/input.txt +5 -0
- package/examples/run.mjs +63 -0
- package/lib/config.mjs +141 -0
- package/lib/detect.mjs +647 -0
- package/lib/glob.mjs +192 -0
- package/lib/key.mjs +52 -0
- package/lib/ledger.mjs +357 -0
- package/lib/report.mjs +144 -0
- package/lib/run.mjs +204 -0
- package/package.json +41 -4
package/lib/glob.mjs
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { lstatSync, readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Paths in configs and ledgers always use forward slashes so a config written
|
|
6
|
+
* on Windows still describes the same inputs on Linux CI.
|
|
7
|
+
*/
|
|
8
|
+
export function toPosix(value) {
|
|
9
|
+
return value.split(path.sep).join('/');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function hasGlob(value) {
|
|
13
|
+
return /[*?[\]{}]/.test(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function expandEnvironment(value, environment = process.env) {
|
|
17
|
+
return value.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (match, braced, bare) => {
|
|
18
|
+
const name = braced ?? bare;
|
|
19
|
+
return Object.prototype.hasOwnProperty.call(environment, name) ? environment[name] : match;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function escapeRegExp(character) {
|
|
24
|
+
return /[\\^$.*+?()[\]{}|]/.test(character) ? `\\${character}` : character;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Translate the small glob dialect used by detectors into a regular
|
|
29
|
+
* expression: `*` stays inside one path segment, `**` crosses segments, and
|
|
30
|
+
* a double-star directory prefix also matches zero directories.
|
|
31
|
+
*/
|
|
32
|
+
export function globToRegExp(pattern) {
|
|
33
|
+
let source = '';
|
|
34
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
35
|
+
const character = pattern[index];
|
|
36
|
+
|
|
37
|
+
if (character === '*') {
|
|
38
|
+
if (pattern[index + 1] === '*') {
|
|
39
|
+
while (pattern[index + 1] === '*') index += 1;
|
|
40
|
+
if (pattern[index + 1] === '/') {
|
|
41
|
+
index += 1;
|
|
42
|
+
source += '(?:.*/)?';
|
|
43
|
+
} else {
|
|
44
|
+
source += '.*';
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
source += '[^/]*';
|
|
48
|
+
}
|
|
49
|
+
} else if (character === '?') {
|
|
50
|
+
source += '[^/]';
|
|
51
|
+
} else {
|
|
52
|
+
source += escapeRegExp(character);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return new RegExp(`^${source}$`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function matchGlob(relativePath, pattern) {
|
|
59
|
+
return globToRegExp(toPosix(pattern)).test(toPosix(relativePath));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function lstatSafe(target) {
|
|
63
|
+
try {
|
|
64
|
+
return lstatSync(target);
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Walk a project without following symlinks. Generated dependency trees are
|
|
72
|
+
* skipped by default because they are never useful build inputs and can make
|
|
73
|
+
* content hashing unexpectedly expensive.
|
|
74
|
+
*/
|
|
75
|
+
export function walkTree(root, options = {}) {
|
|
76
|
+
const skipDirectories = new Set(
|
|
77
|
+
options.skipDirectories ?? ['.git', '.hg', '.svn', '.cache-verdict', 'node_modules'],
|
|
78
|
+
);
|
|
79
|
+
const files = [];
|
|
80
|
+
const directories = [];
|
|
81
|
+
|
|
82
|
+
function walk(relativeDirectory) {
|
|
83
|
+
const absoluteDirectory =
|
|
84
|
+
relativeDirectory === ''
|
|
85
|
+
? root
|
|
86
|
+
: path.join(root, ...relativeDirectory.split('/').filter(Boolean));
|
|
87
|
+
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = readdirSync(absoluteDirectory, { withFileTypes: true });
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
if (entry.isSymbolicLink()) continue;
|
|
97
|
+
|
|
98
|
+
const relative = relativeDirectory === '' ? entry.name : `${relativeDirectory}/${entry.name}`;
|
|
99
|
+
|
|
100
|
+
if (entry.isDirectory()) {
|
|
101
|
+
directories.push(relative);
|
|
102
|
+
if (skipDirectories.has(entry.name)) continue;
|
|
103
|
+
walk(relative);
|
|
104
|
+
} else if (entry.isFile()) {
|
|
105
|
+
files.push(relative);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
walk('');
|
|
111
|
+
files.sort();
|
|
112
|
+
directories.sort();
|
|
113
|
+
return { files, directories };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function staticPrefix(pattern) {
|
|
117
|
+
const segments = pattern.split('/');
|
|
118
|
+
const kept = [];
|
|
119
|
+
for (const segment of segments) {
|
|
120
|
+
if (hasGlob(segment)) break;
|
|
121
|
+
kept.push(segment);
|
|
122
|
+
}
|
|
123
|
+
// The final literal segment may be the file itself, so only walk its parent.
|
|
124
|
+
if (kept.length === segments.length) kept.pop();
|
|
125
|
+
return kept.join('/');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve declared input or cache paths to absolute filesystem targets.
|
|
130
|
+
*
|
|
131
|
+
* A non-glob path is always returned (unless `existingOnly` is set), because
|
|
132
|
+
* `run` needs to know where a cache directory will appear after the build.
|
|
133
|
+
* Globs only return paths that currently exist.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} root absolute project directory
|
|
136
|
+
* @param {string[]} patterns declared paths, possibly containing $ENV or globs
|
|
137
|
+
* @param {{ existingOnly?: boolean, environment?: NodeJS.ProcessEnv }} [options]
|
|
138
|
+
* @returns {{ targets: string[], unresolved: string[] }}
|
|
139
|
+
*/
|
|
140
|
+
export function resolvePathPatterns(root, patterns, options = {}) {
|
|
141
|
+
const existingOnly = options.existingOnly === true;
|
|
142
|
+
const environment = options.environment ?? process.env;
|
|
143
|
+
const targets = new Set();
|
|
144
|
+
const unresolved = [];
|
|
145
|
+
|
|
146
|
+
for (const declared of patterns) {
|
|
147
|
+
if (typeof declared !== 'string' || declared.trim() === '') continue;
|
|
148
|
+
|
|
149
|
+
const expanded = expandEnvironment(declared.trim(), environment);
|
|
150
|
+
if (expanded.includes('$')) {
|
|
151
|
+
unresolved.push(declared);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (path.isAbsolute(expanded)) {
|
|
156
|
+
if (hasGlob(expanded)) {
|
|
157
|
+
unresolved.push(declared);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const stat = lstatSafe(expanded);
|
|
161
|
+
if (!existingOnly || stat !== undefined) targets.add(path.normalize(expanded));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!hasGlob(expanded)) {
|
|
166
|
+
const target = path.resolve(root, expanded);
|
|
167
|
+
const stat = lstatSafe(target);
|
|
168
|
+
if (!existingOnly || stat !== undefined) targets.add(target);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const prefix = staticPrefix(toPosix(expanded));
|
|
173
|
+
const base = prefix === '' ? root : path.resolve(root, ...prefix.split('/'));
|
|
174
|
+
const baseStat = lstatSafe(base);
|
|
175
|
+
if (baseStat === undefined || !baseStat.isDirectory()) continue;
|
|
176
|
+
|
|
177
|
+
const pattern = toPosix(expanded);
|
|
178
|
+
const relativeBase = path.relative(root, base);
|
|
179
|
+
const start = relativeBase === '' ? '' : toPosix(relativeBase);
|
|
180
|
+
const tree = walkTree(root, { skipDirectories: ['.git', '.hg', '.svn', '.cache-verdict', 'node_modules'] });
|
|
181
|
+
|
|
182
|
+
for (const relative of [...tree.files, ...tree.directories]) {
|
|
183
|
+
if (start !== '' && !(relative === start || relative.startsWith(`${start}/`))) continue;
|
|
184
|
+
if (matchGlob(relative, pattern)) targets.add(path.resolve(root, ...relative.split('/')));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
targets: [...targets].sort(),
|
|
190
|
+
unresolved,
|
|
191
|
+
};
|
|
192
|
+
}
|
package/lib/key.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { matchGlob, toPosix, walkTree } from './glob.mjs';
|
|
6
|
+
|
|
7
|
+
function sha256(data) {
|
|
8
|
+
return createHash('sha256').update(data).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Content-address the declared inputs. The command, cache paths, and input
|
|
13
|
+
* patterns are part of the address too: changing how a build is invoked or
|
|
14
|
+
* what it measures must not silently reuse an unrelated stored cache.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} directory absolute project directory
|
|
17
|
+
* @param {{ command: string, inputs: string[], cachePath: string[] }} config
|
|
18
|
+
* @returns {{ key: string, files: Array<{ path: string, sha256: string }> }}
|
|
19
|
+
*/
|
|
20
|
+
export function computeInputKey(directory, config) {
|
|
21
|
+
const patterns = [...new Set(config.inputs.map(toPosix))].sort();
|
|
22
|
+
const cachePatterns = [...new Set(config.cachePath.map(toPosix))].sort();
|
|
23
|
+
const { files } = walkTree(directory);
|
|
24
|
+
|
|
25
|
+
const matched = files.filter((relative) =>
|
|
26
|
+
patterns.some((pattern) => matchGlob(relative, pattern)),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const entries = matched.map((relative) => {
|
|
30
|
+
const absolute = path.join(directory, ...relative.split('/'));
|
|
31
|
+
let contents;
|
|
32
|
+
try {
|
|
33
|
+
contents = readFileSync(absolute);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new Error(`cannot read declared input ${relative}: ${error.message}`);
|
|
36
|
+
}
|
|
37
|
+
return { path: relative, sha256: sha256(contents) };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const payload = {
|
|
41
|
+
schemaVersion: 1,
|
|
42
|
+
command: config.command,
|
|
43
|
+
inputs: patterns,
|
|
44
|
+
cachePath: cachePatterns,
|
|
45
|
+
files: entries,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
key: `sha256:${sha256(JSON.stringify(payload))}`,
|
|
50
|
+
files: entries,
|
|
51
|
+
};
|
|
52
|
+
}
|
package/lib/ledger.mjs
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
cpSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
statSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { toPosix } from './glob.mjs';
|
|
18
|
+
|
|
19
|
+
export const WORK_DIRECTORY = '.cache-verdict';
|
|
20
|
+
export const LEDGER_FILENAME = 'ledger.jsonl';
|
|
21
|
+
export const STORE_DIRECTORY = 'store';
|
|
22
|
+
export const MANIFEST_FILENAME = 'manifest.json';
|
|
23
|
+
export const TMP_DIRECTORY = 'tmp';
|
|
24
|
+
|
|
25
|
+
export function workDirectory(root) {
|
|
26
|
+
return path.join(root, WORK_DIRECTORY);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ledgerPath(root) {
|
|
30
|
+
return path.join(workDirectory(root), LEDGER_FILENAME);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function storeDirectory(root) {
|
|
34
|
+
return path.join(workDirectory(root), STORE_DIRECTORY);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function tempDirectory(root) {
|
|
38
|
+
return path.join(workDirectory(root), TMP_DIRECTORY);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ensureWorkDirectory(root) {
|
|
42
|
+
const directory = workDirectory(root);
|
|
43
|
+
mkdirSync(directory, { recursive: true });
|
|
44
|
+
return directory;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function pathExists(target) {
|
|
48
|
+
return existsSync(target);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function pathSize(target) {
|
|
52
|
+
let stat;
|
|
53
|
+
try {
|
|
54
|
+
stat = lstatSync(target);
|
|
55
|
+
} catch {
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (stat.isSymbolicLink()) return stat.size;
|
|
60
|
+
if (!stat.isDirectory()) return stat.size;
|
|
61
|
+
|
|
62
|
+
let total = 0;
|
|
63
|
+
let entries;
|
|
64
|
+
try {
|
|
65
|
+
entries = readdirSync(target, { withFileTypes: true });
|
|
66
|
+
} catch {
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
total += pathSize(path.join(target, entry.name));
|
|
71
|
+
}
|
|
72
|
+
return total;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function removePath(target) {
|
|
76
|
+
rmSync(target, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function copyPath(source, target) {
|
|
80
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
81
|
+
cpSync(source, target, {
|
|
82
|
+
recursive: true,
|
|
83
|
+
force: true,
|
|
84
|
+
preserveTimestamps: true,
|
|
85
|
+
dereference: false,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Move a cache path out of the build directory. Falling back to copy+delete
|
|
91
|
+
* keeps absolute cache paths on another filesystem working too.
|
|
92
|
+
*/
|
|
93
|
+
export function movePath(source, target) {
|
|
94
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
95
|
+
removePath(target);
|
|
96
|
+
try {
|
|
97
|
+
renameSync(source, target);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error.code !== 'EXDEV') throw error;
|
|
100
|
+
copyPath(source, target);
|
|
101
|
+
removePath(source);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function readLedger(root) {
|
|
106
|
+
const target = ledgerPath(root);
|
|
107
|
+
let text;
|
|
108
|
+
try {
|
|
109
|
+
text = readFileSync(target, 'utf8');
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error.code === 'ENOENT') return [];
|
|
112
|
+
throw new Error(`cannot read ${target}: ${error.message}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const samples = [];
|
|
116
|
+
const lines = text.split(/\r?\n/);
|
|
117
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
118
|
+
const line = lines[index].trim();
|
|
119
|
+
if (line === '') continue;
|
|
120
|
+
try {
|
|
121
|
+
const sample = JSON.parse(line);
|
|
122
|
+
if (sample === null || typeof sample !== 'object' || Array.isArray(sample)) {
|
|
123
|
+
throw new Error('sample must be a JSON object');
|
|
124
|
+
}
|
|
125
|
+
samples.push(sample);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
throw new Error(`cannot parse ${target} line ${index + 1}: ${error.message}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return samples;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function appendLedgerSample(root, sample) {
|
|
134
|
+
ensureWorkDirectory(root);
|
|
135
|
+
appendFileSync(ledgerPath(root), `${JSON.stringify(sample)}\n`, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function keySegment(key) {
|
|
139
|
+
const match = /^sha256:([a-f0-9]{64})$/.exec(key);
|
|
140
|
+
if (!match) throw new Error(`invalid cache key ${JSON.stringify(key)}`);
|
|
141
|
+
return match[1];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function storeKeyDirectory(root, key) {
|
|
145
|
+
return path.join(storeDirectory(root), keySegment(key));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function encodeStoredTarget(root, target) {
|
|
149
|
+
const relative = path.relative(root, target);
|
|
150
|
+
if (relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
151
|
+
return { kind: 'relative', path: toPosix(relative) };
|
|
152
|
+
}
|
|
153
|
+
return { kind: 'absolute', path: target };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function decodeStoredTarget(root, target) {
|
|
157
|
+
if (target?.kind === 'relative' && typeof target.path === 'string') {
|
|
158
|
+
return path.resolve(root, ...target.path.split('/'));
|
|
159
|
+
}
|
|
160
|
+
if (target?.kind === 'absolute' && typeof target.path === 'string') {
|
|
161
|
+
return path.normalize(target.path);
|
|
162
|
+
}
|
|
163
|
+
throw new Error('stored cache manifest contains an invalid target');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readManifestFile(directory) {
|
|
167
|
+
const target = path.join(directory, MANIFEST_FILENAME);
|
|
168
|
+
let text;
|
|
169
|
+
try {
|
|
170
|
+
text = readFileSync(target, 'utf8');
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error.code === 'ENOENT') return null;
|
|
173
|
+
throw new Error(`cannot read ${target}: ${error.message}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let manifest;
|
|
177
|
+
try {
|
|
178
|
+
manifest = JSON.parse(text);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
throw new Error(`cannot parse ${target}: ${error.message}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (manifest === null || typeof manifest !== 'object' || !Array.isArray(manifest.slots)) {
|
|
184
|
+
throw new Error(`${target} must contain a manifest object with "slots"`);
|
|
185
|
+
}
|
|
186
|
+
return manifest;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function readStoredCache(root, key) {
|
|
190
|
+
const directory = storeKeyDirectory(root, key);
|
|
191
|
+
const manifest = readManifestFile(directory);
|
|
192
|
+
if (manifest === null) return null;
|
|
193
|
+
if (manifest.key !== key) {
|
|
194
|
+
throw new Error(`stored cache ${key} has a mismatched manifest`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const slots = manifest.slots
|
|
198
|
+
.filter((slot) => Number.isInteger(slot.index) && slot.target !== undefined)
|
|
199
|
+
.filter((slot) => existsSync(path.join(directory, String(slot.index))))
|
|
200
|
+
.sort((left, right) => left.index - right.index);
|
|
201
|
+
|
|
202
|
+
if (slots.length === 0) return null;
|
|
203
|
+
return { ...manifest, slots, directory };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function restoreStoredCache(root, manifest) {
|
|
207
|
+
let restoredBytes = 0;
|
|
208
|
+
|
|
209
|
+
for (const slot of manifest.slots) {
|
|
210
|
+
const target = decodeStoredTarget(root, slot.target);
|
|
211
|
+
const source = path.join(manifest.directory, String(slot.index));
|
|
212
|
+
removePath(target);
|
|
213
|
+
copyPath(source, target);
|
|
214
|
+
restoredBytes += Number.isFinite(slot.sizeBytes) ? slot.sizeBytes : pathSize(source);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { restoredBytes, restoredSlots: manifest.slots.length };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function touchStoredCache(root, manifest) {
|
|
221
|
+
const { directory, ...stored } = manifest;
|
|
222
|
+
const updated = { ...stored, lastUsedAt: new Date().toISOString() };
|
|
223
|
+
writeFileSync(
|
|
224
|
+
path.join(directory, MANIFEST_FILENAME),
|
|
225
|
+
`${JSON.stringify(updated, null, 2)}\n`,
|
|
226
|
+
'utf8',
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Store the artifacts produced by a build under their content-addressed key.
|
|
232
|
+
* The new directory is built beside the old one and swapped in at the end, so
|
|
233
|
+
* a failed copy cannot destroy the cache that was just used for a hit.
|
|
234
|
+
*/
|
|
235
|
+
export function storeCache(root, key, targets) {
|
|
236
|
+
const store = storeDirectory(root);
|
|
237
|
+
mkdirSync(store, { recursive: true });
|
|
238
|
+
|
|
239
|
+
const segment = keySegment(key);
|
|
240
|
+
const finalDirectory = path.join(store, segment);
|
|
241
|
+
const staging = path.join(store, `.${segment}.${process.pid}.${randomUUID()}.tmp`);
|
|
242
|
+
mkdirSync(staging, { recursive: true });
|
|
243
|
+
|
|
244
|
+
let sizeBytes = 0;
|
|
245
|
+
const slots = [];
|
|
246
|
+
const sorted = [...targets].sort();
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
for (let index = 0; index < sorted.length; index += 1) {
|
|
250
|
+
const target = sorted[index];
|
|
251
|
+
const slotDirectory = path.join(staging, String(index));
|
|
252
|
+
copyPath(target, slotDirectory);
|
|
253
|
+
const slotSize = pathSize(target);
|
|
254
|
+
sizeBytes += slotSize;
|
|
255
|
+
slots.push({
|
|
256
|
+
index,
|
|
257
|
+
target: encodeStoredTarget(root, target),
|
|
258
|
+
sizeBytes: slotSize,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const now = new Date().toISOString();
|
|
263
|
+
const manifest = {
|
|
264
|
+
schemaVersion: 1,
|
|
265
|
+
key,
|
|
266
|
+
createdAt: now,
|
|
267
|
+
lastUsedAt: now,
|
|
268
|
+
sizeBytes,
|
|
269
|
+
slots,
|
|
270
|
+
};
|
|
271
|
+
writeFileSync(
|
|
272
|
+
path.join(staging, MANIFEST_FILENAME),
|
|
273
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
274
|
+
'utf8',
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
removePath(finalDirectory);
|
|
278
|
+
renameSync(staging, finalDirectory);
|
|
279
|
+
return manifest;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
removePath(staging);
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function listStoreDirectories(root) {
|
|
287
|
+
const store = storeDirectory(root);
|
|
288
|
+
let entries;
|
|
289
|
+
try {
|
|
290
|
+
entries = readdirSync(store, { withFileTypes: true });
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error.code === 'ENOENT') return [];
|
|
293
|
+
throw new Error(`cannot read ${store}: ${error.message}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return entries
|
|
297
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
298
|
+
.map((entry) => path.join(store, entry.name));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function storeStats(root) {
|
|
302
|
+
const directories = listStoreDirectories(root);
|
|
303
|
+
let sizeBytes = 0;
|
|
304
|
+
let keys = 0;
|
|
305
|
+
|
|
306
|
+
for (const directory of directories) {
|
|
307
|
+
const manifest = readManifestFile(directory);
|
|
308
|
+
if (manifest === null) {
|
|
309
|
+
sizeBytes += pathSize(directory);
|
|
310
|
+
keys += 1;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
sizeBytes += Number.isFinite(manifest.sizeBytes) ? manifest.sizeBytes : pathSize(directory);
|
|
314
|
+
keys += 1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { keys, sizeBytes };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Evict least-recently-used stored keys. A hit updates lastUsedAt, so pruning
|
|
322
|
+
* keeps the keys the sampled builds actually restore.
|
|
323
|
+
*/
|
|
324
|
+
export function pruneStore(root, keep) {
|
|
325
|
+
const directories = listStoreDirectories(root);
|
|
326
|
+
const entries = directories.map((directory) => {
|
|
327
|
+
let manifest = null;
|
|
328
|
+
try {
|
|
329
|
+
manifest = readManifestFile(directory);
|
|
330
|
+
} catch {
|
|
331
|
+
manifest = null;
|
|
332
|
+
}
|
|
333
|
+
const lastUsed = manifest?.lastUsedAt ? Date.parse(manifest.lastUsedAt) : Number.NaN;
|
|
334
|
+
let fallback = 0;
|
|
335
|
+
try {
|
|
336
|
+
fallback = statSync(directory).mtimeMs;
|
|
337
|
+
} catch {
|
|
338
|
+
fallback = 0;
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
directory,
|
|
342
|
+
key: manifest?.key ?? path.basename(directory),
|
|
343
|
+
lastUsedAt: Number.isFinite(lastUsed) ? lastUsed : fallback,
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
entries.sort((left, right) => right.lastUsedAt - left.lastUsedAt);
|
|
348
|
+
|
|
349
|
+
const kept = entries.slice(0, keep);
|
|
350
|
+
const evicted = entries.slice(keep);
|
|
351
|
+
for (const entry of evicted) removePath(entry.directory);
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
kept: kept.map((entry) => entry.key),
|
|
355
|
+
evicted: evicted.map((entry) => entry.key),
|
|
356
|
+
};
|
|
357
|
+
}
|