millas 0.2.12-beta-1 → 0.2.13-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 (120) hide show
  1. package/package.json +3 -2
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +516 -199
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +318 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +393 -97
  12. package/src/admin/static/admin.css +1422 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +87 -1046
  19. package/src/admin/views/pages/detail.njk +56 -21
  20. package/src/admin/views/pages/error.njk +65 -0
  21. package/src/admin/views/pages/form.njk +47 -599
  22. package/src/admin/views/pages/list.njk +270 -62
  23. package/src/admin/views/partials/form-field.njk +53 -0
  24. package/src/admin/views/partials/form-footer.njk +28 -0
  25. package/src/admin/views/partials/form-readonly.njk +114 -0
  26. package/src/admin/views/partials/form-scripts.njk +480 -0
  27. package/src/admin/views/partials/form-widget.njk +297 -0
  28. package/src/admin/views/partials/icons.njk +64 -0
  29. package/src/admin/views/partials/json-dialog.njk +80 -0
  30. package/src/admin/views/partials/json-editor.njk +37 -0
  31. package/src/ai/AIManager.js +954 -0
  32. package/src/ai/AITokenBudget.js +250 -0
  33. package/src/ai/PromptGuard.js +216 -0
  34. package/src/ai/agents.js +218 -0
  35. package/src/ai/conversation.js +213 -0
  36. package/src/ai/drivers.js +734 -0
  37. package/src/ai/files.js +249 -0
  38. package/src/ai/media.js +303 -0
  39. package/src/ai/pricing.js +152 -0
  40. package/src/ai/provider_tools.js +114 -0
  41. package/src/ai/types.js +356 -0
  42. package/src/auth/Auth.js +18 -2
  43. package/src/auth/AuthUser.js +65 -44
  44. package/src/cli.js +3 -1
  45. package/src/commands/createsuperuser.js +267 -0
  46. package/src/commands/lang.js +589 -0
  47. package/src/commands/migrate.js +154 -81
  48. package/src/commands/serve.js +3 -4
  49. package/src/container/AppInitializer.js +101 -20
  50. package/src/container/Application.js +31 -1
  51. package/src/container/MillasApp.js +10 -3
  52. package/src/container/MillasConfig.js +35 -6
  53. package/src/core/admin.js +5 -0
  54. package/src/core/db.js +2 -1
  55. package/src/core/foundation.js +2 -10
  56. package/src/core/lang.js +1 -0
  57. package/src/errors/HttpError.js +32 -16
  58. package/src/facades/AI.js +411 -0
  59. package/src/facades/Hash.js +67 -0
  60. package/src/facades/Process.js +144 -0
  61. package/src/hashing/Hash.js +262 -0
  62. package/src/http/HtmlEscape.js +162 -0
  63. package/src/http/MillasRequest.js +63 -7
  64. package/src/http/MillasResponse.js +70 -4
  65. package/src/http/ResponseDispatcher.js +21 -27
  66. package/src/http/SafeFilePath.js +195 -0
  67. package/src/http/SafeRedirect.js +62 -0
  68. package/src/http/SecurityBootstrap.js +70 -0
  69. package/src/http/helpers.js +40 -125
  70. package/src/http/index.js +10 -1
  71. package/src/http/middleware/CsrfMiddleware.js +258 -0
  72. package/src/http/middleware/RateLimiter.js +314 -0
  73. package/src/http/middleware/SecurityHeaders.js +281 -0
  74. package/src/i18n/I18nServiceProvider.js +91 -0
  75. package/src/i18n/Translator.js +643 -0
  76. package/src/i18n/defaults.js +122 -0
  77. package/src/i18n/index.js +164 -0
  78. package/src/i18n/locales/en.js +55 -0
  79. package/src/i18n/locales/sw.js +48 -0
  80. package/src/logger/LogRedactor.js +247 -0
  81. package/src/logger/Logger.js +1 -1
  82. package/src/logger/formatters/JsonFormatter.js +11 -4
  83. package/src/logger/formatters/PrettyFormatter.js +103 -65
  84. package/src/logger/formatters/SimpleFormatter.js +14 -3
  85. package/src/middleware/ThrottleMiddleware.js +27 -4
  86. package/src/migrations/system/0001_users.js +21 -0
  87. package/src/migrations/system/0002_admin_log.js +25 -0
  88. package/src/migrations/system/0003_sessions.js +23 -0
  89. package/src/orm/fields/index.js +210 -188
  90. package/src/orm/migration/DefaultValueParser.js +325 -0
  91. package/src/orm/migration/InteractiveResolver.js +191 -0
  92. package/src/orm/migration/Makemigrations.js +312 -0
  93. package/src/orm/migration/MigrationGraph.js +227 -0
  94. package/src/orm/migration/MigrationRunner.js +202 -108
  95. package/src/orm/migration/MigrationWriter.js +463 -0
  96. package/src/orm/migration/ModelInspector.js +143 -74
  97. package/src/orm/migration/ModelScanner.js +225 -0
  98. package/src/orm/migration/ProjectState.js +213 -0
  99. package/src/orm/migration/RenameDetector.js +175 -0
  100. package/src/orm/migration/SchemaBuilder.js +8 -81
  101. package/src/orm/migration/operations/base.js +57 -0
  102. package/src/orm/migration/operations/column.js +191 -0
  103. package/src/orm/migration/operations/fields.js +252 -0
  104. package/src/orm/migration/operations/index.js +55 -0
  105. package/src/orm/migration/operations/models.js +152 -0
  106. package/src/orm/migration/operations/registry.js +131 -0
  107. package/src/orm/migration/operations/special.js +51 -0
  108. package/src/orm/migration/utils.js +208 -0
  109. package/src/orm/model/Model.js +81 -13
  110. package/src/process/Process.js +333 -0
  111. package/src/providers/AdminServiceProvider.js +66 -9
  112. package/src/providers/AuthServiceProvider.js +40 -5
  113. package/src/providers/CacheStorageServiceProvider.js +2 -2
  114. package/src/providers/DatabaseServiceProvider.js +3 -2
  115. package/src/providers/LogServiceProvider.js +4 -1
  116. package/src/providers/MailServiceProvider.js +1 -1
  117. package/src/providers/QueueServiceProvider.js +1 -1
  118. package/src/router/MiddlewareRegistry.js +27 -2
  119. package/src/scaffold/templates.js +80 -21
  120. package/src/validation/Validator.js +348 -607
