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 +34 -2
- package/dist/index.cjs +199 -110
- package/dist/index.esm.js +199 -110
- package/dist/rotate-file-stream.d.ts +19 -13
- package/package.json +1 -1
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.
|
|
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:
|
|
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.
|
|
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|
|
|
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 (
|
|
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
|
-
|
|
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 === ""
|
|
108
|
+
if (v === undefined || v === null || v === "")
|
|
105
109
|
return 0;
|
|
106
|
-
|
|
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, (
|
|
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
|
-
|
|
367
|
-
if (
|
|
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 {
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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.
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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|
|
|
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 (
|
|
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
|
-
|
|
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 === ""
|
|
106
|
+
if (v === undefined || v === null || v === "")
|
|
103
107
|
return 0;
|
|
104
|
-
|
|
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, (
|
|
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
|
-
|
|
365
|
-
if (
|
|
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 {
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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.
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
41
|
-
private
|
|
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.
|
|
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",
|