| @@ -1,6 +1,7 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeFS from "node:fs"; | 2 | import * as NodeFS from "node:fs"; |
| 3 | import * as NodePath from "node:path"; | 3 | import * as NodePath from "node:path"; |
| | 4 | import * as Schema from "effect/Schema"; |
| 4 | | 5 | |
| 5 | export interface RotatingFileSinkOptions { | 6 | export interface RotatingFileSinkOptions { |
| 6 | readonly filePath: string; | 7 | readonly filePath: string; |
| @@ -9,6 +10,37 @@ export interface RotatingFileSinkOptions { · 2 unmodified lines |
| 9 | readonly throwOnError?: boolean; | 10 | readonly throwOnError?: boolean; |
| 10 | } | 11 | } |
| 11 | | 12 | |
| | 13 | export class RotatingFileSinkConfigurationError extends Schema.TaggedErrorClass<RotatingFileSinkConfigurationError>()( |
| | 14 | "RotatingFileSinkConfigurationError", |
| | 15 | { |
| | 16 | option: Schema.Literals(["maxBytes", "maxFiles"]), |
| | 17 | received: Schema.Number, |
| | 18 | minimum: Schema.Number, |
| | 19 | }, |
| | 20 | ) { |
| | 21 | override get message(): string { |
| | 22 | return `${this.option} must be >= ${this.minimum} (received ${this.received})`; |
| | 23 | } |
| | 24 | } |
| | 25 | |
| | 26 | export class RotatingFileSinkError extends Schema.TaggedErrorClass<RotatingFileSinkError>()( |
| | 27 | "RotatingFileSinkError", |
| | 28 | { |
| | 29 | operation: Schema.Literals(["initialize", "read", "write", "rotate", "prune"]), |
| | 30 | filePath: Schema.String, |
| | 31 | cause: Schema.Defect(), |
| | 32 | }, |
| | 33 | ) { |
| | 34 | override get message(): string { |
| | 35 | return `Failed to ${this.operation} rotating log file ${this.filePath}`; |
| | 36 | } |
| | 37 | } |
| | 38 | |
| | 39 | const isRotatingFileSinkError = Schema.is(RotatingFileSinkError); |
| | 40 | |
| | 41 | const isFileNotFoundError = (cause: unknown): cause is NodeJS.ErrnoException => |
| | 42 | cause instanceof Error && "code" in cause && cause.code === "ENOENT"; |
| | 43 | |
| 12 | export class RotatingFileSink { | 44 | export class RotatingFileSink { |
| 13 | private readonly filePath: string; | 45 | private readonly filePath: string; |
| 14 | private readonly maxBytes: number; | 46 | private readonly maxBytes: number; |
| @@ -18,10 +50,18 @@ export class RotatingFileSink { · 3 unmodified lines |
| 18 | | 50 | |
| 19 | constructor(options: RotatingFileSinkOptions) { | 51 | constructor(options: RotatingFileSinkOptions) { |
| 20 | if (options.maxBytes < 1) { | 52 | if (options.maxBytes < 1) { |
| 21 | throw new Error(`maxBytes must be >= 1 (received ${options.maxBytes})`); | 53 | throw new RotatingFileSinkConfigurationError({ |
| | 54 | option: "maxBytes", |
| | 55 | received: options.maxBytes, |
| | 56 | minimum: 1, |
| | 57 | }); |
| 22 | } | 58 | } |
| 23 | if (options.maxFiles < 1) { | 59 | if (options.maxFiles < 1) { |
| 24 | throw new Error(`maxFiles must be >= 1 (received ${options.maxFiles})`); | 60 | throw new RotatingFileSinkConfigurationError({ |
| | 61 | option: "maxFiles", |
| | 62 | received: options.maxFiles, |
| | 63 | minimum: 1, |
| | 64 | }); |
| 25 | } | 65 | } |
| 26 | | 66 | |
| 27 | this.filePath = options.filePath; | 67 | this.filePath = options.filePath; |
| @@ -29,7 +69,15 @@ export class RotatingFileSink { · 1 unmodified line |
| 29 | this.maxFiles = options.maxFiles; | 69 | this.maxFiles = options.maxFiles; |
| 30 | this.throwOnError = options.throwOnError ?? false; | 70 | this.throwOnError = options.throwOnError ?? false; |
| 31 | | 71 | |
| 32 | NodeFS.mkdirSync(NodePath.dirname(this.filePath), { recursive: true }); | 72 | try { |
| | 73 | NodeFS.mkdirSync(NodePath.dirname(this.filePath), { recursive: true }); |
| | 74 | } catch (cause) { |
| | 75 | throw new RotatingFileSinkError({ |
| | 76 | operation: "initialize", |
| | 77 | filePath: this.filePath, |
| | 78 | cause, |
| | 79 | }); |
| | 80 | } |
| 33 | this.pruneOverflowBackups(); | 81 | this.pruneOverflowBackups(); |
| 34 | this.currentSize = this.readCurrentSize(); | 82 | this.currentSize = this.readCurrentSize(); |
| 35 | } | 83 | } |
| @@ -49,11 +97,18 @@ export class RotatingFileSink { · 13 unmodified lines |
| 49 | if (this.currentSize > this.maxBytes) { | 97 | if (this.currentSize > this.maxBytes) { |
| 50 | this.rotate(); | 98 | this.rotate(); |
| 51 | } | 99 | } |
| 52 | } catch { | 100 | } catch (cause) { |
| 53 | this.currentSize = this.readCurrentSize(); | 101 | if (isRotatingFileSinkError(cause)) { |
| | 102 | throw cause; |
| | 103 | } |
| 54 | if (this.throwOnError) { | 104 | if (this.throwOnError) { |
| 55 | throw new Error(`Failed to write log chunk to ${this.filePath}`); | 105 | throw new RotatingFileSinkError({ |
| | 106 | operation: "write", |
| | 107 | filePath: this.filePath, |
| | 108 | cause, |
| | 109 | }); |
| 56 | } | 110 | } |
| | 111 | this.currentSize = this.readCurrentSize(); |
| 57 | } | 112 | } |
| 58 | } | 113 | } |
| 59 | | 114 | |
| @@ -77,11 +132,15 @@ export class RotatingFileSink { · 17 unmodified lines |
| 77 | } | 132 | } |
| 78 | | 133 | |
| 79 | this.currentSize = 0; | 134 | this.currentSize = 0; |
| 80 | } catch { | 135 | } catch (cause) { |
| 81 | this.currentSize = this.readCurrentSize(); | | |
| 82 | if (this.throwOnError) { | 136 | if (this.throwOnError) { |
| 83 | throw new Error(`Failed to rotate log file ${this.filePath}`); | 137 | throw new RotatingFileSinkError({ |
| | 138 | operation: "rotate", |
| | 139 | filePath: this.filePath, |
| | 140 | cause, |
| | 141 | }); |
| 84 | } | 142 | } |
| | 143 | this.currentSize = this.readCurrentSize(); |
| 85 | } | 144 | } |
| 86 | } | 145 | } |
| 87 | | 146 | |
| @@ -95,9 +154,13 @@ export class RotatingFileSink { · 7 unmodified lines |
| 95 | if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue; | 154 | if (!Number.isInteger(suffix) || suffix <= this.maxFiles) continue; |
| 96 | NodeFS.rmSync(NodePath.join(dir, entry), { force: true }); | 155 | NodeFS.rmSync(NodePath.join(dir, entry), { force: true }); |
| 97 | } | 156 | } |
| 98 | } catch { | 157 | } catch (cause) { |
| 99 | if (this.throwOnError) { | 158 | if (this.throwOnError) { |
| 100 | throw new Error(`Failed to prune log backups for ${this.filePath}`); | 159 | throw new RotatingFileSinkError({ |
| | 160 | operation: "prune", |
| | 161 | filePath: this.filePath, |
| | 162 | cause, |
| | 163 | }); |
| 101 | } | 164 | } |
| 102 | } | 165 | } |
| 103 | } | 166 | } |
| @@ -105,8 +168,15 @@ export class RotatingFileSink { · 1 unmodified line |
| 105 | private readCurrentSize(): number { | 168 | private readCurrentSize(): number { |
| 106 | try { | 169 | try { |
| 107 | return NodeFS.statSync(this.filePath).size; | 170 | return NodeFS.statSync(this.filePath).size; |
| 108 | } catch { | 171 | } catch (cause) { |
| 109 | return 0; | 172 | if (isFileNotFoundError(cause)) { |
| | 173 | return 0; |
| | 174 | } |
| | 175 | throw new RotatingFileSinkError({ |
| | 176 | operation: "read", |
| | 177 | filePath: this.filePath, |
| | 178 | cause, |
| | 179 | }); |
| 110 | } | 180 | } |
| 111 | } | 181 | } |
| 112 | | 182 | |