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.
- package/package.json +3 -2
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +516 -199
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +318 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +393 -97
- package/src/admin/static/admin.css +1422 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +87 -1046
- package/src/admin/views/pages/detail.njk +56 -21
- package/src/admin/views/pages/error.njk +65 -0
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +270 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +480 -0
- package/src/admin/views/partials/form-widget.njk +297 -0
- package/src/admin/views/partials/icons.njk +64 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/ai/AIManager.js +954 -0
- package/src/ai/AITokenBudget.js +250 -0
- package/src/ai/PromptGuard.js +216 -0
- package/src/ai/agents.js +218 -0
- package/src/ai/conversation.js +213 -0
- package/src/ai/drivers.js +734 -0
- package/src/ai/files.js +249 -0
- package/src/ai/media.js +303 -0
- package/src/ai/pricing.js +152 -0
- package/src/ai/provider_tools.js +114 -0
- package/src/ai/types.js +356 -0
- package/src/auth/Auth.js +18 -2
- package/src/auth/AuthUser.js +65 -44
- package/src/cli.js +3 -1
- package/src/commands/createsuperuser.js +267 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +3 -4
- package/src/container/AppInitializer.js +101 -20
- package/src/container/Application.js +31 -1
- package/src/container/MillasApp.js +10 -3
- package/src/container/MillasConfig.js +35 -6
- package/src/core/admin.js +5 -0
- package/src/core/db.js +2 -1
- package/src/core/foundation.js +2 -10
- package/src/core/lang.js +1 -0
- package/src/errors/HttpError.js +32 -16
- package/src/facades/AI.js +411 -0
- package/src/facades/Hash.js +67 -0
- package/src/facades/Process.js +144 -0
- package/src/hashing/Hash.js +262 -0
- package/src/http/HtmlEscape.js +162 -0
- package/src/http/MillasRequest.js +63 -7
- package/src/http/MillasResponse.js +70 -4
- package/src/http/ResponseDispatcher.js +21 -27
- package/src/http/SafeFilePath.js +195 -0
- package/src/http/SafeRedirect.js +62 -0
- package/src/http/SecurityBootstrap.js +70 -0
- package/src/http/helpers.js +40 -125
- package/src/http/index.js +10 -1
- package/src/http/middleware/CsrfMiddleware.js +258 -0
- package/src/http/middleware/RateLimiter.js +314 -0
- package/src/http/middleware/SecurityHeaders.js +281 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +643 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/logger/LogRedactor.js +247 -0
- package/src/logger/Logger.js +1 -1
- package/src/logger/formatters/JsonFormatter.js +11 -4
- package/src/logger/formatters/PrettyFormatter.js +103 -65
- package/src/logger/formatters/SimpleFormatter.js +14 -3
- package/src/middleware/ThrottleMiddleware.js +27 -4
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +143 -74
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/process/Process.js +333 -0
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +40 -5
- package/src/providers/CacheStorageServiceProvider.js +2 -2
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/LogServiceProvider.js +4 -1
- package/src/providers/MailServiceProvider.js +1 -1
- package/src/providers/QueueServiceProvider.js +1 -1
- package/src/router/MiddlewareRegistry.js +27 -2
- package/src/scaffold/templates.js +80 -21
- 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 =
|
|
82
|
-
.filter(f => f.endsWith('.js') && !f.startsWith('.') && f !== 'index.js');
|
|
163
|
+
const files = walkJs(this._modelsPath);
|
|
83
164
|
|
|
84
|
-
for (const
|
|
85
|
-
const
|
|
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
|
-
|
|
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 =
|
|
187
|
+
const candidates = extractClasses(exported);
|
|
100
188
|
|
|
101
189
|
for (const ModelClass of candidates) {
|
|
102
|
-
if (!
|
|
190
|
+
if (!isMillasModel(ModelClass)) continue;
|
|
103
191
|
|
|
104
192
|
const table = this._resolveTable(ModelClass, file);
|
|
105
|
-
|
|
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
|
-
*
|
|
114
|
-
*
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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;
|