logroller 1.0.0 → 2.0.0

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/dist/index.cjs CHANGED
@@ -1,22 +1,57 @@
1
1
  /*! logroller v1.0.0 | MIT License */
2
2
  'use strict';
3
3
 
4
+ var node_crypto = require('node:crypto');
4
5
  var fs = require('node:fs');
5
6
  var path = require('node:path');
6
- var zlib = require('node:zlib');
7
7
  var node_stream = require('node:stream');
8
+ var zlib = require('node:zlib');
8
9
  var promises = require('node:stream/promises');
9
- var node_crypto = require('node:crypto');
10
10
 
11
- /* ================================================================
12
- * Option parsers
13
- * ================================================================ */
11
+ /** `stat().size` guarded to 0; string paths only. */
12
+ const statSizeSafe = (p) => {
13
+ try {
14
+ return typeof p === "string" ? fs.statSync(p).size : 0;
15
+ }
16
+ catch {
17
+ return 0;
18
+ }
19
+ };
20
+ const existsSafe = (p) => {
21
+ try {
22
+ return fs.existsSync(p);
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ };
28
+ /**
29
+ * Compress `src` → `dst`, deleting `src` ONLY on verified success.
30
+ * On failure removes the partial `dst` and reports via callback —
31
+ * logs degrade to uncompressed rather than losing data.
32
+ */
33
+ async function gzipMove(src, dst, onWarn) {
34
+ try {
35
+ await promises.pipeline(fs.createReadStream(src), zlib.createGzip({ level: 6 }), fs.createWriteStream(dst));
36
+ await fs.promises.unlink(src);
37
+ return true;
38
+ }
39
+ catch (err) {
40
+ await fs.promises.unlink(dst).catch(() => { });
41
+ onWarn?.(err);
42
+ return false;
43
+ }
44
+ }
45
+ const endStream = (ws) => ws ? new Promise((resolve) => ws.end(() => resolve())) : Promise.resolve();
46
+
14
47
  const UNITS = {
15
48
  b: 1, k: 1024, kb: 1024,
16
49
  m: 1048576, mb: 1048576,
17
50
  g: 1073741824, gb: 1073741824,
18
51
  };
19
- const FREQ_MULT = { ms: 1, s: 1000, m: 60000, h: 3600000 };
52
+ const FREQ_MULT = {
53
+ ms: 1, s: 1000, m: 60_000, h: 3_600_000,
54
+ };
20
55
  function parseSize(v) {
21
56
  if (v === undefined || v === null || v === 0)
22
57
  return 0;
@@ -32,57 +67,58 @@ function parseMaxFiles(v) {
32
67
  return null;
33
68
  if (typeof v === "number")
34
69
  return { count: v };
35
- let m = /^\s*(\d+)\s*d\s*$/i.exec(v);
70
+ const m = /^\s*(\d+)\s*d\s*$/i.exec(v);
36
71
  if (m)
37
72
  return { days: Number.parseInt(m[1], 10) };
38
- m = /^\s*(\d+)\s*$/.exec(v);
39
- if (m)
40
- return { count: Number.parseInt(m[1], 10) };
73
+ const n = /^\s*(\d+)\s*$/.exec(v);
74
+ if (n)
75
+ return { count: Number.parseInt(n[1], 10) };
41
76
  throw new Error(`Invalid maxFiles "${v}"`);
42
77
  }
43
78
  function parseFrequency(v) {
44
79
  if (v === undefined || v === null)
45
- v = "daily";
80
+ return { type: "daily" };
46
81
  if (v === "daily")
47
82
  return { type: "daily" };
48
- if (typeof v === "number" && v > 0)
83
+ if (typeof v === "number") {
84
+ if (!(v > 0))
85
+ throw new Error(`Invalid frequency "${v}"`);
49
86
  return { type: "interval", ms: v };
50
- const m = /^\s*(\d+)\s*(ms|s|m|h)\s*$/i.exec(String(v));
87
+ }
88
+ const m = /^\s*(\d+)\s*(ms|s|m|h)\s*$/i.exec(v);
51
89
  if (!m)
52
90
  throw new Error(`Invalid frequency "${v}"`);
53
- const mult = FREQ_MULT[m[2].toLowerCase()] ?? 1; // ◀ TS FIX
54
- return { type: "interval", ms: Number.parseInt(m[1], 10) * mult };
91
+ return { type: "interval", ms: Number.parseInt(m[1], 10) * FREQ_MULT[m[2].toLowerCase()] };
55
92
  }
56
93
  function parseClockOffset(v) {
57
94
  if (v === undefined || v === null || v === "")
58
95
  return 0;
59
96
  if (typeof v === "number")
60
- return Math.round(v * 3600000);
97
+ return Math.round(v * 3_600_000);
61
98
  const m = /^\s*(-?\d+(?:\.\d+)?)\s*h(?:our)?s?\s*$/i.exec(v);
62
99
  if (!m)
63
100
  throw new Error(`Invalid addHours "${v}"`);
64
- return Math.round(Number.parseFloat(m[1]) * 3600000);
101
+ return Math.round(Number.parseFloat(m[1]) * 3_600_000);
65
102
  }
66
103
  function parseClockStep(v) {
67
104
  if (v === undefined || v === null || v === "" || v === 0)
68
105
  return 0;
69
106
  if (typeof v === "number")
70
- return Math.round(v * 3600000);
107
+ return Math.round(v * 3_600_000);
71
108
  const m = /^\s*(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(v);
72
109
  if (!m)
73
110
  throw new Error(`Invalid addHoursEveryMin "${v}"`);
74
- const mult = FREQ_MULT[(m[2] || "h").toLowerCase()] ?? 1; // ◀ TS FIX
75
- return Math.round(Number.parseFloat(m[1]) * mult);
111
+ return Math.round(Number.parseFloat(m[1]) * FREQ_MULT[(m[2] ?? "h").toLowerCase()]);
76
112
  }
77
- /* ================================================================
78
- * Timezone helpers
79
- * ================================================================ */
113
+
114
+ /* Timezone-aware helpers built exclusively on Intl (zero deps). */
80
115
  const _dtfCache = new Map();
116
+ /** Cached `Intl.DateTimeFormat`; throws synchronously for invalid zones. */
81
117
  function getDtf(tz) {
82
- let d = _dtfCache.get(tz);
83
- if (!d) {
118
+ let dtf = _dtfCache.get(tz);
119
+ if (!dtf) {
84
120
  try {
85
- d = new Intl.DateTimeFormat("en-US", {
121
+ dtf = new Intl.DateTimeFormat("en-US", {
86
122
  timeZone: tz,
87
123
  hourCycle: "h23",
88
124
  year: "numeric",
@@ -96,9 +132,9 @@ function getDtf(tz) {
96
132
  catch {
97
133
  throw new Error(`Invalid timeZone "${tz}"`);
98
134
  }
99
- _dtfCache.set(tz, d);
135
+ _dtfCache.set(tz, dtf);
100
136
  }
101
- return d;
137
+ return dtf;
102
138
  }
103
139
  function partsIn(tz, date) {
104
140
  const o = {};
@@ -107,21 +143,21 @@ function partsIn(tz, date) {
107
143
  return o;
108
144
  }
109
145
  const pad2 = (n) => n.padStart(2, "0");
110
- const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
146
+ /** Escape a literal string for embedding inside a RegExp. */
147
+ function escRe(s) {
148
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ }
150
+ /** Substitute YYYY|YY|MM|DD|HH|mm|ss tokens in `pattern`. */
111
151
  function formatStamp(pattern, parts) {
112
- return pattern.replace(/YYYY|YY|MM|DD|HH|mm|ss/g, (t) => t === "YYYY" ? parts.year :
113
- t === "YY" ? parts.year.slice(-2) :
114
- t === "MM" ? pad2(parts.month) :
115
- t === "DD" ? pad2(parts.day) :
116
- t === "HH" ? pad2(parts.hour) :
117
- t === "mm" ? pad2(parts.minute) :
118
- t === "ss" ? pad2(parts.second) : t);
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);
119
153
  }
154
+ /** Offset of `tz` vs UTC at instant `date`, milliseconds. */
120
155
  function tzOffsetMs(tz, date) {
121
156
  const p = partsIn(tz, date);
122
157
  return (Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) -
123
158
  (date.getTime() - (date.getTime() % 1000)));
124
159
  }
160
+ /** Next local midnight in `tz`, DST-corrected by fixed-point iteration. */
125
161
  function nextMidnight(tz, fromMs) {
126
162
  const p = partsIn(tz, new Date(fromMs));
127
163
  const localNext = Date.UTC(+p.year, +p.month - 1, +p.day + 1);
@@ -133,50 +169,47 @@ function nextMidnight(tz, fromMs) {
133
169
  inst = cand;
134
170
  }
135
171
  if (inst <= fromMs)
136
- inst += 86400000;
172
+ inst += 86_400_000;
137
173
  return inst;
138
174
  }
175
+ /** Next epoch-aligned boundary (e.g. hh:00 for step = 1h). */
139
176
  const nextAligned = (from, step) => Math.floor(from / step) * step + step;
140
- /* ================================================================
141
- * Utilities
142
- * ================================================================ */
143
- const statSizeSafe = (p) => {
144
- try {
145
- return typeof p === "string" ? fs.statSync(p).size : 0;
146
- }
147
- catch {
148
- return 0;
149
- }
150
- };
151
- const existsSafe = (p) => {
152
- try {
153
- return fs.existsSync(p);
154
- }
155
- catch {
156
- return false;
157
- }
158
- };
159
- /** ◀ TS FIX: takes an onWarn CALLBACK instead of an emitter object,
160
- * so the strictly-typed `emit` can be passed without casts. */
161
- async function gzipMove(src, dst, onWarn) {
162
- try {
163
- await promises.pipeline(fs.createReadStream(src), zlib.createGzip({ level: 6 }), fs.createWriteStream(dst));
164
- await fs.promises.unlink(src);
165
- return true;
166
- }
167
- catch (err) {
168
- await fs.promises.unlink(dst).catch(() => { });
169
- onWarn?.(err);
170
- return false;
171
- }
172
- }
173
- const endStream = (ws) => ws ? new Promise((r) => ws.end(() => r())) : Promise.resolve();
177
+
174
178
  const BUMP_DEFAULT_MS = 60_000;
175
179
  const AUDIT_MAX_FILES = 600;
180
+ const AUDIT_MAX_EVENTS = 200;
176
181
  const HEARTBEAT_STALE_MS = 30_000;
177
- /* ================================================================
178
- * Main class
179
- * ================================================================ */
182
+ const emptyAudit = (prefix, ext, datePattern, tz) => ({
183
+ version: 1,
184
+ prefix,
185
+ ext,
186
+ datePattern,
187
+ tz,
188
+ createdAt: null,
189
+ updatedAt: null,
190
+ activeStamp: null,
191
+ activeIndex: null,
192
+ writer: null,
193
+ files: {},
194
+ events: [],
195
+ });
196
+ /**
197
+ * A `Writable` that appends to dated log files, rolling segments on time
198
+ * and/or size, optionally gzipping sealed segments, enforcing retention,
199
+ * and resuming an unbroken series across restarts via disk scan + audit
200
+ * manifest.
201
+ *
202
+ * @example
203
+ * ```ts
204
+ * const log = createStream({
205
+ * filename: "logs/app-%DATE%.log",
206
+ * maxSize: "20m",
207
+ * maxFiles: "90d",
208
+ * zippedArchive: true,
209
+ * });
210
+ * log.write("hello\n"); // or: readable.pipe(log)
211
+ * ```
212
+ */
180
213
  class RotateFileStream extends node_stream.Writable {
181
214
  tz;
182
215
  template;
@@ -187,17 +220,21 @@ class RotateFileStream extends node_stream.Writable {
187
220
  maxFiles;
188
221
  freq;
189
222
  unrefTimers;
223
+ /* ---- filename-template pieces ---- */
190
224
  _tL;
191
225
  _tMid;
192
226
  _tExt;
193
227
  _rxFor;
194
228
  _rxActive;
229
+ /* ---- audit manifest ---- */
195
230
  _auditEnabled;
196
231
  _auditPath;
197
232
  _bootId = node_crypto.randomUUID();
233
+ /* ---- virtual test clock ---- */
198
234
  _offsetMs;
199
235
  _stepMs;
200
236
  _bumpEvery;
237
+ /* ---- live state ---- */
201
238
  _curStamp;
202
239
  _index;
203
240
  _ws = null;
@@ -208,15 +245,16 @@ class RotateFileStream extends node_stream.Writable {
208
245
  _ended = false;
209
246
  _tail = Promise.resolve();
210
247
  _audit;
211
- // ◀ TS FIX: Partial<> + runtime validation (default {} is legal now)
212
248
  constructor(opts = {}) {
213
249
  super(opts.highWaterMark ? { highWaterMark: opts.highWaterMark } : {});
214
- if (typeof opts.filename !== "string" || !opts.filename.includes("%DATE%"))
215
- throw new Error("opts.filename must contain '%DATE%'");
250
+ const filename = opts.filename;
251
+ if (typeof filename !== "string" || !filename.includes("%DATE%")) {
252
+ throw new Error("opts.filename must contain '%DATE%' (e.g. 'logs/app-%DATE%.log')");
253
+ }
216
254
  this.tz = opts.tz || "UTC";
217
- getDtf(this.tz);
218
- this.template = path.basename(opts.filename);
219
- this.dir = path.dirname(opts.filename);
255
+ getDtf(this.tz); // validate early, fail fast
256
+ this.template = path.basename(filename);
257
+ this.dir = path.dirname(filename);
220
258
  this.datePattern = opts.datePattern || "YYYY-MM-DD";
221
259
  this.zippedArchive = !!opts.zippedArchive;
222
260
  this.maxSize = parseSize(opts.maxSize ?? 0);
@@ -226,38 +264,41 @@ class RotateFileStream extends node_stream.Writable {
226
264
  this._offsetMs = parseClockOffset(opts.addHours);
227
265
  this._stepMs = parseClockStep(opts.addHoursEveryMin);
228
266
  this._bumpEvery = Math.max(50, opts.clockStepIntervalMs ?? BUMP_DEFAULT_MS);
267
+ /* Split "prefix-%DATE%mid.ext" → pieces so indices land BETWEEN the
268
+ stamp and the extension: prefix-STAMP.N.ext */
229
269
  const li = this.template.indexOf("%DATE%");
230
- const rest = this.template.slice(li + 6);
270
+ const rest = this.template.slice(li + "%DATE%".length);
231
271
  const dot = rest.lastIndexOf(".");
232
272
  this._tL = this.template.slice(0, li);
233
273
  this._tMid = dot >= 0 ? rest.slice(0, dot) : "";
234
274
  this._tExt = dot >= 0 ? rest.slice(dot) : "";
235
- this._rxFor = (stampSrc) => new RegExp("^" + escRe(this._tL) + stampSrc + escRe(this._tMid) +
275
+ this._rxFor = (stampSrc) => new RegExp("^" +
276
+ escRe(this._tL) + stampSrc + escRe(this._tMid) +
236
277
  "(?:\\.(\\d+))?" + escRe(this._tExt) + "(?:\\.gz)?$");
237
278
  this._rxActive = this._rxFor("[^.]+");
238
279
  this._auditEnabled = opts.audit !== false;
239
- const stem = (this._tL.replace(/[^\w.-]+/g, "") || "log").replace(/^[-.]+|[-.]+$/, "");
280
+ const stem = (this._tL.replace(/[^\w.-]+/g, "") || "log")
281
+ .replace(/^[-.]+|[-.]+$/g, "");
240
282
  this._auditPath = path.join(this.dir, opts.auditFile || `${stem}_audit.json`);
241
283
  fs.mkdirSync(this.dir, { recursive: true });
242
284
  this._curStamp = this._stampNow();
243
- this._resetAuditObject();
285
+ this._audit = emptyAudit(this._tL, this._tExt, this.datePattern, this.tz);
244
286
  const hadAudit = this._auditEnabled && this._loadAudit();
245
287
  this._index = this._recoverIndexFor(this._curStamp);
246
- // ◀ TS FIX: local const narrows away null (Number.isInteger doesn't)
247
- const ai = this._audit.activeIndex;
248
- if (hadAudit && ai !== null && this._audit.activeStamp === this._curStamp) {
249
- this._index = Math.max(this._index, ai);
288
+ const aiBoot = this._audit.activeIndex;
289
+ if (hadAudit && aiBoot !== null && this._audit.activeStamp === this._curStamp) {
290
+ this._index = Math.max(this._index, aiBoot);
250
291
  }
251
- if (this._offsetMs !== 0 || this._stepMs !== 0)
292
+ if (this._offsetMs !== 0 || this._stepMs !== 0) {
252
293
  process.nextTick(() => this.emit("clock", {
253
294
  addHours: opts.addHours ?? undefined,
254
295
  addHoursEveryMin: opts.addHoursEveryMin ?? undefined,
255
296
  offsetMs: this._offsetMs,
256
297
  }));
257
- /* boot audit pass = job #1 in the serial queue.
258
- Chained HERE (not nextTick) so it always runs before any write job:
259
- zombie demotion sees _path === null, and gzip-repair can never race
260
- with a write reopening a pending file. */
298
+ }
299
+ /* Boot housekeeping is job #1 of the serial queue — chained HERE in the
300
+ constructor so it deterministically precedes any write job: zombie
301
+ demotion sees `_path === null`, gzip repair cannot race a reopen. */
261
302
  this._job(async () => {
262
303
  if (this._auditEnabled) {
263
304
  this._demoteZombieActives();
@@ -276,250 +317,53 @@ class RotateFileStream extends node_stream.Writable {
276
317
  this._stopBump();
277
318
  });
278
319
  }
279
- /* ---------------- public ---------------- */
320
+ on(event, listener) {
321
+ return super.on(event, listener);
322
+ }
323
+ once(event, listener) {
324
+ return super.once(event, listener);
325
+ }
326
+ emit(event, ...args) {
327
+ return super.emit(event, ...args);
328
+ }
329
+ /* ==================================================================
330
+ * Public API
331
+ * ================================================================ */
332
+ /** Absolute path of the segment currently written; `null` until first write. */
280
333
  get filename() {
281
334
  return this._path;
282
335
  }
336
+ /** Index of the active segment within the current period. */
283
337
  get currentIndex() {
284
338
  return this._index;
285
339
  }
286
- _now() {
287
- return Date.now() + this._offsetMs;
288
- }
340
+ /**
341
+ * Force an immediate rotation and resolve when the next slot is staged.
342
+ */
289
343
  rotateNow() {
290
344
  return this._job(() => this._rotate("manual"));
291
345
  }
292
- /** Apply one virtual-clock step immediately (test helper). */
346
+ /**
347
+ * Apply ONE virtual-clock tick immediately (bypasses the interval timer
348
+ * for deterministic tests). Crossing into a new period triggers the same
349
+ * archive flow as a real midnight roll.
350
+ */
293
351
  advanceClock() {
294
352
  return this._job(() => this._applyBump());
295
353
  }
296
- /* ---------------- audit internals ---------------- */
297
- _resetAuditObject() {
298
- this._audit = {
299
- version: 1,
300
- prefix: this._tL,
301
- ext: this._tExt,
302
- datePattern: this.datePattern,
303
- tz: this.tz,
304
- createdAt: null,
305
- updatedAt: null,
306
- activeStamp: null,
307
- activeIndex: null,
308
- writer: null,
309
- files: {},
310
- events: [],
311
- };
312
- }
313
- _loadAudit() {
314
- try {
315
- let raw;
316
- try {
317
- raw = JSON.parse(fs.readFileSync(this._auditPath, "utf8"));
318
- }
319
- catch {
320
- return false;
321
- }
322
- const j = raw;
323
- if (j && j.version === 1 && j.prefix === this._tL && j.ext === this._tExt) {
324
- this._audit.createdAt = j.createdAt || new Date().toISOString();
325
- this._audit.updatedAt = null;
326
- this._audit.activeStamp =
327
- typeof j.activeStamp === "string" ? j.activeStamp : null;
328
- this._audit.activeIndex =
329
- Number.isInteger(j.activeIndex) ? j.activeIndex : null;
330
- this._audit.files =
331
- j.files && typeof j.files === "object"
332
- ? j.files
333
- : {};
334
- this._audit.events = Array.isArray(j.events)
335
- ? j.events.slice(-200)
336
- : [];
337
- const w = j.writer;
338
- if (w && w.bootId && w.bootId !== this._bootId &&
339
- typeof w.heartbeat === "number" &&
340
- Date.now() - w.heartbeat < HEARTBEAT_STALE_MS) {
341
- const err = new Error(`concurrent writer detected (pid ${w.pid}, boot ${w.bootId}). ` +
342
- `Two processes are sharing this log family — results will race. ` +
343
- `If using 'node --watch', add --watch-path to exclude the logs dir.`);
344
- this.emit("warn", err);
345
- this._audit.events.push({
346
- id: node_crypto.randomUUID(),
347
- t: new Date().toISOString(),
348
- type: "concurrent-writer",
349
- otherPid: w.pid,
350
- otherBoot: w.bootId,
351
- });
352
- }
353
- return true;
354
- }
355
- try {
356
- fs.renameSync(this._auditPath, this._auditPath + ".corrupt");
357
- }
358
- catch {
359
- /* ignore */
360
- }
361
- }
362
- catch {
363
- /* fallthrough */
364
- }
365
- return false;
366
- }
367
- _saveAudit() {
368
- if (!this._auditEnabled)
369
- return;
370
- try {
371
- this._pruneAuditFiles();
372
- this._audit.updatedAt = new Date().toISOString();
373
- this._audit.writer = {
374
- pid: process.pid,
375
- bootId: this._bootId,
376
- heartbeat: Date.now(),
377
- };
378
- const tmp = this._auditPath + ".tmp";
379
- fs.writeFileSync(tmp, JSON.stringify(this._audit, null, 1));
380
- fs.renameSync(tmp, this._auditPath);
381
- }
382
- catch (e) {
383
- this.emit("warn", e);
384
- }
385
- }
386
- _pruneAuditFiles() {
387
- const f = this._audit.files;
388
- const keys = Object.keys(f);
389
- if (keys.length <= AUDIT_MAX_FILES)
390
- return;
391
- const activeBase = this._path && path.basename(this._path);
392
- const dated = (r) => r.archivedAt || r.closedAt || r.abandonedAt || r.openedAt || "";
393
- const stale = keys
394
- .filter((k) => k !== activeBase && f[k].state !== "active")
395
- .sort((a, b) => dated(f[a]).localeCompare(dated(f[b])));
396
- for (const k of stale.slice(0, keys.length - AUDIT_MAX_FILES + 50))
397
- delete f[k];
398
- }
399
- _aevent(type, extra = {}) {
400
- if (!this._auditEnabled)
401
- return;
402
- this._audit.events.push({
403
- id: node_crypto.randomUUID(),
404
- t: new Date().toISOString(),
405
- type,
406
- ...extra,
407
- });
408
- if (this._audit.events.length > 200)
409
- this._audit.events.splice(0, this._audit.events.length - 200);
410
- }
411
- _setActivePointer(index) {
412
- this._audit.activeStamp = this._curStamp;
413
- this._audit.activeIndex = index;
414
- }
415
- _demoteZombieActives() {
416
- const activeBase = this._path && path.basename(this._path);
417
- for (const [name, rec] of Object.entries(this._audit.files)) {
418
- if (rec.state === "active" && name !== activeBase) {
419
- rec.state = "abandoned";
420
- rec.abandonedAt = new Date().toISOString();
421
- this._aevent("abandon", { file: name });
422
- }
423
- }
424
- }
425
- _reconcileWithDisk() {
426
- for (const name of Object.keys(this._audit.files)) {
427
- const rec = this._audit.files[name];
428
- if (rec.gzipPending)
429
- continue;
430
- if (name.endsWith(".gz")) {
431
- const gzExists = existsSafe(path.join(this.dir, name));
432
- const rawName = name.slice(0, -3);
433
- const rawExists = existsSafe(path.join(this.dir, rawName));
434
- if (gzExists)
435
- continue;
436
- if (rawExists) {
437
- delete this._audit.files[name];
438
- this._audit.files[rawName] = { ...rec, state: "sealed" };
439
- this._aevent("archive-regressed", { file: name, backTo: rawName });
440
- this.emit("warn", new Error(`archive ${name} missing but raw ${rawName} exists — record reverted`));
441
- continue;
442
- }
443
- this._aevent("vanished", { file: name });
444
- delete this._audit.files[name];
445
- continue;
446
- }
447
- const rawExists = existsSafe(path.join(this.dir, name));
448
- const gzExists = existsSafe(path.join(this.dir, name + ".gz"));
449
- if (rawExists)
450
- continue;
451
- if (gzExists) {
452
- this._finishArchivedRecord(name, true);
453
- continue;
454
- }
455
- this._aevent("vanished", { file: name });
456
- delete this._audit.files[name];
457
- }
458
- }
459
- async _repairInterruptedGzips() {
460
- for (const [name, rec] of Object.entries(this._audit.files)) {
461
- if (!rec.gzipPending)
462
- continue;
463
- const src = path.join(this.dir, name);
464
- const dst = src + ".gz";
465
- if (!existsSafe(src)) {
466
- rec.gzipPending = false;
467
- rec.state = "lost";
468
- this._aevent("gzip-lost", { file: name });
469
- continue;
470
- }
471
- if (existsSafe(dst)) {
472
- try {
473
- fs.unlinkSync(src);
474
- }
475
- catch {
476
- /* ignore */
477
- }
478
- this._finishArchivedRecord(name, true);
479
- continue;
480
- }
481
- // ◀ TS FIX: callback instead of emitter object
482
- const ok = await gzipMove(src, dst, (e) => this.emit("warn", e));
483
- if (ok) {
484
- this._finishArchivedRecord(name, true);
485
- }
486
- else {
487
- rec.gzipPending = false;
488
- rec.state = "kept-raw";
489
- this._aevent("gzip-failed", { file: name });
490
- }
491
- }
492
- }
493
- _finishArchivedRecord(rawName, repaired = false) {
494
- const rec = this._audit.files[rawName];
495
- if (!rec)
496
- return;
497
- rec.gzipPending = false;
498
- rec.state = "archived";
499
- rec.archivedAt = new Date().toISOString();
500
- if (rawName.endsWith(".gz")) {
501
- this.emit("archive", path.join(this.dir, rawName));
502
- this._aevent("archive", { file: rawName, ...(repaired && { repaired }) });
503
- }
504
- else {
505
- const gzName = rawName + ".gz";
506
- delete this._audit.files[rawName];
507
- this._audit.files[gzName] = rec;
508
- this.emit("archive", path.join(this.dir, gzName));
509
- this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
510
- }
511
- }
512
- /* ---------------- Writable ---------------- */
513
- _write(chunk, encoding, cb) {
354
+ /* ==================================================================
355
+ * Writable contract
356
+ * ================================================================ */
357
+ _write(chunk, encoding, callback) {
514
358
  if (this._ended) {
515
- process.nextTick(cb, new Error("write after end"));
359
+ process.nextTick(callback, new Error("write after end"));
516
360
  return;
517
361
  }
518
362
  const len = chunk.length;
519
363
  this._job(async () => {
520
364
  try {
521
- const s = this._stampNow();
522
- if (s !== this._curStamp)
365
+ const stamp = this._stampNow();
366
+ if (stamp !== this._curStamp)
523
367
  await this._rotate("time");
524
368
  await this._ensureOpen();
525
369
  if (this.maxSize > 0 &&
@@ -528,50 +372,56 @@ class RotateFileStream extends node_stream.Writable {
528
372
  await this._rotate("size");
529
373
  await this._ensureOpen();
530
374
  }
531
- // ◀ TS FIX: narrow instead of `this._ws!`
532
375
  const ws = this._ws;
533
376
  if (!ws)
534
- throw new Error("write stream not open");
535
- await new Promise((res, rej) => ws.write(chunk, encoding, (e) => (e ? rej(e) : res())));
377
+ throw new Error("segment write stream not open");
378
+ await new Promise((resolve, reject) => ws.write(chunk, encoding, (e) => (e ? reject(e) : resolve())));
536
379
  this._bytes += len;
537
- cb();
380
+ callback();
538
381
  }
539
382
  catch (err) {
540
- this.emit("error", err); // ok now: "error" in the events map
541
- cb(err);
383
+ this.emit("error", err);
384
+ callback(err);
542
385
  }
543
386
  }).catch(() => { });
544
387
  }
545
- _final(cb) {
388
+ _final(callback) {
546
389
  this._ended = true;
547
390
  this._stopTimer();
548
391
  this._stopBump();
549
392
  this._job(async () => {
550
393
  await endStream(this._ws);
551
- const b = this._path && path.basename(this._path);
552
- if (b && this._audit.files[b]) {
553
- this._audit.files[b].state = "closed";
554
- this._audit.files[b].closedAt = new Date().toISOString();
555
- this._audit.files[b].bytes = statSizeSafe(this._path);
556
- this._aevent("close", { file: b });
394
+ const base = this._path && path.basename(this._path);
395
+ if (base && this._audit.files[base]) {
396
+ const rec = this._audit.files[base];
397
+ rec.state = "closed";
398
+ rec.closedAt = new Date().toISOString();
399
+ rec.bytes = statSizeSafe(this._path);
400
+ this._aevent("close", { file: base });
557
401
  this._saveAudit();
558
402
  }
559
403
  this._ws = null;
560
404
  this._path = null;
561
405
  })
562
406
  .catch(() => { })
563
- .finally(() => cb());
407
+ .finally(() => callback());
564
408
  }
565
409
  destroy(...args) {
566
410
  this._stopTimer();
567
411
  this._stopBump();
568
412
  return super.destroy(...args);
569
413
  }
570
- /* ---------------- core ---------------- */
414
+ /* ==================================================================
415
+ * Serialisation — every mutating operation funnels through one queue
416
+ * so rotations/gzips/writes never interleave.
417
+ * ================================================================ */
571
418
  _job(fn) {
572
- const p = this._tail.then(fn, fn);
573
- this._tail = p.catch(() => { });
574
- return p;
419
+ const run = this._tail.then(fn, fn);
420
+ this._tail = run.catch(() => { });
421
+ return run;
422
+ }
423
+ _now() {
424
+ return Date.now() + this._offsetMs;
575
425
  }
576
426
  _stampNow(d = new Date(this._now())) {
577
427
  return formatStamp(this.datePattern, partsIn(this.tz, d));
@@ -584,6 +434,11 @@ class RotateFileStream extends node_stream.Writable {
584
434
  ? base.replace(/\.[^.]+$/, `.${index}$&`)
585
435
  : `${base}.${index}`;
586
436
  }
437
+ /**
438
+ * Highest viable segment index for `stamp`.
439
+ * Disk is truth: resume a raw tail; start after sealed ones.
440
+ * The audit pointer (same stamp only) may only RAISE the result.
441
+ */
587
442
  _recoverIndexFor(stamp) {
588
443
  const rx = this._rxFor(escRe(stamp));
589
444
  let maxAny = -1;
@@ -593,7 +448,7 @@ class RotateFileStream extends node_stream.Writable {
593
448
  names = fs.readdirSync(this.dir);
594
449
  }
595
450
  catch {
596
- /* ignore */
451
+ /* unreadable dir → fresh series */
597
452
  }
598
453
  for (const n of names) {
599
454
  const m = rx.exec(n);
@@ -606,11 +461,8 @@ class RotateFileStream extends node_stream.Writable {
606
461
  maxRaw = idx;
607
462
  }
608
463
  let idx = maxRaw >= 0 ? maxRaw : maxAny >= 0 ? maxAny + 1 : 0;
609
- // ◀ TS FIX: local const narrows away null
610
464
  const ai = this._audit.activeIndex;
611
- if (this._auditEnabled &&
612
- ai !== null &&
613
- this._audit.activeStamp === stamp) {
465
+ if (this._auditEnabled && ai !== null && this._audit.activeStamp === stamp) {
614
466
  idx = Math.max(idx, ai);
615
467
  }
616
468
  return idx;
@@ -625,9 +477,9 @@ class RotateFileStream extends node_stream.Writable {
625
477
  this._ws = ws;
626
478
  this._path = p;
627
479
  if (this._auditEnabled) {
628
- const b = path.basename(p);
629
- const prev = this._audit.files[b];
630
- this._audit.files[b] = {
480
+ const base = path.basename(p);
481
+ const prev = this._audit.files[base];
482
+ this._audit.files[base] = {
631
483
  ...(prev && prev.state === "archived" ? {} : prev),
632
484
  index: this._index,
633
485
  state: "active",
@@ -636,7 +488,7 @@ class RotateFileStream extends node_stream.Writable {
636
488
  };
637
489
  this._setActivePointer(this._index);
638
490
  this._aevent("open", {
639
- file: b,
491
+ file: base,
640
492
  index: this._index,
641
493
  resumedBytes: this._bytes,
642
494
  });
@@ -651,75 +503,78 @@ class RotateFileStream extends node_stream.Writable {
651
503
  this._path = null;
652
504
  this._bytes = 0;
653
505
  let archive = null;
654
- // ◀ TS FIX: guard on oldPath too — narrows string|null for the whole block,
655
- // removing every `!` and the null-concatenation risk
656
506
  if (oldWs && oldPath) {
657
507
  await endStream(oldWs);
658
- const real = statSizeSafe(oldPath);
659
- const abase = path.basename(oldPath);
660
- const arec = this._auditEnabled ? this._audit.files[abase] : null;
661
- if (this.zippedArchive && real > 0) {
662
- archive = oldPath + ".gz";
663
- if (arec) {
664
- arec.gzipPending = true;
665
- arec.bytes = real;
508
+ const realBytes = statSizeSafe(oldPath);
509
+ const oldBase = path.basename(oldPath);
510
+ const rec = this._auditEnabled ? this._audit.files[oldBase] : undefined;
511
+ if (this.zippedArchive && realBytes > 0) {
512
+ archive = `${oldPath}.gz`;
513
+ /* Mark intent BEFORE compressing: crash mid-gzip becomes a
514
+ repairable `gzipPending` flag instead of silent divergence. */
515
+ if (rec) {
516
+ rec.gzipPending = true;
517
+ rec.bytes = realBytes;
666
518
  this._saveAudit();
667
519
  }
668
520
  const ok = await gzipMove(oldPath, archive, (e) => this.emit("warn", e));
669
521
  if (ok) {
670
- this._finishArchivedRecord(abase);
522
+ this._finishArchivedRecord(oldBase);
671
523
  }
672
524
  else {
673
- if (arec) {
674
- arec.gzipPending = false;
675
- arec.state = "kept-raw";
525
+ if (rec) {
526
+ rec.gzipPending = false;
527
+ rec.state = "kept-raw";
676
528
  }
677
- this._aevent("gzip-failed", { file: abase });
529
+ this._aevent("gzip-failed", { file: oldBase });
678
530
  archive = null;
679
531
  }
680
532
  }
681
- else if (real === 0) {
533
+ else if (realBytes === 0) {
682
534
  try {
683
535
  fs.unlinkSync(oldPath);
684
536
  }
685
537
  catch {
686
- /* ignore */
538
+ /* already gone */
687
539
  }
688
- if (arec)
689
- delete this._audit.files[abase];
690
- this._aevent("drop-empty", { file: abase });
540
+ if (rec)
541
+ delete this._audit.files[oldBase];
542
+ this._aevent("drop-empty", { file: oldBase });
691
543
  }
692
- else if (arec) {
693
- arec.state = "sealed";
694
- arec.bytes = real;
544
+ else if (rec) {
545
+ rec.state = "sealed";
546
+ rec.bytes = realBytes;
695
547
  }
696
548
  }
697
- const s = this._stampNow();
698
- const newPeriod = s !== this._curStamp;
699
- this._curStamp = s;
700
- this._index = newPeriod
701
- ? this._recoverIndexFor(s)
702
- : kind === "time"
703
- ? this._recoverIndexFor(s)
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)
704
556
  : this._index + 1;
705
557
  this._bytes = 0;
706
558
  if (this._auditEnabled) {
707
559
  this._setActivePointer(this._index);
708
- this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp: s });
560
+ this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
709
561
  this._saveAudit();
710
562
  }
711
563
  if (kind === "time" || kind === "manual")
712
564
  this._armTimer();
713
- if (oldPath)
565
+ if (oldPath) {
714
566
  this.emit("rotated", {
715
567
  reason: kind,
716
568
  oldFile: oldPath,
717
569
  archive,
718
- newFile: this._resolveName(s, this._index),
570
+ newFile: this._resolveName(stamp, this._index),
719
571
  });
572
+ }
720
573
  this._retire();
721
574
  }
722
- /* ---------------- timers ---------------- */
575
+ /* ==================================================================
576
+ * Timers
577
+ * ================================================================ */
723
578
  _hasContent() {
724
579
  return this._ws
725
580
  ? this._bytes > 0 || statSizeSafe(this._path) > 0
@@ -735,21 +590,22 @@ class RotateFileStream extends node_stream.Writable {
735
590
  : nextAligned(now, this.freq.ms);
736
591
  this._timer = setTimeout(() => {
737
592
  this._job(async () => {
738
- const s = this._stampNow();
739
- if ((this.freq.type === "daily" && s !== this._curStamp) ||
740
- this.freq.type !== "daily") {
593
+ 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) {
741
597
  if (this._hasContent()) {
742
598
  await this._rotate("time");
743
599
  }
744
- else if (s !== this._curStamp) {
745
- this._curStamp = s;
746
- this._index = this._recoverIndexFor(s);
600
+ else if (stamp !== this._curStamp) {
601
+ this._curStamp = stamp;
602
+ this._index = this._recoverIndexFor(stamp);
747
603
  if (this._auditEnabled) {
748
604
  this._setActivePointer(this._index);
749
- this._aevent("period", { stamp: s });
605
+ this._aevent("period", { stamp });
750
606
  this._saveAudit();
751
607
  }
752
- this.emit("period", s);
608
+ this.emit("period", stamp);
753
609
  }
754
610
  }
755
611
  this._armTimer();
@@ -777,26 +633,26 @@ class RotateFileStream extends node_stream.Writable {
777
633
  return;
778
634
  this._offsetMs += this._stepMs;
779
635
  this._armTimer();
780
- const s = this._stampNow();
781
- if (s !== this._curStamp) {
636
+ const stamp = this._stampNow();
637
+ if (stamp !== this._curStamp) {
782
638
  if (this._hasContent()) {
783
639
  await this._rotate("time");
784
640
  }
785
641
  else {
786
- this._curStamp = s;
787
- this._index = this._recoverIndexFor(s);
642
+ this._curStamp = stamp;
643
+ this._index = this._recoverIndexFor(stamp);
788
644
  if (this._auditEnabled) {
789
645
  this._setActivePointer(this._index);
790
- this._aevent("period", { stamp: s, virtual: true });
646
+ this._aevent("period", { stamp, virtual: true });
791
647
  }
792
- this.emit("period", s);
648
+ this.emit("period", stamp);
793
649
  }
794
650
  }
795
651
  if (this._auditEnabled) {
796
652
  this._aevent("clock-step", {
797
653
  addedMsPerMin: this._stepMs,
798
654
  offsetMs: this._offsetMs,
799
- stamp: s,
655
+ stamp,
800
656
  });
801
657
  this._saveAudit();
802
658
  }
@@ -807,66 +663,271 @@ class RotateFileStream extends node_stream.Writable {
807
663
  this._timer = null;
808
664
  }
809
665
  }
810
- /* ---------------- retention ---------------- */
666
+ /* ==================================================================
667
+ * Audit manifest
668
+ * ================================================================ */
669
+ _loadAudit() {
670
+ let parsed;
671
+ try {
672
+ parsed = JSON.parse(fs.readFileSync(this._auditPath, "utf8"));
673
+ }
674
+ catch {
675
+ return false; // missing/unreadable → fresh start
676
+ }
677
+ const j = parsed;
678
+ if (!j || j.version !== 1 || j.prefix !== this._tL || j.ext !== this._tExt) {
679
+ try {
680
+ fs.renameSync(this._auditPath, `${this._auditPath}.corrupt`);
681
+ }
682
+ catch {
683
+ /* ignore */
684
+ }
685
+ return false;
686
+ }
687
+ this._audit.createdAt = j.createdAt ?? new Date().toISOString();
688
+ this._audit.activeStamp =
689
+ typeof j.activeStamp === "string" ? j.activeStamp : null;
690
+ this._audit.activeIndex =
691
+ Number.isInteger(j.activeIndex) ? j.activeIndex : null;
692
+ this._audit.files =
693
+ j.files && typeof j.files === "object"
694
+ ? j.files
695
+ : {};
696
+ this._audit.events = Array.isArray(j.events)
697
+ ? j.events.slice(-AUDIT_MAX_EVENTS)
698
+ : [];
699
+ /* Detect another LIVE process on this family before clobbering it. */
700
+ const w = j.writer;
701
+ if (w &&
702
+ typeof w.bootId === "string" &&
703
+ w.bootId !== this._bootId &&
704
+ typeof w.heartbeat === "number" &&
705
+ Date.now() - w.heartbeat < HEARTBEAT_STALE_MS) {
706
+ const err = new Error(`concurrent writer detected (pid ${w.pid}, boot ${w.bootId}); ` +
707
+ "two processes share this log family and will race. " +
708
+ "If running 'node --watch', exclude the logs dir via --watch-path.");
709
+ this.emit("warn", err);
710
+ this._audit.events.push({
711
+ id: node_crypto.randomUUID(),
712
+ t: new Date().toISOString(),
713
+ type: "concurrent-writer",
714
+ otherPid: w.pid,
715
+ otherBoot: w.bootId,
716
+ });
717
+ }
718
+ return true;
719
+ }
720
+ _saveAudit() {
721
+ if (!this._auditEnabled)
722
+ return;
723
+ try {
724
+ this._pruneAuditFiles();
725
+ this._audit.updatedAt = new Date().toISOString();
726
+ this._audit.writer = {
727
+ pid: process.pid,
728
+ bootId: this._bootId,
729
+ heartbeat: Date.now(),
730
+ };
731
+ const tmp = `${this._auditPath}.tmp`;
732
+ fs.writeFileSync(tmp, JSON.stringify(this._audit, null, 1));
733
+ fs.renameSync(tmp, this._auditPath);
734
+ }
735
+ catch (e) {
736
+ this.emit("warn", e);
737
+ }
738
+ }
739
+ _pruneAuditFiles() {
740
+ const files = this._audit.files;
741
+ const keys = Object.keys(files);
742
+ if (keys.length <= AUDIT_MAX_FILES)
743
+ return;
744
+ const activeBase = this._path && path.basename(this._path);
745
+ const lastTouch = (r) => r.archivedAt ?? r.closedAt ?? r.abandonedAt ?? r.openedAt ?? "";
746
+ const stale = keys
747
+ .filter((k) => k !== activeBase && files[k].state !== "active")
748
+ .sort((a, b) => lastTouch(files[a]).localeCompare(lastTouch(files[b])));
749
+ const excess = keys.length - AUDIT_MAX_FILES + 50;
750
+ for (const key of stale.slice(0, excess))
751
+ delete files[key];
752
+ }
753
+ _aevent(type, extra = {}) {
754
+ if (!this._auditEnabled)
755
+ return;
756
+ const events = this._audit.events;
757
+ events.push({ id: node_crypto.randomUUID(), t: new Date().toISOString(), type, ...extra });
758
+ if (events.length > AUDIT_MAX_EVENTS) {
759
+ events.splice(0, events.length - AUDIT_MAX_EVENTS);
760
+ }
761
+ }
762
+ _setActivePointer(index) {
763
+ this._audit.activeStamp = this._curStamp;
764
+ this._audit.activeIndex = index;
765
+ }
766
+ /** Records left `active` by dead processes → `abandoned`. */
767
+ _demoteZombieActives() {
768
+ const activeBase = this._path && path.basename(this._path);
769
+ for (const [name, rec] of Object.entries(this._audit.files)) {
770
+ if (rec.state === "active" && name !== activeBase) {
771
+ rec.state = "abandoned";
772
+ rec.abandonedAt = new Date().toISOString();
773
+ this._aevent("abandon", { file: name });
774
+ }
775
+ }
776
+ }
777
+ /** Heal audit-vs-disk divergence after crashes / external tampering. */
778
+ _reconcileWithDisk() {
779
+ for (const name of Object.keys(this._audit.files)) {
780
+ const rec = this._audit.files[name];
781
+ if (rec.gzipPending)
782
+ continue; // handled by the repair pass
783
+ if (name.endsWith(".gz")) {
784
+ const gzExists = existsSafe(path.join(this.dir, name));
785
+ const rawName = name.slice(0, -3);
786
+ const rawExists = existsSafe(path.join(this.dir, rawName));
787
+ if (gzExists)
788
+ continue;
789
+ if (rawExists) {
790
+ delete this._audit.files[name];
791
+ this._audit.files[rawName] = { ...rec, state: "sealed" };
792
+ this._aevent("archive-regressed", { file: name, backTo: rawName });
793
+ this.emit("warn", new Error(`archive ${name} missing but raw ${rawName} exists`));
794
+ continue;
795
+ }
796
+ this._aevent("vanished", { file: name });
797
+ delete this._audit.files[name];
798
+ continue;
799
+ }
800
+ const rawExists = existsSafe(path.join(this.dir, name));
801
+ const gzExists = existsSafe(path.join(this.dir, `${name}.gz`));
802
+ if (rawExists)
803
+ continue;
804
+ if (gzExists) {
805
+ this._finishArchivedRecord(name, true); // unlink was interrupted
806
+ continue;
807
+ }
808
+ this._aevent("vanished", { file: name });
809
+ delete this._audit.files[name];
810
+ }
811
+ }
812
+ async _repairInterruptedGzips() {
813
+ for (const [name, rec] of Object.entries(this._audit.files)) {
814
+ if (!rec.gzipPending)
815
+ continue;
816
+ const src = path.join(this.dir, name);
817
+ const dst = `${src}.gz`;
818
+ if (!existsSafe(src)) {
819
+ rec.gzipPending = false;
820
+ rec.state = "lost";
821
+ this._aevent("gzip-lost", { file: name });
822
+ continue;
823
+ }
824
+ if (existsSafe(dst)) {
825
+ try {
826
+ fs.unlinkSync(src);
827
+ }
828
+ catch {
829
+ /* ignore */
830
+ }
831
+ this._finishArchivedRecord(name, true);
832
+ continue;
833
+ }
834
+ const ok = await gzipMove(src, dst, (e) => this.emit("warn", e));
835
+ if (ok) {
836
+ this._finishArchivedRecord(name, true);
837
+ }
838
+ else {
839
+ rec.gzipPending = false;
840
+ rec.state = "kept-raw";
841
+ this._aevent("gzip-failed", { file: name });
842
+ }
843
+ }
844
+ }
845
+ /** Seal a record; re-key `name` → `name.gz` so scans stop ghosting. */
846
+ _finishArchivedRecord(rawName, repaired = false) {
847
+ const rec = this._audit.files[rawName];
848
+ if (!rec)
849
+ return;
850
+ rec.gzipPending = false;
851
+ rec.state = "archived";
852
+ rec.archivedAt = new Date().toISOString();
853
+ if (rawName.endsWith(".gz")) {
854
+ this.emit("archive", path.join(this.dir, rawName));
855
+ this._aevent("archive", { file: rawName, ...(repaired && { repaired }) });
856
+ return;
857
+ }
858
+ const gzName = `${rawName}.gz`;
859
+ delete this._audit.files[rawName];
860
+ this._audit.files[gzName] = rec;
861
+ this.emit("archive", path.join(this.dir, gzName));
862
+ this._aevent("archive", { file: gzName, ...(repaired && { repaired }) });
863
+ }
864
+ /* ==================================================================
865
+ * Retention
866
+ * ================================================================ */
811
867
  async _retire() {
812
- if (!this.maxFiles)
868
+ const policy = this.maxFiles;
869
+ if (!policy)
813
870
  return;
814
871
  const activeBase = this._path && path.basename(this._path);
815
- const activeStem = activeBase && activeBase.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
816
- const auditNames = [
872
+ const activeStem = activeBase?.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
873
+ const reserved = [
817
874
  path.basename(this._auditPath),
818
- path.basename(this._auditPath) + ".tmp",
819
- path.basename(this._auditPath) + ".corrupt",
875
+ `${path.basename(this._auditPath)}.tmp`,
876
+ `${path.basename(this._auditPath)}.corrupt`,
820
877
  ];
821
878
  try {
822
- const names = (await fs.promises.readdir(this.dir)).filter((n) => this._rxActive.test(n) &&
879
+ const dirEntries = await fs.promises.readdir(this.dir);
880
+ const candidates = dirEntries.filter((n) => this._rxActive.test(n) &&
823
881
  n !== activeBase &&
824
882
  !(activeStem && n.startsWith(activeStem)) &&
825
- !auditNames.includes(n));
826
- const info = [];
827
- for (const n of names) {
828
- const full = path.join(this.dir, n);
883
+ !reserved.includes(n));
884
+ const stats = [];
885
+ for (const name of candidates) {
886
+ const full = path.join(this.dir, name);
829
887
  try {
830
- info.push({ full, mt: (await fs.promises.stat(full)).mtimeMs });
888
+ stats.push({ full, mtimeMs: (await fs.promises.stat(full)).mtimeMs });
831
889
  }
832
890
  catch {
833
- /* ignore */
891
+ /* raced deletion */
834
892
  }
835
893
  }
836
- info.sort((a, b) => a.mt - b.mt);
894
+ stats.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
837
895
  const kill = new Set();
838
- if (this.maxFiles.days != null) {
839
- const cut = this._now() - this.maxFiles.days * 86400000;
840
- for (const f of info)
841
- if (f.mt < cut)
896
+ if (policy.days != null) {
897
+ const cutoff = this._now() - policy.days * 86_400_000;
898
+ for (const f of stats)
899
+ if (f.mtimeMs < cutoff)
842
900
  kill.add(f.full);
843
901
  }
844
- if (this.maxFiles.count != null) {
845
- const rest = info.filter((f) => !kill.has(f.full));
846
- for (const f of rest.slice(0, Math.max(0, rest.length - this.maxFiles.count)))
847
- kill.add(f.full);
902
+ if (policy.count != null) {
903
+ const survivors = stats.filter((f) => !kill.has(f.full));
904
+ const excessCount = survivors.length - policy.count;
905
+ for (let i = 0; i < excessCount; i++)
906
+ kill.add(survivors[i].full);
848
907
  }
849
- let removed = false;
850
- for (const f of kill) {
851
- await fs.promises.unlink(f).catch(() => { });
908
+ if (kill.size === 0)
909
+ return;
910
+ for (const file of kill) {
911
+ await fs.promises.unlink(file).catch(() => { });
852
912
  if (this._auditEnabled) {
853
- delete this._audit.files[path.basename(f)];
854
- this._aevent("deleted", { file: path.basename(f) });
913
+ delete this._audit.files[path.basename(file)];
914
+ this._aevent("deleted", { file: path.basename(file) });
855
915
  }
856
- this.emit("deleted", f);
857
- removed = true;
916
+ this.emit("deleted", file);
858
917
  }
859
- if (removed)
860
- this._saveAudit();
918
+ this._saveAudit();
861
919
  }
862
920
  catch (e) {
863
921
  this.emit("warn", e);
864
922
  }
865
923
  }
866
924
  }
867
- /* ================================================================
868
- * Factory
869
- * ================================================================ */
925
+ /**
926
+ * Convenience factory mirroring common rotator APIs.
927
+ *
928
+ * @example
929
+ * const log = createStream({ filename: "logs/app-%DATE%.log" });
930
+ */
870
931
  function createStream(options) {
871
932
  return new RotateFileStream(options);
872
933
  }