logroller 2.0.1 → 2.0.2

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/CHANGELOG.md CHANGED
@@ -5,7 +5,39 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [2.0.1] — 2026-09-0X
8
+ ## [2.0.2] — 2026-08-28
9
+
10
+ ### Fixed
11
+
12
+ - **Corrupt audit manifest quarantine**: an unparseable `<stem>_audit.json`
13
+ (partial write, bad edit) is now renamed to `.corrupt` and preserved,
14
+ matching existing behaviour for version/family mismatches. Previously it
15
+ was silently overwritten by the first manifest save, destroying the
16
+ event journal.
17
+ - **Backward-clock safety**: a clock moving backward (NTP correction,
18
+ manual change) no longer reopens a past log family or forks the segment
19
+ series. The stream keeps the current segment, warns once, and resumes
20
+ normal rotation when the clock catches up.
21
+ - **Timer anti-spin**: with the rotation boundary in the past (backward
22
+ clock), the timer now polls at 1 s instead of firing every 100 ms,
23
+ which previously could keep processes from exiting cleanly.
24
+
25
+ ### Added
26
+
27
+ - **Construction-time pattern warning**: warns when `frequency` is finer
28
+ than the `datePattern` tokens allow (`daily`→`DD`, sub-daily→`HH`,
29
+ sub-minute→`mm`), preventing silent filename collisions where many
30
+ rotations overwrite a single file.
31
+
32
+ ### Tests
33
+
34
+ - Suite expanded to 23 tests: interrupted-gzip repair, manifest divergence
35
+ healing (`archive-regressed`, `vanished`), manifest quarantine,
36
+ concurrent-writer detection, IST-midnight boundary, idle `period` slide,
37
+ `rotateNow()`, over-max resume, backward-clock guard, and coarse-pattern
38
+ warning.
39
+
40
+ ## [2.0.1] — 2026-08-27
9
41
 
10
42
  ### Fixed
11
43
 
@@ -42,7 +74,7 @@ TypeScript consumers gain full type coverage.
42
74
  - Modular source layout (`src/types.ts`, `parsers.ts`, `time.ts`, `fsio.ts`,
43
75
  `rotate-file-stream.ts`) with `.js` extension imports so emitted
44
76
  declarations resolve under `nodenext` consumers.
45
- - Developer toolchain: Prettier, `npm run verify` gate (typecheck + build + tests).
77
+ - Developer toolchain: Prettier, `npm run verify` gate (typecheck + build + tests).
46
78
  - GitHub Actions: CI matrix on Node 18/20/22, release-triggered publish with
47
79
  npm provenance (`id-token: write`, `--provenance`).
48
80
  - Expanded README: comparison table, FAQ, restart-resume matrix, pino recipe.
package/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! logroller v2.0.0 | MIT License */
1
+ /*! logroller v2.0.1 | MIT License */
2
2
  'use strict';
3
3
 
4
4
  var node_crypto = require('node:crypto');
@@ -52,12 +52,21 @@ const UNITS = {
52
52
  const FREQ_MULT = {
53
53
  ms: 1, s: 1000, m: 60_000, h: 3_600_000,
54
54
  };
55
+ function parseDuration(value, defaultUnit, fieldName) {
56
+ if (typeof value === "number") {
57
+ return Math.round(value * FREQ_MULT[defaultUnit]);
58
+ }
59
+ const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(value);
60
+ if (!m)
61
+ throw new Error(`Invalid ${fieldName} "${value}"`);
62
+ return Math.round(Number.parseFloat(m[1]) * FREQ_MULT[(m[2] ?? defaultUnit).toLowerCase()]);
63
+ }
55
64
  function parseSize(v) {
56
65
  if (v === undefined || v === null || v === 0)
57
66
  return 0;
58
67
  if (typeof v === "number")
59
68
  return v;
60
- const m = /^\s*(\d+(?:\.\d+)?)\s*(b|k|kb|m|mb|g|gb)?\s*$/i.exec(v);
69
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|k|mb|m|gb|g)?\s*$/i.exec(v);
61
70
  if (!m)
62
71
  throw new Error(`Invalid maxSize "${v}"`);
63
72
  return Math.round(Number.parseFloat(m[1]) * (UNITS[m[2]?.toLowerCase()] ?? 1));
@@ -81,7 +90,7 @@ function parseFrequency(v) {
81
90
  if (v === "daily")
82
91
  return { type: "daily" };
83
92
  if (typeof v === "number") {
84
- if (!(v > 0))
93
+ if (v <= 0)
85
94
  throw new Error(`Invalid frequency "${v}"`);
86
95
  return { type: "interval", ms: v };
87
96
  }
@@ -93,22 +102,12 @@ function parseFrequency(v) {
93
102
  function parseClockOffset(v) {
94
103
  if (v === undefined || v === null || v === "")
95
104
  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);
105
+ return parseDuration(v, "h", "addHours");
102
106
  }
103
107
  function parseClockStep(v) {
104
- if (v === undefined || v === null || v === "" || v === 0)
108
+ if (v === undefined || v === null || v === "")
105
109
  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()]);
110
+ return parseDuration(v, "h", "addHours");
112
111
  }
