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/CHANGELOG.md +101 -0
- package/README.md +144 -95
- package/dist/fsio.d.ts +11 -0
- package/dist/index.cjs +564 -448
- package/dist/index.d.ts +7 -140
- package/dist/index.esm.js +564 -448
- package/dist/parsers.d.ts +16 -0
- package/dist/rotate-file-stream.d.ts +121 -0
- package/dist/time.d.ts +13 -0
- package/dist/types.d.ts +129 -0
- package/package.json +31 -15
package/dist/index.esm.js
CHANGED
|
@@ -1,20 +1,55 @@
|
|
|
1
|
-
/*! logroller
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
if (
|
|
38
|
-
return { count: Number.parseInt(
|
|
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
|
-
|
|
78
|
+
return { type: "daily" };
|
|
44
79
|
if (v === "daily")
|
|
45
80
|
return { type: "daily" };
|
|
46
|
-
if (typeof v === "number"
|
|
81
|
+
if (typeof v === "number") {
|
|
82
|
+
if (!(v > 0))
|
|
83
|
+
throw new Error(`Invalid frequency "${v}"`);
|
|
47
84
|
return { type: "interval", ms: v };
|
|
48
|
-
|
|
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
|
-
|
|
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 *
|
|
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]) *
|
|
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 *
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
if (!
|
|
116
|
+
let dtf = _dtfCache.get(tz);
|
|
117
|
+
if (!dtf) {
|
|
82
118
|
try {
|
|
83
|
-
|
|
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,
|
|
133
|
+
_dtfCache.set(tz, dtf);
|
|
98
134
|
}
|
|
99
|
-
return
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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(
|
|
217
|
-
this.dir = path.dirname(
|
|
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 +
|
|
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("^" +
|
|
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")
|
|
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.
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
/**
|
|
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
|
-
/*
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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(
|
|
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
|
|
520
|
-
if (
|
|
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((
|
|
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
|
-
|
|
379
|
+
callback();
|
|
536
380
|
}
|
|
537
381
|
catch (err) {
|
|
538
|
-
this.emit("error", err);
|
|
539
|
-
|
|
382
|
+
this.emit("error", err);
|
|
383
|
+
callback(err);
|
|
540
384
|
}
|
|
541
385
|
}).catch(() => { });
|
|
542
386
|
}
|
|
543
|
-
_final(
|
|
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
|
|
550
|
-
if (
|
|
551
|
-
this._audit.files[
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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(() =>
|
|
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
|
-
/*
|
|
413
|
+
/* ==================================================================
|
|
414
|
+
* Serialisation — every mutating operation funnels through one queue
|
|
415
|
+
* so rotations/gzips/writes never interleave.
|
|
416
|
+
* ================================================================ */
|
|
569
417
|
_job(fn) {
|
|
570
|
-
const
|
|
571
|
-
this._tail =
|
|
572
|
-
return
|
|
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
|
-
/*
|
|
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
|
|
627
|
-
|
|
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:
|
|
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
|
|
657
|
-
const
|
|
658
|
-
const
|
|
659
|
-
if (this.zippedArchive &&
|
|
660
|
-
archive = oldPath
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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(
|
|
519
|
+
this._finishArchivedRecord(oldBase);
|
|
669
520
|
}
|
|
670
521
|
else {
|
|
671
|
-
if (
|
|
672
|
-
|
|
673
|
-
|
|
522
|
+
if (rec) {
|
|
523
|
+
rec.gzipPending = false;
|
|
524
|
+
rec.state = "kept-raw";
|
|
674
525
|
}
|
|
675
|
-
this._aevent("gzip-failed", { file:
|
|
526
|
+
this._aevent("gzip-failed", { file: oldBase });
|
|
676
527
|
archive = null;
|
|
677
528
|
}
|
|
678
529
|
}
|
|
679
|
-
else if (
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
/*
|
|
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
|
|
737
|
-
|
|
738
|
-
|
|
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 (
|
|
743
|
-
this._curStamp =
|
|
744
|
-
this._index = this._recoverIndexFor(
|
|
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
|
|
605
|
+
this._aevent("period", { stamp });
|
|
748
606
|
this._saveAudit();
|
|
749
607
|
}
|
|
750
|
-
this.emit("period",
|
|
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
|
|
779
|
-
if (
|
|
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 =
|
|
785
|
-
this._index = this._recoverIndexFor(
|
|
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
|
|
646
|
+
this._aevent("period", { stamp, virtual: true });
|
|
789
647
|
}
|
|
790
|
-
this.emit("period",
|
|
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
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
|
814
|
-
const
|
|
925
|
+
const activeStem = activeBase?.replace(/(?:\.\d+)?(?:\.log|)(?:\.gz)?$/, "");
|
|
926
|
+
const reserved = [
|
|
815
927
|
path.basename(this._auditPath),
|
|
816
|
-
path.basename(this._auditPath)
|
|
817
|
-
path.basename(this._auditPath)
|
|
928
|
+
`${path.basename(this._auditPath)}.tmp`,
|
|
929
|
+
`${path.basename(this._auditPath)}.corrupt`,
|
|
818
930
|
];
|
|
819
931
|
try {
|
|
820
|
-
const
|
|
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
|
-
!
|
|
824
|
-
const
|
|
825
|
-
for (const
|
|
826
|
-
const full = path.join(this.dir,
|
|
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
|
-
|
|
941
|
+
stats.push({ full, mtimeMs: (await fs.promises.stat(full)).mtimeMs });
|
|
829
942
|
}
|
|
830
943
|
catch {
|
|
831
|
-
/*
|
|
944
|
+
/* raced deletion */
|
|
832
945
|
}
|
|
833
946
|
}
|
|
834
|
-
|
|
947
|
+
stats.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
835
948
|
const kill = new Set();
|
|
836
|
-
if (
|
|
837
|
-
const
|
|
838
|
-
for (const f of
|
|
839
|
-
if (f.
|
|
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 (
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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(
|
|
852
|
-
this._aevent("deleted", { file: path.basename(
|
|
966
|
+
delete this._audit.files[path.basename(file)];
|
|
967
|
+
this._aevent("deleted", { file: path.basename(file) });
|
|
853
968
|
}
|
|
854
|
-
this.emit("deleted",
|
|
855
|
-
removed = true;
|
|
969
|
+
this.emit("deleted", file);
|
|
856
970
|
}
|
|
857
|
-
|
|
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
|
-
*
|
|
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
|
}
|