logroller 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.0.1] — 2026-09-0X
9
+
10
+ ### Fixed
11
+
12
+ - Reactivated segments no longer inherit stale lifecycle timestamps
13
+ (`abandonedAt` could predate `openedAt`).
14
+ - Raw tails of already-passed periods are now gzipped at boot
15
+ (crash-near-midnight no longer leaves yesterday's file uncompressed).
16
+ - Rotation adopts a `.gz` produced by a (warned-about) concurrent writer
17
+ instead of recording a false `drop-empty` that orphaned the archive.
18
+
8
19
  ## [2.0.0] — 2026-08-28
9
20
 
10
21
  Architecture release: the library moved from a single-file JavaScript SDK to a
@@ -17,6 +28,7 @@ TypeScript consumers gain full type coverage.
17
28
  > upgrading — mostly due to the deterministic boot-order fix below.
18
29
 
19
30
  ### Added
31
+
20
32
  - **Full TypeScript source with shipped declarations** (`dist/index.d.ts`);
21
33
  strict-mode compile (`noImplicitOverride`, `noUnusedLocals`,
22
34
  `verbatimModuleSyntax`, …).
@@ -30,13 +42,13 @@ TypeScript consumers gain full type coverage.
30
42
  - Modular source layout (`src/types.ts`, `parsers.ts`, `time.ts`, `fsio.ts`,
31
43
  `rotate-file-stream.ts`) with `.js` extension imports so emitted
32
44
  declarations resolve under `nodenext` consumers.
33
- - Developer toolchain: ESLint 9 flat config + typescript-eslint, Prettier,
34
- EditorConfig, `npm run verify` gate (lint + typecheck + build + tests).
45
+ - Developer toolchain: Prettier, `npm run verify` gate (typecheck + build + tests).
35
46
  - GitHub Actions: CI matrix on Node 18/20/22, release-triggered publish with
36
47
  npm provenance (`id-token: write`, `--provenance`).
37
48
  - Expanded README: comparison table, FAQ, restart-resume matrix, pino recipe.
38
49
 
39
50
  ### Fixed
51
+
40
52
  - **Deterministic boot sequencing**: housekeeping (zombie demotion, disk
41
53
  reconciliation, interrupted-gzip repair) is chained as job #1 of the
42
54
  serial operation queue in the constructor instead of `process.nextTick`,
@@ -45,6 +57,7 @@ TypeScript consumers gain full type coverage.
45
57
  - Retention sweep skips `<audit>.corrupt` artifacts alongside `.tmp`.
46
58
 
47
59
  ### Changed
60
+
48
61
  - Internals restructured behind the same public exports; barrels re-export
49
62
  types explicitly (`import type { RotatedInfo } from "logroller"`).
50
63
  - Stricter package hygiene metadata: `sideEffects: false`, explicit
@@ -54,7 +67,7 @@ TypeScript consumers gain full type coverage.
54
67
  manifest creation, size rotation/gzip/indexing, restart resume,
55
68
  `addHours` naming, virtual midnight crossing, count-retention ordering
56
69
  (mtime-deterministic), zombie-record lifecycle (`abandon → active →
57
- closed`), and CJS loadability through the exports map.
70
+ closed`), and CJS loadability through the exports map.
58
71
 
59
72
  ## [1.0.0] — 2026-08-26
60
73
 
@@ -63,6 +76,7 @@ Initial release. Single-file zero-dependency JavaScript SDK
63
76
  rotation.
64
77
 
65
78
  ### Added
79
+
66
80
  - Time-based rotation: `frequency: "daily"` (local midnight in any IANA
67
81
  timezone via `Intl`) or epoch-aligned intervals (`"30s"`, `"5m"`, `"2h"`).
68
82
  - Size-based rotation: `maxSize` (`"20m"` / `"512k"` / raw bytes) producing
@@ -84,4 +98,4 @@ rotation.
84
98
  (`addHoursEveryMin`).
85
99
  - Dual-format distribution (ESM + CJS) bundled with Rollup; named exports
86
100
  `createStream` and `RotateFileStream`; factory options mirroring common
87
- rotator APIs; streams pipe-ready for pino multistreams.
101
+ rotator APIs; streams pipe-ready for pino multistreams.
package/README.md CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  ## README.md
3
2
 
4
3
  <p align="center"><strong>logroller</strong></p>
@@ -72,11 +71,14 @@ const { createStream } = require("logroller");
72
71
  ### pino destination
73
72
 
74
73
  ```js
75
- const logger = pino(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.0 | MIT License */
2
2
  'use strict';
3
3
 
4
4
  var node_crypto = require('node:crypto');
@@ -303,6 +303,7 @@ class RotateFileStream extends node_stream.Writable {
303
303
  if (this._auditEnabled) {
304
304
  this._demoteZombieActives();
305
305
  this._reconcileWithDisk();
306
+ await this._sealOrphanedPastStamps();
306
307
  await this._repairInterruptedGzips();
307
308
  this._aevent("boot", { stamp: this._curStamp, index: this._index });
308
309
  this._saveAudit();
@@ -478,9 +479,7 @@ class RotateFileStream extends node_stream.Writable {
478
479
  this._path = p;
479
480
  if (this._auditEnabled) {
480
481
  const base = path.basename(p);
481
- const prev = this._audit.files[base];
482
482
  this._audit.files[base] = {
483
- ...(prev && prev.state === "archived" ? {} : prev),
484
483
  index: this._index,
485
484
  state: "active",
486
485
  gzipPending: false,
@@ -531,46 +530,49 @@ class RotateFileStream extends node_stream.Writable {
531
530
  }
532
531
  }
533
532
  else if (realBytes === 0) {
534
- try {
535
- fs.unlinkSync(oldPath);
533
+ const adoptedGz = `${oldPath}.gz`;
534
+ if (existsSafe(adoptedGz)) {
535
+ // A concurrent writer archived this segment between our last stat
536
+ // and now — adopt its artifact instead of dropping the record.
537
+ this._finishArchivedRecord(oldBase);
538
+ archive = adoptedGz;
536
539
  }
537
- catch {
538
- /* already gone */
540
+ else {
541
+ try {
542
+ fs.unlinkSync(oldPath);
543
+ }
544
+ catch { /* already gone */ }
545
+ if (rec)
546
+ delete this._audit.files[oldBase];
547
+ this._aevent("drop-empty", { file: oldBase });
539
548
  }
540
- if (rec)
541
- delete this._audit.files[oldBase];
542
- this._aevent("drop-empty", { file: oldBase });
543
549
  }
544
- else if (rec) {
545
- rec.state = "sealed";
546
- rec.bytes = realBytes;
550
+ const stamp = this._stampNow();
551
+ const newPeriod = stamp !== this._curStamp;
552
+ this._curStamp = stamp;
553
+ /* Never blind-reset to 0 — recover what THIS stamp already owns. */
554
+ this._index =
555
+ newPeriod || kind === "time"
556
+ ? this._recoverIndexFor(stamp)
557
+ : this._index + 1;
558
+ this._bytes = 0;
559
+ if (this._auditEnabled) {
560
+ this._setActivePointer(this._index);
561
+ this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
562
+ this._saveAudit();
547
563
  }
564
+ if (kind === "time" || kind === "manual")
565
+ this._armTimer();
566
+ if (oldPath) {
567
+ this.emit("rotated", {
568
+ reason: kind,
569
+ oldFile: oldPath,
570
+ archive,
571
+ newFile: this._resolveName(stamp, this._index),
572
+ });
573
+ }
574
+ this._retire();
548
575
  }
549
- const stamp = this._stampNow();
550
- const newPeriod = stamp !== this._curStamp;
551
- this._curStamp = stamp;
552
- /* Never blind-reset to 0 — recover what THIS stamp already owns. */
553
- this._index =
554
- newPeriod || kind === "time"
555
- ? this._recoverIndexFor(stamp)
556
- : this._index + 1;
557
- this._bytes = 0;
558
- if (this._auditEnabled) {
559
- this._setActivePointer(this._index);
560
- this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
561
- this._saveAudit();
562
- }
563
- if (kind === "time" || kind === "manual")
564
- this._armTimer();
565
- if (oldPath) {
566
- this.emit("rotated", {
567
- reason: kind,
568
- oldFile: oldPath,
569
- archive,
570
- newFile: this._resolveName(stamp, this._index),
571
- });
572
- }
573
- this._retire();
574
576
  }
575
577
  /* ==================================================================
576
578
  * Timers
@@ -861,6 +863,59 @@ class RotateFileStream extends node_stream.Writable {
861
863
  this.emit("archive", path.join(this.dir, gzName));
862
864
  this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
863
865
  }
866
+ /**
867
+ * Seal raw segments whose period has already passed (crash near midnight,
868
+ * virtual-clock leftovers, untracked files). Disk is the source of truth —
869
+ * manifest-tracked or not, a raw tail nothing will ever write to again
870
+ * must still honour zippedArchive.
871
+ */
872
+ async _sealOrphanedPastStamps() {
873
+ if (!this.zippedArchive)
874
+ return;
875
+ const stampRx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
876
+ "(?:\\.\\d+)?" + escRe(this._tExt) + "(?:\\.gz)?$");
877
+ let names = [];
878
+ try {
879
+ names = fs.readdirSync(this.dir);
880
+ }
881
+ catch {
882
+ return;
883
+ }
884
+ for (const name of names) {
885
+ if (name.endsWith(".gz"))
886
+ continue;
887
+ if (name === (this._path && path.basename(this._path)))
888
+ continue;
889
+ const stamp = stampRx.exec(name)?.[1];
890
+ // lexicographic compare is safe for zero-padded patterns (YYYY-MM-DD…);
891
+ // also guards the current stamp and anything future-dated
892
+ if (!stamp || stamp >= this._curStamp)
893
+ continue;
894
+ const full = path.join(this.dir, name);
895
+ if (!existsSafe(full) || statSizeSafe(full) === 0)
896
+ continue;
897
+ const ok = await gzipMove(full, `${full}.gz`, (e) => this.emit("warn", e));
898
+ if (!ok)
899
+ continue;
900
+ if (this._auditEnabled && this._audit.files[name]) {
901
+ // tracked → re-key record name → name.gz (emits archive + journal)
902
+ this._finishArchivedRecord(name, true);
903
+ }
904
+ else {
905
+ // untracked → record it fresh so future reconciles see a truthful state
906
+ const gzName = `${name}.gz`;
907
+ if (this._auditEnabled) {
908
+ this._audit.files[gzName] = {
909
+ state: "archived",
910
+ gzipPending: false,
911
+ archivedAt: new Date().toISOString(),
912
+ };
913
+ this._aevent("seal-past", { file: gzName });
914
+ }
915
+ this.emit("archive", path.join(this.dir, gzName));
916
+ }
917
+ }
918
+ }
864
919
  /* ==================================================================
865
920
  * Retention
866
921
  * ================================================================ */
package/dist/index.esm.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! logroller v1.0.0 | MIT License */
1
+ /*! logroller v2.0.0 | MIT License */
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
@@ -301,6 +301,7 @@ class RotateFileStream extends Writable {
301
301
  if (this._auditEnabled) {
302
302
  this._demoteZombieActives();
303
303
  this._reconcileWithDisk();
304
+ await this._sealOrphanedPastStamps();
304
305
  await this._repairInterruptedGzips();
305
306
  this._aevent("boot", { stamp: this._curStamp, index: this._index });
306
307
  this._saveAudit();
@@ -476,9 +477,7 @@ class RotateFileStream extends Writable {
476
477
  this._path = p;
477
478
  if (this._auditEnabled) {
478
479
  const base = path.basename(p);
479
- const prev = this._audit.files[base];
480
480
  this._audit.files[base] = {
481
- ...(prev && prev.state === "archived" ? {} : prev),
482
481
  index: this._index,
483
482
  state: "active",
484
483
  gzipPending: false,
@@ -529,46 +528,49 @@ class RotateFileStream extends Writable {
529
528
  }
530
529
  }
531
530
  else if (realBytes === 0) {
532
- try {
533
- fs.unlinkSync(oldPath);
531
+ const adoptedGz = `${oldPath}.gz`;
532
+ if (existsSafe(adoptedGz)) {
533
+ // A concurrent writer archived this segment between our last stat
534
+ // and now — adopt its artifact instead of dropping the record.
535
+ this._finishArchivedRecord(oldBase);
536
+ archive = adoptedGz;
534
537
  }
535
- catch {
536
- /* already gone */
538
+ else {
539
+ try {
540
+ fs.unlinkSync(oldPath);
541
+ }
542
+ catch { /* already gone */ }
543
+ if (rec)
544
+ delete this._audit.files[oldBase];
545
+ this._aevent("drop-empty", { file: oldBase });
537
546
  }
538
- if (rec)
539
- delete this._audit.files[oldBase];
540
- this._aevent("drop-empty", { file: oldBase });
541
547
  }
542
- else if (rec) {
543
- rec.state = "sealed";
544
- rec.bytes = realBytes;
548
+ const stamp = this._stampNow();
549
+ const newPeriod = stamp !== this._curStamp;
550
+ this._curStamp = stamp;
551
+ /* Never blind-reset to 0 — recover what THIS stamp already owns. */
552
+ this._index =
553
+ newPeriod || kind === "time"
554
+ ? this._recoverIndexFor(stamp)
555
+ : this._index + 1;
556
+ this._bytes = 0;
557
+ if (this._auditEnabled) {
558
+ this._setActivePointer(this._index);
559
+ this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
560
+ this._saveAudit();
545
561
  }
562
+ if (kind === "time" || kind === "manual")
563
+ this._armTimer();
564
+ if (oldPath) {
565
+ this.emit("rotated", {
566
+ reason: kind,
567
+ oldFile: oldPath,
568
+ archive,
569
+ newFile: this._resolveName(stamp, this._index),
570
+ });
571
+ }
572
+ this._retire();
546
573
  }
547
- const stamp = this._stampNow();
548
- const newPeriod = stamp !== this._curStamp;
549
- this._curStamp = stamp;
550
- /* Never blind-reset to 0 — recover what THIS stamp already owns. */
551
- this._index =
552
- newPeriod || kind === "time"
553
- ? this._recoverIndexFor(stamp)
554
- : this._index + 1;
555
- this._bytes = 0;
556
- if (this._auditEnabled) {
557
- this._setActivePointer(this._index);
558
- this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
559
- this._saveAudit();
560
- }
561
- if (kind === "time" || kind === "manual")
562
- this._armTimer();
563
- if (oldPath) {
564
- this.emit("rotated", {
565
- reason: kind,
566
- oldFile: oldPath,
567
- archive,
568
- newFile: this._resolveName(stamp, this._index),
569
- });
570
- }
571
- this._retire();
572
574
  }
573
575
  /* ==================================================================
574
576
  * Timers
@@ -859,6 +861,59 @@ class RotateFileStream extends Writable {
859
861
  this.emit("archive", path.join(this.dir, gzName));
860
862
  this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
861
863
  }
864
+ /**
865
+ * Seal raw segments whose period has already passed (crash near midnight,
866
+ * virtual-clock leftovers, untracked files). Disk is the source of truth —
867
+ * manifest-tracked or not, a raw tail nothing will ever write to again
868
+ * must still honour zippedArchive.
869
+ */
870
+ async _sealOrphanedPastStamps() {
871
+ if (!this.zippedArchive)
872
+ return;
873
+ const stampRx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
874
+ "(?:\\.\\d+)?" + escRe(this._tExt) + "(?:\\.gz)?$");
875
+ let names = [];
876
+ try {
877
+ names = fs.readdirSync(this.dir);
878
+ }
879
+ catch {
880
+ return;
881
+ }
882
+ for (const name of names) {
883
+ if (name.endsWith(".gz"))
884
+ continue;
885
+ if (name === (this._path && path.basename(this._path)))
886
+ continue;
887
+ const stamp = stampRx.exec(name)?.[1];
888
+ // lexicographic compare is safe for zero-padded patterns (YYYY-MM-DD…);
889
+ // also guards the current stamp and anything future-dated
890
+ if (!stamp || stamp >= this._curStamp)
891
+ continue;
892
+ const full = path.join(this.dir, name);
893
+ if (!existsSafe(full) || statSizeSafe(full) === 0)
894
+ continue;
895
+ const ok = await gzipMove(full, `${full}.gz`, (e) => this.emit("warn", e));
896
+ if (!ok)
897
+ continue;
898
+ if (this._auditEnabled && this._audit.files[name]) {
899
+ // tracked → re-key record name → name.gz (emits archive + journal)
900
+ this._finishArchivedRecord(name, true);
901
+ }
902
+ else {
903
+ // untracked → record it fresh so future reconciles see a truthful state
904
+ const gzName = `${name}.gz`;
905
+ if (this._auditEnabled) {
906
+ this._audit.files[gzName] = {
907
+ state: "archived",
908
+ gzipPending: false,
909
+ archivedAt: new Date().toISOString(),
910
+ };
911
+ this._aevent("seal-past", { file: gzName });
912
+ }
913
+ this.emit("archive", path.join(this.dir, gzName));
914
+ }
915
+ }
916
+ }
862
917
  /* ==================================================================
863
918
  * Retention
864
919
  * ================================================================ */
@@ -103,6 +103,13 @@ export declare class RotateFileStream extends Writable {
103
103
  private _repairInterruptedGzips;
104
104
  /** Seal a record; re-key `name` → `name.gz` so scans stop ghosting. */
105
105
  private _finishArchivedRecord;
106
+ /**
107
+ * Seal raw segments whose period has already passed (crash near midnight,
108
+ * virtual-clock leftovers, untracked files). Disk is the source of truth —
109
+ * manifest-tracked or not, a raw tail nothing will ever write to again
110
+ * must still honour zippedArchive.
111
+ */
112
+ private _sealOrphanedPastStamps;
106
113
  private _retire;
107
114
  }
108
115
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logroller",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Zero-dependency rotating file stream for Node.js — daily/interval/size rotation, gzip archives, retention, timezone-aware, crash-safe resume",
5
5
  "license": "MIT",
6
6
  "author": "Vinau",