millas 0.2.19 → 0.2.20

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.20",
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": {
@@ -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) => {
@@ -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
 
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { tableFromClass, modelNameToTable, isSnakeCase } = require('./utils');
4
+ const { indexName } = require('./operations/indexes');
4
5
 
5
6
  /**
6
7
  * ProjectState
@@ -29,7 +30,7 @@ class ProjectState {
29
30
 
30
31
  // ─── Mutation (called by operations during replay) ────────────────────────
31
32
 
32
- createModel(table, fields) {
33
+ createModel(table, fields, indexes = [], uniqueTogether = []) {
33
34
  if (this.models.has(table)) {
34
35
  throw new Error(`ProjectState: table "${table}" already exists`);
35
36
  }
@@ -37,13 +38,39 @@ class ProjectState {
37
38
  for (const [name, def] of Object.entries(fields)) {
38
39
  fieldMap.set(name, normaliseField(def));
39
40
  }
40
- this.models.set(table, { table, fields: fieldMap });
41
+ this.models.set(table, { table, fields: fieldMap, indexes: [...indexes], uniqueTogether: [...uniqueTogether] });
41
42
  }
42
43
 
43
44
  deleteModel(table) {
44
45
  this.models.delete(table);
45
46
  }
46
47
 
48
+ addIndex(table, index) {
49
+ const model = this._requireModel(table);
50
+ model.indexes = model.indexes || [];
51
+ model.indexes.push(index);
52
+ }
53
+
54
+ removeIndex(table, index) {
55
+ const model = this._requireModel(table);
56
+ model.indexes = (model.indexes || []).filter(
57
+ i => JSON.stringify(i) !== JSON.stringify(index)
58
+ );
59
+ }
60
+
61
+ renameIndex(table, oldName, newName) {
62
+ const model = this._requireModel(table);
63
+ model.indexes = (model.indexes || []).map(i => {
64
+ const iName = i.name || indexName(model.table, i.fields, i.unique);
65
+ return iName === oldName ? { ...i, name: newName } : i;
66
+ });
67
+ }
68
+
69
+ alterUniqueTogether(table, uniqueTogether) {
70
+ const model = this._requireModel(table);
71
+ model.uniqueTogether = uniqueTogether;
72
+ }
73
+
47
74
  addField(table, column, fieldDef) {
48
75
  const model = this._requireModel(table);
49
76
  if (model.fields.has(column)) {
@@ -91,9 +118,13 @@ class ProjectState {
91
118
  toSchema() {
92
119
  const schema = {};
93
120
  for (const [table, model] of this.models) {
94
- schema[table] = {};
121
+ schema[table] = {
122
+ fields: {},
123
+ indexes: model.indexes || [],
124
+ uniqueTogether: model.uniqueTogether || [],
125
+ };
95
126
  for (const [col, def] of model.fields) {
96
- schema[table][col] = { ...def };
127
+ schema[table].fields[col] = { ...def };
97
128
  }
98
129
  }
99
130
  return schema;
@@ -107,7 +138,12 @@ class ProjectState {
107
138
  for (const [col, def] of model.fields) {
108
139
  fieldMap.set(col, { ...def });
109
140
  }
110
- copy.models.set(table, { table, fields: fieldMap });
141
+ copy.models.set(table, {
142
+ table,
143
+ fields: fieldMap,
144
+ indexes: JSON.parse(JSON.stringify(model.indexes || [])),
145
+ uniqueTogether: JSON.parse(JSON.stringify(model.uniqueTogether || [])),
146
+ });
111
147
  }
112
148
  return copy;
113
149
  }
@@ -109,6 +109,10 @@ function _buildColumn(t, name, def, opts = {}) {
109
109
  return null; // increments() doesn't return a chainable column builder
110
110
 
111
111
  case 'string':
112
+ case 'email':
113
+ case 'url':
114
+ case 'slug':
115
+ case 'ipAddress':
112
116
  return t.string(name, def.max || 255);
113
117
 
114
118
  case 'text':
@@ -22,34 +22,17 @@ const { applyColumn, alterColumn,
22
22
  const { CreateModel, DeleteModel, RenameModel } = require('./models');
23
23
  const { AddField, RemoveField,
24
24
  AlterField, RenameField } = require('./fields');
25
+ const { AddIndex, RemoveIndex,
26
+ AlterUniqueTogether, RenameIndex } = require('./indexes');
25
27
  const { RunSQL } = require('./special');
26
28
  const { deserialise, migrations, _tableFromName } = require('./registry');
27
29
 
28
30
  module.exports = {
29
- // Base
30
31
  BaseOperation,
31
-
32
- // Column helpers
33
- applyColumn,
34
- alterColumn,
35
- attachFKConstraints,
36
-
37
- // Table-level ops
38
- CreateModel,
39
- DeleteModel,
40
- RenameModel,
41
-
42
- // Field-level ops
43
- AddField,
44
- RemoveField,
45
- AlterField,
46
- RenameField,
47
-
48
- // Escape hatch
32
+ applyColumn, alterColumn, attachFKConstraints,
33
+ CreateModel, DeleteModel, RenameModel,
34
+ AddField, RemoveField, AlterField, RenameField,
35
+ AddIndex, RemoveIndex, AlterUniqueTogether, RenameIndex,
49
36
  RunSQL,
50
-
51
- // Registry
52
- deserialise,
53
- migrations,
54
- _tableFromName,
37
+ deserialise, migrations, _tableFromName,
55
38
  };
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ const { BaseOperation } = require('./base');
4
+
5
+ // ─── helpers ──────────────────────────────────────────────────────────────────
6
+
7
+ function indexName(table, fields, unique = false) {
8
+ const fieldPart = fields.map(f => f.replace(/^-/, '')).join('_');
9
+ return `${table}_${fieldPart}_${unique ? 'unique' : 'index'}`;
10
+ }
11
+
12
+ function _applyIndex(t, table, idx) {
13
+ const { fields, name, unique } = idx;
14
+ const idxName = name || indexName(table, fields, unique);
15
+ // Separate plain fields from descending fields (prefixed with '-')
16
+ const columns = fields.map(f => f.replace(/^-/, ''));
17
+ const hasDesc = fields.some(f => f.startsWith('-'));
18
+
19
+ if (unique) {
20
+ t.unique(columns, { indexName: idxName });
21
+ } else if (hasDesc) {
22
+ // knex doesn't support per-column sort direction in t.index() —
23
+ // use raw for descending indexes
24
+ const colsSql = fields.map(f =>
25
+ f.startsWith('-') ? `\`${f.slice(1)}\` DESC` : `\`${f}\``
26
+ ).join(', ');
27
+ t.index(columns, idxName); // fallback — knex limitation
28
+ // Note: true DESC index requires raw SQL; knex wraps it as regular index
29
+ } else {
30
+ t.index(columns, idxName);
31
+ }
32
+ }
33
+
34
+ // ─── AddIndex ─────────────────────────────────────────────────────────────────
35
+
36
+ class AddIndex extends BaseOperation {
37
+ /**
38
+ * @param {string} table
39
+ * @param {object} index — { fields, name?, unique? }
40
+ */
41
+ constructor(table, index) {
42
+ super();
43
+ this.type = 'AddIndex';
44
+ this.table = table;
45
+ this.index = index;
46
+ }
47
+
48
+ applyState(state) {
49
+ state.addIndex(this.table, this.index);
50
+ }
51
+
52
+ async up(db) {
53
+ const { fields, name, unique } = this.index;
54
+ const idxName = name || indexName(this.table, fields, unique);
55
+ await db.schema.table(this.table, (t) => _applyIndex(t, this.table, this.index));
56
+ }
57
+
58
+ async down(db) {
59
+ const { fields, name, unique } = this.index;
60
+ const idxName = name || indexName(this.table, fields, unique);
61
+ const columns = fields.map(f => f.replace(/^-/, ''));
62
+ await db.schema.table(this.table, (t) => {
63
+ if (unique) t.dropUnique(columns, idxName);
64
+ else t.dropIndex(columns, idxName);
65
+ });
66
+ }
67
+
68
+ toJSON() {
69
+ return { type: 'AddIndex', table: this.table, index: this.index };
70
+ }
71
+ }
72
+
73
+ // ─── RemoveIndex ──────────────────────────────────────────────────────────────
74
+
75
+ class RemoveIndex extends BaseOperation {
76
+ constructor(table, index) {
77
+ super();
78
+ this.type = 'RemoveIndex';
79
+ this.table = table;
80
+ this.index = index;
81
+ }
82
+
83
+ applyState(state) {
84
+ state.removeIndex(this.table, this.index);
85
+ }
86
+
87
+ async up(db) {
88
+ const { fields, name, unique } = this.index;
89
+ const idxName = name || indexName(this.table, fields, unique);
90
+ const columns = fields.map(f => f.replace(/^-/, ''));
91
+ await db.schema.table(this.table, (t) => {
92
+ if (unique) t.dropUnique(columns, idxName);
93
+ else t.dropIndex(columns, idxName);
94
+ });
95
+ }
96
+
97
+ async down(db) {
98
+ await db.schema.table(this.table, (t) => _applyIndex(t, this.table, this.index));
99
+ }
100
+
101
+ toJSON() {
102
+ return { type: 'RemoveIndex', table: this.table, index: this.index };
103
+ }
104
+ }
105
+
106
+ // ─── AlterUniqueTogether ──────────────────────────────────────────────────────
107
+
108
+ class AlterUniqueTogether extends BaseOperation {
109
+ /**
110
+ * @param {string} table
111
+ * @param {string[][]} newUnique — new uniqueTogether sets
112
+ * @param {string[][]} oldUnique — previous uniqueTogether sets (for down())
113
+ */
114
+ constructor(table, newUnique, oldUnique = []) {
115
+ super();
116
+ this.type = 'AlterUniqueTogether';
117
+ this.table = table;
118
+ this.newUnique = newUnique;
119
+ this.oldUnique = oldUnique;
120
+ }
121
+
122
+ applyState(state) {
123
+ state.alterUniqueTogether(this.table, this.newUnique);
124
+ }
125
+
126
+ async up(db) {
127
+ await this._apply(db, this.oldUnique, this.newUnique);
128
+ }
129
+
130
+ async down(db) {
131
+ await this._apply(db, this.newUnique, this.oldUnique);
132
+ }
133
+
134
+ async _apply(db, remove, add) {
135
+ await db.schema.table(this.table, (t) => {
136
+ for (const fields of remove) {
137
+ const name = `${this.table}_${fields.join('_')}_unique`;
138
+ try { t.dropUnique(fields, name); } catch { /* already gone */ }
139
+ }
140
+ for (const fields of add) {
141
+ const name = `${this.table}_${fields.join('_')}_unique`;
142
+ t.unique(fields, { indexName: name });
143
+ }
144
+ });
145
+ }
146
+
147
+ toJSON() {
148
+ return {
149
+ type: 'AlterUniqueTogether',
150
+ table: this.table,
151
+ newUnique: this.newUnique,
152
+ oldUnique: this.oldUnique,
153
+ };
154
+ }
155
+ }
156
+
157
+ // ─── RenameIndex ─────────────────────────────────────────────────────────────
158
+
159
+ class RenameIndex extends BaseOperation {
160
+ constructor(table, oldName, newName) {
161
+ super();
162
+ this.type = 'RenameIndex';
163
+ this.table = table;
164
+ this.oldName = oldName;
165
+ this.newName = newName;
166
+ }
167
+
168
+ applyState(state) {
169
+ state.renameIndex(this.table, this.oldName, this.newName);
170
+ }
171
+
172
+ async up(db) {
173
+ // knex doesn't have renameIndex — drop and recreate
174
+ // The index definition is stored on the op for reconstruction
175
+ await db.schema.table(this.table, (t) => {
176
+ t.dropIndex([], this.oldName);
177
+ });
178
+ await db.schema.table(this.table, (t) => {
179
+ t.index(this.fields || [], this.newName);
180
+ });
181
+ }
182
+
183
+ async down(db) {
184
+ await db.schema.table(this.table, (t) => {
185
+ t.dropIndex([], this.newName);
186
+ });
187
+ await db.schema.table(this.table, (t) => {
188
+ t.index(this.fields || [], this.oldName);
189
+ });
190
+ }
191
+
192
+ toJSON() {
193
+ return { type: 'RenameIndex', table: this.table, oldName: this.oldName, newName: this.newName, fields: this.fields };
194
+ }
195
+ }
196
+
197
+ module.exports = { AddIndex, RemoveIndex, AlterUniqueTogether, RenameIndex, indexName, _applyIndex };
@@ -28,16 +28,20 @@ class CreateModel extends BaseOperation {
28
28
  /**
29
29
  * @param {string} table
30
30
  * @param {object} fields — { columnName: normalisedFieldDef }
31
+ * @param {Array} [indexes]
32
+ * @param {Array} [uniqueTogether]
31
33
  */
32
- constructor(table, fields) {
34
+ constructor(table, fields, indexes = [], uniqueTogether = []) {
33
35
  super();
34
- this.type = 'CreateModel';
35
- this.table = table;
36
- this.fields = fields;
36
+ this.type = 'CreateModel';
37
+ this.table = table;
38
+ this.fields = fields;
39
+ this.indexes = indexes;
40
+ this.uniqueTogether = uniqueTogether;
37
41
  }
38
42
 
39
43
  applyState(state) {
40
- state.createModel(this.table, this.fields);
44
+ state.createModel(this.table, this.fields, this.indexes, this.uniqueTogether);
41
45
  }
42
46
 
43
47
  // Standard up() — inline FKs. Safe when only one CreateModel in a migration.
@@ -47,6 +51,7 @@ class CreateModel extends BaseOperation {
47
51
  applyColumn(t, name, normaliseField(def));
48
52
  }
49
53
  });
