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,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;