From 13a2177f26a57c08e8000d007b67d0e608a6c127 Mon Sep 17 00:00:00 2001 From: neverland Date: Thu, 13 Aug 2026 12:06:47 +0800 Subject: [PATCH 1/5] perf(fmt): optimize cache serialization --- packages/rstack/src/fmt/cacheHash.ts | 12 + packages/rstack/src/fmt/cacheIdentity.ts | 8 +- packages/rstack/src/fmt/cacheStore.ts | 233 ++++++++++++++---- packages/rstack/src/fmt/worker.ts | 16 +- packages/rstack/tests/cli/fmt/cache.test.ts | 47 ++-- .../rstack/tests/fmt/cacheIdentity.test.ts | 10 +- packages/rstack/tests/fmt/cacheStore.test.ts | 105 +++++--- packages/rstack/tests/fmt/runnerCache.test.ts | 31 ++- packages/rstack/tests/fmt/worker.test.ts | 6 +- 9 files changed, 329 insertions(+), 139 deletions(-) create mode 100644 packages/rstack/src/fmt/cacheHash.ts diff --git a/packages/rstack/src/fmt/cacheHash.ts b/packages/rstack/src/fmt/cacheHash.ts new file mode 100644 index 00000000..7625bb4b --- /dev/null +++ b/packages/rstack/src/fmt/cacheHash.ts @@ -0,0 +1,12 @@ +import { hash } from 'node:crypto'; + +/** A 16-character base64url token preserves 96 bits of the SHA-256 digest. */ +const cacheHashLength = 16; + +const createCacheHash = (content: string | Uint8Array): string => + hash('sha256', content, 'base64url').slice(0, cacheHashLength); + +const isCacheHash = (value: unknown): value is string => + typeof value === 'string' && value.length === cacheHashLength; + +export { cacheHashLength, createCacheHash, isCacheHash }; diff --git a/packages/rstack/src/fmt/cacheIdentity.ts b/packages/rstack/src/fmt/cacheIdentity.ts index 0bbdbbe1..78e69531 100644 --- a/packages/rstack/src/fmt/cacheIdentity.ts +++ b/packages/rstack/src/fmt/cacheIdentity.ts @@ -1,6 +1,6 @@ -import { hash } from 'node:crypto'; import { isAbsolute } from 'node:path'; import stableStringify from 'fast-json-stable-stringify'; +import { createCacheHash } from './cacheHash.ts'; import { fmtCacheVersion } from './cacheStore.ts'; import { createRelativePathResolver, toPosixPath } from './pathHelpers.ts'; import type { ResolvedFmtOptions } from './types.ts'; @@ -12,8 +12,6 @@ type CacheKeyResolver = (filePath: string) => string | undefined; type OptionsHasher = (options: ResolvedFmtOptions) => string | undefined; type PluginFingerprints = ReadonlyMap; -const sha256 = (content: string | Uint8Array): string => hash('sha256', content, 'hex'); - /** Identifies formatter behavior shared by all cache entries in this process. */ const cacheNamespace: string = JSON.stringify([fmtCacheVersion, RSTACK_VERSION, PRETTIER_VERSION]); @@ -55,7 +53,7 @@ const createOptionsHasher = (pluginFingerprints?: PluginFingerprints): OptionsHa } value = { ...options, plugins: fingerprints }; } - hash = sha256(stableStringify(value)); + hash = createCacheHash(stableStringify(value)); } catch { // Circular or unreadable options cannot be cached. } @@ -65,4 +63,4 @@ const createOptionsHasher = (pluginFingerprints?: PluginFingerprints): OptionsHa }; }; -export { cacheNamespace, createCacheKeyResolver, createOptionsHasher, sha256 }; +export { cacheNamespace, createCacheKeyResolver, createOptionsHasher }; diff --git a/packages/rstack/src/fmt/cacheStore.ts b/packages/rstack/src/fmt/cacheStore.ts index dfc47b60..cdaf7ea5 100644 --- a/packages/rstack/src/fmt/cacheStore.ts +++ b/packages/rstack/src/fmt/cacheStore.ts @@ -1,11 +1,27 @@ import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { isCacheHash } from './cacheHash.ts'; -const fmtCacheFileName = 'v1.json'; -const fmtCacheVersion = 1; +const fmtCacheFileName = 'v2.json'; +const fmtCacheVersion = 2; -type FmtCacheState = 'clean' | 'dirty' | 'unsupported'; +const fileEntryWidth = 4; +const contentHashOffset = 1; +const optionsIndexOffset = 2; +const stateOffset = 3; + +const fmtCacheStates = ['clean', 'dirty', 'unsupported'] as const; +type FmtCacheState = (typeof fmtCacheStates)[number]; +type FmtCacheStateId = 0 | 1 | 2; + +const fmtCacheStateIds = { + clean: 0, + dirty: 1, + unsupported: 2, +} as const satisfies Record; + +type FmtCacheFileValue = string | number | null; type FmtCacheEntry = | readonly [contentHash: string, optionsHash: string, state: 'clean' | 'dirty'] | readonly [contentHash: string | null, optionsHash: string, state: 'unsupported']; @@ -13,7 +29,16 @@ type FmtCacheEntry = interface FmtCacheFile { version: typeof fmtCacheVersion; namespace: string; - files: Record; + options: string[]; + /** Repeated tuples of file path, content hash, options index, and numeric state. */ + files: FmtCacheFileValue[]; +} + +interface ParsedFmtCacheFile { + cache: FmtCacheFile; + fileOffsets: Map; + optionsIndexes: Map; + optionsUseCounts: number[]; } interface FmtCacheStore { @@ -23,30 +48,27 @@ interface FmtCacheStore { save(): Promise; } -const createEmptyCache = (namespace: string): FmtCacheFile => ({ - version: fmtCacheVersion, - namespace, - files: Object.create(null) as Record, +const createEmptyCache = (namespace: string): ParsedFmtCacheFile => ({ + cache: { + version: fmtCacheVersion, + namespace, + options: [], + files: [], + }, + fileOffsets: new Map(), + optionsIndexes: new Map(), + optionsUseCounts: [], }); -const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => { - if (!Array.isArray(value) || value.length !== 3 || typeof value[1] !== 'string') { - return; - } +const isFmtCacheStateId = (value: unknown): value is FmtCacheStateId => + typeof value === 'number' && + Number.isInteger(value) && + value >= 0 && + value < fmtCacheStates.length; - if (value[2] === 'unsupported') { - return value[0] === null || typeof value[0] === 'string' - ? [value[0], value[1], value[2]] - : undefined; - } - if (typeof value[0] !== 'string' || (value[2] !== 'clean' && value[2] !== 'dirty')) { - return; - } - - return [value[0], value[1], value[2]]; -}; +const isUnknownArray = (value: unknown): value is unknown[] => Array.isArray(value); -const parseCacheFile = (content: string): FmtCacheFile | undefined => { +const parseCacheFile = (content: string): ParsedFmtCacheFile | undefined => { let value: unknown; try { value = JSON.parse(content); @@ -57,32 +79,60 @@ const parseCacheFile = (content: string): FmtCacheFile | undefined => { if ( typeof value !== 'object' || value === null || - Array.isArray(value) || + isUnknownArray(value) || !('version' in value) || value.version !== fmtCacheVersion || !('namespace' in value) || typeof value.namespace !== 'string' || + !('options' in value) || + !isUnknownArray(value.options) || !('files' in value) || - typeof value.files !== 'object' || - value.files === null || - Array.isArray(value.files) + !isUnknownArray(value.files) || + value.files.length % fileEntryWidth !== 0 ) { return; } - const files = Object.create(null) as Record; - for (const [filePath, rawEntry] of Object.entries(value.files)) { - const entry = parseCacheEntry(rawEntry); - if (!entry) { + const optionsIndexes = new Map(); + for (let index = 0; index < value.options.length; index++) { + const optionsHash = value.options[index]; + if (!isCacheHash(optionsHash) || optionsIndexes.has(optionsHash)) { return; } - files[filePath] = entry; + optionsIndexes.set(optionsHash, index); + } + + const fileOffsets = new Map(); + const optionsUseCounts = new Array(value.options.length).fill(0); + for (let offset = 0; offset < value.files.length; offset += fileEntryWidth) { + const filePath = value.files[offset]; + const contentHash = value.files[offset + contentHashOffset]; + const optionsIndex = value.files[offset + optionsIndexOffset]; + const state = value.files[offset + stateOffset]; + if ( + typeof filePath !== 'string' || + fileOffsets.has(filePath) || + typeof optionsIndex !== 'number' || + !Number.isInteger(optionsIndex) || + optionsIndex < 0 || + optionsIndex >= value.options.length || + !isFmtCacheStateId(state) || + (state === fmtCacheStateIds.unsupported + ? contentHash !== null && !isCacheHash(contentHash) + : !isCacheHash(contentHash)) + ) { + return; + } + + fileOffsets.set(filePath, offset); + optionsUseCounts[optionsIndex]++; } return { - version: fmtCacheVersion, - namespace: value.namespace, - files, + cache: value as FmtCacheFile, + fileOffsets, + optionsIndexes, + optionsUseCounts, }; }; @@ -100,43 +150,126 @@ const getTemporaryPath = (filePath: string): string => class FmtCacheStoreImpl implements FmtCacheStore { readonly #filePath: string; readonly #cache: FmtCacheFile; + readonly #fileOffsets: Map; + readonly #optionsIndexes: Map; + readonly #optionsUseCounts: number[]; #savedContent: string | undefined; #changed: boolean; constructor( filePath: string, - cache: FmtCacheFile, + parsed: ParsedFmtCacheFile, savedContent: string | undefined, changed: boolean, ) { this.#filePath = filePath; - this.#cache = cache; + this.#cache = parsed.cache; + this.#fileOffsets = parsed.fileOffsets; + this.#optionsIndexes = parsed.optionsIndexes; + this.#optionsUseCounts = parsed.optionsUseCounts; this.#savedContent = savedContent; this.#changed = changed; } get(filePath: string): FmtCacheEntry | undefined { - return this.#cache.files[filePath]; + const offset = this.#fileOffsets.get(filePath); + if (offset === undefined) { + return; + } + + const { files, options } = this.#cache; + const contentHash = files[offset + contentHashOffset] as string | null; + const optionsHash = options[files[offset + optionsIndexOffset] as number]; + const state = fmtCacheStates[files[offset + stateOffset] as FmtCacheStateId]; + return state === 'unsupported' + ? [contentHash, optionsHash, state] + : [contentHash as string, optionsHash, state]; } 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; + const { files, options } = this.#cache; + const [contentHash, optionsHash, state] = entry; + const stateId = fmtCacheStateIds[state]; + const offset = this.#fileOffsets.get(filePath); + + if (offset !== undefined) { + const currentOptionsIndex = files[offset + optionsIndexOffset] as number; + if ( + files[offset + contentHashOffset] === contentHash && + options[currentOptionsIndex] === optionsHash && + files[offset + stateOffset] === stateId + ) { + return; + } + + const optionsIndex = this.#getOrCreateOptionsIndex(optionsHash); + if (currentOptionsIndex !== optionsIndex) { + this.#optionsUseCounts[currentOptionsIndex]--; + this.#optionsUseCounts[optionsIndex]++; + files[offset + optionsIndexOffset] = optionsIndex; + } + files[offset + contentHashOffset] = contentHash; + files[offset + stateOffset] = stateId; + } else { + const optionsIndex = this.#getOrCreateOptionsIndex(optionsHash); + const nextOffset = files.length; + files.push(filePath, contentHash, optionsIndex, stateId); + this.#fileOffsets.set(filePath, nextOffset); + this.#optionsUseCounts[optionsIndex]++; } - this.#cache.files[filePath] = - entry[2] === 'unsupported' - ? [entry[0], entry[1], 'unsupported'] - : [entry[0], entry[1], entry[2]]; this.#changed = true; } + #getOrCreateOptionsIndex(optionsHash: string): number { + const current = this.#optionsIndexes.get(optionsHash); + if (current !== undefined) { + return current; + } + + const index = this.#cache.options.length; + this.#cache.options.push(optionsHash); + this.#optionsIndexes.set(optionsHash, index); + this.#optionsUseCounts.push(0); + return index; + } + + #compactUnusedOptions(): void { + if (!this.#optionsUseCounts.includes(0)) { + return; + } + + const { files, options } = this.#cache; + const nextOptions: string[] = []; + const nextUseCounts: number[] = []; + const remappedIndexes = new Int32Array(options.length).fill(-1); + for (let index = 0; index < options.length; index++) { + const useCount = this.#optionsUseCounts[index]; + if (useCount > 0) { + remappedIndexes[index] = nextOptions.length; + nextOptions.push(options[index]); + nextUseCounts.push(useCount); + } + } + for (let offset = 0; offset < files.length; offset += fileEntryWidth) { + const currentIndex = files[offset + optionsIndexOffset] as number; + files[offset + optionsIndexOffset] = remappedIndexes[currentIndex]; + } + + options.splice(0, options.length, ...nextOptions); + this.#optionsUseCounts.splice(0, this.#optionsUseCounts.length, ...nextUseCounts); + this.#optionsIndexes.clear(); + for (let index = 0; index < options.length; index++) { + this.#optionsIndexes.set(options[index], index); + } + } + async save(): Promise { if (!this.#changed) { return false; } + this.#compactUnusedOptions(); const content = serializeCache(this.#cache); if (content === this.#savedContent) { this.#changed = false; @@ -164,13 +297,13 @@ const loadFmtCacheStore = async (filePath: string, namespace: string): Promise hash('sha256', content, 'hex'); - /** * Use synchronous direct I/O inside the dedicated worker to avoid libuv * scheduling overhead. This prioritizes throughput over crash-safe replacement. @@ -37,7 +35,7 @@ const formatFile = async ({ } sourceBuffer = readFileSync(file.path); - contentHash = hashContent(sourceBuffer); + contentHash = createCacheHash(sourceBuffer); return sourceBuffer.toString('utf8'); }; @@ -50,14 +48,14 @@ const formatFile = async ({ } } else { sourceBuffer = readFileSync(file.path); - contentHash = hashContent(sourceBuffer); + contentHash = createCacheHash(sourceBuffer); if (entry[0] === contentHash) { return { status: 'unsupported' }; } } } else { sourceBuffer = readFileSync(file.path); - contentHash = hashContent(sourceBuffer); + contentHash = createCacheHash(sourceBuffer); if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) { return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' }; } @@ -73,7 +71,7 @@ const formatFile = async ({ cacheEntry: [ hasDottedBasename(file.path) ? null - : (contentHash ?? hashContent(sourceBuffer ?? readFileSync(file.path))), + : (contentHash ?? createCacheHash(sourceBuffer ?? readFileSync(file.path))), cache.optionsHash, 'unsupported', ], @@ -94,8 +92,8 @@ const formatFile = async ({ const cacheHash = shouldWrite && !unchanged - ? hashContent(result.formatted) - : (contentHash ?? hashContent(result.source)); + ? createCacheHash(result.formatted) + : (contentHash ?? createCacheHash(result.source)); const cacheEntry: FmtCacheEntry = [ cacheHash, cache.optionsHash, diff --git a/packages/rstack/tests/cli/fmt/cache.test.ts b/packages/rstack/tests/cli/fmt/cache.test.ts index 15e51dea..8e6a819d 100644 --- a/packages/rstack/tests/cli/fmt/cache.test.ts +++ b/packages/rstack/tests/cli/fmt/cache.test.ts @@ -4,6 +4,27 @@ import { expectWriteSummary, normalizeDuration, setupFmtTest } from './helpers.t const { projectFileExists, readProjectFile, resolveProjectPath, runFmt, writeProjectFile } = setupFmtTest(); +interface SerializedFmtCache { + version: number; + namespace: string; + options: string[]; + files: (string | number | null)[]; +} + +const readFmtCache = (filePath: string): SerializedFmtCache => + JSON.parse(readProjectFile(filePath)) as SerializedFmtCache; + +const expectSingleCleanEntry = (cache: SerializedFmtCache, filePath: string): void => { + expect(cache.version).toBe(2); + expect(typeof cache.namespace).toBe('string'); + expect(cache.options).toHaveLength(1); + expect(cache.options[0]).toHaveLength(16); + expect(cache.files).toHaveLength(4); + expect(cache.files[0]).toBe(filePath); + expect(cache.files[1]).toEqual(expect.any(String)); + expect(cache.files.slice(2)).toEqual([0, 0]); +}; + test.each([ ['write', []], ['check', ['--check']], @@ -16,12 +37,7 @@ test.each([ expect(result.status).toBe(0); expect(readProjectFile('.rstack/cache/.gitignore')).toBe('*\n'); - expect(JSON.parse(readProjectFile('.rstack/cache/fmt/v1.json'))).toMatchObject({ - version: 1, - files: { - 'index.ts': [expect.any(String), expect.any(String), 'clean'], - }, - }); + expectSingleCleanEntry(readFmtCache('.rstack/cache/fmt/v2.json'), 'index.ts'); expect(readProjectFile('.rstack/cache/fmt-v1.json')).toBe('legacy'); }); @@ -58,12 +74,7 @@ test.each(['relative', 'absolute'] as const)('uses a %s custom cache location', const result = runFmt(['--cache-location', cacheLocation, 'index.ts']); expect(result.status).toBe(0); - expect(JSON.parse(readProjectFile('custom-cache/v1.json'))).toMatchObject({ - version: 1, - files: { - 'index.ts': [expect.any(String), expect.any(String), 'clean'], - }, - }); + expectSingleCleanEntry(readFmtCache('custom-cache/v2.json'), 'index.ts'); expect(projectFileExists('custom-cache/.gitignore')).toBe(false); expect(projectFileExists('.rstack')).toBe(false); }); @@ -99,26 +110,22 @@ test('uses an explicit config root cache from a subdirectory', () => { expect(result.status).toBe(0); expect(readProjectFile('packages/app/index.ts')).toBe('const value = 1;\n'); - expect(projectFileExists('.rstack/cache/fmt/v1.json')).toBe(true); + expect(projectFileExists('.rstack/cache/fmt/v2.json')).toBe(true); expect(projectFileExists('packages/app/.rstack')).toBe(false); - expect(JSON.parse(readProjectFile('.rstack/cache/fmt/v1.json'))).toMatchObject({ - files: { - 'packages/app/index.ts': [expect.any(String), expect.any(String), 'clean'], - }, - }); + expectSingleCleanEntry(readFmtCache('.rstack/cache/fmt/v2.json'), 'packages/app/index.ts'); }); test('recovers from a corrupted cache', () => { writeProjectFile('index.ts', 'const value = 1;\n'); const first = runFmt(['--check', 'index.ts']); - writeProjectFile('.rstack/cache/fmt/v1.json', '{'); + writeProjectFile('.rstack/cache/fmt/v2.json', '{'); const second = runFmt(['--check', 'index.ts']); expect(second.status).toBe(0); expect(normalizeDuration(second.stdout)).toBe(normalizeDuration(first.stdout)); expect(second.stderr).toBe(first.stderr); - expect(JSON.parse(readProjectFile('.rstack/cache/fmt/v1.json'))).toMatchObject({ version: 1 }); + expect(JSON.parse(readProjectFile('.rstack/cache/fmt/v2.json'))).toMatchObject({ version: 2 }); }); test('formats without a writable cache directory', () => { diff --git a/packages/rstack/tests/fmt/cacheIdentity.test.ts b/packages/rstack/tests/fmt/cacheIdentity.test.ts index e0d48789..a3bdac8a 100644 --- a/packages/rstack/tests/fmt/cacheIdentity.test.ts +++ b/packages/rstack/tests/fmt/cacheIdentity.test.ts @@ -3,11 +3,11 @@ import { pathToFileURL } from 'node:url'; import prettierPkgJson from 'prettier/package.json' with { type: 'json' }; import { expect, test } from 'rstack/test'; import pkgJson from '../../package.json' with { type: 'json' }; +import { cacheHashLength, createCacheHash } from '../../src/fmt/cacheHash.ts'; import { cacheNamespace, createCacheKeyResolver, createOptionsHasher, - sha256, } from '../../src/fmt/cacheIdentity.ts'; import { fmtCacheVersion } from '../../src/fmt/cacheStore.ts'; import type { ResolvedFmtOptions } from '../../src/fmt/types.ts'; @@ -17,7 +17,7 @@ const rootPath = path.join(import.meta.dirname, 'project'); const asOptions = (value: Record): ResolvedFmtOptions => value as ResolvedFmtOptions; -test('creates stable SHA-256 option hashes', () => { +test('creates stable SHA-256-derived option hashes', () => { const hashOptions = createOptionsHasher(); const left: ResolvedFmtOptions = { singleQuote: true, @@ -29,8 +29,8 @@ test('creates stable SHA-256 option hashes', () => { }; expect(hashOptions(left)).toBe(hashOptions(right)); - expect(hashOptions(left)).toHaveLength(64); - expect(sha256('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + expect(hashOptions(left)).toHaveLength(cacheHashLength); + expect(createCacheHash('abc')).toBe('ungWv48Bz-pBQUDe'); }); test('invalidates hashes when final formatter options change', () => { @@ -52,7 +52,7 @@ test('includes plugin fingerprints in option hashes', () => { const first = createOptionsHasher(new Map([[plugin, 'plugin@1']])); const second = createOptionsHasher(new Map([[plugin, 'plugin@2']])); - expect(first({ plugins: [plugin] })).toHaveLength(64); + expect(first({ plugins: [plugin] })).toHaveLength(cacheHashLength); expect(first({ plugins: [new URL(plugin)] })).toBe(first({ plugins: [plugin] })); expect(first({ plugins: [plugin] })).not.toBe(second({ plugins: [plugin] })); }); diff --git a/packages/rstack/tests/fmt/cacheStore.test.ts b/packages/rstack/tests/fmt/cacheStore.test.ts index 713d804d..35cd9d01 100644 --- a/packages/rstack/tests/fmt/cacheStore.test.ts +++ b/packages/rstack/tests/fmt/cacheStore.test.ts @@ -1,21 +1,33 @@ 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 { createCacheHash } from '../../src/fmt/cacheHash.ts'; +import { + fmtCacheFileName, + 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 unsupportedEntry = [null, 'options-c', 'unsupported'] as const; -const hashedUnsupportedEntry = ['content-c', 'options-c', 'unsupported'] as const; +const contentA = createCacheHash('content-a'); +const contentB = createCacheHash('content-b'); +const contentC = createCacheHash('content-c'); +const optionsA = createCacheHash('options-a'); +const optionsB = createCacheHash('options-b'); +const optionsC = createCacheHash('options-c'); +const firstEntry = [contentA, optionsA, 'clean'] as const; +const secondEntry = [contentB, optionsB, 'dirty'] as const; +const unsupportedEntry = [null, optionsC, 'unsupported'] as const; +const hashedUnsupportedEntry = [contentC, optionsC, 'unsupported'] 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 () => { +test('writes flat entries that can be loaded by another store', async () => { await withTempProject(async (rootPath) => { - const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json'); + const cachePath = path.join(rootPath, 'cache', fmtCacheFileName); const store = await loadFmtCacheStore(cachePath, namespace); expect(await store.save()).toBe(false); @@ -26,6 +38,25 @@ test('writes entries that can be loaded by another store', async () => { store.set('script', hashedUnsupportedEntry); expect(await store.save()).toBe(true); expect(await store.save()).toBe(false); + expect(readCache(cachePath)).toEqual({ + version: fmtCacheVersion, + namespace, + options: [optionsA, optionsC], + files: [ + 'src/a.ts', + contentA, + 0, + 0, + 'src/unknown.fixture', + null, + 1, + 2, + 'script', + contentC, + 1, + 2, + ], + }); const loaded = await loadFmtCacheStore(cachePath, namespace); expect(loaded.get('src/a.ts')).toEqual(firstEntry); @@ -36,16 +67,14 @@ test('writes entries that can be loaded by another store', async () => { test('preserves unvisited entries and skips unchanged updates', async () => { await withTempProject(async (rootPath) => { - const cachePath = path.join(rootPath, 'fmt-v1.json'); + const cachePath = path.join(rootPath, fmtCacheFileName); writeFileSync( cachePath, `${JSON.stringify({ version: fmtCacheVersion, namespace, - files: { - 'src/a.ts': firstEntry, - 'src/b.ts': secondEntry, - }, + options: [optionsA, optionsB], + files: ['src/a.ts', contentA, 0, 0, 'src/b.ts', contentB, 1, 1], })}\n`, ); @@ -56,33 +85,41 @@ test('preserves unvisited entries and skips unchanged updates', async () => { 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, + expect(readCache(cachePath)).toEqual({ + version: fmtCacheVersion, + namespace, + options: [optionsB], + files: ['src/a.ts', contentB, 0, 1, 'src/b.ts', contentB, 0, 1], }); }); }); test('discards invalid data and entries from another namespace', async () => { await withTempProject(async (rootPath) => { - const cachePath = path.join(rootPath, 'fmt-v1.json'); + const cachePath = path.join(rootPath, fmtCacheFileName); + const validCache = { + version: fmtCacheVersion, + namespace, + options: [optionsA], + files: ['src/a.ts', contentA, 0, 0], + }; const invalidContents = [ '{invalid', - JSON.stringify({ version: 2, namespace, files: {} }), - JSON.stringify({ - version: fmtCacheVersion, - namespace, - files: { 'src/a.ts': ['content', 'options', 'unknown'] }, - }), - JSON.stringify({ - version: fmtCacheVersion, - namespace, - files: { 'src/a.ts': [42, 'options', 'unsupported'] }, - }), + JSON.stringify({ ...validCache, version: fmtCacheVersion - 1 }), + JSON.stringify({ version: fmtCacheVersion, namespace, files: [] }), + JSON.stringify({ ...validCache, options: ['too-short'] }), + JSON.stringify({ ...validCache, options: [optionsA, optionsA] }), + JSON.stringify({ ...validCache, files: { 'src/a.ts': firstEntry } }), + JSON.stringify({ ...validCache, files: ['src/a.ts', contentA, 0] }), + JSON.stringify({ ...validCache, files: [42, contentA, 0, 0] }), + JSON.stringify({ ...validCache, files: ['src/a.ts', 42, 0, 2] }), + JSON.stringify({ ...validCache, files: ['src/a.ts', null, 0, 0] }), + JSON.stringify({ ...validCache, files: ['src/a.ts', contentA, 1, 0] }), + JSON.stringify({ ...validCache, files: ['src/a.ts', contentA, 0.5, 0] }), + JSON.stringify({ ...validCache, files: ['src/a.ts', contentA, 0, 3] }), JSON.stringify({ - version: fmtCacheVersion, - namespace, - files: { 'src/a.ts': [null, 'options', 'clean'] }, + ...validCache, + files: ['src/a.ts', contentA, 0, 0, 'src/a.ts', contentB, 0, 1], }), ]; @@ -95,9 +132,8 @@ test('discards invalid data and entries from another namespace', async () => { writeFileSync( cachePath, JSON.stringify({ - version: fmtCacheVersion, + ...validCache, namespace: 'old-namespace', - files: { 'src/a.ts': firstEntry }, }), ); const store = await loadFmtCacheStore(cachePath, namespace); @@ -106,14 +142,15 @@ test('discards invalid data and entries from another namespace', async () => { expect(readCache(cachePath)).toEqual({ version: fmtCacheVersion, namespace, - files: {}, + options: [], + 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'); + const cachePath = path.join(rootPath, fmtCacheFileName); mkdirSync(cachePath); const store = await loadFmtCacheStore(cachePath, namespace); diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts index f9432a94..6a14fe02 100644 --- a/packages/rstack/tests/fmt/runnerCache.test.ts +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -2,7 +2,8 @@ import { readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { expect, test } from 'rstack/test'; -import { cacheNamespace, createOptionsHasher, sha256 } from '../../src/fmt/cacheIdentity.ts'; +import { cacheHashLength, createCacheHash } from '../../src/fmt/cacheHash.ts'; +import { cacheNamespace, createOptionsHasher } from '../../src/fmt/cacheIdentity.ts'; import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts'; import { runFmtFiles } from '../../src/fmt/runner.ts'; import type { FmtCacheContext, FmtFileRequest, FmtMode } from '../../src/fmt/types.ts'; @@ -36,12 +37,12 @@ for (const mode of ['check', 'list-different'] as const) { const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); expect(store.get('clean.ts')).toEqual([ - sha256(readFileSync(cleanPath)), + createCacheHash(readFileSync(cleanPath)), expect.any(String), 'clean', ]); expect(store.get('dirty.ts')).toEqual([ - sha256(readFileSync(dirtyPath)), + createCacheHash(readFileSync(dirtyPath)), expect.any(String), 'dirty', ]); @@ -79,7 +80,11 @@ test('uses content hashes instead of file metadata', async () => { const secondStore = await loadFmtCacheStore(cache.filePath, cacheNamespace); const secondEntry = secondStore.get('index.ts'); - expect(secondEntry).toEqual([sha256(readFileSync(filePath)), expect.any(String), 'dirty']); + expect(secondEntry).toEqual([ + createCacheHash(readFileSync(filePath)), + expect.any(String), + 'dirty', + ]); expect(secondEntry?.[0]).not.toBe(firstEntry?.[0]); }); }); @@ -101,7 +106,7 @@ test('invalidates entries when final options change', async () => { const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); expect(store.get('index.ts')).toEqual([ - sha256(readFileSync(filePath)), + createCacheHash(readFileSync(filePath)), createOptionsHasher()(changed.options), 'dirty', ]); @@ -135,7 +140,7 @@ test('caches unsupported parser results until final options change', async () => processedFileCount: 1, }); expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.unknown')).toEqual([ - sha256(readFileSync(filePath)), + createCacheHash(readFileSync(filePath)), createOptionsHasher()(supported.options), 'dirty', ]); @@ -155,7 +160,7 @@ test('invalidates cached unsupported parser results when content changes without processedFileCount: 0, }); expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('script')).toEqual([ - sha256(readFileSync(filePath)), + createCacheHash(readFileSync(filePath)), createOptionsHasher()(file.options), 'unsupported', ]); @@ -169,7 +174,7 @@ test('invalidates cached unsupported parser results when content changes without processedFileCount: 1, }); expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('script')).toEqual([ - sha256(readFileSync(filePath)), + createCacheHash(readFileSync(filePath)), createOptionsHasher()(file.options), 'dirty', ]); @@ -212,14 +217,14 @@ test('caches only plugins with stable fingerprints', async () => { const firstHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get( 'data.fixture', )?.[1]; - expect(firstHash).toHaveLength(64); + expect(firstHash).toHaveLength(cacheHashLength); writePackageJson('2.0.0'); await run([file], 'check', cache); const secondHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get( 'data.fixture', )?.[1]; - expect(secondHash).toHaveLength(64); + expect(secondHash).toHaveLength(cacheHashLength); expect(secondHash).not.toBe(firstHash); }); }); @@ -281,12 +286,12 @@ test('write persists clean results for misses and hits', async () => { const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); expect(store.get('clean.ts')).toEqual([ - sha256(readFileSync(cleanPath)), + createCacheHash(readFileSync(cleanPath)), expect.any(String), 'clean', ]); expect(store.get('dirty.ts')).toEqual([ - sha256(readFileSync(dirtyPath)), + createCacheHash(readFileSync(dirtyPath)), expect.any(String), 'clean', ]); @@ -318,7 +323,7 @@ test('write converts a dirty entry to clean', async () => { const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); expect(store.get('index.ts')).toEqual([ - sha256(readFileSync(filePath)), + createCacheHash(readFileSync(filePath)), expect.any(String), 'clean', ]); diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 6f3631d5..2cf1ce45 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { readFileSync } from 'node:fs'; import { expect, test } from 'rstack/test'; -import { sha256 } from '../../src/fmt/cacheIdentity.ts'; +import { createCacheHash } from '../../src/fmt/cacheHash.ts'; import { formatFile } from '../../src/fmt/worker.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; @@ -11,7 +11,7 @@ test('returns cached states before resolving the parser', async () => { const filePath = writeProjectFile(rootPath, 'example.ts', source); const noExtensionPath = writeProjectFile(rootPath, 'script', source); const missingPath = path.join(rootPath, 'missing.unknown'); - const contentHash = sha256(source); + const contentHash = createCacheHash(source); const optionsHash = 'options'; for (const [entry, targetPath, shouldWrite, status] of [ @@ -60,7 +60,7 @@ test('does not trust path-only unsupported entries for files without extensions' }), ).resolves.toEqual({ status: 'changed', - cacheEntry: [sha256(readFileSync(filePath)), 'options', 'dirty'], + cacheEntry: [createCacheHash(readFileSync(filePath)), 'options', 'dirty'], }); }); }); From dc604f3cbe8ed11629d1369d064e97f701af71b7 Mon Sep 17 00:00:00 2001 From: neverland Date: Thu, 13 Aug 2026 12:39:25 +0800 Subject: [PATCH 2/5] perf(fmt): streamline cache loading --- packages/rstack/src/fmt/cacheHash.ts | 5 +- packages/rstack/src/fmt/cacheStore.ts | 67 ++++++-------------- packages/rstack/tests/fmt/cacheStore.test.ts | 14 +--- 3 files changed, 20 insertions(+), 66 deletions(-) diff --git a/packages/rstack/src/fmt/cacheHash.ts b/packages/rstack/src/fmt/cacheHash.ts index 7625bb4b..fa5b5ffc 100644 --- a/packages/rstack/src/fmt/cacheHash.ts +++ b/packages/rstack/src/fmt/cacheHash.ts @@ -6,7 +6,4 @@ const cacheHashLength = 16; const createCacheHash = (content: string | Uint8Array): string => hash('sha256', content, 'base64url').slice(0, cacheHashLength); -const isCacheHash = (value: unknown): value is string => - typeof value === 'string' && value.length === cacheHashLength; - -export { cacheHashLength, createCacheHash, isCacheHash }; +export { cacheHashLength, createCacheHash }; diff --git a/packages/rstack/src/fmt/cacheStore.ts b/packages/rstack/src/fmt/cacheStore.ts index cdaf7ea5..153c41c9 100644 --- a/packages/rstack/src/fmt/cacheStore.ts +++ b/packages/rstack/src/fmt/cacheStore.ts @@ -1,7 +1,6 @@ import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { isCacheHash } from './cacheHash.ts'; const fmtCacheFileName = 'v2.json'; const fmtCacheVersion = 2; @@ -60,14 +59,6 @@ const createEmptyCache = (namespace: string): ParsedFmtCacheFile => ({ optionsUseCounts: [], }); -const isFmtCacheStateId = (value: unknown): value is FmtCacheStateId => - typeof value === 'number' && - Number.isInteger(value) && - value >= 0 && - value < fmtCacheStates.length; - -const isUnknownArray = (value: unknown): value is unknown[] => Array.isArray(value); - const parseCacheFile = (content: string): ParsedFmtCacheFile | undefined => { let value: unknown; try { @@ -76,60 +67,38 @@ const parseCacheFile = (content: string): ParsedFmtCacheFile | undefined => { return; } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return; + } + + const cache = value as FmtCacheFile; + const { version, namespace, options, files } = cache; if ( - typeof value !== 'object' || - value === null || - isUnknownArray(value) || - !('version' in value) || - value.version !== fmtCacheVersion || - !('namespace' in value) || - typeof value.namespace !== 'string' || - !('options' in value) || - !isUnknownArray(value.options) || - !('files' in value) || - !isUnknownArray(value.files) || - value.files.length % fileEntryWidth !== 0 + version !== fmtCacheVersion || + typeof namespace !== 'string' || + !Array.isArray(options) || + !Array.isArray(files) || + files.length % fileEntryWidth !== 0 ) { return; } const optionsIndexes = new Map(); - for (let index = 0; index < value.options.length; index++) { - const optionsHash = value.options[index]; - if (!isCacheHash(optionsHash) || optionsIndexes.has(optionsHash)) { - return; - } - optionsIndexes.set(optionsHash, index); + for (let index = 0; index < options.length; index++) { + optionsIndexes.set(options[index], index); } const fileOffsets = new Map(); - const optionsUseCounts = new Array(value.options.length).fill(0); - for (let offset = 0; offset < value.files.length; offset += fileEntryWidth) { - const filePath = value.files[offset]; - const contentHash = value.files[offset + contentHashOffset]; - const optionsIndex = value.files[offset + optionsIndexOffset]; - const state = value.files[offset + stateOffset]; - if ( - typeof filePath !== 'string' || - fileOffsets.has(filePath) || - typeof optionsIndex !== 'number' || - !Number.isInteger(optionsIndex) || - optionsIndex < 0 || - optionsIndex >= value.options.length || - !isFmtCacheStateId(state) || - (state === fmtCacheStateIds.unsupported - ? contentHash !== null && !isCacheHash(contentHash) - : !isCacheHash(contentHash)) - ) { - return; - } - + const optionsUseCounts = new Array(options.length).fill(0); + for (let offset = 0; offset < files.length; offset += fileEntryWidth) { + const filePath = files[offset] as string; + const optionsIndex = files[offset + optionsIndexOffset] as number; fileOffsets.set(filePath, offset); optionsUseCounts[optionsIndex]++; } return { - cache: value as FmtCacheFile, + cache, fileOffsets, optionsIndexes, optionsUseCounts, diff --git a/packages/rstack/tests/fmt/cacheStore.test.ts b/packages/rstack/tests/fmt/cacheStore.test.ts index 35cd9d01..d1b9a4ac 100644 --- a/packages/rstack/tests/fmt/cacheStore.test.ts +++ b/packages/rstack/tests/fmt/cacheStore.test.ts @@ -94,7 +94,7 @@ test('preserves unvisited entries and skips unchanged updates', async () => { }); }); -test('discards invalid data and entries from another namespace', async () => { +test('discards invalid schemas and other namespaces', async () => { await withTempProject(async (rootPath) => { const cachePath = path.join(rootPath, fmtCacheFileName); const validCache = { @@ -107,20 +107,8 @@ test('discards invalid data and entries from another namespace', async () => { '{invalid', JSON.stringify({ ...validCache, version: fmtCacheVersion - 1 }), JSON.stringify({ version: fmtCacheVersion, namespace, files: [] }), - JSON.stringify({ ...validCache, options: ['too-short'] }), - JSON.stringify({ ...validCache, options: [optionsA, optionsA] }), JSON.stringify({ ...validCache, files: { 'src/a.ts': firstEntry } }), JSON.stringify({ ...validCache, files: ['src/a.ts', contentA, 0] }), - JSON.stringify({ ...validCache, files: [42, contentA, 0, 0] }), - JSON.stringify({ ...validCache, files: ['src/a.ts', 42, 0, 2] }), - JSON.stringify({ ...validCache, files: ['src/a.ts', null, 0, 0] }), - JSON.stringify({ ...validCache, files: ['src/a.ts', contentA, 1, 0] }), - JSON.stringify({ ...validCache, files: ['src/a.ts', contentA, 0.5, 0] }), - JSON.stringify({ ...validCache, files: ['src/a.ts', contentA, 0, 3] }), - JSON.stringify({ - ...validCache, - files: ['src/a.ts', contentA, 0, 0, 'src/a.ts', contentB, 0, 1], - }), ]; for (const content of invalidContents) { From b023c32c40696c1abfab9e044eea8e2d7b25487e Mon Sep 17 00:00:00 2001 From: neverland Date: Thu, 13 Aug 2026 12:43:37 +0800 Subject: [PATCH 3/5] perf(fmt): use empty cache hash sentinel --- packages/rstack/src/fmt/cacheStore.ts | 12 ++++-------- packages/rstack/src/fmt/runner.ts | 2 +- packages/rstack/src/fmt/worker.ts | 4 ++-- packages/rstack/tests/cli/fmt/cache.test.ts | 2 +- packages/rstack/tests/fmt/cacheStore.test.ts | 4 ++-- packages/rstack/tests/fmt/runnerCache.test.ts | 2 +- .../rstack/tests/fmt/runnerWorkerPreflight.test.ts | 2 +- packages/rstack/tests/fmt/worker.test.ts | 8 ++++---- 8 files changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/rstack/src/fmt/cacheStore.ts b/packages/rstack/src/fmt/cacheStore.ts index 153c41c9..dfd49d8b 100644 --- a/packages/rstack/src/fmt/cacheStore.ts +++ b/packages/rstack/src/fmt/cacheStore.ts @@ -20,10 +20,8 @@ const fmtCacheStateIds = { unsupported: 2, } as const satisfies Record; -type FmtCacheFileValue = string | number | null; -type FmtCacheEntry = - | readonly [contentHash: string, optionsHash: string, state: 'clean' | 'dirty'] - | readonly [contentHash: string | null, optionsHash: string, state: 'unsupported']; +type FmtCacheFileValue = string | number; +type FmtCacheEntry = readonly [contentHash: string, optionsHash: string, state: FmtCacheState]; interface FmtCacheFile { version: typeof fmtCacheVersion; @@ -147,12 +145,10 @@ class FmtCacheStoreImpl implements FmtCacheStore { } const { files, options } = this.#cache; - const contentHash = files[offset + contentHashOffset] as string | null; + const contentHash = files[offset + contentHashOffset] as string; const optionsHash = options[files[offset + optionsIndexOffset] as number]; const state = fmtCacheStates[files[offset + stateOffset] as FmtCacheStateId]; - return state === 'unsupported' - ? [contentHash, optionsHash, state] - : [contentHash as string, optionsHash, state]; + return [contentHash, optionsHash, state]; } set(filePath: string, entry: FmtCacheEntry): void { diff --git a/packages/rstack/src/fmt/runner.ts b/packages/rstack/src/fmt/runner.ts index c594530c..cc2340f4 100644 --- a/packages/rstack/src/fmt/runner.ts +++ b/packages/rstack/src/fmt/runner.ts @@ -116,7 +116,7 @@ const isCachedUnsupported = ({ file, cache }: FmtFileRunTask): boolean => { return false; } return ( - cache.entry[0] === null && + cache.entry[0] === '' && cache.entry[1] === cache.optionsHash && cache.entry[2] === 'unsupported' && hasDottedBasename(file.path) diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index 347d61e0..06029caa 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -42,7 +42,7 @@ const formatFile = async ({ if (cache?.entry && cache.entry[1] === cache.optionsHash) { const { entry } = cache; if (entry[2] === 'unsupported') { - if (entry[0] === null) { + if (entry[0] === '') { if (hasDottedBasename(file.path)) { return { status: 'unsupported' }; } @@ -70,7 +70,7 @@ const formatFile = async ({ status: 'unsupported', cacheEntry: [ hasDottedBasename(file.path) - ? null + ? '' : (contentHash ?? createCacheHash(sourceBuffer ?? readFileSync(file.path))), cache.optionsHash, 'unsupported', diff --git a/packages/rstack/tests/cli/fmt/cache.test.ts b/packages/rstack/tests/cli/fmt/cache.test.ts index 8e6a819d..2693b6f5 100644 --- a/packages/rstack/tests/cli/fmt/cache.test.ts +++ b/packages/rstack/tests/cli/fmt/cache.test.ts @@ -8,7 +8,7 @@ interface SerializedFmtCache { version: number; namespace: string; options: string[]; - files: (string | number | null)[]; + files: (string | number)[]; } const readFmtCache = (filePath: string): SerializedFmtCache => diff --git a/packages/rstack/tests/fmt/cacheStore.test.ts b/packages/rstack/tests/fmt/cacheStore.test.ts index d1b9a4ac..59bfd9ca 100644 --- a/packages/rstack/tests/fmt/cacheStore.test.ts +++ b/packages/rstack/tests/fmt/cacheStore.test.ts @@ -19,7 +19,7 @@ const optionsB = createCacheHash('options-b'); const optionsC = createCacheHash('options-c'); const firstEntry = [contentA, optionsA, 'clean'] as const; const secondEntry = [contentB, optionsB, 'dirty'] as const; -const unsupportedEntry = [null, optionsC, 'unsupported'] as const; +const unsupportedEntry = ['', optionsC, 'unsupported'] as const; const hashedUnsupportedEntry = [contentC, optionsC, 'unsupported'] as const; const readCache = (filePath: string): FmtCacheFile => @@ -48,7 +48,7 @@ test('writes flat entries that can be loaded by another store', async () => { 0, 0, 'src/unknown.fixture', - null, + '', 1, 2, 'script', diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts index 6a14fe02..72e31769 100644 --- a/packages/rstack/tests/fmt/runnerCache.test.ts +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -126,7 +126,7 @@ test('caches unsupported parser results until final options change', async () => processedFileCount: 0, }); expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.unknown')).toEqual([ - null, + '', createOptionsHasher()(unsupported.options), 'unsupported', ]); diff --git a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts index 71d48662..7b9e3b87 100644 --- a/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts +++ b/packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts @@ -34,7 +34,7 @@ const createCachedUnsupportedFile = async (rootPath: string, fileName: string) = } const store = await loadFmtCacheStore(cache.filePath, cacheNamespace); - store.set(fileName, [null, optionsHash, 'unsupported']); + store.set(fileName, ['', optionsHash, 'unsupported']); await expect(store.save()).resolves.toBe(true); return { cache, file }; diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 2cf1ce45..dc465826 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -20,8 +20,8 @@ test('returns cached states before resolving the parser', async () => { [[contentHash, optionsHash, 'clean'], filePath, true, 'unchanged'], [[contentHash, optionsHash, 'unsupported'], noExtensionPath, false, 'unsupported'], [[contentHash, optionsHash, 'unsupported'], noExtensionPath, true, 'unsupported'], - [[null, optionsHash, 'unsupported'], missingPath, false, 'unsupported'], - [[null, optionsHash, 'unsupported'], missingPath, true, 'unsupported'], + [['', optionsHash, 'unsupported'], missingPath, false, 'unsupported'], + [['', optionsHash, 'unsupported'], missingPath, true, 'unsupported'], ] as const) { await expect( formatFile({ @@ -54,7 +54,7 @@ test('does not trust path-only unsupported entries for files without extensions' }, shouldWrite: false, cache: { - entry: [null, 'options', 'unsupported'], + entry: ['', 'options', 'unsupported'], optionsHash: 'options', }, }), @@ -81,7 +81,7 @@ test('resolves parser support before reading on a cache miss', async () => { }), ).resolves.toEqual({ status: 'unsupported', - cacheEntry: [null, 'options', 'unsupported'], + cacheEntry: ['', 'options', 'unsupported'], }); }); }); From 5bb5e45a700689eb1b96ba6302d845c60ffe98a2 Mon Sep 17 00:00:00 2001 From: neverland Date: Thu, 13 Aug 2026 12:46:32 +0800 Subject: [PATCH 4/5] refactor(fmt): inline cache hashing --- packages/rstack/src/fmt/cacheHash.ts | 9 --------- packages/rstack/src/fmt/cacheIdentity.ts | 14 ++++++++++++-- packages/rstack/src/fmt/worker.ts | 17 ++++++++++------- packages/rstack/tests/fmt/cacheIdentity.test.ts | 3 ++- packages/rstack/tests/fmt/cacheStore.test.ts | 13 ++++++------- packages/rstack/tests/fmt/runnerCache.test.ts | 8 ++++++-- packages/rstack/tests/fmt/worker.test.ts | 2 +- 7 files changed, 37 insertions(+), 29 deletions(-) delete mode 100644 packages/rstack/src/fmt/cacheHash.ts diff --git a/packages/rstack/src/fmt/cacheHash.ts b/packages/rstack/src/fmt/cacheHash.ts deleted file mode 100644 index fa5b5ffc..00000000 --- a/packages/rstack/src/fmt/cacheHash.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { hash } from 'node:crypto'; - -/** A 16-character base64url token preserves 96 bits of the SHA-256 digest. */ -const cacheHashLength = 16; - -const createCacheHash = (content: string | Uint8Array): string => - hash('sha256', content, 'base64url').slice(0, cacheHashLength); - -export { cacheHashLength, createCacheHash }; diff --git a/packages/rstack/src/fmt/cacheIdentity.ts b/packages/rstack/src/fmt/cacheIdentity.ts index 78e69531..f7ba278d 100644 --- a/packages/rstack/src/fmt/cacheIdentity.ts +++ b/packages/rstack/src/fmt/cacheIdentity.ts @@ -1,6 +1,6 @@ +import { hash as createDigest } from 'node:crypto'; import { isAbsolute } from 'node:path'; import stableStringify from 'fast-json-stable-stringify'; -import { createCacheHash } from './cacheHash.ts'; import { fmtCacheVersion } from './cacheStore.ts'; import { createRelativePathResolver, toPosixPath } from './pathHelpers.ts'; import type { ResolvedFmtOptions } from './types.ts'; @@ -12,6 +12,10 @@ type CacheKeyResolver = (filePath: string) => string | undefined; type OptionsHasher = (options: ResolvedFmtOptions) => string | undefined; type PluginFingerprints = ReadonlyMap; +const cacheHashLength = 16; +const createCacheHash = (content: string | Uint8Array): string => + createDigest('sha256', content, 'base64url').slice(0, cacheHashLength); + /** Identifies formatter behavior shared by all cache entries in this process. */ const cacheNamespace: string = JSON.stringify([fmtCacheVersion, RSTACK_VERSION, PRETTIER_VERSION]); @@ -63,4 +67,10 @@ const createOptionsHasher = (pluginFingerprints?: PluginFingerprints): OptionsHa }; }; -export { cacheNamespace, createCacheKeyResolver, createOptionsHasher }; +export { + cacheHashLength, + cacheNamespace, + createCacheHash, + createCacheKeyResolver, + createOptionsHasher, +}; diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index 06029caa..6e6505aa 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -1,7 +1,7 @@ // Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md +import { hash } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; -import { createCacheHash } from './cacheHash.ts'; import type { FmtCacheEntry } from './cacheStore.ts'; import { hasDottedBasename } from './pathHelpers.ts'; import type { FmtFileCache, FmtFileRequest, FmtWorkerResult } from './types.ts'; @@ -12,6 +12,9 @@ interface FormatFileTask { cache?: FmtFileCache; } +const hashContent = (content: string | Uint8Array): string => + hash('sha256', content, 'base64url').slice(0, 16); + /** * Use synchronous direct I/O inside the dedicated worker to avoid libuv * scheduling overhead. This prioritizes throughput over crash-safe replacement. @@ -35,7 +38,7 @@ const formatFile = async ({ } sourceBuffer = readFileSync(file.path); - contentHash = createCacheHash(sourceBuffer); + contentHash = hashContent(sourceBuffer); return sourceBuffer.toString('utf8'); }; @@ -48,14 +51,14 @@ const formatFile = async ({ } } else { sourceBuffer = readFileSync(file.path); - contentHash = createCacheHash(sourceBuffer); + contentHash = hashContent(sourceBuffer); if (entry[0] === contentHash) { return { status: 'unsupported' }; } } } else { sourceBuffer = readFileSync(file.path); - contentHash = createCacheHash(sourceBuffer); + contentHash = hashContent(sourceBuffer); if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) { return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' }; } @@ -71,7 +74,7 @@ const formatFile = async ({ cacheEntry: [ hasDottedBasename(file.path) ? '' - : (contentHash ?? createCacheHash(sourceBuffer ?? readFileSync(file.path))), + : (contentHash ?? hashContent(sourceBuffer ?? readFileSync(file.path))), cache.optionsHash, 'unsupported', ], @@ -92,8 +95,8 @@ const formatFile = async ({ const cacheHash = shouldWrite && !unchanged - ? createCacheHash(result.formatted) - : (contentHash ?? createCacheHash(result.source)); + ? hashContent(result.formatted) + : (contentHash ?? hashContent(result.source)); const cacheEntry: FmtCacheEntry = [ cacheHash, cache.optionsHash, diff --git a/packages/rstack/tests/fmt/cacheIdentity.test.ts b/packages/rstack/tests/fmt/cacheIdentity.test.ts index a3bdac8a..933b7229 100644 --- a/packages/rstack/tests/fmt/cacheIdentity.test.ts +++ b/packages/rstack/tests/fmt/cacheIdentity.test.ts @@ -3,9 +3,10 @@ import { pathToFileURL } from 'node:url'; import prettierPkgJson from 'prettier/package.json' with { type: 'json' }; import { expect, test } from 'rstack/test'; import pkgJson from '../../package.json' with { type: 'json' }; -import { cacheHashLength, createCacheHash } from '../../src/fmt/cacheHash.ts'; import { + cacheHashLength, cacheNamespace, + createCacheHash, createCacheKeyResolver, createOptionsHasher, } from '../../src/fmt/cacheIdentity.ts'; diff --git a/packages/rstack/tests/fmt/cacheStore.test.ts b/packages/rstack/tests/fmt/cacheStore.test.ts index 59bfd9ca..e6f038b7 100644 --- a/packages/rstack/tests/fmt/cacheStore.test.ts +++ b/packages/rstack/tests/fmt/cacheStore.test.ts @@ -1,7 +1,6 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { expect, test } from 'rstack/test'; -import { createCacheHash } from '../../src/fmt/cacheHash.ts'; import { fmtCacheFileName, fmtCacheVersion, @@ -11,12 +10,12 @@ import { import { withTempProject } from './helpers.ts'; const namespace = 'test-namespace'; -const contentA = createCacheHash('content-a'); -const contentB = createCacheHash('content-b'); -const contentC = createCacheHash('content-c'); -const optionsA = createCacheHash('options-a'); -const optionsB = createCacheHash('options-b'); -const optionsC = createCacheHash('options-c'); +const contentA = 'content-a'; +const contentB = 'content-b'; +const contentC = 'content-c'; +const optionsA = 'options-a'; +const optionsB = 'options-b'; +const optionsC = 'options-c'; const firstEntry = [contentA, optionsA, 'clean'] as const; const secondEntry = [contentB, optionsB, 'dirty'] as const; const unsupportedEntry = ['', optionsC, 'unsupported'] as const; diff --git a/packages/rstack/tests/fmt/runnerCache.test.ts b/packages/rstack/tests/fmt/runnerCache.test.ts index 72e31769..e78a2ab0 100644 --- a/packages/rstack/tests/fmt/runnerCache.test.ts +++ b/packages/rstack/tests/fmt/runnerCache.test.ts @@ -2,8 +2,12 @@ import { readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { expect, test } from 'rstack/test'; -import { cacheHashLength, createCacheHash } from '../../src/fmt/cacheHash.ts'; -import { cacheNamespace, createOptionsHasher } from '../../src/fmt/cacheIdentity.ts'; +import { + cacheHashLength, + cacheNamespace, + createCacheHash, + createOptionsHasher, +} from '../../src/fmt/cacheIdentity.ts'; import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts'; import { runFmtFiles } from '../../src/fmt/runner.ts'; import type { FmtCacheContext, FmtFileRequest, FmtMode } from '../../src/fmt/types.ts'; diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index dc465826..3ef3d3aa 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { readFileSync } from 'node:fs'; import { expect, test } from 'rstack/test'; -import { createCacheHash } from '../../src/fmt/cacheHash.ts'; +import { createCacheHash } from '../../src/fmt/cacheIdentity.ts'; import { formatFile } from '../../src/fmt/worker.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; From da5f3bdb9a102210c5bb720282206a1c08baab72 Mon Sep 17 00:00:00 2001 From: neverland Date: Thu, 13 Aug 2026 12:50:05 +0800 Subject: [PATCH 5/5] refactor(fmt): use stable cache filename --- packages/rstack/src/fmt/cacheStore.ts | 2 +- packages/rstack/tests/cli/fmt/cache.test.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/rstack/src/fmt/cacheStore.ts b/packages/rstack/src/fmt/cacheStore.ts index dfd49d8b..0dcf3292 100644 --- a/packages/rstack/src/fmt/cacheStore.ts +++ b/packages/rstack/src/fmt/cacheStore.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; -const fmtCacheFileName = 'v2.json'; +const fmtCacheFileName = 'cache.json'; const fmtCacheVersion = 2; const fileEntryWidth = 4; diff --git a/packages/rstack/tests/cli/fmt/cache.test.ts b/packages/rstack/tests/cli/fmt/cache.test.ts index 2693b6f5..12c112e2 100644 --- a/packages/rstack/tests/cli/fmt/cache.test.ts +++ b/packages/rstack/tests/cli/fmt/cache.test.ts @@ -37,7 +37,7 @@ test.each([ expect(result.status).toBe(0); expect(readProjectFile('.rstack/cache/.gitignore')).toBe('*\n'); - expectSingleCleanEntry(readFmtCache('.rstack/cache/fmt/v2.json'), 'index.ts'); + expectSingleCleanEntry(readFmtCache('.rstack/cache/fmt/cache.json'), 'index.ts'); expect(readProjectFile('.rstack/cache/fmt-v1.json')).toBe('legacy'); }); @@ -74,7 +74,7 @@ test.each(['relative', 'absolute'] as const)('uses a %s custom cache location', const result = runFmt(['--cache-location', cacheLocation, 'index.ts']); expect(result.status).toBe(0); - expectSingleCleanEntry(readFmtCache('custom-cache/v2.json'), 'index.ts'); + expectSingleCleanEntry(readFmtCache('custom-cache/cache.json'), 'index.ts'); expect(projectFileExists('custom-cache/.gitignore')).toBe(false); expect(projectFileExists('.rstack')).toBe(false); }); @@ -110,22 +110,22 @@ test('uses an explicit config root cache from a subdirectory', () => { expect(result.status).toBe(0); expect(readProjectFile('packages/app/index.ts')).toBe('const value = 1;\n'); - expect(projectFileExists('.rstack/cache/fmt/v2.json')).toBe(true); + expect(projectFileExists('.rstack/cache/fmt/cache.json')).toBe(true); expect(projectFileExists('packages/app/.rstack')).toBe(false); - expectSingleCleanEntry(readFmtCache('.rstack/cache/fmt/v2.json'), 'packages/app/index.ts'); + expectSingleCleanEntry(readFmtCache('.rstack/cache/fmt/cache.json'), 'packages/app/index.ts'); }); test('recovers from a corrupted cache', () => { writeProjectFile('index.ts', 'const value = 1;\n'); const first = runFmt(['--check', 'index.ts']); - writeProjectFile('.rstack/cache/fmt/v2.json', '{'); + writeProjectFile('.rstack/cache/fmt/cache.json', '{'); const second = runFmt(['--check', 'index.ts']); expect(second.status).toBe(0); expect(normalizeDuration(second.stdout)).toBe(normalizeDuration(first.stdout)); expect(second.stderr).toBe(first.stderr); - expect(JSON.parse(readProjectFile('.rstack/cache/fmt/v2.json'))).toMatchObject({ version: 2 }); + expect(JSON.parse(readProjectFile('.rstack/cache/fmt/cache.json'))).toMatchObject({ version: 2 }); }); test('formats without a writable cache directory', () => {