logroller 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [2.0.0] — 2026-08-28
9
+
10
+ Architecture release: the library moved from a single-file JavaScript SDK to a
11
+ modular **TypeScript** codebase with strict typings, first-class DX tooling,
12
+ and CI-gated releases. The public runtime API (`createStream`,
13
+ `RotateFileStream`, all options and events) remains compatible with 1.x;
14
+ TypeScript consumers gain full type coverage.
15
+
16
+ > ⚠️ Consumers on 1.x should re-run their smoke/integration tests after
17
+ > upgrading — mostly due to the deterministic boot-order fix below.
18
+
19
+ ### Added
20
+ - **Full TypeScript source with shipped declarations** (`dist/index.d.ts`);
21
+ strict-mode compile (`noImplicitOverride`, `noUnusedLocals`,
22
+ `verbatimModuleSyntax`, …).
23
+ - **Tuple-typed events** via `RotateFileStreamEventMap` — `on("rotated", …)`
24
+ autocompletes and payload types are checked (`RotatedInfo`, `ClockInfo`),
25
+ while Node-core fallback overloads keep `.pipe()` structurally valid.
26
+ - `advanceClock()` — apply one virtual-clock tick immediately (previously the
27
+ step timer fired only every minute); makes day-crossing tests fully
28
+ deterministic.
29
+ - `clockStepIntervalMs` option — control how often `addHoursEveryMin` ticks.
30
+ - Modular source layout (`src/types.ts`, `parsers.ts`, `time.ts`, `fsio.ts`,
31
+ `rotate-file-stream.ts`) with `.js` extension imports so emitted
32
+ declarations resolve under `nodenext` consumers.
33
+ - Developer toolchain: ESLint 9 flat config + typescript-eslint, Prettier,
34
+ EditorConfig, `npm run verify` gate (lint + typecheck + build + tests).
35
+ - GitHub Actions: CI matrix on Node 18/20/22, release-triggered publish with
36
+ npm provenance (`id-token: write`, `--provenance`).
37
+ - Expanded README: comparison table, FAQ, restart-resume matrix, pino recipe.
38
+
39
+ ### Fixed
40
+ - **Deterministic boot sequencing**: housekeeping (zombie demotion, disk
41
+ reconciliation, interrupted-gzip repair) is chained as job #1 of the
42
+ serial operation queue in the constructor instead of `process.nextTick`,
43
+ so a rapid first `write()` can no longer race a boot-time reopen or skip
44
+ zombie cleanup.
45
+ - Retention sweep skips `<audit>.corrupt` artifacts alongside `.tmp`.
46
+
47
+ ### Changed
48
+ - Internals restructured behind the same public exports; barrels re-export
49
+ types explicitly (`import type { RotatedInfo } from "logroller"`).
50
+ - Stricter package hygiene metadata: `sideEffects: false`, explicit
51
+ `./package.json` export, `files` whitelist (`dist`, docs, license,
52
+ changelog).
53
+ - Test suite runs against the built bundle and covers: base writing +
54
+ manifest creation, size rotation/gzip/indexing, restart resume,
55
+ `addHours` naming, virtual midnight crossing, count-retention ordering
56
+ (mtime-deterministic), zombie-record lifecycle (`abandon → active →
57
+ closed`), and CJS loadability through the exports map.
58
+
59
+ ## [1.0.0] — 2026-08-26
60
+
61
+ Initial release. Single-file zero-dependency JavaScript SDK
62
+ (`file-rotation-sdk.js`) providing a Writable stream with scheduled file
63
+ rotation.
64
+
65
+ ### Added
66
+ - Time-based rotation: `frequency: "daily"` (local midnight in any IANA
67
+ timezone via `Intl`) or epoch-aligned intervals (`"30s"`, `"5m"`, `"2h"`).
68
+ - Size-based rotation: `maxSize` (`"20m"` / `"512k"` / raw bytes) producing
69
+ numbered segments — `app-D.log`, `app-D.1.log`, … — inserted between the
70
+ date stamp and the extension.
71
+ - Optional gzip archiving of sealed segments (`zippedArchive`), atomic tmp+
72
+ rename writes, source deleted only after verified compression success.
73
+ - Retention via `maxFiles`: age policy (`"90d"`) or count policy (`500`);
74
+ unset means files are never auto-deleted.
75
+ - Crash-safe restart resume from disk scan: raw tails are appended to,
76
+ sealed chains continue at the next free index; lazy period-roll catches
77
+ boundaries missed while suspended or shut down.
78
+ - Audit manifest (`<stem>_audit.json`): lifecycle records per file, capped
79
+ uuid-tagged event journal, `gzipPending` intent markers enabling automatic
80
+ repair of gzips interrupted by a crash, zombie active-record demotion,
81
+ disk-divergence healing (`archive-regressed`, `vanished`),
82
+ concurrent-writer detection via pid/bootId heartbeat.
83
+ - Virtual test clock: static shift (`addHours`) and per-minute stepping
84
+ (`addHoursEveryMin`).
85
+ - Dual-format distribution (ESM + CJS) bundled with Rollup; named exports
86
+ `createStream` and `RotateFileStream`; factory options mirroring common
87
+ rotator APIs; streams pipe-ready for pino multistreams.
package/README.md CHANGED
@@ -1,19 +1,49 @@
1
- # logroller
2
1
 
