logroller 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.1.0] — 2026-08-28
9
+
10
+ ### Added
11
+
12
+ - `symlink: true | string` — stable "current" pointer (e.g. `app-current.log`)
13
+ for `tail -f`/Filebeat/promtail; atomically flipped on every segment open;
14
+ degrades gracefully on symlink-denying filesystems; excluded from retention.
15
+ - `maxTotalSize: "5g"` — byte-budget retention: deletes OLDEST archives until
16
+ under budget; composes with `maxFiles`.
17
+ - `onSeal` hook (+ `sealHookTimeoutMs`) — awaited after a segment is archived
18
+ and BEFORE retention can delete it: the S3/GCS upload extension point.
19
+ Errors/timeouts degrade to `warn`; rotation never stalls.
20
+ - `installShutdown(streams)` / `flushAll()` — graceful SIGINT/SIGTERM
21
+ handling that flushes buffered writes before exit (fixes the classic
22
+ "last log lines missing" support case).
23
+ - `stats()` and `listSegments()` — health snapshot and per-file listing.
24
+ - `compressionLevel` (0–9, default 6) — passes through to gzip.
25
+
8
26
  ## [2.0.2] — 2026-08-28
9
27
 
10
28
  ### Fixed
package/README.md CHANGED
@@ -1,5 +1,3 @@
1
- ## README.md
2
-
3
1
  <p align="center"><strong>logroller</strong></p>
4
2
  <h3 align="center">Zero-dependency rotating file streams for Node.js</h3>
5
3
 
@@ -14,8 +12,8 @@
14
12
 
15
13
  ---
16
14
 
17
- `logroller` turns a plain append destination into a resilient, observable
18
- log sink: **it is a Writable**, so `readable.pipe(log)` works, pino multistreams
15
+ `logroller` turns a plain append destination into a resilient, observable log
16
+ sink: **it is a Writable**, so `readable.pipe(log)` works, pino multistreams
19
17
  work, anything that consumes streams works.
20
18
 
21
19
  ```bash
@@ -27,16 +25,24 @@ npm install logroller
27
25
  - **Daily & interval rotation** — exact local midnight in any IANA zone,
28
26
  or epoch-aligned intervals (`30s`, `5m`, `2h`)
29
27
  - **Size caps** — `"20m"`-style limits roll numbered segments
30
- - **Gzip archives** — sealed segments compressed atomically
31
- - **Retention** — by age or count; unset means files live forever
28
+ - **Gzip archives** — sealed segments compressed atomically, level 0–9
29
+ - **Stable "current" path** — a symlink log collectors can follow
30
+ (`tail -F app-current.log`, Filebeat, promtail) while files rotate beneath
31
+ - **Retention three ways** — by age, by count, **or by total byte budget**
32
+ ("never exceed 5 GB of archives")
33
+ - **Upload pipeline** — an awaited `onSeal` hook that fires after a segment
34
+ is archived and _before_ retention can delete it: ship to S3/GCS safely
35
+ - **Graceful shutdown** — one helper flushes buffered writes on
36
+ SIGINT/SIGTERM (no more missing last lines)
32
37
  - **Timezone-correct** — `Asia/Kolkata` means IST filenames, on a UTC box
33
- - **Crash-safe** — restarts resume mid-series byte-exactly;
34
- gzips interrupted by a kill are completed on boot
35
- - **Observability** — `<stem>_audit.json` manifest with a uuid-tagged
36
- event journal for every file's lifecycle
38
+ - **Crash-safe** — restarts resume mid-series byte-exactly; gzips
39
+ interrupted by a kill are completed on boot
40
+ - **Observability** — `<stem>_audit.json` manifest with a uuid-tagged event
41
+ journal, plus `stats()` / `listSegments()` introspection
37
42
  - **Testable time** — virtual clocks (`addHours`, `addHoursEveryMin`,
38
43
  `advanceClock()`) let a day pass per minute, or instantly
39
44
  - **Types first** — tuple-typed events, strict mode, dual ESM/CJS build
45
+ - **Zero dependencies**
40
46
 
41
47
  ## Quick start
42
48
 
@@ -62,6 +68,25 @@ log.on("rotated", ({ reason, archive, newFile }) =>
62
68
  process.on("exit", () => log.end());
63
69
  ```
64
70
 
