Files
verdaccio-cache-manager/backend/clean.js
T

170 lines
5.6 KiB
JavaScript
Raw Normal View History

/**
* 预警清理原子逻辑(§3.4 / AC-7 原子性)
* 1. 读 package.json
* 2. 按 semver 排序保留最新 N 个
* 3. 删低版本 versions/time 记录
* 4. 修正 dist-tags(latest 指向已删版本时指向保留的最新)
* 5. 写回元数据
* 6. 物理删 .tgz(按物理文件名匹配,兼容 {version}.tgz 与 {pkgname}-{version}.tgz)
* 7. 记日志
* 8. 返回「删除数量、释放空间」
* 失败时不做任何文件修改(先备份→写临时文件→rename 提交)
*/
import fs from 'node:fs'
import path from 'node:path'
import semverCompare from './semver.js'
import { listTgzFiles, findTgzForVersion } from './tgz.js'
export function cleanPackage(storageDir, pkgName, keep) {
const pkgDir = locatePackageDir(storageDir, pkgName)
if (!pkgDir) return { ok: false, message: `未找到包 ${pkgName}` }
const pkgFile = path.join(pkgDir, 'package.json')
let meta
try {
meta = JSON.parse(fs.readFileSync(pkgFile, 'utf8'))
} catch {
return { ok: false, message: `读取 ${pkgName} 元数据失败` }
}
const versions = Object.keys(meta.versions || {})
if (!versions.length) return { ok: false, message: '包内没有可清理的版本' }
// 按 semver 数值排序(旧 → 新),保留最新 keep 个
const sorted = versions.sort((a, b) => semverCompare(a, b))
const toDelete = sorted.slice(0, Math.max(0, sorted.length - keep))
if (!toDelete.length) {
return { ok: false, message: '版本数未超过保留值,无需清理' }
}
// —— 准备提交内容 ——
const nextMeta = JSON.parse(JSON.stringify(meta))
const time = nextMeta.time || {}
const freed = []
// 先列物理 .tgz,按文件名匹配要删的版本(不猜命名规则)
const tgzFiles = listTgzFiles(pkgDir)
for (const v of toDelete) {
const info = nextMeta.versions[v] || {}
delete nextMeta.versions[v]
delete time[v]
const hit = findTgzForVersion(tgzFiles, v)
freed.push({
v,
size: hit ? hit.size : (info.dist?.unpackedSize || 0),
file: hit ? hit.path : null,
})
}
nextMeta.time = time
// —— 修正 dist-tags ——
const distTags = nextMeta['dist-tags'] || {}
const kept = sorted.slice(-keep)
for (const [tag, v] of Object.entries(distTags)) {
if (toDelete.includes(v)) {
distTags[tag] = kept[kept.length - 1]
}
}
nextMeta['dist-tags'] = distTags
// —— 原子提交:临时文件写入 → rename ——
const tmp = `${pkgFile}.clean.tmp`
try {
fs.writeFileSync(tmp, JSON.stringify(nextMeta, null, 2))
fs.renameSync(tmp, pkgFile) // 同目录 rename 为原子操作
} catch (err) {
try { fs.unlinkSync(tmp) } catch { /* ignore */ }
return { ok: false, message: `写回元数据失败:${err.message}` }
}
// —— 物理删除 tgz(元数据已提交,删除失败仅记录不阻塞) ——
const deletedBytes = freed.reduce((s, f) => s + f.size, 0)
for (const f of freed) {
if (!f.file) continue // 该版本无实体文件(元数据残留),无需删除
try {
if (fs.existsSync(f.file)) fs.unlinkSync(f.file)
} catch {
/* 忽略单个文件删除失败 */
}
}
const freedText = `${(deletedBytes / 1024 / 1024).toFixed(1)} MB`
return {
ok: true,
pkgName,
deletedVersions: toDelete.join(', '),
deletedCount: toDelete.length,
freedSpace: freedText,
freedBytes: deletedBytes,
}
}
/** 删除单个版本(抽屉内单版本删除) */
export function deleteSingleVersion(storageDir, pkgName, version) {
const pkgDir = locatePackageDir(storageDir, pkgName)
if (!pkgDir) return { ok: false, message: `未找到包 ${pkgName}` }
const pkgFile = path.join(pkgDir, 'package.json')
let meta
try {
meta = JSON.parse(fs.readFileSync(pkgFile, 'utf8'))
} catch {
return { ok: false, message: '读取元数据失败' }
}
if (!meta.versions || !meta.versions[version]) {
return { ok: false, message: `版本 ${version} 不存在` }
}
const nextMeta = JSON.parse(JSON.stringify(meta))
const info = nextMeta.versions[version]
delete nextMeta.versions[version]
if (nextMeta.time) delete nextMeta.time[version]
// 修正 dist-tags
const distTags = nextMeta['dist-tags'] || {}
const remaining = Object.keys(nextMeta.versions).sort((a, b) => semverCompare(a, b))
for (const [tag, v] of Object.entries(distTags)) {
if (v === version) distTags[tag] = remaining[remaining.length - 1] || version
}
nextMeta['dist-tags'] = distTags
const tmp = `${pkgFile}.clean.tmp`
try {
fs.writeFileSync(tmp, JSON.stringify(nextMeta, null, 2))
fs.renameSync(tmp, pkgFile)
} catch (err) {
try { fs.unlinkSync(tmp) } catch { /* ignore */ }
return { ok: false, message: `写回元数据失败:${err.message}` }
}
let size = info.dist?.unpackedSize || 0
const hit = findTgzForVersion(listTgzFiles(pkgDir), version)
if (hit) {
size = hit.size
try {
fs.unlinkSync(hit.path)
} catch { /* ignore */ }
}
return {
ok: true,
freedSpace: `${(size / 1024 / 1024).toFixed(1)} MB`,
}
}
/** 定位包目录(支持 scoped:@scope/name 嵌套目录) */
export function locatePackageDir(storageDir, pkgName) {
const parts = pkgName.split('/')
const candidates = []
if (parts.length === 2 && parts[0].startsWith('@')) {
candidates.push(path.join(storageDir, parts[0], parts[1]))
} else {
candidates.push(path.join(storageDir, parts[parts.length - 1]))
// 兼容 scoped 包以全名作为单一目录的情况
if (parts.length === 2) candidates.push(path.join(storageDir, pkgName))
}
for (const c of candidates) {
if (fs.existsSync(path.join(c, 'package.json'))) return c
}
return null
}