3
- Zero-dependency rotating file stream for Node.js. Daily or interval rotation,
4
- size limits, gzip archives, retention policies, timezone-aware naming and a
5
- crash-safe resume — in one small Writable stream you can `pipe()` into or hand
6
- to pino.
2
+ ## README.md
3
+
4
+ <p align="center"><strong>logroller</strong></p>
5
+ <h3 align="center">Zero-dependency rotating file streams for Node.js</h3>
6
+
7
+ <div align="center">
8
+
9
+ ![npm](https://img.shields.io/npm/v/logroller)
10
+ ![node](https://img.shields.io/node/v/logroller)
11
+ ![license](https://img.shields.io/npm/l/logroller)
12
+ ![types](https://shields.io/badge/types-included-blue)
13
+
14
+ </div>
15
+
16
+ ---
17
+
18
+ `logroller` turns a plain append destination into a resilient, observable
19
+ log sink: **it is a Writable**, so `readable.pipe(log)` works, pino multistreams
20
+ work, anything that consumes streams works.
7
21
 
8
22
  ```bash
9
23
  npm install logroller
10
24
  ```
11
25
 
26
+ ## Features
27
+
28
+ - **Daily & interval rotation** — exact local midnight in any IANA zone,
29
+ or epoch-aligned intervals (`30s`, `5m`, `2h`)
30
+ - **Size caps** — `"20m"`-style limits roll numbered segments
31
+ - **Gzip archives** — sealed segments compressed atomically
32
+ - **Retention** — by age or count; unset means files live forever
33
+ - **Timezone-correct** — `Asia/Kolkata` means IST filenames, on a UTC box
34
+ - **Crash-safe** — restarts resume mid-series byte-exactly;
35
+ gzips interrupted by a kill are completed on boot
36
+ - **Observability** — `<stem>_audit.json` manifest with a uuid-tagged
37
+ event journal for every file's lifecycle
38
+ - **Testable time** — virtual clocks (`addHours`, `addHoursEveryMin`,
39
+ `advanceClock()`) let a day pass per minute, or instantly
40
+ - **Types first** — tuple-typed events, strict mode, dual ESM/CJS build
41
+
12
42
  ## Quick start
13
43
 
14
44
  ### ESM
15
45
 
16
- ```js
46
+ ```ts
17
47
  import { createStream } from "logroller";
18
48
 
19
49
  const log = createStream({
@@ -27,7 +57,10 @@ const log = createStream({
27
57
  });
28
58
 
29
59
  log.write("hello\n");
30
- log.on("rotated", (r) => console.log("rotated:", r));
60
+ log.on("rotated", ({ reason, archive, newFile }) =>
61
+ console.log(`${reason}: ${archive ?? "-"} → ${newFile}`),
62
+ );
63
+ process.on("exit", () => log.end());
31
64
  ```
32
65
 
33
66
  ### CommonJS
@@ -36,112 +69,126 @@ log.on("rotated", (r) => console.log("rotated:", r));
36
69
  const { createStream } = require("logroller");
37
70
  ```
38
71
 
39
- ### Pipe anything into it
40
-
41
- ```js
42
- source.pipe(createStream({ filename: "logs/data-%DATE%.log" }));
43
- ```
44
-
45
- ### With pino
72
+ ### pino destination
46
73
 
47
74
  ```js
48
- import pino from "pino";
49
- import { createStream } from "logroller";
50
-
51
- const stream = createStream({
52
- filename: "logs/general-%DATE%.log",
53
- zippedArchive: true,
54
- maxSize: "20m",
55
- maxFiles: "90d",
56
- tz: "Asia/Kolkata",
57
- });
58
-
59
75
  const logger = pino(pino.multistream([
60
- { level: "info", stream: pino.transport({ target: "./pretty.js" }) },
61
- { level: "info", stream },
76
+ { level: "info", stream: createStream({ filename: "logs/general-%DATE%.log" }) },
77
+ { level: "error", stream: createStream({ filename: "logs/error-%DATE%.log" }) },
62
78
  ]));
63
79
  ```
64
-
65
- ## Options
66
-
67
- | Option | Values | Default |
68
- |---|---|---|
69
- | `filename` | must contain `%DATE%` | required |
70
- | `datePattern` | `YYYY YY MM DD HH mm ss` | `YYYY-MM-DD` |
71
- | `zippedArchive` | boolean | `false` |
72
- | `maxSize` | `20m`, `1k`, `5g`, number of bytes, `0` = off | off |
73
- | `maxFiles` | `"90d"` (age), `500` (count), unset = never delete | off |
74
- | `frequency` | `"daily"`, `"1s"`, `"5m"`, `"2h"`, ms number | `"daily"` |
75
- | `tz` | any IANA zone (`Intl`-based) | `"UTC"` |
76
- | `audit` | write `<stem>_audit.json` manifest | `true` |
77
- | `auditFile` | custom manifest name | `<stem>_audit.json` |
78
- | `unrefTimers` | don't hold the process open | `false` |
79
- | `addHours` | static clock shift (`"-6h"`) — **test only** | off |
80
- | `addHoursEveryMin` | hours added per step (`"24h"`) — **test only** | off |
81
- | `clockStepIntervalMs` | step interval for `addHoursEveryMin` | `60000` |
82
-
83
- ## File naming
80
+ ## What lands on disk
84
81
 
85
82
  ```
86
83
  app-2026-08-27.log ← active
87
- app-2026-08-27.log.gz ← hit maxSize → gzipped
88
- app-2026-08-27.1.log ← next segment, writing…
89
- app-2026-08-27.1.log.gz ← that one sealed too
90
- app-2026-08-28.log ← new day (in tz), fresh series
84
+ app-2026-08-27.log.gz ← sealed (hit 20 MB)
85
+ app-2026-08-27.1.log ← next segment mid-day
86
+ app-2026-08-27.1.log.gz ← sealed too
87
+ app-2026-08-28.log ← fresh day (in your tz), clean series
88
+ app_audit.json ← library-owned manifest
91
89
  ```
92
90
 
93
- ## Restart behaviour
91
+ A killed process resumes without gaps or duplicates:
94
92
 
95
- | Disk at crash | Resumes into |
93
+ | Disk at crash | Restart continues into |
96
94
  |---|---|
97
95
  | `app-D.log` | `app-D.log` (append) |
98
- | `app-D.log.gz` + `app-D.1.log` | `app-D.1.log` (append) |
99
- | `app-D.1.log.gz` only | `app-D.2.log` |
100
- | crossed midnight while down | new day base file on first write |
101
- | killed mid-gzip | boot finishes the interrupted gzip |
96
+ | `app-D.log.gz` + `app-D.1.log` | `app-D.1.log` |
97
+ | `app-D.1.log.gz` (chain sealed) | `app-D.2.log` |
98
+ | died during a gzip | boot finishes the gzip, then proceeds |
102
99
 
103
- ## Events
100
+ ## API
104
101
 
105
- | Event | Payload |
106
- |---|---|
107
- | `open` | `(file: string)` |
108
- | `rotated` | `({ reason, oldFile, archive, newFile })` |
109
- | `archive` | `(gzFile: string)` |
110
- | `deleted` | `(file: string)` — retention |
111
- | `period` | `(stamp: string)` — new period, nothing to archive |
112
- | `clock` | `({ addHours, addHoursEveryMin, offsetMs })` — test clock active |
113
- | `warn` | `(err: Error)` — non-fatal (gzip failure, concurrent writer, …) |
102
+ ### `createStream(options)` → `RotateFileStream`
114
103
 
115
- Always attach an `error` handler — this is a real Writable stream.
104
+ ### Options
116
105
 
117
- ## Audit manifest
106
+ | Option | Values | Default |
107
+ |---|---|---|
108
+ | `filename` | **required**, must contain `%DATE%` | — |
109
+ | `datePattern` | `YYYY YY MM DD HH mm ss` | `YYYY-MM-DD` |
110
+ | `zippedArchive` | boolean | `false` |
111
+ | `maxSize` | `20m`, `512k`, `2g`, bytes, `0`=off | off |
112
+ | `maxFiles` | `"90d"` age, `500` count, unset=never | — |
113
+ | `frequency` | `daily`, `30s`, `5m`, `2h`, ms | `daily` |
114
+ | `tz` | any IANA zone | `UTC` |
115
+ | `audit` / `auditFile` | manifest control | `true` / auto-name |
116
+ | `unrefTimers` | detach from event loop | `false` |
117
+ | `addHours` | ⚠️ static clock shift, test only | off |
118
+ | `addHoursEveryMin` | ⚠️ virtual time per tick, test only | off |
119
+ | `clockStepIntervalMs` | tick interval | `60000` |
120
+
121
+ ### Methods
122
+
123
+ | Method | Returns | Purpose |
124
+ |---|---|---|
125
+ | `write / end / destroy` | stream semantics | normal `Writable` |
126
+ | `rotateNow()` | `Promise<void>` | force a rotation |
127
+ | `advanceClock()` | `Promise<void>` | apply one virtual tick now |
118
128
 
119
- Each stream family keeps a `*_audit.json` beside the logs (library-owned —
120
- treat it as read-only). It records every file's lifecycle with a capped,
121
- uuid-tagged event journal, restores the series index across restarts, repairs
122
- gzip interrupted by a crash, and demotes records left behind by dead
123
- processes. Writes are atomic (tmp + rename); a corrupt/foreign file is
124
- quarantined as `*.corrupt` and logging continues via disk scan.
129
+ ### Events (typed)
125
130
 
126
- ## Testing rotation without waiting
131
+ | Event | Payload | Meaning |
132
+ |---|---|---|
133
+ | `open` | `file` | segment opened for appending |
134
+ | `rotated` | `{reason, oldFile, archive, newFile}` | segment closed |
135
+ | `archive` | `gzFile` | gzip finished |
136
+ | `deleted` | `file` | retention removed it |
137
+ | `period` | `stamp` | boundary crossed while idle |
138
+ | `clock` | `{offsetMs, …}` | virtual clock engaged |
139
+ | `warn` | `Error` | non-fatal problems |
140
+ | `error` | `Error` | fatal write problems |
127
141
 
128
- ```js
142
+ Errors must be handled — attach a listener on every instance.
143
+
144
+ ## Testing rotation without waiting a week
145
+
146
+ ```ts
129
147
  const s = createStream({
130
- filename: "logs/test-%DATE%.log",
148
+ filename: "logs/demo-%DATE%.log",
131
149
  zippedArchive: true,
132
- addHoursEveryMin: "24h", // a day passes every minute
133
- clockStepIntervalMs: 1000, // optional: step every second instead
134
- tz: "UTC",
150
+ addHoursEveryMin: "24h", // one day per real minute
151
+ clockStepIntervalMs: 1000, // or faster
135
152
  });
136
- await s.advanceClock(); // or trigger one step manually
153
+ await s.advanceClock(); // jump instantly when useful
137
154
  ```
138
155
 
139
- ## Retention
156
+ ## FAQ
157
+
158
+ **Why is `%DATE%` mandatory?**
159
+ It is the anchor that decouples *rotation scheduling* from *filenames*.
160
+ Coarser patterns than your frequency collapse distinct segments onto one
161
+ name — match tokens to cadence (`HH` for hourly, etc.).
162
+
163
+ **Can two processes share one directory?**
164
+ Different prefixes/families: yes. The *same* family: no — the audit
165
+ manifest detects a live second writer (`writer.heartbeat`) and warns loudly.
166
+
167
+ **Is the audit JSON required?**
168
+ No — `audit: false` reduces behaviour to pure disk-scan recovery. Recommended
169
+ to leave on: it repairs interrupted gzips and demotes zombies automatically.
170
+
171
+ **Windows / macOS / Linux?**
172
+ Pure Node APIs; path handling via `node:path`. All three supported
173
+ (Node `>=16.14`).
174
+
175
+ ## Compare
176
+
177
+ | | logroller | file-stream-rotator | rotating-file-stream | winston-daily-rotate-file |
178
+ |---|---|---|---|---|
179
+ | Dependencies | **0** | 4+ | 2+ | winston-bound |
180
+ | Crash-safe resume | ✅ audit-assisted | partial | partial | ✖ |
181
+ | Interrupted-gzip repair | ✅ | ✖ | ✖ | ✖ |
182
+ | Event journal | ✅ | ✖ | ✖ | ✖ |
183
+ | TZ-correct daily rollover | ✅ | partial | ✅ | ✅ |
184
+ | Virtual test clock | ✅ | ✖ | ✖ | ✖ |
185
+ | Winston required | no | no | no | yes |
186
+
187
+ ## Contributing
140
188
 
141
- - `maxFiles: "90d"` — delete family files older than 90 days (mtime)
142
- - `maxFiles: 500` — keep newest 500 archives
143
- - unset — never auto-delete; remove manually
189
+ PRs welcome. `npm run verify` gates every change (lint + typecheck + tests).
190
+ Please file issues before large refactors.
144
191
 
145
192
  ## License
146
193
 
147
- MIT
194
+ [MIT](./LICENSE)
package/dist/fsio.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import fs from "node:fs";
2
+ /** `stat().size` guarded to 0; string paths only. */
3
+ export declare const statSizeSafe: (p: string | null | undefined) => number;
4
+ export declare const existsSafe: (p: string) => boolean;
5
+ /**
6
+ * Compress `src` → `dst`, deleting `src` ONLY on verified success.
7
+ * On failure removes the partial `dst` and reports via callback —
8
+ * logs degrade to uncompressed rather than losing data.
9
+ */
10
+ export declare function gzipMove(src: string, dst: string, onWarn?: (err: Error) => void): Promise<boolean>;
11
+ export declare const endStream: (ws: fs.WriteStream | null) => Promise<void>;