71
+ ### Production shape — collectors, budget, uploads, shutdown
72
+
73
+ ```ts
74
+ import { createStream, installShutdown } from "logroller";
75
+
76
+ const log = createStream({
77
+ filename: "logs/app-%DATE%.log",
78
+ zippedArchive: true,
79
+ maxSize: "20m",
80
+ maxTotalSize: "5g", // never hold more than 5 GB of archives
81
+ maxFiles: "90d",
82
+ symlink: true, // logs/app-current.log always points at the live file
83
+ onSeal: async ({ gz }) => uploadToS3(gz), // awaited BEFORE retention deletes
84
+ });
85
+
86
+ // SIGINT/SIGTERM → flush buffered writes, then exit
87
+ installShutdown(log);
88
+ ```
89
+
65
90
  ### CommonJS
66
91
 
67
92
  ```js
@@ -81,15 +106,37 @@ const logger = pino(
81
106
 
82
107
  ## What lands on disk
83
108
 
109
+ Example snapshot for `filename: "logs/app-%DATE%.log"` on **Aug 28**, with
110
+ `zippedArchive: true` and `symlink: true`:
111
+
84
112
  ```
85
- app-2026-08-27.log ← active
86
- app-2026-08-27.log.gz ← sealed (hit 20 MB)
87
- app-2026-08-27.1.log ← next segment mid-day
88
- app-2026-08-27.1.log.gz ← sealed too
89
- app-2026-08-28.log ← fresh day (in your tz), clean series
113
+ app-current.log ← symlink → app-2026-08-28.log (only with symlink: true)
114
+ app-2026-08-27.log.gz ← yesterday, sealed (hit 20 MB)
115
+ app-2026-08-27.1.log.gz ← yesterday, sealed too
116
+ app-2026-08-28.log ← ACTIVE — the only raw file on disk
90
117
  app_audit.json ← library-owned manifest
