millas 0.2.19 → 0.2.21

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,6 +1,6 @@
1
1
  {
2
2
  "name": "millas",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
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": {
@@ -145,7 +145,7 @@ class QueryEngine {
145
145
  // ── Execute data + count in parallel ──────────────────────────────────
146
146
  const [rows, countResult] = await Promise.all([
147
147
  q.clone().limit(limit).offset(offset),
148
- q.clone().count('* as count').first(),
148
+ q.clone().clearOrder().clearSelect().count('* as count').first(),
149
149
  ]);
150
150
 
151
151
  const total = Number(countResult?.count ?? 0);
@@ -231,12 +231,12 @@ class QueryEngine {
231
231
  case 'in': return q.whereIn(col, Array.isArray(value) ? value : [value]);
232
232
  case 'notin': return q.whereNotIn(col, Array.isArray(value) ? value : [value]);
233
233
  case 'between': return q.whereBetween(col, Array.isArray(value) ? value : [value, value]);
234
- case 'contains':
235
- case 'icontains': return q.where(col, 'like', `%${value}%`);
236
- case 'startswith':
237
- case 'istartswith': return q.where(col, 'like', `${value}%`);
238
- case 'endswith':
239
- case 'iendswith': return q.where(col, 'like', `%${value}`);
234
+ case 'contains': return q.where(col, 'like', `%${value}%`);
235
+ case 'icontains': return q.where(q.client?.config?.client?.includes('pg') ? col : col, q.client?.config?.client?.includes('pg') ? 'ilike' : 'like', `%${value}%`);
236
+ case 'startswith': return q.where(col, 'like', `${value}%`);
237
+ case 'istartswith': return q.where(col, q.client?.config?.client?.includes('pg') ? 'ilike' : 'like', `${value}%`);
238
+ case 'endswith': return q.where(col, 'like', `%${value}`);
239
+ case 'iendswith': return q.where(col, q.client?.config?.client?.includes('pg') ? 'ilike' : 'like', `%${value}`);
240
240
  default: return q.where(key, value);
241
241
  }
242
242
  }
@@ -246,15 +246,19 @@ class QueryEngine {
246
246
  * Uses strftime for SQLite/MySQL; falls back gracefully on PostgreSQL.
247
247
  */
248
248
  _applyDateHierarchy(q, col, year, month) {
249
+ const client = q.client?.config?.client || 'sqlite3';
250
+ const isPg = client.includes('pg') || client.includes('postgres');
251
+ const isMy = client.includes('mysql') || client.includes('maria');
252
+
249
253
  if (year) {
250
- try {
251
- q = q.whereRaw(`strftime('%Y', \`${col}\`) = ?`, [String(year)]);
252
- } catch { /* PG fallback — skip */ }
254
+ if (isPg) q = q.whereRaw(`EXTRACT(YEAR FROM "${col}") = ?`, [Number(year)]);
255
+ else if (isMy) q = q.whereRaw(`YEAR(\`${col}\`) = ?`, [Number(year)]);
256
+ else q = q.whereRaw(`strftime('%Y', \`${col}\`) = ?`, [String(year)]);
253
257
  }
254
258
  if (month) {
255
- try {
256
- q = q.whereRaw(`strftime('%m', \`${col}\`) = ?`, [String(month).padStart(2, '0')]);
257
- } catch { /* PG fallback — skip */ }
259
+ if (isPg) q = q.whereRaw(`EXTRACT(MONTH FROM "${col}") = ?`, [Number(month)]);
260
+ else if (isMy) q = q.whereRaw(`MONTH(\`${col}\`) = ?`, [Number(month)]);
261
+ else q = q.whereRaw(`strftime('%m', \`${col}\`) = ?`, [String(month).padStart(2, '0')]);
258
262
  }
259
263
  return q;
260
264
  }
package/src/cli.js CHANGED
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ // Load .env before anything else so all commands have access to env vars
4
+ require('dotenv').config();
5
+
3
6
  const { Command } = require('commander');
4
7
  const chalk = require('chalk');
5
8
  const program = new Command();
@@ -34,8 +34,22 @@ module.exports = function (program) {
34
34
  // Match Django's +/-/~ prefix style exactly
35
35
  let prefix, label;
36
36
  switch (op.type) {
37
- case 'CreateModel':
38
- prefix = chalk.green('+'); label = `Create model ${op.table}`; break;
37
+ case 'CreateModel': {
38
+ const idxLines = [];
39
+ for (const idx of (op.indexes || [])) {
40
+ const l = idx.name
41
+ ? `${idx.name} on ${op.table}`
42
+ : `on field(s) ${idx.fields.join(', ')} of model ${op.table}`;
43
+ idxLines.push(` ${chalk.green('+')} Create index ${l}`);
44
+ }
45
+ for (const ut of (op.uniqueTogether || [])) {
46
+ idxLines.push(` ${chalk.green('+')} Create constraint on ${op.table} (${ut.join(', ')})`);
47
+ }
48
+ prefix = chalk.green('+'); label = `Create model ${op.table}`;
49
+ console.log(chalk.gray(` ${prefix} ${label}`));
50
+ idxLines.forEach(l => console.log(chalk.gray(l)));
51
+ return; // return from forEach callback
52
+ }
39
53
  case 'DeleteModel':
40
54
  prefix = chalk.red('-'); label = `Delete model ${op.table}`; break;
41
55
  case 'AddField':
@@ -48,6 +62,24 @@ module.exports = function (program) {
48
62
  prefix = chalk.yellow('~'); label = `Rename field ${op.oldColumn} on ${op.table} to ${op.newColumn}`; break;
49
63
  case 'RenameModel':
50
64
  prefix = chalk.yellow('~'); label = `Rename model ${op.oldTable} to ${op.newTable}`; break;
65
+ case 'AddIndex': {
66
+ const idx = op.index;
67
+ const idxLabel = idx.name
68
+ ? `${idx.name} on ${op.table}`
69
+ : `on field(s) ${idx.fields.join(', ')} of model ${op.table}`;
70
+ prefix = chalk.green('+'); label = `Create index ${idxLabel}`; break;
71
+ }
72
+ case 'RemoveIndex': {
73
+ const idx = op.index;
74
+ const idxLabel = idx.name
75
+ ? `${idx.name} from ${op.table}`
76
+ : `on field(s) ${idx.fields.join(', ')} of model ${op.table}`;
77
+ prefix = chalk.red('-'); label = `Remove index ${idxLabel}`; break;
78
+ }
79
+ case 'RenameIndex':
80
+ prefix = chalk.yellow('~'); label = `Rename index ${op.oldName} on ${op.table} to ${op.newName}`; break;
81
+ case 'AlterUniqueTogether':
82
+ prefix = chalk.yellow('~'); label = `Alter unique_together on ${op.table}`; break;
51
83
  default:
52
84
  prefix = chalk.gray(' '); label = op.type;
53
85
  }
@@ -107,6 +107,13 @@ class AppInitializer {
107
107
 
108
108
  this._kernel._container.instance('basePath', basePath);
109
109
 
110
+ // ── Static file serving ───────────────────────────────────────────────
111
+ // Auto-serve each storage disk that has a baseUrl configured.
112
+ // Mirrors Laravel's public disk serving — zero config required.
113
+ // e.g. LocalDriver root=storage/uploads baseUrl=/storage
114
+ // → GET /storage/avatars/photo.jpg serves the file directly.
115
+ this._serveStorageDisks(expressApp, basePath);
116
+
110
117
  const coreProviders = this._buildCoreProviders(cfg);
111
118
  this._kernel.providers([...coreProviders, ...cfg.providers]);
112
119
 
@@ -175,6 +182,42 @@ class AppInitializer {
175
182
 
176
183
  // ── Internal ───────────────────────────────────────────────────────────────
177
184
 
185
+ _serveStorageDisks(expressApp, basePath) {
186
+ const express = require('express');
187
+ const path = require('path');
188
+ const fs = require('fs');
189
+
190
+ let storageConfig;
191
+ try {
192
+ storageConfig = require(basePath + '/config/storage');
193
+ } catch {
194
+ // Fall back to the same defaults StorageServiceProvider uses
195
+ storageConfig = {
196
+ default: 'local',
197
+ disks: {
198
+ local: { driver: 'local', root: 'storage/uploads', baseUrl: '/storage' },
199
+ public: { driver: 'local', root: 'public/storage', baseUrl: '/storage' },
200
+ },
201
+ };
202
+ }
203
+
204
+ const seen = new Set(); // avoid mounting the same baseUrl twice
205
+ for (const [, disk] of Object.entries(storageConfig.disks || {})) {
206
+ if (!disk.baseUrl || !disk.root) continue;
207
+ if (seen.has(disk.baseUrl)) continue;
208
+ seen.add(disk.baseUrl);
209
+
210
+ const absRoot = path.isAbsolute(disk.root)
211
+ ? disk.root
212
+ : path.join(basePath, disk.root);
213
+
214
+ // Ensure the directory exists so express.static doesn't warn
215
+ fs.mkdirSync(absRoot, { recursive: true });
216
+
217
+ expressApp.use(disk.baseUrl, express.static(absRoot));
218
+ }
219
+ }
220
+
178
221
  _buildCoreProviders(cfg) {
179
222
  const providers = [];
180
223
  const load = (p) => {
package/src/core/db.js CHANGED
@@ -1,9 +1,10 @@
1
- const {
2
- Model,
3
- fields
4
- } = require("../orm");
5
- const { migrations } = require("../orm/migration/operations");
1
+ const { Model, fields } = require('../orm');
2
+ const { migrations } = require('../orm/migration/operations');
3
+ const F = require('../orm/query/F');
4
+ const Q = require('../orm/query/Q');
5
+ const HasMany = require('../orm/relations/HasMany');
6
+ const BelongsTo = require('../orm/relations/BelongsTo');
7
+ const HasOne = require('../orm/relations/HasOne');
8
+ const BelongsToMany = require('../orm/relations/BelongsToMany');
6
9
 
7
- module.exports = {
8
- Model, fields,migrations
9
- }
10
+ module.exports = { Model, fields, migrations, F, Q, HasMany, BelongsTo, HasOne, BelongsToMany };
@@ -99,6 +99,12 @@ class DatabaseManager {
99
99
  });
100
100
 
101
101
  case 'mysql':
102
+ try { require('mysql2'); } catch {
103
+ throw new Error(
104
+ 'MySQL driver not installed.\n' +
105
+ 'Run: npm install mysql2'
106
+ );
107
+ }
102
108
  return knex({
103
109
  client: 'mysql2',
104
110
  connection: {
@@ -112,6 +118,12 @@ class DatabaseManager {
112
118
  });
113
119
 
114
120
  case 'postgres':
121
+ try { require('pg'); } catch {
122
+ throw new Error(
123
+ 'PostgreSQL driver not installed.\n' +
124
+ 'Run: npm install pg'
125
+ );
126
+ }
115
127
  return knex({
116
128
  client: 'pg',
117
129
  connection: {
@@ -54,19 +54,10 @@ function _makeModelRef(model) {
54
54
  if (model === 'self') return null;
55
55
  return () => {
56
56
  const path = require('path');
57
- const modelsDir = path.join(process.cwd(), 'app', 'models');
58
57
  try {
59
- return require(path.join(modelsDir, model));
58
+ const all = require(path.join(process.cwd(), 'app', 'models', 'index.js'));
59
+ return all[model] ?? null;
60
60
  } catch {
61
- try {
62
- const fs = require('fs');
63
- const files = fs.readdirSync(modelsDir);
64
- const match = files.find(f =>
65
- f.replace(/\.js$/, '').toLowerCase() === model.toLowerCase()
66
- );
67
- if (match) return require(path.join(modelsDir, match));
68
- } catch {
69
- }
70
61
  return null;
71
62
  }
72
63
  };
@@ -126,6 +117,22 @@ const fields = {
126
117
  return new FieldDefinition('uuid', options);
127
118
  },
128
119
 
120
+ email(options = {}) {
121
+ return new FieldDefinition('email', { max: 254, ...options });
122
+ },
123
+
124
+ url(options = {}) {
125
+ return new FieldDefinition('url', { max: 2048, ...options });
126
+ },
127
+
128
+ slug(options = {}) {
129
+ return new FieldDefinition('slug', { max: 255, ...options });
130
+ },
131
+
132
+ ipAddress(options = {}) {
133
+ return new FieldDefinition('ipAddress', { max: 45, ...options });
134
+ },
135
+
129
136
  /**
130
137
  * ForeignKey — Django-style.
131
138
  *
@@ -93,7 +93,14 @@ class Makemigrations {
93
93
  }
94
94
 
95
95
  // Strip internal flags before writing
96
- ops.forEach(op => { delete op._needsDefault; delete op._madeNullable; });
96
+ ops.forEach(op => {
97
+ if (op._destructiveWarning) {
98
+ process.stderr.write(`\n ⚠ ${op._destructiveWarning}\n`);
99
+ }
100
+ delete op._needsDefault;
101
+ delete op._madeNullable;
102
+ delete op._destructiveWarning;
103
+ });
97
104
 
98
105
  // ── Step 7: Compute file name and content ────────────────────────────────
99
106
  // Detect initial FIRST — isInitial must be known before naming the file
@@ -245,50 +252,48 @@ class Makemigrations {
245
252
  _opsToName(ops) {
246
253
  if (ops.length === 0) return 'auto';
247
254
 
248
- // For initial migrations → 'initial'
249
- // For single op → descriptive name
250
- // For multiple ops → Django style: table_field1_field2 (up to 3 segments)
251
-
252
255
  if (ops.length === 1) {
253
256
  const op = ops[0];
254
257
  switch (op.type) {
255
- // 0001_students (initial already handled above)
256
258
  case 'CreateModel': return op.table;
257
- // 0009_delete_course
258
259
  case 'DeleteModel': return `delete_${op.table}`;
259
- // 0004_student_age4
260
260
  case 'AddField': return `${op.table}_${op.column}`;
261
- // 0008_remove_student_age8
262
261
  case 'RemoveField': return `remove_${op.table}_${op.column}`;
263
- // 0007_alter_student_age8
264
262
  case 'AlterField': return `alter_${op.table}_${op.column}`;
265
- // 0006_rename_age7_student_age8
266
263
  case 'RenameField': return `rename_${op.oldColumn}_${op.table}_${op.newColumn}`;
267
264
  case 'RenameModel': return `rename_${op.oldTable}`;
265
+ case 'AddIndex': return `${op.table}_${(op.index.name || op.index.fields.join('_'))}_idx`;
266
+ case 'RemoveIndex': return `remove_${op.table}_${(op.index.name || op.index.fields.join('_'))}_idx`;
267
+ case 'AlterUniqueTogether': return `${op.table}_unique_together`;
268
268
  }
269
269
  }
270
270
 
271
- // Multiple ops — build Django-style compound name from each op's contribution
272
- // 0005_remove_student_age4_student_age7 (Remove + Add)
273
- const segments = ops.slice(0, 3).map(op => {
271
+ // Multiple ops — deduplicate tables, build a compact name
272
+ // Group by type prefix to avoid repetition
273
+ const seen = new Set();
274
+ const parts = [];
275
+ for (const op of ops.slice(0, 3)) {
276
+ const table = op.table || op.oldTable || 'change';
277
+ let segment;
274
278
  switch (op.type) {
275
- case 'CreateModel': return op.table;
276
- case 'DeleteModel': return `delete_${op.table}`;
277
- case 'AddField': return `${op.table}_${op.column}`;
278
- case 'RemoveField': return `${op.table}_${op.column}`;
279
- case 'AlterField': return `alter_${op.table}_${op.column}`;
280
- case 'RenameField': return `rename_${op.oldColumn}_${op.table}_${op.newColumn}`;
281
- default: {
282
- const table = op.table || op.oldTable || 'change';
283
- const detail = op.column || op.oldColumn || '';
284
- return detail ? `${table}_${detail}` : table;
285
- }
279
+ case 'CreateModel': segment = table; break;
280
+ case 'DeleteModel': segment = `delete_${table}`; break;
281
+ case 'AddField': segment = `${table}_${op.column}`; break;
282
+ case 'RemoveField': segment = `${table}_${op.column}`; break;
283
+ case 'AlterField': segment = `alter_${table}_${op.column}`; break;
284
+ case 'RenameField': segment = `rename_${op.oldColumn}_${table}_${op.newColumn}`; break;
285
+ case 'AddIndex': segment = `${table}_idx`; break;
286
+ case 'RemoveIndex': segment = `${table}_idx`; break;
287
+ case 'AlterUniqueTogether': segment = `${table}_unique`; break;
288
+ default: segment = table;
286
289
  }
287
- });
288
- // Prefix with 'remove_' only when first op is a RemoveField (matches Django)
290
+ if (!seen.has(segment)) {
291
+ seen.add(segment);
292
+ parts.push(segment);
293
+ }
294
+ }
289
295
  const prefix = ops[0]?.type === 'RemoveField' ? 'remove_' : '';
290
- const joined = prefix + segments.join('_');
291
- return joined || 'auto';
296
+ return (prefix + parts.join('_')) || 'auto';
292
297
  }
293
298
 
294
299
  /**
@@ -36,6 +36,7 @@
36
36
  */
37
37
 
38
38
  const { fieldsEqual } = require('./utils');
39
+ const { indexName } = require('./operations/indexes');
39
40
 
40
41
  const MILLAS_VERSION = (() => {
41
42
  try { return require('../../..').version || require('../../../package.json').version; } catch {}
@@ -65,37 +66,44 @@ class MigrationWriter {
65
66
  const histSch = historyState.toSchema();
66
67
  const currSch = currentState.toSchema();
67
68
 
69
+ // ── helpers to get fields/indexes from new schema shape ──────────────────
70
+ const getFields = (s, t) => s[t]?.fields ?? s[t] ?? {};
71
+ const getIndexes = (s, t) => s[t]?.indexes ?? [];
72
+ const getUniqueTogether = (s, t) => s[t]?.uniqueTogether ?? [];
73
+
68
74
  // New tables
69
75
  const newTableOps = [];
70
76
  for (const table of Object.keys(currSch)) {
71
77
  if (SYSTEM_TABLES.has(table)) continue;
72
78
  if (!histSch[table]) {
73
- newTableOps.push({ type: 'CreateModel', table, fields: currSch[table] });
79
+ newTableOps.push({
80
+ type: 'CreateModel',
81
+ table,
82
+ fields: getFields(currSch, table),
83
+ indexes: getIndexes(currSch, table),
84
+ uniqueTogether: getUniqueTogether(currSch, table),
85
+ });
74
86
  }
75
87
  }
76
-
77
- // Topologically sort new CreateModel ops so that if Post has a FK → users,
78
- // CreateModel(users) appears before CreateModel(posts) in the migration file.
79
- // Cycles are detected and noted — the two-pass FK creation in Operations.js
80
- // handles them safely at migrate time.
81
88
  ops.push(...this._sortCreateModels(newTableOps));
82
89
 
83
90
  // Dropped tables
84
91
  for (const table of Object.keys(histSch)) {
85
92
  if (SYSTEM_TABLES.has(table)) continue;
86
93
  if (!currSch[table]) {
87
- ops.push({ type: 'DeleteModel', table, fields: histSch[table] });
94
+ ops.push({ type: 'DeleteModel', table, fields: getFields(histSch, table) });
88
95
  }
89
96
  }
90
97
 
91
- // Column-level changes
98
+ // Column-level + index-level changes
92
99
  for (const table of Object.keys(currSch)) {
93
100
  if (SYSTEM_TABLES.has(table)) continue;
94
101
  if (!histSch[table]) continue;
95
102
 
96
- const curr = currSch[table];
97
- const prev = histSch[table];
103
+ const curr = getFields(currSch, table);
104
+ const prev = getFields(histSch, table);
98
105
 
106
+ // Added columns
99
107
  for (const col of Object.keys(curr)) {
100
108
  if (!prev[col]) {
101
109
  const field = curr[col];
@@ -105,15 +113,62 @@ class MigrationWriter {
105
113
  !!histSch[table];
106
114
  ops.push({ type: 'AddField', table, column: col, field, _needsDefault: isDangerous });
107
115
  } else if (!this._fieldsEqual(curr[col], prev[col])) {
108
- ops.push({ type: 'AlterField', table, column: col, field: curr[col], previousField: prev[col] });
116
+ const op = { type: 'AlterField', table, column: col, field: curr[col], previousField: prev[col] };
117
+ // Warn on destructive type changes — same as Django's warning
118
+ if (this._isDestructiveTypeChange(prev[col].type, curr[col].type)) {
119
+ op._destructiveWarning =
120
+ `Warning: changing '${table}.${col}' from ${prev[col].type} to ${curr[col].type} ` +
121
+ `may cause data loss. Existing data cannot be automatically converted.`;
122
+ }
123
+ ops.push(op);
109
124
  }
110
125
  }
111
126
 
127
+ // Removed columns
112
128
  for (const col of Object.keys(prev)) {
113
129
  if (!curr[col]) {
114
130
  ops.push({ type: 'RemoveField', table, column: col, field: prev[col] });
115
131
  }
116
132
  }
133
+
134
+ // ── Index diffs ────────────────────────────────────────────────────────
135
+ const currIndexes = getIndexes(currSch, table);
136
+ const prevIndexes = getIndexes(histSch, table);
137
+
138
+ const prevIdxKeys = new Set(prevIndexes.map(i => JSON.stringify(i)));
139
+ const currIdxKeys = new Set(currIndexes.map(i => JSON.stringify(i)));
140
+
141
+ // Detect renames: same fields, different name
142
+ const addedIdxs = currIndexes.filter(i => !prevIdxKeys.has(JSON.stringify(i)));
143
+ const removedIdxs = prevIndexes.filter(i => !currIdxKeys.has(JSON.stringify(i)));
144
+
145
+ for (const added of addedIdxs) {
146
+ const matchingRemoved = removedIdxs.find(
147
+ r => JSON.stringify(r.fields) === JSON.stringify(added.fields) &&
148
+ r.unique === added.unique &&
149
+ r.name !== added.name
150
+ );
151
+ if (matchingRemoved) {
152
+ // It's a rename
153
+ const oldName = matchingRemoved.name || indexName(table, matchingRemoved.fields, matchingRemoved.unique);
154
+ const newName = added.name || indexName(table, added.fields, added.unique);
155
+ ops.push({ type: 'RenameIndex', table, oldName, newName, fields: added.fields });
156
+ removedIdxs.splice(removedIdxs.indexOf(matchingRemoved), 1);
157
+ } else {
158
+ ops.push({ type: 'AddIndex', table, index: added });
159
+ }
160
+ }
161
+ for (const idx of removedIdxs) {
162
+ ops.push({ type: 'RemoveIndex', table, index: idx });
163
+ }
164
+
165
+ // ── uniqueTogether diffs ───────────────────────────────────────────────
166
+ const currUT = getUniqueTogether(currSch, table);
167
+ const prevUT = getUniqueTogether(histSch, table);
168
+ if (JSON.stringify(currUT.map(s => [...s].sort()).sort()) !==
169
+ JSON.stringify(prevUT.map(s => [...s].sort()).sort())) {
170
+ ops.push({ type: 'AlterUniqueTogether', table, newUnique: currUT, oldUnique: prevUT });
171
+ }
117
172
  }
118
173
 
119
174
  return ops;
@@ -166,15 +221,22 @@ ${opsCode},
166
221
  _renderOp(op) {
167
222
  switch (op.type) {
168
223
 
169
- case 'CreateModel':
224
+ case 'CreateModel': {
225
+ const idxCode = op.indexes?.length
226
+ ? `,\n indexes: ${JSON.stringify(op.indexes)}`
227
+ : '';
228
+ const utCode = op.uniqueTogether?.length
229
+ ? `,\n uniqueTogether: ${JSON.stringify(op.uniqueTogether)}`
230
+ : '';
170
231
  return `migrations.CreateModel({\n` +
171
232
  ` name: '${this._modelName(op.table)}',\n` +
172
233
  ` fields: [\n` +
173
234
  Object.entries(op.fields)
174
235
  .map(([col, def]) => ` ['${col}', ${this._renderField(def)}],`)
175
236
  .join('\n') + '\n' +
176
- ` ],\n` +
237
+ ` ]${idxCode}${utCode},\n` +
177
238
  ` })`;
239
+ }
178
240
 
179
241
  case 'DeleteModel':
180
242
  // Django omits fields= — not needed in the migration file
@@ -222,6 +284,32 @@ ${opsCode},
222
284
  ` newName: '${op.newTable}',\n` +
223
285
  ` })`;
224
286
 
287
+ case 'AddIndex':
288
+ return `migrations.AddIndex({\n` +
289
+ ` modelName: '${op.table}',\n` +
290
+ ` index: ${JSON.stringify(op.index)},\n` +
291
+ ` })`;
292
+
293
+ case 'RemoveIndex':
294
+ return `migrations.RemoveIndex({\n` +
295
+ ` modelName: '${op.table}',\n` +
296
+ ` index: ${JSON.stringify(op.index)},\n` +
297
+ ` })`;
298
+
299
+ case 'RenameIndex':
300
+ return `migrations.RenameIndex({\n` +
301
+ ` modelName: '${op.table}',\n` +
302
+ ` oldName: '${op.oldName}',\n` +
303
+ ` newName: '${op.newName}',\n` +
304
+ ` })`;
305
+
306
+ case 'AlterUniqueTogether':
307
+ return `migrations.AlterUniqueTogether({\n` +
308
+ ` modelName: '${op.table}',\n` +
309
+ ` newUnique: ${JSON.stringify(op.newUnique)},\n` +
310
+ ` oldUnique: ${JSON.stringify(op.oldUnique)},\n` +
311
+ ` })`;
312
+
225
313
  case 'RunSQL':
226
314
  return `migrations.RunSQL({\n` +
227
315
  ` sql: ${JSON.stringify(op.sql)},\n` +
@@ -458,6 +546,22 @@ ${opsCode},
458
546
  }
459
547
 
460
548
  _fieldsEqual(a, b) { return fieldsEqual(a, b); }
549
+
550
+ /**
551
+ * Returns true when changing from oldType to newType risks data loss.
552
+ * Mirrors Django's check_for_system_checks / check_for_data_loss logic.
553
+ */
554
+ _isDestructiveTypeChange(oldType, newType) {
555
+ if (oldType === newType) return false;
556
+ // String-family — safe between each other
557
+ const stringFamily = new Set(['string', 'text', 'email', 'url', 'slug', 'ipAddress']);
558
+ if (stringFamily.has(oldType) && stringFamily.has(newType)) return false;
559
+ // Number-family — safe between each other (widening)
560
+ const numberFamily = new Set(['integer', 'bigInteger', 'float', 'decimal']);
561
+ if (numberFamily.has(oldType) && numberFamily.has(newType)) return false;
562
+ // Everything else is potentially destructive
563
+ return true;
564
+ }
461
565
  }
462
566
 
463
567
  module.exports = MigrationWriter;
@@ -484,6 +484,10 @@ ${this._renderColumn(' ', diff.column, diff.previous, '.alter()')}
484
484
  return `${indent}t.increments('${name}')${suffix};`;
485
485
 
486
486
  case 'string':
487
+ case 'email':
488
+ case 'url':
489
+ case 'slug':
490
+ case 'ipAddress':
487
491
  line = `t.string('${name}', ${field.max || 255})`;
488
492
  break;
489
493
 
@@ -84,8 +84,10 @@ class ModelScanner {
84
84
 
85
85
  // Build state from the canonical (most-derived) class per table
86
86
  for (const [table, cls] of tableToClass) {
87
- const fields = this._resolveFields(cls, classes, tableToClass);
88
- state.createModel(table, fields);
87
+ const fields = this._resolveFields(cls, classes, tableToClass);
88
+ const indexes = cls.indexes || [];
89
+ const uniqueTogether = cls.uniqueTogether || [];
90
+ state.createModel(table, fields, indexes, uniqueTogether);
89
91
  }
90
92
 
91
93
  return state;
@@ -162,7 +164,7 @@ class ModelScanner {
162
164
 
163
165
 
164
166
  _resolveFields(cls, allClasses, tableToClass) {
165
- const fields = {};
167
+ let fields = {};
166
168
 
167
169
  // Walk the prototype chain collecting fields, child overrides parent
168
170
  const chain = this._inheritanceChain(cls);
@@ -191,10 +193,7 @@ class ModelScanner {
191
193
  if (ancestor.fields && typeof ancestor.fields === 'object') {
192
194
  for (const [name, def] of Object.entries(ancestor.fields)) {
193
195
  if (def && typeof def === 'object' && def.type === 'm2m') continue; // skip M2M
194
- // null = explicit removal, like Django's title = None — remove from merged set
195
196
  if (def === null) { delete fields[name]; continue; }
196
- // Django convention: ForeignKey declared as 'landlord' creates column 'landlord_id'.
197
- // If already ends with _id, use as-is to avoid double-appending.
198
197
  const colName = (def && def._isForeignKey && !name.endsWith('_id'))
199
198
  ? name + '_id'
200
199
  : name;
@@ -203,6 +202,13 @@ class ModelScanner {
203
202
  }
204
203
  }
205
204
 
205
+ // Auto-inject id if no primary key declared — same as Django
206
+ const hasPk = Object.values(fields).some(f => f?.type === 'id' || f?.primary === true);
207
+ if (!hasPk) {
208
+ const { fields: fieldDefs } = require('../fields/index');
209
+ fields = { id: normaliseField(fieldDefs.id()), ...fields };
210
+ }
211
+
206
212
  return fields;
207
213
  }
208
214