millas 0.2.6 → 0.2.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "millas",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "A modern batteries-included backend framework for Node.js — built on Express, inspired by Laravel, Django, and FastAPI",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -165,37 +165,80 @@ class AdminResource {
165
165
  const limit = perPage || this.perPage;
166
166
  const offset = (page - 1) * limit;
167
167
 
168
- let qb = this.model.query().orderBy(sort, order);
168
+ // _db() is available on all ORM versions — it returns a raw knex table query.
169
+ // We build everything via knex directly so this works regardless of whether
170
+ // the ORM changes (changes3) have been applied.
171
+ let q = this.model._db().orderBy(sort, order);
169
172
 
170
- // Search
173
+ // ── Search ───────────────────────────────────────────────────────────────
171
174
  if (search && this.searchable.length) {
172
- const searchable = this.searchable;
173
- qb._query = qb._query.where(function () {
174
- for (const col of searchable) {
175
+ const cols = this.searchable;
176
+ q = q.where(function () {
177
+ for (const col of cols) {
175
178
  this.orWhere(col, 'like', `%${search}%`);
176
179
  }
177
180
  });
178
181
  }
179
182
 
180
- // Filters (supports __ lookups)
183
+ // ── Filters ──────────────────────────────────────────────────────────────
184
+ // Translate __ lookup syntax into knex calls so filter controls work
185
+ // even without the ORM changes applied.
181
186
  for (const [key, value] of Object.entries(filters)) {
182
- if (value !== '' && value !== null && value !== undefined) {
183
- qb.where(key, value);
187
+ if (value === '' || value === null || value === undefined) continue;
188
+
189
+ const dunder = key.lastIndexOf('__');
190
+ if (dunder === -1) {
191
+ q = q.where(key, value);
192
+ continue;
193
+ }
194
+
195
+ const col = key.slice(0, dunder);
196
+ const lookup = key.slice(dunder + 2);
197
+
198
+ switch (lookup) {
199
+ case 'exact': q = q.where(col, value); break;
200
+ case 'not': q = q.where(col, '!=', value); break;
201
+ case 'gt': q = q.where(col, '>', value); break;
202
+ case 'gte': q = q.where(col, '>=', value); break;
203
+ case 'lt': q = q.where(col, '<', value); break;
204
+ case 'lte': q = q.where(col, '<=', value); break;
205
+ case 'isnull': q = value ? q.whereNull(col) : q.whereNotNull(col); break;
206
+ case 'in': q = q.whereIn(col, Array.isArray(value) ? value : [value]); break;
207
+ case 'notin': q = q.whereNotIn(col, Array.isArray(value) ? value : [value]); break;
208
+ case 'between': q = q.whereBetween(col, value); break;
209
+ case 'contains':
210
+ case 'icontains': q = q.where(col, 'like', `%${value}%`); break;
211
+ case 'startswith':
212
+ case 'istartswith': q = q.where(col, 'like', `${value}%`); break;
213
+ case 'endswith':
214
+ case 'iendswith': q = q.where(col, 'like', `%${value}`); break;
215
+ default: q = q.where(key, value); break;
184
216
  }
185
217
  }
186
218
 
187
- // Date hierarchy drill-down
219
+ // ── Date hierarchy ────────────────────────────────────────────────────────
188
220
  if (this.dateHierarchy) {
189
- if (year) qb.where(`${this.dateHierarchy}__year`, Number(year));
190
- if (month) qb.where(`${this.dateHierarchy}__month`, Number(month));
221
+ const col = this.dateHierarchy;
222
+ if (year) {
223
+ // SQLite / MySQL / PG compatible
224
+ q = q.whereRaw(`strftime('%Y', "${col}") = ?`, [String(year)])
225
+ .catch
226
+ // If strftime not available (PG), fall through — best effort
227
+ || q;
228
+ }
229
+ if (month) {
230
+ q = q.whereRaw(`strftime('%m', "${col}") = ?`, [String(month).padStart(2, '0')]);
231
+ }
191
232
  }
192
233
 
193
- const [rows, total] = await Promise.all([
194
- qb._query.clone().limit(limit).offset(offset),
195
- qb._query.clone().clearSelect().count('* as count').first()
196
- .then(r => Number(r?.count ?? 0)),
234
+ // ── Execute ───────────────────────────────────────────────────────────────
235
+ const [rows, countResult] = await Promise.all([
236
+ q.clone().limit(limit).offset(offset),
237
+ q.clone().count('* as count').first(),
197
238
  ]);
198
239
 
240
+ const total = Number(countResult?.count ?? 0);
241
+
199
242
  return {
200
243
  data: rows.map(r => this.model._hydrate(r)),
201
244
  total,
@@ -494,11 +537,11 @@ class AdminInline {
494
537
  async fetchRows(parentId) {
495
538
  if (!this.model || !this.foreignKey) return [];
496
539
  try {
497
- const rows = await this.model.query()
540
+ const rows = await this.model._db()
498
541
  .where(this.foreignKey, parentId)
499
542
  .limit(this.perPage)
500
- .get();
501
- return rows.map(r => r.toJSON ? r.toJSON() : r);
543
+ .orderBy('id', 'desc');
544
+ return rows.map(r => this.model._hydrate ? this.model._hydrate(r) : r);
502
545
  } catch { return []; }
503
546
  }
504
547
 
@@ -1137,7 +1137,7 @@
1137
1137
  const prefix = '{{ adminPrefix }}';
1138
1138
  const resources = [
1139
1139
  {% for r in resources %}
1140
- { slug: '{{ r.slug }}', index: {{ r.index }} }{% if not loop.last %},{% endif %}
1140
+ { slug: '{{ r.slug }}', index: {{ r.index | default('null')}} }{% if not loop.last %},{% endif %}
1141
1141
  {% endfor %}
1142
1142
  ];
1143
1143
 
package/src/index.js CHANGED
@@ -1,5 +1,21 @@
1
1
  'use strict';
2
2
 
3
+ // ── Logger ────────────────────────────────────────────────────────
4
+ const {
5
+ Log,
6
+ Logger,
7
+ LEVELS,
8
+ LEVEL_NAMES,
9
+ PrettyFormatter,
10
+ JsonFormatter,
11
+ SimpleFormatter,
12
+ ConsoleChannel,
13
+ FileChannel,
14
+ NullChannel,
15
+ StackChannel,
16
+ } = require('./logger/index');
17
+ const LogServiceProvider = require('./providers/LogServiceProvider');
18
+
3
19
  // ── HTTP Layer ────────────────────────────────────────────────────
4
20
  const Controller = require('./controller/Controller');
5
21
  const Middleware = require('./middleware/Middleware');
@@ -16,13 +32,8 @@ const ServiceProvider = require('./providers/ServiceProvider');
16
32
  const ProviderRegistry = require('./providers/ProviderRegistry');
17
33
 
18
34
  // ── ORM ───────────────────────────────────────────────────────────
19
- const {
20
- Model, fields, QueryBuilder, DatabaseManager,
21
- SchemaBuilder, MigrationRunner, ModelInspector,
22
- Q, LookupParser,
23
- Sum, Avg, Min, Max, Count, AggregateExpression,
24
- HasOne, HasMany, BelongsTo, BelongsToMany,
25
- } = require('./orm');
35
+ const { Model, fields, QueryBuilder, DatabaseManager,
36
+ SchemaBuilder, MigrationRunner, ModelInspector } = require('./orm');
26
37
  const DatabaseServiceProvider = require('./providers/DatabaseServiceProvider');
27
38
 
28
39
  // ── Auth ──────────────────────────────────────────────────────────
@@ -65,6 +76,11 @@ const Storage = require('./storage/Storage');
65
76
  const LocalDriver = require('./storage/drivers/LocalDriver');
66
77
 
67
78
  module.exports = {
79
+ // Logger
80
+ Log, Logger, LEVELS, LEVEL_NAMES,
81
+ PrettyFormatter, JsonFormatter, SimpleFormatter,
82
+ ConsoleChannel, FileChannel, NullChannel, StackChannel,
83
+ LogServiceProvider,
68
84
  // HTTP
69
85
  Controller, Middleware, MiddlewarePipeline,
70
86
  CorsMiddleware, ThrottleMiddleware, LogMiddleware, HttpError,
@@ -73,9 +89,6 @@ module.exports = {
73
89
  // ORM
74
90
  Model, fields, QueryBuilder, DatabaseManager, SchemaBuilder,
75
91
  MigrationRunner, ModelInspector, DatabaseServiceProvider,
76
- Q, LookupParser,
77
- Sum, Avg, Min, Max, Count, AggregateExpression,
78
- HasOne, HasMany, BelongsTo, BelongsToMany,
79
92
  // Auth
80
93
  Auth, Hasher, JwtDriver, AuthMiddleware, RoleMiddleware,
81
94
  AuthController, AuthServiceProvider,
@@ -0,0 +1,341 @@
1
+ 'use strict';
2
+
3
+ const { LEVELS } = require('./levels');
4
+
5
+ /**
6
+ * Logger
7
+ *
8
+ * The Millas application logger. Inspired by Android's Timber library —
9
+ * a small, extensible logging tree where you plant channels ("trees")
10
+ * and log through a unified facade.
11
+ *
12
+ * ── Quick start ────────────────────────────────────────────────────────────
13
+ *
14
+ * const { Log } = require('millas');
15
+ *
16
+ * Log.i('Server started on port 3000');
17
+ * Log.d('QueryBuilder', 'SELECT * FROM users', { duration: '4ms' });
18
+ * Log.w('Auth', 'Token expiring soon', { userId: 5 });
19
+ * Log.e('Database', 'Connection failed', error);
20
+ * Log.wtf('Payment', 'Stripe returned null transaction');
21
+ *
22
+ * ── Tag chaining (like Timber.tag()) ───────────────────────────────────────
23
+ *
24
+ * Log.tag('UserService').i('User created', { id: 5 });
25
+ * Log.tag('Mailer').d('Sending email', { to: 'a@b.com' });
26
+ *
27
+ * ── Full signatures ────────────────────────────────────────────────────────
28
+ *
29
+ * Log.v(message)
30
+ * Log.v(tag, message)
31
+ * Log.v(tag, message, context) // context = object | primitive
32
+ * Log.v(tag, message, error) // error = Error instance
33
+ * Log.v(tag, message, context, error)
34
+ *
35
+ * ── Configuration ──────────────────────────────────────────────────────────
36
+ *
37
+ * Log.configure({
38
+ * minLevel: LEVELS.INFO, // filter out VERBOSE + DEBUG in production
39
+ * channel: new StackChannel([
40
+ * new ConsoleChannel({ formatter: new PrettyFormatter() }),
41
+ * new FileChannel({ formatter: new SimpleFormatter(), minLevel: LEVELS.WARN }),
42
+ * ]),
43
+ * });
44
+ *
45
+ * ── Request logging (Django-style) ─────────────────────────────────────────
46
+ *
47
+ * // In bootstrap/app.js — installed automatically by LogServiceProvider
48
+ * app.use(Log.requestMiddleware());
49
+ * // Logs: [2026-03-15 12:00:00] I HTTP POST /api/users 201 14ms
50
+ */
51
+ class Logger {
52
+ constructor() {
53
+ this._channel = null; // primary channel (StackChannel in production)
54
+ this._minLevel = LEVELS.DEBUG;
55
+ this._tag = null; // set by .tag() for a single chained call
56
+ this._defaultTag = 'App';
57
+ }
58
+
59
+ // ─── Configuration ────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Configure the logger.
63
+ *
64
+ * @param {object} options
65
+ * @param {object} [options.channel] — a channel or StackChannel instance
66
+ * @param {number} [options.minLevel] — minimum level to emit (LEVELS.*)
67
+ * @param {string} [options.defaultTag] — fallback tag when none supplied
68
+ */
69
+ configure(options = {}) {
70
+ if (options.channel !== undefined) this._channel = options.channel;
71
+ if (options.minLevel !== undefined) this._minLevel = options.minLevel;
72
+ if (options.defaultTag !== undefined) this._defaultTag = options.defaultTag;
73
+ return this;
74
+ }
75
+
76
+ /**
77
+ * Set a one-shot tag for the next log call.
78
+ * Returns a proxy that resets the tag after the call.
79
+ *
80
+ * Log.tag('UserService').i('Created user', { id: 5 });
81
+ */
82
+ tag(name) {
83
+ // Return a lightweight proxy that carries the tag
84
+ return new TaggedLogger(this, name);
85
+ }
86
+
87
+ // ─── Timber-style level methods ───────────────────────────────────────────
88
+
89
+ /** VERBOSE — very detailed tracing, usually disabled in production */
90
+ v(...args) { return this._log(LEVELS.VERBOSE, null, ...args); }
91
+
92
+ /** DEBUG — development diagnostics */
93
+ d(...args) { return this._log(LEVELS.DEBUG, null, ...args); }
94
+
95
+ /** INFO — normal operational messages */
96
+ i(...args) { return this._log(LEVELS.INFO, null, ...args); }
97
+
98
+ /** WARN — unexpected but recoverable */
99
+ w(...args) { return this._log(LEVELS.WARN, null, ...args); }
100
+
101
+ /** ERROR — something failed */
102
+ e(...args) { return this._log(LEVELS.ERROR, null, ...args); }
103
+
104
+ /**
105
+ * WTF — "What a Terrible Failure"
106
+ * A condition that should NEVER happen. Always logged regardless of minLevel.
107
+ * Triggers a big visual warning in the console.
108
+ */
109
+ wtf(...args) { return this._log(LEVELS.WTF, null, ...args); }
110
+
111
+ // ─── Verbose aliases (for readability) ────────────────────────────────────
112
+
113
+ /** Alias for i() */
114
+ info(...args) { return this.i(...args); }
115
+ /** Alias for d() */
116
+ debug(...args) { return this.d(...args); }
117
+ /** Alias for w() */
118
+ warn(...args) { return this.w(...args); }
119
+ /** Alias for e() */
120
+ error(...args) { return this.e(...args); }
121
+ /** Alias for v() */
122
+ verbose(...args) { return this.v(...args); }
123
+
124
+ // ─── Request middleware (Django-style) ────────────────────────────────────
125
+
126
+ /**
127
+ * Returns an Express middleware that logs every HTTP request.
128
+ *
129
+ * Log format:
130
+ * POST /api/users 201 14ms (coloured by status)
131
+ *
132
+ * @param {object} [options]
133
+ * @param {boolean} [options.includeQuery=false] — append ?query to path
134
+ * @param {boolean} [options.includeBody=false] — log request body (be careful with PII)
135
+ * @param {number} [options.slowThreshold=1000] — warn if response > Nms
136
+ * @param {Function} [options.skip] — (req, res) => bool — skip certain routes
137
+ */
138
+ requestMiddleware(options = {}) {
139
+ const self = this;
140
+ const {
141
+ includeQuery = false,
142
+ includeBody = false,
143
+ slowThreshold = 1000,
144
+ skip,
145
+ } = options;
146
+
147
+ return function millaRequestLogger(req, res, next) {
148
+ if (typeof skip === 'function' && skip(req, res)) return next();
149
+
150
+ const start = Date.now();
151
+
152
+ res.on('finish', () => {
153
+ const ms = Date.now() - start;
154
+ const status = res.statusCode;
155
+ const method = req.method;
156
+ let url = req.path || req.url || '/';
157
+
158
+ if (includeQuery && req.url && req.url.includes('?')) {
159
+ url = req.url;
160
+ }
161
+
162
+ // Determine log level from status code — mirrors Django's request logging
163
+ let level;
164
+ if (status >= 500) level = LEVELS.ERROR;
165
+ else if (status >= 400) level = LEVELS.WARN;
166
+ else if (ms > slowThreshold) level = LEVELS.WARN;
167
+ else level = LEVELS.INFO;
168
+
169
+ const ctx = {
170
+ method,
171
+ status,
172
+ ms,
173
+ ip: req.ip || req.connection?.remoteAddress,
174
+ };
175
+
176
+ if (includeBody && req.body && Object.keys(req.body).length) {
177
+ ctx.body = req.body;
178
+ }
179
+
180
+ if (ms > slowThreshold) {
181
+ ctx.slow = true;
182
+ }
183
+
184
+ self._log(level, 'HTTP', `${method} ${url} ${status} ${ms}ms`, ctx);
185
+ });
186
+
187
+ next();
188
+ };
189
+ }
190
+
191
+ // ─── Timer utility ────────────────────────────────────────────────────────
192
+
193
+ /**
194
+ * Start a named timer. Returns a function that logs the elapsed time.
195
+ *
196
+ * const done = Log.time('Database query');
197
+ * await db.query(...);
198
+ * done(); // → I Timer Database query: 42ms
199
+ */
200
+ time(label) {
201
+ const start = Date.now();
202
+ return (extraTag) => {
203
+ const ms = Date.now() - start;
204
+ this._log(LEVELS.DEBUG, extraTag || 'Timer', `${label}: ${ms}ms`, { ms });
205
+ return ms;
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Wrap an async function and log its execution time.
211
+ *
212
+ * const result = await Log.timed('fetchUsers', () => User.all());
213
+ */
214
+ async timed(label, fn, tag) {
215
+ const done = this.time(label);
216
+ try {
217
+ const result = await fn();
218
+ done(tag);
219
+ return result;
220
+ } catch (err) {
221
+ this._log(LEVELS.ERROR, tag || 'Timer', `${label} threw`, err);
222
+ throw err;
223
+ }
224
+ }
225
+
226
+ // ─── Internal ─────────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Core dispatch method.
230
+ *
231
+ * Signatures:
232
+ * _log(level, forcedTag, message)
233
+ * _log(level, forcedTag, tag, message)
234
+ * _log(level, forcedTag, tag, message, context)
235
+ * _log(level, forcedTag, tag, message, context, error)
236
+ * _log(level, forcedTag, tag, message, error) // Error as 4th arg
237
+ */
238
+ _log(level, forcedTag, ...args) {
239
+ // WTF is always emitted regardless of minLevel
240
+ if (level !== LEVELS.WTF && level < this._minLevel) return;
241
+
242
+ const entry = this._parse(level, forcedTag, args);
243
+ this._emit(entry);
244
+ }
245
+
246
+ _parse(level, forcedTag, args) {
247
+ let tag, message, context, error;
248
+
249
+ // Normalise arguments
250
+ if (args.length === 0) {
251
+ message = '';
252
+ } else if (args.length === 1) {
253
+ // Single arg — could be a string message or an Error
254
+ if (args[0] instanceof Error) {
255
+ error = args[0];
256
+ message = error.message;
257
+ } else {
258
+ message = String(args[0]);
259
+ }
260
+ } else if (args.length === 2) {
261
+ // (tag, message) OR (message, context/error)
262
+ if (typeof args[0] === 'string' && typeof args[1] === 'string') {
263
+ // Both strings: tag + message
264
+ tag = args[0];
265
+ message = args[1];
266
+ } else if (args[1] instanceof Error) {
267
+ message = String(args[0]);
268
+ error = args[1];
269
+ } else if (typeof args[0] === 'string') {
270
+ message = args[0];
271
+ context = args[1];
272
+ } else {
273
+ message = String(args[0]);
274
+ context = args[1];
275
+ }
276
+ } else if (args.length === 3) {
277
+ // (tag, message, context/error)
278
+ tag = String(args[0]);
279
+ message = String(args[1]);
280
+ if (args[2] instanceof Error) error = args[2];
281
+ else context = args[2];
282
+ } else {
283
+ // (tag, message, context, error)
284
+ tag = String(args[0]);
285
+ message = String(args[1]);
286
+ context = args[2];
287
+ error = args[3] instanceof Error ? args[3] : undefined;
288
+ }
289
+
290
+ return {
291
+ level,
292
+ tag: forcedTag || tag || this._defaultTag,
293
+ message: message || '',
294
+ context,
295
+ error,
296
+ timestamp: new Date().toISOString(),
297
+ pid: process.pid,
298
+ };
299
+ }
300
+
301
+ _emit(entry) {
302
+ if (!this._channel) {
303
+ // No channel configured — fall back to raw console so nothing is lost
304
+ const prefix = `[${entry.level}] ${entry.tag}: ${entry.message}`;
305
+ if (entry.level >= 4) process.stderr.write(prefix + '\n');
306
+ else process.stdout.write(prefix + '\n');
307
+ return;
308
+ }
309
+
310
+ try {
311
+ this._channel.write(entry);
312
+ } catch (err) {
313
+ // Never crash the app because of a logging failure
314
+ process.stderr.write(`[millas logger] channel error: ${err.message}\n`);
315
+ }
316
+ }
317
+ }
318
+
319
+ // ─── TaggedLogger — returned by Log.tag() ─────────────────────────────────────
320
+
321
+ class TaggedLogger {
322
+ constructor(logger, tag) {
323
+ this._logger = logger;
324
+ this._tag = tag;
325
+ }
326
+
327
+ v(...args) { return this._logger._log(LEVELS.VERBOSE, this._tag, ...args); }
328
+ d(...args) { return this._logger._log(LEVELS.DEBUG, this._tag, ...args); }
329
+ i(...args) { return this._logger._log(LEVELS.INFO, this._tag, ...args); }
330
+ w(...args) { return this._logger._log(LEVELS.WARN, this._tag, ...args); }
331
+ e(...args) { return this._logger._log(LEVELS.ERROR, this._tag, ...args); }
332
+ wtf(...args) { return this._logger._log(LEVELS.WTF, this._tag, ...args); }
333
+
334
+ info(...args) { return this.i(...args); }
335
+ debug(...args) { return this.d(...args); }
336
+ warn(...args) { return this.w(...args); }
337
+ error(...args) { return this.e(...args); }
338
+ verbose(...args) { return this.v(...args); }
339
+ }
340
+
341
+ module.exports = Logger;
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ConsoleChannel
5
+ *
6
+ * Writes log entries to process.stdout (levels < ERROR) or
7
+ * process.stderr (ERROR and WTF).
8
+ *
9
+ * Usage in config/logging.js:
10
+ * new ConsoleChannel({ formatter: new PrettyFormatter() })
11
+ */
12
+ class ConsoleChannel {
13
+ /**
14
+ * @param {object} options
15
+ * @param {object} options.formatter — formatter instance (PrettyFormatter, JsonFormatter, …)
16
+ * @param {number} [options.minLevel] — minimum level to output (default: 0 = all)
17
+ */
18
+ constructor(options = {}) {
19
+ this.formatter = options.formatter;
20
+ this.minLevel = options.minLevel ?? 0;
21
+ }
22
+
23
+ write(entry) {
24
+ if (entry.level < this.minLevel) return;
25
+
26
+ const output = this.formatter
27
+ ? this.formatter.format(entry)
28
+ : `[${entry.level}] ${entry.message}`;
29
+
30
+ // Route errors to stderr
31
+ if (entry.level >= 4) {
32
+ process.stderr.write(output + '\n');
33
+ } else {
34
+ process.stdout.write(output + '\n');
35
+ }
36
+ }
37
+ }
38
+
39
+ module.exports = ConsoleChannel;
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * FileChannel
8
+ *
9
+ * Writes log entries to daily rotating files.
10
+ * storage/logs/millas-2026-03-15.log
11
+ * storage/logs/millas-2026-03-16.log
12
+ * …
13
+ *
14
+ * Uses Node's built-in fs — no external log-rotation library needed.
15
+ * Files are appended synchronously to avoid losing entries on crash.
16
+ *
17
+ * Usage in config/logging.js:
18
+ * new FileChannel({
19
+ * path: 'storage/logs',
20
+ * prefix: 'millas',
21
+ * formatter: new SimpleFormatter(),
22
+ * minLevel: LEVELS.INFO,
23
+ * maxFiles: 30, // keep 30 days of logs
24
+ * })
25
+ */
26
+ class FileChannel {
27
+ /**
28
+ * @param {object} options
29
+ * @param {string} [options.path] — directory path (default: storage/logs)
30
+ * @param {string} [options.prefix] — filename prefix (default: 'millas')
31
+ * @param {object} [options.formatter] — formatter instance
32
+ * @param {number} [options.minLevel] — minimum level (default: 0)
33
+ * @param {number} [options.maxFiles] — max daily files to retain (default: 30)
34
+ */
35
+ constructor(options = {}) {
36
+ this._dir = path.resolve(process.cwd(), options.path || 'storage/logs');
37
+ this._prefix = options.prefix || 'millas';
38
+ this.formatter = options.formatter;
39
+ this.minLevel = options.minLevel ?? 0;
40
+ this._maxFiles = options.maxFiles ?? 30;
41
+ this._lastDate = null;
42
+ this._stream = null;
43
+ this._ensuredDir = false;
44
+ }
45
+
46
+ write(entry) {
47
+ if (entry.level < this.minLevel) return;
48
+
49
+ try {
50
+ this._ensureDir();
51
+
52
+ const today = new Date().toISOString().slice(0, 10);
53
+ if (today !== this._lastDate) {
54
+ this._rotateStream(today);
55
+ this._pruneOldFiles();
56
+ }
57
+
58
+ const line = this.formatter
59
+ ? this.formatter.format(entry)
60
+ : `[${entry.timestamp}] [${entry.level}] ${entry.message}`;
61
+
62
+ fs.appendFileSync(this._currentPath, line + '\n', 'utf8');
63
+ } catch (err) {
64
+ // Never crash the app because of a logging failure
65
+ process.stderr.write(`[millas logger] FileChannel write error: ${err.message}\n`);
66
+ }
67
+ }
68
+
69
+ // ─── Internals ────────────────────────────────────────────────────────────
70
+
71
+ _ensureDir() {
72
+ if (this._ensuredDir) return;
73
+ fs.mkdirSync(this._dir, { recursive: true });
74
+ this._ensuredDir = true;
75
+ }
76
+
77
+ _rotateStream(date) {
78
+ this._lastDate = date;
79
+ this._currentPath = path.join(this._dir, `${this._prefix}-${date}.log`);
80
+ }
81
+
82
+ _pruneOldFiles() {
83
+ try {
84
+ const files = fs.readdirSync(this._dir)
85
+ .filter(f => f.startsWith(this._prefix + '-') && f.endsWith('.log'))
86
+ .sort(); // ISO date prefix sorts chronologically
87
+
88
+ const toDelete = files.slice(0, Math.max(0, files.length - this._maxFiles));
89
+ for (const f of toDelete) {
90
+ try { fs.unlinkSync(path.join(this._dir, f)); } catch {}
91
+ }
92
+ } catch {}
93
+ }
94
+
95
+ /** Current log file path (useful for tooling). */
96
+ get currentFile() {
97
+ return this._currentPath || null;
98
+ }
99
+ }
100
+
101
+ module.exports = FileChannel;