91
118
  ```
92
119
 
120
+ Three invariants that hold at every moment:
121
+
122
+ 1. **Exactly one raw file** — the active segment. Everything else is a
123
+ `.gz` archive (or a raw tail kept only because gzip failed — with a
124
+ `warn` event, never silently).
125
+ 2. **A segment's `.log` and `.log.gz` never coexist.** The raw file is
126
+ deleted only _after_ its archive is verified.
127
+ 3. **`app-current.log` always points at the active segment**, flipped
128
+ atomically on every open. It exists only when `symlink` is enabled, and
129
+ retention never touches it.
130
+
131
+ ### How a day evolves (all states, in order)
132
+
133
+ | Moment (tz) | Directory contains | `app-current.log` → |
134
+ | ------------------------- | ----------------------------------------- | ---------------------------------------------------------------- |
135
+ | morning, first write | `…27.log` | `…27.log` |
136
+ | hit 20 MB → roll | `…27.log.gz` + `…27.1.log` | `…27.1.log` |
137
+ | `.1` fills → roll | `…27.log.gz` + `…27.1.log.gz` | _(stale until next write — segments open lazily on first write)_ |
138
+ | midnight → Aug 28 + write | `…27.log.gz` + `…27.1.log.gz` + `…28.log` | `…28.log` |
139
+
93
140
  A killed process resumes without gaps or duplicates:
94
141
 
95
142
  | Disk at crash | Restart continues into |
@@ -98,6 +145,106 @@ A killed process resumes without gaps or duplicates:
98
145
  | `app-D.log.gz` + `app-D.1.log` | `app-D.1.log` |
99
146
  | `app-D.1.log.gz` (chain sealed) | `app-D.2.log` |
100
147
  | died during a gzip | boot finishes the gzip, then proceeds |
148
+ | crash left yesterday's tail raw | boot seals (gzips) it automatically |
149
+
150
+ ## Log collectors & the stable current path
151
+
152
+ Rotating filenames are great for humans, hostile to consumers: `tail -f`,
153
+ Filebeat, Fluent Bit and promtail want **one path that never changes**.
154
+
155
+ ```ts
156
+ const log = createStream({
157
+ filename: "logs/app-%DATE%.log",
158
+ symlink: true, // → logs/app-current.log
159
+ // symlink: "CURRENT", // → logs/CURRENT (custom name)
160
+ });
161
+ ```
162
+
163
+ ```bash
164
+ tail -F logs/app-current.log # survives every rotation (capital -F: re-resolve the link)
165
+ ```
166
+
167
+ - The pointer is flipped **atomically** (tmp + rename) on every segment open,
168
+ so followers never observe a gap or a half-written name.
169
+ - The target is stored **relative**, so the directory stays portable.
170
+ - Best-effort by design: on filesystems that deny symlinks (Windows without
171
+ Developer Mode) the stream warns once and continues without it.
172
+ - The pointer is never touched by retention and never reported as a segment.
173
+
174
+ ## Upload pipeline — archive, ship, _then_ delete
175
+
176
+ `onSeal` runs inside the rotation queue **after** the segment is compressed
177
+ and **before** retention can remove it — the safe window for shipping:
178
+
179
+ ```ts
180
+ const log = createStream({
181
+ filename: "logs/cdr-%DATE%.log",
182
+ zippedArchive: true,
183
+ maxFiles: "7d", // local copies are disposable
184
+ onSeal: async ({ raw, gz }) => {
185
+ await uploadToS3(gz ?? raw); // awaited — rotation waits
186
+ },
187
+ sealHookTimeoutMs: 60_000, // a wedged upload warns instead of stalling
188
+ });
189
+ ```
190
+
191
+ Guarantees:
192
+
193
+ - invoked exactly once per sealed segment, with `{ raw, gz }` (`gz: null`
194
+ when compression is off or gzip failed)
195
+ - **ordering**: hook → then retention sweep, so an in-flight upload can never
196
+ race a deletion
197
+ - a throwing or timed-out hook degrades to a `warn` event — logging and
198
+ rotation continue
199
+
200
+ ## Graceful shutdown
201
+
202
+ The classic support case: SIGTERM arrives, buffered lines are lost. One call
203
+ ends it:
204
+
205
+ ```ts
206
+ import { createStream, installShutdown } from "logroller";
207
+
208
+ const log = createStream({ filename: "logs/app-%DATE%.log" });
209
+ installShutdown(log); // SIGINT + SIGTERM → end() → exit 0
210
+ ```
211
+
212
+ - `installShutdown(stream | streams, { signals?, timeoutMs?, code? })` —
213
+ installs process listeners once; streams accumulate in a registry.
214
+ - On signal: every registered stream is ended (buffered writes flushed,
215
+ audit manifest finalized), with a hard `timeoutMs` deadline (default 5 s).
216
+ - `flushAll()` is the same graceful end **without exiting** — for tests and
217
+ custom signal handling.
218
+
219
+ ## Introspection
220
+
221
+ ```ts
222
+ const st = log.stats();
223
+ // { dir, currentStamp, activeFile, activeIndex, segments, archived,
224
+ // totalBytes, clockOffsetMs, symlink }
225
+
226
+ for (const seg of log.listSegments()) {
227
+ // { file, stamp, index, gzipped, bytes, state, openedAt?, archivedAt? }
228
+ console.log(seg.stamp, seg.index, seg.gzipped ? "gz" : "raw", seg.bytes);
229
+ }
230
+ ```
231
+
232
+ `listSegments()` reads the **directory** (disk is truth), so it also reports
233
+ files the manifest doesn't know (state `"untracked"`). Perfect for
234
+ `/healthz` endpoints, dashboards, and "where did my logs go" support.
235
+
236
+ ## Retention — three policies, composable
237
+
238
+ ```ts
239
+ maxFiles: "90d" // age: delete archives older than 90 days
240
+ maxFiles: 500 // count: keep the newest 500 archives
241
+ maxTotalSize: "5g" // budget: delete OLDEST archives until under 5 GB
242
+ maxFiles: "30d",
243
+ maxTotalSize: "2g", // combined: either policy may delete
244
+ ```
245
+
246
+ Unset means files live forever until you delete them. The active segment is
247
+ never a retention candidate, and the audit/symlink files are always reserved.
101
248
 
102
249
  ## API
103
250
 
@@ -110,8 +257,13 @@ A killed process resumes without gaps or duplicates:
110
257
  | `filename` | **required**, must contain `%DATE%` | — |
111
258
  | `datePattern` | `YYYY YY MM DD HH mm ss` | `YYYY-MM-DD` |
112
259
  | `zippedArchive` | boolean | `false` |
260
+ | `compressionLevel` | gzip level `0`–`9` (0 = stored) | `6` |
113
261
  | `maxSize` | `20m`, `512k`, `2g`, bytes, `0`=off | off |
114
262
  | `maxFiles` | `"90d"` age, `500` count, unset=never | — |
263
+ | `maxTotalSize` | `"5g"`, `"500m"`, bytes, `0`=off | off |
264
+ | `symlink` | `true` (auto name) or custom string | off |
265
+ | `onSeal` | `async ({ raw, gz }) => void` | — |
266
+ | `sealHookTimeoutMs` | hard deadline for one `onSeal` call | `30000` |
115
267
  | `frequency` | `daily`, `30s`, `5m`, `2h`, ms | `daily` |
116
268
  | `tz` | any IANA zone | `UTC` |
117
269
  | `audit` / `auditFile` | manifest control | `true` / auto-name |
@@ -122,24 +274,33 @@ A killed process resumes without gaps or duplicates:
122
274
 
123
275
  ### Methods
124
276
 
125
- | Method | Returns | Purpose |
126
- | ----------------------- | ---------------- | -------------------------- |
127
- | `write / end / destroy` | stream semantics | normal `Writable` |
128
- | `rotateNow()` | `Promise<void>` | force a rotation |
129
- | `advanceClock()` | `Promise<void>` | apply one virtual tick now |
277
+ | Method | Returns | Purpose |
278
+ | ----------------------- | ---------------- | ------------------------------- |
279
+ | `write / end / destroy` | stream semantics | normal `Writable` |
280
+ | `rotateNow()` | `Promise<void>` | force a rotation |
281
+ | `advanceClock()` | `Promise<void>` | apply one virtual tick now |
282
+ | `stats()` | `StreamStats` | health snapshot |
283
+ | `listSegments()` | `SegmentInfo[]` | every family file, oldest first |
284
+
285
+ ### Module functions
286
+
287
+ | Function | Purpose |
288
+ | --------------------------------- | ------------------------------------------------------------ |
289
+ | `installShutdown(streams, opts?)` | register streams for SIGINT/SIGTERM flush; returns `flush()` |
290
+ | `flushAll(timeoutMs?)` | gracefully end all registered streams, no exit |
130
291
 
131
292
  ### Events (typed)
132
293
 
133
- | Event | Payload | Meaning |
134
- | --------- | ------------------------------------- | ---------------------------- |
135
- | `open` | `file` | segment opened for appending |
136
- | `rotated` | `{reason, oldFile, archive, newFile}` | segment closed |
137
- | `archive` | `gzFile` | gzip finished |
138
- | `deleted` | `file` | retention removed it |
139
- | `period` | `stamp` | boundary crossed while idle |
140
- | `clock` | `{offsetMs, …}` | virtual clock engaged |
141
- | `warn` | `Error` | non-fatal problems |
142
- | `error` | `Error` | fatal write problems |
294
+ | Event | Payload | Meaning |
295
+ | --------- | ------------------------------------- | ------------------------------------------------------------ |
296
+ | `open` | `file` | segment opened for appending |
297
+ | `rotated` | `{reason, oldFile, archive, newFile}` | segment closed |
298
+ | `archive` | `gzFile` | gzip finished |
299
+ | `deleted` | `file` | retention removed it |
300
+ | `period` | `stamp` | boundary crossed while idle |
301
+ | `clock` | `{offsetMs, …}` | virtual clock engaged |
302
+ | `warn` | `Error` | non-fatal (hook failure, gzip retry, symlink unavailable, …) |
303
+ | `error` | `Error` | fatal write problems |
143
304
 
144
305
  Errors must be handled — attach a listener on every instance.
145
306
 
@@ -149,46 +310,71 @@ Errors must be handled — attach a listener on every instance.
149
310
  const s = createStream({
150
311
  filename: "logs/demo-%DATE%.log",
151
312
  zippedArchive: true,
152
- addHoursEveryMin: "24h", // one day per real minute
313
+ addHours: "-48h", // start two days back
314
+ addHoursEveryMin: "24h", // one virtual day per real minute
153
315
  clockStepIntervalMs: 1000, // or faster
154
316
  });
155
317
  await s.advanceClock(); // jump instantly when useful
156
318
  ```
