import { copyFile, mkdir, readFile, rename, rm, writeFile } from 'fs/promises' import { dirname, resolve } from 'path' import { randomUUID } from 'crypto' import YAML, { type DumpOptions } from 'js-yaml' type TextUpdater = (current: string) => string | { content: string; result: T } | Promise type YamlUpdateResult = { data: Record; result: T; write?: boolean } type YamlUpdater = (current: Record) => Record | YamlUpdateResult | Promise | YamlUpdateResult> export interface SafeWriteOptions { backup?: boolean backupPath?: string } export interface SafeYamlOptions extends SafeWriteOptions { dumpOptions?: DumpOptions } function isTextUpdateResult(value: unknown): value is { content: string; result: T } { return !!value && typeof value === 'object' && Object.hasOwn(value as Record, 'content') } function isYamlUpdateResult(value: unknown): value is YamlUpdateResult { return !!value && typeof value === 'object' && Object.hasOwn(value as Record, 'data') } export class SafeFileStore { private queues = new Map>() private normalizePath(filePath: string): string { return resolve(filePath) } private async withLock(filePath: string, task: () => Promise): Promise { const key = this.normalizePath(filePath) const previous = this.queues.get(key) || Promise.resolve() let release!: () => void const current = new Promise(resolve => { release = resolve }) const next = previous.then(() => current, () => current) this.queues.set(key, next) await previous.catch(() => undefined) try { return await task() } finally { release() if (this.queues.get(key) === next) this.queues.delete(key) } } async readText(filePath: string): Promise { return readFile(this.normalizePath(filePath), 'utf-8') } async writeText(filePath: string, content: string, options: SafeWriteOptions = {}): Promise { await this.withLock(filePath, () => this.writeTextUnlocked(filePath, content, options)) } async updateText(filePath: string, updater: TextUpdater, options: SafeWriteOptions = {}): Promise { return this.withLock(filePath, async () => { let current = '' try { current = await this.readText(filePath) } catch (err: any) { if (err?.code !== 'ENOENT') throw err } const updated = await updater(current) const content = isTextUpdateResult(updated) ? updated.content : updated await this.writeTextUnlocked(filePath, content, options) return isTextUpdateResult(updated) ? updated.result : undefined }) } async readYaml(filePath: string): Promise> { try { const raw = await this.readText(filePath) return (YAML.load(raw, { json: true }) as Record) || {} } catch (err: any) { if (err?.code === 'ENOENT') return {} throw err } } async writeYaml(filePath: string, data: Record, options: SafeYamlOptions = {}): Promise { const yamlStr = YAML.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"', ...(options.dumpOptions || {}), }) await this.writeText(filePath, yamlStr, options) } async updateYaml(filePath: string, updater: YamlUpdater, options: SafeYamlOptions = {}): Promise { return this.withLock(filePath, async () => { let raw = '' try { raw = await this.readText(filePath) } catch (err: any) { if (err?.code !== 'ENOENT') throw err } const current = raw ? ((YAML.load(raw, { json: true }) as Record) || {}) : {} const updated = await updater(current) const data = isYamlUpdateResult(updated) ? updated.data : updated if (isYamlUpdateResult(updated) && updated.write === false) { return updated.result } const yamlStr = YAML.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"', ...(options.dumpOptions || {}), }) await this.writeTextUnlocked(filePath, yamlStr, options) return isYamlUpdateResult(updated) ? updated.result : undefined }) } private async writeTextUnlocked(filePath: string, content: string, options: SafeWriteOptions): Promise { const target = this.normalizePath(filePath) const dir = dirname(target) const temp = `${target}.tmp.${process.pid}.${Date.now()}.${randomUUID()}` await mkdir(dir, { recursive: true }) if (options.backup) { try { await copyFile(target, options.backupPath || `${target}.bak`) } catch (err: any) { if (err?.code !== 'ENOENT') throw err } } try { await writeFile(temp, content, 'utf-8') await rename(temp, target) } catch (err) { await rm(temp, { force: true }).catch(() => undefined) throw err } } } export const safeFileStore = new SafeFileStore()