millas 0.2.11 → 0.2.12-beta-1

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 (74) hide show
  1. package/package.json +6 -5
  2. package/src/auth/Auth.js +13 -8
  3. package/src/auth/AuthController.js +45 -134
  4. package/src/auth/AuthMiddleware.js +12 -23
  5. package/src/auth/AuthUser.js +98 -0
  6. package/src/auth/RoleMiddleware.js +7 -17
  7. package/src/cli.js +1 -1
  8. package/src/commands/migrate.js +46 -31
  9. package/src/commands/serve.js +238 -38
  10. package/src/container/AppInitializer.js +158 -0
  11. package/src/container/Application.js +288 -183
  12. package/src/container/HttpServer.js +156 -0
  13. package/src/container/MillasApp.js +23 -280
  14. package/src/container/MillasConfig.js +163 -0
  15. package/src/controller/Controller.js +79 -300
  16. package/src/core/auth.js +9 -0
  17. package/src/core/db.js +8 -0
  18. package/src/core/foundation.js +67 -0
  19. package/src/core/http.js +11 -0
  20. package/src/core/mail.js +6 -0
  21. package/src/core/queue.js +7 -0
  22. package/src/core/validation.js +29 -0
  23. package/src/errors/ErrorRenderer.js +640 -0
  24. package/src/facades/Admin.js +49 -0
  25. package/src/facades/Auth.js +29 -0
  26. package/src/facades/Cache.js +28 -0
  27. package/src/facades/Database.js +43 -0
  28. package/src/facades/Events.js +25 -0
  29. package/src/facades/Facade.js +197 -0
  30. package/src/facades/Http.js +51 -0
  31. package/src/facades/Log.js +32 -0
  32. package/src/facades/Mail.js +35 -0
  33. package/src/facades/Queue.js +30 -0
  34. package/src/facades/Storage.js +25 -0
  35. package/src/facades/Url.js +53 -0
  36. package/src/http/HttpClient.js +673 -0
  37. package/src/http/MillasRequest.js +253 -0
  38. package/src/http/MillasResponse.js +196 -0
  39. package/src/http/RequestContext.js +176 -0
  40. package/src/http/ResponseDispatcher.js +51 -0
  41. package/src/http/UrlGenerator.js +375 -0
  42. package/src/http/WelcomePage.js +273 -0
  43. package/src/http/adapters/ExpressAdapter.js +315 -0
  44. package/src/http/adapters/HttpAdapter.js +168 -0
  45. package/src/http/adapters/index.js +9 -0
  46. package/src/http/helpers.js +164 -0
  47. package/src/http/index.js +13 -0
  48. package/src/index.js +5 -91
  49. package/src/logger/formatters/PrettyFormatter.js +15 -5
  50. package/src/logger/internal.js +76 -0
  51. package/src/logger/patchConsole.js +145 -0
  52. package/src/middleware/CorsMiddleware.js +22 -30
  53. package/src/middleware/LogMiddleware.js +27 -59
  54. package/src/middleware/Middleware.js +24 -15
  55. package/src/middleware/MiddlewarePipeline.js +30 -67
  56. package/src/middleware/MiddlewareRegistry.js +106 -0
  57. package/src/middleware/ThrottleMiddleware.js +22 -26
  58. package/src/orm/fields/index.js +124 -56
  59. package/src/orm/migration/ModelInspector.js +339 -336
  60. package/src/orm/model/Model.js +96 -6
  61. package/src/orm/query/QueryBuilder.js +141 -3
  62. package/src/providers/AuthServiceProvider.js +9 -5
  63. package/src/providers/CacheStorageServiceProvider.js +3 -1
  64. package/src/providers/EventServiceProvider.js +2 -1
  65. package/src/providers/LogServiceProvider.js +88 -17
  66. package/src/providers/MailServiceProvider.js +3 -2
  67. package/src/providers/ProviderRegistry.js +14 -1
  68. package/src/providers/QueueServiceProvider.js +3 -2
  69. package/src/providers/ServiceProvider.js +40 -8
  70. package/src/router/Router.js +121 -222
  71. package/src/scaffold/maker.js +24 -59
  72. package/src/scaffold/templates.js +21 -19
  73. package/src/validation/BaseValidator.js +193 -0
  74. package/src/validation/Validator.js +680 -0
@@ -1,93 +1,56 @@
1
1
  'use strict';
2
2
 
