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