54
+ await this._applyIndexes(db);
50
55
  }
51
56
 
52
57
  /**
@@ -59,6 +64,7 @@ class CreateModel extends BaseOperation {
59
64
  applyColumn(t, name, { ...normaliseField(def), references: null });
60
65
  }
61
66
  });
67
+ await this._applyIndexes(db);
62
68
  }
63
69
 
64
70
  /**
@@ -76,8 +82,28 @@ class CreateModel extends BaseOperation {
76
82
  await db.schema.dropTableIfExists(this.table);
77
83
  }
78
84
 
85
+ async _applyIndexes(db) {
86
+ const { indexName } = require('./indexes');
87
+ const all = [
88
+ ...(this.indexes || []),
89
+ ];
90
+ const ut = this.uniqueTogether || [];
91
+ if (!all.length && !ut.length) return;
92
+ await db.schema.table(this.table, (t) => {
93
+ for (const idx of all) {
94
+ const name = idx.name || indexName(this.table, idx.fields, idx.unique);
95
+ if (idx.unique) t.unique(idx.fields, { indexName: name });
96
+ else t.index(idx.fields, name);
97
+ }
98
+ for (const fields of ut) {
99
+ const name = `${this.table}_${fields.join('_')}_unique`;
100
+ t.unique(fields, { indexName: name });
101
+ }
102
+ });
103
+ }
104
+
79
105
  toJSON() {
80
- return { type: 'CreateModel', table: this.table, fields: this.fields };
106
+ return { type: 'CreateModel', table: this.table, fields: this.fields, indexes: this.indexes, uniqueTogether: this.uniqueTogether };
81
107
  }
82
108
  }
83
109
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { CreateModel, DeleteModel, RenameModel } = require('./models');
4
4
  const { AddField, RemoveField, AlterField, RenameField } = require('./fields');
5
+ const { AddIndex, RemoveIndex, AlterUniqueTogether, RenameIndex } = require('./indexes');
5
6
  const { RunSQL } = require('./special');
6
7
  const { modelNameToTable, isSnakeCase } = require('../utils');
7
8
 
@@ -42,13 +43,17 @@ const { modelNameToTable, isSnakeCase } = require('../utils');
42
43
  */
