@stonyx/logs 1.0.1-alpha.14 → 1.0.1-alpha.16

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
@@ -13,7 +13,12 @@ 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>>;
17
+ writeFailures: Map<string, number>;
16
18
  [key: string]: unknown;
19
+ info: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
20
+ warn: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
21
+ error: (content: string, logToFile?: boolean, overwrite?: boolean) => Promise<void>;
17
22
  constructor(options?: Partial<LogOptions>);
18
23
  defineType(type: string, setting: ColorSetting, options?: Partial<LogOptions> | null): void;
19
24
  createConvenienceMethod(type: string): void;
@@ -23,6 +28,8 @@ export default class Log {
23
28
  log(content: string, type: string, logToFile: boolean, overwrite: boolean): Promise<void>;
24
29
  debug(content: unknown, logToFile?: boolean, overwrite?: boolean): Promise<void>;
25
30
  writeToFile(type: string, content: string, overwrite: boolean): Promise<void>;
31
+ noticeWriteResult(targetLog: string, payload: Record<string, unknown> | null): void;
32
+ emitNotice(severity: string, event: string, payload: Record<string, unknown>): void;
26
33
  resolveFilename(template: string, type: string): string;
27
34
  validateFileAndDirectory(path: string, targetLog: string): Promise<void>;
28
35
  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 target -> consecutive write failures, used to dedupe the operator notice
29
+ writeFailures = new Map();
26
30
  constructor(options = {}) {
27
31
  const merged = {
28
32
  ...defaultOptions,
@@ -118,11 +122,46 @@ export default class Log {
118
122
  async writeToFile(type, content, overwrite) {
119
123
  const path = this.getOptionForType(type, 'path');
120
124
  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);
125
+ const targetLog = `${path}${this.resolveFilename(filenameTemplate, type)}`;
126
+ const cached = this.directoryCache.has(path);
127
+ const attempt = async () => {
128
+ await this.validateFileAndDirectory(path, targetLog);
129
+ await (overwrite ? fsp.writeFile : fsp.appendFile)(targetLog, content);
130
+ };
131
+ try {
132
+ await attempt().catch(async (error) => {
133
+ // a warm cache can outlive its directory, so drop the entry and retry exactly once
134
+ if (!['ENOENT', 'EACCES', 'EPERM', 'EROFS'].includes(error?.code))
135
+ throw error;
136
+ this.directoryCache.delete(path);
137
+ return attempt();
138
+ });
139
+ }
140
+ catch (error) {
141
+ const { code, syscall } = error;
142
+ this.noticeWriteResult(targetLog, { targetLog, path, syscall, code, cached });
143
+ throw error;
144
+ }
145
+ this.noticeWriteResult(targetLog, null);
146
+ }
147
+ // One structured stderr notice per failure episode: an emit on the first failure, silence while
148
+ // it keeps failing, one recovery emit on the next success. A null payload records a success.
149
+ noticeWriteResult(targetLog, payload) {
150
+ const failures = this.writeFailures.get(targetLog) ?? 0;
151
+ if (payload) {
152
+ this.writeFailures.set(targetLog, failures + 1);
153
+ if (!failures)
154
+ this.emitNotice('error', 'log-write-failed', { ...payload, suppressedCount: 0 });
155
+ }
156
+ else if (failures) {
157
+ this.writeFailures.delete(targetLog);
158
+ this.emitNotice('warn', 'log-write-recovered', { targetLog, suppressedCount: failures - 1 });
159
+ }
160
+ }
161
+ // the write-failure path must never re-enter this logger, so notices go straight to stderr
162
+ emitNotice(severity, event, payload) {
163
+ const ts = new Date().toISOString();
164
+ console.error(JSON.stringify({ ts, schemaVersion: 1, surface: 'stonyx-logs', sessionKey: null, project: null, severity, event, payload }));
126
165
  }
127
166
  // resolves template variables in a filename string
128
167
  resolveFilename(template, type) {
@@ -145,16 +184,19 @@ export default class Log {
145
184
  // sanitize: prevent path traversal and disallow directory separators
146
185
  return resolved.replace(/\.\./g, '').replace(/[/\\]/g, '');
147
186
  }
148
- // attempts to create file and/or directory if they don't already exist
187
+ // Ensures the target's directory exists. Both write paths auto-create the file itself, so no
188
+ // bootstrap write is needed. `targetLog` is unused but retained for signature compatibility:
189
+ // resolveFilename strips separators, so the only cached invariant is the directory.
149
190
  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
- });
191
+ let pending = this.directoryCache.get(path);
192
+ if (!pending) {
193
+ // cache the promise before awaiting: a flag here would let a concurrent write append first
194
+ pending = fsp.mkdir(path, { recursive: true }).then(() => undefined);
195
+ this.directoryCache.set(path, pending);
196
+ // never keep a poisoned entry - the next write retries from scratch
197
+ pending.catch(() => this.directoryCache.delete(path));
198
+ }
199
+ return pending;
158
200
  }
159
201
  // method to conditionally sanitize user configuration input
160
202
  sanitizePath(path) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonyx/logs",
3
- "version": "1.0.1-alpha.14",
3
+ "version": "1.0.1-alpha.16",
4
4
  "description": "Simplified logging for node applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",