millas 0.2.11 → 0.2.12-beta

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.
Files changed (47) hide show
  1. package/package.json +17 -3
  2. package/src/auth/AuthController.js +42 -133
  3. package/src/auth/AuthMiddleware.js +12 -23
  4. package/src/auth/RoleMiddleware.js +7 -17
  5. package/src/commands/migrate.js +46 -31
  6. package/src/commands/serve.js +266 -37
  7. package/src/container/Application.js +88 -8
  8. package/src/controller/Controller.js +79 -300
  9. package/src/errors/ErrorRenderer.js +640 -0
  10. package/src/facades/Admin.js +49 -0
  11. package/src/facades/Auth.js +46 -0
  12. package/src/facades/Cache.js +17 -0
  13. package/src/facades/Database.js +43 -0
  14. package/src/facades/Events.js +24 -0
  15. package/src/facades/Http.js +54 -0
  16. package/src/facades/Log.js +56 -0
  17. package/src/facades/Mail.js +40 -0
  18. package/src/facades/Queue.js +23 -0
  19. package/src/facades/Storage.js +17 -0
  20. package/src/facades/Validation.js +69 -0
  21. package/src/http/MillasRequest.js +253 -0
  22. package/src/http/MillasResponse.js +196 -0
  23. package/src/http/RequestContext.js +176 -0
  24. package/src/http/ResponseDispatcher.js +144 -0
  25. package/src/http/helpers.js +164 -0
  26. package/src/http/index.js +13 -0
  27. package/src/index.js +55 -2
  28. package/src/logger/internal.js +76 -0
  29. package/src/logger/patchConsole.js +135 -0
  30. package/src/middleware/CorsMiddleware.js +22 -30
  31. package/src/middleware/LogMiddleware.js +27 -59
  32. package/src/middleware/Middleware.js +24 -15
  33. package/src/middleware/MiddlewarePipeline.js +30 -67
  34. package/src/middleware/MiddlewareRegistry.js +126 -0
  35. package/src/middleware/ThrottleMiddleware.js +22 -26
  36. package/src/orm/fields/index.js +124 -56
  37. package/src/orm/migration/ModelInspector.js +7 -3
  38. package/src/orm/model/Model.js +96 -6
  39. package/src/orm/query/QueryBuilder.js +141 -3
  40. package/src/providers/LogServiceProvider.js +88 -18
  41. package/src/providers/ProviderRegistry.js +14 -1
  42. package/src/providers/ServiceProvider.js +40 -8
  43. package/src/router/Router.js +155 -223
  44. package/src/scaffold/maker.js +24 -59
  45. package/src/scaffold/templates.js +13 -12
  46. package/src/validation/BaseValidator.js +193 -0
  47. package/src/validation/Validator.js +680 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  const LookupParser = require('./LookupParser');
4
4
  const { AggregateExpression } = require('./Aggregates');
5
+ const MillasLog = require('../../logger/internal');
5
6
 
6
7
  /**
7
8
  * QueryBuilder
@@ -81,6 +82,21 @@ class QueryBuilder {
81
82
  return this;
82
83
  }
83
84
 
85
+ /**
86
+ * Bypass all global scopes for this query.
87
+ * Useful when you intentionally need an unfiltered view of the table.
88
+ *
89
+ * User.withoutGlobalScope().get() // skips tenant filter, active-only, etc.
90
+ */
91
+ withoutGlobalScope() {
92
+ // Re-build the base query directly from knex, bypassing _db() where
93
+ // globalScopes are applied.
94
+ const DatabaseManager = require('../drivers/DatabaseManager');
95
+ const db = DatabaseManager.connection(this._model.connection || null);
96
+ this._query = db(this._model.table);
97
+ return this;
98
+ }
99
+
84
100
  // ─── Scopes ───────────────────────────────────────────────────────────────
85
101
 
