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
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ const MillasRequest = require('../http/MillasRequest');
4
+ const MillasResponse = require('../http/MillasResponse');
5
+ const ResponseDispatcher = require('../http/ResponseDispatcher');
6
+ const RequestContext = require('../http/RequestContext');
7
+
8
+ /**
9
+ * MiddlewareRegistry
10
+ *
11
+ * Maps string aliases → middleware handler classes or functions.
12
+ * Resolves them into Express-compatible functions that wrap with MillasRequest.
13
+ */
14
+ class MiddlewareRegistry {
15
+ constructor(container = null) {
16
+ this._map = {};
17
+ this._container = container;
18
+ }
19
+
20
+ register(alias, handler) {
21
+ this._map[alias] = handler;
22
+ return this;
23
+ }
24
+
25
+ /**
26
+ * Resolve a middleware alias or function into an Express-compatible handler.
27
+ *
28
+ * Millas middleware (class with handle(req, next)):
29
+ * - Receives MillasRequest
30
+ * - Returns MillasResponse or calls next()
31
+ * - Kernel dispatches the MillasResponse if returned
32
+ *
33
+ * Raw Express functions (legacy/escape hatch):
34
+ * - Passed through as-is
35
+ */
36
+ resolve(aliasOrFn) {
37
+ // Raw function — check if it's a Millas middleware class or legacy Express fn
38
+ if (typeof aliasOrFn === 'function') {
39
+ return this._wrapHandler(aliasOrFn);
40
+ }
41
+
42
+ const Handler = this._map[aliasOrFn];
43
+ if (!Handler) {
44
+ throw new Error(`Middleware "${aliasOrFn}" is not registered.`);
45
+ }
46
+
47
+ return this._wrapHandler(Handler);
48
+ }
49
+
50
+ resolveAll(list = []) {
51
+ return list.map(m => this.resolve(m));
52
+ }
53
+
54
+ has(alias) {
55
+ return Object.prototype.hasOwnProperty.call(this._map, alias);
56
+ }
57
+
58
+ all() {
59
+ return { ...this._map }; }
60
+
61
+ // ─── Internal ──────────────────────────────────────────────────────────────
62
+
63
+ _wrapHandler(Handler) {
64
+ // Pre-instantiated Millas middleware object: { handle(req, next) }
65
+ if (typeof Handler === 'object' && Handler !== null &&
66
+ typeof Handler.handle === 'function') {
67
+ return this._buildMillasWrapper(Handler);
68
+ }
69
+
70
+ // Millas middleware class (has handle on prototype, not Express signature)
71
+ if (typeof Handler === 'function' &&
72
+ Handler.prototype &&
73
+ typeof Handler.prototype.handle === 'function') {
74
+ const instance = new Handler();
75
+ return this._buildMillasWrapper(instance);
76
+ }
77
+
78
+ // Legacy raw Express function: (req, res, next) => void
79
+ // Pass through unchanged — developers using old style still work
80
+ if (typeof Handler === 'function') {
81
+ return Handler;
82
+ }
83
+
84
+ throw new Error(`Middleware must be a function or a class with handle().`);
85
+ }
86
+
87
+ /**
88
+ * Build an Express-compatible function from a Millas middleware instance.
89
+ *
90
+ * The middleware's handle(req, next) is called with a MillasRequest.
91
+ * If it returns a MillasResponse, that response is dispatched immediately.
92
+ * If it calls next(), Express continues down the chain.
93
+ */
94
+ _buildMillasWrapper(instance) {
95
+ const container = this._container;
96
+
97
+ return (expressReq, expressRes, expressNext) => {
98
+ const millaReq = new MillasRequest(expressReq);
99
+ const ctx = new RequestContext(millaReq, container);
100
+
101
+ const next = () => {
102
+ expressNext();
103
+ return undefined;
104
+ };
105
+
106
+ new Promise((resolve, reject) => {
107
+ try {
108
+ resolve(instance.handle(ctx, next));
109
+ } catch (err) {
110
+ reject(err);
111
+ }
112
+ })
113
+ .then(value => {
114
+ if (value !== undefined && value !== null && !expressRes.headersSent) {
115
+ const response = MillasResponse.isResponse(value)
116
+ ? value
117
+ : ResponseDispatcher.autoWrap(value);
118
+ ResponseDispatcher.dispatch(response, expressRes);
119
+ }
120
+ })
121
+ .catch(expressNext);
122
+ };
123
+ }
124
+ }
125
+
126
+ 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
  });
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs-extra');
4
- const path = require('path');
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const MillasLog = require('../../logger/internal');
5
6
 
6
7
  /**
7
8
  * ModelInspector
@@ -94,7 +95,10 @@ class ModelInspector {
94
95
  exported = require(fullPath);
95
96
  } catch (err) {
96
97
  // Skip files that fail to parse / have runtime errors
97
- process.stderr.write(` [makemigrations] Skipping ${file}: ${err.message}\n`);
98
+ // Log at WARN level — a skipped model is worth knowing about
99
+ // but shouldn't stop the command. Falls back silently if the
100
+ // logger hasn't been configured yet (e.g. bare CLI usage).
101
+ MillasLog.w('makemigrations', `Skipping ${file}: ${err.message}`);
98
102
  continue;
99
103
  }
100
104
 
@@ -412,7 +412,7 @@ class Model {
412
412
  return new QueryBuilder(this._db(), this).distinct(...cols);
413
413
  }
414
414
 
415
- /** Start an eager-load chain. */
415
+ /** Start an eager-load chain. Relations inferred from fields are included automatically. */
416
416
  static with(...relations) {
417
417
  return new QueryBuilder(this._db(), this).with(...relations);
418
418
  }
