millas 0.1.6 → 0.1.8

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 CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "millas",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "A modern batteries-included backend framework for Node.js — built on Express, inspired by Laravel, Django, and FastAPI",
5
5
  "main": "src/index.js",
6
6
  "exports": {
7
- ".": "./src/index.js",
8
- "./src": "./src/index.js",
9
- "./src/*": "./src/*.js",
10
- "./bin/*": "./bin/*.js"
7
+ ".": "./src/index.js",
8
+ "./src": "./src/index.js",
9
+ "./src/*": "./src/*.js",
10
+ "./bin/*": "./bin/*.js"
11
11
  },
12
12
  "bin": {
13
13
  "millas": "./bin/millas.js"
@@ -16,9 +16,20 @@
16
16
  "test": "echo \"Run phase-specific tests in tests/\""
17
17
  },
18
18
  "keywords": [
19
- "framework", "express", "orm", "cli", "auth",
20
- "queue", "cache", "admin", "mail", "events",
21
- "laravel", "django", "fastapi", "millas"
19
+ "framework",
20
+ "express",
21
+ "orm",
22
+ "cli",
23
+ "auth",
24
+ "queue",
25
+ "cache",
26
+ "admin",
27
+ "mail",
28
+ "events",
29
+ "laravel",
30
+ "django",
31
+ "fastapi",
32
+ "millas"
22
33
  ],
23
34
  "author": "Millas Framework",
24
35
  "license": "MIT",
@@ -26,22 +37,24 @@
26
37
  "node": ">=18.0.0"
27
38
  },
28
39
  "dependencies": {
29
- "bcryptjs": "3.0.2",
30
- "chalk": "4.1.2",
31
- "commander": "^11.0.0",
32
- "fs-extra": "^11.0.0",
33
- "inquirer": "8.2.6",
34
- "jsonwebtoken":"^9.0.3",
35
- "knex": "^3.1.0",
36
- "nodemailer": "^6.9.0",
37
- "nunjucks": "^3.2.4",
38
- "ora": "5.4.1"
40
+ "bcryptjs": "3.0.2",
41
+ "chalk": "4.1.2",
42
+ "commander": "^11.0.0",
43
+ "fs-extra": "^11.0.0",
44
+ "inquirer": "8.2.6",
45
+ "jsonwebtoken": "^9.0.3",
46
+ "knex": "^3.1.0",
47
+ "nodemailer": "^6.9.0",
48
+ "nunjucks": "^3.2.4",
49
+ "ora": "5.4.1"
39
50
  },
40
51
  "peerDependencies": {
41
52
  "express": "^4.18.0"
42
53
  },
43
54
  "peerDependenciesMeta": {
44
- "express": { "optional": false }
55
+ "express": {
56
+ "optional": false
57
+ }
45
58
  },
