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