Files
kandev/apps/cli/src/backup.ts
T

102 lines
3.4 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Prefix for dev-prod-db automatic snapshots. Distinct from the backend's
// "kandev-*" auto-snapshots and "manual-*" user snapshots so the families
// don't interfere with each other's retention policies.
const BACKUP_PREFIX = "dev-prod-db-";
const BACKUP_SUFFIX = ".db";
const MAX_BACKUPS = 5;
/**
* Returns true if the given dbPath points to a non-dev database that should
* be backed up before running dev mode. Dev-isolated databases live under
* <repo>/.kandev-dev/ and are considered disposable.
*/
export function isProductionDb(dbPath: string): boolean {
const normalized = path.normalize(dbPath);
const segments = normalized.split(path.sep).filter(Boolean);
return !segments.includes(".kandev-dev");
}
/**
* Backs up the database at dbPath to <data-dir>/backups/ before dev mode
* runs against it. Keeps only the newest MAX_BACKUPS snapshots.
*
* Returns the path of the created backup, or null if the DB didn't exist
* or no backup was needed.
*
* The optional `homeDir` parameter is exposed for tests so they can redirect
* the backup location without mocking os.homedir(). The optional `now`
* parameter lets tests pass an explicit timestamp so back-to-back calls in
* the same millisecond produce distinct filenames + mtimes without sleeping.
*/
export function backupProductionDb(dbPath: string, homeDir?: string, now?: Date): string | null {
if (!fs.existsSync(dbPath)) {
return null;
}
const root = homeDir ?? os.homedir();
// Backups are always placed under ~/.kandev/data/backups/ (or the
// caller-supplied homeDir in tests), even when dbPath points elsewhere.
// This matches the dev-prod-db default flow; custom KANDEV_DATABASE_PATH
// values are advanced usage where the user is responsible for backup location.
const dataDir = path.join(root, ".kandev", "data");
const backupDir = path.join(dataDir, "backups");
fs.mkdirSync(backupDir, { recursive: true });
const stamp = now ?? new Date();
const ts = stamp.toISOString().replace(/[:.]/g, "");
const name = `${BACKUP_PREFIX}${ts}${BACKUP_SUFFIX}`;
const dest = path.join(backupDir, name);
fs.copyFileSync(dbPath, dest);
// Stamp both atime + mtime so pruning (which sorts by mtime) is deterministic.
fs.utimesSync(dest, stamp, stamp);
pruneBackups(backupDir, MAX_BACKUPS);
return dest;
}
/**
* Keeps only the `keep` newest dev-prod-db backup files in `dir`, deleting
* older ones. Non-matching files are left untouched.
*/
function pruneBackups(dir: string, keep: number): void {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
const files = entries
.filter((e) => e.isFile() && e.name.startsWith(BACKUP_PREFIX) && e.name.endsWith(BACKUP_SUFFIX))
.map((e) => {
const fullPath = path.join(dir, e.name);
try {
const stat = fs.statSync(fullPath);
return { path: fullPath, mtime: stat.mtime };
} catch {
return null;
}
})
.filter((f): f is { path: string; mtime: Date } => f !== null);
if (files.length <= keep) {
return;
}
files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
for (const f of files.slice(keep)) {
try {
fs.unlinkSync(f.path);
} catch {
// Non-critical: don't fail the launch if one old backup can't be removed.
}
}
}