86
102
  /**
@@ -117,7 +133,38 @@ class QueryBuilder {
117
133
  return this;
118
134
  }
119
135
 
120
- // ─── Aggregation ──────────────────────────────────────────────────────────
136
+ /**
137
+ * Eager-load a COUNT of related rows onto each result.
138
+ *
139
+ * Post.withCount('comments').get()
140
+ * // each post gets .comments_count
141
+ *
142
+ * Post.withCount('comments', 'likes').get()
143
+ * Post.withCount({ comments: q => q.where('approved', true) }).get()
144
+ */
145
+ withCount(...relations) {
146
+ for (const rel of relations.flat()) {
147
+ if (typeof rel === 'string') {
148
+ this._withs.push({ name: rel, constraint: null, aggregate: 'count' });
149
+ } else if (typeof rel === 'object') {
150
+ for (const [name, constraint] of Object.entries(rel)) {
151
+ this._withs.push({ name, constraint, aggregate: 'count' });
152
+ }
153
+ }
154
+ }
155
+ return this;
156
+ }
157
+
158
+ /**
159
+ * Eager-load a SUM of a related column onto each result.
160
+ *
161
+ * User.withSum('orders', 'amount').get()
162
+ * // each user gets .orders_sum_amount
163
+ */
164
+ withSum(relation, column) {
165
+ this._withs.push({ name: relation, constraint: null, aggregate: 'sum', aggregateColumn: column });
166
+ return this;
167
+ }
121
168
 
122
169
  /**
123
170
  * Add per-row computed columns.
@@ -336,10 +383,18 @@ class QueryBuilder {
336
383
 
337
384
  const relations = this._model.relations || {};
338
385
 
339
- for (const { name, constraint } of this._withs) {
386
+ for (const { name, constraint, aggregate, aggregateColumn } of this._withs) {
387
+
388
+ // ── withCount / withSum ──────────────────────────────────────────────
389
+ if (aggregate) {
390
+ await this._eagerAggregate(instances, name, aggregate, aggregateColumn, constraint, relations);
391
+ continue;
392
+ }
393
+
394
+ // ── Normal eager load ─────────────────────────────────────────────────
340
395
  const rel = relations[name];
341
396
  if (!rel) {
342
- process.stderr.write(` [millas] Warning: relation "${name}" not defined on ${this._model.name}\n`);
397
+ MillasLog.w('ORM', `Relation "${name}" not defined on ${this._model.name} — skipping eager load`);
343
398
  continue;
344
399
  }
345
400
 
@@ -347,6 +402,89 @@ class QueryBuilder {
347
402
  }
348
403
  }
349
404
 
405
+ /**
406
+ * Load an aggregate (COUNT / SUM) of a relation onto each instance
407
+ * without pulling back full related rows.
408
+ */
409
+ async _eagerAggregate(instances, relName, aggFn, aggColumn, constraint, relations) {
410
+ const rel = relations[relName];
411
+ if (!rel) {
412
+ MillasLog.w('ORM', `Relation "${relName}" not defined on ${this._model.name} — cannot compute ${aggFn}`);
413
+ // Zero-fill the attribute
414
+ const attr = aggFn === 'sum'
415
+ ? `${relName}_sum_${aggColumn}`
416
+ : `${relName}_count`;
417
+ for (const i of instances) i[attr] = 0;
418
+ return;
419
+ }
420
+
421
+ const attrName = aggFn === 'sum'
422
+ ? `${relName}_sum_${aggColumn}`
423
+ : `${relName}_count`;
424
+
425
+ // We only support HasMany / BelongsToMany for aggregate loads for now.
426
+ // For those, the foreignKey / pivot info is on the rel object.
427
+ const HasMany = require('../relations/HasMany');
428
+ const BelongsToMany = require('../relations/BelongsToMany');
429
+
430
+ const localKey = rel._localKey || this._model.primaryKey || 'id';
431
+ const keys = [...new Set(instances.map(i => i[localKey]).filter(v => v != null))];
432
+
433
+ if (!keys.length) {
434
+ for (const i of instances) i[attrName] = 0;
435
+ return;
436
+ }
437
+
438
+ const related = rel._related;
439
+ let q;
440
+
441
+ if (rel instanceof HasMany) {
442
+ const fk = rel._foreignKey;
443
+ q = related._db()
444
+ .select(`${fk} as _owner_id`)
445
+ .whereIn(fk, keys);
446
+
447
+ if (aggFn === 'count') {
448
+ q = q.count(`${related.primaryKey || 'id'} as _agg_val`).groupBy(fk);
449
+ } else {
450
+ q = q.sum(`${aggColumn} as _agg_val`).groupBy(fk);
451
+ }
452
+
453
+ } else if (rel instanceof BelongsToMany) {
454
+ const pivot = rel._pivotTable;
455
+ const fk = rel._foreignPivotKey;
456
+ const rk = rel._relatedPivotKey;
457
+ q = related._db()
458
+ .join(pivot, `${related.table}.${related.primaryKey || 'id'}`, '=', `${pivot}.${rk}`)
459
+ .select(`${pivot}.${fk} as _owner_id`)
460
+ .whereIn(`${pivot}.${fk}`, keys);
461
+
462
+ if (aggFn === 'count') {
463
+ q = q.count(`${related.table}.${related.primaryKey || 'id'} as _agg_val`).groupBy(`${pivot}.${fk}`);
464
+ } else {
465
+ q = q.sum(`${related.table}.${aggColumn} as _agg_val`).groupBy(`${pivot}.${fk}`);
466
+ }
467
+ } else {
468
+ // Unsupported relation type — zero-fill
469
+ for (const i of instances) i[attrName] = 0;
470
+ return;
471
+ }
472
+
473
+ if (constraint) {
474
+ const QB = require('./QueryBuilder');
475
+ const qb = new QB(q, related);
476
+ constraint(qb);
477
+ q = qb._query;
478
+ }
479
+
480
+ const rows = await q;
481
+ const map = new Map(rows.map(r => [r._owner_id, Number(r._agg_val ?? 0)]));
482
+
483
+ for (const instance of instances) {
484
+ instance[attrName] = map.get(instance[localKey]) ?? 0;
485
+ }
486
+ }
487
+
350
488
  // ─── Internal where dispatcher ────────────────────────────────────────────
