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.
Files changed (120) hide show
  1. package/package.json +3 -2
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +516 -199
  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 +318 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +393 -97
  12. package/src/admin/static/admin.css +1422 -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 +87 -1046
  19. package/src/admin/views/pages/detail.njk +56 -21
  20. package/src/admin/views/pages/error.njk +65 -0
  21. package/src/admin/views/pages/form.njk +47 -599
  22. package/src/admin/views/pages/list.njk +270 -62
  23. package/src/admin/views/partials/form-field.njk +53 -0
  24. package/src/admin/views/partials/form-footer.njk +28 -0
  25. package/src/admin/views/partials/form-readonly.njk +114 -0
  26. package/src/admin/views/partials/form-scripts.njk +480 -0
  27. package/src/admin/views/partials/form-widget.njk +297 -0
  28. package/src/admin/views/partials/icons.njk +64 -0
  29. package/src/admin/views/partials/json-dialog.njk +80 -0
  30. package/src/admin/views/partials/json-editor.njk +37 -0
  31. package/src/ai/AIManager.js +954 -0
  32. package/src/ai/AITokenBudget.js +250 -0
  33. package/src/ai/PromptGuard.js +216 -0
  34. package/src/ai/agents.js +218 -0
  35. package/src/ai/conversation.js +213 -0
  36. package/src/ai/drivers.js +734 -0
  37. package/src/ai/files.js +249 -0
  38. package/src/ai/media.js +303 -0
  39. package/src/ai/pricing.js +152 -0
  40. package/src/ai/provider_tools.js +114 -0
  41. package/src/ai/types.js +356 -0
  42. package/src/auth/Auth.js +18 -2
  43. package/src/auth/AuthUser.js +65 -44
  44. package/src/cli.js +3 -1
  45. package/src/commands/createsuperuser.js +267 -0
  46. package/src/commands/lang.js +589 -0
  47. package/src/commands/migrate.js +154 -81
  48. package/src/commands/serve.js +3 -4
  49. package/src/container/AppInitializer.js +101 -20
  50. package/src/container/Application.js +31 -1
  51. package/src/container/MillasApp.js +10 -3
  52. package/src/container/MillasConfig.js +35 -6
  53. package/src/core/admin.js +5 -0
  54. package/src/core/db.js +2 -1
  55. package/src/core/foundation.js +2 -10
  56. package/src/core/lang.js +1 -0
  57. package/src/errors/HttpError.js +32 -16
  58. package/src/facades/AI.js +411 -0
  59. package/src/facades/Hash.js +67 -0
  60. package/src/facades/Process.js +144 -0
  61. package/src/hashing/Hash.js +262 -0
  62. package/src/http/HtmlEscape.js +162 -0
  63. package/src/http/MillasRequest.js +63 -7
  64. package/src/http/MillasResponse.js +70 -4
  65. package/src/http/ResponseDispatcher.js +21 -27
  66. package/src/http/SafeFilePath.js +195 -0
  67. package/src/http/SafeRedirect.js +62 -0
  68. package/src/http/SecurityBootstrap.js +70 -0
  69. package/src/http/helpers.js +40 -125
  70. package/src/http/index.js +10 -1
  71. package/src/http/middleware/CsrfMiddleware.js +258 -0
  72. package/src/http/middleware/RateLimiter.js +314 -0
  73. package/src/http/middleware/SecurityHeaders.js +281 -0
  74. package/src/i18n/I18nServiceProvider.js +91 -0
  75. package/src/i18n/Translator.js +643 -0
  76. package/src/i18n/defaults.js +122 -0
  77. package/src/i18n/index.js +164 -0
  78. package/src/i18n/locales/en.js +55 -0
  79. package/src/i18n/locales/sw.js +48 -0
  80. package/src/logger/LogRedactor.js +247 -0
  81. package/src/logger/Logger.js +1 -1
  82. package/src/logger/formatters/JsonFormatter.js +11 -4
  83. package/src/logger/formatters/PrettyFormatter.js +103 -65
  84. package/src/logger/formatters/SimpleFormatter.js +14 -3
  85. package/src/middleware/ThrottleMiddleware.js +27 -4
  86. package/src/migrations/system/0001_users.js +21 -0
  87. package/src/migrations/system/0002_admin_log.js +25 -0
  88. package/src/migrations/system/0003_sessions.js +23 -0
  89. package/src/orm/fields/index.js +210 -188
  90. package/src/orm/migration/DefaultValueParser.js +325 -0
  91. package/src/orm/migration/InteractiveResolver.js +191 -0
  92. package/src/orm/migration/Makemigrations.js +312 -0
  93. package/src/orm/migration/MigrationGraph.js +227 -0
  94. package/src/orm/migration/MigrationRunner.js +202 -108
  95. package/src/orm/migration/MigrationWriter.js +463 -0
  96. package/src/orm/migration/ModelInspector.js +143 -74
  97. package/src/orm/migration/ModelScanner.js +225 -0
  98. package/src/orm/migration/ProjectState.js +213 -0
  99. package/src/orm/migration/RenameDetector.js +175 -0
  100. package/src/orm/migration/SchemaBuilder.js +8 -81
  101. package/src/orm/migration/operations/base.js +57 -0
  102. package/src/orm/migration/operations/column.js +191 -0
  103. package/src/orm/migration/operations/fields.js +252 -0
  104. package/src/orm/migration/operations/index.js +55 -0
  105. package/src/orm/migration/operations/models.js +152 -0
  106. package/src/orm/migration/operations/registry.js +131 -0
  107. package/src/orm/migration/operations/special.js +51 -0
  108. package/src/orm/migration/utils.js +208 -0
  109. package/src/orm/model/Model.js +81 -13
  110. package/src/process/Process.js +333 -0
  111. package/src/providers/AdminServiceProvider.js +66 -9
  112. package/src/providers/AuthServiceProvider.js +40 -5
  113. package/src/providers/CacheStorageServiceProvider.js +2 -2
  114. package/src/providers/DatabaseServiceProvider.js +3 -2
  115. package/src/providers/LogServiceProvider.js +4 -1
  116. package/src/providers/MailServiceProvider.js +1 -1
  117. package/src/providers/QueueServiceProvider.js +1 -1
  118. package/src/router/MiddlewareRegistry.js +27 -2
  119. package/src/scaffold/templates.js +80 -21
  120. package/src/validation/Validator.js +348 -607
