@warlock.js/logger 5.30.0 → 5.31.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +26 -5
  2. package/README.md +8 -11
  3. package/cjs/index.cjs +336 -28
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/channels/console-log.d.mts.map +1 -1
  6. package/esm/channels/console-log.mjs.map +1 -1
  7. package/esm/channels/file-log.d.mts +128 -12
  8. package/esm/channels/file-log.d.mts.map +1 -1
  9. package/esm/channels/file-log.mjs +203 -22
  10. package/esm/channels/file-log.mjs.map +1 -1
  11. package/esm/channels/file-name-pattern.d.mts +16 -0
  12. package/esm/channels/file-name-pattern.d.mts.map +1 -0
  13. package/esm/channels/file-name-pattern.mjs +41 -0
  14. package/esm/channels/file-name-pattern.mjs.map +1 -0
  15. package/esm/channels/index.mjs +1 -1
  16. package/esm/channels/json-file-log.d.mts.map +1 -1
  17. package/esm/channels/json-file-log.mjs +24 -5
  18. package/esm/channels/json-file-log.mjs.map +1 -1
  19. package/esm/channels/sentry-log.d.mts.map +1 -1
  20. package/esm/channels/sentry-log.mjs.map +1 -1
  21. package/esm/index.d.mts +5 -3
  22. package/esm/index.mjs +3 -2
  23. package/esm/log-channel.d.mts +23 -1
  24. package/esm/log-channel.d.mts.map +1 -1
  25. package/esm/log-channel.mjs +22 -0
  26. package/esm/log-channel.mjs.map +1 -1
  27. package/esm/logger.d.mts +5 -0
  28. package/esm/logger.d.mts.map +1 -1
  29. package/esm/logger.mjs +11 -2
  30. package/esm/logger.mjs.map +1 -1
  31. package/esm/redact/escape-log-value.d.mts +26 -0
  32. package/esm/redact/escape-log-value.d.mts.map +1 -0
  33. package/esm/redact/escape-log-value.mjs +37 -0
  34. package/esm/redact/escape-log-value.mjs.map +1 -0
  35. package/esm/redact/index.mjs +1 -0
  36. package/esm/redact/redact.d.mts.map +1 -1
  37. package/esm/redact/redact.mjs.map +1 -1
  38. package/esm/types.d.mts +3 -2
  39. package/esm/types.d.mts.map +1 -1
  40. package/llms-full.txt +241 -141
  41. package/llms.txt +2 -2
  42. package/package.json +2 -2
  43. package/skills/capture-unhandled-errors/SKILL.md +9 -10
  44. package/skills/configure-logger/SKILL.md +7 -10
  45. package/skills/filter-log-entries/SKILL.md +2 -2
  46. package/skills/flush-logs-on-shutdown/SKILL.md +32 -13
  47. package/skills/index.md +4 -1
  48. package/skills/logger-basics/SKILL.md +18 -21
  49. package/skills/overview/SKILL.md +17 -6
  50. package/skills/pick-log-channel/SKILL.md +36 -24
  51. package/skills/redact-sensitive-log-fields/SKILL.md +48 -18
  52. package/skills/ship-logs-to-sentry/SKILL.md +18 -18
  53. package/skills/test-logging-code/SKILL.md +4 -2
  54. package/skills/write-custom-log-channel/SKILL.md +50 -17
