logroller 2.0.0 → 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 +50 -4
- package/README.md +57 -55
- package/dist/index.cjs +184 -40
- package/dist/index.esm.js +184 -40
- package/dist/rotate-file-stream.d.ts +19 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ 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.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
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- Reactivated segments no longer inherit stale lifecycle timestamps
|
|
45
|
+
(`abandonedAt` could predate `openedAt`).
|
|
46
|
+
- Raw tails of already-passed periods are now gzipped at boot
|
|
47
|
+
(crash-near-midnight no longer leaves yesterday's file uncompressed).
|
|
48
|
+
- Rotation adopts a `.gz` produced by a (warned-about) concurrent writer
|
|
49
|
+
instead of recording a false `drop-empty` that orphaned the archive.
|
|
50
|
+
|
|
8
51
|
## [2.0.0] — 2026-08-28
|
|
9
52
|
|
|
10
53
|
Architecture release: the library moved from a single-file JavaScript SDK to a
|
|
@@ -17,6 +60,7 @@ TypeScript consumers gain full type coverage.
|
|
|
17
60
|
> upgrading — mostly due to the deterministic boot-order fix below.
|
|
18
61
|
|
|
19
62
|
### Added
|
|
63
|
+
|
|
20
64
|
- **Full TypeScript source with shipped declarations** (`dist/index.d.ts`);
|
|
21
65
|
strict-mode compile (`noImplicitOverride`, `noUnusedLocals`,
|
|
22
66
|
`verbatimModuleSyntax`, …).
|
|
@@ -30,13 +74,13 @@ TypeScript consumers gain full type coverage.
|
|
|
30
74
|
- Modular source layout (`src/types.ts`, `parsers.ts`, `time.ts`, `fsio.ts`,
|
|
31
75
|
`rotate-file-stream.ts`) with `.js` extension imports so emitted
|
|
32
76
|
declarations resolve under `nodenext` consumers.
|
|
33
|
-
- Developer toolchain:
|
|
34
|
-
EditorConfig, `npm run verify` gate (lint + typecheck + build + tests).
|
|
77
|
+
- Developer toolchain: Prettier, `npm run verify` gate (typecheck + build + tests).
|
|
35
78
|
- GitHub Actions: CI matrix on Node 18/20/22, release-triggered publish with
|
|
36
79
|
npm provenance (`id-token: write`, `--provenance`).
|
|
37
80
|
- Expanded README: comparison table, FAQ, restart-resume matrix, pino recipe.
|
|
38
81
|
|
|
39
82
|
### Fixed
|
|
83
|
+
|
|
40
84
|
- **Deterministic boot sequencing**: housekeeping (zombie demotion, disk
|
|
41
85
|
reconciliation, interrupted-gzip repair) is chained as job #1 of the
|
|
42
86
|
serial operation queue in the constructor instead of `process.nextTick`,
|
|
@@ -45,6 +89,7 @@ TypeScript consumers gain full type coverage.
|
|
|
45
89
|
- Retention sweep skips `<audit>.corrupt` artifacts alongside `.tmp`.
|
|
46
90
|
|
|
47
91
|
### Changed
|
|
92
|
+
|
|
48
93
|
- Internals restructured behind the same public exports; barrels re-export
|
|
49
94
|
types explicitly (`import type { RotatedInfo } from "logroller"`).
|
|
50
95
|
- Stricter package hygiene metadata: `sideEffects: false`, explicit
|
|
@@ -54,7 +99,7 @@ TypeScript consumers gain full type coverage.
|
|
|
54
99
|
manifest creation, size rotation/gzip/indexing, restart resume,
|
|
55
100
|
`addHours` naming, virtual midnight crossing, count-retention ordering
|
|
56
101
|
(mtime-deterministic), zombie-record lifecycle (`abandon → active →
|
|
57
|
-
|
|
102
|
+
closed`), and CJS loadability through the exports map.
|
|
58
103
|
|
|
59
104
|
## [1.0.0] — 2026-08-26
|
|
60
105
|
|
|
@@ -63,6 +108,7 @@ Initial release. Single-file zero-dependency JavaScript SDK
|
|
|
63
108
|
rotation.
|
|
64
109
|
|
|
65
110
|
### Added
|
|
111
|
+
|
|
66
112
|
- Time-based rotation: `frequency: "daily"` (local midnight in any IANA
|
|
67
113
|
timezone via `Intl`) or epoch-aligned intervals (`"30s"`, `"5m"`, `"2h"`).
|
|
68
114
|
- Size-based rotation: `maxSize` (`"20m"` / `"512k"` / raw bytes) producing
|
|
@@ -84,4 +130,4 @@ rotation.
|
|
|
84
130
|
(`addHoursEveryMin`).
|
|
85
131
|
- Dual-format distribution (ESM + CJS) bundled with Rollup; named exports
|
|
86
132
|
`createStream` and `RotateFileStream`; factory options mirroring common
|
|
87
|
-
rotator APIs; streams pipe-ready for pino multistreams.
|
|
133
|
+
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.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,10 +316,26 @@ 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();
|
|
@@ -337,9 +373,7 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
337
373
|
get currentIndex() {
|
|
338
374
|
return this._index;
|
|
339
375
|
}
|
|
340
|
-
/**
|
|
341
|
-
* Force an immediate rotation and resolve when the next slot is staged.
|
|
342
|
-
*/
|
|
376
|
+
/** Force an immediate rotation and resolve when the next slot is staged. */
|
|
343
377
|
rotateNow() {
|
|
344
378
|
return this._job(() => this._rotate("manual"));
|
|
345
379
|
}
|
|
@@ -362,8 +396,8 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
362
396
|
const len = chunk.length;
|
|
363
397
|
this._job(async () => {
|
|
364
398
|
try {
|
|
365
|
-
|
|
366
|
-
if (
|
|
399
|
+
// guarded roll (never rotates into a regressed stamp)
|
|
400
|
+
if (this._stampAdvanced(this._stampNow()))
|
|
367
401
|
await this._rotate("time");
|
|
368
402
|
await this._ensureOpen();
|
|
369
403
|
if (this.maxSize > 0 &&
|
|
@@ -434,6 +468,22 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
434
468
|
? base.replace(/\.[^.]+$/, `.${index}$&`)
|
|
435
469
|
: `${base}.${index}`;
|
|
436
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
|
+
}
|
|
437
487
|
/**
|
|
438
488
|
* Highest viable segment index for `stamp`.
|
|
439
489
|
* Disk is truth: resume a raw tail; start after sealed ones.
|
|
@@ -478,9 +528,10 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
478
528
|
this._path = p;
|
|
479
529
|
if (this._auditEnabled) {
|
|
480
530
|
const base = path.basename(p);
|
|
481
|
-
|
|
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
|
-
...(prev && prev.state === "archived" ? {} : prev),
|
|
484
535
|
index: this._index,
|
|
485
536
|
state: "active",
|
|
486
537
|
gzipPending: false,
|
|
@@ -531,22 +582,42 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
531
582
|
}
|
|
532
583
|
}
|
|
533
584
|
else if (realBytes === 0) {
|
|
534
|
-
|
|
535
|
-
|
|
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.
|
|
588
|
+
const adoptedGz = `${oldPath}.gz`;
|
|
589
|
+
if (existsSafe(adoptedGz)) {
|
|
590
|
+
this._finishArchivedRecord(oldBase);
|
|
591
|
+
archive = adoptedGz;
|
|
536
592
|
}
|
|
537
|
-
|
|
538
|
-
|
|
593
|
+
else {
|
|
594
|
+
try {
|
|
595
|
+
fs.unlinkSync(oldPath);
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
/* already gone */
|
|
599
|
+
}
|
|
600
|
+
if (rec)
|
|
601
|
+
delete this._audit.files[oldBase];
|
|
602
|
+
this._aevent("drop-empty", { file: oldBase });
|
|
539
603
|
}
|
|
540
|
-
if (rec)
|
|
541
|
-
delete this._audit.files[oldBase];
|
|
542
|
-
this._aevent("drop-empty", { file: oldBase });
|
|
543
604
|
}
|
|
544
605
|
else if (rec) {
|
|
545
606
|
rec.state = "sealed";
|
|
546
607
|
rec.bytes = realBytes;
|
|
547
608
|
}
|
|
548
609
|
}
|
|
549
|
-
|
|
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"));
|
|
618
|
+
}
|
|
619
|
+
stamp = this._curStamp;
|
|
620
|
+
}
|
|
550
621
|
const newPeriod = stamp !== this._curStamp;
|
|
551
622
|
this._curStamp = stamp;
|
|
552
623
|
/* Never blind-reset to 0 — recover what THIS stamp already owns. */
|
|
@@ -588,16 +659,26 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
588
659
|
const next = this.freq.type === "daily"
|
|
589
660
|
? nextMidnight(this.tz, now)
|
|
590
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);
|
|
591
666
|
this._timer = setTimeout(() => {
|
|
592
667
|
this._job(async () => {
|
|
593
668
|
const stamp = this._stampNow();
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
597
677
|
if (this._hasContent()) {
|
|
598
678
|
await this._rotate("time");
|
|
599
679
|
}
|
|
600
|
-
else
|
|
680
|
+
else {
|
|
681
|
+
/* idle boundary slide — no file created for empty days */
|
|
601
682
|
this._curStamp = stamp;
|
|
602
683
|
this._index = this._recoverIndexFor(stamp);
|
|
603
684
|
if (this._auditEnabled) {
|
|
@@ -608,9 +689,10 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
608
689
|
this.emit("period", stamp);
|
|
609
690
|
}
|
|
610
691
|
}
|
|
692
|
+
/* stamp regressed or unchanged → keep current segment, re-arm */
|
|
611
693
|
this._armTimer();
|
|
612
694
|
}).catch(() => { });
|
|
613
|
-
},
|
|
695
|
+
}, delay);
|
|
614
696
|
if (this.unrefTimers)
|
|
615
697
|
this._timer.unref();
|
|
616
698
|
}
|
|
@@ -634,7 +716,8 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
634
716
|
this._offsetMs += this._stepMs;
|
|
635
717
|
this._armTimer();
|
|
636
718
|
const stamp = this._stampNow();
|
|
637
|
-
|
|
719
|
+
// guarded crossing (virtual clock can't roll backward either)
|
|
720
|
+
if (this._stampAdvanced(stamp)) {
|
|
638
721
|
if (this._hasContent()) {
|
|
639
722
|
await this._rotate("time");
|
|
640
723
|
}
|
|
@@ -672,7 +755,15 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
672
755
|
parsed = JSON.parse(fs.readFileSync(this._auditPath, "utf8"));
|
|
673
756
|
}
|
|
674
757
|
catch {
|
|
675
|
-
|
|
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;
|
|
676
767
|
}
|
|
677
768
|
const j = parsed;
|
|
678
769
|
if (!j || j.version !== 1 || j.prefix !== this._tL || j.ext !== this._tExt) {
|
|
@@ -809,6 +900,59 @@ class RotateFileStream extends node_stream.Writable {
|
|
|
809
900
|
delete this._audit.files[name];
|
|
810
901
|
}
|
|
811
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
|
+
}
|
|
812
956
|
async _repairInterruptedGzips() {
|
|
813
957
|
for (const [name, rec] of Object.entries(this._audit.files)) {
|
|
814
958
|
if (!rec.gzipPending)
|
package/dist/index.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! logroller
|
|
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,10 +314,26 @@ 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();
|
|
@@ -335,9 +371,7 @@ class RotateFileStream extends Writable {
|
|
|
335
371
|
get currentIndex() {
|
|
336
372
|
return this._index;
|
|
337
373
|
}
|
|
338
|
-
/**
|
|
339
|
-
* Force an immediate rotation and resolve when the next slot is staged.
|
|
340
|
-
*/
|
|
374
|
+
/** Force an immediate rotation and resolve when the next slot is staged. */
|
|
341
375
|
rotateNow() {
|
|
342
376
|
return this._job(() => this._rotate("manual"));
|
|
343
377
|
}
|
|
@@ -360,8 +394,8 @@ class RotateFileStream extends Writable {
|
|
|
360
394
|
const len = chunk.length;
|
|
361
395
|
this._job(async () => {
|
|
362
396
|
try {
|
|
363
|
-
|
|
364
|
-
if (
|
|
397
|
+
// guarded roll (never rotates into a regressed stamp)
|
|
398
|
+
if (this._stampAdvanced(this._stampNow()))
|
|
365
399
|
await this._rotate("time");
|
|
366
400
|
await this._ensureOpen();
|
|
367
401
|
if (this.maxSize > 0 &&
|
|
@@ -432,6 +466,22 @@ class RotateFileStream extends Writable {
|
|
|
432
466
|
? base.replace(/\.[^.]+$/, `.${index}$&`)
|
|
433
467
|
: `${base}.${index}`;
|
|
434
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
|
+
}
|
|
435
485
|
/**
|
|
436
486
|
* Highest viable segment index for `stamp`.
|
|
437
487
|
* Disk is truth: resume a raw tail; start after sealed ones.
|
|
@@ -476,9 +526,10 @@ class RotateFileStream extends Writable {
|
|
|
476
526
|
this._path = p;
|
|
477
527
|
if (this._auditEnabled) {
|
|
478
528
|
const base = path.basename(p);
|
|
479
|
-
|
|
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
|
-
...(prev && prev.state === "archived" ? {} : prev),
|
|
482
533
|
index: this._index,
|
|
483
534
|
state: "active",
|
|
484
535
|
gzipPending: false,
|
|
@@ -529,22 +580,42 @@ class RotateFileStream extends Writable {
|
|
|
529
580
|
}
|
|
530
581
|
}
|
|
531
582
|
else if (realBytes === 0) {
|
|
532
|
-
|
|
533
|
-
|
|
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.
|
|
586
|
+
const adoptedGz = `${oldPath}.gz`;
|
|
587
|
+
if (existsSafe(adoptedGz)) {
|
|
588
|
+
this._finishArchivedRecord(oldBase);
|
|
589
|
+
archive = adoptedGz;
|
|
534
590
|
}
|
|
535
|
-
|
|
536
|
-
|
|
591
|
+
else {
|
|
592
|
+
try {
|
|
593
|
+
fs.unlinkSync(oldPath);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
/* already gone */
|
|
597
|
+
}
|
|
598
|
+
if (rec)
|
|
599
|
+
delete this._audit.files[oldBase];
|
|
600
|
+
this._aevent("drop-empty", { file: oldBase });
|
|
537
601
|
}
|
|
538
|
-
if (rec)
|
|
539
|
-
delete this._audit.files[oldBase];
|
|
540
|
-
this._aevent("drop-empty", { file: oldBase });
|
|
541
602
|
}
|
|
542
603
|
else if (rec) {
|
|
543
604
|
rec.state = "sealed";
|
|
544
605
|
rec.bytes = realBytes;
|
|
545
606
|
}
|
|
546
607
|
}
|
|
547
|
-
|
|
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"));
|
|
616
|
+
}
|
|
617
|
+
stamp = this._curStamp;
|
|
618
|
+
}
|
|
548
619
|
const newPeriod = stamp !== this._curStamp;
|
|
549
620
|
this._curStamp = stamp;
|
|
550
621
|
/* Never blind-reset to 0 — recover what THIS stamp already owns. */
|
|
@@ -586,16 +657,26 @@ class RotateFileStream extends Writable {
|
|
|
586
657
|
const next = this.freq.type === "daily"
|
|
587
658
|
? nextMidnight(this.tz, now)
|
|
588
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);
|
|
589
664
|
this._timer = setTimeout(() => {
|
|
590
665
|
this._job(async () => {
|
|
591
666
|
const stamp = this._stampNow();
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
595
675
|
if (this._hasContent()) {
|
|
596
676
|
await this._rotate("time");
|
|
597
677
|
}
|
|
598
|
-
else
|
|
678
|
+
else {
|
|
679
|
+
/* idle boundary slide — no file created for empty days */
|
|
599
680
|
this._curStamp = stamp;
|
|
600
681
|
this._index = this._recoverIndexFor(stamp);
|
|
601
682
|
if (this._auditEnabled) {
|
|
@@ -606,9 +687,10 @@ class RotateFileStream extends Writable {
|
|
|
606
687
|
this.emit("period", stamp);
|
|
607
688
|
}
|
|
608
689
|
}
|
|
690
|
+
/* stamp regressed or unchanged → keep current segment, re-arm */
|
|
609
691
|
this._armTimer();
|
|
610
692
|
}).catch(() => { });
|
|
611
|
-
},
|
|
693
|
+
}, delay);
|
|
612
694
|
if (this.unrefTimers)
|
|
613
695
|
this._timer.unref();
|
|
614
696
|
}
|
|
@@ -632,7 +714,8 @@ class RotateFileStream extends Writable {
|
|
|
632
714
|
this._offsetMs += this._stepMs;
|
|
633
715
|
this._armTimer();
|
|
634
716
|
const stamp = this._stampNow();
|
|
635
|
-
|
|
717
|
+
// guarded crossing (virtual clock can't roll backward either)
|
|
718
|
+
if (this._stampAdvanced(stamp)) {
|
|
636
719
|
if (this._hasContent()) {
|
|
637
720
|
await this._rotate("time");
|
|
638
721
|
}
|
|
@@ -670,7 +753,15 @@ class RotateFileStream extends Writable {
|
|
|
670
753
|
parsed = JSON.parse(fs.readFileSync(this._auditPath, "utf8"));
|
|
671
754
|
}
|
|
672
755
|
catch {
|
|
673
|
-
|
|
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;
|
|
674
765
|
}
|
|
675
766
|
const j = parsed;
|
|
676
767
|
if (!j || j.version !== 1 || j.prefix !== this._tL || j.ext !== this._tExt) {
|
|
@@ -807,6 +898,59 @@ class RotateFileStream extends Writable {
|
|
|
807
898
|
delete this._audit.files[name];
|
|
808
899
|
}
|
|
809
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
|
+
}
|
|
810
954
|
async _repairInterruptedGzips() {
|
|
811
955
|
for (const [name, rec] of Object.entries(this._audit.files)) {
|
|
812
956
|
if (!rec.gzipPending)
|
|
@@ -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,6 +106,13 @@ 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;
|
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",
|