113
112
 
114
113
  /* Timezone-aware helpers built exclusively on Intl (zero deps). */
@@ -145,11 +144,30 @@ function partsIn(tz, date) {
145
144
  const pad2 = (n) => n.padStart(2, "0");
146
145
  /** Escape a literal string for embedding inside a RegExp. */
147
146
  function escRe(s) {
148
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
147
+ return s.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
149
148
  }
150
149
  /** Substitute YYYY|YY|MM|DD|HH|mm|ss tokens in `pattern`. */
151
150
  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);
151
+ return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (token) => {
152
+ switch (token) {
153
+ case "YYYY":
154
+ return parts.year;
155
+ case "YY":
156
+ return parts.year.slice(-2);
157
+ case "MM":
158
+ return pad2(parts.month);
159
+ case "DD":
160
+ return pad2(parts.day);
161
+ case "HH":
162
+ return pad2(parts.hour);
163
+ case "mm":
164
+ return pad2(parts.minute);
165
+ case "ss":
166
+ return pad2(parts.second);
167
+ default:
168
+ return token;
169
+ }
170
+ });
153
171
  }
154
172
  /** Offset of `tz` vs UTC at instant `date`, milliseconds. */
155
173
  function tzOffsetMs(tz, date) {
@@ -179,6 +197,7 @@ const BUMP_DEFAULT_MS = 60_000;
179
197
  const AUDIT_MAX_FILES = 600;
180
198
  const AUDIT_MAX_EVENTS = 200;
181
199
  const HEARTBEAT_STALE_MS = 30_000;
200
+ const TIMER_POLL_MS = 1_000;
182
201
  const emptyAudit = (prefix, ext, datePattern, tz) => ({
183
202
  version: 1,
184
203
  prefix,
@@ -244,6 +263,7 @@ class RotateFileStream extends node_stream.Writable {
244
263
  _bumpTimer = null;
245
264
  _ended = false;
246
265
  _tail = Promise.resolve();
266
+ _warnedBackward = false;
247
267
  _audit;
248
268
  constructor(opts = {}) {
249
269
  super(opts.highWaterMark ? { highWaterMark: opts.highWaterMark } : {});
@@ -296,14 +316,29 @@ class RotateFileStream extends node_stream.Writable {
296
316
  offsetMs: this._offsetMs,
297
317
  }));
298
318
  }
319
+ process.nextTick(() => {
320
+ const warn = (m) => this.emit("warn", new Error(m));
321
+ if (this.freq.type === "interval") {
322
+ if (this.freq.ms < 86_400_000 && !this.datePattern.includes("HH")) {
323
+ warn(`frequency "${opts.frequency}" needs HH in datePattern — ` +
324
+ "segments would collapse onto one filename");
325
+ }
326
+ if (this.freq.ms < 60_000 && !this.datePattern.includes("mm")) {
327
+ warn(`frequency "${opts.frequency}" needs mm in datePattern`);
328
+ }
329
+ }
330
+ else if (!this.datePattern.includes("DD")) {
331
+ warn("daily frequency needs DD in datePattern");
332
+ }
333
+ });
299
334
  /* Boot housekeeping is job #1 of the serial queue — chained HERE in the
300
335
  constructor so it deterministically precedes any write job: zombie
301
336
  demotion sees `_path === null`, gzip repair cannot race a reopen. */
302
337
  this._job(async () => {
338
+ await this._sealOrphanedPastStamps();
303
339
  if (this._auditEnabled) {
304
340
  this._demoteZombieActives();
305
341
  this._reconcileWithDisk();
306
- await this._sealOrphanedPastStamps();
307
342
  await this._repairInterruptedGzips();
308
343
  this._aevent("boot", { stamp: this._curStamp, index: this._index });
309
344
  this._saveAudit();
@@ -338,9 +373,7 @@ class RotateFileStream extends node_stream.Writable {
338
373
  get currentIndex() {
339
374
  return this._index;
340
375
  }
341
- /**
342
- * Force an immediate rotation and resolve when the next slot is staged.
343
- */
376
+ /** Force an immediate rotation and resolve when the next slot is staged. */
344
377
  rotateNow() {
345
378
  return this._job(() => this._rotate("manual"));
346
379
  }
@@ -363,8 +396,8 @@ class RotateFileStream extends node_stream.Writable {
363
396
  const len = chunk.length;
364
397
  this._job(async () => {
365
398
  try {
366
- const stamp = this._stampNow();
367
- if (stamp !== this._curStamp)
399
+ // guarded roll (never rotates into a regressed stamp)
400
+ if (this._stampAdvanced(this._stampNow()))
368
401
  await this._rotate("time");
369
402
  await this._ensureOpen();
370
403
  if (this.maxSize > 0 &&
@@ -435,6 +468,22 @@ class RotateFileStream extends node_stream.Writable {
435
468
  ? base.replace(/\.[^.]+$/, `.${index}$&`)
436
469
  : `${base}.${index}`;
437
470
  }
471
+ /**
472
+ * true only when the period legally advanced. A regressed
473
+ * clock (NTP correction, manual change) never reopens a past family:
474
+ * writes continue in the current segment until real time catches up.
475
+ * Requires zero-padded datePattern tokens (documented assumption).
476
+ */
477
+ _stampAdvanced(stamp) {
478
+ if (stamp > this._curStamp)
479
+ return true;
480
+ if (stamp < this._curStamp && !this._warnedBackward) {
481
+ this._warnedBackward = true;
482
+ this.emit("warn", new Error(`clock moved backward (stamp ${stamp} < ${this._curStamp}); ` +
483
+ "keeping current segment until the clock catches up"));
484
+ }
485
+ return false;
486
+ }
438
487
  /**
439
488
  * Highest viable segment index for `stamp`.
440
489
  * Disk is truth: resume a raw tail; start after sealed ones.
@@ -479,6 +528,9 @@ class RotateFileStream extends node_stream.Writable {
479
528
  this._path = p;
480
529
  if (this._auditEnabled) {
481
530
  const base = path.basename(p);
531
+ // reopen = NEW incarnation. Never inherit lifecycle
532
+ // fields from the previous record (observed in the wild as
533
+ // abandonedAt < openedAt on reactivated segments).
482
534
  this._audit.files[base] = {
483
535
  index: this._index,
484
536
  state: "active",
@@ -530,10 +582,11 @@ class RotateFileStream extends node_stream.Writable {
530
582
  }
531
583
  }
532
584
  else if (realBytes === 0) {
585
+ // a concurrent writer may have archived this segment
586
+ // between our last stat and now — adopt its artifact instead of
587
+ // recording a false `drop-empty` that orphans the .gz.
533
588
  const adoptedGz = `${oldPath}.gz`;
534
589
  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
590
  this._finishArchivedRecord(oldBase);
538
591
  archive = adoptedGz;
539
592
  }
@@ -541,38 +594,54 @@ class RotateFileStream extends node_stream.Writable {
541
594
  try {
542
595
  fs.unlinkSync(oldPath);
543
596
  }
544
- catch { /* already gone */ }
597
+ catch {
598
+ /* already gone */
599
+ }
545
600
  if (rec)
546
601
  delete this._audit.files[oldBase];
547
602
  this._aevent("drop-empty", { file: oldBase });
548
603
  }
549
604
  }
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();
605
+ else if (rec) {
606
+ rec.state = "sealed";
607
+ rec.bytes = realBytes;
563
608
  }
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
- });
609
+ }
610
+ let stamp = this._stampNow();
611
+ // a rotation itself must never move the series backward
612
+ // (covers rotateNow()/interval/size paths the upstream guards miss)
613
+ if (stamp < this._curStamp) {
614
+ if (!this._warnedBackward) {
615
+ this._warnedBackward = true;
616
+ this.emit("warn", new Error(`clock moved backward (stamp ${stamp} < ${this._curStamp}); ` +
617
+ "rotation clamped to the current period"));
573
618
  }
574
- this._retire();
619
+ stamp = this._curStamp;
620
+ }
621
+ const newPeriod = stamp !== this._curStamp;
622
+ this._curStamp = stamp;
623
+ /* Never blind-reset to 0 — recover what THIS stamp already owns. */
624
+ this._index =
625
+ newPeriod || kind === "time"
626
+ ? this._recoverIndexFor(stamp)
627
+ : this._index + 1;
628
+ this._bytes = 0;
629
+ if (this._auditEnabled) {
630
+ this._setActivePointer(this._index);
631
+ this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
632
+ this._saveAudit();
633
+ }
634
+ if (kind === "time" || kind === "manual")
635
+ this._armTimer();
636
+ if (oldPath) {
637
+ this.emit("rotated", {
638
+ reason: kind,
639
+ oldFile: oldPath,
640
+ archive,
641
+ newFile: this._resolveName(stamp, this._index),
642
+ });
575
643
  }
644
+ this._retire();
576
645
  }
577
646
  /* ==================================================================
578
647
  * Timers
@@ -590,16 +659,26 @@ class RotateFileStream extends node_stream.Writable {
590
659
  const next = this.freq.type === "daily"
591
660
  ? nextMidnight(this.tz, now)
592
661
  : nextAligned(now, this.freq.ms);
662
+ // anti-spin. A backward clock can put the computed
663
+ // boundary in the past; poll gently instead of firing every 100 ms.
664
+ const delta = next - this._now();
665
+ const delay = delta < 0 ? TIMER_POLL_MS : Math.max(100, delta);
593
666
  this._timer = setTimeout(() => {
594
667
  this._job(async () => {
595
668
  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) {
669
+ if (this.freq.type !== "daily") {
670
+ /* interval rotation is schedule-based; runs regardless of
671
+ stamp movement */
672
+ if (this._hasContent())
673
+ await this._rotate("time");
674
+ }
675
+ else if (this._stampAdvanced(stamp)) {
676
+ // guarded daily branch
599
677
  if (this._hasContent()) {
600
678
  await this._rotate("time");
601
679
  }
602
- else if (stamp !== this._curStamp) {
680
+ else {
681
+ /* idle boundary slide — no file created for empty days */
603
682
  this._curStamp = stamp;
604
683
  this._index = this._recoverIndexFor(stamp);
605
684
  if (this._auditEnabled) {
@@ -610,9 +689,10 @@ class RotateFileStream extends node_stream.Writable {
610
689
  this.emit("period", stamp);
611
690
  }
612
691
  }
692
+ /* stamp regressed or unchanged → keep current segment, re-arm */
613
693
  this._armTimer();
614
694
  }).catch(() => { });
615
- }, Math.max(100, next - this._now()));
695
+ }, delay);
616
696
  if (this.unrefTimers)
