millas 0.1.7 → 0.1.9

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.
@@ -6,21 +6,23 @@ const path = require('path');
6
6
  /**
7
7
  * ModelInspector
8
8
  *
9
- * Implements `millas makemigrations` — Django-style migration generation.
9
+ * Implements `millas makemigrations` — Django-style auto-migration generation.
10
10
  *
11
11
  * Workflow:
12
- * 1. Scan app/models/ for Model subclasses with static fields
12
+ * 1. Scan app/models/ for Model subclasses that have static `fields`
13
13
  * 2. Load the last known schema snapshot (.millas/schema.json)
14
14
  * 3. Diff current fields vs snapshot
15
- * 4. Generate timestamped migration file(s) for each change
16
- * 5. Update the snapshot
15
+ * 4. Generate timestamped migration file(s) for each detected change
16
+ * 5. Update the snapshot so the next run starts clean
17
17
  *
18
18
  * Detects:
19
- * - New tables (model added)
20
- * - Dropped tables (model removed)
19
+ * - New tables (model file added)
20
+ * - Dropped tables (model file removed)
21
21
  * - Added columns
22
22
  * - Removed columns
23
- * - Changed column types
23
+ * - Changed column attributes (type, nullable, unique, default, …)
24
+ *
25
+ * Developers only touch model files — never migration files directly.
24
26
  */
