/** * Module: Backup Scheduler * Purpose: Automated scheduled database backups with rotation * Dependencies: node-cron, fs/promises, path, server/db.js */ import cron from 'node-cron'; import fs from 'node:fs/promises'; import path from 'node:path'; import { backupToFile } from '../db.js'; import { createLogger } from '../logger.js'; const log = createLogger('BackupScheduler'); // Configuration from environment variables const BACKUP_SCHEDULE = process.env.BACKUP_SCHEDULE || '0 2 * * *'; // Default: 2 AM daily const BACKUP_DIR = process.env.BACKUP_DIR || './backups'; const BACKUP_KEEP = parseInt(process.env.BACKUP_KEEP || '7', 10); // Default: keep last 7 backups const BACKUP_ENABLED = process.env.BACKUP_ENABLED !== 'false'; // Default: enabled let scheduledTask = null; let lastBackup = null; let lastError = null; /** * Generate timestamped backup filename */ function backupFileName() { const stamp = new Date().toISOString().replace(/[:.]/g, '-'); return `oikos-backup-${stamp}.db`; } /** * Ensure backup directory exists */ async function ensureBackupDir() { try { await fs.mkdir(BACKUP_DIR, { recursive: true }); } catch (err) { log.error('Failed to create backup directory:', err); throw err; } } /** * Get all backup files sorted by modification time (newest first) */ async function getBackupFiles() { try { const files = await fs.readdir(BACKUP_DIR); const backupFiles = files.filter((f) => f.startsWith('oikos-backup-') && f.endsWith('.db')); // Get file stats and sort by modification time const filesWithStats = await Promise.all( backupFiles.map(async (file) => { const filePath = path.join(BACKUP_DIR, file); const stats = await fs.stat(filePath); return { file, mtime: stats.mtime, path: filePath }; }) ); return filesWithStats.sort((a, b) => b.mtime - a.mtime); } catch (err) { if (err.code === 'ENOENT') { return []; } throw err; } } /** * Rotate backups - keep only the last N backups */ async function rotateBackups() { try { const files = await getBackupFiles(); if (files.length <= BACKUP_KEEP) { return; // Nothing to delete } const filesToDelete = files.slice(BACKUP_KEEP); for (const { file, path: filePath } of filesToDelete) { try { await fs.unlink(filePath); log.info(`Rotated old backup: ${file}`); } catch (err) { log.error(`Failed to delete old backup ${file}:`, err); } } } catch (err) { log.error('Backup rotation failed:', err); } } /** * Perform automated backup */ async function performBackup() { try { log.info('Starting scheduled backup...'); await ensureBackupDir(); const fileName = backupFileName(); const filePath = path.join(BACKUP_DIR, fileName); await backupToFile(filePath); log.info(`Backup created: ${fileName}`); // Rotate old backups await rotateBackups(); lastBackup = { timestamp: new Date().toISOString(), file: fileName, success: true, }; lastError = null; } catch (err) { log.error('Scheduled backup failed:', err); lastError = { timestamp: new Date().toISOString(), message: err.message, }; lastBackup = { timestamp: new Date().toISOString(), success: false, error: err.message, }; } } /** * Start the backup scheduler */ export function startScheduler() { if (!BACKUP_ENABLED) { log.info('Automated backups are disabled (BACKUP_ENABLED=false)'); return; } if (!cron.validate(BACKUP_SCHEDULE)) { log.error(`Invalid cron schedule: ${BACKUP_SCHEDULE}`); return; } scheduledTask = cron.schedule(BACKUP_SCHEDULE, performBackup, { timezone: process.env.TZ || 'UTC', }); log.info(`Backup scheduler started: ${BACKUP_SCHEDULE} (keeping last ${BACKUP_KEEP} backups)`); } /** * Stop the backup scheduler */ export function stopScheduler() { if (scheduledTask) { scheduledTask.stop(); scheduledTask = null; log.info('Backup scheduler stopped'); } } /** * Get scheduler status */ export function getStatus() { return { enabled: BACKUP_ENABLED, schedule: BACKUP_SCHEDULE, backupDir: BACKUP_DIR, keepCount: BACKUP_KEEP, running: scheduledTask !== null, lastBackup, lastError, }; } /** * Trigger an immediate backup (for manual/testing purposes) */ export async function triggerBackup() { await performBackup(); return lastBackup; }