// log-rotate.mjs — Bounded-size log rotation for the cron-runner. // Zero dependencies. Standard Linux logrotate idiom: when logPath exceeds // maxSizeBytes, shift generations N..1 down by one, drop the oldest, and // move logPath → logPath.1. The next caller writes to a fresh logPath. import { existsSync, statSync, renameSync, unlinkSync } from 'node:fs'; const DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB const DEFAULT_MAX_GENERATIONS = 5; function silentRename(from, to) { try { renameSync(from, to); return true; } catch { return false; } } function silentUnlink(path) { try { unlinkSync(path); return true; } catch { return false; } } /** * Rotate logPath if it exceeds maxSizeBytes. Keeps at most maxGenerations * total files (1 active + maxGenerations-1 rotated copies). No-op if logPath * is missing or under threshold. * * @param {string} logPath * @param {object} [opts] * @param {number} [opts.maxSizeBytes] — default 10 MB * @param {number} [opts.maxGenerations] — default 5; total active+rotated * @returns {{rotated: boolean, dropped: string|null, kept: string[]}} */ export function rotateLog(logPath, opts = {}) { const result = { rotated: false, dropped: null, kept: [] }; if (!logPath || typeof logPath !== 'string') { throw new Error('rotateLog: logPath is required'); } const maxSizeBytes = opts.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES; const maxGenerations = Math.max(1, opts.maxGenerations ?? DEFAULT_MAX_GENERATIONS); if (!existsSync(logPath)) return result; let size; try { size = statSync(logPath).size; } catch { return result; } if (size <= maxSizeBytes) return result; // The highest rotated generation we keep is maxGenerations - 1. // (Active log = generation 0; rotated copies = generations 1..N-1.) const lastGen = maxGenerations - 1; if (lastGen === 0) { // Only the active log is kept — rotation = drop the previous active. silentUnlink(logPath); result.rotated = true; result.dropped = logPath; return result; } // Drop the oldest generation if it exists. const oldest = `${logPath}.${lastGen}`; if (existsSync(oldest)) { silentUnlink(oldest); result.dropped = oldest; } // Shift down: .N-1 → .N, .N-2 → .N-1, ..., .1 → .2. for (let i = lastGen - 1; i >= 1; i--) { const from = `${logPath}.${i}`; const to = `${logPath}.${i + 1}`; if (existsSync(from)) { silentRename(from, to); result.kept.push(to); } } // Active log → .1. if (silentRename(logPath, `${logPath}.1`)) { result.kept.unshift(`${logPath}.1`); } result.rotated = true; return result; }