157
319
 
320
+ Negative `addHoursEveryMin` deliberately triggers the backward-clock guard
321
+ (warn, no rotation) — it exists so you can test that safety net too.
322
+
158
323
  ## FAQ
159
324
 
160
325
  **Why is `%DATE%` mandatory?**
161
326
  It is the anchor that decouples _rotation scheduling_ from _filenames_.
162
327
  Coarser patterns than your frequency collapse distinct segments onto one
163
- name — match tokens to cadence (`HH` for hourly, etc.).
328
+ name — match tokens to cadence (`HH` for hourly, etc.). The library warns at
329
+ construction when it detects a mismatch.
330
+
331
+ **How do I ship logs to S3 without losing any?**
332
+ `zippedArchive: true` + `onSeal` (see the upload pipeline section). The hook
333
+ is awaited and runs before retention, so an in-flight upload can never race
334
+ a deletion.
335
+
336
+ **How do I keep `tail -f` working across rotations?**
337
+ `symlink: true` — follow `app-current.log`. Use `tail -F` (capital) so the
338
+ follower re-resolves the link after it flips.
164
339
 
165
340
  **Can two processes share one directory?**
166
- Different prefixes/families: yes. The _same_ family: no — the audit
167
- manifest detects a live second writer (`writer.heartbeat`) and warns loudly.
341
+ Different prefixes/families: yes. The _same_ family: no — the audit manifest
342
+ detects a live second writer (`writer.heartbeat`) and warns loudly.
168
343
 
