millas 0.2.12-beta → 0.2.12-beta-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/package.json +3 -16
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +400 -167
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +309 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +383 -97
  12. package/src/admin/static/admin.css +1341 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +65 -1013
  19. package/src/admin/views/pages/detail.njk +40 -16
  20. package/src/admin/views/pages/form.njk +47 -599
  21. package/src/admin/views/pages/list.njk +145 -62
  22. package/src/admin/views/partials/form-field.njk +53 -0
  23. package/src/admin/views/partials/form-footer.njk +28 -0
  24. package/src/admin/views/partials/form-readonly.njk +114 -0
  25. package/src/admin/views/partials/form-scripts.njk +476 -0
  26. package/src/admin/views/partials/form-widget.njk +296 -0
  27. package/src/admin/views/partials/json-dialog.njk +80 -0
  28. package/src/admin/views/partials/json-editor.njk +37 -0
  29. package/src/admin.zip +0 -0
  30. package/src/auth/Auth.js +31 -10
  31. package/src/auth/AuthController.js +3 -1
  32. package/src/auth/AuthUser.js +119 -0
  33. package/src/cli.js +4 -2
  34. package/src/commands/createsuperuser.js +254 -0
  35. package/src/commands/lang.js +589 -0
  36. package/src/commands/migrate.js +154 -81
  37. package/src/commands/serve.js +82 -110
  38. package/src/container/AppInitializer.js +215 -0
  39. package/src/container/Application.js +278 -253
  40. package/src/container/HttpServer.js +156 -0
  41. package/src/container/MillasApp.js +29 -279
  42. package/src/container/MillasConfig.js +192 -0
  43. package/src/core/admin.js +5 -0
  44. package/src/core/auth.js +9 -0
  45. package/src/core/db.js +9 -0
  46. package/src/core/foundation.js +59 -0
  47. package/src/core/http.js +11 -0
  48. package/src/core/lang.js +1 -0
  49. package/src/core/mail.js +6 -0
  50. package/src/core/queue.js +7 -0
  51. package/src/core/validation.js +29 -0
  52. package/src/facades/Admin.js +1 -1
  53. package/src/facades/Auth.js +22 -39
  54. package/src/facades/Cache.js +21 -10
  55. package/src/facades/Database.js +1 -1
  56. package/src/facades/Events.js +18 -17
  57. package/src/facades/Facade.js +197 -0
  58. package/src/facades/Http.js +42 -45
  59. package/src/facades/Log.js +25 -49
  60. package/src/facades/Mail.js +27 -32
  61. package/src/facades/Queue.js +22 -15
  62. package/src/facades/Storage.js +18 -10
  63. package/src/facades/Url.js +53 -0
  64. package/src/http/HttpClient.js +673 -0
  65. package/src/http/ResponseDispatcher.js +18 -111
  66. package/src/http/UrlGenerator.js +375 -0
  67. package/src/http/WelcomePage.js +273 -0
  68. package/src/http/adapters/ExpressAdapter.js +315 -0
  69. package/src/http/adapters/HttpAdapter.js +168 -0
  70. package/src/http/adapters/index.js +9 -0
  71. package/src/i18n/I18nServiceProvider.js +91 -0
  72. package/src/i18n/Translator.js +635 -0
  73. package/src/i18n/defaults.js +122 -0
  74. package/src/i18n/index.js +164 -0
  75. package/src/i18n/locales/en.js +55 -0
  76. package/src/i18n/locales/sw.js +48 -0
  77. package/src/index.js +5 -144
  78. package/src/logger/formatters/PrettyFormatter.js +103 -57
  79. package/src/logger/internal.js +2 -2
  80. package/src/logger/patchConsole.js +91 -81
  81. package/src/middleware/MiddlewareRegistry.js +62 -82
  82. package/src/migrations/system/0001_users.js +21 -0
  83. package/src/migrations/system/0002_admin_log.js +25 -0
  84. package/src/migrations/system/0003_sessions.js +23 -0
  85. package/src/orm/fields/index.js +210 -188
  86. package/src/orm/migration/DefaultValueParser.js +325 -0
  87. package/src/orm/migration/InteractiveResolver.js +191 -0
  88. package/src/orm/migration/Makemigrations.js +312 -0
  89. package/src/orm/migration/MigrationGraph.js +227 -0
  90. package/src/orm/migration/MigrationRunner.js +202 -108
  91. package/src/orm/migration/MigrationWriter.js +463 -0
  92. package/src/orm/migration/ModelInspector.js +412 -344
  93. package/src/orm/migration/ModelScanner.js +225 -0
  94. package/src/orm/migration/ProjectState.js +213 -0
  95. package/src/orm/migration/RenameDetector.js +175 -0
  96. package/src/orm/migration/SchemaBuilder.js +8 -81
  97. package/src/orm/migration/operations/base.js +57 -0
  98. package/src/orm/migration/operations/column.js +191 -0
  99. package/src/orm/migration/operations/fields.js +252 -0
  100. package/src/orm/migration/operations/index.js +55 -0
  101. package/src/orm/migration/operations/models.js +152 -0
  102. package/src/orm/migration/operations/registry.js +131 -0
  103. package/src/orm/migration/operations/special.js +51 -0
  104. package/src/orm/migration/utils.js +208 -0
  105. package/src/orm/model/Model.js +81 -13
  106. package/src/providers/AdminServiceProvider.js +66 -9
  107. package/src/providers/AuthServiceProvider.js +46 -7
  108. package/src/providers/CacheStorageServiceProvider.js +5 -3
  109. package/src/providers/DatabaseServiceProvider.js +3 -2
  110. package/src/providers/EventServiceProvider.js +2 -1
  111. package/src/providers/LogServiceProvider.js +7 -3
  112. package/src/providers/MailServiceProvider.js +4 -3
  113. package/src/providers/QueueServiceProvider.js +4 -3
  114. package/src/router/Router.js +119 -152
  115. package/src/scaffold/templates.js +83 -26
  116. package/src/facades/Validation.js +0 -69
