millas 0.2.12-beta-1 → 0.2.13-beta
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 +3 -2
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +516 -199
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +318 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +393 -97
- package/src/admin/static/admin.css +1422 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +87 -1046
- package/src/admin/views/pages/detail.njk +56 -21
- package/src/admin/views/pages/error.njk +65 -0
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +270 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +480 -0
- package/src/admin/views/partials/form-widget.njk +297 -0
- package/src/admin/views/partials/icons.njk +64 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/ai/AIManager.js +954 -0
- package/src/ai/AITokenBudget.js +250 -0
- package/src/ai/PromptGuard.js +216 -0
- package/src/ai/agents.js +218 -0
- package/src/ai/conversation.js +213 -0
- package/src/ai/drivers.js +734 -0
- package/src/ai/files.js +249 -0
- package/src/ai/media.js +303 -0
- package/src/ai/pricing.js +152 -0
- package/src/ai/provider_tools.js +114 -0
- package/src/ai/types.js +356 -0
- package/src/auth/Auth.js +18 -2
- package/src/auth/AuthUser.js +65 -44
- package/src/cli.js +3 -1
- package/src/commands/createsuperuser.js +267 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +3 -4
- package/src/container/AppInitializer.js +101 -20
- package/src/container/Application.js +31 -1
- package/src/container/MillasApp.js +10 -3
- package/src/container/MillasConfig.js +35 -6
- package/src/core/admin.js +5 -0
- package/src/core/db.js +2 -1
- package/src/core/foundation.js +2 -10
- package/src/core/lang.js +1 -0
- package/src/errors/HttpError.js +32 -16
- package/src/facades/AI.js +411 -0
- package/src/facades/Hash.js +67 -0
- package/src/facades/Process.js +144 -0
- package/src/hashing/Hash.js +262 -0
- package/src/http/HtmlEscape.js +162 -0
- package/src/http/MillasRequest.js +63 -7
- package/src/http/MillasResponse.js +70 -4
- package/src/http/ResponseDispatcher.js +21 -27
- package/src/http/SafeFilePath.js +195 -0
- package/src/http/SafeRedirect.js +62 -0
- package/src/http/SecurityBootstrap.js +70 -0
- package/src/http/helpers.js +40 -125
- package/src/http/index.js +10 -1
- package/src/http/middleware/CsrfMiddleware.js +258 -0
- package/src/http/middleware/RateLimiter.js +314 -0
- package/src/http/middleware/SecurityHeaders.js +281 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +643 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/logger/LogRedactor.js +247 -0
- package/src/logger/Logger.js +1 -1
- package/src/logger/formatters/JsonFormatter.js +11 -4
- package/src/logger/formatters/PrettyFormatter.js +103 -65
- package/src/logger/formatters/SimpleFormatter.js +14 -3
- package/src/middleware/ThrottleMiddleware.js +27 -4
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +143 -74
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/process/Process.js +333 -0
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +40 -5
- package/src/providers/CacheStorageServiceProvider.js +2 -2
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/LogServiceProvider.js +4 -1
- package/src/providers/MailServiceProvider.js +1 -1
- package/src/providers/QueueServiceProvider.js +1 -1
- package/src/router/MiddlewareRegistry.js +27 -2
- package/src/scaffold/templates.js +80 -21
- package/src/validation/Validator.js +348 -607
|
@@ -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;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { applyColumn } = require('./operations/column');
|
|
4
|
+
const { normaliseField } = require('./ProjectState');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* SchemaBuilder
|
|
@@ -27,7 +28,7 @@ class SchemaBuilder {
|
|
|
27
28
|
const fields = ModelClass.fields;
|
|
28
29
|
|
|
29
30
|
await this._db.schema.createTable(table, (t) => {
|
|
30
|
-
this._applyFields(t, fields
|
|
31
|
+
this._applyFields(t, fields);
|
|
31
32
|
});
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -87,87 +88,13 @@ class SchemaBuilder {
|
|
|
87
88
|
|
|
88
89
|
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
// Delegates to operations/column.js applyColumn — single source of truth
|
|
92
|
+
// for the type → knex column builder mapping.
|
|
93
|
+
_applyFields(tableBuilder, fields) {
|
|
91
94
|
for (const [name, field] of Object.entries(fields)) {
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
_applyField(t, name, field) {
|
|
97
|
-
let col;
|
|
98
|
-
|
|
99
|
-
switch (field.type) {
|
|
100
|
-
case 'id':
|
|
101
|
-
t.increments(name);
|
|
102
|
-
return;
|
|
103
|
-
|
|
104
|
-
case 'string':
|
|
105
|
-
col = t.string(name, field.max || 255);
|
|
106
|
-
break;
|
|
107
|
-
|
|
108
|
-
case 'text':
|
|
109
|
-
col = t.text(name);
|
|
110
|
-
break;
|
|
111
|
-
|
|
112
|
-
case 'integer':
|
|
113
|
-
col = field.unsigned ? t.integer(name).unsigned() : t.integer(name);
|
|
114
|
-
break;
|
|
115
|
-
|
|
116
|
-
case 'bigInteger':
|
|
117
|
-
col = field.unsigned ? t.bigInteger(name).unsigned() : t.bigInteger(name);
|
|
118
|
-
break;
|
|
119
|
-
|
|
120
|
-
case 'float':
|
|
121
|
-
col = t.float(name);
|
|
122
|
-
break;
|
|
123
|
-
|
|
124
|
-
case 'decimal':
|
|
125
|
-
col = t.decimal(name, field.precision || 8, field.scale || 2);
|
|
126
|
-
break;
|
|
127
|
-
|
|
128
|
-
case 'boolean':
|
|
129
|
-
col = t.boolean(name);
|
|
130
|
-
break;
|
|
131
|
-
|
|
132
|
-
case 'json':
|
|
133
|
-
col = t.json(name);
|
|
134
|
-
break;
|
|
135
|
-
|
|
136
|
-
case 'date':
|
|
137
|
-
col = t.date(name);
|
|
138
|
-
break;
|
|
139
|
-
|
|
140
|
-
case 'timestamp':
|
|
141
|
-
col = t.timestamp(name, { useTz: false });
|
|
142
|
-
break;
|
|
143
|
-
|
|
144
|
-
case 'enum':
|
|
145
|
-
col = t.enum(name, field.enumValues || []);
|
|
146
|
-
break;
|
|
147
|
-
|
|
148
|
-
case 'uuid':
|
|
149
|
-
col = t.uuid(name);
|
|
150
|
-
break;
|
|
151
|
-
|
|
152
|
-
default:
|
|
153
|
-
col = t.string(name);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (!col) return;
|
|
157
|
-
|
|
158
|
-
if (field.nullable) col = col.nullable();
|
|
159
|
-
else if (field.type !== 'id') col = col.notNullable();
|
|
160
|
-
|
|
161
|
-
if (field.unique) col = col.unique();
|
|
162
|
-
|
|
163
|
-
if (field.default !== undefined) col = col.defaultTo(field.default);
|
|
164
|
-
|
|
165
|
-
if (field.references) {
|
|
166
|
-
col = col.references(field.references.column)
|
|
167
|
-
.inTable(field.references.table)
|
|
168
|
-
.onDelete('CASCADE');
|
|
95
|
+
applyColumn(tableBuilder, name, normaliseField(field));
|
|
169
96
|
}
|
|
170
97
|
}
|
|
171
98
|
}
|
|
172
99
|
|
|
173
|
-
module.exports = SchemaBuilder;
|
|
100
|
+
module.exports = SchemaBuilder;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BaseOperation
|
|
5
|
+
*
|
|
6
|
+
* Abstract base class for all migration operations.
|
|
7
|
+
*
|
|
8
|
+
* Every operation must implement:
|
|
9
|
+
* applyState(projectState) — mutate the in-memory ProjectState (no DB touch)
|
|
10
|
+
* up(db) — apply the change to the live database
|
|
11
|
+
* down(db) — revert the change from the live database
|
|
12
|
+
* toJSON() — return a plain serialisable descriptor
|
|
13
|
+
*
|
|
14
|
+
* The `type` property is set by each subclass and must match the key used
|
|
15
|
+
* in the deserialise() registry in registry.js.
|
|
16
|
+
*/
|
|
17
|
+
class BaseOperation {
|
|
18
|
+
/**
|
|
19
|
+
* Mutate the in-memory ProjectState.
|
|
20
|
+
* Called during migration graph replay (makemigrations) — never touches DB.
|
|
21
|
+
* @param {import('../ProjectState').ProjectState} _state
|
|
22
|
+
*/
|
|
23
|
+
// eslint-disable-next-line no-unused-vars
|
|
24
|
+
applyState(_state) {
|
|
25
|
+
throw new Error(`${this.constructor.name}.applyState() not implemented`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Apply this operation to the live database (forward migration).
|
|
30
|
+
* @param {import('knex').Knex} _db
|
|
31
|
+
*/
|
|
32
|
+
// eslint-disable-next-line no-unused-vars
|
|
33
|
+
async up(_db) {
|
|
34
|
+
throw new Error(`${this.constructor.name}.up() not implemented`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Revert this operation from the live database (rollback).
|
|
39
|
+
* @param {import('knex').Knex} _db
|
|
40
|
+
*/
|
|
41
|
+
// eslint-disable-next-line no-unused-vars
|
|
42
|
+
async down(_db) {
|
|
43
|
+
throw new Error(`${this.constructor.name}.down() not implemented`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return a plain, JSON-serialisable descriptor for this operation.
|
|
48
|
+
* Used by MigrationWriter to write migration files and by MigrationGraph
|
|
49
|
+
* to reload them via deserialise().
|
|
50
|
+
* @returns {object}
|
|
51
|
+
*/
|
|
52
|
+
toJSON() {
|
|
53
|
+
throw new Error(`${this.constructor.name}.toJSON() not implemented`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { BaseOperation };
|