@stonyx/logs 1.0.1-beta.20 → 1.0.1-beta.21

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/README.md CHANGED
@@ -153,16 +153,65 @@ These methods can then be called in your application with [logging parameters](#
153
153
 
154
154
  Color settings are handled by determining whether your input is a color name or a hex value (prefixed with **#**). For example, passing `red` as a color setting will utilize `chalk.red`, while passing `#ff0000` would use `chalk.hex('#ff0000')` instead. A [list of available colors](https://github.com/chalk/chalk#colors) can be found in chalks' documentation.
155
155
 
156
- Additionally, these methods return a promise when `logToFile` is true, allowing you use them with `await` in an async method, or append `then(), catch(), or finally()` for more advanced callback usage.
156
+ Additionally, these methods return a promise when `logToFile` is true. That promise rejecting is the only signal that a write failed, so you must either `await` the call or attach a `.catch()` — see [File Write Failures](#file-write-failures). `then()` and `finally()` are also available.
157
157
 
158
158
  ```js
159
159
  async method() {
160
- await log.error('error message', true);
160
+ try {
161
+ await log.error('error message', true);
161
162
 
162
- // do something after logs/error.log (default) is created
163
+ // do something after logs/error.log (default) is created
164
+ } catch (err) {
165
+ // the write failed; the rejection is the only notice you get
166
+ process.stderr.write(`log write failed: ${err.code}\n`);
167
+ }
163
168
  }
164
169
  ```
165
170
 
171
+ #### File Write Failures
172
+
173
+ When `logToFile` is true, **the returned promise rejecting is the only failure signal.** The log
174
+ line itself is still written to the console as usual, but **no error notice is printed** and no
175
+ fallback log is written when the log directory or file cannot be written: the underlying `fs` error
176
+ is propagated to the caller with its `code` intact (`ENOENT`, `ENOTDIR`, `EACCES`, `EPERM`,
177
+ `EROFS`, ...).
178
+
179
+ A fire-and-forget call therefore produces an **unhandled promise rejection** on a failed write.
180
+ Always `await` the call (or attach a `.catch()`) anywhere file logging is enabled:
181
+
182
+ ```js
183
+ // unhandled rejection if the log directory is not writable
184
+ log.error('error message', true);
185
+
186
+ // handled
187
+ log.error('error message', true).catch(err => process.stderr.write(`log write failed: ${err.code}\n`));
188
+ ```
189
+
190
+ A failed write is retried exactly once, and only for the two codes that recreating the log directory
191
+ can repair: `ENOENT` (the directory was removed at runtime) and `ENOTDIR` (a path component was
192
+ replaced by a non-directory). The directory cache entry is dropped, the directory is recreated, and
193
+ the write is reattempted once before the rejection surfaces. If the recreate itself fails, that
194
+ error is what surfaces.
195
+
196
+ Every other code — including `EACCES`, `EPERM` and `EROFS` — rejects immediately with no retry,
197
+ because `mkdir` on an existing directory is a successful no-op: it cannot change a permission bit or
198
+ a read-only mount, so a retry could only ever repeat the same failure at twice the syscall cost.
199
+
200
+ ##### The directory cache
201
+
202
+ To keep the write path free of repeated `mkdir` calls, each `Log` instance exposes a
203
+ `directoryCache` field: a `Map` keyed on the resolved directory (not on the target filename), whose
204
+ values are the in-flight or settled `mkdir` promises. It is **instance-scoped, not module-scoped** —
205
+ two `Log` instances pointing at the same directory each call `mkdir` once. Entries are **never
206
+ evicted on success**, so a directory is created at most once per instance for the process lifetime;
207
+ entries are dropped only when the `mkdir` rejects, or when a write fails with a retryable code and
208
+ the directory is recreated.
209
+
210
+ It is public only because the class carries an index signature, and it is not part of the supported
211
+ API: treat it as read-only, since mutating it corrupts the write path. Note also that the name is
212
+ reserved — a log type called `directoryCache` is silently skipped rather than overwriting the cache,
213
+ so no convenience method is generated for it.
214
+
166
215
  ### The Debug Method
167
216
 
168
217
  **Log** allows for the `log.debug()` method to be overridden by a color setting. However, by default we do not define a color for debug and debug is handled differently. For console logging, all **debug** does is output the following:
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export default class Log {
13
13
  options: LogOptions;
14
14
  color: Color;
15
15
  typeOptions: Record<string, Partial<LogOptions>>;
16
+ directoryCache: Map<string, Promise<void>>;
16
17
  [key: string]: unknown;
17
18
  info: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
18
19
  warn: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, promises as fsp } from 'fs';
1
+ import { promises as fsp } from 'fs';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { hostname } from 'os';
4
4
  import projectPath from 'path';
@@ -19,10 +19,29 @@ const defaultOptions = {
19
19
  };
20
20
  // used to sanitize defineType() options input
21
21
  const optionKeys = Object.keys(defaultOptions);
22
+ /*
23
+ * Write failures that a recursive mkdir of the log directory can actually repair:
24
+ * ENOENT (the cached directory was removed at runtime) and ENOTDIR (a path component
25
+ * was replaced by a non-directory). These invalidate the directory cache and are
26
+ * retried once.
27
+ *
28
+ * Permission and mount faults (EACCES, EPERM, EROFS) are deliberately excluded: the
29
+ * retry's only remediation is mkdir(recursive), which is a successful no-op on an
30
+ * existing directory and can change neither a mode nor a mount flag. Retrying them
31
+ * doubled the syscalls on a permanently failing write and defeated the
32
+ * one-mkdir-per-directory invariant this cache exists to establish.
33
+ */
34
+ const recoverableWriteCodes = new Set(['ENOENT', 'ENOTDIR']);
22
35
  export default class Log {
23
36
  options;
24
37
  color;
25
38
  typeOptions = {};
39
+ /*
40
+ * Instance-level cache of directory creation, keyed on the resolved directory path.
41
+ * resolveFilename() strips directory separators, so a filename template can never
42
+ * introduce a new directory and date rollover cannot invalidate an entry.
43
+ */
44
+ directoryCache = new Map();
26
45
  constructor(options = {}) {
27
46
  const merged = {
28
47
  ...defaultOptions,
@@ -120,9 +139,24 @@ export default class Log {
120
139
  const filenameTemplate = this.getOptionForType(type, 'filename');
121
140
  const resolvedName = this.resolveFilename(filenameTemplate, type);
122
141
  const targetLog = `${path}${resolvedName}`;
123
- await this.validateFileAndDirectory(path, targetLog);
124
142
  const fileAction = overwrite ? fsp.writeFile : fsp.appendFile;
125
- await fileAction(targetLog, content);
143
+ await this.validateFileAndDirectory(path, targetLog);
144
+ try {
145
+ await fileAction(targetLog, content);
146
+ }
147
+ catch (error) {
148
+ const { code } = error;
149
+ if (!recoverableWriteCodes.has(code))
150
+ throw error;
151
+ /*
152
+ * The cached directory may have been removed underneath a warm cache. Invalidate
153
+ * the entry and retry exactly once so a cache hit can never become a permanent
154
+ * silent write failure. The rejection is the caller's only failure signal.
155
+ */
156
+ this.directoryCache.delete(path);
157
+ await this.validateFileAndDirectory(path, targetLog);
158
+ await fileAction(targetLog, content);
159
+ }
126
160
  }
127
161
  // resolves template variables in a filename string
128
162
  resolveFilename(template, type) {
@@ -145,16 +179,27 @@ export default class Log {
145
179
  // sanitize: prevent path traversal and disallow directory separators
146
180
  return resolved.replace(/\.\./g, '').replace(/[/\\]/g, '');
147
181
  }
148
- // attempts to create file and/or directory if they don't already exist
182
+ /*
183
+ * Ensures the log directory exists, deduping concurrent and repeat calls onto a single
184
+ * mkdir per directory. No file bootstrap happens here: both write paths already create
185
+ * the file (appendFile opens 'a', writeFile opens 'w'), so a bootstrap write's only
186
+ * reachable effect was truncating a concurrent caller's content.
187
+ *
188
+ * targetLog is unused but retained for signature compatibility.
189
+ */
149
190
  async validateFileAndDirectory(path, targetLog) {
150
- const errorMethod = this.error;
151
- mkdirSync(path, { recursive: true });
152
- await fsp.access(targetLog).catch(() => {
153
- fsp.writeFile(targetLog, '').catch(() => {
154
- errorMethod(`Failed to create log file: ${targetLog}.`
155
- + '\n Verify that the application runner has write permissions');
156
- });
191
+ const cached = this.directoryCache.get(path);
192
+ if (cached)
193
+ return cached;
194
+ // cache the promise before awaiting so concurrent writers dedupe and none run early
195
+ const pending = fsp.mkdir(path, { recursive: true }).then(() => undefined);
196
+ this.directoryCache.set(path, pending);
197
+ // never cache a poisoned promise: drop the entry so the next write retries
198
+ pending.catch(() => {
199
+ if (this.directoryCache.get(path) === pending)
200
+ this.directoryCache.delete(path);
157
201
  });
202
+ return pending;
158
203
  }
159
204
  // method to conditionally sanitize user configuration input
160
205
  sanitizePath(path) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonyx/logs",
3
- "version": "1.0.1-beta.20",
3
+ "version": "1.0.1-beta.21",
4
4
  "description": "Simplified logging for node applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",