From b70553af63069a42ed35d89ccb569b82eb737d6e Mon Sep 17 00:00:00 2001 From: neverland Date: Thu, 6 Aug 2026 18:47:22 +0800 Subject: [PATCH] feat(fmt): add persistent cache store --- packages/rstack/src/fmt/cacheStore.ts | 174 +++++++++++++++++++ packages/rstack/tests/fmt/cacheStore.test.ts | 109 ++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 packages/rstack/src/fmt/cacheStore.ts create mode 100644 packages/rstack/tests/fmt/cacheStore.test.ts diff --git a/packages/rstack/src/fmt/cacheStore.ts b/packages/rstack/src/fmt/cacheStore.ts new file mode 100644 index 00000000..1eca9562 --- /dev/null +++ b/packages/rstack/src/fmt/cacheStore.ts @@ -0,0 +1,174 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const fmtCacheFileName = 'fmt-v1.json'; +const fmtCacheVersion = 1; + +type FmtCacheState = 'clean' | 'dirty'; +type FmtCacheEntry = readonly [contentHash: string, optionsHash: string, state: FmtCacheState]; + +interface FmtCacheFile { + version: typeof fmtCacheVersion; + namespace: string; + files: Record; +} + +interface FmtCacheStore { + get(filePath: string): FmtCacheEntry | undefined; + set(filePath: string, entry: FmtCacheEntry): void; + /** Persists changed entries and returns whether the cache file was replaced. */ + save(): Promise; +} + +const createEmptyCache = (namespace: string): FmtCacheFile => ({ + version: fmtCacheVersion, + namespace, + files: Object.create(null) as Record, +}); + +const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => { + if ( + !Array.isArray(value) || + value.length !== 3 || + typeof value[0] !== 'string' || + typeof value[1] !== 'string' || + (value[2] !== 'clean' && value[2] !== 'dirty') + ) { + return; + } + + return [value[0], value[1], value[2]]; +}; + +const parseCacheFile = (content: string): FmtCacheFile | undefined => { + let value: unknown; + try { + value = JSON.parse(content); + } catch { + return; + } + + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + !('version' in value) || + value.version !== fmtCacheVersion || + !('namespace' in value) || + typeof value.namespace !== 'string' || + !('files' in value) || + typeof value.files !== 'object' || + value.files === null || + Array.isArray(value.files) + ) { + return; + } + + const files = Object.create(null) as Record; + for (const [filePath, rawEntry] of Object.entries(value.files)) { + const entry = parseCacheEntry(rawEntry); + if (!entry) { + return; + } + files[filePath] = entry; + } + + return { + version: fmtCacheVersion, + namespace: value.namespace, + files, + }; +}; + +const serializeCache = (cache: FmtCacheFile): string => `${JSON.stringify(cache)}\n`; + +const isFileNotFoundError = (error: unknown): error is NodeJS.ErrnoException => + error instanceof Error && 'code' in error && error.code === 'ENOENT'; + +const getTemporaryPath = (filePath: string): string => + path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`, + ); + +class FmtCacheStoreImpl implements FmtCacheStore { + readonly #filePath: string; + readonly #cache: FmtCacheFile; + #savedContent: string | undefined; + #changed: boolean; + + constructor( + filePath: string, + cache: FmtCacheFile, + savedContent: string | undefined, + changed: boolean, + ) { + this.#filePath = filePath; + this.#cache = cache; + this.#savedContent = savedContent; + this.#changed = changed; + } + + get(filePath: string): FmtCacheEntry | undefined { + return this.#cache.files[filePath]; + } + + set(filePath: string, entry: FmtCacheEntry): void { + const current = this.#cache.files[filePath]; + if (current?.[0] === entry[0] && current[1] === entry[1] && current[2] === entry[2]) { + return; + } + + this.#cache.files[filePath] = [entry[0], entry[1], entry[2]]; + this.#changed = true; + } + + async save(): Promise { + if (!this.#changed) { + return false; + } + + const content = serializeCache(this.#cache); + if (content === this.#savedContent) { + this.#changed = false; + return false; + } + + const temporaryPath = getTemporaryPath(this.#filePath); + try { + await mkdir(path.dirname(this.#filePath), { recursive: true }); + await writeFile(temporaryPath, content); + await rename(temporaryPath, this.#filePath); + this.#savedContent = content; + this.#changed = false; + return true; + } catch { + return false; + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } + } +} + +const loadFmtCacheStore = async (filePath: string, namespace: string): Promise => { + const emptyCache = createEmptyCache(namespace); + + try { + const content = await readFile(filePath, 'utf8'); + const cache = parseCacheFile(content); + if (!cache) { + return new FmtCacheStoreImpl(filePath, emptyCache, undefined, true); + } + + return cache.namespace === namespace + ? new FmtCacheStoreImpl(filePath, cache, content, false) + : new FmtCacheStoreImpl(filePath, emptyCache, undefined, true); + } catch (error) { + const missing = isFileNotFoundError(error); + return new FmtCacheStoreImpl(filePath, emptyCache, undefined, !missing); + } +}; + +export { fmtCacheFileName, fmtCacheVersion, loadFmtCacheStore }; +export type { FmtCacheEntry, FmtCacheFile, FmtCacheState, FmtCacheStore }; diff --git a/packages/rstack/tests/fmt/cacheStore.test.ts b/packages/rstack/tests/fmt/cacheStore.test.ts new file mode 100644 index 00000000..dcee1a6b --- /dev/null +++ b/packages/rstack/tests/fmt/cacheStore.test.ts @@ -0,0 +1,109 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { fmtCacheVersion, loadFmtCacheStore, type FmtCacheFile } from '../../src/fmt/cacheStore.ts'; +import { withTempProject } from './helpers.ts'; + +const namespace = 'test-namespace'; +const firstEntry = ['content-a', 'options-a', 'clean'] as const; +const secondEntry = ['content-b', 'options-b', 'dirty'] as const; + +const readCache = (filePath: string): FmtCacheFile => + JSON.parse(readFileSync(filePath, 'utf8')) as FmtCacheFile; + +test('writes entries that can be loaded by another store', async () => { + await withTempProject(async (rootPath) => { + const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json'); + const store = await loadFmtCacheStore(cachePath, namespace); + + expect(await store.save()).toBe(false); + expect(existsSync(cachePath)).toBe(false); + + store.set('src/a.ts', firstEntry); + expect(await store.save()).toBe(true); + expect(await store.save()).toBe(false); + + const loaded = await loadFmtCacheStore(cachePath, namespace); + expect(loaded.get('src/a.ts')).toEqual(firstEntry); + }); +}); + +test('preserves unvisited entries and skips unchanged updates', async () => { + await withTempProject(async (rootPath) => { + const cachePath = path.join(rootPath, 'fmt-v1.json'); + writeFileSync( + cachePath, + `${JSON.stringify({ + version: fmtCacheVersion, + namespace, + files: { + 'src/a.ts': firstEntry, + 'src/b.ts': secondEntry, + }, + })}\n`, + ); + + const store = await loadFmtCacheStore(cachePath, namespace); + store.set('src/a.ts', secondEntry); + store.set('src/a.ts', firstEntry); + expect(await store.save()).toBe(false); + + store.set('src/a.ts', secondEntry); + expect(await store.save()).toBe(true); + expect(readCache(cachePath).files).toEqual({ + 'src/a.ts': secondEntry, + 'src/b.ts': secondEntry, + }); + }); +}); + +test('discards invalid data and entries from another namespace', async () => { + await withTempProject(async (rootPath) => { + const cachePath = path.join(rootPath, 'fmt-v1.json'); + const invalidContents = [ + '{invalid', + JSON.stringify({ version: 2, namespace, files: {} }), + JSON.stringify({ + version: fmtCacheVersion, + namespace, + files: { 'src/a.ts': ['content', 'options', 'unknown'] }, + }), + ]; + + for (const content of invalidContents) { + writeFileSync(cachePath, content); + const store = await loadFmtCacheStore(cachePath, namespace); + expect(store.get('src/a.ts')).toBeUndefined(); + } + + writeFileSync( + cachePath, + JSON.stringify({ + version: fmtCacheVersion, + namespace: 'old-namespace', + files: { 'src/a.ts': firstEntry }, + }), + ); + const store = await loadFmtCacheStore(cachePath, namespace); + expect(store.get('src/a.ts')).toBeUndefined(); + expect(await store.save()).toBe(true); + expect(readCache(cachePath)).toEqual({ + version: fmtCacheVersion, + namespace, + files: {}, + }); + }); +}); + +test('does not throw or leave temporary files when persistence fails', async () => { + await withTempProject(async (rootPath) => { + const cachePath = path.join(rootPath, 'fmt-v1.json'); + mkdirSync(cachePath); + + const store = await loadFmtCacheStore(cachePath, namespace); + store.set('src/a.ts', firstEntry); + + await expect(store.save()).resolves.toBe(false); + expect(readdirSync(rootPath).filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); +});