@@ -1 +1 @@
1
- {"version":3,"file":"console-log.d.mts","names":[],"sources":["../../../../../../../../packages/logger/src/channels/console-log.ts"],"mappings":";;;;KAKY,gBAAA,GAAmB,sBAAsB;;AAArD;;;;;;;;EAUE,WAAA;EAiFW;;;;;;EA1EX,YAAA;AAAA;AAAA,cA0EW,UAAA,SAAmB,UAAA,CAAW,gBAAA;EAAA;;;EAIlC,IAAA;EAUU;;;EALV,QAAA;;;;EAKA,GAAA,CAAI,IAAA,EAAM,WAAA;AAAA"}
1
+ {"version":3,"file":"console-log.d.mts","names":[],"sources":["../../../../../../../../packages/logger/src/channels/console-log.ts"],"mappings":";;;;KAKY,gBAAA,GAAmB,sBAAsB;;AAArD;;;;;;;;EAUE,WAAA;EA6EW;;;;;;EAtEX,YAAA;AAAA;AAAA,cAsEW,UAAA,SAAmB,UAAA,CAAW,gBAAA;EAAA;;;EAIlC,IAAA;EAUU;;;EALV,QAAA;;;;EAKA,GAAA,CAAI,IAAA,EAAM,WAAA;AAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"console-log.mjs","names":[],"sources":["../../../../../../../../packages/logger/src/channels/console-log.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\nimport { inspect } from \"util\";\nimport { LogChannel } from \"../log-channel\";\nimport type { BasicLogConfigurations, LoggingData, LogLevel } from \"../types\";\n\nexport type ConsoleLogConfig = BasicLogConfigurations & {\n /**\n * Render the log entry's `context` object on a second line after the main\n * message. When `false`, context is silently dropped (the historical\n * behavior). When `true`, contexts are pretty-printed with `util.inspect`\n * — colored, depth-limited, ideal for development. Persistent channels\n * (`FileLog`, `JSONFileLog`) always retain context regardless of this flag.\n *\n * @default false\n */\n showContext?: boolean;\n /**\n * Depth passed to `util.inspect` when rendering context. Only applies when\n * `showContext` is enabled.\n *\n * @default 4\n */\n contextDepth?: number;\n};\n\n/**\n * The longest level name (`success`) sets the tag column width. Padding every\n * level name to this width keeps the timestamp / module / action columns\n * aligned across lines. The level icons are bare (text-style, single-cell)\n * glyphs, so padding the name alone produces true columns.\n */\nconst LEVEL_NAME_WIDTH = 7;\n\n/**\n * Per-level console presentation: the colored, fixed-width `{icon} {name}` tag\n * and the function that colors the message body.\n */\ntype LevelStyle = {\n tag: string;\n message: (message: LoggingData[\"message\"]) => string;\n};\n\n/**\n * Build a single-color, fixed-width tag — `{icon} {name}` with the name padded\n * so every tag spans the same number of columns.\n */\nfunction buildTag(\n icon: string,\n name: string,\n color: (text: string) => string,\n): string {\n return color(`${icon} ${name.padEnd(LEVEL_NAME_WIDTH)}`);\n}\n\n/**\n * Console styling per log level. The `fatal` tag is a bright-red background\n * badge — the same column width as the others (`\" ☠ fatal \"` is 9 cells, like\n * `{icon} {name.padEnd(7)}`), but impossible to miss in a wall of red `error`\n * lines, where a plain red tag would blend in.\n */\nconst LEVEL_STYLES: Record<LogLevel, LevelStyle> = {\n debug: {\n tag: buildTag(\"⚙\", \"debug\", colors.magentaBright),\n message: colors.magentaBright,\n },\n info: {\n tag: buildTag(\"ℹ\", \"info\", colors.blueBright),\n message: colors.blueBright,\n },\n warn: {\n tag: buildTag(\"⚠\", \"warn\", colors.yellow),\n message: colors.yellowBright,\n },\n error: {\n tag: buildTag(\"✗\", \"error\", colors.red),\n message: colors.redBright,\n },\n success: {\n tag: buildTag(\"✓\", \"success\", colors.green),\n message: colors.greenBright,\n },\n fatal: {\n tag: colors.bgRedBright(colors.whiteBright(colors.bold(\" ☠ fatal \"))),\n message: colors.redBright.bold,\n },\n};\n\n/**\n * Fallback for an unrecognized level — a plain `[log]` tag padded to the same\n * width so the columns stay aligned.\n */\nconst DEFAULT_STYLE: LevelStyle = {\n tag: \"[log]\".padEnd(LEVEL_NAME_WIDTH + 2),\n message: message => message as string,\n};\n\nexport class ConsoleLog extends LogChannel<ConsoleLogConfig> {\n /**\n * {@inheritdoc}\n */\n public name = \"console\";\n\n /**\n * Determine if channel is logging in terminal\n */\n public terminal = true;\n\n /**\n * {@inheritdoc}\n */\n public log(data: LoggingData) {\n const { module, action, message, type: level } = data;\n\n if (!this.shouldBeLogged(data)) return;\n\n // Console shows time-only (HH:mm:ss.SSS) — within a dev session the date\n // rarely changes, so the full ISO date + `T`/`Z` are just noise. Persistent\n // channels (FileLog / JSONFileLog) keep the full ISO timestamp.\n const time = new Date().toISOString().slice(11, 23); // i.e. \"10:22:00.000\"\n\n const style = LEVEL_STYLES[level] ?? DEFAULT_STYLE;\n\n // The slate timestamp recedes so the colored level + message lead the eye;\n // module (cyan) and action (magenta) stay colored for subsystem scanning.\n // `slate` (256-color neutral gray) reads cleaner than bright-black `gray`,\n // which sits too close to the background on most terminal themes.\n console.log(\n style.tag,\n colors.slate(`(${time})`),\n colors.cyan(`[${module}]`),\n colors.magenta(`[${action}]`),\n style.message(message),\n );\n\n if (typeof message === \"object\") {\n console.log(message);\n }\n\n // Render context on a second line when explicitly enabled. We only\n // attempt rendering if there's anything meaningful to show — empty\n // objects clutter the terminal without adding signal.\n if (this.config(\"showContext\") && data.context && Object.keys(data.context).length > 0) {\n const depth = this.config(\"contextDepth\") ?? 4;\n console.log(\n colors.slate(\" ↳\"),\n inspect(data.context, { colors: true, depth, breakLength: 80 }),\n );\n }\n }\n}\n"],"mappings":";;;;;;;;;;;AA+BA,MAAM,mBAAmB;;;;;AAezB,SAAS,SACP,MACA,MACA,OACQ;CACR,OAAO,MAAM,GAAG,KAAK,GAAG,KAAK,OAAO,gBAAgB,GAAG;AACzD;;;;;;;AAQA,MAAM,eAA6C;CACjD,OAAO;EACL,KAAK,SAAS,KAAK,SAAS,OAAO,aAAa;EAChD,SAAS,OAAO;CAClB;CACA,MAAM;EACJ,KAAK,SAAS,KAAK,QAAQ,OAAO,UAAU;EAC5C,SAAS,OAAO;CAClB;CACA,MAAM;EACJ,KAAK,SAAS,KAAK,QAAQ,OAAO,MAAM;EACxC,SAAS,OAAO;CAClB;CACA,OAAO;EACL,KAAK,SAAS,KAAK,SAAS,OAAO,GAAG;EACtC,SAAS,OAAO;CAClB;CACA,SAAS;EACP,KAAK,SAAS,KAAK,WAAW,OAAO,KAAK;EAC1C,SAAS,OAAO;CAClB;CACA,OAAO;EACL,KAAK,OAAO,YAAY,OAAO,YAAY,OAAO,KAAK,WAAW,CAAC,CAAC;EACpE,SAAS,OAAO,UAAU;CAC5B;AACF;;;;;AAMA,MAAM,gBAA4B;CAChC,KAAK,QAAQ,OAAO,CAAoB;CACxC,UAAS,YAAW;AACtB;AAEA,IAAa,aAAb,cAAgC,WAA6B;;;cAI7C;kBAKI;;;;;CAKlB,AAAO,IAAI,MAAmB;EAC5B,MAAM,EAAE,QAAQ,QAAQ,SAAS,MAAM,UAAU;EAEjD,IAAI,CAAC,KAAK,eAAe,IAAI,GAAG;EAKhC,MAAM,wBAAO,IAAI,KAAK,EAAC,CAAC,YAAY,CAAC,CAAC,MAAM,IAAI,EAAE;EAElD,MAAM,QAAQ,aAAa,UAAU;EAMrC,QAAQ,IACN,MAAM,KACN,OAAO,MAAM,IAAI,KAAK,EAAE,GACxB,OAAO,KAAK,IAAI,OAAO,EAAE,GACzB,OAAO,QAAQ,IAAI,OAAO,EAAE,GAC5B,MAAM,QAAQ,OAAO,CACvB;EAEA,IAAI,OAAO,YAAY,UACrB,QAAQ,IAAI,OAAO;EAMrB,IAAI,KAAK,OAAO,aAAa,KAAK,KAAK,WAAW,OAAO,KAAK,KAAK,OAAO,CAAC,CAAC,SAAS,GAAG;GACtF,MAAM,QAAQ,KAAK,OAAO,cAAc,KAAK;GAC7C,QAAQ,IACN,OAAO,MAAM,KAAK,GAClB,QAAQ,KAAK,SAAS;IAAE,QAAQ;IAAM;IAAO,aAAa;GAAG,CAAC,CAChE;EACF;CACF;AACF"}
1
+ {"version":3,"file":"console-log.mjs","names":[],"sources":["../../../../../../../../packages/logger/src/channels/console-log.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\nimport { inspect } from \"util\";\nimport { LogChannel } from \"../log-channel\";\nimport type { BasicLogConfigurations, LoggingData, LogLevel } from \"../types\";\n\nexport type ConsoleLogConfig = BasicLogConfigurations & {\n /**\n * Render the log entry's `context` object on a second line after the main\n * message. When `false`, context is silently dropped (the historical\n * behavior). When `true`, contexts are pretty-printed with `util.inspect`\n * — colored, depth-limited, ideal for development. Persistent channels\n * (`FileLog`, `JSONFileLog`) always retain context regardless of this flag.\n *\n * @default false\n */\n showContext?: boolean;\n /**\n * Depth passed to `util.inspect` when rendering context. Only applies when\n * `showContext` is enabled.\n *\n * @default 4\n */\n contextDepth?: number;\n};\n\n/**\n * The longest level name (`success`) sets the tag column width. Padding every\n * level name to this width keeps the timestamp / module / action columns\n * aligned across lines. The level icons are bare (text-style, single-cell)\n * glyphs, so padding the name alone produces true columns.\n */\nconst LEVEL_NAME_WIDTH = 7;\n\n/**\n * Per-level console presentation: the colored, fixed-width `{icon} {name}` tag\n * and the function that colors the message body.\n */\ntype LevelStyle = {\n tag: string;\n message: (message: LoggingData[\"message\"]) => string;\n};\n\n/**\n * Build a single-color, fixed-width tag — `{icon} {name}` with the name padded\n * so every tag spans the same number of columns.\n */\nfunction buildTag(icon: string, name: string, color: (text: string) => string): string {\n return color(`${icon} ${name.padEnd(LEVEL_NAME_WIDTH)}`);\n}\n\n/**\n * Console styling per log level. The `fatal` tag is a bright-red background\n * badge — the same column width as the others (`\" ☠ fatal \"` is 9 cells, like\n * `{icon} {name.padEnd(7)}`), but impossible to miss in a wall of red `error`\n * lines, where a plain red tag would blend in.\n */\nconst LEVEL_STYLES: Record<LogLevel, LevelStyle> = {\n debug: {\n tag: buildTag(\"⚙\", \"debug\", colors.magentaBright),\n message: colors.magentaBright,\n },\n info: {\n tag: buildTag(\"ℹ\", \"info\", colors.blueBright),\n message: colors.blueBright,\n },\n warn: {\n tag: buildTag(\"⚠\", \"warn\", colors.yellow),\n message: colors.yellowBright,\n },\n error: {\n tag: buildTag(\"✗\", \"error\", colors.red),\n message: colors.redBright,\n },\n success: {\n tag: buildTag(\"✓\", \"success\", colors.green),\n message: colors.greenBright,\n },\n fatal: {\n tag: colors.bgRedBright(colors.whiteBright(colors.bold(\" ☠ fatal \"))),\n message: colors.redBright.bold,\n },\n};\n\n/**\n * Fallback for an unrecognized level — a plain `[log]` tag padded to the same\n * width so the columns stay aligned.\n */\nconst DEFAULT_STYLE: LevelStyle = {\n tag: \"[log]\".padEnd(LEVEL_NAME_WIDTH + 2),\n message: (message) => message as string,\n};\n\nexport class ConsoleLog extends LogChannel<ConsoleLogConfig> {\n /**\n * {@inheritdoc}\n */\n public name = \"console\";\n\n /**\n * Determine if channel is logging in terminal\n */\n public terminal = true;\n\n /**\n * {@inheritdoc}\n */\n public log(data: LoggingData) {\n const { module, action, message, type: level } = data;\n\n if (!this.shouldBeLogged(data)) return;\n\n // Console shows time-only (HH:mm:ss.SSS) — within a dev session the date\n // rarely changes, so the full ISO date + `T`/`Z` are just noise. Persistent\n // channels (FileLog / JSONFileLog) keep the full ISO timestamp.\n const time = new Date().toISOString().slice(11, 23); // i.e. \"10:22:00.000\"\n\n const style = LEVEL_STYLES[level] ?? DEFAULT_STYLE;\n\n // The slate timestamp recedes so the colored level + message lead the eye;\n // module (cyan) and action (magenta) stay colored for subsystem scanning.\n // `slate` (256-color neutral gray) reads cleaner than bright-black `gray`,\n // which sits too close to the background on most terminal themes.\n console.log(\n style.tag,\n colors.slate(`(${time})`),\n colors.cyan(`[${module}]`),\n colors.magenta(`[${action}]`),\n style.message(message),\n );\n\n if (typeof message === \"object\") {\n console.log(message);\n }\n\n // Render context on a second line when explicitly enabled. We only\n // attempt rendering if there's anything meaningful to show — empty\n // objects clutter the terminal without adding signal.\n if (this.config(\"showContext\") && data.context && Object.keys(data.context).length > 0) {\n const depth = this.config(\"contextDepth\") ?? 4;\n console.log(\n colors.slate(\" ↳\"),\n inspect(data.context, { colors: true, depth, breakLength: 80 }),\n );\n }\n }\n}\n"],"mappings":";;;;;;;;;;;AA+BA,MAAM,mBAAmB;;;;;AAezB,SAAS,SAAS,MAAc,MAAc,OAAyC;CACrF,OAAO,MAAM,GAAG,KAAK,GAAG,KAAK,OAAO,gBAAgB,GAAG;AACzD;;;;;;;AAQA,MAAM,eAA6C;CACjD,OAAO;EACL,KAAK,SAAS,KAAK,SAAS,OAAO,aAAa;EAChD,SAAS,OAAO;CAClB;CACA,MAAM;EACJ,KAAK,SAAS,KAAK,QAAQ,OAAO,UAAU;EAC5C,SAAS,OAAO;CAClB;CACA,MAAM;EACJ,KAAK,SAAS,KAAK,QAAQ,OAAO,MAAM;EACxC,SAAS,OAAO;CAClB;CACA,OAAO;EACL,KAAK,SAAS,KAAK,SAAS,OAAO,GAAG;EACtC,SAAS,OAAO;CAClB;CACA,SAAS;EACP,KAAK,SAAS,KAAK,WAAW,OAAO,KAAK;EAC1C,SAAS,OAAO;CAClB;CACA,OAAO;EACL,KAAK,OAAO,YAAY,OAAO,YAAY,OAAO,KAAK,WAAW,CAAC,CAAC;EACpE,SAAS,OAAO,UAAU;CAC5B;AACF;;;;;AAMA,MAAM,gBAA4B;CAChC,KAAK,QAAQ,OAAO,CAAoB;CACxC,UAAU,YAAY;AACxB;AAEA,IAAa,aAAb,cAAgC,WAA6B;;;cAI7C;kBAKI;;;;;CAKlB,AAAO,IAAI,MAAmB;EAC5B,MAAM,EAAE,QAAQ,QAAQ,SAAS,MAAM,UAAU;EAEjD,IAAI,CAAC,KAAK,eAAe,IAAI,GAAG;EAKhC,MAAM,wBAAO,IAAI,KAAK,EAAC,CAAC,YAAY,CAAC,CAAC,MAAM,IAAI,EAAE;EAElD,MAAM,QAAQ,aAAa,UAAU;EAMrC,QAAQ,IACN,MAAM,KACN,OAAO,MAAM,IAAI,KAAK,EAAE,GACxB,OAAO,KAAK,IAAI,OAAO,EAAE,GACzB,OAAO,QAAQ,IAAI,OAAO,EAAE,GAC5B,MAAM,QAAQ,OAAO,CACvB;EAEA,IAAI,OAAO,YAAY,UACrB,QAAQ,IAAI,OAAO;EAMrB,IAAI,KAAK,OAAO,aAAa,KAAK,KAAK,WAAW,OAAO,KAAK,KAAK,OAAO,CAAC,CAAC,SAAS,GAAG;GACtF,MAAM,QAAQ,KAAK,OAAO,cAAc,KAAK;GAC7C,QAAQ,IACN,OAAO,MAAM,KAAK,GAClB,QAAQ,KAAK,SAAS;IAAE,QAAQ;IAAM;IAAO,aAAa;GAAG,CAAC,CAChE;EACF;CACF;AACF"}
@@ -1,18 +1,61 @@
1
1
  import { BasicLogConfigurations, LogContract, LogLevel, LogMessage, LoggingData } from "../types.mjs";