@@ -3,6 +3,8 @@
3
3
  const fs = require('fs-extra');
4
4
  const path = require('path');
5
5
  const MillasLog = require('../../logger/internal');
6
+ const { walkJs, extractClasses, isMillasModel, fieldsEqual } = require('./utils');
7
+ const { normaliseField } = require('./ProjectState');
6
8
 
7
9
  /**
8
10
  * ModelInspector
@@ -32,6 +34,85 @@ class ModelInspector {
32
34
  this._snapshotPath = snapshotPath || path.join(process.cwd(), '.millas', 'schema.json');
33
35
  }
34
36
 
37
+ /**
38
+ * The baseline schema that each system migration creates.
39
+ * Keyed by table name → field definitions (same shape as _extractFields output).
40
+ *
41
+ * When makemigrations encounters a system-owned table for the first time
42
+ * (no snapshot entry yet), it seeds the snapshot from this baseline rather
43
+ * than from the current model. That ensures any fields the developer added
44
+ * beyond the baseline are detected as add_column diffs — not silently ignored.
45
+ *
46
+ * Keep in sync with src/migrations/system/000*.js.
47
+ */
48
+ static get SYSTEM_BASELINES() {
49
+ // Lazy-load AuthUser so this file has no hard dependency at module load time.
50
+ // The getter is only called during makeMigrations(), never at require() time.
51
+ const AuthUser = require('../../auth/AuthUser');
52
+ const { fields } = require('../fields/index');
53
+
54
+ const extractFields = (fieldsMap) => {
55
+ const result = {};
56
+ for (const [name, field] of Object.entries(fieldsMap)) {
57
+ result[name] = {
58
+ type: field.type ?? 'string',
59
+ nullable: field.nullable ?? false,
60
+ unique: field.unique ?? false,
61
+ default: field.default !== undefined ? field.default : null,
62
+ max: field.max ?? null,
63
+ unsigned: field.unsigned ?? false,
64
+ enumValues: field.enumValues ?? null,
65
+ references: field.references ?? null,
66
+ precision: field.precision ?? null,
67
+ scale: field.scale ?? null,
68
+ };
69
+ }
70
+ return result;
71
+ };
72
+
73
+ return {
74
+ // system/0001_users.js — mirrors AuthUser.fields exactly
75
+ users: extractFields(AuthUser.fields),
76
+
77
+ // system/0002_admin_log.js
78
+ millas_admin_log: extractFields({
79
+ id: fields.id(),
80
+ user_id: fields.integer({ unsigned: true, nullable: true }),
81
+ user_email: fields.string({ nullable: true }),
82
+ resource: fields.string(),
83
+ record_id: fields.string({ nullable: true }),
84
+ action: fields.enum(['create', 'update', 'delete']),
85
+ label: fields.string({ nullable: true }),
86
+ change_msg: fields.text({ nullable: true }),
87
+ created_at: fields.timestamp(),
88
+ }),
89
+
90
+ // system/0003_sessions.js
91
+ millas_sessions: extractFields({
92
+ session_key: fields.string({ max: 64 }),
93
+ user_id: fields.integer({ unsigned: true }),
94
+ payload: fields.text({ nullable: true }),
95
+ ip_address: fields.string({ max: 45, nullable: true }),
96
+ user_agent: fields.string({ max: 512, nullable: true }),
97
+ expires_at: fields.timestamp(),
98
+ created_at: fields.timestamp(),
99
+ }),
100
+
101
+ // millas_migrations — internal tracking table, not user-accessible
102
+ millas_migrations: extractFields({
103
+ id: fields.id(),
104
+ name: fields.string(),
105
+ pool: fields.string({ max: 20 }),
106
+ batch: fields.integer(),
107
+ }),
108
+ };
109
+ }
110
+
111
+ /** Convenience: just the set of system table names. */
112
+ static get SYSTEM_TABLES() {
113
+ return new Set(Object.keys(ModelInspector.SYSTEM_BASELINES));
114
+ }
115
+
35
116
  /**
36
117
  * Detect changes and generate migration files.
37
118
  * Returns { files: string[], message: string }
@@ -75,34 +156,60 @@ class ModelInspector {
75
156
  */