@@ -0,0 +1,225 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs-extra');
4
+ const path = require('path');
5
+ const { ProjectState, normaliseField } = require('./ProjectState');
6
+ const { walkJs, extractClasses, isMillasModel, resolveTable } = require('./utils');
7
+
8
+ /**
9
+ * ModelScanner
10
+ *
11
+ * Scans model files from disk and builds a ProjectState representing
12
+ * the CURRENT state of the schema as defined in source code.
13
+ *
14
+ * This is the "current state" that makemigrations diffs against the
15
+ * "reconstructed state" from migration files.
16
+ *
17
+ * ── Model loading convention ──────────────────────────────────────────────────
18
+ *
19
+ * Models are loaded from a single entry point — app/models/index.js —
20
+ * which must export all models as named exports:
21
+ *
22
+ * // app/models/index.js
23
+ * module.exports = { User, Post, Comment, Order };
24
+ *
25
+ * This mirrors Django's pattern where each app's models/__init__.py
26
+ * explicitly imports and exposes every model class. The developer
27
+ * decides what is registered — ModelScanner never auto-discovers files.
28
+ *
29
+ * Sub-folders are supported as long as index.js re-exports everything:
30
+ *
31
+ * // app/models/index.js
32
+ * const { User } = require('./auth/User');
33
+ * const { Post } = require('./content/Post');
34
+ * module.exports = { User, Post };
35
+ *
36
+ * ── Inheritance handling ──────────────────────────────────────────────────────
37
+ *
38
+ * Abstract Base Class (static abstract = true):
39
+ * - No table created
40
+ * - Fields merged into every concrete subclass
41
+ *
42
+ * Multi-table Inheritance (different static table from parent):
43
+ * - Parent gets its own table (already in migration history)
44
+ * - Child gets its own table
45
+ * - A OneToOne FK from child → parent is auto-injected
46
+ *
47
+ * Same-table inheritance (child overrides fields, same static table):
48
+ * - Treated as one model — most-derived fields win
49
+ *
50
+ * Proxy model (static proxy = true):
51
+ * - No table created, no migration generated
52
+ */
53
+ class ModelScanner {
54
+ constructor(modelsPath) {
55
+ this._modelsPath = modelsPath;
56
+ }
57
+
58
+ /**
59
+ * Scan all model files and return a ProjectState.
60
+ * Never touches the database.
61
+ */
62
+ scan() {
63
+ const state = new ProjectState();
64
+ const classes = this._loadAllClasses();
65
+
66
+ // Two passes: first register concrete tables, then detect relationships
67
+ const tableToClass = new Map(); // table → most-derived class
68
+
69
+ for (const cls of classes) {
70
+ // Skip abstract and proxy models — no table
71
+ // Only skip if the class itself declares abstract/proxy (not inherited)
72
+ if (cls.hasOwnProperty('abstract') && cls.abstract) continue;
73
+ if (cls.hasOwnProperty('proxy') && cls.proxy) continue;
74
+
75
+ const table = resolveTable(cls);
76
+ if (!table) continue;
77
+
78
+ // Keep the most-derived class for each table
79
+ const existing = tableToClass.get(table);
80
+ if (!existing || this._fieldCount(cls) >= this._fieldCount(existing)) {
81
+ tableToClass.set(table, cls);
82
+ }
83
+ }
84
+
85
+ // Build state from the canonical (most-derived) class per table
86
+ for (const [table, cls] of tableToClass) {
87
+ const fields = this._resolveFields(cls, classes, tableToClass);
88
+ state.createModel(table, fields);
89
+ }
90
+
91
+ return state;
92
+ }
93
+
94
+ // ─── Internal ─────────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Load all model classes from app/models/index.js.
98
+ *
99
+ * The index must export all models as named (or default) exports.
100
+ * ModelScanner never auto-discovers individual files — the developer
101
+ * controls what is registered, exactly like Django's models/__init__.py.
102
+ *
103
+ * Falls back to scanning individual .js files in the models directory
104
+ * only if index.js does not exist, so existing projects without an
105
+ * index.js keep working. A warning is printed to encourage migration.
106
+ */
107
+ _loadAllClasses() {
108
+ if (!fs.existsSync(this._modelsPath)) return [];
109
+
110
+ const indexPath = path.join(this._modelsPath, 'index.js');
111
+
112
+ // ── Primary path: load from index.js ────────────────────────────────
113
+ if (fs.existsSync(indexPath)) {
114
+ try { delete require.cache[require.resolve(indexPath)]; } catch {}
115
+ let exported;
116
+ try {
117
+ exported = require(indexPath);
118
+ } catch (err) {
119
+ process.stderr.write(
120
+ ` ✖ [makemigrations] Failed to load app/models/index.js: ${err.message}\n`
121
+ );
122
+ return [];
123
+ }
124
+ const classes = [];
125
+ const candidates = extractClasses(exported);
126
+ for (const cls of candidates) {
127
+ if (isMillasModel(cls)) classes.push(cls);
128
+ }
129
+ return classes;
130
+ }
131
+
132
+ // ── Fallback: walk individual files (legacy — no index.js yet) ───────
133
+ process.stderr.write(
134
+ ` ⚠ [makemigrations] No app/models/index.js found. ` +
135
+ `Create one that exports all your models to follow the recommended pattern.\n` +
136
+ ` Falling back to file scanning (slower, may miss models in subfolders).\n`
137
+ );
138
+
139
+ const classes = [];
140
+ const files = walkJs(this._modelsPath);
141
+
142
+ for (const fullPath of files) {
143
+ try { delete require.cache[require.resolve(fullPath)]; } catch {}
144
+ let exported;
145
+ try {
146
+ exported = require(fullPath);
147
+ } catch (err) {
148
+ process.stderr.write(
149
+ ` ⚠ [makemigrations] Could not load ${path.relative(this._modelsPath, fullPath)}: ${err.message}\n`
150
+ );
151
+ continue;
152
+ }
153
+ const candidates = extractClasses(exported);
154
+ for (const cls of candidates) {
155
+ if (isMillasModel(cls)) classes.push(cls);
156
+ }
157
+ }
158
+
159
+ return classes;
160
+ }
161
+
162
+
163
+
164
+ _resolveFields(cls, allClasses, tableToClass) {
165
+ const fields = {};
166
+
167
+ // Walk the prototype chain collecting fields, child overrides parent
168
+ const chain = this._inheritanceChain(cls);
169
+
170
+ for (const ancestor of chain.reverse()) {
171
+ // Skip ancestors that have their own table (multi-table inheritance)
172
+ // unless they are the starting class itself
173
+ if (ancestor !== cls) {
174
+ const ancestorTable = resolveTable(ancestor);
175
+ if (ancestorTable && ancestorTable !== resolveTable(cls)) {
176
+ // Multi-table: inject OneToOne FK instead of merging fields
177
+ // The FK column name is `${ancestorTable.replace(/s$/, '')}_ptr`
178
+ const ptrCol = `${ancestorTable.replace(/s$/, '')}_ptr`;
179
+ fields[ptrCol] = normaliseField({
180
+ type: 'integer',
181
+ unsigned: true,
182
+ nullable: false,
183
+ unique: true,
184
+ references: { table: ancestorTable, column: 'id', onDelete: 'CASCADE' },
185
+ });
186
+ continue;
187
+ }
188
+ }
189
+
190
+ // Merge fields from this ancestor (skip abstract flag, just take fields)
191
+ if (ancestor.fields && typeof ancestor.fields === 'object') {
192
+ for (const [name, def] of Object.entries(ancestor.fields)) {
193
+ 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
+ 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
+ const colName = (def && def._isForeignKey && !name.endsWith('_id'))
199
+ ? name + '_id'
200
+ : name;
201
+ fields[colName] = normaliseField(def);
202
+ }
203
+ }
204
+ }
205
+
206
+ return fields;
207
+ }
208
+
209
+ _inheritanceChain(cls) {
210
+ const chain = [];
211
+ let current = cls;
212
+ while (current && current.fields !== undefined) {
213
+ chain.push(current);
214
+ current = Object.getPrototypeOf(current);
215
+ }
216
+ return chain; // child first, ancestors last
217
+ }
218
+
219
+ _fieldCount(cls) {
220
+ return cls.fields ? Object.keys(cls.fields).length : 0;
221
+ }
222
+
223
+ }
224
+
225
+ module.exports = ModelScanner;
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+
3
+ const { tableFromClass, modelNameToTable, isSnakeCase } = require('./utils');
4
+
5
+ /**
6
+ * ProjectState
7
+ *
8
+ * An in-memory representation of the full database schema at a given point
9
+ * in migration history. Built by replaying migration operations in order.
10
+ *
11
+ * This is the Django-equivalent of ProjectState / ModelState.
12
+ * It is NEVER derived from the live database — only from migration files.
13
+ *
14
+ * Shape:
15
+ * state.models = Map<tableName, ModelState>
16
+ *
17
+ * ModelState:
18
+ * { table, fields: Map<columnName, FieldState>, meta: {} }
19
+ *
20
+ * FieldState (plain object, serialisable):
21
+ * { type, nullable, unique, default, max, unsigned, enumValues,
22
+ * references, precision, scale }
23
+ */
24
+ class ProjectState {
25
+ constructor() {
26
+ // Map<table, { table, fields: Map<name, fieldState> }>
27
+ this.models = new Map();
28
+ }
29
+
30
+ // ─── Mutation (called by operations during replay) ────────────────────────
31
+
32
+ createModel(table, fields) {
33
+ if (this.models.has(table)) {
34
+ throw new Error(`ProjectState: table "${table}" already exists`);
35
+ }
36
+ const fieldMap = new Map();
37
+ for (const [name, def] of Object.entries(fields)) {
38
+ fieldMap.set(name, normaliseField(def));
39
+ }
40
+ this.models.set(table, { table, fields: fieldMap });
41
+ }
42
+
43
+ deleteModel(table) {
44
+ this.models.delete(table);
45
+ }
46
+
47
+ addField(table, column, fieldDef) {
48
+ const model = this._requireModel(table);
49
+ if (model.fields.has(column)) {
50
+ throw new Error(`ProjectState: column "${column}" already exists on "${table}"`);
51
+ }
52
+ model.fields.set(column, normaliseField(fieldDef));
53
+ }
54
+
55
+ removeField(table, column) {
56
+ const model = this._requireModel(table);
57
+ model.fields.delete(column);
58
+ }
59
+
60
+ alterField(table, column, fieldDef) {
61
+ const model = this._requireModel(table);
62
+ model.fields.set(column, normaliseField(fieldDef));
63
+ }
64
+
65
+ renameField(table, oldColumn, newColumn) {
66
+ const model = this._requireModel(table);
67
+ const def = model.fields.get(oldColumn);
68
+ if (!def) throw new Error(`ProjectState: column "${oldColumn}" not found on "${table}"`);
69
+ model.fields.delete(oldColumn);
70
+ model.fields.set(newColumn, def);
71
+ }
72
+
73
+ renameModel(oldTable, newTable) {
74
+ const model = this._requireModel(oldTable);
75
+ this.models.delete(oldTable);
76
+ model.table = newTable;
77
+ this.models.set(newTable, model);
78
+ }
79
+
80
+ // ─── Queries ──────────────────────────────────────────────────────────────
81
+
82
+ hasTable(table) {
83
+ return this.models.has(table);
84
+ }
85
+
86
+ getFields(table) {
87
+ return this._requireModel(table).fields;
88
+ }
89
+
90
+ /** Return a plain-object snapshot of the full state (for diffing). */
91
+ toSchema() {
92
+ const schema = {};
93
+ for (const [table, model] of this.models) {
94
+ schema[table] = {};
95
+ for (const [col, def] of model.fields) {
96
+ schema[table][col] = { ...def };
97
+ }
98
+ }
99
+ return schema;
100
+ }
101
+
102
+ /** Deep clone — used to capture state at a point in time. */
103
+ clone() {
104
+ const copy = new ProjectState();
105
+ for (const [table, model] of this.models) {
106
+ const fieldMap = new Map();
107
+ for (const [col, def] of model.fields) {
108
+ fieldMap.set(col, { ...def });
109
+ }
110
+ copy.models.set(table, { table, fields: fieldMap });
111
+ }
112
+ return copy;
113
+ }
114
+
115
+ // ─── Internal ─────────────────────────────────────────────────────────────
116
+
117
+ _requireModel(table) {
118
+ const m = this.models.get(table);
119
+ if (!m) throw new Error(`ProjectState: table "${table}" not found`);
120
+ return m;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Normalise a field definition (FieldDefinition instance or plain object)
126
+ * into a stable plain object for storage in ProjectState.
127
+ *
128
+ * Handles both:
129
+ * - Legacy foreignId() / raw references object → references: { table, column, onDelete }
130
+ * - Modern ForeignKey() / OneToOne() → _isForeignKey + _fkModel* resolved here
131
+ *
132
+ * For ForeignKey fields, the target table is resolved eagerly from _fkModel so that
133
+ * the migration system can diff and generate FK constraints correctly.
134
+ */
135
+ function normaliseField(def) {
136
+ if (!def) return { type: 'string', nullable: false, unique: false, default: null, max: null, unsigned: false, enumValues: null, references: null, precision: null, scale: null };
137
+ const type = def.type ?? 'string';
138
+ const precision = def.precision ?? (type === 'decimal' ? 8 : null);
139
+ const scale = def.scale ?? (type === 'decimal' ? 2 : null);
140
+
141
+ // ── Resolve modern ForeignKey / OneToOne references ───────────────────────
142
+ // fields.ForeignKey() stores metadata in _isForeignKey + _fkModel* rather
143
+ // than the legacy `references` plain object. Resolve that here so all
144
+ // downstream code (Operations, SchemaBuilder, MigrationWriter) sees a
145
+ // uniform `references: { table, column, onDelete }` shape.
146
+ let references = def.references ?? null;
147
+ if (def._isForeignKey && !references) {
148
+ const targetTable = _resolveTargetTable(def._fkModel, def._fkModelRef);
149
+ if (targetTable) {
150
+ references = {
151
+ table: targetTable,
152
+ column: def._fkToField ?? 'id',
153
+ onDelete: def._fkOnDelete ?? 'CASCADE',
154
+ };
155
+ }
156
+ }
157
+
158
+ return {
159
+ type,
160
+ nullable: def.nullable ?? false,
161
+ unique: def.unique ?? false,
162
+ default: def.default !== undefined ? def.default : null,
163
+ max: def.max ?? null,
164
+ unsigned: def.unsigned ?? false,
165
+ enumValues: def.enumValues ?? null,
166
+ references,
167
+ precision,
168
+ scale,
169
+ // Preserve FK metadata so MigrationWriter can render fields.ForeignKey(...)
170
+ // instead of a bare fields.integer(...). Stripped from plain objects (migration files).
171
+ _isForeignKey: def._isForeignKey ?? false,
172
+ _isOneToOne: def._isOneToOne ?? false,
173
+ _fkOnDelete: def._fkOnDelete ?? null,
174
+ _fkRelatedName: def._fkRelatedName ?? null,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Resolve a _fkModel / _fkModelRef pair to a table name string.
180
+ *
181
+ * _fkModel may be:
182
+ * - A Model class (has static .table)
183
+ * - A string model name like 'User' or 'self'
184
+ * - null / undefined
185
+ *
186
+ * _fkModelRef is a lazy () => ModelClass resolver generated by _makeModelRef().
187
+ */
188
+ /**
189
+ * Resolve a _fkModel / _fkModelRef pair to a table name string.
190
+ * Delegates to tableFromClass / modelNameToTable from utils.js.
191
+ */
192
+ function _resolveTargetTable(fkModel, fkModelRef) {
193
+ if (fkModel && typeof fkModel === 'function') {
194
+ const table = tableFromClass(fkModel);
195
+ if (table) return table;
196
+ }
197
+ if (typeof fkModelRef === 'function') {
198
+ try {
199
+ const resolved = fkModelRef();
200
+ if (resolved) {
201
+ const table = tableFromClass(resolved);
202
+ if (table) return table;
203
+ }
204
+ } catch { /* unresolvable at scan time */ }
205
+ }
206
+ if (typeof fkModel === 'string' && fkModel !== 'self') {
207
+ return isSnakeCase(fkModel) ? fkModel : modelNameToTable(fkModel);
208
+ }
209
+ return null;
210
+ }
211
+
212
+
213
+ module.exports = { ProjectState, normaliseField };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const readline = require('readline');
4
+
5
+ /**
6
+ * RenameDetector
7
+ *
8
+ * Detects likely field renames during makemigrations and prompts the developer
9
+ * to confirm, exactly as Django does:
10
+ *
11
+ * Was student.age4 renamed to student.age7 (a IntegerField)? [y/N]
12
+ *
13
+ * ── Algorithm ─────────────────────────────────────────────────────────────────
14
+ *
15
+ * For each table with both RemoveField and AddField ops:
16
+ * 1. Find pairs where types match
17
+ * 2. Score similarity (type match required; attribute similarity is a bonus)
18
+ * 3. Prompt for each candidate, highest-score first
19
+ * 4. On confirm → replace the Remove+Add pair with a single RenameField
20
+ * 5. On deny → keep Remove + Add as separate ops
21
+ *
22
+ * Multiple renames in a single run: each candidate is prompted independently.
23
+ * Chained renames across migrations: handled automatically because the history
24
+ * state is replayed from all existing migrations, so the "old name" is always
25
+ * whatever the field is currently called in the DB.
26
+ *
27
+ * ── Matching rules ────────────────────────────────────────────────────────────
28
+ *
29
+ * Required: same table, same type
30
+ * Bonus: same nullable, same default, same max/precision/enumValues
31
+ * No match: different types (an integer cannot rename to a string)
32
+ *
33
+ * ── Non-interactive mode ──────────────────────────────────────────────────────
34
+ *
35
+ * Never prompts. All Remove+Add pairs are kept as-is.
36
+ * This matches Django's --no-input behaviour.
37
+ */
38
+ class RenameDetector {
39
+ constructor(options = {}) {
40
+ this._nonInteractive = options.nonInteractive || !process.stdin.isTTY;
41
+ }
42
+
43
+ /**
44
+ * Given a flat ops list from MigrationWriter.diff(), detect and resolve renames.
45
+ * Returns a new ops list with confirmed renames replaced by RenameField ops.
46
+ *
47
+ * @param {Array<object>} ops
48
+ * @returns {Promise<Array<object>>}
49
+ */
50
+ async detect(ops) {
51
+ if (this._nonInteractive) return ops;
52
+
53
+ // Group RemoveField and AddField by table
54
+ const removes = ops.filter(op => op.type === 'RemoveField');
55
+ const adds = ops.filter(op => op.type === 'AddField');
56
+
57
+ if (removes.length === 0 || adds.length === 0) return ops;
58
+
59
+ // Build rename candidates: (remove, add) pairs on the same table with same type
60
+ const candidates = [];
61
+ for (const rem of removes) {
62
+ for (const add of adds) {
63
+ if (rem.table !== add.table) continue;
64
+ if (rem.field.type !== add.field.type) continue;
65
+
66
+ const score = this._similarity(rem.field, add.field);
67
+ candidates.push({ rem, add, score });
68
+ }
69
+ }
70
+
71
+ if (candidates.length === 0) return ops;
72
+
73
+ // Sort by score descending — highest confidence first
74
+ candidates.sort((a, b) => b.score - a.score);
75
+
76
+ // Track which ops have been consumed by a confirmed rename
77
+ const consumed = new Set(); // op references
78
+
79
+ for (const { rem, add } of candidates) {
80
+ // Skip if either op was already consumed by a previous rename
81
+ if (consumed.has(rem) || consumed.has(add)) continue;
82
+
83
+ const fieldTypeLabel = this._fieldTypeLabel(rem.field);
84
+ const confirmed = await this._ask(rem.table, rem.column, add.column, fieldTypeLabel);
85
+
86
+ if (confirmed) {
87
+ consumed.add(rem);
88
+ consumed.add(add);
89
+ // Mark for replacement — attach the rename info to the RemoveField op
90
+ rem._renameToColumn = add.column;
91
+ }
92
+ }
93
+
94
+ // Rebuild ops list: replace consumed pairs with RenameField, preserve order
95
+ const result = [];
96
+ for (const op of ops) {
97
+ if (consumed.has(op)) {
98
+ if (op.type === 'RemoveField' && op._renameToColumn) {
99
+ // Replace Remove with RenameField
100
+ result.push({
101
+ type: 'RenameField',
102
+ table: op.table,
103
+ oldColumn: op.column,
104
+ newColumn: op._renameToColumn,
105
+ });
106
+ delete op._renameToColumn;
107
+ }
108
+ // Skip the AddField that was consumed — it's been folded into RenameField
109
+ } else {
110
+ result.push(op);
111
+ }
112
+ }
113
+
114
+ return result;
115
+ }
116
+
117
+ // ─── Internals ─────────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Score how similar two field definitions are.
121
+ * Type match is a prerequisite (checked before calling this).
122
+ * Returns a score 0–5: higher = more likely a rename.
123
+ */
124
+ _similarity(a, b) {
125
+ let score = 1; // base: types match
126
+ if (a.nullable === b.nullable) score++;
127
+ if (JSON.stringify(a.default) === JSON.stringify(b.default)) score++;
128
+ if (a.max === b.max) score++;
129
+ if (JSON.stringify(a.enumValues) === JSON.stringify(b.enumValues)) score++;
130
+ if (a.unique === b.unique) score++;
131
+ return score;
132
+ }
133
+
134
+ /**
135
+ * Human-readable field type label — matches Django's "a IntegerField" style.
136
+ */
137
+ _fieldTypeLabel(field) {
138
+ const map = {
139
+ id: 'AutoField',
140
+ string: 'CharField',
141
+ text: 'TextField',
142
+ integer: 'IntegerField',
143
+ bigInteger: 'BigIntegerField',
144
+ float: 'FloatField',
145
+ decimal: 'DecimalField',
146
+ boolean: 'BooleanField',
147
+ json: 'JSONField',
148
+ date: 'DateField',
149
+ timestamp: 'DateTimeField',
150
+ enum: 'CharField',
151
+ uuid: 'UUIDField',
152
+ };
153
+ return map[field.type] || `${field.type}Field`;
154
+ }
155
+
156
+ /**
157
+ * Prompt: Was <table>.<oldCol> renamed to <table>.<newCol> (a <Type>)? [y/N]
158
+ * Returns true if confirmed.
159
+ */
160
+ async _ask(table, oldCol, newCol, typeLabel) {
161
+ return new Promise((resolve) => {
162
+ const question = `Was ${table}.${oldCol} renamed to ${table}.${newCol} (a ${typeLabel})? [y/N] `;
163
+ const rl = readline.createInterface({
164
+ input: process.stdin,
165
+ output: process.stdout,
166
+ });
167
+ rl.question(question, (answer) => {
168
+ rl.close();
169
+ resolve(answer.trim().toLowerCase() === 'y');
170
+ });
171
+ });
172
+ }
173
+ }
174
+
175
+ module.exports = RenameDetector;