logroller 2.0.1 → 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/dist/parsers.d.ts CHANGED
@@ -12,5 +12,7 @@ export type ParsedFrequency = {
12
12
  export declare function parseSize(v: RotateFileStreamOptions["maxSize"]): number;
13
13
  export declare function parseMaxFiles(v: RotateFileStreamOptions["maxFiles"]): ParsedMaxFiles | null;
14
14
  export declare function parseFrequency(v: RotateFileStreamOptions["frequency"]): ParsedFrequency;
15
+ /** Static virtual-clock shift. Accepts hours by default; h/hours/m/s/ms. */
15
16
  export declare function parseClockOffset(v: RotateFileStreamOptions["addHours"]): number;
17
+ /** Per-tick virtual-clock step. Accepts hours by default; h/hours/m/s/ms. */
16
18
  export declare function parseClockStep(v: RotateFileStreamOptions["addHoursEveryMin"]): number;
@@ -1,19 +1,24 @@
1
1
  import { Writable } from "node:stream";
2
2
  import { type ParsedFrequency, type ParsedMaxFiles } from "./parsers.js";
3
- import type { RotateFileStreamEventMap, RotateFileStreamOptions } from "./types";
3
+ import type { RotateFileStreamEventMap, RotateFileStreamOptions, SegmentInfo, StreamStats } from "./types.js";
4
4
  /**
5
5
  * A `Writable` that appends to dated log files, rolling segments on time
6
- * and/or size, optionally gzipping sealed segments, enforcing retention,
7
- * and resuming an unbroken series across restarts via disk scan + audit
8
- * manifest.
6
+ * and/or size, optionally gzipping sealed segments, enforcing retention
7
+ * (count, age, and total-byte budget), maintaining a stable "current"
8
+ * symlink for log collectors, running an awaited seal hook (upload
9
+ * pipeline), and resuming an unbroken series across restarts via disk
10
+ * scan + audit manifest.
9
11
  *
10
12
  * @example
11
13
  * ```ts
12
14
  * const log = createStream({
13
15
  * filename: "logs/app-%DATE%.log",
14
16
  * maxSize: "20m",
17
+ * maxTotalSize: "5g",
15
18
  * maxFiles: "90d",
16
19
  * zippedArchive: true,
20
+ * symlink: true,
21
+ * onSeal: async ({ gz }) => uploadToS3(gz),
17
22
  * });
18
23
  * log.write("hello\n"); // or: readable.pipe(log)
19
24
  * ```
@@ -28,6 +33,7 @@ export declare class RotateFileStream extends Writable {
28
33
  readonly maxFiles: Readonly<ParsedMaxFiles> | null;
29
34
  readonly freq: ParsedFrequency;
30
35
  readonly unrefTimers: boolean;
36
+ readonly maxTotalSize: number;
31
37
  private readonly _tL;
32
38
  private readonly _tMid;
33
39
  private readonly _tExt;
@@ -36,9 +42,13 @@ export declare class RotateFileStream extends Writable {
36
42
  private readonly _auditEnabled;
37
43
  private readonly _auditPath;
38
44
  private readonly _bootId;
45
+ private readonly _onSeal?;
46
+ private readonly _sealHookTimeoutMs;
47
+ private readonly _compressionLevel;
48
+ private _symlinkPath;
39
49
  private _offsetMs;
40
- private readonly _stepMs;
41
- private readonly _bumpEvery;
50
+ private _stepMs;
51
+ private _bumpEvery;
42
52
  private _curStamp;
43
53
  private _index;
44
54
  private _ws;
@@ -48,6 +58,7 @@ export declare class RotateFileStream extends Writable {
48
58
  private _bumpTimer;
49
59
  private _ended;
50
60
  private _tail;
61
+ private _warnedBackward;
51
62
  private _audit;
52
63
  constructor(opts?: Partial<RotateFileStreamOptions>);
53
64
  on<E extends keyof RotateFileStreamEventMap>(event: E, listener: (...args: RotateFileStreamEventMap[E]) => void): this;
@@ -60,9 +71,7 @@ export declare class RotateFileStream extends Writable {
60
71
  get filename(): string | null;
61
72
  /** Index of the active segment within the current period. */
62
73
  get currentIndex(): number;
63
- /**
64
- * Force an immediate rotation and resolve when the next slot is staged.
65
- */
74
+ /** Force an immediate rotation and resolve when the next slot is staged. */
66
75
  rotateNow(): Promise<void>;
67
76
  /**
68
77
  * Apply ONE virtual-clock tick immediately (bypasses the interval timer
@@ -70,6 +79,15 @@ export declare class RotateFileStream extends Writable {
70
79
  * archive flow as a real midnight roll.
71
80
  */
72
81
  advanceClock(): Promise<void>;
82
+ /**
83
+ * Every family file on disk, oldest stamp first.
84
+ * Disk is truth: untracked files are reported with state "untracked".
85
+ */
86
+ listSegments(): SegmentInfo[];
87
+ /**
88
+ * Health-check snapshot for dashboards and support triage.
89
+ */
90
+ stats(): StreamStats;
73
91
  _write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null) => void): void;
