@stonyx/logs 1.0.1-beta.16 → 1.0.1-beta.18

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/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import Color, { type ColorSetting } from './color.js';
2
+ export type Severity = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
2
3
  export interface LogOptions {
3
4
  logToFileByDefault: boolean;
4
5
  logTimestamp: boolean;
@@ -13,7 +14,12 @@ export default class Log {
13
14
  options: LogOptions;
14
15
  color: Color;
15
16
  typeOptions: Record<string, Partial<LogOptions>>;
17
+ directoryCache: Map<string, Promise<void>>;
18
+ writeFailures: Map<string, number>;
16
19
  [key: string]: unknown;
20
+ info: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
21
+ warn: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
22
+ error: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
17
23
  constructor(options?: Partial<LogOptions>);
18
24
  defineType(type: string, setting: ColorSetting, options?: Partial<LogOptions> | null): void;
19
25
  createConvenienceMethod(type: string): void;
@@ -23,6 +29,8 @@ export default class Log {
23
29
  log(content: string, type: string, logToFile: boolean, overwrite: boolean): Promise<void>;
24
30
  debug(content: unknown, logToFile?: boolean, overwrite?: boolean): Promise<void>;
25
31
  writeToFile(type: string, content: string, overwrite: boolean): Promise<void>;
32
+ noticeWriteResult(path: string, targetLog: string, payload: Record<string, unknown> | null): void;
33
+ emitNotice(severity: Severity, event: string, payload: Record<string, unknown>): void;
26
34
  resolveFilename(template: string, type: string): string;
27
35
  validateFileAndDirectory(path: string, targetLog: string): Promise<void>;
28
36
  sanitizePath(path: string): string;
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';
@@ -23,6 +23,10 @@ export default class Log {
23
23
  options;
24
24
  color;
25
25
  typeOptions = {};
26
+ // resolved directory -> in-flight or settled mkdir; instance-scoped so it cannot outlive its directory
27
+ directoryCache = new Map();
28
+ // resolved directory -> consecutive write failures, used to dedupe the operator notice
29
+ writeFailures = new Map();
26
30
  constructor(options = {}) {
27
31
  const merged = {
28
32
  ...defaultOptions,
@@ -89,7 +93,7 @@ export default class Log {
89
93
  chalk() {
90
94
  return this.color.getChalkInstance();
91
95
  }
92
- // logs to console, and conditionally to file
96
+ // logs to console, and conditionally to file; propagates any writeToFile rejection to the caller
93
97
  async log(content, type, logToFile, overwrite) {
94
98
  const logTimestamp = this.getOptionForType(type, 'logTimestamp');
95
99
  const timestamp = `[${new Date().toLocaleString('en-US')}]`;
@@ -108,21 +112,101 @@ export default class Log {
108
112
  return;
109
113
  await this.writeToFile(type, `${timestamp} ${content}\n`, overwrite);
110
114
  }
111
- // direct hardcoded debug method (log to file functionality is limited)
115
+ // direct hardcoded debug method (limited file logging); propagates any writeToFile rejection
112
116
  async debug(content, logToFile = false, overwrite = true) {
113
117
  console.dir(content, { depth: 6 }); // eslint-disable-line no-console
114
118
  if (!logToFile)
115
119
  return;
116
120
  await this.writeToFile('debug', JSON.stringify(content, null, 2), overwrite);
117
121
  }
122
+ /*
123
+ * Writes `content` to the resolved target for `type`, creating the target's directory on first
124
+ * use for that directory.
125
+ *
126
+ * The returned promise rejecting with the underlying `NodeJS.ErrnoException` (with `err.code`
127
+ * preserved) is the sole failure signal - callers that care must handle it. The structured
128
+ * stderr notice emitted alongside a failure is informational only and is deduped per episode.
129
+ *
130
+ * On `ENOENT`, `EACCES`, `EPERM` or `EROFS` the cached directory entry is dropped and the write
131
+ * is retried exactly once; every other code rejects on the first attempt without a retry.
132
+ */
118
133
  async writeToFile(type, content, overwrite) {
119
134
  const path = this.getOptionForType(type, 'path');
120
135
  const filenameTemplate = this.getOptionForType(type, 'filename');
121
- const resolvedName = this.resolveFilename(filenameTemplate, type);
122
- const targetLog = `${path}${resolvedName}`;
123
- await this.validateFileAndDirectory(path, targetLog);
124
- const fileAction = overwrite ? fsp.writeFile : fsp.appendFile;
125
- await fileAction(targetLog, content);
136
+ const targetLog = `${path}${this.resolveFilename(filenameTemplate, type)}`;
137
+ const cached = this.directoryCache.has(path);
138
+ const attempt = async () => {
139
+ await this.validateFileAndDirectory(path, targetLog);
140
+ await (overwrite ? fsp.writeFile : fsp.appendFile)(targetLog, content);
141
+ };
142
+ try {
143
+ await attempt().catch(async (error) => {
144
+ // a warm cache can outlive its directory, so drop the entry and retry exactly once
145
+ if (!['ENOENT', 'EACCES', 'EPERM', 'EROFS'].includes(error?.code))
146
+ throw error;
147
+ this.directoryCache.delete(path);
148
+ return attempt();
149
+ });
150
+ }
151
+ catch (error) {
152
+ const { code, syscall } = error;
153
+ this.noticeWriteResult(path, targetLog, {
154
+ targetLog,
155
+ path,
156
+ syscall,
157
+ code,
158
+ cached,
159
+ });
160
+ throw error;
161
+ }
162
+ this.noticeWriteResult(path, targetLog, null);
163
+ }
164
+ /*
165
+ * One structured stderr notice per failure episode: an emit on the first failure, silence while
166
+ * it keeps failing, one recovery emit on the next success. A null payload records a success.
167
+ *
168
+ * Keyed on the directory rather than the resolved target, matching `directoryCache`: every code
169
+ * in the retry allowlist is a directory-level condition, so keying on the target would strand an
170
+ * un-reaped entry - and silently drop its suppressed count - whenever a `{date}` template rotates
171
+ * away from a still-failing filename.
172
+ */
173
+ noticeWriteResult(path, targetLog, payload) {
174
+ const failures = this.writeFailures.get(path) ?? 0;
175
+ if (payload) {
176
+ this.writeFailures.set(path, failures + 1);
177
+ if (!failures) {
178
+ this.emitNotice('error', 'log-write-failed', {
179
+ ...payload,
180
+ suppressedCount: 0,
181
+ });
182
+ }
183
+ }
184
+ else if (failures) {
185
+ this.writeFailures.delete(path);
186
+ this.emitNotice('warn', 'log-write-recovered', {
187
+ targetLog,
188
+ path,
189
+ suppressedCount: failures - 1,
190
+ });
191
+ }
192
+ }
193
+ /*
194
+ * The write-failure path must never re-enter this logger, so notices go straight to stderr as a
195
+ * single JSONL record. Field order is the canonical one mandated by the framework logging
196
+ * schema, with the optional `schemaVersion` last.
197
+ */
198
+ emitNotice(severity, event, payload) {
199
+ const ts = new Date().toISOString();
200
+ console.error(JSON.stringify({
201
+ ts,
202
+ surface: 'stonyx-logs',
203
+ sessionKey: null,
204
+ project: null,
205
+ severity,
206
+ event,
207
+ payload,
208
+ schemaVersion: 1,
209
+ }));
126
210
  }
127
211
  // resolves template variables in a filename string
128
212
  resolveFilename(template, type) {
@@ -145,16 +229,21 @@ export default class Log {
145
229
  // sanitize: prevent path traversal and disallow directory separators
146
230
  return resolved.replace(/\.\./g, '').replace(/[/\\]/g, '');
147
231
  }
148
- // attempts to create file and/or directory if they don't already exist
232
+ /*
233
+ * Ensures the target's directory exists. Both write paths auto-create the file itself, so no
234
+ * bootstrap write is needed. `targetLog` is unused but retained for signature compatibility:
235
+ * resolveFilename strips separators, so the only cached invariant is the directory.
236
+ */
149
237
  async validateFileAndDirectory(path, targetLog) {
150
- const errorMethod = this.error || console.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
- });
157
- });
238
+ let pending = this.directoryCache.get(path);
239
+ if (!pending) {
240
+ // cache the promise before awaiting: a flag here would let a concurrent write append first
241
+ pending = fsp.mkdir(path, { recursive: true }).then(() => undefined);
242
+ this.directoryCache.set(path, pending);
243
+ // never keep a poisoned entry - the next write retries from scratch
244
+ pending.catch(() => this.directoryCache.delete(path));
245
+ }
246
+ return pending;
158
247
  }
159
248
  // method to conditionally sanitize user configuration input
160
249
  sanitizePath(path) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonyx/logs",
3
- "version": "1.0.1-beta.16",
3
+ "version": "1.0.1-beta.18",
4
4
  "description": "Simplified logging for node applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",