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