millas 0.2.6 → 0.2.7

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.7",
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": {
@@ -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;
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * NullChannel
5
+ *
6
+ * Silently discards all log entries.
7
+ * Useful in tests where you want to suppress all output.
8
+ *
9
+ * Log.configure({ channels: [new NullChannel()] });
10
+ */
11
+ class NullChannel {
12
+ write() { /* intentionally empty */ }
13
+ }
14
+
15
+ /**
16
+ * StackChannel
17
+ *
18
+ * Fans a single log entry out to multiple channels simultaneously.
19
+ * This is the standard "stack" pattern — one channel for console,
20
+ * another for file, optionally one for an external service.
21
+ *
22
+ * new StackChannel([
23
+ * new ConsoleChannel({ formatter: new PrettyFormatter() }),
24
+ * new FileChannel({ formatter: new SimpleFormatter(), minLevel: LEVELS.INFO }),
25
+ * ])
26
+ */
27
+ class StackChannel {
28
+ /**
29
+ * @param {Array} channels — array of channel instances
30
+ */
31
+ constructor(channels = []) {
32
+ this._channels = channels;
33
+ }
34
+
35
+ /** Add a channel at runtime. */
36
+ add(channel) {
37
+ this._channels.push(channel);
38
+ return this;
39
+ }
40
+
41
+ write(entry) {
42
+ for (const ch of this._channels) {
43
+ try { ch.write(entry); } catch {}
44
+ }
45
+ }
46
+ }
47
+
48
+ module.exports = { NullChannel, StackChannel };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const { LEVEL_NAMES } = require('../levels');
4
+
5
+ /**
6
+ * JsonFormatter
7
+ *
8
+ * Emits one JSON object per log entry — ideal for production environments
9
+ * where logs are shipped to Datadog, Elasticsearch, CloudWatch, etc.
10
+ *
11
+ * Output (one line per entry):
12
+ * {"ts":"2026-03-15T12:00:00.000Z","level":"INFO","tag":"Auth","msg":"Login","ctx":{...}}
13
+ */
14
+ class JsonFormatter {
15
+ /**
16
+ * @param {object} options
17
+ * @param {boolean} [options.pretty=false] — pretty-print JSON (for debugging)
18
+ * @param {object} [options.extra] — static fields merged into every entry (e.g. service name)
19
+ */
20
+ constructor(options = {}) {
21
+ this.pretty = options.pretty || false;
22
+ this.extra = options.extra || {};
23
+ }
24
+
25
+ format(entry) {
26
+ const { level, tag, message, context, error, timestamp } = entry;
27
+
28
+ const record = {
29
+ ts: timestamp || new Date().toISOString(),
30
+ level: LEVEL_NAMES[level] || String(level),
31
+ ...this.extra,
32
+ };
33
+
34
+ if (tag) record.tag = tag;
35
+ record.msg = message;
36
+ if (context !== undefined && context !== null) record.ctx = context;
37
+
38
+ if (error instanceof Error) {
39
+ record.error = {
40
+ message: error.message,
41
+ name: error.name,
42
+ stack: error.stack,
43
+ };
44
+ }
45
+
46
+ return this.pretty
47
+ ? JSON.stringify(record, null, 2)
48
+ : JSON.stringify(record);
49
+ }
50
+ }
51
+
52
+ module.exports = JsonFormatter;
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ const { LEVEL_NAMES, LEVEL_TAGS, LEVEL_COLOURS, RESET, BOLD, DIM } = require('../levels');
4
+
5
+ /**
6
+ * PrettyFormatter
7
+ *
8
+ * Colourful, human-readable output. Designed for development.
9
+ * Inspired by Timber (Android) and Laravel's log formatting.
10
+ *
11
+ * Output:
12
+ * [2026-03-15 12:00:00] I UserController User #5 logged in
13
+ * [2026-03-15 12:00:01] E Database Connection refused { host: 'localhost' }
14
+ * [2026-03-15 12:00:02] W Auth Token expiring soon
15
+ *
16
+ * WTF level also prints the full stack trace.
17
+ */
18
+ class PrettyFormatter {
19
+ /**
20
+ * @param {object} options
21
+ * @param {boolean} [options.timestamp=true] — show timestamp
22
+ * @param {boolean} [options.tag=true] — show tag/component name
23
+ * @param {boolean} [options.colour=true] — ANSI colour (disable for pipes/files)
24
+ * @param {string} [options.timestampFormat] — 'iso' | 'short' (default: 'short')
25
+ */
26
+ constructor(options = {}) {
27
+ this.showTimestamp = options.timestamp !== false;
28
+ this.showTag = options.tag !== false;
29
+ this.colour = options.colour !== false;
30
+ this.tsFormat = options.timestampFormat || 'short';
31
+ }
32
+
33
+ format(entry) {
34
+ const { level, tag, message, context, error } = entry;
35
+
36
+ const c = this.colour ? LEVEL_COLOURS[level] : '';
37
+ const r = this.colour ? RESET : '';
38
+ const b = this.colour ? BOLD : '';
39
+ const d = this.colour ? '\x1b[2m' : '';
40
+ const lvl = LEVEL_TAGS[level] || '?';
41
+
42
+ const parts = [];
43
+
44
+ // Timestamp
45
+ if (this.showTimestamp) {
46
+ const ts = this._timestamp();
47
+ parts.push(`${d}[${ts}]${r}`);
48
+ }
49
+
50
+ // Level tag (single letter, coloured)
51
+ parts.push(`${c}${b}${lvl}${r}`);
52
+
53
+ // Component/tag
54
+ if (this.showTag && tag) {
55
+ const tagStr = tag.padEnd(18);
56
+ parts.push(`${b}${tagStr}${r}`);
57
+ }
58
+
59
+ // Message
60
+ parts.push(`${c}${message}${r}`);
61
+
62
+ // Context object
63
+ if (context !== undefined && context !== null) {
64
+ const ctx = typeof context === 'object'
65
+ ? JSON.stringify(context, null, 0)
66
+ : String(context);
67
+ parts.push(`${d}${ctx}${r}`);
68
+ }
69
+
70
+ let output = parts.join(' ');
71
+
72
+ // Error stack
73
+ if (error instanceof Error) {
74
+ output += `\n${d}${error.stack || error.message}${r}`;
75
+ }
76
+
77
+ // WTF: print big warning banner
78
+ if (level === 5) {
79
+ const banner = this.colour
80
+ ? `\x1b[35m\x1b[1m${'━'.repeat(60)}\x1b[0m`
81
+ : '━'.repeat(60);
82
+ output = `${banner}\n${output}\n${banner}`;
83
+ }
84
+
85
+ return output;
86
+ }
87
+
88
+ _timestamp() {
89
+ const now = new Date();
90
+ if (this.tsFormat === 'iso') return now.toISOString();
91
+ return now.toISOString().replace('T', ' ').slice(0, 19);
92
+ }
93
+ }
94
+
95
+ module.exports = PrettyFormatter;
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ const { LEVEL_NAMES } = require('../levels');
4
+
5
+ /**
6
+ * SimpleFormatter
7
+ *
8
+ * Plain, no-colour text. Suitable for file output or any sink
9
+ * where ANSI codes would be noise.
10
+ *
11
+ * Output:
12
+ * [2026-03-15 12:00:00] [INFO] Auth: User logged in
13
+ * [2026-03-15 12:00:01] [ERROR] DB: Query failed {"table":"users"}
14
+ */
15
+ class SimpleFormatter {
16
+ format(entry) {
17
+ const { level, tag, message, context, error, timestamp } = entry;
18
+
19
+ const ts = (timestamp || new Date().toISOString()).replace('T', ' ').slice(0, 23);
20
+ const lvlName = (LEVEL_NAMES[level] || String(level)).padEnd(7);
21
+ const tagPart = tag ? `${tag}: ` : '';
22
+
23
+ let line = `[${ts}] [${lvlName}] ${tagPart}${message}`;
24
+
25
+ if (context !== undefined && context !== null) {
26
+ line += ' ' + (typeof context === 'object' ? JSON.stringify(context) : String(context));
27
+ }
28
+
29
+ if (error instanceof Error) {
30
+ line += '\n ' + (error.stack || error.message).replace(/\n/g, '\n ');
31
+ }
32
+
33
+ return line;
34
+ }
35
+ }
36
+
37
+ module.exports = SimpleFormatter;
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ const Logger = require('./Logger');
4
+ const { LEVELS, LEVEL_NAMES } = require('./levels');
5
+ const PrettyFormatter = require('./formatters/PrettyFormatter');
6
+ const JsonFormatter = require('./formatters/JsonFormatter');
7
+ const SimpleFormatter = require('./formatters/SimpleFormatter');
8
+ const ConsoleChannel = require('./channels/ConsoleChannel');
9
+ const FileChannel = require('./channels/FileChannel');
10
+ const { NullChannel, StackChannel } = require('./channels/index');
11
+
12
+ /**
13
+ * Log
14
+ *
15
+ * The global logger singleton — the one you import everywhere.
16
+ *
17
+ * const { Log } = require('millas');
18
+ *
19
+ * Log.i('App booted');
20
+ * Log.tag('UserService').d('Fetching user', { id: 5 });
21
+ * Log.e('Payment', 'Stripe failed', error);
22
+ * Log.wtf('Impossible state reached');
23
+ *
24
+ * Configured automatically by LogServiceProvider when you add it
25
+ * to your providers list. For manual setup:
26
+ *
27
+ * Log.configure({
28
+ * minLevel: LEVELS.INFO,
29
+ * channel: new StackChannel([
30
+ * new ConsoleChannel({ formatter: new PrettyFormatter() }),
31
+ * new FileChannel({ formatter: new SimpleFormatter(), minLevel: LEVELS.WARN }),
32
+ * ]),
33
+ * });
34
+ */
35
+ const Log = new Logger();
36
+
37
+ // Apply sensible defaults so Log works out-of-the-box before
38
+ // LogServiceProvider runs (during framework boot, tests, etc.)
39
+ Log.configure({
40
+ minLevel: process.env.NODE_ENV === 'production' ? LEVELS.INFO : LEVELS.DEBUG,
41
+ channel: new ConsoleChannel({
42
+ formatter: new PrettyFormatter({
43
+ colour: process.stdout.isTTY !== false,
44
+ }),
45
+ }),
46
+ });
47
+
48
+ module.exports = {
49
+ // The singleton you use everywhere
50
+ Log,
51
+
52
+ // The class (for constructing named loggers)
53
+ Logger,
54
+
55
+ // Level constants
56
+ LEVELS,
57
+ LEVEL_NAMES,
58
+
59
+ // Formatters
60
+ PrettyFormatter,
61
+ JsonFormatter,
62
+ SimpleFormatter,
63
+
64
+ // Channels
65
+ ConsoleChannel,
66
+ FileChannel,
67
+ NullChannel,
68
+ StackChannel,
69
+ };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Log levels — ordered by severity (lowest to highest).
5
+ *
6
+ * Inspired by Android's Timber / Log class:
7
+ * VERBOSE — extremely detailed; usually filtered out in production
8
+ * DEBUG — development diagnostics
9
+ * INFO — normal operational messages (default minimum in production)
10
+ * WARN — something unexpected but recoverable
11
+ * ERROR — something failed; needs attention
12
+ * WTF — "What a Terrible Failure" — should never happen; always logged
13
+ */
14
+ const LEVELS = {
15
+ VERBOSE: 0,
16
+ DEBUG: 1,
17
+ INFO: 2,
18
+ WARN: 3,
19
+ ERROR: 4,
20
+ WTF: 5,
21
+ };
22
+
23
+ /** Reverse map: number → name */
24
+ const LEVEL_NAMES = Object.fromEntries(
25
+ Object.entries(LEVELS).map(([k, v]) => [v, k])
26
+ );
27
+
28
+ /** Single-letter tags (like Android logcat) */
29
+ const LEVEL_TAGS = {
30
+ 0: 'V',
31
+ 1: 'D',
32
+ 2: 'I',
33
+ 3: 'W',
34
+ 4: 'E',
35
+ 5: 'F', // Fatal / WTF
36
+ };
37
+
38
+ /** ANSI colour codes for each level */
39
+ const LEVEL_COLOURS = {
40
+ 0: '\x1b[90m', // VERBOSE — dark grey
41
+ 1: '\x1b[36m', // DEBUG — cyan
42
+ 2: '\x1b[32m', // INFO — green
43
+ 3: '\x1b[33m', // WARN — yellow
44
+ 4: '\x1b[31m', // ERROR — red
45
+ 5: '\x1b[35m', // WTF — magenta
46
+ };
47
+
48
+ const RESET = '\x1b[0m';
49
+ const BOLD = '\x1b[1m';
50
+ const DIM = '\x1b[2m';
51
+
52
+ module.exports = { LEVELS, LEVEL_NAMES, LEVEL_TAGS, LEVEL_COLOURS, RESET, BOLD, DIM };
@@ -5,53 +5,79 @@ const Middleware = require('./Middleware');
5
5
  /**
6
6
  * LogMiddleware
7
7
  *
8
- * Logs every incoming request and its response time.
8
+ * Django-style HTTP request logger.
9
+ * Uses the Millas Log singleton — output goes through your configured channels.
9
10
  *
10
- * Output format:
11
- * [2026-03-14 12:00:00] GET /api/users 200 12ms
11
+ * Log levels per status:
12
+ * 2xx, 3xx → INFO (green)
13
+ * 4xx → WARN (yellow)
14
+ * 5xx → ERROR (red)
15
+ * slow req → WARN (with slow=true in context)
12
16
  *
13
- * Register:
14
- * middlewareRegistry.register('log', LogMiddleware);
17
+ * Output:
18
+ * I HTTP GET /api/users 200 4ms
19
+ * W HTTP POST /api/login 401 12ms
20
+ * E HTTP GET /api/crash 500 3ms
21
+ *
22
+ * Usage (register as middleware):
23
+ * app.use(new LogMiddleware().handle.bind(new LogMiddleware()));
24
+ *
25
+ * Or use Log.requestMiddleware() directly for more options.
15
26
  *
16
27
  * Options:
17
- * silent — suppress output (default: false)
18
- * format'dev' (coloured) | 'combined' (plain)
28
+ * silent — suppress all output (default: false)
29
+ * includeQueryinclude query string in URL (default: false)
30
+ * includeIp — include client IP (default: true)
31
+ * slowThreshold — warn if response > Nms (default: 1000)
32
+ * skip — function(req, res) => bool — skip matching routes
19
33
  */