617
697
  this._timer.unref();
618
698
  }
@@ -636,7 +716,8 @@ class RotateFileStream extends node_stream.Writable {
636
716
  this._offsetMs += this._stepMs;
637
717
  this._armTimer();
638
718
  const stamp = this._stampNow();
639
- if (stamp !== this._curStamp) {
719
+ // guarded crossing (virtual clock can't roll backward either)
720
+ if (this._stampAdvanced(stamp)) {
640
721
  if (this._hasContent()) {
641
722
  await this._rotate("time");
642
723
  }
@@ -674,7 +755,15 @@ class RotateFileStream extends node_stream.Writable {
674
755
  parsed = JSON.parse(fs.readFileSync(this._auditPath, "utf8"));
675
756
  }
676
757
  catch {
677
- return false; // missing/unreadable → fresh start
758
+ // unparseable manifest is quarantined too (was silently
759
+ // clobbered by the first save — caught by the quarantine test)
760
+ try {
761
+ fs.renameSync(this._auditPath, `${this._auditPath}.corrupt`);
762
+ }
763
+ catch {
764
+ /* ignore */
765
+ }
766
+ return false;
678
767
  }
679
768
  const j = parsed;
680
769
  if (!j || j.version !== 1 || j.prefix !== this._tL || j.ext !== this._tExt) {
@@ -811,6 +900,59 @@ class RotateFileStream extends node_stream.Writable {
811
900
  delete this._audit.files[name];
812
901
  }
813
902
  }
903
+ /**
904
+ * DISK-DRIVEN seal. Seal raw segments whose period has
905
+ * already passed (crash near midnight, virtual-clock leftovers,
906
+ * untracked/pruned files). Iterates the DIRECTORY, not the manifest:
907
+ * the filesystem is the source of truth; the manifest is an index.
908
+ */
909
+ async _sealOrphanedPastStamps() {
910
+ if (!this.zippedArchive)
911
+ return;
912
+ const stampRx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
913
+ "(?:\\.\\d+)?" + escRe(this._tExt) + "(?:\\.gz)?$");
914
+ let names = [];
915
+ try {
916
+ names = fs.readdirSync(this.dir);
917
+ }
918
+ catch {
919
+ return;
920
+ }
921
+ for (const name of names) {
922
+ if (name.endsWith(".gz"))
923
+ continue;
924
+ if (name === (this._path && path.basename(this._path)))
925
+ continue;
926
+ const stamp = stampRx.exec(name)?.[1];
927
+ // lexicographic compare is safe for zero-padded patterns (YYYY-MM-DD…);
928
+ // also guards the current stamp and anything future-dated
929
+ if (!stamp || stamp >= this._curStamp)
930
+ continue;
931
+ const full = path.join(this.dir, name);
932
+ if (!existsSafe(full) || statSizeSafe(full) === 0)
933
+ continue;
934
+ const ok = await gzipMove(full, `${full}.gz`, (e) => this.emit("warn", e));
935
+ if (!ok)
936
+ continue;
937
+ if (this._auditEnabled && this._audit.files[name]) {
938
+ // tracked → re-key record name → name.gz (emits archive + journal)
939
+ this._finishArchivedRecord(name, true);
940
+ }
941
+ else {
942
+ // untracked → record it fresh so future reconciles see truth
943
+ const gzName = `${name}.gz`;
944
+ if (this._auditEnabled) {
945
+ this._audit.files[gzName] = {
946
+ state: "archived",
947
+ gzipPending: false,
948
+ archivedAt: new Date().toISOString(),
949
+ };
950
+ this._aevent("seal-past", { file: gzName });
951
+ }
952
+ this.emit("archive", path.join(this.dir, gzName));
953
+ }
954
+ }
955
+ }
814
956
  async _repairInterruptedGzips() {
815
957
  for (const [name, rec] of Object.entries(this._audit.files)) {
816
958
  if (!rec.gzipPending)
@@ -863,59 +1005,6 @@ class RotateFileStream extends node_stream.Writable {
863
1005
  this.emit("archive", path.join(this.dir, gzName));
864
1006
  this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
865
1007
  }
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
1008
  /* ==================================================================
920
1009
  * Retention
921
1010
  * ================================================================ */
package/dist/index.esm.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! logroller v2.0.0 | MIT License */
1
+ /*! logroller v2.0.1 | MIT License */
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
@@ -50,12 +50,21 @@ const UNITS = {
50
50
  const FREQ_MULT = {
51
51
  ms: 1, s: 1000, m: 60_000, h: 3_600_000,
52
52
  };
53
+ function parseDuration(value, defaultUnit, fieldName) {
54
+ if (typeof value === "number") {
55
+ return Math.round(value * FREQ_MULT[defaultUnit]);
56
+ }
57
+ const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(value);
58
+ if (!m)
59
+ throw new Error(`Invalid ${fieldName} "${value}"`);
60
+ return Math.round(Number.parseFloat(m[1]) * FREQ_MULT[(m[2] ?? defaultUnit).toLowerCase()]);
61
+ }
53
62
  function parseSize(v) {
54
63
  if (v === undefined || v === null || v === 0)
55
64
  return 0;
56
65
  if (typeof v === "number")
57
66
  return v;
58
- const m = /^\s*(\d+(?:\.\d+)?)\s*(b|k|kb|m|mb|g|gb)?\s*$/i.exec(v);
67
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|k|mb|m|gb|g)?\s*$/i.exec(v);
59
68
  if (!m)
60
69
  throw new Error(`Invalid maxSize "${v}"`);
61
70
  return Math.round(Number.parseFloat(m[1]) * (UNITS[m[2]?.toLowerCase()] ?? 1));
@@ -79,7 +88,7 @@ function parseFrequency(v) {
79
88
  if (v === "daily")
80
89
  return { type: "daily" };
81
90
  if (typeof v === "number") {
82
- if (!(v > 0))
91
+ if (v <= 0)
83
92
  throw new Error(`Invalid frequency "${v}"`);
84
93
  return { type: "interval", ms: v };
85
94
  }
@@ -91,22 +100,12 @@ function parseFrequency(v) {
91
100
  function parseClockOffset(v) {
92
101
  if (v === undefined || v === null || v === "")
93
102
  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);
103
+ return parseDuration(v, "h", "addHours");
100
104
  }
101
105
  function parseClockStep(v) {
102
- if (v === undefined || v === null || v === "" || v === 0)
106
+ if (v === undefined || v === null || v === "")
103
107
  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()]);
108
+ return parseDuration(v, "h", "addHours");
110
109
  }
111
110
 
112
111
  /* Timezone-aware helpers built exclusively on Intl (zero deps). */
@@ -143,11 +142,30 @@ function partsIn(tz, date) {
143
142
  const pad2 = (n) => n.padStart(2, "0");
144
143
  /** Escape a literal string for embedding inside a RegExp. */
145
144
  function escRe(s) {
146
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
145
+ return s.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
147
146
  }
148
147
  /** Substitute YYYY|YY|MM|DD|HH|mm|ss tokens in `pattern`. */
149
148
  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);
149
+ return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (token) => {
150
+ switch (token) {
151
+ case "YYYY":
152
+ return parts.year;
153
+ case "YY":
154
+ return parts.year.slice(-2);
155
+ case "MM":
156
+ return pad2(parts.month);
157
+ case "DD":
158
+ return pad2(parts.day);
159
+ case "HH":
160
+ return pad2(parts.hour);
161
+ case "mm":
162
+ return pad2(parts.minute);
163
+ case "ss":
164
+ return pad2(parts.second);
165
+ default:
166
+ return token;
167
+ }
168
+ });
151
169
  }
152
170
  /** Offset of `tz` vs UTC at instant `date`, milliseconds. */
153
171
  function tzOffsetMs(tz, date) {
@@ -177,6 +195,7 @@ const BUMP_DEFAULT_MS = 60_000;
177
195
  const AUDIT_MAX_FILES = 600;
178
196
  const AUDIT_MAX_EVENTS = 200;
179
197
  const HEARTBEAT_STALE_MS = 30_000;
198
+ const TIMER_POLL_MS = 1_000;
180
199
  const emptyAudit = (prefix, ext, datePattern, tz) => ({
181
200
  version: 1,
182
201
  prefix,
@@ -242,6 +261,7 @@ class RotateFileStream extends Writable {
242
261
  _bumpTimer = null;
243
262
  _ended = false;
244
263
  _tail = Promise.resolve();
264
+ _warnedBackward = false;
245
265
  _audit;
246
266
  constructor(opts = {}) {
247
267
  super(opts.highWaterMark ? { highWaterMark: opts.highWaterMark } : {});
@@ -294,14 +314,29 @@ class RotateFileStream extends Writable {
294
314
  offsetMs: this._offsetMs,
295
315
  }));
296
316
  }
317
+ process.nextTick(() => {
318
+ const warn = (m) => this.emit("warn", new Error(m));
319
+ if (this.freq.type === "interval") {
320
+ if (this.freq.ms < 86_400_000 && !this.datePattern.includes("HH")) {
321
+ warn(`frequency "${opts.frequency}" needs HH in datePattern — ` +
322
+ "segments would collapse onto one filename");
323
+ }
324
+ if (this.freq.ms < 60_000 && !this.datePattern.includes("mm")) {
325
+ warn(`frequency "${opts.frequency}" needs mm in datePattern`);
326
+ }
327
+ }
328
+ else if (!this.datePattern.includes("DD")) {
329
+ warn("daily frequency needs DD in datePattern");
330
+ }
331
+ });
297
332
  /* Boot housekeeping is job #1 of the serial queue — chained HERE in the
298
333
  constructor so it deterministically precedes any write job: zombie
299
334
  demotion sees `_path === null`, gzip repair cannot race a reopen. */
300
335
  this._job(async () => {
336
+ await this._sealOrphanedPastStamps();
301
337
  if (this._auditEnabled) {
302
338
  this._demoteZombieActives();
303
339
  this._reconcileWithDisk();
304
- await this._sealOrphanedPastStamps();
305
340
  await this._repairInterruptedGzips();
306
341
  this._aevent("boot", { stamp: this._curStamp, index: this._index });
307
342
  this._saveAudit();
@@ -336,9 +371,7 @@ class RotateFileStream extends Writable {
336
371
  get currentIndex() {
337
372
  return this._index;
338
373
  }
339
- /**
340
- * Force an immediate rotation and resolve when the next slot is staged.
341
- */
374
+ /** Force an immediate rotation and resolve when the next slot is staged. */
342
375
  rotateNow() {
343
376
  return this._job(() => this._rotate("manual"));
344
377
  }
@@ -361,8 +394,8 @@ class RotateFileStream extends Writable {
361
394
  const len = chunk.length;
362
395
  this._job(async () => {
363
396
  try {
364
- const stamp = this._stampNow();
365
- if (stamp !== this._curStamp)
397
+ // guarded roll (never rotates into a regressed stamp)
398
+ if (this._stampAdvanced(this._stampNow()))
366
399
  await this._rotate("time");
367
400
  await this._ensureOpen();
368
401
  if (this.maxSize > 0 &&
@@ -433,6 +466,22 @@ class RotateFileStream extends Writable {
433
466
  ? base.replace(/\.[^.]+$/, `.${index}$&`)
434
467
  : `${base}.${index}`;
435
468
  }
469
+ /**
470
+ * true only when the period legally advanced. A regressed
471
+ * clock (NTP correction, manual change) never reopens a past family:
472
+ * writes continue in the current segment until real time catches up.
473
+ * Requires zero-padded datePattern tokens (documented assumption).
474
+ */
475
+ _stampAdvanced(stamp) {
476
+ if (stamp > this._curStamp)
477
+ return true;
478
+ if (stamp < this._curStamp && !this._warnedBackward) {
479
+ this._warnedBackward = true;
480
+ this.emit("warn", new Error(`clock moved backward (stamp ${stamp} < ${this._curStamp}); ` +
481
+ "keeping current segment until the clock catches up"));
482
+ }
483
+ return false;
484
+ }
436
485
  /**
437
486
  * Highest viable segment index for `stamp`.
438
487
  * Disk is truth: resume a raw tail; start after sealed ones.
@@ -477,6 +526,9 @@ class RotateFileStream extends Writable {
477
526
  this._path = p;
478
527
  if (this._auditEnabled) {
479
528
  const base = path.basename(p);
529
+ // reopen = NEW incarnation. Never inherit lifecycle
530
+ // fields from the previous record (observed in the wild as
531
+ // abandonedAt < openedAt on reactivated segments).
480
532
  this._audit.files[base] = {
481
533
  index: this._index,
482
534
  state: "active",
@@ -528,10 +580,11 @@ class RotateFileStream extends Writable {
528
580
  }
529
581
  }
530
582
  else if (realBytes === 0) {
583
+ // a concurrent writer may have archived this segment
584
+ // between our last stat and now — adopt its artifact instead of
585
+ // recording a false `drop-empty` that orphans the .gz.
531
586
  const adoptedGz = `${oldPath}.gz`;
532
587
  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
588
  this._finishArchivedRecord(oldBase);
536
589
  archive = adoptedGz;
537
590
  }
@@ -539,38 +592,54 @@ class RotateFileStream extends Writable {
539
592
  try {
540
593
  fs.unlinkSync(oldPath);
541
594
  }
542
- catch { /* already gone */ }
595
+ catch {
596
+ /* already gone */
597
+ }
543
598
  if (rec)
544
599
  delete this._audit.files[oldBase];
545
600
  this._aevent("drop-empty", { file: oldBase });
546
601
  }
547
602
  }
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();
603
+ else if (rec) {
604
+ rec.state = "sealed";
605
+ rec.bytes = realBytes;
561
606
  }
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
- });
607
+ }
608
+ let stamp = this._stampNow();
609
+ // a rotation itself must never move the series backward
610
+ // (covers rotateNow()/interval/size paths the upstream guards miss)
611
+ if (stamp < this._curStamp) {
612
+ if (!this._warnedBackward) {
613
+ this._warnedBackward = true;
614
+ this.emit("warn", new Error(`clock moved backward (stamp ${stamp} < ${this._curStamp}); ` +
615
+ "rotation clamped to the current period"));
571
616
  }
572
- this._retire();
617
+ stamp = this._curStamp;
618
+ }
619
+ const newPeriod = stamp !== this._curStamp;
620
+ this._curStamp = stamp;
621
+ /* Never blind-reset to 0 — recover what THIS stamp already owns. */
622
+ this._index =
623
+ newPeriod || kind === "time"
624
+ ? this._recoverIndexFor(stamp)
625
+ : this._index + 1;
626
+ this._bytes = 0;
627
+ if (this._auditEnabled) {
628
+ this._setActivePointer(this._index);
629
+ this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
630
+ this._saveAudit();
631
+ }
632
+ if (kind === "time" || kind === "manual")
633
+ this._armTimer();
634
+ if (oldPath) {
635
+ this.emit("rotated", {
636
+ reason: kind,
637
+ oldFile: oldPath,
638
+ archive,
639
+ newFile: this._resolveName(stamp, this._index),
640
+ });
573
641
  }
642
+ this._retire();
574
643
  }
575
644
  /* ==================================================================
576
645
  * Timers
@@ -588,16 +657,26 @@ class RotateFileStream extends Writable {
588
657
  const next = this.freq.type === "daily"
589
658
  ? nextMidnight(this.tz, now)
590
659
  : nextAligned(now, this.freq.ms);
660
+ // anti-spin. A backward clock can put the computed
661
+ // boundary in the past; poll gently instead of firing every 100 ms.
662
+ const delta = next - this._now();
663
+ const delay = delta < 0 ? TIMER_POLL_MS : Math.max(100, delta);
591
664
  this._timer = setTimeout(() => {
592
665
  this._job(async () => {
593
666
  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) {
667
+ if (this.freq.type !== "daily") {
668
+ /* interval rotation is schedule-based; runs regardless of
669
+ stamp movement */
670
+ if (this._hasContent())
671
+ await this._rotate("time");
672
+ }
673
+ else if (this._stampAdvanced(stamp)) {
674
+ // guarded daily branch
597
675
  if (this._hasContent()) {
598
676
  await this._rotate("time");
599
677
  }
600
- else if (stamp !== this._curStamp) {
678
+ else {
679
+ /* idle boundary slide — no file created for empty days */
601
680
  this._curStamp = stamp;
602
681
  this._index = this._recoverIndexFor(stamp);
603
682
  if (this._auditEnabled) {
@@ -608,9 +687,10 @@ class RotateFileStream extends Writable {
608
687
  this.emit("period", stamp);
609
688
  }
610
689
  }
690
+ /* stamp regressed or unchanged → keep current segment, re-arm */
611
691
  this._armTimer();
612
692
  }).catch(() => { });
613
- }, Math.max(100, next - this._now()));
693
+ }, delay);
614
694
  if (this.unrefTimers)
615
695
  this._timer.unref();
616
696
  }
@@ -634,7 +714,8 @@ class RotateFileStream extends Writable {
634
714
  this._offsetMs += this._stepMs;
635
715
  this._armTimer();
636
716
  const stamp = this._stampNow();
637
- if (stamp !== this._curStamp) {
717
+ // guarded crossing (virtual clock can't roll backward either)
718
+ if (this._stampAdvanced(stamp)) {
638
719
  if (this._hasContent()) {
639
720
  await this._rotate("time");
640
721
  }
@@ -672,7 +753,15 @@ class RotateFileStream extends Writable {
672
753
  parsed = JSON.parse(fs.readFileSync(this._auditPath, "utf8"));
673
754
  }
674
755
  catch {
675
- return false; // missing/unreadable → fresh start
756
+ // unparseable manifest is quarantined too (was silently
757
+ // clobbered by the first save — caught by the quarantine test)
758
+ try {
759
+ fs.renameSync(this._auditPath, `${this._auditPath}.corrupt`);
760
+ }
761
+ catch {
762
+ /* ignore */
763
+ }
764
+ return false;
676
765
  }
677
766
  const j = parsed;
678
767
  if (!j || j.version !== 1 || j.prefix !== this._tL || j.ext !== this._tExt) {
@@ -809,6 +898,59 @@ class RotateFileStream extends Writable {
809
898
  delete this._audit.files[name];
810
899
  }
811
900
  }
901
+ /**
902
+ * DISK-DRIVEN seal. Seal raw segments whose period has
903
+ * already passed (crash near midnight, virtual-clock leftovers,
904
+ * untracked/pruned files). Iterates the DIRECTORY, not the manifest:
905
+ * the filesystem is the source of truth; the manifest is an index.
906
+ */
907
+ async _sealOrphanedPastStamps() {
908
+ if (!this.zippedArchive)
909
+ return;
910
+ const stampRx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
911
+ "(?:\\.\\d+)?" + escRe(this._tExt) + "(?:\\.gz)?$");
912
+ let names = [];
913
+ try {
914
+ names = fs.readdirSync(this.dir);
915
+ }
916
+ catch {
917
+ return;
918
+ }
919
+ for (const name of names) {
920
+ if (name.endsWith(".gz"))
921
+ continue;
922
+ if (name === (this._path && path.basename(this._path)))
923
+ continue;
924
+ const stamp = stampRx.exec(name)?.[1];
925
+ // lexicographic compare is safe for zero-padded patterns (YYYY-MM-DD…);
926
+ // also guards the current stamp and anything future-dated
927
+ if (!stamp || stamp >= this._curStamp)
928
+ continue;
929
+ const full = path.join(this.dir, name);
930
+ if (!existsSafe(full) || statSizeSafe(full) === 0)
931
+ continue;
932
+ const ok = await gzipMove(full, `${full}.gz`, (e) => this.emit("warn", e));
933
+ if (!ok)
934
+ continue;
935
+ if (this._auditEnabled && this._audit.files[name]) {
936
+ // tracked → re-key record name → name.gz (emits archive + journal)
937
+ this._finishArchivedRecord(name, true);
938
+ }
939
+ else {
940
+ // untracked → record it fresh so future reconciles see truth
941
+ const gzName = `${name}.gz`;
942
+ if (this._auditEnabled) {
943
+ this._audit.files[gzName] = {
944
+ state: "archived",
945
+ gzipPending: false,
946
+ archivedAt: new Date().toISOString(),
947
+ };
948
+ this._aevent("seal-past", { file: gzName });
949
+ }
950
+ this.emit("archive", path.join(this.dir, gzName));
951
+ }
952
+ }
953
+ }
812
954
  async _repairInterruptedGzips() {
813
955
  for (const [name, rec] of Object.entries(this._audit.files)) {
814
956
  if (!rec.gzipPending)
@@ -861,59 +1003,6 @@ class RotateFileStream extends Writable {
861
1003
  this.emit("archive", path.join(this.dir, gzName));
862
1004
  this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
863
1005
  }
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
1006
  /* ==================================================================
918
1007
  * Retention
919
1008
  * ================================================================ */
@@ -1,6 +1,6 @@
1
1
  import { Writable } from "node:stream";
2
2
  import { type ParsedFrequency, type ParsedMaxFiles } from "./parsers.js";
3
- import type { RotateFileStreamEventMap, RotateFileStreamOptions } from "./types";
3
+ import type { RotateFileStreamEventMap, RotateFileStreamOptions } from "./types.js";
4
4
  /**
5
5
  * A `Writable` that appends to dated log files, rolling segments on time
6
6
  * and/or size, optionally gzipping sealed segments, enforcing retention,
@@ -37,8 +37,8 @@ export declare class RotateFileStream extends Writable {
37
37
  private readonly _auditPath;
38
38
  private readonly _bootId;
39
39
  private _offsetMs;
40
- private readonly _stepMs;
41
- private readonly _bumpEvery;
40
+ private _stepMs;
41
+ private _bumpEvery;
42
42
  private _curStamp;
43
43
  private _index;
44
44
  private _ws;
@@ -48,6 +48,7 @@ export declare class RotateFileStream extends Writable {
48
48
  private _bumpTimer;
49
49
  private _ended;
50
50
  private _tail;
51
+ private _warnedBackward;
51
52
  private _audit;
52
53
  constructor(opts?: Partial<RotateFileStreamOptions>);
53
54
  on<E extends keyof RotateFileStreamEventMap>(event: E, listener: (...args: RotateFileStreamEventMap[E]) => void): this;
@@ -60,9 +61,7 @@ export declare class RotateFileStream extends Writable {
60
61
  get filename(): string | null;
61
62
  /** Index of the active segment within the current period. */
62
63
  get currentIndex(): number;
63
- /**
64
- * Force an immediate rotation and resolve when the next slot is staged.
65
- */
64
+ /** Force an immediate rotation and resolve when the next slot is staged. */
66
65
  rotateNow(): Promise<void>;
67
66
  /**
68
67
  * Apply ONE virtual-clock tick immediately (bypasses the interval timer
@@ -77,6 +76,13 @@ export declare class RotateFileStream extends Writable {
77
76
  private _now;
78
77
  private _stampNow;
79
78
  private _resolveName;
79
+ /**
80
+ * true only when the period legally advanced. A regressed
81
+ * clock (NTP correction, manual change) never reopens a past family:
82
+ * writes continue in the current segment until real time catches up.
83
+ * Requires zero-padded datePattern tokens (documented assumption).
84
+ */
85
+ private _stampAdvanced;
80
86
  /**
81
87
  * Highest viable segment index for `stamp`.
82
88
  * Disk is truth: resume a raw tail; start after sealed ones.
@@ -100,16 +106,16 @@ export declare class RotateFileStream extends Writable {
100
106
  private _demoteZombieActives;
101
107
  /** Heal audit-vs-disk divergence after crashes / external tampering. */
102
108
  private _reconcileWithDisk;
109
+ /**
110
+ * DISK-DRIVEN seal. Seal raw segments whose period has
111
+ * already passed (crash near midnight, virtual-clock leftovers,
112
+ * untracked/pruned files). Iterates the DIRECTORY, not the manifest:
113
+ * the filesystem is the source of truth; the manifest is an index.
114
+ */
115
+ private _sealOrphanedPastStamps;
103
116
  private _repairInterruptedGzips;
104
117
  /** Seal a record; re-key `name` → `name.gz` so scans stop ghosting. */
105
118
  private _finishArchivedRecord;
106
- /**
107
- * Seal raw segments whose period has already passed (crash near midnight,
108
- * virtual-clock leftovers, untracked files). Disk is the source of truth —
109
- * manifest-tracked or not, a raw tail nothing will ever write to again
110
- * must still honour zippedArchive.
111
- */
112
- private _sealOrphanedPastStamps;
113
119
  private _retire;
114
120
  }
115
121
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logroller",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Zero-dependency rotating file stream for Node.js — daily/interval/size rotation, gzip archives, retention, timezone-aware, crash-safe resume",
5
5
  "license": "MIT",
6
6
  "author": "Vinau",