logroller 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! logroller v2.0.0 | MIT License */
1
+ /*! logroller v2.1.0 | MIT License */
2
2
  'use strict';
3
3
 
4
4
  var node_crypto = require('node:crypto');
@@ -26,13 +26,13 @@ const existsSafe = (p) => {
26
26
  }
27
27
  };
28
28
  /**
29
- * Compress `src` → `dst`, deleting `src` ONLY on verified success.
30
- * On failure removes the partial `dst` and reports via callback —
29
+ * Compress `src` → `dst` at gzip `level`, deleting `src` ONLY on verified
30
+ * success. On failure removes the partial `dst` and reports via callback —
31
31
  * logs degrade to uncompressed rather than losing data.
32
32
  */
33
- async function gzipMove(src, dst, onWarn) {
33
+ async function gzipMove(src, dst, onWarn, level = 6) {
34
34
  try {
35
- await promises.pipeline(fs.createReadStream(src), zlib.createGzip({ level: 6 }), fs.createWriteStream(dst));
35
+ await promises.pipeline(fs.createReadStream(src), zlib.createGzip({ level }), fs.createWriteStream(dst));
36
36
  await fs.promises.unlink(src);
37
37
  return true;
38
38
  }
@@ -45,19 +45,54 @@ async function gzipMove(src, dst, onWarn) {
45
45
  const endStream = (ws) => ws ? new Promise((resolve) => ws.end(() => resolve())) : Promise.resolve();
46
46
 
47
47
  const UNITS = {
48
- b: 1, k: 1024, kb: 1024,
49
- m: 1048576, mb: 1048576,
50
- g: 1073741824, gb: 1073741824,
48
+ b: 1,
49
+ k: 1024,
50
+ kb: 1024,
51
+ m: 1048576,
52
+ mb: 1048576,
53
+ g: 1073741824,
54
+ gb: 1073741824,
51
55
  };
52
- const FREQ_MULT = {
53
- ms: 1, s: 1000, m: 60_000, h: 3_600_000,
56
+ /**
57
+ * Every unit token the duration regex can capture MUST have an entry here —
58
+ * a captured-but-unmapped unit would otherwise surface as a NaN that
59
+ * detonates later (e.g. `RangeError: Invalid time value` from Intl),
60
+ * far away from the actual misconfiguration.
61
+ */
62
+ const DURATION_ALIASES = {
63
+ ms: 1,
64
+ s: 1_000,
65
+ m: 60_000,
66
+ h: 3_600_000,
67
+ hour: 3_600_000,
68
+ hours: 3_600_000,
54
69
  };
70
+ /**
71
+ * Parse `"<n><unit?>"` or a bare number into milliseconds.
72
+ * Bare numbers are interpreted in `defaultUnit`.
73
+ *
74
+ * Accepted unit spellings: ms, s, m, h, hour, hours (case-insensitive).
75
+ */
76
+ function parseDuration(value, defaultUnit, fieldName) {
77
+ if (typeof value === "number") {
78
+ return Math.round(value * DURATION_ALIASES[defaultUnit]);
79
+ }
80
+ const m = /^\s*(-?\d+(?:\.\d+)?)\s*(h(?:our)?s?|ms|s|m)?\s*$/i.exec(value);
81
+ if (!m)
82
+ throw new Error(`Invalid ${fieldName} "${value}"`);
83
+ const unit = (m[2] ?? defaultUnit).toLowerCase();
84
+ const mult = DURATION_ALIASES[unit];
85
+ if (mult === undefined) {
86
+ throw new Error(`Invalid ${fieldName} unit "${m[2]}" in "${value}"`);
87
+ }
88
+ return Math.round(Number.parseFloat(m[1]) * mult);
89
+ }
55
90
  function parseSize(v) {
56
91
  if (v === undefined || v === null || v === 0)
57
92
  return 0;
58
93
  if (typeof v === "number")
59
94
  return v;
60
- const m = /^\s*(\d+(?:\.\d+)?)\s*(b|k|kb|m|mb|g|gb)?\s*$/i.exec(v);
95
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|k|mb|m|gb|g)?\s*$/i.exec(v);
61
96
  if (!m)
62
97
  throw new Error(`Invalid maxSize "${v}"`);
63
98
  return Math.round(Number.parseFloat(m[1]) * (UNITS[m[2]?.toLowerCase()] ?? 1));
@@ -65,8 +100,11 @@ function parseSize(v) {
65
100
  function parseMaxFiles(v) {
66
101
  if (v === undefined || v === null || v === "" || v === 0)
67
102
  return null;
68
- if (typeof v === "number")
103
+ if (typeof v === "number") {
104
+ if (v < 0)
105
+ throw new Error(`Invalid maxFiles "${v}"`);
69
106
  return { count: v };
107
+ }
70
108
  const m = /^\s*(\d+)\s*d\s*$/i.exec(v);
71
109
  if (m)
72
110
  return { days: Number.parseInt(m[1], 10) };
@@ -81,7 +119,7 @@ function parseFrequency(v) {
81
119
  if (v === "daily")
82
120
  return { type: "daily" };
83
121
  if (typeof v === "number") {
84
- if (!(v > 0))
122
+ if (v <= 0)
85
123
  throw new Error(`Invalid frequency "${v}"`);
86
124
  return { type: "interval", ms: v };
87
125
  }
@@ -90,25 +128,23 @@ function parseFrequency(v) {
90
128
  throw new Error(`Invalid frequency "${v}"`);
91
129
  return { type: "interval", ms: Number.parseInt(m[1], 10) * FREQ_MULT[m[2].toLowerCase()] };
92
130
  }
131
+ const FREQ_MULT = {
132
+ ms: 1,
133
+ s: 1_000,
134
+ m: 60_000,
135
+ h: 3_600_000,
136
+ };
137
+ /** Static virtual-clock shift. Accepts hours by default; h/hours/m/s/ms. */
93
138
  function parseClockOffset(v) {
94
139
  if (v === undefined || v === null || v === "")
95
140
  return 0;
96
- if (typeof v === "number")
97
- return Math.round(v * 3_600_000);
98
- const m = /^\s*(-?\d+(?:\.\d+)?)\s*h(?:our)?s?\s*$/i.exec(v);
99
- if (!m)
100
- throw new Error(`Invalid addHours "${v}"`);
101
- return Math.round(Number.parseFloat(m[1]) * 3_600_000);
141
+ return parseDuration(v, "h", "addHours");
102
142
  }
143
+ /** Per-tick virtual-clock step. Accepts hours by default; h/hours/m/s/ms. */
103
144
  function parseClockStep(v) {
104
- if (v === undefined || v === null || v === "" || v === 0)
145
+ if (v === undefined || v === null || v === "")
105
146
  return 0;
106
- if (typeof v === "number")
107
- return Math.round(v * 3_600_000);
108
- const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(v);
109
- if (!m)
110
- throw new Error(`Invalid addHoursEveryMin "${v}"`);
111
- return Math.round(Number.parseFloat(m[1]) * FREQ_MULT[(m[2] ?? "h").toLowerCase()]);
147
+ return parseDuration(v, "h", "addHoursEveryMin");
112
148
  }
113
149
 
114
150
  /* Timezone-aware helpers built exclusively on Intl (zero deps). */
@@ -145,11 +181,30 @@ function partsIn(tz, date) {
145
181
  const pad2 = (n) => n.padStart(2, "0");
146
182
  /** Escape a literal string for embedding inside a RegExp. */
147
183
  function escRe(s) {
148
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
184
+ return s.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
149
185
  }
150
186
  /** Substitute YYYY|YY|MM|DD|HH|mm|ss tokens in `pattern`. */
151
187
  function formatStamp(pattern, parts) {
152
- return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (t) => t === "YYYY" ? parts.year : t === "YY" ? parts.year.slice(-2) : t === "MM" ? pad2(parts.month) : t === "DD" ? pad2(parts.day) : t === "HH" ? pad2(parts.hour) : t === "mm" ? pad2(parts.minute) : t === "ss" ? pad2(parts.second) : t);
188
+ return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (token) => {
189
+ switch (token) {
190
+ case "YYYY":
191
+ return parts.year;
192
+ case "YY":
193
+ return parts.year.slice(-2);
194
+ case "MM":
195
+ return pad2(parts.month);
196
+ case "DD":
197
+ return pad2(parts.day);
198
+ case "HH":
199
+ return pad2(parts.hour);
200
+ case "mm":
201
+ return pad2(parts.minute);
202
+ case "ss":
203
+ return pad2(parts.second);
204
+ default:
205
+ return token;
206
+ }
207
+ });
153
208
  }
154
209
  /** Offset of `tz` vs UTC at instant `date`, milliseconds. */
155
210
  function tzOffsetMs(tz, date) {
@@ -179,6 +234,7 @@ const BUMP_DEFAULT_MS = 60_000;
179
234
  const AUDIT_MAX_FILES = 600;
180
235
  const AUDIT_MAX_EVENTS = 200;
181
236
  const HEARTBEAT_STALE_MS = 30_000;
237
+ const TIMER_POLL_MS = 1_000;
182
238
  const emptyAudit = (prefix, ext, datePattern, tz) => ({
183
239
  version: 1,
184
240
  prefix,
@@ -195,17 +251,22 @@ const emptyAudit = (prefix, ext, datePattern, tz) => ({
195
251
  });
196
252
  /**
197
253
  * A `Writable` that appends to dated log files, rolling segments on time
198
- * and/or size, optionally gzipping sealed segments, enforcing retention,
199
- * and resuming an unbroken series across restarts via disk scan + audit
200
- * manifest.
254
+ * and/or size, optionally gzipping sealed segments, enforcing retention
255
+ * (count, age, and total-byte budget), maintaining a stable "current"
256
+ * symlink for log collectors, running an awaited seal hook (upload
257
+ * pipeline), and resuming an unbroken series across restarts via disk
258
+ * scan + audit manifest.
201
259
  *
202
260
  * @example
203
261
  * ```ts
204
262
  * const log = createStream({
205
263
  * filename: "logs/app-%DATE%.log",
206
264
  * maxSize: "20m",
265
+ * maxTotalSize: "5g",
207
266
  * maxFiles: "90d",
208
267
  * zippedArchive: true,
268
+ * symlink: true,
269
+ * onSeal: async ({ gz }) => uploadToS3(gz),
209
270
  * });
210
271
  * log.write("hello\n"); // or: readable.pipe(log)
211
272
  * ```
@@ -220,6 +281,7 @@ class RotateFileStream extends node_stream.Writable {
220
281
  maxFiles;
221
282
  freq;
222
283
  unrefTimers;
284
+ maxTotalSize;
223
285
  /* ---- filename-template pieces ---- */
224
286
  _tL;
225
287
  _tMid;
@@ -230,6 +292,10 @@ class RotateFileStream extends node_stream.Writable {
230
292
  _auditEnabled;
231
293
  _auditPath;
232
294
  _bootId = node_crypto.randomUUID();
295
+ _onSeal;
296
+ _sealHookTimeoutMs;
297
+ _compressionLevel;
298
+ _symlinkPath = null;
233
299
  /* ---- virtual test clock ---- */
234
300
  _offsetMs;
235
301
  _stepMs;
@@ -244,6 +310,7 @@ class RotateFileStream extends node_stream.Writable {
244
310
  _bumpTimer = null;
245
311
  _ended = false;
246
312
  _tail = Promise.resolve();
313
+ _warnedBackward = false;
247
314
  _audit;
248
315
  constructor(opts = {}) {
249
316
  super(opts.highWaterMark ? { highWaterMark: opts.highWaterMark } : {});
@@ -259,11 +326,16 @@ class RotateFileStream extends node_stream.Writable {
259
326
  this.zippedArchive = !!opts.zippedArchive;
260
327
  this.maxSize = parseSize(opts.maxSize ?? 0);
261
328
  this.maxFiles = parseMaxFiles(opts.maxFiles);
329
+ this.maxTotalSize = parseSize(opts.maxTotalSize ?? 0);
262
330
  this.freq = parseFrequency(opts.frequency);
263
331
  this.unrefTimers = !!opts.unrefTimers;
264
332
  this._offsetMs = parseClockOffset(opts.addHours);
265
333
  this._stepMs = parseClockStep(opts.addHoursEveryMin);
266
334
  this._bumpEvery = Math.max(50, opts.clockStepIntervalMs ?? BUMP_DEFAULT_MS);
335
+ // compression + seal hook
336
+ this._compressionLevel = Math.min(9, Math.max(0, opts.compressionLevel ?? 6));
337
+ this._onSeal = opts.onSeal;
338
+ this._sealHookTimeoutMs = Math.max(0, opts.sealHookTimeoutMs ?? 30_000);
267
339
  /* Split "prefix-%DATE%mid.ext" → pieces so indices land BETWEEN the
268
340
  stamp and the extension: prefix-STAMP.N.ext */
269
341
  const li = this.template.indexOf("%DATE%");
@@ -280,6 +352,12 @@ class RotateFileStream extends node_stream.Writable {
280
352
  const stem = (this._tL.replace(/[^\w.-]+/g, "") || "log")
281
353
  .replace(/^[-.]+|[-.]+$/g, "");
282
354
  this._auditPath = path.join(this.dir, opts.auditFile || `${stem}_audit.json`);
355
+ // stable "current" pointer target
356
+ this._symlinkPath = opts.symlink
357
+ ? path.join(this.dir, typeof opts.symlink === "string"
358
+ ? opts.symlink
359
+ : `${stem}-current.log`)
360
+ : null;
283
361
  fs.mkdirSync(this.dir, { recursive: true });
284
362
  this._curStamp = this._stampNow();
285
363
  this._audit = emptyAudit(this._tL, this._tExt, this.datePattern, this.tz);
@@ -296,20 +374,37 @@ class RotateFileStream extends node_stream.Writable {
296
374
  offsetMs: this._offsetMs,
297
375
  }));
298
376
  }
377
+ // warn when frequency is finer than the datePattern tokens
378
+ process.nextTick(() => {
379
+ const warn = (m) => this.emit("warn", new Error(m));
380
+ if (this.freq.type === "interval") {
381
+ if (this.freq.ms < 86_400_000 && !this.datePattern.includes("HH")) {
382
+ warn(`frequency "${opts.frequency}" needs HH in datePattern — ` +
383
+ "segments would collapse onto one filename");
384
+ }
385
+ if (this.freq.ms < 60_000 && !this.datePattern.includes("mm")) {
386
+ warn(`frequency "${opts.frequency}" needs mm in datePattern`);
387
+ }
388
+ }
389
+ else if (!this.datePattern.includes("DD")) {
390
+ warn("daily frequency needs DD in datePattern");
391
+ }
392
+ });
299
393
  /* Boot housekeeping is job #1 of the serial queue — chained HERE in the
300
394
  constructor so it deterministically precedes any write job: zombie
301
395
  demotion sees `_path === null`, gzip repair cannot race a reopen. */
302
396
  this._job(async () => {
397
+ // disk-driven, audit-independent (runs even with audit:false)
398
+ await this._sealOrphanedPastStamps();
303
399
  if (this._auditEnabled) {
304
400
  this._demoteZombieActives();
305
401
  this._reconcileWithDisk();
306
- await this._sealOrphanedPastStamps();
307
402
  await this._repairInterruptedGzips();
308
403
  this._aevent("boot", { stamp: this._curStamp, index: this._index });
309
404
  this._saveAudit();
310
405
  }
311
- if (this.maxFiles)
312
- this._retire();
406
+ if (this.maxFiles || this.maxTotalSize > 0)
407
+ this._retire(); // budget policy also gates
313
408
  }).catch(() => { });
314
409
  this._armTimer();
315
410
  this._armBump();
@@ -338,9 +433,7 @@ class RotateFileStream extends node_stream.Writable {
338
433
  get currentIndex() {
339
434
  return this._index;
340
435
  }
341
- /**
342
- * Force an immediate rotation and resolve when the next slot is staged.
343
- */
436
+ /** Force an immediate rotation and resolve when the next slot is staged. */
344
437
  rotateNow() {
345
438
  return this._job(() => this._rotate("manual"));
346
439
  }
@@ -352,6 +445,60 @@ class RotateFileStream extends node_stream.Writable {
352
445
  advanceClock() {
353
446
  return this._job(() => this._applyBump());
354
447
  }
448
+ /**
449
+ * Every family file on disk, oldest stamp first.
450
+ * Disk is truth: untracked files are reported with state "untracked".
451
+ */
452
+ listSegments() {
453
+ const rx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
454
+ "(?:\\.(\\d+))?" + escRe(this._tExt) + "(?:\\.gz)?$");
455
+ const skip = this._symlinkPath && path.basename(this._symlinkPath);
456
+ const out = [];
457
+ let names = [];
458
+ try {
459
+ names = fs.readdirSync(this.dir);
460
+ }
461
+ catch {
462
+ return out;
463
+ }
464
+ for (const n of names) {
465
+ if (n === skip)
466
+ continue;
467
+ const m = rx.exec(n);
468
+ if (!m)
469
+ continue;
470
+ const rec = this._audit.files[n];
471
+ out.push({
472
+ file: path.join(this.dir, n),
473
+ stamp: m[1],
474
+ index: m[2] ? Number.parseInt(m[2], 10) : 0,
475
+ gzipped: n.endsWith(".gz"),
476
+ bytes: statSizeSafe(path.join(this.dir, n)),
477
+ state: rec?.state ?? "untracked",
478
+ openedAt: rec?.openedAt,
479
+ archivedAt: rec?.archivedAt,
480
+ });
481
+ }
482
+ out.sort((a, b) => a.stamp.localeCompare(b.stamp) || a.index - b.index);
483
+ return out;
484
+ }
485
+ /**
486
+ * Health-check snapshot for dashboards and support triage.
487
+ */
488
+ stats() {
489
+ const segs = this.listSegments();
490
+ return {
491
+ dir: this.dir,
492
+ currentStamp: this._curStamp,
493
+ activeFile: this._path,
494
+ activeIndex: this._index,
495
+ segments: segs.length,
496
+ archived: segs.filter((s) => s.gzipped).length,
497
+ totalBytes: segs.reduce((sum, s) => sum + s.bytes, 0),
498
+ clockOffsetMs: this._offsetMs,
499
+ symlink: this._symlinkPath,
500
+ };
501
+ }
355
502
  /* ==================================================================
356
503
  * Writable contract
357
504
  * ================================================================ */
@@ -363,8 +510,8 @@ class RotateFileStream extends node_stream.Writable {
363
510
  const len = chunk.length;
364
511
  this._job(async () => {
365
512
  try {
366
- const stamp = this._stampNow();
367
- if (stamp !== this._curStamp)
513
+ // guarded roll (never rotates into a regressed stamp)
514
+ if (this._stampAdvanced(this._stampNow()))
368
515
  await this._rotate("time");
369
516
  await this._ensureOpen();
370
517
  if (this.maxSize > 0 &&
@@ -435,6 +582,22 @@ class RotateFileStream extends node_stream.Writable {
435
582
  ? base.replace(/\.[^.]+$/, `.${index}$&`)
436
583
  : `${base}.${index}`;
437
584
  }
585
+ /**
586
+ * true only when the period legally advanced. A regressed
587
+ * clock (NTP correction, manual change) never reopens a past family:
588
+ * writes continue in the current segment until real time catches up.
589
+ * Requires zero-padded datePattern tokens (documented assumption).
590
+ */
591
+ _stampAdvanced(stamp) {
592
+ if (stamp > this._curStamp)
593
+ return true;
594
+ if (stamp < this._curStamp && !this._warnedBackward) {
595
+ this._warnedBackward = true;
596
+ this.emit("warn", new Error(`clock moved backward (stamp ${stamp} < ${this._curStamp}); ` +
597
+ "keeping current segment until the clock catches up"));
598
+ }
599
+ return false;
600
+ }
438
601
  /**
439
602
  * Highest viable segment index for `stamp`.
440
603
  * Disk is truth: resume a raw tail; start after sealed ones.
@@ -468,6 +631,57 @@ class RotateFileStream extends node_stream.Writable {
468
631
  }
469
632
  return idx;
470
633
  }
634
+ /**
635
+ * Atomically flip the "current" symlink to the active segment.
636
+ * tmp + rename so followers (`tail -f`, Filebeat) never observe a gap.
637
+ * Relative target keeps the link portable. Best-effort: filesystems
638
+ * denying symlinks warn once and disable the feature permanently.
639
+ */
640
+ _updateSymlink() {
641
+ const link = this._symlinkPath;
642
+ if (!link || !this._path)
643
+ return;
644
+ const tmp = `${link}.tmp`;
645
+ try {
646
+ try {
647
+ fs.unlinkSync(tmp);
648
+ }
649
+ catch { /* absent */ }
650
+ fs.symlinkSync(path.basename(this._path), tmp, "file");
651
+ fs.renameSync(tmp, link);
652
+ }
653
+ catch (e) {
654
+ this._symlinkPath = null;
655
+ this.emit("warn", new Error(`symlink unavailable ("${link}"): ${e.message} — continuing without it`));
656
+ }
657
+ }
658
+ /**
659
+ * Await the user's onSeal hook with a hard timeout. Result or
660
+ * failure degrades to `warn` — a wedged upload must never stall rotation.
661
+ * Called BEFORE retention so uploads finish before deletion.
662
+ */
663
+ async _runSealHook(raw, gz) {
664
+ const hook = this._onSeal;
665
+ if (!hook)
666
+ return;
667
+ let timer;
668
+ try {
669
+ await Promise.race([
670
+ Promise.resolve().then(() => hook({ raw, gz })),
671
+ new Promise((_, reject) => {
672
+ timer = setTimeout(() => reject(new Error(`onSeal hook timed out after ${this._sealHookTimeoutMs}ms ` +
673
+ `(${path.basename(gz ?? raw)})`)), this._sealHookTimeoutMs);
674
+ }),
675
+ ]);
676
+ }
677
+ catch (err) {
678
+ this.emit("warn", err);
679
+ }
680
+ finally {
681
+ if (timer)
682
+ clearTimeout(timer);
683
+ }
684
+ }
471
685
  async _ensureOpen() {
472
686
  if (this._ws)
473
687
  return;
@@ -477,8 +691,12 @@ class RotateFileStream extends node_stream.Writable {
477
691
  ws.on("error", (e) => this.emit("error", e));
478
692
  this._ws = ws;
479
693
  this._path = p;
694
+ this._updateSymlink(); // flip pointer on every open
480
695
  if (this._auditEnabled) {
481
696
  const base = path.basename(p);
697
+ // reopen = NEW incarnation. Never inherit lifecycle
698
+ // fields from the previous record (observed in the wild as
699
+ // abandonedAt < openedAt on reactivated segments).
482
700
  this._audit.files[base] = {
483
701
  index: this._index,
484
702
  state: "active",
@@ -516,9 +734,10 @@ class RotateFileStream extends node_stream.Writable {
516
734
  rec.bytes = realBytes;
517
735
  this._saveAudit();
518
736
  }
519
- const ok = await gzipMove(oldPath, archive, (e) => this.emit("warn", e));
737
+ const ok = await gzipMove(oldPath, archive, (e) => this.emit("warn", e), this._compressionLevel);
520
738
  if (ok) {
521
739
  this._finishArchivedRecord(oldBase);
740
+ await this._runSealHook(oldPath, archive);
522
741
  }
523
742
  else {
524
743
  if (rec) {
@@ -527,52 +746,75 @@ class RotateFileStream extends node_stream.Writable {
527
746
  }
528
747
  this._aevent("gzip-failed", { file: oldBase });
529
748
  archive = null;
749
+ await this._runSealHook(oldPath, null);
530
750
  }
531
751
  }
532
752
  else if (realBytes === 0) {
753
+ // a concurrent writer may have archived this segment
754
+ // between our last stat and now — adopt its artifact instead of
755
+ // recording a false `drop-empty` that orphans the .gz.
533
756
  const adoptedGz = `${oldPath}.gz`;
534
757
  if (existsSafe(adoptedGz)) {
535
- // A concurrent writer archived this segment between our last stat
536
- // and now — adopt its artifact instead of dropping the record.
537
758
  this._finishArchivedRecord(oldBase);
538
759
  archive = adoptedGz;
760
+ await this._runSealHook(oldPath, adoptedGz);
539
761
  }
540
762
  else {
541
763
  try {
542
764
  fs.unlinkSync(oldPath);
543
765
  }
544
- catch { /* already gone */ }
766
+ catch {
767
+ /* already gone */
768
+ }
545
769
  if (rec)
546
770
  delete this._audit.files[oldBase];
547
771
  this._aevent("drop-empty", { file: oldBase });
548
772
  }
549
773
  }
550
- const stamp = this._stampNow();
551
- const newPeriod = stamp !== this._curStamp;
552
- this._curStamp = stamp;
553
- /* Never blind-reset to 0 — recover what THIS stamp already owns. */
554
- this._index =
555
- newPeriod || kind === "time"
556
- ? this._recoverIndexFor(stamp)
557
- : this._index + 1;
558
- this._bytes = 0;
559
- if (this._auditEnabled) {
560
- this._setActivePointer(this._index);
561
- this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
562
- this._saveAudit();
774
+ else if (realBytes > 0) {
775
+ // uncompressed seal (zippedArchive off): still notify sealers
776
+ if (rec) {
777
+ rec.state = "sealed";
778
+ rec.bytes = realBytes;
779
+ }
780
+ await this._runSealHook(oldPath, null);
563
781
  }
564
- if (kind === "time" || kind === "manual")
565
- this._armTimer();
566
- if (oldPath) {
567
- this.emit("rotated", {
568
- reason: kind,
569
- oldFile: oldPath,
570
- archive,
571
- newFile: this._resolveName(stamp, this._index),
572
- });
782
+ }
783
+ let stamp = this._stampNow();
784
+ // a rotation itself must never move the series backward
785
+ // (covers rotateNow()/interval/size paths the upstream guards miss)
786
+ if (stamp < this._curStamp) {
787
+ if (!this._warnedBackward) {
788
+ this._warnedBackward = true;
789
+ this.emit("warn", new Error(`clock moved backward (stamp ${stamp} < ${this._curStamp}); ` +
790
+ "rotation clamped to the current period"));
573
791
  }
574
- this._retire();
792
+ stamp = this._curStamp;
575
793
  }
794
+ const newPeriod = stamp !== this._curStamp;
795
+ this._curStamp = stamp;
796
+ /* Never blind-reset to 0 — recover what THIS stamp already owns. */
797
+ this._index =
798
+ newPeriod || kind === "time"
799
+ ? this._recoverIndexFor(stamp)
800
+ : this._index + 1;
801
+ this._bytes = 0;
802
+ if (this._auditEnabled) {
803
+ this._setActivePointer(this._index);
804
+ this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
805
+ this._saveAudit();
806
+ }
807
+ if (kind === "time" || kind === "manual")
808
+ this._armTimer();
809
+ if (oldPath) {
810
+ this.emit("rotated", {
811
+ reason: kind,
812
+ oldFile: oldPath,
813
+ archive,
814
+ newFile: this._resolveName(stamp, this._index),
815
+ });
816
+ }
817
+ this._retire();
576
818
  }
577
819
  /* ==================================================================
578
820
  * Timers
@@ -590,16 +832,26 @@ class RotateFileStream extends node_stream.Writable {
590
832
  const next = this.freq.type === "daily"
591
833
  ? nextMidnight(this.tz, now)
592
834
  : nextAligned(now, this.freq.ms);
835
+ // anti-spin. A backward clock can put the computed
836
+ // boundary in the past; poll gently instead of firing every 100 ms.
837
+ const delta = next - this._now();
838
+ const delay = delta < 0 ? TIMER_POLL_MS : Math.max(100, delta);
593
839
  this._timer = setTimeout(() => {
594
840
  this._job(async () => {
595
841
  const stamp = this._stampNow();
596
- const dueDaily = this.freq.type === "daily" && stamp !== this._curStamp;
597
- const dueInterval = this.freq.type !== "daily";
598
- if (dueDaily || dueInterval) {
842
+ if (this.freq.type !== "daily") {
843
+ /* interval rotation is schedule-based; runs regardless of
844
+ stamp movement */
845
+ if (this._hasContent())
846
+ await this._rotate("time");
847
+ }
848
+ else if (this._stampAdvanced(stamp)) {
849
+ // guarded daily branch
599
850
  if (this._hasContent()) {
600
851
  await this._rotate("time");
601
852
  }
602
- else if (stamp !== this._curStamp) {
853
+ else {
854
+ /* idle boundary slide — no file created for empty days */
603
855
  this._curStamp = stamp;
604
856
  this._index = this._recoverIndexFor(stamp);
605
857
  if (this._auditEnabled) {
@@ -610,9 +862,10 @@ class RotateFileStream extends node_stream.Writable {
610
862
  this.emit("period", stamp);
611
863
  }
612
864
  }
865
+ /* stamp regressed or unchanged → keep current segment, re-arm */
613
866
  this._armTimer();
614
867
  }).catch(() => { });
615
- }, Math.max(100, next - this._now()));
868
+ }, delay);
616
869
  if (this.unrefTimers)
617
870
  this._timer.unref();
618
871
  }
@@ -636,7 +889,8 @@ class RotateFileStream extends node_stream.Writable {
636
889
  this._offsetMs += this._stepMs;
637
890
  this._armTimer();
638
891
  const stamp = this._stampNow();
639
- if (stamp !== this._curStamp) {
892
+ // guarded crossing (virtual clock can't roll backward either)
893
+ if (this._stampAdvanced(stamp)) {
640
894
  if (this._hasContent()) {
641
895
  await this._rotate("time");
642
896
  }
@@ -674,7 +928,15 @@ class RotateFileStream extends node_stream.Writable {
674
928
  parsed = JSON.parse(fs.readFileSync(this._auditPath, "utf8"));
675
929
  }
676
930
  catch {
677
- return false; // missing/unreadable → fresh start
931
+ // unparseable manifest is quarantined too (was silently
932
+ // clobbered by the first save — caught by the quarantine test)
933
+ try {
934
+ fs.renameSync(this._auditPath, `${this._auditPath}.corrupt`);
935
+ }
936
+ catch {
937
+ /* ignore */
938
+ }
939
+ return false;
678
940
  }
679
941
  const j = parsed;
680
942
  if (!j || j.version !== 1 || j.prefix !== this._tL || j.ext !== this._tExt) {
@@ -811,6 +1073,60 @@ class RotateFileStream extends node_stream.Writable {
811
1073
  delete this._audit.files[name];
812
1074
  }
813
1075
  }
1076
+ /**
1077
+ * DISK-DRIVEN seal. Seal raw segments whose period has
1078
+ * already passed (crash near midnight, virtual-clock leftovers,
1079
+ * untracked/pruned files). Iterates the DIRECTORY, not the manifest:
1080
+ * the filesystem is the source of truth; the manifest is an index.
1081
+ */
1082
+ async _sealOrphanedPastStamps() {
1083
+ if (!this.zippedArchive)
1084
+ return;
1085
+ const stampRx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
1086
+ "(?:\\.\\d+)?" + escRe(this._tExt) + "(?:\\.gz)?$");
1087
+ let names = [];
1088
+ try {
1089
+ names = fs.readdirSync(this.dir);
1090
+ }
1091
+ catch {
1092
+ return;
1093
+ }
1094
+ for (const name of names) {
1095
+ if (name.endsWith(".gz"))
1096
+ continue;
1097
+ if (name === (this._path && path.basename(this._path)))
1098
+ continue;
1099
+ const stamp = stampRx.exec(name)?.[1];
1100
+ // lexicographic compare is safe for zero-padded patterns (YYYY-MM-DD…);
1101
+ // also guards the current stamp and anything future-dated
1102
+ if (!stamp || stamp >= this._curStamp)
1103
+ continue;
1104
+ const full = path.join(this.dir, name);
1105
+ if (!existsSafe(full) || statSizeSafe(full) === 0)
1106
+ continue;
1107
+ const gzName = `${full}.gz`;
1108
+ const ok = await gzipMove(full, gzName, (e) => this.emit("warn", e), this._compressionLevel);
1109
+ if (!ok)
1110
+ continue;
1111
+ if (this._auditEnabled && this._audit.files[name]) {
1112
+ // tracked → re-key record name → name.gz (emits archive + journal)
1113
+ this._finishArchivedRecord(name, true);
1114
+ }
1115
+ else {
1116
+ // untracked → record it fresh so future reconciles see truth
1117
+ if (this._auditEnabled) {
1118
+ this._audit.files[gzName] = {
1119
+ state: "archived",
1120
+ gzipPending: false,
1121
+ archivedAt: new Date().toISOString(),
1122
+ };
1123
+ this._aevent("seal-past", { file: gzName });
1124
+ }
1125
+ this.emit("archive", gzName);
1126
+ }
1127
+ await this._runSealHook(full, gzName);
1128
+ }
1129
+ }
814
1130
  async _repairInterruptedGzips() {
815
1131
  for (const [name, rec] of Object.entries(this._audit.files)) {
816
1132
  if (!rec.gzipPending)
@@ -831,11 +1147,13 @@ class RotateFileStream extends node_stream.Writable {
831
1147
  /* ignore */
832
1148
  }
833
1149
  this._finishArchivedRecord(name, true);
1150
+ await this._runSealHook(src, dst);
834
1151
  continue;
835
1152
  }
836
- const ok = await gzipMove(src, dst, (e) => this.emit("warn", e));
1153
+ const ok = await gzipMove(src, dst, (e) => this.emit("warn", e), this._compressionLevel);
837
1154
  if (ok) {
838
1155
  this._finishArchivedRecord(name, true);
1156
+ await this._runSealHook(src, dst);
839
1157
  }
840
1158
  else {
841
1159
  rec.gzipPending = false;
@@ -863,72 +1181,24 @@ class RotateFileStream extends node_stream.Writable {
863
1181
  this.emit("archive", path.join(this.dir, gzName));
864
1182
  this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
865
1183
  }
866
- /**
867
- * Seal raw segments whose period has already passed (crash near midnight,
868
- * virtual-clock leftovers, untracked files). Disk is the source of truth —
869
- * manifest-tracked or not, a raw tail nothing will ever write to again
870
- * must still honour zippedArchive.
871
- */
872
- async _sealOrphanedPastStamps() {
873
- if (!this.zippedArchive)
874
- return;
875
- const stampRx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
876
- "(?:\\.\\d+)?" + escRe(this._tExt) + "(?:\\.gz)?$");
877
- let names = [];
878
- try {
879
- names = fs.readdirSync(this.dir);
880
- }
881
- catch {
882
- return;
883
- }
884
- for (const name of names) {
885
- if (name.endsWith(".gz"))
886
- continue;
887
- if (name === (this._path && path.basename(this._path)))
888
- continue;
889
- const stamp = stampRx.exec(name)?.[1];
890
- // lexicographic compare is safe for zero-padded patterns (YYYY-MM-DD…);
891
- // also guards the current stamp and anything future-dated
892
- if (!stamp || stamp >= this._curStamp)
893
- continue;
894
- const full = path.join(this.dir, name);
895
- if (!existsSafe(full) || statSizeSafe(full) === 0)
896
- continue;
897
- const ok = await gzipMove(full, `${full}.gz`, (e) => this.emit("warn", e));
898
- if (!ok)
899
- continue;
900
- if (this._auditEnabled && this._audit.files[name]) {
901
- // tracked → re-key record name → name.gz (emits archive + journal)
902
- this._finishArchivedRecord(name, true);
903
- }
904
- else {
905
- // untracked → record it fresh so future reconciles see a truthful state
906
- const gzName = `${name}.gz`;
907
- if (this._auditEnabled) {
908
- this._audit.files[gzName] = {
909
- state: "archived",
910
- gzipPending: false,
911
- archivedAt: new Date().toISOString(),
912
- };
913
- this._aevent("seal-past", { file: gzName });
914
- }
915
- this.emit("archive", path.join(this.dir, gzName));
916
- }
917
- }
918
- }
919
1184
  /* ==================================================================
920
1185
  * Retention
921
1186
  * ================================================================ */
922
1187
  async _retire() {
923
1188
  const policy = this.maxFiles;
924
- if (!policy)
925
- return;
1189
+ if (!policy && this.maxTotalSize <= 0)
1190
+ return; // budget gates too
926
1191
  const activeBase = this._path && path.basename(this._path);
927
1192
  const activeStem = activeBase?.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
928
1193
  const reserved = [
929
1194
  path.basename(this._auditPath),
930
1195
  `${path.basename(this._auditPath)}.tmp`,
931
1196
  `${path.basename(this._auditPath)}.corrupt`,
1197
+ // never eat the "current" pointer (or its tmp)
1198
+ ...(this._symlinkPath
1199
+ ? [path.basename(this._symlinkPath),
1200
+ `${path.basename(this._symlinkPath)}.tmp`]
1201
+ : []),
932
1202
  ];
933
1203
  try {
934
1204
  const dirEntries = await fs.promises.readdir(this.dir);
@@ -940,7 +1210,8 @@ class RotateFileStream extends node_stream.Writable {
940
1210
  for (const name of candidates) {
941
1211
  const full = path.join(this.dir, name);
942
1212
  try {
943
- stats.push({ full, mtimeMs: (await fs.promises.stat(full)).mtimeMs });
1213
+ const st = await fs.promises.stat(full);
1214
+ stats.push({ full, mtimeMs: st.mtimeMs, size: st.size }); // size
944
1215
  }
945
1216
  catch {
946
1217
  /* raced deletion */
@@ -948,18 +1219,30 @@ class RotateFileStream extends node_stream.Writable {
948
1219
  }
949
1220
  stats.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
950
1221
  const kill = new Set();
951
- if (policy.days != null) {
1222
+ if (policy?.days != null) {
952
1223
  const cutoff = this._now() - policy.days * 86_400_000;
953
1224
  for (const f of stats)
954
1225
  if (f.mtimeMs < cutoff)
955
1226
  kill.add(f.full);
956
1227
  }
957
- if (policy.count != null) {
1228
+ if (policy?.count != null) {
958
1229
  const survivors = stats.filter((f) => !kill.has(f.full));
959
1230
  const excessCount = survivors.length - policy.count;
960
1231
  for (let i = 0; i < excessCount; i++)
961
1232
  kill.add(survivors[i].full);
962
1233
  }
1234
+ // byte budget — delete OLDEST until under budget.
1235
+ // The active segment is never a candidate, so only archives count.
1236
+ if (this.maxTotalSize > 0) {
1237
+ const survivors = stats.filter((f) => !kill.has(f.full)); // oldest→newest
1238
+ let total = survivors.reduce((sum, f) => sum + f.size, 0);
1239
+ let i = 0;
1240
+ while (total > this.maxTotalSize && i < survivors.length) {
1241
+ kill.add(survivors[i].full);
1242
+ total -= survivors[i].size;
1243
+ i++;
1244
+ }
1245
+ }
963
1246
  if (kill.size === 0)
964
1247
  return;
965
1248
  for (const file of kill) {
@@ -987,5 +1270,67 @@ function createStream(options) {
987
1270
  return new RotateFileStream(options);
988
1271
  }
989
1272
 
1273
+ const registry = new Set();
1274
+ let installed = false;
1275
+ let inProgress = false;
1276
+ /**
1277
+ * Register streams for graceful shutdown: on SIGINT/SIGTERM every
1278
+ * registered stream is ended — flushing buffered writes and finishing the
1279
+ * audit manifest — before the process exits, with a hard deadline.
1280
+ *
1281
+ * Safe to call multiple times: process listeners install once, streams
1282
+ * accumulate in the registry.
1283
+ *
1284
+ * Returns `flush()`, the same graceful end WITHOUT exiting — for tests
1285
+ * and custom signal handling.
1286
+ *
1287
+ * @example
1288
+ * const flush = installShutdown(logStream);
1289
+ * process.on("exit", () => { void flush(); });
1290
+ */
1291
+ function installShutdown(streams, opts = {}) {
1292
+ const list = Array.isArray(streams) ? streams : [streams];
1293
+ for (const s of list)
1294
+ registry.add(s);
1295
+ if (!installed) {
1296
+ installed = true;
1297
+ const timeoutMs = opts.timeoutMs ?? 5_000;
1298
+ const code = opts.code ?? 0;
1299
+ for (const sig of opts.signals ?? ["SIGINT", "SIGTERM"]) {
1300
+ process.on(sig, () => {
1301
+ void flushAll(timeoutMs).finally(() => process.exit(code));
1302
+ });
1303
+ }
1304
+ }
1305
+ return async () => {
1306
+ // flush FIRST — flushAll() drains the registry, so unregistering
1307
+ // before flushing would flush nothing (caught by the shutdown test)
1308
+ await flushAll(opts.timeoutMs ?? 5_000);
1309
+ for (const s of list)
1310
+ registry.delete(s);
1311
+ };
1312
+ }
1313
+ /**
1314
+ * End every registered stream (flush buffered writes) with a deadline.
1315
+ * Does NOT exit the process. Double-ending an already-ended stream is
1316
+ * harmless — the error lands in the ignored callback.
1317
+ */
1318
+ async function flushAll(timeoutMs = 5_000) {
1319
+ if (inProgress)
1320
+ return;
1321
+ inProgress = true;
1322
+ const deadline = setTimeout(() => process.exit(1), timeoutMs);
1323
+ deadline.unref();
1324
+ try {
1325
+ await Promise.allSettled([...registry].map((s) => new Promise((res) => s.end(() => res()))));
1326
+ }
1327
+ finally {
1328
+ clearTimeout(deadline);
1329
+ inProgress = false;
1330
+ }
1331
+ }
1332
+
990
1333
  exports.RotateFileStream = RotateFileStream;
991
1334
  exports.createStream = createStream;
1335
+ exports.flushAll = flushAll;
1336
+ exports.installShutdown = installShutdown;