73 lines
3.0 KiB
TypeScript
73 lines
3.0 KiB
TypeScript
import { resolve, sep } from 'node:path'
|
|
|
|
/**
|
|
* Decides whether a curated resource's PREVIOUSLY-installed file should be
|
|
* deleted now that a newer version has been downloaded (issue #634 — old map
|
|
* and ZIM versions accumulated on disk indefinitely because only Wikipedia had
|
|
* version cleanup).
|
|
*
|
|
* This is intentionally a pure function so every safety rail is unit-testable
|
|
* without touching the DB or filesystem. The caller looks up the prior
|
|
* `InstalledResource` row, records the new version, then asks this whether the
|
|
* old file is safe to remove.
|
|
*
|
|
* Safety rails (a "delete" decision requires ALL of these):
|
|
* - There was a prior install for this exact resource_id (`existing` non-null).
|
|
* Untracked / sideloaded files have no row and are therefore never touched.
|
|
* - The old file path actually differs from the new one (a genuine version
|
|
* swap, not a re-download of the same file).
|
|
* - The new file is confirmed present on disk — we never remove the old copy
|
|
* before the replacement is verified.
|
|
* - The new version is strictly newer than the recorded one, so a re-install
|
|
* or downgrade can't wipe a newer file.
|
|
* - The old path resolves to within the resource's storage directory, so a
|
|
* malformed DB value can't direct a delete outside the content store.
|
|
*/
|
|
|
|
export interface SupersededInputs {
|
|
/** Prior InstalledResource row for this resource_id, or null on first install. */
|
|
existing: { file_path: string; version: string } | null
|
|
/** Absolute path of the newly downloaded file. */
|
|
newFilePath: string
|
|
/** Version of the newly downloaded file (e.g. "2026-05"). */
|
|
newVersion: string
|
|
/** Whether the new file is confirmed present on disk. */
|
|
newFileExists: boolean
|
|
/** Absolute storage directory the old file must live under to be eligible. */
|
|
storageBaseDir: string
|
|
}
|
|
|
|
export type SupersededReason =
|
|
| 'first_install'
|
|
| 'same_file'
|
|
| 'new_file_missing'
|
|
| 'not_newer'
|
|
| 'outside_storage'
|
|
| 'superseded'
|
|
|
|
export interface SupersededDecision {
|
|
delete: boolean
|
|
/** Resolved old path to delete — set only when `delete` is true. */
|
|
path?: string
|
|
reason: SupersededReason
|
|
}
|
|
|
|
export function decideSupersededDeletion(inputs: SupersededInputs): SupersededDecision {
|
|
const { existing, newFilePath, newVersion, newFileExists, storageBaseDir } = inputs
|
|
|
|
if (!existing) return { delete: false, reason: 'first_install' }
|
|
if (existing.file_path === newFilePath) return { delete: false, reason: 'same_file' }
|
|
if (!newFileExists) return { delete: false, reason: 'new_file_missing' }
|
|
// Versions are zero-padded date strings (YYYY-MM / YYYY-MM-DD), so a lexical
|
|
// compare orders them correctly. Require strictly newer.
|
|
if (!(newVersion > existing.version)) return { delete: false, reason: 'not_newer' }
|
|
|
|
const resolvedOld = resolve(existing.file_path)
|
|
const base = resolve(storageBaseDir)
|
|
if (resolvedOld !== base && !resolvedOld.startsWith(base + sep)) {
|
|
return { delete: false, reason: 'outside_storage' }
|
|
}
|
|
|
|
return { delete: true, path: resolvedOld, reason: 'superseded' }
|
|
}
|