74
92
  _final(callback: (error?: Error | null) => void): void;
75
93
  destroy(...args: Parameters<Writable["destroy"]>): this;
@@ -77,12 +95,32 @@ export declare class RotateFileStream extends Writable {
77
95
  private _now;
78
96
  private _stampNow;
79
97
  private _resolveName;
98
+ /**
99
+ * true only when the period legally advanced. A regressed
100
+ * clock (NTP correction, manual change) never reopens a past family:
101
+ * writes continue in the current segment until real time catches up.
102
+ * Requires zero-padded datePattern tokens (documented assumption).
103
+ */
104
+ private _stampAdvanced;
80
105
  /**
81
106
  * Highest viable segment index for `stamp`.
82
107
  * Disk is truth: resume a raw tail; start after sealed ones.
83
108
  * The audit pointer (same stamp only) may only RAISE the result.
84
109
  */
85
110
  private _recoverIndexFor;
111
+ /**
112
+ * Atomically flip the "current" symlink to the active segment.
113
+ * tmp + rename so followers (`tail -f`, Filebeat) never observe a gap.
114
+ * Relative target keeps the link portable. Best-effort: filesystems
115
+ * denying symlinks warn once and disable the feature permanently.
116
+ */
117
+ private _updateSymlink;
118
+ /**
119
+ * Await the user's onSeal hook with a hard timeout. Result or
120
+ * failure degrades to `warn` — a wedged upload must never stall rotation.
121
+ * Called BEFORE retention so uploads finish before deletion.
122
+ */
123
+ private _runSealHook;
86
124
  private _ensureOpen;
87
125
  private _rotate;
88
126
  private _hasContent;
@@ -100,16 +138,16 @@ export declare class RotateFileStream extends Writable {
100
138
  private _demoteZombieActives;
101
139
  /** Heal audit-vs-disk divergence after crashes / external tampering. */
102
140
  private _reconcileWithDisk;
141
+ /**
142
+ * DISK-DRIVEN seal. Seal raw segments whose period has
143
+ * already passed (crash near midnight, virtual-clock leftovers,
144
+ * untracked/pruned files). Iterates the DIRECTORY, not the manifest:
145
+ * the filesystem is the source of truth; the manifest is an index.
146
+ */
147
+ private _sealOrphanedPastStamps;
103
148
  private _repairInterruptedGzips;
104
149
  /** Seal a record; re-key `name` → `name.gz` so scans stop ghosting. */
105
150
  private _finishArchivedRecord;
106
- /**
107
- * Seal raw segments whose period has already passed (crash near midnight,
108
- * virtual-clock leftovers, untracked files). Disk is the source of truth —
109
- * manifest-tracked or not, a raw tail nothing will ever write to again
110
- * must still honour zippedArchive.
111
- */
112
- private _sealOrphanedPastStamps;
113
151
  private _retire;
114
152
  }
115
153
  /**
@@ -0,0 +1,32 @@
1
+ import type { RotateFileStream } from "./rotate-file-stream.js";
2
+ export interface ShutdownOptions {
3
+ /** Signals to listen on. @default ["SIGINT", "SIGTERM"] */
4
+ signals?: NodeJS.Signals[];
5
+ /** Hard-exit deadline (ms) if a stream refuses to end.
6
+ * @default 5000 */
7
+ timeoutMs?: number;
8
+ /** Process exit code. @default 0 */
9
+ code?: number;
10
+ }
11
+ /**
12
+ * Register streams for graceful shutdown: on SIGINT/SIGTERM every
13
+ * registered stream is ended — flushing buffered writes and finishing the
14
+ * audit manifest — before the process exits, with a hard deadline.
15
+ *
16
+ * Safe to call multiple times: process listeners install once, streams
17
+ * accumulate in the registry.
18
+ *
19
+ * Returns `flush()`, the same graceful end WITHOUT exiting — for tests
20
+ * and custom signal handling.
21
+ *
22
+ * @example
23
+ * const flush = installShutdown(logStream);
24
+ * process.on("exit", () => { void flush(); });
25
+ */
26
+ export declare function installShutdown(streams: RotateFileStream | RotateFileStream[], opts?: ShutdownOptions): () => Promise<void>;
27
+ /**
28
+ * End every registered stream (flush buffered writes) with a deadline.
29
+ * Does NOT exit the process. Double-ending an already-ended stream is
30
+ * harmless — the error lands in the ignored callback.
31
+ */
32
+ export declare function flushAll(timeoutMs?: number): Promise<void>;
package/dist/types.d.ts CHANGED
@@ -8,122 +8,152 @@ export type RotationReason = "time" | "size" | "manual";
8
8
  export interface RotateFileStreamOptions {
9
9
  /**
10
10
  * Destination filename pattern. REQUIRED and must contain the `%DATE%`
11
- * token, which is replaced by the formatted current date/time.
12
- *
11
+ * token.
13
12
  * @example "logs/app-%DATE%.log"
14
13
  */
15
14
  filename: string;
16
- /**
17
- * Date token pattern substituted for `%DATE%`.
18
- *
19
- * Supported tokens: `YYYY YY MM DD HH mm ss`.
20
- * Use tokens coarser-or-equal to your rotation frequency — hourly
21
- * rotation with a day-only pattern collapses multiple files onto one name.
22
- *
23
- * @default "YYYY-MM-DD"
24
- */
15
+ /** Tokens: `YYYY YY MM DD HH mm ss`. Must be coarse-or-equal to your
16
+ * rotation frequency, or segments collapse onto one name.
17
+ * @default "YYYY-MM-DD" */
25
18
  datePattern?: string;
26
- /** Gzip closed segments (`name.log` → `name.log.gz`). */
27
- /** @default false */
19
+ /** Gzip closed segments (`name.log` → `name.log.gz`).
20
+ * @default false */
28
21
  zippedArchive?: boolean;
29
22
  /**
30
- * Maximum bytes per segment before rolling. Accepts raw numbers (bytes)
31
- * or human strings: `"20m"`, `"512k"`, `"2g"`, `"100b"`.
32
- * `0` disables size-based rotation.
23
+ * gzip level 0–9. 0 = stored (fastest, no ratio), 9 = best ratio,
24
+ * 6 = default library default. Ignored when `zippedArchive` is false.
25
+ * @default 6
26
+ */
27
+ compressionLevel?: number;
28
+ /**
29
+ * Roll when the next write would exceed this size per segment.
30
+ * `"20m"`, `"512k"`, `"2g"`, or raw bytes. `0` disables.
33
31
  * @default 0 (unlimited)
34
32
  */
35
33
  maxSize?: number | string;
36
34
  /**
37
- * Retention policy for this stream family.
38
- * - `"90d"` — delete archived files older than 90 days (by mtime)
35
+ * Retention by age or count:
36
+ * - `"90d"` — delete archived files older than 90 days (mtime)
39
37
  * - `500` — keep at most the newest 500 archived files
40
- * Unset means **never** auto-delete.
38
+ * Unset means never auto-delete.
41
39
  */
42
40
  maxFiles?: number | string;
43
41
  /**
44
- * IANA timezone used for date stamps and the daily boundary.
45
- * Resolved via `Intl.DateTimeFormat`; throws early if invalid.
46
- * @default "UTC"
42
+ * Retention by total byte budget across archived files (the active
43
+ * segment is never counted). When archives exceed the budget, the
44
+ * OLDEST are deleted until under budget. Composes with `maxFiles`
45
+ * (either policy may delete). `"5g"`, `"500m"`, or raw bytes.
46
+ * Unset/0 = no byte budget.
47
47
  */
48
+ maxTotalSize?: number | string;
49
+ /** IANA timezone for stamps and the daily boundary.
50
+ * @default "UTC" */
48
51
  tz?: string;
49
- /**
50
- * Rotation cadence: `"daily"` (local midnight in `tz`) or an interval
51
- * like `"30s"`, `"5m"`, `"2h"`, or a millisecond number.
52
- * Interval boundaries align to epoch multiples (hh:00:00 for `"1h"`).
53
- * @default "daily"
54
- */
52
+ /** `"daily"` (local midnight in `tz`) or interval `"30s"`/`"5m"`/`"2h"`
53
+ * /ms. Interval boundaries align to epoch multiples.
54
+ * @default "daily" */
55
55
  frequency?: number | string;
56
56
  /**
57
- * TEST ONLY. Static shift of the internal clock, in hours.
58
- * `"-6h"`, `"24h"`, or a plain number (fractional allowed).
59
- * Affects naming, timers, and retention cutoffs — never production data.
57
+ * TEST ONLY. Static shift of the internal clock. `"-6h"`, `"24h"`,
58
+ * plain/fractional number, or `"30m"`/`"90s"`.
60
59
  */
61
60
  addHours?: number | string;
62
61
  /**
63
- * TEST ONLY. Amount of virtual time added to the internal clock at
64
- * every tick ({@linkcode RotateFileStreamOptions.clockStepIntervalMs}),
65
- * simulating passing days: crossing into a new period archives the old
66
- * segment exactly like a real midnight roll.
62
+ * TEST ONLY. Virtual time added per tick (see `clockStepIntervalMs`).
63
+ * Negative values exercise the backward-clock guard (no rotations).
67
64
  */
68
65
  addHoursEveryMin?: number | string;
69
- /**
70
- * Real interval between virtual-clock ticks (see `addHoursEveryMin`).
71
- * Lower it to fast-forward faster in integration tests.
72
- * @default 60_000
73
- */
66
+ /** Real interval between virtual-clock ticks.
67
+ * @default 60_000 */
74
68
  clockStepIntervalMs?: number;
75
- /** Detach rotation timers from the event loop (`timer.unref()`). */
76
- /** @default false */
77
- unrefTimers?: boolean;
78
69
  /**
79
- * Maintain `<stem>_audit.json` beside the logs: lifecycle manifest,
80
- * uuid-tagged journal, restart recovery, interrupted-gzip repair,
81
- * zombie-process detection. Library-owned; treat as read-only.
82
- * @default true
83
- */
70
+ * Maintain a stable "current" pointer for log collectors
71
+ * (`tail -f`, Filebeat, promtail). `true` derives the name from the
72
+ * prefix (`"app-"` → `app-current.log`); a string names it exactly.
73
+ * The link is updated atomically each time a segment opens. Best-effort:
74
+ * on filesystems denying symlinks (Windows without Developer Mode) it
75
+ * warns once and disables itself.
76
+ * @default false */
77
+ symlink?: boolean | string;
78
+ /**
79
+ * Awaited after a segment is sealed/archived and BEFORE retention can
80
+ * delete it — the extension point for uploading to S3/GCS. Errors and
81
+ * timeouts degrade to a `warn`; they never break rotation.
82
+ * @default undefined */
83
+ onSeal?: (info: SealInfo) => Promise<void> | void;
84
+ /** Hard deadline for one `onSeal` invocation.
85
+ * @default 30_000 */
86
+ sealHookTimeoutMs?: number;
87
+ /** Detach rotation timers from the event loop.
88
+ * @default false */
89
+ unrefTimers?: boolean;
90
+ /** Maintain `<stem>_audit.json` (library-owned; treat as read-only).
91
+ * @default true */
84
92
  audit?: boolean;
85
- /** Override the audit manifest filename. Default derives from the prefix. */
93
+ /** Override the audit manifest filename. */
86
94
  auditFile?: string;
87
95
  /** Passed through to the underlying `Writable`. */
88
96
  highWaterMark?: number;
89
97
  }