43
44
  function deserialise(op) {
44
45
  switch (op.type) {
45
- case 'CreateModel': return new CreateModel(op.table, op.fields);
46
+ case 'CreateModel': return new CreateModel(op.table, op.fields, op.indexes || [], op.uniqueTogether || []);
46
47
  case 'DeleteModel': return new DeleteModel(op.table, op.fields);
47
48
  case 'RenameModel': return new RenameModel(op.oldTable, op.newTable);
48
49
  case 'AddField': return new AddField(op.table, op.column, op.field, op.oneOffDefault);
49
50
  case 'RemoveField': return new RemoveField(op.table, op.column, op.field);
50
51
  case 'AlterField': return new AlterField(op.table, op.column, op.field, op.previousField);
51
52
  case 'RenameField': return new RenameField(op.table, op.oldColumn, op.newColumn);
53
+ case 'AddIndex': return new AddIndex(op.table, op.index);
54
+ case 'RemoveIndex': return new RemoveIndex(op.table, op.index);
55
+ case 'RenameIndex': return new RenameIndex(op.table, op.oldName, op.newName);
56
+ case 'AlterUniqueTogether': return new AlterUniqueTogether(op.table, op.newUnique, op.oldUnique);
52
57
  case 'RunSQL': return new RunSQL(op.sql, op.reverseSql);
53
58
  default:
54
59
  throw new Error(`Unknown migration operation type: "${op.type}"`);
@@ -63,10 +68,10 @@ function deserialise(op) {
63
68
  */
64
69
  const migrations = {
65
70
 
66
- CreateModel({ name, fields: fieldList = [] }) {
71
+ CreateModel({ name, fields: fieldList = [], indexes = [], uniqueTogether = [] }) {
67
72
  const fields = {};
68
73
  for (const [col, def] of fieldList) fields[col] = def;
69
- return { type: 'CreateModel', table: _tableFromName(name), fields };
74
+ return { type: 'CreateModel', table: _tableFromName(name), fields, indexes, uniqueTogether };
70
75
  },
71
76
 
72
77
  DeleteModel({ name, fields: fieldList = [] }) {
@@ -101,6 +106,22 @@ const migrations = {
101
106
  return { type: 'RenameField', table: modelName, oldColumn: oldName, newColumn: newName };
102
107
  },
103
108
 
109
+ AddIndex({ modelName, index }) {
110
+ return { type: 'AddIndex', table: modelName, index };
111
+ },
112
+
113
+ RemoveIndex({ modelName, index }) {
114
+ return { type: 'RemoveIndex', table: modelName, index };
115
+ },
116
+
117
+ RenameIndex({ modelName, oldName, newName }) {
118
+ return { type: 'RenameIndex', table: modelName, oldName, newName };
119
+ },
120
+
121
+ AlterUniqueTogether({ modelName, newUnique, oldUnique = [] }) {
122
+ return { type: 'AlterUniqueTogether', table: modelName, newUnique, oldUnique };
123
+ },
124
+
104
125
  RunSQL({ sql, reverseSql = null }) {
105
126
  return { type: 'RunSQL', sql, reverseSql };
106
127
  },
@@ -173,16 +173,22 @@ class Model {
173
173
 
174
174
  while (cur && cur !== Function.prototype) {
175
175
  if (Object.prototype.hasOwnProperty.call(cur, 'fields')) {
176
- chain.unshift(cur.fields); // ancestor first → child wins in Object.assign
176
+ chain.unshift(cur.fields);
177
177
  }
178
178
  const curTable = cur.table || cur.name;
179
- // Stop walking when we reach a non-abstract ancestor with a different table
180
- // (that's a separate model with its own migration — don't merge its fields)
181
179
  if (cur !== this && !cur.abstract && curTable !== myTable) break;
182
180
  cur = Object.getPrototypeOf(cur);
183
181
  }
184
182
 
185
- const merged = Object.assign({}, ...chain);
183
+ let merged = Object.assign({}, ...chain);
184
+
185
+ // Auto-inject id if no primary key is declared — same as Django
186
+ const hasPk = Object.values(merged).some(f => f?.primary === true || f?.type === 'id');
187
+ if (!hasPk) {
188
+ const { fields } = require('../fields/index');
189
+ merged = { id: fields.id(), ...merged };
190
+ }
191
+
186
192
  Object.defineProperty(this, '_cachedFields', {
187
193
  value: merged, writable: true, configurable: true, enumerable: false,
188
194
  });
@@ -659,7 +665,7 @@ class Model {
659
665
  };
660
666
 
661
667
  payload = await this.constructor.beforeUpdate(payload) ?? payload;
662
- payload = this.constructor._serializeForDb(payload);
668
+ const dbPayload = this.constructor._serializeForDb(payload);
663
669
 
664
670
  const q = trx
665
671
  ? trx(this.constructor.table)
@@ -667,9 +673,9 @@ class Model {
667
673
 
668
674
  await q
669
675
  .where(this.constructor.primaryKey, this[this.constructor.primaryKey])
670
- .update(payload);
676
+ .update(dbPayload);
671
677
 
672
- Object.assign(this, payload);
678
+ Object.assign(this, payload); // keep JS types on the instance, not serialized values
673
679
  await this.constructor.afterUpdate(this);
674
680
  return this;
675
681
  }
@@ -774,15 +780,21 @@ class Model {
774
780
  static _castValue(val, type) {
775
781
  if (val == null) return val;
776
782
  switch (type) {
777
- case 'boolean': return Boolean(val);
778
- case 'integer': return Number.isInteger(val) ? val : parseInt(val, 10);
779
- case 'bigInteger':return typeof val === 'bigint' ? val : parseInt(val, 10);
783
+ case 'boolean': return Boolean(val);
784
+ case 'integer': return Number.isInteger(val) ? val : parseInt(val, 10);
785
+ case 'bigInteger': return typeof val === 'bigint' ? val : parseInt(val, 10);
780
786
  case 'float':
781
- case 'decimal': return typeof val === 'number' ? val : parseFloat(val);
782
- case 'json': return typeof val === 'string' ? JSON.parse(val) : val;
787
+ case 'decimal': return typeof val === 'number' ? val : parseFloat(val);
788
+ case 'json': return typeof val === 'string' ? JSON.parse(val) : val;
783
789
  case 'date':
784
- case 'timestamp': return val instanceof Date ? val : new Date(val);
785
- default: return val;
790
+ case 'timestamp': return val instanceof Date ? val : new Date(val);
791
+ // string-backed types — no casting needed
792
+ case 'string':
793
+ case 'email':
794
+ case 'url':
795
+ case 'slug':
796
+ case 'ipAddress': return String(val);
797
+ default: return val;
786
798
  }
787
799
  }
788
800
 
@@ -381,7 +381,7 @@ class QueryBuilder {
381
381
  async _eagerLoad(instances) {
382
382
  if (!instances.length) return;
383
383
 
384
- const relations = this._model.relations || {};
384
+ const relations = this._model._effectiveRelations();
385
385
 
386
386
  for (const { name, constraint, aggregate, aggregateColumn } of this._withs) {
387
387