logroller 1.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.esm.js CHANGED
@@ -1,20 +1,55 @@
1
- /*! logroller v1.0.0 | MIT License */
1
+ /*! logroller v2.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,42 +262,46 @@ 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();
262
303
  this._reconcileWithDisk();
304
+ await this._sealOrphanedPastStamps();
263
305
  await this._repairInterruptedGzips();
264
306
  this._aevent("boot", { stamp: this._curStamp, index: this._index });
265
307
  this._saveAudit();
@@ -274,250 +316,53 @@ class RotateFileStream extends Writable {
274
316
  this._stopBump();
275
317
  });
276
318
  }
277
- /* ---------------- public ---------------- */
319
+ on(event, listener) {
320
+ return super.on(event, listener);
321
+ }
322
+ once(event, listener) {
323
+ return super.once(event, listener);
324
+ }
325
+ emit(event, ...args) {
326
+ return super.emit(event, ...args);
327
+ }
328
+ /* ==================================================================
329
+ * Public API
330
+ * ================================================================ */
331
+ /** Absolute path of the segment currently written; `null` until first write. */
278
332
  get filename() {
279
333
  return this._path;
280
334
  }
335
+ /** Index of the active segment within the current period. */
281
336
  get currentIndex() {
282
337
  return this._index;
283
338
  }
284
- _now() {
285
- return Date.now() + this._offsetMs;
286
- }
339
+ /**
340
+ * Force an immediate rotation and resolve when the next slot is staged.
341
+ */
287
342
  rotateNow() {
288
343
  return this._job(() => this._rotate("manual"));
289
344
  }
290
- /** Apply one virtual-clock step immediately (test helper). */
345
+ /**
346
+ * Apply ONE virtual-clock tick immediately (bypasses the interval timer
347
+ * for deterministic tests). Crossing into a new period triggers the same
348
+ * archive flow as a real midnight roll.
349
+ */
291
350
  advanceClock() {
292
351
  return this._job(() => this._applyBump());
293
352
  }
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) {
353
+ /* ==================================================================
354
+ * Writable contract
355
+ * ================================================================ */
356
+ _write(chunk, encoding, callback) {
512
357
  if (this._ended) {
513
- process.nextTick(cb, new Error("write after end"));
358
+ process.nextTick(callback, new Error("write after end"));
514
359
  return;
515
360
  }
516
361
  const len = chunk.length;
517
362
  this._job(async () => {
518
363
  try {
519
- const s = this._stampNow();
520
- if (s !== this._curStamp)
364
+ const stamp = this._stampNow();
365
+ if (stamp !== this._curStamp)
521
366
  await this._rotate("time");
522
367
  await this._ensureOpen();
523
368
  if (this.maxSize > 0 &&
@@ -526,50 +371,56 @@ class RotateFileStream extends Writable {
526
371
  await this._rotate("size");
527
372
  await this._ensureOpen();
528
373
  }
529
- // ◀ TS FIX: narrow instead of `this._ws!`
530
374
  const ws = this._ws;
531
375
  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())));
376
+ throw new Error("segment write stream not open");
377
+ await new Promise((resolve, reject) => ws.write(chunk, encoding, (e) => (e ? reject(e) : resolve())));
534
378
  this._bytes += len;
535
- cb();
379
+ callback();
536
380
  }
537
381
  catch (err) {
538
- this.emit("error", err); // ok now: "error" in the events map
539
- cb(err);
382
+ this.emit("error", err);
383
+ callback(err);
540
384
  }
541
385
  }).catch(() => { });
542
386
  }
543
- _final(cb) {
387
+ _final(callback) {
544
388
  this._ended = true;
545
389
  this._stopTimer();
546
390
  this._stopBump();
547
391
  this._job(async () => {
548
392
  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 });
393
+ const base = this._path && path.basename(this._path);
394
+ if (base && this._audit.files[base]) {
395
+ const rec = this._audit.files[base];
396
+ rec.state = "closed";
397
+ rec.closedAt = new Date().toISOString();
398
+ rec.bytes = statSizeSafe(this._path);
399
+ this._aevent("close", { file: base });
555
400
  this._saveAudit();
556
401
  }
557
402
  this._ws = null;
558
403
  this._path = null;
559
404
  })