98
+ /** Payload delivered to `onSeal` when a segment completes its lifecycle. */
99
+ export interface SealInfo {
100
+ /** Closed segment path. May already be unlinked when `gz` exists. */
101
+ raw: string;
102
+ /** Compressed artifact; `null` when compression is off or failed. */
103
+ gz: string | null;
104
+ }
105
+ /** One file of the stream family, as reported by `listSegments()`. */
106
+ export interface SegmentInfo {
107
+ file: string;
108
+ /** Date stamp embedded in the filename. */
109
+ stamp: string;
110
+ /** Segment index within the stamp (0 = base file). */
111
+ index: number;
112
+ gzipped: boolean;
113
+ bytes: number;
114
+ /** Audit state, or `"untracked"` for files unknown to the manifest. */
115
+ state: string;
116
+ openedAt?: string;
117
+ archivedAt?: string;
118
+ }
119
+ /** Snapshot for dashboards/health checks — see `stats()`. */
120
+ export interface StreamStats {
121
+ dir: string;
122
+ currentStamp: string;
123
+ activeFile: string | null;
124
+ activeIndex: number;
125
+ /** All family files on disk (active included). */
126
+ segments: number;
127
+ /** Of those, how many are compressed archives. */
128
+ archived: number;
129
+ totalBytes: number;
130
+ /** Active virtual-clock shift (non-zero only under test knobs). */
131
+ clockOffsetMs: number;
132
+ /** Active symlink path, or `null`. */
133
+ symlink: string | null;
134
+ }
90
135
  /** Payload for the `rotated` event. */
