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.
- package/package.json +3 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- 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 +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -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 +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -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 +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -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/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- 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 +412 -344
- 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/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- package/src/facades/Validation.js +0 -69
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MigrationWriter
|
|
5
|
+
*
|
|
6
|
+
* Diffs two ProjectState objects and produces a list of Operations,
|
|
7
|
+
* then renders them as a clean, Django-style migration file.
|
|
8
|
+
*
|
|
9
|
+
* Generated file format mirrors Django closely:
|
|
10
|
+
*
|
|
11
|
+
* // Generated by Millas 0.2.12 on 2026-03-17 14:30
|
|
12
|
+
* const { migrations, fields } = require('millas/core/db');
|
|
13
|
+
*
|
|
14
|
+
* module.exports = class Migration {
|
|
15
|
+
* static initial = true; // first migration only
|
|
16
|
+
* static dependencies = [
|
|
17
|
+
* ['system', '0001_users'],
|
|
18
|
+
* ];
|
|
19
|
+
* static operations = [
|
|
20
|
+
* migrations.CreateModel({
|
|
21
|
+
* name: 'Student',
|
|
22
|
+
* fields: [
|
|
23
|
+
* ['id', fields.id()],
|
|
24
|
+
* ['name', fields.string({ max: 100 })],
|
|
25
|
+
* ['age', fields.integer()],
|
|
26
|
+
* ],
|
|
27
|
+
* }),
|
|
28
|
+
* migrations.AddField({
|
|
29
|
+
* modelName: 'student',
|
|
30
|
+
* name: 'slug',
|
|
31
|
+
* field: fields.string({ max: 255 }),
|
|
32
|
+
* preserveDefault: false,
|
|
33
|
+
* }),
|
|
34
|
+
* ];
|
|
35
|
+
* };
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const { fieldsEqual } = require('./utils');
|
|
39
|
+
|
|
40
|
+
const MILLAS_VERSION = (() => {
|
|
41
|
+
try { return require('../../..').version || require('../../../package.json').version; } catch {}
|
|
42
|
+
try { return require('../../../package.json').version; } catch {}
|
|
43
|
+
return '0.x';
|
|
44
|
+
})();
|
|
45
|
+
|
|
46
|
+
// Tables created by system migrations — never appear in app migrations.
|
|
47
|
+
// NOTE: 'users' is intentionally absent — the app owns its own user table
|
|
48
|
+
// by defining a User model that extends AuthUser (abstract). Same as Django's
|
|
49
|
+
// AUTH_USER_MODEL pattern where AbstractUser creates no table.
|
|
50
|
+
const SYSTEM_TABLES = new Set([
|
|
51
|
+
'millas_admin_log',
|
|
52
|
+
'millas_sessions',
|
|
53
|
+
'millas_migrations',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
class MigrationWriter {
|
|
57
|
+
|
|
58
|
+
// ─── Diff ──────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Diff history state vs current state → list of operation descriptors.
|
|
62
|
+
*/
|
|
63
|
+
diff(historyState, currentState) {
|
|
64
|
+
const ops = [];
|
|
65
|
+
const histSch = historyState.toSchema();
|
|
66
|
+
const currSch = currentState.toSchema();
|
|
67
|
+
|
|
68
|
+
// New tables
|
|
69
|
+
const newTableOps = [];
|
|
70
|
+
for (const table of Object.keys(currSch)) {
|
|
71
|
+
if (SYSTEM_TABLES.has(table)) continue;
|
|
72
|
+
if (!histSch[table]) {
|
|
73
|
+
newTableOps.push({ type: 'CreateModel', table, fields: currSch[table] });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
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
|
+
ops.push(...this._sortCreateModels(newTableOps));
|
|
82
|
+
|
|
83
|
+
// Dropped tables
|
|
84
|
+
for (const table of Object.keys(histSch)) {
|
|
85
|
+
if (SYSTEM_TABLES.has(table)) continue;
|
|
86
|
+
if (!currSch[table]) {
|
|
87
|
+
ops.push({ type: 'DeleteModel', table, fields: histSch[table] });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Column-level changes
|
|
92
|
+
for (const table of Object.keys(currSch)) {
|
|
93
|
+
if (SYSTEM_TABLES.has(table)) continue;
|
|
94
|
+
if (!histSch[table]) continue;
|
|
95
|
+
|
|
96
|
+
const curr = currSch[table];
|
|
97
|
+
const prev = histSch[table];
|
|
98
|
+
|
|
99
|
+
for (const col of Object.keys(curr)) {
|
|
100
|
+
if (!prev[col]) {
|
|
101
|
+
const field = curr[col];
|
|
102
|
+
const isDangerous = !field.nullable &&
|
|
103
|
+
(field.default === null || field.default === undefined) &&
|
|
104
|
+
field.type !== 'id' &&
|
|
105
|
+
!!histSch[table];
|
|
106
|
+
ops.push({ type: 'AddField', table, column: col, field, _needsDefault: isDangerous });
|
|
107
|
+
} else if (!this._fieldsEqual(curr[col], prev[col])) {
|
|
108
|
+
ops.push({ type: 'AlterField', table, column: col, field: curr[col], previousField: prev[col] });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const col of Object.keys(prev)) {
|
|
113
|
+
if (!curr[col]) {
|
|
114
|
+
ops.push({ type: 'RemoveField', table, column: col, field: prev[col] });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return ops;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Render ────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render a complete migration file as JavaScript source.
|
|
126
|
+
*
|
|
127
|
+
* @param {Array<object>} ops — from diff(), already resolved
|
|
128
|
+
* @param {Array<Array>} dependencies — [[source, migName], ...]
|
|
129
|
+
* @param {string} name — snake_case migration name
|
|
130
|
+
* @param {object} meta — { initial?, number, date }
|
|
131
|
+
*/
|
|
132
|
+
render(ops, dependencies, name, meta = {}) {
|
|
133
|
+
const date = meta.date || new Date().toISOString().replace('T', ' ').slice(0, 16);
|
|
134
|
+
const isInitial = meta.initial || false;
|
|
135
|
+
|
|
136
|
+
// ── Header ────────────────────────────────────────────────────────────────
|
|
137
|
+
const header = `// Generated by Millas ${MILLAS_VERSION} on ${date}`;
|
|
138
|
+
|
|
139
|
+
// ── Dependencies ──────────────────────────────────────────────────────────
|
|
140
|
+
const depsCode = dependencies.length > 0
|
|
141
|
+
? `[\n${dependencies.map(([s, n]) => ` ['${s}', '${n}'],`).join('\n')}\n ]`
|
|
142
|
+
: '[]';
|
|
143
|
+
|
|
144
|
+
// ── Operations ────────────────────────────────────────────────────────────
|
|
145
|
+
const opsCode = ops.map(op => ' ' + this._renderOp(op)).join(',\n\n');
|
|
146
|
+
|
|
147
|
+
// ── initial flag ─────────────────────────────────────────────────────────
|
|
148
|
+
const initialLine = isInitial ? '\n static initial = true;\n' : '';
|
|
149
|
+
|
|
150
|
+
return `${header}
|
|
151
|
+
|
|
152
|
+
const { migrations, fields } = require('millas/core/db');
|
|
153
|
+
|
|
154
|
+
module.exports = class Migration {
|
|
155
|
+
static dependencies = ${depsCode};
|
|
156
|
+
${initialLine}
|
|
157
|
+
static operations = [
|
|
158
|
+
${opsCode},
|
|
159
|
+
];
|
|
160
|
+
};
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Operation rendering ──────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
_renderOp(op) {
|
|
167
|
+
switch (op.type) {
|
|
168
|
+
|
|
169
|
+
case 'CreateModel':
|
|
170
|
+
return `migrations.CreateModel({\n` +
|
|
171
|
+
` name: '${this._modelName(op.table)}',\n` +
|
|
172
|
+
` fields: [\n` +
|
|
173
|
+
Object.entries(op.fields)
|
|
174
|
+
.map(([col, def]) => ` ['${col}', ${this._renderField(def)}],`)
|
|
175
|
+
.join('\n') + '\n' +
|
|
176
|
+
` ],\n` +
|
|
177
|
+
` })`;
|
|
178
|
+
|
|
179
|
+
case 'DeleteModel':
|
|
180
|
+
// Django omits fields= — not needed in the migration file
|
|
181
|
+
return `migrations.DeleteModel({\n` +
|
|
182
|
+
` name: '${this._modelName(op.table)}',\n` +
|
|
183
|
+
` })`;
|
|
184
|
+
|
|
185
|
+
case 'AddField': {
|
|
186
|
+
const lines = [
|
|
187
|
+
` modelName: '${op.table}',`,
|
|
188
|
+
` name: '${op.column}',`,
|
|
189
|
+
` field: ${this._renderField(op.field)},`,
|
|
190
|
+
];
|
|
191
|
+
if (op.oneOffDefault !== undefined) {
|
|
192
|
+
lines.push(` oneOffDefault: ${this._renderDefault(op.oneOffDefault)},`);
|
|
193
|
+
lines.push(` preserveDefault: false,`);
|
|
194
|
+
}
|
|
195
|
+
return `migrations.AddField({\n${lines.join('\n')}\n })`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case 'RemoveField':
|
|
199
|
+
// Django omits field= — it's not needed in the migration file
|
|
200
|
+
return `migrations.RemoveField({\n` +
|
|
201
|
+
` modelName: '${op.table}',\n` +
|
|
202
|
+
` name: '${op.column}',\n` +
|
|
203
|
+
` })`;
|
|
204
|
+
|
|
205
|
+
case 'AlterField':
|
|
206
|
+
return `migrations.AlterField({\n` +
|
|
207
|
+
` modelName: '${op.table}',\n` +
|
|
208
|
+
` name: '${op.column}',\n` +
|
|
209
|
+
` field: ${this._renderField(op.field)},\n` +
|
|
210
|
+
` })`;
|
|
211
|
+
|
|
212
|
+
case 'RenameField':
|
|
213
|
+
return `migrations.RenameField({\n` +
|
|
214
|
+
` modelName: '${op.table}',\n` +
|
|
215
|
+
` oldName: '${op.oldColumn}',\n` +
|
|
216
|
+
` newName: '${op.newColumn}',\n` +
|
|
217
|
+
` })`;
|
|
218
|
+
|
|
219
|
+
case 'RenameModel':
|
|
220
|
+
return `migrations.RenameModel({\n` +
|
|
221
|
+
` oldName: '${op.oldTable}',\n` +
|
|
222
|
+
` newName: '${op.newTable}',\n` +
|
|
223
|
+
` })`;
|
|
224
|
+
|
|
225
|
+
case 'RunSQL':
|
|
226
|
+
return `migrations.RunSQL({\n` +
|
|
227
|
+
` sql: ${JSON.stringify(op.sql)},\n` +
|
|
228
|
+
(op.reverseSql ? ` reverseSql: ${JSON.stringify(op.reverseSql)},\n` : '') +
|
|
229
|
+
` })`;
|
|
230
|
+
|
|
231
|
+
default:
|
|
232
|
+
return `/* unknown: ${JSON.stringify(op)} */`;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Render a field definition as a readable `fields.xxx(...)` call.
|
|
238
|
+
* This is the key readability improvement over JSON.stringify.
|
|
239
|
+
*
|
|
240
|
+
* Examples:
|
|
241
|
+
* fields.id()
|
|
242
|
+
* fields.string({ max: 100 })
|
|
243
|
+
* fields.string({ max: 255, unique: true })
|
|
244
|
+
* fields.integer({ nullable: true })
|
|
245
|
+
* fields.boolean({ default: false })
|
|
246
|
+
* fields.decimal(10, 4)
|
|
247
|
+
* fields.decimal(10, 4, { default: 0 })
|
|
248
|
+
* fields.enum(['admin', 'user'], { default: 'user' })
|
|
249
|
+
* fields.timestamp({ nullable: true })
|
|
250
|
+
*/
|
|
251
|
+
_renderField(def) {
|
|
252
|
+
if (!def) return 'fields.string()';
|
|
253
|
+
|
|
254
|
+
// ── ForeignKey / OneToOne ─────────────────────────────────────────────────
|
|
255
|
+
// Both are stored as type:'integer' with _isForeignKey:true after normalisation.
|
|
256
|
+
// Render as fields.ForeignKey('table', {...}) / fields.OneToOne('table', {...})
|
|
257
|
+
// so the generated migration file is human-readable and round-trips correctly.
|
|
258
|
+
if (def._isForeignKey && def.references) {
|
|
259
|
+
const fn = def._isOneToOne ? 'OneToOne' : 'ForeignKey';
|
|
260
|
+
const table = def.references.table;
|
|
261
|
+
const opts = {};
|
|
262
|
+
if (def.nullable) opts.nullable = true;
|
|
263
|
+
const onDelete = def._fkOnDelete ?? def.references.onDelete ?? 'CASCADE';
|
|
264
|
+
if (onDelete !== 'CASCADE') opts.onDelete = onDelete;
|
|
265
|
+
const toField = def.references.column ?? 'id';
|
|
266
|
+
if (toField !== 'id') opts.toField = toField;
|
|
267
|
+
if (def._fkRelatedName) opts.relatedName = def._fkRelatedName;
|
|
268
|
+
const optsStr = Object.keys(opts).length
|
|
269
|
+
? `, ${this._renderOpts(opts)}`
|
|
270
|
+
: '';
|
|
271
|
+
return `fields.${fn}('${table}'${optsStr})`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const type = def.type || 'string';
|
|
275
|
+
|
|
276
|
+
// ── decimal: positional args (precision, scale, options) ─────────────────
|
|
277
|
+
// fields.decimal() signature is decimal(precision=8, scale=2, options={})
|
|
278
|
+
// We must NOT pass precision/scale in the options object — they are positional.
|
|
279
|
+
if (type === 'decimal') {
|
|
280
|
+
const precision = def.precision ?? 8;
|
|
281
|
+
const scale = def.scale ?? 2;
|
|
282
|
+
const isDefaultPrecision = precision === 8;
|
|
283
|
+
const isDefaultScale = scale === 2;
|
|
284
|
+
|
|
285
|
+
// Extra options (anything besides precision/scale)
|
|
286
|
+
const extraOpts = {};
|
|
287
|
+
if (def.nullable === true) extraOpts.nullable = true;
|
|
288
|
+
if (def.unique === true) extraOpts.unique = true;
|
|
289
|
+
if (def.default !== null && def.default !== undefined) extraOpts.default = def.default;
|
|
290
|
+
|
|
291
|
+
const hasExtra = Object.keys(extraOpts).length > 0;
|
|
292
|
+
|
|
293
|
+
if (isDefaultPrecision && isDefaultScale && !hasExtra) {
|
|
294
|
+
// fields.decimal()
|
|
295
|
+
return 'fields.decimal()';
|
|
296
|
+
}
|
|
297
|
+
if (isDefaultPrecision && isDefaultScale && hasExtra) {
|
|
298
|
+
// fields.decimal(8, 2, { default: 0 }) — but since 8,2 are defaults, omit them
|
|
299
|
+
// Actually we must pass them explicitly so the reader gets correct positional args
|
|
300
|
+
return `fields.decimal(8, 2, ${this._renderOpts(extraOpts)})`;
|
|
301
|
+
}
|
|
302
|
+
if (!hasExtra) {
|
|
303
|
+
// fields.decimal(10, 4)
|
|
304
|
+
return `fields.decimal(${precision}, ${scale})`;
|
|
305
|
+
}
|
|
306
|
+
// fields.decimal(10, 4, { default: 0 })
|
|
307
|
+
return `fields.decimal(${precision}, ${scale}, ${this._renderOpts(extraOpts)})`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Collect non-default options only — keeps output clean
|
|
311
|
+
const opts = {};
|
|
312
|
+
if (def.nullable === true) opts.nullable = true;
|
|
313
|
+
if (def.unique === true) opts.unique = true;
|
|
314
|
+
if (def.unsigned === true) opts.unsigned = true;
|
|
315
|
+
if (def.default !== null && def.default !== undefined) opts.default = def.default;
|
|
316
|
+
if (def.max !== null && def.max !== undefined && type !== 'id') opts.max = def.max;
|
|
317
|
+
if (def.references) opts.references = def.references;
|
|
318
|
+
|
|
319
|
+
switch (type) {
|
|
320
|
+
case 'id':
|
|
321
|
+
return 'fields.id()';
|
|
322
|
+
|
|
323
|
+
case 'enum': {
|
|
324
|
+
const vals = JSON.stringify(def.enumValues || []);
|
|
325
|
+
const optsStr = Object.keys(opts).length
|
|
326
|
+
? `, ${this._renderOpts(opts)}`
|
|
327
|
+
: '';
|
|
328
|
+
return `fields.enum(${vals}${optsStr})`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
default: {
|
|
332
|
+
const optsStr = Object.keys(opts).length
|
|
333
|
+
? `(${this._renderOpts(opts)})`
|
|
334
|
+
: '()';
|
|
335
|
+
return `fields.${type}${optsStr}`;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
_renderOpts(opts) {
|
|
341
|
+
if (Object.keys(opts).length === 0) return '';
|
|
342
|
+
const entries = Object.entries(opts).map(([k, v]) => `${k}: ${JSON.stringify(v)}`);
|
|
343
|
+
return `{ ${entries.join(', ')} }`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Render a oneOffDefault descriptor.
|
|
348
|
+
* Callable → readable expression string reference.
|
|
349
|
+
* Literal → the value itself.
|
|
350
|
+
*/
|
|
351
|
+
_renderDefault(descriptor) {
|
|
352
|
+
if (descriptor === null || descriptor === undefined) return 'null';
|
|
353
|
+
if (typeof descriptor === 'object' && 'kind' in descriptor) {
|
|
354
|
+
if (descriptor.kind === 'callable') return descriptor.expression;
|
|
355
|
+
if (descriptor.kind === 'literal') return JSON.stringify(descriptor.value);
|
|
356
|
+
}
|
|
357
|
+
return JSON.stringify(descriptor); // legacy
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── Naming helpers ───────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Return the table name as-is for use in migration files.
|
|
364
|
+
* We write the actual table name (e.g. 'landlord_verification') directly
|
|
365
|
+
* so that _tableFromName() in the migrations proxy gets back the exact
|
|
366
|
+
* same string — no lossy PascalCase round-trip.
|
|
367
|
+
*/
|
|
368
|
+
_modelName(table) {
|
|
369
|
+
return table;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Topologically sort a list of CreateModel ops by their FK dependencies.
|
|
374
|
+
*
|
|
375
|
+
* If Post has a FK → users, CreateModel(users) must come before
|
|
376
|
+
* CreateModel(posts). This makes the generated migration file readable
|
|
377
|
+
* and correct for most cases.
|
|
378
|
+
*
|
|
379
|
+
* Circular references (A → B → A) are detected. The sort still completes —
|
|
380
|
+
* cycles are broken arbitrarily and a warning is emitted. The two-pass
|
|
381
|
+
* FK creation in Operations.CreateModel.up() handles the actual DB safety.
|
|
382
|
+
*
|
|
383
|
+
* @param {Array<object>} createOps — CreateModel op descriptors
|
|
384
|
+
* @returns {Array<object>} — same ops, dependency-first order
|
|
385
|
+
*/
|
|
386
|
+
_sortCreateModels(createOps) {
|
|
387
|
+
if (createOps.length <= 1) return createOps;
|
|
388
|
+
|
|
389
|
+
// Build a set of tables being created in this batch
|
|
390
|
+
const newTables = new Set(createOps.map(op => op.table));
|
|
391
|
+
|
|
392
|
+
// Build adjacency: table → Set of tables it depends on (via FK).
|
|
393
|
+
// Only track deps within this batch — cross-migration deps are handled
|
|
394
|
+
// by migration-level dependencies[], not op ordering.
|
|
395
|
+
const deps = new Map();
|
|
396
|
+
for (const op of createOps) {
|
|
397
|
+
const opDeps = new Set();
|
|
398
|
+
for (const def of Object.values(op.fields)) {
|
|
399
|
+
const ref = def.references;
|
|
400
|
+
if (ref && ref.table && newTables.has(ref.table) && ref.table !== op.table) {
|
|
401
|
+
opDeps.add(ref.table);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
deps.set(op.table, opDeps);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Kahn's algorithm — processes cycles gracefully without recursion
|
|
408
|
+
// in-degree here means: how many tables in this batch must be created
|
|
409
|
+
// BEFORE this table (i.e. how many tables does this table depend on).
|
|
410
|
+
const inDegree = new Map();
|
|
411
|
+
for (const op of createOps) inDegree.set(op.table, 0);
|
|
412
|
+
for (const [table, tableDeps] of deps) {
|
|
413
|
+
// table depends on each dep → table's in-degree increases by 1 per dep
|
|
414
|
+
inDegree.set(table, (inDegree.get(table) || 0) + tableDeps.size);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Tables with no in-edges (nothing depends on them yet) go first
|
|
418
|
+
const queue = createOps
|
|
419
|
+
.filter(op => inDegree.get(op.table) === 0)
|
|
420
|
+
.map(op => op.table)
|
|
421
|
+
.sort(); // sort for determinism
|
|
422
|
+
|
|
423
|
+
const tableIndex = new Map(createOps.map(op => [op.table, op]));
|
|
424
|
+
const sorted = [];
|
|
425
|
+
const remaining = new Set(createOps.map(op => op.table));
|
|
426
|
+
|
|
427
|
+
while (queue.length > 0) {
|
|
428
|
+
queue.sort(); // deterministic pick among ready nodes
|
|
429
|
+
const table = queue.shift();
|
|
430
|
+
remaining.delete(table);
|
|
431
|
+
sorted.push(tableIndex.get(table));
|
|
432
|
+
|
|
433
|
+
// Reduce in-degree for every table that depended on the one we just placed
|
|
434
|
+
for (const [candidate, candidateDeps] of deps) {
|
|
435
|
+
if (candidateDeps.has(table)) {
|
|
436
|
+
const newDeg = (inDegree.get(candidate) || 0) - 1;
|
|
437
|
+
inDegree.set(candidate, newDeg);
|
|
438
|
+
if (newDeg === 0 && remaining.has(candidate)) queue.push(candidate);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Any remaining nodes are part of a cycle — append with a warning.
|
|
444
|
+
// Operations.CreateModel.up() handles them safely via deferred FK constraints.
|
|
445
|
+
if (remaining.size > 0) {
|
|
446
|
+
const cycleList = [...remaining].sort().join(', ');
|
|
447
|
+
process.stderr.write(
|
|
448
|
+
`[millas] Warning: circular FK reference detected between tables: ${cycleList}.\n` +
|
|
449
|
+
` Tables will still be created safely (FK constraints applied after all tables exist),\n` +
|
|
450
|
+
` but you should review the relationship design.\n`
|
|
451
|
+
);
|
|
452
|
+
for (const table of [...remaining].sort()) {
|
|
453
|
+
sorted.push(tableIndex.get(table));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return sorted;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
_fieldsEqual(a, b) { return fieldsEqual(a, b); }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
module.exports = MigrationWriter;
|