@@ -1,146 +1,263 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs-extra');
4
3
  const path = require('path');
4
+ const MigrationGraph = require('./MigrationGraph');
5
5
 
6
6
  /**
7
7
  * MigrationRunner
8
8
  *
9
- * Handles the full migration lifecycle:
10
- * - run pending migrations (migrate)
11
- * - rollback last batch (migrate:rollback)
12
- * - show status table (migrate:status)
13
- * - drop all + re-run (migrate:fresh)
14
- * - rollback all (migrate:reset)
15
- * - rollback all + re-run (migrate:refresh)
9
+ * Implements `millas migrate` and related commands.
16
10
  *
17
- * Migration history is tracked in the `millas_migrations` table.
18
- * Each migration file must export { up(db), down(db) }.
11
+ * Critical separation:
12
+ * - NEVER generates migrations
13
+ * - NEVER reads model files
14
+ * - Only applies existing migration files to the database
15
+ *
16
+ * Tracking table: millas_migrations
17
+ * - app_name (source: 'system' | 'app')
18
+ * - name (migration name without .js)
19
+ * - applied_at (timestamp)
20
+ * - batch (integer, for rollback grouping)
21
+ *
22
+ * Execution order: topological sort of the DAG — dependencies always run first.
19
23
  */
20
24
  class MigrationRunner {
21
25
  /**
22
- * @param {object} knexConn — live knex connection
23
- * @param {string} migrationsPath absolute path to migrations dir
26
+ * @param {object} db — live knex connection
27
+ * @param {string} appMigPath abs path to database/migrations/
28
+ * @param {string} systemMigPath — abs path to millas/src/migrations/system/
24
29
  */
25
- constructor(knexConn, migrationsPath) {
26
- this._db = knexConn;
27
- this._path = migrationsPath;
30
+ constructor(db, appMigPath, systemMigPath) {
31
+ this._db = db;
32
+ this._appMigPath = appMigPath;
33
+ this._systemMigPath = systemMigPath || path.join(__dirname, '../../../migrations/system');
28
34
  }
29
35
 
30
- // ─── Public commands ──────────────────────────────────────────────────────
36
+ // ─── Public commands ───────────────────────────────────────────────────────
31
37
 
32
- /** Run all pending migrations. */
33
38
  async migrate() {
34
39
  await this._ensureTable();
35
- const pending = await this._pending();
40
+ const graph = this._buildGraph();
41
+ const applied = await this._appliedSet();
42
+ const pending = graph.topoSort().filter(n => !applied.has(n.key));
36
43
 
37
- if (pending.length === 0) {
38
- return { ran: [], message: 'Nothing to migrate.' };
39
- }
44
+ if (pending.length === 0) return { ran: [], message: 'Nothing to migrate.' };
40
45
 
41
46
  const batch = await this._nextBatch();
42
47
  const ran = [];
43
48
 
44
- for (const file of pending) {
45
- const migration = this._load(file);
46
- await migration.up(this._db);
47
- await this._record(file, batch);
48
- ran.push(file);
49
+ for (const node of pending) {
50
+ await this._applyNode(node);
51
+ await this._record(node, batch);
52
+ ran.push({ label: node.key, source: node.source, name: node.name });
49
53
  }
50
54
 
51
55
  return { ran, batch, message: `Ran ${ran.length} migration(s).` };
52
56
  }
53
57
 
54
- /** Rollback the last batch of migrations. */
55
58
  async rollback(steps = 1) {
56
59
  await this._ensureTable();
57
- const batches = await this._lastBatches(steps);
58
-
59
- if (batches.length === 0) {
60
- return { rolledBack: [], message: 'Nothing to rollback.' };
61
- }
62
-
63
- const rolledBack = [];
64
-
65
- for (const row of [...batches].reverse()) {
66
- const migration = this._load(row.name);
67
- await migration.down(this._db);
68
- await this._db('millas_migrations').where('name', row.name).delete();
69
- rolledBack.push(row.name);
60
+ const rows = await this._lastBatchRows(steps);
61
+ if (rows.length === 0) return { rolledBack: [], message: 'Nothing to rollback.' };
62
+
63
+ const graph = this._buildGraph();
64
+ const rolledBack = [];
65
+
66
+ // Reverse the topo order for rollback
67
+ const topoKeys = graph.topoSort().map(n => n.key);
68
+ rows.sort((a, b) => topoKeys.indexOf(`${b.app_name}:${b.name}`) - topoKeys.indexOf(`${a.app_name}:${a.name}`));
69
+
70
+ for (const row of rows) {
71
+ const key = `${row.app_name}:${row.name}`;
72
+ const node = graph.get(key);
73
+ if (!node) {
74
+ process.stderr.write(` ⚠ Migration "${key}" not found — skipping rollback\n`);
75
+ continue;
76
+ }
77
+ await this._revertNode(node);
78
+ await this._db('millas_migrations')
79
+ .where('app_name', row.app_name)
80
+ .where('name', row.name)
81
+ .delete();
82
+ rolledBack.push({ label: key, source: node.source, name: node.name });
70
83
  }
71
84
 
72
85
  return { rolledBack, message: `Rolled back ${rolledBack.length} migration(s).` };
73
86
  }
74
87
 
75
- /** Drop all tables and re-run every migration. */
76
88
  async fresh() {
77
89
  await this._dropAllTables();
78
90
  await this._ensureTable();
79
91
  return this.migrate();
80
92
  }
81
93
 
82
- /** Rollback ALL migrations. */
83
94
  async reset() {
84
95
  await this._ensureTable();
85
- const all = await this._db('millas_migrations').orderBy('id', 'desc');
86
-
87
- if (all.length === 0) {
88
- return { rolledBack: [], message: 'Nothing to reset.' };
89
- }
96
+ const all = await this._db('millas_migrations').select('*').orderBy('id', 'desc');
97
+ if (all.length === 0) return { rolledBack: [], message: 'Nothing to reset.' };
90
98
 
99
+ const graph = this._buildGraph();
91
100
  const rolledBack = [];
101
+
92
102
  for (const row of all) {
93
- const migration = this._load(row.name);
94
- await migration.down(this._db);
95
- await this._db('millas_migrations').where('name', row.name).delete();
96
- rolledBack.push(row.name);
103
+ const key = `${row.app_name}:${row.name}`;
104
+ const node = graph.get(key);
105
+ if (node) {
106
+ try { await this._revertNode(node); } catch { /* already gone */ }
107
+ }
108
+ await this._db('millas_migrations')
109
+ .where('app_name', row.app_name).where('name', row.name).delete();
110
+ rolledBack.push({ label: key });
97
111
  }
98
112
 
99
113
  return { rolledBack, message: `Reset ${rolledBack.length} migration(s).` };
100
114
  }
101
115
 
102
- /** Rollback all then re-run all. */
103
116
  async refresh() {
104
117
  await this.reset();
105
118
  return this.migrate();
106
119
  }
107
120
 
108
- /** Return status of all migration files. */
109
121
  async status() {
110
122
  await this._ensureTable();
111
- const files = this._files();
112
- const ran = await this._ranNames();
113
123
 
114
- return files.map(file => ({
115
- name: file,
116
- status: ran.has(file) ? 'Ran' : 'Pending',
117
- batch: ran.get(file) || null,
118
- }));
124
+ const graph = this._buildGraph();
125
+ const applied = await this._appliedMap();
126
+ const rows = [];
127
+
128
+ for (const node of graph.topoSort()) {
129
+ const rec = applied.get(node.key);
130
+ rows.push({
131
+ key: node.key,
132
+ source: node.source,
133
+ name: node.name,
134
+ status: rec ? 'Applied' : 'Pending',
135
+ batch: rec?.batch ?? null,
136
+ appliedAt: rec?.applied_at ?? null,
137
+ });
138
+ }
139
+
140
+ return rows;
141
+ }
142
+
143
+ /**
144
+ * Mark a migration as applied without running it (--fake).
145
+ */
146
+ async fake(source, name) {
147
+ await this._ensureTable();
148
+ const key = `${source}:${name}`;
149
+ const already = await this._db('millas_migrations')
150
+ .where('app_name', source).where('name', name).first();
151
+ if (already) throw new Error(`Migration "${key}" is already applied.`);
152
+ const batch = await this._nextBatch();
153
+ await this._record({ source, name }, batch);
154
+ return { key };
155
+ }
156
+
157
+ /**
158
+ * Show which migrations WOULD run (plan preview, no DB changes).
159
+ */
160
+ async plan() {
161
+ await this._ensureTable();
162
+ const graph = this._buildGraph();
163
+ const applied = await this._appliedSet();
164
+ return graph.topoSort()
165
+ .filter(n => !applied.has(n.key))
166
+ .map(n => ({ key: n.key, source: n.source, name: n.name }));
119
167
  }
120
168
 
121
169
  // ─── Internal ─────────────────────────────────────────────────────────────
122
170
 
171
+ _buildGraph() {
172
+ const graph = new MigrationGraph()
173
+ .addSource('system', this._systemMigPath)
174
+ .addSource('app', this._appMigPath);
175
+ graph.loadAll();
176
+ return graph;
177
+ }
178
+
179
+ async _applyNode(node) {
180
+ if (node.legacy) {
181
+ // Legacy up/down migration — no operations array, just call up()
182
+ await node.raw.up(this._db);
183
+ return;
184
+ }
185
+
186
+ const ops = node.operations || [];
187
+
188
+ // ── FK-safe two-phase execution ───────────────────────────────────────────
189
+ //
190
+ // When a migration contains multiple CreateModel ops, running them one by
191
+ // one with inline FK constraints fails whenever a table references another
192
+ // table that appears later in the op list (or in a circular relationship).
193
+ //
194
+ // Strategy:
195
+ // Phase 1 — run all CreateModel ops without FK constraints
196
+ // (plain integer columns only)
197
+ // Phase 2 — attach all FK constraints in a single alterTable per table,
198
+ // now that every referenced table is guaranteed to exist
199
+ // Phase 3 — run all remaining ops (AddField, AlterField, etc.) normally
200
+ //
201
+ // This costs exactly the same number of DB round-trips as the naive approach
202
+ // for the common case (one CreateModel per migration = one CREATE TABLE +
203
+ // one ALTER TABLE if it has FKs, vs one CREATE TABLE inline). For migrations
204
+ // with multiple CreateModel ops it is strictly cheaper than per-op ALTER TABLE
205
+ // calls because FK constraints are batched per table in phase 2.
206
+
207
+ const createOps = ops.filter(op => op.type === 'CreateModel');
208
+ const otherOps = ops.filter(op => op.type !== 'CreateModel');
209
+
210
+ // Phase 1: create all tables, FK columns as plain integers
211
+ for (const op of createOps) {
212
+ await op.upWithoutFKs(this._db);
213
+ }
214
+
215
+ // Phase 2: attach all FK constraints — one alterTable per table
216
+ for (const op of createOps) {
217
+ await op.applyFKConstraints(this._db);
218
+ }
219
+
220
+ // Phase 3: remaining ops (AddField, RemoveField, AlterField, RunSQL, etc.)
221
+ for (const op of otherOps) {
222
+ await op.up(this._db);
223
+ }
224
+ }
225
+
226
+ async _revertNode(node) {
227
+ if (node.legacy) {
228
+ await node.raw.down(this._db);
229
+ } else {
230
+ const ops = [...(node.operations || [])].reverse();
231
+ for (const op of ops) {
232
+ await op.down(this._db);
233
+ }
234
+ }
235
+ }
236
+
123
237
  async _ensureTable() {
124
238
  const exists = await this._db.schema.hasTable('millas_migrations');
125
239
  if (exists) return;
126
240
 
127
241
  await this._db.schema.createTable('millas_migrations', (t) => {
128
242
  t.increments('id');
129
- t.string('name').notNullable().unique();
243
+ t.string('app_name', 50).notNullable();
244
+ t.string('name', 200).notNullable();
130
245
  t.integer('batch').notNullable();
131
- t.timestamp('ran_at').defaultTo(this._db.fn.now());
246
+ t.timestamp('applied_at').defaultTo(this._db.fn.now());
247
+ t.unique(['app_name', 'name']);
132
248
  });
133
249
  }
134
250
 
135
- async _pending() {
136
- const ran = await this._ranNames();
137
- const files = this._files();
138
- return files.filter(f => !ran.has(f));
251
+ async _appliedSet() {
252
+ const rows = await this._db('millas_migrations').select('app_name', 'name');
253
+ return new Set(rows.map(r => `${r.app_name}:${r.name}`));
139
254
  }
140
255
 
141
- async _ranNames() {
142
- const rows = await this._db('millas_migrations').select('name', 'batch');
143
- return new Map(rows.map(r => [r.name, r.batch]));
256
+ async _appliedMap() {
257
+ const rows = await this._db('millas_migrations').select('*');
258
+ const map = new Map();
259
+ for (const r of rows) map.set(`${r.app_name}:${r.name}`, r);
260
+ return map;
144
261
  }
145
262
 
146
263
  async _nextBatch() {
@@ -148,59 +265,36 @@ class MigrationRunner {
148
265
  return (result?.max || 0) + 1;
149
266
  }
150
267
 
151
- async _lastBatches(steps = 1) {
152
- const maxBatch = await this._db('millas_migrations').max('batch as max').first();
153
- if (!maxBatch?.max) return [];
268
+ async _lastBatchRows(steps) {
269
+ const result = await this._db('millas_migrations').max('batch as max').first();
270
+ if (!result?.max) return [];
271
+ const fromBatch = result.max - steps + 1;
272
+ return this._db('millas_migrations')
273
+ .where('batch', '>=', fromBatch)
274
+ .orderBy('id', 'desc');
275
+ }
154
276
 
155
- const fromBatch = maxBatch.max - steps + 1;
156
- const all = await this._db('millas_migrations').orderBy('id', 'desc');
157
- return all.filter(r => r.batch >= fromBatch);
277
+ async _record(node, batch) {
278
+ await this._db('millas_migrations').insert({
279
+ app_name: node.source,
280
+ name: node.name,
281
+ batch,
282
+ applied_at: new Date().toISOString(),
283
+ });
158
284
  }
159
285
 
160
- /**
161
- * Drop all user tables — dialect-aware.
162
- * Resolves the knex client name and delegates to the right helper.
163
- */
164
286
  async _dropAllTables() {
165
287
  const clientName = this._db.client.config.client || 'sqlite3';
166
-
167
288
  let dialect;
168
289
  if (clientName.includes('pg') || clientName.includes('postgres')) {
169
290
  dialect = require('./dialects/postgres');
170
291
  } else if (clientName.includes('mysql') || clientName.includes('maria')) {
171
292
  dialect = require('./dialects/mysql');
172
293
  } else {
173
- // Default: sqlite / sqlite3
174
294
  dialect = require('./dialects/sqlite');
175
295
  }
176
-
177
296
  await dialect.dropAllTables(this._db);
178
297
  }
179
-
180
- _files() {
181
- if (!fs.existsSync(this._path)) return [];
182
- return fs.readdirSync(this._path)
183
- .filter(f => f.endsWith('.js') && !f.startsWith('.'))
184
- .sort();
185
- }
186
-
187
- _load(name) {
188
- const filePath = path.join(this._path, name);
189
- delete require.cache[require.resolve(filePath)];
190
- const migration = require(filePath);
191
- if (typeof migration.up !== 'function' || typeof migration.down !== 'function') {
192
- throw new Error(`Migration "${name}" must export { up(db), down(db) }`);
193
- }
194
- return migration;
195
- }
196
-
197
- async _record(name, batch) {
198
- await this._db('millas_migrations').insert({
199
- name,
200
- batch,
201
- ran_at: new Date().toISOString(),
202
- });
203
- }
204
298
  }
205
299
 
206
- module.exports = MigrationRunner;
300
+ module.exports = MigrationRunner;