@@ -461,15 +461,14 @@ class Model {
461
461
  Object.assign(this, attributes);
462
462
  this._original = { ...attributes };
463
463
 
464
- // Attach lazy relation loaders as callable methods
465
- const relations = this.constructor.relations || {};
464
+ // Use effective relations: explicit static relations PLUS those
465
+ // auto-inferred from ForeignKey / OneToOne / ManyToMany fields.
466
+ const relations = this.constructor._effectiveRelations();
467
+
466
468
  for (const [name, rel] of Object.entries(relations)) {
467
- // Skip if already set by eager load
468
469
  if (!(name in this)) {
469
470
  this[name] = () => rel.load(this);
470
471
  }
471
-
472
- // Attach pivot manager for BelongsToMany
473
472
  const BelongsToMany = require('../relations/BelongsToMany');
474
473
  if (rel instanceof BelongsToMany) {
475
474
  Object.assign(this[name], rel._pivotManager(this));
@@ -477,6 +476,97 @@ class Model {
477
476
  }
478
477
  }
479
478
 
479
+ /**
480
+ * Returns the merged relation map for this model class.
481
+ *
482
+ * Priority (highest → lowest):
483
+ * 1. Explicitly declared `static relations = {}`
484
+ * 2. Auto-inferred from ForeignKey / OneToOne / ManyToMany fields
485
+ *
486
+ * Result is cached per class after first call.
487
+ */
488
+ static _effectiveRelations() {
489
+ if (this._cachedRelations) return this._cachedRelations;
490
+
491
+ const BelongsTo = require('../relations/BelongsTo');
492
+ const HasOne = require('../relations/HasOne');
493
+ const BelongsToMany = require('../relations/BelongsToMany');
494
+
495
+ // Start with explicitly declared relations
496
+ const merged = { ...(this.relations || {}) };
497
+
498
+ for (const [fieldName, fieldDef] of Object.entries(this.fields || {})) {
499
+
500
+ // ── ForeignKey / OneToOne ────────────────────────────────────────────
501
+ if (fieldDef._isForeignKey) {
502
+ // Infer accessor name:
503
+ // author_id → author
504
+ // author → author (column will be author_id in migration)
505
+ const accessorName = fieldName.endsWith('_id')
506
+ ? fieldName.slice(0, -3)
507
+ : fieldName;
508
+
509
+ // Don't overwrite an explicitly declared relation
510
+ if (!merged[accessorName]) {
511
+ const modelRef = fieldDef._fkModelRef;
512
+ const toField = fieldDef._fkToField || 'id';
513
+ const self = this;
514
+
515
+ // self-referential: 'self' means this very model
516
+ const resolveModel = () => {
517
+ if (fieldDef._fkModel === 'self') return self;
518
+ const M = typeof modelRef === 'function' ? modelRef() : modelRef;
519
+ return M;
520
+ };
521
+
522
+ if (fieldDef._isOneToOne) {
523
+ // OneToOne: BelongsTo on the declaring side
524
+ merged[accessorName] = new BelongsTo(resolveModel, fieldName.endsWith('_id') ? fieldName : fieldName + '_id', toField);
525
+ } else {
526
+ merged[accessorName] = new BelongsTo(resolveModel, fieldName.endsWith('_id') ? fieldName : fieldName + '_id', toField);
527
+ }
528
+ }
529
+ }
530
+
531
+ // ── ManyToMany ────────────────────────────────────────────────────────
532
+ if (fieldDef._isManyToMany && !merged[fieldName]) {
533
+ const thisTableBase = (this.table || this.name.toLowerCase()).replace(/s$/, '');
534
+ const modelRef = fieldDef._fkModelRef;
535
+
536
+ const resolveRelated = () => {
537
+ const M = typeof modelRef === 'function' ? modelRef() : modelRef;
538
+ return M;
539
+ };
540
+
541
+ // Infer pivot table: sort both singular table names alphabetically
542
+ const relatedName = typeof fieldDef._fkModel === 'string'
543
+ ? fieldDef._fkModel.toLowerCase().replace(/s$/, '')
544
+ : fieldName.replace(/s$/, '');
545
+
546
+ const pivotTable = fieldDef._m2mThrough
547
+ || [thisTableBase, relatedName].sort().join('_') + 's';
548
+
549
+ const thisFk = thisTableBase + '_id';
550
+ const relatedFk = relatedName + '_id';
551
+
552
+ merged[fieldName] = new BelongsToMany(
553
+ resolveRelated,
554
+ pivotTable,
555
+ thisFk,
556
+ relatedFk,
557
+ );
558
+ }
559
+ }
560
+
561
+ this._cachedRelations = merged;
562
+ return merged;
563
+ }
564
+
565
+ /** Clear the cached relations (call if fields are modified at runtime). */
566
+ static _clearRelationCache() {
567
+ this._cachedRelations = null;
568
+ }
569
+
480
570
  /**
481
571
  * Persist changes to this instance.
482
572
  * @param {object} data