560
405
  .catch(() => { })
561
- .finally(() => cb());
406
+ .finally(() => callback());
562
407
  }
563
408
  destroy(...args) {
564
409
  this._stopTimer();
565
410
  this._stopBump();
566
411
  return super.destroy(...args);
567
412
  }
568
- /* ---------------- core ---------------- */
413
+ /* ==================================================================
414
+ * Serialisation — every mutating operation funnels through one queue
415
+ * so rotations/gzips/writes never interleave.
416
+ * ================================================================ */
569
417
  _job(fn) {
570
- const p = this._tail.then(fn, fn);
571
- this._tail = p.catch(() => { });
572
- return p;
418
+ const run = this._tail.then(fn, fn);
419
+ this._tail = run.catch(() => { });
420
+ return run;
421
+ }
422
+ _now() {
423
+ return Date.now() + this._offsetMs;
573
424
  }
574
425
  _stampNow(d = new Date(this._now())) {
575
426
  return formatStamp(this.datePattern, partsIn(this.tz, d));
@@ -582,6 +433,11 @@ class RotateFileStream extends Writable {
582
433
  ? base.replace(/\.[^.]+$/, `.${index}$&`)
583
434
  : `${base}.${index}`;
584
435
  }
436
+ /**
437
+ * Highest viable segment index for `stamp`.
438
+ * Disk is truth: resume a raw tail; start after sealed ones.
439
+ * The audit pointer (same stamp only) may only RAISE the result.
440
+ */
585
441
  _recoverIndexFor(stamp) {
586
442
  const rx = this._rxFor(escRe(stamp));
587
443
  let maxAny = -1;
@@ -591,7 +447,7 @@ class RotateFileStream extends Writable {
591
447
  names = fs.readdirSync(this.dir);
592
448
  }
593
449
  catch {
594
- /* ignore */
450
+ /* unreadable dir → fresh series */
595
451
  }
596
452
  for (const n of names) {
597
453
  const m = rx.exec(n);
@@ -604,11 +460,8 @@ class RotateFileStream extends Writable {
604
460
  maxRaw = idx;
605
461
  }
606
462
  let idx = maxRaw >= 0 ? maxRaw : maxAny >= 0 ? maxAny + 1 : 0;
607
- // ◀ TS FIX: local const narrows away null
608
463
  const ai = this._audit.activeIndex;
609
- if (this._auditEnabled &&
610
- ai !== null &&
611
- this._audit.activeStamp === stamp) {
464
+ if (this._auditEnabled && ai !== null && this._audit.activeStamp === stamp) {
612
465
  idx = Math.max(idx, ai);
613
466
  }
614
467
  return idx;
@@ -623,10 +476,8 @@ class RotateFileStream extends Writable {
623
476
  this._ws = ws;
624
477
  this._path = p;
625
478
  if (this._auditEnabled) {
626
- const b = path.basename(p);
627
- const prev = this._audit.files[b];
628
- this._audit.files[b] = {
629
- ...(prev && prev.state === "archived" ? {} : prev),
479
+ const base = path.basename(p);
480
+ this._audit.files[base] = {
630
481
  index: this._index,
631
482
  state: "active",
632
483
  gzipPending: false,
@@ -634,7 +485,7 @@ class RotateFileStream extends Writable {
634
485
  };
635
486
  this._setActivePointer(this._index);
636
487
  this._aevent("open", {
637
- file: b,
488
+ file: base,
638
489
  index: this._index,
639
490
  resumedBytes: this._bytes,
640
491
  });
@@ -649,75 +500,81 @@ class RotateFileStream extends Writable {
649
500
  this._path = null;
650
501
  this._bytes = 0;
651
502
  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
503
  if (oldWs && oldPath) {
655
504
  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;
505
+ const realBytes = statSizeSafe(oldPath);
506
+ const oldBase = path.basename(oldPath);
507
+ const rec = this._auditEnabled ? this._audit.files[oldBase] : undefined;
508
+ if (this.zippedArchive && realBytes > 0) {
509
+ archive = `${oldPath}.gz`;
510
+ /* Mark intent BEFORE compressing: crash mid-gzip becomes a
511
+ repairable `gzipPending` flag instead of silent divergence. */
512
+ if (rec) {
513
+ rec.gzipPending = true;
514
+ rec.bytes = realBytes;
664
515
  this._saveAudit();
665
516
  }
666
517
  const ok = await gzipMove(oldPath, archive, (e) => this.emit("warn", e));
667
518
  if (ok) {
668
- this._finishArchivedRecord(abase);
519
+ this._finishArchivedRecord(oldBase);
669
520
  }
670
521
  else {
671
- if (arec) {
672
- arec.gzipPending = false;
673
- arec.state = "kept-raw";
522
+ if (rec) {
523
+ rec.gzipPending = false;
524
+ rec.state = "kept-raw";
674
525
  }
675
- this._aevent("gzip-failed", { file: abase });
526
+ this._aevent("gzip-failed", { file: oldBase });
676
527
  archive = null;
677
528
  }
678
529
  }
679
- else if (real === 0) {
680
- try {
681
- fs.unlinkSync(oldPath);
530
+ else if (realBytes === 0) {
531
+ const adoptedGz = `${oldPath}.gz`;
532
+ if (existsSafe(adoptedGz)) {
533
+ // A concurrent writer archived this segment between our last stat
534
+ // and now — adopt its artifact instead of dropping the record.
535
+ this._finishArchivedRecord(oldBase);
536
+ archive = adoptedGz;
682
537
  }
683
- catch {
684
- /* ignore */
538
+ else {
539
+ try {
540
+ fs.unlinkSync(oldPath);
541
+ }
542
+ catch { /* already gone */ }
543
+ if (rec)
544
+ delete this._audit.files[oldBase];
545
+ this._aevent("drop-empty", { file: oldBase });
685
546
  }
686
- if (arec)
687
- delete this._audit.files[abase];
688
- this._aevent("drop-empty", { file: abase });
689
547
  }
690
- else if (arec) {
691
- arec.state = "sealed";
692
- arec.bytes = real;
548
+ const stamp = this._stampNow();
549
+ const newPeriod = stamp !== this._curStamp;
550
+ this._curStamp = stamp;
551
+ /* Never blind-reset to 0 — recover what THIS stamp already owns. */
552
+ this._index =
553
+ newPeriod || kind === "time"
554
+ ? this._recoverIndexFor(stamp)
555
+ : this._index + 1;
556
+ this._bytes = 0;
557
+ if (this._auditEnabled) {
558
+ this._setActivePointer(this._index);
559
+ this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp });
560
+ this._saveAudit();
693
561
  }
562
+ if (kind === "time" || kind === "manual")
563
+ this._armTimer();
564
+ if (oldPath) {
565
+ this.emit("rotated", {
566
+ reason: kind,
567
+ oldFile: oldPath,
568
+ archive,
569
+ newFile: this._resolveName(stamp, this._index),
570
+ });
571
+ }
572
+ this._retire();
694
573
  }
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)
702
- : this._index + 1;
703
- this._bytes = 0;
704
- if (this._auditEnabled) {
705
- this._setActivePointer(this._index);
706
- this._aevent("rotate", { reason: kind, nextIndex: this._index, stamp: s });
707
- this._saveAudit();
708
- }
709
- if (kind === "time" || kind === "manual")
710
- this._armTimer();
711
- if (oldPath)
712
- this.emit("rotated", {
713
- reason: kind,
714
- oldFile: oldPath,
715
- archive,
716
- newFile: this._resolveName(s, this._index),
717
- });
718
- this._retire();
719
574
  }
720
- /* ---------------- timers ---------------- */
575
+ /* ==================================================================
576
+ * Timers
577
+ * ================================================================ */
721
578
  _hasContent() {
722
579
  return this._ws
723
580
  ? this._bytes > 0 || statSizeSafe(this._path) > 0
@@ -733,21 +590,22 @@ class RotateFileStream extends Writable {
733
590
  : nextAligned(now, this.freq.ms);
734
591
  this._timer = setTimeout(() => {
735
592
  this._job(async () => {
736
- const s = this._stampNow();
737
- if ((this.freq.type === "daily" && s !== this._curStamp) ||
738
- 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) {
739
597
  if (this._hasContent()) {
740
598
  await this._rotate("time");
741
599
  }
742
- else if (s !== this._curStamp) {
743
- this._curStamp = s;
744
- this._index = this._recoverIndexFor(s);
600
+ else if (stamp !== this._curStamp) {
601
+ this._curStamp = stamp;
602
+ this._index = this._recoverIndexFor(stamp);
745
603
  if (this._auditEnabled) {
746
604
  this._setActivePointer(this._index);
747
- this._aevent("period", { stamp: s });
605
+ this._aevent("period", { stamp });
748
606
  this._saveAudit();
749
607
  }
750
- this.emit("period", s);
608
+ this.emit("period", stamp);
751
609
  }
752
610
  }
753
611
  this._armTimer();
@@ -775,26 +633,26 @@ class RotateFileStream extends Writable {
775
633
  return;
776
634
  this._offsetMs += this._stepMs;
777
635
  this._armTimer();
778
- const s = this._stampNow();
779
- if (s !== this._curStamp) {
636
+ const stamp = this._stampNow();
637
+ if (stamp !== this._curStamp) {
780
638
  if (this._hasContent()) {
781
639
  await this._rotate("time");
782
640
  }
783
641
  else {
784
- this._curStamp = s;
785
- this._index = this._recoverIndexFor(s);
642
+ this._curStamp = stamp;
643
+ this._index = this._recoverIndexFor(stamp);
786
644
  if (this._auditEnabled) {
787
645
  this._setActivePointer(this._index);
788
- this._aevent("period", { stamp: s, virtual: true });
646
+ this._aevent("period", { stamp, virtual: true });
789
647
  }
790
- this.emit("period", s);
648
+ this.emit("period", stamp);
791
649
  }
792
650
  }
793
651
  if (this._auditEnabled) {
794
652
  this._aevent("clock-step", {
795
653
  addedMsPerMin: this._stepMs,
796
654
  offsetMs: this._offsetMs,
797
- stamp: s,
655
+ stamp,
798
656
  });
799
657
  this._saveAudit();
800
658
  }
@@ -805,66 +663,324 @@ class RotateFileStream extends Writable {
805
663
  this._timer = null;
806
664
  }
807
665
  }
808
- /* ---------------- 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: 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: 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
+ * Seal raw segments whose period has already passed (crash near midnight,
866
+ * virtual-clock leftovers, untracked files). Disk is the source of truth —
867
+ * manifest-tracked or not, a raw tail nothing will ever write to again
868
+ * must still honour zippedArchive.
869
+ */
870
+ async _sealOrphanedPastStamps() {
871
+ if (!this.zippedArchive)
872
+ return;
873
+ const stampRx = new RegExp("^" + escRe(this._tL) + "(.+?)" + escRe(this._tMid) +
874
+ "(?:\\.\\d+)?" + escRe(this._tExt) + "(?:\\.gz)?$");
875
+ let names = [];
876
+ try {
877
+ names = fs.readdirSync(this.dir);
878
+ }
879
+ catch {
880
+ return;
881
+ }
882
+ for (const name of names) {
883
+ if (name.endsWith(".gz"))
884
+ continue;
885
+ if (name === (this._path && path.basename(this._path)))
886
+ continue;
887
+ const stamp = stampRx.exec(name)?.[1];
888
+ // lexicographic compare is safe for zero-padded patterns (YYYY-MM-DD…);
889
+ // also guards the current stamp and anything future-dated
890
+ if (!stamp || stamp >= this._curStamp)
891
+ continue;
892
+ const full = path.join(this.dir, name);
893
+ if (!existsSafe(full) || statSizeSafe(full) === 0)
894
+ continue;
895
+ const ok = await gzipMove(full, `${full}.gz`, (e) => this.emit("warn", e));
896
+ if (!ok)
897
+ continue;
898
+ if (this._auditEnabled && this._audit.files[name]) {
899
+ // tracked → re-key record name → name.gz (emits archive + journal)
900
+ this._finishArchivedRecord(name, true);
901
+ }
902
+ else {
903
+ // untracked → record it fresh so future reconciles see a truthful state
904
+ const gzName = `${name}.gz`;
905
+ if (this._auditEnabled) {
906
+ this._audit.files[gzName] = {
907
+ state: "archived",
908
+ gzipPending: false,
909
+ archivedAt: new Date().toISOString(),
910
+ };
911
+ this._aevent("seal-past", { file: gzName });
912
+ }
913
+ this.emit("archive", path.join(this.dir, gzName));
914
+ }
915
+ }
916
+ }
917
+ /* ==================================================================
918
+ * Retention
919
+ * ================================================================ */
809
920
  async _retire() {
810
- if (!this.maxFiles)
921
+ const policy = this.maxFiles;
922
+ if (!policy)
811
923
  return;
812
924
  const activeBase = this._path && path.basename(this._path);
813
- const activeStem = activeBase && activeBase.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
814
- const auditNames = [
925
+ const activeStem = activeBase?.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
926
+ const reserved = [
815
927
  path.basename(this._auditPath),
816
- path.basename(this._auditPath) + ".tmp",
817
- path.basename(this._auditPath) + ".corrupt",
928
+ `${path.basename(this._auditPath)}.tmp`,
929
+ `${path.basename(this._auditPath)}.corrupt`,
818
930
  ];
819
931
  try {
820
- const names = (await fs.promises.readdir(this.dir)).filter((n) => this._rxActive.test(n) &&
932
+ const dirEntries = await fs.promises.readdir(this.dir);
933
+ const candidates = dirEntries.filter((n) => this._rxActive.test(n) &&
821
934
  n !== activeBase &&
822
935
  !(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);
936
+ !reserved.includes(n));
937
+ const stats = [];
938
+ for (const name of candidates) {
939
+ const full = path.join(this.dir, name);
827
940
  try {
828
- info.push({ full, mt: (await fs.promises.stat(full)).mtimeMs });
941
+ stats.push({ full, mtimeMs: (await fs.promises.stat(full)).mtimeMs });
829
942
  }
830
943
  catch {
831
- /* ignore */
944
+ /* raced deletion */
832
945
  }
833
946
  }
834
- info.sort((a, b) => a.mt - b.mt);
947
+ stats.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
835
948
  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)
949
+ if (policy.days != null) {
950
+ const cutoff = this._now() - policy.days * 86_400_000;
951
+ for (const f of stats)
952
+ if (f.mtimeMs < cutoff)
840
953
  kill.add(f.full);
841
954
  }
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);
955
+ if (policy.count != null) {
956
+ const survivors = stats.filter((f) => !kill.has(f.full));
957
+ const excessCount = survivors.length - policy.count;
958
+ for (let i = 0; i < excessCount; i++)
959
+ kill.add(survivors[i].full);
846
960
  }
847
- let removed = false;
848
- for (const f of kill) {
849
- await fs.promises.unlink(f).catch(() => { });
961
+ if (kill.size === 0)
962
+ return;
963
+ for (const file of kill) {
964
+ await fs.promises.unlink(file).catch(() => { });
850
965
  if (this._auditEnabled) {
851
- delete this._audit.files[path.basename(f)];
852
- this._aevent("deleted", { file: path.basename(f) });
966
+ delete this._audit.files[path.basename(file)];
967
+ this._aevent("deleted", { file: path.basename(file) });
853
968
  }
854
- this.emit("deleted", f);
855
- removed = true;
969
+ this.emit("deleted", file);
856
970
  }
857
- if (removed)
858
- this._saveAudit();
971
+ this._saveAudit();
859
972
  }
860
973
  catch (e) {
861
974
  this.emit("warn", e);
862
975
  }
863
976
  }
864
977
  }
865
- /* ================================================================
866
- * Factory
867
- * ================================================================ */
978
+ /**
979
+ * Convenience factory mirroring common rotator APIs.
980
+ *
981
+ * @example
982
+ * const log = createStream({ filename: "logs/app-%DATE%.log" });
983
+ */
868
984
  function createStream(options) {
869
985
  return new RotateFileStream(options);
870
986
  }