25
27
  class ModelInspector {
26
28
  constructor(modelsPath, migrationsPath, snapshotPath) {
@@ -31,7 +33,7 @@ class ModelInspector {
31
33
 
32
34
  /**
33
35
  * Detect changes and generate migration files.
34
- * Returns an array of generated file paths.
36
+ * Returns { files: string[], message: string }
35
37
  */
36
38
  async makeMigrations() {
37
39
  const current = this._scanModels();
@@ -42,54 +44,144 @@ class ModelInspector {
42
44
  return { files: [], message: 'No changes detected.' };
43
45
  }
44
46
 
47
+ await fs.ensureDir(this._migrationsPath);
48
+
49
+ // All diffs in this run share the same timestamp prefix so they sort
50
+ // together and apply as a logical group.
51
+ const ts = this._timestamp();
45
52
  const files = [];
53
+
46
54
  for (const diff of diffs) {
47
- const file = await this._generateMigration(diff);
55
+ const file = await this._generateMigration(diff, ts);
48
56
  if (file) files.push(file);
49
57
  }
50
58
 
59
+ // Persist the new baseline — must happen AFTER generating files so
60
+ // a crash mid-generation doesn't advance the snapshot prematurely.
51
61
  this._saveSnapshot(current);
62
+
52
63
  return { files, message: `Generated ${files.length} migration file(s).` };
53
64
  }
54
65
 
55
66
  // ─── Model scanning ───────────────────────────────────────────────────────
56
67
 
68
+ /**
69
+ * Walk app/models/ and return a plain-object schema map:
70
+ * { tableName: { columnName: { type, nullable, … }, … }, … }
71
+ *
72
+ * Handles both default exports (`module.exports = MyModel`) and
73
+ * named exports (`module.exports = { MyModel }`).
74
+ */
57
75
  _scanModels() {
58
76
  const schema = {};
77
+
59
78
  if (!fs.existsSync(this._modelsPath)) return schema;
60
79
 
61
80
  const files = fs.readdirSync(this._modelsPath)
62
- .filter(f => f.endsWith('.js') && !f.startsWith('.'));
81
+ .filter(f => f.endsWith('.js') && !f.startsWith('.') && f !== 'index.js');
63
82
 
64
83
  for (const file of files) {
84
+ const fullPath = path.join(this._modelsPath, file);
85
+
86
+ // Always bust require cache so the inspector picks up edits made
87
+ // in the same process (e.g. during tests).
65
88
  try {
66
- const ModelClass = require(path.join(this._modelsPath, file));
67
- if (!ModelClass || !ModelClass.fields) continue;
89
+ delete require.cache[require.resolve(fullPath)];
90
+ } catch { /* path not yet cached — fine */ }
68
91
 
69
- const table = ModelClass.table || file.replace('.js', '').toLowerCase() + 's';
92
+ let exported;
93
+ try {
94
+ exported = require(fullPath);
95
+ } catch (err) {
96
+ // Skip files that fail to parse / have runtime errors
97
+ process.stderr.write(` [makemigrations] Skipping ${file}: ${err.message}\n`);
98
+ continue;
99
+ }
100
+
101
+ // Collect every candidate class from the export
102
+ const candidates = this._extractClasses(exported);
103
+
104
+ for (const ModelClass of candidates) {
105
+ if (!this._isMillasModel(ModelClass)) continue;
106
+
107
+ const table = this._resolveTable(ModelClass, file);
70
108
  schema[table] = this._extractFields(ModelClass.fields);
71
- } catch {
72
- // Skip unloadable models
73
109
  }
74
110
  }
75
111
 
76
112
  return schema;
77
113
  }
78
114
 
115
+ /**
116
+ * Given a module export (class, plain object, or anything), return an
117
+ * array of class/function values that might be Model subclasses.
118
+ */
119
+ _extractClasses(exported) {
120
+ if (!exported) return [];
121
+
122
+ // Direct class export: module.exports = MyModel
123
+ if (typeof exported === 'function') return [exported];
124
+
125
+ // Named export object: module.exports = { MyModel, AnotherModel }
126
+ if (typeof exported === 'object') {
127
+ return Object.values(exported).filter(v => typeof v === 'function');
128
+ }
129
+
130
+ return [];
131
+ }
132
+
133
+ /**
134
+ * A class qualifies as a Millas Model if:
135
+ * - It is a function (class)
136
+ * - It has a static `fields` property that is a non-null object
137
+ *
138
+ * We intentionally do NOT do `instanceof` checks so the inspector
139
+ * works even when the user imports Model from a different resolution
140
+ * path than the one this file was loaded from.
141
+ */
142
+ _isMillasModel(cls) {
143
+ if (typeof cls !== 'function') return false;
144
+ if (!cls.fields || typeof cls.fields !== 'object') return false;
145
+ // Must have at least one field
146
+ return Object.keys(cls.fields).length > 0;
147
+ }
148
+
149
+ /**
150
+ * Derive the table name from the model class or fall back to the file name.
151
+ */
152
+ _resolveTable(ModelClass, fileName) {
153
+ // Explicitly set static table = '...'
154
+ if (typeof ModelClass.table === 'string' && ModelClass.table) {
155
+ return ModelClass.table;
156
+ }
157
+ // Convention: file name without extension, pluralised, lowercased
158
+ return fileName.replace(/\.js$/, '').toLowerCase() + 's';
159
+ }
160
+
161
+ /**
162
+ * Convert a fields map (whose values may be FieldDefinition instances or
163
+ * plain objects) into a stable plain-object representation suitable for
164
+ * snapshot storage and deterministic JSON comparison.
165
+ */
79
166
  _extractFields(fields) {
80
167
  const result = {};
168
+
81
169
  for (const [name, field] of Object.entries(fields)) {
170
+ // Normalise — accept both FieldDefinition instances and plain objects
82
171
  result[name] = {
83
- type: field.type,
84
- nullable: field.nullable ?? false,
85
- unique: field.unique ?? false,
86
- default: field.default !== undefined ? field.default : null,
87
- max: field.max ?? null,
88
- unsigned: field.unsigned ?? false,
89
- enumValues: field.enumValues ?? null,
90
- references: field.references ?? null,
172
+ type: field.type ?? 'string',
173
+ nullable: field.nullable ?? false,
174
+ unique: field.unique ?? false,
175
+ default: field.default !== undefined ? field.default : null,
176
+ max: field.max ?? null,
177
+ unsigned: field.unsigned ?? false,
178
+ enumValues: field.enumValues ?? null,
179
+ references: field.references ?? null,
180
+ precision: field.precision ?? null,
181
+ scale: field.scale ?? null,
91
182
  };
92
183
  }
184
+
93
185
  return result;
94
186
  }
95
187
 
@@ -98,23 +190,23 @@ class ModelInspector {
98
190
  _diff(current, snapshot) {
99
191
  const diffs = [];
100
192
 
101
- // New tables
193
+ // New tables (model added / first run)
102
194
  for (const table of Object.keys(current)) {
103
195
  if (!snapshot[table]) {
104
196
  diffs.push({ type: 'create_table', table, fields: current[table] });
105
197
  }
106
198
  }
107
199
 
108
- // Dropped tables
200
+ // Dropped tables (model file removed)
109
201
  for (const table of Object.keys(snapshot)) {
110
202
  if (!current[table]) {
111
- diffs.push({ type: 'drop_table', table });
203
+ diffs.push({ type: 'drop_table', table, fields: snapshot[table] });
112
204
  }
113
205
  }
114
206
 
115
- // Column changes on existing tables
207
+ // Column-level changes on existing tables
116
208
  for (const table of Object.keys(current)) {
117
- if (!snapshot[table]) continue;
209
+ if (!snapshot[table]) continue; // handled above as create_table
118
210
 
119
211
  const curr = current[table];
120
212
  const prev = snapshot[table];
@@ -129,15 +221,21 @@ class ModelInspector {
129
221
  // Removed columns
130
222
  for (const col of Object.keys(prev)) {
131
223
  if (!curr[col]) {
132
- diffs.push({ type: 'drop_column', table, column: col });
224
+ diffs.push({ type: 'drop_column', table, column: col, field: prev[col] });
133
225
  }
134
226
  }
135
227
 
136
- // Changed columns
228
+ // Changed columns — compare each attribute individually for stability
137
229
  for (const col of Object.keys(curr)) {
138
- if (!prev[col]) continue;
139
- if (JSON.stringify(curr[col]) !== JSON.stringify(prev[col])) {
140
- diffs.push({ type: 'alter_column', table, column: col, field: curr[col], previous: prev[col] });
230
+ if (!prev[col]) continue; // new column — already handled above
231
+ if (!this._fieldsEqual(curr[col], prev[col])) {
232
+ diffs.push({
233
+ type: 'alter_column',
234
+ table,
235
+ column: col,
236
+ field: curr[col],
237
+ previous: prev[col],
238
+ });
141
239
  }
142
240
  }
143
241
  }
@@ -145,16 +243,25 @@ class ModelInspector {
145
243
  return diffs;
146
244
  }
147
245
 
246
+ /**
247
+ * Stable field equality check that ignores key-ordering differences
248
+ * which can appear when objects are reconstituted from JSON.
249
+ */
250
+ _fieldsEqual(a, b) {
251
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
252
+ for (const k of keys) {
253
+ if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
254
+ }
255
+ return true;
256
+ }
257
+
148
258
  // ─── Migration generation ─────────────────────────────────────────────────
149
259
 
150
- async _generateMigration(diff) {
260
+ async _generateMigration(diff, ts) {
151
261
  const name = this._diffToName(diff);
152
- const ts = new Date().toISOString().replace(/[-T:.Z]/g, '').slice(0, 14);
153
262
  const fileName = `${ts}_${name}.js`;
154
263
  const filePath = path.join(this._migrationsPath, fileName);
155
264
 
156
- await fs.ensureDir(this._migrationsPath);
157
-
158
265
  const content = this._renderMigration(diff, name);
159
266
  await fs.writeFile(filePath, content, 'utf8');
160
267
  return fileName;
@@ -167,7 +274,7 @@ class ModelInspector {
167
274
  case 'add_column': return `add_${diff.column}_to_${diff.table}`;
168
275
  case 'drop_column': return `remove_${diff.column}_from_${diff.table}`;
169
276
  case 'alter_column': return `alter_${diff.column}_on_${diff.table}`;
170
- default: return `migration_${diff.type}`;
277
+ default: return `auto_migration`;
171
278
  }
172
279
  }
173
280
 
@@ -178,8 +285,10 @@ class ModelInspector {
178
285
  return `'use strict';
179
286
 
180
287
  /**
181
- * Migration: ${name}
182
- * Auto-generated by: millas makemigrations
288
+ * Auto-generated migration: ${name}
289
+ * Created by: millas makemigrations
290
+ *
291
+ * DO NOT EDIT — changes to your model will generate a new migration.
183
292
  */
184
293
  module.exports = {
185
294
  async up(db) {
@@ -197,7 +306,8 @@ ${this._renderColumns(diff.fields)} });
197
306
  return `'use strict';
198
307
 
199
308
  /**
200
- * Migration: ${name}
309
+ * Auto-generated migration: ${name}
310
+ * Created by: millas makemigrations
201
311
  */
202
312
  module.exports = {
203
313
  async up(db) {
@@ -205,11 +315,10 @@ module.exports = {
205
315
  },
206
316
 
207
317
  async down(db) {
208
- // Recreate if neededadd column definitions here
318
+ // Restore the tableregenerate by reverting the model deletion
319
+ // and running: millas makemigrations
209
320
  await db.schema.createTable('${diff.table}', (t) => {
210
- t.increments('id');
211
- t.timestamps();
212
- });
321
+ ${this._renderColumns(diff.fields || {})} });
213
322
  },
214
323
  };
215
324
  `;
@@ -218,7 +327,8 @@ module.exports = {
218
327
  return `'use strict';
219
328
 
220
329
  /**
221
- * Migration: ${name}
330
+ * Auto-generated migration: ${name}
331
+ * Created by: millas makemigrations
222
332
  */
223
333
  module.exports = {
224
334
  async up(db) {
@@ -239,7 +349,8 @@ ${this._renderColumn(' ', diff.column, diff.field)}
239
349
  return `'use strict';
240
350
 
241
351
  /**
242
- * Migration: ${name}
352
+ * Auto-generated migration: ${name}
353
+ * Created by: millas makemigrations
243
354
  */
244
355
  module.exports = {
245
356
  async up(db) {
@@ -250,7 +361,7 @@ module.exports = {
250
361
 
251
362
  async down(db) {
252
363
  await db.schema.table('${diff.table}', (t) => {
253
- t.string('${diff.column}').nullable();
364
+ ${this._renderColumn(' ', diff.column, diff.field)}
254
365
  });
255
366
  },
256
367
  };
@@ -260,7 +371,8 @@ module.exports = {
260
371
  return `'use strict';
261
372
 
262
373
  /**
263
- * Migration: ${name}
374
+ * Auto-generated migration: ${name}
375
+ * Created by: millas makemigrations
264
376
  * Changed: ${JSON.stringify(diff.previous)} → ${JSON.stringify(diff.field)}
265
377
  */
266
378
  module.exports = {
@@ -284,6 +396,9 @@ ${this._renderColumn(' ', diff.column, diff.previous, '.alter()')}
284
396
  }
285
397
 
286
398
  _renderColumns(fields) {
399
+ if (!fields || Object.keys(fields).length === 0) {
400
+ return ' t.increments(\'id\');\n t.timestamps();\n';
401
+ }
287
402
  return Object.entries(fields)
288
403
  .map(([name, field]) => this._renderColumn(' ', name, field))
289
404
  .join('\n') + '\n';
@@ -291,30 +406,81 @@ ${this._renderColumn(' ', diff.column, diff.previous, '.alter()')}
291
406
 
292
407
  _renderColumn(indent, name, field, suffix = '') {
293
408
  let line;
409
+
294
410
  switch (field.type) {
295
- case 'id': line = `t.increments('${name}')`; break;
296
- case 'string': line = `t.string('${name}', ${field.max || 255})`; break;
297
- case 'text': line = `t.text('${name}')`; break;
298
- case 'integer': line = field.unsigned ? `t.integer('${name}').unsigned()` : `t.integer('${name}')`; break;
299
- case 'bigInteger': line = `t.bigInteger('${name}')`; break;
300
- case 'float': line = `t.float('${name}')`; break;
301
- case 'decimal': line = `t.decimal('${name}', ${field.precision||8}, ${field.scale||2})`; break;
302
- case 'boolean': line = `t.boolean('${name}')`; break;
303
- case 'json': line = `t.json('${name}')`; break;
304
- case 'date': line = `t.date('${name}')`; break;
305
- case 'timestamp': line = `t.timestamp('${name}', { useTz: false })`; break;
306
- case 'enum': line = `t.enum('${name}', ${JSON.stringify(field.enumValues||[])})`;break;
307
- case 'uuid': line = `t.uuid('${name}')`; break;
308
- default: line = `t.string('${name}')`;
411
+ case 'id':
412
+ return `${indent}t.increments('${name}')${suffix};`;
413
+
414
+ case 'string':
415
+ line = `t.string('${name}', ${field.max || 255})`;
416
+ break;
417
+
418
+ case 'text':
419
+ line = `t.text('${name}')`;
420
+ break;
421
+
422
+ case 'integer':
423
+ line = field.unsigned
424
+ ? `t.integer('${name}').unsigned()`
425
+ : `t.integer('${name}')`;
426
+ break;
427
+
428
+ case 'bigInteger':
429
+ line = field.unsigned
430
+ ? `t.bigInteger('${name}').unsigned()`
431
+ : `t.bigInteger('${name}')`;
432
+ break;
433
+
434
+ case 'float':
435
+ line = `t.float('${name}')`;
436
+ break;
437
+
438
+ case 'decimal':
439
+ line = `t.decimal('${name}', ${field.precision || 8}, ${field.scale || 2})`;
440
+ break;
441
+
442
+ case 'boolean':
443
+ line = `t.boolean('${name}')`;
444
+ break;
445
+
446
+ case 'json':
447
+ line = `t.json('${name}')`;
448
+ break;
449
+
450
+ case 'date':
451
+ line = `t.date('${name}')`;
452
+ break;
453
+
454
+ case 'timestamp':
455
+ line = `t.timestamp('${name}', { useTz: false })`;
456
+ break;
457
+
458
+ case 'enum':
459
+ line = `t.enum('${name}', ${JSON.stringify(field.enumValues || [])})`;
460
+ break;
461
+
462
+ case 'uuid':
463
+ line = `t.uuid('${name}')`;
464
+ break;
465
+
466
+ default:
467
+ line = `t.string('${name}')`;
309
468
  }
310
469
 
311
- if (field.nullable) line += '.nullable()';
312
- else if (field.type !== 'id') line += '.notNullable()';
313
- if (field.unique) line += '.unique()';
314
- if (field.default !== null && field.default !== undefined)
315
- line += `.defaultTo(${JSON.stringify(field.default)})`;
316
- if (field.references)
317
- line += `.references('${field.references.column}').inTable('${field.references.table}').onDelete('CASCADE')`;
470
+ if (field.nullable) line += '.nullable()';
471
+ else if (field.type !== 'id') line += '.notNullable()';
472
+
473
+ if (field.unique) line += '.unique()';
474
+
475
+ if (field.default !== null && field.default !== undefined) {
476
+ line += `.defaultTo(${JSON.stringify(field.default)})`;
477
+ }
478
+
479
+ if (field.references) {
480
+ line += `.references('${field.references.column}')` +
481
+ `.inTable('${field.references.table}')` +
482
+ `.onDelete('CASCADE')`;
483
+ }
318
484
 
319
485
  return `${indent}${line}${suffix};`;
320
486
  }
@@ -333,6 +499,12 @@ ${this._renderColumn(' ', diff.column, diff.previous, '.alter()')}
333
499
  fs.ensureDirSync(path.dirname(this._snapshotPath));
334
500
  fs.writeJsonSync(this._snapshotPath, schema, { spaces: 2 });
335
501
  }
502
+
503
+ // ─── Helpers ──────────────────────────────────────────────────────────────
504
+
505
+ _timestamp() {
506
+ return new Date().toISOString().replace(/[-T:.Z]/g, '').slice(0, 14);
507
+ }
336
508
  }
337
509
 
338
510
  module.exports = ModelInspector;
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MySQL / MariaDB — drop all user tables in the active database.
5
+ */
6
+ async function dropAllTables(db) {
7
+ const dbName = db.client.config.connection.database;
8
+
9
+ const rows = await db
10
+ .select('TABLE_NAME as name')
11
+ .from('information_schema.TABLES')
12
+ .where('TABLE_SCHEMA', dbName)
13
+ .where('TABLE_TYPE', 'BASE TABLE');
14
+
15
+ if (rows.length === 0) return;
16
+
17
+ await db.raw('SET FOREIGN_KEY_CHECKS = 0');
18
+
19
+ for (const { name } of rows) {
20
+ await db.schema.dropTableIfExists(name);
21
+ }
22
+
23
+ await db.raw('SET FOREIGN_KEY_CHECKS = 1');
24
+ }
25
+
26
+ module.exports = { dropAllTables };
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PostgreSQL — drop all user tables in the public schema.
5
+ */
6
+ async function dropAllTables(db) {
7
+ const rows = await db
8
+ .select('tablename')
9
+ .from('pg_tables')
10
+ .where('schemaname', 'public');
11
+
12
+ if (rows.length === 0) return;
13
+
14
+ const names = rows.map(r => `"${r.tablename}"`).join(', ');
15
+ await db.raw(`DROP TABLE IF EXISTS ${names} CASCADE`);
16
+ }
17
+
18
+ module.exports = { dropAllTables };
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SQLite — drop all user tables.
5
+ * Reads table names from sqlite_master.
6
+ */
7
+ async function dropAllTables(db) {
8
+ // Disable FK checks so drops don't fail on references
9
+ await db.raw('PRAGMA foreign_keys = OFF');
10
+
11
+ const tables = await db
12
+ .select('name')
13
+ .from('sqlite_master')
14
+ .where('type', 'table')
15
+ .whereNot('name', 'like', 'sqlite_%');
16
+
17
+ for (const { name } of tables) {
18
+ await db.schema.dropTableIfExists(name);
19
+ }
20
+
21
+ await db.raw('PRAGMA foreign_keys = ON');
22
+ }
23
+
24
+ module.exports = { dropAllTables };