351
489
 
352
490
  _applyWhere(method, column, operatorOrValue, value) {
@@ -13,47 +13,117 @@ const {
13
13
  NullChannel,
14
14
  StackChannel,
15
15
  } = require('../logger/index');
16
+ const MillasLog = require('../logger/internal');
17
+ const patchConsole = require('../logger/patchConsole');
16
18
 
17
19
  /**
18
20
  * LogServiceProvider
19
21
  *
20
- * Reads config/logging.js and configures the Log singleton.
21
- * Also registers Log and Logger in the DI container.
22
+ * On boot:
23
+ * 1. Configures Log (app logger) from config/logging.js
24
+ * 2. Configures MillasLog (internal framework logger) from config.internal
25
+ * 3. Patches console.* to route through Log (unless interceptConsole: false)
22
26
  *
23
- * Add to bootstrap/app.js:
24
- * app.providers([LogServiceProvider, ...]);
27
+ * config/logging.js reference:
25
28
  *
26
- * The provider is intentionally ordered first so every other provider
27
- * can use Log during their boot() method.
29
+ * module.exports = {
30
+ * level: 'debug',
31
+ * channels: [{ driver: 'console', format: 'pretty' }],
28
32
  *
29
- * config/logging.js is optional sensible defaults apply if absent.
33
+ * // Opt out of console patching:
34
+ * interceptConsole: false,
35
+ *
36
+ * // Framework-internal logs (ORM, migrations, admin):
37
+ * internal: { level: 'warn' },
38
+ * };
30
39
  */
31
40
  class LogServiceProvider extends ServiceProvider {
32
41
  register(container) {
33
- container.instance('Log', Log);
34
- container.instance('Logger', Logger);
42
+ container.instance('Log', Log);
43
+ container.instance('Logger', Logger);
44
+ container.instance('MillasLog', MillasLog);
35
45
  }
36
46
 
37
- async boot(container, app) {
47
+ /**
48
+ * beforeBoot — runs before any provider's register() call.
49
+ *
50
+ * We configure logging and patch console here so that:
51
+ * - Every register() call in every provider already produces
52
+ * formatted log output
53
+ * - No provider boots without a working logger
54
+ *
55
+ * This is synchronous — no async allowed in beforeBoot.
56
+ * We load config synchronously and apply defaults eagerly.
57
+ */
58
+ beforeBoot(container) {
38
59
  let config = {};
39
60
  try {
40
61
  config = require(process.cwd() + '/config/logging');
41
62
  } catch {
42
- // No config — use defaults already set in logger/index.js
43
- return;
63
+ // No config file — defaults already applied in logger/index.js
44
64
  }
45
65
 
46
- const channels = this._buildChannels(config);
66
+ // Store config on the instance so boot() can use it without re-reading
67
+ this._loggingConfig = config;
47
68
 
69
+ // ── Configure Log from config ─────────────────────────────────────────
70
+ const channels = this._buildChannels(config);
48
71
  Log.configure({
49
72
  minLevel: this._resolveLevel(config.level ?? config.minLevel),
50
- defaultTag: config.defaultTag || 'App',
51
- channel: channels.length === 1
52
- ? channels[0]
53
- : new StackChannel(channels),
73
+ defaultTag: config.defaultTag || 'SystemOut',
74
+ channel: channels.length === 1 ? channels[0] : new StackChannel(channels),
54
75
  });
55
76
 
56
- Log.tag('Millas').i(`Logger configured level: ${this._levelName(Log._minLevel)}, channels: ${channels.length}`);
77
+ // ── Configure MillasLog (internal framework logger) ───────────────────
78
+ this._configureMillasLog(config.internal);
79
+
80
+ // ── Patch console.* → Log.* ───────────────────────────────────────────
81
+ this._restoreConsole = patchConsole(Log, config.defaultTag || 'SystemOut');
82
+
83
+ }
84
+
85
+ async boot(container, app) {
86
+ const config = this._loggingConfig || {};
87
+ const intercept = config.interceptConsole !== false;
88
+
89
+ // Store restore fn so tests can call container.make('console.restore')
90
+ if (this._restoreConsole) {
91
+ container.instance('console.restore', this._restoreConsole);
92
+ }
93
+
94
+ // Log.tag('Millas').i(
95
+ // `Logger ready` +
96
+ // ` — level: ${this._levelName(Log._minLevel)}` +
97
+ // `, internal: ${this._levelName(MillasLog._minLevel)}` +
98
+ // `, console: ${intercept ? 'intercepted' : 'native'}`
99
+ // );
100
+ }
101
+
102
+ // ─── Private ──────────────────────────────────────────────────────────────
103
+
104
+ _configureMillasLog(internalConfig) {
105
+ if (internalConfig === false) {
106
+ MillasLog.configure({ channel: new NullChannel() });
107
+ } else if (internalConfig && typeof internalConfig === 'object') {
108
+ if (internalConfig.channels) {
109
+ const internalChannels = this._buildChannels(internalConfig);
110
+ MillasLog.configure({
111
+ minLevel: this._resolveLevel(internalConfig.level ?? internalConfig.minLevel),
112
+ defaultTag: 'Millas',
113
+ channel: internalChannels.length === 1
114
+ ? internalChannels[0]
115
+ : new StackChannel(internalChannels),
116
+ });
117
+ } else {
118
+ const fmt = this._buildFormatter(internalConfig.format || 'pretty', internalConfig);
119
+ const level = this._resolveLevel(internalConfig.level ?? internalConfig.minLevel);
120
+ MillasLog.configure({
121
+ minLevel: level,
122
+ defaultTag: 'Millas',
123
+ channel: new ConsoleChannel({ formatter: fmt, minLevel: level }),
124
+ });
125
+ }
126
+ }
57
127
  }
58
128
 
59
129
  // ─── Private ──────────────────────────────────────────────────────────────
@@ -53,9 +53,22 @@ class ProviderRegistry {
53
53
  }
54
54
 
55
55
  /**
56
- * Run the full register boot lifecycle.
56
+ * Run the full provider lifecycle in order:
57
+ * Phase 0 — beforeBoot (synchronous, no bindings yet — for global setup)
58
+ * Phase 1 — register (synchronous, bind into container)
59
+ * Phase 2 — boot (async-safe, all bindings exist)
57
60
  */
58
61
  async boot() {
62
+ // Phase 0: beforeBoot — runs before ANY register() call.
63
+ // Synchronous only. Used for global setup that must happen first
64
+ // (e.g. LogServiceProvider patches console here so all register()
65
+ // calls already produce formatted output).
66
+ for (const provider of this._providers) {
67
+ if (typeof provider.beforeBoot === 'function') {
68
+ provider.beforeBoot(this._container);
69
+ }
70
+ }
71
+
59
72
  // Phase 1: register all bindings
60
73
  for (const provider of this._providers) {
61
74
  if (typeof provider.register === 'function') {
@@ -5,37 +5,69 @@
5
5
  *
6
6
  * Base class for all Millas service providers.
7
7
  *
8
- * Providers have two lifecycle hooks:
8
+ * ── Lifecycle hooks (called in this order) ─────────────────────────────────
9
+ *
10
+ * beforeBoot(container)
11
+ * Called FIRST — before any provider's register() runs.
12
+ * Use for setup that must happen before everything else:
13
+ * - Configuring logging (so register() calls get formatted output)
14
+ * - Patching globals (console, process handlers)
15
+ * - Reading config files that other providers depend on
16
+ * The container exists but has NO bindings yet.
17
+ * Must be synchronous — async is not supported here.
9
18
  *
10
19
  * register(container)
11
- * Called first. Bind things into the container.
12
- * Do NOT use other bindings here they may not exist yet.
20
+ * Called after ALL beforeBoot() hooks have run.
21
+ * Bind things into the container (singletons, factories, instances).
22
+ * Do NOT resolve other bindings here — they may not exist yet.
23
+ * Must be synchronous.
13
24
  *
14
25
  * boot(container, app)
15
26
  * Called after ALL providers have registered.
16
- * Safe to resolve other bindings and set up routes,
17
- * event listeners, middleware, etc.
27
+ * Safe to resolve other bindings, set up routes, register
28
+ * event listeners, mount middleware, etc.
29
+ * Async-safe — await is fine here.
30
+ *
31
+ * ── Example ────────────────────────────────────────────────────────────────
18
32
  *
19
- * Usage:
20
33
  * class AppServiceProvider extends ServiceProvider {
34
+ * beforeBoot(container) {
35
+ * // Runs before any register() — good for global setup
36
+ * }
37
+ *
21
38
  * register(container) {
22
39
  * container.singleton('UserService', UserService);
23
40
  * }
41
+ *
24
42
  * async boot(container, app) {
25
- * const logger = container.make('Logger');
26
- * logger.info('AppServiceProvider booted');
43
+ * const db = container.make('db');
44
+ * await db.migrate();
27
45
  * }
28
46
  * }
29
47
  */
30
48
  class ServiceProvider {
49
+ /**
50
+ * Called before any provider's register() runs.
51
+ * Container exists but has no bindings yet.
52
+ * Must be synchronous.
53
+ *
54
+ * @param {import('./Container')} container
55
+ */
56
+ beforeBoot(container) {}
57
+
31
58
  /**
32
59
  * Register bindings into the container.
60
+ * Called after all beforeBoot() hooks have run.
61
+ * Must be synchronous.
62
+ *
33
63
  * @param {import('./Container')} container
34
64
  */
35
65
  register(container) {}
36
66
 
37
67
  /**
38
68
  * Bootstrap services after all providers have registered.
69
+ * Safe to resolve other bindings. Async-safe.
70
+ *
39
71
  * @param {import('./Container')} container
40
72
  * @param {import('express').Application} app
41
73
  */