76
157
  _scanModels() {
77
158
  const schema = {};
159
+ const tableToFile = {}; // track which file owns each table name
78
160
 
79
161
  if (!fs.existsSync(this._modelsPath)) return schema;
80
162
 
81
- const files = fs.readdirSync(this._modelsPath)
82
- .filter(f => f.endsWith('.js') && !f.startsWith('.') && f !== 'index.js');
163
+ const files = walkJs(this._modelsPath);
83
164
 
84
- for (const file of files) {
85
- const fullPath = path.join(this._modelsPath, file);
165
+ for (const fullPath of files) {
166
+ const file = path.basename(fullPath);
167
+ const relPath = path.relative(this._modelsPath, fullPath);
86
168
 
87
169
  // Always bust require cache so the inspector picks up edits made
88
170
  // in the same process (e.g. during tests).
89
171
  try {
90
172
  delete require.cache[require.resolve(fullPath)];
91
- } catch { /* path not yet cached — fine */
92
- }
173
+ } catch { /* path not yet cached — fine */ }
93
174
 
94
175
  let exported;
95
- exported = require(fullPath);
96
-
176
+ try {
177
+ exported = require(fullPath);
178
+ } catch (err) {
179
+ // Surface require errors so developers know why a model was skipped.
180
+ // Common causes: missing dependency, syntax error, bad import path.
181
+ MillasLog.warn(`[makemigrations] Skipping ${path.relative(this._modelsPath, fullPath)}: ${err.message}`);
182
+ process.stderr.write(` ⚠ Could not load model: ${path.relative(this._modelsPath, fullPath)}\n ${err.message}\n`);
183
+ continue;
184
+ }
97
185
 
98
186
  // Collect every candidate class from the export
99
- const candidates = this._extractClasses(exported);
187
+ const candidates = extractClasses(exported);
100
188
 
101
189
  for (const ModelClass of candidates) {
102
- if (!this._isMillasModel(ModelClass)) continue;
190
+ if (!isMillasModel(ModelClass)) continue;
103
191
 
104
192
  const table = this._resolveTable(ModelClass, file);
105
- schema[table] = this._extractFields(ModelClass.fields);
193
+
194
+ if (tableToFile[table] && tableToFile[table] !== relPath) {
195
+ // Same table claimed by two files.
196
+ // This is the inheritance pattern: User extends BaseUser, same table.
197
+ // Keep the one with MORE fields — that's always the most derived class,
198
+ // which has the complete column set for the table.
199
+ const existingFieldCount = Object.keys(schema[table] || {}).length;
200
+ const newFieldCount = Object.keys(ModelClass.fields || {}).length;
201
+
202
+ if (newFieldCount > existingFieldCount) {
203
+ // New class is more derived — replace
204
+ tableToFile[table] = relPath;
205
+ schema[table] = this._extractFields(ModelClass.fields);
206
+ }
207
+ // Otherwise keep the existing (more derived) definition silently.
208
+ // No warning — this is expected when extending a base model.
209
+ } else if (!tableToFile[table]) {
210
+ tableToFile[table] = relPath;
211
+ schema[table] = this._extractFields(ModelClass.fields);
212
+ }
106
213
  }
107
214
  }
108
215
 
@@ -110,49 +217,19 @@ class ModelInspector {
110
217
  }
111
218
 
112
219
  /**
113
- * Given a module export (class, plain object, or anything), return an
114
- * array of class/function values that might be Model subclasses.
220
+ * Recursively collect all .js files under a directory,
221
+ * excluding dotfiles and index.js at any depth.
115
222
  */
116
- _extractClasses(exported) {
117
- if (!exported) return [];
118
223
 
119
- // Direct class export: module.exports = MyModel
120
- if (typeof exported === 'function') return [exported];
121
-
122
- // Named export object: module.exports = { MyModel, AnotherModel }
123
- if (typeof exported === 'object') {
124
- return Object.values(exported).filter(v => typeof v === 'function');
125
- }
126
-
127
- return [];
128
- }
129
-
130
- /**
131
- * A class qualifies as a Millas Model if:
132
- * - It is a function (class)
133
- * - It has a static `fields` property that is a non-null object
134
- *
135
- * We intentionally do NOT do `instanceof` checks so the inspector
136
- * works even when the user imports Model from a different resolution
137
- * path than the one this file was loaded from.
138
- */
139
- _isMillasModel(cls) {
140
- if (typeof cls !== 'function') return false;
141
- if (!cls.fields || typeof cls.fields !== 'object') return false;
142
- // Must have at least one field
143
- return Object.keys(cls.fields).length > 0;
144
- }
145
224
 
146
225
  /**
147
- * Derive the table name from the model class or fall back to the file name.
226
+ * Derive the table name from the model class.
227
+ * Delegates to utils.resolveTable which respects abstract flag and convention.
228
+ * fileName fallback kept for backward compat with old snapshot entries.
148
229
  */
149
230
  _resolveTable(ModelClass, fileName) {
150
- // Explicitly set static table = '...'
151
- if (typeof ModelClass.table === 'string' && ModelClass.table) {
152
- return ModelClass.table;
153
- }
154
- // Convention: file name without extension, pluralised, lowercased
155
- return fileName.replace(/\.js$/, '').toLowerCase() + 's';
231
+ return resolveTable(ModelClass) ||
232
+ (fileName ? fileName.replace(/\.js$/, '').toLowerCase() + 's' : null);
156
233
  }
157
234
 
158
235
  /**
@@ -161,24 +238,11 @@ class ModelInspector {
161
238
  * snapshot storage and deterministic JSON comparison.
162
239
  */
163
240
  _extractFields(fields) {
241
+ // Delegate to normaliseField — single source of truth for field shape.
164
242
  const result = {};
165
-
166
243
  for (const [name, field] of Object.entries(fields)) {
167
- // Normalise — accept both FieldDefinition instances and plain objects
168
- result[name] = {
169
- type: field.type ?? 'string',
170
- nullable: field.nullable ?? false,
171
- unique: field.unique ?? false,
172
- default: field.default !== undefined ? field.default : null,
173
- max: field.max ?? null,
174
- unsigned: field.unsigned ?? false,
175
- enumValues: field.enumValues ?? null,
176
- references: field.references ?? null,
177
- precision: field.precision ?? null,
178
- scale: field.scale ?? null,
179
- };
244
+ result[name] = normaliseField(field);
180
245
  }
181
-
182
246
  return result;
183
247
  }
184
248
 
@@ -190,13 +254,24 @@ class ModelInspector {
190
254
  // New tables (model added / first run)
191
255
  for (const table of Object.keys(current)) {
192
256
  if (!snapshot[table]) {
193
- diffs.push({type: 'create_table', table, fields: current[table]});
257
+ if (ModelInspector.SYSTEM_TABLES.has(table)) {
258
+ // System table — already created by a system migration.
259
+ // Seed the snapshot from the KNOWN SYSTEM BASELINE (what the
260
+ // migration actually created), NOT from the current model.
261
+ // This ensures any extra fields the developer added beyond the
262
+ // baseline are detected as add_column diffs below.
263
+ snapshot[table] = ModelInspector.SYSTEM_BASELINES[table] || current[table];
264
+ } else {
265
+ diffs.push({type: 'create_table', table, fields: current[table]});
266
+ }
194
267
  }
195
268
  }
196
269
 
197
270
  // Dropped tables (model file removed)
271
+ // Never generate drop_table for system tables — they are managed by
272
+ // system migrations, not by user model files.
198
273
  for (const table of Object.keys(snapshot)) {
199
- if (!current[table]) {
274
+ if (!current[table] && !ModelInspector.SYSTEM_TABLES.has(table)) {
200
275
  diffs.push({type: 'drop_table', table, fields: snapshot[table]});
201
276
  }
202
277
  }
@@ -225,7 +300,7 @@ class ModelInspector {
225
300
  // Changed columns — compare each attribute individually for stability
226
301
  for (const col of Object.keys(curr)) {
227
302
  if (!prev[col]) continue; // new column — already handled above
228
- if (!this._fieldsEqual(curr[col], prev[col])) {
303
+ if (!fieldsEqual(curr[col], prev[col])) {
229
304
  diffs.push({
230
305
  type: 'alter_column',
231
306
  table,
@@ -244,13 +319,7 @@ class ModelInspector {
244
319
  * Stable field equality check that ignores key-ordering differences
245
320
  * which can appear when objects are reconstituted from JSON.
246
321
  */
247
- _fieldsEqual(a, b) {
248
- const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
249
- for (const k of keys) {
250
- if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
251
- }
252
- return true;
253
- }
322
+
254
323
 
255
324
  // ─── Migration generation ─────────────────────────────────────────────────
256
325
 
@@ -510,4 +579,4 @@ ${this._renderColumn(' ', diff.column, diff.previous, '.alter()')}
510
579
  }
511
580
  }
512
581
 
513
- module.exports = ModelInspector;
582
+ module.exports = ModelInspector;
@@ -0,0 +1,225 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const { ProjectState, normaliseField } = require('./ProjectState');
6
+ const { walkJs, extractClasses, isMillasModel, resolveTable } = require('./utils');
7
+
8
+ /**
9
+ * ModelScanner
10
+ *
11
+ * Scans model files from disk and builds a ProjectState representing
12
+ * the CURRENT state of the schema as defined in source code.
13
+ *
14
+ * This is the "current state" that makemigrations diffs against the
15
+ * "reconstructed state" from migration files.
16
+ *
17
+ * ── Model loading convention ──────────────────────────────────────────────────
18
+ *
19
+ * Models are loaded from a single entry point — app/models/index.js —
20
+ * which must export all models as named exports:
21
+ *
22
+ * // app/models/index.js
23
+ * module.exports = { User, Post, Comment, Order };
24
+ *
25
+ * This mirrors Django's pattern where each app's models/__init__.py
26
+ * explicitly imports and exposes every model class. The developer
27
+ * decides what is registered — ModelScanner never auto-discovers files.
28
+ *
29
+ * Sub-folders are supported as long as index.js re-exports everything:
30
+ *
31
+ * // app/models/index.js
32
+ * const { User } = require('./auth/User');
33
+ * const { Post } = require('./content/Post');
34
+ * module.exports = { User, Post };
35
+ *
36
+ * ── Inheritance handling ──────────────────────────────────────────────────────
37
+ *
38
+ * Abstract Base Class (static abstract = true):
39
+ * - No table created
40
+ * - Fields merged into every concrete subclass
41
+ *
42
+ * Multi-table Inheritance (different static table from parent):
43
+ * - Parent gets its own table (already in migration history)
44
+ * - Child gets its own table
45
+ * - A OneToOne FK from child → parent is auto-injected
46
+ *
47
+ * Same-table inheritance (child overrides fields, same static table):
48
+ * - Treated as one model — most-derived fields win
49
+ *
50
+ * Proxy model (static proxy = true):
51
+ * - No table created, no migration generated
52
+ */
53
+ class ModelScanner {
54
+ constructor(modelsPath) {
55
+ this._modelsPath = modelsPath;
56
+ }
57
+
58
+ /**
59
+ * Scan all model files and return a ProjectState.
60
+ * Never touches the database.
61
+ */
62
+ scan() {
63
+ const state = new ProjectState();
64
+ const classes = this._loadAllClasses();
65
+
66
+ // Two passes: first register concrete tables, then detect relationships
67
+ const tableToClass = new Map(); // table → most-derived class
68
+
69
+ for (const cls of classes) {
70
+ // Skip abstract and proxy models — no table
71
+ // Only skip if the class itself declares abstract/proxy (not inherited)
72
+ if (cls.hasOwnProperty('abstract') && cls.abstract) continue;
73
+ if (cls.hasOwnProperty('proxy') && cls.proxy) continue;
74
+
75
+ const table = resolveTable(cls);
76
+ if (!table) continue;
77
+
78
+ // Keep the most-derived class for each table
79
+ const existing = tableToClass.get(table);
80
+ if (!existing || this._fieldCount(cls) >= this._fieldCount(existing)) {
81
+ tableToClass.set(table, cls);
82
+ }
83
+ }
84
+
85
+ // Build state from the canonical (most-derived) class per table
86
+ for (const [table, cls] of tableToClass) {
87
+ const fields = this._resolveFields(cls, classes, tableToClass);
88
+ state.createModel(table, fields);
89
+ }
90
+
91
+ return state;
92
+ }
93
+
94
+ // ─── Internal ─────────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Load all model classes from app/models/index.js.
98
+ *
99
+ * The index must export all models as named (or default) exports.
100
+ * ModelScanner never auto-discovers individual files — the developer
101
+ * controls what is registered, exactly like Django's models/__init__.py.
102
+ *
103
+ * Falls back to scanning individual .js files in the models directory
104
+ * only if index.js does not exist, so existing projects without an
105
+ * index.js keep working. A warning is printed to encourage migration.
106
+ */
107
+ _loadAllClasses() {
108
+ if (!fs.existsSync(this._modelsPath)) return [];
109
+
110
+ const indexPath = path.join(this._modelsPath, 'index.js');
111
+
112
+ // ── Primary path: load from index.js ────────────────────────────────
113
+ if (fs.existsSync(indexPath)) {
114
+ try { delete require.cache[require.resolve(indexPath)]; } catch {}
115
+ let exported;
116
+ try {
117
+ exported = require(indexPath);
118
+ } catch (err) {
119
+ process.stderr.write(
120
+ ` ✖ [makemigrations] Failed to load app/models/index.js: ${err.message}\n`
121
+ );
122
+ return [];
123
+ }
124
+ const classes = [];
125
+ const candidates = extractClasses(exported);
126
+ for (const cls of candidates) {
127
+ if (isMillasModel(cls)) classes.push(cls);
128
+ }
129
+ return classes;
130
+ }
131
+
132
+ // ── Fallback: walk individual files (legacy — no index.js yet) ───────
133
+ process.stderr.write(
134
+ ` ⚠ [makemigrations] No app/models/index.js found. ` +
135
+ `Create one that exports all your models to follow the recommended pattern.\n` +
136
+ ` Falling back to file scanning (slower, may miss models in subfolders).\n`
137
+ );
138
+
139
+ const classes = [];
140
+ const files = walkJs(this._modelsPath);
141
+
142
+ for (const fullPath of files) {
143
+ try { delete require.cache[require.resolve(fullPath)]; } catch {}
144
+ let exported;
145
+ try {
146
+ exported = require(fullPath);
147
+ } catch (err) {
148
+ process.stderr.write(
149
+ ` ⚠ [makemigrations] Could not load ${path.relative(this._modelsPath, fullPath)}: ${err.message}\n`
150
+ );
151
+ continue;
152
+ }
153
+ const candidates = extractClasses(exported);
154
+ for (const cls of candidates) {
155
+ if (isMillasModel(cls)) classes.push(cls);
156
+ }
157
+ }
158
+
159
+ return classes;
160
+ }
161
+
162
+
163
+
164
+ _resolveFields(cls, allClasses, tableToClass) {
165
+ const fields = {};
166
+
167
+ // Walk the prototype chain collecting fields, child overrides parent
168
+ const chain = this._inheritanceChain(cls);
169
+
170
+ for (const ancestor of chain.reverse()) {
171
+ // Skip ancestors that have their own table (multi-table inheritance)
172
+ // unless they are the starting class itself
173
+ if (ancestor !== cls) {
174
+ const ancestorTable = resolveTable(ancestor);
175
+ if (ancestorTable && ancestorTable !== resolveTable(cls)) {
176
+ // Multi-table: inject OneToOne FK instead of merging fields
177
+ // The FK column name is `${ancestorTable.replace(/s$/, '')}_ptr`
178
+ const ptrCol = `${ancestorTable.replace(/s$/, '')}_ptr`;
179
+ fields[ptrCol] = normaliseField({
180
+ type: 'integer',
181
+ unsigned: true,
182
+ nullable: false,
183
+ unique: true,
184
+ references: { table: ancestorTable, column: 'id', onDelete: 'CASCADE' },
185
+ });
186
+ continue;
187
+ }
188
+ }
189
+
190
+ // Merge fields from this ancestor (skip abstract flag, just take fields)
191
+ if (ancestor.fields && typeof ancestor.fields === 'object') {
192
+ for (const [name, def] of Object.entries(ancestor.fields)) {
193
+ if (def && typeof def === 'object' && def.type === 'm2m') continue; // skip M2M
194
+ // null = explicit removal, like Django's title = None — remove from merged set
195
+ if (def === null) { delete fields[name]; continue; }
196
+ // Django convention: ForeignKey declared as 'landlord' creates column 'landlord_id'.
197
+ // If already ends with _id, use as-is to avoid double-appending.
198
+ const colName = (def && def._isForeignKey && !name.endsWith('_id'))
199
+ ? name + '_id'
200
+ : name;
201
+ fields[colName] = normaliseField(def);
202
+ }
203
+ }
204
+ }
205
+
206
+ return fields;
207
+ }
208
+
209
+ _inheritanceChain(cls) {
210
+ const chain = [];
211
+ let current = cls;
212
+ while (current && current.fields !== undefined) {
213
+ chain.push(current);
214
+ current = Object.getPrototypeOf(current);
215
+ }
216
+ return chain; // child first, ancestors last
217
+ }
218
+
219
+ _fieldCount(cls) {
220
+ return cls.fields ? Object.keys(cls.fields).length : 0;
221
+ }
222
+
223
+ }
224
+
225
+ module.exports = ModelScanner;