millas 0.2.12-beta → 0.2.12-beta-2
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 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- 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 +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -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 +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -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 +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -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/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- 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 +412 -344
- 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/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- package/src/facades/Validation.js +0 -69
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const fs
|
|
4
|
-
const path
|
|
3
|
+
const fs = require('fs-extra');
|
|
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
|
|
@@ -26,267 +28,333 @@ const MillasLog = require('../../logger/internal');
|
|
|
26
28
|
* Developers only touch model files — never migration files directly.
|
|
27
29
|
*/
|
|
28
30
|
class ModelInspector {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Detect changes and generate migration files.
|
|
37
|
-
* Returns { files: string[], message: string }
|
|
38
|
-
*/
|
|
39
|
-
async makeMigrations() {
|
|
40
|
-
const current = this._scanModels();
|
|
41
|
-
const snapshot = this._loadSnapshot();
|
|
42
|
-
const diffs = this._diff(current, snapshot);
|
|
43
|
-
|
|
44
|
-
if (diffs.length === 0) {
|
|
45
|
-
return { files: [], message: 'No changes detected.' };
|
|
31
|
+
constructor(modelsPath, migrationsPath, snapshotPath) {
|
|
32
|
+
this._modelsPath = modelsPath;
|
|
33
|
+
this._migrationsPath = migrationsPath;
|
|
34
|
+
this._snapshotPath = snapshotPath || path.join(process.cwd(), '.millas', 'schema.json');
|
|
46
35
|
}
|
|
47
36
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
};
|
|
58
109
|
}
|
|
59
110
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return { files, message: `Generated ${files.length} migration file(s).` };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ─── Model scanning ───────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Walk app/models/ and return a plain-object schema map:
|
|
71
|
-
* { tableName: { columnName: { type, nullable, … }, … }, … }
|
|
72
|
-
*
|
|
73
|
-
* Handles both default exports (`module.exports = MyModel`) and
|
|
74
|
-
* named exports (`module.exports = { MyModel }`).
|
|
75
|
-
*/
|
|
76
|
-
_scanModels() {
|
|
77
|
-
const schema = {};
|
|
78
|
-
|
|
79
|
-
if (!fs.existsSync(this._modelsPath)) return schema;
|
|
80
|
-
|
|
81
|
-
const files = fs.readdirSync(this._modelsPath)
|
|
82
|
-
.filter(f => f.endsWith('.js') && !f.startsWith('.') && f !== 'index.js');
|
|
83
|
-
|
|
84
|
-
for (const file of files) {
|
|
85
|
-
const fullPath = path.join(this._modelsPath, file);
|
|
86
|
-
|
|
87
|
-
// Always bust require cache so the inspector picks up edits made
|
|
88
|
-
// in the same process (e.g. during tests).
|
|
89
|
-
try {
|
|
90
|
-
delete require.cache[require.resolve(fullPath)];
|
|
91
|
-
} catch { /* path not yet cached — fine */ }
|
|
92
|
-
|
|
93
|
-
let exported;
|
|
94
|
-
try {
|
|
95
|
-
exported = require(fullPath);
|
|
96
|
-
} catch (err) {
|
|
97
|
-
// Skip files that fail to parse / have runtime errors
|
|
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}`);
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Collect every candidate class from the export
|
|
106
|
-
const candidates = this._extractClasses(exported);
|
|
107
|
-
|
|
108
|
-
for (const ModelClass of candidates) {
|
|
109
|
-
if (!this._isMillasModel(ModelClass)) continue;
|
|
110
|
-
|
|
111
|
-
const table = this._resolveTable(ModelClass, file);
|
|
112
|
-
schema[table] = this._extractFields(ModelClass.fields);
|
|
113
|
-
}
|
|
111
|
+
/** Convenience: just the set of system table names. */
|
|
112
|
+
static get SYSTEM_TABLES() {
|
|
113
|
+
return new Set(Object.keys(ModelInspector.SYSTEM_BASELINES));
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Detect changes and generate migration files.
|
|
118
|
+
* Returns { files: string[], message: string }
|
|
119
|
+
*/
|
|
120
|
+
async makeMigrations() {
|
|
121
|
+
const current = this._scanModels();
|
|
122
|
+
const snapshot = this._loadSnapshot();
|
|
123
|
+
const diffs = this._diff(current, snapshot);
|
|
124
|
+
|
|
125
|
+
if (diffs.length === 0) {
|
|
126
|
+
return {files: [], message: 'No changes detected.'};
|
|
127
|
+
}
|
|
118
128
|
|
|
119
|
-
|
|
120
|
-
* Given a module export (class, plain object, or anything), return an
|
|
121
|
-
* array of class/function values that might be Model subclasses.
|
|
122
|
-
*/
|
|
123
|
-
_extractClasses(exported) {
|
|
124
|
-
if (!exported) return [];
|
|
129
|
+
await fs.ensureDir(this._migrationsPath);
|
|
125
130
|
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
// All diffs in this run share the same timestamp prefix so they sort
|
|
132
|
+
// together and apply as a logical group.
|
|
133
|
+
const ts = this._timestamp();
|
|
134
|
+
const files = [];
|
|
128
135
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
for (const diff of diffs) {
|
|
137
|
+
const file = await this._generateMigration(diff, ts);
|
|
138
|
+
if (file) files.push(file);
|
|
139
|
+
}
|
|
133
140
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
* - It is a function (class)
|
|
140
|
-
* - It has a static `fields` property that is a non-null object
|
|
141
|
-
*
|
|
142
|
-
* We intentionally do NOT do `instanceof` checks so the inspector
|
|
143
|
-
* works even when the user imports Model from a different resolution
|
|
144
|
-
* path than the one this file was loaded from.
|
|
145
|
-
*/
|
|
146
|
-
_isMillasModel(cls) {
|
|
147
|
-
if (typeof cls !== 'function') return false;
|
|
148
|
-
if (!cls.fields || typeof cls.fields !== 'object') return false;
|
|
149
|
-
// Must have at least one field
|
|
150
|
-
return Object.keys(cls.fields).length > 0;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Derive the table name from the model class or fall back to the file name.
|
|
155
|
-
*/
|
|
156
|
-
_resolveTable(ModelClass, fileName) {
|
|
157
|
-
// Explicitly set static table = '...'
|
|
158
|
-
if (typeof ModelClass.table === 'string' && ModelClass.table) {
|
|
159
|
-
return ModelClass.table;
|
|
160
|
-
}
|
|
161
|
-
// Convention: file name without extension, pluralised, lowercased
|
|
162
|
-
return fileName.replace(/\.js$/, '').toLowerCase() + 's';
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Convert a fields map (whose values may be FieldDefinition instances or
|
|
167
|
-
* plain objects) into a stable plain-object representation suitable for
|
|
168
|
-
* snapshot storage and deterministic JSON comparison.
|
|
169
|
-
*/
|
|
170
|
-
_extractFields(fields) {
|
|
171
|
-
const result = {};
|
|
172
|
-
|
|
173
|
-
for (const [name, field] of Object.entries(fields)) {
|
|
174
|
-
// Normalise — accept both FieldDefinition instances and plain objects
|
|
175
|
-
result[name] = {
|
|
176
|
-
type: field.type ?? 'string',
|
|
177
|
-
nullable: field.nullable ?? false,
|
|
178
|
-
unique: field.unique ?? false,
|
|
179
|
-
default: field.default !== undefined ? field.default : null,
|
|
180
|
-
max: field.max ?? null,
|
|
181
|
-
unsigned: field.unsigned ?? false,
|
|
182
|
-
enumValues: field.enumValues ?? null,
|
|
183
|
-
references: field.references ?? null,
|
|
184
|
-
precision: field.precision ?? null,
|
|
185
|
-
scale: field.scale ?? null,
|
|
186
|
-
};
|
|
141
|
+
// Persist the new baseline — must happen AFTER generating files so
|
|
142
|
+
// a crash mid-generation doesn't advance the snapshot prematurely.
|
|
143
|
+
this._saveSnapshot(current);
|
|
144
|
+
|
|
145
|
+
return {files, message: `Generated ${files.length} migration file(s).`};
|
|
187
146
|
}
|
|
188
147
|
|
|
189
|
-
|
|
190
|
-
|
|
148
|
+
// ─── Model scanning ───────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Walk app/models/ and return a plain-object schema map:
|
|
152
|
+
* { tableName: { columnName: { type, nullable, … }, … }, … }
|
|
153
|
+
*
|
|
154
|
+
* Handles both default exports (`module.exports = MyModel`) and
|
|
155
|
+
* named exports (`module.exports = { MyModel }`).
|
|
156
|
+
*/
|
|
157
|
+
_scanModels() {
|
|
158
|
+
const schema = {};
|
|
159
|
+
const tableToFile = {}; // track which file owns each table name
|
|
160
|
+
|
|
161
|
+
if (!fs.existsSync(this._modelsPath)) return schema;
|
|
162
|
+
|
|
163
|
+
const files = walkJs(this._modelsPath);
|
|
164
|
+
|
|
165
|
+
for (const fullPath of files) {
|
|
166
|
+
const file = path.basename(fullPath);
|
|
167
|
+
const relPath = path.relative(this._modelsPath, fullPath);
|
|
168
|
+
|
|
169
|
+
// Always bust require cache so the inspector picks up edits made
|
|
170
|
+
// in the same process (e.g. during tests).
|
|
171
|
+
try {
|
|
172
|
+
delete require.cache[require.resolve(fullPath)];
|
|
173
|
+
} catch { /* path not yet cached — fine */ }
|
|
174
|
+
|
|
175
|
+
let exported;
|
|
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
|
+
}
|
|
185
|
+
|
|
186
|
+
// Collect every candidate class from the export
|
|
187
|
+
const candidates = extractClasses(exported);
|
|
188
|
+
|
|
189
|
+
for (const ModelClass of candidates) {
|
|
190
|
+
if (!isMillasModel(ModelClass)) continue;
|
|
191
|
+
|
|
192
|
+
const table = this._resolveTable(ModelClass, file);
|
|
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
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
191
215
|
|
|
192
|
-
|
|
216
|
+
return schema;
|
|
217
|
+
}
|
|
193
218
|
|
|
194
|
-
|
|
195
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Recursively collect all .js files under a directory,
|
|
221
|
+
* excluding dotfiles and index.js at any depth.
|
|
222
|
+
*/
|
|
196
223
|
|
|
197
|
-
// New tables (model added / first run)
|
|
198
|
-
for (const table of Object.keys(current)) {
|
|
199
|
-
if (!snapshot[table]) {
|
|
200
|
-
diffs.push({ type: 'create_table', table, fields: current[table] });
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
224
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
225
|
+
/**
|
|
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.
|
|
229
|
+
*/
|
|
230
|
+
_resolveTable(ModelClass, fileName) {
|
|
231
|
+
return resolveTable(ModelClass) ||
|
|
232
|
+
(fileName ? fileName.replace(/\.js$/, '').toLowerCase() + 's' : null);
|
|
209
233
|
}
|
|
210
234
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Convert a fields map (whose values may be FieldDefinition instances or
|
|
237
|
+
* plain objects) into a stable plain-object representation suitable for
|
|
238
|
+
* snapshot storage and deterministic JSON comparison.
|
|
239
|
+
*/
|
|
240
|
+
_extractFields(fields) {
|
|
241
|
+
// Delegate to normaliseField — single source of truth for field shape.
|
|
242
|
+
const result = {};
|
|
243
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
244
|
+
result[name] = normaliseField(field);
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
217
248
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
249
|
+
// ─── Diffing ──────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
_diff(current, snapshot) {
|
|
252
|
+
const diffs = [];
|
|
253
|
+
|
|
254
|
+
// New tables (model added / first run)
|
|
255
|
+
for (const table of Object.keys(current)) {
|
|
256
|
+
if (!snapshot[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
|
+
}
|
|
267
|
+
}
|
|
222
268
|
}
|
|
223
|
-
}
|
|
224
269
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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.
|
|
273
|
+
for (const table of Object.keys(snapshot)) {
|
|
274
|
+
if (!current[table] && !ModelInspector.SYSTEM_TABLES.has(table)) {
|
|
275
|
+
diffs.push({type: 'drop_table', table, fields: snapshot[table]});
|
|
276
|
+
}
|
|
229
277
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
278
|
+
|
|
279
|
+
// Column-level changes on existing tables
|
|
280
|
+
for (const table of Object.keys(current)) {
|
|
281
|
+
if (!snapshot[table]) continue; // handled above as create_table
|
|
282
|
+
|
|
283
|
+
const curr = current[table];
|
|
284
|
+
const prev = snapshot[table];
|
|
285
|
+
|
|
286
|
+
// Added columns
|
|
287
|
+
for (const col of Object.keys(curr)) {
|
|
288
|
+
if (!prev[col]) {
|
|
289
|
+
diffs.push({type: 'add_column', table, column: col, field: curr[col]});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Removed columns
|
|
294
|
+
for (const col of Object.keys(prev)) {
|
|
295
|
+
if (!curr[col]) {
|
|
296
|
+
diffs.push({type: 'drop_column', table, column: col, field: prev[col]});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Changed columns — compare each attribute individually for stability
|
|
301
|
+
for (const col of Object.keys(curr)) {
|
|
302
|
+
if (!prev[col]) continue; // new column — already handled above
|
|
303
|
+
if (!fieldsEqual(curr[col], prev[col])) {
|
|
304
|
+
diffs.push({
|
|
305
|
+
type: 'alter_column',
|
|
306
|
+
table,
|
|
307
|
+
column: col,
|
|
308
|
+
field: curr[col],
|
|
309
|
+
previous: prev[col],
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
243
313
|
}
|
|
244
|
-
|
|
314
|
+
|
|
315
|
+
return diffs;
|
|
245
316
|
}
|
|
246
317
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Stable field equality check that ignores key-ordering differences
|
|
320
|
+
* which can appear when objects are reconstituted from JSON.
|
|
321
|
+
*/
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
// ─── Migration generation ─────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
async _generateMigration(diff, ts) {
|
|
327
|
+
const name = this._diffToName(diff);
|
|
328
|
+
const fileName = `${ts}_${name}.js`;
|
|
329
|
+
const filePath = path.join(this._migrationsPath, fileName);
|
|
330
|
+
|
|
331
|
+
const content = this._renderMigration(diff, name);
|
|
332
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
333
|
+
return fileName;
|
|
258
334
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
switch (diff.type) {
|
|
276
|
-
case 'create_table': return `create_${diff.table}_table`;
|
|
277
|
-
case 'drop_table': return `drop_${diff.table}_table`;
|
|
278
|
-
case 'add_column': return `add_${diff.column}_to_${diff.table}`;
|
|
279
|
-
case 'drop_column': return `remove_${diff.column}_from_${diff.table}`;
|
|
280
|
-
case 'alter_column': return `alter_${diff.column}_on_${diff.table}`;
|
|
281
|
-
default: return `auto_migration`;
|
|
335
|
+
|
|
336
|
+
_diffToName(diff) {
|
|
337
|
+
switch (diff.type) {
|
|
338
|
+
case 'create_table':
|
|
339
|
+
return `create_${diff.table}_table`;
|
|
340
|
+
case 'drop_table':
|
|
341
|
+
return `drop_${diff.table}_table`;
|
|
342
|
+
case 'add_column':
|
|
343
|
+
return `add_${diff.column}_to_${diff.table}`;
|
|
344
|
+
case 'drop_column':
|
|
345
|
+
return `remove_${diff.column}_from_${diff.table}`;
|
|
346
|
+
case 'alter_column':
|
|
347
|
+
return `alter_${diff.column}_on_${diff.table}`;
|
|
348
|
+
default:
|
|
349
|
+
return `auto_migration`;
|
|
350
|
+
}
|
|
282
351
|
}
|
|
283
|
-
}
|
|
284
352
|
|
|
285
|
-
|
|
286
|
-
|
|
353
|
+
_renderMigration(diff, name) {
|
|
354
|
+
switch (diff.type) {
|
|
287
355
|
|
|
288
|
-
|
|
289
|
-
|
|
356
|
+
case 'create_table':
|
|
357
|
+
return `'use strict';
|
|
290
358
|
|
|
291
359
|
/**
|
|
292
360
|
* Auto-generated migration: ${name}
|
|
@@ -306,8 +374,8 @@ ${this._renderColumns(diff.fields)} });
|
|
|
306
374
|
};
|
|
307
375
|
`;
|
|
308
376
|
|
|
309
|
-
|
|
310
|
-
|
|
377
|
+
case 'drop_table':
|
|
378
|
+
return `'use strict';
|
|
311
379
|
|
|
312
380
|
/**
|
|
313
381
|
* Auto-generated migration: ${name}
|
|
@@ -327,8 +395,8 @@ ${this._renderColumns(diff.fields || {})} });
|
|
|
327
395
|
};
|
|
328
396
|
`;
|
|
329
397
|
|
|
330
|
-
|
|
331
|
-
|
|
398
|
+
case 'add_column':
|
|
399
|
+
return `'use strict';
|
|
332
400
|
|
|
333
401
|
/**
|
|
334
402
|
* Auto-generated migration: ${name}
|
|
@@ -349,8 +417,8 @@ ${this._renderColumn(' ', diff.column, diff.field)}
|
|
|
349
417
|
};
|
|
350
418
|
`;
|
|
351
419
|
|
|
352
|
-
|
|
353
|
-
|
|
420
|
+
case 'drop_column':
|
|
421
|
+
return `'use strict';
|
|
354
422
|
|
|
355
423
|
/**
|
|
356
424
|
* Auto-generated migration: ${name}
|
|
@@ -371,8 +439,8 @@ ${this._renderColumn(' ', diff.column, diff.field)}
|
|
|
371
439
|
};
|
|
372
440
|
`;
|
|
373
441
|
|
|
374
|
-
|
|
375
|
-
|
|
442
|
+
case 'alter_column':
|
|
443
|
+
return `'use strict';
|
|
376
444
|
|
|
377
445
|
/**
|
|
378
446
|
* Auto-generated migration: ${name}
|
|
@@ -394,121 +462,121 @@ ${this._renderColumn(' ', diff.column, diff.previous, '.alter()')}
|
|
|
394
462
|
};
|
|
395
463
|
`;
|
|
396
464
|
|
|
397
|
-
|
|
398
|
-
|
|
465
|
+
default:
|
|
466
|
+
return `'use strict';\nmodule.exports = { async up(db) {}, async down(db) {} };\n`;
|
|
467
|
+
}
|
|
399
468
|
}
|
|
400
|
-
}
|
|
401
469
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
_renderColumn(indent, name, field, suffix = '') {
|
|
412
|
-
let line;
|
|
413
|
-
|
|
414
|
-
switch (field.type) {
|
|
415
|
-
case 'id':
|
|
416
|
-
return `${indent}t.increments('${name}')${suffix};`;
|
|
417
|
-
|
|
418
|
-
case 'string':
|
|
419
|
-
line = `t.string('${name}', ${field.max || 255})`;
|
|
420
|
-
break;
|
|
421
|
-
|
|
422
|
-
case 'text':
|
|
423
|
-
line = `t.text('${name}')`;
|
|
424
|
-
break;
|
|
425
|
-
|
|
426
|
-
case 'integer':
|
|
427
|
-
line = field.unsigned
|
|
428
|
-
? `t.integer('${name}').unsigned()`
|
|
429
|
-
: `t.integer('${name}')`;
|
|
430
|
-
break;
|
|
431
|
-
|
|
432
|
-
case 'bigInteger':
|
|
433
|
-
line = field.unsigned
|
|
434
|
-
? `t.bigInteger('${name}').unsigned()`
|
|
435
|
-
: `t.bigInteger('${name}')`;
|
|
436
|
-
break;
|
|
437
|
-
|
|
438
|
-
case 'float':
|
|
439
|
-
line = `t.float('${name}')`;
|
|
440
|
-
break;
|
|
441
|
-
|
|
442
|
-
case 'decimal':
|
|
443
|
-
line = `t.decimal('${name}', ${field.precision || 8}, ${field.scale || 2})`;
|
|
444
|
-
break;
|
|
445
|
-
|
|
446
|
-
case 'boolean':
|
|
447
|
-
line = `t.boolean('${name}')`;
|
|
448
|
-
break;
|
|
449
|
-
|
|
450
|
-
case 'json':
|
|
451
|
-
line = `t.json('${name}')`;
|
|
452
|
-
break;
|
|
453
|
-
|
|
454
|
-
case 'date':
|
|
455
|
-
line = `t.date('${name}')`;
|
|
456
|
-
break;
|
|
457
|
-
|
|
458
|
-
case 'timestamp':
|
|
459
|
-
line = `t.timestamp('${name}', { useTz: false })`;
|
|
460
|
-
break;
|
|
461
|
-
|
|
462
|
-
case 'enum':
|
|
463
|
-
line = `t.enum('${name}', ${JSON.stringify(field.enumValues || [])})`;
|
|
464
|
-
break;
|
|
465
|
-
|
|
466
|
-
case 'uuid':
|
|
467
|
-
line = `t.uuid('${name}')`;
|
|
468
|
-
break;
|
|
469
|
-
|
|
470
|
-
default:
|
|
471
|
-
line = `t.string('${name}')`;
|
|
470
|
+
_renderColumns(fields) {
|
|
471
|
+
if (!fields || Object.keys(fields).length === 0) {
|
|
472
|
+
return ' t.increments(\'id\');\n t.timestamps();\n';
|
|
473
|
+
}
|
|
474
|
+
return Object.entries(fields)
|
|
475
|
+
.map(([name, field]) => this._renderColumn(' ', name, field))
|
|
476
|
+
.join('\n') + '\n';
|
|
472
477
|
}
|
|
473
478
|
|
|
474
|
-
|
|
475
|
-
|
|
479
|
+
_renderColumn(indent, name, field, suffix = '') {
|
|
480
|
+
let line;
|
|
476
481
|
|
|
477
|
-
|
|
482
|
+
switch (field.type) {
|
|
483
|
+
case 'id':
|
|
484
|
+
return `${indent}t.increments('${name}')${suffix};`;
|
|
478
485
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
486
|
+
case 'string':
|
|
487
|
+
line = `t.string('${name}', ${field.max || 255})`;
|
|
488
|
+
break;
|
|
482
489
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
490
|
+
case 'text':
|
|
491
|
+
line = `t.text('${name}')`;
|
|
492
|
+
break;
|
|
493
|
+
|
|
494
|
+
case 'integer':
|
|
495
|
+
line = field.unsigned
|
|
496
|
+
? `t.integer('${name}').unsigned()`
|
|
497
|
+
: `t.integer('${name}')`;
|
|
498
|
+
break;
|
|
499
|
+
|
|
500
|
+
case 'bigInteger':
|
|
501
|
+
line = field.unsigned
|
|
502
|
+
? `t.bigInteger('${name}').unsigned()`
|
|
503
|
+
: `t.bigInteger('${name}')`;
|
|
504
|
+
break;
|
|
505
|
+
|
|
506
|
+
case 'float':
|
|
507
|
+
line = `t.float('${name}')`;
|
|
508
|
+
break;
|
|
509
|
+
|
|
510
|
+
case 'decimal':
|
|
511
|
+
line = `t.decimal('${name}', ${field.precision || 8}, ${field.scale || 2})`;
|
|
512
|
+
break;
|
|
513
|
+
|
|
514
|
+
case 'boolean':
|
|
515
|
+
line = `t.boolean('${name}')`;
|
|
516
|
+
break;
|
|
517
|
+
|
|
518
|
+
case 'json':
|
|
519
|
+
line = `t.json('${name}')`;
|
|
520
|
+
break;
|
|
521
|
+
|
|
522
|
+
case 'date':
|
|
523
|
+
line = `t.date('${name}')`;
|
|
524
|
+
break;
|
|
525
|
+
|
|
526
|
+
case 'timestamp':
|
|
527
|
+
line = `t.timestamp('${name}', { useTz: false })`;
|
|
528
|
+
break;
|
|
529
|
+
|
|
530
|
+
case 'enum':
|
|
531
|
+
line = `t.enum('${name}', ${JSON.stringify(field.enumValues || [])})`;
|
|
532
|
+
break;
|
|
533
|
+
|
|
534
|
+
case 'uuid':
|
|
535
|
+
line = `t.uuid('${name}')`;
|
|
536
|
+
break;
|
|
488
537
|
|
|
489
|
-
|
|
490
|
-
|
|
538
|
+
default:
|
|
539
|
+
line = `t.string('${name}')`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (field.nullable) line += '.nullable()';
|
|
543
|
+
else if (field.type !== 'id') line += '.notNullable()';
|
|
544
|
+
|
|
545
|
+
if (field.unique) line += '.unique()';
|
|
546
|
+
|
|
547
|
+
if (field.default !== null && field.default !== undefined) {
|
|
548
|
+
line += `.defaultTo(${JSON.stringify(field.default)})`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (field.references) {
|
|
552
|
+
line += `.references('${field.references.column}')` +
|
|
553
|
+
`.inTable('${field.references.table}')` +
|
|
554
|
+
`.onDelete('CASCADE')`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return `${indent}${line}${suffix};`;
|
|
558
|
+
}
|
|
491
559
|
|
|
492
|
-
|
|
560
|
+
// ─── Snapshot ─────────────────────────────────────────────────────────────
|
|
493
561
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
562
|
+
_loadSnapshot() {
|
|
563
|
+
try {
|
|
564
|
+
return fs.readJsonSync(this._snapshotPath);
|
|
565
|
+
} catch {
|
|
566
|
+
return {};
|
|
567
|
+
}
|
|
499
568
|
}
|
|
500
|
-
}
|
|
501
569
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
570
|
+
_saveSnapshot(schema) {
|
|
571
|
+
fs.ensureDirSync(path.dirname(this._snapshotPath));
|
|
572
|
+
fs.writeJsonSync(this._snapshotPath, schema, {spaces: 2});
|
|
573
|
+
}
|
|
506
574
|
|
|
507
|
-
|
|
575
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
508
576
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
577
|
+
_timestamp() {
|
|
578
|
+
return new Date().toISOString().replace(/[-T:.Z]/g, '').slice(0, 14);
|
|
579
|
+
}
|
|
512
580
|
}
|
|
513
581
|
|
|
514
|
-
module.exports = ModelInspector;
|
|
582
|
+
module.exports = ModelInspector;
|