2
2
  import { LogChannel } from "../log-channel.mjs";
3
+ import { FileNameOption } from "./file-name-pattern.mjs";
3
4
 
4
5
  //#region ../../packages/logger/src/channels/file-log.d.ts
6
+ /** Default file name of a `daily` chunk: sortable ISO date. */
7
+ declare const DAILY_FILE_NAME = "{YYYY}-{MM}-{DD}";
8
+ /** Default file name of an `hourly` chunk: sortable ISO date and hour. */
9
+ declare const HOURLY_FILE_NAME = "{YYYY}-{MM}-{DD}-{HH}";
10
+ /** Default date part of a size-rotated file's name. */
11
+ declare const ROTATE_FILE_NAME = "{YYYY}-{MM}-{DD}";
12
+ /**
13
+ * Which old log files `FileLog` removes, on boot and whenever it starts a new
14
+ * file (a size rotation or a dated name rolling over). Only files in the log
15
+ * directory (each group directory with `groupBy`) whose names match the
16
+ * channel's file name pattern are considered; the file being written is
17
+ * always kept.
18
+ */
19
+ type FileLogRetention = {
20
+ /**
21
+ * Keep at most this many matching files per directory, newest (by
22
+ * modification time) first, counting the file being written.
23
+ */
24
+ maxFiles?: number;
25
+ /**
26
+ * Remove matching files last modified more than this many days ago.
27
+ */
28
+ maxAgeDays?: number;
29
+ /**
30
+ * Which file names (with extension) belong to this channel. Defaults to one
31
+ * derived from `fileName` and `rotateFileName`; required when `fileName` is
32
+ * a function.
33
+ */
34
+ match?: RegExp;
35
+ };
5
36
  type FileLogConfig = BasicLogConfigurations & {
6
37
  storagePath?: string;
7
38
  /**
8
- * File name, without extension
39
+ * Base name, without extension: the file name of a `single` chunk and the
40
+ * `{name}` token of a `fileName` pattern.
41
+ *
42
+ * @default app
9
43
  */
10
44
  name?: string;
11
45
  /**
12
- * chunk mode
13
- * If set to `single`, the logs will be created in a single file, unless the rotate is set to true
14
- * If set to `daily`, the logs will be created in a daily file, unless the rotate is set to true
15
- * If set to `hourly`, the logs will be created in an hourly file, unless the rotate is set to true
46
+ * The file name, without extension: a pattern such as
47
+ * `"app-{YYYY}-{MM}-{DD}"` (tokens `{YYYY}` `{MM}` `{DD}` `{HH}` `{mm}`
48
+ * `{name}`, local time) or a function of the current date. Evaluated at
49
+ * every write, so a dated name starts a new file when it changes.
50
+ *
51
+ * @default by chunk: `single` → name, `daily` → `{YYYY}-{MM}-{DD}`, `hourly` → `{YYYY}-{MM}-{DD}-{HH}`
52
+ */
53
+ fileName?: FileNameOption;
54
+ /**
55
+ * chunk mode: picks the default `fileName`.
56
+ * `single` writes one file named `name`, `daily` one file per day
57
+ * (`YYYY-MM-DD`), `hourly` one per hour (`YYYY-MM-DD-HH`). Size rotation
58
+ * applies to each.
16
59
  * @default single
17
60
  */
18
61
  chunk?: "single" | "daily" | "hourly";
@@ -29,17 +72,26 @@ type FileLogConfig = BasicLogConfigurations & {
29
72
  */
30
73
  extension?: string;
31
74
  /**
32
- * If rotate is set, the rotate name will be added to the file name suffixed with `-`
75
+ * Middle part of a size-rotated file's name:
76
+ * `<file name>-<rotateFileName>-<epoch ms>.<extension>`. Same forms as
77
+ * `fileName` (a token pattern or a function of the rotation date); text
78
+ * without tokens is used as is.
33
79
  *
34
- * @default DD-MM-YYYY
80
+ * @default {YYYY}-{MM}-{DD}
35
81
  */
36
- rotateFileName?: string;
82
+ rotateFileName?: FileNameOption;
37
83
  /**
38
84
  * Max file size before rotating the file
39
85
  *
40
86
  * @default 10MB
41
87
  */
42
88
  maxFileSize?: number;
89
+ /**
90
+ * Remove old log files on boot and whenever a new file is started.
91
+ *
92
+ * @default none (nothing is removed)
93
+ */
94
+ retention?: FileLogRetention;
43
95
  /**
44
96
  * Set the max messages that needs to be added before writing to the file
45
97
  *
@@ -99,14 +151,36 @@ declare class FileLog extends LogChannel<FileLogConfig> implements LogContract {
99
151
  * would otherwise leak one timer per channel.
100
152
  */
101
153
  protected flushIntervalHandle?: NodeJS.Timeout;
154
+ /**
155
+ * Set when a size rotation started a new file, so the next write prunes.
156
+ */
157
+ protected retentionDue: boolean;
158
+ /**
159
+ * File name of the last write, to notice a dated name rolling over.
160
+ */
161
+ protected lastFileName?: string;
162
+ /**
163
+ * The running (or last) prune, so prunes never overlap.
164
+ */
165
+ protected pruning?: Promise<void>;
166
+ /**
167
+ * Whether the "function `fileName` needs `retention.match`" error was
168
+ * already reported.
169
+ */
170
+ protected missingMatchReported: boolean;
171
+ /**
172
+ * {@inheritdoc}
173
+ */
174
+ protected setConfigurations(configurations: FileLogConfig): this;
102
175
  /**
103
176
  * Check file size for file rotation
104
177
  */
105
178
  protected checkAndRotateFile(filePath?: string): Promise<void>;
106
179
  /**
107
- * Rotate log file
180
+ * Rotate the given log file (the active one by default) aside as
181
+ * `<file name>-<rotateFileName>-<epoch ms>.<extension>` in its directory.
108
182
  */
109
- protected rotateLogFile(): Promise<void>;
183
+ protected rotateLogFile(filePath?: string): Promise<void>;
110
184
  /**
111
185
  * Flush messages
112
186
  *
@@ -133,7 +207,16 @@ declare class FileLog extends LogChannel<FileLogConfig> implements LogContract {
133
207
  */
134
208
  protected get maxMessagesToWrite(): number;
135
209
  /**
136
- * Get file name
210
+ * The configured `name` option.
211
+ */
212
+ protected get baseName(): string;
213
+ /**
214
+ * The `fileName` option, or the chunk's default. `undefined` means the
215
+ * plain `name` of a `single` chunk, used as is (braces included).
216
+ */
217
+ protected get fileNamePattern(): FileNameOption | undefined;
218
+ /**
219
+ * Current file name, without extension
137
220
  */
138
221
  get fileName(): string;
139
222
  /**
@@ -152,6 +235,39 @@ declare class FileLog extends LogChannel<FileLogConfig> implements LogContract {
152
235
  * {@inheritdoc}
153
236
  */
154
237
  protected init(): Promise<void>;
238
+ /**
239
+ * Remove old log files as configured by `retention`. Runs on boot and
240
+ * whenever a new file is started; call it yourself to prune on demand.
241
+ * Never rejects: failures go to the channel error handler.
242
+ */
243
+ prune(): Promise<void>;
244
+ /**
245
+ * Prune once a write started a new file: after a size rotation, or when a
246
+ * dated file name rolled over. Never throws.
247
+ */
248
+ protected afterWrite(): Promise<void>;
249
+ /**
250
+ * Apply `retention` to every directory this channel writes to.
251
+ */
252
+ protected pruneFiles(): Promise<void>;
253
+ /**
254
+ * The expression a file name must match to be pruned, or `undefined` when
255
+ * none can be derived (a function `fileName` without `retention.match`).
256
+ */
257
+ protected retentionMatcher(retention: FileLogRetention): RegExp | undefined;
258
+ /**
259
+ * The log directory, or each group directory when `groupBy` is set.
260
+ */
261
+ protected retentionDirectories(): Promise<string[]>;
262
+ /**
263
+ * Remove the matching files of one directory that `retention` no longer
264
+ * keeps. Each failure is reported and the rest still run.
265
+ */
266
+ protected pruneDirectory(directory: string, matcher: RegExp, retention: FileLogRetention, activeFile: string): Promise<void>;
267
+ /**
268
+ * Send a retention failure to the channel error handler.
269
+ */
270
+ protected reportRetentionError(error: unknown, target: string): void;
155
271
  /**
156
272
  * Synchronously flush messages
157
273
  */
@@ -200,5 +316,5 @@ declare class FileLog extends LogChannel<FileLogConfig> implements LogContract {
200
316
  protected write(filePath: string, content: string): Promise<unknown>;
201
317
  }
202
318
  //#endregion
203
- export { FileLog, FileLogConfig };
319
+ export { DAILY_FILE_NAME, FileLog, FileLogConfig, FileLogRetention, HOURLY_FILE_NAME, ROTATE_FILE_NAME };
204
320
  //# sourceMappingURL=file-log.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"file-log.d.mts","names":[],"sources":["../../../../../../../../packages/logger/src/channels/file-log.ts"],"mappings":";;;;KAgBY,aAAA,GAAgB,sBAAA;EAC1B,WAAA;EADU;;;EAKV,IAAA;EAL0B;;;;;;;EAa1B,KAAA;EA8BA;;;;;EAxBA,MAAA;EA4CE;;AAAI;AAIR;;EA1CE,SAAA;EA0CsC;;;;;EApCtC,cAAA;EA6F2D;;;;;EAvF3D,WAAA;EA2T8C;;;;;EArT9C,kBAAA;EAwB2E;;;;;;;EAhB3E,OAAA;EA8BU;;;;;EAxBV,MAAA,GAAS,QAAQ;EAuDP;;;EAnDV,UAAA;IACE,IAAA;IACA,IAAA;EAAA;AAAA;AAAA,cAIS,OAAA,SAAgB,UAAA,CAAW,aAAA,aAA0B,WAAA;EAiGtD;;;EA7FH,IAAA;EA6II;;;EAAA,UAxID,QAAA,EAAU,UAAA;EA8KJ;;;EAAA,UAzKN,eAAA,EAAiB,MAAA,SAAe,UAAA;EAqNpB;;;EAAA,UAhNZ,qBAAA,EAAuB,aAAA;EAiOC;;;EAAA,UA5MxB,aAAA;EAwQI;;;EAAA,UAnQJ,SAAA;EAkSoC;;;;;EAAA,UA3RpC,mBAAA,GAAsB,MAAA,CAAO,OAAA;EAuUgB;AAAA;;EAAA,UAlUvC,kBAAA,CAAmB,QAAA,YAAwB,OAAA;;;;YAoB3C,aAAA,IAAa,OAAA;;;;;;;;;YAoBnB,gBAAA;;;;;;;;EAkBH,OAAA;;;;MAYI,QAAA;;;;gBAWG,kBAAA;;;;MAOH,QAAA;;;;MAiBA,SAAA;;;;gBAOG,OAAA;;;;MAOH,WAAA;;;;YAOK,IAAA,IAAI,OAAA;;;;EAWb,SAAA;;;;;;;;;;EAiCM,KAAA,IAAS,OAAA;;;;EAiBT,GAAA,CAAI,IAAA,EAAM,WAAA,GAAW,OAAA;;;;YAyClB,8BAAA,IAA8B,OAAA;;;;YASpC,MAAA;;;;gBAUI,uBAAA;;;;YAOE,mBAAA,IAAmB,OAAA;;;;YAwBnB,0BAAA,IAA8B,OAAA;;;;YA8BpC,sBAAA;;;;YAcM,KAAA,CAAM,QAAA,UAAkB,OAAA,WAAe,OAAA;AAAA"}
1
+ {"version":3,"file":"file-log.d.mts","names":[],"sources":["../../../../../../../../packages/logger/src/channels/file-log.ts"],"mappings":";;;;;;cAuBa,eAAA;;cAGA,gBAAA;;cAGA,gBAAA;AAHb;;;;AAA6B;AAG7B;;AAHA,KAcY,gBAAA;EAXiB;AAAA;AAW7B;;EAKE,QAAA;EAUc;;;EANd,UAAA;EAMQ;;AAAM;AAGhB;;EAHE,KAAA,GAAQ,MAAM;AAAA;AAAA,KAGJ,aAAA,GAAgB,sBAAA;EAC1B,WAAA;EAyDY;;;;;;EAlDZ,IAAA;EASA;;;;;;;;EAAA,QAAA,GAAW,cAAA;EAyCC;;;;;;;EAjCZ,KAAA;EA2DM;AAAA;AAIR;;;EAzDE,MAAA;EAkEoB;;;;;EA5DpB,SAAA;EA+H4C;;;;;;;;EAtH5C,cAAA,GAAiB,cAAA;EA4WwC;;;;;EAtWzD,WAAA;EAqiBuB;;;;;EA/hBvB,SAAA,GAAY,gBAAA;EA8Be;;;;;EAxB3B,kBAAA;EAwBgE;;;;;;;EAhBhE,OAAA;EAmCiC;;;;;EA7BjC,MAAA,GAAS,QAAA;EAiEC;;;EA7DV,UAAA;IACE,IAAA;IACA,IAAA;EAAA;AAAA;AAAA,cAIS,OAAA,SAAgB,UAAA,CAAW,aAAA,aAA0B,WAAA;EAqF7B;;;EAjF5B,IAAA;EAsG+C;;;EAAA,UAjG5C,QAAA,EAAU,UAAA;EA2KN;;;EAAA,UAtKJ,eAAA,EAAiB,MAAA,SAAe,UAAA;EAwM/B;;;EAAA,UAnMD,qBAAA,EAAuB,aAAA;EAmOjB;;;EAAA,UAhNN,aAAA;EA2OM;;;EAAA,UAtON,SAAA;EAuRA;;;;;EAAA,UAhRA,mBAAA,GAAsB,MAAA,CAAO,OAAA;EAmVvB;;;EAAA,UA9UN,YAAA;EAiVG;;;EAAA,UA5UH,YAAA;EAuYA;;;EAAA,UAlYA,OAAA,GAAU,OAAA;EA+aP;;;;EAAA,UAzaH,oBAAA;EA0bwB;;;EAAA,UArbxB,iBAAA,CAAkB,cAAA,EAAgB,aAAA;EAif9B;;;EAAA,UAxeE,kBAAA,CAAmB,QAAA,YAAwB,OAAA;EA2gBb;;;;EAAA,UAtf9B,aAAA,CAAc,QAAA,YAAwB,OAAA;EAoiBC;;AAAA;;;;;;EAAA,UAngB7C,gBAAA;;;;;;;;EAkBH,OAAA;;;;MAYI,QAAA;;;;gBAWG,kBAAA;;;;gBAOA,QAAA;;;;;gBAQA,eAAA,IAAmB,cAAA;;;;MAmBtB,QAAA;;;;MAWA,SAAA;;;;gBAOG,OAAA;;;;MAOH,WAAA;;;;YAOK,IAAA,IAAI,OAAA;;;;;;EAeb,KAAA,IAAS,OAAA;;;;;YAYA,UAAA,IAAc,OAAA;;;;YAuBd,UAAA,IAAc,OAAA;;;;;YA0BpB,gBAAA,CAAiB,SAAA,EAAW,gBAAA,GAAmB,MAAA;;;;YAqCzC,oBAAA,IAAwB,OAAA;;;;;YA8BxB,cAAA,CACd,SAAA,UACA,OAAA,EAAS,MAAA,EACT,SAAA,EAAW,gBAAA,EACX,UAAA,WACC,OAAA;;;;YAyDO,oBAAA,CAAqB,KAAA,WAAgB,MAAA;;;;EAYxC,SAAA;;;;;;;;;;EAiCM,KAAA,IAAS,OAAA;;;;EAiBT,GAAA,CAAI,IAAA,EAAM,WAAA,GAAW,OAAA;;;;YAyClB,8BAAA,IAA8B,OAAA;;;;YASpC,MAAA;;;;gBAUI,uBAAA;;;;YAOE,mBAAA,IAAmB,OAAA;;;;YA4BnB,0BAAA,IAA8B,OAAA;;;;YAgCpC,sBAAA;;;;YAcM,KAAA,CAAM,QAAA,UAAkB,OAAA,WAAe,OAAA;AAAA"}
@@ -1,4 +1,5 @@
1
1
  import { LogChannel } from "../log-channel.mjs";
2
+ import { escapeRegExp, fileNamePatternSource, renderFileName } from "./file-name-pattern.mjs";
2
3
  import { ensureDirectoryAsync } from "@warlock.js/fs";
3
4
  import dayjs from "dayjs";
4
5
  import fs from "fs";
@@ -6,6 +7,13 @@ import { EOL } from "os";
6
7
  import path from "path";
7
8
 
8
9
  //#region ../../packages/logger/src/channels/file-log.ts
10
+ /** Default file name of a `daily` chunk: sortable ISO date. */
11
+ const DAILY_FILE_NAME = "{YYYY}-{MM}-{DD}";
12
+ /** Default file name of an `hourly` chunk: sortable ISO date and hour. */
13
+ const HOURLY_FILE_NAME = "{YYYY}-{MM}-{DD}-{HH}";
14
+ /** Default date part of a size-rotated file's name. */
15
+ const ROTATE_FILE_NAME = "{YYYY}-{MM}-{DD}";
16
+ const DAY_MS = 1440 * 60 * 1e3;
9
17
  var FileLog = class extends LogChannel {
10
18
  constructor(..._args) {
11
19
  super(..._args);
@@ -21,9 +29,7 @@ var FileLog = class extends LogChannel {
21
29
  maxMessagesToWrite: 100,
22
30
  filter: () => true,
23
31
  maxFileSize: 10 * 1024 * 1024,
24
- get rotateFileName() {
25
- return dayjs().format("DD-MM-YYYY");
26
- },
32
+ rotateFileName: ROTATE_FILE_NAME,
27
33
  dateFormat: {
28
34
  date: "DD-MM-YYYY",
29
35
  time: "HH:mm:ss"
@@ -31,6 +37,15 @@ var FileLog = class extends LogChannel {
31
37
  };
32
38
  this.lastWriteTime = Date.now();
33
39
  this.isWriting = false;
40
+ this.retentionDue = false;
41
+ this.missingMatchReported = false;
42
+ }
43
+ /**
44
+ * {@inheritdoc}
45
+ */
46
+ setConfigurations(configurations) {
47
+ assertRetention(configurations.retention);
48
+ return super.setConfigurations(configurations);
34
49
  }
35
50
  /**
36
51
  * Check file size for file rotation
@@ -38,19 +53,24 @@ var FileLog = class extends LogChannel {
38
53
  async checkAndRotateFile(filePath = this.filePath) {
39
54
  if (!this.config("rotate")) return;
40
55
  try {
41
- if ((await fs.promises.stat(filePath)).size >= this.config("maxFileSize")) await this.rotateLogFile();
56
+ if ((await fs.promises.stat(filePath)).size >= this.config("maxFileSize")) await this.rotateLogFile(filePath);
42
57
  } catch (error) {
43
58
  if (error.code !== "ENOENT") console.error("Error checking log file:", error);
44
59
  }
45
60
  }
46
61
  /**
47
- * Rotate log file
62
+ * Rotate the given log file (the active one by default) aside as
63
+ * `<file name>-<rotateFileName>-<epoch ms>.<extension>` in its directory.
48
64
  */
49
- async rotateLogFile() {
50
- const fileName = `${this.fileName}-${this.config("rotateFileName")}-${Date.now()}`;
65
+ async rotateLogFile(filePath = this.filePath) {
66
+ const now = /* @__PURE__ */ new Date();
51
67
  const extension = this.extension;
52
- const rotatedFilePath = path.join(this.storagePath, `${fileName}.${extension}`);
53
- await fs.promises.rename(this.filePath, rotatedFilePath).catch((error) => {
68
+ const baseName = path.basename(filePath, `.${extension}`);
69
+ const rotateName = renderFileName(this.config("rotateFileName") ?? "{YYYY}-{MM}-{DD}", now, this.baseName);
70
+ const rotatedFilePath = path.join(path.dirname(filePath), `${baseName}-${rotateName}-${now.getTime()}.${extension}`);
71
+ await fs.promises.rename(filePath, rotatedFilePath).then(() => {
72
+ this.retentionDue = true;
73
+ }, (error) => {
54
74
  console.error("Error rotating file:", error);
55
75
  });
56
76
  }
@@ -96,17 +116,32 @@ var FileLog = class extends LogChannel {
96
116
  return this.config("maxMessagesToWrite");
97
117
  }
98
118
  /**
99
- * Get file name
119
+ * The configured `name` option.
100
120
  */
101
- get fileName() {
121
+ get baseName() {
122
+ return this.config("name") ?? "app";
123
+ }
124
+ /**
125
+ * The `fileName` option, or the chunk's default. `undefined` means the
126
+ * plain `name` of a `single` chunk, used as is (braces included).
127
+ */
128
+ get fileNamePattern() {
129
+ const configured = this.config("fileName");
130
+ if (configured !== void 0) return configured;
102
131
  switch (this.config("chunk")) {
103
- case "single":
104
- default: return this.config("name");
105
- case "daily": return dayjs().format("DD-MM-YYYY");
106
- case "hourly": return dayjs().format("DD-MM-YYYY-HH-00-00-a");
132
+ case "daily": return DAILY_FILE_NAME;
133
+ case "hourly": return HOURLY_FILE_NAME;
134
+ default: return;
107
135
  }
108
136
  }
109
137
  /**
138
+ * Current file name, without extension
139
+ */
140
+ get fileName() {
141
+ const pattern = this.fileNamePattern;
142
+ return pattern === void 0 ? this.baseName : renderFileName(pattern, /* @__PURE__ */ new Date(), this.baseName);
143
+ }
144
+ /**
110
145
  * Get file extension
111
146
  */
112
147
  get extension() {
@@ -131,6 +166,138 @@ var FileLog = class extends LogChannel {
131
166
  const logsDirectory = this.storagePath;
132
167
  await ensureDirectoryAsync(logsDirectory);
133
168
  this.initMessageFlush();
169
+ await this.prune();
170
+ }
171
+ /**
172
+ * Remove old log files as configured by `retention`. Runs on boot and
173
+ * whenever a new file is started; call it yourself to prune on demand.
174
+ * Never rejects: failures go to the channel error handler.
175
+ */
176
+ prune() {
177
+ const run = (this.pruning ?? Promise.resolve()).then(() => this.pruneFiles());
178
+ this.pruning = run;
179
+ return run;
180
+ }
181
+ /**
182
+ * Prune once a write started a new file: after a size rotation, or when a
183
+ * dated file name rolled over. Never throws.
184
+ */
185
+ async afterWrite() {
186
+ let fileName;
187
+ try {
188
+ fileName = this.fileName;
189
+ } catch {
190
+ return;
191
+ }
192
+ const rolledOver = this.lastFileName !== void 0 && this.lastFileName !== fileName;
193
+ this.lastFileName = fileName;
194
+ if (!rolledOver && !this.retentionDue) return;
195
+ this.retentionDue = false;
196
+ await this.prune();
197
+ }
198
+ /**
199
+ * Apply `retention` to every directory this channel writes to.
200
+ */
201
+ async pruneFiles() {
202
+ const retention = this.config("retention");
203
+ if (!retention || retention.maxFiles === void 0 && retention.maxAgeDays === void 0) return;
204
+ try {
205
+ const matcher = this.retentionMatcher(retention);
206
+ if (!matcher) return;
207
+ const activeFile = `${this.fileName}.${this.extension}`;
208
+ for (const directory of await this.retentionDirectories()) await this.pruneDirectory(directory, matcher, retention, activeFile);
209
+ } catch (error) {
210
+ this.reportRetentionError(error, this.storagePath);
211
+ }
212
+ }
213
+ /**
214
+ * The expression a file name must match to be pruned, or `undefined` when
215
+ * none can be derived (a function `fileName` without `retention.match`).
216
+ */
217
+ retentionMatcher(retention) {
218
+ if (retention.match) return new RegExp(retention.match.source, retention.match.flags.replace(/[gy]/g, ""));
219
+ const pattern = this.fileNamePattern;
220
+ if (typeof pattern === "function") {
221
+ if (!this.missingMatchReported) {
222
+ this.missingMatchReported = true;
223
+ this.reportRetentionError(/* @__PURE__ */ new Error("FileLog retention needs `retention.match` when `fileName` is a function; no files were pruned"), this.storagePath);
224
+ }
225
+ return;
226
+ }
227
+ const name = this.baseName;
228
+ const fileSource = pattern === void 0 ? escapeRegExp(name) : fileNamePatternSource(pattern, name);
229
+ const rotateName = this.config("rotateFileName") ?? "{YYYY}-{MM}-{DD}";
230
+ const rotateSource = typeof rotateName === "function" ? ".+" : fileNamePatternSource(rotateName, name);
231
+ return new RegExp(`^${fileSource}(?:-${rotateSource}-\\d{13,})?\\.${escapeRegExp(this.extension)}$`);
232
+ }
233
+ /**
234
+ * The log directory, or each group directory when `groupBy` is set.
235
+ */
236
+ async retentionDirectories() {
237
+ let directories = [this.storagePath];
238
+ const depth = this.config("groupBy")?.length ?? 0;
239
+ for (let level = 0; level < depth; level++) {
240
+ const next = [];
241
+ for (const directory of directories) {
242
+ const entries = await fs.promises.readdir(directory, { withFileTypes: true }).catch((error) => {
243
+ if (error.code !== "ENOENT") this.reportRetentionError(error, directory);
244
+ return [];
245
+ });
246
+ for (const entry of entries) if (entry.isDirectory()) next.push(path.join(directory, entry.name));
247
+ }
248
+ directories = next;
249
+ }
250
+ return directories;
251
+ }
252
+ /**
253
+ * Remove the matching files of one directory that `retention` no longer
254
+ * keeps. Each failure is reported and the rest still run.
255
+ */
256
+ async pruneDirectory(directory, matcher, retention, activeFile) {
257
+ let names;
258
+ try {
259
+ names = await fs.promises.readdir(directory);
260
+ } catch (error) {
261
+ if (error.code !== "ENOENT") this.reportRetentionError(error, directory);
262
+ return;
263
+ }
264
+ const candidates = [];
265
+ for (const name of names) {
266
+ if (name === activeFile || !matcher.test(name)) continue;
267
+ const filePath = path.join(directory, name);
268
+ try {
269
+ const stats = await fs.promises.stat(filePath);
270
+ if (stats.isFile()) candidates.push({
271
+ filePath,
272
+ name,
273
+ modifiedAt: stats.mtimeMs
274
+ });
275
+ } catch (error) {
276
+ if (error.code !== "ENOENT") this.reportRetentionError(error, filePath);
277
+ }
278
+ }
279
+ candidates.sort((a, b) => b.modifiedAt - a.modifiedAt || (a.name < b.name ? 1 : -1));
280
+ const keep = retention.maxFiles === void 0 ? Infinity : retention.maxFiles - (names.includes(activeFile) ? 1 : 0);
281
+ const oldestKept = retention.maxAgeDays === void 0 ? -Infinity : Date.now() - retention.maxAgeDays * DAY_MS;
282
+ for (const [index, candidate] of candidates.entries()) {
283
+ if (index < keep && candidate.modifiedAt >= oldestKept) continue;
284
+ try {
285
+ await fs.promises.unlink(candidate.filePath);
286
+ } catch (error) {
287
+ if (error.code !== "ENOENT") this.reportRetentionError(error, candidate.filePath);
288
+ }
289
+ }
290
+ }
291
+ /**
292
+ * Send a retention failure to the channel error handler.
293
+ */
294
+ reportRetentionError(error, target) {
295
+ this.reportError(error, {
296
+ type: "error",
297
+ module: "logger",
298
+ action: "retention",
299
+ message: `Log retention failed for ${target}`
300
+ });
134
301
  }
135
302
  /**
136
303
  * Synchronously flush messages
@@ -222,14 +389,17 @@ var FileLog = class extends LogChannel {
222
389
  if (this.messages.length === 0 || this.isWriting || !this.isInitialized) return;
223
390
  this.isWriting = true;
224
391
  if (this.messagedShouldBeGrouped) return await this.writeGroupedMessagesToFile();
225
- await this.checkAndRotateFile();
226
392
  try {
227
- await this.write(this.filePath, this.content);
393
+ const filePath = this.filePath;
394
+ await this.checkAndRotateFile(filePath);
395
+ await this.write(filePath, this.content);
228
396
  this.onSave();
229
397
  } catch (error) {
230
398
  console.error("Failed to write log:", error);
231
399
  this.isWriting = false;
400
+ return;
232
401
  }
402
+ await this.afterWrite();
233
403
  }
234
404
  /**
235
405
  * Write grouped messages to the file
@@ -238,11 +408,11 @@ var FileLog = class extends LogChannel {
238
408
  this.prepareGroupedMessages();
239
409
  for (const [key, groupMessages] of Object.entries(this.groupedMessages)) {
240
410
  const directoryPath = path.join(this.storagePath, key);
241
- await ensureDirectoryAsync(directoryPath);
242
- const filePath = path.join(directoryPath, `${this.fileName}.${this.extension}`);
243
- await this.checkAndRotateFile(filePath);
244
- const content = groupMessages.map((message) => message.content).join(EOL) + EOL;
245
411
  try {
412
+ await ensureDirectoryAsync(directoryPath);
413
+ const filePath = path.join(directoryPath, `${this.fileName}.${this.extension}`);
414
+ await this.checkAndRotateFile(filePath);
415
+ const content = groupMessages.map((message) => message.content).join(EOL) + EOL;
246
416
  await this.write(filePath, content);
247
417
  } catch (error) {
248
418
  console.error("Failed to write log:", error);
@@ -250,6 +420,7 @@ var FileLog = class extends LogChannel {
250
420
  }
251
421
  this.onSave();
252
422
  this.isWriting = false;
423
+ await this.afterWrite();
253
424
  }
254
425
  /**
255
426
  * Prepare grouped messages
@@ -275,7 +446,17 @@ var FileLog = class extends LogChannel {
275
446
  });
276
447
  }
277
448
  };
449
+ /**
450
+ * Reject a `retention` that could never keep or remove anything sensibly.
451
+ */
452
+ function assertRetention(retention) {
453
+ if (!retention) return;
454
+ const { maxFiles, maxAgeDays, match } = retention;
455
+ if (maxFiles !== void 0 && (!Number.isInteger(maxFiles) || maxFiles < 1)) throw new TypeError("FileLog retention.maxFiles must be a positive integer");
456
+ if (maxAgeDays !== void 0 && (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0)) throw new TypeError("FileLog retention.maxAgeDays must be a positive number");
457
+ if (match !== void 0 && !(match instanceof RegExp)) throw new TypeError("FileLog retention.match must be a RegExp");
458
+ }
278
459
 
279
460
  //#endregion
280
- export { FileLog };
461
+ export { DAILY_FILE_NAME, FileLog, HOURLY_FILE_NAME, ROTATE_FILE_NAME };
281
462
  //# sourceMappingURL=file-log.mjs.map