logroller 2.0.0 → 2.0.1
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 +18 -4
- package/README.md +57 -55
- package/dist/index.cjs +93 -38
- package/dist/index.esm.js +93 -38
- package/dist/rotate-file-stream.d.ts +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ 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
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Reactivated segments no longer inherit stale lifecycle timestamps
|
|
13
|
+
(`abandonedAt` could predate `openedAt`).
|
|
14
|
+
- Raw tails of already-passed periods are now gzipped at boot
|
|
15
|
+
(crash-near-midnight no longer leaves yesterday's file uncompressed).
|
|
16
|
+
- Rotation adopts a `.gz` produced by a (warned-about) concurrent writer
|
|
17
|
+
instead of recording a false `drop-empty` that orphaned the archive.
|
|
18
|
+
|
|
8
19
|
## [2.0.0] — 2026-08-28
|
|
9
20
|
|
|
10
21
|
Architecture release: the library moved from a single-file JavaScript SDK to a
|
|
@@ -17,6 +28,7 @@ TypeScript consumers gain full type coverage.
|
|
|
17
28
|
> upgrading — mostly due to the deterministic boot-order fix below.
|
|
18
29
|
|
|
19
30
|
### Added
|
|
31
|
+
|
|
20
32
|
- **Full TypeScript source with shipped declarations** (`dist/index.d.ts`);
|
|
21
33
|
strict-mode compile (`noImplicitOverride`, `noUnusedLocals`,
|
|
22
34
|
`verbatimModuleSyntax`, …).
|
|
@@ -30,13 +42,13 @@ TypeScript consumers gain full type coverage.
|
|
|
30
42
|
- Modular source layout (`src/types.ts`, `parsers.ts`, `time.ts`, `fsio.ts`,
|
|
31
43
|
`rotate-file-stream.ts`) with `.js` extension imports so emitted
|
|
32
44
|
declarations resolve under `nodenext` consumers.
|
|
33
|
-
- Developer toolchain:
|
|
34
|
-
EditorConfig, `npm run verify` gate (lint + typecheck + build + tests).
|
|
45
|
+
- Developer toolchain: Prettier, `npm run verify` gate (typecheck + build + tests).
|
|
35
46
|
- GitHub Actions: CI matrix on Node 18/20/22, release-triggered publish with
|
|
36
47
|
npm provenance (`id-token: write`, `--provenance`).
|
|
37
48
|
- Expanded README: comparison table, FAQ, restart-resume matrix, pino recipe.
|
|
38
49
|
|
|
39
50
|
### Fixed
|
|
51
|
+
|
|
40
52
|
- **Deterministic boot sequencing**: housekeeping (zombie demotion, disk
|
|
41
53
|
reconciliation, interrupted-gzip repair) is chained as job #1 of the
|
|
42
54
|
serial operation queue in the constructor instead of `process.nextTick`,
|
|
@@ -45,6 +57,7 @@ TypeScript consumers gain full type coverage.
|
|
|
45
57
|
- Retention sweep skips `<audit>.corrupt` artifacts alongside `.tmp`.
|
|
46
58
|
|
|
47
59
|
### Changed
|
|
60
|
+
|
|
48
61
|
- Internals restructured behind the same public exports; barrels re-export
|
|
49
62
|
types explicitly (`import type { RotatedInfo } from "logroller"`).
|
|
50
63
|
- Stricter package hygiene metadata: `sideEffects: false`, explicit
|
|
@@ -54,7 +67,7 @@ TypeScript consumers gain full type coverage.
|
|
|
54
67
|
manifest creation, size rotation/gzip/indexing, restart resume,
|
|
55
68
|
`addHours` naming, virtual midnight crossing, count-retention ordering
|
|
56
69
|
(mtime-deterministic), zombie-record lifecycle (`abandon → active →
|
|
57
|
-
|
|
70
|
+
closed`), and CJS loadability through the exports map.
|
|
58
71
|
|
|
59
72
|
## [1.0.0] — 2026-08-26
|
|
60
73
|
|
|
@@ -63,6 +76,7 @@ Initial release. Single-file zero-dependency JavaScript SDK
|
|
|
63
76
|
rotation.
|
|
64
77
|
|
|
65
78
|
### Added
|
|
79
|
+
|
|
66
80
|
- Time-based rotation: `frequency: "daily"` (local midnight in any IANA
|
|
67
81
|
timezone via `Intl`) or epoch-aligned intervals (`"30s"`, `"5m"`, `"2h"`).
|
|
68
82
|
- Size-based rotation: `maxSize` (`"20m"` / `"512k"` / raw bytes) producing
|
|
@@ -84,4 +98,4 @@ rotation.
|
|
|
84
98
|
(`addHoursEveryMin`).
|
|
85
99
|
- Dual-format distribution (ESM + CJS) bundled with Rollup; named exports
|
|
86
100
|
`createStream` and `RotateFileStream`; factory options mirroring common
|
|
87
|
-
rotator APIs; streams pipe-ready for pino multistreams.
|
|
101
|
+
rotator APIs; streams pipe-ready for pino multistreams.
|
package/README.md
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
## README.md
|
|
3
2
|
|
|
4
3
|
<p align="center"><strong>logroller</strong></p>
|
|
@@ -72,11 +71,14 @@ const { createStream } = require("logroller");
|
|
|
72
71
|
### pino destination
|
|
73
72
|
|
|
74
73
|
```js
|
|
75
|
-
const logger = pino(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
const logger = pino(
|
|
75
|
+
pino.multistream([
|
|
76
|
+
{ level: "info", stream: createStream({ filename: "logs/general-%DATE%.log" }) },
|
|
77
|
+
{ level: "error", stream: createStream({ filename: "logs/error-%DATE%.log" }) },
|
|
78
|
+
]),
|
|
79
|
+
);
|
|
79
80
|
```
|
|
81
|
+
|
|
80
82
|
## What lands on disk
|
|
81
83
|
|
|
82
84
|
```
|
|
@@ -90,12 +92,12 @@ app_audit.json ← library-owned manifest
|
|
|
90
92
|
|
|
91
93
|
A killed process resumes without gaps or duplicates:
|
|
92
94
|
|
|
93
|
-
| Disk at crash
|
|
94
|
-
|
|
95
|
-
| `app-D.log`
|
|
96
|
-
| `app-D.log.gz` + `app-D.1.log`
|
|
97
|
-
| `app-D.1.log.gz` (chain sealed) | `app-D.2.log`
|
|
98
|
-
| died during a gzip
|
|
95
|
+
| Disk at crash | Restart continues into |
|
|
96
|
+
| ------------------------------- | ------------------------------------- |
|
|
97
|
+
| `app-D.log` | `app-D.log` (append) |
|
|
98
|
+
| `app-D.log.gz` + `app-D.1.log` | `app-D.1.log` |
|
|
99
|
+
| `app-D.1.log.gz` (chain sealed) | `app-D.2.log` |
|
|
100
|
+
| died during a gzip | boot finishes the gzip, then proceeds |
|
|
99
101
|
|
|
100
102
|
## API
|
|
101
103
|
|
|
@@ -103,41 +105,41 @@ A killed process resumes without gaps or duplicates:
|
|
|
103
105
|
|
|
104
106
|
### Options
|
|
105
107
|
|
|
106
|
-
| Option
|
|
107
|
-
|
|
108
|
-
| `filename`
|
|
109
|
-
| `datePattern`
|
|
110
|
-
| `zippedArchive`
|
|
111
|
-
| `maxSize`
|
|
112
|
-
| `maxFiles`
|
|
113
|
-
| `frequency`
|
|
114
|
-
| `tz`
|
|
115
|
-
| `audit` / `auditFile` | manifest control
|
|
116
|
-
| `unrefTimers`
|
|
117
|
-
| `addHours`
|
|
118
|
-
| `addHoursEveryMin`
|
|
119
|
-
| `clockStepIntervalMs` | tick interval
|
|
108
|
+
| Option | Values | Default |
|
|
109
|
+
| --------------------- | ------------------------------------- | ------------------ |
|
|
110
|
+
| `filename` | **required**, must contain `%DATE%` | — |
|
|
111
|
+
| `datePattern` | `YYYY YY MM DD HH mm ss` | `YYYY-MM-DD` |
|
|
112
|
+
| `zippedArchive` | boolean | `false` |
|
|
113
|
+
| `maxSize` | `20m`, `512k`, `2g`, bytes, `0`=off | off |
|
|
114
|
+
| `maxFiles` | `"90d"` age, `500` count, unset=never | — |
|
|
115
|
+
| `frequency` | `daily`, `30s`, `5m`, `2h`, ms | `daily` |
|
|
116
|
+
| `tz` | any IANA zone | `UTC` |
|
|
117
|
+
| `audit` / `auditFile` | manifest control | `true` / auto-name |
|
|
118
|
+
| `unrefTimers` | detach from event loop | `false` |
|
|
119
|
+
| `addHours` | ⚠️ static clock shift, test only | off |
|
|
120
|
+
| `addHoursEveryMin` | ⚠️ virtual time per tick, test only | off |
|
|
121
|
+
| `clockStepIntervalMs` | tick interval | `60000` |
|
|
120
122
|
|
|
121
123
|
### Methods
|
|
122
124
|
|
|
123
|
-
| Method
|
|
124
|
-
|
|
125
|
-
| `write / end / destroy` | stream semantics | normal `Writable`
|
|
126
|
-
| `rotateNow()`
|
|
127
|
-
| `advanceClock()`
|
|
125
|
+
| Method | Returns | Purpose |
|
|
126
|
+
| ----------------------- | ---------------- | -------------------------- |
|
|
127
|
+
| `write / end / destroy` | stream semantics | normal `Writable` |
|
|
128
|
+
| `rotateNow()` | `Promise<void>` | force a rotation |
|
|
129
|
+
| `advanceClock()` | `Promise<void>` | apply one virtual tick now |
|
|
128
130
|
|
|
129
131
|
### Events (typed)
|
|
130
132
|
|
|
131
|
-
| Event
|
|
132
|
-
|
|
133
|
-
| `open`
|
|
134
|
-
| `rotated` | `{reason, oldFile, archive, newFile}` | segment closed
|
|
135
|
-
| `archive` | `gzFile`
|
|
136
|
-
| `deleted` | `file`
|
|
137
|
-
| `period`
|
|
138
|
-
| `clock`
|
|
139
|
-
| `warn`
|
|
140
|
-
| `error`
|
|
133
|
+
| Event | Payload | Meaning |
|
|
134
|
+
| --------- | ------------------------------------- | ---------------------------- |
|
|
135
|
+
| `open` | `file` | segment opened for appending |
|
|
136
|
+
| `rotated` | `{reason, oldFile, archive, newFile}` | segment closed |
|
|
137
|
+
| `archive` | `gzFile` | gzip finished |
|
|
138
|
+
| `deleted` | `file` | retention removed it |
|
|
139
|
+
| `period` | `stamp` | boundary crossed while idle |
|
|
140
|
+
| `clock` | `{offsetMs, …}` | virtual clock engaged |
|
|
141
|
+
| `warn` | `Error` | non-fatal problems |
|
|
142
|
+
| `error` | `Error` | fatal write problems |
|
|
141
143
|
|
|
142
144
|
Errors must be handled — attach a listener on every instance.
|
|
143
145
|
|
|
@@ -147,21 +149,21 @@ Errors must be handled — attach a listener on every instance.
|
|
|
147
149
|
const s = createStream({
|
|
148
150
|
filename: "logs/demo-%DATE%.log",
|
|
149
151
|
zippedArchive: true,
|
|
150
|
-
addHoursEveryMin: "24h",
|
|
151
|
-
clockStepIntervalMs: 1000,
|
|
152
|
+
addHoursEveryMin: "24h", // one day per real minute
|
|
153
|
+
clockStepIntervalMs: 1000, // or faster
|
|
152
154
|
});
|
|
153
|
-
await s.advanceClock();
|
|
155
|
+
await s.advanceClock(); // jump instantly when useful
|
|
154
156
|
```
|
|
155
157
|
|
|
156
158
|
## FAQ
|
|
157
159
|
|
|
158
160
|
**Why is `%DATE%` mandatory?**
|
|
159
|
-
It is the anchor that decouples
|
|
161
|
+
It is the anchor that decouples _rotation scheduling_ from _filenames_.
|
|
160
162
|
Coarser patterns than your frequency collapse distinct segments onto one
|
|
161
163
|
name — match tokens to cadence (`HH` for hourly, etc.).
|
|
162
164
|
|
|
163
165
|
**Can two processes share one directory?**
|
|
164
|
-
Different prefixes/families: yes. The
|
|
166
|
+
Different prefixes/families: yes. The _same_ family: no — the audit
|
|
165
167
|
manifest detects a live second writer (`writer.heartbeat`) and warns loudly.
|
|
166
168
|
|
|
167
169
|
**Is the audit JSON required?**
|
|
@@ -174,19 +176,19 @@ Pure Node APIs; path handling via `node:path`. All three supported
|
|
|
174
176
|
|
|
175
177
|
## Compare
|
|
176
178
|
|
|
177
|
-
|
|
|
178
|
-
|
|
179
|
-
| Dependencies
|
|
180
|
-
| Crash-safe resume
|
|
181
|
-
| Interrupted-gzip repair
|
|
182
|
-
| Event journal
|
|
183
|
-
| TZ-correct daily rollover | ✅
|
|
184
|
-
| Virtual test clock
|
|
185
|
-
| Winston required
|
|
179
|
+
| | logroller | file-stream-rotator | rotating-file-stream | winston-daily-rotate-file |
|
|
180
|
+
| ------------------------- | ----------------- | ------------------- | -------------------- | ------------------------- |
|
|
181
|
+
| Dependencies | **0** | 4+ | 2+ | winston-bound |
|
|
182
|
+
| Crash-safe resume | ✅ audit-assisted | partial | partial | ✖ |
|
|
183
|
+
| Interrupted-gzip repair | ✅ | ✖ | ✖ | ✖ |
|
|
184
|
+
| Event journal | ✅ | ✖ | ✖ | ✖ |
|
|
185
|
+
| TZ-correct daily rollover | ✅ | partial | ✅ | ✅ |
|
|
186
|
+
| Virtual test clock | ✅ | ✖ | ✖ | ✖ |
|
|
187
|
+
| Winston required | no | no | no | yes |
|
|
186
188
|
|
|
187
189
|
## Contributing
|
|
188
190
|
|
|
189
|
-
PRs welcome. `npm run verify` gates every change (
|
|
191
|
+
PRs welcome. `npm run verify` gates every change (typecheck + tests).
|
|
190
192
|
Please file issues before large refactors.
|
|
191
193
|
|
|
192
194
|
## License
|
package/dist/index.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! logroller
|
|
1
|
+
/*! logroller v2.0.0 | MIT License */
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
var node_crypto = require('node:crypto');
|
|
@@ -303,6 +303,7 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
303
303
|
if (this._auditEnabled) {
|
|
304
304
|
this._demoteZombieActives();
|
|
305
305
|
this._reconcileWithDisk();
|
|
306
|
+
await this._sealOrphanedPastStamps();
|
|
306
307
|
await this._repairInterruptedGzips();
|
|
307
308
|
this._aevent("boot", { stamp: this._curStamp, index: this._index });
|
|
308
309
|
this._saveAudit();
|
|
@@ -478,9 +479,7 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
478
479
|
this._path = p;
|
|
479
480
|
if (this._auditEnabled) {
|
|
480
481
|
const base = path.basename(p);
|
|
481
|
-
const prev = this._audit.files[base];
|
|
482
482
|
this._audit.files[base] = {
|
|
483
|
-
...(prev && prev.state === "archived" ? {} : prev),
|
|
484
483
|
index: this._index,
|
|
485
484
|
state: "active",
|
|
486
485
|
gzipPending: false,
|
|
@@ -531,46 +530,49 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
531
530
|
}
|
|
532
531
|
}
|
|
533
532
|
else if (realBytes === 0) {
|
|
534
|
-
|
|
535
|
-
|
|
533
|
+
const adoptedGz = `${oldPath}.gz`;
|
|
534
|
+
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
|
+
this._finishArchivedRecord(oldBase);
|
|
538
|
+
archive = adoptedGz;
|
|
536
539
|
}
|
|
537
|
-
|
|
538
|
-
|
|
540
|
+
else {
|
|
541
|
+
try {
|
|
542
|
+
fs.unlinkSync(oldPath);
|
|
543
|
+
}
|
|
544
|
+
catch { /* already gone */ }
|
|
545
|
+
if (rec)
|
|
546
|
+
delete this._audit.files[oldBase];
|
|
547
|
+
this._aevent("drop-empty", { file: oldBase });
|
|
539
548
|
}
|
|
540
|
-
if (rec)
|
|
541
|
-
delete this._audit.files[oldBase];
|
|
542
|
-
this._aevent("drop-empty", { file: oldBase });
|
|
543
549
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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();
|
|
547
563
|
}
|
|
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
|
+
});
|
|
573
|
+
}
|
|
574
|
+
this._retire();
|
|
548
575
|
}
|
|
549
|
-
const stamp = this._stampNow();
|
|
550
|
-
const newPeriod = stamp !== this._curStamp;
|
|
551
|
-
this._curStamp = stamp;
|
|
552
|
-
/* Never blind-reset to 0 — recover what THIS stamp already owns. */
|
|
553
|
-
this._index =
|
|
554
|
-
newPeriod || kind === "time"
|
|
555
|
-
? this._recoverIndexFor(stamp)
|
|
556
|
-
: this._index + 1;
|
|
557
|
-
this._bytes = 0;
|
|
558
|
-
if (this._auditEnabled) {
|
|
559
|
-
this._setActivePointer(this._index);
|
|
560
|
-
this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
|
|
561
|
-
this._saveAudit();
|
|
562
|
-
}
|
|
563
|
-
if (kind === "time" || kind === "manual")
|
|
564
|
-
this._armTimer();
|
|
565
|
-
if (oldPath) {
|
|
566
|
-
this.emit("rotated", {
|
|
567
|
-
reason: kind,
|
|
568
|
-
oldFile: oldPath,
|
|
569
|
-
archive,
|
|
570
|
-
newFile: this._resolveName(stamp, this._index),
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
this._retire();
|
|
574
576
|
}
|
|
575
577
|
/* ==================================================================
|
|
576
578
|
* Timers
|
|
@@ -861,6 +863,59 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
861
863
|
this.emit("archive", path.join(this.dir, gzName));
|
|
862
864
|
this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
|
|
863
865
|
}
|
|
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
|
+
}
|
|
864
919
|
/* ==================================================================
|
|
865
920
|
* Retention
|
|
866
921
|
* ================================================================ */
|
package/dist/index.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! logroller
|
|
1
|
+
/*! logroller v2.0.0 | MIT License */
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
@@ -301,6 +301,7 @@ class RotateFileStream extends Writable {
|
|
|
301
301
|
if (this._auditEnabled) {
|
|
302
302
|
this._demoteZombieActives();
|
|
303
303
|
this._reconcileWithDisk();
|
|
304
|
+
await this._sealOrphanedPastStamps();
|
|
304
305
|
await this._repairInterruptedGzips();
|
|
305
306
|
this._aevent("boot", { stamp: this._curStamp, index: this._index });
|
|
306
307
|
this._saveAudit();
|
|
@@ -476,9 +477,7 @@ class RotateFileStream extends Writable {
|
|
|
476
477
|
this._path = p;
|
|
477
478
|
if (this._auditEnabled) {
|
|
478
479
|
const base = path.basename(p);
|
|
479
|
-
const prev = this._audit.files[base];
|
|
480
480
|
this._audit.files[base] = {
|
|
481
|
-
...(prev && prev.state === "archived" ? {} : prev),
|
|
482
481
|
index: this._index,
|
|
483
482
|
state: "active",
|
|
484
483
|
gzipPending: false,
|
|
@@ -529,46 +528,49 @@ class RotateFileStream extends Writable {
|
|
|
529
528
|
}
|
|
530
529
|
}
|
|
531
530
|
else if (realBytes === 0) {
|
|
532
|
-
|
|
533
|
-
|
|
531
|
+
const adoptedGz = `${oldPath}.gz`;
|
|
532
|
+
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
|
+
this._finishArchivedRecord(oldBase);
|
|
536
|
+
archive = adoptedGz;
|
|
534
537
|
}
|
|
535
|
-
|
|
536
|
-
|
|
538
|
+
else {
|
|
539
|
+
try {
|
|
540
|
+
fs.unlinkSync(oldPath);
|
|
541
|
+
}
|
|
542
|
+
catch { /* already gone */ }
|
|
543
|
+
if (rec)
|
|
544
|
+
delete this._audit.files[oldBase];
|
|
545
|
+
this._aevent("drop-empty", { file: oldBase });
|
|
537
546
|
}
|
|
538
|
-
if (rec)
|
|
539
|
-
delete this._audit.files[oldBase];
|
|
540
|
-
this._aevent("drop-empty", { file: oldBase });
|
|
541
547
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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();
|
|
545
561
|
}
|
|
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
|
+
});
|
|
571
|
+
}
|
|
572
|
+
this._retire();
|
|
546
573
|
}
|
|
547
|
-
const stamp = this._stampNow();
|
|
548
|
-
const newPeriod = stamp !== this._curStamp;
|
|
549
|
-
this._curStamp = stamp;
|
|
550
|
-
/* Never blind-reset to 0 — recover what THIS stamp already owns. */
|
|
551
|
-
this._index =
|
|
552
|
-
newPeriod || kind === "time"
|
|
553
|
-
? this._recoverIndexFor(stamp)
|
|
554
|
-
: this._index + 1;
|
|
555
|
-
this._bytes = 0;
|
|
556
|
-
if (this._auditEnabled) {
|
|
557
|
-
this._setActivePointer(this._index);
|
|
558
|
-
this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
|
|
559
|
-
this._saveAudit();
|
|
560
|
-
}
|
|
561
|
-
if (kind === "time" || kind === "manual")
|
|
562
|
-
this._armTimer();
|
|
563
|
-
if (oldPath) {
|
|
564
|
-
this.emit("rotated", {
|
|
565
|
-
reason: kind,
|
|
566
|
-
oldFile: oldPath,
|
|
567
|
-
archive,
|
|
568
|
-
newFile: this._resolveName(stamp, this._index),
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
this._retire();
|
|
572
574
|
}
|
|
573
575
|
/* ==================================================================
|
|
574
576
|
* Timers
|
|
@@ -859,6 +861,59 @@ class RotateFileStream extends Writable {
|
|
|
859
861
|
this.emit("archive", path.join(this.dir, gzName));
|
|
860
862
|
this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
|
|
861
863
|
}
|
|
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
|
+
}
|
|
862
917
|
/* ==================================================================
|
|
863
918
|
* Retention
|
|
864
919
|
* ================================================================ */
|
|
@@ -103,6 +103,13 @@ export declare class RotateFileStream extends Writable {
|
|
|
103
103
|
private _repairInterruptedGzips;
|
|
104
104
|
/** Seal a record; re-key `name` → `name.gz` so scans stop ghosting. */
|
|
105
105
|
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;
|
|
106
113
|
private _retire;
|
|
107
114
|
}
|
|
108
115
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "logroller",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
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",
|