core_backup_cleanup.js

'use strict';

/**
 * @fileoverview Backup session cleanup and retention management.
 * @module backup/cleanup
 */

const { Session, parseSessionId } = require('./session');
const { removeDirSync } = require('./file-ops');
const { DEFAULT_CONFIG, getSessionDir, SessionStatus } = require('./constants');

/**
 * Returns session age in days
 * @param {string} sessionId
 * @returns {number}
 */
function getSessionAge(sessionId) {
  const parsed = parseSessionId(sessionId);
  if (!parsed) {
    return Infinity;
  }
  const now = new Date();
  const diff = now - parsed.timestamp;
  return Math.floor(diff / (1000 * 60 * 60 * 24));
}

function shouldKeepSession(session) {
  return (
    session.status === SessionStatus.IN_PROGRESS || session.status === SessionStatus.BACKING_UP
  );
}

function isWithinLimits(index, age, config) {
  return index < config.maxSessions && age <= config.maxAgeDays;
}

function categorizeSession(session, index, config) {
  const age = getSessionAge(session.id);
  return { session, keep: isWithinLimits(index, age, config) };
}

/**
 * @param {Object[]} sessions
 * @param {Object} config
 * @returns {{keep: Object[], delete: Object[]}}
 */
function selectSessionsForCleanup(sessions, config) {
  const protectedSessions = sessions.filter(shouldKeepSession);
  const cleanableSessions = sessions.filter(s => !shouldKeepSession(s));
  const categorized = cleanableSessions.map((s, i) => categorizeSession(s, i, config));
  const toKeep = categorized.filter(c => c.keep).map(c => c.session);
  const toDelete = categorized.filter(c => !c.keep).map(c => c.session);
  return { keep: [...protectedSessions, ...toKeep], delete: toDelete };
}

function logVerbose(verbose, log, message) {
  if (verbose) {
    log(message);
  }
}

function deleteSession(sessionDir, sessionId, ctx) {
  const { dryRun, verbose, log } = ctx;
  if (dryRun) {
    log(`Would delete: ${sessionId}`);
    return false;
  }
  try {
    removeDirSync(sessionDir);
    logVerbose(verbose, log, `Deleted: ${sessionId}`);
    return true;
  } catch (err) {
    logVerbose(verbose, log, `Failed to delete ${sessionId}: ${err.message}`);
    return false;
  }
}

function processDeletions(cwd, toDelete, ctx) {
  return toDelete.map(session => {
    const sessionDir = getSessionDir(cwd, session.id);
    return deleteSession(sessionDir, session.id, ctx);
  });
}

function handleEmptySessions(verbose, log) {
  logVerbose(verbose, log, 'No backup sessions found');
  return { deleted: 0, kept: 0 };
}

function handleNoDeletions(keep, verbose, log) {
  logVerbose(verbose, log, `Keeping all ${keep.length} sessions`);
  return { deleted: 0, kept: keep.length };
}

function executeCleanup(ctx) {
  const { cwd, sessions, config, dryRun, verbose, log } = ctx;
  const { keep, delete: toDelete } = selectSessionsForCleanup(sessions, config);
  if (toDelete.length === 0) {
    return handleNoDeletions(keep, verbose, log);
  }
  processDeletions(cwd, toDelete, { dryRun, verbose, log });
  return { deleted: dryRun ? 0 : toDelete.length, kept: keep.length };
}

function loadSessions(cwd) {
  const sessions = Session.listAll(cwd);
  return sessions.length === 0 ? null : sessions;
}

function buildCleanupContext(cwd, options) {
  const config = { ...DEFAULT_CONFIG, ...options };
  const { dryRun = false, verbose = false, log = console.log } = options;
  return { cwd, config, dryRun, verbose, log };
}

/**
 * Removes sessions exceeding retention limits
 * @param {string} cwd
 * @param {Object} [options]
 * @returns {{deleted: number, kept: number}}
 */
function cleanupOldSessions(cwd, options = {}) {
  const ctx = buildCleanupContext(cwd, options);
  const sessions = loadSessions(cwd);
  if (!sessions) {
    return handleEmptySessions(ctx.verbose, ctx.log);
  }
  return executeCleanup({ ...ctx, sessions });
}

function mapSessionForPreview(session) {
  return {
    id: session.id,
    age: getSessionAge(session.id),
    status: session.status,
    fileCount: session.fileCount,
  };
}

/**
 * Returns cleanup preview without executing
 * @param {string} cwd
 * @param {Object} [options]
 * @returns {{sessions: Object[], toDelete: Object[], toKeep: Object[]}}
 */
function getCleanupPreview(cwd, options = {}) {
  const config = { ...DEFAULT_CONFIG, ...options };
  const sessions = Session.listAll(cwd);
  if (sessions.length === 0) {
    return { sessions: [], toDelete: [], toKeep: [] };
  }
  const { keep, delete: toDelete } = selectSessionsForCleanup(sessions, config);
  return {
    sessions,
    toDelete: toDelete.map(mapSessionForPreview),
    toKeep: keep.map(mapSessionForPreview),
  };
}

/**
 * @param {string} cwd
 * @param {Object} config
 * @returns {{deleted: number, kept: number}|null}
 */
function autoCleanupIfEnabled(cwd, config) {
  if (!config.autoCleanup) {
    return null;
  }
  return cleanupOldSessions(cwd, { ...config, verbose: false });
}

module.exports = {
  cleanupOldSessions,
  getCleanupPreview,
  autoCleanupIfEnabled,
};