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.
Files changed (116) hide show
  1. package/package.json +3 -16
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +400 -167
  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 +309 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +383 -97
  12. package/src/admin/static/admin.css +1341 -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 +65 -1013
  19. package/src/admin/views/pages/detail.njk +40 -16
  20. package/src/admin/views/pages/form.njk +47 -599
  21. package/src/admin/views/pages/list.njk +145 -62
  22. package/src/admin/views/partials/form-field.njk +53 -0
  23. package/src/admin/views/partials/form-footer.njk +28 -0
  24. package/src/admin/views/partials/form-readonly.njk +114 -0
  25. package/src/admin/views/partials/form-scripts.njk +476 -0
  26. package/src/admin/views/partials/form-widget.njk +296 -0
  27. package/src/admin/views/partials/json-dialog.njk +80 -0
  28. package/src/admin/views/partials/json-editor.njk +37 -0
  29. package/src/admin.zip +0 -0
  30. package/src/auth/Auth.js +31 -10
  31. package/src/auth/AuthController.js +3 -1
  32. package/src/auth/AuthUser.js +119 -0
  33. package/src/cli.js +4 -2
  34. package/src/commands/createsuperuser.js +254 -0
  35. package/src/commands/lang.js +589 -0
  36. package/src/commands/migrate.js +154 -81
  37. package/src/commands/serve.js +82 -110
  38. package/src/container/AppInitializer.js +215 -0
  39. package/src/container/Application.js +278 -253
  40. package/src/container/HttpServer.js +156 -0
  41. package/src/container/MillasApp.js +29 -279
  42. package/src/container/MillasConfig.js +192 -0
  43. package/src/core/admin.js +5 -0
  44. package/src/core/auth.js +9 -0
  45. package/src/core/db.js +9 -0
  46. package/src/core/foundation.js +59 -0
  47. package/src/core/http.js +11 -0
  48. package/src/core/lang.js +1 -0
  49. package/src/core/mail.js +6 -0
  50. package/src/core/queue.js +7 -0
  51. package/src/core/validation.js +29 -0
  52. package/src/facades/Admin.js +1 -1
  53. package/src/facades/Auth.js +22 -39
  54. package/src/facades/Cache.js +21 -10
  55. package/src/facades/Database.js +1 -1
  56. package/src/facades/Events.js +18 -17
  57. package/src/facades/Facade.js +197 -0
  58. package/src/facades/Http.js +42 -45
  59. package/src/facades/Log.js +25 -49
  60. package/src/facades/Mail.js +27 -32
  61. package/src/facades/Queue.js +22 -15
  62. package/src/facades/Storage.js +18 -10
  63. package/src/facades/Url.js +53 -0
  64. package/src/http/HttpClient.js +673 -0
  65. package/src/http/ResponseDispatcher.js +18 -111
  66. package/src/http/UrlGenerator.js +375 -0
  67. package/src/http/WelcomePage.js +273 -0
  68. package/src/http/adapters/ExpressAdapter.js +315 -0
  69. package/src/http/adapters/HttpAdapter.js +168 -0
  70. package/src/http/adapters/index.js +9 -0
  71. package/src/i18n/I18nServiceProvider.js +91 -0
  72. package/src/i18n/Translator.js +635 -0
  73. package/src/i18n/defaults.js +122 -0
  74. package/src/i18n/index.js +164 -0
  75. package/src/i18n/locales/en.js +55 -0
  76. package/src/i18n/locales/sw.js +48 -0
  77. package/src/index.js +5 -144
  78. package/src/logger/formatters/PrettyFormatter.js +103 -57
  79. package/src/logger/internal.js +2 -2
  80. package/src/logger/patchConsole.js +91 -81
  81. package/src/middleware/MiddlewareRegistry.js +62 -82
  82. package/src/migrations/system/0001_users.js +21 -0
  83. package/src/migrations/system/0002_admin_log.js +25 -0
  84. package/src/migrations/system/0003_sessions.js +23 -0
  85. package/src/orm/fields/index.js +210 -188
  86. package/src/orm/migration/DefaultValueParser.js +325 -0
  87. package/src/orm/migration/InteractiveResolver.js +191 -0
  88. package/src/orm/migration/Makemigrations.js +312 -0
  89. package/src/orm/migration/MigrationGraph.js +227 -0
  90. package/src/orm/migration/MigrationRunner.js +202 -108
  91. package/src/orm/migration/MigrationWriter.js +463 -0
  92. package/src/orm/migration/ModelInspector.js +412 -344
  93. package/src/orm/migration/ModelScanner.js +225 -0
  94. package/src/orm/migration/ProjectState.js +213 -0
  95. package/src/orm/migration/RenameDetector.js +175 -0
  96. package/src/orm/migration/SchemaBuilder.js +8 -81
  97. package/src/orm/migration/operations/base.js +57 -0
  98. package/src/orm/migration/operations/column.js +191 -0
  99. package/src/orm/migration/operations/fields.js +252 -0
  100. package/src/orm/migration/operations/index.js +55 -0
  101. package/src/orm/migration/operations/models.js +152 -0
  102. package/src/orm/migration/operations/registry.js +131 -0
  103. package/src/orm/migration/operations/special.js +51 -0
  104. package/src/orm/migration/utils.js +208 -0
  105. package/src/orm/model/Model.js +81 -13
  106. package/src/providers/AdminServiceProvider.js +66 -9
  107. package/src/providers/AuthServiceProvider.js +46 -7
  108. package/src/providers/CacheStorageServiceProvider.js +5 -3
  109. package/src/providers/DatabaseServiceProvider.js +3 -2
  110. package/src/providers/EventServiceProvider.js +2 -1
  111. package/src/providers/LogServiceProvider.js +7 -3
  112. package/src/providers/MailServiceProvider.js +4 -3
  113. package/src/providers/QueueServiceProvider.js +4 -3
  114. package/src/router/Router.js +119 -152
  115. package/src/scaffold/templates.js +83 -26
  116. package/src/facades/Validation.js +0 -69
