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 +1 -1
- package/src/admin/resources/AdminResource.js +61 -18
- package/src/admin/views/layouts/base.njk +1 -1
- package/src/index.js +23 -10
- package/src/logger/Logger.js +341 -0
- package/src/logger/channels/ConsoleChannel.js +39 -0
- package/src/logger/channels/FileChannel.js +101 -0
- package/src/logger/channels/index.js +48 -0
- package/src/logger/formatters/JsonFormatter.js +52 -0
- package/src/logger/formatters/PrettyFormatter.js +95 -0
- package/src/logger/formatters/SimpleFormatter.js +37 -0
- package/src/logger/index.js +69 -0
- package/src/logger/levels.js +52 -0
- package/src/middleware/LogMiddleware.js +54 -28
- package/src/providers/LogServiceProvider.js +138 -0
- package/src/scaffold/templates.js +37 -26
package/package.json
CHANGED
|
@@ -165,37 +165,80 @@ class AdminResource {
|
|
|
165
165
|
const limit = perPage || this.perPage;
|
|
166
166
|
const offset = (page - 1) * limit;
|
|
167
167
|
|
|
168
|
-
|
|
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
|
|
173
|
-
|
|
174
|
-
for (const col of
|
|
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
|
|
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
|
|
183
|
-
|
|
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
|
|
219
|
+
// ── Date hierarchy ────────────────────────────────────────────────────────
|
|
188
220
|
if (this.dateHierarchy) {
|
|
189
|
-
|
|
190
|
-
if (
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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.
|
|
540
|
+
const rows = await this.model._db()
|
|
498
541
|
.where(this.foreignKey, parentId)
|
|
499
542
|
.limit(this.perPage)
|
|
500
|
-
.
|
|
501
|
-
return rows.map(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
|
-
|
|
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;
|