project-nomad/admin/app/utils/superseded_resource.ts
Chris Sherwood bbd62d8ed1
fix(content): remove superseded curated map/ZIM files when a new version installs
Only Wikipedia had version cleanup; every other curated map and non-Wikipedia
ZIM left its prior version on disk when a newer one installed, so users silently
accumulated orphaned content (potentially hundreds of GB). (#634)

The install paths already record each resource via InstalledResource
{resource_id, resource_type, version, file_path}, so the authoritative old-file
path for a resource is known. On install of a new version we now capture the
prior row before updateOrCreate repoints it, then delete the old file — gated
behind a pure, fully unit-tested decision function with strict safety rails:

  - tracked-only: requires a prior InstalledResource row for the same
    resource_id, so sideloaded/untracked files are never touched
  - genuine replacement: old and new file paths must differ
  - new-file-verified: the new file must be confirmed on disk first
  - strictly-newer: a re-install or downgrade can't wipe a newer file
  - within-storage-dir: the old path must resolve under the content store

ZIM cleanup deletes the old file directly (NOT via this.delete(), which would
drop the InstalledResource row by resource_id that updateOrCreate just
repointed) and rebuilds the Kiwix library only if a file was actually removed,
so its XML never references a deleted ZIM. Maps need no library step. Wikipedia
keeps its own existing cleanup path. All deletions are best-effort and logged;
a failure never breaks the install.

Closes #634

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:47:01 +00:00

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' }
}