Foundation lib for v1.12.0 cron rewrite — closes brief deliverable
"log-rotate" that was missing from the original plan (Phase 9 scope
revision). Standard logrotate idiom, zero dependencies.
- rotateLog(logPath, opts) returns {rotated, dropped, kept}
- Defaults: maxSizeBytes 10 MB, maxGenerations 5 (1 active + 4 rotated)
- No-op when log missing or under threshold
- Over-size: drop oldest, shift .N..1 down by one, move active → .1
- maxGenerations=1 keeps only the active slot (no rotated copies)
- Pure stdlib fs.renameSync chain with silent try/catch on missing gens
8/8 tests pass: missing/under-size/over-size paths, chained 6 rotations
capped at maxGenerations, oldest dropped, two-step content shift.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
94 lines
2.6 KiB
JavaScript
94 lines
2.6 KiB
JavaScript
// 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;
|
|
}
|