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 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: ESLint 9 flat config + typescript-eslint, Prettier,
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
- closed`), and CJS loadability through the exports map.
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(pino.multistream([
76
- { level: "info", stream: createStream({ filename: "logs/general-%DATE%.log" }) },
77
- { level: "error", stream: createStream({ filename: "logs/error-%DATE%.log" }) },
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 | Restart continues into |
94
- |---|---|
95
- | `app-D.log` | `app-D.log` (append) |
96
- | `app-D.log.gz` + `app-D.1.log` | `app-D.1.log` |
97
- | `app-D.1.log.gz` (chain sealed) | `app-D.2.log` |
98
- | died during a gzip | boot finishes the gzip, then proceeds |
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 | Values | Default |
107
- |---|---|---|
108
- | `filename` | **required**, must contain `%DATE%` | — |
109
- | `datePattern` | `YYYY YY MM DD HH mm ss` | `YYYY-MM-DD` |
110
- | `zippedArchive` | boolean | `false` |
111
- | `maxSize` | `20m`, `512k`, `2g`, bytes, `0`=off | off |
112
- | `maxFiles` | `"90d"` age, `500` count, unset=never | — |
113
- | `frequency` | `daily`, `30s`, `5m`, `2h`, ms | `daily` |
114
- | `tz` | any IANA zone | `UTC` |
115
- | `audit` / `auditFile` | manifest control | `true` / auto-name |
116
- | `unrefTimers` | detach from event loop | `false` |
117
- | `addHours` | ⚠️ static clock shift, test only | off |
118
- | `addHoursEveryMin` | ⚠️ virtual time per tick, test only | off |
119
- | `clockStepIntervalMs` | tick interval | `60000` |
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 | Returns | Purpose |
124
- |---|---|---|
125
- | `write / end / destroy` | stream semantics | normal `Writable` |
126
- | `rotateNow()` | `Promise<void>` | force a rotation |
127
- | `advanceClock()` | `Promise<void>` | apply one virtual tick now |
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 | Payload | Meaning |
132
- |---|---|---|
133
- | `open` | `file` | segment opened for appending |
134
- | `rotated` | `{reason, oldFile, archive, newFile}` | segment closed |
135
- | `archive` | `gzFile` | gzip finished |
136
- | `deleted` | `file` | retention removed it |
137
- | `period` | `stamp` | boundary crossed while idle |
138
- | `clock` | `{offsetMs, …}` | virtual clock engaged |
139
- | `warn` | `Error` | non-fatal problems |
140
- | `error` | `Error` | fatal write problems |
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", // one day per real minute
151
- clockStepIntervalMs: 1000, // or faster
152
+ addHoursEveryMin: "24h", // one day per real minute
153
+ clockStepIntervalMs: 1000, // or faster
152
154
  });
153
- await s.advanceClock(); // jump instantly when useful
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 *rotation scheduling* from *filenames*.
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 *same* family: no — the audit
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
- | | logroller | file-stream-rotator | rotating-file-stream | winston-daily-rotate-file |
178
- |---|---|---|---|---|
179
- | Dependencies | **0** | 4+ | 2+ | winston-bound |
180
- | Crash-safe resume | ✅ audit-assisted | partial | partial | ✖ |
181
- | Interrupted-gzip repair | ✅ | ✖ | ✖ | ✖ |
182
- | Event journal | ✅ | ✖ | ✖ | ✖ |
183
- | TZ-correct daily rollover | ✅ | partial | ✅ | ✅ |
184
- | Virtual test clock | ✅ | ✖ | ✖ | ✖ |
185
- | Winston required | no | no | no | yes |
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 (lint + typecheck + tests).
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 v1.0.0 | MIT License */
1
+ /*! logroller v2.0.1 | MIT License */
2
2
  'use strict';
3
3
 
4
4
  var node_crypto = require('node:crypto');
@@ -52,12 +52,21 @@ const UNITS = {
52
52
  const FREQ_MULT = {
53
53
  ms: 1, s: 1000, m: 60_000, h: 3_600_000,
54
54
  };
55
+ function parseDuration(value, defaultUnit, fieldName) {
56
+ if (typeof value === "number") {
57
+ return Math.round(value * FREQ_MULT[defaultUnit]);
58
+ }
59
+ const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(value);
60
+ if (!m)
61
+ throw new Error(`Invalid ${fieldName} "${value}"`);
62
+ return Math.round(Number.parseFloat(m[1]) * FREQ_MULT[(m[2] ?? defaultUnit).toLowerCase()]);
63
+ }
55
64
  function parseSize(v) {
56
65
  if (v === undefined || v === null || v === 0)
57
66
  return 0;
58
67
  if (typeof v === "number")
59
68
  return v;
60
- const m = /^\s*(\d+(?:\.\d+)?)\s*(b|k|kb|m|mb|g|gb)?\s*$/i.exec(v);
69
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|k|mb|m|gb|g)?\s*$/i.exec(v);
61
70
  if (!m)
62
71
  throw new Error(`Invalid maxSize "${v}"`);
63
72
  return Math.round(Number.parseFloat(m[1]) * (UNITS[m[2]?.toLowerCase()] ?? 1));
@@ -81,7 +90,7 @@ function parseFrequency(v) {
81
90
  if (v === "daily")
82
91
  return { type: "daily" };
83
92
  if (typeof v === "number") {
84
- if (!(v > 0))
93
+ if (v <= 0)
85
94
  throw new Error(`Invalid frequency "${v}"`);
86
95
  return { type: "interval", ms: v };
87
96
  }
@@ -93,22 +102,12 @@ function parseFrequency(v) {
93
102
  function parseClockOffset(v) {
94
103
  if (v === undefined || v === null || v === "")
95
104
  return 0;
96
- if (typeof v === "number")
97
- return Math.round(v * 3_600_000);
98
- const m = /^\s*(-?\d+(?:\.\d+)?)\s*h(?:our)?s?\s*$/i.exec(v);
99
- if (!m)
100
- throw new Error(`Invalid addHours "${v}"`);
101
- return Math.round(Number.parseFloat(m[1]) * 3_600_000);
105
+ return parseDuration(v, "h", "addHours");
102
106
  }
103
107
  function parseClockStep(v) {
104
- if (v === undefined || v === null || v === "" || v === 0)
108
+ if (v === undefined || v === null || v === "")
105
109
  return 0;
106
- if (typeof v === "number")
107
- return Math.round(v * 3_600_000);
108
- const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(v);
109
- if (!m)
110
- throw new Error(`Invalid addHoursEveryMin "${v}"`);
111
- return Math.round(Number.parseFloat(m[1]) * FREQ_MULT[(m[2] ?? "h").toLowerCase()]);
110
+ return parseDuration(v, "h", "addHours");
112
111
  }
113
112
 
114
113
  /* Timezone-aware helpers built exclusively on Intl (zero deps). */
@@ -145,11 +144,30 @@ function partsIn(tz, date) {
145
144
  const pad2 = (n) => n.padStart(2, "0");
146
145
  /** Escape a literal string for embedding inside a RegExp. */
147
146
  function escRe(s) {
148
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
147
+ return s.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
149
148
  }
150
149
  /** Substitute YYYY|YY|MM|DD|HH|mm|ss tokens in `pattern`. */
151
150
  function formatStamp(pattern, parts) {
152
- return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (t) => t === "YYYY" ? parts.year : t === "YY" ? parts.year.slice(-2) : t === "MM" ? pad2(parts.month) : t === "DD" ? pad2(parts.day) : t === "HH" ? pad2(parts.hour) : t === "mm" ? pad2(parts.minute) : t === "ss" ? pad2(parts.second) : t);
151
+ return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (token) => {
152
+ switch (token) {
153
+ case "YYYY":
154
+ return parts.year;
155
+ case "YY":
156
+ return parts.year.slice(-2);
157
+ case "MM":
158
+ return pad2(parts.month);
159
+ case "DD":
160
+ return pad2(parts.day);
161
+ case "HH":
162
+ return pad2(parts.hour);
163
+ case "mm":
164
+ return pad2(parts.minute);
165
+ case "ss":
166
+ return pad2(parts.second);
167
+ default:
168
+ return token;
169
+ }
170
+ });
153
171
  }
154
172
  /** Offset of `tz` vs UTC at instant `date`, milliseconds. */
155
173
  function tzOffsetMs(tz, date) {
@@ -179,6 +197,7 @@ const BUMP_DEFAULT_MS = 60_000;
179
197
  const AUDIT_MAX_FILES = 600;
180
198
  const AUDIT_MAX_EVENTS = 200;
181
199
  const HEARTBEAT_STALE_MS = 30_000;
200
+ const TIMER_POLL_MS = 1_000;
182
201
  const emptyAudit = (prefix, ext, datePattern, tz) => ({
183
202
  version: 1,
184
203
  prefix,
@@ -244,6 +263,7 @@ class RotateFileStream extends node_stream.Writable {
244
263
  _bumpTimer = null;
245
264
  _ended = false;
246
265
  _tail = Promise.resolve();
266
+ _warnedBackward = false;
247
267
  _audit;
248
268
  constructor(opts = {}) {
249
269
  super(opts.highWaterMark ? { highWaterMark: opts.highWaterMark } : {});
@@ -296,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
- const stamp = this._stampNow();
366
- if (stamp !== this._curStamp)
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
- const prev = this._audit.files[base];
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
- try {
535
- fs.unlinkSync(oldPath);
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
- catch {
538
- /* already gone */
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
- const stamp = this._stampNow();
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
- const dueDaily = this.freq.type === "daily" && stamp !== this._curStamp;
595
- const dueInterval = this.freq.type !== "daily";
596
- if (dueDaily || dueInterval) {
669
+ if (this.freq.type !== "daily") {
670
+ /* interval rotation is schedule-based; runs regardless of
671
+ stamp movement */
672
+ if (this._hasContent())
673
+ await this._rotate("time");
674
+ }
675
+ else if (this._stampAdvanced(stamp)) {
676
+ // guarded daily branch
597
677
  if (this._hasContent()) {
598
678
  await this._rotate("time");
599
679
  }
600
- else if (stamp !== this._curStamp) {
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
- }, Math.max(100, next - this._now()));
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
- if (stamp !== this._curStamp) {
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
- return false; // missing/unreadable → fresh start
758
+ // unparseable manifest is quarantined too (was silently
759
+ // clobbered by the first save — caught by the quarantine test)
760
+ try {
761
+ fs.renameSync(this._auditPath, `${this._auditPath}.corrupt`);
762
+ }
763
+ catch {
764
+ /* ignore */
765
+ }
766
+ return false;
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 v1.0.0 | MIT License */
1
+ /*! logroller v2.0.1 | MIT License */
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
@@ -50,12 +50,21 @@ const UNITS = {
50
50
  const FREQ_MULT = {
51
51
  ms: 1, s: 1000, m: 60_000, h: 3_600_000,
52
52
  };
53
+ function parseDuration(value, defaultUnit, fieldName) {
54
+ if (typeof value === "number") {
55
+ return Math.round(value * FREQ_MULT[defaultUnit]);
56
+ }
57
+ const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(value);
58
+ if (!m)
59
+ throw new Error(`Invalid ${fieldName} "${value}"`);
60
+ return Math.round(Number.parseFloat(m[1]) * FREQ_MULT[(m[2] ?? defaultUnit).toLowerCase()]);
61
+ }
53
62
  function parseSize(v) {
54
63
  if (v === undefined || v === null || v === 0)
55
64
  return 0;
56
65
  if (typeof v === "number")
57
66
  return v;
58
- const m = /^\s*(\d+(?:\.\d+)?)\s*(b|k|kb|m|mb|g|gb)?\s*$/i.exec(v);
67
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|k|mb|m|gb|g)?\s*$/i.exec(v);
59
68
  if (!m)
60
69
  throw new Error(`Invalid maxSize "${v}"`);
61
70
  return Math.round(Number.parseFloat(m[1]) * (UNITS[m[2]?.toLowerCase()] ?? 1));
@@ -79,7 +88,7 @@ function parseFrequency(v) {
79
88
  if (v === "daily")
80
89
  return { type: "daily" };
81
90
  if (typeof v === "number") {
82
- if (!(v > 0))
91
+ if (v <= 0)
83
92
  throw new Error(`Invalid frequency "${v}"`);
84
93
  return { type: "interval", ms: v };
85
94
  }
@@ -91,22 +100,12 @@ function parseFrequency(v) {
91
100
  function parseClockOffset(v) {
92
101
  if (v === undefined || v === null || v === "")
93
102
  return 0;
94
- if (typeof v === "number")
95
- return Math.round(v * 3_600_000);
96
- const m = /^\s*(-?\d+(?:\.\d+)?)\s*h(?:our)?s?\s*$/i.exec(v);
97
- if (!m)
98
- throw new Error(`Invalid addHours "${v}"`);
99
- return Math.round(Number.parseFloat(m[1]) * 3_600_000);
103
+ return parseDuration(v, "h", "addHours");
100
104
  }
101
105
  function parseClockStep(v) {
102
- if (v === undefined || v === null || v === "" || v === 0)
106
+ if (v === undefined || v === null || v === "")
103
107
  return 0;
104
- if (typeof v === "number")
105
- return Math.round(v * 3_600_000);
106
- const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(v);
107
- if (!m)
108
- throw new Error(`Invalid addHoursEveryMin "${v}"`);
109
- return Math.round(Number.parseFloat(m[1]) * FREQ_MULT[(m[2] ?? "h").toLowerCase()]);
108
+ return parseDuration(v, "h", "addHours");
110
109
  }
111
110
 
112
111
  /* Timezone-aware helpers built exclusively on Intl (zero deps). */
@@ -143,11 +142,30 @@ function partsIn(tz, date) {
143
142
  const pad2 = (n) => n.padStart(2, "0");
144
143
  /** Escape a literal string for embedding inside a RegExp. */
145
144
  function escRe(s) {
146
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
145
+ return s.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
147
146
  }
148
147
  /** Substitute YYYY|YY|MM|DD|HH|mm|ss tokens in `pattern`. */
149
148
  function formatStamp(pattern, parts) {
150
- return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (t) => t === "YYYY" ? parts.year : t === "YY" ? parts.year.slice(-2) : t === "MM" ? pad2(parts.month) : t === "DD" ? pad2(parts.day) : t === "HH" ? pad2(parts.hour) : t === "mm" ? pad2(parts.minute) : t === "ss" ? pad2(parts.second) : t);
149
+ return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (token) => {
150
+ switch (token) {
151
+ case "YYYY":
152
+ return parts.year;
153
+ case "YY":
154
+ return parts.year.slice(-2);
155
+ case "MM":
156
+ return pad2(parts.month);
157
+ case "DD":
158
+ return pad2(parts.day);
159
+ case "HH":
160
+ return pad2(parts.hour);
161
+ case "mm":
162
+ return pad2(parts.minute);
163
+ case "ss":
164
+ return pad2(parts.second);
165
+ default:
166
+ return token;
167
+ }
168
+ });
151
169
  }
152
170
  /** Offset of `tz` vs UTC at instant `date`, milliseconds. */
153
171
  function tzOffsetMs(tz, date) {
@@ -177,6 +195,7 @@ const BUMP_DEFAULT_MS = 60_000;
177
195
  const AUDIT_MAX_FILES = 600;
178
196
  const AUDIT_MAX_EVENTS = 200;
179
197
  const HEARTBEAT_STALE_MS = 30_000;
198
+ const TIMER_POLL_MS = 1_000;
180
199
  const emptyAudit = (prefix, ext, datePattern, tz) => ({
181
200
  version: 1,
182
201
  prefix,
@@ -242,6 +261,7 @@ class RotateFileStream extends Writable {
242
261
  _bumpTimer = null;
243
262
  _ended = false;
244
263
  _tail = Promise.resolve();
264
+ _warnedBackward = false;
245
265
  _audit;
246
266
  constructor(opts = {}) {
247
267
  super(opts.highWaterMark ? { highWaterMark: opts.highWaterMark } : {});
@@ -294,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
- const stamp = this._stampNow();
364
- if (stamp !== this._curStamp)
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
- const prev = this._audit.files[base];
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
- try {
533
- fs.unlinkSync(oldPath);
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
- catch {
536
- /* already gone */
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
- const stamp = this._stampNow();
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
- const dueDaily = this.freq.type === "daily" && stamp !== this._curStamp;
593
- const dueInterval = this.freq.type !== "daily";
594
- if (dueDaily || dueInterval) {
667
+ if (this.freq.type !== "daily") {
668
+ /* interval rotation is schedule-based; runs regardless of
669
+ stamp movement */
670
+ if (this._hasContent())
671
+ await this._rotate("time");
672
+ }
673
+ else if (this._stampAdvanced(stamp)) {
674
+ // guarded daily branch
595
675
  if (this._hasContent()) {
596
676
  await this._rotate("time");
597
677
  }
598
- else if (stamp !== this._curStamp) {
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
- }, Math.max(100, next - this._now()));
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
- if (stamp !== this._curStamp) {
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
- return false; // missing/unreadable → fresh start
756
+ // unparseable manifest is quarantined too (was silently
757
+ // clobbered by the first save — caught by the quarantine test)
758
+ try {
759
+ fs.renameSync(this._auditPath, `${this._auditPath}.corrupt`);
760
+ }
761
+ catch {
762
+ /* ignore */
763
+ }
764
+ return false;
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 readonly _stepMs;
41
- private readonly _bumpEvery;
40
+ private _stepMs;
41
+ private _bumpEvery;
42
42
  private _curStamp;
43
43
  private _index;
44
44
  private _ws;
@@ -48,6 +48,7 @@ export declare class RotateFileStream extends Writable {
48
48
  private _bumpTimer;
49
49
  private _ended;
50
50
  private _tail;
51
+ private _warnedBackward;
51
52
  private _audit;
52
53
  constructor(opts?: Partial<RotateFileStreamOptions>);
53
54
  on<E extends keyof RotateFileStreamEventMap>(event: E, listener: (...args: RotateFileStreamEventMap[E]) => void): this;
@@ -60,9 +61,7 @@ export declare class RotateFileStream extends Writable {
60
61
  get filename(): string | null;
61
62
  /** Index of the active segment within the current period. */
62
63
  get currentIndex(): number;
63
- /**
64
- * Force an immediate rotation and resolve when the next slot is staged.
65
- */
64
+ /** Force an immediate rotation and resolve when the next slot is staged. */
66
65
  rotateNow(): Promise<void>;
67
66
  /**
68
67
  * Apply ONE virtual-clock tick immediately (bypasses the interval timer
@@ -77,6 +76,13 @@ export declare class RotateFileStream extends Writable {
77
76
  private _now;
78
77
  private _stampNow;
79
78
  private _resolveName;
79
+ /**
80
+ * true only when the period legally advanced. A regressed
81
+ * clock (NTP correction, manual change) never reopens a past family:
82
+ * writes continue in the current segment until real time catches up.
83
+ * Requires zero-padded datePattern tokens (documented assumption).
84
+ */
85
+ private _stampAdvanced;
80
86
  /**
81
87
  * Highest viable segment index for `stamp`.
82
88
  * Disk is truth: resume a raw tail; start after sealed ones.
@@ -100,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.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",