46
59
  "files": [
47
60
  "bin/",
@@ -6,133 +6,185 @@ const fs = require('fs-extra');
6
6
 
7
7
  module.exports = function (program) {
8
8
 
9
+ // ── makemigrations ────────────────────────────────────────────────────────
10
+
9
11
  program
10
12
  .command('makemigrations')
11
- .description('Detect model changes and generate migration files')
13
+ .description('Scan model files, detect schema changes, generate migration files')
12
14
  .action(async () => {
13
- const ctx = getProjectContext();
14
- const ModelInspector = require('../orm/migration/ModelInspector');
15
- const inspector = new ModelInspector(
16
- ctx.modelsPath,
17
- ctx.migrationsPath,
18
- ctx.snapshotPath
19
- );
20
- const result = await inspector.makeMigrations();
21
- if (result.files.length === 0) {
22
- console.log(chalk.yellow(`\n ${result.message}\n`));
23
- } else {
24
- console.log(chalk.green(`\n ✔ ${result.message}`));
25
- result.files.forEach(f => console.log(chalk.cyan(` + ${f}`)));
26
- console.log();
15
+ try {
16
+ const ctx = getProjectContext();
17
+ // Fixed: was incorrectly destructured as { ModelInspector }
18
+ const ModelInspector = require('../orm/migration/ModelInspector');
19
+ const inspector = new ModelInspector(
20
+ ctx.modelsPath,
21
+ ctx.migrationsPath,
22
+ ctx.snapshotPath,
23
+ );
24
+ const result = await inspector.makeMigrations();
25
+
26
+ if (result.files.length === 0) {
27
+ console.log(chalk.yellow(`\n ${result.message}\n`));
28
+ } else {
29
+ console.log(chalk.green(`\n ✔ ${result.message}`));
30
+ result.files.forEach(f => console.log(chalk.cyan(` + ${f}`)));
31
+ console.log(chalk.gray('\n Run: millas migrate to apply these migrations.\n'));
32
+ }
33
+ } catch (err) {
34
+ console.error(chalk.red(`\n ✖ makemigrations failed: ${err.message}\n`));
35
+ if (process.env.DEBUG) console.error(err.stack);
36
+ process.exit(1);
27
37
  }
28
38
  });
29
39
 
40
+ // ── migrate ───────────────────────────────────────────────────────────────
41
+
30
42
  program
31
43
  .command('migrate')
32
44
  .description('Run all pending migrations')
33
45
  .action(async () => {
34
- const runner = await getRunner();
35
- const result = await runner.migrate();
36
- printMigrationResult(result, 'Ran');
46
+ try {
47
+ const runner = await getRunner();
48
+ const result = await runner.migrate();
49
+ printResult(result, 'Ran');
50
+ } catch (err) {
51
+ bail('migrate', err);
52
+ }
37
53
  });
38
54
 
55
+ // ── migrate:fresh ─────────────────────────────────────────────────────────
56
+
39
57
  program
40
58
  .command('migrate:fresh')
41
- .description('Drop all tables and re-run all migrations')
59
+ .description('Drop ALL tables then re-run every migration from scratch')
42
60
  .action(async () => {
43
- console.log(chalk.yellow('\n ⚠ Dropping all tables...\n'));
44
- const runner = await getRunner();
45
- const result = await runner.fresh();
46
- printMigrationResult(result, 'Ran');
61
+ try {
62
+ console.log(chalk.yellow('\n ⚠ Dropping all tables…\n'));
63
+ const runner = await getRunner();
64
+ const result = await runner.fresh();
65
+ printResult(result, 'Ran');
66
+ } catch (err) {
67
+ bail('migrate:fresh', err);
68
+ }
47
69
  });
48
70
 
71
+ // ── migrate:rollback ──────────────────────────────────────────────────────
72
+
49
73
  program
50
74
  .command('migrate:rollback')
51
75
  .description('Rollback the last batch of migrations')
52
76
  .option('--steps <n>', 'Number of batches to rollback', '1')
53
77
  .action(async (options) => {
54
- const runner = await getRunner();
55
- const result = await runner.rollback(Number(options.steps));
56
- printMigrationResult(result, 'Rolled back');
78
+ try {
79
+ const runner = await getRunner();
80
+ const result = await runner.rollback(Number(options.steps));
81
+ printResult(result, 'Rolled back');
82
+ } catch (err) {
83
+ bail('migrate:rollback', err);
84
+ }
57
85
  });
58
86
 
87
+ // ── migrate:reset ─────────────────────────────────────────────────────────
88
+
59
89
  program
60
90
  .command('migrate:reset')
61
- .description('Rollback all migrations')
91
+ .description('Rollback ALL migrations')
62
92
  .action(async () => {
63
- const runner = await getRunner();
64
- const result = await runner.reset();
65
- printMigrationResult(result, 'Rolled back');
93
+ try {
94
+ const runner = await getRunner();
95
+ const result = await runner.reset();
96
+ printResult(result, 'Rolled back');
97
+ } catch (err) {
98
+ bail('migrate:reset', err);
99
+ }
66
100
  });
67
101
 
102
+ // ── migrate:refresh ───────────────────────────────────────────────────────
103
+
68
104
  program
69
105
  .command('migrate:refresh')
70
- .description('Rollback all and re-run all migrations')
106
+ .description('Rollback all then re-run all migrations')
71
107
  .action(async () => {
72
- const runner = await getRunner();
73
- const result = await runner.refresh();
74
- printMigrationResult(result, 'Ran');
108
+ try {
109
+ const runner = await getRunner();
110
+ const result = await runner.refresh();
111
+ printResult(result, 'Ran');
112
+ } catch (err) {
113
+ bail('migrate:refresh', err);
114
+ }
75
115
  });
76
116
 
117
+ // ── migrate:status ────────────────────────────────────────────────────────
118
+
77
119
  program
78
120
  .command('migrate:status')
79
- .description('Show the status of all migrations')
121
+ .description('Show the status of all migration files')
80
122
  .action(async () => {
81
- const runner = await getRunner();
82
- const rows = await runner.status();
83
-
84
- if (rows.length === 0) {
85
- console.log(chalk.yellow('\n No migration files found.\n'));
86
- return;
87
- }
88
-
89
- const colW = Math.max(...rows.map(r => r.name.length)) + 2;
90
- console.log(`\n ${'Migration'.padEnd(colW)} ${'Status'.padEnd(10)} Batch`);
91
- console.log(chalk.gray(' ' + '─'.repeat(colW + 20)));
92
-
93
- for (const row of rows) {
94
- const status = row.status === 'Ran'
95
- ? chalk.green(row.status.padEnd(10))
96
- : chalk.yellow(row.status.padEnd(10));
97
- const batch = row.batch ? chalk.gray(String(row.batch)) : chalk.gray('—');
98
- console.log(` ${chalk.cyan(row.name.padEnd(colW))} ${status} ${batch}`);
123
+ try {
124
+ const runner = await getRunner();
125
+ const rows = await runner.status();
126
+
127
+ if (rows.length === 0) {
128
+ console.log(chalk.yellow('\n No migration files found.\n'));
129
+ return;
130
+ }
131
+
132
+ const colW = Math.max(...rows.map(r => r.name.length)) + 2;
133
+ console.log(`\n ${'Migration'.padEnd(colW)} ${'Status'.padEnd(10)} Batch`);
134
+ console.log(chalk.gray(' ' + '─'.repeat(colW + 20)));
135
+
136
+ for (const row of rows) {
137
+ const status = row.status === 'Ran'
138
+ ? chalk.green(row.status.padEnd(10))
139
+ : chalk.yellow(row.status.padEnd(10));
140
+ const batch = row.batch ? chalk.gray(String(row.batch)) : chalk.gray('—');
141
+ console.log(` ${chalk.cyan(row.name.padEnd(colW))} ${status} ${batch}`);
142
+ }
143
+ console.log();
144
+ } catch (err) {
145
+ bail('migrate:status', err);
99
146
  }
100
- console.log();
101
147
  });
102
148
 
149
+ // ── db:seed ───────────────────────────────────────────────────────────────
150
+
103
151
  program
104
152
  .command('db:seed')
105
153
  .description('Run all database seeders')
106
154
  .action(async () => {
107
- const ctx = getProjectContext();
108
- const seedersDir = ctx.seedersPath;
155
+ try {
156
+ const ctx = getProjectContext();
157
+ const seedersDir = ctx.seedersPath;
109
158
 
110
- if (!fs.existsSync(seedersDir)) {
111
- console.log(chalk.yellow('\n No seeders directory found.\n'));
112
- return;
113
- }
159
+ if (!fs.existsSync(seedersDir)) {
160
+ console.log(chalk.yellow('\n No seeders directory found.\n'));
161
+ return;
162
+ }
114
163
 
115
- const files = fs.readdirSync(seedersDir)
116
- .filter(f => f.endsWith('.js') && !f.startsWith('.'))
117
- .sort();
164
+ const files = fs.readdirSync(seedersDir)
165
+ .filter(f => f.endsWith('.js') && !f.startsWith('.'))
166
+ .sort();
118
167
 
119
- if (files.length === 0) {
120
- console.log(chalk.yellow('\n No seeder files found.\n'));
121
- return;
122
- }
168
+ if (files.length === 0) {
169
+ console.log(chalk.yellow('\n No seeder files found.\n'));
170
+ return;
171
+ }
123
172
 
124
- console.log();
125
- for (const file of files) {
126
- const seeder = require(path.join(seedersDir, file));
127
173
  const db = await getDbConnection();
128
- await seeder.run(db);
129
- console.log(chalk.green(` ✔ Seeded: ${file}`));
174
+ console.log();
175
+ for (const file of files) {
176
+ const seeder = require(path.join(seedersDir, file));
177
+ await seeder.run(db);
178
+ console.log(chalk.green(` ✔ Seeded: ${file}`));
179
+ }
180
+ console.log();
181
+ } catch (err) {
182
+ bail('db:seed', err);
130
183
  }
131
- console.log();
132
184
  });
133
185
  };
134
186
 
135
- // ─── Helpers ─────────────────────────────────────────────────────────────────
187
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
136
188
 
137
189
  function getProjectContext() {
138
190
  const cwd = process.cwd();
@@ -149,26 +201,35 @@ async function getDbConnection() {
149
201
  if (!fs.existsSync(configPath + '.js')) {
150
202
  throw new Error('config/database.js not found. Are you inside a Millas project?');
151
203
  }
152
- const config = require(configPath);
204
+ const config = require(configPath);
153
205
  const DatabaseManager = require('../orm/drivers/DatabaseManager');
154
206
  DatabaseManager.configure(config);
155
207
  return DatabaseManager.connection();
156
208
  }
157
209
 
158
210
  async function getRunner() {
159
- const { MigrationRunner } = require('../orm/migration/MigrationRunner');
211
+ // Fixed: was incorrectly destructured as { MigrationRunner }
212
+ const MigrationRunner = require('../orm/migration/MigrationRunner');
160
213
  const ctx = getProjectContext();
161
214
  const db = await getDbConnection();
162
215
  return new MigrationRunner(db, ctx.migrationsPath);
163
216
  }
164
217
 
165
- function printMigrationResult(result, verb) {
218
+ function printResult(result, verb) {
166
219
  const list = result.ran || result.rolledBack || [];
167
220
  if (list.length === 0) {
168
221
  console.log(chalk.yellow(`\n ${result.message}\n`));
169
222
  return;
170
223
  }
171
224
  console.log(chalk.green(`\n ✔ ${result.message}`));
172
- list.forEach(f => console.log(chalk.cyan(` ${verb === 'Ran' ? '+' : '-'} ${f}`)));
225
+ list.forEach(f =>
226
+ console.log(chalk.cyan(` ${verb === 'Ran' ? '+' : '-'} ${f}`)),
227
+ );
173
228
  console.log();
174
229
  }
230
+
231
+ function bail(cmd, err) {
232
+ console.error(chalk.red(`\n ✖ ${cmd} failed: ${err.message}\n`));
233
+ if (process.env.DEBUG) console.error(err.stack);
234
+ process.exit(1);
235
+ }
@@ -7,19 +7,19 @@ const path = require('path');
7
7
  * MigrationRunner
8
8
  *
9
9
  * Handles the full migration lifecycle:
10
- * - run pending migrations (migrate)
11
- * - rollback last batch (migrate:rollback)
12
- * - show status table (migrate:status)
13
- * - drop all + re-run (migrate:fresh)
14
- * - rollback all (migrate:reset)
15
- * - rollback all + re-run (migrate:refresh)
10
+ * - run pending migrations (migrate)
11
+ * - rollback last batch (migrate:rollback)
12
+ * - show status table (migrate:status)
13
+ * - drop all + re-run (migrate:fresh)
14
+ * - rollback all (migrate:reset)
15
+ * - rollback all + re-run (migrate:refresh)
16
16
  *
17
17
  * Migration history is tracked in the `millas_migrations` table.
18
18
  * Each migration file must export { up(db), down(db) }.
19
19
  */
20
20
  class MigrationRunner {
21
21
  /**
22
- * @param {object} knexConn — live knex connection
22
+ * @param {object} knexConn — live knex connection
23
23
  * @param {string} migrationsPath — absolute path to migrations dir
24
24
  */
25
25
  constructor(knexConn, migrationsPath) {
@@ -29,9 +29,7 @@ class MigrationRunner {
29
29
 
30
30
  // ─── Public commands ──────────────────────────────────────────────────────
31
31
 
32
- /**
33
- * Run all pending migrations.
34
- */
32
+ /** Run all pending migrations. */
35
33
  async migrate() {
36
34
  await this._ensureTable();
37
35
  const pending = await this._pending();
@@ -53,9 +51,7 @@ class MigrationRunner {
53
51
  return { ran, batch, message: `Ran ${ran.length} migration(s).` };
54
52
  }
55
53
 
56
- /**
57
- * Rollback the last batch of migrations.
58
- */
54
+ /** Rollback the last batch of migrations. */
59
55
  async rollback(steps = 1) {
60
56
  await this._ensureTable();
61
57
  const batches = await this._lastBatches(steps);
@@ -66,7 +62,6 @@ class MigrationRunner {
66
62
 
67
63
  const rolledBack = [];
68
64
 
69
- // Run in reverse order
70
65
  for (const row of [...batches].reverse()) {
71
66
  const migration = this._load(row.name);
72
67
  await migration.down(this._db);
@@ -77,18 +72,14 @@ class MigrationRunner {
77
72
  return { rolledBack, message: `Rolled back ${rolledBack.length} migration(s).` };
78
73
  }
79
74
 
80
- /**
81
- * Drop all tables and re-run every migration.
82
- */
75
+ /** Drop all tables and re-run every migration. */
83
76
  async fresh() {
84
77
  await this._dropAllTables();
85
78
  await this._ensureTable();
86
79
  return this.migrate();
87
80
  }
88
81
 
89
- /**
90
- * Rollback ALL migrations.
91
- */
82
+ /** Rollback ALL migrations. */
92
83
  async reset() {
93
84
  await this._ensureTable();
94
85
  const all = await this._db('millas_migrations').orderBy('id', 'desc');
@@ -108,17 +99,13 @@ class MigrationRunner {
108
99
  return { rolledBack, message: `Reset ${rolledBack.length} migration(s).` };
109
100
  }
110
101
 
111
- /**
112
- * Rollback all then re-run all.
113
- */
102
+ /** Rollback all then re-run all. */
114
103
  async refresh() {
115
104
  await this.reset();
116
105
  return this.migrate();
117
106
  }
118
107
 
119
- /**
120
- * Return status of all migration files.
121
- */
108
+ /** Return status of all migration files. */
122
109
  async status() {
123
110
  await this._ensureTable();
124
111
  const files = this._files();
@@ -127,7 +114,7 @@ class MigrationRunner {
127
114
  return files.map(file => ({
128
115
  name: file,
129
116
  status: ran.has(file) ? 'Ran' : 'Pending',
130
- batch: ran.get ? ran.get(file) : null,
117
+ batch: ran.get(file) || null,
131
118
  }));
132
119
  }
133
120
 
@@ -153,9 +140,7 @@ class MigrationRunner {
153
140
 
154
141
  async _ranNames() {
155
142
  const rows = await this._db('millas_migrations').select('name', 'batch');
156
- const map = new Map(rows.map(r => [r.name, r.batch]));
157
- // Map with has() and get()
158
- return map;
143
+ return new Map(rows.map(r => [r.name, r.batch]));
159
144
  }
160
145
 
161
146
  async _nextBatch() {
@@ -168,34 +153,39 @@ class MigrationRunner {
168
153
  if (!maxBatch?.max) return [];
169
154
 
170
155
  const fromBatch = maxBatch.max - steps + 1;
171
- // Fetch all then filter in JS to avoid needing >= support in all drivers
172
156
  const all = await this._db('millas_migrations').orderBy('id', 'desc');
173
157
  return all.filter(r => r.batch >= fromBatch);
174
158
  }
175
159
 
160
+ /**
161
+ * Drop all user tables — dialect-aware.
162
+ * Resolves the knex client name and delegates to the right helper.
163
+ */
176
164
  async _dropAllTables() {
177
- // Get all table names (SQLite compatible)
178
- const tables = await this._db
179
- .select('name')
180
- .from('sqlite_master')
181
- .where('type', 'table')
182
- .whereNot('name', 'like', 'sqlite_%');
183
-
184
- for (const { name } of tables) {
185
- await this._db.schema.dropTableIfExists(name);
165
+ const clientName = this._db.client.config.client || 'sqlite3';
166
+
167
+ let dialect;
168
+ if (clientName.includes('pg') || clientName.includes('postgres')) {
169
+ dialect = require('./dialects/postgres');
170
+ } else if (clientName.includes('mysql') || clientName.includes('maria')) {
171
+ dialect = require('./dialects/mysql');
172
+ } else {
173
+ // Default: sqlite / sqlite3
174
+ dialect = require('./dialects/sqlite');
186
175
  }
176
+
177
+ await dialect.dropAllTables(this._db);
187
178
  }
188
179
 
189
180
  _files() {
190
181
  if (!fs.existsSync(this._path)) return [];
191
182
  return fs.readdirSync(this._path)
192
183
  .filter(f => f.endsWith('.js') && !f.startsWith('.'))
193
- .sort(); // Sorted by timestamp prefix
184
+ .sort();
194
185
  }
195
186
 
196
187
  _load(name) {
197
188
  const filePath = path.join(this._path, name);
198
- // Clear require cache so fresh loads work in tests
199
189
  delete require.cache[require.resolve(filePath)];
200
190
  const migration = require(filePath);
201
191
  if (typeof migration.up !== 'function' || typeof migration.down !== 'function') {
@@ -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 };
@@ -66,6 +66,8 @@ storage/logs/*.log
66
66
  storage/uploads/*
67
67
  !storage/uploads/.gitkeep
68
68
  database/database.sqlite
69
+ # Millas migration snapshot — auto-generated, do not commit
70
+ .millas/
69
71
  `,
70
72
 
71
73
  // ─── millas.config.js ─────────────────────────────────────────
@@ -356,17 +358,37 @@ millas make:controller UserController
356
358
 
357
359
  # Generate a model
358
360
  millas make:model User
361
+ \`\`\`
362
+
363
+ ## Database Migrations (Django-style)
364
+
365
+ Millas handles migrations automatically — you only edit your model files.
366
+
367
+ \`\`\`bash
368
+ # 1. Edit app/models/User.js — add, remove, or change fields
369
+ # 2. Generate migration files from your changes
370
+ millas makemigrations
359
371
 
360
- # Run migrations
372
+ # 3. Apply pending migrations to the database
361
373
  millas migrate
362
374
  \`\`\`
363
375
 
376
+ Other migration commands:
377
+
378
+ \`\`\`bash
379
+ millas migrate:status # Show which migrations have run
380
+ millas migrate:rollback # Undo the last batch
381
+ millas migrate:fresh # Drop everything and re-run all migrations
382
+ millas migrate:reset # Roll back all migrations
383
+ millas migrate:refresh # Reset + re-run (like fresh but using down() methods)
384
+ \`\`\`
385
+
364
386
  ## Project Structure
365
387
 
366
388
  \`\`\`
367
389
  app/
368
390
  controllers/ # HTTP controllers
369
- models/ # ORM models
391
+ models/ # ORM models ← only file you edit for schema changes
370
392
  services/ # Business logic
371
393
  middleware/ # HTTP middleware
372
394
  jobs/ # Background jobs
@@ -374,13 +396,14 @@ bootstrap/
374
396
  app.js # Application entry point
375
397
  config/ # Configuration files
376
398
  database/
377
- migrations/ # Database migrations
399
+ migrations/ # Auto-generated — do not edit by hand
378
400
  seeders/ # Database seeders
379
401
  routes/
380
402
  web.js # Web routes
381
403
  api.js # API routes
382
404
  storage/ # Logs, uploads
383
405
  providers/ # Service providers
406
+ .millas/ # Migration snapshot (gitignored)
384
407
  \`\`\`
385
408
  `,
386
409
  };