Skip to content

Commit f2949b5

Browse files
authored
feat(fmt): add persistent cache store (#223)
1 parent 6ff1ff2 commit f2949b5

2 files changed

Lines changed: 283 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { randomUUID } from 'node:crypto';
2+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3+
import path from 'node:path';
4+
5+
const fmtCacheFileName = 'fmt-v1.json';
6+
const fmtCacheVersion = 1;
7+
8+
type FmtCacheState = 'clean' | 'dirty';
9+
type FmtCacheEntry = readonly [contentHash: string, optionsHash: string, state: FmtCacheState];
10+
11+
interface FmtCacheFile {
12+
version: typeof fmtCacheVersion;
13+
namespace: string;
14+
files: Record<string, FmtCacheEntry>;
15+
}
16+
17+
interface FmtCacheStore {
18+
get(filePath: string): FmtCacheEntry | undefined;
19+
set(filePath: string, entry: FmtCacheEntry): void;
20+
/** Persists changed entries and returns whether the cache file was replaced. */
21+
save(): Promise<boolean>;
22+
}
23+
24+
const createEmptyCache = (namespace: string): FmtCacheFile => ({
25+
version: fmtCacheVersion,
26+
namespace,
27+
files: Object.create(null) as Record<string, FmtCacheEntry>,
28+
});
29+
30+
const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => {
31+
if (
32+
!Array.isArray(value) ||
33+
value.length !== 3 ||
34+
typeof value[0] !== 'string' ||
35+
typeof value[1] !== 'string' ||
36+
(value[2] !== 'clean' && value[2] !== 'dirty')
37+
) {
38+
return;
39+
}
40+
41+
return [value[0], value[1], value[2]];
42+
};
43+
44+
const parseCacheFile = (content: string): FmtCacheFile | undefined => {
45+
let value: unknown;
46+
try {
47+
value = JSON.parse(content);
48+
} catch {
49+
return;
50+
}
51+
52+
if (
53+
typeof value !== 'object' ||
54+
value === null ||
55+
Array.isArray(value) ||
56+
!('version' in value) ||
57+
value.version !== fmtCacheVersion ||
58+
!('namespace' in value) ||
59+
typeof value.namespace !== 'string' ||
60+
!('files' in value) ||
61+
typeof value.files !== 'object' ||
62+
value.files === null ||
63+
Array.isArray(value.files)
64+
) {
65+
return;
66+
}
67+
68+
const files = Object.create(null) as Record<string, FmtCacheEntry>;
69+
for (const [filePath, rawEntry] of Object.entries(value.files)) {
70+
const entry = parseCacheEntry(rawEntry);
71+
if (!entry) {
72+
return;
73+
}
74+
files[filePath] = entry;
75+
}
76+
77+
return {
78+
version: fmtCacheVersion,
79+
namespace: value.namespace,
80+
files,
81+
};
82+
};
83+
84+
const serializeCache = (cache: FmtCacheFile): string => `${JSON.stringify(cache)}\n`;
85+
86+
const isFileNotFoundError = (error: unknown): error is NodeJS.ErrnoException =>
87+
error instanceof Error && 'code' in error && error.code === 'ENOENT';
88+
89+
const getTemporaryPath = (filePath: string): string =>
90+
path.join(
91+
path.dirname(filePath),
92+
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
93+
);
94+
95+
class FmtCacheStoreImpl implements FmtCacheStore {
96+
readonly #filePath: string;
97+
readonly #cache: FmtCacheFile;
98+
#savedContent: string | undefined;
99+
#changed: boolean;
100+
101+
constructor(
102+
filePath: string,
103+
cache: FmtCacheFile,
104+
savedContent: string | undefined,
105+
changed: boolean,
106+
) {
107+
this.#filePath = filePath;
108+
this.#cache = cache;
109+
this.#savedContent = savedContent;
110+
this.#changed = changed;
111+
}
112+
113+
get(filePath: string): FmtCacheEntry | undefined {
114+
return this.#cache.files[filePath];
115+
}
116+
117+
set(filePath: string, entry: FmtCacheEntry): void {
118+
const current = this.#cache.files[filePath];
119+
if (current?.[0] === entry[0] && current[1] === entry[1] && current[2] === entry[2]) {
120+
return;
121+
}
122+
123+
this.#cache.files[filePath] = [entry[0], entry[1], entry[2]];
124+
this.#changed = true;
125+
}
126+
127+
async save(): Promise<boolean> {
128+
if (!this.#changed) {
129+
return false;
130+
}
131+
132+
const content = serializeCache(this.#cache);
133+
if (content === this.#savedContent) {
134+
this.#changed = false;
135+
return false;
136+
}
137+
138+
const temporaryPath = getTemporaryPath(this.#filePath);
139+
try {
140+
await mkdir(path.dirname(this.#filePath), { recursive: true });
141+
await writeFile(temporaryPath, content);
142+
await rename(temporaryPath, this.#filePath);
143+
this.#savedContent = content;
144+
this.#changed = false;
145+
return true;
146+
} catch {
147+
return false;
148+
} finally {
149+
await rm(temporaryPath, { force: true }).catch(() => undefined);
150+
}
151+
}
152+
}
153+
154+
const loadFmtCacheStore = async (filePath: string, namespace: string): Promise<FmtCacheStore> => {
155+
const emptyCache = createEmptyCache(namespace);
156+
157+
try {
158+
const content = await readFile(filePath, 'utf8');
159+
const cache = parseCacheFile(content);
160+
if (!cache) {
161+
return new FmtCacheStoreImpl(filePath, emptyCache, undefined, true);
162+
}
163+
164+
return cache.namespace === namespace
165+
? new FmtCacheStoreImpl(filePath, cache, content, false)
166+
: new FmtCacheStoreImpl(filePath, emptyCache, undefined, true);
167+
} catch (error) {
168+
const missing = isFileNotFoundError(error);
169+
return new FmtCacheStoreImpl(filePath, emptyCache, undefined, !missing);
170+
}
171+
};
172+
173+
export { fmtCacheFileName, fmtCacheVersion, loadFmtCacheStore };
174+
export type { FmtCacheEntry, FmtCacheFile, FmtCacheState, FmtCacheStore };
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2+
import path from 'node:path';
3+
import { expect, test } from 'rstack/test';
4+
import { fmtCacheVersion, loadFmtCacheStore, type FmtCacheFile } from '../../src/fmt/cacheStore.ts';
5+
import { withTempProject } from './helpers.ts';
6+
7+
const namespace = 'test-namespace';
8+
const firstEntry = ['content-a', 'options-a', 'clean'] as const;
9+
const secondEntry = ['content-b', 'options-b', 'dirty'] as const;
10+
11+
const readCache = (filePath: string): FmtCacheFile =>
12+
JSON.parse(readFileSync(filePath, 'utf8')) as FmtCacheFile;
13+
14+
test('writes entries that can be loaded by another store', async () => {
15+
await withTempProject(async (rootPath) => {
16+
const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json');
17+
const store = await loadFmtCacheStore(cachePath, namespace);
18+
19+
expect(await store.save()).toBe(false);
20+
expect(existsSync(cachePath)).toBe(false);
21+
22+
store.set('src/a.ts', firstEntry);
23+
expect(await store.save()).toBe(true);
24+
expect(await store.save()).toBe(false);
25+
26+
const loaded = await loadFmtCacheStore(cachePath, namespace);
27+
expect(loaded.get('src/a.ts')).toEqual(firstEntry);
28+
});
29+
});
30+
31+
test('preserves unvisited entries and skips unchanged updates', async () => {
32+
await withTempProject(async (rootPath) => {
33+
const cachePath = path.join(rootPath, 'fmt-v1.json');
34+
writeFileSync(
35+
cachePath,
36+
`${JSON.stringify({
37+
version: fmtCacheVersion,
38+
namespace,
39+
files: {
40+
'src/a.ts': firstEntry,
41+
'src/b.ts': secondEntry,
42+
},
43+
})}\n`,
44+
);
45+
46+
const store = await loadFmtCacheStore(cachePath, namespace);
47+
store.set('src/a.ts', secondEntry);
48+
store.set('src/a.ts', firstEntry);
49+
expect(await store.save()).toBe(false);
50+
51+
store.set('src/a.ts', secondEntry);
52+
expect(await store.save()).toBe(true);
53+
expect(readCache(cachePath).files).toEqual({
54+
'src/a.ts': secondEntry,
55+
'src/b.ts': secondEntry,
56+
});
57+
});
58+
});
59+
60+
test('discards invalid data and entries from another namespace', async () => {
61+
await withTempProject(async (rootPath) => {
62+
const cachePath = path.join(rootPath, 'fmt-v1.json');
63+
const invalidContents = [
64+
'{invalid',
65+
JSON.stringify({ version: 2, namespace, files: {} }),
66+
JSON.stringify({
67+
version: fmtCacheVersion,
68+
namespace,
69+
files: { 'src/a.ts': ['content', 'options', 'unknown'] },
70+
}),
71+
];
72+
73+
for (const content of invalidContents) {
74+
writeFileSync(cachePath, content);
75+
const store = await loadFmtCacheStore(cachePath, namespace);
76+
expect(store.get('src/a.ts')).toBeUndefined();
77+
}
78+
79+
writeFileSync(
80+
cachePath,
81+
JSON.stringify({
82+
version: fmtCacheVersion,
83+
namespace: 'old-namespace',
84+
files: { 'src/a.ts': firstEntry },
85+
}),
86+
);
87+
const store = await loadFmtCacheStore(cachePath, namespace);
88+
expect(store.get('src/a.ts')).toBeUndefined();
89+
expect(await store.save()).toBe(true);
90+
expect(readCache(cachePath)).toEqual({
91+
version: fmtCacheVersion,
92+
namespace,
93+
files: {},
94+
});
95+
});
96+
});
97+
98+
test('does not throw or leave temporary files when persistence fails', async () => {
99+
await withTempProject(async (rootPath) => {
100+
const cachePath = path.join(rootPath, 'fmt-v1.json');
101+
mkdirSync(cachePath);
102+
103+
const store = await loadFmtCacheStore(cachePath, namespace);
104+
store.set('src/a.ts', firstEntry);
105+
106+
await expect(store.save()).resolves.toBe(false);
107+
expect(readdirSync(rootPath).filter((name) => name.endsWith('.tmp'))).toEqual([]);
108+
});
109+
});

0 commit comments

Comments
 (0)