20
34
  class LogMiddleware extends Middleware {
21
35
  constructor(options = {}) {
22
36
  super();
23
- this.silent = options.silent || false;
24
- this.format = options.format || 'dev';
37
+ this.silent = options.silent ?? false;
38
+ this.includeQuery = options.includeQuery ?? false;
39
+ this.includeIp = options.includeIp ?? true;
40
+ this.slowThreshold = options.slowThreshold ?? 1000;
41
+ this.skip = options.skip ?? null;
25
42
  }
26
43
 
27
44
  async handle(req, res, next) {
28
45
  if (this.silent) return next();
46
+ if (typeof this.skip === 'function' && this.skip(req, res)) return next();
47
+
48
+ // Lazy-require Log so this file loads even before LogServiceProvider runs
49
+ let Log;
50
+ try {
51
+ Log = require('../logger/index').Log;
52
+ } catch {
53
+ return next();
54
+ }
29
55
 
30
56
  const start = Date.now();
31
- const { method, path: routePath, url } = req;
57
+ const { LEVELS } = require('../logger/levels');
32
58
 
33
59
  res.on('finish', () => {
34
60
  const ms = Date.now() - start;
35
61
  const status = res.statusCode;
36
- const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
37
-
38
- if (this.format === 'dev') {
39
- const statusColor = status < 300 ? '\x1b[32m'
40
- : status < 400 ? '\x1b[36m'
41
- : status < 500 ? '\x1b[33m'
42
- : '\x1b[31m';
43
- const reset = '\x1b[0m';
44
- const methodColor = '\x1b[1m';
45
- console.log(
46
- ` ${'\x1b[90m'}[${ts}]${reset} ` +
47
- `${methodColor}${method.padEnd(7)}${reset} ` +
48
- `${routePath || url} ` +
49
- `${statusColor}${status}${reset} ` +
50
- `${'\x1b[90m'}${ms}ms${reset}`
51
- );
52
- } else {
53
- console.log(`[${ts}] ${method} ${routePath || url} ${status} ${ms}ms`);
62
+ const method = req.method;
63
+ let url = req.path || req.url || '/';
64
+
65
+ if (this.includeQuery && req.url && req.url.includes('?')) {
66
+ url = req.url;
54
67
  }
68
+
69
+ // Level based on status + response time
70
+ let level;
71
+ if (status >= 500) level = LEVELS.ERROR;
72
+ else if (status >= 400) level = LEVELS.WARN;
73
+ else if (ms > this.slowThreshold) level = LEVELS.WARN;
74
+ else level = LEVELS.INFO;
75
+
76
+ const ctx = { status, ms };
77
+ if (this.includeIp) ctx.ip = req.ip || req.connection?.remoteAddress;
78
+ if (ms > this.slowThreshold) ctx.slow = true;
79
+
80
+ Log._log(level, 'HTTP', `${method} ${url} ${status} ${ms}ms`, ctx);
55
81
  });
56
82
 
57
83
  next();
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ const ServiceProvider = require('./ServiceProvider');
4
+ const {
5
+ Log,
6
+ Logger,
7
+ LEVELS,
8
+ PrettyFormatter,
9
+ JsonFormatter,
10
+ SimpleFormatter,
11
+ ConsoleChannel,
12
+ FileChannel,
13
+ NullChannel,
14
+ StackChannel,
15
+ } = require('../logger/index');
16
+
17
+ /**
18
+ * LogServiceProvider
19
+ *
20
+ * Reads config/logging.js and configures the Log singleton.
21
+ * Also registers Log and Logger in the DI container.
22
+ *
23
+ * Add to bootstrap/app.js:
24
+ * app.providers([LogServiceProvider, ...]);
25
+ *
26
+ * The provider is intentionally ordered first so every other provider
27
+ * can use Log during their boot() method.
28
+ *
29
+ * config/logging.js is optional — sensible defaults apply if absent.
30
+ */
31
+ class LogServiceProvider extends ServiceProvider {
32
+ register(container) {
33
+ container.instance('Log', Log);
34
+ container.instance('Logger', Logger);
35
+ }
36
+
37
+ async boot(container, app) {
38
+ let config = {};
39
+ try {
40
+ config = require(process.cwd() + '/config/logging');
41
+ } catch {
42
+ // No config — use defaults already set in logger/index.js
43
+ return;
44
+ }
45
+
46
+ const channels = this._buildChannels(config);
47
+
48
+ Log.configure({
49
+ minLevel: this._resolveLevel(config.level ?? config.minLevel),
50
+ defaultTag: config.defaultTag || 'App',
51
+ channel: channels.length === 1
52
+ ? channels[0]
53
+ : new StackChannel(channels),
54
+ });
55
+
56
+ Log.tag('Millas').i(`Logger configured — level: ${this._levelName(Log._minLevel)}, channels: ${channels.length}`);
57
+ }
58
+
59
+ // ─── Private ──────────────────────────────────────────────────────────────
60
+
61
+ _buildChannels(config) {
62
+ const channelDefs = config.channels || ['console'];
63
+ const built = [];
64
+
65
+ for (const def of channelDefs) {
66
+ // String shorthand: 'console' | 'file' | 'null'
67
+ if (def === 'console' || def?.driver === 'console') {
68
+ built.push(this._buildConsole(def));
69
+ } else if (def === 'file' || def?.driver === 'file') {
70
+ built.push(this._buildFile(def));
71
+ } else if (def === 'null' || def?.driver === 'null') {
72
+ built.push(new NullChannel());
73
+ } else if (def && typeof def.write === 'function') {
74
+ // Already an instantiated channel — use directly
75
+ built.push(def);
76
+ }
77
+ }
78
+
79
+ // Always have at least a console channel
80
+ if (!built.length) built.push(this._buildConsole({}));
81
+
82
+ return built;
83
+ }
84
+
85
+ _buildConsole(opts = {}) {
86
+ const fmt = this._buildFormatter(opts.formatter || opts.format || 'pretty', opts);
87
+ return new ConsoleChannel({
88
+ formatter: fmt,
89
+ minLevel: this._resolveLevel(opts.level ?? opts.minLevel),
90
+ });
91
+ }
92
+
93
+ _buildFile(opts = {}) {
94
+ const fmt = this._buildFormatter(opts.formatter || opts.format || 'simple', opts);
95
+ return new FileChannel({
96
+ path: opts.path || 'storage/logs',
97
+ prefix: opts.prefix || 'millas',
98
+ formatter: fmt,
99
+ minLevel: this._resolveLevel(opts.level ?? opts.minLevel),
100
+ maxFiles: opts.maxFiles ?? 30,
101
+ });
102
+ }
103
+
104
+ _buildFormatter(name, opts = {}) {
105
+ if (name && typeof name === 'object' && typeof name.format === 'function') {
106
+ return name; // already an instance
107
+ }
108
+ switch (String(name).toLowerCase()) {
109
+ case 'json':
110
+ return new JsonFormatter({ extra: opts.extra });
111
+ case 'simple':
112
+ return new SimpleFormatter();
113
+ case 'pretty':
114
+ default:
115
+ return new PrettyFormatter({
116
+ colour: opts.colour !== false && process.stdout.isTTY !== false,
117
+ timestamp: opts.timestamp !== false,
118
+ tag: opts.tag !== false,
119
+ timestampFormat: opts.timestampFormat || 'short',
120
+ });
121
+ }
122
+ }
123
+
124
+ _resolveLevel(level) {
125
+ if (level === undefined || level === null) {
126
+ return process.env.NODE_ENV === 'production' ? LEVELS.INFO : LEVELS.DEBUG;
127
+ }
128
+ if (typeof level === 'number') return level;
129
+ return LEVELS[String(level).toUpperCase()] ?? LEVELS.DEBUG;
130
+ }
131
+
132
+ _levelName(n) {
133
+ const names = ['VERBOSE','DEBUG','INFO','WARN','ERROR','WTF'];
134
+ return names[n] || String(n);
135
+ }
136
+ }
137
+
138
+ module.exports = LogServiceProvider;
@@ -66,8 +66,6 @@ storage/logs/*.log
66
66
  storage/uploads/*
67
67
  !storage/uploads/.gitkeep
68
68
  database/database.sqlite
69
- # Millas migration snapshot — auto-generated, do not commit
70
- .millas/
71
69
  `,
72
70
 
73
71
  // ─── millas.config.js ─────────────────────────────────────────
@@ -106,6 +104,7 @@ function resolveMillas() {
106
104
  const {
107
105
  Application,
108
106
  Admin,
107
+ LogServiceProvider,
109
108
  CacheServiceProvider,
110
109
  StorageServiceProvider,
111
110
  MailServiceProvider,
@@ -124,6 +123,7 @@ const app = new Application(expressApp);
124
123
 
125
124
  // ── Register service providers ──────────────────────────────────
126
125
  app.providers([
126
+ LogServiceProvider, // first — so Log works in all other providers
127
127
  CacheServiceProvider,
128
128
  StorageServiceProvider,
129
129
  MailServiceProvider,
@@ -198,6 +198,38 @@ module.exports = function (Route) {
198
198
 
199
199
  });
200
200
  };
201
+ `,
202
+
203
+ // ─── config/logging.js ────────────────────────────────────────
204
+ 'config/logging.js': `'use strict';
205
+
206
+ /**
207
+ * Logging Configuration
208
+ *
209
+ * Levels (lowest → highest): verbose | debug | info | warn | error | wtf
210
+ * Channels: 'console' | 'file' | 'null'
211
+ * Formats: 'pretty' (coloured) | 'simple' (plain text) | 'json' (structured)
212
+ */
213
+ module.exports = {
214
+ level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
215
+
216
+ defaultTag: process.env.APP_NAME || 'App',
217
+
218
+ channels: [
219
+ {
220
+ driver: 'console',
221
+ format: 'pretty',
222
+ },
223
+ {
224
+ driver: 'file',
225
+ format: 'simple',
226
+ path: 'storage/logs',
227
+ prefix: '${projectName}',
228
+ level: 'warn',
229
+ maxFiles: 30,
230
+ },
231
+ ],
232
+ };
201
233
  `,
202
234
 
203
235
  // ─── config/app.js ────────────────────────────────────────────
@@ -358,37 +390,17 @@ millas make:controller UserController
358
390
 
359
391
  # Generate a model
360
392
  millas make:model User
361
- \`\`\`
362
393
 
363
- ## Database Migrations (Django-style)
364
-
365
- Millas handles migrations automatically — you only edit your model files.
366
-
367
- \`\`\`bash
368
- # 1. Edit app/models/User.js — add, remove, or change fields
369
- # 2. Generate migration files from your changes
370
- millas makemigrations
371
-
372
- # 3. Apply pending migrations to the database
394
+ # Run migrations
373
395
  millas migrate
374
396
  \`\`\`
375
397
 
376
- Other migration commands:
377
-
378
- \`\`\`bash
379
- millas migrate:status # Show which migrations have run
380
- millas migrate:rollback # Undo the last batch
381
- millas migrate:fresh # Drop everything and re-run all migrations
382
- millas migrate:reset # Roll back all migrations
383
- millas migrate:refresh # Reset + re-run (like fresh but using down() methods)
384
- \`\`\`
385
-
386
398
  ## Project Structure
387
399
 
388
400
  \`\`\`
389
401
  app/
390
402
  controllers/ # HTTP controllers
391
- models/ # ORM models ← only file you edit for schema changes
403
+ models/ # ORM models
392
404
  services/ # Business logic
393
405
  middleware/ # HTTP middleware
394
406
  jobs/ # Background jobs
@@ -396,14 +408,13 @@ bootstrap/
396
408
  app.js # Application entry point
397
409
  config/ # Configuration files
398
410
  database/
399
- migrations/ # Auto-generated — do not edit by hand
411
+ migrations/ # Database migrations
400
412
  seeders/ # Database seeders
401
413
  routes/
402
414
  web.js # Web routes
403
415
  api.js # API routes
404
416
  storage/ # Logs, uploads
405
417
  providers/ # Service providers
406
- .millas/ # Migration snapshot (gitignored)
407
418
  \`\`\`
408
419
  `,
409
420
  };