169
344
  **Is the audit JSON required?**
170
345
  No — `audit: false` reduces behaviour to pure disk-scan recovery. Recommended
171
- to leave on: it repairs interrupted gzips and demotes zombies automatically.
346
+ to leave on: it repairs interrupted gzips, demotes zombie records, and
347
+ powers `listSegments()` states.
348
+
349
+ **What if the system clock jumps backward?**
350
+ The stream keeps the current segment, warns once, and resumes normal
351
+ rotation when the clock catches up — the series is never forked into a past
352
+ family. (Requires zero-padded `datePattern` tokens, which all presets are.)
172
353
 
173
354
  **Windows / macOS / Linux?**
174
355
  Pure Node APIs; path handling via `node:path`. All three supported
175
- (Node `>=16.14`).
356
+ (Node `>=16.14`). Symlinks are best-effort on Windows — see above.
176
357
 
177
358
  ## Compare
178
359
 
179
- | | logroller | file-stream-rotator | rotating-file-stream | winston-daily-rotate-file |
180
- | ------------------------- | ----------------- | ------------------- | -------------------- | ------------------------- |
181
- | Dependencies | **0** | 4+ | 2+ | winston-bound |
182
- | Crash-safe resume | ✅ audit-assisted | partial | partial | ✖ |
183
- | Interrupted-gzip repair | ✅ | ✖ | ✖ | ✖ |
184
- | Event journal | ✅ | ✖ | ✖ | ✖ |
185
- | TZ-correct daily rollover | ✅ | partial | ✅ | ✅ |
186
- | Virtual test clock | ✅ | ✖ | ✖ | ✖ |
187
- | Winston required | no | no | no | yes |
360
+ | | logroller | file-stream-rotator | rotating-file-stream | winston-daily-rotate-file |
361
+ | --------------------------------- | ----------------- | ------------------- | -------------------- | ------------------------- |
362
+ | Dependencies | **0** | 4+ | 2+ | winston-bound |
363
+ | Crash-safe resume | ✅ audit-assisted | partial | partial | ✖ |
364
+ | Interrupted-gzip repair | ✅ | ✖ | ✖ | ✖ |
365
+ | Event journal | ✅ | partial | ✖ | ✖ |
366
+ | TZ-correct daily rollover | ✅ | partial | ✅ | ✅ |
367
+ | Stable current path | ✅ atomic | ✅ | ✅ | ✅ |
368
+ | Byte-budget retention | ✅ | ✅ (bytes only) | ✖ | ✖ |
369
+ | Awaited upload-before-delete hook | ✅ | ✖ | ✖ | ✖ |
370
+ | Graceful shutdown helper | ✅ | ✖ | ✖ | via winston |
371
+ | Introspection API | ✅ | ✖ | ✖ | partial |
372
+ | Virtual test clock | ✅ | ✖ | ✖ | ✖ |
373
+ | Winston required | no | no | no | yes |
188
374
 
189
375
  ## Contributing
190
376
 
191
- PRs welcome. `npm run verify` gates every change (typecheck + tests).
377
+ PRs welcome. `npm run verify` gates every change (lint + typecheck + tests).
192
378
  Please file issues before large refactors.
193
379
 
194
380
  ## License
package/dist/fsio.d.ts CHANGED
@@ -3,9 +3,9 @@ import fs from "node:fs";
3
3
  export declare const statSizeSafe: (p: string | null | undefined) => number;
4
4
  export declare const existsSafe: (p: string) => boolean;
5
5
  /**
6
- * Compress `src` → `dst`, deleting `src` ONLY on verified success.
7
- * On failure removes the partial `dst` and reports via callback —
6
+ * Compress `src` → `dst` at gzip `level`, deleting `src` ONLY on verified
7
+ * success. On failure removes the partial `dst` and reports via callback —
8
8
  * logs degrade to uncompressed rather than losing data.
9
9
  */
10
- export declare function gzipMove(src: string, dst: string, onWarn?: (err: Error) => void): Promise<boolean>;
10
+ export declare function gzipMove(src: string, dst: string, onWarn?: (err: Error) => void, level?: number): Promise<boolean>;
11
11
  export declare const endStream: (ws: fs.WriteStream | null) => Promise<void>;