import { promises as fs } from 'fs' import path from 'path' const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10) function getProjectRoot() { const cwd = process.cwd() if (cwd.endsWith('.output')) { return path.resolve(cwd, '..') } return cwd } function getBackupRoot() { const configured = process.env.DATA_FILE_BACKUP_DIR if (!configured) { return path.join(getProjectRoot(), 'backups', 'data-rotation') } if (path.isAbsolute(configured)) { return configured } return path.join(getProjectRoot(), configured) } function sanitizeFileKey(filePath) { const projectRoot = getProjectRoot() const relative = path.relative(projectRoot, filePath) const normalized = relative.split(path.sep).join('__') return normalized.replace(/[^a-zA-Z0-9._-]/g, '_') } function buildBackupName(date = new Date()) { const randomSuffix = Math.random().toString(36).slice(2, 8) return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak` } async function ensureDirectory(dirPath) { await fs.mkdir(dirPath, { recursive: true }) } async function rotateOldBackups(backupDir, maxBackups) { if (!Number.isFinite(maxBackups) || maxBackups < 1) { return } const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => []) const backups = entries .filter((entry) => entry.isFile() && entry.name.endsWith('.bak')) .map((entry) => entry.name) .sort() const overflowCount = Math.max(0, backups.length - maxBackups) if (overflowCount === 0) { return } const toDelete = backups.slice(0, overflowCount) await Promise.all(toDelete.map((name) => fs.unlink(path.join(backupDir, name)).catch(() => {}))) } export function getBackupDirectoryForDataFile(filePath) { const resolvedPath = path.resolve(filePath) return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath)) } export async function listDataFileBackups(filePath) { const backupDir = getBackupDirectoryForDataFile(filePath) const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => []) return entries .filter((entry) => entry.isFile() && entry.name.endsWith('.bak')) .map((entry) => entry.name) .sort() .reverse() } export async function writeDataFileWithRotation(filePath, content, { encoding = 'utf-8', maxBackups = DEFAULT_MAX_BACKUPS } = {}) { const resolvedPath = path.resolve(filePath) await ensureDirectory(path.dirname(resolvedPath)) let existingContent = null try { existingContent = await fs.readFile(resolvedPath, encoding) } catch (error) { if (error.code !== 'ENOENT') { throw error } } if (existingContent !== null && existingContent === content) { return { changed: false, backupPath: null } } let backupPath = null if (existingContent !== null) { const backupDir = getBackupDirectoryForDataFile(resolvedPath) await ensureDirectory(backupDir) backupPath = path.join(backupDir, buildBackupName()) await fs.copyFile(resolvedPath, backupPath) await rotateOldBackups(backupDir, maxBackups) } const tmpPath = `${resolvedPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}` await fs.writeFile(tmpPath, content, encoding) await fs.rename(tmpPath, resolvedPath) return { changed: true, backupPath } } export async function restoreDataFileBackup(filePath, backupName, options = {}) { const resolvedPath = path.resolve(filePath) const backupDir = getBackupDirectoryForDataFile(resolvedPath) const sourceBackupPath = path.join(backupDir, backupName) const backupContent = await fs.readFile(sourceBackupPath, 'utf-8') return writeDataFileWithRotation(resolvedPath, backupContent, options) }