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,312 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const MigrationGraph = require('./MigrationGraph');
|
|
7
|
+
const ModelScanner = require('./ModelScanner');
|
|
8
|
+
const MigrationWriter = require('./MigrationWriter');
|
|
9
|
+
const InteractiveResolver = require('./InteractiveResolver');
|
|
10
|
+
const RenameDetector = require('./RenameDetector');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Makemigrations
|
|
14
|
+
*
|
|
15
|
+
* Implements `millas makemigrations`.
|
|
16
|
+
*
|
|
17
|
+
* Algorithm:
|
|
18
|
+
* 1. Build MigrationGraph from all sources (system + app)
|
|
19
|
+
* 2. Replay all migration operations → historyState (ProjectState)
|
|
20
|
+
* 3. Scan model files → currentState (ProjectState)
|
|
21
|
+
* 4. Diff historyState vs currentState → list of operations
|
|
22
|
+
* 5. If ops exist, write a new migration file
|
|
23
|
+
* 6. If no ops, report "No changes detected"
|
|
24
|
+
*
|
|
25
|
+
* Critical separation:
|
|
26
|
+
* - NEVER touches the database
|
|
27
|
+
* - NEVER reads the database schema
|
|
28
|
+
* - NEVER writes to the database
|
|
29
|
+
* - Only reads model files and existing migration files
|
|
30
|
+
*/
|
|
31
|
+
class Makemigrations {
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} modelsPath — abs path to app/models/
|
|
34
|
+
* @param {string} appMigPath — abs path to database/migrations/
|
|
35
|
+
* @param {string} systemMigPath — abs path to millas/src/migrations/system/
|
|
36
|
+
*/
|
|
37
|
+
constructor(modelsPath, appMigPath, systemMigPath, options = {}) {
|
|
38
|
+
this._modelsPath = modelsPath;
|
|
39
|
+
this._appMigPath = appMigPath;
|
|
40
|
+
this._systemMigPath = systemMigPath;
|
|
41
|
+
this._nonInteractive = options.nonInteractive || false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run makemigrations.
|
|
46
|
+
* Returns { files: string[], message: string, ops: object[] }
|
|
47
|
+
*/
|
|
48
|
+
async run(options = {}) {
|
|
49
|
+
// ── Step 1: Build graph from all migration sources ────────────────────────
|
|
50
|
+
const graph = new MigrationGraph()
|
|
51
|
+
.addSource('system', this._systemMigPath)
|
|
52
|
+
.addSource('app', this._appMigPath);
|
|
53
|
+
|
|
54
|
+
graph.loadAll();
|
|
55
|
+
|
|
56
|
+
// ── Step 2: Replay migrations → history state ─────────────────────────────
|
|
57
|
+
const historyState = graph.buildState();
|
|
58
|
+
|
|
59
|
+
// ── Step 3: Scan models → current state ───────────────────────────────────
|
|
60
|
+
const scanner = new ModelScanner(this._modelsPath);
|
|
61
|
+
const currentState = scanner.scan();
|
|
62
|
+
|
|
63
|
+
// ── Step 4: Diff ──────────────────────────────────────────────────────────
|
|
64
|
+
const writer = new MigrationWriter();
|
|
65
|
+
const ops = writer.diff(historyState, currentState);
|
|
66
|
+
|
|
67
|
+
if (ops.length === 0) {
|
|
68
|
+
return { files: [], ops: [], message: 'No changes detected.' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Step 5: Detect field renames ─────────────────────────────────────────
|
|
72
|
+
// Must happen BEFORE non-nullable resolution — a renamed field that is
|
|
73
|
+
// non-nullable needs only one prompt, not two.
|
|
74
|
+
// Django prompt: "Was student.age4 renamed to student.age7 (a IntegerField)? [y/N]"
|
|
75
|
+
{
|
|
76
|
+
const detector = new RenameDetector({
|
|
77
|
+
nonInteractive: this._nonInteractive || options.nonInteractive,
|
|
78
|
+
});
|
|
79
|
+
const afterRename = await detector.detect(ops);
|
|
80
|
+
ops.splice(0, ops.length, ...afterRename);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Step 6: Resolve dangerous ops (non-nullable fields without defaults) ──
|
|
84
|
+
// This MUST happen before writing — never during migrate.
|
|
85
|
+
const dangerousOps = ops.filter(op => op._needsDefault);
|
|
86
|
+
if (dangerousOps.length > 0) {
|
|
87
|
+
const resolver = new InteractiveResolver({
|
|
88
|
+
nonInteractive: this._nonInteractive || options.nonInteractive,
|
|
89
|
+
});
|
|
90
|
+
// resolveAll mutates ops in place (returns new array with resolved ops)
|
|
91
|
+
const resolved = await resolver.resolveAll(ops);
|
|
92
|
+
ops.splice(0, ops.length, ...resolved);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Strip internal flags before writing
|
|
96
|
+
ops.forEach(op => { delete op._needsDefault; delete op._madeNullable; });
|
|
97
|
+
|
|
98
|
+
// ── Step 7: Compute file name and content ────────────────────────────────
|
|
99
|
+
// Detect initial FIRST — isInitial must be known before naming the file
|
|
100
|
+
const dependencies = this._buildDependencies(graph, ops);
|
|
101
|
+
const appMigs = graph.topoSortForSource('app');
|
|
102
|
+
const isInitial = appMigs.length === 0;
|
|
103
|
+
|
|
104
|
+
const opName = isInitial ? 'initial' : this._opsToName(ops);
|
|
105
|
+
const nextNumber = this._nextMigrationNumber();
|
|
106
|
+
const fileName = `${nextNumber}_${opName}.js`;
|
|
107
|
+
const filePath = path.join(this._appMigPath, fileName);
|
|
108
|
+
|
|
109
|
+
const meta = {
|
|
110
|
+
initial: isInitial,
|
|
111
|
+
number: nextNumber,
|
|
112
|
+
date: new Date().toISOString().replace('T', ' ').slice(0, 16),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const content = writer.render(ops, dependencies, opName, meta);
|
|
116
|
+
|
|
117
|
+
// ── Dry run: return without writing ───────────────────────────────────────
|
|
118
|
+
if (options.dryRun) {
|
|
119
|
+
return { files: [fileName], ops, dryRun: true, message: `Would generate: ${fileName}` };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Write ─────────────────────────────────────────────────────────────────
|
|
123
|
+
await fs.ensureDir(this._appMigPath);
|
|
124
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
files: [fileName],
|
|
128
|
+
ops,
|
|
129
|
+
message: `Generated ${ops.length} operation(s) in ${fileName}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Determine the dependency list for the new migration file.
|
|
137
|
+
*
|
|
138
|
+
* Django's rule (which we mirror):
|
|
139
|
+
* 1. Initial migration: depends on the leaf(s) of every source (system + app).
|
|
140
|
+
* 2. Subsequent migrations: depends on the last app migration PLUS any system
|
|
141
|
+
* migration whose tables are directly referenced by the new ops but are NOT
|
|
142
|
+
* already transitively covered through the existing app migration chain.
|
|
143
|
+
* (e.g. extending a system model adds that system source as a dep — like Django's auth)
|
|
144
|
+
*
|
|
145
|
+
* @param {MigrationGraph} graph
|
|
146
|
+
* @param {Array<object>} ops — the new operations about to be written
|
|
147
|
+
*/
|
|
148
|
+
_buildDependencies(graph, ops = []) {
|
|
149
|
+
const appNodes = graph.topoSortForSource('app');
|
|
150
|
+
const isInitial = appNodes.length === 0;
|
|
151
|
+
|
|
152
|
+
if (isInitial) {
|
|
153
|
+
// First ever app migration.
|
|
154
|
+
// Only include system deps if the new ops actually reference a system table —
|
|
155
|
+
// same rule Django uses: empty dependencies = [] when models are self-contained.
|
|
156
|
+
const deps = [];
|
|
157
|
+
|
|
158
|
+
// Collect tables referenced by FK in the new ops
|
|
159
|
+
const referencedTables = new Set();
|
|
160
|
+
for (const op of ops) {
|
|
161
|
+
const fieldEntries = op.fields ? Object.values(op.fields) : [];
|
|
162
|
+
if (op.field) fieldEntries.push(op.field);
|
|
163
|
+
for (const f of fieldEntries) {
|
|
164
|
+
if (f && f.references && f.references.table) referencedTables.add(f.references.table);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (referencedTables.size === 0) return deps; // fully self-contained — no deps
|
|
169
|
+
|
|
170
|
+
// Add system leaves whose tables are referenced
|
|
171
|
+
const systemNodes = graph.topoSortForSource('system');
|
|
172
|
+
const sysDepKeys = new Set(
|
|
173
|
+
systemNodes.flatMap(n => n.dependencies.map(([s, nm]) => `${s}:${nm}`))
|
|
174
|
+
);
|
|
175
|
+
const systemLeaves = systemNodes.filter(n => !sysDepKeys.has(n.key));
|
|
176
|
+
|
|
177
|
+
for (const leaf of systemLeaves) {
|
|
178
|
+
const tablesFromLeaf = new Set();
|
|
179
|
+
for (const op of (leaf.operations || [])) {
|
|
180
|
+
if (op.type === 'CreateModel' && op.table) tablesFromLeaf.add(op.table);
|
|
181
|
+
}
|
|
182
|
+
if ([...tablesFromLeaf].some(t => referencedTables.has(t))) {
|
|
183
|
+
deps.push([leaf.source, leaf.name]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return deps;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Non-initial ───────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
// Start with the last app migration (leaf of app source)
|
|
193
|
+
const appDepKeys = new Set(
|
|
194
|
+
appNodes.flatMap(n => n.dependencies.map(([s, nm]) => `${s}:${nm}`))
|
|
195
|
+
);
|
|
196
|
+
const appLeaves = appNodes.filter(n => !appDepKeys.has(n.key));
|
|
197
|
+
const deps = appLeaves.map(n => [n.source, n.name]);
|
|
198
|
+
|
|
199
|
+
// Collect all tables already created by any migration in the full graph
|
|
200
|
+
// (all are transitively reachable through the app chain)
|
|
201
|
+
const coveredTables = new Set();
|
|
202
|
+
for (const node of graph.topoSort()) {
|
|
203
|
+
for (const op of (node.operations || [])) {
|
|
204
|
+
if (op.type === 'CreateModel' && op.table) coveredTables.add(op.table);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Collect tables directly referenced via FK in the new ops
|
|
209
|
+
const referencedTables = new Set();
|
|
210
|
+
for (const op of ops) {
|
|
211
|
+
const fieldEntries = op.fields ? Object.values(op.fields) : [];
|
|
212
|
+
if (op.field) fieldEntries.push(op.field);
|
|
213
|
+
for (const f of fieldEntries) {
|
|
214
|
+
if (f && f.references && f.references.table) {
|
|
215
|
+
referencedTables.add(f.references.table);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// For each system leaf: add it as a dep if it creates a table that is
|
|
221
|
+
// referenced by the new ops but NOT already covered by existing migrations
|
|
222
|
+
const alreadyDeclared = new Set(deps.map(([s, n]) => `${s}:${n}`));
|
|
223
|
+
const systemNodes = graph.topoSortForSource('system');
|
|
224
|
+
const sysDepKeys = new Set(
|
|
225
|
+
systemNodes.flatMap(n => n.dependencies.map(([s, nm]) => `${s}:${nm}`))
|
|
226
|
+
);
|
|
227
|
+
const systemLeaves = systemNodes.filter(n => !sysDepKeys.has(n.key));
|
|
228
|
+
|
|
229
|
+
for (const leaf of systemLeaves) {
|
|
230
|
+
if (alreadyDeclared.has(leaf.key)) continue;
|
|
231
|
+
const tablesFromLeaf = new Set();
|
|
232
|
+
for (const op of (leaf.operations || [])) {
|
|
233
|
+
if (op.type === 'CreateModel' && op.table) tablesFromLeaf.add(op.table);
|
|
234
|
+
}
|
|
235
|
+
const needsLeaf = [...tablesFromLeaf].some(
|
|
236
|
+
t => referencedTables.has(t) && !coveredTables.has(t)
|
|
237
|
+
);
|
|
238
|
+
if (needsLeaf) deps.push([leaf.source, leaf.name]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return deps;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_opsToName(ops) {
|
|
246
|
+
if (ops.length === 0) return 'auto';
|
|
247
|
+
|
|
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
|
+
if (ops.length === 1) {
|
|
253
|
+
const op = ops[0];
|
|
254
|
+
switch (op.type) {
|
|
255
|
+
// 0001_students (initial already handled above)
|
|
256
|
+
case 'CreateModel': return op.table;
|
|
257
|
+
// 0009_delete_course
|
|
258
|
+
case 'DeleteModel': return `delete_${op.table}`;
|
|
259
|
+
// 0004_student_age4
|
|
260
|
+
case 'AddField': return `${op.table}_${op.column}`;
|
|
261
|
+
// 0008_remove_student_age8
|
|
262
|
+
case 'RemoveField': return `remove_${op.table}_${op.column}`;
|
|
263
|
+
// 0007_alter_student_age8
|
|
264
|
+
case 'AlterField': return `alter_${op.table}_${op.column}`;
|
|
265
|
+
// 0006_rename_age7_student_age8
|
|
266
|
+
case 'RenameField': return `rename_${op.oldColumn}_${op.table}_${op.newColumn}`;
|
|
267
|
+
case 'RenameModel': return `rename_${op.oldTable}`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
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 => {
|
|
274
|
+
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
|
+
}
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
// Prefix with 'remove_' only when first op is a RemoveField (matches Django)
|
|
289
|
+
const prefix = ops[0]?.type === 'RemoveField' ? 'remove_' : '';
|
|
290
|
+
const joined = prefix + segments.join('_');
|
|
291
|
+
return joined || 'auto';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Return the next sequential 4-digit migration number as a string.
|
|
296
|
+
* Scans existing files in appMigPath for the highest NNNN_ prefix.
|
|
297
|
+
* First migration → '0001', second → '0002', etc. (Django style)
|
|
298
|
+
*/
|
|
299
|
+
_nextMigrationNumber() {
|
|
300
|
+
let max = 0;
|
|
301
|
+
try {
|
|
302
|
+
const files = require('fs').readdirSync(this._appMigPath);
|
|
303
|
+
for (const f of files) {
|
|
304
|
+
const m = f.match(/^(\d{4})_/);
|
|
305
|
+
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
306
|
+
}
|
|
307
|
+
} catch { /* directory doesn't exist yet */ }
|
|
308
|
+
return String(max + 1).padStart(4, '0');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = Makemigrations;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { deserialise } = require('./operations');
|
|
6
|
+
const { ProjectState } = require('./ProjectState');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* MigrationGraph
|
|
10
|
+
*
|
|
11
|
+
* Builds a directed acyclic graph (DAG) of migration files from one or more
|
|
12
|
+
* source directories. Nodes are migrations; edges are declared dependencies.
|
|
13
|
+
*
|
|
14
|
+
* Responsibilities:
|
|
15
|
+
* - Load all migration files from all registered sources
|
|
16
|
+
* - Validate dependency declarations
|
|
17
|
+
* - Topologically sort into a deterministic execution order
|
|
18
|
+
* - Detect circular dependencies with a clear error message
|
|
19
|
+
* - Replay operations to produce a ProjectState at any point
|
|
20
|
+
*
|
|
21
|
+
* Migration file format:
|
|
22
|
+
*
|
|
23
|
+
* module.exports = {
|
|
24
|
+
* dependencies: [
|
|
25
|
+
* ['system', '0001_users'], // [source, migrationName]
|
|
26
|
+
* ],
|
|
27
|
+
* operations: [
|
|
28
|
+
* new CreateModel('posts', { ... }),
|
|
29
|
+
* ],
|
|
30
|
+
* };
|
|
31
|
+
*
|
|
32
|
+
* Source names:
|
|
33
|
+
* 'system' — framework built-in migrations (millas/src/migrations/system/)
|
|
34
|
+
* 'app' — project migrations (database/migrations/)
|
|
35
|
+
* or any named string for future multi-app support
|
|
36
|
+
*/
|
|
37
|
+
class MigrationGraph {
|
|
38
|
+
constructor() {
|
|
39
|
+
// Map<key, MigrationNode> key = `${source}:${name}`
|
|
40
|
+
this._nodes = new Map();
|
|
41
|
+
// Map<sourceName, absPath>
|
|
42
|
+
this._sources = new Map();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Registration ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Register a source directory under a given name.
|
|
49
|
+
* Call this before loadAll().
|
|
50
|
+
*/
|
|
51
|
+
addSource(name, dirPath) {
|
|
52
|
+
this._sources.set(name, dirPath);
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Load all .js migration files from all registered sources.
|
|
58
|
+
* Populates this._nodes.
|
|
59
|
+
*/
|
|
60
|
+
loadAll() {
|
|
61
|
+
this._nodes.clear();
|
|
62
|
+
|
|
63
|
+
for (const [source, dirPath] of this._sources) {
|
|
64
|
+
if (!fs.existsSync(dirPath)) continue;
|
|
65
|
+
|
|
66
|
+
const files = fs.readdirSync(dirPath)
|
|
67
|
+
.filter(f => f.endsWith('.js') && !f.startsWith('.'))
|
|
68
|
+
.sort();
|
|
69
|
+
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const name = file.replace(/\.js$/, '');
|
|
72
|
+
const key = `${source}:${name}`;
|
|
73
|
+
const fullPath = path.join(dirPath, file);
|
|
74
|
+
|
|
75
|
+
// Bust require cache so re-runs pick up edits
|
|
76
|
+
try { delete require.cache[require.resolve(fullPath)]; } catch {}
|
|
77
|
+
|
|
78
|
+
let mod;
|
|
79
|
+
try {
|
|
80
|
+
mod = require(fullPath);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
throw new Error(`Failed to load migration "${key}": ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Normalise: support three formats:
|
|
86
|
+
// 1. New class-based: module.exports = class Migration { static operations = [...] }
|
|
87
|
+
// 2. Old object-based: module.exports = { operations: [...], dependencies: [...] }
|
|
88
|
+
// 3. Legacy: module.exports = { up(db), down(db) }
|
|
89
|
+
const cls = typeof mod === 'function' ? mod : null; // class export
|
|
90
|
+
const plain = typeof mod === 'object' ? mod : {}; // object export
|
|
91
|
+
|
|
92
|
+
const rawDeps = cls ? (cls.dependencies || []) : (plain.dependencies || []);
|
|
93
|
+
const rawOps = cls ? (cls.operations || []) : (plain.operations || []);
|
|
94
|
+
const isInitial = cls ? !!cls.initial : !!plain.initial;
|
|
95
|
+
const isLegacy = !cls && typeof plain.up === 'function' && !Array.isArray(plain.operations);
|
|
96
|
+
|
|
97
|
+
const node = {
|
|
98
|
+
key,
|
|
99
|
+
source,
|
|
100
|
+
name,
|
|
101
|
+
file,
|
|
102
|
+
fullPath,
|
|
103
|
+
initial: isInitial,
|
|
104
|
+
dependencies: rawDeps,
|
|
105
|
+
operations: isLegacy ? null : rawOps.map(op =>
|
|
106
|
+
// Already-instantiated operation objects (from migrations proxy) pass through;
|
|
107
|
+
// plain JSON descriptors go through deserialise()
|
|
108
|
+
(op && typeof op.applyState === 'function') ? op : deserialise(op)
|
|
109
|
+
),
|
|
110
|
+
legacy: isLegacy,
|
|
111
|
+
raw: plain,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
this._nodes.set(key, node);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Validate all declared dependencies exist
|
|
119
|
+
for (const node of this._nodes.values()) {
|
|
120
|
+
for (const [depSource, depName] of node.dependencies) {
|
|
121
|
+
const depKey = `${depSource}:${depName}`;
|
|
122
|
+
if (!this._nodes.has(depKey)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Migration "${node.key}" declares dependency "${depKey}" which does not exist.\n` +
|
|
125
|
+
` Available in "${depSource}": ${this._keysForSource(depSource).join(', ') || '(none)'}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return this;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Topological sort ──────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Return all nodes in dependency-safe execution order (topological sort).
|
|
138
|
+
* Throws if a circular dependency is detected.
|
|
139
|
+
*/
|
|
140
|
+
topoSort() {
|
|
141
|
+
const visited = new Set();
|
|
142
|
+
const inStack = new Set(); // cycle detection
|
|
143
|
+
const result = [];
|
|
144
|
+
|
|
145
|
+
const visit = (key) => {
|
|
146
|
+
if (visited.has(key)) return;
|
|
147
|
+
if (inStack.has(key)) {
|
|
148
|
+
throw new Error(`Circular dependency detected: ${[...inStack, key].join(' → ')}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
inStack.add(key);
|
|
152
|
+
const node = this._nodes.get(key);
|
|
153
|
+
|
|
154
|
+
for (const [depSource, depName] of (node.dependencies || [])) {
|
|
155
|
+
visit(`${depSource}:${depName}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
inStack.delete(key);
|
|
159
|
+
visited.add(key);
|
|
160
|
+
result.push(node);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Sort keys for determinism before visiting
|
|
164
|
+
const sortedKeys = [...this._nodes.keys()].sort();
|
|
165
|
+
for (const key of sortedKeys) {
|
|
166
|
+
visit(key);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Return all nodes for a specific source in topo order.
|
|
174
|
+
*/
|
|
175
|
+
topoSortForSource(source) {
|
|
176
|
+
return this.topoSort().filter(n => n.source === source);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── ProjectState replay ──────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Replay all migration operations in order to produce the final ProjectState.
|
|
183
|
+
* This is the "reconstructed state" that makemigrations diffs against.
|
|
184
|
+
*
|
|
185
|
+
* @param {Set<string>} [upToKeys] — if given, only replay these migrations
|
|
186
|
+
* @returns {ProjectState}
|
|
187
|
+
*/
|
|
188
|
+
buildState(upToKeys = null) {
|
|
189
|
+
const state = new ProjectState();
|
|
190
|
+
const nodes = this.topoSort();
|
|
191
|
+
|
|
192
|
+
for (const node of nodes) {
|
|
193
|
+
if (upToKeys && !upToKeys.has(node.key)) continue;
|
|
194
|
+
if (node.legacy) continue; // legacy up/down migrations have no state
|
|
195
|
+
|
|
196
|
+
for (const op of (node.operations || [])) {
|
|
197
|
+
op.applyState(state);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return state;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Accessors ────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
get(key) {
|
|
207
|
+
return this._nodes.get(key);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
all() {
|
|
211
|
+
return [...this._nodes.values()];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
keysForSource(source) {
|
|
215
|
+
return this._keysForSource(source);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
_keysForSource(source) {
|
|
221
|
+
return [...this._nodes.keys()]
|
|
222
|
+
.filter(k => k.startsWith(source + ':'))
|
|
223
|
+
.map(k => k.slice(source.length + 1));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = MigrationGraph;
|