91
136
  export interface RotatedInfo {
92
137
  reason: RotationReason;
93
- /** Segment that was closed; `null` when nothing was open. */
94
138
  oldFile: string | null;
95
- /** Gzip artifact created for it; `null` when unzipped, empty, or failed. */
96
139
  archive: string | null;
97
- /** Segment subsequent writes go to (opened lazily). */
98
140
  newFile: string;
99
141
  }
100
- /** Payload for the `clock` event (emitted when a virtual clock is active). */
142
+ /** Payload for the `clock` event. */
101
143
  export interface ClockInfo {
102
144
  addHours?: number | string;
103
145
  addHoursEveryMin?: number | string;
104
146
  offsetMs: number;
105
147
  }
106
- /**
107
- * Tuple-style event map used to type `on` / `once` / `emit`.
108
- * Keys mirror Node core lifecycle events where relevant.
109
- */
148
+ /** Tuple-style event map typing `on` / `once` / `emit`. */
110
149
  export interface RotateFileStreamEventMap {
111
- /** A segment file was opened for appending. */
112
150
  open: [file: string];
113
- /** A segment completed its lifecycle; see {@linkcode RotatedInfo}. */
114
151
  rotated: [info: RotatedInfo];
115
- /** A gzip archive finished successfully. */
116
152
  archive: [gzFile: string];
117
- /** Retention removed a file. */
118
153
  deleted: [file: string];
119
- /** Period changed with nothing to archive (idle boundary slide). */
120
154
  period: [stamp: string];
121
- /** Virtual clock engaged (test knobs). */
122
155
  clock: [info: ClockInfo];
123
- /** Non-fatal problem: gzip failure, divergent state, stray writer… */
124
156
  warn: [err: Error];
125
- /** Fatal problem mid-write; mirrors `Writable#close` semantics. */
126
157
  error: [err: Error];
127
- /** Stream fully closed. */
128
158
  close: [];
129
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logroller",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Zero-dependency rotating file stream for Node.js — daily/interval/size rotation, gzip archives, retention, timezone-aware, crash-safe resume",
5
5
  "license": "MIT",
6
6
  "author": "Vinau",
@@ -59,7 +59,7 @@
59
59
  "build": "tsc -p tsconfig.json && rollup -c",
60
60
  "typecheck": "tsc --noEmit -p tsconfig.json",
61
61
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.js\"",
62
- "test": "node --test --experimental-strip-types test/rotate.test.ts",
62
+ "test": "npm run build && node --test --experimental-strip-types test/rotate.test.ts test/features.test.ts",
63
63
  "verify": "npm run typecheck && npm test",
64
64
  "prepublishOnly": "npm run verify",
65
65
  "release": "npm run verify && npm pack --dry-run"