@@ -1,8 +1,10 @@
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
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
- constructor(modelsPath, migrationsPath, snapshotPath) {
30
- this._modelsPath = modelsPath;
31
- this._migrationsPath = migrationsPath;
32
- this._snapshotPath = snapshotPath || path.join(process.cwd(), '.millas', 'schema.json');
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
- await fs.ensureDir(this._migrationsPath);
49
-
50
- // All diffs in this run share the same timestamp prefix so they sort
51
- // together and apply as a logical group.
52
- const ts = this._timestamp();
53
- const files = [];
54
-
55
- for (const diff of diffs) {
56
- const file = await this._generateMigration(diff, ts);
57
- if (file) files.push(file);
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
- // Persist the new baseline must happen AFTER generating files so
61
- // a crash mid-generation doesn't advance the snapshot prematurely.
62
- this._saveSnapshot(current);
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
- return schema;
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
- // Direct class export: module.exports = MyModel
127
- if (typeof exported === 'function') return [exported];
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
- // Named export object: module.exports = { MyModel, AnotherModel }
130
- if (typeof exported === 'object') {
131
- return Object.values(exported).filter(v => typeof v === 'function');
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
- return [];
135
- }
136
-
137
- /**
138
- * A class qualifies as a Millas Model if:
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
- return result;
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
- // ─── Diffing ──────────────────────────────────────────────────────────────
216
+ return schema;
217
+ }
193
218
 
194
- _diff(current, snapshot) {
195
- const diffs = [];
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
- // Dropped tables (model file removed)
205
- for (const table of Object.keys(snapshot)) {
206
- if (!current[table]) {
207
- diffs.push({ type: 'drop_table', table, fields: snapshot[table] });
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
- // Column-level changes on existing tables
212
- for (const table of Object.keys(current)) {
213
- if (!snapshot[table]) continue; // handled above as create_table
214
-
215
- const curr = current[table];
216
- const prev = snapshot[table];
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
- // Added columns
219
- for (const col of Object.keys(curr)) {
220
- if (!prev[col]) {
221
- diffs.push({ type: 'add_column', table, column: col, field: curr[col] });
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
- // Removed columns
226
- for (const col of Object.keys(prev)) {
227
- if (!curr[col]) {
228
- diffs.push({ type: 'drop_column', table, column: col, field: prev[col] });
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
- // Changed columns compare each attribute individually for stability
233
- for (const col of Object.keys(curr)) {
234
- if (!prev[col]) continue; // new column — already handled above
235
- if (!this._fieldsEqual(curr[col], prev[col])) {
236
- diffs.push({
237
- type: 'alter_column',
238
- table,
239
- column: col,
240
- field: curr[col],
241
- previous: prev[col],
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
- return diffs;
248
- }
249
-
250
- /**
251
- * Stable field equality check that ignores key-ordering differences
252
- * which can appear when objects are reconstituted from JSON.
253
- */
254
- _fieldsEqual(a, b) {
255
- const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
256
- for (const k of keys) {
257
- if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
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
- return true;
260
- }
261
-
262
- // ─── Migration generation ─────────────────────────────────────────────────
263
-
264
- async _generateMigration(diff, ts) {
265
- const name = this._diffToName(diff);
266
- const fileName = `${ts}_${name}.js`;
267
- const filePath = path.join(this._migrationsPath, fileName);
268
-
269
- const content = this._renderMigration(diff, name);
270
- await fs.writeFile(filePath, content, 'utf8');
271
- return fileName;
272
- }
273
-
274
- _diffToName(diff) {
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
- _renderMigration(diff, name) {
286
- switch (diff.type) {
353
+ _renderMigration(diff, name) {
354
+ switch (diff.type) {
287
355
 
288
- case 'create_table':
289
- return `'use strict';
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
- case 'drop_table':
310
- return `'use strict';
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
- case 'add_column':
331
- return `'use strict';
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
- case 'drop_column':
353
- return `'use strict';
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
- case 'alter_column':
375
- return `'use strict';
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
- default:
398
- return `'use strict';\nmodule.exports = { async up(db) {}, async down(db) {} };\n`;
465
+ default:
466
+ return `'use strict';\nmodule.exports = { async up(db) {}, async down(db) {} };\n`;
467
+ }
399
468
  }
400
- }
401
469
 
402
- _renderColumns(fields) {
403
- if (!fields || Object.keys(fields).length === 0) {
404
- return ' t.increments(\'id\');\n t.timestamps();\n';
405
- }
406
- return Object.entries(fields)
407
- .map(([name, field]) => this._renderColumn(' ', name, field))
408
- .join('\n') + '\n';
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
- if (field.nullable) line += '.nullable()';
475
- else if (field.type !== 'id') line += '.notNullable()';
479
+ _renderColumn(indent, name, field, suffix = '') {
480
+ let line;
476
481
 
477
- if (field.unique) line += '.unique()';
482
+ switch (field.type) {
483
+ case 'id':
484
+ return `${indent}t.increments('${name}')${suffix};`;
478
485
 
479
- if (field.default !== null && field.default !== undefined) {
480
- line += `.defaultTo(${JSON.stringify(field.default)})`;
481
- }
486
+ case 'string':
487
+ line = `t.string('${name}', ${field.max || 255})`;
488
+ break;
482
489
 
483
- if (field.references) {
484
- line += `.references('${field.references.column}')` +
485
- `.inTable('${field.references.table}')` +
486
- `.onDelete('CASCADE')`;
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
- return `${indent}${line}${suffix};`;
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
- // ─── Snapshot ─────────────────────────────────────────────────────────────
560
+ // ─── Snapshot ─────────────────────────────────────────────────────────────
493
561
 
494
- _loadSnapshot() {
495
- try {
496
- return fs.readJsonSync(this._snapshotPath);
497
- } catch {
498
- return {};
562
+ _loadSnapshot() {
563
+ try {
564
+ return fs.readJsonSync(this._snapshotPath);
565
+ } catch {
566
+ return {};
567
+ }
499
568
  }
500
- }
501
569
 
502
- _saveSnapshot(schema) {
503
- fs.ensureDirSync(path.dirname(this._snapshotPath));
504
- fs.writeJsonSync(this._snapshotPath, schema, { spaces: 2 });
505
- }
570
+ _saveSnapshot(schema) {
571
+ fs.ensureDirSync(path.dirname(this._snapshotPath));
572
+ fs.writeJsonSync(this._snapshotPath, schema, {spaces: 2});
573
+ }
506
574
 
507
- // ─── Helpers ──────────────────────────────────────────────────────────────
575
+ // ─── Helpers ──────────────────────────────────────────────────────────────
508
576
 
509
- _timestamp() {
510
- return new Date().toISOString().replace(/[-T:.Z]/g, '').slice(0, 14);
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;