3
+ const MillasRequest = require('../http/MillasRequest');
4
+ const RequestContext = require('../http/RequestContext');
5
+ const MillasResponse = require('../http/MillasResponse');
6
+ const ResponseDispatcher = require('../http/ResponseDispatcher');
7
+
3
8
  /**
4
9
  * MiddlewarePipeline
5
10
  *
6
- * Composes an ordered list of middleware handlers into a single
7
- * Express-compatible function. Used internally by the Router.
8
- *
9
- * Each handler can be:
10
- * - A Middleware subclass (instantiated automatically)
11
- * - An already-instantiated Middleware object
12
- * - A raw Express function (req, res, next) => {}
11
+ * Runs an ordered list of middleware instances against a request.
12
+ * Used for programmatic pipelines outside of the router (e.g. queue webhooks).
13
13
  *
14
- * Usage (internal):
15
- * const pipeline = new MiddlewarePipeline([AuthMiddleware, LogMiddleware]);
16
- * app.use(pipeline.compose());
14
+ * Each middleware receives a RequestContext and a next() function.
17
15
  */
18
16
  class MiddlewarePipeline {
19
- constructor(handlers = []) {
20
- this._handlers = handlers;
21
- }
22
-
23
- /**
24
- * Add a handler to the end of the pipeline.
25
- */
26
- pipe(handler) {
27
- this._handlers.push(handler);
28
- return this;
17
+ constructor(middlewares = []) {
18
+ this._middlewares = middlewares;
29
19
  }
30
20
 
31
- /**
32
- * Add a handler to the beginning of the pipeline.
33
- */
34
- prepend(handler) {
35
- this._handlers.unshift(handler);
21
+ add(middleware) {
22
+ this._middlewares.push(middleware);
36
23
  return this;
37
24
  }
38
25
 
39
26
  /**
40
- * Compose all handlers into a single (req, res, next) function.
27
+ * Run the pipeline against an Express req/res.
28
+ * @param {object} expressReq
29
+ * @param {object} expressRes
30
+ * @param {object|null} container
41
31
  */
42
- compose() {
43
- const fns = this._handlers.map(h => this._resolve(h));
32
+ async run(expressReq, expressRes, container = null) {
33
+ const millaReq = new MillasRequest(expressReq);
34
+ const ctx = new RequestContext(millaReq, container);
44
35
 
45
- return function pipeline(req, res, next) {
46
- let index = 0;
36
+ const dispatch = async (index) => {
37
+ if (index >= this._middlewares.length) return null;
47
38
 
48
- function dispatch(i) {
49
- if (i >= fns.length) return next();
50
- const fn = fns[i];
39
+ const mw = this._middlewares[index];
40
+ const next = () => dispatch(index + 1);
51
41
 
52
- try {
53
- const result = fn(req, res, (err) => {
54
- if (err) return next(err);
55
- dispatch(i + 1);
56
- });
57
- // Handle async middleware that returns a Promise
58
- if (result && typeof result.catch === 'function') {
59
- result.catch(next);
60
- }
61
- } catch (err) {
62
- next(err);
63
- }
64
- }
42
+ const instance = typeof mw === 'function' && mw.prototype?.handle
43
+ ? new mw()
44
+ : mw;
65
45
 
66
- dispatch(0);
46
+ return instance.handle(ctx, next);
67
47
  };
68
- }
69
-
70
- /**
71
- * Resolve a handler to a plain (req, res, next) => {} function.
72
- */
73
- _resolve(handler) {
74
- // Raw Express function
75
- if (typeof handler === 'function' && !(handler.prototype instanceof require('./Middleware'))) {
76
- return handler;
77
- }
78
48
 
79
- // Middleware class (not yet instantiated)
80
- if (typeof handler === 'function' && handler.prototype instanceof require('./Middleware')) {
81
- const instance = new handler();
82
- return (req, res, next) => instance.handle(req, res, next);
83
- }
49
+ const response = await dispatch(0);
84
50
 
85
- // Instantiated Middleware object
86
- if (handler && typeof handler.handle === 'function') {
87
- return (req, res, next) => handler.handle(req, res, next);
51
+ if (response && MillasResponse.isResponse(response) && !expressRes.headersSent) {
52
+ ResponseDispatcher.dispatch(response, expressRes);
88
53
  }
89
-
90
- throw new Error(`Invalid middleware: ${handler}. Must be a Middleware class, instance, or function.`);
91
54
  }
92
55
  }
93
56
 
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MiddlewareRegistry
5
+ *
6
+ * Maps string aliases → Millas middleware classes or instances.
7
+ * Resolution produces adapter-native handler functions via the adapter,
8
+ * so this class has zero knowledge of Express (or any HTTP engine).
9
+ *
10
+ * The adapter is injected at resolution time (not construction time)
11
+ * so the registry can be built before the adapter exists.
12
+ */
13
+ class MiddlewareRegistry {
14
+ constructor() {
15
+ this._map = {};
16
+ }
17
+
18
+ /**
19
+ * Register a middleware alias.
20
+ *
21
+ * registry.register('auth', AuthMiddleware)
22
+ * registry.register('throttle', new ThrottleMiddleware({ max: 60 }))
23
+ */
24
+ register(alias, handler) {
25
+ this._map[alias] = handler;
26
+ return this;
27
+ }
28
+
29
+ /**
30
+ * Resolve a middleware alias or class/instance into an adapter-native handler.
31
+ *
32
+ * @param {string|Function|object} aliasOrFn
33
+ * @param {import('../http/adapters/HttpAdapter')} adapter
34
+ * @param {object|null} container
35
+ * @returns {Function} adapter-native handler
36
+ */
37
+ resolve(aliasOrFn, adapter, container = null) {
38
+ const Handler = typeof aliasOrFn === 'string'
39
+ ? this._map[aliasOrFn]
40
+ : aliasOrFn;
41
+
42
+ if (!Handler) {
43
+ throw new Error(`Middleware "${aliasOrFn}" is not registered.`);
44
+ }
45
+
46
+ return this._wrap(Handler, adapter, container);
47
+ }
48
+
49
+ /**
50
+ * Resolve all aliases in a list.
51
+ */
52
+ resolveAll(list = [], adapter, container = null) {
53
+ return list.map(m => this.resolve(m, adapter, container));
54
+ }
55
+
56
+ /**
57
+ * Return a no-op passthrough handler for the given adapter.
58
+ * Used when a middleware alias is missing but should not crash the app.
59
+ */
60
+ resolvePassthrough(adapter) {
61
+ // Adapter-agnostic: return a function matching the native signature
62
+ // by asking the adapter to wrap a no-op middleware instance.
63
+ return adapter.wrapMiddleware({
64
+ handle: (_ctx, next) => next(),
65
+ }, null);
66
+ }
67
+
68
+ has(alias) {
69
+ return Object.prototype.hasOwnProperty.call(this._map, alias);
70
+ }
71
+
72
+ all() {
73
+ return { ...this._map };
74
+ }
75
+
76
+ // ── Internal ────────────────────────────────────────────────────────────────
77
+
78
+ _wrap(Handler, adapter, container) {
79
+ // Pre-instantiated Millas middleware object with handle()
80
+ if (
81
+ typeof Handler === 'object' &&
82
+ Handler !== null &&
83
+ typeof Handler.handle === 'function'
84
+ ) {
85
+ return adapter.wrapMiddleware(Handler, container);
86
+ }
87
+
88
+ // Millas middleware class (handle on prototype)
89
+ if (
90
+ typeof Handler === 'function' &&
91
+ Handler.prototype &&
92
+ typeof Handler.prototype.handle === 'function'
93
+ ) {
94
+ return adapter.wrapMiddleware(new Handler(), container);
95
+ }
96
+
97
+ // Raw adapter-native function — pass through as-is (escape hatch)
98
+ if (typeof Handler === 'function') {
99
+ return Handler;
100
+ }
101
+
102
+ throw new Error('Middleware must be a function or a class with handle().');
103
+ }
104
+ }
105
+
106
+ module.exports = MiddlewareRegistry;
@@ -1,35 +1,28 @@
1
1
  'use strict';
2
2
 
3
- const Middleware = require('./Middleware');
3
+ const Middleware = require('./Middleware');
4
+ const MillasResponse = require('../http/MillasResponse');
5
+ const { jsonify } = require('../http/helpers');
4
6
 
5
7
  /**
6
8
  * ThrottleMiddleware
7
9
  *
8
10
  * Simple in-memory rate limiter.
9
- * For production use, replace the store with a Redis-backed one (Phase 11).
10
- *
11
- * Register:
12
- * middlewareRegistry.register('throttle', new ThrottleMiddleware({ max: 60, window: 60 }));
13
- *
14
- * Options:
15
- * max — max requests per window (default: 60)
16
- * window — window in seconds (default: 60)
17
- * keyBy — function(req) => string, defaults to IP
11
+ * Uses the Millas middleware signature: handle(req, next).
18
12
  */
19
13
  class ThrottleMiddleware extends Middleware {
20
14
  constructor(options = {}) {
21
15
  super();
22
16
  this.max = options.max || 60;
23
- this.window = options.window || 60; // seconds
17
+ this.window = options.window || 60;
24
18
  this.keyBy = options.keyBy || ((req) => req.ip || 'anonymous');
25
- this._store = new Map(); // { key: { count, resetAt } }
19
+ this._store = new Map();
26
20
  }
27
21
 
28
- async handle(req, res, next) {
29
- const key = this.keyBy(req);
30
- const now = Date.now();
31
-
32
- let record = this._store.get(key);
22
+ async handle(req, next) {
23
+ const key = this.keyBy(req);
24
+ const now = Date.now();
25
+ let record = this._store.get(key);
33
26
 
34
27
  if (!record || now > record.resetAt) {
35
28
  record = { count: 0, resetAt: now + this.window * 1000 };
@@ -41,20 +34,23 @@ class ThrottleMiddleware extends Middleware {
41
34
  const remaining = Math.max(0, this.max - record.count);
42
35
  const resetIn = Math.ceil((record.resetAt - now) / 1000);
43
36
 
44
- res.setHeader('X-RateLimit-Limit', String(this.max));
45
- res.setHeader('X-RateLimit-Remaining', String(remaining));
46
- res.setHeader('X-RateLimit-Reset', String(Math.ceil(record.resetAt / 1000)));
37
+ // Rate limit headers — added to whatever response comes back
38
+ // We set them on the raw Express res since we don't have the final response yet.
39
+ // These headers will be present on all responses from throttled routes.
40
+ req.raw.res.setHeader('X-RateLimit-Limit', String(this.max));
41
+ req.raw.res.setHeader('X-RateLimit-Remaining', String(remaining));
42
+ req.raw.res.setHeader('X-RateLimit-Reset', String(Math.ceil(record.resetAt / 1000)));
47
43
 
48
44
  if (record.count > this.max) {
49
- return res.status(429).json({
50
- error: 'Too Many Requests',
51
- message: `Rate limit exceeded. Try again in ${resetIn}s.`,
52
- status: 429,
45
+ return jsonify({
46
+ error: 'Too Many Requests',
47
+ message: `Rate limit exceeded. Try again in ${resetIn}s.`,
48
+ status: 429,
53
49
  retryAfter: resetIn,
54
- });
50
+ }, { status: 429 });
55
51
  }
56
52
 
57
- next();
53
+ return next();
58
54
  }
59
55
  }
60
56
 
@@ -1,128 +1,196 @@
1
1
  'use strict';
2
2
 
3
- /**
4
- * fields
5
- *
6
- * Schema field definitions used inside Model.fields = { ... }
7
- *
8
- * Usage:
9
- * class User extends Model {
10
- * static fields = {
11
- * id: fields.id(),
12
- * name: fields.string({ max: 100 }),
13
- * email: fields.string({ unique: true }),
14
- * age: fields.integer({ nullable: true }),
15
- * score: fields.float({ default: 0.0 }),
16
- * active: fields.boolean({ default: true }),
17
- * bio: fields.text({ nullable: true }),
18
- * data: fields.json({ nullable: true }),
19
- * role: fields.enum(['admin', 'user', 'guest'], { default: 'user' }),
20
- * created_at: fields.timestamp(),
21
- * updated_at: fields.timestamp(),
22
- * };
23
- * }
24
- */
25
-
26
3
  class FieldDefinition {
27
4
  constructor(type, options = {}) {
28
- this.type = type;
29
- this.options = options;
30
- this.nullable = options.nullable ?? false;
31
- this.unique = options.unique ?? false;
32
- this.default = options.default !== undefined ? options.default : undefined;
33
- this.primary = options.primary ?? false;
34
- this.unsigned = options.unsigned ?? false;
35
- this.max = options.max ?? null;
36
- this.enumValues = options.enumValues ?? null;
37
- this.references = options.references ?? null; // { table, column }
5
+ this.type = type;
6
+ this.options = options;
7
+ this.nullable = options.nullable ?? false;
8
+ this.unique = options.unique ?? false;
9
+ this.default = options.default !== undefined ? options.default : undefined;
10
+ this.primary = options.primary ?? false;
11
+ this.unsigned = options.unsigned ?? false;
12
+ this.max = options.max ?? null;
13
+ this.enumValues = options.enumValues ?? null;
14
+ this.references = options.references ?? null;
15
+ this._isForeignKey = options._isForeignKey ?? false;
16
+ this._isOneToOne = options._isOneToOne ?? false;
17
+ this._isManyToMany = options._isManyToMany ?? false;
18
+ this._fkModel = options._fkModel ?? null;
19
+ this._fkModelRef = options._fkModelRef ?? null;
20
+ this._fkToField = options._fkToField ?? 'id';
21
+ this._fkOnDelete = options._fkOnDelete ?? 'CASCADE';
22
+ this._fkRelatedName = options._fkRelatedName ?? null;
23
+ this._m2mThrough = options._m2mThrough ?? null;
38
24
  }
39
25
 
40
- // ─── Fluent modifiers ──────────────────────────────────────────
26
+ nullable_(val = true) { this.nullable = val; return this; }
27
+ unique_(val = true) { this.unique = val; return this; }
28
+ default_(val) { this.default = val; return this; }
29
+ unsigned_(val = true) { this.unsigned = val; return this; }
30
+ references_(table, col) { this.references = { table, column: col }; return this; }
31
+ }
41
32
 
42
- nullable_(val = true) { this.nullable = val; return this; }
43
- unique_(val = true) { this.unique = val; return this; }
44
- default_(val) { this.default = val; return this; }
45
- unsigned_(val = true) { this.unsigned = val; return this; }
46
- references_(table, col) { this.references = { table, column: col }; return this; }
33
+ function _makeModelRef(model) {
34
+ if (typeof model === 'function') return model;
35
+ if (model === 'self') return null;
36
+ return () => {
37
+ const path = require('path');
38
+ const modelsDir = path.join(process.cwd(), 'app', 'models');
39
+ try {
40
+ return require(path.join(modelsDir, model));
41
+ } catch {
42
+ try {
43
+ const fs = require('fs');
44
+ const files = fs.readdirSync(modelsDir);
45
+ const match = files.find(f =>
46
+ f.replace(/\.js$/, '').toLowerCase() === model.toLowerCase()
47
+ );
48
+ if (match) return require(path.join(modelsDir, match));
49
+ } catch {}
50
+ return null;
51
+ }
52
+ };
47
53
  }
48
54
 
49
55
  const fields = {
50
- /** Auto-incrementing primary key */
56
+
51
57
  id(options = {}) {
52
58
  return new FieldDefinition('id', { primary: true, unsigned: true, ...options });
53
59
  },
54
60
 
55
- /** VARCHAR / TEXT string */
56
61
  string(options = {}) {
57
62
  return new FieldDefinition('string', { max: 255, ...options });
58
63
  },
59
64
 
60
- /** LONGTEXT */
61
65
  text(options = {}) {
62
66
  return new FieldDefinition('text', options);
63
67
  },
64
68
 
65
- /** INT */
66
69
  integer(options = {}) {
67
70
  return new FieldDefinition('integer', options);
68
71
  },
69
72
 
70
- /** BIGINT */
71
73
  bigInteger(options = {}) {
72
74
  return new FieldDefinition('bigInteger', options);
73
75
  },
74
76
 
75
- /** FLOAT / DOUBLE */
76
77
  float(options = {}) {
77
78
  return new FieldDefinition('float', options);
78
79
  },
79
80
 
80
- /** DECIMAL(precision, scale) */
81
81
  decimal(precision = 8, scale = 2, options = {}) {
82
82
  return new FieldDefinition('decimal', { precision, scale, ...options });
83
83
  },
84
84
 
85
- /** TINYINT(1) boolean */
86
85
  boolean(options = {}) {
87
86
  return new FieldDefinition('boolean', options);
88
87
  },
89
88
 
90
- /** JSON blob */
91
89
  json(options = {}) {
92
90
  return new FieldDefinition('json', options);
93
91
  },
94
92
 
95
- /** DATE */
96
93
  date(options = {}) {
97
94
  return new FieldDefinition('date', options);
98
95
  },
99
96
 
100
- /** DATETIME / TIMESTAMP */
101
97
  timestamp(options = {}) {
102
98
  return new FieldDefinition('timestamp', { nullable: true, ...options });
103
99
  },
104
100
 
105
- /** ENUM */
106
101
  enum(values, options = {}) {
107
102
  return new FieldDefinition('enum', { enumValues: values, ...options });
108
103
  },
109
104
 
110
- /** UUID */
111
105
  uuid(options = {}) {
112
106
  return new FieldDefinition('uuid', options);
113
107
  },
114
108
 
115
109
  /**
116
- * Foreign key integer shorthand.
117
- * fields.foreignId('user_id') → unsigned integer, references users.id
110
+ * ForeignKey Django-style.
111
+ *
112
+ * Declares the integer column AND wires the BelongsTo relation automatically.
113
+ * No `static relations` block needed.
114
+ *
115
+ * Field name convention:
116
+ * author → accessor: book.author() column: author_id
117
+ * author_id → accessor: book.author() column: author_id
118
+ *
119
+ * @param {string|Function} model 'Author' | () => Author | 'self'
120
+ * @param {object} [opts]
121
+ * @param {boolean} [opts.nullable] allow NULL (default: false)
122
+ * @param {string} [opts.onDelete] CASCADE|SET NULL|RESTRICT|PROTECT|DO_NOTHING (default: CASCADE)
123
+ * @param {string} [opts.relatedName] reverse accessor on target, e.g. 'books' → author.books()
124
+ * pass '+' to suppress the reverse relation
125
+ * @param {string} [opts.toField] target column (default: 'id')
126
+ *
127
+ * @example
128
+ * author: fields.ForeignKey('Author', { onDelete: 'CASCADE', relatedName: 'books' })
129
+ * editor: fields.ForeignKey('User', { nullable: true, onDelete: 'SET NULL' })
130
+ * parent: fields.ForeignKey('self', { nullable: true, relatedName: 'children' })
131
+ */
132
+ ForeignKey(model, opts = {}) {
133
+ return new FieldDefinition('integer', {
134
+ unsigned: true,
135
+ nullable: opts.nullable ?? false,
136
+ _isForeignKey: true,
137
+ _fkModel: model,
138
+ _fkModelRef: _makeModelRef(model),
139
+ _fkToField: opts.toField ?? 'id',
140
+ _fkOnDelete: opts.onDelete ?? 'CASCADE',
141
+ _fkRelatedName: opts.relatedName ?? null,
142
+ });
143
+ },
144
+
145
+ /**
146
+ * OneToOne — unique ForeignKey. Both directions wired automatically.
147
+ *
148
+ * @example
149
+ * user: fields.OneToOne('User', { relatedName: 'profile' })
150
+ * // profile.user() and user.profile() both work
118
151
  */
152
+ OneToOne(model, opts = {}) {
153
+ return new FieldDefinition('integer', {
154
+ unsigned: true,
155
+ unique: true,
156
+ nullable: opts.nullable ?? false,
157
+ _isForeignKey: true,
158
+ _isOneToOne: true,
159
+ _fkModel: model,
160
+ _fkModelRef: _makeModelRef(model),
161
+ _fkToField: opts.toField ?? 'id',
162
+ _fkOnDelete: opts.onDelete ?? 'CASCADE',
163
+ _fkRelatedName: opts.relatedName ?? null,
164
+ });
165
+ },
166
+
167
+ /**
168
+ * ManyToMany — no DB column. Generates pivot table migration.
169
+ * Pivot table auto-named: sorted model names joined with underscore.
170
+ *
171
+ * @example
172
+ * tags: fields.ManyToMany('Tag', { relatedName: 'courses' })
173
+ * tags: fields.ManyToMany('Tag', { through: 'course_tags', relatedName: 'courses' })
174
+ */
175
+ ManyToMany(model, opts = {}) {
176
+ return new FieldDefinition('m2m', {
177
+ nullable: true,
178
+ _isManyToMany: true,
179
+ _fkModel: model,
180
+ _fkModelRef: _makeModelRef(model),
181
+ _fkRelatedName: opts.relatedName ?? null,
182
+ _m2mThrough: opts.through ?? null,
183
+ });
184
+ },
185
+
186
+ /** Legacy — kept for backward compatibility. Prefer ForeignKey(). */
119
187
  foreignId(column, options = {}) {
120
188
  const [table, col] = column.endsWith('_id')
121
189
  ? [column.slice(0, -3) + 's', 'id']
122
190
  : [null, null];
123
191
  return new FieldDefinition('integer', {
124
- unsigned: true,
125
- nullable: options.nullable ?? false,
192
+ unsigned: true,
193
+ nullable: options.nullable ?? false,
126
194
  references: table ? { table, column: col } : null,
127
195
  ...options,
128
196
  });